Merge branch 'next' into next

This commit is contained in:
RHQYZ
2026-08-28 10:16:47 +08:00
committed by GitHub
228 changed files with 13947 additions and 8169 deletions
+1
View File
@@ -20,3 +20,4 @@ dist
/certimate.exe
/docker/data
/pb_data
/plugins
+7 -33
View File
@@ -43,11 +43,14 @@ require (
github.com/baidubce/bce-sdk-go v0.9.272
github.com/byteplus-sdk/byteplus-go-sdk-v2 v1.0.73
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.71
github.com/fsnotify/fsnotify v1.10.1
github.com/go-acme/lego/v5 v5.3.1
github.com/go-cmd/cmd v1.4.3
github.com/go-resty/resty/v2 v2.17.2
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/go-querystring v1.2.0
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.8.0
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.208
github.com/jdcloud-api/jdcloud-sdk-go v1.67.0
github.com/jlaffaye/ftp v0.2.0
@@ -61,7 +64,6 @@ require (
github.com/pocketbase/pocketbase v0.39.10
github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df
github.com/pquerna/otp v1.5.0
github.com/qiniu/go-sdk/v7 v7.26.18
github.com/samber/lo v1.53.0
github.com/spf13/cobra v1.10.2
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.3.116
@@ -85,7 +87,6 @@ require (
github.com/yandex-cloud/go-sdk/services/certificatemanager v0.0.81
github.com/yandex-cloud/go-sdk/v2 v2.144.0
github.com/yunify/qingcloud-sdk-go v2.0.0-alpha.38+incompatible
github.com/zenlayer/zenlayercloud-sdk-go v0.2.49
gitlab.ecloud.com/ecloud/ecloudsdkcloudcore v1.0.0
gitlab.ecloud.com/ecloud/ecloudsdkclouddns v1.0.1
gitlab.ecloud.com/ecloud/ecloudsdkcmcdn v1.0.0
@@ -96,9 +97,8 @@ require (
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
google.golang.org/api v0.291.0
k8s.io/api v0.35.3
k8s.io/apimachinery v0.35.3
k8s.io/client-go v0.35.3
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
software.sslmate.com/src/go-pkcs12 v0.7.3
)
@@ -114,27 +114,17 @@ require (
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-acme/tencentclouddnspod v1.3.24 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/swag v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/namedotcom/go/v4 v4.0.2 // indirect
github.com/nrdcg/bunny-go v0.1.0 // indirect
github.com/nrdcg/desec v0.11.1 // indirect
@@ -144,35 +134,22 @@ require (
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/qiniu/dyn v1.3.0 // indirect
github.com/qiniu/x v1.17.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1
github.com/vultr/govultr/v3 v3.31.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ns1/ns1-go.v2 v2.18.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
require (
cloud.google.com/go/auth v0.22.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
@@ -202,7 +179,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
@@ -225,6 +201,7 @@ require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jarcoal/httpmock v1.4.1 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
@@ -250,6 +227,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nrdcg/namesilo v0.5.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.120.0 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
@@ -274,14 +252,10 @@ require (
golang.org/x/image v0.44.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
+16 -85
View File
@@ -66,8 +66,6 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/G-Core/gcorelabscdn-go v1.0.37 h1:IHqiSfnHp3tnuUSU1FIH5+3XepNnSsyJn0JoWgvUYL8=
@@ -264,6 +262,8 @@ github.com/bodgit/tsig v1.3.1/go.mod h1:Ez+xu0W5Ew/D28XqthucLOX8k9A0UGVPETvagYxb
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/byteplus-sdk/byteplus-go-sdk-v2 v1.0.73 h1:7DuEWPVPzTBMqYbm4I2XUpEq6gMQt3TyiVTUiWBbzhA=
github.com/byteplus-sdk/byteplus-go-sdk-v2 v1.0.73/go.mod h1:eqR3bDX3XtdPBcB9vERznOyi0MAcAI1GFAdcRspNMBU=
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.16/go.mod h1:7iCaE+dR9EycrJU0GQyMhptbInLbQhsKXiDKDjNi8Vs=
@@ -316,8 +316,6 @@ github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -329,6 +327,7 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -344,8 +343,6 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
@@ -385,48 +382,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU=
github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA=
github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c=
github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk=
github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw=
github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY=
github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU=
github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
@@ -491,8 +452,6 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -572,6 +531,8 @@ github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHh
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
@@ -593,6 +554,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.208 h1:l2UfLQ6UrdEg4Juw7LJUQMqsPJy8U6mybPHOWaELpwI=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.208/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
@@ -619,6 +582,8 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jdcloud-api/jdcloud-sdk-go v1.67.0 h1:glakpye2Zxhyrxx1oBPL/znRzZPyFoN90OwVI2JmmMg=
github.com/jdcloud-api/jdcloud-sdk-go v1.67.0/go.mod h1:UrKjuULIWLjHFlG6aSPunArE5QX57LftMmStAZJBEX8=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@@ -670,12 +635,12 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
@@ -722,8 +687,6 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/namedotcom/go/v4 v4.0.2 h1:4gNkPaPRG/2tqFNUUof7jAVsA6vDutFutEOd7ivnDwA=
@@ -756,6 +719,8 @@ github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw=
github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@@ -826,13 +791,6 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/qiniu/dyn v1.3.0 h1:s+xPTeV0H8yikgM4ZMBc7Rrefam8UNI3asBlkaOQg5o=
github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk=
github.com/qiniu/go-sdk/v7 v7.26.18 h1:74rUItkJGFwD7wV6vUSXLfR23r8NqbAyjFj3goiFeN0=
github.com/qiniu/go-sdk/v7 v7.26.18/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k=
github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs=
github.com/qiniu/x v1.17.0 h1:OsyDKXzYp5vw9Hc7VAe4Cso1Sp50fLKGsBuDteyTevE=
github.com/qiniu/x v1.17.0/go.mod h1:AiovSOCaRijaf3fj+0CBOpR1457pn24b0Vdb1JpwhII=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -892,6 +850,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -950,8 +909,6 @@ github.com/vultr/govultr/v3 v3.31.2 h1:2l3/KDvfemG+4azw4LLquJoh9mFOAVEdBXtPPzix3
github.com/vultr/govultr/v3 v3.31.2/go.mod h1:2zyUw9yADQaGwKnwDesmIOlBNLrm7edsCfWHFJpWKf8=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
@@ -983,8 +940,6 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
github.com/zenlayer/zenlayercloud-sdk-go v0.2.49 h1:mmOwDPnRhQq1Qvi2CJpBLR83/FdOHGYyHpwAIhqxOCo=
github.com/zenlayer/zenlayercloud-sdk-go v0.2.49/go.mod h1:iOR3Pzb2t3Z0+UvXhdUqi7nhwZM5Ux2oH4ixKH+qe88=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
@@ -1031,8 +986,6 @@ go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -1255,7 +1208,9 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1487,12 +1442,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
@@ -1522,18 +1473,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg=
k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
@@ -1566,14 +1505,6 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+38
View File
@@ -0,0 +1,38 @@
package accessschema
import (
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
)
func init() {
schema, err := providerschema.New(string(domain.AccessProviderTypeAliyun), providerschema.CategoryAccess).
Field("accessKeyId", providerschema.ValueTypeText,
providerschema.LabelKey("access.form.aliyun_access_key_id.label"),
providerschema.PlaceholderKey("access.form.aliyun_access_key_id.placeholder"),
providerschema.TooltipKey("access.form.aliyun_access_key_id.tooltip"),
providerschema.TooltipHtml(),
providerschema.Secret(),
providerschema.Required(),
).
Field("accessKeySecret", providerschema.ValueTypeText,
providerschema.LabelKey("access.form.aliyun_access_key_secret.label"),
providerschema.PlaceholderKey("access.form.aliyun_access_key_secret.placeholder"),
providerschema.TooltipKey("access.form.aliyun_access_key_secret.tooltip"),
providerschema.TooltipHtml(),
providerschema.Secret(),
providerschema.Required(),
).
Field("resourceGroupId", providerschema.ValueTypeText,
providerschema.LabelKey("access.form.aliyun_resource_group_id.label"),
providerschema.PlaceholderKey("access.form.aliyun_resource_group_id.placeholder"),
providerschema.TooltipKey("access.form.aliyun_resource_group_id.tooltip"),
providerschema.TooltipHtml(),
).
Build()
if err != nil {
panic(err)
}
providerschema.Registries.MustRegister(string(domain.AccessProviderTypeAliyun), schema)
}
+79
View File
@@ -0,0 +1,79 @@
package accessschema
import (
"reflect"
"strings"
"testing"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
)
func TestAccessSchema_DriftGuard_Aliyun(t *testing.T) {
schema, err := providerschema.Registries.Get(string(domain.AccessProviderTypeAliyun))
if err != nil {
t.Fatalf("aliyun access schema not registered: %v", err)
}
schemaFields := make(map[string]struct{}, len(schema.Fields))
for _, f := range schema.Fields {
schemaFields[f.Name] = struct{}{}
}
structTags := jsonFieldNames(reflect.TypeFor[domain.AccessConfigForAliyun]())
for name := range schemaFields {
if _, ok := structTags[name]; !ok {
t.Errorf("schema field %q has no matching json tag on AccessConfigForAliyun", name)
}
}
for name := range structTags {
if _, ok := schemaFields[name]; !ok {
t.Errorf("AccessConfigForAliyun json tag %q has no matching schema field", name)
}
}
for _, f := range schema.Fields {
if (f.Name == "accessKeyId" || f.Name == "accessKeySecret") && !f.Secret {
t.Errorf("access field %q should be marked secret", f.Name)
}
}
}
func jsonFieldNames(t reflect.Type) map[string]struct{} {
names := make(map[string]struct{})
walkStruct(t, names)
return names
}
func walkStruct(t reflect.Type, names map[string]struct{}) {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Anonymous {
walkStruct(field.Type, names)
continue
}
tag := field.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name, _, _ := strings.Cut(tag, ",")
if name == "" {
name = field.Name
}
if field.Type.Kind() == reflect.Struct || (field.Type.Kind() == reflect.Slice && field.Type.Elem().Kind() == reflect.Struct) {
names[name] = struct{}{}
if field.Type.Kind() == reflect.Struct {
walkStruct(field.Type, names)
}
continue
}
names[name] = struct{}{}
}
}
+119
View File
@@ -0,0 +1,119 @@
package acmeaccount
import (
"context"
"fmt"
"github.com/certimate-go/certimate/internal/certacme"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/domain/dtos"
)
type acmeAccountRepository interface {
List(ctx context.Context, page, perPage int, ca string) ([]*domain.ACMEAccount, int64, error)
GetById(ctx context.Context, id string) (*domain.ACMEAccount, error)
}
type Service struct {
repo acmeAccountRepository
}
func NewService(repo acmeAccountRepository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(ctx context.Context, req *dtos.ACMEAccountListReq) (*dtos.ACMEAccountListResp, error) {
page := 1
perPage := 15
ca := ""
if req != nil {
page = req.Page
perPage = req.PerPage
ca = req.CA
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 15
}
if perPage > 100 {
perPage = 100
}
accounts, total, err := s.repo.List(ctx, page, perPage, ca)
if err != nil {
return nil, fmt.Errorf("failed to list acme accounts: %w", err)
}
items := make([]*dtos.ACMEAccountView, 0, len(accounts))
for _, account := range accounts {
items = append(items, toView(account))
}
return &dtos.ACMEAccountListResp{
Items: items,
TotalItems: total,
Page: page,
PerPage: perPage,
}, nil
}
func (s *Service) Import(ctx context.Context, req *dtos.ACMEAccountImportReq) (*dtos.ACMEAccountImportResp, error) {
if req == nil {
return nil, domain.NewError(400, "request body is empty")
}
account, err := certacme.ImportACMEAccount(ctx, &certacme.ImportACMEAccountOptions{
CA: req.CA,
ACMEDirUrl: req.ACMEDirUrl,
PrivateKeyPem: req.PrivateKeyPem,
Email: req.Email,
})
if err != nil {
return nil, err
}
return &dtos.ACMEAccountImportResp{Item: toView(account)}, nil
}
func (s *Service) Export(ctx context.Context, accountId string) (*dtos.ACMEAccountExportResp, error) {
if accountId == "" {
return nil, domain.NewError(400, "the account id is empty")
}
account, err := s.repo.GetById(ctx, accountId)
if err != nil {
if domain.IsRecordNotFoundError(err) {
return nil, domain.ErrRecordNotFound
}
return nil, fmt.Errorf("failed to get acme account: %w", err)
}
if account.PrivateKey == "" {
return nil, domain.NewError(404, "private key is empty")
}
return &dtos.ACMEAccountExportResp{PrivateKeyPem: account.PrivateKey}, nil
}
func (s *Service) Rotate(ctx context.Context, accountId string) (*dtos.ACMEAccountRotateResp, error) {
account, err := certacme.RotateACMEAccountKey(ctx, accountId)
if err != nil {
return nil, err
}
return &dtos.ACMEAccountRotateResp{Item: toView(account)}, nil
}
func toView(account *domain.ACMEAccount) *dtos.ACMEAccountView {
if account == nil {
return nil
}
return &dtos.ACMEAccountView{
Id: account.Id,
CA: account.CA,
Email: account.Email,
ACMEDirectoryUrl: account.ACMEDirectoryUrl,
ACMEAccountUrl: account.ACMEAccountUrl,
CreatedAt: account.CreatedAt,
UpdatedAt: account.UpdatedAt,
}
}
+160
View File
@@ -0,0 +1,160 @@
package acmeaccount
import (
"context"
"testing"
"time"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/domain/dtos"
)
type mockRepo struct {
accounts []*domain.ACMEAccount
byID map[string]*domain.ACMEAccount
page int
perPage int
ca string
}
func (m *mockRepo) List(ctx context.Context, page, perPage int, ca string) ([]*domain.ACMEAccount, int64, error) {
m.page = page
m.perPage = perPage
m.ca = ca
filtered := make([]*domain.ACMEAccount, 0)
for _, a := range m.accounts {
if ca != "" && a.CA != ca {
continue
}
filtered = append(filtered, a)
}
total := int64(len(filtered))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 15
}
start := (page - 1) * perPage
if start >= len(filtered) {
return []*domain.ACMEAccount{}, total, nil
}
end := start + perPage
if end > len(filtered) {
end = len(filtered)
}
return filtered[start:end], total, nil
}
func (m *mockRepo) GetById(ctx context.Context, id string) (*domain.ACMEAccount, error) {
if a, ok := m.byID[id]; ok {
return a, nil
}
return nil, domain.ErrRecordNotFound
}
func TestList_OmitsPrivateKeyAndPaginates(t *testing.T) {
repo := &mockRepo{
accounts: []*domain.ACMEAccount{
{
Meta: domain.Meta{Id: "a1", CreatedAt: time.Now(), UpdatedAt: time.Now()},
CA: "letsencrypt",
Email: "a@example.com",
PrivateKey: "-----BEGIN EC PRIVATE KEY-----\nSECRET\n-----END EC PRIVATE KEY-----",
ACMEDirectoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
ACMEAccountUrl: "https://acme-v02.api.letsencrypt.org/acme/acct/1",
},
{
Meta: domain.Meta{Id: "a2", CreatedAt: time.Now(), UpdatedAt: time.Now()},
CA: "zerossl",
Email: "b@example.com",
PrivateKey: "SECRET2",
ACMEDirectoryUrl: "https://acme.zerossl.com/v2/DV90",
ACMEAccountUrl: "https://acme.zerossl.com/v2/DV90/account/2",
},
},
}
svc := NewService(repo)
resp, err := svc.List(context.Background(), &dtos.ACMEAccountListReq{Page: 1, PerPage: 1})
if err != nil {
t.Fatal(err)
}
if len(resp.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(resp.Items))
}
if resp.TotalItems != 2 {
t.Fatalf("totalItems = %d", resp.TotalItems)
}
if resp.Page != 1 || resp.PerPage != 1 {
t.Fatalf("page/perPage = %d/%d", resp.Page, resp.PerPage)
}
if resp.Items[0].Email != "a@example.com" {
t.Fatalf("email = %q", resp.Items[0].Email)
}
if resp.Items[0].Id != "a1" || resp.Items[0].ACMEAccountUrl == "" {
t.Fatalf("unexpected view: %+v", resp.Items[0])
}
}
func TestList_FilterByCA(t *testing.T) {
repo := &mockRepo{
accounts: []*domain.ACMEAccount{
{Meta: domain.Meta{Id: "a1"}, CA: "letsencrypt", Email: "a@example.com"},
{Meta: domain.Meta{Id: "a2"}, CA: "zerossl", Email: "b@example.com"},
},
}
svc := NewService(repo)
resp, err := svc.List(context.Background(), &dtos.ACMEAccountListReq{Page: 1, PerPage: 10, CA: "zerossl"})
if err != nil {
t.Fatal(err)
}
if len(resp.Items) != 1 || resp.TotalItems != 1 || resp.Items[0].CA != "zerossl" {
t.Fatalf("unexpected: %+v total=%d", resp.Items, resp.TotalItems)
}
if repo.ca != "zerossl" {
t.Fatalf("repo ca = %q", repo.ca)
}
}
func TestExport_ReturnsPEM(t *testing.T) {
pem := "-----BEGIN EC PRIVATE KEY-----\nTEST\n-----END EC PRIVATE KEY-----"
repo := &mockRepo{
byID: map[string]*domain.ACMEAccount{
"a1": {
Meta: domain.Meta{Id: "a1"},
PrivateKey: pem,
},
},
}
svc := NewService(repo)
resp, err := svc.Export(context.Background(), "a1")
if err != nil {
t.Fatal(err)
}
if resp.PrivateKeyPem != pem {
t.Fatalf("pem mismatch")
}
}
func TestExport_NotFound(t *testing.T) {
svc := NewService(&mockRepo{byID: map[string]*domain.ACMEAccount{}})
_, err := svc.Export(context.Background(), "missing")
if !domain.IsRecordNotFoundError(err) {
t.Fatalf("expected not found, got %v", err)
}
}
func TestToView_Nil(t *testing.T) {
if toView(nil) != nil {
t.Fatal("expected nil")
}
}
func TestImport_NilRequest(t *testing.T) {
svc := NewService(&mockRepo{})
_, err := svc.Import(context.Background(), nil)
if err == nil {
t.Fatal("expected error")
}
_ = &dtos.ACMEAccountImportReq{}
}
+173
View File
@@ -0,0 +1,173 @@
package certacme
import (
"context"
"crypto"
"fmt"
"strings"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/lego"
"github.com/certimate-go/certimate/internal/app"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/repository"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
type ImportACMEAccountOptions struct {
CA string
ACMEDirUrl string
PrivateKeyPem string
Email string
}
func ImportACMEAccount(ctx context.Context, opts *ImportACMEAccountOptions) (*ACMEAccount, error) {
if opts == nil {
return nil, domain.NewError(400, "the import options are nil")
}
if strings.TrimSpace(opts.CA) == "" {
return nil, domain.NewError(400, "the ca is empty")
}
if strings.TrimSpace(opts.PrivateKeyPem) == "" {
return nil, domain.NewError(400, "the private key is empty")
}
dirUrl, err := resolveImportDirectoryURL(opts.CA, opts.ACMEDirUrl)
if err != nil {
return nil, err
}
privKey, err := xcert.ParsePrivateKeyFromPEM(opts.PrivateKeyPem)
if err != nil {
return nil, domain.NewError(400, fmt.Sprintf("invalid private key pem: %v", err))
}
signer, ok := privKey.(crypto.Signer)
if !ok {
return nil, domain.NewError(400, "private key is not a crypto.Signer")
}
keyPEM, err := xcert.ConvertPrivateKeyToPEM(privKey, false)
if err != nil {
keyPEM = opts.PrivateKeyPem
}
placeholder := &acme.Account{}
tempEmail := strings.TrimSpace(opts.Email)
if tempEmail == "" {
tempEmail = "import@local"
}
legoUser := &importLegoUser{
email: tempEmail,
privateKey: signer,
registration: &acme.ExtendedAccount{
Account: *placeholder,
Location: "",
},
}
legoCfg := lego.NewConfig(legoUser)
legoCfg.UserAgent = app.AppUserAgent
legoCfg.CADirURL = dirUrl
legoClient, err := lego.NewClient(legoCfg)
if err != nil {
return nil, fmt.Errorf("failed to create lego client: %w", err)
}
resolved, err := legoClient.Registration.ResolveAccountByKey(ctx)
if err != nil {
return nil, domain.NewError(404, fmt.Sprintf("failed to resolve acme account by key: %v", err))
}
if resolved == nil || resolved.Location == "" {
return nil, domain.NewError(404, "acme account not found for the given private key")
}
email := extractEmailFromContacts(resolved.Contact)
if email == "" {
email = strings.TrimSpace(opts.Email)
}
if email == "" {
return nil, domain.NewError(400, "the account has no email contact; please provide an email")
}
accountRepo := repository.NewACMEAccountRepository()
existing, err := accountRepo.GetByCAAndAcctUrl(ctx, opts.CA, resolved.Location)
if err != nil && !domain.IsRecordNotFoundError(err) {
return nil, fmt.Errorf("failed to check existing acme account: %w", err)
}
if existing != nil {
return nil, domain.NewError(409, "acme account already exists")
}
if byEmail, err := accountRepo.GetByCAAndEmail(ctx, opts.CA, dirUrl, email); err == nil && byEmail != nil {
if byEmail.ACMEAccountUrl == resolved.Location {
return nil, domain.NewError(409, "acme account already exists")
}
} else if err != nil && !domain.IsRecordNotFoundError(err) {
return nil, fmt.Errorf("failed to check existing acme account by email: %w", err)
}
account := &ACMEAccount{
CA: opts.CA,
Email: email,
PrivateKey: keyPEM,
ACMEDirectoryUrl: dirUrl,
ACMEAccountUrl: resolved.Location,
ResourceObject: &resolved.Account,
}
if _, err := accountRepo.Save(ctx, account); err != nil {
return nil, fmt.Errorf("failed to save acme account record: %w", err)
}
return account, nil
}
func resolveImportDirectoryURL(ca, explicitDir string) (string, error) {
if strings.TrimSpace(explicitDir) != "" {
return strings.TrimSpace(explicitDir), nil
}
provider := domain.CAProviderType(ca)
if provider == domain.CAProviderTypeACMECA {
return "", domain.NewError(400, "acmeDirUrl is required for custom ACME CA")
}
dirUrl, err := getCADirUrl(provider, nil, "")
if err != nil {
return "", domain.NewError(400, err.Error())
}
return dirUrl, nil
}
func extractEmailFromContacts(contacts []string) string {
for _, c := range contacts {
c = strings.TrimSpace(c)
if strings.HasPrefix(strings.ToLower(c), "mailto:") {
email := strings.TrimSpace(c[len("mailto:"):])
if email != "" {
return email
}
}
}
return ""
}
type importLegoUser struct {
email string
privateKey crypto.Signer
registration *acme.ExtendedAccount
}
func (u *importLegoUser) GetEmail() string {
return u.email
}
func (u *importLegoUser) GetRegistration() *acme.ExtendedAccount {
return u.registration
}
func (u *importLegoUser) GetPrivateKey() crypto.Signer {
return u.privateKey
}
+48
View File
@@ -0,0 +1,48 @@
package certacme
import "testing"
func TestExtractEmailFromContacts(t *testing.T) {
tests := []struct {
name string
contacts []string
want string
}{
{"empty", nil, ""},
{"mailto", []string{"mailto:user@example.com"}, "user@example.com"},
{"mixed", []string{"tel:+1", "mailto:u@x.com"}, "u@x.com"},
{"whitespace", []string{" mailto: spaced@x.com "}, "spaced@x.com"},
{"no mailto", []string{"user@example.com"}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractEmailFromContacts(tt.contacts)
if got != tt.want {
t.Fatalf("got %q want %q", got, tt.want)
}
})
}
}
func TestResolveImportDirectoryURL(t *testing.T) {
dir, err := resolveImportDirectoryURL("letsencrypt", "")
if err != nil {
t.Fatal(err)
}
if dir != "https://acme-v02.api.letsencrypt.org/directory" {
t.Fatalf("dir = %q", dir)
}
dir, err = resolveImportDirectoryURL("letsencrypt", "https://custom.example/directory")
if err != nil {
t.Fatal(err)
}
if dir != "https://custom.example/directory" {
t.Fatalf("override dir = %q", dir)
}
_, err = resolveImportDirectoryURL("acmeca", "")
if err == nil {
t.Fatal("expected error for acmeca without dir")
}
}
+91
View File
@@ -0,0 +1,91 @@
package certacme
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"fmt"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/lego"
"github.com/certimate-go/certimate/internal/app"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/repository"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
func RotateACMEAccountKey(ctx context.Context, accountId string) (*ACMEAccount, error) {
if accountId == "" {
return nil, domain.NewError(400, "the account id is empty")
}
accountRepo := repository.NewACMEAccountRepository()
account, err := accountRepo.GetById(ctx, accountId)
if err != nil {
if domain.IsRecordNotFoundError(err) {
return nil, domain.ErrRecordNotFound
}
return nil, fmt.Errorf("failed to get acme account: %w", err)
}
currentKey := account.GetPrivateKey()
if currentKey == nil {
return nil, domain.NewError(400, "stored private key is invalid or empty")
}
newKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate new account key: %w", err)
}
newKeyPEM, err := xcert.ConvertECPrivateKeyToPEM(newKey, false)
if err != nil {
return nil, fmt.Errorf("failed to encode new account key: %w", err)
}
reg := account.GetRegistration()
if reg == nil || reg.Location == "" {
return nil, domain.NewError(400, "account registration location is empty")
}
legoUser := &importLegoUser{
email: account.Email,
privateKey: currentKey,
registration: reg,
}
legoCfg := lego.NewConfig(legoUser)
legoCfg.UserAgent = app.AppUserAgent
legoCfg.CADirURL = account.ACMEDirectoryUrl
legoClient, err := lego.NewClient(legoCfg)
if err != nil {
return nil, fmt.Errorf("failed to create lego client: %w", err)
}
if err := legoClient.Registration.KeyRollover(ctx, newKey); err != nil {
return nil, fmt.Errorf("failed to rollover acme account key: %w", err)
}
legoUser.privateKey = newKey
legoUser.registration = &acme.ExtendedAccount{
Account: *account.ResourceObject,
Location: account.ACMEAccountUrl,
}
legoCfg2 := lego.NewConfig(legoUser)
legoCfg2.UserAgent = app.AppUserAgent
legoCfg2.CADirURL = account.ACMEDirectoryUrl
legoClient2, err := lego.NewClient(legoCfg2)
if err == nil {
if refreshed, qerr := legoClient2.Registration.QueryRegistration(ctx); qerr == nil && refreshed != nil {
account.ResourceObject = &refreshed.Account
}
}
account.PrivateKey = newKeyPEM
if _, err := accountRepo.Save(ctx, account); err != nil {
return nil, fmt.Errorf("failed to save rotated acme account: %w", err)
}
return account, nil
}
+45
View File
@@ -0,0 +1,45 @@
package certacme
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-acme/lego/v5/acme/api"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
type ARIInfo struct {
WindowStart time.Time
WindowEnd time.Time
NextRefreshAt time.Time
Supported bool
}
func (c *ACMEClient) GetARIInfo(ctx context.Context, certPEM string) (*ARIInfo, error) {
if certPEM == "" {
return nil, fmt.Errorf("the certificate PEM is empty")
}
certX509, err := xcert.ParseCertificateFromPEM(certPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate PEM: %w", err)
}
renewalInfo, err := c.client.Certificate.GetRenewalInfo(ctx, certX509)
if err != nil {
if errors.Is(err, api.ErrNoARI) {
return &ARIInfo{Supported: false}, nil
}
return nil, err
}
return &ARIInfo{
WindowStart: renewalInfo.SuggestedWindow.Start,
WindowEnd: renewalInfo.SuggestedWindow.End,
NextRefreshAt: time.Now().Add(renewalInfo.RetryAfter),
Supported: true,
}, nil
}
+16 -2
View File
@@ -60,6 +60,7 @@ type ObtainCertificateRequest struct {
// ARI 相关
_ struct{}
DisableARI bool
ARIReplacesAccountUrl string
ARIReplacesCertId string
}
@@ -72,6 +73,8 @@ type ObtainCertificateResponse struct {
PrivateKey string
ACMEAccountUrl string
ACMECertificateUrl string
ARIInfo *ARIInfo
ARIError string
ARIReplaced bool
}
@@ -191,7 +194,7 @@ func (c *ACMEClient) ObtainCertificate(ctx context.Context, request *ObtainCerti
}
}
return &ObtainCertificateResponse{
obtainResp := &ObtainCertificateResponse{
CAProvider: domain.CAProviderType(c.account.CA),
CSR: strings.TrimSpace(string(resp.CSR)),
FullChainCertificate: strings.TrimSpace(string(resp.Certificate)),
@@ -200,5 +203,16 @@ func (c *ACMEClient) ObtainCertificate(ctx context.Context, request *ObtainCerti
ACMEAccountUrl: c.account.ACMEAccountUrl,
ACMECertificateUrl: resp.CertURL,
ARIReplaced: req.ReplacesCertID != "",
}, nil
}
if !request.DisableARI {
ariInfo, err := c.GetARIInfo(ctx, obtainResp.FullChainCertificate)
if err != nil {
obtainResp.ARIError = err.Error()
} else {
obtainResp.ARIInfo = ariInfo
}
}
return obtainResp, nil
}
@@ -0,0 +1,41 @@
package deployers
import (
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
)
func init() {
schema, err := providerschema.New(string(domain.DeploymentProviderTypeAliyunCDN), providerschema.CategoryDeploy).
Field("region", providerschema.ValueTypeAutocomplete,
providerschema.LabelKey("workflow_node.deploy.form.aliyun_cdn_region.label"),
providerschema.PlaceholderKey("workflow_node.deploy.form.aliyun_cdn_region.placeholder"),
providerschema.TooltipKey("workflow_node.deploy.form.aliyun_cdn_region.tooltip"),
providerschema.TooltipHtml(),
providerschema.Options("cn-hangzhou", "ap-southeast-1"),
providerschema.WithFilterMode(providerschema.FilterModePrefix),
).
Field("domainMatchPattern", providerschema.ValueTypeRadio,
providerschema.LabelKey("workflow_node.deploy.form.shared_domain_match_pattern.label"),
providerschema.OptionsWith(
providerschema.Option{Value: "exact", LabelKey: "workflow_node.deploy.form.shared_domain_match_pattern.option.exact.label"},
providerschema.Option{Value: "wildcard", LabelKey: "workflow_node.deploy.form.shared_domain_match_pattern.option.wildcard.label"},
providerschema.Option{Value: "certsan", LabelKey: "workflow_node.deploy.form.shared_domain_match_pattern.option.certsan.label"},
),
providerschema.Default("exact"),
providerschema.Required(),
).
Field("domain", providerschema.ValueTypeText,
providerschema.LabelKey("workflow_node.deploy.form.aliyun_cdn_domain.label"),
providerschema.PlaceholderKey("workflow_node.deploy.form.aliyun_cdn_domain.placeholder"),
providerschema.VisibleWhen("domainMatchPattern").NotIn("certsan"),
providerschema.RequiredWhen("domainMatchPattern").In("exact", "wildcard"),
providerschema.ValidateWhen("domainMatchPattern").In("exact", "wildcard").Validator(providerschema.ValidatorDomain, providerschema.WithParam("allowWildcard", true)),
).
Build()
if err != nil {
panic(err)
}
providerschema.Registries.MustRegister(string(domain.DeploymentProviderTypeAliyunCDN), schema)
}
@@ -0,0 +1,137 @@
package deployers
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
)
func TestPilot_AliyunCDN_SchemaMatchesStaticForm(t *testing.T) {
schema, err := providerschema.Registries.Get(string(domain.DeploymentProviderTypeAliyunCDN))
if err != nil {
t.Fatalf("aliyun-cdn schema not registered: %v", err)
}
env := providerschema.Emit(schema)
got := fieldNames(env)
want := []string{"region", "domainMatchPattern", "domain"}
if !equalSlices(got, want) {
t.Fatalf("field order/names = %v, want %v", got, want)
}
region := env.Schema.Columns[0]
if region.ValueType != providerschema.ValueTypeAutocomplete || len(region.Options) != 2 || !region.TooltipHtml {
t.Fatalf("region column not as expected: %+v", region)
}
dmp := env.Schema.Columns[1]
if dmp.ValueType != providerschema.ValueTypeRadio || len(dmp.Options) != 3 || dmp.Default != "exact" || !dmp.Required {
t.Fatalf("domainMatchPattern column not as expected: %+v", dmp)
}
domain := env.Schema.Columns[2]
if len(domain.VisibleWhen) != 1 || domain.VisibleWhen[0].Op != providerschema.OpNotIn {
t.Fatalf("domain visibleWhen not as expected: %+v", domain.VisibleWhen)
}
if len(domain.RequiredWhen) != 1 || domain.RequiredWhen[0].Op != providerschema.OpIn {
t.Fatalf("domain requiredWhen not as expected: %+v", domain.RequiredWhen)
}
if len(domain.ValidateWhen) != 1 || domain.ValidateWhen[0].Name != providerschema.ValidatorDomain {
t.Fatalf("domain validateWhen not as expected: %+v", domain.ValidateWhen)
}
if allow, _ := domain.ValidateWhen[0].Params["allowWildcard"].(bool); !allow {
t.Fatalf("domain validator should allow wildcards: %+v", domain.ValidateWhen[0].Params)
}
}
func TestPilot_AliyunCDN_DeploySideDriftGuard(t *testing.T) {
schema, err := providerschema.Registries.Get(string(domain.DeploymentProviderTypeAliyunCDN))
if err != nil {
t.Fatalf("aliyun-cdn schema not registered: %v", err)
}
schemaFields := make(map[string]struct{}, len(schema.Fields))
for _, f := range schema.Fields {
schemaFields[f.Name] = struct{}{}
}
readKeys, err := extendedConfigKeys("sp_aliyun_cdn.go")
if err != nil {
t.Fatalf("parse sp_aliyun_cdn.go: %v", err)
}
for key := range readKeys {
if _, ok := schemaFields[key]; !ok {
t.Errorf("deployer reads extended-config key %q but schema has no such field", key)
}
}
for name := range schemaFields {
if _, ok := readKeys[name]; !ok {
t.Errorf("schema field %q has no matching xmaps.Get* reader in sp_aliyun_cdn.go", name)
}
}
}
func fieldNames(env *providerschema.Envelope) []string {
out := make([]string, len(env.Schema.Columns))
for i, c := range env.Schema.Columns {
out[i] = c.Name
}
return out
}
func equalSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func extendedConfigKeys(filename string) (map[string]struct{}, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
if err != nil {
return nil, err
}
keys := make(map[string]struct{})
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
pkg, ok := sel.X.(*ast.Ident)
if !ok || pkg.Name != "xmaps" {
return true
}
if len(call.Args) < 2 {
return true
}
src, ok := call.Args[0].(*ast.SelectorExpr)
if !ok {
return true
}
if src.Sel == nil || src.Sel.Name != "ProviderExtendedConfig" {
return true
}
lit, ok := call.Args[1].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
keys[strings.Trim(lit.Value, `"`)] = struct{}{}
return true
})
return keys, nil
}
+18 -4
View File
@@ -2,6 +2,7 @@ package deployers
import (
"fmt"
"sync"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
@@ -18,35 +19,48 @@ type Registry[T comparable] interface {
Register(T, ProviderFactoryFunc) error
MustRegister(T, ProviderFactoryFunc)
Get(T) (ProviderFactoryFunc, error)
Unregister(T)
}
type registry[T comparable] struct {
mu sync.RWMutex
factories map[T]ProviderFactoryFunc
}
func (r *registry[T]) Register(name T, factory ProviderFactoryFunc) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.factories[name]; exists {
return fmt.Errorf("provider '%v' already registered", name)
}
r.factories[name] = factory
return nil
}
func (r *registry[T]) MustRegister(name T, factory ProviderFactoryFunc) {
if err := r.Register(name, factory); err != nil {
panic(err)
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.factories[name]; exists {
panic(fmt.Sprintf("provider '%v' already registered", name))
}
r.factories[name] = factory
}
func (r *registry[T]) Get(name T) (ProviderFactoryFunc, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if factory, exists := r.factories[name]; exists {
return factory, nil
}
return nil, fmt.Errorf("provider '%v' not registered", name)
}
func (r *registry[T]) Unregister(name T) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.factories, name)
}
func newRegistry[T comparable]() Registry[T] {
return &registry[T]{factories: make(map[T]ProviderFactoryFunc)}
}
@@ -1,27 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/dogecloud-cdn"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeDogeCloudCDN, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForDogeCloud{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
AccessKey: credentials.AccessKey,
SecretKey: credentials.SecretKey,
DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
})
return provider, err
})
}
@@ -1,78 +0,0 @@
package deployers
import (
"fmt"
"strings"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/k8s-secret"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeKubernetesSecret, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForKubernetes{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
parseKeyValueMap := func(s string) (map[string]string, error) {
result := make(map[string]string)
lines := strings.Split(s, "\n")
for i, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
pos := strings.Index(line, ":")
if pos == -1 {
return nil, fmt.Errorf("invalid line format at line %d", i+1)
}
key := strings.TrimSpace(line[:pos])
value := strings.TrimSpace(line[pos+1:])
if key == "" {
return nil, fmt.Errorf("invalid key at line %d", i+1)
}
result[key] = value
}
return result, nil
}
secretAnnotations := make(map[string]string)
if secretAnnotationsString := xmaps.GetString(options.ProviderExtendedConfig, "secretAnnotations"); secretAnnotationsString != "" {
temp, err := parseKeyValueMap(secretAnnotationsString)
if err != nil {
return nil, fmt.Errorf("failed to parse kubernetes secret annotations: %w", err)
}
secretAnnotations = temp
}
secretLabels := make(map[string]string)
if secretLabelsString := xmaps.GetString(options.ProviderExtendedConfig, "secretLabels"); secretLabelsString != "" {
temp, err := parseKeyValueMap(secretLabelsString)
if err != nil {
return nil, fmt.Errorf("failed to parse kubernetes secret labels: %w", err)
}
secretLabels = temp
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
KubeConfig: credentials.KubeConfig,
Namespace: xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "namespace", "default"),
SecretName: xmaps.GetString(options.ProviderExtendedConfig, "secretName"),
SecretType: xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "secretType", "kubernetes.io/tls"),
SecretDataKeyForKey: xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "secretDataKeyForKey", "tls.key"),
SecretDataKeyForCrt: xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "secretDataKeyForCrt", "tls.crt"),
SecretDataKeyForCrtOnlyServer: xmaps.GetString(options.ProviderExtendedConfig, "secretDataKeyForCrtOnlyServer"),
SecretDataKeyForCrtOnlyIntermedia: xmaps.GetString(options.ProviderExtendedConfig, "secretDataKeyForCrtOnlyIntermedia"),
SecretAnnotations: secretAnnotations,
SecretLabels: secretLabels,
})
return provider, err
})
}
@@ -1,27 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-cdn"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeQiniuCDN, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForQiniu{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
AccessKey: credentials.AccessKey,
SecretKey: credentials.SecretKey,
DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
})
return provider, err
})
}
@@ -1,27 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-kodo"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeQiniuKodo, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForQiniu{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
AccessKey: credentials.AccessKey,
SecretKey: credentials.SecretKey,
Bucket: xmaps.GetString(options.ProviderExtendedConfig, "bucket"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
})
return provider, err
})
}
@@ -1,28 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-pili"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeQiniuPili, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForQiniu{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
AccessKey: credentials.AccessKey,
SecretKey: credentials.SecretKey,
Hub: xmaps.GetString(options.ProviderExtendedConfig, "hub"),
DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
})
return provider, err
})
}
@@ -1,27 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/upyun-cdn"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeUpyunCDN, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForUpyun{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
Username: credentials.Username,
Password: credentials.Password,
DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
})
return provider, err
})
}
@@ -1,27 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/upyun-file"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeUpyunFile, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForUpyun{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
Username: credentials.Username,
Password: credentials.Password,
Bucket: xmaps.GetString(options.ProviderExtendedConfig, "bucket"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
})
return provider, err
})
}
@@ -1,30 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/zenlayer-cdn"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeZenlayerCDN, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForZenlayer{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
AccessKeyId: credentials.AccessKeyId,
AccessKeyPassword: credentials.AccessKeyPassword,
ResourceGroupId: credentials.ResourceGroupId,
DeployTarget: xmaps.GetString(options.ProviderExtendedConfig, "deployTarget"),
DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
CertificateId: xmaps.GetString(options.ProviderExtendedConfig, "certificateId"),
})
return provider, err
})
}
@@ -1,29 +0,0 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core"
dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/zenlayer-ga"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeZenlayerGA, func(options *ProviderFactoryOptions) (core.Deployer, error) {
credentials := domain.AccessConfigForZenlayer{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
AccessKeyId: credentials.AccessKeyId,
AccessKeyPassword: credentials.AccessKeyPassword,
ResourceGroupId: credentials.ResourceGroupId,
DeployTarget: xmaps.GetString(options.ProviderExtendedConfig, "deployTarget"),
AcceleratorId: xmaps.GetString(options.ProviderExtendedConfig, "acceleratorId"),
CertificateId: xmaps.GetString(options.ProviderExtendedConfig, "certificateId"),
})
return provider, err
})
}
-25
View File
@@ -232,11 +232,6 @@ type AccessConfigForDNSMadeEasy struct {
ApiSecret string `json:"apiSecret"`
}
type AccessConfigForDogeCloud struct {
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
}
type AccessConfigForDokploy struct {
ServerUrl string `json:"serverUrl"`
ApiKey string `json:"apiKey"`
@@ -380,10 +375,6 @@ type AccessConfigForKong struct {
AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"`
}
type AccessConfigForKubernetes struct {
KubeConfig string `json:"kubeConfig,omitempty"`
}
type AccessConfigForKsyun struct {
AccessKeyId string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
@@ -517,11 +508,6 @@ type AccessConfigForQingCloud struct {
SecretAccessKey string `json:"secretAccessKey"`
}
type AccessConfigForQiniu struct {
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
}
type AccessConfigForRainYun struct {
ApiKey string `json:"apiKey"`
}
@@ -658,11 +644,6 @@ type AccessConfigForUniCloud struct {
Password string `json:"password"`
}
type AccessConfigForUpyun struct {
Username string `json:"username"`
Password string `json:"password"`
}
type AccessConfigForVercel struct {
ApiAccessToken string `json:"apiAccessToken"`
TeamId string `json:"teamId,omitempty"`
@@ -712,12 +693,6 @@ type AccessConfigForYandexCloud struct {
ServiceAccountKey string `json:"serviceAccountKey"`
}
type AccessConfigForZenlayer struct {
AccessKeyId string `json:"accessKeyId"`
AccessKeyPassword string `json:"accessKeyPassword"`
ResourceGroupId string `json:"resourceGroupId,omitempty"`
}
type AccessConfigForZeroSSL struct {
AccessConfigForACMEExternalAccountBinding
}
+45
View File
@@ -0,0 +1,45 @@
package dtos
import "time"
type ACMEAccountView struct {
Id string `json:"id"`
CA string `json:"ca"`
Email string `json:"email"`
ACMEDirectoryUrl string `json:"acmeDirUrl"`
ACMEAccountUrl string `json:"acmeAcctUrl"`
CreatedAt time.Time `json:"created"`
UpdatedAt time.Time `json:"updated"`
}
type ACMEAccountListReq struct {
Page int `json:"-"`
PerPage int `json:"-"`
CA string `json:"-"`
}
type ACMEAccountListResp struct {
Items []*ACMEAccountView `json:"items"`
TotalItems int64 `json:"totalItems"`
Page int `json:"page"`
PerPage int `json:"perPage"`
}
type ACMEAccountImportReq struct {
PrivateKeyPem string `json:"privateKeyPem"`
CA string `json:"ca"`
ACMEDirUrl string `json:"acmeDirUrl,omitempty"`
Email string `json:"email,omitempty"`
}
type ACMEAccountImportResp struct {
Item *ACMEAccountView `json:"item"`
}
type ACMEAccountExportResp struct {
PrivateKeyPem string `json:"privateKeyPem"`
}
type ACMEAccountRotateResp struct {
Item *ACMEAccountView `json:"item"`
}
-14
View File
@@ -54,7 +54,6 @@ const (
AccessProviderTypeDNSExit = AccessProviderType("dnsexit")
AccessProviderTypeDNSLA = AccessProviderType("dnsla")
AccessProviderTypeDNSMadeEasy = AccessProviderType("dnsmadeeasy")
AccessProviderTypeDogeCloud = AccessProviderType("dogecloud")
AccessProviderTypeDokploy = AccessProviderType("dokploy")
AccessProviderTypeDuckDNS = AccessProviderType("duckdns")
AccessProviderTypeDynadot = AccessProviderType("dynadot")
@@ -83,7 +82,6 @@ const (
AccessProviderTypeJDCloud = AccessProviderType("jdcloud")
AccessProviderTypeKong = AccessProviderType("kong")
AccessProviderTypeKsyun = AccessProviderType("ksyun")
AccessProviderTypeKubernetes = AccessProviderType("k8s")
AccessProviderTypeLarkBot = AccessProviderType("larkbot")
AccessProviderTypeLeCDN = AccessProviderType("lecdn")
AccessProviderTypeLetsEncrypt = AccessProviderType("letsencrypt")
@@ -107,7 +105,6 @@ const (
AccessProviderTypePowerDNS = AccessProviderType("powerdns")
AccessProviderTypeProxmoxBS = AccessProviderType("proxmoxbs")
AccessProviderTypeProxmoxVE = AccessProviderType("proxmoxve")
AccessProviderTypeQiniu = AccessProviderType("qiniu")
AccessProviderTypeQingCloud = AccessProviderType("qingcloud")
AccessProviderTypeRainYun = AccessProviderType("rainyun")
AccessProviderTypeRatPanel = AccessProviderType("ratpanel")
@@ -130,7 +127,6 @@ const (
AccessProviderTypeTodayNIC = AccessProviderType("todaynic")
AccessProviderTypeUCloud = AccessProviderType("ucloud")
AccessProviderTypeUniCloud = AccessProviderType("unicloud")
AccessProviderTypeUpyun = AccessProviderType("upyun")
AccessProviderTypeVercel = AccessProviderType("vercel")
AccessProviderTypeVolcEngine = AccessProviderType("volcengine")
AccessProviderTypeVultr = AccessProviderType("vultr")
@@ -140,7 +136,6 @@ const (
AccessProviderTypeWestcn = AccessProviderType("westcn")
AccessProviderTypeXinnet = AccessProviderType("xinnet")
AccessProviderTypeYandexCloud = AccessProviderType("yandexcloud")
AccessProviderTypeZenlayer = AccessProviderType("zenlayer")
AccessProviderTypeZeroSSL = AccessProviderType("zerossl")
)
@@ -380,7 +375,6 @@ const (
DeploymentProviderTypeCTCCCloudICDN = DeploymentProviderType(AccessProviderTypeCTCCCloud + "-icdn")
DeploymentProviderTypeCTCCCloudLVDN = DeploymentProviderType(AccessProviderTypeCTCCCloud + "-lvdn")
DeploymentProviderTypeDigitalOceanCertificate = DeploymentProviderType(AccessProviderTypeDigitalOcean + "-certificate")
DeploymentProviderTypeDogeCloudCDN = DeploymentProviderType(AccessProviderTypeDogeCloud + "-cdn")
DeploymentProviderTypeDokploy = DeploymentProviderType(AccessProviderTypeDokploy)
DeploymentProviderTypeFlexCDN = DeploymentProviderType(AccessProviderTypeFlexCDN)
DeploymentProviderTypeFlyIO = DeploymentProviderType(AccessProviderTypeFlyIO)
@@ -405,7 +399,6 @@ const (
DeploymentProviderTypeJDCloudSSL = DeploymentProviderType(AccessProviderTypeJDCloud + "-ssl")
DeploymentProviderTypeJDCloudWAF = DeploymentProviderType(AccessProviderTypeJDCloud + "-waf")
DeploymentProviderTypeKong = DeploymentProviderType(AccessProviderTypeKong)
DeploymentProviderTypeKubernetesSecret = DeploymentProviderType(AccessProviderTypeKubernetes + "-secret")
DeploymentProviderTypeKsyunCDN = DeploymentProviderType(AccessProviderTypeKsyun + "-cdn")
DeploymentProviderTypeKsyunKCM = DeploymentProviderType(AccessProviderTypeKsyun + "-kcm")
DeploymentProviderTypeKsyunSLB = DeploymentProviderType(AccessProviderTypeKsyun + "-slb")
@@ -419,9 +412,6 @@ const (
DeploymentProviderTypeProxmoxBS = DeploymentProviderType(AccessProviderTypeProxmoxBS)
DeploymentProviderTypeProxmoxVE = DeploymentProviderType(AccessProviderTypeProxmoxVE)
DeploymentProviderTypeQingCloudLB = DeploymentProviderType(AccessProviderTypeQingCloud + "-lb")
DeploymentProviderTypeQiniuCDN = DeploymentProviderType(AccessProviderTypeQiniu + "-cdn")
DeploymentProviderTypeQiniuKodo = DeploymentProviderType(AccessProviderTypeQiniu + "-kodo")
DeploymentProviderTypeQiniuPili = DeploymentProviderType(AccessProviderTypeQiniu + "-pili")
DeploymentProviderTypeRainYunRCDN = DeploymentProviderType(AccessProviderTypeRainYun + "-rcdn")
DeploymentProviderTypeRainYunSSLCenter = DeploymentProviderType(AccessProviderTypeRainYun + "-sslcenter")
DeploymentProviderTypeRatPanel = DeploymentProviderType(AccessProviderTypeRatPanel)
@@ -455,8 +445,6 @@ const (
DeploymentProviderTypeUCloudUPathX = DeploymentProviderType(AccessProviderTypeUCloud + "-pathx")
DeploymentProviderTypeUCloudUS3 = DeploymentProviderType(AccessProviderTypeUCloud + "-us3")
DeploymentProviderTypeUniCloudWebHost = DeploymentProviderType(AccessProviderTypeUniCloud + "-webhost")
DeploymentProviderTypeUpyunCDN = DeploymentProviderType(AccessProviderTypeUpyun + "-cdn")
DeploymentProviderTypeUpyunFile = DeploymentProviderType(AccessProviderTypeUpyun + "-file")
DeploymentProviderTypeVercel = DeploymentProviderType(AccessProviderTypeVercel)
DeploymentProviderTypeVolcEngineALB = DeploymentProviderType(AccessProviderTypeVolcEngine + "-alb")
DeploymentProviderTypeVolcEngineAPIG = DeploymentProviderType(AccessProviderTypeVolcEngine + "-apig")
@@ -474,8 +462,6 @@ const (
DeploymentProviderTypeWangsuCertificate = DeploymentProviderType(AccessProviderTypeWangsu + "-certificate")
DeploymentProviderTypeWebhook = DeploymentProviderType(AccessProviderTypeWebhook)
DeploymentProviderTypeYandexCloudCertificateManager = DeploymentProviderType(AccessProviderTypeYandexCloud + "-certificatemanager")
DeploymentProviderTypeZenlayerCDN = DeploymentProviderType(AccessProviderTypeZenlayer + "-cdn")
DeploymentProviderTypeZenlayerGA = DeploymentProviderType(AccessProviderTypeZenlayer + "-ga")
)
type PurgeProviderType string
+85
View File
@@ -0,0 +1,85 @@
package pluginhost
import (
"context"
"encoding/json"
"log/slog"
"sync"
"github.com/certimate-go/certimate/internal/certmgmt/deployers"
"github.com/certimate-go/certimate/pkg/core"
"github.com/certimate-go/certimate/pkg/plugin"
)
type pluginDeployerFactory struct {
manager *plugin.Manager
dp *plugin.DiscoveredPlugin
providerType string
logLevel string
logger *slog.Logger
mu sync.Mutex
}
func (f *pluginDeployerFactory) new(options *deployers.ProviderFactoryOptions) (core.Deployer, error) {
return &pluginDeployer{
factory: f,
access: options.ProviderAccessConfig,
extended: options.ProviderExtendedConfig,
logger: f.logger,
}, nil
}
type pluginDeployer struct {
factory *pluginDeployerFactory
access map[string]any
extended map[string]any
logger *slog.Logger
}
func (d *pluginDeployer) SetLogger(logger *slog.Logger) {
if logger != nil {
d.logger = logger
}
}
func (d *pluginDeployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*core.DeployerDeployResult, error) {
d.factory.mu.Lock()
defer d.factory.mu.Unlock()
if d.logger != nil {
d.logger.Info("deploying via plugin", slog.String("provider", d.factory.providerType))
}
req := &plugin.DeployRequest{
LogLevel: d.factory.logLevel,
AccessConfigJSON: marshalJSON(d.access),
ExtendedConfigJSON: marshalJSON(d.extended),
CertificatePEM: certPEM,
PrivateKeyPEM: privkeyPEM,
}
res, err := d.factory.manager.Deploy(ctx, d.factory.dp, req, d.logger)
if err != nil {
return nil, err
}
result := &core.DeployerDeployResult{}
if res != nil && res.ExtendedDataJSON != "" {
var extended map[string]any
if err := json.Unmarshal([]byte(res.ExtendedDataJSON), &extended); err == nil {
result.ExtendedData = extended
}
}
return result, nil
}
func marshalJSON(m map[string]any) string {
if len(m) == 0 {
return "{}"
}
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}
+24
View File
@@ -0,0 +1,24 @@
package pluginhost
import (
"encoding/json"
"testing"
)
func TestMarshalJSON_EmptyMap(t *testing.T) {
if got := marshalJSON(nil); got != "{}" {
t.Fatalf("empty map should be {}, got %q", got)
}
}
func TestMarshalJSON_RoundTrips(t *testing.T) {
in := map[string]any{"url": "https://example.com", "port": float64(8443)}
s := marshalJSON(in)
var out map[string]any
if err := json.Unmarshal([]byte(s), &out); err != nil {
t.Fatalf("round-trip unmarshal: %v", err)
}
if out["url"] != "https://example.com" {
t.Fatalf("url lost: %v", out)
}
}
+97
View File
@@ -0,0 +1,97 @@
package pluginhost
import (
"sort"
"sync"
)
const (
SourcePlugin = "plugin"
SourceMarketplace = "marketplace"
)
type CatalogEntry struct {
Source string `json:"source"`
ProviderType string `json:"providerType"`
AccessProviderType string `json:"accessProviderType"`
DeployCategory string `json:"deployCategory"`
DisplayNameKey string `json:"displayNameKey"`
AccessDisplayNameKey string `json:"accessDisplayNameKey"`
Icon string `json:"icon,omitempty"`
I18n map[string]map[string]string `json:"i18n,omitempty"`
AccessUsages []string `json:"accessUsages,omitempty"`
Priority int `json:"priority"`
Description string `json:"description,omitempty"`
Deployers []string `json:"deployers,omitempty"`
}
type Catalog struct {
mu sync.RWMutex
entries []*CatalogEntry
}
func NewCatalog() *Catalog {
return &Catalog{}
}
func (c *Catalog) Add(entry *CatalogEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = append(c.entries, entry)
c.sortLocked()
c.buildDeployersLocked()
}
func (c *Catalog) sortLocked() {
sort.SliceStable(c.entries, func(i, j int) bool {
if c.entries[i].Priority != c.entries[j].Priority {
return c.entries[i].Priority > c.entries[j].Priority
}
return c.entries[i].ProviderType < c.entries[j].ProviderType
})
}
func (c *Catalog) buildDeployersLocked() {
byAccess := make(map[string][]string)
for _, e := range c.entries {
byAccess[e.AccessProviderType] = append(byAccess[e.AccessProviderType], e.ProviderType)
}
for _, e := range c.entries {
e.Deployers = byAccess[e.ProviderType]
}
}
func (c *Catalog) Entries() []CatalogEntry {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]CatalogEntry, len(c.entries))
for i, e := range c.entries {
out[i] = *e
}
return out
}
func (c *Catalog) Remove(providerType string) {
c.mu.Lock()
defer c.mu.Unlock()
for i, e := range c.entries {
if e.ProviderType == providerType {
c.entries = append(c.entries[:i], c.entries[i+1:]...)
c.buildDeployersLocked()
return
}
}
}
func (c *Catalog) I18nBundles() map[string]map[string]map[string]string {
c.mu.RLock()
defer c.mu.RUnlock()
out := make(map[string]map[string]map[string]string, len(c.entries))
for _, e := range c.entries {
if len(e.I18n) == 0 {
continue
}
out[e.ProviderType] = e.I18n
}
return out
}
+36
View File
@@ -0,0 +1,36 @@
package pluginhost
import (
"testing"
)
func TestCatalog_AddAndEntries(t *testing.T) {
c := NewCatalog()
c.Add(&CatalogEntry{Source: SourcePlugin, ProviderType: "b", DeployCategory: "other"})
c.Add(&CatalogEntry{Source: SourcePlugin, ProviderType: "a", DeployCategory: "other"})
got := c.Entries()
if len(got) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got))
}
if got[0].ProviderType != "a" || got[1].ProviderType != "b" {
t.Fatalf("entries not sorted by type: %+v", got)
}
}
func TestCatalog_I18nBundles(t *testing.T) {
c := NewCatalog()
c.Add(&CatalogEntry{
ProviderType: "demo",
I18n: map[string]map[string]string{"zh": {"plugin.demo.name": "示例"}},
})
c.Add(&CatalogEntry{ProviderType: "noi18n"})
bundles := c.I18nBundles()
if len(bundles) != 1 {
t.Fatalf("expected 1 bundle, got %d", len(bundles))
}
if bundles["demo"]["zh"]["plugin.demo.name"] != "示例" {
t.Fatalf("bundle content mismatch: %+v", bundles)
}
}
+318
View File
@@ -0,0 +1,318 @@
package pluginhost_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/certimate-go/certimate/internal/certmgmt/deployers"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/pluginhost"
"github.com/certimate-go/certimate/internal/providerschema"
"github.com/certimate-go/certimate/internal/repository"
"github.com/certimate-go/certimate/pkg/plugin"
)
const (
e2eDeployType = "webhook-deployer"
e2eAccessType = "webhook"
)
var (
pilotOnce sync.Once
pilotBin string
pilotErr error
)
func buildPilot(t *testing.T) string {
t.Helper()
pilotOnce.Do(func() {
out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output()
if err != nil {
pilotErr = err
return
}
certimateRoot := strings.TrimSpace(string(out))
pluginsDir := filepath.Join(certimateRoot, "..", "plugins")
if _, err := os.Stat(filepath.Join(pluginsDir, "go.mod")); err != nil {
pilotErr = errors.New("plugins module not found at " + pluginsDir)
return
}
bin := filepath.Join(os.TempDir(), "certimate-e2e-webhook-deployer")
if runtime.GOOS == "windows" {
bin += ".exe"
}
build := exec.Command("go", "build", "-o", bin, "./webhook-deployer")
build.Dir = pluginsDir
if buildOut, err := build.CombinedOutput(); err != nil {
pilotErr = errors.New(strings.TrimSpace(string(buildOut)) + " | " + err.Error())
return
}
pilotBin = bin
})
if pilotErr != nil {
t.Skipf("pilot build unavailable: %v", pilotErr)
}
return pilotBin
}
func setupE2EPluginDir(t *testing.T) string {
t.Helper()
bin := buildPilot(t)
dir := t.TempDir()
pluginDir := filepath.Join(dir, e2eDeployType)
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := map[string]any{
"version": "0.1.0",
"provider_type": e2eDeployType,
"access_provider_type": e2eAccessType,
"display_name_key": "plugin.webhook-deployer.name",
"deploy_category": "other",
"protocol_version": plugin.ProtocolVersion,
"binary": "webhook-deployer",
}
mb, _ := json.Marshal(manifest)
if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), mb, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(bin, filepath.Join(pluginDir, "webhook-deployer")); err != nil {
t.Fatal(err)
}
return dir
}
func TestE2E_PilotDeploysThroughFullStack(t *testing.T) {
dir := setupE2EPluginDir(t)
var seen struct {
method string
path string
auth string
body string
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen.method = r.Method
seen.path = r.URL.Path
seen.auth = r.Header.Get("Authorization")
b, _ := io.ReadAll(r.Body)
seen.body = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
catalog, errs := pluginhost.ScanAndRegister(context.Background(), plugin.PluginConfig{
PluginDir: dir,
CoreVersion: "0.4.28",
}, slog.Default())
if len(errs) != 0 {
t.Fatalf("scan/register errors: %+v", errs)
}
entries := catalog.Entries()
if len(entries) != 1 || entries[0].ProviderType != e2eDeployType {
t.Fatalf("catalog missing pilot entry: %+v", entries)
}
if entries[0].Source != pluginhost.SourcePlugin {
t.Fatalf("entry source = %q", entries[0].Source)
}
factory, err := deployers.Registries.Get(domain.DeploymentProviderType(e2eDeployType))
if err != nil {
t.Fatalf("pilot not in deployer registry: %v", err)
}
access, _ := json.Marshal(map[string]string{"url": srv.URL, "method": "POST", "headers": "Authorization: Bearer e2e-secret"})
extended, _ := json.Marshal(map[string]string{})
deployer, err := factory(&deployers.ProviderFactoryOptions{
ProviderAccessConfig: jsonMap(access),
ProviderExtendedConfig: jsonMap(extended),
})
if err != nil {
t.Fatalf("factory: %v", err)
}
sink := &captureSink{}
deployer.SetLogger(slog.New(sink))
certPEM, keyPEM := e2eSelfSignedCert(t, "e2e.example.com")
if _, err := deployer.Deploy(context.Background(), certPEM, keyPEM); err != nil {
t.Fatalf("deploy through full stack: %v", err)
}
if !sink.has("webhook responded") {
t.Fatalf("plugin logs did not reach the deployer sink (parity with built-in); records=%+v", sink.records)
}
if seen.method != "POST" {
t.Fatalf("webhook target saw %s", seen.method)
}
if seen.auth != "Bearer e2e-secret" {
t.Fatalf("auth = %q", seen.auth)
}
var gotBody map[string]string
if err := json.Unmarshal([]byte(seen.body), &gotBody); err != nil {
t.Fatalf("body not json: %v (%s)", err, seen.body)
}
if gotBody["cert"] != certPEM {
t.Fatalf("cert not forwarded: %s", seen.body)
}
}
func e2eSelfSignedCert(t *testing.T, commonName string) (certPEM, keyPEM string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: commonName},
DNSNames: []string{commonName},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
return certPEM, keyPEM
}
func TestE2E_SchemaEndpointServesPluginEnvelope(t *testing.T) {
dir := setupE2EPluginDir(t)
if _, errs := pluginhost.ScanAndRegister(context.Background(), plugin.PluginConfig{
PluginDir: dir, CoreVersion: "0.4.28",
}, slog.Default()); len(errs) != 0 {
t.Fatalf("scan/register errors: %+v", errs)
}
repo := repository.NewProviderSchemaRepository()
svc := providerschema.NewProviderSchemaService(repo)
env, err := svc.GetByProviderType(context.Background(), e2eDeployType)
if err != nil {
t.Fatalf("schema endpoint for plugin deploy type: %v", err)
}
if env.Provider != e2eDeployType || env.SchemaVersion != providerschema.SchemaVersion {
t.Fatalf("unexpected envelope: %+v", env)
}
if len(env.Schema.Columns) == 0 {
t.Fatal("deploy envelope has no columns")
}
if env, ok := providerschema.Envelopes.GetEnvelope(e2eAccessType); ok || env != nil {
t.Fatalf("plugin must NOT register an access envelope for the reused builtin %q", e2eAccessType)
}
}
func TestE2E_IncompatibleVersionRejected(t *testing.T) {
bin := buildPilot(t)
dir := t.TempDir()
pluginDir := filepath.Join(dir, "bad-version")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := map[string]any{
"provider_type": "bad-version",
"access_provider_type": "bad-version-access",
"protocol_version": 999,
"binary": "webhook-deployer",
}
mb, _ := json.Marshal(manifest)
if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), mb, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(bin, filepath.Join(pluginDir, "webhook-deployer")); err != nil {
t.Fatal(err)
}
_, errs := pluginhost.ScanAndRegister(context.Background(), plugin.PluginConfig{
PluginDir: dir, CoreVersion: "0.4.28",
}, slog.Default())
if len(errs) == 0 {
t.Fatal("expected incompatible-version plugin to be rejected")
}
var inc *plugin.ErrPluginIncompatible
found := false
for _, e := range errs {
if errors.As(e, &inc) {
found = true
break
}
}
if !found {
t.Fatalf("expected ErrPluginIncompatible, got %+v", errs)
}
}
func TestE2E_BuiltinStillRegistered(t *testing.T) {
if _, err := deployers.Registries.Get(domain.DeploymentProviderTypeLocal); err != nil {
t.Fatalf("builtin LOCAL deployer missing: %v", err)
}
}
func jsonMap(raw []byte) map[string]any {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
panic(err)
}
return m
}
type captureSink struct {
mu sync.Mutex
records []sinkRecord
}
type sinkRecord struct {
Level slog.Level
Message string
}
func (c *captureSink) Enabled(_ context.Context, _ slog.Level) bool { return true }
func (c *captureSink) Handle(_ context.Context, r slog.Record) error {
c.mu.Lock()
defer c.mu.Unlock()
c.records = append(c.records, sinkRecord{Level: r.Level, Message: r.Message})
return nil
}
func (c *captureSink) WithAttrs(_ []slog.Attr) slog.Handler { return c }
func (c *captureSink) WithGroup(_ string) slog.Handler { return c }
func (c *captureSink) has(msg string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, r := range c.records {
if r.Message == msg {
return true
}
}
return false
}
+212
View File
@@ -0,0 +1,212 @@
package pluginhost
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/certimate-go/certimate/internal/certmgmt/deployers"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
"github.com/certimate-go/certimate/pkg/plugin"
)
func ScanAndRegister(ctx context.Context, cfg plugin.PluginConfig, logger *slog.Logger) (*Catalog, []error) {
if logger == nil {
logger = slog.Default()
}
catalog := NewCatalog()
if cfg.PluginDir == "" {
return catalog, nil
}
if info, err := os.Stat(cfg.PluginDir); err != nil || !info.IsDir() {
logger.Debug("plugin dir not present, skipping plugin scan", slog.String("dir", cfg.PluginDir))
return catalog, nil
}
manager := plugin.NewManager(cfg, logger)
discovered, failures := plugin.Discover(ctx, cfg)
var errs []error
for _, f := range failures {
logger.Warn("plugin discovery skipped entry",
slog.String("dir", f.Dir),
slog.Any("error", f.Err))
errs = append(errs, f)
}
for _, dp := range discovered {
if err := registerOne(ctx, manager, dp, catalog, logger); err != nil {
logger.Error("plugin registration failed",
slog.String("provider", dp.Manifest.ProviderType),
slog.Any("error", err))
errs = append(errs, fmt.Errorf("plugin %q: %w", dp.Manifest.ProviderType, err))
}
}
return catalog, errs
}
func registerOne(ctx context.Context, manager *plugin.Manager, dp *plugin.DiscoveredPlugin, catalog *Catalog, logger *slog.Logger) error {
meta, schema, err := manager.Bootstrap(ctx, dp)
if err != nil {
return fmt.Errorf("bootstrap: %w", err)
}
if meta.ProtocolVersion != plugin.ProtocolVersion {
return &plugin.ErrPluginIncompatible{
ProviderType: meta.ProviderType,
Have: meta.ProtocolVersion,
Want: plugin.ProtocolVersion,
}
}
deployType := meta.ProviderType
accessType := meta.AccessProviderType
if accessType == "" {
accessType = deployType
}
factory := &pluginDeployerFactory{
manager: manager,
dp: dp,
providerType: deployType,
logLevel: manager.Config().LogLevel,
logger: logger.With(slog.String("plugin", deployType)),
}
if err := deployers.Registries.Register(domain.DeploymentProviderType(deployType), factory.new); err != nil {
logger.Warn("deployer registry register skipped",
slog.String("provider", deployType),
slog.Any("error", err))
}
registerAccessEnvelope(deployType, accessType, schema.AccessSchemaJSON, logger)
registerEnvelope(deployType, schema.DeploySchemaJSON, providerschema.CategoryDeploy, logger)
usages := dp.Manifest.Usages
if len(usages) == 0 {
usages = []string{"hosting"}
}
catalog.Add(&CatalogEntry{
Source: SourcePlugin,
ProviderType: deployType,
AccessProviderType: accessType,
DeployCategory: meta.DeployCategory,
DisplayNameKey: meta.DeployDisplayNameKey,
AccessDisplayNameKey: meta.AccessDisplayNameKey,
Icon: readIcon(dp, logger),
I18n: schema.I18n,
AccessUsages: usages,
Priority: dp.Manifest.Priority,
Description: dp.Manifest.Description,
})
return nil
}
func registerAccessEnvelope(deployType, accessType string, raw []byte, logger *slog.Logger) {
if len(raw) == 0 {
logger.Info("plugin reuses an existing access type; no access schema registered",
slog.String("provider", deployType),
slog.String("accessType", accessType))
return
}
if accessType == deployType {
logger.Warn("plugin access type equals deploy type; skipping access schema to avoid collision",
slog.String("provider", deployType))
return
}
if providerschema.Registries.Has(accessType) {
logger.Info("plugin reuses a built-in access type; keeping built-in access schema",
slog.String("provider", deployType),
slog.String("accessType", accessType))
return
}
if env, ok := providerschema.Envelopes.GetEnvelope(accessType); ok && env != nil {
logger.Info("plugin shares an access type already registered by another plugin; keeping existing schema",
slog.String("provider", deployType),
slog.String("accessType", accessType))
_ = env
return
}
registerEnvelope(accessType, raw, providerschema.CategoryAccess, logger)
}
func registerEnvelope(providerType string, raw []byte, category providerschema.Category, logger *slog.Logger) {
env, err := parseEnvelope(raw)
if err != nil {
logger.Warn("plugin schema envelope parse failed",
slog.String("provider", providerType),
slog.Any("error", err))
return
}
if env.Provider == "" {
env.Provider = providerType
}
env.Category = category
if err := providerschema.Envelopes.RegisterEnvelope(providerType, env); err != nil {
logger.Warn("envelope register skipped",
slog.String("provider", providerType),
slog.Any("error", err))
}
}
func parseEnvelope(raw []byte) (*providerschema.Envelope, error) {
if len(raw) == 0 {
return &providerschema.Envelope{SchemaVersion: providerschema.SchemaVersion}, nil
}
var env providerschema.Envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
return &env, nil
}
func readIcon(dp *plugin.DiscoveredPlugin, logger *slog.Logger) string {
iconName := strings.TrimSpace(dp.Manifest.Icon)
if iconName == "" {
return ""
}
iconPath := iconName
if !filepath.IsAbs(iconPath) {
iconPath = filepath.Join(dp.Dir, iconName)
}
data, err := os.ReadFile(iconPath)
if err != nil {
logger.Debug("plugin icon unreadable, using placeholder",
slog.String("provider", dp.Manifest.ProviderType),
slog.String("icon", iconPath))
return ""
}
mime, ok := mimeForExt(filepath.Ext(iconName))
if !ok {
logger.Debug("plugin icon format unsupported, using placeholder",
slog.String("provider", dp.Manifest.ProviderType),
slog.String("icon", iconPath))
return ""
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
func mimeForExt(ext string) (string, bool) {
switch strings.ToLower(ext) {
case ".png":
return "image/png", true
case ".jpg", ".jpeg":
return "image/jpeg", true
case ".gif":
return "image/gif", true
case ".webp":
return "image/webp", true
case ".svg":
return "image/svg+xml", true
default:
return "", false
}
}
+191
View File
@@ -0,0 +1,191 @@
package pluginhost
import (
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"github.com/certimate-go/certimate/internal/certmgmt/deployers"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
"github.com/certimate-go/certimate/pkg/plugin"
)
var (
hostFakeOnce sync.Once
hostFakeBin string
hostFakeErr error
)
func buildFakeBinary(t *testing.T) string {
t.Helper()
hostFakeOnce.Do(func() {
out := filepath.Join(os.TempDir(), "certimate-fakeplugin-pluginhost-test")
if runtime.GOOS == "windows" {
out += ".exe"
}
cmd := exec.Command("go", "build", "-tags=fakeplugin", "-o", out, "github.com/certimate-go/certimate/pkg/plugin/testplugin")
if buildOut, err := cmd.CombinedOutput(); err != nil {
hostFakeErr = errors.New(strings.TrimSpace(string(buildOut)) + " | " + err.Error())
return
}
hostFakeBin = out
})
if hostFakeErr != nil {
t.Skipf("fakeplugin build failed: %v", hostFakeErr)
}
return hostFakeBin
}
func setupPluginDir(t *testing.T, providerType, accessType string) (string, *plugin.DiscoveredPlugin) {
t.Helper()
bin := buildFakeBinary(t)
dir := t.TempDir()
pluginDir := filepath.Join(dir, providerType)
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := map[string]any{
"version": "0.1.0",
"provider_type": providerType,
"access_provider_type": accessType,
"display_name_key": "plugin." + providerType + ".name",
"deploy_category": "other",
"protocol_version": plugin.ProtocolVersion,
"binary": "fakeplugin",
}
mb := jsonMarshal(manifest)
if err := os.WriteFile(filepath.Join(pluginDir, "manifest.json"), mb, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(bin, filepath.Join(pluginDir, "fakeplugin")); err != nil {
t.Fatal(err)
}
return dir, &plugin.DiscoveredPlugin{Manifest: &plugin.Manifest{
ProviderType: providerType,
AccessProviderType: accessType,
Binary: "fakeplugin",
}, Dir: pluginDir}
}
func jsonMarshal(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func setFakeEnv(providerType, behavior string) func() {
os.Setenv("FAKEPLUGIN_PROVIDER_TYPE", providerType)
os.Setenv("FAKEPLUGIN_ACCESS_TYPE", providerType+"-access")
os.Setenv("FAKEPLUGIN_BEHAVIOR", behavior)
return func() {
os.Unsetenv("FAKEPLUGIN_PROVIDER_TYPE")
os.Unsetenv("FAKEPLUGIN_ACCESS_TYPE")
os.Unsetenv("FAKEPLUGIN_BEHAVIOR")
}
}
func TestScanAndRegister_RegistersFactorySchemaAndCatalog(t *testing.T) {
deployType := "hosttest-reg-" + t.Name()
accessType := deployType + "-access"
defer setFakeEnv(deployType, "ok")()
dir, _ := setupPluginDir(t, deployType, accessType)
catalog, errs := ScanAndRegister(context.Background(), plugin.PluginConfig{
PluginDir: dir,
CoreVersion: "0.4.28",
}, slog.Default())
if len(errs) != 0 {
t.Fatalf("unexpected errs: %+v", errs)
}
entries := catalog.Entries()
if len(entries) != 1 || entries[0].ProviderType != deployType {
t.Fatalf("catalog entry missing: %+v", entries)
}
factory, err := deployers.Registries.Get(domain.DeploymentProviderType(deployType))
if err != nil {
t.Fatalf("deployer not registered: %v", err)
}
deployer, err := factory(&deployers.ProviderFactoryOptions{
ProviderAccessConfig: map[string]any{"url": "https://example.com", "secret": "tok"},
ProviderExtendedConfig: map[string]any{"method": "POST"},
})
if err != nil {
t.Fatalf("factory: %v", err)
}
deployer.SetLogger(slog.Default())
res, err := deployer.Deploy(context.Background(), "CERT", "KEY")
if err != nil {
t.Fatalf("deploy via adapter: %v", err)
}
if res == nil {
t.Fatal("nil deploy result")
}
if env, ok := providerschema.Envelopes.GetEnvelope(deployType); !ok || env == nil {
t.Fatal("deploy schema envelope not registered")
}
if env, ok := providerschema.Envelopes.GetEnvelope(accessType); !ok || env == nil {
t.Fatal("access schema envelope not registered")
}
}
func TestScanAndRegister_BrokenPluginSkipped_CoreContinues(t *testing.T) {
goodType := "hosttest-broken-good"
defer setFakeEnv(goodType, "ok")()
dir, _ := setupPluginDir(t, goodType, goodType+"-access")
badDir := filepath.Join(dir, "hosttest-broken-bad")
if err := os.MkdirAll(badDir, 0o755); err != nil {
t.Fatal(err)
}
mb := jsonMarshal(map[string]any{
"provider_type": "hosttest-broken-bad",
"access_provider_type": "hosttest-broken-bad",
"protocol_version": plugin.ProtocolVersion,
"binary": "missing-binary",
})
if err := os.WriteFile(filepath.Join(badDir, "manifest.json"), mb, 0o644); err != nil {
t.Fatal(err)
}
catalog, errs := ScanAndRegister(context.Background(), plugin.PluginConfig{
PluginDir: dir,
CoreVersion: "0.4.28",
}, slog.Default())
if len(errs) == 0 {
t.Fatal("expected at least one error for the broken plugin")
}
if len(catalog.Entries()) != 1 || catalog.Entries()[0].ProviderType != goodType {
t.Fatalf("good plugin should still register: %+v", catalog.Entries())
}
}
func TestScanAndRegister_EmptyDir_NoOp(t *testing.T) {
dir := t.TempDir()
catalog, errs := ScanAndRegister(context.Background(), plugin.PluginConfig{
PluginDir: dir,
CoreVersion: "0.4.28",
}, slog.Default())
if len(errs) != 0 || len(catalog.Entries()) != 0 {
t.Fatalf("empty dir should be no-op: errs=%+v entries=%+v", errs, catalog.Entries())
}
}
func TestScanAndRegister_BuiltinProvidersStillRegistered(t *testing.T) {
if _, err := deployers.Registries.Get(domain.DeploymentProviderTypeLocal); err != nil {
t.Fatalf("builtin LOCAL deployer missing after plugin scan wiring: %v", err)
}
}
+165
View File
@@ -0,0 +1,165 @@
package pluginhost
import (
"sync"
"time"
"github.com/certimate-go/certimate/internal/domain"
)
type JobState string
const (
JobQueued JobState = "queued"
JobDownloading JobState = "downloading"
JobVerifying JobState = "verifying"
JobExtracting JobState = "extracting"
JobReloading JobState = "reloading"
JobInstalled JobState = "installed"
JobFailed JobState = "failed"
)
func (s JobState) Terminal() bool {
return s == JobInstalled || s == JobFailed
}
type JobStatus struct {
ProviderType string `json:"providerType"`
State JobState `json:"state"`
Stage string `json:"stage,omitempty"`
Error string `json:"error,omitempty"`
Downloaded int64 `json:"downloaded,omitempty"`
Total int64 `json:"total,omitempty"`
Result *ReloadResult `json:"result,omitempty"`
}
type InstallJob struct {
providerType string
state JobState
stage string
err string
downloaded int64
total int64
result *ReloadResult
startedAt time.Time
mu sync.RWMutex
}
func newInstallJob(providerType string) *InstallJob {
return &InstallJob{providerType: providerType, state: JobQueued, startedAt: time.Now()}
}
func (j *InstallJob) setState(s JobState, stage string) {
if j == nil {
return
}
j.mu.Lock()
j.state = s
j.stage = stage
j.mu.Unlock()
}
func (j *InstallJob) fail(msg string) {
if j == nil {
return
}
j.mu.Lock()
j.state = JobFailed
j.err = msg
j.mu.Unlock()
}
func (j *InstallJob) succeed(result *ReloadResult) {
if j == nil {
return
}
j.mu.Lock()
j.state = JobInstalled
j.result = result
j.mu.Unlock()
}
func (j *InstallJob) setProgress(downloaded, total int64) {
if j == nil {
return
}
j.mu.Lock()
j.downloaded = downloaded
j.total = max(j.total, total)
j.mu.Unlock()
}
func (j *InstallJob) Status() JobStatus {
if j == nil {
return JobStatus{}
}
j.mu.RLock()
defer j.mu.RUnlock()
total := max(0, j.total)
return JobStatus{
ProviderType: j.providerType,
State: j.state,
Stage: j.stage,
Error: j.err,
Downloaded: j.downloaded,
Total: total,
Result: j.result,
}
}
type jobStore struct {
mu sync.Mutex
jobs map[string]*InstallJob
}
func newJobStore() *jobStore {
return &jobStore{jobs: make(map[string]*InstallJob)}
}
func (s *jobStore) get(providerType string) *InstallJob {
s.mu.Lock()
defer s.mu.Unlock()
return s.jobs[providerType]
}
func (s *jobStore) set(j *InstallJob) {
s.mu.Lock()
s.jobs[j.providerType] = j
s.mu.Unlock()
}
type opKind string
const (
opNone opKind = ""
opInstall opKind = "install"
opUpdate opKind = "update"
opDelete opKind = "delete"
)
type pluginOps struct {
mu sync.Mutex
active map[string]opKind
}
func newPluginOps() *pluginOps {
return &pluginOps{active: make(map[string]opKind)}
}
var ErrOpInProgress = domain.NewError(409, "market: another operation is in progress for this plugin")
func (p *pluginOps) claim(providerType string, kind opKind) error {
p.mu.Lock()
defer p.mu.Unlock()
if cur := p.active[providerType]; cur != opNone {
return ErrOpInProgress
}
p.active[providerType] = kind
return nil
}
func (p *pluginOps) release(providerType string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.active, providerType)
}
+815
View File
@@ -0,0 +1,815 @@
package pluginhost
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/plugin"
xhttp "github.com/certimate-go/certimate/pkg/utils/http"
)
const (
maxDownloadBytes int64 = 512 * 1024 * 1024
maxUncompressedBytes int64 = 1024 * 1024 * 1024
maxZipEntries = 64
defaultDownloadStallTimeout = 60 * time.Second
)
var (
ErrMissingProviderType = errors.New("market: providerType is required")
ErrAlreadyInstalled = domain.NewError(409, "market: plugin is already installed")
ErrUpdateRolledBack = errors.New("market: update failed; previous version restored")
ErrUpdateCorrupted = errors.New("market: update failed and rollback also failed; plugin left in an inconsistent state")
)
type MarketEntry struct {
*plugin.MarketManifest
Status string `json:"status"`
}
type MarketService struct {
marketRepo string
indexURL string
downloadMirror string
pluginDir string
cache []*plugin.MarketManifest
cachedAt time.Time
cacheTTL time.Duration
mu sync.RWMutex
httpClient *http.Client
downloadClient *http.Client
stallTimeout time.Duration
logger *slog.Logger
jobs *jobStore
ops *pluginOps
}
type MarketConfig struct {
MarketRepo string
IndexURL string
DownloadMirror string
PluginDir string
CacheTTL time.Duration
StallTimeout time.Duration
Logger *slog.Logger
}
func NewMarketService(cfg MarketConfig) *MarketService {
if cfg.CacheTTL <= 0 {
cfg.CacheTTL = 5 * time.Minute
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if cfg.MarketRepo == "" {
cfg.MarketRepo = "certimate-go/plugins"
}
if cfg.IndexURL == "" {
cfg.IndexURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/main/index.json", cfg.MarketRepo)
}
if cfg.StallTimeout <= 0 {
cfg.StallTimeout = defaultDownloadStallTimeout
}
dlTransport := xhttp.NewDefaultTransport()
dlTransport.ResponseHeaderTimeout = 30 * time.Second
return &MarketService{
marketRepo: cfg.MarketRepo,
indexURL: cfg.IndexURL,
downloadMirror: cfg.DownloadMirror,
pluginDir: cfg.PluginDir,
cacheTTL: cfg.CacheTTL,
httpClient: &http.Client{Timeout: 30 * time.Second},
downloadClient: &http.Client{Transport: dlTransport},
stallTimeout: cfg.StallTimeout,
logger: cfg.Logger.With(slog.String("component", "market")),
jobs: newJobStore(),
ops: newPluginOps(),
}
}
func (s *MarketService) ListMarket(ctx context.Context) ([]MarketEntry, error) {
manifests, err := s.manifests(ctx)
if err != nil {
return nil, err
}
entries := make([]MarketEntry, len(manifests))
for i, mm := range manifests {
entries[i] = MarketEntry{MarketManifest: mm, Status: s.computeStatus(mm)}
}
return entries, nil
}
func (s *MarketService) manifests(ctx context.Context) ([]*plugin.MarketManifest, error) {
s.mu.RLock()
if s.cache != nil && time.Since(s.cachedAt) < s.cacheTTL {
manifests := s.cache
s.mu.RUnlock()
s.logger.Debug("market manifests served from cache", slog.Int("entries", len(manifests)))
return manifests, nil
}
s.mu.RUnlock()
return s.fetchAndCache(ctx)
}
func (s *MarketService) fetchAndCache(ctx context.Context) ([]*plugin.MarketManifest, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cache != nil && time.Since(s.cachedAt) < s.cacheTTL {
return s.cache, nil
}
manifests, err := s.fetchMarketListing(ctx)
if err != nil {
if s.cache != nil {
s.logger.Warn("market fetch failed, returning stale cache", slog.Any("error", err))
return s.cache, nil
}
return nil, err
}
s.cache = manifests
s.cachedAt = time.Now()
s.logger.Info("market listing fetched", slog.Int("entries", len(manifests)))
return manifests, nil
}
func (s *MarketService) fetchMarketListing(ctx context.Context) ([]*plugin.MarketManifest, error) {
url := s.indexURL
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("market: create request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("market: fetch index: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, fmt.Errorf("market: index not available at %s", url)
case http.StatusForbidden, http.StatusTooManyRequests:
return nil, fmt.Errorf("market: rate limited (status %d)", resp.StatusCode)
default:
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("market: index fetch returned status %d: %s", resp.StatusCode, string(body))
}
var idx struct {
Plugins []*plugin.MarketManifest `json:"plugins"`
}
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
return nil, fmt.Errorf("market: decode index: %w", err)
}
return idx.Plugins, nil
}
func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string {
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
if mm.Release == nil || mm.Release.Assets[key] == "" {
return "unsupported_platform"
}
pluginPath := filepath.Join(s.pluginDir, mm.ProviderType)
info, err := os.Stat(pluginPath)
if err != nil || !info.IsDir() {
return "not_installed"
}
meta, err := plugin.ReadMarketMeta(s.pluginDir, mm.ProviderType)
if err != nil || meta == nil {
return "installed_manual"
}
if plugin.CompareVersions(meta.InstalledVersion, mm.Version) < 0 {
return "update_available"
}
return "installed"
}
func (s *MarketService) GetMarketManifest(ctx context.Context, providerType string) (*plugin.MarketManifest, error) {
manifests, err := s.manifests(ctx)
if err != nil {
return nil, err
}
for _, mm := range manifests {
if mm.ProviderType == providerType {
return mm, nil
}
}
return nil, fmt.Errorf("market: plugin %q not found in market listing", providerType)
}
func (s *MarketService) JobStatus(providerType string) (JobStatus, bool) {
j := s.jobs.get(providerType)
if j == nil {
return JobStatus{}, false
}
return j.Status(), true
}
func (s *MarketService) Install(ctx context.Context, providerType string) (*InstallJob, error) {
if err := plugin.ValidateProviderType(providerType); err != nil {
return nil, err
}
targetDir := filepath.Join(s.pluginDir, providerType)
if _, err := os.Stat(targetDir); err == nil {
return nil, ErrAlreadyInstalled
}
if err := s.ops.claim(providerType, opInstall); err != nil {
return nil, err
}
job := newInstallJob(providerType)
s.jobs.set(job)
go s.runInstall(providerType, job)
return job, nil
}
func (s *MarketService) runInstall(providerType string, job *InstallJob) {
defer s.ops.release(providerType)
ctx := context.Background()
tmpDir, _, err := s.installPipeline(ctx, providerType, job)
if err != nil {
job.fail(err.Error())
s.logger.Warn("market: async install failed", slog.String("provider", providerType), slog.Any("error", err))
return
}
job.setState(JobReloading, "reloading")
targetDir := filepath.Join(s.pluginDir, providerType)
if err := os.Rename(tmpDir, targetDir); err != nil {
_ = os.RemoveAll(tmpDir)
job.fail(fmt.Sprintf("market: install rename: %v", err))
return
}
reloader := GlobalReloader()
if reloader == nil {
job.fail("market: reloader not initialized")
return
}
job.succeed(reloader.ReloadWait(ctx))
}
func (s *MarketService) Delete(ctx context.Context, providerType string) (*ReloadResult, error) {
if err := plugin.ValidateProviderType(providerType); err != nil {
return nil, err
}
if err := s.ops.claim(providerType, opDelete); err != nil {
return nil, err
}
defer s.ops.release(providerType)
targetDir := filepath.Join(s.pluginDir, providerType)
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
return nil, fmt.Errorf("market: plugin %q is not installed", providerType)
}
meta, err := plugin.ReadMarketMeta(s.pluginDir, providerType)
if err != nil || meta == nil {
return nil, fmt.Errorf("market: plugin %q is not managed by marketplace", providerType)
}
if err := os.RemoveAll(targetDir); err != nil {
return nil, fmt.Errorf("market: remove plugin dir: %w", err)
}
reloader := GlobalReloader()
if reloader == nil {
return nil, fmt.Errorf("market: reloader not initialized")
}
return reloader.ReloadWait(ctx), nil
}
func (s *MarketService) Update(ctx context.Context, providerType string) (*ReloadResult, error) {
if err := plugin.ValidateProviderType(providerType); err != nil {
return nil, err
}
if err := s.ops.claim(providerType, opUpdate); err != nil {
return nil, err
}
defer s.ops.release(providerType)
targetDir := filepath.Join(s.pluginDir, providerType)
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
return nil, fmt.Errorf("market: plugin %q is not installed", providerType)
}
mm, err := s.GetMarketManifest(ctx, providerType)
if err != nil {
return nil, err
}
meta, err := plugin.ReadMarketMeta(s.pluginDir, providerType)
if err != nil || meta == nil {
return nil, fmt.Errorf("market: plugin %q is not managed by marketplace", providerType)
}
if plugin.CompareVersions(meta.InstalledVersion, mm.Version) >= 0 {
return nil, fmt.Errorf("market: plugin %q is already up to date", providerType)
}
tmpDir, _, err := s.installPipeline(ctx, providerType, nil)
if err != nil {
return nil, err
}
backupDir := filepath.Join(s.pluginDir, ".bak-"+providerType)
_ = os.RemoveAll(backupDir)
if err := os.Rename(targetDir, backupDir); err != nil {
_ = os.RemoveAll(tmpDir)
return nil, fmt.Errorf("market: backup old plugin dir: %w", err)
}
if err := os.Rename(tmpDir, targetDir); err != nil {
if rerr := os.Rename(backupDir, targetDir); rerr != nil {
return nil, ErrUpdateCorrupted
}
return nil, ErrUpdateRolledBack
}
_ = os.RemoveAll(backupDir)
reloader := GlobalReloader()
if reloader == nil {
return nil, fmt.Errorf("market: reloader not initialized")
}
return reloader.ReloadWait(ctx), nil
}
func (s *MarketService) installPipeline(ctx context.Context, providerType string, job *InstallJob) (tmpDir string, mm *plugin.MarketManifest, err error) {
mm, err = s.GetMarketManifest(ctx, providerType)
if err != nil {
return "", nil, err
}
if err := plugin.ValidateReleaseRepo(mm.Release.Repo); err != nil {
return "", nil, err
}
if err := plugin.ValidateBinaryName(mm.Binary); err != nil {
return "", nil, err
}
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
assetName := mm.Release.Assets[key]
if assetName == "" {
return "", nil, fmt.Errorf("market: plugin %q has no asset for %s", providerType, key)
}
if !strings.HasSuffix(assetName, ".zip") {
return "", nil, fmt.Errorf("market: plugin %q asset %q is not a zip archive", providerType, assetName)
}
tmpDir = filepath.Join(s.pluginDir, ".tmp-"+providerType)
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return "", nil, fmt.Errorf("market: create temp dir: %w", err)
}
defer func() {
if err != nil {
_ = os.RemoveAll(tmpDir)
}
}()
job.setState(JobDownloading, "downloading")
downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", mm.Release.Repo, mm.Release.Tag, assetName)
if s.downloadMirror != "" {
downloadURL = strings.TrimRight(s.downloadMirror, "/") + "/" + downloadURL
}
downloadPath := filepath.Join(s.pluginDir, ".dl-"+providerType+".zip")
if err := s.fetchZip(ctx, downloadURL, downloadPath, func(downloaded, total int64) {
job.setProgress(downloaded, total)
}); err != nil {
return "", nil, err
}
job.setState(JobVerifying, "verifying")
computed, err := sha256Path(downloadPath)
if err != nil {
return "", nil, fmt.Errorf("market: compute sha256: %w", err)
}
expected := mm.Release.Checksums[key]
if expected == "" {
_ = os.Remove(downloadPath)
return "", nil, fmt.Errorf("market: plugin %q has no published checksum for %s", providerType, key)
}
if expected != computed {
_ = os.Remove(downloadPath)
return "", nil, fmt.Errorf("market: plugin %q checksum mismatch for %s: expected %s, got %s", providerType, key, expected, computed)
}
job.setState(JobExtracting, "extracting")
zipFile, err := os.Open(downloadPath)
if err != nil {
return "", nil, fmt.Errorf("market: open download: %w", err)
}
zipStat, err := zipFile.Stat()
if err != nil {
zipFile.Close()
return "", nil, fmt.Errorf("market: stat download: %w", err)
}
_, extractErr := extractZipArchive(zipFile, zipStat.Size(), tmpDir, mm.Binary)
zipFile.Close()
_ = os.Remove(downloadPath)
if extractErr != nil {
return "", nil, extractErr
}
bundledPath := filepath.Join(tmpDir, "manifest.json")
bundled, err := os.ReadFile(bundledPath)
if err != nil {
return "", nil, fmt.Errorf("market: read bundled manifest: %w", err)
}
bm, err := plugin.ParseManifest(bundled)
if err != nil {
return "", nil, fmt.Errorf("market: parse bundled manifest: %w", err)
}
if bm.ProviderType != providerType {
return "", nil, fmt.Errorf("market: bundled manifest provider_type %q does not match %q", bm.ProviderType, providerType)
}
if bm.ProtocolVersion != mm.ProtocolVersion {
return "", nil, fmt.Errorf("market: bundled manifest protocol_version %d does not match index %d", bm.ProtocolVersion, mm.ProtocolVersion)
}
if bm.Binary != mm.Binary {
return "", nil, fmt.Errorf("market: bundled manifest binary %q does not match index %q", bm.Binary, mm.Binary)
}
localManifest := mm.Manifest
localManifest.OS = runtime.GOOS
localManifest.Arch = runtime.GOARCH
localManifest.SHA256 = computed
manifestData, err := json.MarshalIndent(&localManifest, "", " ")
if err != nil {
return "", nil, fmt.Errorf("market: marshal manifest: %w", err)
}
if err := os.WriteFile(bundledPath, manifestData, 0o644); err != nil {
return "", nil, fmt.Errorf("market: write manifest: %w", err)
}
meta := plugin.NewMarketMeta("official", mm.Version, mm.Version, mm.Release.Tag)
if err := plugin.WriteMarketMeta(tmpDir, ".", meta); err != nil {
return "", nil, err
}
return tmpDir, mm, nil
}
const maxDownloadRetries = 3
var (
errDownloadNotRetryable = errors.New("market: download failed (non-retryable)")
errDownloadStalled = errors.New("market: download stalled (no progress within timeout)")
)
func (s *MarketService) fetchZip(ctx context.Context, url, destPath string, onProgress func(downloaded, total int64)) error {
var lastErr error
for attempt := 0; attempt <= maxDownloadRetries; attempt++ {
err := s.downloadAttempt(ctx, url, destPath, onProgress)
if err == nil {
return nil
}
lastErr = err
if !isRetryableDownloadError(err) || attempt == maxDownloadRetries {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoffFor(attempt)):
}
}
return lastErr
}
func (s *MarketService) downloadAttempt(ctx context.Context, url, destPath string, onProgress func(downloaded, total int64)) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("market: open download file: %w", err)
}
defer f.Close()
offset, err := f.Seek(0, io.SeekEnd)
if err != nil {
return fmt.Errorf("market: seek download file: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("market: create download request: %w", err)
}
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
resp, err := s.downloadClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var total int64
switch resp.StatusCode {
case http.StatusOK:
if err := f.Truncate(0); err != nil {
return fmt.Errorf("market: reset partial download: %w", err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("market: seek partial download: %w", err)
}
offset = 0
total = resp.ContentLength
case http.StatusPartialContent:
total = parseFullSize(resp.Header.Get("Content-Range"))
if total <= 0 {
total = offset + resp.ContentLength
}
default:
if resp.StatusCode < 500 {
_ = os.Remove(destPath)
return fmt.Errorf("market: download returned status %d: %w", resp.StatusCode, errDownloadNotRetryable)
}
return fmt.Errorf("market: download returned status %d", resp.StatusCode)
}
if total > 0 && total > maxDownloadBytes {
return fmt.Errorf("market: download size %d exceeds max %d: %w", total, maxDownloadBytes, errDownloadNotRetryable)
}
var activity atomic.Int64
activity.Store(time.Now().UnixNano())
stalled, stopWatchdog := s.watchDownloadStall(ctx, cancel, &activity)
defer stopWatchdog()
pr := &progressReader{
r: io.LimitReader(resp.Body, maxDownloadBytes+1),
total: total,
base: offset,
on: onProgress,
activity: &activity,
}
if _, err := io.Copy(f, pr); err != nil {
if stalled.Load() {
return fmt.Errorf("market: %w", errDownloadStalled)
}
return err
}
if total > 0 {
fi, err := f.Stat()
if err != nil {
return fmt.Errorf("market: stat download file: %w", err)
}
if fi.Size() < total {
return fmt.Errorf("market: download incomplete: %d/%d bytes", fi.Size(), total)
}
if onProgress != nil {
onProgress(total, total)
}
}
return nil
}
func (s *MarketService) watchDownloadStall(ctx context.Context, cancel context.CancelFunc, activity *atomic.Int64) (*atomic.Bool, func()) {
stalled := &atomic.Bool{}
done := make(chan struct{})
tick := time.NewTicker(stallTickInterval(s.stallTimeout))
go func() {
defer tick.Stop()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-tick.C:
last := time.Unix(0, activity.Load())
if time.Since(last) >= s.stallTimeout {
stalled.Store(true)
cancel()
return
}
}
}
}()
return stalled, func() { close(done) }
}
func stallTickInterval(stall time.Duration) time.Duration {
t := stall / 4
if t < 10*time.Millisecond {
t = 10 * time.Millisecond
}
if t > 15*time.Second {
t = 15 * time.Second
}
return t
}
func isRetryableDownloadError(err error) bool {
if errors.Is(err, errDownloadNotRetryable) {
return false
}
if errors.Is(err, context.Canceled) {
return false
}
return true
}
func backoffFor(attempt int) time.Duration {
d := time.Duration(1<<attempt) * time.Second
return min(d, 10*time.Second)
}
func parseFullSize(contentRange string) int64 {
if contentRange == "" {
return -1
}
idx := strings.LastIndex(contentRange, "/")
if idx < 0 {
return -1
}
n, err := strconv.ParseInt(strings.TrimSpace(contentRange[idx+1:]), 10, 64)
if err != nil {
return -1
}
return n
}
type progressReader struct {
r io.Reader
total int64
base int64
read int64
last int64
on func(downloaded, total int64)
activity *atomic.Int64
}
func (pr *progressReader) Read(p []byte) (int, error) {
n, err := pr.r.Read(p)
if n > 0 {
pr.read += int64(n)
if pr.activity != nil {
pr.activity.Store(time.Now().UnixNano())
}
abs := pr.base + pr.read
if pr.on != nil && (abs-pr.last >= 64*1024 || err != nil) {
pr.last = abs
pr.on(abs, pr.total)
}
}
return n, err
}
func sha256Path(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func extractZipArchive(r io.ReaderAt, size int64, dest, binaryName string) (string, error) {
zr, err := zip.NewReader(r, size)
if err != nil {
return "", fmt.Errorf("market: open zip: %w", err)
}
if len(zr.File) > maxZipEntries {
return "", fmt.Errorf("market: zip has too many entries (%d)", len(zr.File))
}
destClean := filepath.Clean(dest)
sep := string(os.PathSeparator)
var (
total int64
binPath string
)
for _, f := range zr.File {
name := filepath.Clean(f.Name)
if zipEntryEscapes(f.Name, dest, sep) {
return "", fmt.Errorf("market: zip entry %q escapes destination", f.Name)
}
mode := f.Mode()
if mode&(os.ModeSymlink|os.ModeDevice|os.ModeNamedPipe|os.ModeSocket) != 0 {
return "", fmt.Errorf("market: zip entry %q has unsafe type", f.Name)
}
target := filepath.Join(dest, name)
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, mode.Perm()); err != nil {
return "", fmt.Errorf("market: mkdir %q: %w", name, err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", fmt.Errorf("market: mkdir for %q: %w", name, err)
}
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm()|0o600)
if err != nil {
return "", fmt.Errorf("market: create %q: %w", name, err)
}
rc, err := f.Open()
if err != nil {
out.Close()
return "", fmt.Errorf("market: open zip entry %q: %w", name, err)
}
n, copyErr := io.Copy(out, io.LimitReader(rc, maxUncompressedBytes+1))
rc.Close()
out.Close()
if copyErr != nil {
return "", fmt.Errorf("market: extract %q: %w", name, copyErr)
}
total += n
if total > maxUncompressedBytes {
return "", fmt.Errorf("market: zip exceeds max uncompressed size %d", maxUncompressedBytes)
}
if name == binaryName {
if err := os.Chmod(target, 0o755); err != nil {
return "", fmt.Errorf("market: chmod binary: %w", err)
}
binPath = target
}
}
if binPath == "" {
return "", fmt.Errorf("market: zip missing binary entry %q", binaryName)
}
_ = destClean
return binPath, nil
}
func zipEntryEscapes(name, dest, sep string) bool {
if filepath.IsAbs(name) {
return true
}
cleaned := filepath.Clean(name)
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+sep) {
return true
}
target := filepath.Clean(filepath.Join(dest, cleaned))
d := filepath.Clean(dest)
if target != d && !strings.HasPrefix(target, d+sep) {
return true
}
return false
}
func SweepOrphans(pluginDir string, logger *slog.Logger) {
entries, err := os.ReadDir(pluginDir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
switch {
case strings.HasPrefix(name, ".tmp-"):
if err := os.RemoveAll(filepath.Join(pluginDir, name)); err != nil {
logger.Warn("market: failed to remove orphan temp dir", slog.String("dir", name), slog.Any("error", err))
}
case strings.HasPrefix(name, ".bak-"):
pt := strings.TrimPrefix(name, ".bak-")
target := filepath.Join(pluginDir, pt)
if _, err := os.Stat(target); err == nil {
_ = os.RemoveAll(filepath.Join(pluginDir, name))
} else {
if err := os.Rename(filepath.Join(pluginDir, name), target); err != nil {
logger.Warn("market: failed to restore plugin from backup", slog.String("provider", pt), slog.Any("error", err))
} else {
logger.Info("market: restored plugin from backup after interrupted update", slog.String("provider", pt))
}
}
}
}
}
+791
View File
@@ -0,0 +1,791 @@
package pluginhost
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/plugin"
)
func newTestService(t *testing.T, handler http.HandlerFunc) *MarketService {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return NewMarketService(MarketConfig{
IndexURL: srv.URL,
PluginDir: t.TempDir(),
CacheTTL: time.Hour,
})
}
func indexJSON(t *testing.T, plugins ...map[string]any) string {
t.Helper()
buf, err := json.Marshal(map[string]any{"plugins": plugins})
if err != nil {
t.Fatal(err)
}
return string(buf)
}
func entry(providerType, version string, release map[string]any) map[string]any {
m := map[string]any{"provider_type": providerType, "version": version, "binary": providerType}
if release != nil {
m["release"] = release
}
return m
}
func releaseWithCurrentAsset() map[string]any {
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
return map[string]any{
"repo": "certimate-go/plugins",
"tag": "v1.0.0",
"assets": map[string]any{key: "bin-" + key},
"checksums": map[string]any{key: "deadbeef"},
}
}
func TestListMarket_HappyPath(t *testing.T) {
body := indexJSON(t,
entry("beta", "2.0.0", nil),
entry("alpha", "1.0.0", releaseWithCurrentAsset()),
)
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, body)
})
got, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("want 2 entries, got %d", len(got))
}
if got[0].ProviderType != "beta" || got[0].Version != "2.0.0" {
t.Fatalf("entry 0 mismatch: %+v", got[0])
}
if got[1].ProviderType != "alpha" || got[1].Version != "1.0.0" {
t.Fatalf("entry 1 mismatch: %+v", got[1])
}
}
func TestListMarket_Empty(t *testing.T) {
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"plugins":[]}`)
})
got, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("want 0 entries, got %d", len(got))
}
}
func TestListMarket_UnsupportedPlatform(t *testing.T) {
body := indexJSON(t,
entry("noRelease", "1.0.0", nil),
entry("wrongAsset", "1.0.0", map[string]any{
"repo": "x",
"tag": "v1",
"assets": map[string]any{"none/none": "bin"},
"checksums": map[string]any{"none/none": "x"},
}),
)
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, body)
})
got, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("want 2 entries, got %d", len(got))
}
for _, e := range got {
if e.Status != "unsupported_platform" {
t.Fatalf("want unsupported_platform for %q, got %q", e.ProviderType, e.Status)
}
}
}
func TestListMarket_StatusNotInstalled(t *testing.T) {
body := indexJSON(t, entry("alpha", "1.0.0", releaseWithCurrentAsset()))
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, body)
})
got, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Status != "not_installed" {
t.Fatalf("want one not_installed entry, got %+v", got)
}
}
func TestListMarket_CacheNoRefetch(t *testing.T) {
var hits atomic.Int32
body := indexJSON(t, entry("alpha", "1.0.0", nil))
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
io.WriteString(w, body)
})
if _, err := svc.ListMarket(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := svc.ListMarket(context.Background()); err != nil {
t.Fatal(err)
}
if got := hits.Load(); got != 1 {
t.Fatalf("want 1 server hit (cached second call), got %d", got)
}
}
func TestListMarket_StaleCacheOnError(t *testing.T) {
var fail atomic.Bool
body := indexJSON(t, entry("alpha", "1.0.0", nil))
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
if fail.Load() {
w.WriteHeader(http.StatusTooManyRequests)
return
}
io.WriteString(w, body)
})
first, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatal(err)
}
svc.cachedAt = time.Now().Add(-2 * time.Hour)
fail.Store(true)
second, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatalf("want stale cache on fetch failure, got error: %v", err)
}
if len(second) != len(first) {
t.Fatalf("stale cache mismatch: first %d, second %d", len(first), len(second))
}
}
func TestListMarket_404(t *testing.T) {
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
_, err := svc.ListMarket(context.Background())
if err == nil || !strings.Contains(err.Error(), "not available") {
t.Fatalf("want 'not available' error, got %v", err)
}
}
func TestListMarket_RateLimitedNoCache(t *testing.T) {
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
})
_, err := svc.ListMarket(context.Background())
if err == nil || !strings.Contains(err.Error(), "rate limited") {
t.Fatalf("want rate-limit error, got %v", err)
}
}
func TestListMarket_Malformed(t *testing.T) {
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{not valid json`)
})
_, err := svc.ListMarket(context.Background())
if err == nil {
t.Fatal("want parse error for malformed index, got nil")
}
}
func TestGetMarketManifest_FromCache(t *testing.T) {
body := indexJSON(t, entry("alpha", "1.0.0", releaseWithCurrentAsset()))
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, body)
})
if _, err := svc.ListMarket(context.Background()); err != nil {
t.Fatal(err)
}
mm, err := svc.GetMarketManifest(context.Background(), "alpha")
if err != nil {
t.Fatal(err)
}
if mm.ProviderType != "alpha" || mm.Release == nil {
t.Fatalf("unexpected manifest: %+v", mm)
}
}
type zipEntry struct {
Name string
Data []byte
}
func buildZip(t *testing.T, entries []zipEntry) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for _, e := range entries {
f, err := w.Create(e.Name)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(e.Data); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func alphaManifestJSON(providerType, version string) string {
return fmt.Sprintf(`{"provider_type":%q,"version":%q,"binary":%q,"protocol_version":%d,"access_provider_type":%q,"deploy_category":"cdn","usages":["hosting"]}`, providerType, version, providerType, plugin.ProtocolVersion, providerType)
}
func alphaIndex(t *testing.T, version, checksum string) string {
t.Helper()
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
asset := "alpha_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip"
if checksum == "" {
return fmt.Sprintf(`{"plugins":[{"provider_type":"alpha","version":%q,"binary":"alpha","protocol_version":%d,"release":{"repo":"certimate-go/plugins","tag":"alpha/v%s","assets":{%q:%q}}}]}`, version, plugin.ProtocolVersion, version, key, asset)
}
return fmt.Sprintf(`{"plugins":[{"provider_type":"alpha","version":%q,"binary":"alpha","protocol_version":%d,"release":{"repo":"certimate-go/plugins","tag":"alpha/v%s","assets":{%q:%q},"checksums":{%q:%q}}}]}`, version, plugin.ProtocolVersion, version, key, asset, key, checksum)
}
func newZipTestService(t *testing.T, indexBody string, zipBytes []byte) *MarketService {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/index.json"):
io.WriteString(w, indexBody)
case strings.Contains(r.URL.Path, "/releases/download/"):
_, _ = w.Write(zipBytes)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return NewMarketService(MarketConfig{
IndexURL: srv.URL + "/index.json",
DownloadMirror: srv.URL,
PluginDir: t.TempDir(),
CacheTTL: time.Hour,
})
}
func setupReloader(t *testing.T, pluginDir string) {
t.Helper()
cfg := plugin.PluginConfig{PluginDir: pluginDir, StartTimeout: 100 * time.Millisecond}
r := NewReloader(cfg, NewCatalog(), slog.Default())
prev := GlobalReloader()
SetGlobalReloader(r)
t.Cleanup(func() { SetGlobalReloader(prev) })
}
func waitForJob(t *testing.T, job *InstallJob, timeout time.Duration) JobStatus {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
st := job.Status()
if st.State.Terminal() {
return st
}
time.Sleep(5 * time.Millisecond)
}
return job.Status()
}
func TestInstall_AsyncZip(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{
{Name: "alpha", Data: []byte("fake binary bytes")},
{Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "1.0.0"))},
{Name: "alpha.svg", Data: []byte("<svg/>")},
})
sum := sha256.Sum256(zipBytes)
svc := newZipTestService(t, alphaIndex(t, "1.0.0", hex.EncodeToString(sum[:])), zipBytes)
setupReloader(t, svc.pluginDir)
job, err := svc.Install(context.Background(), "alpha")
if err != nil {
t.Fatal(err)
}
st := waitForJob(t, job, 5*time.Second)
if st.State != JobInstalled {
t.Fatalf("want installed, got %s (%s)", st.State, st.Error)
}
target := filepath.Join(svc.pluginDir, "alpha")
if _, err := os.Stat(filepath.Join(target, "alpha")); err != nil {
t.Fatalf("binary missing after install: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "manifest.json")); err != nil {
t.Fatalf("manifest missing after install: %v", err)
}
if _, err := os.Stat(filepath.Join(target, ".market.json")); err != nil {
t.Fatalf("market meta missing after install: %v", err)
}
if _, err := os.Stat(filepath.Join(svc.pluginDir, ".tmp-alpha")); err == nil {
t.Fatal("temp dir was not cleaned up")
}
}
func TestInstall_ChecksumMismatchFails(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{
{Name: "alpha", Data: []byte("fake binary")},
{Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "1.0.0"))},
})
svc := newZipTestService(t, alphaIndex(t, "1.0.0", "deadbeef"), zipBytes)
setupReloader(t, svc.pluginDir)
job, err := svc.Install(context.Background(), "alpha")
if err != nil {
t.Fatal(err)
}
st := waitForJob(t, job, 5*time.Second)
if st.State != JobFailed || !strings.Contains(st.Error, "checksum mismatch") {
t.Fatalf("want failed checksum mismatch, got %s (%s)", st.State, st.Error)
}
if _, err := os.Stat(filepath.Join(svc.pluginDir, "alpha")); err == nil {
t.Fatal("plugin dir must not exist after failed install")
}
}
func TestInstall_AbsentChecksumFailsClosed(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{
{Name: "alpha", Data: []byte("fake binary")},
{Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "1.0.0"))},
})
svc := newZipTestService(t, alphaIndex(t, "1.0.0", ""), zipBytes)
setupReloader(t, svc.pluginDir)
job, err := svc.Install(context.Background(), "alpha")
if err != nil {
t.Fatal(err)
}
st := waitForJob(t, job, 5*time.Second)
if st.State != JobFailed || !strings.Contains(st.Error, "no published checksum") {
t.Fatalf("want failed (no published checksum), got %s (%s)", st.State, st.Error)
}
}
func TestInstall_ZipSlipRejected(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{
{Name: "alpha", Data: []byte("fake binary")},
{Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "1.0.0"))},
{Name: "../evil.txt", Data: []byte("escaped")},
})
sum := sha256.Sum256(zipBytes)
svc := newZipTestService(t, alphaIndex(t, "1.0.0", hex.EncodeToString(sum[:])), zipBytes)
setupReloader(t, svc.pluginDir)
job, err := svc.Install(context.Background(), "alpha")
if err != nil {
t.Fatal(err)
}
st := waitForJob(t, job, 5*time.Second)
if st.State != JobFailed || !strings.Contains(st.Error, "escapes destination") {
t.Fatalf("want failed zip-slip, got %s (%s)", st.State, st.Error)
}
escaped := filepath.Join(svc.pluginDir, "evil.txt")
if _, err := os.Stat(escaped); err == nil {
t.Fatalf("zip-slip wrote outside plugin dir: %s", escaped)
}
}
func TestInstall_BundledManifestMismatchFails(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{
{Name: "alpha", Data: []byte("fake binary")},
{Name: "manifest.json", Data: []byte(alphaManifestJSON("beta", "1.0.0"))},
})
sum := sha256.Sum256(zipBytes)
svc := newZipTestService(t, alphaIndex(t, "1.0.0", hex.EncodeToString(sum[:])), zipBytes)
setupReloader(t, svc.pluginDir)
job, err := svc.Install(context.Background(), "alpha")
if err != nil {
t.Fatal(err)
}
st := waitForJob(t, job, 5*time.Second)
if st.State != JobFailed || !strings.Contains(st.Error, "provider_type") {
t.Fatalf("want failed provider_type mismatch, got %s (%s)", st.State, st.Error)
}
}
func TestInstall_AlreadyInstalled(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{{Name: "alpha", Data: []byte("x")}, {Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "1.0.0"))}})
sum := sha256.Sum256(zipBytes)
svc := newZipTestService(t, alphaIndex(t, "1.0.0", hex.EncodeToString(sum[:])), zipBytes)
if err := os.MkdirAll(filepath.Join(svc.pluginDir, "alpha"), 0o755); err != nil {
t.Fatal(err)
}
_, err := svc.Install(context.Background(), "alpha")
if err != ErrAlreadyInstalled {
t.Fatalf("want ErrAlreadyInstalled, got %v", err)
}
}
func TestInstall_OpInProgressReturns409(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{{Name: "alpha", Data: []byte("x")}, {Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "1.0.0"))}})
sum := sha256.Sum256(zipBytes)
svc := newZipTestService(t, alphaIndex(t, "1.0.0", hex.EncodeToString(sum[:])), zipBytes)
if err := svc.ops.claim("alpha", opUpdate); err != nil {
t.Fatal(err)
}
defer svc.ops.release("alpha")
_, err := svc.Install(context.Background(), "alpha")
if err == nil {
t.Fatal("want conflict error, got nil")
}
de, ok := err.(*domain.Error)
if !ok || de.Code != 409 {
t.Fatalf("want domain 409, got %v", err)
}
}
func TestDelete_BlockedWhileInstallActive(t *testing.T) {
zipBytes := buildZip(t, []zipEntry{{Name: "alpha", Data: []byte("x")}, {Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "1.0.0"))}})
sum := sha256.Sum256(zipBytes)
svc := newZipTestService(t, alphaIndex(t, "1.0.0", hex.EncodeToString(sum[:])), zipBytes)
if err := svc.ops.claim("alpha", opInstall); err != nil {
t.Fatal(err)
}
defer svc.ops.release("alpha")
_, err := svc.Delete(context.Background(), "alpha")
if err == nil {
t.Fatal("want conflict error, got nil")
}
de, ok := err.(*domain.Error)
if !ok || de.Code != 409 {
t.Fatalf("want domain 409, got %v", err)
}
}
func TestUpdate_UpgradesToNewerVersion(t *testing.T) {
newZip := buildZip(t, []zipEntry{
{Name: "alpha", Data: []byte("new fake binary")},
{Name: "manifest.json", Data: []byte(alphaManifestJSON("alpha", "2.0.0"))},
})
sum := sha256.Sum256(newZip)
svc := newZipTestService(t, alphaIndex(t, "2.0.0", hex.EncodeToString(sum[:])), newZip)
setupReloader(t, svc.pluginDir)
installed := filepath.Join(svc.pluginDir, "alpha")
if err := os.MkdirAll(installed, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installed, "alpha"), []byte("old binary"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installed, "manifest.json"), []byte(alphaManifestJSON("alpha", "1.0.0")), 0o644); err != nil {
t.Fatal(err)
}
oldMeta, _ := json.Marshal(plugin.NewMarketMeta("official", "1.0.0", "1.0.0", "alpha/v1.0.0"))
if err := os.WriteFile(filepath.Join(installed, ".market.json"), oldMeta, 0o644); err != nil {
t.Fatal(err)
}
if _, err := svc.Update(context.Background(), "alpha"); err != nil {
t.Fatalf("update failed: %v", err)
}
meta, err := plugin.ReadMarketMeta(svc.pluginDir, "alpha")
if err != nil || meta == nil {
t.Fatalf("market meta missing after update: %v", err)
}
if meta.InstalledVersion != "2.0.0" {
t.Fatalf("want installed version 2.0.0, got %q", meta.InstalledVersion)
}
if _, err := os.Stat(filepath.Join(svc.pluginDir, ".bak-alpha")); err == nil {
t.Fatal("backup dir was not cleaned up after successful update")
}
}
func TestSweepOrphans_RemovesTemp(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".tmp-alpha"), 0o755); err != nil {
t.Fatal(err)
}
SweepOrphans(dir, slog.Default())
if _, err := os.Stat(filepath.Join(dir, ".tmp-alpha")); err == nil {
t.Fatal("temp dir should be removed")
}
}
func TestSweepOrphans_RestoresBackup(t *testing.T) {
dir := t.TempDir()
bak := filepath.Join(dir, ".bak-alpha")
if err := os.MkdirAll(bak, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bak, "manifest.json"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
SweepOrphans(dir, slog.Default())
if _, err := os.Stat(bak); err == nil {
t.Fatal("backup dir should be renamed away")
}
got, err := os.ReadFile(filepath.Join(dir, "alpha", "manifest.json"))
if err != nil {
t.Fatalf("plugin should be restored from backup: %v", err)
}
if string(got) != "old" {
t.Fatalf("restored content mismatch: %q", got)
}
}
func TestSweepOrphans_RemovesStaleBackup(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "alpha"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, ".bak-alpha"), 0o755); err != nil {
t.Fatal(err)
}
SweepOrphans(dir, slog.Default())
if _, err := os.Stat(filepath.Join(dir, ".bak-alpha")); err == nil {
t.Fatal("stale backup should be removed when target exists")
}
if _, err := os.Stat(filepath.Join(dir, "alpha")); err != nil {
t.Fatal("existing plugin should be untouched")
}
}
func TestFetchZip_ReportsProgress(t *testing.T) {
payload := bytes.Repeat([]byte("z"), 150*1024)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
_, _ = w.Write(payload)
}))
t.Cleanup(srv.Close)
svc := NewMarketService(MarketConfig{IndexURL: srv.URL, PluginDir: t.TempDir()})
dest := filepath.Join(svc.pluginDir, "test.zip")
var lastD, lastT int64
var calls int
if err := svc.fetchZip(context.Background(), srv.URL, dest, func(downloaded, total int64) {
lastD = downloaded
lastT = total
calls++
}); err != nil {
t.Fatalf("fetchZip failed: %v", err)
}
data, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if len(data) != len(payload) {
t.Fatalf("downloaded %d bytes, want %d", len(data), len(payload))
}
if calls == 0 {
t.Fatal("progress callback was never invoked")
}
if lastT != int64(len(payload)) {
t.Fatalf("reported total %d, want %d", lastT, len(payload))
}
if lastD != int64(len(payload)) {
t.Fatalf("final reported downloaded %d, want %d", lastD, len(payload))
}
}
func TestFetchZip_ResumesAfterTruncation(t *testing.T) {
payload := bytes.Repeat([]byte("z"), 200*1024)
cut := int64(100 * 1024)
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
var start int64
if rng := r.Header.Get("Range"); strings.HasPrefix(rng, "bytes=") {
fmt.Sscanf(rng, "bytes=%d-", &start)
}
if n == 1 && start == 0 {
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
_, _ = w.Write(payload[:cut])
return
}
end := int64(len(payload)) - 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload)))
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(payload))-start, 10))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(payload[start:])
}))
t.Cleanup(srv.Close)
svc := NewMarketService(MarketConfig{IndexURL: srv.URL, PluginDir: t.TempDir()})
dest := filepath.Join(svc.pluginDir, "test.zip")
if err := svc.fetchZip(context.Background(), srv.URL, dest, nil); err != nil {
t.Fatalf("fetchZip failed: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("resumed download produced wrong content: got %d bytes, want %d", len(got), len(payload))
}
if attempts.Load() < 2 {
t.Fatalf("expected at least 2 attempts (one truncated, one resumed), got %d", attempts.Load())
}
}
func TestFetchZip_ResumesAfterStall(t *testing.T) {
payload := bytes.Repeat([]byte("z"), 200*1024)
cut := int64(100 * 1024)
stall := 80 * time.Millisecond
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
var start int64
if rng := r.Header.Get("Range"); strings.HasPrefix(rng, "bytes=") {
fmt.Sscanf(rng, "bytes=%d-", &start)
}
if n == 1 && start == 0 {
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
_, _ = w.Write(payload[:cut])
if fl, ok := w.(http.Flusher); ok {
fl.Flush()
}
select {
case <-r.Context().Done():
case <-time.After(stall * 30):
}
return
}
end := int64(len(payload)) - 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload)))
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(payload))-start, 10))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(payload[start:])
}))
t.Cleanup(srv.Close)
svc := NewMarketService(MarketConfig{
IndexURL: srv.URL,
PluginDir: t.TempDir(),
StallTimeout: stall,
})
dest := filepath.Join(svc.pluginDir, "test.zip")
if err := svc.fetchZip(context.Background(), srv.URL, dest, nil); err != nil {
t.Fatalf("fetchZip failed: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("resumed download produced wrong content: got %d bytes, want %d", len(got), len(payload))
}
if attempts.Load() < 2 {
t.Fatalf("expected at least 2 attempts (one stalled, one resumed), got %d", attempts.Load())
}
}
func TestIsRetryableDownloadError(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"generic", errors.New("boom"), true},
{"deadline exceeded", context.DeadlineExceeded, true},
{"wrapped deadline", fmt.Errorf("fetch: %w", context.DeadlineExceeded), true},
{"stalled", errDownloadStalled, true},
{"wrapped stalled", fmt.Errorf("market: %w", errDownloadStalled), true},
{"canceled", context.Canceled, false},
{"wrapped canceled", fmt.Errorf("ctx: %w", context.Canceled), false},
{"non-retryable", errDownloadNotRetryable, false},
{"wrapped non-retryable", fmt.Errorf("size: %w", errDownloadNotRetryable), false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isRetryableDownloadError(c.err); got != c.want {
t.Fatalf("isRetryableDownloadError(%v) = %v, want %v", c.err, got, c.want)
}
})
}
}
func TestJob_SetProgressMonotonicTotal(t *testing.T) {
job := newInstallJob("alpha")
job.setProgress(10, 100)
job.setProgress(50, 80)
st := job.Status()
if st.Downloaded != 50 {
t.Fatalf("downloaded: want 50, got %d", st.Downloaded)
}
if st.Total != 100 {
t.Fatalf("total should stay at the larger value 100, got %d", st.Total)
}
job.setProgress(60, -1)
if job.Status().Total != 100 {
t.Fatalf("total should remain 100 after a -1 sample, got %d", job.Status().Total)
}
}
func TestListMarket_StatusRecomputedFromCache(t *testing.T) {
var hits atomic.Int32
body := indexJSON(t, entry("alpha", "1.0.0", releaseWithCurrentAsset()))
svc := newTestService(t, func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
io.WriteString(w, body)
})
first, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatal(err)
}
if first[0].Status != "not_installed" {
t.Fatalf("first listing: want not_installed, got %s", first[0].Status)
}
installed := filepath.Join(svc.pluginDir, "alpha")
if err := os.MkdirAll(installed, 0o755); err != nil {
t.Fatal(err)
}
metaBytes, _ := json.Marshal(plugin.NewMarketMeta("official", "1.0.0", "1.0.0", "alpha/v1.0.0"))
if err := os.WriteFile(filepath.Join(installed, ".market.json"), metaBytes, 0o644); err != nil {
t.Fatal(err)
}
second, err := svc.ListMarket(context.Background())
if err != nil {
t.Fatal(err)
}
if second[0].Status != "installed" {
t.Fatalf("status must be recomputed from the warm cache after install: want installed, got %s", second[0].Status)
}
if got := hits.Load(); got != 1 {
t.Fatalf("index server should be hit only once while cache is warm, got %d", got)
}
}
+170
View File
@@ -0,0 +1,170 @@
package pluginhost
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/certimate-go/certimate/internal/certmgmt/deployers"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
"github.com/certimate-go/certimate/pkg/plugin"
)
type Reloader struct {
cfg plugin.PluginConfig
catalog *Catalog
manager *plugin.Manager
state map[string]*plugin.DiscoveredPlugin
mu sync.Mutex
logger *slog.Logger
}
func NewReloader(cfg plugin.PluginConfig, catalog *Catalog, logger *slog.Logger) *Reloader {
if logger == nil {
logger = slog.Default()
}
return &Reloader{
cfg: cfg,
catalog: catalog,
manager: plugin.NewManager(cfg, logger),
state: make(map[string]*plugin.DiscoveredPlugin),
logger: logger.With(slog.String("component", "pluginreloader")),
}
}
func (r *Reloader) InitFromCatalog() {
dps, _ := plugin.Discover(context.Background(), r.cfg)
r.mu.Lock()
defer r.mu.Unlock()
for _, dp := range dps {
r.state[dp.Manifest.ProviderType] = dp
}
}
func (r *Reloader) Start(ctx context.Context, watcher *Watcher) {
go func() {
for {
select {
case <-ctx.Done():
return
case <-watcher.ReloadTrigger():
r.ReloadNow(ctx)
}
}
}()
}
type ReloadResult struct {
Added []string `json:"added"`
Changed []string `json:"changed"`
Removed []string `json:"removed"`
Errors []string `json:"errors"`
}
func (r *Reloader) ReloadNow(ctx context.Context) *ReloadResult {
if !r.mu.TryLock() {
return &ReloadResult{Errors: []string{"reload already in progress"}}
}
defer r.mu.Unlock()
return r.reloadLocked(ctx)
}
func (r *Reloader) ReloadWait(ctx context.Context) *ReloadResult {
backoff := 50 * time.Millisecond
deadline := time.Now().Add(10 * time.Second)
for {
if r.mu.TryLock() {
res := r.reloadLocked(ctx)
r.mu.Unlock()
return res
}
if !time.Now().Before(deadline) {
break
}
wait := backoff
if d := time.Until(deadline); d < wait {
wait = d
}
select {
case <-ctx.Done():
return &ReloadResult{Errors: []string{"reload wait cancelled: " + ctx.Err().Error()}}
case <-time.After(wait):
}
backoff *= 2
if backoff > time.Second {
backoff = time.Second
}
}
r.mu.Lock()
defer r.mu.Unlock()
return r.reloadLocked(ctx)
}
func (r *Reloader) reloadLocked(ctx context.Context) *ReloadResult {
result := &ReloadResult{}
discovered, failures := plugin.Discover(ctx, r.cfg)
for _, f := range failures {
errMsg := fmt.Sprintf("discovery: %s: %v", f.Dir, f.Err)
r.logger.Warn("plugin reload discovery skipped", slog.String("dir", f.Dir), slog.Any("error", f.Err))
result.Errors = append(result.Errors, errMsg)
}
next := make(map[string]*plugin.DiscoveredPlugin, len(discovered))
for _, dp := range discovered {
next[dp.Manifest.ProviderType] = dp
}
for pt := range r.state {
if _, ok := next[pt]; !ok {
r.unregisterOne(pt)
result.Removed = append(result.Removed, pt)
}
}
for pt, dp := range next {
if _, ok := r.state[pt]; ok {
r.unregisterOne(pt)
if err := registerOne(ctx, r.manager, dp, r.catalog, r.logger); err != nil {
errMsg := fmt.Sprintf("update %q: %v", pt, err)
r.logger.Error("plugin reload update failed", slog.String("provider", pt), slog.Any("error", err))
result.Errors = append(result.Errors, errMsg)
continue
}
result.Changed = append(result.Changed, pt)
} else {
if err := registerOne(ctx, r.manager, dp, r.catalog, r.logger); err != nil {
errMsg := fmt.Sprintf("add %q: %v", pt, err)
r.logger.Error("plugin reload add failed", slog.String("provider", pt), slog.Any("error", err))
result.Errors = append(result.Errors, errMsg)
continue
}
result.Added = append(result.Added, pt)
}
}
r.state = next
r.logger.Info("plugin reload complete",
slog.Int("added", len(result.Added)),
slog.Int("changed", len(result.Changed)),
slog.Int("removed", len(result.Removed)),
slog.Int("errors", len(result.Errors)))
return result
}
func (r *Reloader) unregisterOne(providerType string) {
unregisterEnvelope(providerType)
deployers.Registries.Unregister(domain.DeploymentProviderType(providerType))
r.catalog.Remove(providerType)
r.logger.Debug("plugin unregistered", slog.String("provider", providerType))
}
func unregisterEnvelope(providerType string) {
if env, ok := providerschema.Envelopes.GetEnvelope(providerType); ok && env != nil {
providerschema.Envelopes.UnregisterEnvelope(providerType)
}
}
+31
View File
@@ -0,0 +1,31 @@
package pluginhost
var globalCatalog = NewCatalog()
var globalReloader *Reloader
var globalMarketService *MarketService
func SetGlobalCatalog(c *Catalog) {
if c != nil {
globalCatalog = c
}
}
func GlobalCatalog() *Catalog {
return globalCatalog
}
func SetGlobalReloader(r *Reloader) {
globalReloader = r
}
func GlobalReloader() *Reloader {
return globalReloader
}
func SetGlobalMarketService(s *MarketService) {
globalMarketService = s
}
func GlobalMarketService() *MarketService {
return globalMarketService
}
+137
View File
@@ -0,0 +1,137 @@
package pluginhost
import (
"context"
"log/slog"
"os"
"time"
"github.com/fsnotify/fsnotify"
)
const (
defaultDebounce = 500 * time.Millisecond
defaultPollInterval = 30 * time.Second
)
type Watcher struct {
dir string
debounce time.Duration
pollInterval time.Duration
reloadCh chan struct{}
logger *slog.Logger
}
func NewWatcher(dir string, logger *slog.Logger) *Watcher {
if logger == nil {
logger = slog.Default()
}
return &Watcher{
dir: dir,
debounce: defaultDebounce,
pollInterval: defaultPollInterval,
reloadCh: make(chan struct{}, 1),
logger: logger.With(slog.String("component", "pluginwatcher")),
}
}
func (w *Watcher) ReloadTrigger() <-chan struct{} {
return w.reloadCh
}
func (w *Watcher) Start(ctx context.Context) {
go w.pollLoop(ctx)
fsw, err := fsnotify.NewWatcher()
if err != nil {
w.logger.Warn("fsnotify init failed, using polling-only mode", slog.Any("error", err))
return
}
if err := fsw.Add(w.dir); err != nil {
w.logger.Warn("fsnotify cannot watch plugin dir, using polling-only mode", slog.String("dir", w.dir), slog.Any("error", err))
fsw.Close()
return
}
go w.fsnotifyLoop(ctx, fsw)
}
func (w *Watcher) Stop() {
}
func (w *Watcher) trigger() {
select {
case w.reloadCh <- struct{}{}:
default:
}
}
func (w *Watcher) fsnotifyLoop(ctx context.Context, fsw *fsnotify.Watcher) {
defer fsw.Close()
timer := time.NewTimer(0)
if !timer.Stop() {
<-timer.C
}
timerActive := false
for {
select {
case <-ctx.Done():
if timerActive {
timer.Stop()
}
return
case event, ok := <-fsw.Events:
if !ok {
return
}
if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove|fsnotify.Rename) != 0 {
if !timerActive {
timer.Reset(w.debounce)
timerActive = true
}
}
case err, ok := <-fsw.Errors:
if !ok {
return
}
w.logger.Warn("fsnotify error", slog.Any("error", err))
case <-timer.C:
timerActive = false
w.trigger()
}
}
}
func (w *Watcher) pollLoop(ctx context.Context) {
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.trigger()
}
}
}
func (w *Watcher) Fingerprint() map[string][2]int64 {
entries, err := os.ReadDir(w.dir)
if err != nil {
return nil
}
fp := make(map[string][2]int64, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
fp[entry.Name()] = [2]int64{info.ModTime().Unix(), info.Size()}
}
return fp
}
+330
View File
@@ -0,0 +1,330 @@
package providerschema
import "fmt"
type FieldOption func(*Field)
func LabelKey(key string) FieldOption {
return func(f *Field) { f.LabelKey = key }
}
func PlaceholderKey(key string) FieldOption {
return func(f *Field) { f.PlaceholderKey = key }
}
func TooltipKey(key string) FieldOption {
return func(f *Field) { f.TooltipKey = key }
}
func ExtraKey(key string) FieldOption {
return func(f *Field) { f.ExtraKey = key }
}
func TooltipHtml() FieldOption {
return func(f *Field) { f.TooltipHtml = true }
}
func ExtraHtml() FieldOption {
return func(f *Field) { f.ExtraHtml = true }
}
func Default(value any) FieldOption {
return func(f *Field) {
f.Default = value
f.hasDefault = true
}
}
func Secret() FieldOption {
return func(f *Field) { f.Secret = true }
}
func Required() FieldOption {
return func(f *Field) { f.Required = true }
}
func Span(columns int) FieldOption {
return func(f *Field) { f.Span = columns }
}
func Min(value float64) FieldOption {
return func(f *Field) { v := value; f.Min = &v }
}
func Max(value float64) FieldOption {
return func(f *Field) { v := value; f.Max = &v }
}
func WithFilterMode(mode FilterMode) FieldOption {
return func(f *Field) { f.FilterMode = mode }
}
func Options(values ...string) FieldOption {
return func(f *Field) {
opts := make([]Option, len(values))
for i, v := range values {
opts[i] = Option{Value: v}
}
f.Options = opts
}
}
func OptionsWith(opts ...Option) FieldOption {
return func(f *Field) { f.Options = opts }
}
type condKind int
const (
condVisible condKind = iota
condRequired
)
type visibilityBuilder struct {
field string
kind condKind
}
func (b *visibilityBuilder) apply(c Condition) FieldOption {
return func(f *Field) {
switch b.kind {
case condVisible:
f.VisibleWhen = append(f.VisibleWhen, c)
case condRequired:
f.RequiredWhen = append(f.RequiredWhen, c)
}
}
}
func VisibleWhen(field string) *visibilityBuilder {
return &visibilityBuilder{field: field, kind: condVisible}
}
func RequiredWhen(field string) *visibilityBuilder {
return &visibilityBuilder{field: field, kind: condRequired}
}
func (b *visibilityBuilder) Equals(value string) FieldOption {
return b.apply(Condition{Field: b.field, Op: OpEquals, Values: []string{value}})
}
func (b *visibilityBuilder) Not(value string) FieldOption {
return b.apply(Condition{Field: b.field, Op: OpNotEquals, Values: []string{value}})
}
func (b *visibilityBuilder) In(values ...string) FieldOption {
return b.apply(Condition{Field: b.field, Op: OpIn, Values: values})
}
func (b *visibilityBuilder) NotIn(values ...string) FieldOption {
return b.apply(Condition{Field: b.field, Op: OpNotIn, Values: values})
}
type validateBuilder struct {
cond Condition
set bool
}
func (b *validateBuilder) on(op Op, values []string) *validateBuilder {
b.cond = Condition{Field: b.cond.Field, Op: op, Values: values}
b.set = true
return b
}
func (b *validateBuilder) Equals(value string) *validateBuilder {
return b.on(OpEquals, []string{value})
}
func (b *validateBuilder) Not(value string) *validateBuilder {
return b.on(OpNotEquals, []string{value})
}
func (b *validateBuilder) In(values ...string) *validateBuilder { return b.on(OpIn, values) }
func (b *validateBuilder) NotIn(values ...string) *validateBuilder { return b.on(OpNotIn, values) }
func (b *validateBuilder) Validator(name Validator, params ...ValidatorParam) FieldOption {
rule := ValidationRule{
Condition: b.cond,
Name: name,
Params: validatorParams(params),
}
return func(f *Field) { f.ValidateWhen = append(f.ValidateWhen, rule) }
}
type ValidatorParam struct {
Key string
Value any
}
func WithParam(key string, value any) ValidatorParam {
return ValidatorParam{Key: key, Value: value}
}
func validatorParams(params []ValidatorParam) map[string]any {
if len(params) == 0 {
return nil
}
out := make(map[string]any, len(params))
for _, p := range params {
out[p.Key] = p.Value
}
return out
}
func ValidateWhen(field string) *validateBuilder {
return &validateBuilder{cond: Condition{Field: field}}
}
type builder struct {
schema Schema
err error
}
func New(provider string, category Category) *builder {
return &builder{schema: Schema{Provider: provider, Category: category}}
}
func (b *builder) Field(name string, vt ValueType, opts ...FieldOption) *builder {
if b.err != nil {
return b
}
f := Field{Name: name, ValueType: vt}
for _, opt := range opts {
opt(&f)
}
b.schema.Fields = append(b.schema.Fields, f)
return b
}
func (b *builder) Build() (*Schema, error) {
if b.err != nil {
return nil, b.err
}
if err := validate(&b.schema); err != nil {
return nil, err
}
schema := b.schema
return &schema, nil
}
func validate(s *Schema) error {
if s.Provider == "" {
return fmt.Errorf("providerschema: provider must not be empty")
}
declared := make(map[string]struct{}, len(s.Fields))
for _, f := range s.Fields {
if f.Name == "" {
return fmt.Errorf("providerschema: field name must not be empty")
}
if _, dup := declared[f.Name]; dup {
return fmt.Errorf("providerschema: duplicate field name %q", f.Name)
}
declared[f.Name] = struct{}{}
}
for i := range s.Fields {
if err := validateField(&s.Fields[i], declared); err != nil {
return err
}
}
return nil
}
func validateField(f *Field, declared map[string]struct{}) error {
if _, ok := validValueTypes[f.ValueType]; !ok {
return fmt.Errorf("providerschema: field %q has unknown valueType %q", f.Name, f.ValueType)
}
switch f.ValueType {
case ValueTypeSelect, ValueTypeRadio:
if len(f.Options) == 0 {
return fmt.Errorf("providerschema: field %q of valueType %q requires Options", f.Name, f.ValueType)
}
case ValueTypeNumber:
if f.Min != nil && f.Max != nil && *f.Min > *f.Max {
return fmt.Errorf("providerschema: field %q has min (%v) greater than max (%v)", f.Name, *f.Min, *f.Max)
}
}
if f.hasDefault {
if err := validateDefault(f); err != nil {
return err
}
}
for _, c := range f.VisibleWhen {
if err := validateCondition(c, f.Name, "visibleWhen", declared); err != nil {
return err
}
}
for _, c := range f.RequiredWhen {
if err := validateCondition(c, f.Name, "requiredWhen", declared); err != nil {
return err
}
}
for _, r := range f.ValidateWhen {
if err := validateCondition(r.Condition, f.Name, "validateWhen", declared); err != nil {
return err
}
if _, ok := validValidators[r.Name]; !ok {
return fmt.Errorf("providerschema: field %q validateWhen uses unknown validator %q", f.Name, r.Name)
}
}
return nil
}
func validateCondition(c Condition, owner, clause string, declared map[string]struct{}) error {
if _, ok := declared[c.Field]; !ok {
return fmt.Errorf("providerschema: field %q %s references undeclared discriminator %q", owner, clause, c.Field)
}
if len(c.Values) == 0 {
return fmt.Errorf("providerschema: field %q %s on %q has no values", owner, clause, c.Field)
}
switch c.Op {
case OpEquals, OpNotEquals, OpIn, OpNotIn:
default:
return fmt.Errorf("providerschema: field %q %s on %q has unknown op %q", owner, clause, c.Field, c.Op)
}
return nil
}
func validateDefault(f *Field) error {
v := f.Default
switch f.ValueType {
case ValueTypeText, ValueTypeTextarea, ValueTypeSecret, ValueTypeCode, ValueTypeAutocomplete:
if _, ok := v.(string); !ok {
return fmt.Errorf("providerschema: field %q default must be a string, got %T", f.Name, v)
}
case ValueTypeSelect, ValueTypeRadio:
s, ok := v.(string)
if !ok {
return fmt.Errorf("providerschema: field %q default must be a string, got %T", f.Name, v)
}
if !optionContains(f.Options, s) {
return fmt.Errorf("providerschema: field %q default %q is not among its options", f.Name, s)
}
case ValueTypeSwitch:
if _, ok := v.(bool); !ok {
return fmt.Errorf("providerschema: field %q default must be a bool, got %T", f.Name, v)
}
case ValueTypeNumber:
switch n := v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
_ = n
default:
return fmt.Errorf("providerschema: field %q default must be a number, got %T", f.Name, v)
}
}
return nil
}
func optionContains(opts []Option, value string) bool {
for _, o := range opts {
if o.Value == value {
return true
}
}
return false
}
+223
View File
@@ -0,0 +1,223 @@
package providerschema
import (
"errors"
"testing"
)
func TestBuilder_HappyPath_TextSelectConditional(t *testing.T) {
s, err := New("aliyun-cdn", CategoryDeploy).
Field("region", ValueTypeAutocomplete,
LabelKey("workflow_node.deploy.form.aliyun_cdn_region.label"),
PlaceholderKey("workflow_node.deploy.form.aliyun_cdn_region.placeholder"),
TooltipKey("workflow_node.deploy.form.aliyun_cdn_region.tooltip"),
TooltipHtml(),
Options("cn-hangzhou", "ap-southeast-1"),
WithFilterMode(FilterModePrefix),
).
Field("domainMatchPattern", ValueTypeRadio,
LabelKey("workflow_node.deploy.form.shared_domain_match_pattern.label"),
ExtraKey("workflow_node.deploy.form.shared_domain_match_pattern.option.exact.help.wildcard"),
ExtraHtml(),
Options("exact", "wildcard", "certsan"),
Default("exact"),
).
Field("domain", ValueTypeText,
LabelKey("workflow_node.deploy.form.aliyun_cdn_domain.label"),
PlaceholderKey("workflow_node.deploy.form.aliyun_cdn_domain.placeholder"),
VisibleWhen("domainMatchPattern").NotIn("certsan"),
RequiredWhen("domainMatchPattern").In("exact", "wildcard"),
ValidateWhen("domainMatchPattern").In("exact", "wildcard").Validator(ValidatorDomain, WithParam("allowWildcard", true)),
).
Build()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.Provider != "aliyun-cdn" || s.Category != CategoryDeploy {
t.Fatalf("provider/category = %q/%q", s.Provider, s.Category)
}
if len(s.Fields) != 3 {
t.Fatalf("field count = %d, want 3", len(s.Fields))
}
region := s.Fields[0]
if region.ValueType != ValueTypeAutocomplete || len(region.Options) != 2 || !region.TooltipHtml || region.FilterMode != FilterModePrefix {
t.Fatalf("region field not as declared: %+v", region)
}
dmp := s.Fields[1]
if dmp.Default != "exact" || len(dmp.Options) != 3 || !dmp.ExtraHtml {
t.Fatalf("domainMatchPattern not as declared: %+v", dmp)
}
domain := s.Fields[2]
if len(domain.VisibleWhen) != 1 || domain.VisibleWhen[0].Op != OpNotIn {
t.Fatalf("domain visibleWhen not declared: %+v", domain.VisibleWhen)
}
if len(domain.RequiredWhen) != 1 || domain.RequiredWhen[0].Op != OpIn {
t.Fatalf("domain requiredWhen not declared: %+v", domain.RequiredWhen)
}
if len(domain.ValidateWhen) != 1 {
t.Fatalf("domain validateWhen not declared: %+v", domain.ValidateWhen)
}
vr := domain.ValidateWhen[0]
if vr.Name != ValidatorDomain || vr.Op != OpIn || vr.Params["allowWildcard"] != true {
t.Fatalf("domain validateWhen rule not as declared: %+v", vr)
}
}
func TestBuilder_HappyPath_MultiDiscriminator(t *testing.T) {
s, err := New("ssh", CategoryDeploy).
Field("useSCP", ValueTypeSwitch).
Field("fileFormat", ValueTypeSelect, Options("PEM", "PFX", "JKS"), Default("PEM")).
Field("pfxPassword", ValueTypeSecret,
VisibleWhen("fileFormat").Equals("PFX"),
RequiredWhen("fileFormat").Equals("PFX"),
VisibleWhen("useSCP").Equals("true"),
).
Field("jksAlias", ValueTypeText,
VisibleWhen("fileFormat").Equals("JKS"),
RequiredWhen("fileFormat").Equals("JKS"),
).
Build()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
pfx := s.Fields[2]
if len(pfx.VisibleWhen) != 2 {
t.Fatalf("pfxPassword visibleWhen count = %d, want 2 (multi-discriminator)", len(pfx.VisibleWhen))
}
discs := map[string]bool{}
for _, c := range pfx.VisibleWhen {
discs[c.Field] = true
}
if !discs["fileFormat"] || !discs["useSCP"] {
t.Fatalf("pfxPassword should react to both fileFormat and useSCP, got %v", discs)
}
if len(pfx.RequiredWhen) != 1 || pfx.RequiredWhen[0].Field != "fileFormat" {
t.Fatalf("pfxPassword requiredWhen not as declared: %+v", pfx.RequiredWhen)
}
}
func TestBuilder_HappyPath_DefaultAndSecret(t *testing.T) {
s, err := New("p", CategoryDeploy).
Field("token", ValueTypeSecret, Secret()).
Field("port", ValueTypeNumber, Default(443), Min(1), Max(65535)).
Field("enabled", ValueTypeSwitch, Default(true)).
Build()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !s.Fields[0].Secret {
t.Fatal("token should be secret")
}
if s.Fields[1].Default != 443 || *s.Fields[1].Min != 1 || *s.Fields[1].Max != 65535 {
t.Fatalf("port not as declared: %+v", s.Fields[1])
}
if s.Fields[2].Default != true {
t.Fatalf("enabled default not as declared: %+v", s.Fields[2])
}
}
func TestBuilder_Edge_EmptySchema(t *testing.T) {
s, err := New("p", CategoryDeploy).Build()
if err != nil {
t.Fatalf("empty schema should build, got %v", err)
}
if len(s.Fields) != 0 {
t.Fatalf("expected 0 fields, got %d", len(s.Fields))
}
}
func TestBuilder_Edge_OptionsWithoutLabelKey(t *testing.T) {
s, err := New("p", CategoryDeploy).
Field("fmt", ValueTypeSelect, Options("a", "b")).
Build()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.Fields[0].Options[0].LabelKey != "" {
t.Fatalf("option label key should be empty, got %q", s.Fields[0].Options[0].LabelKey)
}
}
func TestBuilder_Error_DuplicateFieldName(t *testing.T) {
_, err := New("p", CategoryDeploy).
Field("region", ValueTypeText).
Field("region", ValueTypeText).
Build()
if err == nil {
t.Fatal("expected duplicate-field error")
}
}
func TestBuilder_Error_ConditionReferencesUndeclaredField(t *testing.T) {
_, err := New("p", CategoryDeploy).
Field("domain", ValueTypeText, VisibleWhen("missing").Equals("x")).
Build()
if err == nil {
t.Fatal("expected undeclared-discriminator error")
}
}
func TestBuilder_Error_UnknownValueType(t *testing.T) {
_, err := New("p", CategoryDeploy).
Field("x", ValueType("bogus")).
Build()
if err == nil {
t.Fatal("expected unknown-valueType error")
}
}
func TestBuilder_Error_DefaultTypeMismatch(t *testing.T) {
_, err := New("p", CategoryDeploy).
Field("x", ValueTypeText, Default(123)).
Build()
if err == nil {
t.Fatal("expected default type mismatch error")
}
}
func TestBuilder_Error_DefaultNotAmongOptions(t *testing.T) {
_, err := New("p", CategoryDeploy).
Field("fmt", ValueTypeSelect, Options("a", "b"), Default("c")).
Build()
if err == nil {
t.Fatal("expected default-not-among-options error")
}
}
func TestBuilder_Error_SelectWithoutOptions(t *testing.T) {
_, err := New("p", CategoryDeploy).
Field("fmt", ValueTypeSelect).
Build()
if err == nil {
t.Fatal("expected select-without-options error")
}
}
func TestBuilder_Error_UnknownValidator(t *testing.T) {
_, err := New("p", CategoryDeploy).
Field("disc", ValueTypeSelect, Options("a")).
Field("x", ValueTypeText, ValidateWhen("disc").Equals("a").Validator(Validator("nope"))).
Build()
if err == nil {
t.Fatal("expected unknown-validator error")
}
}
func TestBuilder_Error_StickyAfterFirstFailure(t *testing.T) {
b := New("p", CategoryDeploy)
_, err := b.
Field("x", ValueType("bogus")).
Field("y", ValueTypeText).
Build()
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, err) {
t.Fatal("error should be non-nil and sticky")
}
}
+110
View File
@@ -0,0 +1,110 @@
package providerschema
const SchemaVersion = "form/v1"
type Envelope struct {
SchemaVersion string `json:"schemaVersion"`
Provider string `json:"provider"`
Category Category `json:"category"`
Schema EnvelopeSchema `json:"schema"`
}
type EnvelopeSchema struct {
Columns []Column `json:"columns"`
}
type Column struct {
Name string `json:"name"`
ValueType ValueType `json:"valueType"`
LabelKey string `json:"labelKey,omitempty"`
PlaceholderKey string `json:"placeholderKey,omitempty"`
TooltipKey string `json:"tooltipKey,omitempty"`
ExtraKey string `json:"extraKey,omitempty"`
TooltipHtml bool `json:"tooltipHtml,omitempty"`
ExtraHtml bool `json:"extraHtml,omitempty"`
Default any `json:"default,omitempty"`
Secret bool `json:"secret,omitempty"`
Required bool `json:"required,omitempty"`
Options []Option `json:"options,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Language string `json:"language,omitempty"`
UnitKey string `json:"unitKey,omitempty"`
FilterMode FilterMode `json:"filterMode,omitempty"`
Span int `json:"span,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
VisibleWhen []Condition `json:"visibleWhen,omitempty"`
RequiredWhen []Condition `json:"requiredWhen,omitempty"`
ValidateWhen []ValidationRule `json:"validateWhen,omitempty"`
}
func Emit(s *Schema) *Envelope {
columns := make([]Column, len(s.Fields))
for i := range s.Fields {
columns[i] = emitColumn(&s.Fields[i])
}
return &Envelope{
SchemaVersion: SchemaVersion,
Provider: s.Provider,
Category: s.Category,
Schema: EnvelopeSchema{Columns: columns},
}
}
func emitColumn(f *Field) Column {
return Column{
Name: f.Name,
ValueType: f.ValueType,
LabelKey: f.LabelKey,
PlaceholderKey: f.PlaceholderKey,
TooltipKey: f.TooltipKey,
ExtraKey: f.ExtraKey,
TooltipHtml: f.TooltipHtml,
ExtraHtml: f.ExtraHtml,
Default: f.Default,
Secret: f.Secret,
Required: f.Required,
Options: emitOptions(f),
Min: f.Min,
Max: f.Max,
FilterMode: f.FilterMode,
Span: f.Span,
Dependencies: dependencies(f),
VisibleWhen: f.VisibleWhen,
RequiredWhen: f.RequiredWhen,
ValidateWhen: f.ValidateWhen,
}
}
func emitOptions(f *Field) []Option {
switch f.ValueType {
case ValueTypeSelect, ValueTypeRadio, ValueTypeAutocomplete:
return f.Options
}
return nil
}
func dependencies(f *Field) []string {
seen := make(map[string]struct{})
var deps []string
add := func(field string) {
if field == "" {
return
}
if _, ok := seen[field]; ok {
return
}
seen[field] = struct{}{}
deps = append(deps, field)
}
for _, c := range f.VisibleWhen {
add(c.Field)
}
for _, c := range f.RequiredWhen {
add(c.Field)
}
for _, r := range f.ValidateWhen {
add(r.Field)
}
return deps
}
+129
View File
@@ -0,0 +1,129 @@
package providerschema
import (
"encoding/json"
"strings"
"testing"
)
func TestEmit_PreservesFieldOrderAndEnvelope(t *testing.T) {
s, err := New("aliyun-cdn", CategoryDeploy).
Field("region", ValueTypeText, LabelKey("k.region")).
Field("domainMatchPattern", ValueTypeRadio, Options("exact", "wildcard"), Default("exact")).
Field("domain", ValueTypeText, VisibleWhen("domainMatchPattern").NotIn("certsan")).
Build()
if err != nil {
t.Fatal(err)
}
env := Emit(s)
if env.SchemaVersion != "form/v1" {
t.Fatalf("schemaVersion = %q", env.SchemaVersion)
}
if env.Provider != "aliyun-cdn" || env.Category != CategoryDeploy {
t.Fatalf("provider/category = %q/%q", env.Provider, env.Category)
}
if len(env.Schema.Columns) != 3 {
t.Fatalf("column count = %d", len(env.Schema.Columns))
}
if env.Schema.Columns[0].Name != "region" || env.Schema.Columns[2].Name != "domain" {
t.Fatalf("field order not preserved: %v", columnNames(env))
}
}
func TestEmit_OptionsAndSecret(t *testing.T) {
s, _ := New("p", CategoryDeploy).
Field("fmt", ValueTypeSelect, Options("a", "b")).
Field("token", ValueTypeSecret, Secret()).
Build()
env := Emit(s)
if len(env.Schema.Columns[0].Options) != 2 {
t.Fatalf("select options not emitted: %+v", env.Schema.Columns[0])
}
if env.Schema.Columns[0].Options[0].Value != "a" {
t.Fatalf("option value not emitted: %+v", env.Schema.Columns[0].Options[0])
}
if !env.Schema.Columns[1].Secret || env.Schema.Columns[1].ValueType != ValueTypeSecret {
t.Fatalf("secret field not emitted as secret: %+v", env.Schema.Columns[1])
}
textOnly, _ := New("p2", CategoryDeploy).Field("x", ValueTypeText).Build()
if len(Emit(textOnly).Schema.Columns[0].Options) != 0 {
t.Fatal("text field should not emit options")
}
}
func TestEmit_ConditionalsAndDependencies(t *testing.T) {
s, _ := New("ssh", CategoryDeploy).
Field("useSCP", ValueTypeSwitch).
Field("fileFormat", ValueTypeSelect, Options("PEM", "PFX")).
Field(
"pfxPassword", ValueTypeSecret,
VisibleWhen("fileFormat").Equals("PFX"),
RequiredWhen("fileFormat").Equals("PFX"),
VisibleWhen("useSCP").Equals("true"),
ValidateWhen("fileFormat").Equals("PFX").Validator(ValidatorDomain),
).
Build()
env := Emit(s)
col := env.Schema.Columns[2]
if len(col.VisibleWhen) != 2 {
t.Fatalf("visibleWhen count = %d, want 2", len(col.VisibleWhen))
}
if len(col.RequiredWhen) != 1 || col.RequiredWhen[0].Field != "fileFormat" {
t.Fatalf("requiredWhen not emitted: %+v", col.RequiredWhen)
}
if len(col.ValidateWhen) != 1 || col.ValidateWhen[0].Name != ValidatorDomain {
t.Fatalf("validateWhen not emitted: %+v", col.ValidateWhen)
}
deps := col.Dependencies
if len(deps) != 2 {
t.Fatalf("dependencies count = %d, want 2 (fileFormat+useSCP): %v", len(deps), deps)
}
hasFileFormat, hasUseSCP := false, false
for _, d := range deps {
if d == "fileFormat" {
hasFileFormat = true
}
if d == "useSCP" {
hasUseSCP = true
}
}
if !hasFileFormat || !hasUseSCP {
t.Fatalf("dependencies missing discriminators: %v", deps)
}
}
func TestEmit_JSONRoundTrip(t *testing.T) {
s, _ := New("aliyun-cdn", CategoryDeploy).
Field("domainMatchPattern", ValueTypeRadio, Options("exact", "wildcard", "certsan"), Default("exact")).
Field("domain", ValueTypeText, RequiredWhen("domainMatchPattern").In("exact", "wildcard")).
Build()
env := Emit(s)
data, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
str := string(data)
if !strings.Contains(str, `"schemaVersion":"form/v1"`) {
t.Fatalf("envelope missing schemaVersion: %s", str)
}
if !strings.Contains(str, `"columns"`) {
t.Fatalf("envelope missing columns: %s", str)
}
if !strings.Contains(str, `"requiredWhen"`) {
t.Fatalf("envelope missing requiredWhen: %s", str)
}
}
func columnNames(env *Envelope) []string {
out := make([]string, len(env.Schema.Columns))
for i, c := range env.Schema.Columns {
out[i] = c.Name
}
return out
}
+143
View File
@@ -0,0 +1,143 @@
package providerschema
import (
"fmt"
"sort"
"sync"
)
type Registry[T comparable] interface {
Register(name T, schema *Schema) error
MustRegister(name T, schema *Schema)
Get(name T) (*Schema, error)
Has(name T) bool
List() []T
Unregister(T)
}
type registry[T comparable] struct {
mu sync.RWMutex
schemas map[T]*Schema
}
func (r *registry[T]) Register(name T, schema *Schema) error {
r.mu.Lock()
defer r.mu.Unlock()
if schema == nil {
return fmt.Errorf("providerschema: schema must not be nil")
}
if _, exists := r.schemas[name]; exists {
return fmt.Errorf("providerschema: schema for %v already registered", name)
}
r.schemas[name] = schema
return nil
}
func (r *registry[T]) MustRegister(name T, schema *Schema) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.schemas[name]; exists {
panic(fmt.Sprintf("providerschema: schema for %v already registered", name))
}
r.schemas[name] = schema
}
func (r *registry[T]) Get(name T) (*Schema, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if schema, exists := r.schemas[name]; exists {
return schema, nil
}
return nil, fmt.Errorf("providerschema: schema for %v not registered", name)
}
func (r *registry[T]) Has(name T) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, exists := r.schemas[name]
return exists
}
func (r *registry[T]) List() []T {
r.mu.RLock()
defer r.mu.RUnlock()
keys := make([]T, 0, len(r.schemas))
for k := range r.schemas {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return fmt.Sprint(keys[i]) < fmt.Sprint(keys[j])
})
return keys
}
func (r *registry[T]) Unregister(name T) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.schemas, name)
}
func newRegistry[T comparable]() Registry[T] {
return &registry[T]{schemas: make(map[T]*Schema)}
}
var Registries = newRegistry[string]()
type EnvelopeRegistry interface {
RegisterEnvelope(name string, env *Envelope) error
GetEnvelope(name string) (*Envelope, bool)
ListEnvelopes() []*Envelope
UnregisterEnvelope(name string)
}
type envelopeRegistry struct {
mu sync.RWMutex
envelopes map[string]*Envelope
}
func (r *envelopeRegistry) RegisterEnvelope(name string, env *Envelope) error {
r.mu.Lock()
defer r.mu.Unlock()
if env == nil {
return fmt.Errorf("providerschema: envelope must not be nil")
}
if _, exists := r.envelopes[name]; exists {
return fmt.Errorf("providerschema: envelope for %q already registered", name)
}
r.envelopes[name] = env
return nil
}
func (r *envelopeRegistry) GetEnvelope(name string) (*Envelope, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
env, ok := r.envelopes[name]
return env, ok
}
func (r *envelopeRegistry) ListEnvelopes() []*Envelope {
r.mu.RLock()
defer r.mu.RUnlock()
keys := make([]string, 0, len(r.envelopes))
for k := range r.envelopes {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]*Envelope, 0, len(keys))
for _, k := range keys {
out = append(out, r.envelopes[k])
}
return out
}
func (r *envelopeRegistry) UnregisterEnvelope(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.envelopes, name)
}
func newEnvelopeRegistry() EnvelopeRegistry {
return &envelopeRegistry{envelopes: make(map[string]*Envelope)}
}
var Envelopes = newEnvelopeRegistry()
+76
View File
@@ -0,0 +1,76 @@
package providerschema
import "testing"
func TestRegistry_RegisterAndGet(t *testing.T) {
r := newRegistry[string]()
s, err := New("aliyun-cdn", CategoryDeploy).
Field("region", ValueTypeText).
Build()
if err != nil {
t.Fatal(err)
}
if err := r.Register("aliyun-cdn", s); err != nil {
t.Fatal(err)
}
got, err := r.Get("aliyun-cdn")
if err != nil {
t.Fatal(err)
}
if got != s {
t.Fatal("Get should return the registered schema")
}
}
func TestRegistry_GetUnregistered(t *testing.T) {
r := newRegistry[string]()
if _, err := r.Get("missing"); err == nil {
t.Fatal("expected not-found error")
}
if r.Has("missing") {
t.Fatal("Has should be false for unregistered")
}
}
func TestRegistry_DuplicatePanics(t *testing.T) {
r := newRegistry[string]()
s, _ := New("p", CategoryDeploy).Field("a", ValueTypeText).Build()
r.MustRegister("p", s)
defer func() {
if recover() == nil {
t.Fatal("expected panic on duplicate MustRegister")
}
}()
r.MustRegister("p", s)
}
func TestRegistry_NilSchemaRejected(t *testing.T) {
r := newRegistry[string]()
if err := r.Register("p", nil); err == nil {
t.Fatal("expected error for nil schema")
}
}
func TestRegistry_ListIsSorted(t *testing.T) {
r := newRegistry[string]()
for _, name := range []string{"c", "a", "b"} {
s, _ := New(name, CategoryDeploy).Field("f", ValueTypeText).Build()
r.MustRegister(name, s)
}
got := r.List()
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
t.Fatalf("List not sorted: %v", got)
}
}
func TestRegistry_SingletonTypedByDeploymentProviderType(t *testing.T) {
if Registries == nil {
t.Fatal("Registries singleton should be initialized")
}
if Registries.Has("test-provider-not-registered") {
t.Fatal("Has should be false for an unregistered provider type")
}
if _, err := Registries.Get("test-provider-not-registered"); err == nil {
t.Fatal("Get should error for an unregistered provider type")
}
}
+139
View File
@@ -0,0 +1,139 @@
package providerschema
type Category string
const (
CategoryDeploy Category = "deploy"
CategoryAccess Category = "access"
CategoryApply Category = "apply"
CategoryNotify Category = "notify"
)
type ValueType string
const (
ValueTypeText ValueType = "text"
ValueTypeTextarea ValueType = "textarea"
ValueTypeSelect ValueType = "select"
ValueTypeRadio ValueType = "radio"
ValueTypeSwitch ValueType = "switch"
ValueTypeNumber ValueType = "number"
ValueTypeSecret ValueType = "secret"
ValueTypeCode ValueType = "code"
ValueTypeAutocomplete ValueType = "autocomplete"
)
var validValueTypes = map[ValueType]struct{}{
ValueTypeText: {},
ValueTypeTextarea: {},
ValueTypeSelect: {},
ValueTypeRadio: {},
ValueTypeSwitch: {},
ValueTypeNumber: {},
ValueTypeSecret: {},
ValueTypeCode: {},
ValueTypeAutocomplete: {},
}
type FilterMode string
const (
FilterModeFuzzy FilterMode = "fuzzy"
FilterModePrefix FilterMode = "prefix"
FilterModeNone FilterMode = "none"
)
type Validator string
const (
ValidatorDomain Validator = "domain"
ValidatorHostname Validator = "hostname"
ValidatorIPv4 Validator = "ipv4"
ValidatorIPv6 Validator = "ipv6"
ValidatorPort Validator = "port"
ValidatorURL Validator = "url"
ValidatorURLHttp Validator = "url_http"
ValidatorURLHttps Validator = "url_https"
ValidatorJsonObject Validator = "json_object"
ValidatorRegex Validator = "regex"
ValidatorCron Validator = "cron"
)
var validValidators = map[Validator]struct{}{
ValidatorDomain: {},
ValidatorHostname: {},
ValidatorIPv4: {},
ValidatorIPv6: {},
ValidatorPort: {},
ValidatorURL: {},
ValidatorURLHttp: {},
ValidatorURLHttps: {},
ValidatorJsonObject: {},
ValidatorRegex: {},
ValidatorCron: {},
}
type Op string
const (
OpEquals Op = "eq"
OpNotEquals Op = "ne"
OpIn Op = "in"
OpNotIn Op = "notIn"
)
type Condition struct {
Field string `json:"field"`
Op Op `json:"op"`
Values []string `json:"values,omitempty"`
}
type ValidationRule struct {
Condition
Name Validator `json:"validator"`
Params map[string]any `json:"params,omitempty"`
}
type Option struct {
Value string `json:"value"`
LabelKey string `json:"labelKey,omitempty"`
}
type Field struct {
Name string `json:"name"`
ValueType ValueType `json:"valueType"`
LabelKey string `json:"labelKey,omitempty"`
PlaceholderKey string `json:"placeholderKey,omitempty"`
TooltipKey string `json:"tooltipKey,omitempty"`
ExtraKey string `json:"extraKey,omitempty"`
TooltipHtml bool `json:"tooltipHtml,omitempty"`
ExtraHtml bool `json:"extraHtml,omitempty"`
Default any `json:"default,omitempty"`
hasDefault bool
Secret bool `json:"secret,omitempty"`
Required bool `json:"required,omitempty"`
Options []Option `json:"options,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
FilterMode FilterMode `json:"filterMode,omitempty"`
Span int `json:"span,omitempty"`
VisibleWhen []Condition `json:"visibleWhen,omitempty"`
RequiredWhen []Condition `json:"requiredWhen,omitempty"`
ValidateWhen []ValidationRule `json:"validateWhen,omitempty"`
}
type Schema struct {
Provider string `json:"provider"`
Category Category `json:"category"`
Fields []Field `json:"fields"`
}
+37
View File
@@ -0,0 +1,37 @@
package providerschema
import "context"
type ProviderSchemaService struct {
repo providerSchemaRepository
}
func NewProviderSchemaService(repo providerSchemaRepository) *ProviderSchemaService {
return &ProviderSchemaService{repo: repo}
}
func (s *ProviderSchemaService) GetByProviderType(ctx context.Context, providerType string) (*Envelope, error) {
if env, ok, _ := s.repo.GetEnvelope(ctx, providerType); ok && env != nil {
return env, nil
}
schema, err := s.repo.Get(ctx, providerType)
if err != nil {
return nil, err
}
return Emit(schema), nil
}
func (s *ProviderSchemaService) List(ctx context.Context) ([]*Envelope, error) {
builtIn, err := s.repo.List(ctx)
if err != nil {
return nil, err
}
out := make([]*Envelope, 0, len(builtIn))
for _, sc := range builtIn {
out = append(out, Emit(sc))
}
if envs, err := s.repo.ListEnvelopes(ctx); err == nil {
out = append(out, envs...)
}
return out, nil
}
+10
View File
@@ -0,0 +1,10 @@
package providerschema
import "context"
type providerSchemaRepository interface {
Get(ctx context.Context, providerType string) (*Schema, error)
List(ctx context.Context) ([]*Schema, error)
GetEnvelope(ctx context.Context, providerType string) (*Envelope, bool, error)
ListEnvelopes(ctx context.Context) ([]*Envelope, error)
}
+144
View File
@@ -0,0 +1,144 @@
package providerschema
import (
"context"
"errors"
"testing"
"github.com/certimate-go/certimate/internal/domain"
)
type fakeRepo struct {
schemas map[string]*Schema
err error
}
func (f *fakeRepo) Get(_ context.Context, providerType string) (*Schema, error) {
if f.err != nil {
return nil, f.err
}
if s, ok := f.schemas[providerType]; ok {
return s, nil
}
return nil, domain.ErrRecordNotFound
}
func (f *fakeRepo) List(_ context.Context) ([]*Schema, error) {
if f.err != nil {
return nil, f.err
}
out := make([]*Schema, 0, len(f.schemas))
for _, s := range f.schemas {
out = append(out, s)
}
return out, nil
}
func (f *fakeRepo) GetEnvelope(_ context.Context, _ string) (*Envelope, bool, error) {
return nil, false, nil
}
func (f *fakeRepo) ListEnvelopes(_ context.Context) ([]*Envelope, error) {
return nil, nil
}
type envelopeRepo struct {
fakeRepo
envelopes map[string]*Envelope
}
func (e *envelopeRepo) GetEnvelope(_ context.Context, providerType string) (*Envelope, bool, error) {
env, ok := e.envelopes[providerType]
return env, ok, nil
}
func (e *envelopeRepo) ListEnvelopes(_ context.Context) ([]*Envelope, error) {
out := make([]*Envelope, 0, len(e.envelopes))
for _, env := range e.envelopes {
out = append(out, env)
}
return out, nil
}
func TestService_GetByProviderType_PrefersEnvelope(t *testing.T) {
builtIn, _ := New("plugin-demo", CategoryDeploy).Field("a", ValueTypeText).Build()
env := &Envelope{SchemaVersion: "form/v1", Provider: "plugin-demo", Category: CategoryDeploy, Schema: EnvelopeSchema{Columns: []Column{{Name: "envelopeField"}}}}
repo := &envelopeRepo{
fakeRepo: fakeRepo{schemas: map[string]*Schema{"plugin-demo": builtIn}},
envelopes: map[string]*Envelope{"plugin-demo": env},
}
svc := NewProviderSchemaService(repo)
got, err := svc.GetByProviderType(context.Background(), "plugin-demo")
if err != nil {
t.Fatal(err)
}
if len(got.Schema.Columns) != 1 || got.Schema.Columns[0].Name != "envelopeField" {
t.Fatalf("expected envelope to win, got %+v", got.Schema.Columns)
}
}
func TestService_GetByProviderType_Emits(t *testing.T) {
s, _ := New("aliyun-cdn", CategoryDeploy).Field("region", ValueTypeText).Build()
repo := &fakeRepo{schemas: map[string]*Schema{"aliyun-cdn": s}}
svc := NewProviderSchemaService(repo)
env, err := svc.GetByProviderType(context.Background(), "aliyun-cdn")
if err != nil {
t.Fatal(err)
}
if env.Provider != "aliyun-cdn" || env.SchemaVersion != "form/v1" {
t.Fatalf("envelope not emitted: %+v", env)
}
if len(env.Schema.Columns) != 1 || env.Schema.Columns[0].Name != "region" {
t.Fatalf("column not emitted: %+v", env.Schema.Columns)
}
}
func TestService_GetByProviderType_NotFound(t *testing.T) {
svc := NewProviderSchemaService(&fakeRepo{schemas: map[string]*Schema{}})
_, err := svc.GetByProviderType(context.Background(), "missing")
if err == nil {
t.Fatal("expected not-found error")
}
if !errors.Is(err, domain.ErrRecordNotFound) {
var de *domain.Error
if !errors.As(err, &de) || de.Code != domain.ErrRecordNotFound.Code {
t.Fatalf("expected 404 domain error, got %v", err)
}
}
}
func TestService_List_EmitsAll(t *testing.T) {
a, _ := New("aliyun-cdn", CategoryDeploy).Field("region", ValueTypeText).Build()
b, _ := New("tencentcloud-cdn", CategoryDeploy).Field("region", ValueTypeText).Build()
repo := &fakeRepo{schemas: map[string]*Schema{
"aliyun-cdn": a,
"tencentcloud-cdn": b,
}}
svc := NewProviderSchemaService(repo)
envs, err := svc.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(envs) != 2 {
t.Fatalf("envelope count = %d, want 2", len(envs))
}
for _, env := range envs {
if env.SchemaVersion != "form/v1" {
t.Fatalf("envelope schemaVersion = %q", env.SchemaVersion)
}
}
}
func TestService_PropagatesRepoError(t *testing.T) {
boom := errors.New("repo boom")
svc := NewProviderSchemaService(&fakeRepo{err: boom})
if _, err := svc.GetByProviderType(context.Background(), "x"); err != boom {
t.Fatalf("expected propagated error, got %v", err)
}
if _, err := svc.List(context.Background()); err != boom {
t.Fatalf("expected propagated error, got %v", err)
}
}
+61
View File
@@ -52,6 +52,67 @@ func (r *ACMEAccountRepository) GetByCAAndAcctUrl(ctx context.Context, ca string
return r.castRecordToModel(record)
}
func (r *ACMEAccountRepository) GetById(ctx context.Context, id string) (*domain.ACMEAccount, error) {
record, err := app.GetApp().FindRecordById(domain.CollectionNameACMEAccount, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
return r.castRecordToModel(record)
}
func (r *ACMEAccountRepository) List(ctx context.Context, page, perPage int, ca string) ([]*domain.ACMEAccount, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 15
}
if perPage > 100 {
perPage = 100
}
filter := ""
params := dbx.Params{}
var countExprs []dbx.Expression
if ca != "" {
filter = "ca={:ca}"
params["ca"] = ca
countExprs = append(countExprs, dbx.HashExp{"ca": ca})
}
total, err := app.GetApp().CountRecords(domain.CollectionNameACMEAccount, countExprs...)
if err != nil {
return nil, 0, err
}
offset := (page - 1) * perPage
records, err := app.GetApp().FindRecordsByFilter(
domain.CollectionNameACMEAccount,
filter,
"ca,created",
perPage,
offset,
params,
)
if err != nil {
return nil, 0, err
}
accounts := make([]*domain.ACMEAccount, 0, len(records))
for _, record := range records {
account, err := r.castRecordToModel(record)
if err != nil {
return nil, 0, err
}
accounts = append(accounts, account)
}
return accounts, total, nil
}
func (r *ACMEAccountRepository) Save(ctx context.Context, acmeAccount *domain.ACMEAccount) (*domain.ACMEAccount, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameACMEAccount)
if err != nil {
+44
View File
@@ -0,0 +1,44 @@
package repository
import (
"context"
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
)
type ProviderSchemaRepository struct{}
func NewProviderSchemaRepository() *ProviderSchemaRepository {
return &ProviderSchemaRepository{}
}
func (r *ProviderSchemaRepository) Get(_ context.Context, providerType string) (*providerschema.Schema, error) {
if !providerschema.Registries.Has(providerType) {
return nil, domain.NewError(domain.ErrRecordNotFound.Code, fmt.Sprintf("schema for provider %q not found", providerType))
}
return providerschema.Registries.Get(providerType)
}
func (r *ProviderSchemaRepository) List(_ context.Context) ([]*providerschema.Schema, error) {
keys := providerschema.Registries.List()
out := make([]*providerschema.Schema, 0, len(keys))
for _, k := range keys {
s, err := providerschema.Registries.Get(k)
if err != nil {
continue
}
out = append(out, s)
}
return out, nil
}
func (r *ProviderSchemaRepository) GetEnvelope(_ context.Context, providerType string) (*providerschema.Envelope, bool, error) {
env, ok := providerschema.Envelopes.GetEnvelope(providerType)
return env, ok, nil
}
func (r *ProviderSchemaRepository) ListEnvelopes(_ context.Context) ([]*providerschema.Envelope, error) {
return providerschema.Envelopes.ListEnvelopes(), nil
}
@@ -0,0 +1,67 @@
package repository
import (
"context"
"testing"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/providerschema"
)
const testPSRProvider = "__test_provider_schema_repo__"
func registerTestSchema(t *testing.T) {
t.Helper()
if providerschema.Registries.Has(testPSRProvider) {
return
}
s, err := providerschema.New(string(testPSRProvider), providerschema.CategoryDeploy).
Field("region", providerschema.ValueTypeText).
Build()
if err != nil {
t.Fatal(err)
}
providerschema.Registries.MustRegister(testPSRProvider, s)
}
func TestProviderSchemaRepository_GetAndList(t *testing.T) {
registerTestSchema(t)
repo := NewProviderSchemaRepository()
s, err := repo.Get(context.Background(), testPSRProvider)
if err != nil {
t.Fatal(err)
}
if s.Provider != string(testPSRProvider) {
t.Fatalf("provider = %q", s.Provider)
}
all, err := repo.List(context.Background())
if err != nil {
t.Fatal(err)
}
found := false
for _, sc := range all {
if sc.Provider == string(testPSRProvider) {
found = true
}
}
if !found {
t.Fatal("List did not include the registered test schema")
}
}
func TestProviderSchemaRepository_GetUnknownReturns404(t *testing.T) {
repo := NewProviderSchemaRepository()
_, err := repo.Get(context.Background(), "__definitely_not_registered__")
if err == nil {
t.Fatal("expected error for unknown provider")
}
de, ok := err.(*domain.Error)
if !ok {
t.Fatalf("expected *domain.Error, got %T", err)
}
if de.Code != domain.ErrRecordNotFound.Code {
t.Fatalf("expected code %d, got %d", domain.ErrRecordNotFound.Code, de.Code)
}
}
+80
View File
@@ -0,0 +1,80 @@
package handlers
import (
"context"
"strconv"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/domain/dtos"
"github.com/certimate-go/certimate/internal/rest/resp"
)
type acmeAccountService interface {
List(ctx context.Context, req *dtos.ACMEAccountListReq) (*dtos.ACMEAccountListResp, error)
Import(ctx context.Context, req *dtos.ACMEAccountImportReq) (*dtos.ACMEAccountImportResp, error)
Export(ctx context.Context, accountId string) (*dtos.ACMEAccountExportResp, error)
Rotate(ctx context.Context, accountId string) (*dtos.ACMEAccountRotateResp, error)
}
type ACMEAccountsHandler struct {
service acmeAccountService
}
func NewACMEAccountsHandler(router *router.RouterGroup[*core.RequestEvent], service acmeAccountService) {
handler := &ACMEAccountsHandler{service: service}
group := router.Group("/acme-accounts")
group.GET("", handler.list)
group.POST("/import", handler.importAccount)
group.POST("/{accountId}/export", handler.exportAccount)
group.POST("/{accountId}/rotate", handler.rotateAccount)
}
func (handler *ACMEAccountsHandler) list(e *core.RequestEvent) error {
page, _ := strconv.Atoi(e.Request.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(e.Request.URL.Query().Get("perPage"))
ca := e.Request.URL.Query().Get("ca")
res, err := handler.service.List(e.Request.Context(), &dtos.ACMEAccountListReq{
Page: page,
PerPage: perPage,
CA: ca,
})
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
func (handler *ACMEAccountsHandler) importAccount(e *core.RequestEvent) error {
req := &dtos.ACMEAccountImportReq{}
if err := e.BindBody(req); err != nil {
return resp.Err(e, err)
}
res, err := handler.service.Import(e.Request.Context(), req)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
func (handler *ACMEAccountsHandler) exportAccount(e *core.RequestEvent) error {
accountId := e.Request.PathValue("accountId")
res, err := handler.service.Export(e.Request.Context(), accountId)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
func (handler *ACMEAccountsHandler) rotateAccount(e *core.RequestEvent) error {
accountId := e.Request.PathValue("accountId")
res, err := handler.service.Rotate(e.Request.Context(), accountId)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/pluginhost"
"github.com/certimate-go/certimate/internal/rest/resp"
)
type PluginAdminHandler struct{}
func NewPluginAdminHandler(rg *router.RouterGroup[*core.RequestEvent]) {
h := &PluginAdminHandler{}
rg.POST("/plugin/reload", h.reload)
}
func (h *PluginAdminHandler) reload(e *core.RequestEvent) error {
reloader := pluginhost.GlobalReloader()
if reloader == nil {
return resp.Err(e, fmt.Errorf("plugin reloader not initialized"))
}
result := reloader.ReloadNow(e.Request.Context())
return resp.Ok(e, result)
}
+26
View File
@@ -0,0 +1,26 @@
package handlers
import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/pluginhost"
"github.com/certimate-go/certimate/internal/rest/resp"
)
type pluginCatalogService interface {
Entries() []pluginhost.CatalogEntry
}
type PluginCatalogHandler struct {
service pluginCatalogService
}
func NewPluginCatalogHandler(router *router.RouterGroup[*core.RequestEvent], service pluginCatalogService) {
handler := &PluginCatalogHandler{service: service}
router.GET("/plugin-catalog", handler.list)
}
func (handler *PluginCatalogHandler) list(e *core.RequestEvent) error {
return resp.Ok(e, handler.service.Entries())
}
+108
View File
@@ -0,0 +1,108 @@
package handlers
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/pluginhost"
"github.com/certimate-go/certimate/internal/rest/resp"
)
type marketService interface {
ListMarket(ctx context.Context) ([]pluginhost.MarketEntry, error)
Install(ctx context.Context, providerType string) (*pluginhost.InstallJob, error)
Delete(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error)
Update(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error)
JobStatus(providerType string) (pluginhost.JobStatus, bool)
}
type PluginMarketHandler struct {
service marketService
}
func NewPluginMarketHandler(rg *router.RouterGroup[*core.RequestEvent], service marketService) {
h := &PluginMarketHandler{service: service}
rg.GET("/plugin/market", h.listMarket)
rg.POST("/plugin/market/install", h.install)
rg.GET("/plugin/market/install/status/{providerType}", h.installStatus)
rg.DELETE("/plugin/market/{providerType}", h.delete)
rg.POST("/plugin/market/update/{providerType}", h.updatePlugin)
}
func (h *PluginMarketHandler) listMarket(e *core.RequestEvent) error {
entries, err := h.service.ListMarket(e.Request.Context())
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, entries)
}
type installRequest struct {
ProviderType string `json:"providerType"`
}
func (h *PluginMarketHandler) install(e *core.RequestEvent) error {
var req installRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return resp.Err(e, err)
}
if req.ProviderType == "" {
return resp.Err(e, pluginhost.ErrMissingProviderType)
}
job, err := h.service.Install(e.Request.Context(), req.ProviderType)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, job.Status())
}
func (h *PluginMarketHandler) installStatus(e *core.RequestEvent) error {
providerType := e.Request.PathValue("providerType")
if providerType == "" {
return resp.Err(e, pluginhost.ErrMissingProviderType)
}
status, ok := h.service.JobStatus(providerType)
if !ok {
return resp.Err(e, domain.NewError(404, "market: no install job for "+providerType))
}
return resp.Ok(e, status)
}
func (h *PluginMarketHandler) delete(e *core.RequestEvent) error {
providerType := e.Request.PathValue("providerType")
if providerType == "" {
return resp.Err(e, pluginhost.ErrMissingProviderType)
}
result, err := h.service.Delete(e.Request.Context(), providerType)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, result)
}
type updateRequest struct {
ProviderType string `json:"providerType"`
}
func (h *PluginMarketHandler) updatePlugin(e *core.RequestEvent) error {
var req updateRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return resp.Err(e, err)
}
providerType := req.ProviderType
if providerType == "" {
providerType = e.Request.PathValue("providerType")
}
if providerType == "" {
return resp.Err(e, pluginhost.ErrMissingProviderType)
}
result, err := h.service.Update(e.Request.Context(), providerType)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, result)
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"context"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/providerschema"
"github.com/certimate-go/certimate/internal/rest/resp"
)
type providerSchemaService interface {
GetByProviderType(ctx context.Context, providerType string) (*providerschema.Envelope, error)
List(ctx context.Context) ([]*providerschema.Envelope, error)
}
type ProviderSchemaHandler struct {
service providerSchemaService
}
func NewProviderSchemaHandler(router *router.RouterGroup[*core.RequestEvent], service providerSchemaService) {
handler := &ProviderSchemaHandler{
service: service,
}
router.GET("/provider-schemas", handler.list)
router.GET("/provider-schemas/{providerType}", handler.getByProviderType)
}
func (handler *ProviderSchemaHandler) getByProviderType(e *core.RequestEvent) error {
providerType := e.Request.PathValue("providerType")
res, err := handler.service.GetByProviderType(e.Request.Context(), providerType)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
func (handler *ProviderSchemaHandler) list(e *core.RequestEvent) error {
res, err := handler.service.List(e.Request.Context())
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
+16 -4
View File
@@ -5,8 +5,11 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/acmeaccount"
"github.com/certimate-go/certimate/internal/certificate"
"github.com/certimate-go/certimate/internal/notify"
"github.com/certimate-go/certimate/internal/pluginhost"
"github.com/certimate-go/certimate/internal/providerschema"
"github.com/certimate-go/certimate/internal/repository"
"github.com/certimate-go/certimate/internal/rest/handlers"
"github.com/certimate-go/certimate/internal/statistics"
@@ -14,10 +17,11 @@ import (
)
var (
certificateSvc *certificate.CertificateService
workflowSvc *workflow.WorkflowService
statisticsSvc *statistics.StatisticsService
notifySvc *notify.NotifyService
certificateSvc *certificate.CertificateService
workflowSvc *workflow.WorkflowService
statisticsSvc *statistics.StatisticsService
notifySvc *notify.NotifyService
providerSchemaSvc *providerschema.ProviderSchemaService
)
func BindRouter(router *router.Router[*core.RequestEvent]) {
@@ -27,11 +31,14 @@ func BindRouter(router *router.Router[*core.RequestEvent]) {
acmeAccountRepo := repository.NewACMEAccountRepository()
certificateRepo := repository.NewCertificateRepository()
statisticsRepo := repository.NewStatisticsRepository()
providerSchemaRepo := repository.NewProviderSchemaRepository()
certificateSvc = certificate.NewCertificateService(acmeAccountRepo, certificateRepo)
workflowSvc = workflow.NewWorkflowService(workflowRepo, workflowRunRepo)
statisticsSvc = statistics.NewStatisticsService(statisticsRepo)
notifySvc = notify.NewNotifyService(accessRepo)
providerSchemaSvc = providerschema.NewProviderSchemaService(providerSchemaRepo)
acmeAccountSvc := acmeaccount.NewService(acmeAccountRepo)
group := router.Group("/api")
group.Bind(apis.RequireSuperuserAuth())
@@ -39,4 +46,9 @@ func BindRouter(router *router.Router[*core.RequestEvent]) {
handlers.NewWorkflowsHandler(group, workflowSvc)
handlers.NewStatisticsHandler(group, statisticsSvc)
handlers.NewNotificationsHandler(group, notifySvc)
handlers.NewProviderSchemaHandler(group, providerSchemaSvc)
handlers.NewPluginCatalogHandler(group, pluginhost.GlobalCatalog())
handlers.NewPluginAdminHandler(group)
handlers.NewPluginMarketHandler(group, pluginhost.GlobalMarketService())
handlers.NewACMEAccountsHandler(group, acmeAccountSvc)
}
+4
View File
@@ -10,6 +10,10 @@ type accessRepository interface {
GetById(ctx context.Context, id string) (*domain.Access, error)
}
type acmeAccountRepository interface {
GetByCAAndAcctUrl(ctx context.Context, ca string, acctUrl string) (*domain.ACMEAccount, error)
}
type certificateRepository interface {
GetById(ctx context.Context, id string) (*domain.Certificate, error)
GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error)
+78 -2
View File
@@ -10,7 +10,9 @@ import (
"strings"
"time"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/acme/api"
legocert "github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/lego"
"github.com/samber/lo"
"github.com/xhit/go-str2duration/v2"
@@ -55,6 +57,7 @@ type bizApplyNodeExecutor struct {
nodeExecutor
accessRepo accessRepository
acmeAccountRepo acmeAccountRepository
certificateRepo certificateRepository
wfoutputRepo workflowOutputRepository
}
@@ -215,15 +218,31 @@ func (ne *bizApplyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, last
return false, "the last requested certificate has been revoked"
}
now := time.Now()
ariTriggered := false
if !thisNodeCfg.DisableARI {
ariTriggered = ne.evaluateARI(now, lastCertificate, func() (*certacme.ARIInfo, error) {
return ne.getARIInfoForCertificate(execCtx, lastCertificate)
})
}
renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ValidityNotAfter)
daysLeft := int(math.Floor(expirationTime.Hours() / 24))
if expirationTime > renewalInterval {
staticTriggered := expirationTime <= renewalInterval
if !ariTriggered && !staticTriggered {
daysUntilRenewal := int(math.Ceil((expirationTime - renewalInterval).Hours() / 24))
return true, fmt.Sprintf("the last requested certificate expires in %d day(s), a renewal will be performed when the remaining validity is less than %d day(s), and the next renewal will be in %d day(s)", daysLeft, thisNodeCfg.SkipBeforeExpiryDays, daysUntilRenewal)
}
return false, fmt.Sprintf("the last requested certificate expires in %d day(s), the renewal window period has been reached", daysLeft)
switch {
case ariTriggered && staticTriggered:
return false, fmt.Sprintf("the last requested certificate expires in %d day(s), renewal triggered by both ARI suggestedWindow and SkipBeforeExpiryDays threshold", daysLeft)
case ariTriggered:
return false, fmt.Sprintf("the last requested certificate expires in %d day(s), renewal triggered by ARI suggestedWindow", daysLeft)
default:
return false, fmt.Sprintf("the last requested certificate expires in %d day(s), renewal triggered by SkipBeforeExpiryDays threshold", daysLeft)
}
}
return false, ""
@@ -345,6 +364,7 @@ func (ne *bizApplyNodeExecutor) execObtainCertificate(execCtx *NodeExecutionCont
HttpDelayWait: nodeCfg.HttpDelayWait,
PreferredChain: nodeCfg.PreferredChain,
ACMEProfile: nodeCfg.ACMEProfile,
DisableARI: nodeCfg.DisableARI,
ARIReplacesAccountUrl: lo.
If(nodeCfg.DisableARI || lastCertificate == nil, "").
ElseF(func() string {
@@ -437,6 +457,61 @@ func (ne *bizApplyNodeExecutor) execObtainCertificate(execCtx *NodeExecutionCont
return obtainResp, nil
}
func (ne *bizApplyNodeExecutor) getARIInfoForCertificate(execCtx *NodeExecutionContext, certificate *domain.Certificate) (*certacme.ARIInfo, error) {
if certificate == nil {
return nil, fmt.Errorf("the certificate is nil")
}
if certificate.ACMEAccountUrl == "" {
return nil, fmt.Errorf("the certificate has no ACME account URL")
}
acmeAcct, err := ne.acmeAccountRepo.GetByCAAndAcctUrl(execCtx.Context(), certificate.CA, certificate.ACMEAccountUrl)
if err != nil {
return nil, fmt.Errorf("failed to get ACME account by URL: %w", err)
}
acmeClient, err := certacme.NewACMEClientWithAccount(acmeAcct)
if err != nil {
return nil, fmt.Errorf("failed to initialize ACME client: %w", err)
}
return acmeClient.GetARIInfo(execCtx.Context(), certificate.Certificate)
}
func (ne *bizApplyNodeExecutor) evaluateARI(now time.Time, certificate *domain.Certificate, fetch func() (*certacme.ARIInfo, error)) bool {
ariInfo, err := fetch()
if err != nil {
ne.logger.Warn("could not refresh ARI info", slog.String("certificateId", certificate.Id), slog.Any("error", err))
return false
}
if ariInfo == nil || !ariInfo.Supported {
return false
}
return shouldRenewByARI(ariInfo.WindowStart, ariInfo.WindowEnd, now)
}
func shouldRenewByARI(windowStart, windowEnd, now time.Time) bool {
if windowStart.IsZero() || windowEnd.IsZero() {
return false
}
renewalInfo := &legocert.RenewalInfo{
ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{
RenewalInfo: acme.RenewalInfo{
SuggestedWindow: acme.Window{
Start: windowStart,
End: windowEnd,
},
},
},
}
return renewalInfo.ShouldRenewAt(now, 0) != nil ||
(!now.Before(windowStart) && !now.After(windowEnd)) ||
now.After(windowEnd)
}
func (ne *bizApplyNodeExecutor) setOuputsOfResult(execCtx *NodeExecutionContext, execRes *NodeExecutionResult, certificate *domain.Certificate, persistent bool) {
if certificate != nil {
key := "certificate"
@@ -492,6 +567,7 @@ func newBizApplyNodeExecutor() NodeExecutor {
return &bizApplyNodeExecutor{
nodeExecutor: nodeExecutor{logger: slog.Default()},
accessRepo: repository.NewAccessRepository(),
acmeAccountRepo: repository.NewACMEAccountRepository(),
certificateRepo: repository.NewCertificateRepository(),
wfoutputRepo: repository.NewWorkflowOutputRepository(),
}
@@ -0,0 +1,98 @@
package engine
import (
"errors"
"log/slog"
"testing"
"time"
"github.com/certimate-go/certimate/internal/certacme"
"github.com/certimate-go/certimate/internal/domain"
)
func TestShouldRenewByARI(t *testing.T) {
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
windowStart time.Time
windowEnd time.Time
want bool
}{
{
name: "inactive window does not renew",
windowStart: now.Add(time.Hour),
windowEnd: now.Add(2 * time.Hour),
want: false,
},
{
name: "active window renews",
windowStart: now.Add(-time.Hour),
windowEnd: now.Add(time.Hour),
want: true,
},
{
name: "passed window renews",
windowStart: now.Add(-2 * time.Hour),
windowEnd: now.Add(-time.Hour),
want: true,
},
{
name: "zero window does not renew",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldRenewByARI(tt.windowStart, tt.windowEnd, now); got != tt.want {
t.Fatalf("shouldRenewByARI() = %v, want %v", got, tt.want)
}
})
}
}
func TestEvaluateARI(t *testing.T) {
now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
ne := &bizApplyNodeExecutor{nodeExecutor: nodeExecutor{logger: slog.Default()}}
t.Run("supported active window triggers renewal", func(t *testing.T) {
fetch := func() (*certacme.ARIInfo, error) {
return &certacme.ARIInfo{WindowStart: now.Add(-time.Hour), WindowEnd: now.Add(time.Hour), Supported: true}, nil
}
if !ne.evaluateARI(now, &domain.Certificate{}, fetch) {
t.Fatal("expected ARI renewal within the active window")
}
})
t.Run("unsupported CA does not trigger", func(t *testing.T) {
fetch := func() (*certacme.ARIInfo, error) {
return &certacme.ARIInfo{Supported: false}, nil
}
if ne.evaluateARI(now, &domain.Certificate{}, fetch) {
t.Fatal("expected no ARI renewal when CA is unsupported")
}
})
t.Run("fetch error does not trigger and does not block", func(t *testing.T) {
fetch := func() (*certacme.ARIInfo, error) {
return nil, errors.New("503 service unavailable")
}
if ne.evaluateARI(now, &domain.Certificate{}, fetch) {
t.Fatal("expected no ARI renewal on fetch error")
}
})
t.Run("queries every run regardless of past refresh", func(t *testing.T) {
calls := 0
fetch := func() (*certacme.ARIInfo, error) {
calls++
return &certacme.ARIInfo{WindowStart: now.Add(-time.Hour), WindowEnd: now.Add(time.Hour), Supported: true}, nil
}
_ = ne.evaluateARI(now, &domain.Certificate{}, fetch)
_ = ne.evaluateARI(now, &domain.Certificate{}, fetch)
if calls != 2 {
t.Fatalf("expected renewal-info fetched on every run (2 calls), got %d", calls)
}
})
}
+52
View File
@@ -1,8 +1,10 @@
package main
import (
"context"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
_ "time/tzdata"
@@ -15,12 +17,15 @@ import (
"github.com/certimate-go/certimate/cmd"
"github.com/certimate-go/certimate/internal/app"
"github.com/certimate-go/certimate/internal/pluginhost"
"github.com/certimate-go/certimate/internal/rest/routes"
"github.com/certimate-go/certimate/internal/scheduler"
"github.com/certimate-go/certimate/internal/settings"
"github.com/certimate-go/certimate/internal/workflow"
"github.com/certimate-go/certimate/pkg/plugin"
"github.com/certimate-go/certimate/ui"
_ "github.com/certimate-go/certimate/internal/accessschema"
_ "github.com/certimate-go/certimate/migrations"
)
@@ -49,6 +54,7 @@ func main() {
}
settings.Setup()
scanPlugins()
return nil
})
@@ -92,3 +98,49 @@ func main() {
}
}
}
func scanPlugins() {
pluginDir := resolvePluginDir()
if pluginDir == "" {
return
}
cfg := plugin.PluginConfig{
PluginDir: pluginDir,
CoreVersion: app.AppVersion,
}
logger := slog.Default().With(slog.String("component", "pluginhost"))
pluginhost.SweepOrphans(pluginDir, logger)
catalog, errs := pluginhost.ScanAndRegister(context.Background(), cfg, logger)
pluginhost.SetGlobalCatalog(catalog)
for _, err := range errs {
logger.Warn("[CERTIMATE] plugin registration error", slog.Any("error", err))
}
reloader := pluginhost.NewReloader(cfg, catalog, logger)
reloader.InitFromCatalog()
pluginhost.SetGlobalReloader(reloader)
marketSvc := pluginhost.NewMarketService(pluginhost.MarketConfig{
PluginDir: pluginDir,
DownloadMirror: os.Getenv("CERTIMATE_PLUGIN_DOWNLOAD_MIRROR"),
Logger: logger,
})
pluginhost.SetGlobalMarketService(marketSvc)
watcher := pluginhost.NewWatcher(pluginDir, logger)
watcher.Start(context.Background())
reloader.Start(context.Background(), watcher)
}
func resolvePluginDir() string {
if dir := strings.TrimSpace(os.Getenv("CERTIMATE_PLUGIN_DIR")); dir != "" {
return dir
}
cwd, err := os.Getwd()
if err != nil {
return ""
}
return filepath.Join(cwd, "plugins")
}
@@ -1,50 +0,0 @@
//go:build tester
package dogecloud_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/certmgr/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/dogecloud"
)
var (
fp = tester.InitArgs("DOGECLOUD_")
fTestCertPath string
fTestKeyPath string
fAccessKey string
fSecretKey string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKey, "ACCESSKEY")
fp.DefineString(&fSecretKey, "SECRETKEY")
}
/*
Shell command to run this test:
go test -tags=tester -v ./dogecloud_test.go -args \
--DOGECLOUD_TESTCERTPATH="/path/to/your-test-cert.pem" \
--DOGECLOUD_TESTKEYPATH="/path/to/your-test-key.pem" \
--DOGECLOUD_ACCESSKEY="your-access-key" \
--DOGECLOUD_SECRETKEY="your-secret-key"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Upload", func(t *testing.T) {
provider, err := impl.NewCertmgr(&impl.CertmgrConfig{
AccessKey: fAccessKey,
SecretKey: fSecretKey,
})
require.NoError(t, err)
tester.Upload(t, provider, tester.UploadInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,50 +0,0 @@
//go:build tester
package qiniusslcert_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/certmgr/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/qiniu-sslcert"
)
var (
fp = tester.InitArgs("QINIUSSLCERT_")
fTestCertPath string
fTestKeyPath string
fAccessKey string
fSecretKey string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKey, "ACCESSKEY")
fp.DefineString(&fSecretKey, "SECRETKEY")
}
/*
Shell command to run this test:
go test -tags=tester -v ./qiniu_sslcert_test.go -args \
--QINIUSSLCERT_TESTCERTPATH="/path/to/your-test-cert.pem" \
--QINIUSSLCERT_TESTKEYPATH="/path/to/your-test-key.pem" \
--QINIUSSLCERT_ACCESSKEY="your-access-key" \
--QINIUSSLCERT_SECRETKEY="your-secret-key"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Upload", func(t *testing.T) {
provider, err := impl.NewCertmgr(&impl.CertmgrConfig{
AccessKey: fAccessKey,
SecretKey: fSecretKey,
})
require.NoError(t, err)
tester.Upload(t, provider, tester.UploadInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,50 +0,0 @@
//go:build tester
package upyunssl_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/certmgr/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/upyun-ssl"
)
var (
fp = tester.InitArgs("UPYUNSSL_")
fTestCertPath string
fTestKeyPath string
fUsername string
fPassword string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fUsername, "USERNAME")
fp.DefineString(&fPassword, "PASSWORD")
}
/*
Shell command to run this test:
go test -tags=tester -v ./upyun_ssl_test.go -args \
--UPYUNSSL_TESTCERTPATH="/path/to/your-test-cert.pem" \
--UPYUNSSL_TESTKEYPATH="/path/to/your-test-key.pem" \
--UPYUNSSL_USERNAME="your-username" \
--UPYUNSSL_PASSWORD="your-password"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Upload", func(t *testing.T) {
provider, err := impl.NewCertmgr(&impl.CertmgrConfig{
Username: fUsername,
Password: fPassword,
})
require.NoError(t, err)
tester.Upload(t, provider, tester.UploadInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,50 +0,0 @@
//go:build tester
package zenlayercdn_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/certmgr/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/zenlayer-cdn"
)
var (
fp = tester.InitArgs("ZENLAYERCDN_")
fTestCertPath string
fTestKeyPath string
fAccessKeyId string
fAccessKeyPassword string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKeyId, "ACCESSKEYID")
fp.DefineString(&fAccessKeyPassword, "ACCESSKEYPASSWORD")
}
/*
Shell command to run this test:
go test -tags=tester -v ./zenlayer_cdn_test.go -args \
--ZENLAYERCDN_TESTCERTPATH="/path/to/your-test-cert.pem" \
--ZENLAYERCDN_TESTKEYPATH="/path/to/your-test-key.pem" \
--ZENLAYERCDN_ACCESSKEYID="your-access-key-id" \
--ZENLAYERCDN_ACCESSKEYPASSWORD="your-secret-access-key"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Upload", func(t *testing.T) {
provider, err := impl.NewCertmgr(&impl.CertmgrConfig{
AccessKeyId: fAccessKeyId,
AccessKeyPassword: fAccessKeyPassword,
})
require.NoError(t, err)
tester.Upload(t, provider, tester.UploadInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,50 +0,0 @@
//go:build tester
package zenlayerga_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/certmgr/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/zenlayer-ga"
)
var (
fp = tester.InitArgs("ZENLAYERGA_")
fTestCertPath string
fTestKeyPath string
fAccessKeyId string
fAccessKeyPassword string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKeyId, "ACCESSKEYID")
fp.DefineString(&fAccessKeyPassword, "ACCESSKEYPASSWORD")
}
/*
Shell command to run this test:
go test -tags=tester -v ./zenlayer_ga_test.go -args \
--ZENLAYERGA_TESTCERTPATH="/path/to/your-test-cert.pem" \
--ZENLAYERGA_TESTKEYPATH="/path/to/your-test-key.pem" \
--ZENLAYERGA_ACCESSKEYID="your-access-key-id" \
--ZENLAYERGA_ACCESSKEYPASSWORD="your-secret-access-key"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Upload", func(t *testing.T) {
provider, err := impl.NewCertmgr(&impl.CertmgrConfig{
AccessKeyId: fAccessKeyId,
AccessKeyPassword: fAccessKeyPassword,
})
require.NoError(t, err)
tester.Upload(t, provider, tester.UploadInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,95 +0,0 @@
package dogecloud
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/certimate-go/certimate/pkg/core"
dogecloudsdk "github.com/certimate-go/certimate/pkg/sdk3rd/dogecloud"
)
type (
Provider = core.Certmgr
UploadResult = core.CertmgrUploadResult
ReplaceResult = core.CertmgrReplaceResult
)
type CertmgrConfig struct {
// 多吉云 AccessKey。
AccessKey string `json:"accessKey"`
// 多吉云 SecretKey。
SecretKey string `json:"secretKey"`
}
type Certmgr struct {
config *CertmgrConfig
logger *slog.Logger
sdkClient *dogecloudsdk.Client
}
var _ Provider = (*Certmgr)(nil)
func NewCertmgr(config *CertmgrConfig) (*Certmgr, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the certmgr provider is nil")
}
client, err := createSDKClient(config.AccessKey, config.SecretKey)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
return &Certmgr{
config: config,
logger: slog.Default(),
sdkClient: client,
}, nil
}
func (c *Certmgr) SetLogger(logger *slog.Logger) {
if logger == nil {
c.logger = slog.New(slog.DiscardHandler)
} else {
c.logger = logger
}
}
func (c *Certmgr) Upload(ctx context.Context, certPEM, privkeyPEM string) (*UploadResult, error) {
// 生成新证书名(需符合多吉云命名规则)
certName := fmt.Sprintf("certimate-%d", time.Now().UnixMilli())
// 上传新证书
// REF: https://docs.dogecloud.com/cdn/api-cert-upload
uploadSslCertReq := &dogecloudsdk.UploadCdnCertRequest{
Note: certName,
Certificate: certPEM,
PrivateKey: privkeyPEM,
}
uploadSslCertResp, err := c.sdkClient.UploadCdnCertWithContext(ctx, uploadSslCertReq)
c.logger.Debug("sdk request 'cdn.UploadCdnCert'", slog.Any("request", uploadSslCertReq), slog.Any("response", uploadSslCertResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'cdn.UploadCdnCert': %w", err)
}
return &UploadResult{
CertId: fmt.Sprintf("%d", uploadSslCertResp.Data.Id),
CertName: certName,
}, nil
}
func (c *Certmgr) Replace(ctx context.Context, certIdOrName string, certPEM, privkeyPEM string) (*ReplaceResult, error) {
return nil, core.ErrUnsupported
}
func createSDKClient(accessKey, secretKey string) (*dogecloudsdk.Client, error) {
client, err := dogecloudsdk.NewClient(
dogecloudsdk.WithAkSk(accessKey, secretKey),
)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,170 +0,0 @@
package qiniusslcert
import (
"context"
"crypto/x509"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/qiniu/go-sdk/v7/auth"
"github.com/certimate-go/certimate/pkg/core"
qiniusdk "github.com/certimate-go/certimate/pkg/sdk3rd/qiniu"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
type (
Provider = core.Certmgr
UploadResult = core.CertmgrUploadResult
ReplaceResult = core.CertmgrReplaceResult
)
type CertmgrConfig struct {
// 七牛云 AccessKey。
AccessKey string `json:"accessKey"`
// 七牛云 SecretKey。
SecretKey string `json:"secretKey"`
}
type Certmgr struct {
config *CertmgrConfig
logger *slog.Logger
sdkClient *qiniusdk.SslCertManager
}
var _ Provider = (*Certmgr)(nil)
func NewCertmgr(config *CertmgrConfig) (*Certmgr, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the certmgr provider is nil")
}
client, err := createSDKClient(config.AccessKey, config.SecretKey)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
return &Certmgr{
config: config,
logger: slog.Default(),
sdkClient: client,
}, nil
}
func (c *Certmgr) SetLogger(logger *slog.Logger) {
if logger == nil {
c.logger = slog.New(slog.DiscardHandler)
} else {
c.logger = logger
}
}
func (c *Certmgr) Upload(ctx context.Context, certPEM, privkeyPEM string) (*UploadResult, error) {
// 解析证书内容
certX509, err := xcert.ParseCertificateFromPEM(certPEM)
if err != nil {
return nil, err
}
// 生成新证书名(需符合七牛云命名规则)
certName := fmt.Sprintf("certimate-%d", time.Now().UnixMilli())
// 查询已有证书,避免重复上传
getSslCertListMarker := ""
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
getSslCertListResp, err := c.sdkClient.GetSslCertList(ctx, getSslCertListMarker, 200)
c.logger.Debug("sdk request 'sslcert.GetList'", slog.Any("params.marker", getSslCertListMarker), slog.Any("response", getSslCertListResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'sslcert.GetList': %w", err)
}
for _, sslItem := range getSslCertListResp.Certs {
// 对比证书通用名称
if !strings.EqualFold(certX509.Subject.CommonName, sslItem.CommonName) {
continue
}
// 对比证书备用名称
if !slices.Equal(certX509.DNSNames, sslItem.DnsNames) {
continue
}
// 对比证书有效期
if certX509.NotBefore.Unix() != sslItem.NotBefore {
continue
} else if certX509.NotAfter.Unix() != sslItem.NotAfter {
continue
}
// 对比证书公钥算法
switch certX509.PublicKeyAlgorithm {
case x509.RSA:
if !strings.EqualFold(sslItem.Encrypt, "RSA") {
continue
}
case x509.ECDSA:
if !strings.EqualFold(sslItem.Encrypt, "ECDSA") {
continue
}
case x509.Ed25519:
if !strings.EqualFold(sslItem.Encrypt, "Ed25519") {
continue
}
default:
continue
}
// 如果以上信息都一致,则视为已存在相同证书,直接返回
c.logger.Info("ssl certificate already exists")
return &UploadResult{
CertId: sslItem.CertID,
CertName: sslItem.Name,
}, nil
}
if len(getSslCertListResp.Certs) == 0 || getSslCertListResp.Marker == "" {
break
}
getSslCertListMarker = getSslCertListResp.Marker
}
// 上传新证书
// REF: https://developer.qiniu.com/fusion/8593/interface-related-certificate
uploadSslCertResp, err := c.sdkClient.UploadSslCert(ctx, certName, certX509.Subject.CommonName, certPEM, privkeyPEM)
c.logger.Debug("sdk request 'sslcert.Upload'", slog.Any("response", uploadSslCertResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'sslcert.Upload': %w", err)
}
return &UploadResult{
CertId: uploadSslCertResp.CertID,
CertName: certName,
}, nil
}
func (c *Certmgr) Replace(ctx context.Context, certIdOrName string, certPEM, privkeyPEM string) (*ReplaceResult, error) {
return nil, core.ErrUnsupported
}
func createSDKClient(accessKey, secretKey string) (*qiniusdk.SslCertManager, error) {
if secretKey == "" {
return nil, fmt.Errorf("qiniu: invalid access key")
}
if secretKey == "" {
return nil, fmt.Errorf("qiniu: invalid secret key")
}
credential := auth.New(accessKey, secretKey)
client := qiniusdk.NewSslCertManager(credential)
return client, nil
}
@@ -1,88 +0,0 @@
package upyunssl
import (
"context"
"fmt"
"log/slog"
"github.com/certimate-go/certimate/pkg/core"
upyunsdk "github.com/certimate-go/certimate/pkg/sdk3rd/upyun/console"
)
type (
Provider = core.Certmgr
UploadResult = core.CertmgrUploadResult
ReplaceResult = core.CertmgrReplaceResult
)
type CertmgrConfig struct {
// 又拍云账号用户名。
Username string `json:"username"`
// 又拍云账号密码。
Password string `json:"password"`
}
type Certmgr struct {
config *CertmgrConfig
logger *slog.Logger
sdkClient *upyunsdk.Client
}
var _ Provider = (*Certmgr)(nil)
func NewCertmgr(config *CertmgrConfig) (*Certmgr, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the certmgr provider is nil")
}
client, err := createSDKClient(config.Username, config.Password)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
return &Certmgr{
config: config,
logger: slog.Default(),
sdkClient: client,
}, nil
}
func (c *Certmgr) SetLogger(logger *slog.Logger) {
if logger == nil {
c.logger = slog.New(slog.DiscardHandler)
} else {
c.logger = logger
}
}
func (c *Certmgr) Upload(ctx context.Context, certPEM, privkeyPEM string) (*UploadResult, error) {
// 上传证书
uploadHttpsCertificateReq := &upyunsdk.UploadHttpsCertificateRequest{
Certificate: certPEM,
PrivateKey: privkeyPEM,
}
uploadHttpsCertificateResp, err := c.sdkClient.UploadHttpsCertificateWithContext(ctx, uploadHttpsCertificateReq)
c.logger.Debug("sdk request 'console.UploadHttpsCertificate'", slog.Any("response", uploadHttpsCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'console.UploadHttpsCertificate': %w", err)
}
return &UploadResult{
CertId: uploadHttpsCertificateResp.Data.Result.CertificateId,
}, nil
}
func (c *Certmgr) Replace(ctx context.Context, certIdOrName string, certPEM, privkeyPEM string) (*ReplaceResult, error) {
return nil, core.ErrUnsupported
}
func createSDKClient(username, password string) (*upyunsdk.Client, error) {
client, err := upyunsdk.NewClient(
upyunsdk.WithLogins(username, password),
)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,205 +0,0 @@
package zenlayercdn
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/x509"
"encoding/hex"
"fmt"
"log/slog"
"slices"
"strings"
"time"
zcommon "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
"github.com/certimate-go/certimate/pkg/core"
zcdnsdk "github.com/certimate-go/certimate/pkg/sdk3rd/zenlayer/cdn"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
type (
Provider = core.Certmgr
UploadResult = core.CertmgrUploadResult
ReplaceResult = core.CertmgrReplaceResult
)
type CertmgrConfig struct {
// Zenlayer AccessKeyId。
AccessKeyId string `json:"accessKeyId"`
// Zenlayer AccessKeyPassword。
AccessKeyPassword string `json:"accessKeyPassword"`
// Zenlayer 资源组 ID。
ResourceGroupId string `json:"resourceGroupId,omitempty"`
}
type Certmgr struct {
config *CertmgrConfig
logger *slog.Logger
sdkClient *zcdnsdk.Client
}
var _ Provider = (*Certmgr)(nil)
func NewCertmgr(config *CertmgrConfig) (*Certmgr, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the certmgr provider is nil")
}
client, err := createSDKClient(config.AccessKeyId, config.AccessKeyPassword)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
return &Certmgr{
config: config,
logger: slog.Default(),
sdkClient: client,
}, nil
}
func (c *Certmgr) SetLogger(logger *slog.Logger) {
if logger == nil {
c.logger = slog.New(slog.DiscardHandler)
} else {
c.logger = logger
}
}
func (c *Certmgr) Upload(ctx context.Context, certPEM, privkeyPEM string) (*UploadResult, error) {
// 解析证书内容
certX509, err := xcert.ParseCertificateFromPEM(certPEM)
if err != nil {
return nil, err
}
// 查询证书列表,避免重复上传
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/cdn/certificate/describecertificates
describeCertificatesPageNum := 1
describeCertificatesPageSize := 100
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
describeCertificatesReq := zcdnsdk.NewDescribeCertificatesRequest()
describeCertificatesReq.ResourceGroupId = c.config.ResourceGroupId
describeCertificatesReq.PageNum = describeCertificatesPageNum
describeCertificatesReq.PageSize = describeCertificatesPageSize
describeCertificatesResp, err := c.sdkClient.DescribeCertificates(describeCertificatesReq)
c.logger.Debug("sdk request 'cdn.DescribeCertificates'", slog.Any("request", describeCertificatesReq), slog.Any("response", describeCertificatesResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'cdn.DescribeCertificates': %w", err)
}
fingerprintMd5 := md5.Sum(certX509.Raw)
fingerprintMd5Hex := hex.EncodeToString(fingerprintMd5[:])
fingerprintSha1 := sha1.Sum(certX509.Raw)
fingerprintSha1Hex := hex.EncodeToString(fingerprintSha1[:])
for _, certItem := range describeCertificatesResp.Response.DataSet {
// 对比证书通用名称
if !strings.EqualFold(certX509.Subject.CommonName, certItem.Common) {
continue
}
// 对比证书扩展名称
if !slices.Equal(certX509.DNSNames, certItem.Sans) {
continue
}
// 对比证书有效期
newCertNotBefore := certX509.NotBefore
newCertNotAfter := certX509.NotAfter
oldCertNotBefore, _ := time.Parse("2006-01-02T15:04:05Z", certItem.StartTime)
oldCertNotAfter, _ := time.Parse("2006-01-02T15:04:05Z", certItem.EndTime)
if !newCertNotBefore.Equal(oldCertNotBefore) || !newCertNotAfter.Equal(oldCertNotAfter) {
continue
}
// 对比证书指纹
//
// 注意,虽然文档中描述为 MD5 摘要,但示例给出的是 SHA-1 摘要,因此这里都尝试对比一下
if !strings.EqualFold(fingerprintMd5Hex, certItem.Fingerprint) && !strings.EqualFold(fingerprintSha1Hex, certItem.Fingerprint) {
continue
}
// 对比证书公钥算法
switch certX509.PublicKeyAlgorithm {
case x509.RSA:
if !strings.EqualFold(certItem.Algorithm, "RSA") {
continue
}
case x509.ECDSA:
if !strings.EqualFold(certItem.Algorithm, "EC") && !strings.EqualFold(certItem.Algorithm, "ECDSA") {
continue
}
default:
continue
}
// 如果以上信息都一致,则视为已存在相同证书,直接返回
c.logger.Info("ssl certificate already exists")
return &UploadResult{
CertId: certItem.CertificateId,
CertName: certItem.CertificateLabel,
}, nil
}
if len(describeCertificatesResp.Response.DataSet) < describeCertificatesPageSize {
break
}
describeCertificatesPageNum++
}
// 生成新证书名(需符合 Zenlayer 命名规则)
certName := fmt.Sprintf("certimate_%d", time.Now().UnixMilli())
// 创建证书
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/cdn/certificate/createcertificate
createCertificateReq := zcdnsdk.NewCreateCertificateRequest()
createCertificateReq.ResourceGroupId = c.config.ResourceGroupId
createCertificateReq.CertificateLabel = certName
createCertificateReq.CertificateContent = certPEM
createCertificateReq.CertificateKey = privkeyPEM
createCertificateResp, err := c.sdkClient.CreateCertificate(createCertificateReq)
c.logger.Debug("sdk request 'cdn.CreateCertificate'", slog.Any("request", createCertificateReq), slog.Any("response", createCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'cdn.CreateCertificate': %w", err)
}
return &UploadResult{
CertId: createCertificateResp.Response.CertificateId,
CertName: certName,
}, nil
}
func (c *Certmgr) Replace(ctx context.Context, certIdOrName string, certPEM, privkeyPEM string) (*ReplaceResult, error) {
// 更新证书
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/cdn/certificate/modifycertificate
modifyCertificateReq := zcdnsdk.NewModifyCertificateRequest()
modifyCertificateReq.CertificateId = certIdOrName
modifyCertificateReq.CertificateContent = certPEM
modifyCertificateReq.CertificateKey = privkeyPEM
modifyCertificateResp, err := c.sdkClient.ModifyCertificate(modifyCertificateReq)
c.logger.Debug("sdk request 'cdn.ModifyCertificate'", slog.Any("request", modifyCertificateReq), slog.Any("response", modifyCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'cdn.ModifyCertificate': %w", err)
}
return &ReplaceResult{}, nil
}
func createSDKClient(accessKeyId, accessKeyPassword string) (*zcdnsdk.Client, error) {
config := zcommon.NewConfig()
client, err := zcdnsdk.NewClient(config, accessKeyId, accessKeyPassword)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,205 +0,0 @@
package zenlayerga
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/x509"
"encoding/hex"
"fmt"
"log/slog"
"slices"
"strings"
"time"
zcommon "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
"github.com/certimate-go/certimate/pkg/core"
zgasdk "github.com/certimate-go/certimate/pkg/sdk3rd/zenlayer/zga"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
type (
Provider = core.Certmgr
UploadResult = core.CertmgrUploadResult
ReplaceResult = core.CertmgrReplaceResult
)
type CertmgrConfig struct {
// Zenlayer AccessKeyId。
AccessKeyId string `json:"accessKeyId"`
// Zenlayer AccessKeyPassword。
AccessKeyPassword string `json:"accessKeyPassword"`
// Zenlayer 资源组 ID。
ResourceGroupId string `json:"resourceGroupId,omitempty"`
}
type Certmgr struct {
config *CertmgrConfig
logger *slog.Logger
sdkClient *zgasdk.Client
}
var _ Provider = (*Certmgr)(nil)
func NewCertmgr(config *CertmgrConfig) (*Certmgr, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the certmgr provider is nil")
}
client, err := createSDKClient(config.AccessKeyId, config.AccessKeyPassword)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
return &Certmgr{
config: config,
logger: slog.Default(),
sdkClient: client,
}, nil
}
func (c *Certmgr) SetLogger(logger *slog.Logger) {
if logger == nil {
c.logger = slog.New(slog.DiscardHandler)
} else {
c.logger = logger
}
}
func (c *Certmgr) Upload(ctx context.Context, certPEM, privkeyPEM string) (*UploadResult, error) {
// 解析证书内容
certX509, err := xcert.ParseCertificateFromPEM(certPEM)
if err != nil {
return nil, err
}
// 查询证书列表,避免重复上传
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/certificate/describecertificates
describeCertificatesPageNum := 1
describeCertificatesPageSize := 100
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
describeCertificatesReq := zgasdk.NewDescribeCertificatesRequest()
describeCertificatesReq.ResourceGroupId = c.config.ResourceGroupId
describeCertificatesReq.PageNum = describeCertificatesPageNum
describeCertificatesReq.PageSize = describeCertificatesPageSize
describeCertificatesResp, err := c.sdkClient.DescribeCertificates(describeCertificatesReq)
c.logger.Debug("sdk request 'zga.DescribeCertificates'", slog.Any("request", describeCertificatesReq), slog.Any("response", describeCertificatesResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'zga.DescribeCertificates': %w", err)
}
fingerprintMd5 := md5.Sum(certX509.Raw)
fingerprintMd5Hex := hex.EncodeToString(fingerprintMd5[:])
fingerprintSha1 := sha1.Sum(certX509.Raw)
fingerprintSha1Hex := hex.EncodeToString(fingerprintSha1[:])
for _, certItem := range describeCertificatesResp.Response.DataSet {
// 对比证书通用名称
if !strings.EqualFold(certX509.Subject.CommonName, certItem.Common) {
continue
}
// 对比证书扩展名称
if !slices.Equal(certX509.DNSNames, certItem.Sans) {
continue
}
// 对比证书有效期
newCertNotBefore := certX509.NotBefore
newCertNotAfter := certX509.NotAfter
oldCertNotBefore, _ := time.Parse("2006-01-02T15:04:05Z", certItem.StartTime)
oldCertNotAfter, _ := time.Parse("2006-01-02T15:04:05Z", certItem.EndTime)
if !newCertNotBefore.Equal(oldCertNotBefore) || !newCertNotAfter.Equal(oldCertNotAfter) {
continue
}
// 对比证书指纹
//
// 注意,虽然文档中描述为 MD5 摘要,但示例给出的是 SHA-1 摘要,因此这里都尝试对比一下
if !strings.EqualFold(fingerprintMd5Hex, certItem.Fingerprint) && !strings.EqualFold(fingerprintSha1Hex, certItem.Fingerprint) {
continue
}
// 对比证书公钥算法
switch certX509.PublicKeyAlgorithm {
case x509.RSA:
if !strings.EqualFold(certItem.Algorithm, "RSA") {
continue
}
case x509.ECDSA:
if !strings.EqualFold(certItem.Algorithm, "EC") && !strings.EqualFold(certItem.Algorithm, "ECDSA") {
continue
}
default:
continue
}
// 如果以上信息都一致,则视为已存在相同证书,直接返回
c.logger.Info("ssl certificate already exists")
return &UploadResult{
CertId: certItem.CertificateId,
CertName: certItem.CertificateLabel,
}, nil
}
if len(describeCertificatesResp.Response.DataSet) < describeCertificatesPageSize {
break
}
describeCertificatesPageNum++
}
// 生成新证书名(需符合 Zenlayer 命名规则)
certName := fmt.Sprintf("certimate_%d", time.Now().UnixMilli())
// 创建证书
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/certificate/createcertificate
createCertificateReq := zgasdk.NewCreateCertificateRequest()
createCertificateReq.ResourceGroupId = c.config.ResourceGroupId
createCertificateReq.CertificateLabel = certName
createCertificateReq.CertificateContent = certPEM
createCertificateReq.CertificateKey = privkeyPEM
createCertificateResp, err := c.sdkClient.CreateCertificate(createCertificateReq)
c.logger.Debug("sdk request 'zga.CreateCertificate'", slog.Any("request", createCertificateReq), slog.Any("response", createCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'zga.CreateCertificate': %w", err)
}
return &UploadResult{
CertId: createCertificateResp.Response.CertificateId,
CertName: certName,
}, nil
}
func (c *Certmgr) Replace(ctx context.Context, certIdOrName string, certPEM, privkeyPEM string) (*ReplaceResult, error) {
// 更新证书
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/certificate/modifycertificate
modifyCertificateReq := zgasdk.NewModifyCertificateRequest()
modifyCertificateReq.CertificateId = certIdOrName
modifyCertificateReq.CertificateContent = certPEM
modifyCertificateReq.CertificateKey = privkeyPEM
modifyCertificateResp, err := c.sdkClient.ModifyCertificate(modifyCertificateReq)
c.logger.Debug("sdk request 'zga.ModifyCertificate'", slog.Any("request", modifyCertificateReq), slog.Any("response", modifyCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'zga.ModifyCertificate': %w", err)
}
return &ReplaceResult{}, nil
}
func createSDKClient(accessKeyId, accessKeyPassword string) (*zgasdk.Client, error) {
config := zcommon.NewConfig()
client, err := zgasdk.NewClient(config, accessKeyId, accessKeyPassword)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,54 +0,0 @@
//go:build tester
package dogecloudcdn_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/dogecloud-cdn"
)
var (
fp = tester.InitArgs("DOGECLOUDCDN_")
fTestCertPath string
fTestKeyPath string
fAccessKey string
fSecretKey string
fDomain string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKey, "ACCESSKEY")
fp.DefineString(&fSecretKey, "SECRETKEY")
fp.DefineString(&fDomain, "DOMAIN")
}
/*
Shell command to run this test:
go test -tags=tester -v ./dogecloud_cdn_test.go -args \
--DOGECLOUDCDN_TESTCERTPATH="/path/to/your-test-cert.pem" \
--DOGECLOUDCDN_TESTKEYPATH="/path/to/your-test-key.pem" \
--DOGECLOUDCDN_ACCESSKEY="your-access-key" \
--DOGECLOUDCDN_SECRETKEY="your-secret-key" \
--DOGECLOUDCDN_DOMAIN="example.com"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKey: fAccessKey,
SecretKey: fSecretKey,
Domain: fDomain,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,68 +0,0 @@
//go:build tester
package k8ssecret_test
import (
"os"
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/k8s-secret"
)
var (
fp = tester.InitArgs("K8SSECRET_")
fTestCertPath string
fTestKeyPath string
fKubeConfig string
fNamespace string
fSecretName string
fSecretDataKeyForCrt string
fSecretDataKeyForKey string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fKubeConfig, "KUBECONFIG")
fp.DefineString(&fNamespace, "NAMESPACE", "default")
fp.DefineString(&fSecretName, "SECRETNAME")
fp.DefineString(&fSecretDataKeyForCrt, "SECRETDATAKEYFORCRT", "tls.crt")
fp.DefineString(&fSecretDataKeyForKey, "SECRETDATAKEYFORKEY", "tls.key")
}
/*
Shell command to run this test:
go test -tags=tester -v ./k8s_secret_test.go -args \
--K8SSECRET_TESTCERTPATH="/path/to/your-test-cert.pem" \
--K8SSECRET_TESTKEYPATH="/path/to/your-test-key.pem" \
--K8SSECRET_KUBECONFIG="..." \
--K8SSECRET_NAMESPACE="default" \
--K8SSECRET_SECRETNAME="secret" \
--K8SSECRET_SECRETDATAKEYFORCRT="tls.crt" \
--K8SSECRET_SECRETDATAKEYFORKEY="tls.key"
*/
func TestProvider(t *testing.T) {
fp.Parse()
if fKubeConfigStat, err := os.Stat(fKubeConfig); err == nil && !fKubeConfigStat.IsDir() {
fKubeConfigBytes, _ := os.ReadFile(fKubeConfig)
fKubeConfig = string(fKubeConfigBytes)
}
t.Run("Deploy", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
KubeConfig: fKubeConfig,
Namespace: fNamespace,
SecretName: fSecretName,
SecretDataKeyForCrt: fSecretDataKeyForCrt,
SecretDataKeyForKey: fSecretDataKeyForKey,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,55 +0,0 @@
//go:build tester
package qiniucdn_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-cdn"
)
var (
fp = tester.InitArgs("QINIUCDN_")
fTestCertPath string
fTestKeyPath string
fAccessKey string
fSecretKey string
fDomain string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKey, "ACCESSKEY")
fp.DefineString(&fSecretKey, "SECRETKEY")
fp.DefineString(&fDomain, "DOMAIN")
}
/*
Shell command to run this test:
go test -tags=tester -v ./qiniu_cdn_test.go -args \
--QINIUCDN_TESTCERTPATH="/path/to/your-test-cert.pem" \
--QINIUCDN_TESTKEYPATH="/path/to/your-test-key.pem" \
--QINIUCDN_ACCESSKEY="your-access-key" \
--QINIUCDN_SECRETKEY="your-secret-key" \
--QINIUCDN_DOMAIN="example.com"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKey: fAccessKey,
SecretKey: fSecretKey,
DomainMatchPattern: impl.DOMAIN_MATCH_PATTERN_EXACT,
Domain: fDomain,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,58 +0,0 @@
//go:build tester
package qiniukodo_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-kodo"
)
var (
fp = tester.InitArgs("QINIUKODO_")
fTestCertPath string
fTestKeyPath string
fAccessKey string
fSecretKey string
fBucket string
fDomain string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKey, "ACCESSKEY")
fp.DefineString(&fSecretKey, "SECRETKEY")
fp.DefineString(&fBucket, "BUCKET")
fp.DefineString(&fDomain, "DOMAIN")
}
/*
Shell command to run this test:
go test -tags=tester -v ./qiniu_kodo_test.go -args \
--QINIUKODO_TESTCERTPATH="/path/to/your-test-cert.pem" \
--QINIUKODO_TESTKEYPATH="/path/to/your-test-key.pem" \
--QINIUKODO_ACCESSKEY="your-access-key" \
--QINIUKODO_SECRETKEY="your-secret-key" \
--QINIUKODO_BUCKET="your-bucket" \
--QINIUKODO_DOMAIN="example.com"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKey: fAccessKey,
SecretKey: fSecretKey,
Bucket: fBucket,
Domain: fDomain,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,58 +0,0 @@
//go:build tester
package qiniupili_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-pili"
)
var (
fp = tester.InitArgs("QINIUPILI_")
fTestCertPath string
fTestKeyPath string
fAccessKey string
fSecretKey string
fHub string
fDomain string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKey, "ACCESSKEY")
fp.DefineString(&fSecretKey, "SECRETKEY")
fp.DefineString(&fHub, "HUB")
fp.DefineString(&fDomain, "DOMAIN")
}
/*
Shell command to run this test:
go test -tags=tester -v ./qiniu_pili_test.go -args \
--QINIUPILI_TESTCERTPATH="/path/to/your-test-cert.pem" \
--QINIUPILI_TESTKEYPATH="/path/to/your-test-key.pem" \
--QINIUPILI_ACCESSKEY="your-access-key" \
--QINIUPILI_SECRETKEY="your-secret-key" \
--QINIUPILI_HUB="your-hub-name" \
--QINIUPILI_DOMAIN="example.com"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKey: fAccessKey,
SecretKey: fSecretKey,
DomainMatchPattern: impl.DOMAIN_MATCH_PATTERN_EXACT,
Domain: fDomain,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,54 +0,0 @@
//go:build tester
package upyuncdn_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/upyun-cdn"
)
var (
fp = tester.InitArgs("UPYUNCDN_")
fTestCertPath string
fTestKeyPath string
fUsername string
fPassword string
fDomain string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fUsername, "USERNAME")
fp.DefineString(&fPassword, "PASSWORD")
fp.DefineString(&fDomain, "DOMAIN")
}
/*
Shell command to run this test:
go test -tags=tester -v ./upyun_cdn_test.go -args \
--UPYUNCDN_TESTCERTPATH="/path/to/your-test-cert.pem" \
--UPYUNCDN_TESTKEYPATH="/path/to/your-test-key.pem" \
--UPYUNCDN_USERNAME="your-username" \
--UPYUNCDN_PASSWORD="your-password" \
--UPYUNCDN_DOMAIN="example.com"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
Username: fUsername,
Password: fPassword,
Domain: fDomain,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,58 +0,0 @@
//go:build tester
package upyunfile_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/upyun-file"
)
var (
fp = tester.InitArgs("UPYUNFILE_")
fTestCertPath string
fTestKeyPath string
fUsername string
fPassword string
fBucket string
fDomain string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fUsername, "USERNAME")
fp.DefineString(&fPassword, "PASSWORD")
fp.DefineString(&fBucket, "BUCKET")
fp.DefineString(&fDomain, "DOMAIN")
}
/*
Shell command to run this test:
go test -tags=tester -v ./upyun_file_test.go -args \
--UPYUNFILE_TESTCERTPATH="/path/to/your-test-cert.pem" \
--UPYUNFILE_TESTKEYPATH="/path/to/your-test-key.pem" \
--UPYUNFILE_USERNAME="your-username" \
--UPYUNFILE_PASSWORD="your-password" \
--UPYUNFILE_BUCKET="your-bucket" \
--UPYUNFILE_DOMAIN="example.com"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
Username: fUsername,
Password: fPassword,
Bucket: fBucket,
Domain: fDomain,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,71 +0,0 @@
//go:build tester
package zenlayercdn_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/zenlayer-cdn"
)
var (
fp = tester.InitArgs("ZENLAYERCDN_")
fTestCertPath string
fTestKeyPath string
fAccessKeyId string
fAccessKeyPassword string
fDomain string
fCertificateId string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKeyId, "ACCESSKEYID")
fp.DefineString(&fAccessKeyPassword, "ACCESSKEYPASSWORD")
fp.DefineString(&fDomain, "DOMAIN")
fp.DefineString(&fCertificateId, "CERTIFICATEID")
}
/*
Shell command to run this test:
go test -tags=tester -v ./zenlayer_cdn_test.go -args \
--ZENLAYERCDN_TESTCERTPATH="/path/to/your-test-cert.pem" \
--ZENLAYERCDN_TESTKEYPATH="/path/to/your-test-key.pem" \
--ZENLAYERCDN_ACCESSKEYID="your-access-key-id" \
--ZENLAYERCDN_ACCESSKEYPASSWORD="your-access-key-secret" \
--ZENLAYERCDN_DOMAIN="example.com" \
--ZENLAYERCDN_CERTIFICATEID="your-cdn-certificate-id"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy_ToDomain", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKeyId: fAccessKeyId,
AccessKeyPassword: fAccessKeyPassword,
DeployTarget: impl.DEPLOY_TARGET_DOMAIN,
DomainMatchPattern: impl.DOMAIN_MATCH_PATTERN_EXACT,
Domain: fDomain,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
t.Run("Deploy_ToCertificate", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKeyId: fAccessKeyId,
AccessKeyPassword: fAccessKeyPassword,
DeployTarget: impl.DEPLOY_TARGET_CERTIFICATE,
CertificateId: fCertificateId,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,70 +0,0 @@
//go:build tester
package zenlayerga_test
import (
"testing"
"github.com/stretchr/testify/require"
tester "github.com/certimate-go/certimate/pkg/core/deployer/providers-tester"
impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/zenlayer-ga"
)
var (
fp = tester.InitArgs("ZENLAYERGA_")
fTestCertPath string
fTestKeyPath string
fAccessKeyId string
fAccessKeyPassword string
fAcceleratorId string
fCertificateId string
)
func init() {
fp.DefineString(&fTestCertPath, "TESTCERTPATH")
fp.DefineString(&fTestKeyPath, "TESTKEYPATH")
fp.DefineString(&fAccessKeyId, "ACCESSKEYID")
fp.DefineString(&fAccessKeyPassword, "ACCESSKEYPASSWORD")
fp.DefineString(&fAcceleratorId, "ACCELERATORID")
fp.DefineString(&fCertificateId, "CERTIFICATEID")
}
/*
Shell command to run this test:
go test -tags=tester -v ./zenlayer_ga_test.go -args \
--ZENLAYERGA_TESTCERTPATH="/path/to/your-test-cert.pem" \
--ZENLAYERGA_TESTKEYPATH="/path/to/your-test-key.pem" \
--ZENLAYERGA_ACCESSKEYID="your-access-key-id" \
--ZENLAYERGA_ACCESSKEYPASSWORD="your-access-key-password" \
--ZENLAYERGA_ACCELERATORID="your-ga-accelerator-id" \
--ZENLAYERGA_CERTIFICATEID="your-ga-certificate-id"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Deploy_ToAccelerator", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKeyId: fAccessKeyId,
AccessKeyPassword: fAccessKeyPassword,
DeployTarget: impl.DEPLOY_TARGET_ACCELERATOR,
AcceleratorId: fAcceleratorId,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
t.Run("Deploy_ToCertificate", func(t *testing.T) {
provider, err := impl.NewDeployer(&impl.DeployerConfig{
AccessKeyId: fAccessKeyId,
AccessKeyPassword: fAccessKeyPassword,
DeployTarget: impl.DEPLOY_TARGET_CERTIFICATE,
CertificateId: fCertificateId,
})
require.NoError(t, err)
tester.Deploy(t, provider, tester.DeployInput{CertPath: fTestCertPath, KeyPath: fTestKeyPath})
})
}
@@ -1,8 +0,0 @@
package dogecloudcdn
const (
// 匹配模式:精确匹配。
DOMAIN_MATCH_PATTERN_EXACT = "exact"
// 匹配模式:证书 SAN 匹配。
DOMAIN_MATCH_PATTERN_CERTSAN = "certsan"
)
@@ -1,187 +0,0 @@
package dogecloudcdn
import (
"context"
"fmt"
"log/slog"
"strconv"
"github.com/samber/lo"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/dogecloud"
dogecloudsdk "github.com/certimate-go/certimate/pkg/sdk3rd/dogecloud"
xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname"
xloop "github.com/certimate-go/certimate/pkg/utils/loop"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// 多吉云 AccessKey。
AccessKey string `json:"accessKey"`
// 多吉云 SecretKey。
SecretKey string `json:"secretKey"`
// 域名匹配模式。
// 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。
DomainMatchPattern string `json:"domainMatchPattern,omitempty"`
// 加速域名(不支持泛域名)。
Domain string `json:"domain"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *dogecloudsdk.Client
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client, err := createSDKClient(config.AccessKey, config.SecretKey)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
AccessKey: config.AccessKey,
SecretKey: config.SecretKey,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 获取待部署的域名列表
var domains []string
switch d.config.DomainMatchPattern {
case "", DOMAIN_MATCH_PATTERN_EXACT:
{
if d.config.Domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
domains = []string{d.config.Domain}
}
case DOMAIN_MATCH_PATTERN_CERTSAN:
{
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return nil, err
}
domains = lo.Filter(domainCandidates, func(domain string, _ int) bool {
return xcerthostname.IsMatchByCertificatePEM(certPEM, domain)
})
if len(domains) == 0 {
return nil, fmt.Errorf("could not find any domains matched by certificate")
}
}
default:
return nil, fmt.Errorf("unsupported domain match pattern: '%s'", d.config.DomainMatchPattern)
}
// 批量更新域名证书
if len(domains) == 0 {
d.logger.Info("no cdn domains to deploy")
} else {
d.logger.Info("found cdn domains to deploy", slog.Any("domains", domains))
if err := xloop.ForRangeAllWithContext(ctx, domains, func(ctx context.Context, domain string, _ int) error {
certId, _ := strconv.ParseInt(upres.CertId, 10, 64)
return d.updateDomainCertificate(ctx, domain, certId)
}); err != nil {
return nil, err
}
}
return &DeployResult{}, nil
}
func (d *Deployer) getAllDomains(ctx context.Context) ([]string, error) {
domains := make([]string, 0)
// 获取域名列表
// REF: https://docs.dogecloud.com/cdn/api-domain-list
listCdnDomainResp, err := d.sdkClient.ListCdnDomainWithContext(ctx)
d.logger.Debug("sdk request 'cdn.ListCdnDomain'", slog.Any("response", listCdnDomainResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'cdn.ListCdnDomain': %w", err)
}
if listCdnDomainResp.Data != nil {
ignoredStatuses := []string{"offline"}
for _, domainItem := range listCdnDomainResp.Data.Domains {
if lo.Contains(ignoredStatuses, domainItem.Status) {
continue
}
domains = append(domains, domainItem.Name)
}
}
return domains, nil
}
func (d *Deployer) updateDomainCertificate(ctx context.Context, domain string, cloudCertId int64) error {
// 绑定证书
// REF: https://docs.dogecloud.com/cdn/api-cert-bind
bindCdnCertReq := &dogecloudsdk.BindCdnCertRequest{
CertId: cloudCertId,
Domain: domain,
}
bindCdnCertResp, err := d.sdkClient.BindCdnCertWithContext(ctx, bindCdnCertReq)
d.logger.Debug("sdk request 'cdn.BindCdnCert'", slog.Any("request", bindCdnCertReq), slog.Any("response", bindCdnCertResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'cdn.BindCdnCert': %w", err)
}
return nil
}
func createSDKClient(accessKey, secretKey string) (*dogecloudsdk.Client, error) {
client, err := dogecloudsdk.NewClient(
dogecloudsdk.WithAkSk(accessKey, secretKey),
)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,252 +0,0 @@
package k8ssecret
import (
"context"
"fmt"
"log/slog"
"strings"
k8score "k8s.io/api/core/v1"
k8serrs "k8s.io/apimachinery/pkg/api/errors"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"github.com/certimate-go/certimate/pkg/core"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// kubeconfig 文件内容。
KubeConfig string `json:"kubeConfig,omitempty"`
// Kubernetes 命名空间。
Namespace string `json:"namespace,omitempty"`
// Kubernetes Secret 名称。
SecretName string `json:"secretName"`
// Kubernetes Secret 类型。
SecretType string `json:"secretType"`
// Kubernetes Secret 中用于存放私钥的键。
SecretDataKeyForKey string `json:"secretDataKeyForKey,omitempty"`
// Kubernetes Secret 中用于存放证书的键。
SecretDataKeyForCrt string `json:"secretDataKeyForCrt,omitempty"`
// Kubernetes Secret 中用于存放证书(仅含服务器证书)的键。
// 选填。
SecretDataKeyForCrtOnlyServer string `json:"secretDataKeyForCrtOnlyServer,omitempty"`
// Kubernetes Secret 中用于存放证书(仅含中间证书)的键。
// 选填。
SecretDataKeyForCrtOnlyIntermedia string `json:"secretDataKeyForCrtOnlyIntermedia,omitempty"`
// Kubernetes Secret 注解。
SecretAnnotations map[string]string `json:"secretAnnotations,omitempty"`
// Kubernetes Secret 标签。
SecretLabels map[string]string `json:"secretLabels,omitempty"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
return &Deployer{
logger: slog.Default(),
config: config,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
if d.config.Namespace == "" {
return nil, fmt.Errorf("config `namespace` is required")
}
if d.config.SecretName == "" {
return nil, fmt.Errorf("config `secretName` is required")
}
if d.config.SecretType == "" {
return nil, fmt.Errorf("config `secretType` is required")
}
// 解析证书内容
certX509, err := xcert.ParseCertificateFromPEM(certPEM)
if err != nil {
return nil, err
}
// 提取服务器证书和中间证书
serverCertPEM, issuerCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM)
if err != nil {
return nil, fmt.Errorf("failed to extract certs: %w", err)
}
// 连接到 Kubernetes
client, err := createK8sClient(d.config.KubeConfig)
if err != nil {
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
}
// 获取 Secret 实例
secretPayload := &k8score.Secret{}
secretIsNew := false
secretGetResp := client.Get().
Namespace(d.config.Namespace).
Resource("secrets").
Name(d.config.SecretName).
VersionedParams(&meta.GetOptions{}, meta.ParameterCodec).
Do(ctx)
if err := secretGetResp.Error(); err != nil {
if !k8serrs.IsNotFound(err) {
return nil, fmt.Errorf("failed to get kubernetes secret: %w", err)
}
secretPayload = &k8score.Secret{
Type: k8score.SecretType(d.config.SecretType),
TypeMeta: meta.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: meta.ObjectMeta{
Name: d.config.SecretName,
},
}
secretIsNew = true
} else if err := secretGetResp.Into(secretPayload); err != nil {
return nil, fmt.Errorf("failed to parse kubernetes secret: %w", err)
}
d.logger.Debug("kubernetes operate 'Secrets.Get'", slog.String("namespace", d.config.Namespace), slog.Any("secret", d.config.SecretName))
// 生成 Secret 注解和标签
secretAnnotations := map[string]string{
"certimate/common-name": certX509.Subject.CommonName,
"certimate/subject-sn": certX509.Subject.SerialNumber,
"certimate/subject-alt-names": strings.Join(certX509.DNSNames, ","),
"certimate/issuer-sn": certX509.Issuer.SerialNumber,
"certimate/issuer-org": strings.Join(certX509.Issuer.Organization, ","),
}
secretLabels := map[string]string{}
if d.config.SecretAnnotations != nil {
xmaps.CopyTo(d.config.SecretAnnotations, secretAnnotations)
}
if d.config.SecretLabels != nil {
xmaps.CopyTo(d.config.SecretLabels, secretLabels)
}
// 赋值 Secret 实例
secretPayload.Type = k8score.SecretType(d.config.SecretType)
if secretPayload.ObjectMeta.Annotations == nil {
secretPayload.ObjectMeta.Annotations = secretAnnotations
} else {
xmaps.CopyTo(secretAnnotations, secretPayload.ObjectMeta.Annotations)
}
if secretPayload.ObjectMeta.Labels == nil {
secretPayload.ObjectMeta.Labels = secretLabels
} else {
xmaps.CopyTo(secretLabels, secretPayload.ObjectMeta.Labels)
}
if secretPayload.Data == nil {
secretPayload.Data = make(map[string][]byte)
}
if d.config.SecretDataKeyForKey != "" {
secretPayload.Data[d.config.SecretDataKeyForKey] = []byte(privkeyPEM)
}
if d.config.SecretDataKeyForCrt != "" {
secretPayload.Data[d.config.SecretDataKeyForCrt] = []byte(certPEM)
}
if d.config.SecretDataKeyForCrtOnlyServer != "" {
secretPayload.Data[d.config.SecretDataKeyForCrtOnlyServer] = []byte(serverCertPEM)
}
if d.config.SecretDataKeyForCrtOnlyIntermedia != "" {
secretPayload.Data[d.config.SecretDataKeyForCrtOnlyIntermedia] = []byte(issuerCertPEM)
}
// 创建或更新 Secret 实例
if secretIsNew {
secretPostResp := client.Post().
Namespace(d.config.Namespace).
Resource("secrets").
Name(d.config.SecretName).
VersionedParams(&meta.GetOptions{}, meta.ParameterCodec).
Body(secretPayload).
Do(ctx)
d.logger.Debug("kubernetes operate 'Secrets.Post'", slog.String("namespace", d.config.Namespace), slog.Any("secret", d.config.SecretName))
if err := secretPostResp.Error(); err != nil {
return nil, fmt.Errorf("failed to create kubernetes secret: %w", err)
}
} else {
secretPutResp := client.Put().
Namespace(d.config.Namespace).
Resource("secrets").
Name(d.config.SecretName).
VersionedParams(&meta.GetOptions{}, meta.ParameterCodec).
Body(secretPayload).
Do(ctx)
d.logger.Debug("kubernetes operate 'Secrets.Put'", slog.String("namespace", d.config.Namespace), slog.Any("secret", d.config.SecretName))
if err := secretPutResp.Error(); err != nil {
return nil, fmt.Errorf("failed to update kubernetes secret: %w", err)
}
}
return &DeployResult{}, nil
}
func createK8sClient(kubeConfig string) (*rest.RESTClient, error) {
var config *rest.Config
var err error
if kubeConfig == "" {
config, err = rest.InClusterConfig()
} else {
kubeConfig, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig))
if err != nil {
return nil, err
}
config, err = kubeConfig.ClientConfig()
}
if err != nil {
return nil, err
}
// InClusterConfig does not set GroupVersion, NegotiatedSerializer or APIPath,
// but all of them are required by rest.RESTClientFor.
// Note that an empty APIPath makes every request target "/v1/namespaces/..."
// instead of "/api/v1/namespaces/...", which the API server rejects with
// 404 "the server could not find the requested resource".
if config.GroupVersion == nil {
config.GroupVersion = &schema.GroupVersion{Group: "", Version: "v1"}
}
if config.NegotiatedSerializer == nil {
config.NegotiatedSerializer = scheme.Codecs.WithoutConversion()
}
if config.APIPath == "" {
if config.GroupVersion.Group == "" {
config.APIPath = "/api"
} else {
config.APIPath = "/apis"
}
}
client, err := rest.RESTClientFor(config)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,41 +0,0 @@
package k8ssecret
import (
"strings"
"testing"
)
const testKubeConfig = `apiVersion: v1
kind: Config
clusters:
- name: test
cluster:
server: https://127.0.0.1:6443
insecure-skip-tls-verify: true
contexts:
- name: test
context:
cluster: test
user: test
current-context: test
users:
- name: test
user:
token: test-token
`
// Secrets live in the core API group, which is served under "/api".
// If APIPath is left empty, rest.RESTClientFor builds requests against
// "/v1/namespaces/..." and the API server answers 404
// ("the server could not find the requested resource").
func TestCreateK8sClientSetsCoreAPIPath(t *testing.T) {
client, err := createK8sClient(testKubeConfig)
if err != nil {
t.Fatalf("createK8sClient() returned an unexpected error: %v", err)
}
const want = "/api/v1"
if got := client.Get().URL().Path; !strings.HasPrefix(got, want) {
t.Errorf("request path = %q, want it to start with %q", got, want)
}
}
@@ -1,10 +0,0 @@
package qiniucdn
const (
// 匹配模式:精确匹配。
DOMAIN_MATCH_PATTERN_EXACT = "exact"
// 匹配模式:通配符匹配。
DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard"
// 匹配模式:证书 SAN 匹配。
DOMAIN_MATCH_PATTERN_CERTSAN = "certsan"
)
@@ -1,231 +0,0 @@
package qiniucdn
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/qiniu/go-sdk/v7/auth"
"github.com/samber/lo"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/qiniu-sslcert"
qiniusdk "github.com/certimate-go/certimate/pkg/sdk3rd/qiniu"
xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname"
xloop "github.com/certimate-go/certimate/pkg/utils/loop"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// 七牛云 AccessKey。
AccessKey string `json:"accessKey"`
// 七牛云 SecretKey。
SecretKey string `json:"secretKey"`
// 域名匹配模式。
// 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。
DomainMatchPattern string `json:"domainMatchPattern,omitempty"`
// 加速域名(支持泛域名)。
Domain string `json:"domain"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *qiniusdk.CdnManager
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client := qiniusdk.NewCdnManager(auth.New(config.AccessKey, config.SecretKey))
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
AccessKey: config.AccessKey,
SecretKey: config.SecretKey,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 获取待部署的域名列表
var domains []string
switch d.config.DomainMatchPattern {
case "", DOMAIN_MATCH_PATTERN_EXACT:
{
domain := normalizeDomain(d.config.Domain)
if domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
domains = []string{domain}
}
case DOMAIN_MATCH_PATTERN_WILDCARD:
{
if d.config.Domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
if strings.HasPrefix(d.config.Domain, "*.") {
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return nil, err
}
domains = lo.Filter(domainCandidates, func(domain string, _ int) bool {
return xcerthostname.IsMatch(d.config.Domain, domain)
})
if len(domains) == 0 {
return nil, fmt.Errorf("could not find any domains matched by wildcard")
}
} else {
domains = []string{d.config.Domain}
}
}
case DOMAIN_MATCH_PATTERN_CERTSAN:
{
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return nil, err
}
domains = lo.Filter(domainCandidates, func(domain string, _ int) bool {
return xcerthostname.IsMatchByCertificatePEM(certPEM, domain)
})
if len(domains) == 0 {
return nil, fmt.Errorf("could not find any domains matched by certificate")
}
}
default:
return nil, fmt.Errorf("unsupported domain match pattern: '%s'", d.config.DomainMatchPattern)
}
// 批量更新域名证书
if len(domains) == 0 {
d.logger.Info("no cdn domains to deploy")
} else {
d.logger.Info("found cdn domains to deploy", slog.Any("domains", domains))
if err := xloop.ForRangeAllWithContext(ctx, domains, func(ctx context.Context, domain string, _ int) error {
return d.updateDomainCertificate(ctx, domain, upres.CertId)
}); err != nil {
return nil, err
}
}
return &DeployResult{}, nil
}
func (d *Deployer) getAllDomains(ctx context.Context) ([]string, error) {
domains := make([]string, 0)
// 查询域名列表
// REF: https://developer.qiniu.com/fusion/4246/the-domain-name
getDomainListMarker := ""
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
getDomainListResp, err := d.sdkClient.GetDomainList(ctx, getDomainListMarker, 100)
d.logger.Debug("sdk request 'cdn.GetDomainList'", slog.String("params.marker", getDomainListMarker), slog.Any("response", getDomainListResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'cdn.GetDomainList': %w", err)
}
ignoredStatuses := []string{"frozen", "offlined"}
for _, domainItem := range getDomainListResp.Domains {
if lo.Contains(ignoredStatuses, domainItem.OperatingState) {
continue
}
domains = append(domains, domainItem.Name)
}
if len(getDomainListResp.Domains) == 0 || getDomainListResp.Marker == "" {
break
}
getDomainListMarker = getDomainListResp.Marker
}
return domains, nil
}
func (d *Deployer) updateDomainCertificate(ctx context.Context, domain string, cloudCertId string) error {
// 获取域名信息
// REF: https://developer.qiniu.com/fusion/4246/the-domain-name
getDomainInfoResp, err := d.sdkClient.GetDomainInfo(ctx, domain)
d.logger.Debug("sdk request 'cdn.GetDomainInfo'", slog.String("params.domain", domain), slog.Any("response", getDomainInfoResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'cdn.GetDomainInfo': %w", err)
}
// 判断域名是否已启用 HTTPS
// 如果已启用,修改域名证书;否则,启用 HTTPS
// REF: https://developer.qiniu.com/fusion/4246/the-domain-name
if getDomainInfoResp.Https == nil || getDomainInfoResp.Https.CertID == "" {
enableDomainHttpsResp, err := d.sdkClient.EnableDomainHttps(ctx, domain, cloudCertId, true, true)
d.logger.Debug("sdk request 'cdn.EnableDomainHttps'", slog.String("params.domain", domain), slog.String("params.certId", cloudCertId), slog.Any("response", enableDomainHttpsResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'cdn.EnableDomainHttps': %w", err)
}
} else if getDomainInfoResp.Https.CertID != cloudCertId {
modifyDomainHttpsConfResp, err := d.sdkClient.ModifyDomainHttpsConf(ctx, domain, cloudCertId, getDomainInfoResp.Https.ForceHttps, getDomainInfoResp.Https.Http2Enable)
d.logger.Debug("sdk request 'cdn.ModifyDomainHttpsConf'", slog.String("params.domain", domain), slog.String("params.certId", cloudCertId), slog.Any("response", modifyDomainHttpsConfResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'cdn.ModifyDomainHttpsConf': %w", err)
}
}
return nil
}
func normalizeDomain(domain string) string {
// "*.example.com" → ".example.com",适配七牛云 CDN 的泛域名参数要求
if strings.HasPrefix(domain, "*.") {
return strings.TrimPrefix(domain, "*")
}
return domain
}
@@ -1,94 +0,0 @@
package qiniukodo
import (
"context"
"fmt"
"log/slog"
"github.com/qiniu/go-sdk/v7/auth"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/qiniu-sslcert"
qiniusdk "github.com/certimate-go/certimate/pkg/sdk3rd/qiniu"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// 七牛云 AccessKey。
AccessKey string `json:"accessKey"`
// 七牛云 SecretKey。
SecretKey string `json:"secretKey"`
// 存储桶名。暂时无用。
Bucket string `json:"bucket"`
// 自定义域名(不支持泛域名)。
Domain string `json:"domain"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *qiniusdk.KodoManager
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client := qiniusdk.NewKodoManager(auth.New(config.AccessKey, config.SecretKey))
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
AccessKey: config.AccessKey,
SecretKey: config.SecretKey,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
if d.config.Domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 绑定空间域名证书
bindBucketCertResp, err := d.sdkClient.BindBucketCert(ctx, d.config.Domain, upres.CertId)
d.logger.Debug("sdk request 'kodo.BindCert'", slog.String("params.domain", d.config.Domain), slog.String("params.certId", upres.CertId), slog.Any("response", bindBucketCertResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'kodo.BindCert': %w", err)
}
return &DeployResult{}, nil
}
@@ -1,8 +0,0 @@
package qiniupili
const (
// 匹配模式:精确匹配。
DOMAIN_MATCH_PATTERN_EXACT = "exact"
// 匹配模式:证书 SAN 匹配。
DOMAIN_MATCH_PATTERN_CERTSAN = "certsan"
)
@@ -1,174 +0,0 @@
package qiniupili
import (
"context"
"fmt"
"log/slog"
"github.com/qiniu/go-sdk/v7/pili"
"github.com/samber/lo"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/qiniu-sslcert"
xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname"
xloop "github.com/certimate-go/certimate/pkg/utils/loop"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// 七牛云 AccessKey。
AccessKey string `json:"accessKey"`
// 七牛云 SecretKey。
SecretKey string `json:"secretKey"`
// 直播空间名。
Hub string `json:"hub"`
// 域名匹配模式。
// 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。
DomainMatchPattern string `json:"domainMatchPattern,omitempty"`
// 直播流域名(不支持泛域名)。
Domain string `json:"domain"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *pili.Manager
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
manager := pili.NewManager(pili.ManagerConfig{AccessKey: config.AccessKey, SecretKey: config.SecretKey})
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
AccessKey: config.AccessKey,
SecretKey: config.SecretKey,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: manager,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
if d.config.Domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 获取待部署的域名列表
var domains []string
switch d.config.DomainMatchPattern {
case "", DOMAIN_MATCH_PATTERN_EXACT:
{
if d.config.Domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
domains = []string{d.config.Domain}
}
case DOMAIN_MATCH_PATTERN_CERTSAN:
{
domainCandidates, err := d.getAllDomainsByHub(ctx, d.config.Hub)
if err != nil {
return nil, err
}
domains = lo.Filter(domainCandidates, func(domain string, _ int) bool {
return xcerthostname.IsMatchByCertificatePEM(certPEM, domain)
})
if len(domains) == 0 {
return nil, fmt.Errorf("could not find any domains matched by certificate")
}
}
default:
return nil, fmt.Errorf("unsupported domain match pattern: '%s'", d.config.DomainMatchPattern)
}
// 批量更新域名证书
if len(domains) == 0 {
d.logger.Info("no pili domains to deploy")
} else {
d.logger.Info("found pili domains to deploy", slog.Any("domains", domains))
if err := xloop.ForRangeAllWithContext(ctx, domains, func(ctx context.Context, domain string, _ int) error {
return d.updateDomainCertificate(ctx, d.config.Hub, domain, upres.CertName)
}); err != nil {
return nil, err
}
}
return &DeployResult{}, nil
}
func (d *Deployer) getAllDomainsByHub(ctx context.Context, hub string) ([]string, error) {
domains := make([]string, 0)
// 查询域名列表
// REF: https://developer.qiniu.com/pili/9910/pili-service-sdk#6
getDomainListReq := pili.GetDomainsListRequest{
Hub: hub,
}
getDomainListResp, err := d.sdkClient.GetDomainsList(ctx, getDomainListReq)
d.logger.Debug("sdk request 'pili.GetDomainsList'", slog.Any("request", getDomainListReq), slog.Any("response", getDomainListResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'pili.GetDomainsList': %w", err)
}
for _, domainItem := range getDomainListResp.Domains {
domains = append(domains, domainItem.Domain)
}
return domains, nil
}
func (d *Deployer) updateDomainCertificate(ctx context.Context, hub string, domain string, cloudCertName string) error {
// 修改域名证书配置
// REF: https://developer.qiniu.com/pili/9910/pili-service-sdk#6
setDomainCertReq := pili.SetDomainCertRequest{
Hub: hub,
Domain: domain,
CertName: cloudCertName,
}
err := d.sdkClient.SetDomainCert(ctx, setDomainCertReq)
d.logger.Debug("sdk request 'pili.SetDomainCert'", slog.Any("request", setDomainCertReq))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'pili.SetDomainCert': %w", err)
}
return nil
}
@@ -1,10 +0,0 @@
package upyuncdn
const (
// 匹配模式:精确匹配。
DOMAIN_MATCH_PATTERN_EXACT = "exact"
// 匹配模式:通配符匹配。
DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard"
// 匹配模式:证书 SAN 匹配。
DOMAIN_MATCH_PATTERN_CERTSAN = "certsan"
)
@@ -1,262 +0,0 @@
package upyuncdn
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/samber/lo"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/upyun-ssl"
upyunsdk "github.com/certimate-go/certimate/pkg/sdk3rd/upyun/console"
xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname"
xloop "github.com/certimate-go/certimate/pkg/utils/loop"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// 又拍云账号用户名。
Username string `json:"username"`
// 又拍云账号密码。
Password string `json:"password"`
// 域名匹配模式。
// 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。
DomainMatchPattern string `json:"domainMatchPattern,omitempty"`
// 加速域名(支持泛域名)。
Domain string `json:"domain"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *upyunsdk.Client
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client, err := createSDKClient(config.Username, config.Password)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
Username: config.Username,
Password: config.Password,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 获取待部署的域名列表
var domains []string
switch d.config.DomainMatchPattern {
case "", DOMAIN_MATCH_PATTERN_EXACT:
{
if d.config.Domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
domains = []string{d.config.Domain}
}
case DOMAIN_MATCH_PATTERN_WILDCARD:
{
if d.config.Domain == "" {
return nil, fmt.Errorf("config `domain` is required")
}
if strings.HasPrefix(d.config.Domain, "*.") {
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return nil, err
}
domains = lo.Filter(domainCandidates, func(domain string, _ int) bool {
return xcerthostname.IsMatch(d.config.Domain, domain)
})
if len(domains) == 0 {
return nil, fmt.Errorf("could not find any domains matched by wildcard")
}
} else {
domains = []string{d.config.Domain}
}
}
case DOMAIN_MATCH_PATTERN_CERTSAN:
{
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return nil, err
}
domains = lo.Filter(domainCandidates, func(domain string, _ int) bool {
return xcerthostname.IsMatchByCertificatePEM(certPEM, domain)
})
if len(domains) == 0 {
return nil, fmt.Errorf("could not find any domains matched by certificate")
}
}
default:
return nil, fmt.Errorf("unsupported domain match pattern: '%s'", d.config.DomainMatchPattern)
}
// 批量更新域名证书
if len(domains) == 0 {
d.logger.Info("no cdn domains to deploy")
} else {
d.logger.Info("found cdn domains to deploy", slog.Any("domains", domains))
if err := xloop.ForRangeAllWithContext(ctx, domains, func(ctx context.Context, domain string, _ int) error {
return d.updateDomainCertificate(ctx, domain, upres.CertId)
}); err != nil {
return nil, err
}
}
return &DeployResult{}, nil
}
func (d *Deployer) getAllDomains(ctx context.Context) ([]string, error) {
domains := make([]string, 0)
// 获取服务列表
getBucketsPage := 1
getBucketsPerPage := 10
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
getBucketsReq := &upyunsdk.GetBucketsRequest{
Type: "ucdn",
Tag: "all",
Status: "all",
IsSecurityCDN: false,
WithDomains: true,
Page: int32(getBucketsPage),
PerPage: int32(getBucketsPerPage),
}
getBucketsResp, err := d.sdkClient.GetBucketsWithContext(ctx, getBucketsReq)
d.logger.Debug("sdk request 'console.GetBuckets'", slog.Any("request", getBucketsReq), slog.Any("response", getBucketsResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'console.GetBuckets': %w", err)
}
if getBucketsResp.Data == nil {
break
}
for _, bucketItem := range getBucketsResp.Data.Buckets {
if !bucketItem.Visible {
continue
}
for _, domainItem := range bucketItem.Domains {
if strings.EqualFold(domainItem.Status, "NORMAL") && !strings.HasSuffix(domainItem.Domain, ".test.upcdn.net") {
domains = append(domains, domainItem.Domain)
}
}
}
if len(getBucketsResp.Data.Buckets) < getBucketsPerPage {
break
}
getBucketsPage++
}
return domains, nil
}
func (d *Deployer) updateDomainCertificate(ctx context.Context, domain string, cloudCertId string) error {
// 获取域名证书配置
getHttpsServiceManagerResp, err := d.sdkClient.GetHttpsServiceManagerWithContext(ctx, domain)
d.logger.Debug("sdk request 'console.GetHttpsServiceManager'", slog.String("params.domain", domain), slog.Any("response", getHttpsServiceManagerResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'console.GetHttpsServiceManager': %w", err)
}
// 判断域名是否已启用 HTTPS
// 如果已启用,迁移域名证书;否则,设置新证书
_, lastCertIndex, _ := lo.FindIndexOf(getHttpsServiceManagerResp.Data.Domains, func(item upyunsdk.HttpsServiceManagerDomain) bool {
return item.Https
})
if lastCertIndex == -1 {
updateHttpsCertificateManagerReq := &upyunsdk.UpdateHttpsCertificateManagerRequest{
CertificateId: cloudCertId,
Domain: domain,
Https: true,
ForceHttps: true,
}
updateHttpsCertificateManagerResp, err := d.sdkClient.UpdateHttpsCertificateManagerWithContext(ctx, updateHttpsCertificateManagerReq)
d.logger.Debug("sdk request 'console.EnableDomainHttps'", slog.Any("request", updateHttpsCertificateManagerReq), slog.Any("response", updateHttpsCertificateManagerResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'console.UpdateHttpsCertificateManager': %w", err)
}
} else if getHttpsServiceManagerResp.Data.Domains[lastCertIndex].CertificateId != cloudCertId {
migrateHttpsDomainReq := &upyunsdk.MigrateHttpsDomainRequest{
CertificateId: cloudCertId,
Domain: domain,
}
migrateHttpsDomainResp, err := d.sdkClient.MigrateHttpsDomainWithContext(ctx, migrateHttpsDomainReq)
d.logger.Debug("sdk request 'console.MigrateHttpsDomain'", slog.Any("request", migrateHttpsDomainReq), slog.Any("response", migrateHttpsDomainResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'console.MigrateHttpsDomain': %w", err)
}
}
return nil
}
func createSDKClient(username, password string) (*upyunsdk.Client, error) {
client, err := upyunsdk.NewClient(
upyunsdk.WithLogins(username, password),
)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,133 +0,0 @@
package upyunfile
import (
"context"
"fmt"
"log/slog"
"github.com/samber/lo"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/upyun-ssl"
upyunsdk "github.com/certimate-go/certimate/pkg/sdk3rd/upyun/console"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// 又拍云账号用户名。
Username string `json:"username"`
// 又拍云账号密码。
Password string `json:"password"`
// 存储桶名。暂时无用。
Bucket string `json:"bucket"`
// 自定义域名(支持泛域名)。
Domain string `json:"domain"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *upyunsdk.Client
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client, err := createSDKClient(config.Username, config.Password)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
Username: config.Username,
Password: config.Password,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 获取域名证书配置
getHttpsServiceManagerResp, err := d.sdkClient.GetHttpsServiceManagerWithContext(ctx, d.config.Domain)
d.logger.Debug("sdk request 'console.GetHttpsServiceManager'", slog.String("params.domain", d.config.Domain), slog.Any("response", getHttpsServiceManagerResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'console.GetHttpsServiceManager': %w", err)
}
// 判断域名是否已启用 HTTPS
// 如果已启用,迁移域名证书;否则,设置新证书
_, lastCertIndex, _ := lo.FindIndexOf(getHttpsServiceManagerResp.Data.Domains, func(item upyunsdk.HttpsServiceManagerDomain) bool {
return item.Https
})
if lastCertIndex == -1 {
updateHttpsCertificateManagerReq := &upyunsdk.UpdateHttpsCertificateManagerRequest{
CertificateId: upres.CertId,
Domain: d.config.Domain,
Https: true,
ForceHttps: true,
}
updateHttpsCertificateManagerResp, err := d.sdkClient.UpdateHttpsCertificateManagerWithContext(ctx, updateHttpsCertificateManagerReq)
d.logger.Debug("sdk request 'console.EnableDomainHttps'", slog.Any("request", updateHttpsCertificateManagerReq), slog.Any("response", updateHttpsCertificateManagerResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'console.UpdateHttpsCertificateManager': %w", err)
}
} else if getHttpsServiceManagerResp.Data.Domains[lastCertIndex].CertificateId != upres.CertId {
migrateHttpsDomainReq := &upyunsdk.MigrateHttpsDomainRequest{
CertificateId: upres.CertId,
Domain: d.config.Domain,
}
migrateHttpsDomainResp, err := d.sdkClient.MigrateHttpsDomainWithContext(ctx, migrateHttpsDomainReq)
d.logger.Debug("sdk request 'console.MigrateHttpsDomain'", slog.Any("request", migrateHttpsDomainReq), slog.Any("response", migrateHttpsDomainResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'console.MigrateHttpsDomain': %w", err)
}
}
return &DeployResult{}, nil
}
func createSDKClient(username, password string) (*upyunsdk.Client, error) {
client, err := upyunsdk.NewClient(
upyunsdk.WithLogins(username, password),
)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,17 +0,0 @@
package zenlayercdn
const (
// 部署目标:替换指定域名的证书。
DEPLOY_TARGET_DOMAIN = "domain"
// 部署目标:替换指定证书。
DEPLOY_TARGET_CERTIFICATE = "certificate"
)
const (
// 匹配模式:精确匹配。
DOMAIN_MATCH_PATTERN_EXACT = "exact"
// 匹配模式:通配符匹配。
DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard"
// 匹配模式:证书 SAN 匹配。
DOMAIN_MATCH_PATTERN_CERTSAN = "certsan"
)
@@ -1,332 +0,0 @@
package zenlayercdn
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/samber/lo"
zcommon "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/zenlayer-cdn"
zcdnsdk "github.com/certimate-go/certimate/pkg/sdk3rd/zenlayer/cdn"
xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname"
xloop "github.com/certimate-go/certimate/pkg/utils/loop"
xwait "github.com/certimate-go/certimate/pkg/utils/wait"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// Zenlayer AccessKeyId。
AccessKeyId string `json:"accessKeyId"`
// Zenlayer AccessKeyPassword。
AccessKeyPassword string `json:"accessKeyPassword"`
// Zenlayer 资源组 ID。
ResourceGroupId string `json:"resourceGroupId,omitempty"`
// 部署目标。
DeployTarget string `json:"deployTarget"`
// 全球网络加速器 ID。
// 部署目标为 [DEPLOY_TARGET_ACCELERATOR] 时必填。
AcceleratorId string `json:"acceleratorId,omitempty"`
// 域名匹配模式。
// 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。
DomainMatchPattern string `json:"domainMatchPattern,omitempty"`
// 加速域名(支持泛域名)。
Domain string `json:"domain"`
// 证书 ID。
// 部署目标为 [DEPLOY_TARGET_CERTIFICATE] 时必填。
CertificateId string `json:"certificateId,omitempty"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *zcdnsdk.Client
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client, err := createSDKClient(config.AccessKeyId, config.AccessKeyPassword)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
AccessKeyId: config.AccessKeyId,
AccessKeyPassword: config.AccessKeyPassword,
ResourceGroupId: config.ResourceGroupId,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
// 根据部署目标决定业务流程
switch d.config.DeployTarget {
case DEPLOY_TARGET_DOMAIN:
if err := d.deployToDomain(ctx, certPEM, privkeyPEM); err != nil {
return nil, err
}
case DEPLOY_TARGET_CERTIFICATE:
if err := d.deployToCertificate(ctx, certPEM, privkeyPEM); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported deploy target '%s'", d.config.DeployTarget)
}
return &DeployResult{}, nil
}
func (d *Deployer) deployToDomain(ctx context.Context, certPEM, privkeyPEM string) error {
if d.config.Domain == "" {
return fmt.Errorf("config `domain` is required")
}
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 获取待部署的域名列表
domainIds := make([]string, 0)
switch d.config.DomainMatchPattern {
case "", DOMAIN_MATCH_PATTERN_EXACT:
{
if d.config.Domain == "" {
return fmt.Errorf("config `domain` is required")
}
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return err
}
domains := lo.Filter(domainCandidates, func(domainItem *zcdnsdk.DomainInfo, _ int) bool {
return d.config.Domain == domainItem.DomainName
})
if len(domains) == 0 {
return fmt.Errorf("could not find domain")
}
domainIds = lo.Map(domains, func(domainItem *zcdnsdk.DomainInfo, _ int) string {
return domainItem.DomainId
})
}
case DOMAIN_MATCH_PATTERN_WILDCARD:
{
if d.config.Domain == "" {
return fmt.Errorf("config `domain` is required")
}
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return err
}
domains := lo.Filter(domainCandidates, func(domainItem *zcdnsdk.DomainInfo, _ int) bool {
return xcerthostname.IsMatch(d.config.Domain, domainItem.DomainName)
})
if len(domains) == 0 {
return fmt.Errorf("could not find any domains matched by wildcard")
}
domainIds = lo.Map(domains, func(domainItem *zcdnsdk.DomainInfo, _ int) string {
return domainItem.DomainId
})
}
case DOMAIN_MATCH_PATTERN_CERTSAN:
{
domainCandidates, err := d.getAllDomains(ctx)
if err != nil {
return err
}
domains := lo.Filter(domainCandidates, func(domainItem *zcdnsdk.DomainInfo, _ int) bool {
return xcerthostname.IsMatchByCertificatePEM(certPEM, domainItem.DomainName)
})
if len(domains) == 0 {
return fmt.Errorf("could not find any domains matched by certificate")
}
domainIds = lo.Map(domains, func(domainItem *zcdnsdk.DomainInfo, _ int) string {
return domainItem.DomainId
})
}
default:
return fmt.Errorf("unsupported domain match pattern: '%s'", d.config.DomainMatchPattern)
}
// 批量绑定证书
if len(domainIds) == 0 {
d.logger.Info("no cdn domains to deploy")
} else {
d.logger.Info("found cdn domains to deploy", slog.Any("domainIds", domainIds))
if err := xloop.ForRangeAllWithContext(ctx, domainIds, func(ctx context.Context, domainId string, _ int) error {
return d.updateDomainCertificate(ctx, domainId, upres.CertId)
}); err != nil {
return err
}
}
return nil
}
func (d *Deployer) deployToCertificate(ctx context.Context, certPEM, privkeyPEM string) error {
if d.config.CertificateId == "" {
return fmt.Errorf("config `certificateId` is required")
}
// 替换证书
rplres, err := d.sdkCertmgr.Replace(ctx, d.config.CertificateId, certPEM, privkeyPEM)
if err != nil {
return fmt.Errorf("failed to replace certificate file: %w", err)
} else {
d.logger.Info("ssl certificate replaced", slog.Any("result", rplres))
}
return nil
}
func (d *Deployer) getAllDomains(ctx context.Context) ([]*zcdnsdk.DomainInfo, error) {
domains := make([]*zcdnsdk.DomainInfo, 0)
// 查询加速域名列表
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/cdn/domain/describedomains
describeDomainsPageNum := 1
describeDomainsPageSize := 100
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
describeDomainsReq := zcdnsdk.NewDescribeDomainsRequest()
describeDomainsReq.DomainStatus = "ENABLED"
describeDomainsReq.PageNum = describeDomainsPageNum
describeDomainsReq.PageSize = describeDomainsPageSize
describeDomainsResp, err := d.sdkClient.DescribeDomains(describeDomainsReq)
d.logger.Debug("sdk request 'cdn.DescribeDomains'", slog.Any("request", describeDomainsReq), slog.Any("response", describeDomainsResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'cdn.DescribeDomains': %w", err)
}
for _, domainItem := range describeDomainsResp.Response.DataSet {
domains = append(domains, domainItem)
}
if len(describeDomainsResp.Response.DataSet) < describeDomainsPageSize {
break
}
describeDomainsPageNum++
}
return domains, nil
}
func (d *Deployer) updateDomainCertificate(ctx context.Context, cloudDomainId string, cloudCertId string) error {
// 查询加速域名的 SSL 证书
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/cdn/domain/describedomaincertificate
describeDomainCertificateReq := zcdnsdk.NewDescribeDomainCertificateRequest()
describeDomainCertificateReq.DomainId = cloudDomainId
describeDomainCertificateResp, err := d.sdkClient.DescribeDomainCertificate(describeDomainCertificateReq)
d.logger.Debug("sdk request 'cdn.DescribeDomainCertificate'", slog.Any("request", describeDomainCertificateReq), slog.Any("response", describeDomainCertificateResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'cdn.DescribeDomainCertificate': %w", err)
} else if describeDomainCertificateResp.Response.Certificate != nil && describeDomainCertificateResp.Response.Certificate.CertificateId == cloudCertId {
return nil
}
// 修改加速域名的 SSL 证书
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/cdn/domain/modifydomaincertificate
modifyDomainCertificateReq := zcdnsdk.NewModifyDomainCertificateRequest()
modifyDomainCertificateReq.DomainId = cloudDomainId
modifyDomainCertificateReq.CertificateId = cloudCertId
modifyDomainCertificateResp, err := d.sdkClient.ModifyDomainCertificate(modifyDomainCertificateReq)
d.logger.Debug("sdk request 'cdn.ModifyDomainCertificate'", slog.Any("request", modifyDomainCertificateReq), slog.Any("response", modifyDomainCertificateResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'cdn.ModifyDomainCertificate': %w", err)
}
// 查询加速域名状态,等待部署状态变更
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/cdn/domain/describedomains
if _, err := xwait.UntilWithContext(ctx, func(_ context.Context, _ int) (bool, error) {
describeDomainsReq := zcdnsdk.NewDescribeDomainsRequest()
describeDomainsReq.DomainIds = []string{cloudDomainId}
describeDomainsReq.PageNum = 1
describeDomainsReq.PageSize = 1
describeDomainsResp, err := d.sdkClient.DescribeDomains(describeDomainsReq)
d.logger.Debug("sdk request 'cdn.DescribeDomains'", slog.Any("request", describeDomainsReq), slog.Any("response", describeDomainsResp))
if err != nil {
return false, fmt.Errorf("failed to execute sdk request 'cdn.DescribeDomains': %w", err)
} else if len(describeDomainsResp.Response.DataSet) == 0 {
return false, fmt.Errorf("could not found domain '%s'", cloudDomainId)
}
switch describeDomainsResp.Response.DataSet[0].ConfigStatus {
case "DEPLOYED":
return true, nil
case "FAILED":
return false, fmt.Errorf("unexpected domain status")
}
d.logger.Info("waiting for domain deploying completion ...")
return false, nil
}, 10*time.Second); err != nil {
return err
}
return nil
}
func createSDKClient(accessKeyId, accessKeyPassword string) (*zcdnsdk.Client, error) {
config := zcommon.NewConfig()
client, err := zcdnsdk.NewClient(config, accessKeyId, accessKeyPassword)
if err != nil {
return nil, err
}
return client, nil
}
@@ -1,8 +0,0 @@
package zenlayerga
const (
// 部署目标:部署到指定全球网络加速器。
DEPLOY_TARGET_ACCELERATOR = "accelerator"
// 部署目标:替换指定证书。
DEPLOY_TARGET_CERTIFICATE = "certificate"
)
@@ -1,202 +0,0 @@
package zenlayerga
import (
"context"
"fmt"
"log/slog"
"time"
zcommon "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/zenlayer-ga"
zgasdk "github.com/certimate-go/certimate/pkg/sdk3rd/zenlayer/zga"
xwait "github.com/certimate-go/certimate/pkg/utils/wait"
)
type (
Provider = core.Deployer
DeployResult = core.DeployerDeployResult
)
type DeployerConfig struct {
// Zenlayer AccessKeyId。
AccessKeyId string `json:"accessKeyId"`
// Zenlayer AccessKeyPassword。
AccessKeyPassword string `json:"accessKeyPassword"`
// Zenlayer 资源组 ID。
ResourceGroupId string `json:"resourceGroupId,omitempty"`
// 部署目标。
DeployTarget string `json:"deployTarget"`
// 加速器 ID。
// 部署目标为 [DEPLOY_TARGET_ACCELERATOR] 时必填。
AcceleratorId string `json:"acceleratorId,omitempty"`
// 证书 ID。
// 部署目标为 [DEPLOY_TARGET_CERTIFICATE] 时必填。
CertificateId string `json:"certificateId,omitempty"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *zgasdk.Client
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client, err := createSDKClient(config.AccessKeyId, config.AccessKeyPassword)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
AccessKeyId: config.AccessKeyId,
AccessKeyPassword: config.AccessKeyPassword,
ResourceGroupId: config.ResourceGroupId,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
// 根据部署目标决定业务流程
switch d.config.DeployTarget {
case DEPLOY_TARGET_ACCELERATOR:
if err := d.deployToAccelerator(ctx, certPEM, privkeyPEM); err != nil {
return nil, err
}
case DEPLOY_TARGET_CERTIFICATE:
if err := d.deployToCertificate(ctx, certPEM, privkeyPEM); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported deploy target '%s'", d.config.DeployTarget)
}
return &DeployResult{}, nil
}
func (d *Deployer) deployToAccelerator(ctx context.Context, certPEM, privkeyPEM string) error {
if d.config.AcceleratorId == "" {
return fmt.Errorf("config `acceleratorId` is required")
}
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// 查询加速器信息
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/accelerator/describeaccelerators
describeAcceleratorsReq := zgasdk.NewDescribeAcceleratorsRequest()
describeAcceleratorsReq.AcceleratorIds = []string{d.config.AcceleratorId}
describeAcceleratorsReq.PageNum = 1
describeAcceleratorsReq.PageSize = 1
describeAcceleratorsResp, err := d.sdkClient.DescribeAccelerators(describeAcceleratorsReq)
d.logger.Debug("sdk request 'zga.DescribeAccelerators'", slog.Any("request", describeAcceleratorsReq), slog.Any("response", describeAcceleratorsResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'zga.DescribeAccelerators': %w", err)
} else if len(describeAcceleratorsResp.Response.DataSet) == 0 {
return fmt.Errorf("could not found accelerator '%s'", d.config.AcceleratorId)
}
// 修改加速器证书
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/accelerator/modifyacceleratorcertificate
acceleratorInfo := describeAcceleratorsResp.Response.DataSet[0]
if acceleratorInfo.Certificate == nil || acceleratorInfo.Certificate.CertificateId != upres.CertId {
modifyAcceleratorCertificateReq := zgasdk.NewModifyAcceleratorCertificateRequest()
modifyAcceleratorCertificateReq.AcceleratorId = acceleratorInfo.AcceleratorId
modifyAcceleratorCertificateReq.CertificateId = upres.CertId
modifyAcceleratorCertificateResp, err := d.sdkClient.ModifyAcceleratorCertificate(modifyAcceleratorCertificateReq)
d.logger.Debug("sdk request 'zga.ModifyAcceleratorCertificate'", slog.Any("request", modifyAcceleratorCertificateReq), slog.Any("response", modifyAcceleratorCertificateResp))
if err != nil {
return fmt.Errorf("failed to execute sdk request 'zga.ModifyAcceleratorCertificate': %w", err)
}
}
// 查询加速器状态,等待部署状态变更
// REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/accelerator/describeaccelerators
if _, err := xwait.UntilWithContext(ctx, func(_ context.Context, _ int) (bool, error) {
describeAcceleratorsReq := zgasdk.NewDescribeAcceleratorsRequest()
describeAcceleratorsReq.AcceleratorIds = []string{acceleratorInfo.AcceleratorId}
describeAcceleratorsReq.PageNum = 1
describeAcceleratorsReq.PageSize = 1
describeAcceleratorsResp, err := d.sdkClient.DescribeAccelerators(describeAcceleratorsReq)
d.logger.Debug("sdk request 'zga.DescribeAccelerators'", slog.Any("request", describeAcceleratorsReq), slog.Any("response", describeAcceleratorsResp))
if err != nil {
return false, fmt.Errorf("failed to execute sdk request 'zga.DescribeAccelerators': %w", err)
} else if len(describeAcceleratorsResp.Response.DataSet) == 0 {
return false, fmt.Errorf("could not found accelerator '%s'", d.config.AcceleratorId)
}
switch describeAcceleratorsResp.Response.DataSet[0].AcceleratorStatus {
case "Accelerating":
return true, nil
case "NotAccelerate", "StopAccelerate", "AccelerateFailure":
return false, fmt.Errorf("unexpected accelerator status")
}
d.logger.Info("waiting for accelerator deploying completion ...")
return false, nil
}, 10*time.Second); err != nil {
return err
}
return nil
}
func (d *Deployer) deployToCertificate(ctx context.Context, certPEM, privkeyPEM string) error {
if d.config.CertificateId == "" {
return fmt.Errorf("config `certificateId` is required")
}
// 替换证书
upres, err := d.sdkCertmgr.Replace(ctx, d.config.CertificateId, certPEM, privkeyPEM)
if err != nil {
return fmt.Errorf("failed to replace certificate file: %w", err)
} else {
d.logger.Info("ssl certificate replaced", slog.Any("result", upres))
}
return nil
}
func createSDKClient(accessKeyId, accessKeyPassword string) (*zgasdk.Client, error) {
config := zcommon.NewConfig()
client, err := zgasdk.NewClient(config, accessKeyId, accessKeyPassword)
if err != nil {
return nil, err
}
return client, nil
}
+46
View File
@@ -0,0 +1,46 @@
package plugin
import "time"
const (
defaultMinPort = 10000
defaultMaxPort = 25000
defaultStartTimeout = 10 * time.Second
defaultDeployTimeout = 30 * time.Minute
defaultMaxLogFrames = 1000
)
type PluginConfig struct {
PluginDir string
MinPort uint
MaxPort uint
StartTimeout time.Duration
DeployTimeout time.Duration
MaxLogFrames int
LogLevel string
CoreVersion string
}
func (c *PluginConfig) defaults() {
if c.MinPort == 0 {
c.MinPort = defaultMinPort
}
if c.MaxPort == 0 {
c.MaxPort = defaultMaxPort
}
if c.MaxPort < c.MinPort {
c.MinPort, c.MaxPort = c.MaxPort, c.MinPort
}
if c.StartTimeout == 0 {
c.StartTimeout = defaultStartTimeout
}
if c.DeployTimeout == 0 {
c.DeployTimeout = defaultDeployTimeout
}
if c.MaxLogFrames == 0 {
c.MaxLogFrames = defaultMaxLogFrames
}
if c.LogLevel == "" {
c.LogLevel = "INFO"
}
}
+194
View File
@@ -0,0 +1,194 @@
package plugin
import (
"context"
"log/slog"
githubplugin "github.com/hashicorp/go-plugin"
"google.golang.org/grpc"
"github.com/certimate-go/certimate/pkg/plugin/proto"
)
const PluginName = "deployer"
type Metadata struct {
ProviderType string
AccessProviderType string
ProtocolVersion uint32
DeployCategory string
DeployDisplayNameKey string
AccessDisplayNameKey string
}
type ConfigSchema struct {
AccessSchemaJSON []byte
DeploySchemaJSON []byte
I18n map[string]map[string]string
}
type DeployRequest struct {
LogLevel string
AccessConfigJSON string
ExtendedConfigJSON string
CertificatePEM string
PrivateKeyPEM string
}
type DeployResult struct {
ExtendedDataJSON string
}
type Deployer interface {
GetMetadata(ctx context.Context) (*Metadata, error)
GetConfigSchema(ctx context.Context) (*ConfigSchema, error)
Deploy(ctx context.Context, req *DeployRequest, logger *slog.Logger) (*DeployResult, error)
}
type PluginSet = githubplugin.PluginSet
func PluginSetForDeployer() PluginSet {
return PluginSet{PluginName: &DeployerGRPCPlugin{}}
}
type DeployerGRPCPlugin struct {
githubplugin.NetRPCUnsupportedPlugin
Impl Deployer
}
func (p *DeployerGRPCPlugin) GRPCServer(_ *githubplugin.GRPCBroker, s *grpc.Server) error {
proto.RegisterDeployerPluginServer(s, &deployerGRPCServer{impl: p.Impl})
return nil
}
func (p *DeployerGRPCPlugin) GRPCClient(_ context.Context, _ *githubplugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
return &deployerGRPCClient{raw: proto.NewDeployerPluginClient(c)}, nil
}
type deployerGRPCServer struct {
proto.UnimplementedDeployerPluginServer
impl Deployer
}
func (s *deployerGRPCServer) GetMetadata(ctx context.Context, _ *proto.GetMetadataRequest) (*proto.GetMetadataResponse, error) {
m, err := s.impl.GetMetadata(ctx)
if err != nil {
return nil, err
}
return &proto.GetMetadataResponse{
ProviderType: m.ProviderType,
AccessProviderType: m.AccessProviderType,
ProtocolVersion: m.ProtocolVersion,
DeployCategory: m.DeployCategory,
DeployDisplayNameKey: m.DeployDisplayNameKey,
AccessDisplayNameKey: m.AccessDisplayNameKey,
}, nil
}
func (s *deployerGRPCServer) GetConfigSchema(ctx context.Context, _ *proto.GetConfigSchemaRequest) (*proto.GetConfigSchemaResponse, error) {
cs, err := s.impl.GetConfigSchema(ctx)
if err != nil {
return nil, err
}
i18n := make(map[string]*proto.StringMap, len(cs.I18n))
for locale, entries := range cs.I18n {
i18n[locale] = &proto.StringMap{Entries: entries}
}
return &proto.GetConfigSchemaResponse{
AccessSchemaJson: cs.AccessSchemaJSON,
DeploySchemaJson: cs.DeploySchemaJSON,
I18NResources: i18n,
}, nil
}
func (s *deployerGRPCServer) Deploy(req *proto.DeployRequest, stream proto.DeployerPlugin_DeployServer) error {
ctx := stream.Context()
logger := NewStreamLogger(stream, parseLogLevel(req.LogLevel))
res, err := s.impl.Deploy(ctx, &DeployRequest{
LogLevel: req.LogLevel,
AccessConfigJSON: req.AccessConfigJson,
ExtendedConfigJSON: req.ExtendedConfigJson,
CertificatePEM: req.CertificatePem,
PrivateKeyPEM: req.PrivateKeyPem,
}, logger)
if err != nil {
return err
}
return stream.Send(&proto.DeployFrame{
Frame: &proto.DeployFrame_Result{
Result: &proto.DeployResponse{ExtendedDataJson: res.ExtendedDataJSON},
},
})
}
type deployerGRPCClient struct {
raw proto.DeployerPluginClient
}
func (c *deployerGRPCClient) GetMetadata(ctx context.Context) (*Metadata, error) {
resp, err := c.raw.GetMetadata(ctx, &proto.GetMetadataRequest{})
if err != nil {
return nil, err
}
return &Metadata{
ProviderType: resp.ProviderType,
AccessProviderType: resp.AccessProviderType,
ProtocolVersion: resp.ProtocolVersion,
DeployCategory: resp.DeployCategory,
DeployDisplayNameKey: resp.DeployDisplayNameKey,
AccessDisplayNameKey: resp.AccessDisplayNameKey,
}, nil
}
func (c *deployerGRPCClient) GetConfigSchema(ctx context.Context) (*ConfigSchema, error) {
resp, err := c.raw.GetConfigSchema(ctx, &proto.GetConfigSchemaRequest{})
if err != nil {
return nil, err
}
i18n := make(map[string]map[string]string, len(resp.I18NResources))
for locale, sm := range resp.I18NResources {
i18n[locale] = sm.GetEntries()
}
return &ConfigSchema{
AccessSchemaJSON: resp.AccessSchemaJson,
DeploySchemaJSON: resp.DeploySchemaJson,
I18n: i18n,
}, nil
}
func (c *deployerGRPCClient) Deploy(ctx context.Context, req *DeployRequest, logger *slog.Logger) (*DeployResult, error) {
stream, err := c.raw.Deploy(ctx, &proto.DeployRequest{
LogLevel: req.LogLevel,
AccessConfigJson: req.AccessConfigJSON,
ExtendedConfigJson: req.ExtendedConfigJSON,
CertificatePem: req.CertificatePEM,
PrivateKeyPem: req.PrivateKeyPEM,
})
if err != nil {
return nil, err
}
for {
frame, err := stream.Recv()
if err != nil {
return nil, err
}
switch f := frame.Frame.(type) {
case *proto.DeployFrame_Log:
forwardLog(ctx, logger, f.Log)
case *proto.DeployFrame_Result:
return &DeployResult{ExtendedDataJSON: f.Result.ExtendedDataJson}, nil
}
}
}
func forwardLog(ctx context.Context, logger *slog.Logger, e *proto.LogEntry) {
if logger == nil || e == nil {
return
}
attrs := make([]any, 0, len(e.Data))
for k, v := range e.Data {
attrs = append(attrs, slog.String(k, v))
}
logger.Log(ctx, slog.Level(e.Level), e.Message, attrs...)
}

Some files were not shown because too many files have changed in this diff Show More