mirror of
https://github.com/certimate-go/certimate.git
synced 2026-10-16 07:05:59 +08:00
Merge branch 'next' into next
This commit is contained in:
@@ -20,3 +20,4 @@ dist
|
||||
/certimate.exe
|
||||
/docker/data
|
||||
/pb_data
|
||||
/plugins
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ®istry[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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ®istry[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()
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user