From 57720dc872f6ba41902ab3e2ccb438a2e6e0b58c Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Tue, 9 Jun 2026 17:20:29 +0800 Subject: [PATCH 01/19] feat: support ACME Renewal Information (ARI) --- internal/certacme/client_ari.go | 45 +++++++ internal/certacme/client_obtain.go | 18 ++- internal/domain/certificate.go | 4 + internal/repository/certificate.go | 8 ++ internal/workflow/engine/deps.go | 4 + internal/workflow/engine/executor_bizapply.go | 110 +++++++++++++++- .../workflow/engine/executor_bizapply_test.go | 98 ++++++++++++++ migrations/1781020800_upgrade_v0.4.26.go | 122 ++++++++++++++++++ ui/package-lock.json | 4 + .../certificate/CertificateDetail.tsx | 40 +++++- .../components/workflow/WorkflowRunDetail.tsx | 22 +++- ui/src/domain/certificate.ts | 4 + ui/src/i18n/resources/en/nls.certificate.json | 8 ++ ui/src/i18n/resources/zh/nls.certificate.json | 8 ++ ui/src/repository/certificate.ts | 4 + 15 files changed, 493 insertions(+), 6 deletions(-) create mode 100644 internal/certacme/client_ari.go create mode 100644 internal/workflow/engine/executor_bizapply_test.go create mode 100644 migrations/1781020800_upgrade_v0.4.26.go diff --git a/internal/certacme/client_ari.go b/internal/certacme/client_ari.go new file mode 100644 index 000000000..16bbac5e0 --- /dev/null +++ b/internal/certacme/client_ari.go @@ -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 +} diff --git a/internal/certacme/client_obtain.go b/internal/certacme/client_obtain.go index 32c1b6d5f..a1c44692b 100644 --- a/internal/certacme/client_obtain.go +++ b/internal/certacme/client_obtain.go @@ -54,6 +54,7 @@ type ObtainCertificateRequest struct { ACMEProfile string // ARI 相关 + DisableARI bool ARIReplacesAccountUrl string ARIReplacesCertId string } @@ -66,6 +67,8 @@ type ObtainCertificateResponse struct { PrivateKey string ACMEAccountUrl string ACMECertificateUrl string + ARIInfo *ARIInfo + ARIError string ARIReplaced bool } @@ -183,7 +186,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)), @@ -192,5 +195,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 } diff --git a/internal/domain/certificate.go b/internal/domain/certificate.go index b2d44491b..f3a595ecd 100644 --- a/internal/domain/certificate.go +++ b/internal/domain/certificate.go @@ -34,6 +34,10 @@ type Certificate struct { CA string `db:"ca" json:"ca"` ACMEAccountUrl string `db:"acmeAcctUrl" json:"acmeAcctUrl"` ACMECertificateUrl string `db:"acmeCertUrl" json:"acmeCertUrl"` + ARIWindowStart time.Time `db:"ariWindowStart" json:"ariWindowStart"` + ARIWindowEnd time.Time `db:"ariWindowEnd" json:"ariWindowEnd"` + ARINextRefreshAt time.Time `db:"ariNextRefreshAt" json:"ariNextRefreshAt"` + ARISupported bool `db:"ariSupported" json:"ariSupported"` IsRenewed bool `db:"isRenewed" json:"isRenewed"` IsRevoked bool `db:"isRevoked" json:"isRevoked"` WorkflowId string `db:"workflowRef" json:"workflowId"` diff --git a/internal/repository/certificate.go b/internal/repository/certificate.go index f09b8f638..82f8db71a 100644 --- a/internal/repository/certificate.go +++ b/internal/repository/certificate.go @@ -110,6 +110,10 @@ func (r *CertificateRepository) Save(ctx context.Context, certificate *domain.Ce record.Set("ca", certificate.CA) record.Set("acmeAcctUrl", certificate.ACMEAccountUrl) record.Set("acmeCertUrl", certificate.ACMECertificateUrl) + record.Set("ariWindowStart", certificate.ARIWindowStart) + record.Set("ariWindowEnd", certificate.ARIWindowEnd) + record.Set("ariNextRefreshAt", certificate.ARINextRefreshAt) + record.Set("ariSupported", certificate.ARISupported) record.Set("isRenewed", certificate.IsRenewed) record.Set("isRevoked", certificate.IsRevoked) record.Set("workflowRef", certificate.WorkflowId) @@ -176,6 +180,10 @@ func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain. CA: record.GetString("ca"), ACMEAccountUrl: record.GetString("acmeAcctUrl"), ACMECertificateUrl: record.GetString("acmeCertUrl"), + ARIWindowStart: record.GetDateTime("ariWindowStart").Time(), + ARIWindowEnd: record.GetDateTime("ariWindowEnd").Time(), + ARINextRefreshAt: record.GetDateTime("ariNextRefreshAt").Time(), + ARISupported: record.GetBool("ariSupported"), IsRenewed: record.GetBool("isRenewed"), IsRevoked: record.GetBool("isRevoked"), WorkflowId: record.GetString("workflowRef"), diff --git a/internal/workflow/engine/deps.go b/internal/workflow/engine/deps.go index a33e66c5a..d556b019d 100644 --- a/internal/workflow/engine/deps.go +++ b/internal/workflow/engine/deps.go @@ -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) diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go index e5df9c24e..5737c1cc2 100644 --- a/internal/workflow/engine/executor_bizapply.go +++ b/internal/workflow/engine/executor_bizapply.go @@ -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 { settingsRepo settingsRepository accessRepo accessRepository + acmeAccountRepo acmeAccountRepository certificateRepo certificateRepository wfoutputRepo workflowOutputRepository } @@ -117,6 +120,12 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe WorkflowNodeId: execCtx.Node.Id, } certificate.PopulateFromPEM(obtainResp.FullChainCertificate, obtainResp.PrivateKey) + if obtainResp.ARIInfo != nil { + applyARIInfoToCertificate(certificate, obtainResp.ARIInfo) + } else if obtainResp.ARIError != "" { + ne.logger.Warn("could not get ARI info", slog.String("error", obtainResp.ARIError)) + copyARIFields(certificate, lastCertificate) + } if certificate, err := ne.certificateRepo.Save(execCtx.Context(), certificate); err != nil { ne.logger.Warn("could not save certificate") return execRes, err @@ -215,15 +224,40 @@ func (ne *bizApplyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, last return false, "the last requested certificate has been revoked" } + now := time.Now() + ariTriggered := false + if !thisNodeCfg.DisableARI && !lastCertificate.ARIWindowStart.IsZero() { + if lastCertificate.ARINextRefreshAt.IsZero() || !now.Before(lastCertificate.ARINextRefreshAt) { + if ariInfo, err := ne.getARIInfoForCertificate(execCtx, lastCertificate); err != nil { + ne.logger.Warn("could not refresh ARI info", slog.String("certificateId", lastCertificate.Id), slog.Any("error", err)) + } else if ariInfo != nil { + applyARIInfoToCertificate(lastCertificate, ariInfo) + if _, err := ne.certificateRepo.Save(execCtx.Context(), lastCertificate); err != nil { + ne.logger.Warn("could not save refreshed ARI info", slog.String("certificateId", lastCertificate.Id), slog.Any("error", err)) + } + } + } + + ariTriggered = shouldRenewByARI(lastCertificate.ARIWindowStart, lastCertificate.ARIWindowEnd, now) + } + 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 +379,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 { @@ -440,6 +475,76 @@ 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 applyARIInfoToCertificate(certificate *domain.Certificate, ariInfo *certacme.ARIInfo) { + if certificate == nil || ariInfo == nil { + return + } + + certificate.ARISupported = ariInfo.Supported + if !ariInfo.Supported { + certificate.ARIWindowStart = time.Time{} + certificate.ARIWindowEnd = time.Time{} + certificate.ARINextRefreshAt = time.Time{} + return + } + + certificate.ARIWindowStart = ariInfo.WindowStart + certificate.ARIWindowEnd = ariInfo.WindowEnd + certificate.ARINextRefreshAt = ariInfo.NextRefreshAt +} + +func copyARIFields(target *domain.Certificate, source *domain.Certificate) { + if target == nil || source == nil { + return + } + + target.ARIWindowStart = source.ARIWindowStart + target.ARIWindowEnd = source.ARIWindowEnd + target.ARINextRefreshAt = source.ARINextRefreshAt + target.ARISupported = source.ARISupported +} + +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" @@ -496,6 +601,7 @@ func newBizApplyNodeExecutor() NodeExecutor { nodeExecutor: nodeExecutor{logger: slog.Default()}, settingsRepo: repository.NewSettingsRepository(), accessRepo: repository.NewAccessRepository(), + acmeAccountRepo: repository.NewACMEAccountRepository(), certificateRepo: repository.NewCertificateRepository(), wfoutputRepo: repository.NewWorkflowOutputRepository(), } diff --git a/internal/workflow/engine/executor_bizapply_test.go b/internal/workflow/engine/executor_bizapply_test.go new file mode 100644 index 000000000..c97e6d89f --- /dev/null +++ b/internal/workflow/engine/executor_bizapply_test.go @@ -0,0 +1,98 @@ +package engine + +import ( + "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 TestApplyARIInfoToCertificate(t *testing.T) { + windowStart := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + windowEnd := windowStart.Add(time.Hour) + nextRefreshAt := windowStart.Add(30 * time.Minute) + + certificate := &domain.Certificate{} + applyARIInfoToCertificate(certificate, &certacme.ARIInfo{ + WindowStart: windowStart, + WindowEnd: windowEnd, + NextRefreshAt: nextRefreshAt, + Supported: true, + }) + + if !certificate.ARISupported { + t.Fatal("ARISupported = false, want true") + } + if !certificate.ARIWindowStart.Equal(windowStart) || !certificate.ARIWindowEnd.Equal(windowEnd) || !certificate.ARINextRefreshAt.Equal(nextRefreshAt) { + t.Fatal("ARI timestamps were not applied") + } + + applyARIInfoToCertificate(certificate, &certacme.ARIInfo{Supported: false}) + if certificate.ARISupported { + t.Fatal("ARISupported = true, want false") + } + if !certificate.ARIWindowStart.IsZero() || !certificate.ARIWindowEnd.IsZero() || !certificate.ARINextRefreshAt.IsZero() { + t.Fatal("ARI timestamps were not cleared for unsupported CA") + } +} + +func TestCopyARIFields(t *testing.T) { + source := &domain.Certificate{ + ARIWindowStart: time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC), + ARIWindowEnd: time.Date(2026, 6, 9, 13, 0, 0, 0, time.UTC), + ARINextRefreshAt: time.Date(2026, 6, 9, 12, 30, 0, 0, time.UTC), + ARISupported: true, + } + target := &domain.Certificate{} + + copyARIFields(target, source) + if !target.ARISupported { + t.Fatal("ARISupported = false, want true") + } + if !target.ARIWindowStart.Equal(source.ARIWindowStart) || !target.ARIWindowEnd.Equal(source.ARIWindowEnd) || !target.ARINextRefreshAt.Equal(source.ARINextRefreshAt) { + t.Fatal("ARI timestamps were not copied") + } +} diff --git a/migrations/1781020800_upgrade_v0.4.26.go b/migrations/1781020800_upgrade_v0.4.26.go new file mode 100644 index 000000000..97a3ac002 --- /dev/null +++ b/migrations/1781020800_upgrade_v0.4.26.go @@ -0,0 +1,122 @@ +package migrations + +import ( + "strings" + + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + tracer := NewTracer("v0.4.26") + tracer.Printf("go ...") + + // update collection `certificate` + // - add ARI metadata fields + { + collection, err := app.FindCollectionByNameOrId("4szxr9x43tpj6np") + if err != nil { + return err + } + + fields := []struct { + index int + name string + json string + }{ + {22, "ariWindowStart", `{ + "hidden": false, + "id": "date2264264971", + "max": "", + "min": "", + "name": "ariWindowStart", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }`}, + {23, "ariWindowEnd", `{ + "hidden": false, + "id": "date2359593433", + "max": "", + "min": "", + "name": "ariWindowEnd", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }`}, + {24, "ariNextRefreshAt", `{ + "hidden": false, + "id": "date553418413", + "max": "", + "min": "", + "name": "ariNextRefreshAt", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }`}, + {25, "ariSupported", `{ + "hidden": false, + "id": "bool1675854919", + "name": "ariSupported", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }`}, + } + + changed := false + for _, field := range fields { + if collection.Fields.GetByName(field.name) != nil { + continue + } + + if err := collection.Fields.AddMarshaledJSONAt(field.index, []byte(field.json)); err != nil { + return err + } + changed = true + } + + if changed { + if err := app.Save(collection); err != nil { + return err + } + + tracer.Printf("collection '%s' updated", collection.Name) + } + + records, err := app.FindAllRecords(collection) + if err != nil { + return err + } + + for _, record := range records { + if record.GetBool("ariSupported") { + continue + } + + if isKnownARISupportingCA(record.GetString("acmeAcctUrl")) { + record.Set("ariSupported", true) + if err := app.Save(record); err != nil { + return err + } + + tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name) + } + } + } + + tracer.Printf("done") + return nil + }, nil) +} + +func isKnownARISupportingCA(acmeAcctUrl string) bool { + acmeAcctUrl = strings.ToLower(acmeAcctUrl) + return strings.Contains(acmeAcctUrl, "letsencrypt.org/") || + strings.Contains(acmeAcctUrl, "api.pki.goog/") +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 1fb62e223..06e6e9f90 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -74,6 +74,10 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.60.1", "vite": "^8.0.16" + }, + "engines": { + "node": ">=24", + "npm": ">=11.10.0" } }, "node_modules/@alloc/quick-lru": { diff --git a/ui/src/components/certificate/CertificateDetail.tsx b/ui/src/components/certificate/CertificateDetail.tsx index 4c87c84e2..47e727717 100644 --- a/ui/src/components/certificate/CertificateDetail.tsx +++ b/ui/src/components/certificate/CertificateDetail.tsx @@ -1,7 +1,7 @@ import { CopyToClipboard } from "react-copy-to-clipboard"; import { useTranslation } from "react-i18next"; import { IconClipboard, IconDownload } from "@tabler/icons-react"; -import { App, Button, Form, Input, Tag, Tooltip } from "antd"; +import { App, Button, Form, Input, Tag, Tooltip, Typography } from "antd"; import dayjs from "dayjs"; import { type CertificateModel } from "@/domain/certificate"; @@ -18,6 +18,8 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => { const { t } = useTranslation(); const { message } = App.useApp(); + const hasARIWindow = data.ariSupported && data.ariWindowStart && data.ariWindowEnd; + const showARI = data.source === "request" && (data.ariSupported || data.ariSupported === false); return (
@@ -55,6 +57,42 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => { + {showARI && ( + <> + + {t("certificate.props.ari_supported.supported")} + ) : ( + {t("certificate.props.ari_supported.not_supported")} + ) + } + /> + {!data.ariSupported && {t("certificate.props.ari_not_supported_tip")}} + + + {hasARIWindow && ( + + + + )} + + {data.ariSupported && data.ariNextRefreshAt && ( + + + + )} + + )} +
diff --git a/ui/src/components/workflow/WorkflowRunDetail.tsx b/ui/src/components/workflow/WorkflowRunDetail.tsx index 318787a0b..691bcfb07 100644 --- a/ui/src/components/workflow/WorkflowRunDetail.tsx +++ b/ui/src/components/workflow/WorkflowRunDetail.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditorState, FlowLayoutDefault } from "@flowgram.ai/fixed-layout-editor"; -import { IconBrowserShare, IconBug, IconCheck, IconDots, IconDownload, IconSettings2, IconTransferOut } from "@tabler/icons-react"; +import { IconBrowserShare, IconBug, IconCheck, IconClock, IconDots, IconDownload, IconMinus, IconSettings2, IconTransferOut } from "@tabler/icons-react"; import { useRequest } from "ahooks"; import { Alert, App, Button, Card, Divider, Dropdown, Empty, Skeleton, Table, type TableProps, Tooltip, Typography, theme } from "antd"; import dayjs from "dayjs"; @@ -409,6 +409,26 @@ const WorkflowRunArtifacts = ({ runData }: { runData: WorkflowRunModel }) => { ); }, }, + { + key: "ari", + align: "center", + width: 56, + render: (_, record) => { + if (record.ariSupported) { + return ( + + + + ); + } + + return ( + + + + ); + }, + }, { key: "$action", align: "end", diff --git a/ui/src/domain/certificate.ts b/ui/src/domain/certificate.ts index d91fd73a3..fc4f34a81 100644 --- a/ui/src/domain/certificate.ts +++ b/ui/src/domain/certificate.ts @@ -13,6 +13,10 @@ export interface CertificateModel extends BaseModel { validationPolicy: string; validityNotBefore: ISO8601String; validityNotAfter: ISO8601String; + ariWindowStart?: ISO8601String; + ariWindowEnd?: ISO8601String; + ariNextRefreshAt?: ISO8601String; + ariSupported?: boolean; isRenewed: boolean; isRevoked: boolean; workflowRef: string; diff --git a/ui/src/i18n/resources/en/nls.certificate.json b/ui/src/i18n/resources/en/nls.certificate.json index b16244656..e8e7d093d 100644 --- a/ui/src/i18n/resources/en/nls.certificate.json +++ b/ui/src/i18n/resources/en/nls.certificate.json @@ -121,6 +121,14 @@ "issuer_name": "Issuer common name", "issuer_org": "Issuer organization", "key_algorithm": "Key algorithm", + "ari_supported": { + "": "ARI", + "supported": "Supported", + "not_supported": "Not supported" + }, + "ari_window": "ARI suggested window", + "ari_next_refresh": "Next ARI refresh", + "ari_not_supported_tip": "This CA does not support ARI (RFC 9773).", "created_at": "Created at", "updated_at": "Updated at" } diff --git a/ui/src/i18n/resources/zh/nls.certificate.json b/ui/src/i18n/resources/zh/nls.certificate.json index 8af563a82..10d6df8f6 100644 --- a/ui/src/i18n/resources/zh/nls.certificate.json +++ b/ui/src/i18n/resources/zh/nls.certificate.json @@ -121,6 +121,14 @@ "issuer_name": "颁发者通用名称", "issuer_org": "颁发者组织", "key_algorithm": "私钥算法", + "ari_supported": { + "": "ARI", + "supported": "支持", + "not_supported": "不支持" + }, + "ari_window": "ARI 建议续期窗口", + "ari_next_refresh": "下次 ARI 刷新时间", + "ari_not_supported_tip": "当前 CA 不支持 ARI(RFC 9773)。", "created_at": "创建时间", "updated_at": "更新时间" } diff --git a/ui/src/repository/certificate.ts b/ui/src/repository/certificate.ts index 331d51772..dcc2b400d 100644 --- a/ui/src/repository/certificate.ts +++ b/ui/src/repository/certificate.ts @@ -19,6 +19,10 @@ const _commonFields = [ "validityNotBefore", "validityNotAfter", "validityInterval", + "ariWindowStart", + "ariWindowEnd", + "ariNextRefreshAt", + "ariSupported", "isRenewed", "isRevoked", "workflowRef", From 46cce4316ee32bda045951b8c9970de34b35460d Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Thu, 11 Jun 2026 18:08:37 +0800 Subject: [PATCH 02/19] ui: refactor --- .../certificate/CertificateDetail.tsx | 17 +++++++- .../components/workflow/WorkflowRunDetail.tsx | 39 +++++++++++++++++-- .../designer/forms/BizApplyNodeConfigForm.tsx | 14 ++++++- ui/src/i18n/resources/en/nls.certificate.json | 5 ++- .../i18n/resources/en/nls.workflow.nodes.json | 5 ++- .../i18n/resources/en/nls.workflow.runs.json | 4 ++ ui/src/i18n/resources/zh/nls.certificate.json | 5 ++- .../i18n/resources/zh/nls.workflow.nodes.json | 5 ++- .../i18n/resources/zh/nls.workflow.runs.json | 4 ++ 9 files changed, 87 insertions(+), 11 deletions(-) diff --git a/ui/src/components/certificate/CertificateDetail.tsx b/ui/src/components/certificate/CertificateDetail.tsx index 47e727717..f901eb664 100644 --- a/ui/src/components/certificate/CertificateDetail.tsx +++ b/ui/src/components/certificate/CertificateDetail.tsx @@ -1,7 +1,7 @@ import { CopyToClipboard } from "react-copy-to-clipboard"; import { useTranslation } from "react-i18next"; import { IconClipboard, IconDownload } from "@tabler/icons-react"; -import { App, Button, Form, Input, Tag, Tooltip, Typography } from "antd"; +import { Alert, App, Button, Form, Input, Tag, Tooltip, Typography } from "antd"; import dayjs from "dayjs"; import { type CertificateModel } from "@/domain/certificate"; @@ -20,6 +20,9 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => { const { message } = App.useApp(); const hasARIWindow = data.ariSupported && data.ariWindowStart && data.ariWindowEnd; const showARI = data.source === "request" && (data.ariSupported || data.ariSupported === false); + const ariWindowPassed = hasARIWindow && dayjs(data.ariWindowEnd).isBefore(dayjs()); + const validityNotAfter = dayjs(data.validityNotAfter); + const daysLeft = Math.max(0, Math.ceil(validityNotAfter.diff(dayjs(), "millisecond") / 86_400_000)); return (
@@ -82,6 +85,18 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => { variant="filled" placeholder="" /> + + {t("certificate.props.ari_window_expiry_context", { + validityNotAfter: validityNotAfter.format("YYYY-MM-DD HH:mm:ss"), + daysLeft, + })} + + )} diff --git a/ui/src/components/workflow/WorkflowRunDetail.tsx b/ui/src/components/workflow/WorkflowRunDetail.tsx index 691bcfb07..09a4901d2 100644 --- a/ui/src/components/workflow/WorkflowRunDetail.tsx +++ b/ui/src/components/workflow/WorkflowRunDetail.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { EditorState, FlowLayoutDefault } from "@flowgram.ai/fixed-layout-editor"; import { IconBrowserShare, IconBug, IconCheck, IconClock, IconDots, IconDownload, IconMinus, IconSettings2, IconTransferOut } from "@tabler/icons-react"; import { useRequest } from "ahooks"; -import { Alert, App, Button, Card, Divider, Dropdown, Empty, Skeleton, Table, type TableProps, Tooltip, Typography, theme } from "antd"; +import { Alert, App, Button, Card, Divider, Dropdown, Empty, Skeleton, Table, type TableProps, Tag, Tooltip, Typography, theme } from "antd"; import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; @@ -24,6 +24,9 @@ import WorkflowToolbar from "./designer/Toolbar"; import WorkflowGraphExportModal from "./WorkflowGraphExportModal"; import WorkflowStatus from "./WorkflowStatus"; +const RENEWAL_TRIGGER_ARI_PATTERN = "ARI suggestedWindow"; +const RENEWAL_TRIGGER_THRESHOLD_PATTERN = "SkipBeforeExpiryDays threshold"; + export interface WorkflowRunDetailProps { className?: string; style?: React.CSSProperties; @@ -237,15 +240,45 @@ const WorkflowRunLogs = ({ runData }: { runData: WorkflowRunModel }) => { const [showTimestamp, setShowTimestamp] = useState(true); const [showWhitespace, setShowWhitespace] = useState(true); + const renderRenewalTriggerTags = (message: string) => { + const tags = []; + if (message.includes(RENEWAL_TRIGGER_ARI_PATTERN)) { + tags.push( + + {t("workflow_run.logs.renewal_trigger.ari")} + + ); + } + + if (message.includes(RENEWAL_TRIGGER_THRESHOLD_PATTERN)) { + tags.push( + + {t("workflow_run.logs.renewal_trigger.threshold")} + + ); + } + + return tags.length > 0 ? tags : null; + }; + const renderLogRecord = (record: Log) => { let timestamp = dayjs(record.timestamp).format("YYYY-MM-DD HH:mm:ss"); timestamp = `[${timestamp}]`; - let message = <>{record.message}; + const renewalTriggerTags = renderRenewalTriggerTags(record.message); + let message = ( + <> + {record.message} + {renewalTriggerTags} + + ); if (record.data != null && Object.keys(record.data).length > 0) { message = (
- {record.message} + + {record.message} + {renewalTriggerTags} + {Object.entries(record.data).map(([key, value]) => (
{key}:
diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index a7b23177a..2d3ffb384 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -5,6 +5,7 @@ import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor"; import { IconArrowRight, IconChevronRight, IconCircleMinus, IconMapPin, IconPlus, IconWorldWww } from "@tabler/icons-react"; import { useControllableValue, useMount } from "ahooks"; import { + Alert, type AnchorProps, AutoComplete, Avatar, @@ -103,6 +104,7 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const fieldKeySource = Form.useWatch("keySource", { form: formInst, preserve: true }); const fieldCAProvider = Form.useWatch("caProvider", { form: formInst, preserve: true }); const fieldCAProviderAccessId = Form.useWatch("caProviderAccessId", { form: formInst, preserve: true }); + const fieldDisableARI = Form.useWatch("disableARI", { form: formInst, preserve: true }); const renderNestedFieldProviderComponent = BizApplyNodeConfigFieldsProvider.useComponent(fieldChallengeType, fieldProvider, {}); @@ -769,7 +771,17 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) - + +
{t("workflow_node.apply.form.skip_before_expiry_days.help")}
+ {!fieldDisableARI && ( + + )} + + } + > {t("workflow_node.apply.form.skip_before_expiry_days.prefix")} diff --git a/ui/src/i18n/resources/en/nls.certificate.json b/ui/src/i18n/resources/en/nls.certificate.json index e8e7d093d..fa247e32f 100644 --- a/ui/src/i18n/resources/en/nls.certificate.json +++ b/ui/src/i18n/resources/en/nls.certificate.json @@ -126,7 +126,10 @@ "supported": "Supported", "not_supported": "Not supported" }, - "ari_window": "ARI suggested window", + "ari_window": "ARI Suggested Renewal Window (CA Guidance)", + "ari_window_expiry_context": "Certificate expires: {{validityNotAfter}}, {{daysLeft}} days remaining", + "ari_window_active_tip": "The CA recommends renewing within this window. If the current time falls within this window on the next workflow run, the certificate will be renewed early (regardless of your configured threshold).", + "ari_window_passed_tip": "The CA's suggested renewal window has ended. The certificate will be renewed immediately on the next workflow run.", "ari_next_refresh": "Next ARI refresh", "ari_not_supported_tip": "This CA does not support ARI (RFC 9773).", "created_at": "Created at", diff --git a/ui/src/i18n/resources/en/nls.workflow.nodes.json b/ui/src/i18n/resources/en/nls.workflow.nodes.json index a752d626e..5a82b2af2 100644 --- a/ui/src/i18n/resources/en/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/en/nls.workflow.nodes.json @@ -266,7 +266,7 @@ }, "disable_ari": { "label": "Disable ARI", - "tooltip": "It determines whether to disable ARI (ACME Renewal Information). If you don't understand this option, just keep it by default.
Click here to learn more." + "tooltip": "ARI (ACME Renewal Information) is a renewal scheduling hint from the CA. When enabled, if the CA suggests early renewal (e.g., pending mass revocation), the system will renew early on the next workflow run, overriding your configured threshold. When disabled, renewal is determined solely by your configured threshold.
Click here to learn more." }, "skip_before_expiry_days": { "label": "Repeated application", @@ -274,7 +274,8 @@ "prefix": "Perform renewal if the last issued certificate expires in ≤", "suffix": ".", "unit": "days", - "help": "Notes: The actual renewal interval also depends on the CRON expression that triggers the workflow." + "help": "Notes: The actual renewal timing is also affected by the workflow CRON expression and the CA's ARI suggestion (if supported).", + "ari_notice": "Certificate renewal is determined by two conditions: your configured threshold and the CA's ARI suggested window (if supported). Renewal triggers when either condition is met. To rely solely on your threshold, disable ARI in 'Advanced Settings'." }, "aliyun_esa_region": { diff --git a/ui/src/i18n/resources/en/nls.workflow.runs.json b/ui/src/i18n/resources/en/nls.workflow.runs.json index d6d0ced09..ff442ffa1 100644 --- a/ui/src/i18n/resources/en/nls.workflow.runs.json +++ b/ui/src/i18n/resources/en/nls.workflow.runs.json @@ -64,6 +64,10 @@ "logs": { "": "Logs", + "renewal_trigger": { + "ari": "ARI Triggered", + "threshold": "Threshold Triggered" + }, "menu": { "show_timestamps": "Show timestamps", "show_whitespaces": "Show whitespaces", diff --git a/ui/src/i18n/resources/zh/nls.certificate.json b/ui/src/i18n/resources/zh/nls.certificate.json index 10d6df8f6..674667038 100644 --- a/ui/src/i18n/resources/zh/nls.certificate.json +++ b/ui/src/i18n/resources/zh/nls.certificate.json @@ -126,7 +126,10 @@ "supported": "支持", "not_supported": "不支持" }, - "ari_window": "ARI 建议续期窗口", + "ari_window": "ARI 建议续期窗口(CA 建议)", + "ari_window_expiry_context": "证书到期时间:{{validityNotAfter}},距离到期 {{daysLeft}} 天", + "ari_window_active_tip": "CA 建议在此窗口内续期。如果在下次工作流运行时当前时间处于该窗口内,系统将提前续期证书(无论你设置的续期阈值如何)。", + "ari_window_passed_tip": "CA 建议的续期窗口已结束。系统将在下次工作流运行时立即续期。", "ari_next_refresh": "下次 ARI 刷新时间", "ari_not_supported_tip": "当前 CA 不支持 ARI(RFC 9773)。", "created_at": "创建时间", diff --git a/ui/src/i18n/resources/zh/nls.workflow.nodes.json b/ui/src/i18n/resources/zh/nls.workflow.nodes.json index 10b79fc39..fc543a803 100644 --- a/ui/src/i18n/resources/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/zh/nls.workflow.nodes.json @@ -265,7 +265,7 @@ }, "disable_ari": { "label": "阻止 ARI 续期", - "tooltip": "在 ACME 证书续期时是否阻止 ARI(ACME Renewal Information)。如果你不了解该选项的用途,保持默认即可。
点此了解更多。" + "tooltip": "ARI(ACME Renewal Information)是 CA 提供的续期时间建议。开启后,如果 CA 建议提前续期(例如证书即将被批量吊销),系统会在下次工作流运行时提前续期,而忽略你设置的续期阈值。关闭后,续期完全由你设置的续期阈值决定。
点此了解更多。" }, "skip_before_expiry_days": { "label": "重复申请", @@ -273,7 +273,8 @@ "prefix": "当上次申请的证书剩余时长大于", "suffix": ",再次运行工作流时跳过此节点。", "unit": "天", - "help": "提示:证书实际续期间隔还取决于触发运行的 CRON 表达式。" + "help": "提示:证书实际续期时机同时受工作流 CRON 表达式和 CA 的 ARI 建议(如支持)影响。", + "ari_notice": "证书续期由两个条件共同决定:你设置的续期阈值和 CA 的 ARI 建议窗口(如果 CA 支持)。任一条件满足时都会触发续期。如需完全由你控制续期时间,可在「高级设置」中关闭 ARI。" }, "aliyun_esa_region": { diff --git a/ui/src/i18n/resources/zh/nls.workflow.runs.json b/ui/src/i18n/resources/zh/nls.workflow.runs.json index 72d9b60c3..1643a88bf 100644 --- a/ui/src/i18n/resources/zh/nls.workflow.runs.json +++ b/ui/src/i18n/resources/zh/nls.workflow.runs.json @@ -64,6 +64,10 @@ "logs": { "": "日志", + "renewal_trigger": { + "ari": "ARI 触发", + "threshold": "阈值触发" + }, "menu": { "show_timestamps": "显示日期时间", "show_whitespaces": "显示转义换行符", From 8ad3ae7003caea3b43d38e7c760537d5c08f1055 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Thu, 23 Jul 2026 17:59:24 +0800 Subject: [PATCH 03/19] feat: dynamic form --- internal/accessschema/as_aliyun.go | 38 + internal/accessschema/as_aliyun_test.go | 79 + internal/certmgmt/deployers/ps_aliyun_cdn.go | 41 + .../certmgmt/deployers/ps_aliyun_cdn_test.go | 137 ++ internal/providerschema/builder.go | 330 ++++ internal/providerschema/builder_test.go | 223 +++ internal/providerschema/emit.go | 108 ++ internal/providerschema/emit_test.go | 128 ++ internal/providerschema/registry.go | 64 + internal/providerschema/registry_test.go | 76 + internal/providerschema/schema.go | 139 ++ internal/providerschema/service.go | 31 + internal/providerschema/service_deps.go | 8 + internal/providerschema/service_test.go | 100 ++ internal/repository/providerschema.go | 35 + internal/repository/providerschema_test.go | 67 + internal/rest/handlers/providerschema.go | 49 + internal/rest/routes/routes.go | 13 +- main.go | 1 + ui/package-lock.json | 1533 ++++++++++++++++- ui/package.json | 11 +- ui/src/api/providerschema.ts | 101 ++ .../_shared/formNestedFieldsContext.ts | 19 + .../forms/AccessConfigFieldsProvider.tsx | 29 +- .../AccessConfigFieldsProviderAliyun.tsx | 75 - ui/src/components/access/forms/_context.ts | 27 +- .../BizDeployNodeConfigFieldsProvider.tsx | 29 +- ...eployNodeConfigFieldsProviderAliyunCDN.tsx | 123 -- .../designer/forms/SchemaConfigFields.tsx | 66 + .../workflow/designer/forms/_context.ts | 27 +- .../designer/forms/schemaValidation.test.ts | 123 ++ .../designer/forms/schemaValidation.ts | 182 ++ .../designer/forms/schemaValueTypes.test.ts | 69 + .../designer/forms/schemaValueTypes.ts | 55 + ui/vitest.config.ts | 19 + ui/vitest.setup.ts | 1 + 36 files changed, 3840 insertions(+), 316 deletions(-) create mode 100644 internal/accessschema/as_aliyun.go create mode 100644 internal/accessschema/as_aliyun_test.go create mode 100644 internal/certmgmt/deployers/ps_aliyun_cdn.go create mode 100644 internal/certmgmt/deployers/ps_aliyun_cdn_test.go create mode 100644 internal/providerschema/builder.go create mode 100644 internal/providerschema/builder_test.go create mode 100644 internal/providerschema/emit.go create mode 100644 internal/providerschema/emit_test.go create mode 100644 internal/providerschema/registry.go create mode 100644 internal/providerschema/registry_test.go create mode 100644 internal/providerschema/schema.go create mode 100644 internal/providerschema/service.go create mode 100644 internal/providerschema/service_deps.go create mode 100644 internal/providerschema/service_test.go create mode 100644 internal/repository/providerschema.go create mode 100644 internal/repository/providerschema_test.go create mode 100644 internal/rest/handlers/providerschema.go create mode 100644 ui/src/api/providerschema.ts create mode 100644 ui/src/components/_shared/formNestedFieldsContext.ts delete mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderAliyun.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunCDN.tsx create mode 100644 ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx create mode 100644 ui/src/components/workflow/designer/forms/schemaValidation.test.ts create mode 100644 ui/src/components/workflow/designer/forms/schemaValidation.ts create mode 100644 ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts create mode 100644 ui/src/components/workflow/designer/forms/schemaValueTypes.ts create mode 100644 ui/vitest.config.ts create mode 100644 ui/vitest.setup.ts diff --git a/internal/accessschema/as_aliyun.go b/internal/accessschema/as_aliyun.go new file mode 100644 index 000000000..262b9b3f1 --- /dev/null +++ b/internal/accessschema/as_aliyun.go @@ -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) +} diff --git a/internal/accessschema/as_aliyun_test.go b/internal/accessschema/as_aliyun_test.go new file mode 100644 index 000000000..d5bdd4767 --- /dev/null +++ b/internal/accessschema/as_aliyun_test.go @@ -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{}{} + } +} diff --git a/internal/certmgmt/deployers/ps_aliyun_cdn.go b/internal/certmgmt/deployers/ps_aliyun_cdn.go new file mode 100644 index 000000000..c6eabed2e --- /dev/null +++ b/internal/certmgmt/deployers/ps_aliyun_cdn.go @@ -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) +} diff --git a/internal/certmgmt/deployers/ps_aliyun_cdn_test.go b/internal/certmgmt/deployers/ps_aliyun_cdn_test.go new file mode 100644 index 000000000..9cd002fe4 --- /dev/null +++ b/internal/certmgmt/deployers/ps_aliyun_cdn_test.go @@ -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 +} diff --git a/internal/providerschema/builder.go b/internal/providerschema/builder.go new file mode 100644 index 000000000..60029c63e --- /dev/null +++ b/internal/providerschema/builder.go @@ -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 +} diff --git a/internal/providerschema/builder_test.go b/internal/providerschema/builder_test.go new file mode 100644 index 000000000..2ce81b2a6 --- /dev/null +++ b/internal/providerschema/builder_test.go @@ -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") + } +} diff --git a/internal/providerschema/emit.go b/internal/providerschema/emit.go new file mode 100644 index 000000000..6c3f989a4 --- /dev/null +++ b/internal/providerschema/emit.go @@ -0,0 +1,108 @@ +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"` + 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 +} diff --git a/internal/providerschema/emit_test.go b/internal/providerschema/emit_test.go new file mode 100644 index 000000000..de59d0e24 --- /dev/null +++ b/internal/providerschema/emit_test.go @@ -0,0 +1,128 @@ +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 +} diff --git a/internal/providerschema/registry.go b/internal/providerschema/registry.go new file mode 100644 index 000000000..2844cc9e2 --- /dev/null +++ b/internal/providerschema/registry.go @@ -0,0 +1,64 @@ +package providerschema + +import ( + "fmt" + "sort" +) + +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 +} + +type registry[T comparable] struct { + schemas map[T]*Schema +} + +func (r *registry[T]) Register(name T, schema *Schema) error { + 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) { + if err := r.Register(name, schema); err != nil { + panic(err) + } +} + +func (r *registry[T]) Get(name T) (*Schema, error) { + 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 { + _, exists := r.schemas[name] + return exists +} + +func (r *registry[T]) List() []T { + 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 newRegistry[T comparable]() Registry[T] { + return ®istry[T]{schemas: make(map[T]*Schema)} +} + +var Registries = newRegistry[string]() diff --git a/internal/providerschema/registry_test.go b/internal/providerschema/registry_test.go new file mode 100644 index 000000000..7518f9849 --- /dev/null +++ b/internal/providerschema/registry_test.go @@ -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") + } +} diff --git a/internal/providerschema/schema.go b/internal/providerschema/schema.go new file mode 100644 index 000000000..a1067eb41 --- /dev/null +++ b/internal/providerschema/schema.go @@ -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"` +} diff --git a/internal/providerschema/service.go b/internal/providerschema/service.go new file mode 100644 index 000000000..a0ab561a7 --- /dev/null +++ b/internal/providerschema/service.go @@ -0,0 +1,31 @@ +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) { + 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) { + schemas, err := s.repo.List(ctx) + if err != nil { + return nil, err + } + out := make([]*Envelope, len(schemas)) + for i, sc := range schemas { + out[i] = Emit(sc) + } + return out, nil +} diff --git a/internal/providerschema/service_deps.go b/internal/providerschema/service_deps.go new file mode 100644 index 000000000..1c73d366c --- /dev/null +++ b/internal/providerschema/service_deps.go @@ -0,0 +1,8 @@ +package providerschema + +import "context" + +type providerSchemaRepository interface { + Get(ctx context.Context, providerType string) (*Schema, error) + List(ctx context.Context) ([]*Schema, error) +} diff --git a/internal/providerschema/service_test.go b/internal/providerschema/service_test.go new file mode 100644 index 000000000..b18a808cb --- /dev/null +++ b/internal/providerschema/service_test.go @@ -0,0 +1,100 @@ +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 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) + } +} diff --git a/internal/repository/providerschema.go b/internal/repository/providerschema.go new file mode 100644 index 000000000..099a1f77e --- /dev/null +++ b/internal/repository/providerschema.go @@ -0,0 +1,35 @@ +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 +} diff --git a/internal/repository/providerschema_test.go b/internal/repository/providerschema_test.go new file mode 100644 index 000000000..a600d52f0 --- /dev/null +++ b/internal/repository/providerschema_test.go @@ -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) + } +} diff --git a/internal/rest/handlers/providerschema.go b/internal/rest/handlers/providerschema.go new file mode 100644 index 000000000..9a888c98b --- /dev/null +++ b/internal/rest/handlers/providerschema.go @@ -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) +} diff --git a/internal/rest/routes/routes.go b/internal/rest/routes/routes.go index 165160602..62cb29989 100644 --- a/internal/rest/routes/routes.go +++ b/internal/rest/routes/routes.go @@ -7,6 +7,7 @@ import ( "github.com/certimate-go/certimate/internal/certificate" "github.com/certimate-go/certimate/internal/notify" + "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 +15,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 +29,13 @@ 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) group := router.Group("/api") group.Bind(apis.RequireSuperuserAuth()) @@ -39,4 +43,5 @@ func BindRouter(router *router.Router[*core.RequestEvent]) { handlers.NewWorkflowsHandler(group, workflowSvc) handlers.NewStatisticsHandler(group, statisticsSvc) handlers.NewNotificationsHandler(group, notifySvc) + handlers.NewProviderSchemaHandler(group, providerSchemaSvc) } diff --git a/main.go b/main.go index fc9cf6b87..488665584 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "github.com/certimate-go/certimate/internal/workflow" "github.com/certimate-go/certimate/ui" + _ "github.com/certimate-go/certimate/internal/accessschema" _ "github.com/certimate-go/certimate/migrations" ) diff --git a/ui/package-lock.json b/ui/package-lock.json index e2bf9cde0..cf5aa0329 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@certimate/webui", "dependencies": { + "@ant-design/pro-components": "^3.1.14-2", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/language": "^6.12.4", @@ -52,6 +53,8 @@ "@eslint/js": "^9.39.4", "@tailwindcss/postcss": "^4.3.2", "@tailwindcss/vite": "^4.3.2", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/file-saver": "^2.0.7", "@types/fs-extra": "^11.0.4", "@types/node": "^24.12.4", @@ -69,17 +72,25 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "fs-extra": "^11.3.6", + "jsdom": "^29.1.1", "prettier": "^3.9.4", "tailwindcss": "^4.3.2", "typescript": "^6.0.3", "typescript-eslint": "^8.62.1", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.10" }, "engines": { "node": ">=24", "npm": ">=11.10.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -169,6 +180,41 @@ "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", "license": "MIT" }, + "node_modules/@ant-design/pro-components": { + "version": "3.1.14-2", + "resolved": "https://registry.npmmirror.com/@ant-design/pro-components/-/pro-components-3.1.14-2.tgz", + "integrity": "sha512-4s4ltv9jxK9ZxZ4W1BY8Pf6M/Ww2LzFs+PNMWYJdj4OI2fYVlTp5JkXrZkxcw2Wh6xwMt7PTl9J0x/QhCUAO3g==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "2.1.2", + "@ant-design/icons": "^6.3.2", + "@babel/runtime": "^7.29.7", + "@ctrl/tinycolor": "^4.2.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/css": "^11.13.5", + "@rc-component/form": "^1.8.5", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/table": "^1.10.2", + "@rc-component/util": "^1.11.1", + "@umijs/route-utils": "^4.0.3", + "clsx": "^2.1.1", + "dayjs": "^1.11.21", + "lodash-es": "^4.18.1", + "path-to-regexp": "^8.4.2", + "react-draggable": "~4.4.6", + "safe-stable-stringify": "^2.5.0", + "swr": "^2.4.2" + }, + "peerDependencies": { + "antd": "^6.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@ant-design/react-slick": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", @@ -185,11 +231,57 @@ "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -254,7 +346,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -388,7 +479,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -412,7 +502,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -517,7 +606,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -527,7 +615,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -576,7 +663,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -1719,9 +1805,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1731,7 +1817,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -1746,7 +1831,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -1765,7 +1849,6 @@ "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1775,6 +1858,18 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.18.6", "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", @@ -1893,6 +1988,216 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", @@ -1927,18 +2232,145 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmmirror.com/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmmirror.com/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", "license": "MIT" }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/serialize/node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/serialize/node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, "node_modules/@emotion/unitless": { "version": "0.7.5", "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT" }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2084,6 +2516,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@flowgram.ai/background-plugin": { "version": "1.0.12", "resolved": "https://registry.npmmirror.com/@flowgram.ai/background-plugin/-/background-plugin-1.0.12.tgz", @@ -2707,7 +3156,6 @@ "version": "0.3.12", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" @@ -2727,7 +3175,6 @@ "version": "3.1.2", "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -2746,14 +3193,12 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.29", "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -4019,6 +4464,12 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, "node_modules/@tabler/icons": { "version": "3.44.0", "resolved": "https://registry.npmmirror.com/@tabler/icons/-/icons-3.44.0.tgz", @@ -4178,9 +4629,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4198,9 +4646,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4218,9 +4663,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4238,9 +4680,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4343,6 +4782,81 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tweenjs/tween.js": { "version": "18.6.4", "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-18.6.4.tgz", @@ -4359,6 +4873,29 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -4418,6 +4955,12 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -4819,6 +5362,12 @@ "react-dom": ">=17.0.0" } }, + "node_modules/@umijs/route-utils": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@umijs/route-utils/-/route-utils-4.0.3.tgz", + "integrity": "sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw==", + "license": "MIT" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.9.2", "resolved": "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", @@ -5136,6 +5685,112 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", @@ -5197,6 +5852,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5295,6 +5960,15 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -5425,6 +6099,15 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5440,6 +6123,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -5512,6 +6210,15 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "11.1.4", "resolved": "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-11.1.4.tgz", @@ -5639,7 +6346,6 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5666,6 +6372,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -5783,6 +6498,31 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz", @@ -5814,12 +6554,50 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-tree/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5881,7 +6659,6 @@ "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5895,6 +6672,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", @@ -5935,6 +6718,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.0.4.tgz", @@ -5944,6 +6735,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5979,6 +6777,27 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.0.tgz", @@ -6060,11 +6879,16 @@ "version": "1.3.0", "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6131,6 +6955,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", @@ -6510,18 +7346,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -6598,6 +7422,15 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", @@ -6607,6 +7440,15 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6657,6 +7499,12 @@ "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", @@ -6741,7 +7589,6 @@ "version": "1.1.2", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6988,7 +7835,6 @@ "version": "2.0.2", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -7011,6 +7857,18 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -7091,7 +7949,6 @@ "version": "3.3.1", "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -7113,6 +7970,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7161,6 +8027,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.0.0.tgz", @@ -7232,7 +8104,6 @@ "version": "2.16.1", "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "dependencies": { "hasown": "^2.0.2" }, @@ -7373,6 +8244,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", @@ -7561,11 +8438,59 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -7580,6 +8505,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7909,6 +8840,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", @@ -7930,9 +8867,10 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -7976,6 +8914,16 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -8020,6 +8968,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", @@ -8045,8 +9002,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { "version": "5.1.16", @@ -8193,6 +9149,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", @@ -8261,7 +9230,6 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -8270,6 +9238,36 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", @@ -8291,14 +9289,37 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "4.0.4", @@ -8414,6 +9435,41 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", @@ -8501,6 +9557,29 @@ "react": "^18.3.1" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmmirror.com/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -8577,6 +9656,19 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -8689,6 +9781,15 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -8698,7 +9799,6 @@ "version": "1.22.12", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8720,7 +9820,6 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -8842,6 +9941,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", @@ -9027,6 +10147,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", @@ -9067,6 +10193,18 @@ "node": ">=12.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9151,6 +10289,18 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -9191,7 +10341,6 @@ "version": "1.0.0", "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -9199,6 +10348,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmmirror.com/synckit/-/synckit-0.11.13.tgz", @@ -9309,6 +10477,21 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9343,11 +10526,62 @@ } } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9579,6 +10813,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", @@ -9816,6 +11059,95 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", @@ -9829,6 +11161,50 @@ "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", @@ -9935,6 +11311,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9944,6 +11336,21 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/ui/package.json b/ui/package.json index 43fbc92e8..7166de086 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,7 +6,9 @@ "dev": "vite --host", "build": "tsc -b && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "engineStrict": true, "engines": { @@ -26,6 +28,7 @@ } }, "dependencies": { + "@ant-design/pro-components": "^3.1.14-2", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/language": "^6.12.4", @@ -71,6 +74,8 @@ "@eslint/js": "^9.39.4", "@tailwindcss/postcss": "^4.3.2", "@tailwindcss/vite": "^4.3.2", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/file-saver": "^2.0.7", "@types/fs-extra": "^11.0.4", "@types/node": "^24.12.4", @@ -88,10 +93,12 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "fs-extra": "^11.3.6", + "jsdom": "^29.1.1", "prettier": "^3.9.4", "tailwindcss": "^4.3.2", "typescript": "^6.0.3", "typescript-eslint": "^8.62.1", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.10" } } diff --git a/ui/src/api/providerschema.ts b/ui/src/api/providerschema.ts new file mode 100644 index 000000000..6198b585a --- /dev/null +++ b/ui/src/api/providerschema.ts @@ -0,0 +1,101 @@ +import { get as httpGet } from "./_api"; + +export type SchemaCategory = "deploy" | "access" | "apply" | "notify"; + +export type SchemaFilterMode = "fuzzy" | "prefix" | "none"; + +export type SchemaConditionOp = "eq" | "ne" | "in" | "notIn"; + +export interface SchemaOption { + value: string; + labelKey?: string; +} + +export interface SchemaCondition { + field: string; + op: SchemaConditionOp; + values: string[]; +} + +export interface SchemaValidationRule extends SchemaCondition { + validator: string; + params?: Record; +} + +export interface SchemaColumn { + name: string; + valueType: string; + labelKey?: string; + placeholderKey?: string; + tooltipKey?: string; + extraKey?: string; + tooltipHtml?: boolean; + extraHtml?: boolean; + default?: unknown; + secret?: boolean; + required?: boolean; + options?: SchemaOption[]; + min?: number; + max?: number; + filterMode?: SchemaFilterMode; + span?: number; + dependencies?: string[]; + visibleWhen?: SchemaCondition[]; + requiredWhen?: SchemaCondition[]; + validateWhen?: SchemaValidationRule[]; +} + +export interface ProviderSchemaEnvelope { + schemaVersion: string; + provider: string; + category: SchemaCategory; + schema: { + columns: SchemaColumn[]; + }; +} + +const CURRENT_SCHEMA_VERSION = "form/v1"; + +const cache = new Map(); + +const cacheKey = (provider: string, schemaVersion: string) => `${schemaVersion}::${provider}`; + +export const getProviderSchema = async (provider: string): Promise => { + const key = cacheKey(provider, CURRENT_SCHEMA_VERSION); + const cached = cache.get(key); + if (cached) { + return cached; + } + + try { + const resp = await httpGet({ + url: `/api/provider-schemas/${encodeURIComponent(provider)}`, + }); + const envelope = resp.data; + if (envelope && envelope.schemaVersion === CURRENT_SCHEMA_VERSION) { + cache.set(key, envelope); + } + return envelope ?? null; + } catch { + return null; + } +}; + +export const listProviderSchemas = async (): Promise => { + try { + const resp = await httpGet({ + url: `/api/provider-schemas`, + }); + return resp.data ?? []; + } catch { + return []; + } +}; + +export const clearProviderSchemaCache = () => { + cache.clear(); +}; + +export const isProviderSchemaCached = (provider: string) => { + return cache.has(cacheKey(provider, CURRENT_SCHEMA_VERSION)); +}; diff --git a/ui/src/components/_shared/formNestedFieldsContext.ts b/ui/src/components/_shared/formNestedFieldsContext.ts new file mode 100644 index 000000000..4f8f0b6df --- /dev/null +++ b/ui/src/components/_shared/formNestedFieldsContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; + +export type FormNestedFieldsContextType = { + parentNamePath: string; +}; + +export const FormNestedFieldsContext = createContext({ + parentNamePath: "", +}); + +export const FormNestedFieldsContextProvider = FormNestedFieldsContext.Provider; + +export const useFormNestedFieldsContext = () => { + const context = useContext(FormNestedFieldsContext); + if (!context) { + throw new Error("`FormNestedFieldsContext` must be used within a `FormNestedFieldsContextProvider`"); + } + return context; +}; diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx index 8657c105d..47f6c35c1 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; +import { type ProviderSchemaEnvelope, getProviderSchema } from "@/api/providerschema"; +import SchemaConfigFields from "@/components/workflow/designer/forms/SchemaConfigFields"; import { ACCESS_PROVIDERS, type AccessProviderType } from "@/domain/provider"; import AccessConfigFieldsProvider1Panel from "./AccessConfigFieldsProvider1Panel"; @@ -10,7 +12,6 @@ import AccessConfigFieldsProviderACMEDNS from "./AccessConfigFieldsProviderACMED import AccessConfigFieldsProviderACMEHttpReq from "./AccessConfigFieldsProviderACMEHttpReq"; import AccessConfigFieldsProviderActalisSSL from "./AccessConfigFieldsProviderActalisSSL"; import AccessConfigFieldsProviderAkamai from "./AccessConfigFieldsProviderAkamai"; -import AccessConfigFieldsProviderAliyun from "./AccessConfigFieldsProviderAliyun"; import AccessConfigFieldsProviderAPISIX from "./AccessConfigFieldsProviderAPISIX"; import AccessConfigFieldsProviderArvanCloud from "./AccessConfigFieldsProviderArvanCloud"; import AccessConfigFieldsProviderAWS from "./AccessConfigFieldsProviderAWS"; @@ -136,7 +137,6 @@ const providerComponentMap: Partial any; deps?: unknown[] }) => { + const [envelope, setEnvelope] = useState(null); + + useEffect(() => { + let cancelled = false; + setEnvelope(null); + if (!provider) { + return; + } + void getProviderSchema(provider).then((env) => { + if (!cancelled) { + setEnvelope(env); + } + }); + return () => { + cancelled = true; + }; + }, [provider]); + const initComponent = () => { const Component = providerComponentMap[provider as AccessProviderType]; if (!Component) return null; @@ -263,9 +281,12 @@ const useComponent = (provider: string, { initProps, deps = [] }: { initProps?: return ; }; - const [component, setComponent] = useState(() => initComponent()); + const [component, setComponent] = useState(() => initComponent()); - useEffect(() => setComponent(initComponent()), [provider]); + useEffect(() => { + setComponent(envelope ? : initComponent()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider, envelope]); useEffect(() => setComponent(initComponent()), deps); return component; diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderAliyun.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderAliyun.tsx deleted file mode 100644 index ce312ebf8..000000000 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderAliyun.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import { useFormNestedFieldsContext } from "./_context"; - -const AccessConfigFormFieldsProviderAliyun = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> -
} - > - -
- - } - > - - - - } - > - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - accessKeyId: "", - accessKeySecret: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { - const { t: _ } = i18n; - - return z.object({ - accessKeyId: z.string().nonempty(), - accessKeySecret: z.string().nonempty(), - resourceGroupId: z.string().nullish(), - }); -}; - -const _default = Object.assign(AccessConfigFormFieldsProviderAliyun, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/access/forms/_context.ts b/ui/src/components/access/forms/_context.ts index 7a8081ac2..a33c492ab 100644 --- a/ui/src/components/access/forms/_context.ts +++ b/ui/src/components/access/forms/_context.ts @@ -1,21 +1,6 @@ -import { createContext, useContext } from "react"; - -// #region FormNestedFieldsContext -export type FormNestedFieldsContextType = { - parentNamePath: string; -}; - -export const FormNestedFieldsContext = createContext({ - parentNamePath: "", -}); - -export const FormNestedFieldsContextProvider = FormNestedFieldsContext.Provider; - -export const useFormNestedFieldsContext = () => { - const context = useContext(FormNestedFieldsContext); - if (!context) { - throw new Error("`FormNestedFieldsContext` must be used within a `FormNestedFieldsContextProvider`"); - } - return context; -}; -// #endregion +export { + FormNestedFieldsContext, + FormNestedFieldsContextProvider, + useFormNestedFieldsContext, + type FormNestedFieldsContextType, +} from "@/components/_shared/formNestedFieldsContext"; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index 618095fb4..4bd0c1f5c 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; +import { type ProviderSchemaEnvelope, getProviderSchema } from "@/api/providerschema"; import { DEPLOYMENT_PROVIDERS, type DeploymentProviderType } from "@/domain/provider"; import BizDeployNodeConfigFieldsProvider1Panel from "./BizDeployNodeConfigFieldsProvider1Panel"; @@ -8,7 +9,6 @@ import BizDeployNodeConfigFieldsProviderAliyunALB from "./BizDeployNodeConfigFie import BizDeployNodeConfigFieldsProviderAliyunAPIGW from "./BizDeployNodeConfigFieldsProviderAliyunAPIGW"; import BizDeployNodeConfigFieldsProviderAliyunCAS from "./BizDeployNodeConfigFieldsProviderAliyunCAS"; import BizDeployNodeConfigFieldsProviderAliyunCASDeploy from "./BizDeployNodeConfigFieldsProviderAliyunCASDeploy"; -import BizDeployNodeConfigFieldsProviderAliyunCDN from "./BizDeployNodeConfigFieldsProviderAliyunCDN"; import BizDeployNodeConfigFieldsProviderAliyunCLB from "./BizDeployNodeConfigFieldsProviderAliyunCLB"; import BizDeployNodeConfigFieldsProviderAliyunDCDN from "./BizDeployNodeConfigFieldsProviderAliyunDCDN"; import BizDeployNodeConfigFieldsProviderAliyunDDoSPro from "./BizDeployNodeConfigFieldsProviderAliyunDDoSPro"; @@ -146,6 +146,7 @@ import BizDeployNodeConfigFieldsProviderWangsuCertificate from "./BizDeployNodeC import BizDeployNodeConfigFieldsProviderWebhook from "./BizDeployNodeConfigFieldsProviderWebhook"; import BizDeployNodeConfigFieldsProviderZenlayerCDN from "./BizDeployNodeConfigFieldsProviderZenlayerCDN"; import BizDeployNodeConfigFieldsProviderZenlayerGA from "./BizDeployNodeConfigFieldsProviderZenlayerGA"; +import SchemaConfigFields from "./SchemaConfigFields"; const providerComponentMap: Partial>> = { /* @@ -159,7 +160,6 @@ const providerComponentMap: Partial any; deps?: unknown[] }) => { + const [envelope, setEnvelope] = useState(null); + + useEffect(() => { + let cancelled = false; + setEnvelope(null); + if (!provider) { + return; + } + void getProviderSchema(provider).then((env) => { + if (!cancelled) { + setEnvelope(env); + } + }); + return () => { + cancelled = true; + }; + }, [provider]); + const initComponent = () => { const Component = providerComponentMap[provider as DeploymentProviderType]; if (!Component) return null; @@ -311,9 +329,12 @@ const useComponent = (provider: string, { initProps, deps = [] }: { initProps?: return ; }; - const [component, setComponent] = useState(() => initComponent()); + const [component, setComponent] = useState(() => initComponent()); - useEffect(() => setComponent(initComponent()), [provider]); + useEffect(() => { + setComponent(envelope ? : initComponent()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider, envelope]); useEffect(() => setComponent(initComponent()), deps); return component; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunCDN.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunCDN.tsx deleted file mode 100644 index 842e11c8b..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunCDN.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { AutoComplete, Form, Input, Radio } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import Show from "@/components/Show"; -import { isDomain } from "@/utils/validator"; - -import { useFormNestedFieldsContext } from "./_context"; - -const DOMAIN_MATCH_PATTERN_EXACT = "exact" as const; -const DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" as const; -const DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" as const; - -const BizDeployNodeConfigFieldsProviderAliyunCDN = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { form: formInst, preserve: true }); - - return ( - <> - } - > - ({ value: s }))} - placeholder={t("workflow_node.deploy.form.aliyun_cdn_region.placeholder")} - /> - - - - ) : ( - void 0 - ) - } - rules={[formRule]} - > - ({ - label: t(`workflow_node.deploy.form.shared_domain_match_pattern.option.${s}.label`), - value: s, - }))} - /> - - - - - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - region: "", - domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z - .object({ - region: z.string().nullish(), - domainMatchPattern: z.string().nonempty().default(DOMAIN_MATCH_PATTERN_EXACT), - domain: z.string().nullish(), - }) - .superRefine((values, ctx) => { - if (values.domainMatchPattern) { - switch (values.domainMatchPattern) { - case DOMAIN_MATCH_PATTERN_EXACT: - case DOMAIN_MATCH_PATTERN_WILDCARD: - { - const scDomain = z.string().refine((v) => isDomain(v, { allowWildcard: true }), t("common.errmsg.domain_invalid")); - const spDomain = scDomain.safeParse(values.domain); - if (!spDomain.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spDomain.error).errors.join(), - path: ["domain"], - }); - } - } - break; - } - } - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderAliyunCDN, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx b/ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx new file mode 100644 index 000000000..3dc83b991 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx @@ -0,0 +1,66 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { BetaSchemaForm, type ProFormColumnsType } from "@ant-design/pro-components"; +import { Form } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import type { ProviderSchemaEnvelope, SchemaColumn } from "@/api/providerschema"; + +import { useFormNestedFieldsContext } from "./_context"; +import { deriveZodSchema } from "./schemaValidation"; +import { buildValueEnum, isColumnVisible, mapValueType } from "./schemaValueTypes"; + +export interface SchemaConfigFieldsProps { + envelope: ProviderSchemaEnvelope; +} + +const SchemaConfigFields = ({ envelope }: SchemaConfigFieldsProps) => { + const { t } = useTranslation(); + const { parentNamePath } = useFormNestedFieldsContext(); + const formInst = Form.useFormInstance(); + + const providerConfig = Form.useWatch | undefined>(parentNamePath, { form: formInst, preserve: true }); + const getValue = (field: string) => providerConfig?.[field]; + + const formSchema = useMemo(() => z.object({ [parentNamePath]: deriveZodSchema(envelope) }), [envelope, parentNamePath]); + const formRule = createSchemaFieldRule(formSchema); + + const columns = useMemo( + () => envelope.schema.columns.filter((col) => isColumnVisible(col, getValue)).map((col) => toColumn(col, parentNamePath, t, formRule)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [envelope, parentNamePath, t, providerConfig] + ); + + return ; +}; + +const toColumn = (col: SchemaColumn, parentNamePath: string, t: (key: string) => string, formRule: unknown): ProFormColumnsType => { + const tooltip = col.tooltipKey + ? col.tooltipHtml + ? { title: } + : t(col.tooltipKey) + : undefined; + + const extra = col.extraKey ? col.extraHtml ? : t(col.extraKey) : undefined; + + return { + title: col.labelKey ? t(col.labelKey) : col.name, + name: [parentNamePath, col.name], + valueType: mapValueType(col.valueType), + valueEnum: buildValueEnum(col, t), + initialValue: col.default, + tooltip, + fieldProps: { + placeholder: col.placeholderKey ? t(col.placeholderKey) : undefined, + min: col.min, + max: col.max, + }, + formItemProps: { + extra, + rules: [formRule as never], + }, + } as ProFormColumnsType; +}; + +export default SchemaConfigFields; diff --git a/ui/src/components/workflow/designer/forms/_context.ts b/ui/src/components/workflow/designer/forms/_context.ts index 9d72a5653..a3230acfe 100644 --- a/ui/src/components/workflow/designer/forms/_context.ts +++ b/ui/src/components/workflow/designer/forms/_context.ts @@ -1,25 +1,12 @@ -import { createContext, useContext } from "react"; +import { createContext, useContext } from "react"; import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor"; -// #region FormNestedFieldsContext -export type FormNestedFieldsContextType = { - parentNamePath: string; -}; - -export const FormNestedFieldsContext = createContext({ - parentNamePath: "", -}); - -export const FormNestedFieldsContextProvider = FormNestedFieldsContext.Provider; - -export const useFormNestedFieldsContext = () => { - const context = useContext(FormNestedFieldsContext); - if (!context) { - throw new Error("`FormNestedFieldsContext` must be used within a `FormNestedFieldsContextProvider`"); - } - return context; -}; -// #endregion +export { + FormNestedFieldsContext, + FormNestedFieldsContextProvider, + useFormNestedFieldsContext, + type FormNestedFieldsContextType, +} from "@/components/_shared/formNestedFieldsContext"; // #region NodeFormContext export type NodeFormContextType = { diff --git a/ui/src/components/workflow/designer/forms/schemaValidation.test.ts b/ui/src/components/workflow/designer/forms/schemaValidation.test.ts new file mode 100644 index 000000000..eb35312a9 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/schemaValidation.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import type { ProviderSchemaEnvelope, SchemaConditionOp } from "@/api/providerschema"; + +import { deriveZodSchema, matchCondition } from "./schemaValidation"; + +const envelope = (columns: ProviderSchemaEnvelope["schema"]["columns"]): ProviderSchemaEnvelope => ({ + schemaVersion: "form/v1", + provider: "test", + category: "deploy", + schema: { columns }, +}); + +describe("deriveZodSchema - plain object", () => { + it("validates required vs optional fields", () => { + const schema = deriveZodSchema( + envelope([ + { name: "region", valueType: "text", required: true }, + { name: "note", valueType: "text" }, + ]) + ); + + expect(schema.safeParse({ region: "cn-hangzhou", note: "x" }).success).toBe(true); + expect(schema.safeParse({ region: "", note: "x" }).success).toBe(false); + expect(schema.safeParse({ note: "x" }).success).toBe(false); + expect(schema.safeParse({ region: "cn-hangzhou" }).success).toBe(true); + }); + + it("treats unknown valueType as string (graceful fallback)", () => { + const schema = deriveZodSchema(envelope([{ name: "x", valueType: "totally-unknown", required: true }])); + expect(schema.safeParse({ x: "ok" }).success).toBe(true); + expect(schema.safeParse({ x: "" }).success).toBe(false); + }); + + it("validates number with min/max", () => { + const schema = deriveZodSchema(envelope([{ name: "port", valueType: "number", min: 1, max: 65535 }])); + expect(schema.safeParse({ port: 443 }).success).toBe(true); + expect(schema.safeParse({ port: 0 }).success).toBe(false); + expect(schema.safeParse({ port: 70000 }).success).toBe(false); + }); +}); + +describe("deriveZodSchema - discriminated union", () => { + const cdn = (): ProviderSchemaEnvelope => + envelope([ + { name: "domainMatchPattern", valueType: "radio", options: [{ value: "exact" }, { value: "wildcard" }, { value: "certsan" }] }, + { + name: "domain", + valueType: "text", + visibleWhen: [{ field: "domainMatchPattern", op: "notIn", values: ["certsan"] }], + requiredWhen: [{ field: "domainMatchPattern", op: "in", values: ["exact", "wildcard"] }], + validateWhen: [{ field: "domainMatchPattern", op: "in", values: ["exact", "wildcard"], validator: "domain", params: { allowWildcard: true } }], + }, + ]); + + it("requires domain for exact/wildcard but not certsan", () => { + const schema = deriveZodSchema(cdn()); + expect(schema.safeParse({ domainMatchPattern: "exact", domain: "example.com" }).success).toBe(true); + expect(schema.safeParse({ domainMatchPattern: "exact", domain: "" }).success).toBe(false); + expect(schema.safeParse({ domainMatchPattern: "certsan" }).success).toBe(true); + }); + + it("runs the domain validator on matching variants", () => { + const schema = deriveZodSchema(cdn()); + expect(schema.safeParse({ domainMatchPattern: "exact", domain: "not a domain" }).success).toBe(false); + expect(schema.safeParse({ domainMatchPattern: "wildcard", domain: "*.example.com" }).success).toBe(true); + }); + + it("rejects an unknown discriminator value", () => { + const schema = deriveZodSchema(cdn()); + expect(schema.safeParse({ domainMatchPattern: "bogus", domain: "example.com" }).success).toBe(false); + }); +}); + +describe("deriveZodSchema - multi-discriminator falls back to object+superRefine", () => { + it("enforces conditional required across two discriminators without throwing", () => { + const schema = deriveZodSchema( + envelope([ + { name: "useSCP", valueType: "switch" }, + { name: "fileFormat", valueType: "select", options: [{ value: "PEM" }, { value: "PFX" }] }, + { + name: "pfxPassword", + valueType: "text", + requiredWhen: [{ field: "fileFormat", op: "eq", values: ["PFX"] }], + visibleWhen: [{ field: "fileFormat", op: "eq", values: ["PFX"] }], + }, + ]) + ); + + expect(schema.safeParse({ useSCP: false, fileFormat: "PEM" }).success).toBe(true); + expect(schema.safeParse({ useSCP: false, fileFormat: "PFX", pfxPassword: "secret" }).success).toBe(true); + expect(schema.safeParse({ useSCP: false, fileFormat: "PFX", pfxPassword: "" }).success).toBe(false); + }); +}); + +describe("matchCondition", () => { + it("evaluates eq/ne/in/notIn", () => { + const c = (op: SchemaConditionOp, values: string[]) => ({ field: "f", op, values }); + expect(matchCondition(c("eq", ["a"]), "a")).toBe(true); + expect(matchCondition(c("eq", ["a"]), "b")).toBe(false); + expect(matchCondition(c("ne", ["a"]), "b")).toBe(true); + expect(matchCondition(c("in", ["a", "b"]), "b")).toBe(true); + expect(matchCondition(c("notIn", ["a"]), "b")).toBe(true); + expect(matchCondition(c("notIn", ["a"]), "a")).toBe(false); + }); +}); + +describe("deriveZodSchema - graceful degradation (future plugin)", () => { + it("derives without throwing for a future schemaVersion + unknown valueType", () => { + const future = envelope([ + { name: "futureField", valueType: "monaco-editor" }, + { name: "port", valueType: "number", min: 1, max: 65535 }, + ]); + future.schemaVersion = "form/v999"; + + expect(() => deriveZodSchema(future)).not.toThrow(); + + const schema = deriveZodSchema(future); + expect(schema.safeParse({ futureField: "anything" }).success).toBe(true); + expect(schema.safeParse({ port: 443 }).success).toBe(true); + expect(schema.safeParse({ port: 0 }).success).toBe(false); + }); +}); diff --git a/ui/src/components/workflow/designer/forms/schemaValidation.ts b/ui/src/components/workflow/designer/forms/schemaValidation.ts new file mode 100644 index 000000000..b67d91b9c --- /dev/null +++ b/ui/src/components/workflow/designer/forms/schemaValidation.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; + +import type { ProviderSchemaEnvelope, SchemaColumn, SchemaCondition, SchemaValidationRule } from "@/api/providerschema"; +import { + isCron, + isDomain, + isHostname, + isIPv4, + isIPv6, + isJsonObject, + isPortNumber, + isUrlWithHttp, + isUrlWithHttpOrHttps, + isUrlWithHttps, +} from "@/utils/validator"; + +const isEmpty = (v: unknown) => v == null || v === ""; + +export const matchCondition = (cond: SchemaCondition, value: unknown): boolean => { + const present = cond.values.includes(String(value)); + switch (cond.op) { + case "eq": + case "in": + return present; + case "ne": + case "notIn": + return !present; + default: + return true; + } +}; + +const evalVisibilityFor = (col: SchemaColumn, discName: string, value: string): boolean => { + for (const cond of col.visibleWhen ?? []) { + if (cond.field !== discName) continue; + if (!matchCondition(cond, value)) return false; + } + return true; +}; + +const matchesDisc = (col: SchemaColumn, clause: "requiredWhen" | "validateWhen", discName: string, value: string): boolean => { + const list = (col[clause] ?? []) as SchemaCondition[]; + for (const cond of list) { + if (cond.field !== discName) continue; + if (matchCondition(cond, value)) return true; + } + return false; +}; + +const runValidator = (name: string, params: Record | undefined, value: unknown): boolean => { + const s = String(value); + switch (name) { + case "domain": + return isDomain(s, { allowWildcard: !!params?.allowWildcard }); + case "hostname": + return isHostname(s); + case "ipv4": + return isIPv4(s); + case "ipv6": + return isIPv6(s); + case "port": + return isPortNumber(value as string | number); + case "url": + return isUrlWithHttpOrHttps(s); + case "url_http": + return isUrlWithHttp(s); + case "url_https": + return isUrlWithHttps(s); + case "json_object": + return isJsonObject(s); + case "cron": + return isCron(s); + case "regex": { + const pattern = params?.pattern; + if (typeof pattern !== "string") return true; + return new RegExp(pattern).test(s); + } + default: + return true; + } +}; + +const fieldBaseType = (col: SchemaColumn): z.ZodType => { + switch (col.valueType) { + case "switch": + return z.boolean(); + case "number": { + let s = z.coerce.number(); + if (col.min != null) s = s.min(col.min); + if (col.max != null) s = s.max(col.max); + return s; + } + default: + return z.string(); + } +}; + +const applyRules = (schema: z.ZodType, rules: SchemaValidationRule[], allowEmpty: boolean): z.ZodType => { + for (const rule of rules) { + const ok = (v: unknown) => (allowEmpty && isEmpty(v)) || runValidator(rule.validator, rule.params, v); + schema = schema.refine(ok, { message: `common.errmsg.${rule.validator}_invalid` }); + } + return schema; +}; + +const buildFieldSchema = (col: SchemaColumn, required: boolean, rules: SchemaValidationRule[]): z.ZodType => { + let schema = fieldBaseType(col); + if (col.valueType !== "switch" && col.valueType !== "number") { + schema = required ? (schema as z.ZodString).min(1) : schema.nullish(); + } else if (!required) { + schema = schema.nullish(); + } + return applyRules(schema, rules, !required); +}; + +const detectDiscriminator = (columns: SchemaColumn[]): SchemaColumn | null => { + const referenced = new Set(); + for (const col of columns) { + for (const clause of ["visibleWhen", "requiredWhen", "validateWhen"] as const) { + for (const cond of (col[clause] ?? []) as SchemaCondition[]) { + referenced.add(cond.field); + } + } + } + if (referenced.size !== 1) return null; + const name = [...referenced][0]; + const col = columns.find((c) => c.name === name); + if (!col || !col.options || col.options.length === 0) return null; + return col; +}; + +const buildDiscriminated = (disc: SchemaColumn, columns: SchemaColumn[]): z.ZodType => { + const variants = disc.options!.map((opt) => { + const shape: Record = { + [disc.name]: z.literal(opt.value), + }; + for (const col of columns) { + if (col.name === disc.name) continue; + if (!evalVisibilityFor(col, disc.name, opt.value)) continue; + const required = col.required === true || matchesDisc(col, "requiredWhen", disc.name, opt.value); + const rules = (col.validateWhen ?? []).filter((r) => r.field === disc.name && matchCondition(r, opt.value)); + shape[col.name] = buildFieldSchema(col, required, rules); + } + return z.object(shape); + }); + return z.discriminatedUnion(disc.name, variants as unknown as Parameters[1]); +}; + +const buildPlainObject = (columns: SchemaColumn[]): z.ZodType => { + const shape: Record = {}; + for (const col of columns) { + shape[col.name] = buildFieldSchema(col, col.required === true, []); + } + const base = z.object(shape); + return base.superRefine((values, ctx) => { + const record = values as Record; + for (const col of columns) { + const value = record[col.name]; + for (const cond of col.requiredWhen ?? []) { + if (!matchCondition(cond, record[cond.field])) continue; + if (isEmpty(value)) { + ctx.addIssue({ code: "custom", message: `common.errmsg.required`, path: [col.name] }); + } + } + for (const rule of col.validateWhen ?? []) { + if (!matchCondition(rule, record[rule.field])) continue; + if (!isEmpty(value) && !runValidator(rule.validator, rule.params, value)) { + ctx.addIssue({ code: "custom", message: `common.errmsg.${rule.validator}_invalid`, path: [col.name] }); + } + } + } + }); +}; + +export const deriveZodSchema = (envelope: ProviderSchemaEnvelope): z.ZodType => { + const columns = envelope.schema?.columns ?? []; + const disc = detectDiscriminator(columns); + if (disc) { + return buildDiscriminated(disc, columns); + } + return buildPlainObject(columns); +}; diff --git a/ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts b/ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts new file mode 100644 index 000000000..80c2c98a3 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { SchemaColumn } from "@/api/providerschema"; + +import { buildValueEnum, discriminatorFields, isColumnVisible, mapValueType } from "./schemaValueTypes"; + +describe("mapValueType", () => { + it("maps known valueTypes to ProComponents valueTypes", () => { + expect(mapValueType("text")).toBe("text"); + expect(mapValueType("textarea")).toBe("textarea"); + expect(mapValueType("select")).toBe("select"); + expect(mapValueType("radio")).toBe("radio"); + expect(mapValueType("switch")).toBe("switch"); + expect(mapValueType("number")).toBe("digit"); + expect(mapValueType("secret")).toBe("password"); + expect(mapValueType("code")).toBe("code"); + }); + + it("falls back unknown valueTypes to text (graceful degradation)", () => { + expect(mapValueType("totally-unknown")).toBe("text"); + expect(mapValueType("monaco")).toBe("text"); + expect(mapValueType("")).toBe("text"); + }); +}); + +describe("buildValueEnum", () => { + const t = (k: string) => `T(${k})`; + + it("builds a valueEnum for select/radio from options", () => { + const col: SchemaColumn = { + name: "fmt", + valueType: "select", + options: [{ value: "PEM", labelKey: "k.pem" }, { value: "PFX" }], + }; + expect(buildValueEnum(col, t)).toEqual({ + PEM: { text: "T(k.pem)" }, + PFX: { text: "PFX" }, + }); + }); + + it("returns undefined for non-select fields or fields without options", () => { + expect(buildValueEnum({ name: "x", valueType: "text" } as SchemaColumn, t)).toBeUndefined(); + expect(buildValueEnum({ name: "x", valueType: "select" } as SchemaColumn, t)).toBeUndefined(); + }); +}); + +describe("isColumnVisible", () => { + const col = (visibleWhen: SchemaColumn["visibleWhen"]): SchemaColumn => ({ name: "domain", valueType: "text", visibleWhen }); + + it("is visible when it has no visibleWhen", () => { + expect(isColumnVisible(col(undefined), () => undefined)).toBe(true); + }); + + it("hides when a visibleWhen condition does not hold", () => { + const c = col([{ field: "domainMatchPattern", op: "notIn", values: ["certsan"] }]); + expect(isColumnVisible(c, () => "certsan")).toBe(false); + expect(isColumnVisible(c, () => "exact")).toBe(true); + }); +}); + +describe("discriminatorFields", () => { + it("collects the union of dependency fields", () => { + const cols: SchemaColumn[] = [ + { name: "a", valueType: "text", dependencies: ["fmt", "useSCP"] }, + { name: "b", valueType: "text", dependencies: ["fmt"] }, + ]; + expect(discriminatorFields(cols).sort()).toEqual(["fmt", "useSCP"]); + }); +}); diff --git a/ui/src/components/workflow/designer/forms/schemaValueTypes.ts b/ui/src/components/workflow/designer/forms/schemaValueTypes.ts new file mode 100644 index 000000000..6229342d9 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/schemaValueTypes.ts @@ -0,0 +1,55 @@ +import type { SchemaColumn } from "@/api/providerschema"; + +import { matchCondition } from "./schemaValidation"; + +export const mapValueType = (valueType: string): string => { + switch (valueType) { + case "text": + return "text"; + case "textarea": + return "textarea"; + case "select": + return "select"; + case "radio": + return "radio"; + case "switch": + return "switch"; + case "number": + return "digit"; + case "secret": + return "password"; + case "code": + return "code"; + case "autocomplete": + return "text"; + default: + return "text"; + } +}; + +export const buildValueEnum = (col: SchemaColumn, t: (key: string) => string): Record | undefined => { + if (!col.options) return undefined; + if (col.valueType !== "select" && col.valueType !== "radio") return undefined; + const valueEnum: Record = {}; + for (const opt of col.options) { + valueEnum[opt.value] = { text: opt.labelKey ? t(opt.labelKey) : opt.value }; + } + return valueEnum; +}; + +export const isColumnVisible = (col: SchemaColumn, getValue: (field: string) => unknown): boolean => { + for (const cond of col.visibleWhen ?? []) { + if (!matchCondition(cond, getValue(cond.field))) return false; + } + return true; +}; + +export const discriminatorFields = (columns: SchemaColumn[]): string[] => { + const seen = new Set(); + for (const col of columns) { + for (const dep of col.dependencies ?? []) { + seen.add(dep); + } + } + return [...seen]; +}; diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 000000000..2340cf13e --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,19 @@ +import path from "node:path"; + +import reactPlugin from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [reactPlugin({})], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/vitest.setup.ts b/ui/vitest.setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/ui/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; From 5231bbb648a2a7ccdb82ac303eefb43d641944e7 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Fri, 24 Jul 2026 16:34:13 +0800 Subject: [PATCH 04/19] feat: plugin --- .gitignore | 1 + go.mod | 9 +- go.sum | 16 + internal/certmgmt/deployers/sp_webhook.go | 51 -- internal/pluginhost/adapter.go | 85 +++ internal/pluginhost/adapter_test.go | 24 + internal/pluginhost/catalog.go | 64 +++ internal/pluginhost/catalog_test.go | 36 ++ internal/pluginhost/e2e_test.go | 241 +++++++++ internal/pluginhost/host.go | 196 +++++++ internal/pluginhost/host_test.go | 191 +++++++ internal/pluginhost/state.go | 13 + internal/providerschema/emit_test.go | 3 +- internal/providerschema/registry.go | 45 ++ internal/providerschema/service.go | 14 +- internal/providerschema/service_deps.go | 2 + internal/providerschema/service_test.go | 44 ++ internal/repository/providerschema.go | 9 + internal/rest/handlers/plugincatalog.go | 26 + internal/rest/routes/routes.go | 2 + main.go | 33 ++ .../deployer/providers/webhook/webhook.go | 226 -------- .../providers/webhook/webhook_test.go | 57 -- pkg/plugin/config.go | 36 ++ pkg/plugin/deployer_grpc.go | 165 ++++++ pkg/plugin/deployer_grpc_test.go | 173 ++++++ pkg/plugin/discovery.go | 156 ++++++ pkg/plugin/discovery_test.go | 139 +++++ pkg/plugin/errors.go | 96 ++++ pkg/plugin/errors_test.go | 41 ++ pkg/plugin/handshake.go | 17 + pkg/plugin/logging.go | 105 ++++ pkg/plugin/logging_test.go | 68 +++ pkg/plugin/manager.go | 216 ++++++++ pkg/plugin/manager_test.go | 182 +++++++ pkg/plugin/manifest.go | 73 +++ pkg/plugin/manifest_test.go | 54 ++ pkg/plugin/proto/plugin.pb.go | 506 ++++++++++++++++++ pkg/plugin/proto/plugin.proto | 46 ++ pkg/plugin/proto/plugin_grpc.pb.go | 197 +++++++ pkg/plugin/semver.go | 38 ++ pkg/plugin/semver_test.go | 39 ++ pkg/plugin/testing_helpers_test.go | 20 + pkg/plugin/testplugin/main.go | 70 +++ ui/public/imgs/providers/plugin.svg | 5 + ui/src/api/plugincatalog.ts | 23 + ui/src/components/access/AccessForm.tsx | 2 +- .../provider/AccessProviderPicker.tsx | 7 +- .../provider/DeploymentProviderPicker.tsx | 16 +- .../BizDeployNodeConfigFieldsProvider.tsx | 2 - ...zDeployNodeConfigFieldsProviderWebhook.tsx | 94 ---- ui/src/domain/pluginCatalog.ts | 47 ++ ui/src/domain/provider.ts | 5 +- ui/src/i18n/index.ts | 7 +- ui/src/i18n/resources/en/index.ts | 28 +- ui/src/i18n/resources/en/nls.provider.json | 4 + .../i18n/resources/en/nls.workflow.nodes.json | 11 - ui/src/i18n/resources/zh/index.ts | 28 +- ui/src/i18n/resources/zh/nls.provider.json | 4 + .../i18n/resources/zh/nls.workflow.nodes.json | 11 - ui/src/main.tsx | 17 +- ui/src/stores/pluginCatalog/index.ts | 33 ++ 62 files changed, 3666 insertions(+), 503 deletions(-) delete mode 100644 internal/certmgmt/deployers/sp_webhook.go create mode 100644 internal/pluginhost/adapter.go create mode 100644 internal/pluginhost/adapter_test.go create mode 100644 internal/pluginhost/catalog.go create mode 100644 internal/pluginhost/catalog_test.go create mode 100644 internal/pluginhost/e2e_test.go create mode 100644 internal/pluginhost/host.go create mode 100644 internal/pluginhost/host_test.go create mode 100644 internal/pluginhost/state.go create mode 100644 internal/rest/handlers/plugincatalog.go delete mode 100644 pkg/core/deployer/providers/webhook/webhook.go delete mode 100644 pkg/core/deployer/providers/webhook/webhook_test.go create mode 100644 pkg/plugin/config.go create mode 100644 pkg/plugin/deployer_grpc.go create mode 100644 pkg/plugin/deployer_grpc_test.go create mode 100644 pkg/plugin/discovery.go create mode 100644 pkg/plugin/discovery_test.go create mode 100644 pkg/plugin/errors.go create mode 100644 pkg/plugin/errors_test.go create mode 100644 pkg/plugin/handshake.go create mode 100644 pkg/plugin/logging.go create mode 100644 pkg/plugin/logging_test.go create mode 100644 pkg/plugin/manager.go create mode 100644 pkg/plugin/manager_test.go create mode 100644 pkg/plugin/manifest.go create mode 100644 pkg/plugin/manifest_test.go create mode 100644 pkg/plugin/proto/plugin.pb.go create mode 100644 pkg/plugin/proto/plugin.proto create mode 100644 pkg/plugin/proto/plugin_grpc.pb.go create mode 100644 pkg/plugin/semver.go create mode 100644 pkg/plugin/semver_test.go create mode 100644 pkg/plugin/testing_helpers_test.go create mode 100644 pkg/plugin/testplugin/main.go create mode 100644 ui/public/imgs/providers/plugin.svg create mode 100644 ui/src/api/plugincatalog.ts delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx create mode 100644 ui/src/domain/pluginCatalog.ts create mode 100644 ui/src/stores/pluginCatalog/index.ts diff --git a/.gitignore b/.gitignore index 269287f28..f75385042 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ build /certimate.exe /docker/data /pb_data +/plugins diff --git a/go.mod b/go.mod index 422b6b6ee..b7f6e5e60 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,8 @@ require ( 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.205 github.com/jdcloud-api/jdcloud-sdk-go v1.67.0 github.com/jlaffaye/ftp v0.2.0 @@ -94,6 +96,8 @@ require ( golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 google.golang.org/api v0.288.0 + google.golang.org/grpc v1.82.0 + google.golang.org/protobuf v1.36.11 k8s.io/api v0.35.3 k8s.io/apimachinery v0.35.3 k8s.io/client-go v0.35.3 @@ -212,6 +216,7 @@ require ( github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect github.com/go-test/deep v1.1.1 // indirect github.com/gofrs/flock v0.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect @@ -220,6 +225,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 @@ -245,6 +251,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.116.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 @@ -269,8 +276,6 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect - google.golang.org/grpc v1.82.0 // 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.72.3 // indirect diff --git a/go.sum b/go.sum index 8d7507d9f..7f097ce46 100644 --- a/go.sum +++ b/go.sum @@ -263,6 +263,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.71 h1:xkwkLuX3xcDZaIp3WNWalv3x6CMXicKtIhwWipTq5+k= github.com/byteplus-sdk/byteplus-go-sdk-v2 v1.0.71/go.mod h1:eqR3bDX3XtdPBcB9vERznOyi0MAcAI1GFAdcRspNMBU= github.com/byteplus-sdk/byteplus-sdk-golang v1.0.16/go.mod h1:7iCaE+dR9EycrJU0GQyMhptbInLbQhsKXiDKDjNi8Vs= @@ -328,6 +330,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= @@ -568,6 +571,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= @@ -589,6 +594,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.205 h1:YnI2+56S5oxByz8BYChRrsmqTUmCp1Ww2KIzZtF4XOU= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.205/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= @@ -615,6 +622,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= @@ -672,6 +681,8 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO 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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= @@ -750,6 +761,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= @@ -884,6 +897,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= @@ -1230,7 +1244,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= diff --git a/internal/certmgmt/deployers/sp_webhook.go b/internal/certmgmt/deployers/sp_webhook.go deleted file mode 100644 index d21181857..000000000 --- a/internal/certmgmt/deployers/sp_webhook.go +++ /dev/null @@ -1,51 +0,0 @@ -package deployers - -import ( - "fmt" - "net/http" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/pkg/core" - dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/webhook" - xhttp "github.com/certimate-go/certimate/pkg/utils/http" - xmaps "github.com/certimate-go/certimate/pkg/utils/maps" -) - -func init() { - Registries.MustRegister(domain.DeploymentProviderTypeWebhook, func(options *ProviderFactoryOptions) (core.Deployer, error) { - credentials := domain.AccessConfigForWebhook{} - if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { - return nil, fmt.Errorf("failed to populate provider access config: %w", err) - } - - mergedHeaders := make(map[string]string) - if defaultHeadersString := credentials.HeadersString; defaultHeadersString != "" { - h, err := xhttp.ParseHeaders(defaultHeadersString) - if err != nil { - return nil, fmt.Errorf("failed to parse webhook headers: %w", err) - } - for key := range h { - mergedHeaders[http.CanonicalHeaderKey(key)] = h.Get(key) - } - } - if extendedHeadersString := xmaps.GetString(options.ProviderExtendedConfig, "headers"); extendedHeadersString != "" { - h, err := xhttp.ParseHeaders(extendedHeadersString) - if err != nil { - return nil, fmt.Errorf("failed to parse webhook headers: %w", err) - } - for key := range h { - mergedHeaders[http.CanonicalHeaderKey(key)] = h.Get(key) - } - } - - provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{ - WebhookUrl: credentials.Url, - WebhookData: xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "webhookData", credentials.DataString), - Method: credentials.Method, - Headers: mergedHeaders, - Timeout: xmaps.GetInt(options.ProviderExtendedConfig, "timeout"), - AllowInsecureConnections: credentials.AllowInsecureConnections, - }) - return provider, err - }) -} diff --git a/internal/pluginhost/adapter.go b/internal/pluginhost/adapter.go new file mode 100644 index 000000000..a953f2e48 --- /dev/null +++ b/internal/pluginhost/adapter.go @@ -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) + 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) +} diff --git a/internal/pluginhost/adapter_test.go b/internal/pluginhost/adapter_test.go new file mode 100644 index 000000000..99d04b431 --- /dev/null +++ b/internal/pluginhost/adapter_test.go @@ -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) + } +} diff --git a/internal/pluginhost/catalog.go b/internal/pluginhost/catalog.go new file mode 100644 index 000000000..e7bc912c9 --- /dev/null +++ b/internal/pluginhost/catalog.go @@ -0,0 +1,64 @@ +package pluginhost + +import ( + "sort" + "sync" +) + +const SourcePlugin = "plugin" + +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"` +} + +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() +} + +func (c *Catalog) sortLocked() { + sort.SliceStable(c.entries, func(i, j int) bool { + return c.entries[i].ProviderType < c.entries[j].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) 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 +} diff --git a/internal/pluginhost/catalog_test.go b/internal/pluginhost/catalog_test.go new file mode 100644 index 000000000..e11db467f --- /dev/null +++ b/internal/pluginhost/catalog_test.go @@ -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) + } +} diff --git a/internal/pluginhost/e2e_test.go b/internal/pluginhost/e2e_test.go new file mode 100644 index 000000000..960ecddda --- /dev/null +++ b/internal/pluginhost/e2e_test.go @@ -0,0 +1,241 @@ +package pluginhost_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "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/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, "headers": "Authorization: Bearer e2e-secret"}) + extended, _ := json.Marshal(map[string]string{"method": "POST", "path": "cert"}) + + deployer, err := factory(&deployers.ProviderFactoryOptions{ + ProviderAccessConfig: jsonMap(access), + ProviderExtendedConfig: jsonMap(extended), + }) + if err != nil { + t.Fatalf("factory: %v", err) + } + deployer.SetLogger(slog.Default()) + if _, err := deployer.Deploy(context.Background(), "E2E_CERT", "E2E_KEY"); err != nil { + t.Fatalf("deploy through full stack: %v", err) + } + + if seen.method != "POST" || seen.path != "/cert" { + t.Fatalf("webhook target saw %s %s", seen.method, seen.path) + } + if seen.auth != "Bearer e2e-secret" { + t.Fatalf("auth = %q", seen.auth) + } + if !strings.Contains(seen.body, "E2E_CERT") { + t.Fatalf("cert not forwarded: %s", seen.body) + } +} + +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 +} diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go new file mode 100644 index 000000000..24f6ffac6 --- /dev/null +++ b/internal/pluginhost/host.go @@ -0,0 +1,196 @@ +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) + + 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, + }) + + 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 := mimeForExt(filepath.Ext(iconName)) + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) +} + +func mimeForExt(ext string) string { + switch strings.ToLower(ext) { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + default: + return "image/svg+xml" + } +} diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go new file mode 100644 index 000000000..8368ac9a4 --- /dev/null +++ b/internal/pluginhost/host_test.go @@ -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) + } +} diff --git a/internal/pluginhost/state.go b/internal/pluginhost/state.go new file mode 100644 index 000000000..c38c661e7 --- /dev/null +++ b/internal/pluginhost/state.go @@ -0,0 +1,13 @@ +package pluginhost + +var globalCatalog = NewCatalog() + +func SetGlobalCatalog(c *Catalog) { + if c != nil { + globalCatalog = c + } +} + +func GlobalCatalog() *Catalog { + return globalCatalog +} diff --git a/internal/providerschema/emit_test.go b/internal/providerschema/emit_test.go index de59d0e24..a9e0e8643 100644 --- a/internal/providerschema/emit_test.go +++ b/internal/providerschema/emit_test.go @@ -58,7 +58,8 @@ func TestEmit_ConditionalsAndDependencies(t *testing.T) { s, _ := New("ssh", CategoryDeploy). Field("useSCP", ValueTypeSwitch). Field("fileFormat", ValueTypeSelect, Options("PEM", "PFX")). - Field("pfxPassword", ValueTypeSecret, + Field( + "pfxPassword", ValueTypeSecret, VisibleWhen("fileFormat").Equals("PFX"), RequiredWhen("fileFormat").Equals("PFX"), VisibleWhen("useSCP").Equals("true"), diff --git a/internal/providerschema/registry.go b/internal/providerschema/registry.go index 2844cc9e2..03b86b717 100644 --- a/internal/providerschema/registry.go +++ b/internal/providerschema/registry.go @@ -62,3 +62,48 @@ func newRegistry[T comparable]() Registry[T] { } var Registries = newRegistry[string]() + +type EnvelopeRegistry interface { + RegisterEnvelope(name string, env *Envelope) error + GetEnvelope(name string) (*Envelope, bool) + ListEnvelopes() []*Envelope +} + +type envelopeRegistry struct { + envelopes map[string]*Envelope +} + +func (r *envelopeRegistry) RegisterEnvelope(name string, env *Envelope) error { + 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) { + env, ok := r.envelopes[name] + return env, ok +} + +func (r *envelopeRegistry) ListEnvelopes() []*Envelope { + 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 newEnvelopeRegistry() EnvelopeRegistry { + return &envelopeRegistry{envelopes: make(map[string]*Envelope)} +} + +var Envelopes = newEnvelopeRegistry() diff --git a/internal/providerschema/service.go b/internal/providerschema/service.go index a0ab561a7..7725aea66 100644 --- a/internal/providerschema/service.go +++ b/internal/providerschema/service.go @@ -11,6 +11,9 @@ func NewProviderSchemaService(repo providerSchemaRepository) *ProviderSchemaServ } 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 @@ -19,13 +22,16 @@ func (s *ProviderSchemaService) GetByProviderType(ctx context.Context, providerT } func (s *ProviderSchemaService) List(ctx context.Context) ([]*Envelope, error) { - schemas, err := s.repo.List(ctx) + builtIn, err := s.repo.List(ctx) if err != nil { return nil, err } - out := make([]*Envelope, len(schemas)) - for i, sc := range schemas { - out[i] = Emit(sc) + 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 } diff --git a/internal/providerschema/service_deps.go b/internal/providerschema/service_deps.go index 1c73d366c..6836eef52 100644 --- a/internal/providerschema/service_deps.go +++ b/internal/providerschema/service_deps.go @@ -5,4 +5,6 @@ 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) } diff --git a/internal/providerschema/service_test.go b/internal/providerschema/service_test.go index b18a808cb..6afe26b30 100644 --- a/internal/providerschema/service_test.go +++ b/internal/providerschema/service_test.go @@ -34,6 +34,50 @@ func (f *fakeRepo) List(_ context.Context) ([]*Schema, error) { 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}} diff --git a/internal/repository/providerschema.go b/internal/repository/providerschema.go index 099a1f77e..8a851cb39 100644 --- a/internal/repository/providerschema.go +++ b/internal/repository/providerschema.go @@ -33,3 +33,12 @@ func (r *ProviderSchemaRepository) List(_ context.Context) ([]*providerschema.Sc } 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 +} diff --git a/internal/rest/handlers/plugincatalog.go b/internal/rest/handlers/plugincatalog.go new file mode 100644 index 000000000..e53eb89a7 --- /dev/null +++ b/internal/rest/handlers/plugincatalog.go @@ -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()) +} diff --git a/internal/rest/routes/routes.go b/internal/rest/routes/routes.go index 62cb29989..a60a896f9 100644 --- a/internal/rest/routes/routes.go +++ b/internal/rest/routes/routes.go @@ -7,6 +7,7 @@ import ( "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" @@ -44,4 +45,5 @@ func BindRouter(router *router.Router[*core.RequestEvent]) { handlers.NewStatisticsHandler(group, statisticsSvc) handlers.NewNotificationsHandler(group, notifySvc) handlers.NewProviderSchemaHandler(group, providerSchemaSvc) + handlers.NewPluginCatalogHandler(group, pluginhost.GlobalCatalog()) } diff --git a/main.go b/main.go index 488665584..d7037d476 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,10 @@ package main import ( + "context" "log/slog" "os" + "path/filepath" "strings" _ "time/tzdata" @@ -15,10 +17,12 @@ 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" @@ -56,6 +60,7 @@ func main() { } settings.Setup() + scanPlugins() return nil }) @@ -99,3 +104,31 @@ 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")) + 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)) + } +} + +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") +} diff --git a/pkg/core/deployer/providers/webhook/webhook.go b/pkg/core/deployer/providers/webhook/webhook.go deleted file mode 100644 index 638e39b0a..000000000 --- a/pkg/core/deployer/providers/webhook/webhook.go +++ /dev/null @@ -1,226 +0,0 @@ -package webhook - -import ( - "context" - "crypto/tls" - "encoding/json" - "fmt" - "log/slog" - "mime" - "net/http" - "net/url" - "strings" - "time" - - "github.com/go-resty/resty/v2" - - "github.com/certimate-go/certimate/pkg/core" - xcert "github.com/certimate-go/certimate/pkg/utils/cert" - xcertx509 "github.com/certimate-go/certimate/pkg/utils/cert/x509" - xmaps "github.com/certimate-go/certimate/pkg/utils/maps" -) - -type ( - Provider = core.Deployer - DeployResult = core.DeployerDeployResult -) - -type DeployerConfig struct { - // Webhook URL。 - WebhookUrl string `json:"webhookUrl"` - // Webhook 回调数据(application/json 或 application/x-www-form-urlencoded 格式)。 - WebhookData string `json:"webhookData,omitempty"` - // 请求谓词。 - // 零值时默认值 "POST"。 - Method string `json:"method,omitempty"` - // 请求标头。 - Headers map[string]string `json:"headers,omitempty"` - // 请求超时(单位:秒)。 - // 零值时默认值 30。 - Timeout int `json:"timeout,omitempty"` - // 是否允许不安全的连接。 - AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` -} - -type Deployer struct { - config *DeployerConfig - logger *slog.Logger - httpClient *resty.Client -} - -var _ Provider = (*Deployer)(nil) - -const ( - contentTypeJson = "application/json" - contentTypeForm = "application/x-www-form-urlencoded" - contentTypeMultipart = "multipart/form-data" -) - -var allowedContentTypes = map[string]bool{ - contentTypeJson: true, - contentTypeForm: true, - contentTypeMultipart: true, -} - -var allowedMethods = map[string]bool{ - http.MethodGet: true, - http.MethodPost: true, - http.MethodPut: true, - http.MethodPatch: true, - http.MethodDelete: true, -} - -func NewDeployer(config *DeployerConfig) (*Deployer, error) { - if config == nil { - return nil, fmt.Errorf("the configuration of the deployer provider is nil") - } - - client := resty.New(). - SetTimeout(30 * time.Second). - SetRetryCount(3). - SetRetryWaitTime(5 * time.Second). - AddRetryCondition(func(resp *resty.Response, _ error) bool { - return resp == nil || resp.StatusCode() >= 500 - }) - if config.Timeout > 0 { - client.SetTimeout(time.Duration(config.Timeout) * time.Second) - } - if config.AllowInsecureConnections { - client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) - } - - return &Deployer{ - config: config, - logger: slog.Default(), - httpClient: client, - }, 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) { - // 解析证书内容 - certX509, err := xcert.ParseCertificateFromPEM(certPEM) - if err != nil { - return nil, fmt.Errorf("failed to parse x509: %w", err) - } - - // 提取服务器证书和中间证书 - serverCertPEM, issuerCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM) - if err != nil { - return nil, fmt.Errorf("failed to extract certs: %w", err) - } - - // 处理 Webhook URL - webhookUrl, err := url.Parse(d.config.WebhookUrl) - if err != nil { - return nil, fmt.Errorf("failed to parse webhook url: %w", err) - } else if webhookUrl.Scheme != "http" && webhookUrl.Scheme != "https" { - return nil, fmt.Errorf("unsupported webhook url scheme '%s'", webhookUrl.Scheme) - } - - // 处理 Webhook 请求谓词 - webhookMethod := strings.ToUpper(d.config.Method) - if webhookMethod == "" { - webhookMethod = http.MethodPost - } else if !allowedMethods[webhookMethod] { - return nil, fmt.Errorf("unsupported webhook request method '%s'", webhookMethod) - } - - // 处理 Webhook 请求标头 - webhookHeaders := make(http.Header) - for k, v := range d.config.Headers { - webhookHeaders.Set(k, v) - } - - // 处理 Webhook 请求内容类型 - webhookContentType := webhookHeaders.Get("Content-Type") - if webhookContentType == "" { - webhookContentType = contentTypeJson - webhookHeaders.Set("Content-Type", contentTypeJson) - } else if mediaType, _, err := mime.ParseMediaType(webhookContentType); err != nil || !allowedContentTypes[mediaType] { - return nil, fmt.Errorf("unsupported webhook content type '%s'", webhookContentType) - } - - // 处理 Webhook 请求数据 - var webhookData any - if d.config.WebhookData == "" { - webhookData = map[string]string{ - "name": strings.Join(xcertx509.GetSubjectAltNames(certX509), ";"), - "cert": certPEM, - "privkey": privkeyPEM, - } - } else { - err = json.Unmarshal([]byte(d.config.WebhookData), &webhookData) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) - } - - if webhookMethod == http.MethodGet || webhookContentType == contentTypeForm || webhookContentType == contentTypeMultipart { - temp := make(map[string]string) - jsonb, err := json.Marshal(webhookData) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) - } else if err := json.Unmarshal(jsonb, &temp); err != nil { - return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) - } else { - webhookData = temp - } - } - } - - // 替换变量值 - webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${CERTIMATE_DEPLOYER_COMMONNAME}", url.PathEscape(xcertx509.GetSubjectCommonName(certX509))) - xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_COMMONNAME}", xcertx509.GetSubjectCommonName(certX509)) - xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}", strings.Join(xcertx509.GetSubjectAltNames(certX509), ";")) - xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE}", certPEM) - xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}", serverCertPEM) - xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}", issuerCertPEM) - xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_PRIVATEKEY}", privkeyPEM) - - // 兼容旧版变量 - // TODO: remove in future version - webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${DOMAIN}", url.PathEscape(certX509.Subject.CommonName)) - xmaps.DeepReplaceValueUnsafe(webhookData, "${DOMAIN}", certX509.Subject.CommonName) - xmaps.DeepReplaceValueUnsafe(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";")) - xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIFICATE}", certPEM) - xmaps.DeepReplaceValueUnsafe(webhookData, "${SERVER_CERTIFICATE}", serverCertPEM) - xmaps.DeepReplaceValueUnsafe(webhookData, "${INTERMEDIA_CERTIFICATE}", issuerCertPEM) - xmaps.DeepReplaceValueUnsafe(webhookData, "${PRIVATE_KEY}", privkeyPEM) - - // 生成请求 - // 其中 GET 请求需转换为查询参数 - req := d.httpClient.R().SetHeaderMultiValues(webhookHeaders) - req.URL = webhookUrl.String() - req.Method = webhookMethod - if webhookMethod == http.MethodGet { - req.SetQueryParams(webhookData.(map[string]string)) - } else { - switch webhookContentType { - case contentTypeJson: - req.SetBody(webhookData) - case contentTypeForm: - req.SetFormData(webhookData.(map[string]string)) - case contentTypeMultipart: - req.SetMultipartFormData(webhookData.(map[string]string)) - } - } - - // 发送请求 - resp, err := req.Send() - if err != nil { - return nil, fmt.Errorf("failed to send webhook request: %w", err) - } else if resp.IsError() { - return nil, fmt.Errorf("unexpected webhook response status code: %d", resp.StatusCode()) - } - - d.logger.Debug("webhook responded", slog.Any("response", resp.String())) - - return &DeployResult{}, nil -} diff --git a/pkg/core/deployer/providers/webhook/webhook_test.go b/pkg/core/deployer/providers/webhook/webhook_test.go deleted file mode 100644 index 41e7ad3b4..000000000 --- a/pkg/core/deployer/providers/webhook/webhook_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package webhook_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/webhook" - tester "github.com/certimate-go/certimate/pkg/core/deployer/testing" -) - -var ( - fp = tester.Args("WEBHOOK_") - fTestCertPath string - fTestKeyPath string - fWebhookUrl string - fWebhookContentType string - fWebhookData string -) - -func init() { - fp.DefineString(&fTestCertPath, "TESTCERTPATH") - fp.DefineString(&fTestKeyPath, "TESTKEYPATH") - fp.DefineString(&fWebhookUrl, "URL") - fp.DefineString(&fWebhookContentType, "CONTENTTYPE", "application/json") - fp.DefineString(&fWebhookData, "DATA") -} - -/* -Shell command to run this test: - - go test -v ./webhook_test.go -args \ - --WEBHOOK_TESTCERTPATH="/path/to/your-test-cert.pem" \ - --WEBHOOK_TESTKEYPATH="/path/to/your-test-key.pem" \ - --WEBHOOK_URL="https://example.com/your-webhook-url" \ - --WEBHOOK_CONTENTTYPE="application/json" \ - --WEBHOOK_DATA="{\"certificate\":\"${CERTIFICATE}\",\"privateKey\":\"${PRIVATE_KEY}\"}" -*/ -func TestProvider(t *testing.T) { - fp.Parse() - - t.Run("Deploy", func(t *testing.T) { - provider, err := impl.NewDeployer(&impl.DeployerConfig{ - WebhookUrl: fWebhookUrl, - WebhookData: fWebhookData, - Method: "POST", - Headers: map[string]string{ - "Content-Type": fWebhookContentType, - }, - AllowInsecureConnections: true, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go new file mode 100644 index 000000000..6fbbaf0e8 --- /dev/null +++ b/pkg/plugin/config.go @@ -0,0 +1,36 @@ +package plugin + +import "time" + +const ( + defaultMinPort = 10000 + defaultMaxPort = 25000 + defaultStartTimeout = 10 * time.Second +) + +type PluginConfig struct { + PluginDir string + MinPort uint + MaxPort uint + StartTimeout time.Duration + 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.LogLevel == "" { + c.LogLevel = "INFO" + } +} diff --git a/pkg/plugin/deployer_grpc.go b/pkg/plugin/deployer_grpc.go new file mode 100644 index 000000000..698b15c7b --- /dev/null +++ b/pkg/plugin/deployer_grpc.go @@ -0,0 +1,165 @@ +package plugin + +import ( + "context" + + 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) (*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(ctx context.Context, req *proto.DeployRequest) (*proto.DeployResponse, error) { + res, err := s.impl.Deploy(ctx, &DeployRequest{ + LogLevel: req.LogLevel, + AccessConfigJSON: req.AccessConfigJson, + ExtendedConfigJSON: req.ExtendedConfigJson, + CertificatePEM: req.CertificatePem, + PrivateKeyPEM: req.PrivateKeyPem, + }) + if err != nil { + return nil, err + } + return &proto.DeployResponse{ExtendedDataJson: res.ExtendedDataJSON}, nil +} + +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) (*DeployResult, error) { + resp, 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 + } + return &DeployResult{ExtendedDataJSON: resp.ExtendedDataJson}, nil +} diff --git a/pkg/plugin/deployer_grpc_test.go b/pkg/plugin/deployer_grpc_test.go new file mode 100644 index 000000000..80d5ec83f --- /dev/null +++ b/pkg/plugin/deployer_grpc_test.go @@ -0,0 +1,173 @@ +package plugin + +import ( + "context" + "errors" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + "github.com/certimate-go/certimate/pkg/plugin/proto" +) + +type fakeDeployer struct { + metadata *Metadata + configSchema *ConfigSchema + deployRequest *DeployRequest + deployResult *DeployResult + deployErr error + metadataCalls int + schemaCalls int + deployCalls int +} + +func (f *fakeDeployer) GetMetadata(ctx context.Context) (*Metadata, error) { + f.metadataCalls++ + return f.metadata, nil +} + +func (f *fakeDeployer) GetConfigSchema(ctx context.Context) (*ConfigSchema, error) { + f.schemaCalls++ + return f.configSchema, nil +} + +func (f *fakeDeployer) Deploy(ctx context.Context, req *DeployRequest) (*DeployResult, error) { + f.deployCalls++ + f.deployRequest = req + if f.deployErr != nil { + return nil, f.deployErr + } + return f.deployResult, nil +} + +func newRoundTrip(t *testing.T, impl Deployer) (Deployer, func()) { + t.Helper() + lis := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + proto.RegisterDeployerPluginServer(srv, &deployerGRPCServer{impl: impl}) + go func() { _ = srv.Serve(lis) }() + + ctx := context.Background() + conn, err := grpc.DialContext( + ctx, "bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock(), + ) + if err != nil { + t.Fatalf("dial bufnet: %v", err) + } + p := &DeployerGRPCPlugin{} + raw, err := p.GRPCClient(ctx, nil, conn) + if err != nil { + t.Fatalf("grpc client: %v", err) + } + cleanup := func() { + _ = conn.Close() + srv.Stop() + } + return raw.(Deployer), cleanup +} + +func TestDeployerGRPC_GetMetadataRoundTrip(t *testing.T) { + impl := &fakeDeployer{metadata: &Metadata{ + ProviderType: "webhook-deployer", + AccessProviderType: "webhook-deployer", + ProtocolVersion: ProtocolVersion, + DeployCategory: "other", + DeployDisplayNameKey: "plugin.webhook-deployer.name", + AccessDisplayNameKey: "plugin.webhook-deployer.name", + }} + client, cleanup := newRoundTrip(t, impl) + defer cleanup() + + got, err := client.GetMetadata(context.Background()) + if err != nil { + t.Fatalf("GetMetadata: %v", err) + } + if got.ProviderType != "webhook-deployer" || got.ProtocolVersion != ProtocolVersion { + t.Fatalf("metadata mismatch: %+v", got) + } + if impl.metadataCalls != 1 { + t.Fatalf("expected 1 metadata call, got %d", impl.metadataCalls) + } +} + +func TestDeployerGRPC_GetConfigSchemaRoundTrip(t *testing.T) { + impl := &fakeDeployer{configSchema: &ConfigSchema{ + AccessSchemaJSON: []byte(`{"schemaVersion":"form/v1","provider":"webhook-deployer","category":"access"}`), + DeploySchemaJSON: []byte(`{"schemaVersion":"form/v1","provider":"webhook-deployer","category":"deploy"}`), + I18n: map[string]map[string]string{ + "zh": {"plugin.webhook-deployer.name": "Webhook 部署"}, + "en": {"plugin.webhook-deployer.name": "Webhook Deployer"}, + }, + }} + client, cleanup := newRoundTrip(t, impl) + defer cleanup() + + got, err := client.GetConfigSchema(context.Background()) + if err != nil { + t.Fatalf("GetConfigSchema: %v", err) + } + if string(got.AccessSchemaJSON) == "" { + t.Fatal("empty access schema") + } + if got.I18n["zh"]["plugin.webhook-deployer.name"] != "Webhook 部署" { + t.Fatalf("i18n zh mismatch: %v", got.I18n) + } + if got.I18n["en"]["plugin.webhook-deployer.name"] != "Webhook Deployer" { + t.Fatalf("i18n en mismatch: %v", got.I18n) + } +} + +func TestDeployerGRPC_DeployRoundTrip(t *testing.T) { + impl := &fakeDeployer{deployResult: &DeployResult{ExtendedDataJSON: `{"foo":"bar"}`}} + client, cleanup := newRoundTrip(t, impl) + defer cleanup() + + req := &DeployRequest{ + LogLevel: "DEBUG", + AccessConfigJSON: `{"url":"https://example.com","secret":"tok"}`, + ExtendedConfigJSON: `{"method":"POST","path":"/cert"}`, + CertificatePEM: "CERT", + PrivateKeyPEM: "KEY", + } + res, err := client.Deploy(context.Background(), req) + if err != nil { + t.Fatalf("Deploy: %v", err) + } + if res.ExtendedDataJSON != `{"foo":"bar"}` { + t.Fatalf("deploy result mismatch: %v", res) + } + if impl.deployRequest.CertificatePEM != "CERT" || impl.deployRequest.PrivateKeyPEM != "KEY" { + t.Fatalf("cert/key not forwarded: %+v", impl.deployRequest) + } + if impl.deployRequest.AccessConfigJSON == "" || impl.deployRequest.ExtendedConfigJSON == "" { + t.Fatalf("config not forwarded: %+v", impl.deployRequest) + } + if impl.deployRequest.LogLevel != "DEBUG" { + t.Fatalf("log level not forwarded: %q", impl.deployRequest.LogLevel) + } +} + +func TestDeployerGRPC_PluginErrorPreserved(t *testing.T) { + impl := &fakeDeployer{deployErr: errors.New("boom from plugin")} + client, cleanup := newRoundTrip(t, impl) + defer cleanup() + + _, err := client.Deploy(context.Background(), &DeployRequest{}) + if err == nil { + t.Fatal("expected plugin error, got nil") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got %T: %v", err, err) + } + if st.Message() != "boom from plugin" { + t.Fatalf("expected message preserved across RPC, got %q", st.Message()) + } +} diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go new file mode 100644 index 000000000..5d8ef6a69 --- /dev/null +++ b/pkg/plugin/discovery.go @@ -0,0 +1,156 @@ +package plugin + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" +) + +type DiscoveredPlugin struct { + Manifest *Manifest + Dir string + BinaryPath string +} + +type DiscoveryFailure struct { + ProviderType string + Dir string + Err error +} + +func (f DiscoveryFailure) Error() string { + return fmt.Sprintf("plugin discovery %s: %v", f.Dir, f.Err) +} + +func (f DiscoveryFailure) Unwrap() error { + return f.Err +} + +func Discover(ctx context.Context, cfg PluginConfig) ([]*DiscoveredPlugin, []DiscoveryFailure) { + cfg.defaults() + + entries, err := os.ReadDir(cfg.PluginDir) + if err != nil { + return nil, []DiscoveryFailure{{Dir: cfg.PluginDir, Err: fmt.Errorf("read plugin dir: %w", err)}} + } + + var found []*DiscoveredPlugin + var failures []DiscoveryFailure + for _, entry := range entries { + if ctx.Err() != nil { + break + } + if !entry.IsDir() { + continue + } + dir := filepath.Join(cfg.PluginDir, entry.Name()) + dp, ferr := discoverOne(dir, cfg.CoreVersion) + if ferr != nil { + failures = append(failures, *ferr) + continue + } + found = append(found, dp) + } + return found, failures +} + +func discoverOne(dir, coreVersion string) (*DiscoveredPlugin, *DiscoveryFailure) { + manifest, _, err := loadManifest(dir) + if err != nil { + return nil, &DiscoveryFailure{Dir: dir, Err: err} + } + + if err := versionGate(manifest, coreVersion); err != nil { + return nil, &DiscoveryFailure{ProviderType: manifest.ProviderType, Dir: dir, Err: err} + } + + if manifest.OS != "" && manifest.Arch != "" { + if manifest.OS != runtime.GOOS || manifest.Arch != runtime.GOARCH { + return nil, &DiscoveryFailure{ + ProviderType: manifest.ProviderType, + Dir: dir, + Err: &ErrPluginIncompatible{ + ProviderType: manifest.ProviderType, + Reason: fmt.Sprintf("os/arch mismatch: manifest %s/%s, host %s/%s", manifest.OS, manifest.Arch, runtime.GOOS, runtime.GOARCH), + }, + } + } + } + + binaryPath, err := resolveBinary(dir, manifest.Binary) + if err != nil { + return nil, &DiscoveryFailure{ProviderType: manifest.ProviderType, Dir: dir, Err: err} + } + + if err := permissionGate(dir, binaryPath); err != nil { + return nil, &DiscoveryFailure{ProviderType: manifest.ProviderType, Dir: dir, Err: err} + } + + return &DiscoveredPlugin{Manifest: manifest, Dir: dir, BinaryPath: binaryPath}, nil +} + +func versionGate(m *Manifest, coreVersion string) error { + if m.ProtocolVersion != ProtocolVersion { + return &ErrPluginIncompatible{ + ProviderType: m.ProviderType, + Have: m.ProtocolVersion, + Want: ProtocolVersion, + } + } + if coreVersion == "" { + return nil + } + if m.MinCoreVersion != "" && semverLess(coreVersion, m.MinCoreVersion) { + return &ErrPluginIncompatible{ProviderType: m.ProviderType, Reason: fmt.Sprintf("core %s below min %s", coreVersion, m.MinCoreVersion)} + } + if m.MaxCoreVersion != "" && semverLess(m.MaxCoreVersion, coreVersion) { + return &ErrPluginIncompatible{ProviderType: m.ProviderType, Reason: fmt.Sprintf("core %s above max %s", coreVersion, m.MaxCoreVersion)} + } + return nil +} + +func resolveBinary(dir, name string) (string, error) { + candidate := name + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(dir, name) + } + abs, err := filepath.Abs(candidate) + if err != nil { + return "", fmt.Errorf("plugin: resolve binary path: %w", err) + } + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("plugin: binary not found %q: %w", abs, err) + } + if info.IsDir() { + return "", fmt.Errorf("plugin: binary path %q is a directory", abs) + } + return abs, nil +} + +func permissionGate(dir, binaryPath string) error { + if runtime.GOOS == "windows" { + return nil + } + for _, path := range []string{dir, binaryPath} { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("plugin: stat %q: %w", path, err) + } + mode := info.Mode().Perm() + if mode&(writeGroup|writeOther) != 0 { + return &ErrPluginConfig{ + ProviderType: filepath.Base(dir), + Inner: fmt.Errorf("refusing group/world-writable path %q (mode %o)", path, mode), + } + } + } + return nil +} + +const ( + writeGroup = 0o020 + writeOther = 0o002 +) diff --git a/pkg/plugin/discovery_test.go b/pkg/plugin/discovery_test.go new file mode 100644 index 000000000..41002f3a7 --- /dev/null +++ b/pkg/plugin/discovery_test.go @@ -0,0 +1,139 @@ +package plugin + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +func writePluginDir(t *testing.T, layout map[string]map[string]string) string { + t.Helper() + root := t.TempDir() + for subdir, files := range layout { + dir := filepath.Join(root, subdir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for name, content := range files { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + } + return root +} + +func manifestJSON(overrides ...func(map[string]any)) string { + m := map[string]any{ + "version": "0.1.0", + "provider_type": "demo", + "access_provider_type": "demo", + "display_name_key": "plugin.demo.name", + "deploy_category": "other", + "protocol_version": ProtocolVersion, + "binary": "demo-binary", + } + for _, o := range overrides { + o(m) + } + return string(jsonMarshal(m)) +} + +func TestDiscovery_ParsesManifestAndResolvesAbsPath_NoExec(t *testing.T) { + dir := writePluginDir(t, map[string]map[string]string{ + "demo": { + "manifest.json": manifestJSON(), + "demo-binary": "#!/bin/sh\nexit 0\n", + }, + }) + found, failures := Discover(testContext(t), PluginConfig{PluginDir: dir, CoreVersion: "0.1.0"}) + if len(failures) != 0 { + t.Fatalf("unexpected failures: %+v", failures) + } + if len(found) != 1 || found[0].Manifest.ProviderType != "demo" { + t.Fatalf("expected 1 demo plugin, got %+v", found) + } + abs := found[0].BinaryPath + if !filepath.IsAbs(abs) { + t.Fatalf("binary path not absolute: %q", abs) + } +} + +func TestDiscovery_VersionGate_RejectsIncompatible_NoExec(t *testing.T) { + dir := writePluginDir(t, map[string]map[string]string{ + "demo": { + "manifest.json": manifestJSON(func(m map[string]any) { m["protocol_version"] = 999 }), + "demo-binary": "", + }, + }) + found, failures := Discover(testContext(t), PluginConfig{PluginDir: dir}) + if len(found) != 0 { + t.Fatalf("expected no plugins discovered, got %+v", found) + } + if len(failures) != 1 { + t.Fatalf("expected 1 failure, got %+v", failures) + } + var ie *ErrPluginIncompatible + if !errors.As(failures[0].Err, &ie) { + t.Fatalf("expected ErrPluginIncompatible, got %v", failures[0].Err) + } +} + +func TestDiscovery_MinMaxCoreGate(t *testing.T) { + dir := writePluginDir(t, map[string]map[string]string{ + "demo": { + "manifest.json": manifestJSON(func(m map[string]any) { m["min_core_version"] = "9.0.0" }), + "demo-binary": "", + }, + }) + found, failures := Discover(testContext(t), PluginConfig{PluginDir: dir, CoreVersion: "0.1.0"}) + if len(found) != 0 || len(failures) != 1 { + t.Fatalf("expected rejection for core below min, found=%+v failures=%+v", found, failures) + } + var ie *ErrPluginIncompatible + if !errors.As(failures[0].Err, &ie) { + t.Fatalf("expected ErrPluginIncompatible, got %v", failures[0].Err) + } +} + +func TestDiscovery_PermissionGate_RejectsWorldWritable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits not applicable on windows") + } + dir := writePluginDir(t, map[string]map[string]string{ + "demo": { + "manifest.json": manifestJSON(), + "demo-binary": "x", + }, + }) + if err := os.Chmod(filepath.Join(dir, "demo", "demo-binary"), 0o777); err != nil { + t.Fatal(err) + } + found, failures := Discover(testContext(t), PluginConfig{PluginDir: dir, CoreVersion: "0.1.0"}) + if len(found) != 0 || len(failures) != 1 { + t.Fatalf("expected rejection for world-writable binary, found=%+v failures=%+v", found, failures) + } +} + +func TestDiscovery_MissingBinary_Fails(t *testing.T) { + dir := writePluginDir(t, map[string]map[string]string{ + "demo": { + "manifest.json": manifestJSON(), + }, + }) + found, failures := Discover(testContext(t), PluginConfig{PluginDir: dir, CoreVersion: "0.1.0"}) + if len(found) != 0 || len(failures) != 1 { + t.Fatalf("expected failure for missing binary, found=%+v failures=%+v", found, failures) + } +} + +func TestDiscovery_EmptyDir_NoFailures(t *testing.T) { + dir := t.TempDir() + found, failures := Discover(testContext(t), PluginConfig{PluginDir: dir}) + if len(found) != 0 || len(failures) != 0 { + t.Fatalf("expected nothing for empty dir, found=%+v failures=%+v", found, failures) + } +} diff --git a/pkg/plugin/errors.go b/pkg/plugin/errors.go new file mode 100644 index 000000000..ed4318bd7 --- /dev/null +++ b/pkg/plugin/errors.go @@ -0,0 +1,96 @@ +package plugin + +import ( + "errors" + "fmt" +) + +type ErrPluginNotFound struct { + ProviderType string +} + +func (e *ErrPluginNotFound) Error() string { + return fmt.Sprintf("plugin: provider %q is not installed", e.ProviderType) +} + +type ErrPluginIncompatible struct { + ProviderType string + Have uint32 + Want uint32 + Reason string +} + +func (e *ErrPluginIncompatible) Error() string { + if e.Reason != "" { + return fmt.Sprintf("plugin: provider %q incompatible: %s", e.ProviderType, e.Reason) + } + return fmt.Sprintf("plugin: provider %q incompatible protocol version: have %d, want %d", e.ProviderType, e.Have, e.Want) +} + +type ErrPluginCrashed struct { + ProviderType string + StderrTail string + Inner error +} + +func (e *ErrPluginCrashed) Error() string { + inner := "" + if e.Inner != nil { + inner = e.Inner.Error() + } + if e.StderrTail != "" { + return fmt.Sprintf("plugin: provider %q crashed: %s\nstderr tail:\n%s", e.ProviderType, inner, e.StderrTail) + } + return fmt.Sprintf("plugin: provider %q crashed: %s", e.ProviderType, inner) +} + +func (e *ErrPluginCrashed) Unwrap() error { return e.Inner } + +type ErrPluginChecksum struct { + ProviderType string + Have string + Want string +} + +func (e *ErrPluginChecksum) Error() string { + return fmt.Sprintf("plugin: provider %q checksum mismatch: have %s, want %s (advisory)", e.ProviderType, e.Have, e.Want) +} + +type ErrPluginConfig struct { + ProviderType string + Inner error +} + +func (e *ErrPluginConfig) Error() string { + return fmt.Sprintf("plugin: provider %q misconfigured: %v", e.ProviderType, e.Inner) +} + +func (e *ErrPluginConfig) Unwrap() error { return e.Inner } + +func AsPluginError(err error) error { + if err == nil { + return nil + } + var nf *ErrPluginNotFound + var inc *ErrPluginIncompatible + var crash *ErrPluginCrashed + if errors.As(err, &nf) || errors.As(err, &inc) || errors.As(err, &crash) { + return err + } + return err +} + +func IsPluginNotFound(err error) bool { + var e *ErrPluginNotFound + return errors.As(err, &e) +} + +func IsPluginIncompatible(err error) bool { + var e *ErrPluginIncompatible + return errors.As(err, &e) +} + +func IsPluginCrashed(err error) bool { + var e *ErrPluginCrashed + return errors.As(err, &e) +} diff --git a/pkg/plugin/errors_test.go b/pkg/plugin/errors_test.go new file mode 100644 index 000000000..da93727e1 --- /dev/null +++ b/pkg/plugin/errors_test.go @@ -0,0 +1,41 @@ +package plugin + +import ( + "errors" + "testing" +) + +func TestTypedErrors_Distinguishable(t *testing.T) { + notFound := &ErrPluginNotFound{ProviderType: "x"} + incompat := &ErrPluginIncompatible{ProviderType: "x", Have: 1, Want: 2} + crashed := &ErrPluginCrashed{ProviderType: "x", StderrTail: "boom", Inner: errors.New("conn closed")} + + if !IsPluginNotFound(notFound) || IsPluginNotFound(incompat) { + t.Fatal("IsPluginNotFound misclassified") + } + if !IsPluginIncompatible(incompat) || IsPluginIncompatible(crashed) { + t.Fatal("IsPluginIncompatible misclassified") + } + if !IsPluginCrashed(crashed) || IsPluginCrashed(notFound) { + t.Fatal("IsPluginCrashed misclassified") + } + + var target *ErrPluginCrashed + if !errors.As(crashed, &target) { + t.Fatal("errors.As failed for ErrPluginCrashed") + } + if target.StderrTail != "boom" { + t.Fatalf("stderr tail not preserved: %q", target.StderrTail) + } +} + +func TestErrPluginIncompatible_WithReason(t *testing.T) { + e := &ErrPluginIncompatible{ProviderType: "x", Reason: "core above max"} + if e.Error() == "" { + t.Fatal("empty message") + } + e2 := &ErrPluginIncompatible{ProviderType: "x", Have: 1, Want: 2} + if e2.Error() == "" { + t.Fatal("empty message without reason") + } +} diff --git a/pkg/plugin/handshake.go b/pkg/plugin/handshake.go new file mode 100644 index 000000000..6d332a6c1 --- /dev/null +++ b/pkg/plugin/handshake.go @@ -0,0 +1,17 @@ +package plugin + +import ( + githubplugin "github.com/hashicorp/go-plugin" +) + +const ProtocolVersion = 1 + +const MagicCookieKey = "CERTIMATE_PLUGIN" + +const MagicCookieValue = "certimate-deployer-plugin" + +var HandshakeConfig = githubplugin.HandshakeConfig{ + ProtocolVersion: ProtocolVersion, + MagicCookieKey: MagicCookieKey, + MagicCookieValue: MagicCookieValue, +} diff --git a/pkg/plugin/logging.go b/pkg/plugin/logging.go new file mode 100644 index 000000000..877bdd394 --- /dev/null +++ b/pkg/plugin/logging.go @@ -0,0 +1,105 @@ +package plugin + +import ( + "context" + "io" + "log" + "log/slog" + + gohclog "github.com/hashicorp/go-hclog" +) + +type slogHclogAdapter struct { + logger *slog.Logger + level gohclog.Level + name string + implied []any +} + +func NewHclogLogger(base *slog.Logger) gohclog.Logger { + if base == nil { + base = slog.Default() + } + return &slogHclogAdapter{logger: base, level: gohclog.Info} +} + +func (a *slogHclogAdapter) Log(level gohclog.Level, msg string, args ...any) { + a.logAt(level, msg, args) +} + +func (a *slogHclogAdapter) Trace(msg string, args ...any) { a.logAt(gohclog.Trace, msg, args) } +func (a *slogHclogAdapter) Debug(msg string, args ...any) { a.logAt(gohclog.Debug, msg, args) } +func (a *slogHclogAdapter) Info(msg string, args ...any) { a.logAt(gohclog.Info, msg, args) } +func (a *slogHclogAdapter) Warn(msg string, args ...any) { a.logAt(gohclog.Warn, msg, args) } +func (a *slogHclogAdapter) Error(msg string, args ...any) { a.logAt(gohclog.Error, msg, args) } + +func (a *slogHclogAdapter) logAt(level gohclog.Level, msg string, args []any) { + if level < a.level { + return + } + attrs := a.implied + if len(args) > 0 { + attrs = append(append([]any{}, a.implied...), args...) + } + ctx := context.Background() + switch { + case level >= gohclog.Error: + a.logger.Log(ctx, slog.LevelError, msg, attrs...) + case level >= gohclog.Warn: + a.logger.Log(ctx, slog.LevelWarn, msg, attrs...) + case level >= gohclog.Info: + a.logger.Log(ctx, slog.LevelInfo, msg, attrs...) + default: + a.logger.Log(ctx, slog.LevelDebug, msg, attrs...) + } +} + +func (a *slogHclogAdapter) IsTrace() bool { return a.level <= gohclog.Trace } +func (a *slogHclogAdapter) IsDebug() bool { return a.level <= gohclog.Debug } +func (a *slogHclogAdapter) IsInfo() bool { return a.level <= gohclog.Info } +func (a *slogHclogAdapter) IsWarn() bool { return a.level <= gohclog.Warn } +func (a *slogHclogAdapter) IsError() bool { return a.level <= gohclog.Error } + +func (a *slogHclogAdapter) ImpliedArgs() []any { + out := make([]any, len(a.implied)) + copy(out, a.implied) + return out +} + +func (a *slogHclogAdapter) With(args ...any) gohclog.Logger { + clone := *a + clone.implied = append(append([]any{}, a.implied...), args...) + clone.logger = a.logger.With(args...) + return &clone +} + +func (a *slogHclogAdapter) Name() string { return a.name } + +func (a *slogHclogAdapter) Named(name string) gohclog.Logger { + clone := *a + if a.name == "" { + clone.name = name + } else { + clone.name = a.name + "." + name + } + clone.logger = a.logger.With(slog.String("plugin", clone.name)) + return &clone +} + +func (a *slogHclogAdapter) ResetNamed(name string) gohclog.Logger { + clone := *a + clone.name = name + clone.logger = a.logger.With(slog.String("plugin", name)) + return &clone +} + +func (a *slogHclogAdapter) SetLevel(level gohclog.Level) { a.level = level } +func (a *slogHclogAdapter) GetLevel() gohclog.Level { return a.level } + +func (a *slogHclogAdapter) StandardLogger(_ *gohclog.StandardLoggerOptions) *log.Logger { + return slog.NewLogLogger(a.logger.Handler(), slog.LevelInfo) +} + +func (a *slogHclogAdapter) StandardWriter(_ *gohclog.StandardLoggerOptions) io.Writer { + return io.Discard +} diff --git a/pkg/plugin/logging_test.go b/pkg/plugin/logging_test.go new file mode 100644 index 000000000..c5c4fb257 --- /dev/null +++ b/pkg/plugin/logging_test.go @@ -0,0 +1,68 @@ +package plugin + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + gohclog "github.com/hashicorp/go-hclog" +) + +func newCaptureLogger() (*slog.Logger, *bytes.Buffer) { + buf := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + return logger, buf +} + +func TestHclogLogger_AdaptsToSlog(t *testing.T) { + logger, buf := newCaptureLogger() + adapter := NewHclogLogger(logger) + + adapter.Info("hello", "provider", "demo") + adapter.Warn("careful", "code", 42) + adapter.Error("broken", "err", "x") + + out := buf.String() + for _, want := range []string{"hello", "careful", "broken"} { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in slog output, got %s", want, out) + } + } + if !strings.Contains(out, `"provider":"demo"`) { + t.Fatalf("expected kv attrs in slog output, got %s", out) + } +} + +func TestHclogLogger_LevelGuards(t *testing.T) { + logger, buf := newCaptureLogger() + adapter := NewHclogLogger(logger).(*slogHclogAdapter) + adapter.level = gohclog.Warn + + adapter.Info("should-be-suppressed") + adapter.Warn("should-pass") + + out := buf.String() + if strings.Contains(out, "should-be-suppressed") { + t.Fatalf("info leaked at warn level: %s", out) + } + if !strings.Contains(out, "should-pass") { + t.Fatalf("warn missing: %s", out) + } +} + +func TestHclogLogger_NamedReturnsLogger(t *testing.T) { + logger, _ := newCaptureLogger() + adapter := NewHclogLogger(logger) + child := adapter.Named("webhook-deployer") + if child == nil { + t.Fatal("Named returned nil") + } + if child.Name() != "webhook-deployer" { + t.Fatalf("unexpected name %q", child.Name()) + } + withChild := adapter.With("k", "v") + if withChild == nil { + t.Fatal("With returned nil") + } +} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go new file mode 100644 index 000000000..be40e0829 --- /dev/null +++ b/pkg/plugin/manager.go @@ -0,0 +1,216 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os/exec" + "strings" + + githubplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const stderrTailLimit = 8 * 1024 + +type secretRedactor func(string) string + +type Manager struct { + cfg PluginConfig + logger *slog.Logger +} + +func NewManager(cfg PluginConfig, logger *slog.Logger) *Manager { + cfg.defaults() + if logger == nil { + logger = slog.Default() + } + return &Manager{cfg: cfg, logger: logger.With(slog.String("component", "pluginmgr"))} +} + +func (m *Manager) Config() PluginConfig { return m.cfg } + +func (m *Manager) Bootstrap(ctx context.Context, dp *DiscoveredPlugin) (*Metadata, *ConfigSchema, error) { + var meta *Metadata + var schema *ConfigSchema + err := m.withPlugin(ctx, dp, noopRedactor, func(d Deployer) error { + var err error + meta, err = d.GetMetadata(ctx) + if err != nil { + return err + } + schema, err = d.GetConfigSchema(ctx) + return err + }) + if err != nil { + return nil, nil, err + } + return meta, schema, nil +} + +func (m *Manager) Deploy(ctx context.Context, dp *DiscoveredPlugin, req *DeployRequest) (*DeployResult, error) { + var res *DeployResult + err := m.withPlugin(ctx, dp, redactorFor(req), func(d Deployer) error { + var err error + res, err = d.Deploy(ctx, req) + return err + }) + return res, err +} + +type dispensed struct { + client *githubplugin.Client + deployer Deployer + stderr *bytes.Buffer +} + +func (m *Manager) clientConfig(dp *DiscoveredPlugin, stderr *bytes.Buffer) *githubplugin.ClientConfig { + cfg := &githubplugin.ClientConfig{ + HandshakeConfig: HandshakeConfig, + Plugins: PluginSetForDeployer(), + Cmd: exec.Command(dp.BinaryPath), + AllowedProtocols: []githubplugin.Protocol{githubplugin.ProtocolGRPC}, + Logger: NewHclogLogger(m.logger), + StartTimeout: m.cfg.StartTimeout, + MinPort: m.cfg.MinPort, + MaxPort: m.cfg.MaxPort, + AutoMTLS: true, + } + if stderr != nil { + cfg.Stderr = stderr + } + return cfg +} + +func (m *Manager) dispense(dp *DiscoveredPlugin) (*dispensed, error) { + stderr := &bytes.Buffer{} + client := githubplugin.NewClient(m.clientConfig(dp, stderr)) + rpcClient, err := client.Client() + if err != nil { + client.Kill() + return nil, mapStartError(dp, err) + } + raw, err := rpcClient.Dispense(PluginName) + if err != nil { + client.Kill() + return nil, fmt.Errorf("plugin: dispense %q: %w", dp.Manifest.ProviderType, err) + } + d, ok := raw.(Deployer) + if !ok { + client.Kill() + return nil, fmt.Errorf("plugin: dispensed %q is not a Deployer (%T)", dp.Manifest.ProviderType, raw) + } + return &dispensed{client: client, deployer: d, stderr: stderr}, nil +} + +func (m *Manager) withPlugin(ctx context.Context, dp *DiscoveredPlugin, redact secretRedactor, fn func(Deployer) error) error { + d, err := m.dispense(dp) + if err != nil { + return err + } + defer d.client.Kill() + + done := make(chan error, 1) + go func() { done <- fn(d.deployer) }() + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return m.wrapError(dp, d, redact, err) + } +} + +func (m *Manager) wrapError(dp *DiscoveredPlugin, d *dispensed, redact secretRedactor, err error) error { + if err == nil { + return nil + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + tail := stderrTail(d.stderr, redact) + if d.client.Exited() { + return &ErrPluginCrashed{ProviderType: dp.Manifest.ProviderType, StderrTail: tail, Inner: err} + } + if st, ok := status.FromError(err); ok { + switch st.Code() { + case codes.Unavailable, codes.Canceled: + return &ErrPluginCrashed{ProviderType: dp.Manifest.ProviderType, StderrTail: tail, Inner: err} + } + return fmt.Errorf("plugin %q: %s", dp.Manifest.ProviderType, st.Message()) + } + return fmt.Errorf("plugin %q: %w", dp.Manifest.ProviderType, err) +} + +func mapStartError(dp *DiscoveredPlugin, err error) error { + if err == nil { + return nil + } + msg := err.Error() + switch { + case strings.Contains(msg, "incompatible API version"), + strings.Contains(msg, "plugin version"), + strings.Contains(strings.ToLower(msg), "magic cookie"), + strings.Contains(strings.ToLower(msg), "protocol"): + return &ErrPluginIncompatible{ProviderType: dp.Manifest.ProviderType, Reason: msg} + } + return err +} + +func stderrTail(buf *bytes.Buffer, redact secretRedactor) string { + if buf == nil || buf.Len() == 0 { + return "" + } + data := buf.Bytes() + if len(data) > stderrTailLimit { + data = data[len(data)-stderrTailLimit:] + } + return redact(string(data)) +} + +func noopRedactor(s string) string { return s } + +func redactorFor(req *DeployRequest) secretRedactor { + var secrets []string + collect := func(jsonStr string) { + var m map[string]any + if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { + return + } + for _, v := range flattenValues(m) { + if s, ok := v.(string); ok && len(s) >= 4 { + secrets = append(secrets, s) + } + } + } + if req != nil { + collect(req.AccessConfigJSON) + collect(req.ExtendedConfigJSON) + } + return func(s string) string { + for _, sec := range secrets { + s = strings.ReplaceAll(s, sec, "[REDACTED]") + } + return s + } +} + +func flattenValues(v any) []any { + var out []any + switch t := v.(type) { + case map[string]any: + for _, child := range t { + out = append(out, flattenValues(child)...) + } + case []any: + for _, child := range t { + out = append(out, flattenValues(child)...) + } + default: + out = append(out, v) + } + return out +} diff --git a/pkg/plugin/manager_test.go b/pkg/plugin/manager_test.go new file mode 100644 index 000000000..b9a0462be --- /dev/null +++ b/pkg/plugin/manager_test.go @@ -0,0 +1,182 @@ +package plugin + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +var ( + fakeBinaryOnce sync.Once + fakeBinaryPath string + fakeBinaryErr error +) + +func fakeBinary(t *testing.T) string { + t.Helper() + fakeBinaryOnce.Do(func() { + out := filepath.Join(os.TempDir(), "certimate-fakeplugin-test") + if runtime.GOOS == "windows" { + out += ".exe" + } + cmd := exec.Command("go", "build", "-tags=fakeplugin", "-o", out, "./testplugin") + cmd.Dir = "." + if buildOut, err := cmd.CombinedOutput(); err != nil { + fakeBinaryErr = errors.New(strings.TrimSpace(string(buildOut)) + " | " + err.Error()) + return + } + fakeBinaryPath = out + }) + if fakeBinaryErr != nil { + t.Skipf("fakeplugin build failed: %v", fakeBinaryErr) + } + return fakeBinaryPath +} + +func discoveredFake(t *testing.T, providerType string) *DiscoveredPlugin { + t.Helper() + bin := fakeBinary(t) + dir := t.TempDir() + manifest := manifestJSON(func(m map[string]any) { + m["provider_type"] = providerType + m["access_provider_type"] = providerType + m["binary"] = "fakeplugin" + }) + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "fakeplugin") + if err := os.Symlink(bin, link); err != nil { + t.Fatal(err) + } + dp, ferr := discoverOne(dir, "0.1.0") + if ferr != nil { + t.Fatalf("discoverOne: %v", ferr.Err) + } + return dp +} + +func withFakeEnv(providerType, behavior string) func() { + os.Setenv("FAKEPLUGIN_PROVIDER_TYPE", providerType) + os.Setenv("FAKEPLUGIN_BEHAVIOR", behavior) + return func() { + os.Unsetenv("FAKEPLUGIN_PROVIDER_TYPE") + os.Unsetenv("FAKEPLUGIN_BEHAVIOR") + } +} + +func TestManager_Bootstrap_FetchesMetadataAndSchema(t *testing.T) { + defer withFakeEnv("bootstrap-demo", "ok")() + dp := discoveredFake(t, "bootstrap-demo") + + mgr := NewManager(PluginConfig{}, nil) + meta, schema, err := mgr.Bootstrap(context.Background(), dp) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + if meta.ProviderType != "bootstrap-demo" || meta.ProtocolVersion != ProtocolVersion { + t.Fatalf("metadata mismatch: %+v", meta) + } + if schema == nil || len(schema.DeploySchemaJSON) == 0 { + t.Fatalf("schema not fetched: %+v", schema) + } + if schema.I18n["en"]["plugin.fake.name"] == "" { + t.Fatalf("i18n not fetched: %+v", schema.I18n) + } +} + +func TestManager_Deploy_OnDemandLifecycle(t *testing.T) { + defer withFakeEnv("deploy-demo", "ok")() + dp := discoveredFake(t, "deploy-demo") + + mgr := NewManager(PluginConfig{}, nil) + res1, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + if err != nil { + t.Fatalf("first deploy: %v", err) + } + if res1.ExtendedDataJSON == "" { + t.Fatal("empty deploy result") + } + res2, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + if err != nil { + t.Fatalf("second deploy (fresh client): %v", err) + } + if res2 == nil { + t.Fatal("nil second result") + } +} + +func TestManager_Deploy_PluginConfigError_Mapped(t *testing.T) { + defer withFakeEnv("cfgerr-demo", "configerror")() + dp := discoveredFake(t, "cfgerr-demo") + + mgr := NewManager(PluginConfig{}, nil) + _, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + if err == nil { + t.Fatal("expected config error") + } + if !strings.Contains(err.Error(), "bad config") { + t.Fatalf("expected config error message preserved, got %q", err.Error()) + } +} + +func TestManager_Deploy_CrashIsolated_ReturnsErrPluginCrashed(t *testing.T) { + defer withFakeEnv("crash-demo", "crash")() + dp := discoveredFake(t, "crash-demo") + + mgr := NewManager(PluginConfig{}, nil) + _, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + if err == nil { + t.Fatal("expected crash error") + } + var crashed *ErrPluginCrashed + if !errors.As(err, &crashed) { + t.Fatalf("expected ErrPluginCrashed, got %T: %v", err, err) + } + if crashed.ProviderType != "crash-demo" { + t.Fatalf("crash error wrong provider: %q", crashed.ProviderType) + } + if crashed.StderrTail == "" { + t.Fatal("expected non-empty stderr tail") + } +} + +func TestManager_Deploy_CrashRedactsCredentials(t *testing.T) { + defer withFakeEnv("crash-redact", "crash")() + dp := discoveredFake(t, "crash-redact") + + const secret = "supersecrettoken1234" + req := &DeployRequest{ + LogLevel: "INFO", + AccessConfigJSON: `{"url":"https://example.com","secret":"` + secret + `"}`, + ExtendedConfigJSON: `{"path":"/x"}`, + CertificatePEM: "CERT", + PrivateKeyPEM: "KEY", + } + + mgr := NewManager(PluginConfig{}, nil) + _, err := mgr.Deploy(context.Background(), dp, req) + var crashed *ErrPluginCrashed + if !errors.As(err, &crashed) { + t.Fatalf("expected ErrPluginCrashed, got %T: %v", err, err) + } + if strings.Contains(crashed.StderrTail, secret) { + t.Fatalf("secret leaked into crash stderr tail:\n%s", crashed.StderrTail) + } +} + +func sampleDeployReq() *DeployRequest { + return &DeployRequest{ + LogLevel: "INFO", + AccessConfigJSON: `{"url":"https://example.com","secret":"tok"}`, + ExtendedConfigJSON: `{"method":"POST"}`, + CertificatePEM: "CERT", + PrivateKeyPEM: "KEY", + } +} diff --git a/pkg/plugin/manifest.go b/pkg/plugin/manifest.go new file mode 100644 index 000000000..7a274f689 --- /dev/null +++ b/pkg/plugin/manifest.go @@ -0,0 +1,73 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +type Manifest struct { + Version string `json:"version"` + ProviderType string `json:"provider_type"` + AccessProviderType string `json:"access_provider_type"` + DisplayNameKey string `json:"display_name_key"` + DeployCategory string `json:"deploy_category"` + ProtocolVersion uint32 `json:"protocol_version"` + MinCoreVersion string `json:"min_core_version"` + MaxCoreVersion string `json:"max_core_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Binary string `json:"binary"` + Icon string `json:"icon"` + SHA256 string `json:"sha256"` +} + +func ParseManifest(data []byte) (*Manifest, error) { + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("plugin: invalid manifest json: %w", err) + } + return &m, nil +} + +func (m *Manifest) Validate() error { + var problems []string + if m.ProviderType == "" { + problems = append(problems, "provider_type must not be empty") + } + if m.AccessProviderType == "" { + problems = append(problems, "access_provider_type must not be empty") + } + if m.Binary == "" { + problems = append(problems, "binary must not be empty") + } + if m.ProtocolVersion == 0 { + problems = append(problems, "protocol_version must not be zero") + } + if m.OS != "" || m.Arch != "" { + if m.OS == "" || m.Arch == "" { + problems = append(problems, "os and arch must be specified together") + } + } + if len(problems) > 0 { + return fmt.Errorf("plugin: manifest for %q invalid: %s", m.ProviderType, strings.Join(problems, "; ")) + } + return nil +} + +func loadManifest(dir string) (*Manifest, []byte, error) { + path := dir + "/manifest.json" + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + m, err := ParseManifest(data) + if err != nil { + return nil, nil, err + } + if err := m.Validate(); err != nil { + return nil, nil, err + } + return m, data, nil +} diff --git a/pkg/plugin/manifest_test.go b/pkg/plugin/manifest_test.go new file mode 100644 index 000000000..71293eb6e --- /dev/null +++ b/pkg/plugin/manifest_test.go @@ -0,0 +1,54 @@ +package plugin + +import ( + "testing" +) + +func TestParseManifest_Valid(t *testing.T) { + raw := []byte(`{ + "version": "0.1.0", + "provider_type": "webhook-deployer", + "access_provider_type": "webhook-deployer", + "display_name_key": "plugin.webhook-deployer.name", + "deploy_category": "other", + "protocol_version": 1, + "binary": "webhook-deployer", + "sha256": "abcd" + }`) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if err := m.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if m.ProviderType != "webhook-deployer" || m.ProtocolVersion != 1 { + t.Fatalf("unexpected manifest: %+v", m) + } +} + +func TestParseManifest_InvalidJSON(t *testing.T) { + if _, err := ParseManifest([]byte(`{not json`)); err == nil { + t.Fatal("expected parse error") + } +} + +func TestValidate_MissingFields(t *testing.T) { + m := &Manifest{ProviderType: "x"} + if err := m.Validate(); err == nil { + t.Fatal("expected validation error for missing fields") + } +} + +func TestValidate_OSArchTogether(t *testing.T) { + m := &Manifest{ + ProviderType: "x", + AccessProviderType: "x", + ProtocolVersion: 1, + Binary: "b", + OS: "linux", + } + if err := m.Validate(); err == nil { + t.Fatal("expected error when only os specified") + } +} diff --git a/pkg/plugin/proto/plugin.pb.go b/pkg/plugin/proto/plugin.pb.go new file mode 100644 index 000000000..5c8ef0175 --- /dev/null +++ b/pkg/plugin/proto/plugin.pb.go @@ -0,0 +1,506 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: pkg/plugin/proto/plugin.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetMetadataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMetadataRequest) Reset() { + *x = GetMetadataRequest{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMetadataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMetadataRequest) ProtoMessage() {} + +func (x *GetMetadataRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMetadataRequest.ProtoReflect.Descriptor instead. +func (*GetMetadataRequest) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{0} +} + +type GetMetadataResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProviderType string `protobuf:"bytes,1,opt,name=provider_type,json=providerType,proto3" json:"provider_type,omitempty"` + AccessProviderType string `protobuf:"bytes,2,opt,name=access_provider_type,json=accessProviderType,proto3" json:"access_provider_type,omitempty"` + ProtocolVersion uint32 `protobuf:"varint,3,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + DeployCategory string `protobuf:"bytes,4,opt,name=deploy_category,json=deployCategory,proto3" json:"deploy_category,omitempty"` + DeployDisplayNameKey string `protobuf:"bytes,5,opt,name=deploy_display_name_key,json=deployDisplayNameKey,proto3" json:"deploy_display_name_key,omitempty"` + AccessDisplayNameKey string `protobuf:"bytes,6,opt,name=access_display_name_key,json=accessDisplayNameKey,proto3" json:"access_display_name_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMetadataResponse) Reset() { + *x = GetMetadataResponse{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMetadataResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMetadataResponse) ProtoMessage() {} + +func (x *GetMetadataResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMetadataResponse.ProtoReflect.Descriptor instead. +func (*GetMetadataResponse) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{1} +} + +func (x *GetMetadataResponse) GetProviderType() string { + if x != nil { + return x.ProviderType + } + return "" +} + +func (x *GetMetadataResponse) GetAccessProviderType() string { + if x != nil { + return x.AccessProviderType + } + return "" +} + +func (x *GetMetadataResponse) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *GetMetadataResponse) GetDeployCategory() string { + if x != nil { + return x.DeployCategory + } + return "" +} + +func (x *GetMetadataResponse) GetDeployDisplayNameKey() string { + if x != nil { + return x.DeployDisplayNameKey + } + return "" +} + +func (x *GetMetadataResponse) GetAccessDisplayNameKey() string { + if x != nil { + return x.AccessDisplayNameKey + } + return "" +} + +type GetConfigSchemaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConfigSchemaRequest) Reset() { + *x = GetConfigSchemaRequest{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConfigSchemaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConfigSchemaRequest) ProtoMessage() {} + +func (x *GetConfigSchemaRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetConfigSchemaRequest.ProtoReflect.Descriptor instead. +func (*GetConfigSchemaRequest) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{2} +} + +type StringMap struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries map[string]string `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StringMap) Reset() { + *x = StringMap{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StringMap) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StringMap) ProtoMessage() {} + +func (x *StringMap) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StringMap.ProtoReflect.Descriptor instead. +func (*StringMap) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *StringMap) GetEntries() map[string]string { + if x != nil { + return x.Entries + } + return nil +} + +type GetConfigSchemaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccessSchemaJson []byte `protobuf:"bytes,1,opt,name=access_schema_json,json=accessSchemaJson,proto3" json:"access_schema_json,omitempty"` + DeploySchemaJson []byte `protobuf:"bytes,2,opt,name=deploy_schema_json,json=deploySchemaJson,proto3" json:"deploy_schema_json,omitempty"` + I18NResources map[string]*StringMap `protobuf:"bytes,3,rep,name=i18n_resources,json=i18nResources,proto3" json:"i18n_resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConfigSchemaResponse) Reset() { + *x = GetConfigSchemaResponse{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConfigSchemaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConfigSchemaResponse) ProtoMessage() {} + +func (x *GetConfigSchemaResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetConfigSchemaResponse.ProtoReflect.Descriptor instead. +func (*GetConfigSchemaResponse) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *GetConfigSchemaResponse) GetAccessSchemaJson() []byte { + if x != nil { + return x.AccessSchemaJson + } + return nil +} + +func (x *GetConfigSchemaResponse) GetDeploySchemaJson() []byte { + if x != nil { + return x.DeploySchemaJson + } + return nil +} + +func (x *GetConfigSchemaResponse) GetI18NResources() map[string]*StringMap { + if x != nil { + return x.I18NResources + } + return nil +} + +type DeployRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + LogLevel string `protobuf:"bytes,1,opt,name=log_level,json=logLevel,proto3" json:"log_level,omitempty"` + AccessConfigJson string `protobuf:"bytes,2,opt,name=access_config_json,json=accessConfigJson,proto3" json:"access_config_json,omitempty"` + ExtendedConfigJson string `protobuf:"bytes,3,opt,name=extended_config_json,json=extendedConfigJson,proto3" json:"extended_config_json,omitempty"` + CertificatePem string `protobuf:"bytes,4,opt,name=certificate_pem,json=certificatePem,proto3" json:"certificate_pem,omitempty"` + PrivateKeyPem string `protobuf:"bytes,5,opt,name=private_key_pem,json=privateKeyPem,proto3" json:"private_key_pem,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeployRequest) Reset() { + *x = DeployRequest{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeployRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeployRequest) ProtoMessage() {} + +func (x *DeployRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeployRequest.ProtoReflect.Descriptor instead. +func (*DeployRequest) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{5} +} + +func (x *DeployRequest) GetLogLevel() string { + if x != nil { + return x.LogLevel + } + return "" +} + +func (x *DeployRequest) GetAccessConfigJson() string { + if x != nil { + return x.AccessConfigJson + } + return "" +} + +func (x *DeployRequest) GetExtendedConfigJson() string { + if x != nil { + return x.ExtendedConfigJson + } + return "" +} + +func (x *DeployRequest) GetCertificatePem() string { + if x != nil { + return x.CertificatePem + } + return "" +} + +func (x *DeployRequest) GetPrivateKeyPem() string { + if x != nil { + return x.PrivateKeyPem + } + return "" +} + +type DeployResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExtendedDataJson string `protobuf:"bytes,1,opt,name=extended_data_json,json=extendedDataJson,proto3" json:"extended_data_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeployResponse) Reset() { + *x = DeployResponse{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeployResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeployResponse) ProtoMessage() {} + +func (x *DeployResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeployResponse.ProtoReflect.Descriptor instead. +func (*DeployResponse) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{6} +} + +func (x *DeployResponse) GetExtendedDataJson() string { + if x != nil { + return x.ExtendedDataJson + } + return "" +} + +var File_pkg_plugin_proto_plugin_proto protoreflect.FileDescriptor + +const file_pkg_plugin_proto_plugin_proto_rawDesc = "" + + "\n" + + "\x1dpkg/plugin/proto/plugin.proto\x12\x13certimate.plugin.v1\"\x14\n" + + "\x12GetMetadataRequest\"\xae\x02\n" + + "\x13GetMetadataResponse\x12#\n" + + "\rprovider_type\x18\x01 \x01(\tR\fproviderType\x120\n" + + "\x14access_provider_type\x18\x02 \x01(\tR\x12accessProviderType\x12)\n" + + "\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x12'\n" + + "\x0fdeploy_category\x18\x04 \x01(\tR\x0edeployCategory\x125\n" + + "\x17deploy_display_name_key\x18\x05 \x01(\tR\x14deployDisplayNameKey\x125\n" + + "\x17access_display_name_key\x18\x06 \x01(\tR\x14accessDisplayNameKey\"\x18\n" + + "\x16GetConfigSchemaRequest\"\x8e\x01\n" + + "\tStringMap\x12E\n" + + "\aentries\x18\x01 \x03(\v2+.certimate.plugin.v1.StringMap.EntriesEntryR\aentries\x1a:\n" + + "\fEntriesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbf\x02\n" + + "\x17GetConfigSchemaResponse\x12,\n" + + "\x12access_schema_json\x18\x01 \x01(\fR\x10accessSchemaJson\x12,\n" + + "\x12deploy_schema_json\x18\x02 \x01(\fR\x10deploySchemaJson\x12f\n" + + "\x0ei18n_resources\x18\x03 \x03(\v2?.certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntryR\ri18nResources\x1a`\n" + + "\x12I18nResourcesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x124\n" + + "\x05value\x18\x02 \x01(\v2\x1e.certimate.plugin.v1.StringMapR\x05value:\x028\x01\"\xdd\x01\n" + + "\rDeployRequest\x12\x1b\n" + + "\tlog_level\x18\x01 \x01(\tR\blogLevel\x12,\n" + + "\x12access_config_json\x18\x02 \x01(\tR\x10accessConfigJson\x120\n" + + "\x14extended_config_json\x18\x03 \x01(\tR\x12extendedConfigJson\x12'\n" + + "\x0fcertificate_pem\x18\x04 \x01(\tR\x0ecertificatePem\x12&\n" + + "\x0fprivate_key_pem\x18\x05 \x01(\tR\rprivateKeyPem\">\n" + + "\x0eDeployResponse\x12,\n" + + "\x12extended_data_json\x18\x01 \x01(\tR\x10extendedDataJson2\xb3\x02\n" + + "\x0eDeployerPlugin\x12`\n" + + "\vGetMetadata\x12'.certimate.plugin.v1.GetMetadataRequest\x1a(.certimate.plugin.v1.GetMetadataResponse\x12l\n" + + "\x0fGetConfigSchema\x12+.certimate.plugin.v1.GetConfigSchemaRequest\x1a,.certimate.plugin.v1.GetConfigSchemaResponse\x12Q\n" + + "\x06Deploy\x12\".certimate.plugin.v1.DeployRequest\x1a#.certimate.plugin.v1.DeployResponseB4Z2github.com/certimate-go/certimate/pkg/plugin/protob\x06proto3" + +var ( + file_pkg_plugin_proto_plugin_proto_rawDescOnce sync.Once + file_pkg_plugin_proto_plugin_proto_rawDescData []byte +) + +func file_pkg_plugin_proto_plugin_proto_rawDescGZIP() []byte { + file_pkg_plugin_proto_plugin_proto_rawDescOnce.Do(func() { + file_pkg_plugin_proto_plugin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pkg_plugin_proto_plugin_proto_rawDesc), len(file_pkg_plugin_proto_plugin_proto_rawDesc))) + }) + return file_pkg_plugin_proto_plugin_proto_rawDescData +} + +var file_pkg_plugin_proto_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_pkg_plugin_proto_plugin_proto_goTypes = []any{ + (*GetMetadataRequest)(nil), // 0: certimate.plugin.v1.GetMetadataRequest + (*GetMetadataResponse)(nil), // 1: certimate.plugin.v1.GetMetadataResponse + (*GetConfigSchemaRequest)(nil), // 2: certimate.plugin.v1.GetConfigSchemaRequest + (*StringMap)(nil), // 3: certimate.plugin.v1.StringMap + (*GetConfigSchemaResponse)(nil), // 4: certimate.plugin.v1.GetConfigSchemaResponse + (*DeployRequest)(nil), // 5: certimate.plugin.v1.DeployRequest + (*DeployResponse)(nil), // 6: certimate.plugin.v1.DeployResponse + nil, // 7: certimate.plugin.v1.StringMap.EntriesEntry + nil, // 8: certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry +} +var file_pkg_plugin_proto_plugin_proto_depIdxs = []int32{ + 7, // 0: certimate.plugin.v1.StringMap.entries:type_name -> certimate.plugin.v1.StringMap.EntriesEntry + 8, // 1: certimate.plugin.v1.GetConfigSchemaResponse.i18n_resources:type_name -> certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry + 3, // 2: certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry.value:type_name -> certimate.plugin.v1.StringMap + 0, // 3: certimate.plugin.v1.DeployerPlugin.GetMetadata:input_type -> certimate.plugin.v1.GetMetadataRequest + 2, // 4: certimate.plugin.v1.DeployerPlugin.GetConfigSchema:input_type -> certimate.plugin.v1.GetConfigSchemaRequest + 5, // 5: certimate.plugin.v1.DeployerPlugin.Deploy:input_type -> certimate.plugin.v1.DeployRequest + 1, // 6: certimate.plugin.v1.DeployerPlugin.GetMetadata:output_type -> certimate.plugin.v1.GetMetadataResponse + 4, // 7: certimate.plugin.v1.DeployerPlugin.GetConfigSchema:output_type -> certimate.plugin.v1.GetConfigSchemaResponse + 6, // 8: certimate.plugin.v1.DeployerPlugin.Deploy:output_type -> certimate.plugin.v1.DeployResponse + 6, // [6:9] is the sub-list for method output_type + 3, // [3:6] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pkg_plugin_proto_plugin_proto_init() } +func file_pkg_plugin_proto_plugin_proto_init() { + if File_pkg_plugin_proto_plugin_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pkg_plugin_proto_plugin_proto_rawDesc), len(file_pkg_plugin_proto_plugin_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pkg_plugin_proto_plugin_proto_goTypes, + DependencyIndexes: file_pkg_plugin_proto_plugin_proto_depIdxs, + MessageInfos: file_pkg_plugin_proto_plugin_proto_msgTypes, + }.Build() + File_pkg_plugin_proto_plugin_proto = out.File + file_pkg_plugin_proto_plugin_proto_goTypes = nil + file_pkg_plugin_proto_plugin_proto_depIdxs = nil +} diff --git a/pkg/plugin/proto/plugin.proto b/pkg/plugin/proto/plugin.proto new file mode 100644 index 000000000..be8802e24 --- /dev/null +++ b/pkg/plugin/proto/plugin.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +package certimate.plugin.v1; + +option go_package = "github.com/certimate-go/certimate/pkg/plugin/proto"; + +service DeployerPlugin { + rpc GetMetadata(GetMetadataRequest) returns (GetMetadataResponse); + rpc GetConfigSchema(GetConfigSchemaRequest) returns (GetConfigSchemaResponse); + rpc Deploy(DeployRequest) returns (DeployResponse); +} + +message GetMetadataRequest {} + +message GetMetadataResponse { + string provider_type = 1; + string access_provider_type = 2; + uint32 protocol_version = 3; + string deploy_category = 4; + string deploy_display_name_key = 5; + string access_display_name_key = 6; +} + +message GetConfigSchemaRequest {} + +message StringMap { + map entries = 1; +} + +message GetConfigSchemaResponse { + bytes access_schema_json = 1; + bytes deploy_schema_json = 2; + map i18n_resources = 3; +} + +message DeployRequest { + string log_level = 1; + string access_config_json = 2; + string extended_config_json = 3; + string certificate_pem = 4; + string private_key_pem = 5; +} + +message DeployResponse { + string extended_data_json = 1; +} diff --git a/pkg/plugin/proto/plugin_grpc.pb.go b/pkg/plugin/proto/plugin_grpc.pb.go new file mode 100644 index 000000000..381b52be4 --- /dev/null +++ b/pkg/plugin/proto/plugin_grpc.pb.go @@ -0,0 +1,197 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v5.29.3 +// source: pkg/plugin/proto/plugin.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + DeployerPlugin_GetMetadata_FullMethodName = "/certimate.plugin.v1.DeployerPlugin/GetMetadata" + DeployerPlugin_GetConfigSchema_FullMethodName = "/certimate.plugin.v1.DeployerPlugin/GetConfigSchema" + DeployerPlugin_Deploy_FullMethodName = "/certimate.plugin.v1.DeployerPlugin/Deploy" +) + +// DeployerPluginClient is the client API for DeployerPlugin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type DeployerPluginClient interface { + GetMetadata(ctx context.Context, in *GetMetadataRequest, opts ...grpc.CallOption) (*GetMetadataResponse, error) + GetConfigSchema(ctx context.Context, in *GetConfigSchemaRequest, opts ...grpc.CallOption) (*GetConfigSchemaResponse, error) + Deploy(ctx context.Context, in *DeployRequest, opts ...grpc.CallOption) (*DeployResponse, error) +} + +type deployerPluginClient struct { + cc grpc.ClientConnInterface +} + +func NewDeployerPluginClient(cc grpc.ClientConnInterface) DeployerPluginClient { + return &deployerPluginClient{cc} +} + +func (c *deployerPluginClient) GetMetadata(ctx context.Context, in *GetMetadataRequest, opts ...grpc.CallOption) (*GetMetadataResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetMetadataResponse) + err := c.cc.Invoke(ctx, DeployerPlugin_GetMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *deployerPluginClient) GetConfigSchema(ctx context.Context, in *GetConfigSchemaRequest, opts ...grpc.CallOption) (*GetConfigSchemaResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetConfigSchemaResponse) + err := c.cc.Invoke(ctx, DeployerPlugin_GetConfigSchema_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *deployerPluginClient) Deploy(ctx context.Context, in *DeployRequest, opts ...grpc.CallOption) (*DeployResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeployResponse) + err := c.cc.Invoke(ctx, DeployerPlugin_Deploy_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DeployerPluginServer is the server API for DeployerPlugin service. +// All implementations must embed UnimplementedDeployerPluginServer +// for forward compatibility. +type DeployerPluginServer interface { + GetMetadata(context.Context, *GetMetadataRequest) (*GetMetadataResponse, error) + GetConfigSchema(context.Context, *GetConfigSchemaRequest) (*GetConfigSchemaResponse, error) + Deploy(context.Context, *DeployRequest) (*DeployResponse, error) + mustEmbedUnimplementedDeployerPluginServer() +} + +// UnimplementedDeployerPluginServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDeployerPluginServer struct{} + +func (UnimplementedDeployerPluginServer) GetMetadata(context.Context, *GetMetadataRequest) (*GetMetadataResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetMetadata not implemented") +} +func (UnimplementedDeployerPluginServer) GetConfigSchema(context.Context, *GetConfigSchemaRequest) (*GetConfigSchemaResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetConfigSchema not implemented") +} +func (UnimplementedDeployerPluginServer) Deploy(context.Context, *DeployRequest) (*DeployResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Deploy not implemented") +} +func (UnimplementedDeployerPluginServer) mustEmbedUnimplementedDeployerPluginServer() {} +func (UnimplementedDeployerPluginServer) testEmbeddedByValue() {} + +// UnsafeDeployerPluginServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DeployerPluginServer will +// result in compilation errors. +type UnsafeDeployerPluginServer interface { + mustEmbedUnimplementedDeployerPluginServer() +} + +func RegisterDeployerPluginServer(s grpc.ServiceRegistrar, srv DeployerPluginServer) { + // If the following call panics, it indicates UnimplementedDeployerPluginServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DeployerPlugin_ServiceDesc, srv) +} + +func _DeployerPlugin_GetMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetMetadataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DeployerPluginServer).GetMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DeployerPlugin_GetMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DeployerPluginServer).GetMetadata(ctx, req.(*GetMetadataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DeployerPlugin_GetConfigSchema_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetConfigSchemaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DeployerPluginServer).GetConfigSchema(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DeployerPlugin_GetConfigSchema_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DeployerPluginServer).GetConfigSchema(ctx, req.(*GetConfigSchemaRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DeployerPlugin_Deploy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeployRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DeployerPluginServer).Deploy(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DeployerPlugin_Deploy_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DeployerPluginServer).Deploy(ctx, req.(*DeployRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// DeployerPlugin_ServiceDesc is the grpc.ServiceDesc for DeployerPlugin service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DeployerPlugin_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "certimate.plugin.v1.DeployerPlugin", + HandlerType: (*DeployerPluginServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetMetadata", + Handler: _DeployerPlugin_GetMetadata_Handler, + }, + { + MethodName: "GetConfigSchema", + Handler: _DeployerPlugin_GetConfigSchema_Handler, + }, + { + MethodName: "Deploy", + Handler: _DeployerPlugin_Deploy_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "pkg/plugin/proto/plugin.proto", +} diff --git a/pkg/plugin/semver.go b/pkg/plugin/semver.go new file mode 100644 index 000000000..011804478 --- /dev/null +++ b/pkg/plugin/semver.go @@ -0,0 +1,38 @@ +package plugin + +import ( + "strconv" + "strings" +) + +func semverLess(a, b string) bool { + return semverCompare(a, b) < 0 +} + +func semverCompare(a, b string) int { + pa := semverParts(a) + pb := semverParts(b) + for i := 0; i < 3; i++ { + if pa[i] != pb[i] { + if pa[i] < pb[i] { + return -1 + } + return 1 + } + } + return 0 +} + +func semverParts(v string) [3]int64 { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + var out [3]int64 + for i, part := range strings.SplitN(v, ".", 3) { + if i >= 3 { + break + } + num, _, _ := strings.Cut(part, "-") + n, _ := strconv.ParseInt(num, 10, 64) + out[i] = n + } + return out +} diff --git a/pkg/plugin/semver_test.go b/pkg/plugin/semver_test.go new file mode 100644 index 000000000..1cb85bec8 --- /dev/null +++ b/pkg/plugin/semver_test.go @@ -0,0 +1,39 @@ +package plugin + +import "testing" + +func TestSemverCompare(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"0.1.0", "0.1.0", 0}, + {"0.1.0", "0.2.0", -1}, + {"0.2.0", "0.1.0", 1}, + {"1.0.0", "0.9.9", 1}, + {"v0.1.0", "0.1.0", 0}, + {"0.1", "0.1.0", 0}, + {"1.2.3-rc", "1.2.3", 0}, + } + for _, c := range cases { + got := semverCompare(c.a, c.b) + sign := 0 + if got < 0 { + sign = -1 + } else if got > 0 { + sign = 1 + } + if sign != c.want { + t.Errorf("semverCompare(%q,%q)=%d want %d", c.a, c.b, sign, c.want) + } + } +} + +func TestSemverLess(t *testing.T) { + if !semverLess("0.1.0", "0.2.0") { + t.Fatal("0.1.0 < 0.2.0 expected") + } + if semverLess("0.2.0", "0.1.0") { + t.Fatal("0.2.0 < 0.1.0 not expected") + } +} diff --git a/pkg/plugin/testing_helpers_test.go b/pkg/plugin/testing_helpers_test.go new file mode 100644 index 000000000..d431cabdb --- /dev/null +++ b/pkg/plugin/testing_helpers_test.go @@ -0,0 +1,20 @@ +package plugin + +import ( + "context" + "encoding/json" + "testing" +) + +func testContext(t *testing.T) context.Context { + t.Helper() + return context.Background() +} + +func jsonMarshal(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} diff --git a/pkg/plugin/testplugin/main.go b/pkg/plugin/testplugin/main.go new file mode 100644 index 000000000..2d953ddca --- /dev/null +++ b/pkg/plugin/testplugin/main.go @@ -0,0 +1,70 @@ +//go:build fakeplugin + +package main + +import ( + "context" + "fmt" + "os" + + githubplugin "github.com/hashicorp/go-plugin" + + "github.com/certimate-go/certimate/pkg/plugin" +) + +type fakeDeployer struct { + behavior string +} + +func (f *fakeDeployer) GetMetadata(ctx context.Context) (*plugin.Metadata, error) { + accessType := os.Getenv("FAKEPLUGIN_ACCESS_TYPE") + if accessType == "" { + accessType = os.Getenv("FAKEPLUGIN_PROVIDER_TYPE") + } + return &plugin.Metadata{ + ProviderType: os.Getenv("FAKEPLUGIN_PROVIDER_TYPE"), + AccessProviderType: accessType, + ProtocolVersion: plugin.ProtocolVersion, + DeployCategory: "other", + DeployDisplayNameKey: "plugin.fake.name", + AccessDisplayNameKey: "plugin.fake.name", + }, nil +} + +func (f *fakeDeployer) GetConfigSchema(ctx context.Context) (*plugin.ConfigSchema, error) { + return &plugin.ConfigSchema{ + AccessSchemaJSON: []byte(`{"schemaVersion":"form/v1","provider":"fake","category":"access"}`), + DeploySchemaJSON: []byte(`{"schemaVersion":"form/v1","provider":"fake","category":"deploy"}`), + I18n: map[string]map[string]string{ + "en": {"plugin.fake.name": "Fake Plugin"}, + }, + }, nil +} + +func (f *fakeDeployer) Deploy(ctx context.Context, req *plugin.DeployRequest) (*plugin.DeployResult, error) { + fmt.Fprintf(os.Stderr, "fakeplugin deploy behavior=%s access=%s\n", f.behavior, req.AccessConfigJSON) + switch f.behavior { + case "crash": + fmt.Fprintln(os.Stderr, "fakeplugin crashing now") + os.Exit(2) + case "configerror": + return nil, fmt.Errorf("bad config: missing field") + } + return &plugin.DeployResult{ExtendedDataJSON: `{"deployed":true}`}, nil +} + +func main() { + behavior := os.Getenv("FAKEPLUGIN_BEHAVIOR") + if behavior == "" { + behavior = "ok" + } + impl := &fakeDeployer{behavior: behavior} + + githubplugin.Serve(&githubplugin.ServeConfig{ + HandshakeConfig: plugin.HandshakeConfig, + Plugins: map[string]githubplugin.Plugin{ + plugin.PluginName: &plugin.DeployerGRPCPlugin{Impl: impl}, + }, + GRPCServer: githubplugin.DefaultGRPCServer, + }) +} diff --git a/ui/public/imgs/providers/plugin.svg b/ui/public/imgs/providers/plugin.svg new file mode 100644 index 000000000..bdb3e3cc6 --- /dev/null +++ b/ui/public/imgs/providers/plugin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/api/plugincatalog.ts b/ui/src/api/plugincatalog.ts new file mode 100644 index 000000000..034840a78 --- /dev/null +++ b/ui/src/api/plugincatalog.ts @@ -0,0 +1,23 @@ +import { get as httpGet } from "./_api"; + +export interface PluginCatalogEntry { + source: string; + providerType: string; + accessProviderType: string; + deployCategory: string; + displayNameKey: string; + accessDisplayNameKey: string; + icon?: string; + i18n?: Record>; +} + +export const listPluginCatalog = async (): Promise => { + try { + const resp = await httpGet({ + url: "/api/plugin-catalog", + }); + return resp.data ?? []; + } catch { + return []; + } +}; diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index 9999d49ae..dd5795572 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -33,7 +33,7 @@ const AccessForm = ({ className, style, disabled, initialValues, mode, usage, on const formSchema = z.object({ name: z.string().nonempty().max(64), - provider: z.enum(ACCESS_PROVIDERS), + provider: z.string().nonempty(), config: z.any(), reserve: z.string().nullish(), }); diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index b3a0e21ff..df161f88a 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -4,6 +4,7 @@ import { Avatar, Card, Empty, Input, type InputRef, Tag, Typography } from "antd import Show from "@/components/Show"; import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; +import { usePluginCatalogStore } from "@/stores/pluginCatalog"; import { mergeCls } from "@/utils/css"; import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; @@ -41,11 +42,15 @@ const AccessProviderPicker = forwardRef s.loaded); + const [keyword, setKeyword] = useState(); const keywordInputRef = useRef(null); + const allProviders = useMemo(() => Array.from(accessProvidersMap.values()), [pluginCatalogLoaded]); + const dataSources = usePickerDataSource({ - dataSource: Array.from(accessProvidersMap.values()), + dataSource: allProviders, filters: [onFilter!], keyword: keyword, }); diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index ef08240bd..92e4f707f 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -1,9 +1,10 @@ -import { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Card, Divider, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd"; +import { Avatar, Card, Divider, Empty, Flex, Input, type InputRef, Tabs, Tag, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; import { DEPLOYMENT_CATEGORIES, type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider"; +import { usePluginCatalogStore } from "@/stores/pluginCatalog"; import { mergeCls } from "@/utils/css"; import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; @@ -20,6 +21,8 @@ const DeploymentProviderPicker = forwardRef { const { t } = useTranslation(); + const pluginCatalogLoaded = usePluginCatalogStore((s) => s.loaded); + const { wrapperElRef, cols } = usePickerWrapperCols(320); const [category, setCategory] = useState(DEPLOYMENT_CATEGORIES.ALL); @@ -27,8 +30,10 @@ const DeploymentProviderPicker = forwardRef(); const keywordInputRef = useRef(null); + const allProviders = useMemo(() => Array.from(deploymentProvidersMap.values()), [pluginCatalogLoaded]); + const dataSources = usePickerDataSource({ - dataSource: Array.from(deploymentProvidersMap.values()), + dataSource: allProviders, filters: [ onFilter!, (_, provider) => { @@ -64,6 +69,11 @@ const DeploymentProviderPicker = forwardRef {t(provider.name) || "\u00A0"} + {provider.source === "plugin" ? ( + + {t("provider.tag.plugin")} + + ) : null}
diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index 4bd0c1f5c..80b7ee7dd 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -143,7 +143,6 @@ import BizDeployNodeConfigFieldsProviderVolcEngineWAF from "./BizDeployNodeConfi import BizDeployNodeConfigFieldsProviderWangsuCDN from "./BizDeployNodeConfigFieldsProviderWangsuCDN"; import BizDeployNodeConfigFieldsProviderWangsuCDNPro from "./BizDeployNodeConfigFieldsProviderWangsuCDNPro"; import BizDeployNodeConfigFieldsProviderWangsuCertificate from "./BizDeployNodeConfigFieldsProviderWangsuCertificate"; -import BizDeployNodeConfigFieldsProviderWebhook from "./BizDeployNodeConfigFieldsProviderWebhook"; import BizDeployNodeConfigFieldsProviderZenlayerCDN from "./BizDeployNodeConfigFieldsProviderZenlayerCDN"; import BizDeployNodeConfigFieldsProviderZenlayerGA from "./BizDeployNodeConfigFieldsProviderZenlayerGA"; import SchemaConfigFields from "./SchemaConfigFields"; @@ -293,7 +292,6 @@ const providerComponentMap: Partial { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const handleWebhookDataBlur = () => { - const value = formInst.getFieldValue([parentNamePath, "webhookData"]); - try { - const json = JSON.stringify(JSON.parse(value), null, 2); - formInst.setFieldValue([parentNamePath, "webhookData"], json); - } catch { - return; - } - }; - - return ( - <> - -
- } mouseEnterDelay={1}> - - -
- - - -
- - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return {}; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z.object({ - webhookData: z - .string() - .nullish() - .refine((v) => { - if (!v) return true; - return isJsonObject(v); - }, t("common.errmsg.json_invalid")), - timeout: z.coerce.number().int().gt(0).or(z.literal("")).nullish(), - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderWebhook, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/domain/pluginCatalog.ts b/ui/src/domain/pluginCatalog.ts new file mode 100644 index 000000000..b934fe701 --- /dev/null +++ b/ui/src/domain/pluginCatalog.ts @@ -0,0 +1,47 @@ +import i18n from "@/i18n"; + +import type { PluginCatalogEntry } from "@/api/plugincatalog"; +import { ACCESS_USAGES, DEPLOYMENT_CATEGORIES, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider"; + +const PLUGIN_PLACEHOLDER_ICON = + "data:image/svg+xml," + + encodeURIComponent( + "" + ); + +export const pluginNamespace = (providerType: string) => `plugin.${providerType}`; + +export const applyPluginCatalog = (entries: PluginCatalogEntry[]): void => { + for (const entry of entries) { + const accessType = entry.accessProviderType || entry.providerType; + + if (entry.providerType && !deploymentProvidersMap.has(entry.providerType)) { + deploymentProvidersMap.set(entry.providerType, { + type: entry.providerType as never, + provider: accessType as never, + name: entry.displayNameKey || `${pluginNamespace(entry.providerType)}.name`, + icon: entry.icon || PLUGIN_PLACEHOLDER_ICON, + builtin: false, + source: "plugin", + category: (entry.deployCategory as never) || DEPLOYMENT_CATEGORIES.OTHER, + }); + } + + if (accessType && !accessProvidersMap.has(accessType)) { + accessProvidersMap.set(accessType, { + type: accessType as never, + name: entry.accessDisplayNameKey || `${pluginNamespace(accessType)}.name`, + icon: entry.icon || PLUGIN_PLACEHOLDER_ICON, + usages: [ACCESS_USAGES.HOSTING], + builtin: false, + source: "plugin", + }); + } + + if (entry.i18n) { + for (const [locale, bundle] of Object.entries(entry.i18n)) { + i18n.addResourceBundle(locale, "translation", bundle, true, true); + } + } + } +}; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index f3b247028..7d79fcf0e 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -1,8 +1,11 @@ +export type ProviderSource = "core" | "plugin"; + interface BaseProvider

{ type: P; name: string; icon: string; builtin: boolean; + source?: ProviderSource; } interface BaseProviderWithAccess

extends BaseProvider

{ @@ -753,7 +756,6 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({ WANGSU_CDN: `${ACCESS_PROVIDERS.WANGSU}-cdn`, WANGSU_CDNPRO: `${ACCESS_PROVIDERS.WANGSU}-cdnpro`, WANGSU_CERTIFICATE: `${ACCESS_PROVIDERS.WANGSU}-certificate`, - WEBHOOK: `${ACCESS_PROVIDERS.WEBHOOK}`, ZENLAYER_CDN: `${ACCESS_PROVIDERS.ZENLAYER}-cdn`, ZENLAYER_GA: `${ACCESS_PROVIDERS.ZENLAYER}-ga`, } as const); @@ -791,7 +793,6 @@ export const deploymentProvidersMap: Maphttps://cdn.console.wangsu.com/v2/index#/certificate/list" }, - "webhook_data": { - "label": "Webhook data (Optional)", - "placeholder": "Please enter Webhook data", - "help": "Notes: Leave it blank to use the default Webhook data provided by the selected credential.", - "vartips": "Supported variables:

  1. ${CERTIMATE_DEPLOYER_COMMONNAME}:
    The primary domain or IP address of the certificate.
  2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}:
    The domains or IP addresses of the certificate, separated by semicolons.
  3. ${CERTIMATE_DEPLOYER_CERTIFICATE}:
    The PEM format content of the certificate file.
  4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}:
    The PEM format content of the server certificate file.
  5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}:
    The PEM format content of the intermediate CA certificate file.
  6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}:
    The PEM format content of the private key file.
" - }, - "webhook_timeout": { - "label": "Webhook timeout (Optional)", - "placeholder": "Please enter Webhook timeout", - "unit": "seconds" - }, "zenlayer_cdn_deploy_target": { "option": { "domain": { diff --git a/ui/src/i18n/resources/zh/index.ts b/ui/src/i18n/resources/zh/index.ts index 95183652b..85c8ff8b0 100644 --- a/ui/src/i18n/resources/zh/index.ts +++ b/ui/src/i18n/resources/zh/index.ts @@ -1,4 +1,4 @@ -import nlsAccess from "./nls.access.json"; +import nlsAccess from "./nls.access.json"; import nlsCertificate from "./nls.certificate.json"; import nlsCommon from "./nls.common.json"; import nlsDashboard from "./nls.dashboard.json"; @@ -11,18 +11,16 @@ import nlsWorkflowNodes from "./nls.workflow.nodes.json"; import nlsWorkflowRuns from "./nls.workflow.runs.json"; import { buildTranslations } from "../utils"; -export default Object.freeze( - buildTranslations( - nlsCommon, - nlsLogin, - nlsDashboard, - nlsSettings, - nlsProvider, - nlsAccess, - nlsPreset, - nlsCertificate, - nlsWorkflow, - nlsWorkflowNodes, - nlsWorkflowRuns - ) +export default buildTranslations( + nlsCommon, + nlsLogin, + nlsDashboard, + nlsSettings, + nlsProvider, + nlsAccess, + nlsPreset, + nlsCertificate, + nlsWorkflow, + nlsWorkflowNodes, + nlsWorkflowRuns ); diff --git a/ui/src/i18n/resources/zh/nls.provider.json b/ui/src/i18n/resources/zh/nls.provider.json index 70baabbd8..d5fd1f2ac 100644 --- a/ui/src/i18n/resources/zh/nls.provider.json +++ b/ui/src/i18n/resources/zh/nls.provider.json @@ -11,6 +11,10 @@ "unavailable_divider": "以下提供商不可用(即未添加过授权凭据)" }, + "tag": { + "plugin": "插件" + }, + "category": { "all": "全部", "cdn": "CDN", diff --git a/ui/src/i18n/resources/zh/nls.workflow.nodes.json b/ui/src/i18n/resources/zh/nls.workflow.nodes.json index d43d3c1bb..a95704042 100644 --- a/ui/src/i18n/resources/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/zh/nls.workflow.nodes.json @@ -2887,17 +2887,6 @@ "help": "提示:不填写时,将上传新证书;否则,将替换原证书。", "tooltip": "这是什么?请参阅 https://cdn.console.wangsu.com/v2/index#/certificate/list" }, - "webhook_data": { - "label": "Webhook 回调数据(可选)", - "placeholder": "请输入 Webhook 回调数据以覆盖默认值", - "help": "提示:不填写时,将使用所选提供商授权的默认 Webhook 回调数据。", - "vartips": "支持的变量:
  1. ${CERTIMATE_DEPLOYER_COMMONNAME}:
    证书的主域名或 IP。
  2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}:
    证书的多域名或 IP,以半角分号隔开。
  3. ${CERTIMATE_DEPLOYER_CERTIFICATE}:
    证书文件 PEM 格式内容。
  4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}:
    证书文件(仅含服务器证书)PEM 格式内容。
  5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}:
    证书文件(仅含中间证书)PEM 格式内容。
  6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}:
    私钥文件 PEM 格式内容。
" - }, - "webhook_timeout": { - "label": "Webhook 超时时间(可选)", - "placeholder": "请输入 Webhook 超时时间", - "unit": "秒" - }, "zenlayer_cdn_deploy_target": { "option": { "domain": { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 8a313b09f..4d01620b0 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -7,11 +7,18 @@ import App from "./App"; import "./i18n"; import "./index.css"; import "./global.css"; +import { usePluginCatalogStore } from "./stores/pluginCatalog"; dayjs.extend(dayjsUtc); -createRoot(document.getElementById("root")!).render( - - - -); +const renderApp = () => { + createRoot(document.getElementById("root")!).render( + + + + ); +}; + +const pluginCatalogReady = usePluginCatalogStore.getState().init().catch(() => undefined); +const renderDeadline = new Promise((resolve) => setTimeout(resolve, 2000)); +void Promise.race([pluginCatalogReady, renderDeadline]).finally(renderApp); diff --git a/ui/src/stores/pluginCatalog/index.ts b/ui/src/stores/pluginCatalog/index.ts new file mode 100644 index 000000000..30f634b10 --- /dev/null +++ b/ui/src/stores/pluginCatalog/index.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; + +import { listPluginCatalog } from "@/api/plugincatalog"; +import { applyPluginCatalog } from "@/domain/pluginCatalog"; + +interface PluginCatalogState { + loaded: boolean; + init: () => Promise; +} + +export const usePluginCatalogStore = create((set) => { + let inflight: Promise | null = null; + return { + loaded: false, + init: () => { + if (inflight) { + return inflight; + } + inflight = (async () => { + try { + const entries = await listPluginCatalog(); + applyPluginCatalog(entries); + } catch { + // graceful degrade: builtin providers still work + } finally { + set({ loaded: true }); + inflight = null; + } + })(); + return inflight; + }, + }; +}); From 045c4b5b042816b04bfff658af4febc08ef40935 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Fri, 24 Jul 2026 21:43:11 +0800 Subject: [PATCH 05/19] feat: plugin refactor --- internal/pluginhost/catalog.go | 18 ++++ internal/pluginhost/host.go | 30 +++++-- pkg/plugin/manifest.go | 41 +++++++--- pkg/plugin/manifest_test.go | 82 +++++++++++++++++++ ui/src/api/plugincatalog.ts | 4 + .../provider/AccessProviderPicker.tsx | 20 ++++- .../provider/AccessProviderSelect.tsx | 16 +++- ui/src/domain/pluginCatalog.ts | 21 ++++- ui/src/domain/provider.ts | 1 + ui/src/i18n/resources/en/nls.provider.json | 4 + ui/src/i18n/resources/zh/nls.provider.json | 4 + 11 files changed, 216 insertions(+), 25 deletions(-) diff --git a/internal/pluginhost/catalog.go b/internal/pluginhost/catalog.go index e7bc912c9..3c0584ec4 100644 --- a/internal/pluginhost/catalog.go +++ b/internal/pluginhost/catalog.go @@ -16,6 +16,10 @@ type CatalogEntry struct { 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 { @@ -32,14 +36,28 @@ func (c *Catalog) Add(entry *CatalogEntry) { 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() diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index 24f6ffac6..f6fd54cbc 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -88,6 +88,11 @@ func registerOne(ctx context.Context, manager *plugin.Manager, dp *plugin.Discov 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, @@ -97,6 +102,9 @@ func registerOne(ctx context.Context, manager *plugin.Manager, dp *plugin.Discov AccessDisplayNameKey: meta.AccessDisplayNameKey, Icon: readIcon(dp, logger), I18n: schema.I18n, + AccessUsages: usages, + Priority: dp.Manifest.Priority, + Description: dp.Manifest.Description, }) return nil @@ -176,21 +184,29 @@ func readIcon(dp *plugin.DiscoveredPlugin, logger *slog.Logger) string { slog.String("icon", iconPath)) return "" } - mime := mimeForExt(filepath.Ext(iconName)) + 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 { +func mimeForExt(ext string) (string, bool) { switch strings.ToLower(ext) { case ".png": - return "image/png" + return "image/png", true case ".jpg", ".jpeg": - return "image/jpeg" + return "image/jpeg", true case ".gif": - return "image/gif" + return "image/gif", true case ".webp": - return "image/webp" + return "image/webp", true + case ".svg": + return "image/svg+xml", true default: - return "image/svg+xml" + return "", false } } diff --git a/pkg/plugin/manifest.go b/pkg/plugin/manifest.go index 7a274f689..2114e7293 100644 --- a/pkg/plugin/manifest.go +++ b/pkg/plugin/manifest.go @@ -7,20 +7,30 @@ import ( "strings" ) +var validAccessUsages = map[string]bool{ + "dns": true, + "hosting": true, + "notification": true, + "ca": true, +} + type Manifest struct { - Version string `json:"version"` - ProviderType string `json:"provider_type"` - AccessProviderType string `json:"access_provider_type"` - DisplayNameKey string `json:"display_name_key"` - DeployCategory string `json:"deploy_category"` - ProtocolVersion uint32 `json:"protocol_version"` - MinCoreVersion string `json:"min_core_version"` - MaxCoreVersion string `json:"max_core_version"` - OS string `json:"os"` - Arch string `json:"arch"` - Binary string `json:"binary"` - Icon string `json:"icon"` - SHA256 string `json:"sha256"` + Version string `json:"version"` + ProviderType string `json:"provider_type"` + AccessProviderType string `json:"access_provider_type"` + DisplayNameKey string `json:"display_name_key"` + DeployCategory string `json:"deploy_category"` + ProtocolVersion uint32 `json:"protocol_version"` + MinCoreVersion string `json:"min_core_version"` + MaxCoreVersion string `json:"max_core_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Binary string `json:"binary"` + Icon string `json:"icon"` + SHA256 string `json:"sha256"` + Usages []string `json:"usages"` + Priority int `json:"priority"` + Description string `json:"description"` } func ParseManifest(data []byte) (*Manifest, error) { @@ -50,6 +60,11 @@ func (m *Manifest) Validate() error { problems = append(problems, "os and arch must be specified together") } } + for _, u := range m.Usages { + if !validAccessUsages[u] { + problems = append(problems, fmt.Sprintf("usages contains invalid value %q", u)) + } + } if len(problems) > 0 { return fmt.Errorf("plugin: manifest for %q invalid: %s", m.ProviderType, strings.Join(problems, "; ")) } diff --git a/pkg/plugin/manifest_test.go b/pkg/plugin/manifest_test.go index 71293eb6e..1fb89e2f8 100644 --- a/pkg/plugin/manifest_test.go +++ b/pkg/plugin/manifest_test.go @@ -52,3 +52,85 @@ func TestValidate_OSArchTogether(t *testing.T) { t.Fatal("expected error when only os specified") } } + +func TestParseManifest_WithNewFields(t *testing.T) { + raw := []byte(`{ + "version": "0.1.0", + "provider_type": "aliyun-cdn", + "access_provider_type": "aliyun", + "display_name_key": "plugin.aliyun-cdn.name", + "deploy_category": "cdn", + "protocol_version": 1, + "binary": "aliyun-cdn", + "usages": ["dns", "hosting"], + "priority": 10, + "description": "Deploys certs to Aliyun CDN" + }`) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if err := m.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if len(m.Usages) != 2 || m.Usages[0] != "dns" || m.Usages[1] != "hosting" { + t.Fatalf("unexpected usages: %v", m.Usages) + } + if m.Priority != 10 { + t.Fatalf("unexpected priority: %d", m.Priority) + } + if m.Description != "Deploys certs to Aliyun CDN" { + t.Fatalf("unexpected description: %q", m.Description) + } +} + +func TestParseManifest_WithoutNewFields(t *testing.T) { + raw := []byte(`{ + "version": "0.1.0", + "provider_type": "webhook-deployer", + "access_provider_type": "webhook-deployer", + "display_name_key": "plugin.webhook-deployer.name", + "deploy_category": "other", + "protocol_version": 1, + "binary": "webhook-deployer" + }`) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if err := m.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if len(m.Usages) != 0 { + t.Fatalf("expected zero usages by default, got %v", m.Usages) + } + if m.Priority != 0 { + t.Fatalf("expected zero priority by default, got %d", m.Priority) + } +} + +func TestValidate_InvalidUsages(t *testing.T) { + m := &Manifest{ + ProviderType: "x", + AccessProviderType: "x", + ProtocolVersion: 1, + Binary: "b", + Usages: []string{"invalid"}, + } + if err := m.Validate(); err == nil { + t.Fatal("expected validation error for invalid usages") + } +} + +func TestValidate_EmptyUsages(t *testing.T) { + m := &Manifest{ + ProviderType: "x", + AccessProviderType: "x", + ProtocolVersion: 1, + Binary: "b", + Usages: []string{}, + } + if err := m.Validate(); err != nil { + t.Fatalf("empty usages should be valid: %v", err) + } +} diff --git a/ui/src/api/plugincatalog.ts b/ui/src/api/plugincatalog.ts index 034840a78..d3831ac1c 100644 --- a/ui/src/api/plugincatalog.ts +++ b/ui/src/api/plugincatalog.ts @@ -9,6 +9,10 @@ export interface PluginCatalogEntry { accessDisplayNameKey: string; icon?: string; i18n?: Record>; + accessUsages?: string[]; + priority?: number; + description?: string; + deployers?: string[]; } export const listPluginCatalog = async (): Promise => { diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index df161f88a..64e2f2374 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -1,6 +1,6 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Card, Empty, Input, type InputRef, Tag, Typography } from "antd"; +import { Avatar, Card, Empty, Input, type InputRef, Tag, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; @@ -9,6 +9,11 @@ import { mergeCls } from "@/utils/css"; import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; +const formatDeployers = (deployers: string[], limit = 3) => { + if (deployers.length <= limit) return deployers.join(", "); + return `${deployers.slice(0, limit).join(", ")} +${deployers.length - limit} more`; +}; + export interface AccessProviderPickerProps extends SharedPickerProps { showOptionTags?: boolean | { [key in AccessUsageType | "builtin"]?: boolean }; } @@ -56,7 +61,8 @@ const AccessProviderPicker = forwardRef { - return ( + const hasDeployers = provider.deployers && provider.deployers.length > 0; + const card = (
); + + if (hasDeployers) { + return ( + + {card} + + ); + } + + return card; }; const handleProviderTypeSelect = (value: string) => { diff --git a/ui/src/components/provider/AccessProviderSelect.tsx b/ui/src/components/provider/AccessProviderSelect.tsx index 00951d103..281e319bb 100644 --- a/ui/src/components/provider/AccessProviderSelect.tsx +++ b/ui/src/components/provider/AccessProviderSelect.tsx @@ -94,7 +94,21 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv }} options={options} optionLabelProp={void 0} - optionRender={(option) => renderOption(option.data.value)} + optionRender={(option) => { + const provider = accessProvidersMap.get(option.data.value as string); + const hasDeployers = provider?.deployers && provider.deployers.length > 0; + return ( +
+ {renderOption(option.data.value)} + {hasDeployers && ( + + {t("provider.access.usedBy")} {provider!.deployers!.slice(0, 3).join(", ")} + {provider!.deployers!.length > 3 ? ` +${provider!.deployers!.length - 3} more` : ""} + + )} +
+ ); + }} showSearch={{ filterOption: (inputValue, option) => matchSearchOption(inputValue, option!), optionFilterProp: "label", diff --git a/ui/src/domain/pluginCatalog.ts b/ui/src/domain/pluginCatalog.ts index b934fe701..280851d02 100644 --- a/ui/src/domain/pluginCatalog.ts +++ b/ui/src/domain/pluginCatalog.ts @@ -6,15 +6,26 @@ import { ACCESS_USAGES, DEPLOYMENT_CATEGORIES, accessProvidersMap, deploymentPro const PLUGIN_PLACEHOLDER_ICON = "data:image/svg+xml," + encodeURIComponent( - "" + "" ); export const pluginNamespace = (providerType: string) => `plugin.${providerType}`; +const usageMap: Record = { + dns: ACCESS_USAGES.DNS, + hosting: ACCESS_USAGES.HOSTING, + notification: ACCESS_USAGES.NOTIFICATION, + ca: ACCESS_USAGES.CA, +}; + export const applyPluginCatalog = (entries: PluginCatalogEntry[]): void => { for (const entry of entries) { const accessType = entry.accessProviderType || entry.providerType; + const usages = (entry.accessUsages || ["hosting"]) + .map((u) => usageMap[u]) + .filter(Boolean); + if (entry.providerType && !deploymentProvidersMap.has(entry.providerType)) { deploymentProvidersMap.set(entry.providerType, { type: entry.providerType as never, @@ -32,10 +43,16 @@ export const applyPluginCatalog = (entries: PluginCatalogEntry[]): void => { type: accessType as never, name: entry.accessDisplayNameKey || `${pluginNamespace(accessType)}.name`, icon: entry.icon || PLUGIN_PLACEHOLDER_ICON, - usages: [ACCESS_USAGES.HOSTING], + usages: usages as never, builtin: false, source: "plugin", + deployers: entry.deployers, }); + } else if (accessType && entry.deployers) { + const existing = accessProvidersMap.get(accessType); + if (existing) { + existing.deployers = entry.deployers; + } } if (entry.i18n) { diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 7d79fcf0e..f10851f30 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -156,6 +156,7 @@ export type AccessUsageType = (typeof ACCESS_USAGES)[keyof typeof ACCESS_USAGES] export interface AccessProvider extends BaseProvider { usages: AccessUsageType[]; + deployers?: string[]; } export const accessProvidersMap: Map = new Map( diff --git a/ui/src/i18n/resources/en/nls.provider.json b/ui/src/i18n/resources/en/nls.provider.json index 926bafb28..ec427f2b1 100644 --- a/ui/src/i18n/resources/en/nls.provider.json +++ b/ui/src/i18n/resources/en/nls.provider.json @@ -1,6 +1,10 @@ { "$ns": "provider", + "access": { + "usedBy": "Used by:" + }, + "text": { "nodata": "No providers available", "default_ca": "(Default) Follow global settings", diff --git a/ui/src/i18n/resources/zh/nls.provider.json b/ui/src/i18n/resources/zh/nls.provider.json index d5fd1f2ac..67af99732 100644 --- a/ui/src/i18n/resources/zh/nls.provider.json +++ b/ui/src/i18n/resources/zh/nls.provider.json @@ -1,6 +1,10 @@ { "$ns": "provider", + "access": { + "usedBy": "使用者:" + }, + "text": { "nodata": "暂无提供商", "default_ca": "(默认)不指定,跟随全局设置", From 59a3d594f46a6f9247a2b9ee93fcb39a6a483872 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Sat, 25 Jul 2026 07:32:32 +0800 Subject: [PATCH 06/19] feat: plugin schema embed --- pkg/plugin/embed.go | 49 +++++++++++++++++++++++++++++++++++ pkg/plugin/testplugin/main.go | 3 +++ 2 files changed, 52 insertions(+) create mode 100644 pkg/plugin/embed.go diff --git a/pkg/plugin/embed.go b/pkg/plugin/embed.go new file mode 100644 index 000000000..c27d665cc --- /dev/null +++ b/pkg/plugin/embed.go @@ -0,0 +1,49 @@ +package plugin + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "strings" +) + +func LoadDeploySchema(fsys embed.FS) ([]byte, error) { + data, err := fsys.ReadFile("schema/deploy.json") + if err != nil { + return nil, fmt.Errorf("plugin: embedded deploy schema not found: %w", err) + } + return data, nil +} + +func LoadAccessSchema(fsys embed.FS) ([]byte, error) { + data, err := fsys.ReadFile("schema/access.json") + if err != nil { + return nil, nil + } + return data, nil +} + +func LoadI18n(fsys embed.FS) (map[string]map[string]string, error) { + entries, err := fs.ReadDir(fsys, "schema/i18n") + if err != nil { + return nil, fmt.Errorf("plugin: embedded i18n directory not found: %w", err) + } + result := make(map[string]map[string]string, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + locale := strings.TrimSuffix(entry.Name(), ".json") + data, err := fsys.ReadFile("schema/i18n/" + entry.Name()) + if err != nil { + return nil, fmt.Errorf("plugin: cannot read i18n file %s: %w", entry.Name(), err) + } + var kv map[string]string + if err := json.Unmarshal(data, &kv); err != nil { + return nil, fmt.Errorf("plugin: invalid i18n JSON in %s: %w", entry.Name(), err) + } + result[locale] = kv + } + return result, nil +} diff --git a/pkg/plugin/testplugin/main.go b/pkg/plugin/testplugin/main.go index 2d953ddca..1890ffd5e 100644 --- a/pkg/plugin/testplugin/main.go +++ b/pkg/plugin/testplugin/main.go @@ -32,6 +32,9 @@ func (f *fakeDeployer) GetMetadata(ctx context.Context) (*plugin.Metadata, error } func (f *fakeDeployer) GetConfigSchema(ctx context.Context) (*plugin.ConfigSchema, error) { + // NOTE: Real plugins use //go:embed to bundle schema JSON files at compile time. + // This fakeplugin uses inline JSON for testing simplicity. + // See plugins/webhook-deployer/embed.go for the canonical pattern. return &plugin.ConfigSchema{ AccessSchemaJSON: []byte(`{"schemaVersion":"form/v1","provider":"fake","category":"access"}`), DeploySchemaJSON: []byte(`{"schemaVersion":"form/v1","provider":"fake","category":"deploy"}`), From 5ec4a49cb317616e7f06ce587a96c0d681de93a8 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Sat, 25 Jul 2026 18:57:50 +0800 Subject: [PATCH 07/19] feat: plugin hot load --- internal/certmgmt/deployers/registry.go | 22 +++- internal/pluginhost/catalog.go | 12 +++ internal/pluginhost/reloader.go | 135 +++++++++++++++++++++++ internal/pluginhost/state.go | 9 ++ internal/pluginhost/watcher.go | 137 ++++++++++++++++++++++++ internal/providerschema/registry.go | 38 ++++++- internal/rest/handlers/pluginadmin.go | 27 +++++ internal/rest/routes/routes.go | 1 + main.go | 9 ++ ui/src/stores/pluginCatalog/index.ts | 22 +++- 10 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 internal/pluginhost/reloader.go create mode 100644 internal/pluginhost/watcher.go create mode 100644 internal/rest/handlers/pluginadmin.go diff --git a/internal/certmgmt/deployers/registry.go b/internal/certmgmt/deployers/registry.go index 9ae327781..6c0e8a735 100644 --- a/internal/certmgmt/deployers/registry.go +++ b/internal/certmgmt/deployers/registry.go @@ -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)} } diff --git a/internal/pluginhost/catalog.go b/internal/pluginhost/catalog.go index 3c0584ec4..be359b599 100644 --- a/internal/pluginhost/catalog.go +++ b/internal/pluginhost/catalog.go @@ -68,6 +68,18 @@ func (c *Catalog) Entries() []CatalogEntry { 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() diff --git a/internal/pluginhost/reloader.go b/internal/pluginhost/reloader.go new file mode 100644 index 000000000..cbd5e3d11 --- /dev/null +++ b/internal/pluginhost/reloader.go @@ -0,0 +1,135 @@ +package pluginhost + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "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() + + 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) + } +} diff --git a/internal/pluginhost/state.go b/internal/pluginhost/state.go index c38c661e7..6d1318a2a 100644 --- a/internal/pluginhost/state.go +++ b/internal/pluginhost/state.go @@ -1,6 +1,7 @@ package pluginhost var globalCatalog = NewCatalog() +var globalReloader *Reloader func SetGlobalCatalog(c *Catalog) { if c != nil { @@ -11,3 +12,11 @@ func SetGlobalCatalog(c *Catalog) { func GlobalCatalog() *Catalog { return globalCatalog } + +func SetGlobalReloader(r *Reloader) { + globalReloader = r +} + +func GlobalReloader() *Reloader { + return globalReloader +} diff --git a/internal/pluginhost/watcher.go b/internal/pluginhost/watcher.go new file mode 100644 index 000000000..f3435a881 --- /dev/null +++ b/internal/pluginhost/watcher.go @@ -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 +} diff --git a/internal/providerschema/registry.go b/internal/providerschema/registry.go index 03b86b717..43f5135e8 100644 --- a/internal/providerschema/registry.go +++ b/internal/providerschema/registry.go @@ -3,6 +3,7 @@ package providerschema import ( "fmt" "sort" + "sync" ) type Registry[T comparable] interface { @@ -11,13 +12,17 @@ type Registry[T comparable] interface { 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") } @@ -29,12 +34,17 @@ func (r *registry[T]) Register(name T, schema *Schema) error { } func (r *registry[T]) MustRegister(name T, schema *Schema) { - if err := r.Register(name, schema); err != nil { - panic(err) + 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 } @@ -42,11 +52,15 @@ func (r *registry[T]) Get(name T) (*Schema, error) { } 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) @@ -57,6 +71,12 @@ func (r *registry[T]) List() []T { 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)} } @@ -67,13 +87,17 @@ 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") } @@ -85,11 +109,15 @@ func (r *envelopeRegistry) RegisterEnvelope(name string, env *Envelope) error { } 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) @@ -102,6 +130,12 @@ func (r *envelopeRegistry) ListEnvelopes() []*Envelope { 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)} } diff --git a/internal/rest/handlers/pluginadmin.go b/internal/rest/handlers/pluginadmin.go new file mode 100644 index 000000000..98ce6b89f --- /dev/null +++ b/internal/rest/handlers/pluginadmin.go @@ -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) +} diff --git a/internal/rest/routes/routes.go b/internal/rest/routes/routes.go index a60a896f9..13c5c38c2 100644 --- a/internal/rest/routes/routes.go +++ b/internal/rest/routes/routes.go @@ -46,4 +46,5 @@ func BindRouter(router *router.Router[*core.RequestEvent]) { handlers.NewNotificationsHandler(group, notifySvc) handlers.NewProviderSchemaHandler(group, providerSchemaSvc) handlers.NewPluginCatalogHandler(group, pluginhost.GlobalCatalog()) + handlers.NewPluginAdminHandler(group) } diff --git a/main.go b/main.go index d7037d476..195e4add5 100644 --- a/main.go +++ b/main.go @@ -115,11 +115,20 @@ func scanPlugins() { CoreVersion: app.AppVersion, } logger := slog.Default().With(slog.String("component", "pluginhost")) + 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) + + watcher := pluginhost.NewWatcher(pluginDir, logger) + watcher.Start(context.Background()) + reloader.Start(context.Background(), watcher) } func resolvePluginDir() string { diff --git a/ui/src/stores/pluginCatalog/index.ts b/ui/src/stores/pluginCatalog/index.ts index 30f634b10..69a8d5362 100644 --- a/ui/src/stores/pluginCatalog/index.ts +++ b/ui/src/stores/pluginCatalog/index.ts @@ -6,9 +6,10 @@ import { applyPluginCatalog } from "@/domain/pluginCatalog"; interface PluginCatalogState { loaded: boolean; init: () => Promise; + reload: () => Promise; } -export const usePluginCatalogStore = create((set) => { +export const usePluginCatalogStore = create((set, get) => { let inflight: Promise | null = null; return { loaded: false, @@ -21,7 +22,6 @@ export const usePluginCatalogStore = create((set) => { const entries = await listPluginCatalog(); applyPluginCatalog(entries); } catch { - // graceful degrade: builtin providers still work } finally { set({ loaded: true }); inflight = null; @@ -29,5 +29,23 @@ export const usePluginCatalogStore = create((set) => { })(); return inflight; }, + reload: () => { + if (!get().loaded) { + return Promise.resolve(); + } + if (inflight) { + return inflight; + } + inflight = (async () => { + try { + const entries = await listPluginCatalog(); + applyPluginCatalog(entries); + } catch { + } finally { + inflight = null; + } + })(); + return inflight; + }, }; }); From 09f452a7e9b64fbcac099ed9e856e367b172048e Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Mon, 27 Jul 2026 09:53:50 +0800 Subject: [PATCH 08/19] feat: plugin marketplace --- internal/pluginhost/catalog.go | 5 +- internal/pluginhost/market.go | 493 +++++++++++++++++++++ internal/pluginhost/state.go | 9 + internal/rest/handlers/pluginmarket.go | 93 ++++ internal/rest/routes/routes.go | 1 + main.go | 6 + pkg/plugin/market_manifest.go | 67 +++ pkg/plugin/market_manifest_test.go | 177 ++++++++ pkg/plugin/market_meta.go | 68 +++ ui/src/api/pluginmarket.ts | 55 +++ ui/src/domain/pluginCatalog.ts | 7 + ui/src/i18n/resources/en/index.ts | 2 + ui/src/i18n/resources/en/nls.plugin.json | 40 ++ ui/src/i18n/resources/en/nls.settings.json | 4 + ui/src/i18n/resources/zh/index.ts | 2 + ui/src/i18n/resources/zh/nls.plugin.json | 40 ++ ui/src/i18n/resources/zh/nls.settings.json | 4 + ui/src/pages/accesses/AccessNew.tsx | 13 +- ui/src/pages/settings/Settings.tsx | 3 +- ui/src/pages/settings/SettingsPlugins.tsx | 207 +++++++++ ui/src/routers/index.tsx | 5 + 21 files changed, 1298 insertions(+), 3 deletions(-) create mode 100644 internal/pluginhost/market.go create mode 100644 internal/rest/handlers/pluginmarket.go create mode 100644 pkg/plugin/market_manifest.go create mode 100644 pkg/plugin/market_manifest_test.go create mode 100644 pkg/plugin/market_meta.go create mode 100644 ui/src/api/pluginmarket.ts create mode 100644 ui/src/i18n/resources/en/nls.plugin.json create mode 100644 ui/src/i18n/resources/zh/nls.plugin.json create mode 100644 ui/src/pages/settings/SettingsPlugins.tsx diff --git a/internal/pluginhost/catalog.go b/internal/pluginhost/catalog.go index be359b599..a42f011fb 100644 --- a/internal/pluginhost/catalog.go +++ b/internal/pluginhost/catalog.go @@ -5,7 +5,10 @@ import ( "sync" ) -const SourcePlugin = "plugin" +const ( + SourcePlugin = "plugin" + SourceMarketplace = "marketplace" +) type CatalogEntry struct { Source string `json:"source"` diff --git a/internal/pluginhost/market.go b/internal/pluginhost/market.go new file mode 100644 index 000000000..6bc6078c1 --- /dev/null +++ b/internal/pluginhost/market.go @@ -0,0 +1,493 @@ +package pluginhost + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/certimate-go/certimate/pkg/plugin" +) + +var ErrMissingProviderType = errors.New("market: providerType is required") + +type MarketEntry struct { + *plugin.MarketManifest + Status string `json:"status"` +} + +type MarketService struct { + marketRepo string + pluginDir string + cache []MarketEntry + cachedAt time.Time + cacheTTL time.Duration + mu sync.RWMutex + httpClient *http.Client + logger *slog.Logger +} + +type MarketConfig struct { + MarketRepo string + PluginDir string + CacheTTL 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" + } + return &MarketService{ + marketRepo: cfg.MarketRepo, + pluginDir: cfg.PluginDir, + cacheTTL: cfg.CacheTTL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + logger: cfg.Logger.With(slog.String("component", "market")), + } +} + +func (s *MarketService) ListMarket(ctx context.Context) ([]MarketEntry, error) { + s.mu.RLock() + if s.cache != nil && time.Since(s.cachedAt) < s.cacheTTL { + entries := s.cache + s.mu.RUnlock() + s.logger.Debug("market listing served from cache", slog.Int("entries", len(entries))) + return entries, nil + } + s.mu.RUnlock() + + return s.fetchAndCache(ctx) +} + +func (s *MarketService) fetchAndCache(ctx context.Context) ([]MarketEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cache != nil && time.Since(s.cachedAt) < s.cacheTTL { + return s.cache, nil + } + + entries, 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 = entries + s.cachedAt = time.Now() + s.logger.Info("market listing fetched", slog.Int("entries", len(entries))) + return entries, nil +} + +func (s *MarketService) fetchMarketListing(ctx context.Context) ([]MarketEntry, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/contents/", s.marketRepo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("market: create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + s.setAuth(req) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("market: fetch directory listing: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { + return nil, fmt.Errorf("market: GitHub API rate limit reached (status %d)", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("market: GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var dirEntries []struct { + Name string `json:"name"` + Type string `json:"type"` + } + if err := json.NewDecoder(resp.Body).Decode(&dirEntries); err != nil { + return nil, fmt.Errorf("market: decode directory listing: %w", err) + } + + var entries []MarketEntry + for _, de := range dirEntries { + if de.Type != "dir" { + continue + } + entry, err := s.fetchPluginManifest(ctx, de.Name) + if err != nil { + s.logger.Warn("market: skipping plugin directory", + slog.String("dir", de.Name), + slog.Any("error", err)) + continue + } + entries = append(entries, *entry) + } + + return entries, nil +} + +func (s *MarketService) fetchPluginManifest(ctx context.Context, dirName string) (*MarketEntry, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s/manifest.json", s.marketRepo, dirName) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("market: create manifest request for %s: %w", dirName, err) + } + req.Header.Set("Accept", "application/vnd.github.raw+json") + s.setAuth(req) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("market: fetch manifest for %s: %w", dirName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("market: manifest fetch for %s returned status %d", dirName, resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("market: read manifest for %s: %w", dirName, err) + } + + mm, err := plugin.ParseMarketManifest(data) + if err != nil { + return nil, fmt.Errorf("market: parse manifest for %s: %w", dirName, err) + } + + status := s.computeStatus(mm) + return &MarketEntry{MarketManifest: mm, Status: status}, 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) setAuth(req *http.Request) { + token := os.Getenv("CERTIMATE_GITHUB_TOKEN") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +func (s *MarketService) GetMarketManifest(ctx context.Context, providerType string) (*plugin.MarketManifest, error) { + entries, err := s.ListMarket(ctx) + if err != nil { + return nil, err + } + for _, e := range entries { + if e.ProviderType == providerType { + return e.MarketManifest, nil + } + } + return nil, fmt.Errorf("market: plugin %q not found in market listing", providerType) +} + +func (s *MarketService) Install(ctx context.Context, providerType string) (*ReloadResult, 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, fmt.Errorf("market: plugin %q is already installed", providerType) + } + + 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 binary for %s", providerType, key) + } + + downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", + mm.Release.Repo, mm.Release.Tag, assetName) + + tmpDir := filepath.Join(s.pluginDir, ".tmp-"+providerType) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return nil, fmt.Errorf("market: create temp dir: %w", err) + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + s.logger.Warn("market: failed to clean up temp dir", slog.String("dir", tmpDir), slog.Any("error", err)) + } + }() + + binaryPath := filepath.Join(tmpDir, mm.Binary) + if err := s.downloadFile(ctx, downloadURL, binaryPath); err != nil { + return nil, err + } + + if err := os.Chmod(binaryPath, 0o755); err != nil { + return nil, fmt.Errorf("market: chmod binary: %w", err) + } + + computedSHA256, err := sha256File(binaryPath) + if err != nil { + return nil, fmt.Errorf("market: compute sha256: %w", err) + } + + if expected, ok := mm.Release.Checksums[key]; ok && expected != "" && expected != computedSHA256 { + return nil, fmt.Errorf("market: plugin %q checksum mismatch for %s: expected %s, got %s", providerType, key, expected, computedSHA256) + } + + localManifest := *mm + localManifest.OS = runtime.GOOS + localManifest.Arch = runtime.GOARCH + localManifest.SHA256 = computedSHA256 + + manifestData, err := json.MarshalIndent(&localManifest.Manifest, "", " ") + if err != nil { + return nil, fmt.Errorf("market: marshal local manifest: %w", err) + } + manifestPath := filepath.Join(tmpDir, "manifest.json") + if err := os.WriteFile(manifestPath, 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 + } + + if err := os.Rename(tmpDir, targetDir); err != nil { + return nil, fmt.Errorf("market: atomic rename: %w", err) + } + + reloader := GlobalReloader() + if reloader == nil { + return nil, fmt.Errorf("market: reloader not initialized") + } + return reloader.ReloadNow(ctx), nil +} + +func (s *MarketService) Delete(ctx context.Context, providerType string) (*ReloadResult, error) { + if err := plugin.ValidateProviderType(providerType); err != nil { + return nil, err + } + + 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.ReloadNow(ctx), nil +} + +func (s *MarketService) Update(ctx context.Context, providerType string) (*ReloadResult, error) { + if err := plugin.ValidateProviderType(providerType); err != nil { + return nil, err + } + + 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) + } + + 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 binary for %s", providerType, key) + } + + downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", + mm.Release.Repo, mm.Release.Tag, assetName) + + tmpDir := filepath.Join(s.pluginDir, ".tmp-"+providerType) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return nil, fmt.Errorf("market: create temp dir: %w", err) + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + s.logger.Warn("market: failed to clean up temp dir", slog.String("dir", tmpDir), slog.Any("error", err)) + } + }() + + binaryPath := filepath.Join(tmpDir, mm.Binary) + if err := s.downloadFile(ctx, downloadURL, binaryPath); err != nil { + return nil, err + } + + if err := os.Chmod(binaryPath, 0o755); err != nil { + return nil, fmt.Errorf("market: chmod binary: %w", err) + } + + computedSHA256, err := sha256File(binaryPath) + if err != nil { + return nil, fmt.Errorf("market: compute sha256: %w", err) + } + + if expected, ok := mm.Release.Checksums[key]; ok && expected != "" && expected != computedSHA256 { + return nil, fmt.Errorf("market: plugin %q checksum mismatch for %s: expected %s, got %s", providerType, key, expected, computedSHA256) + } + + localManifest := *mm + localManifest.OS = runtime.GOOS + localManifest.Arch = runtime.GOARCH + localManifest.SHA256 = computedSHA256 + + manifestData, err := json.MarshalIndent(&localManifest.Manifest, "", " ") + if err != nil { + return nil, fmt.Errorf("market: marshal local manifest: %w", err) + } + manifestPath := filepath.Join(tmpDir, "manifest.json") + if err := os.WriteFile(manifestPath, manifestData, 0o644); err != nil { + return nil, fmt.Errorf("market: write manifest: %w", err) + } + + newMeta := plugin.NewMarketMeta("official", mm.Version, mm.Version, mm.Release.Tag) + if err := plugin.WriteMarketMeta(tmpDir, ".", newMeta); err != nil { + return nil, err + } + + backupDir := filepath.Join(s.pluginDir, ".bak-"+providerType) + _ = os.RemoveAll(backupDir) + if err := os.Rename(targetDir, backupDir); err != nil { + 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, fmt.Errorf("market: install rename failed (%v) and rollback failed (%v)", err, rerr) + } + return nil, fmt.Errorf("market: install rename: %w", err) + } + if err := os.RemoveAll(backupDir); err != nil { + s.logger.Warn("market: failed to clean up backup dir", slog.String("dir", backupDir), slog.Any("error", err)) + } + + reloader := GlobalReloader() + if reloader == nil { + return nil, fmt.Errorf("market: reloader not initialized") + } + return reloader.ReloadNow(ctx), nil +} + +func (s *MarketService) downloadFile(ctx context.Context, url, dest string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("market: create download request: %w", err) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("market: download %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("market: download returned status %d", resp.StatusCode) + } + + f, err := os.Create(dest) + if err != nil { + return fmt.Errorf("market: create dest file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + return fmt.Errorf("market: write download: %w", err) + } + return nil +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/pluginhost/state.go b/internal/pluginhost/state.go index 6d1318a2a..b86b9f5a7 100644 --- a/internal/pluginhost/state.go +++ b/internal/pluginhost/state.go @@ -2,6 +2,7 @@ package pluginhost var globalCatalog = NewCatalog() var globalReloader *Reloader +var globalMarketService *MarketService func SetGlobalCatalog(c *Catalog) { if c != nil { @@ -20,3 +21,11 @@ func SetGlobalReloader(r *Reloader) { func GlobalReloader() *Reloader { return globalReloader } + +func SetGlobalMarketService(s *MarketService) { + globalMarketService = s +} + +func GlobalMarketService() *MarketService { + return globalMarketService +} diff --git a/internal/rest/handlers/pluginmarket.go b/internal/rest/handlers/pluginmarket.go new file mode 100644 index 000000000..a26f335d6 --- /dev/null +++ b/internal/rest/handlers/pluginmarket.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "context" + "encoding/json" + + "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 marketService interface { + ListMarket(ctx context.Context) ([]pluginhost.MarketEntry, error) + Install(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error) + Delete(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error) + Update(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error) +} + +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.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) + } + result, err := h.service.Install(e.Request.Context(), req.ProviderType) + if err != nil { + return resp.Err(e, err) + } + return resp.Ok(e, result) +} + +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) +} diff --git a/internal/rest/routes/routes.go b/internal/rest/routes/routes.go index 13c5c38c2..e2eaecf71 100644 --- a/internal/rest/routes/routes.go +++ b/internal/rest/routes/routes.go @@ -47,4 +47,5 @@ func BindRouter(router *router.Router[*core.RequestEvent]) { handlers.NewProviderSchemaHandler(group, providerSchemaSvc) handlers.NewPluginCatalogHandler(group, pluginhost.GlobalCatalog()) handlers.NewPluginAdminHandler(group) + handlers.NewPluginMarketHandler(group, pluginhost.GlobalMarketService()) } diff --git a/main.go b/main.go index 195e4add5..5d5e30d63 100644 --- a/main.go +++ b/main.go @@ -126,6 +126,12 @@ func scanPlugins() { reloader.InitFromCatalog() pluginhost.SetGlobalReloader(reloader) + marketSvc := pluginhost.NewMarketService(pluginhost.MarketConfig{ + PluginDir: pluginDir, + Logger: logger, + }) + pluginhost.SetGlobalMarketService(marketSvc) + watcher := pluginhost.NewWatcher(pluginDir, logger) watcher.Start(context.Background()) reloader.Start(context.Background(), watcher) diff --git a/pkg/plugin/market_manifest.go b/pkg/plugin/market_manifest.go new file mode 100644 index 000000000..9e2bea98c --- /dev/null +++ b/pkg/plugin/market_manifest.go @@ -0,0 +1,67 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "regexp" +) + +var providerTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) +var repoPattern = regexp.MustCompile(`^certimate-go/[a-z0-9_.-]+$`) + +type Release struct { + Repo string `json:"repo"` + Tag string `json:"tag"` + Assets map[string]string `json:"assets"` + Checksums map[string]string `json:"checksums,omitempty"` +} + +type MarketManifest struct { + Manifest + Release *Release `json:"release,omitempty"` +} + +func ParseMarketManifest(data []byte) (*MarketManifest, error) { + var m MarketManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("plugin: invalid market manifest json: %w", err) + } + if m.Release == nil { + return nil, fmt.Errorf("plugin: market manifest for %q has no release block", m.ProviderType) + } + return &m, nil +} + +func ValidateProviderType(pt string) error { + if pt == "" { + return fmt.Errorf("plugin: provider_type must not be empty") + } + if !providerTypePattern.MatchString(pt) { + return fmt.Errorf("plugin: provider_type %q contains invalid characters (allowed: a-z, 0-9, underscore, dot, hyphen)", pt) + } + return nil +} + +func ValidateBinaryName(name string) error { + if name == "" { + return fmt.Errorf("plugin: binary must not be empty") + } + if !providerTypePattern.MatchString(name) { + return fmt.Errorf("plugin: binary %q contains invalid characters (allowed: a-z, 0-9, underscore, dot, hyphen)", name) + } + return nil +} + +func ValidateReleaseRepo(repo string) error { + if repo == "" { + return fmt.Errorf("plugin: release repo must not be empty") + } + if !repoPattern.MatchString(repo) { + return fmt.Errorf("plugin: release repo %q is not in the trusted organization (certimate-go/)", repo) + } + return nil +} + +func AssetKey(goos, goarch string) string { + return goos + "/" + goarch +} diff --git a/pkg/plugin/market_manifest_test.go b/pkg/plugin/market_manifest_test.go new file mode 100644 index 000000000..e6f36720b --- /dev/null +++ b/pkg/plugin/market_manifest_test.go @@ -0,0 +1,177 @@ +package plugin + +import ( + "testing" +) + +func TestParseMarketManifest(t *testing.T) { + data := []byte(`{ + "version": "1.0.0", + "provider_type": "webhook-deployer", + "access_provider_type": "webhook", + "display_name_key": "plugin.webhook-deployer.name", + "deploy_category": "other", + "protocol_version": 1, + "binary": "webhook-deployer", + "description": "Deploy via webhook", + "release": { + "repo": "certimate-go/plugins", + "tag": "webhook-deployer/v1.0.0", + "assets": { + "linux/amd64": "webhook-deployer_linux_amd64", + "darwin/arm64": "webhook-deployer_darwin_arm64" + } + } + }`) + + mm, err := ParseMarketManifest(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mm.ProviderType != "webhook-deployer" { + t.Errorf("expected provider_type webhook-deployer, got %s", mm.ProviderType) + } + if mm.Version != "1.0.0" { + t.Errorf("expected version 1.0.0, got %s", mm.Version) + } + if mm.Release == nil { + t.Fatal("expected non-nil release") + } + if mm.Release.Repo != "certimate-go/plugins" { + t.Errorf("expected repo certimate-go/plugins, got %s", mm.Release.Repo) + } + if mm.Release.Tag != "webhook-deployer/v1.0.0" { + t.Errorf("expected tag webhook-deployer/v1.0.0, got %s", mm.Release.Tag) + } + if len(mm.Release.Assets) != 2 { + t.Errorf("expected 2 assets, got %d", len(mm.Release.Assets)) + } +} + +func TestParseMarketManifest_NoRelease(t *testing.T) { + data := []byte(`{"version": "1.0.0", "provider_type": "test"}`) + _, err := ParseMarketManifest(data) + if err == nil { + t.Fatal("expected error for manifest without release block") + } +} + +func TestParseMarketManifest_InvalidJSON(t *testing.T) { + _, err := ParseMarketManifest([]byte(`{invalid`)) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestValidateProviderType(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + {"simple", "webhook-deployer", true}, + {"with dots", "com.example.plugin", true}, + {"with underscores", "my_plugin_v2", true}, + {"alphanumeric only", "plugin123", true}, + {"empty", "", false}, + {"path traversal", "../../etc/passwd", false}, + {"with slash", "foo/bar", false}, + {"starts with dot", ".hidden", false}, + {"with spaces", "my plugin", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateProviderType(tt.input) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } + if !tt.valid && err == nil { + t.Errorf("expected error for %q", tt.input) + } + }) + } +} + +func TestValidateReleaseRepo(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + {"valid", "certimate-go/plugins", true}, + {"valid with dots", "certimate-go/my.plugin-repo_v2", true}, + {"empty", "", false}, + {"wrong org", "other-org/plugins", false}, + {"path traversal", "../etc/passwd", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateReleaseRepo(tt.input) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } + if !tt.valid && err == nil { + t.Errorf("expected error for %q", tt.input) + } + }) + } +} + +func TestAssetKey(t *testing.T) { + key := AssetKey("linux", "amd64") + if key != "linux/amd64" { + t.Errorf("expected linux/amd64, got %s", key) + } +} + +func TestValidateBinaryName(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + {"simple", "webhook-deployer", true}, + {"with arch suffix", "webhook-deployer_linux_amd64", true}, + {"with exe", "webhook-deployer.exe", true}, + {"empty", "", false}, + {"path traversal", "../evil", false}, + {"absolute path", "/bin/sh", false}, + {"with slash", "foo/bar", false}, + {"parent dir", "..", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateBinaryName(tt.input) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } + if !tt.valid && err == nil { + t.Errorf("expected error for %q", tt.input) + } + }) + } +} + +func TestParseMarketManifest_WithChecksums(t *testing.T) { + data := []byte(`{ + "version": "1.0.0", + "provider_type": "webhook-deployer", + "access_provider_type": "webhook", + "protocol_version": 1, + "binary": "webhook-deployer", + "release": { + "repo": "certimate-go/plugins", + "tag": "v1.0.0", + "assets": {"linux/amd64": "webhook-deployer_linux_amd64"}, + "checksums": {"linux/amd64": "abc123"} + } + }`) + + mm, err := ParseMarketManifest(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := mm.Release.Checksums["linux/amd64"]; got != "abc123" { + t.Errorf("expected checksum abc123, got %s", got) + } +} diff --git a/pkg/plugin/market_meta.go b/pkg/plugin/market_meta.go new file mode 100644 index 000000000..c89602428 --- /dev/null +++ b/pkg/plugin/market_meta.go @@ -0,0 +1,68 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// MarketMeta records provenance and version information for a marketplace-installed +// plugin. Stored as .market.json alongside the plugin's manifest.json. +type MarketMeta struct { + Source string `json:"source"` + InstalledVersion string `json:"installed_version"` + MarketVersionAtInstall string `json:"market_version_at_install"` + InstalledAt string `json:"installed_at"` + ReleaseTag string `json:"release_tag"` +} + +// ReadMarketMeta reads the .market.json file for a plugin in the given directory. +// Returns (nil, nil) if the file does not exist (plugin is not marketplace-managed). +func ReadMarketMeta(pluginDir, providerType string) (*MarketMeta, error) { + path := filepath.Join(pluginDir, providerType, ".market.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("plugin: read market meta for %q: %w", providerType, err) + } + var meta MarketMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("plugin: parse market meta for %q: %w", providerType, err) + } + return &meta, nil +} + +// WriteMarketMeta writes the .market.json file for a plugin. +func WriteMarketMeta(pluginDir, providerType string, meta *MarketMeta) error { + path := filepath.Join(pluginDir, providerType, ".market.json") + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("plugin: marshal market meta for %q: %w", providerType, err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("plugin: write market meta for %q: %w", providerType, err) + } + return nil +} + +// NewMarketMeta creates a MarketMeta with the current timestamp. +func NewMarketMeta(source, installedVersion, marketVersion, releaseTag string) *MarketMeta { + return &MarketMeta{ + Source: source, + InstalledVersion: installedVersion, + MarketVersionAtInstall: marketVersion, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + ReleaseTag: releaseTag, + } +} + +// CompareVersions compares two version strings using semver comparison. +// Returns -1 if a < b, 0 if a == b, 1 if a > b. +// Falls back to string comparison for non-semver versions. +func CompareVersions(a, b string) int { + return semverCompare(a, b) +} diff --git a/ui/src/api/pluginmarket.ts b/ui/src/api/pluginmarket.ts new file mode 100644 index 000000000..3f8274a7d --- /dev/null +++ b/ui/src/api/pluginmarket.ts @@ -0,0 +1,55 @@ +import { get as httpGet, post as httpPost } from "./_api"; +import { getPocketBase } from "@/repository/_pocketbase"; + +const pb = getPocketBase(); + +export interface MarketEntry { + provider_type: string; + access_provider_type: string; + display_name_key: string; + deploy_category: string; + version: string; + description: string; + icon: string; + protocol_version: number; + status: "not_installed" | "installed" | "update_available" | "installed_manual" | "unsupported_platform"; + release?: { + repo: string; + tag: string; + assets: Record; + }; +} + +export const fetchMarketListing = async (): Promise => { + try { + const resp = await httpGet({ + url: "/api/plugin/market", + }); + return resp.data ?? []; + } catch { + return []; + } +}; + +export const installPlugin = async (providerType: string): Promise => { + const resp = await httpPost({ + url: "/api/plugin/market/install", + body: { providerType }, + }); + return resp.data; +}; + +export const deletePlugin = async (providerType: string): Promise => { + const resp = await pb.send<{ code: number; data: unknown }>(`/api/plugin/market/${providerType}`, { + method: "DELETE", + }); + return resp.data; +}; + +export const updatePlugin = async (providerType: string): Promise => { + const resp = await httpPost({ + url: `/api/plugin/market/update/${providerType}`, + body: { providerType }, + }); + return resp.data; +}; diff --git a/ui/src/domain/pluginCatalog.ts b/ui/src/domain/pluginCatalog.ts index 280851d02..098e105fc 100644 --- a/ui/src/domain/pluginCatalog.ts +++ b/ui/src/domain/pluginCatalog.ts @@ -19,6 +19,13 @@ const usageMap: Record = { }; export const applyPluginCatalog = (entries: PluginCatalogEntry[]): void => { + for (const [key, val] of deploymentProvidersMap) { + if (val.source === "plugin") deploymentProvidersMap.delete(key); + } + for (const [key, val] of accessProvidersMap) { + if (val.source === "plugin") accessProvidersMap.delete(key); + } + for (const entry of entries) { const accessType = entry.accessProviderType || entry.providerType; diff --git a/ui/src/i18n/resources/en/index.ts b/ui/src/i18n/resources/en/index.ts index 85c8ff8b0..f0e902973 100644 --- a/ui/src/i18n/resources/en/index.ts +++ b/ui/src/i18n/resources/en/index.ts @@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json"; import nlsCommon from "./nls.common.json"; import nlsDashboard from "./nls.dashboard.json"; import nlsLogin from "./nls.login.json"; +import nlsPlugin from "./nls.plugin.json"; import nlsPreset from "./nls.preset.json"; import nlsProvider from "./nls.provider.json"; import nlsSettings from "./nls.settings.json"; @@ -19,6 +20,7 @@ export default buildTranslations( nlsProvider, nlsAccess, nlsPreset, + nlsPlugin, nlsCertificate, nlsWorkflow, nlsWorkflowNodes, diff --git a/ui/src/i18n/resources/en/nls.plugin.json b/ui/src/i18n/resources/en/nls.plugin.json new file mode 100644 index 000000000..aae652b0c --- /dev/null +++ b/ui/src/i18n/resources/en/nls.plugin.json @@ -0,0 +1,40 @@ +{ + "$ns": "plugin", + + "market": { + "nudge": "Didn't find the provider you need? Browse the plugin marketplace for more.", + "browse": "Browse marketplace", + + "empty": "No plugins available", + "error": { + "load_failed": "Failed to load plugin marketplace", + "install_failed": "Failed to install plugin", + "delete_failed": "Failed to delete plugin", + "update_failed": "Failed to update plugin" + }, + "msg": { + "installed": "Plugin installed", + "deleted": "Plugin removed", + "updated": "Plugin updated" + }, + "action": { + "install": "Install", + "delete": "Delete", + "update": "Update to", + "retry": "Retry", + "refresh": "Refresh" + }, + "label": { + "update_available": "Update available", + "manual": "Manual", + "unsupported_platform": "Unsupported platform" + }, + "status": { + "not_installed": "Not installed", + "installed": "Installed", + "update_available": "Update available", + "installed_manual": "Manual", + "unsupported_platform": "Unsupported" + } + } +} diff --git a/ui/src/i18n/resources/en/nls.settings.json b/ui/src/i18n/resources/en/nls.settings.json index 7aaa51b4c..e584ca784 100644 --- a/ui/src/i18n/resources/en/nls.settings.json +++ b/ui/src/i18n/resources/en/nls.settings.json @@ -96,6 +96,10 @@ } }, + "plugins": { + "tab": "Plugins" + }, + "sslprovider": { "tab": "Certificate authority", "ca": { diff --git a/ui/src/i18n/resources/zh/index.ts b/ui/src/i18n/resources/zh/index.ts index 85c8ff8b0..f0e902973 100644 --- a/ui/src/i18n/resources/zh/index.ts +++ b/ui/src/i18n/resources/zh/index.ts @@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json"; import nlsCommon from "./nls.common.json"; import nlsDashboard from "./nls.dashboard.json"; import nlsLogin from "./nls.login.json"; +import nlsPlugin from "./nls.plugin.json"; import nlsPreset from "./nls.preset.json"; import nlsProvider from "./nls.provider.json"; import nlsSettings from "./nls.settings.json"; @@ -19,6 +20,7 @@ export default buildTranslations( nlsProvider, nlsAccess, nlsPreset, + nlsPlugin, nlsCertificate, nlsWorkflow, nlsWorkflowNodes, diff --git a/ui/src/i18n/resources/zh/nls.plugin.json b/ui/src/i18n/resources/zh/nls.plugin.json new file mode 100644 index 000000000..01d2c9477 --- /dev/null +++ b/ui/src/i18n/resources/zh/nls.plugin.json @@ -0,0 +1,40 @@ +{ + "$ns": "plugin", + + "market": { + "nudge": "没找到你需要的提供商?前往插件市场发现更多。", + "browse": "浏览插件市场", + + "empty": "暂无可用插件", + "error": { + "load_failed": "加载插件市场失败", + "install_failed": "安装插件失败", + "delete_failed": "删除插件失败", + "update_failed": "更新插件失败" + }, + "msg": { + "installed": "插件安装成功", + "deleted": "插件已删除", + "updated": "插件已更新" + }, + "action": { + "install": "安装", + "delete": "删除", + "update": "更新到", + "retry": "重试", + "refresh": "刷新" + }, + "label": { + "update_available": "有新版本", + "manual": "手动安装", + "unsupported_platform": "不支持当前平台" + }, + "status": { + "not_installed": "未安装", + "installed": "已安装", + "update_available": "可更新", + "installed_manual": "手动安装", + "unsupported_platform": "不支持" + } + } +} diff --git a/ui/src/i18n/resources/zh/nls.settings.json b/ui/src/i18n/resources/zh/nls.settings.json index e76d88739..6e5355cb9 100644 --- a/ui/src/i18n/resources/zh/nls.settings.json +++ b/ui/src/i18n/resources/zh/nls.settings.json @@ -96,6 +96,10 @@ } }, + "plugins": { + "tab": "插件" + }, + "sslprovider": { "tab": "证书颁发机构", "ca": { diff --git a/ui/src/pages/accesses/AccessNew.tsx b/ui/src/pages/accesses/AccessNew.tsx index 5f41320e2..07753cdd9 100644 --- a/ui/src/pages/accesses/AccessNew.tsx +++ b/ui/src/pages/accesses/AccessNew.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useMount } from "ahooks"; -import { App, Button, Flex, Form } from "antd"; +import { App, Button, Flex, Form, Alert } from "antd"; import AccessForm, { type AccessFormUsages } from "@/components/access/AccessForm"; import AccessProviderPicker, { type AccessProviderPickerInstance } from "@/components/provider/AccessProviderPicker"; @@ -80,6 +80,17 @@ const AccessNew = () => {
+ navigate("/settings/plugins")}> + {t("plugin.market.browse")} + + } + closable + className="mb-4" + /> { @@ -14,6 +14,7 @@ const Settings = () => { ["account", "settings.account.tab", ], ["appearance", "settings.appearance.tab", ], ["ssl-provider", "settings.sslprovider.tab", ], + ["plugins", "settings.plugins.tab", ], ["persistence", "settings.persistence.tab", ], ["diagnostics", "settings.diagnostics.tab", ], ["about", "settings.about.tab", ], diff --git a/ui/src/pages/settings/SettingsPlugins.tsx b/ui/src/pages/settings/SettingsPlugins.tsx new file mode 100644 index 000000000..42dc9e6fd --- /dev/null +++ b/ui/src/pages/settings/SettingsPlugins.tsx @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconDownload, IconPuzzle, IconTrash, IconRefresh } from "@tabler/icons-react"; +import { Alert, Button, Card, Empty, Skeleton, Tag, App } from "antd"; + +import { fetchMarketListing, installPlugin, deletePlugin, updatePlugin, type MarketEntry } from "@/api/pluginmarket"; +import { usePluginCatalogStore } from "@/stores/pluginCatalog"; + +const statusLabel: Record = { + not_installed: "plugin.market.status.not_installed", + installed: "plugin.market.status.installed", + update_available: "plugin.market.status.update_available", + installed_manual: "plugin.market.status.installed_manual", + unsupported_platform: "plugin.market.status.unsupported_platform", +}; + +const SettingsPlugins = () => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const catalogReload = usePluginCatalogStore((s) => s.reload); + + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [operating, setOperating] = useState>({}); + + const loadListing = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchMarketListing(); + setEntries(data); + } catch { + setError(t("plugin.market.error.load_failed")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + loadListing(); + }, [loadListing]); + + const setOp = (pt: string, v: boolean) => { + setOperating((prev) => ({ ...prev, [pt]: v })); + }; + + const handleInstall = async (entry: MarketEntry) => { + const pt = entry.provider_type; + setOp(pt, true); + try { + await installPlugin(pt); + message.success(t("plugin.market.msg.installed")); + await catalogReload(); + await loadListing(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : t("plugin.market.error.install_failed"); + message.error(msg); + } finally { + setOp(pt, false); + } + }; + + const handleDelete = async (entry: MarketEntry) => { + const pt = entry.provider_type; + setOp(pt, true); + try { + await deletePlugin(pt); + message.success(t("plugin.market.msg.deleted")); + await catalogReload(); + await loadListing(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : t("plugin.market.error.delete_failed"); + message.error(msg); + } finally { + setOp(pt, false); + } + }; + + const handleUpdate = async (entry: MarketEntry) => { + const pt = entry.provider_type; + setOp(pt, true); + try { + await updatePlugin(pt); + message.success(t("plugin.market.msg.updated")); + await catalogReload(); + await loadListing(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : t("plugin.market.error.update_failed"); + message.error(msg); + } finally { + setOp(pt, false); + } + }; + + const renderActions = (entry: MarketEntry) => { + const pt = entry.provider_type; + const busy = operating[pt]; + + switch (entry.status) { + case "not_installed": + return ( + + ); + case "installed": + return ( + + ); + case "update_available": + return ( +
+ {t("plugin.market.label.update_available")} + +
+ ); + case "installed_manual": + return ( + {t("plugin.market.label.manual")} + ); + case "unsupported_platform": + return ( + {t("plugin.market.label.unsupported_platform")} + ); + default: + return null; + } + }; + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( + + {t("plugin.market.action.retry")} + + } + /> + ); + } + + if (entries.length === 0) { + return ; + } + + return ( +
+
+ +
+ +
+ {entries.map((entry) => ( + + {entry.provider_type} +
+ ) : ( +
+ +
+ ) + } + > +
+ +
+ v{entry.version} + {t(statusLabel[entry.status], entry.status)} +
+
{renderActions(entry)}
+
+ + ))} +
+
+ ); +}; + +export default SettingsPlugins; diff --git a/ui/src/routers/index.tsx b/ui/src/routers/index.tsx index ef27c35c7..061132c81 100644 --- a/ui/src/routers/index.tsx +++ b/ui/src/routers/index.tsx @@ -14,6 +14,7 @@ import SettingsAbout from "@/pages/settings/SettingsAbout"; import SettingsAccount from "@/pages/settings/SettingsAccount"; import SettingsAppearance from "@/pages/settings/SettingsAppearance"; import SettingsDiagnostics from "@/pages/settings/SettingsDiagnostics"; +import SettingsPlugins from "@/pages/settings/SettingsPlugins"; import SettingsPersistence from "@/pages/settings/SettingsPersistence"; import SettingsSSLProvider from "@/pages/settings/SettingsSSLProvider"; import WorkflowDetail from "@/pages/workflows/WorkflowDetail"; @@ -89,6 +90,10 @@ export const router = createHashRouter([ path: "/settings/persistence", element: , }, + { + path: "/settings/plugins", + element: , + }, { path: "/settings/diagnostics", element: , From 19935067e8d4404f459a9efc7c082cbeb488103f Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Mon, 27 Jul 2026 17:33:08 +0800 Subject: [PATCH 09/19] refactor: plugin schema --- internal/providerschema/emit.go | 2 + ui/src/api/providerschema.ts | 2 + .../designer/forms/SchemaConfigFields.tsx | 91 ++++++++++++------- .../designer/forms/schemaValueTypes.test.ts | 20 +++- .../designer/forms/schemaValueTypes.ts | 10 ++ 5 files changed, 90 insertions(+), 35 deletions(-) diff --git a/internal/providerschema/emit.go b/internal/providerschema/emit.go index 6c3f989a4..a8042c253 100644 --- a/internal/providerschema/emit.go +++ b/internal/providerschema/emit.go @@ -28,6 +28,8 @@ type Column struct { 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"` diff --git a/ui/src/api/providerschema.ts b/ui/src/api/providerschema.ts index 6198b585a..7d36c72c2 100644 --- a/ui/src/api/providerschema.ts +++ b/ui/src/api/providerschema.ts @@ -37,6 +37,8 @@ export interface SchemaColumn { options?: SchemaOption[]; min?: number; max?: number; + language?: string; + unitKey?: string; filterMode?: SchemaFilterMode; span?: number; dependencies?: string[]; diff --git a/ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx b/ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx index 3dc83b991..5f5e0b938 100644 --- a/ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx +++ b/ui/src/components/workflow/designer/forms/SchemaConfigFields.tsx @@ -1,15 +1,15 @@ -import { useMemo } from "react"; +import { type ReactNode, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { BetaSchemaForm, type ProFormColumnsType } from "@ant-design/pro-components"; -import { Form } from "antd"; +import { Form, Input, Radio, Select, Switch } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; import type { ProviderSchemaEnvelope, SchemaColumn } from "@/api/providerschema"; +import CodeTextInput from "@/components/CodeTextInput"; import { useFormNestedFieldsContext } from "./_context"; import { deriveZodSchema } from "./schemaValidation"; -import { buildValueEnum, isColumnVisible, mapValueType } from "./schemaValueTypes"; +import { isColumnVisible, unitFieldProps } from "./schemaValueTypes"; export interface SchemaConfigFieldsProps { envelope: ProviderSchemaEnvelope; @@ -26,41 +26,64 @@ const SchemaConfigFields = ({ envelope }: SchemaConfigFieldsProps) => { const formSchema = useMemo(() => z.object({ [parentNamePath]: deriveZodSchema(envelope) }), [envelope, parentNamePath]); const formRule = createSchemaFieldRule(formSchema); - const columns = useMemo( - () => envelope.schema.columns.filter((col) => isColumnVisible(col, getValue)).map((col) => toColumn(col, parentNamePath, t, formRule)), - // eslint-disable-next-line react-hooks/exhaustive-deps - [envelope, parentNamePath, t, providerConfig] - ); + const columns = envelope.schema.columns.filter((col) => isColumnVisible(col, getValue)); - return ; + return ( + <> + {columns.map((col) => ( + + {renderControl(col, t)} + + ))} + + ); }; -const toColumn = (col: SchemaColumn, parentNamePath: string, t: (key: string) => string, formRule: unknown): ProFormColumnsType => { - const tooltip = col.tooltipKey - ? col.tooltipHtml - ? { title: } - : t(col.tooltipKey) - : undefined; +const renderTooltip = (col: SchemaColumn, t: (key: string) => string): ReactNode => { + if (!col.tooltipKey) return undefined; + return col.tooltipHtml ? : t(col.tooltipKey); +}; - const extra = col.extraKey ? col.extraHtml ? : t(col.extraKey) : undefined; +const renderExtra = (col: SchemaColumn, t: (key: string) => string): ReactNode => { + if (!col.extraKey) return undefined; + return col.extraHtml ? : t(col.extraKey); +}; - return { - title: col.labelKey ? t(col.labelKey) : col.name, - name: [parentNamePath, col.name], - valueType: mapValueType(col.valueType), - valueEnum: buildValueEnum(col, t), - initialValue: col.default, - tooltip, - fieldProps: { - placeholder: col.placeholderKey ? t(col.placeholderKey) : undefined, - min: col.min, - max: col.max, - }, - formItemProps: { - extra, - rules: [formRule as never], - }, - } as ProFormColumnsType; +const optionList = (col: SchemaColumn, t: (key: string) => string) => + (col.options ?? []).map((o) => ({ label: o.labelKey ? t(o.labelKey) : o.value, value: o.value })); + +const renderControl = (col: SchemaColumn, t: (key: string) => string): ReactNode => { + const placeholder = col.placeholderKey ? t(col.placeholderKey) : undefined; + switch (col.valueType) { + case "switch": + return ; + case "number": + return ; + case "select": + return ; + } }; export default SchemaConfigFields; diff --git a/ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts b/ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts index 80c2c98a3..1d872b4af 100644 --- a/ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts +++ b/ui/src/components/workflow/designer/forms/schemaValueTypes.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { SchemaColumn } from "@/api/providerschema"; -import { buildValueEnum, discriminatorFields, isColumnVisible, mapValueType } from "./schemaValueTypes"; +import { buildValueEnum, discriminatorFields, isColumnVisible, mapValueType, unitFieldProps } from "./schemaValueTypes"; describe("mapValueType", () => { it("maps known valueTypes to ProComponents valueTypes", () => { @@ -67,3 +67,21 @@ describe("discriminatorFields", () => { expect(discriminatorFields(cols).sort()).toEqual(["fmt", "useSCP"]); }); }); + +describe("unitFieldProps", () => { + const t = (k: string) => `T(${k})`; + + it("returns addonAfter for digit (number) fields", () => { + expect(unitFieldProps("digit", "unit.seconds", t)).toEqual({ addonAfter: "T(unit.seconds)" }); + }); + + it("returns suffix for non-digit fields", () => { + expect(unitFieldProps("text", "unit.seconds", t)).toEqual({ suffix: "T(unit.seconds)" }); + expect(unitFieldProps("textarea", "unit.seconds", t)).toEqual({ suffix: "T(unit.seconds)" }); + }); + + it("returns nothing when no unitKey is provided", () => { + expect(unitFieldProps("digit", undefined, t)).toEqual({}); + expect(unitFieldProps("text", undefined, t)).toEqual({}); + }); +}); diff --git a/ui/src/components/workflow/designer/forms/schemaValueTypes.ts b/ui/src/components/workflow/designer/forms/schemaValueTypes.ts index 6229342d9..2d7e13cb6 100644 --- a/ui/src/components/workflow/designer/forms/schemaValueTypes.ts +++ b/ui/src/components/workflow/designer/forms/schemaValueTypes.ts @@ -53,3 +53,13 @@ export const discriminatorFields = (columns: SchemaColumn[]): string[] => { } return [...seen]; }; + +export const unitFieldProps = ( + mappedValueType: string, + unitKey: string | undefined, + t: (key: string) => string +): Record => { + if (!unitKey) return {}; + const text = t(unitKey); + return mappedValueType === "digit" ? { addonAfter: text } : { suffix: text }; +}; From 8c7062e462b93c735ef06abd30c6423ad6589eae Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Tue, 28 Jul 2026 13:21:39 +0800 Subject: [PATCH 10/19] feat: plugin log --- internal/pluginhost/adapter.go | 2 +- internal/pluginhost/e2e_test.go | 91 +++++++- pkg/plugin/config.go | 28 ++- pkg/plugin/deployer_grpc.go | 45 +++- pkg/plugin/deployer_grpc_test.go | 87 ++++++- pkg/plugin/handshake.go | 2 +- pkg/plugin/logger.go | 93 ++++++++ pkg/plugin/logger_test.go | 76 +++++++ pkg/plugin/manager.go | 78 ++++++- pkg/plugin/manager_test.go | 47 +++- pkg/plugin/proto/plugin.pb.go | 214 ++++++++++++++++-- pkg/plugin/proto/plugin.proto | 16 +- pkg/plugin/proto/plugin_grpc.pb.go | 56 ++--- pkg/plugin/testplugin/main.go | 6 +- .../provider/AccessProviderPicker.tsx | 4 +- .../provider/DeploymentProviderPicker.tsx | 4 +- ui/src/pages/ConsoleLayout.tsx | 5 + ui/src/stores/pluginCatalog/index.ts | 59 ++--- 18 files changed, 788 insertions(+), 125 deletions(-) create mode 100644 pkg/plugin/logger.go create mode 100644 pkg/plugin/logger_test.go diff --git a/internal/pluginhost/adapter.go b/internal/pluginhost/adapter.go index a953f2e48..b680ff549 100644 --- a/internal/pluginhost/adapter.go +++ b/internal/pluginhost/adapter.go @@ -58,7 +58,7 @@ func (d *pluginDeployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) PrivateKeyPEM: privkeyPEM, } - res, err := d.factory.manager.Deploy(ctx, d.factory.dp, req) + res, err := d.factory.manager.Deploy(ctx, d.factory.dp, req, d.logger) if err != nil { return nil, err } diff --git a/internal/pluginhost/e2e_test.go b/internal/pluginhost/e2e_test.go index 960ecddda..5c9991891 100644 --- a/internal/pluginhost/e2e_test.go +++ b/internal/pluginhost/e2e_test.go @@ -2,10 +2,17 @@ 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" @@ -15,6 +22,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/certimate-go/certimate/internal/certmgmt/deployers" "github.com/certimate-go/certimate/internal/domain" @@ -133,8 +141,8 @@ func TestE2E_PilotDeploysThroughFullStack(t *testing.T) { if err != nil { t.Fatalf("pilot not in deployer registry: %v", err) } - access, _ := json.Marshal(map[string]string{"url": srv.URL, "headers": "Authorization: Bearer e2e-secret"}) - extended, _ := json.Marshal(map[string]string{"method": "POST", "path": "cert"}) + 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), @@ -143,22 +151,58 @@ func TestE2E_PilotDeploysThroughFullStack(t *testing.T) { if err != nil { t.Fatalf("factory: %v", err) } - deployer.SetLogger(slog.Default()) - if _, err := deployer.Deploy(context.Background(), "E2E_CERT", "E2E_KEY"); err != nil { + 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 seen.method != "POST" || seen.path != "/cert" { - t.Fatalf("webhook target saw %s %s", seen.method, seen.path) + 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) } - if !strings.Contains(seen.body, "E2E_CERT") { + 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{ @@ -239,3 +283,36 @@ func jsonMap(raw []byte) map[string]any { } 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 +} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index 6fbbaf0e8..39b949da4 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -3,18 +3,22 @@ package plugin import "time" const ( - defaultMinPort = 10000 - defaultMaxPort = 25000 - defaultStartTimeout = 10 * time.Second + 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 - LogLevel string - CoreVersion string + PluginDir string + MinPort uint + MaxPort uint + StartTimeout time.Duration + DeployTimeout time.Duration + MaxLogFrames int + LogLevel string + CoreVersion string } func (c *PluginConfig) defaults() { @@ -30,6 +34,12 @@ func (c *PluginConfig) defaults() { 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" } diff --git a/pkg/plugin/deployer_grpc.go b/pkg/plugin/deployer_grpc.go index 698b15c7b..edcb6bff7 100644 --- a/pkg/plugin/deployer_grpc.go +++ b/pkg/plugin/deployer_grpc.go @@ -2,6 +2,7 @@ package plugin import ( "context" + "log/slog" githubplugin "github.com/hashicorp/go-plugin" "google.golang.org/grpc" @@ -41,7 +42,7 @@ type DeployResult struct { type Deployer interface { GetMetadata(ctx context.Context) (*Metadata, error) GetConfigSchema(ctx context.Context) (*ConfigSchema, error) - Deploy(ctx context.Context, req *DeployRequest) (*DeployResult, error) + Deploy(ctx context.Context, req *DeployRequest, logger *slog.Logger) (*DeployResult, error) } type PluginSet = githubplugin.PluginSet @@ -101,18 +102,24 @@ func (s *deployerGRPCServer) GetConfigSchema(ctx context.Context, _ *proto.GetCo }, nil } -func (s *deployerGRPCServer) Deploy(ctx context.Context, req *proto.DeployRequest) (*proto.DeployResponse, error) { +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 nil, err + return err } - return &proto.DeployResponse{ExtendedDataJson: res.ExtendedDataJSON}, nil + return stream.Send(&proto.DeployFrame{ + Frame: &proto.DeployFrame_Result{ + Result: &proto.DeployResponse{ExtendedDataJson: res.ExtendedDataJSON}, + }, + }) } type deployerGRPCClient struct { @@ -150,8 +157,8 @@ func (c *deployerGRPCClient) GetConfigSchema(ctx context.Context) (*ConfigSchema }, nil } -func (c *deployerGRPCClient) Deploy(ctx context.Context, req *DeployRequest) (*DeployResult, error) { - resp, err := c.raw.Deploy(ctx, &proto.DeployRequest{ +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, @@ -161,5 +168,27 @@ func (c *deployerGRPCClient) Deploy(ctx context.Context, req *DeployRequest) (*D if err != nil { return nil, err } - return &DeployResult{ExtendedDataJSON: resp.ExtendedDataJson}, nil + 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...) } diff --git a/pkg/plugin/deployer_grpc_test.go b/pkg/plugin/deployer_grpc_test.go index 80d5ec83f..3d0a90704 100644 --- a/pkg/plugin/deployer_grpc_test.go +++ b/pkg/plugin/deployer_grpc_test.go @@ -3,7 +3,9 @@ package plugin import ( "context" "errors" + "log/slog" "net" + "sync" "testing" "google.golang.org/grpc" @@ -20,6 +22,7 @@ type fakeDeployer struct { deployRequest *DeployRequest deployResult *DeployResult deployErr error + deployLogs []string metadataCalls int schemaCalls int deployCalls int @@ -35,9 +38,14 @@ func (f *fakeDeployer) GetConfigSchema(ctx context.Context) (*ConfigSchema, erro return f.configSchema, nil } -func (f *fakeDeployer) Deploy(ctx context.Context, req *DeployRequest) (*DeployResult, error) { +func (f *fakeDeployer) Deploy(ctx context.Context, req *DeployRequest, logger *slog.Logger) (*DeployResult, error) { f.deployCalls++ f.deployRequest = req + for _, msg := range f.deployLogs { + if logger != nil { + logger.Info(msg) + } + } if f.deployErr != nil { return nil, f.deployErr } @@ -136,7 +144,7 @@ func TestDeployerGRPC_DeployRoundTrip(t *testing.T) { CertificatePEM: "CERT", PrivateKeyPEM: "KEY", } - res, err := client.Deploy(context.Background(), req) + res, err := client.Deploy(context.Background(), req, nil) if err != nil { t.Fatalf("Deploy: %v", err) } @@ -159,7 +167,7 @@ func TestDeployerGRPC_PluginErrorPreserved(t *testing.T) { client, cleanup := newRoundTrip(t, impl) defer cleanup() - _, err := client.Deploy(context.Background(), &DeployRequest{}) + _, err := client.Deploy(context.Background(), &DeployRequest{}, nil) if err == nil { t.Fatal("expected plugin error, got nil") } @@ -171,3 +179,76 @@ func TestDeployerGRPC_PluginErrorPreserved(t *testing.T) { t.Fatalf("expected message preserved across RPC, got %q", st.Message()) } } + +type captureHandler struct { + mu sync.Mutex + records []capturedRecord +} + +type capturedRecord struct { + Level slog.Level + Message string + Attrs map[string]string +} + +func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } + +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + attrs := map[string]string{} + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.String() + return true + }) + h.records = append(h.records, capturedRecord{Level: r.Level, Message: r.Message, Attrs: attrs}) + return nil +} + +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +func TestDeployerGRPC_DeployStreamsLogsThenResult(t *testing.T) { + impl := &fakeDeployer{ + deployLogs: []string{"sending webhook", "got status 200"}, + deployResult: &DeployResult{ExtendedDataJSON: `{"foo":"bar"}`}, + } + client, cleanup := newRoundTrip(t, impl) + defer cleanup() + + cap := &captureHandler{} + res, err := client.Deploy(context.Background(), &DeployRequest{LogLevel: "INFO"}, slog.New(cap)) + if err != nil { + t.Fatalf("Deploy: %v", err) + } + if res.ExtendedDataJSON != `{"foo":"bar"}` { + t.Fatalf("result mismatch: %v", res) + } + if len(cap.records) != 2 { + t.Fatalf("expected 2 forwarded log records, got %d: %+v", len(cap.records), cap.records) + } + if cap.records[0].Message != "sending webhook" || cap.records[1].Message != "got status 200" { + t.Fatalf("log order/content mismatch: %+v", cap.records) + } + if cap.records[0].Level != slog.LevelInfo { + t.Fatalf("level not preserved: %+v", cap.records) + } +} + +func TestDeployerGRPC_DeployNoLogsStillReturnsResult(t *testing.T) { + impl := &fakeDeployer{deployResult: &DeployResult{ExtendedDataJSON: `{"ok":true}`}} + client, cleanup := newRoundTrip(t, impl) + defer cleanup() + + cap := &captureHandler{} + res, err := client.Deploy(context.Background(), &DeployRequest{}, slog.New(cap)) + if err != nil { + t.Fatalf("Deploy: %v", err) + } + if res.ExtendedDataJSON != `{"ok":true}` { + t.Fatalf("result mismatch: %v", res) + } + if len(cap.records) != 0 { + t.Fatalf("expected no log records, got %+v", cap.records) + } +} diff --git a/pkg/plugin/handshake.go b/pkg/plugin/handshake.go index 6d332a6c1..4ebf5240b 100644 --- a/pkg/plugin/handshake.go +++ b/pkg/plugin/handshake.go @@ -4,7 +4,7 @@ import ( githubplugin "github.com/hashicorp/go-plugin" ) -const ProtocolVersion = 1 +const ProtocolVersion = 2 const MagicCookieKey = "CERTIMATE_PLUGIN" diff --git a/pkg/plugin/logger.go b/pkg/plugin/logger.go new file mode 100644 index 000000000..6a36aa5b2 --- /dev/null +++ b/pkg/plugin/logger.go @@ -0,0 +1,93 @@ +package plugin + +import ( + "context" + "log/slog" + "strconv" + "strings" + "sync" + + "github.com/certimate-go/certimate/pkg/plugin/proto" +) + +type frameSender interface { + Send(*proto.DeployFrame) error +} + +type streamLogHandler struct { + mu sync.Mutex + sender frameSender + level slog.Level +} + +func NewStreamLogger(sender frameSender, level slog.Level) *slog.Logger { + return slog.New(&streamLogHandler{sender: sender, level: level}) +} + +func (h *streamLogHandler) Enabled(_ context.Context, l slog.Level) bool { + return l >= h.level +} + +func (h *streamLogHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + return h.sender.Send(&proto.DeployFrame{ + Frame: &proto.DeployFrame_Log{Log: &proto.LogEntry{ + TimestampMilli: r.Time.UnixMilli(), + Level: int32(r.Level), + Message: r.Message, + Data: recordToMap(r), + }}, + }) +} + +func (h *streamLogHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *streamLogHandler) WithGroup(_ string) slog.Handler { return h } + +func recordToMap(r slog.Record) map[string]string { + if r.NumAttrs() == 0 { + return nil + } + out := make(map[string]string, r.NumAttrs()) + r.Attrs(func(a slog.Attr) bool { + flattenAttr(out, "", a) + return true + }) + return out +} + +func flattenAttr(out map[string]string, prefix string, a slog.Attr) { + a.Value = a.Value.Resolve() + if a.Value.Kind() == slog.KindGroup { + gp := a.Key + if prefix != "" { + gp = prefix + "." + a.Key + } + for _, sub := range a.Value.Group() { + flattenAttr(out, gp, sub) + } + return + } + k := a.Key + if prefix != "" { + k = prefix + "." + a.Key + } + out[k] = a.Value.String() +} + +func parseLogLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + case "info", "": + return slog.LevelInfo + } + if n, err := strconv.Atoi(s); err == nil { + return slog.Level(n) + } + return slog.LevelInfo +} diff --git a/pkg/plugin/logger_test.go b/pkg/plugin/logger_test.go new file mode 100644 index 000000000..fe8ca073a --- /dev/null +++ b/pkg/plugin/logger_test.go @@ -0,0 +1,76 @@ +package plugin + +import ( + "log/slog" + "testing" + + "github.com/certimate-go/certimate/pkg/plugin/proto" +) + +type fakeSender struct { + frames []*proto.DeployFrame + err error +} + +func (s *fakeSender) Send(f *proto.DeployFrame) error { + if s.err != nil { + return s.err + } + s.frames = append(s.frames, f) + return nil +} + +func TestParseLogLevel(t *testing.T) { + cases := map[string]slog.Level{ + "DEBUG": slog.LevelDebug, + "info": slog.LevelInfo, + "Warn": slog.LevelWarn, + "ERROR": slog.LevelError, + "": slog.LevelInfo, + "warning": slog.LevelWarn, + "8": slog.Level(8), + "bogus": slog.LevelInfo, + } + for in, want := range cases { + if got := parseLogLevel(in); got != want { + t.Errorf("parseLogLevel(%q) = %v, want %v", in, got, want) + } + } +} + +func TestStreamLoggerEmitsFramesAndRespectsLevel(t *testing.T) { + s := &fakeSender{} + logger := NewStreamLogger(s, slog.LevelWarn) + logger.Debug("d") + logger.Info("i") + logger.Warn("w", slog.String("k", "v")) + logger.Error("e") + + if len(s.frames) != 2 { + t.Fatalf("expected 2 frames (warn+error), got %d", len(s.frames)) + } + w := s.frames[0].GetLog() + if w.Message != "w" || w.Level != int32(slog.LevelWarn) { + t.Fatalf("warn frame mismatch: %+v", w) + } + if w.Data["k"] != "v" { + t.Fatalf("attr not flattened: %+v", w.Data) + } + if w.TimestampMilli == 0 { + t.Fatal("timestamp not set") + } + if s.frames[1].GetLog().Level != int32(slog.LevelError) { + t.Fatalf("error level mismatch: %+v", s.frames[1].GetLog()) + } +} + +func TestStreamLoggerNoAttrsNilMap(t *testing.T) { + s := &fakeSender{} + NewStreamLogger(s, slog.LevelInfo).Info("plain") + if len(s.frames) != 1 { + t.Fatalf("expected 1 frame, got %d", len(s.frames)) + } + if s.frames[0].GetLog().Data != nil { + t.Fatalf("expected nil data for no-attr record, got %+v", s.frames[0].GetLog().Data) + } +} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go index be40e0829..7e5a2e2e4 100644 --- a/pkg/plugin/manager.go +++ b/pkg/plugin/manager.go @@ -9,6 +9,7 @@ import ( "log/slog" "os/exec" "strings" + "sync" githubplugin "github.com/hashicorp/go-plugin" "google.golang.org/grpc/codes" @@ -52,11 +53,18 @@ func (m *Manager) Bootstrap(ctx context.Context, dp *DiscoveredPlugin) (*Metadat return meta, schema, nil } -func (m *Manager) Deploy(ctx context.Context, dp *DiscoveredPlugin, req *DeployRequest) (*DeployResult, error) { +func (m *Manager) Deploy(ctx context.Context, dp *DiscoveredPlugin, req *DeployRequest, forward *slog.Logger) (*DeployResult, error) { + if m.cfg.DeployTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, m.cfg.DeployTimeout) + defer cancel() + } + redact := redactorFor(req) + sink := newForwardLogger(forward, redact, m.cfg.MaxLogFrames) var res *DeployResult - err := m.withPlugin(ctx, dp, redactorFor(req), func(d Deployer) error { + err := m.withPlugin(ctx, dp, redact, func(d Deployer) error { var err error - res, err = d.Deploy(ctx, req) + res, err = d.Deploy(ctx, req, sink) return err }) return res, err @@ -189,6 +197,8 @@ func redactorFor(req *DeployRequest) secretRedactor { if req != nil { collect(req.AccessConfigJSON) collect(req.ExtendedConfigJSON) + collectPEM(&secrets, req.CertificatePEM) + collectPEM(&secrets, req.PrivateKeyPEM) } return func(s string) string { for _, sec := range secrets { @@ -214,3 +224,65 @@ func flattenValues(v any) []any { } return out } + +func collectPEM(secrets *[]string, pem string) { + pem = strings.TrimSpace(pem) + if len(pem) >= 4 { + *secrets = append(*secrets, pem) + } + for _, line := range strings.Split(pem, "\n") { + line = strings.TrimSpace(line) + if len(line) >= 32 && !strings.HasPrefix(line, "-----") { + *secrets = append(*secrets, line) + } + } +} + +type forwardHandler struct { + mu sync.Mutex + inner slog.Handler + redact secretRedactor + cap int + count int +} + +func newForwardLogger(forward *slog.Logger, redact secretRedactor, cap int) *slog.Logger { + if forward == nil { + return nil + } + return slog.New(&forwardHandler{inner: forward.Handler(), redact: redact, cap: cap}) +} + +func (h *forwardHandler) Enabled(ctx context.Context, l slog.Level) bool { + return h.inner.Enabled(ctx, l) +} + +func (h *forwardHandler) Handle(ctx context.Context, r slog.Record) error { + h.mu.Lock() + if h.cap > 0 && h.count >= h.cap { + h.mu.Unlock() + return nil + } + h.count++ + h.mu.Unlock() + attrs := redactAttrs(r, h.redact) + rec := slog.NewRecord(r.Time, r.Level, h.redact(r.Message), r.PC) + rec.AddAttrs(attrs...) + return h.inner.Handle(ctx, rec) +} + +func (h *forwardHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *forwardHandler) WithGroup(_ string) slog.Handler { return h } + +func redactAttrs(r slog.Record, redact secretRedactor) []slog.Attr { + out := make([]slog.Attr, 0, r.NumAttrs()) + r.Attrs(func(a slog.Attr) bool { + a.Value = a.Value.Resolve() + if a.Value.Kind() == slog.KindString { + a.Value = slog.StringValue(redact(a.Value.String())) + } + out = append(out, a) + return true + }) + return out +} diff --git a/pkg/plugin/manager_test.go b/pkg/plugin/manager_test.go index b9a0462be..bdf4db85a 100644 --- a/pkg/plugin/manager_test.go +++ b/pkg/plugin/manager_test.go @@ -3,6 +3,7 @@ package plugin import ( "context" "errors" + "log/slog" "os" "os/exec" "path/filepath" @@ -96,14 +97,14 @@ func TestManager_Deploy_OnDemandLifecycle(t *testing.T) { dp := discoveredFake(t, "deploy-demo") mgr := NewManager(PluginConfig{}, nil) - res1, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + res1, err := mgr.Deploy(context.Background(), dp, sampleDeployReq(), nil) if err != nil { t.Fatalf("first deploy: %v", err) } if res1.ExtendedDataJSON == "" { t.Fatal("empty deploy result") } - res2, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + res2, err := mgr.Deploy(context.Background(), dp, sampleDeployReq(), nil) if err != nil { t.Fatalf("second deploy (fresh client): %v", err) } @@ -117,7 +118,7 @@ func TestManager_Deploy_PluginConfigError_Mapped(t *testing.T) { dp := discoveredFake(t, "cfgerr-demo") mgr := NewManager(PluginConfig{}, nil) - _, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + _, err := mgr.Deploy(context.Background(), dp, sampleDeployReq(), nil) if err == nil { t.Fatal("expected config error") } @@ -131,7 +132,7 @@ func TestManager_Deploy_CrashIsolated_ReturnsErrPluginCrashed(t *testing.T) { dp := discoveredFake(t, "crash-demo") mgr := NewManager(PluginConfig{}, nil) - _, err := mgr.Deploy(context.Background(), dp, sampleDeployReq()) + _, err := mgr.Deploy(context.Background(), dp, sampleDeployReq(), nil) if err == nil { t.Fatal("expected crash error") } @@ -161,7 +162,7 @@ func TestManager_Deploy_CrashRedactsCredentials(t *testing.T) { } mgr := NewManager(PluginConfig{}, nil) - _, err := mgr.Deploy(context.Background(), dp, req) + _, err := mgr.Deploy(context.Background(), dp, req, nil) var crashed *ErrPluginCrashed if !errors.As(err, &crashed) { t.Fatalf("expected ErrPluginCrashed, got %T: %v", err, err) @@ -171,6 +172,42 @@ func TestManager_Deploy_CrashRedactsCredentials(t *testing.T) { } } +func TestRedactorFor_RedactsPEM(t *testing.T) { + body := strings.Repeat("A", 64) + key := "-----BEGIN PRIVATE KEY-----\n" + body + "\n-----END PRIVATE KEY-----" + redact := redactorFor(&DeployRequest{PrivateKeyPEM: key}) + + fullOut := redact("leaked: " + key) + if strings.Contains(fullOut, body) { + t.Fatalf("private key body not redacted when full PEM logged: %q", fullOut) + } + bodyOut := redact("leaked body: " + body) + if strings.Contains(bodyOut, body) { + t.Fatalf("private key body line not redacted: %q", bodyOut) + } +} + +func TestManager_Deploy_ForwardsPluginLogs(t *testing.T) { + defer withFakeEnv("forward-demo", "ok")() + dp := discoveredFake(t, "forward-demo") + + mgr := NewManager(PluginConfig{}, nil) + cap := &captureHandler{} + _, err := mgr.Deploy(context.Background(), dp, sampleDeployReq(), slog.New(cap)) + if err != nil { + t.Fatalf("deploy: %v", err) + } + found := false + for _, r := range cap.records { + if r.Message == "fakeplugin deploy starting" { + found = true + } + } + if !found { + t.Fatalf("plugin log not forwarded to sink: %+v", cap.records) + } +} + func sampleDeployReq() *DeployRequest { return &DeployRequest{ LogLevel: "INFO", diff --git a/pkg/plugin/proto/plugin.pb.go b/pkg/plugin/proto/plugin.pb.go index 5c8ef0175..c3288eae0 100644 --- a/pkg/plugin/proto/plugin.pb.go +++ b/pkg/plugin/proto/plugin.pb.go @@ -401,6 +401,156 @@ func (x *DeployResponse) GetExtendedDataJson() string { return "" } +type LogEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + TimestampMilli int64 `protobuf:"varint,1,opt,name=timestamp_milli,json=timestampMilli,proto3" json:"timestamp_milli,omitempty"` + Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + Data map[string]string `protobuf:"bytes,4,rep,name=data,proto3" json:"data,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogEntry) Reset() { + *x = LogEntry{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogEntry) ProtoMessage() {} + +func (x *LogEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. +func (*LogEntry) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{7} +} + +func (x *LogEntry) GetTimestampMilli() int64 { + if x != nil { + return x.TimestampMilli + } + return 0 +} + +func (x *LogEntry) GetLevel() int32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *LogEntry) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *LogEntry) GetData() map[string]string { + if x != nil { + return x.Data + } + return nil +} + +type DeployFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *DeployFrame_Log + // *DeployFrame_Result + Frame isDeployFrame_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeployFrame) Reset() { + *x = DeployFrame{} + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeployFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeployFrame) ProtoMessage() {} + +func (x *DeployFrame) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_proto_plugin_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeployFrame.ProtoReflect.Descriptor instead. +func (*DeployFrame) Descriptor() ([]byte, []int) { + return file_pkg_plugin_proto_plugin_proto_rawDescGZIP(), []int{8} +} + +func (x *DeployFrame) GetFrame() isDeployFrame_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *DeployFrame) GetLog() *LogEntry { + if x != nil { + if x, ok := x.Frame.(*DeployFrame_Log); ok { + return x.Log + } + } + return nil +} + +func (x *DeployFrame) GetResult() *DeployResponse { + if x != nil { + if x, ok := x.Frame.(*DeployFrame_Result); ok { + return x.Result + } + } + return nil +} + +type isDeployFrame_Frame interface { + isDeployFrame_Frame() +} + +type DeployFrame_Log struct { + Log *LogEntry `protobuf:"bytes,1,opt,name=log,proto3,oneof"` +} + +type DeployFrame_Result struct { + Result *DeployResponse `protobuf:"bytes,2,opt,name=result,proto3,oneof"` +} + +func (*DeployFrame_Log) isDeployFrame_Frame() {} + +func (*DeployFrame_Result) isDeployFrame_Frame() {} + var File_pkg_plugin_proto_plugin_proto protoreflect.FileDescriptor const file_pkg_plugin_proto_plugin_proto_rawDesc = "" + @@ -434,11 +584,23 @@ const file_pkg_plugin_proto_plugin_proto_rawDesc = "" + "\x0fcertificate_pem\x18\x04 \x01(\tR\x0ecertificatePem\x12&\n" + "\x0fprivate_key_pem\x18\x05 \x01(\tR\rprivateKeyPem\">\n" + "\x0eDeployResponse\x12,\n" + - "\x12extended_data_json\x18\x01 \x01(\tR\x10extendedDataJson2\xb3\x02\n" + + "\x12extended_data_json\x18\x01 \x01(\tR\x10extendedDataJson\"\xd9\x01\n" + + "\bLogEntry\x12'\n" + + "\x0ftimestamp_milli\x18\x01 \x01(\x03R\x0etimestampMilli\x12\x14\n" + + "\x05level\x18\x02 \x01(\x05R\x05level\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\x12;\n" + + "\x04data\x18\x04 \x03(\v2'.certimate.plugin.v1.LogEntry.DataEntryR\x04data\x1a7\n" + + "\tDataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x88\x01\n" + + "\vDeployFrame\x121\n" + + "\x03log\x18\x01 \x01(\v2\x1d.certimate.plugin.v1.LogEntryH\x00R\x03log\x12=\n" + + "\x06result\x18\x02 \x01(\v2#.certimate.plugin.v1.DeployResponseH\x00R\x06resultB\a\n" + + "\x05frame2\xb2\x02\n" + "\x0eDeployerPlugin\x12`\n" + "\vGetMetadata\x12'.certimate.plugin.v1.GetMetadataRequest\x1a(.certimate.plugin.v1.GetMetadataResponse\x12l\n" + - "\x0fGetConfigSchema\x12+.certimate.plugin.v1.GetConfigSchemaRequest\x1a,.certimate.plugin.v1.GetConfigSchemaResponse\x12Q\n" + - "\x06Deploy\x12\".certimate.plugin.v1.DeployRequest\x1a#.certimate.plugin.v1.DeployResponseB4Z2github.com/certimate-go/certimate/pkg/plugin/protob\x06proto3" + "\x0fGetConfigSchema\x12+.certimate.plugin.v1.GetConfigSchemaRequest\x1a,.certimate.plugin.v1.GetConfigSchemaResponse\x12P\n" + + "\x06Deploy\x12\".certimate.plugin.v1.DeployRequest\x1a .certimate.plugin.v1.DeployFrame0\x01B4Z2github.com/certimate-go/certimate/pkg/plugin/protob\x06proto3" var ( file_pkg_plugin_proto_plugin_proto_rawDescOnce sync.Once @@ -452,7 +614,7 @@ func file_pkg_plugin_proto_plugin_proto_rawDescGZIP() []byte { return file_pkg_plugin_proto_plugin_proto_rawDescData } -var file_pkg_plugin_proto_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_pkg_plugin_proto_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_pkg_plugin_proto_plugin_proto_goTypes = []any{ (*GetMetadataRequest)(nil), // 0: certimate.plugin.v1.GetMetadataRequest (*GetMetadataResponse)(nil), // 1: certimate.plugin.v1.GetMetadataResponse @@ -461,24 +623,30 @@ var file_pkg_plugin_proto_plugin_proto_goTypes = []any{ (*GetConfigSchemaResponse)(nil), // 4: certimate.plugin.v1.GetConfigSchemaResponse (*DeployRequest)(nil), // 5: certimate.plugin.v1.DeployRequest (*DeployResponse)(nil), // 6: certimate.plugin.v1.DeployResponse - nil, // 7: certimate.plugin.v1.StringMap.EntriesEntry - nil, // 8: certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry + (*LogEntry)(nil), // 7: certimate.plugin.v1.LogEntry + (*DeployFrame)(nil), // 8: certimate.plugin.v1.DeployFrame + nil, // 9: certimate.plugin.v1.StringMap.EntriesEntry + nil, // 10: certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry + nil, // 11: certimate.plugin.v1.LogEntry.DataEntry } var file_pkg_plugin_proto_plugin_proto_depIdxs = []int32{ - 7, // 0: certimate.plugin.v1.StringMap.entries:type_name -> certimate.plugin.v1.StringMap.EntriesEntry - 8, // 1: certimate.plugin.v1.GetConfigSchemaResponse.i18n_resources:type_name -> certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry - 3, // 2: certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry.value:type_name -> certimate.plugin.v1.StringMap - 0, // 3: certimate.plugin.v1.DeployerPlugin.GetMetadata:input_type -> certimate.plugin.v1.GetMetadataRequest - 2, // 4: certimate.plugin.v1.DeployerPlugin.GetConfigSchema:input_type -> certimate.plugin.v1.GetConfigSchemaRequest - 5, // 5: certimate.plugin.v1.DeployerPlugin.Deploy:input_type -> certimate.plugin.v1.DeployRequest - 1, // 6: certimate.plugin.v1.DeployerPlugin.GetMetadata:output_type -> certimate.plugin.v1.GetMetadataResponse - 4, // 7: certimate.plugin.v1.DeployerPlugin.GetConfigSchema:output_type -> certimate.plugin.v1.GetConfigSchemaResponse - 6, // 8: certimate.plugin.v1.DeployerPlugin.Deploy:output_type -> certimate.plugin.v1.DeployResponse - 6, // [6:9] is the sub-list for method output_type - 3, // [3:6] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 9, // 0: certimate.plugin.v1.StringMap.entries:type_name -> certimate.plugin.v1.StringMap.EntriesEntry + 10, // 1: certimate.plugin.v1.GetConfigSchemaResponse.i18n_resources:type_name -> certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry + 11, // 2: certimate.plugin.v1.LogEntry.data:type_name -> certimate.plugin.v1.LogEntry.DataEntry + 7, // 3: certimate.plugin.v1.DeployFrame.log:type_name -> certimate.plugin.v1.LogEntry + 6, // 4: certimate.plugin.v1.DeployFrame.result:type_name -> certimate.plugin.v1.DeployResponse + 3, // 5: certimate.plugin.v1.GetConfigSchemaResponse.I18nResourcesEntry.value:type_name -> certimate.plugin.v1.StringMap + 0, // 6: certimate.plugin.v1.DeployerPlugin.GetMetadata:input_type -> certimate.plugin.v1.GetMetadataRequest + 2, // 7: certimate.plugin.v1.DeployerPlugin.GetConfigSchema:input_type -> certimate.plugin.v1.GetConfigSchemaRequest + 5, // 8: certimate.plugin.v1.DeployerPlugin.Deploy:input_type -> certimate.plugin.v1.DeployRequest + 1, // 9: certimate.plugin.v1.DeployerPlugin.GetMetadata:output_type -> certimate.plugin.v1.GetMetadataResponse + 4, // 10: certimate.plugin.v1.DeployerPlugin.GetConfigSchema:output_type -> certimate.plugin.v1.GetConfigSchemaResponse + 8, // 11: certimate.plugin.v1.DeployerPlugin.Deploy:output_type -> certimate.plugin.v1.DeployFrame + 9, // [9:12] is the sub-list for method output_type + 6, // [6:9] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_pkg_plugin_proto_plugin_proto_init() } @@ -486,13 +654,17 @@ func file_pkg_plugin_proto_plugin_proto_init() { if File_pkg_plugin_proto_plugin_proto != nil { return } + file_pkg_plugin_proto_plugin_proto_msgTypes[8].OneofWrappers = []any{ + (*DeployFrame_Log)(nil), + (*DeployFrame_Result)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pkg_plugin_proto_plugin_proto_rawDesc), len(file_pkg_plugin_proto_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/plugin/proto/plugin.proto b/pkg/plugin/proto/plugin.proto index be8802e24..ac5e82742 100644 --- a/pkg/plugin/proto/plugin.proto +++ b/pkg/plugin/proto/plugin.proto @@ -7,7 +7,7 @@ option go_package = "github.com/certimate-go/certimate/pkg/plugin/proto"; service DeployerPlugin { rpc GetMetadata(GetMetadataRequest) returns (GetMetadataResponse); rpc GetConfigSchema(GetConfigSchemaRequest) returns (GetConfigSchemaResponse); - rpc Deploy(DeployRequest) returns (DeployResponse); + rpc Deploy(DeployRequest) returns (stream DeployFrame); } message GetMetadataRequest {} @@ -44,3 +44,17 @@ message DeployRequest { message DeployResponse { string extended_data_json = 1; } + +message LogEntry { + int64 timestamp_milli = 1; + int32 level = 2; + string message = 3; + map data = 4; +} + +message DeployFrame { + oneof frame { + LogEntry log = 1; + DeployResponse result = 2; + } +} diff --git a/pkg/plugin/proto/plugin_grpc.pb.go b/pkg/plugin/proto/plugin_grpc.pb.go index 381b52be4..850327ed2 100644 --- a/pkg/plugin/proto/plugin_grpc.pb.go +++ b/pkg/plugin/proto/plugin_grpc.pb.go @@ -30,7 +30,7 @@ const ( type DeployerPluginClient interface { GetMetadata(ctx context.Context, in *GetMetadataRequest, opts ...grpc.CallOption) (*GetMetadataResponse, error) GetConfigSchema(ctx context.Context, in *GetConfigSchemaRequest, opts ...grpc.CallOption) (*GetConfigSchemaResponse, error) - Deploy(ctx context.Context, in *DeployRequest, opts ...grpc.CallOption) (*DeployResponse, error) + Deploy(ctx context.Context, in *DeployRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployFrame], error) } type deployerPluginClient struct { @@ -61,23 +61,32 @@ func (c *deployerPluginClient) GetConfigSchema(ctx context.Context, in *GetConfi return out, nil } -func (c *deployerPluginClient) Deploy(ctx context.Context, in *DeployRequest, opts ...grpc.CallOption) (*DeployResponse, error) { +func (c *deployerPluginClient) Deploy(ctx context.Context, in *DeployRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployFrame], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeployResponse) - err := c.cc.Invoke(ctx, DeployerPlugin_Deploy_FullMethodName, in, out, cOpts...) + stream, err := c.cc.NewStream(ctx, &DeployerPlugin_ServiceDesc.Streams[0], DeployerPlugin_Deploy_FullMethodName, cOpts...) if err != nil { return nil, err } - return out, nil + x := &grpc.GenericClientStream[DeployRequest, DeployFrame]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil } +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DeployerPlugin_DeployClient = grpc.ServerStreamingClient[DeployFrame] + // DeployerPluginServer is the server API for DeployerPlugin service. // All implementations must embed UnimplementedDeployerPluginServer // for forward compatibility. type DeployerPluginServer interface { GetMetadata(context.Context, *GetMetadataRequest) (*GetMetadataResponse, error) GetConfigSchema(context.Context, *GetConfigSchemaRequest) (*GetConfigSchemaResponse, error) - Deploy(context.Context, *DeployRequest) (*DeployResponse, error) + Deploy(*DeployRequest, grpc.ServerStreamingServer[DeployFrame]) error mustEmbedUnimplementedDeployerPluginServer() } @@ -94,8 +103,8 @@ func (UnimplementedDeployerPluginServer) GetMetadata(context.Context, *GetMetada func (UnimplementedDeployerPluginServer) GetConfigSchema(context.Context, *GetConfigSchemaRequest) (*GetConfigSchemaResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetConfigSchema not implemented") } -func (UnimplementedDeployerPluginServer) Deploy(context.Context, *DeployRequest) (*DeployResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Deploy not implemented") +func (UnimplementedDeployerPluginServer) Deploy(*DeployRequest, grpc.ServerStreamingServer[DeployFrame]) error { + return status.Error(codes.Unimplemented, "method Deploy not implemented") } func (UnimplementedDeployerPluginServer) mustEmbedUnimplementedDeployerPluginServer() {} func (UnimplementedDeployerPluginServer) testEmbeddedByValue() {} @@ -154,24 +163,17 @@ func _DeployerPlugin_GetConfigSchema_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } -func _DeployerPlugin_Deploy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeployRequest) - if err := dec(in); err != nil { - return nil, err +func _DeployerPlugin_Deploy_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(DeployRequest) + if err := stream.RecvMsg(m); err != nil { + return err } - if interceptor == nil { - return srv.(DeployerPluginServer).Deploy(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: DeployerPlugin_Deploy_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(DeployerPluginServer).Deploy(ctx, req.(*DeployRequest)) - } - return interceptor(ctx, in, info, handler) + return srv.(DeployerPluginServer).Deploy(m, &grpc.GenericServerStream[DeployRequest, DeployFrame]{ServerStream: stream}) } +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DeployerPlugin_DeployServer = grpc.ServerStreamingServer[DeployFrame] + // DeployerPlugin_ServiceDesc is the grpc.ServiceDesc for DeployerPlugin service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -187,11 +189,13 @@ var DeployerPlugin_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetConfigSchema", Handler: _DeployerPlugin_GetConfigSchema_Handler, }, + }, + Streams: []grpc.StreamDesc{ { - MethodName: "Deploy", - Handler: _DeployerPlugin_Deploy_Handler, + StreamName: "Deploy", + Handler: _DeployerPlugin_Deploy_Handler, + ServerStreams: true, }, }, - Streams: []grpc.StreamDesc{}, Metadata: "pkg/plugin/proto/plugin.proto", } diff --git a/pkg/plugin/testplugin/main.go b/pkg/plugin/testplugin/main.go index 1890ffd5e..a30e62317 100644 --- a/pkg/plugin/testplugin/main.go +++ b/pkg/plugin/testplugin/main.go @@ -5,6 +5,7 @@ package main import ( "context" "fmt" + "log/slog" "os" githubplugin "github.com/hashicorp/go-plugin" @@ -44,8 +45,11 @@ func (f *fakeDeployer) GetConfigSchema(ctx context.Context) (*plugin.ConfigSchem }, nil } -func (f *fakeDeployer) Deploy(ctx context.Context, req *plugin.DeployRequest) (*plugin.DeployResult, error) { +func (f *fakeDeployer) Deploy(ctx context.Context, req *plugin.DeployRequest, logger *slog.Logger) (*plugin.DeployResult, error) { fmt.Fprintf(os.Stderr, "fakeplugin deploy behavior=%s access=%s\n", f.behavior, req.AccessConfigJSON) + if logger != nil { + logger.Info("fakeplugin deploy starting", slog.String("behavior", f.behavior)) + } switch f.behavior { case "crash": fmt.Fprintln(os.Stderr, "fakeplugin crashing now") diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index 64e2f2374..71929d950 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -47,12 +47,12 @@ const AccessProviderPicker = forwardRef s.loaded); + const pluginCatalogVersion = usePluginCatalogStore((s) => s.version); const [keyword, setKeyword] = useState(); const keywordInputRef = useRef(null); - const allProviders = useMemo(() => Array.from(accessProvidersMap.values()), [pluginCatalogLoaded]); + const allProviders = useMemo(() => Array.from(accessProvidersMap.values()), [pluginCatalogVersion]); const dataSources = usePickerDataSource({ dataSource: allProviders, diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index 92e4f707f..102166a66 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -21,7 +21,7 @@ const DeploymentProviderPicker = forwardRef { const { t } = useTranslation(); - const pluginCatalogLoaded = usePluginCatalogStore((s) => s.loaded); + const pluginCatalogVersion = usePluginCatalogStore((s) => s.version); const { wrapperElRef, cols } = usePickerWrapperCols(320); @@ -30,7 +30,7 @@ const DeploymentProviderPicker = forwardRef(); const keywordInputRef = useRef(null); - const allProviders = useMemo(() => Array.from(deploymentProvidersMap.values()), [pluginCatalogLoaded]); + const allProviders = useMemo(() => Array.from(deploymentProvidersMap.values()), [pluginCatalogVersion]); const dataSources = usePickerDataSource({ dataSource: allProviders, diff --git a/ui/src/pages/ConsoleLayout.tsx b/ui/src/pages/ConsoleLayout.tsx index 9af81ca0d..af3e3568f 100644 --- a/ui/src/pages/ConsoleLayout.tsx +++ b/ui/src/pages/ConsoleLayout.tsx @@ -24,6 +24,7 @@ import Show from "@/components/Show"; import { APP_DOCUMENT_URL, APP_REPO_URL } from "@/domain/app"; import { useTriggerElement } from "@/hooks"; import { getAuthStore } from "@/repository/admin"; +import { usePluginCatalogStore } from "@/stores/pluginCatalog"; import { isBrowserHappy } from "@/utils/browser"; const ConsoleLayout = () => { @@ -227,6 +228,10 @@ const SiderMenu = memo(({ collapsed, onSelect }: { collapsed?: boolean; onSelect } }, [menuSelectedKey]); + useEffect(() => { + void usePluginCatalogStore.getState().reload(); + }, []); + return ( <>
diff --git a/ui/src/stores/pluginCatalog/index.ts b/ui/src/stores/pluginCatalog/index.ts index 69a8d5362..cb2b57759 100644 --- a/ui/src/stores/pluginCatalog/index.ts +++ b/ui/src/stores/pluginCatalog/index.ts @@ -5,47 +5,36 @@ import { applyPluginCatalog } from "@/domain/pluginCatalog"; interface PluginCatalogState { loaded: boolean; + version: number; init: () => Promise; reload: () => Promise; } -export const usePluginCatalogStore = create((set, get) => { +export const usePluginCatalogStore = create((set) => { let inflight: Promise | null = null; + + const run = (): Promise => { + if (inflight) { + return inflight; + } + inflight = (async () => { + try { + const entries = await listPluginCatalog(); + applyPluginCatalog(entries); + set((s) => ({ loaded: true, version: s.version + 1 })); + } catch { + set({ loaded: true }); + } finally { + inflight = null; + } + })(); + return inflight; + }; + return { loaded: false, - init: () => { - if (inflight) { - return inflight; - } - inflight = (async () => { - try { - const entries = await listPluginCatalog(); - applyPluginCatalog(entries); - } catch { - } finally { - set({ loaded: true }); - inflight = null; - } - })(); - return inflight; - }, - reload: () => { - if (!get().loaded) { - return Promise.resolve(); - } - if (inflight) { - return inflight; - } - inflight = (async () => { - try { - const entries = await listPluginCatalog(); - applyPluginCatalog(entries); - } catch { - } finally { - inflight = null; - } - })(); - return inflight; - }, + version: 0, + init: run, + reload: run, }; }); From 6cfe28e431142263a040700015288c883f6fcd07 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Tue, 28 Jul 2026 18:07:25 +0800 Subject: [PATCH 11/19] feat: deployer migration --- go.mod | 10 +- go.sum | 19 -- internal/certmgmt/deployers/sp_qiniu_cdn.go | 27 -- internal/certmgmt/deployers/sp_qiniu_kodo.go | 27 -- internal/certmgmt/deployers/sp_qiniu_pili.go | 28 --- internal/certmgmt/deployers/sp_webhook.go | 51 ++++ .../providers/qiniu-sslcert/qiniu_sslcert.go | 170 ------------- .../qiniu-sslcert/qiniu_sslcert_test.go | 49 ---- .../deployer/providers/qiniu-cdn/consts.go | 10 - .../deployer/providers/qiniu-cdn/qiniu_cdn.go | 231 ------------------ .../providers/qiniu-cdn/qiniu_cdn_test.go | 54 ---- .../providers/qiniu-kodo/qiniu_kodo.go | 94 ------- .../providers/qiniu-kodo/qiniu_kodo_test.go | 57 ----- .../deployer/providers/qiniu-pili/consts.go | 8 - .../providers/qiniu-pili/qiniu_pili.go | 174 ------------- .../providers/qiniu-pili/qiniu_pili_test.go | 57 ----- .../deployer/providers/webhook/webhook.go | 226 +++++++++++++++++ .../providers/webhook/webhook_test.go | 57 +++++ pkg/sdk3rd/qiniu/auth.go | 30 --- pkg/sdk3rd/qiniu/cdn.go | 130 ---------- pkg/sdk3rd/qiniu/doc.go | 3 - pkg/sdk3rd/qiniu/kodo.go | 44 ---- pkg/sdk3rd/qiniu/sslcert.go | 80 ------ pkg/sdk3rd/qiniu/util.go | 14 -- .../forms/AccessConfigFieldsProvider.tsx | 6 +- .../forms/AccessConfigFieldsProviderQiniu.tsx | 64 ----- .../provider/DeploymentProviderPicker.tsx | 10 +- .../BizDeployNodeConfigFieldsProvider.tsx | 12 +- ...DeployNodeConfigFieldsProviderQiniuCDN.tsx | 107 -------- ...eployNodeConfigFieldsProviderQiniuKodo.tsx | 64 ----- ...eployNodeConfigFieldsProviderQiniuPili.tsx | 110 --------- ...zDeployNodeConfigFieldsProviderWebhook.tsx | 94 +++++++ .../designer/forms/schemaValidation.ts | 9 +- ui/src/domain/provider.ts | 10 +- ui/src/i18n/resources/en/nls.access.json | 10 - ui/src/i18n/resources/en/nls.provider.json | 4 - .../i18n/resources/en/nls.workflow.nodes.json | 32 +-- ui/src/i18n/resources/zh/nls.access.json | 10 - ui/src/i18n/resources/zh/nls.provider.json | 4 - .../i18n/resources/zh/nls.workflow.nodes.json | 32 +-- 40 files changed, 471 insertions(+), 1757 deletions(-) delete mode 100644 internal/certmgmt/deployers/sp_qiniu_cdn.go delete mode 100644 internal/certmgmt/deployers/sp_qiniu_kodo.go delete mode 100644 internal/certmgmt/deployers/sp_qiniu_pili.go create mode 100644 internal/certmgmt/deployers/sp_webhook.go delete mode 100644 pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert.go delete mode 100644 pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert_test.go delete mode 100644 pkg/core/deployer/providers/qiniu-cdn/consts.go delete mode 100644 pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn.go delete mode 100644 pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn_test.go delete mode 100644 pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo.go delete mode 100644 pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo_test.go delete mode 100644 pkg/core/deployer/providers/qiniu-pili/consts.go delete mode 100644 pkg/core/deployer/providers/qiniu-pili/qiniu_pili.go delete mode 100644 pkg/core/deployer/providers/qiniu-pili/qiniu_pili_test.go create mode 100644 pkg/core/deployer/providers/webhook/webhook.go create mode 100644 pkg/core/deployer/providers/webhook/webhook_test.go delete mode 100644 pkg/sdk3rd/qiniu/auth.go delete mode 100644 pkg/sdk3rd/qiniu/cdn.go delete mode 100644 pkg/sdk3rd/qiniu/doc.go delete mode 100644 pkg/sdk3rd/qiniu/kodo.go delete mode 100644 pkg/sdk3rd/qiniu/sslcert.go delete mode 100644 pkg/sdk3rd/qiniu/util.go delete mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderQiniu.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuKodo.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuPili.tsx create mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx diff --git a/go.mod b/go.mod index b7f6e5e60..a1c3eb070 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/baidubce/bce-sdk-go v0.9.270 github.com/byteplus-sdk/byteplus-go-sdk-v2 v1.0.71 github.com/byteplus-sdk/byteplus-sdk-golang v1.0.71 + github.com/fsnotify/fsnotify v1.10.1 github.com/go-acme/lego/v5 v5.2.2 github.com/go-cmd/cmd v1.4.3 github.com/go-resty/resty/v2 v2.17.2 @@ -63,7 +64,6 @@ require ( github.com/pocketbase/pocketbase v0.39.6 github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df github.com/pquerna/otp v1.5.0 - github.com/qiniu/go-sdk/v7 v7.26.15 github.com/samber/lo v1.53.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -122,9 +122,6 @@ require ( 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.2 // 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 @@ -134,7 +131,6 @@ require ( 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 @@ -146,8 +142,6 @@ 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 @@ -174,7 +168,6 @@ require ( cloud.google.com/go/auth v0.20.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 @@ -204,7 +197,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.5.0 // indirect github.com/go-acme/alidns-20150109/v5 v5.4.1 // indirect diff --git a/go.sum b/go.sum index 7f097ce46..96d0fe879 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -417,17 +415,9 @@ github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lE 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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= 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= @@ -675,8 +665,6 @@ 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= @@ -831,13 +819,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.15 h1:CaKVcP29ZnOp/pqE7U3RAZxqwL7CmtMTWlV1gAV71H0= -github.com/qiniu/go-sdk/v7 v7.26.15/go.mod h1:ri7fGwbio0pRDFr8EK5TUpx0DbnpIMJ2bMSDxGWfCbk= -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= diff --git a/internal/certmgmt/deployers/sp_qiniu_cdn.go b/internal/certmgmt/deployers/sp_qiniu_cdn.go deleted file mode 100644 index fe71ce8b4..000000000 --- a/internal/certmgmt/deployers/sp_qiniu_cdn.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/certmgmt/deployers/sp_qiniu_kodo.go b/internal/certmgmt/deployers/sp_qiniu_kodo.go deleted file mode 100644 index a12e7e7bd..000000000 --- a/internal/certmgmt/deployers/sp_qiniu_kodo.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/certmgmt/deployers/sp_qiniu_pili.go b/internal/certmgmt/deployers/sp_qiniu_pili.go deleted file mode 100644 index 4a5315e86..000000000 --- a/internal/certmgmt/deployers/sp_qiniu_pili.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/certmgmt/deployers/sp_webhook.go b/internal/certmgmt/deployers/sp_webhook.go new file mode 100644 index 000000000..d21181857 --- /dev/null +++ b/internal/certmgmt/deployers/sp_webhook.go @@ -0,0 +1,51 @@ +package deployers + +import ( + "fmt" + "net/http" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core" + dplyimpl "github.com/certimate-go/certimate/pkg/core/deployer/providers/webhook" + xhttp "github.com/certimate-go/certimate/pkg/utils/http" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + Registries.MustRegister(domain.DeploymentProviderTypeWebhook, func(options *ProviderFactoryOptions) (core.Deployer, error) { + credentials := domain.AccessConfigForWebhook{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + mergedHeaders := make(map[string]string) + if defaultHeadersString := credentials.HeadersString; defaultHeadersString != "" { + h, err := xhttp.ParseHeaders(defaultHeadersString) + if err != nil { + return nil, fmt.Errorf("failed to parse webhook headers: %w", err) + } + for key := range h { + mergedHeaders[http.CanonicalHeaderKey(key)] = h.Get(key) + } + } + if extendedHeadersString := xmaps.GetString(options.ProviderExtendedConfig, "headers"); extendedHeadersString != "" { + h, err := xhttp.ParseHeaders(extendedHeadersString) + if err != nil { + return nil, fmt.Errorf("failed to parse webhook headers: %w", err) + } + for key := range h { + mergedHeaders[http.CanonicalHeaderKey(key)] = h.Get(key) + } + } + + provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{ + WebhookUrl: credentials.Url, + WebhookData: xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "webhookData", credentials.DataString), + Method: credentials.Method, + Headers: mergedHeaders, + Timeout: xmaps.GetInt(options.ProviderExtendedConfig, "timeout"), + AllowInsecureConnections: credentials.AllowInsecureConnections, + }) + return provider, err + }) +} diff --git a/pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert.go b/pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert.go deleted file mode 100644 index 5f4d566d3..000000000 --- a/pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert_test.go b/pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert_test.go deleted file mode 100644 index 966773112..000000000 --- a/pkg/core/certmgr/providers/qiniu-sslcert/qiniu_sslcert_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package qiniusslcert_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/qiniu-sslcert" - tester "github.com/certimate-go/certimate/pkg/core/certmgr/testing" -) - -var ( - fp = tester.Args("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 -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, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestUpload(t, provider, tester.TestUploadArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/core/deployer/providers/qiniu-cdn/consts.go b/pkg/core/deployer/providers/qiniu-cdn/consts.go deleted file mode 100644 index a4bbfb19e..000000000 --- a/pkg/core/deployer/providers/qiniu-cdn/consts.go +++ /dev/null @@ -1,10 +0,0 @@ -package qiniucdn - -const ( - // 匹配模式:精确匹配。 - DOMAIN_MATCH_PATTERN_EXACT = "exact" - // 匹配模式:通配符匹配。 - DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" - // 匹配模式:证书 SAN 匹配。 - DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" -) diff --git a/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn.go b/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn.go deleted file mode 100644 index 14b0816c8..000000000 --- a/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn_test.go b/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn_test.go deleted file mode 100644 index 15a5f0e71..000000000 --- a/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package qiniucdn_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-cdn" - tester "github.com/certimate-go/certimate/pkg/core/deployer/testing" -) - -var ( - fp = tester.Args("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 -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, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo.go b/pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo.go deleted file mode 100644 index e4754f2ff..000000000 --- a/pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo_test.go b/pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo_test.go deleted file mode 100644 index cc514bff0..000000000 --- a/pkg/core/deployer/providers/qiniu-kodo/qiniu_kodo_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package qiniukodo_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-kodo" - tester "github.com/certimate-go/certimate/pkg/core/deployer/testing" -) - -var ( - fp = tester.Args("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 -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, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/core/deployer/providers/qiniu-pili/consts.go b/pkg/core/deployer/providers/qiniu-pili/consts.go deleted file mode 100644 index 256a9beeb..000000000 --- a/pkg/core/deployer/providers/qiniu-pili/consts.go +++ /dev/null @@ -1,8 +0,0 @@ -package qiniupili - -const ( - // 匹配模式:精确匹配。 - DOMAIN_MATCH_PATTERN_EXACT = "exact" - // 匹配模式:证书 SAN 匹配。 - DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" -) diff --git a/pkg/core/deployer/providers/qiniu-pili/qiniu_pili.go b/pkg/core/deployer/providers/qiniu-pili/qiniu_pili.go deleted file mode 100644 index 3035c8725..000000000 --- a/pkg/core/deployer/providers/qiniu-pili/qiniu_pili.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/qiniu-pili/qiniu_pili_test.go b/pkg/core/deployer/providers/qiniu-pili/qiniu_pili_test.go deleted file mode 100644 index a3f5484e5..000000000 --- a/pkg/core/deployer/providers/qiniu-pili/qiniu_pili_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package qiniupili_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/qiniu-pili" - tester "github.com/certimate-go/certimate/pkg/core/deployer/testing" -) - -var ( - fp = tester.Args("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 -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, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/core/deployer/providers/webhook/webhook.go b/pkg/core/deployer/providers/webhook/webhook.go new file mode 100644 index 000000000..638e39b0a --- /dev/null +++ b/pkg/core/deployer/providers/webhook/webhook.go @@ -0,0 +1,226 @@ +package webhook + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log/slog" + "mime" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-resty/resty/v2" + + "github.com/certimate-go/certimate/pkg/core" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" + xcertx509 "github.com/certimate-go/certimate/pkg/utils/cert/x509" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +type ( + Provider = core.Deployer + DeployResult = core.DeployerDeployResult +) + +type DeployerConfig struct { + // Webhook URL。 + WebhookUrl string `json:"webhookUrl"` + // Webhook 回调数据(application/json 或 application/x-www-form-urlencoded 格式)。 + WebhookData string `json:"webhookData,omitempty"` + // 请求谓词。 + // 零值时默认值 "POST"。 + Method string `json:"method,omitempty"` + // 请求标头。 + Headers map[string]string `json:"headers,omitempty"` + // 请求超时(单位:秒)。 + // 零值时默认值 30。 + Timeout int `json:"timeout,omitempty"` + // 是否允许不安全的连接。 + AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` +} + +type Deployer struct { + config *DeployerConfig + logger *slog.Logger + httpClient *resty.Client +} + +var _ Provider = (*Deployer)(nil) + +const ( + contentTypeJson = "application/json" + contentTypeForm = "application/x-www-form-urlencoded" + contentTypeMultipart = "multipart/form-data" +) + +var allowedContentTypes = map[string]bool{ + contentTypeJson: true, + contentTypeForm: true, + contentTypeMultipart: true, +} + +var allowedMethods = map[string]bool{ + http.MethodGet: true, + http.MethodPost: true, + http.MethodPut: true, + http.MethodPatch: true, + http.MethodDelete: true, +} + +func NewDeployer(config *DeployerConfig) (*Deployer, error) { + if config == nil { + return nil, fmt.Errorf("the configuration of the deployer provider is nil") + } + + client := resty.New(). + SetTimeout(30 * time.Second). + SetRetryCount(3). + SetRetryWaitTime(5 * time.Second). + AddRetryCondition(func(resp *resty.Response, _ error) bool { + return resp == nil || resp.StatusCode() >= 500 + }) + if config.Timeout > 0 { + client.SetTimeout(time.Duration(config.Timeout) * time.Second) + } + if config.AllowInsecureConnections { + client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) + } + + return &Deployer{ + config: config, + logger: slog.Default(), + httpClient: client, + }, 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) { + // 解析证书内容 + certX509, err := xcert.ParseCertificateFromPEM(certPEM) + if err != nil { + return nil, fmt.Errorf("failed to parse x509: %w", err) + } + + // 提取服务器证书和中间证书 + serverCertPEM, issuerCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM) + if err != nil { + return nil, fmt.Errorf("failed to extract certs: %w", err) + } + + // 处理 Webhook URL + webhookUrl, err := url.Parse(d.config.WebhookUrl) + if err != nil { + return nil, fmt.Errorf("failed to parse webhook url: %w", err) + } else if webhookUrl.Scheme != "http" && webhookUrl.Scheme != "https" { + return nil, fmt.Errorf("unsupported webhook url scheme '%s'", webhookUrl.Scheme) + } + + // 处理 Webhook 请求谓词 + webhookMethod := strings.ToUpper(d.config.Method) + if webhookMethod == "" { + webhookMethod = http.MethodPost + } else if !allowedMethods[webhookMethod] { + return nil, fmt.Errorf("unsupported webhook request method '%s'", webhookMethod) + } + + // 处理 Webhook 请求标头 + webhookHeaders := make(http.Header) + for k, v := range d.config.Headers { + webhookHeaders.Set(k, v) + } + + // 处理 Webhook 请求内容类型 + webhookContentType := webhookHeaders.Get("Content-Type") + if webhookContentType == "" { + webhookContentType = contentTypeJson + webhookHeaders.Set("Content-Type", contentTypeJson) + } else if mediaType, _, err := mime.ParseMediaType(webhookContentType); err != nil || !allowedContentTypes[mediaType] { + return nil, fmt.Errorf("unsupported webhook content type '%s'", webhookContentType) + } + + // 处理 Webhook 请求数据 + var webhookData any + if d.config.WebhookData == "" { + webhookData = map[string]string{ + "name": strings.Join(xcertx509.GetSubjectAltNames(certX509), ";"), + "cert": certPEM, + "privkey": privkeyPEM, + } + } else { + err = json.Unmarshal([]byte(d.config.WebhookData), &webhookData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) + } + + if webhookMethod == http.MethodGet || webhookContentType == contentTypeForm || webhookContentType == contentTypeMultipart { + temp := make(map[string]string) + jsonb, err := json.Marshal(webhookData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) + } else if err := json.Unmarshal(jsonb, &temp); err != nil { + return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) + } else { + webhookData = temp + } + } + } + + // 替换变量值 + webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${CERTIMATE_DEPLOYER_COMMONNAME}", url.PathEscape(xcertx509.GetSubjectCommonName(certX509))) + xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_COMMONNAME}", xcertx509.GetSubjectCommonName(certX509)) + xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}", strings.Join(xcertx509.GetSubjectAltNames(certX509), ";")) + xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE}", certPEM) + xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}", serverCertPEM) + xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}", issuerCertPEM) + xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIMATE_DEPLOYER_PRIVATEKEY}", privkeyPEM) + + // 兼容旧版变量 + // TODO: remove in future version + webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${DOMAIN}", url.PathEscape(certX509.Subject.CommonName)) + xmaps.DeepReplaceValueUnsafe(webhookData, "${DOMAIN}", certX509.Subject.CommonName) + xmaps.DeepReplaceValueUnsafe(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";")) + xmaps.DeepReplaceValueUnsafe(webhookData, "${CERTIFICATE}", certPEM) + xmaps.DeepReplaceValueUnsafe(webhookData, "${SERVER_CERTIFICATE}", serverCertPEM) + xmaps.DeepReplaceValueUnsafe(webhookData, "${INTERMEDIA_CERTIFICATE}", issuerCertPEM) + xmaps.DeepReplaceValueUnsafe(webhookData, "${PRIVATE_KEY}", privkeyPEM) + + // 生成请求 + // 其中 GET 请求需转换为查询参数 + req := d.httpClient.R().SetHeaderMultiValues(webhookHeaders) + req.URL = webhookUrl.String() + req.Method = webhookMethod + if webhookMethod == http.MethodGet { + req.SetQueryParams(webhookData.(map[string]string)) + } else { + switch webhookContentType { + case contentTypeJson: + req.SetBody(webhookData) + case contentTypeForm: + req.SetFormData(webhookData.(map[string]string)) + case contentTypeMultipart: + req.SetMultipartFormData(webhookData.(map[string]string)) + } + } + + // 发送请求 + resp, err := req.Send() + if err != nil { + return nil, fmt.Errorf("failed to send webhook request: %w", err) + } else if resp.IsError() { + return nil, fmt.Errorf("unexpected webhook response status code: %d", resp.StatusCode()) + } + + d.logger.Debug("webhook responded", slog.Any("response", resp.String())) + + return &DeployResult{}, nil +} diff --git a/pkg/core/deployer/providers/webhook/webhook_test.go b/pkg/core/deployer/providers/webhook/webhook_test.go new file mode 100644 index 000000000..41e7ad3b4 --- /dev/null +++ b/pkg/core/deployer/providers/webhook/webhook_test.go @@ -0,0 +1,57 @@ +package webhook_test + +import ( + "testing" + + impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/webhook" + tester "github.com/certimate-go/certimate/pkg/core/deployer/testing" +) + +var ( + fp = tester.Args("WEBHOOK_") + fTestCertPath string + fTestKeyPath string + fWebhookUrl string + fWebhookContentType string + fWebhookData string +) + +func init() { + fp.DefineString(&fTestCertPath, "TESTCERTPATH") + fp.DefineString(&fTestKeyPath, "TESTKEYPATH") + fp.DefineString(&fWebhookUrl, "URL") + fp.DefineString(&fWebhookContentType, "CONTENTTYPE", "application/json") + fp.DefineString(&fWebhookData, "DATA") +} + +/* +Shell command to run this test: + + go test -v ./webhook_test.go -args \ + --WEBHOOK_TESTCERTPATH="/path/to/your-test-cert.pem" \ + --WEBHOOK_TESTKEYPATH="/path/to/your-test-key.pem" \ + --WEBHOOK_URL="https://example.com/your-webhook-url" \ + --WEBHOOK_CONTENTTYPE="application/json" \ + --WEBHOOK_DATA="{\"certificate\":\"${CERTIFICATE}\",\"privateKey\":\"${PRIVATE_KEY}\"}" +*/ +func TestProvider(t *testing.T) { + fp.Parse() + + t.Run("Deploy", func(t *testing.T) { + provider, err := impl.NewDeployer(&impl.DeployerConfig{ + WebhookUrl: fWebhookUrl, + WebhookData: fWebhookData, + Method: "POST", + Headers: map[string]string{ + "Content-Type": fWebhookContentType, + }, + AllowInsecureConnections: true, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) + }) +} diff --git a/pkg/sdk3rd/qiniu/auth.go b/pkg/sdk3rd/qiniu/auth.go deleted file mode 100644 index a1668dd76..000000000 --- a/pkg/sdk3rd/qiniu/auth.go +++ /dev/null @@ -1,30 +0,0 @@ -package qiniu - -import ( - "net/http" - - "github.com/qiniu/go-sdk/v7/auth" - "github.com/qiniu/go-sdk/v7/client" -) - -type transport struct { - http.RoundTripper - mac *auth.Credentials -} - -func newTransport(mac *auth.Credentials, tr http.RoundTripper) *transport { - if tr == nil { - tr = client.DefaultTransport - } - return &transport{tr, mac} -} - -func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { - token, err := t.mac.SignRequestV2(req) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Qiniu "+token) - return t.RoundTripper.RoundTrip(req) -} diff --git a/pkg/sdk3rd/qiniu/cdn.go b/pkg/sdk3rd/qiniu/cdn.go deleted file mode 100644 index 627b7c902..000000000 --- a/pkg/sdk3rd/qiniu/cdn.go +++ /dev/null @@ -1,130 +0,0 @@ -package qiniu - -import ( - "context" - "fmt" - "net/http" - "net/url" - - "github.com/qiniu/go-sdk/v7/auth" - "github.com/qiniu/go-sdk/v7/client" -) - -type CdnManager struct { - client *client.Client -} - -func NewCdnManager(mac *auth.Credentials) *CdnManager { - if mac == nil { - mac = auth.Default() - } - - client := &client.Client{Client: &http.Client{Transport: newTransport(mac, nil)}} - return &CdnManager{client: client} -} - -type GetDomainListResponse struct { - Code *int `json:"code,omitempty"` - Error *string `json:"error,omitempty"` - Marker string `json:"marker"` - Domains []*struct { - Name string `json:"name"` - Type string `json:"type"` - CName string `json:"cname"` - OperatingState string `json:"operatingState"` - OperatingStateDesc string `json:"operatingStateDesc"` - CreateAt string `json:"createAt"` - ModifyAt string `json:"modifyAt"` - } `json:"domains"` -} - -func (m *CdnManager) GetDomainList(ctx context.Context, marker string, limit int) (*GetDomainListResponse, error) { - query := url.Values{} - if marker != "" { - query.Set("marker", marker) - } - if limit > 0 { - query.Set("limit", fmt.Sprintf("%d", limit)) - } - - resp := new(GetDomainListResponse) - if err := m.client.Call(ctx, resp, http.MethodGet, urlf("domain?%s", query.Encode()), nil); err != nil { - return nil, err - } - return resp, nil -} - -type GetDomainInfoResponse struct { - Code *int `json:"code,omitempty"` - Error *string `json:"error,omitempty"` - Name string `json:"name"` - Type string `json:"type"` - CName string `json:"cname"` - Https *struct { - CertID string `json:"certId"` - ForceHttps bool `json:"forceHttps"` - Http2Enable bool `json:"http2Enable"` - } `json:"https"` - PareDomain string `json:"pareDomain"` - OperationType string `json:"operationType"` - OperatingState string `json:"operatingState"` - OperatingStateDesc string `json:"operatingStateDesc"` - CreateAt string `json:"createAt"` - ModifyAt string `json:"modifyAt"` -} - -func (m *CdnManager) GetDomainInfo(ctx context.Context, domain string) (*GetDomainInfoResponse, error) { - resp := new(GetDomainInfoResponse) - if err := m.client.Call(ctx, resp, http.MethodGet, urlf("domain/%s", domain), nil); err != nil { - return nil, err - } - return resp, nil -} - -type ModifyDomainHttpsConfRequest struct { - CertID string `json:"certId"` - ForceHttps bool `json:"forceHttps"` - Http2Enable bool `json:"http2Enable"` -} - -type ModifyDomainHttpsConfResponse struct { - Code *int `json:"code,omitempty"` - Error *string `json:"error,omitempty"` -} - -func (m *CdnManager) ModifyDomainHttpsConf(ctx context.Context, domain string, certId string, forceHttps bool, http2Enable bool) (*ModifyDomainHttpsConfResponse, error) { - req := &ModifyDomainHttpsConfRequest{ - CertID: certId, - ForceHttps: forceHttps, - Http2Enable: http2Enable, - } - resp := new(ModifyDomainHttpsConfResponse) - if err := m.client.CallWithJson(ctx, resp, http.MethodPut, urlf("domain/%s/httpsconf", domain), nil, req); err != nil { - return nil, err - } - return resp, nil -} - -type EnableDomainHttpsRequest struct { - CertID string `json:"certId"` - ForceHttps bool `json:"forceHttps"` - Http2Enable bool `json:"http2Enable"` -} - -type EnableDomainHttpsResponse struct { - Code *int `json:"code,omitempty"` - Error *string `json:"error,omitempty"` -} - -func (m *CdnManager) EnableDomainHttps(ctx context.Context, domain string, certId string, forceHttps bool, http2Enable bool) (*EnableDomainHttpsResponse, error) { - req := &EnableDomainHttpsRequest{ - CertID: certId, - ForceHttps: forceHttps, - Http2Enable: http2Enable, - } - resp := new(EnableDomainHttpsResponse) - if err := m.client.CallWithJson(ctx, resp, http.MethodPut, urlf("domain/%s/sslize", domain), nil, req); err != nil { - return nil, err - } - return resp, nil -} diff --git a/pkg/sdk3rd/qiniu/doc.go b/pkg/sdk3rd/qiniu/doc.go deleted file mode 100644 index 5931d1ca5..000000000 --- a/pkg/sdk3rd/qiniu/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// An extension SDK client for Qiniu. -// Based on github.com/qiniu/go-sdk. -package qiniu \ No newline at end of file diff --git a/pkg/sdk3rd/qiniu/kodo.go b/pkg/sdk3rd/qiniu/kodo.go deleted file mode 100644 index 6a3245a28..000000000 --- a/pkg/sdk3rd/qiniu/kodo.go +++ /dev/null @@ -1,44 +0,0 @@ -package qiniu - -import ( - "context" - "net/http" - - "github.com/qiniu/go-sdk/v7/auth" - "github.com/qiniu/go-sdk/v7/client" -) - -type KodoManager struct { - client *client.Client -} - -func NewKodoManager(mac *auth.Credentials) *KodoManager { - if mac == nil { - mac = auth.Default() - } - - client := &client.Client{Client: &http.Client{Transport: newTransport(mac, nil)}} - return &KodoManager{client: client} -} - -type BindBucketCertRequest struct { - CertID string `json:"certid"` - Domain string `json:"domain"` -} - -type BindBucketCertResponse struct { - Code *int `json:"code,omitempty"` - Error *string `json:"error,omitempty"` -} - -func (m *KodoManager) BindBucketCert(ctx context.Context, domain string, certId string) (*BindBucketCertResponse, error) { - req := &BindBucketCertRequest{ - CertID: certId, - Domain: domain, - } - resp := new(BindBucketCertResponse) - if err := m.client.CallWithJson(ctx, resp, http.MethodPut, urlf("cert/bind"), nil, req); err != nil { - return nil, err - } - return resp, nil -} diff --git a/pkg/sdk3rd/qiniu/sslcert.go b/pkg/sdk3rd/qiniu/sslcert.go deleted file mode 100644 index f9784270f..000000000 --- a/pkg/sdk3rd/qiniu/sslcert.go +++ /dev/null @@ -1,80 +0,0 @@ -package qiniu - -import ( - "context" - "net/http" - "net/url" - - "github.com/qiniu/go-sdk/v7/auth" - "github.com/qiniu/go-sdk/v7/client" -) - -type SslCertManager struct { - client *client.Client -} - -func NewSslCertManager(mac *auth.Credentials) *SslCertManager { - if mac == nil { - mac = auth.Default() - } - - client := &client.Client{Client: &http.Client{Transport: newTransport(mac, nil)}} - return &SslCertManager{client: client} -} - -type GetSslCertListResponse struct { - Code *int `json:"code,omitempty"` - Error *string `json:"error,omitempty"` - Certs []*struct { - CertID string `json:"certid"` - Name string `json:"name"` - CommonName string `json:"common_name"` - DnsNames []string `json:"dnsnames"` - CreateTime int64 `json:"create_time"` - NotBefore int64 `json:"not_before"` - NotAfter int64 `json:"not_after"` - ProductType string `json:"product_type"` - ProductShortName string `json:"product_short_name,omitempty"` - OrderId string `json:"orderid,omitempty"` - CertType string `json:"cert_type"` - Encrypt string `json:"encrypt"` - EncryptParameter string `json:"encryptParameter,omitempty"` - Enable bool `json:"enable"` - } `json:"certs"` - Marker string `json:"marker"` -} - -func (m *SslCertManager) GetSslCertList(ctx context.Context, marker string, limit int32) (*GetSslCertListResponse, error) { - resp := new(GetSslCertListResponse) - if err := m.client.Call(ctx, resp, http.MethodGet, urlf("sslcert?marker=%s&limit=%d", url.QueryEscape(marker), limit), nil); err != nil { - return nil, err - } - return resp, nil -} - -type UploadSslCertRequest struct { - Name string `json:"name"` - CommonName string `json:"common_name"` - Certificate string `json:"ca"` - PrivateKey string `json:"pri"` -} - -type UploadSslCertResponse struct { - Code *int `json:"code,omitempty"` - Error *string `json:"error,omitempty"` - CertID string `json:"certID"` -} - -func (m *SslCertManager) UploadSslCert(ctx context.Context, name string, commonName string, certificate string, privateKey string) (*UploadSslCertResponse, error) { - req := &UploadSslCertRequest{ - Name: name, - CommonName: commonName, - Certificate: certificate, - PrivateKey: privateKey, - } - resp := new(UploadSslCertResponse) - if err := m.client.CallWithJson(ctx, resp, http.MethodPost, urlf("sslcert"), nil, req); err != nil { - return nil, err - } - return resp, nil -} diff --git a/pkg/sdk3rd/qiniu/util.go b/pkg/sdk3rd/qiniu/util.go deleted file mode 100644 index 0957a3102..000000000 --- a/pkg/sdk3rd/qiniu/util.go +++ /dev/null @@ -1,14 +0,0 @@ -package qiniu - -import ( - "fmt" - "strings" -) - -const qiniuHost = "https://api.qiniu.com" - -func urlf(pathf string, pathargs ...any) string { - path := fmt.Sprintf(pathf, pathargs...) - path = strings.TrimPrefix(path, "/") - return qiniuHost + "/" + path -} diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx index 47f6c35c1..bc802e1da 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx @@ -90,7 +90,6 @@ import AccessConfigFieldsProviderPorkbun from "./AccessConfigFieldsProviderPorkb import AccessConfigFieldsProviderPowerDNS from "./AccessConfigFieldsProviderPowerDNS"; import AccessConfigFieldsProviderProxmoxVE from "./AccessConfigFieldsProviderProxmoxVE"; import AccessConfigFieldsProviderQingCloud from "./AccessConfigFieldsProviderQingCloud"; -import AccessConfigFieldsProviderQiniu from "./AccessConfigFieldsProviderQiniu"; import AccessConfigFieldsProviderRainYun from "./AccessConfigFieldsProviderRainYun"; import AccessConfigFieldsProviderRatPanel from "./AccessConfigFieldsProviderRatPanel"; import AccessConfigFieldsProviderRegru from "./AccessConfigFieldsProviderRegru"; @@ -215,7 +214,6 @@ const providerComponentMap: Partial : initComponent()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider, envelope]); - useEffect(() => setComponent(initComponent()), deps); + useEffect(() => { + setComponent(envelope ? : initComponent()); + }, deps); return component; }; diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderQiniu.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderQiniu.tsx deleted file mode 100644 index 6c017dbdb..000000000 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderQiniu.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import { useFormNestedFieldsContext } from "./_context"; - -const AccessConfigFormFieldsProviderQiniu = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> - } - > - - - - } - > - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - accessKey: "", - secretKey: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { - const { t: _ } = i18n; - - return z.object({ - accessKey: z.string().nonempty(), - secretKey: z.string().nonempty(), - }); -}; - -const _default = Object.assign(AccessConfigFormFieldsProviderQiniu, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index 102166a66..d221c776d 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -69,13 +69,13 @@ const DeploymentProviderPicker = forwardRef {t(provider.name) || "\u00A0"} - {provider.source === "plugin" ? ( - - {t("provider.tag.plugin")} - - ) : null}
+ {provider.source === "plugin" ? ( + + {t("provider.tag.plugin")} + + ) : null} diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index 80b7ee7dd..ff9a14f1d 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -92,9 +92,6 @@ import BizDeployNodeConfigFieldsProviderNginxProxyManager from "./BizDeployNodeC import BizDeployNodeConfigFieldsProviderOracleCloudCertificatesMgmt from "./BizDeployNodeConfigFieldsProviderOracleCloudCertificatesMgmt"; import BizDeployNodeConfigFieldsProviderProxmoxVE from "./BizDeployNodeConfigFieldsProviderProxmoxVE"; import BizDeployNodeConfigFieldsProviderQingCloudLB from "./BizDeployNodeConfigFieldsProviderQingCloudLB"; -import BizDeployNodeConfigFieldsProviderQiniuCDN from "./BizDeployNodeConfigFieldsProviderQiniuCDN"; -import BizDeployNodeConfigFieldsProviderQiniuKodo from "./BizDeployNodeConfigFieldsProviderQiniuKodo"; -import BizDeployNodeConfigFieldsProviderQiniuPili from "./BizDeployNodeConfigFieldsProviderQiniuPili"; import BizDeployNodeConfigFieldsProviderRainYunRCDN from "./BizDeployNodeConfigFieldsProviderRainYunRCDN"; import BizDeployNodeConfigFieldsProviderRainYunSSLCenter from "./BizDeployNodeConfigFieldsProviderRainYunSSLCenter"; import BizDeployNodeConfigFieldsProviderRatPanel from "./BizDeployNodeConfigFieldsProviderRatPanel"; @@ -143,6 +140,7 @@ import BizDeployNodeConfigFieldsProviderVolcEngineWAF from "./BizDeployNodeConfi import BizDeployNodeConfigFieldsProviderWangsuCDN from "./BizDeployNodeConfigFieldsProviderWangsuCDN"; import BizDeployNodeConfigFieldsProviderWangsuCDNPro from "./BizDeployNodeConfigFieldsProviderWangsuCDNPro"; import BizDeployNodeConfigFieldsProviderWangsuCertificate from "./BizDeployNodeConfigFieldsProviderWangsuCertificate"; +import BizDeployNodeConfigFieldsProviderWebhook from "./BizDeployNodeConfigFieldsProviderWebhook"; import BizDeployNodeConfigFieldsProviderZenlayerCDN from "./BizDeployNodeConfigFieldsProviderZenlayerCDN"; import BizDeployNodeConfigFieldsProviderZenlayerGA from "./BizDeployNodeConfigFieldsProviderZenlayerGA"; import SchemaConfigFields from "./SchemaConfigFields"; @@ -241,9 +239,6 @@ const providerComponentMap: Partial : initComponent()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider, envelope]); - useEffect(() => setComponent(initComponent()), deps); + useEffect(() => { + setComponent(envelope ? : initComponent()); + }, deps); return component; }; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.tsx deleted file mode 100644 index 1590b3283..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input, Radio } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import Show from "@/components/Show"; -import { isDomain } from "@/utils/validator"; - -import { useFormNestedFieldsContext } from "./_context"; - -const DOMAIN_MATCH_PATTERN_EXACT = "exact" as const; -const DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" as const; -const DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" as const; - -const BizDeployNodeConfigFieldsProviderQiniuCDN = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { form: formInst, preserve: true }); - - return ( - <> - - ) : ( - void 0 - ) - } - rules={[formRule]} - > - ({ - label: t(`workflow_node.deploy.form.shared_domain_match_pattern.option.${s}.label`), - value: s, - }))} - /> - - - - - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z - .object({ - domainMatchPattern: z.string().nonempty().default(DOMAIN_MATCH_PATTERN_EXACT), - domain: z.string().nullish(), - }) - .superRefine((values, ctx) => { - if (values.domainMatchPattern) { - switch (values.domainMatchPattern) { - case DOMAIN_MATCH_PATTERN_EXACT: - case DOMAIN_MATCH_PATTERN_WILDCARD: - { - const scDomain = z.string().refine((v) => isDomain(v, { allowWildcard: true }), t("common.errmsg.domain_invalid")); - const spDomain = scDomain.safeParse(values.domain); - if (!spDomain.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spDomain.error).errors.join(), - path: ["domain"], - }); - } - } - break; - } - } - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderQiniuCDN, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuKodo.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuKodo.tsx deleted file mode 100644 index aa6c0e366..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuKodo.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import { isDomain } from "@/utils/validator"; - -import { useFormNestedFieldsContext } from "./_context"; - -const BizDeployNodeConfigFieldsProviderQiniuKodo = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> - - - - - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - bucket: "", - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z.object({ - bucket: z.string().nonempty(), - domain: z.string().refine((v) => isDomain(v), t("common.errmsg.domain_invalid")), - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderQiniuKodo, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuPili.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuPili.tsx deleted file mode 100644 index 9463f3f99..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuPili.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input, Radio } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import Show from "@/components/Show"; -import { isDomain } from "@/utils/validator"; - -import { useFormNestedFieldsContext } from "./_context"; - -const DOMAIN_MATCH_PATTERN_EXACT = "exact" as const; -const DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" as const; - -const BizDeployNodeConfigFieldsProviderQiniuPili = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { form: formInst, preserve: true }); - - return ( - <> - } - > - - - - - ({ - label: t(`workflow_node.deploy.form.shared_domain_match_pattern.option.${s}.label`), - value: s, - }))} - /> - - - - - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - hub: "", - domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z - .object({ - hub: z.string().nonempty(), - domainMatchPattern: z.string().nonempty().default(DOMAIN_MATCH_PATTERN_EXACT), - domain: z.string().nullish(), - }) - .superRefine((values, ctx) => { - if (values.domainMatchPattern) { - switch (values.domainMatchPattern) { - case DOMAIN_MATCH_PATTERN_EXACT: - { - const scDomain = z.string().refine((v) => isDomain(v), t("common.errmsg.domain_invalid")); - const spDomain = scDomain.safeParse(values.domain); - if (!spDomain.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spDomain.error).errors.join(), - path: ["domain"], - }); - } - } - break; - } - } - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderQiniuPili, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx new file mode 100644 index 000000000..8a628bfd3 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx @@ -0,0 +1,94 @@ +import { getI18n, useTranslation } from "react-i18next"; +import { IconBulb } from "@tabler/icons-react"; +import { Button, Form, Input, Popover } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import CodeTextInput from "@/components/CodeTextInput"; +import { isJsonObject } from "@/utils/validator"; + +import { useFormNestedFieldsContext } from "./_context"; + +const BizDeployNodeConfigFieldsProviderWebhook = () => { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const formInst = Form.useFormInstance(); + const initialValues = getInitialValues(); + + const handleWebhookDataBlur = () => { + const value = formInst.getFieldValue([parentNamePath, "webhookData"]); + try { + const json = JSON.stringify(JSON.parse(value), null, 2); + formInst.setFieldValue([parentNamePath, "webhookData"], json); + } catch { + return; + } + }; + + return ( + <> + +
+ } mouseEnterDelay={1}> + + +
+ + + +
+ + + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return {}; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t } = i18n; + + return z.object({ + webhookData: z + .string() + .nullish() + .refine((v) => { + if (!v) return true; + return isJsonObject(v); + }, t("common.errmsg.json_invalid")), + timeout: z.coerce.number().int().gt(0).or(z.literal("")).nullish(), + }); +}; + +const _default = Object.assign(BizDeployNodeConfigFieldsProviderWebhook, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/components/workflow/designer/forms/schemaValidation.ts b/ui/src/components/workflow/designer/forms/schemaValidation.ts index b67d91b9c..d05bc82a1 100644 --- a/ui/src/components/workflow/designer/forms/schemaValidation.ts +++ b/ui/src/components/workflow/designer/forms/schemaValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ProviderSchemaEnvelope, SchemaColumn, SchemaCondition, SchemaValidationRule } from "@/api/providerschema"; +import i18n from "@/i18n"; import { isCron, isDomain, @@ -17,7 +18,7 @@ import { const isEmpty = (v: unknown) => v == null || v === ""; export const matchCondition = (cond: SchemaCondition, value: unknown): boolean => { - const present = cond.values.includes(String(value)); + const present = (cond.values ?? []).includes(String(value)); switch (cond.op) { case "eq": case "in": @@ -98,7 +99,7 @@ const fieldBaseType = (col: SchemaColumn): z.ZodType => { const applyRules = (schema: z.ZodType, rules: SchemaValidationRule[], allowEmpty: boolean): z.ZodType => { for (const rule of rules) { const ok = (v: unknown) => (allowEmpty && isEmpty(v)) || runValidator(rule.validator, rule.params, v); - schema = schema.refine(ok, { message: `common.errmsg.${rule.validator}_invalid` }); + schema = schema.refine(ok, { message: i18n.t(`common.errmsg.${rule.validator}_invalid`) }); } return schema; }; @@ -159,13 +160,13 @@ const buildPlainObject = (columns: SchemaColumn[]): z.ZodType => { for (const cond of col.requiredWhen ?? []) { if (!matchCondition(cond, record[cond.field])) continue; if (isEmpty(value)) { - ctx.addIssue({ code: "custom", message: `common.errmsg.required`, path: [col.name] }); + ctx.addIssue({ code: "custom", message: i18n.t("common.errmsg.required"), path: [col.name] }); } } for (const rule of col.validateWhen ?? []) { if (!matchCondition(rule, record[rule.field])) continue; if (!isEmpty(value) && !runValidator(rule.validator, rule.params, value)) { - ctx.addIssue({ code: "custom", message: `common.errmsg.${rule.validator}_invalid`, path: [col.name] }); + ctx.addIssue({ code: "custom", message: i18n.t(`common.errmsg.${rule.validator}_invalid`), path: [col.name] }); } } } diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index f10851f30..29e744ad2 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -108,7 +108,6 @@ export const ACCESS_PROVIDERS = Object.freeze({ POWERDNS: "powerdns", PROXMOXVE: "proxmoxve", QINGCLOUD: "qingcloud", - QINIU: "qiniu", RAINYUN: "rainyun", RATPANEL: "ratpanel", REGRU: "regru", @@ -201,7 +200,6 @@ export const accessProvidersMap: Maphttps://console.qingcloud.com/access_keys/" }, - "qiniu_access_key": { - "label": "Qiniu AccessKey", - "placeholder": "Please enter Qiniu AccessKey", - "tooltip": "For more information, see https://portal.qiniu.com/" - }, - "qiniu_secret_key": { - "label": "Qiniu SecretKey", - "placeholder": "Please enter Qiniu SecretKey", - "tooltip": "For more information, see https://portal.qiniu.com/" - }, "rainyun_api_key": { "label": "Rain Yun API key", "placeholder": "Please enter Rain Yun API key", diff --git a/ui/src/i18n/resources/en/nls.provider.json b/ui/src/i18n/resources/en/nls.provider.json index ec427f2b1..b89c62629 100644 --- a/ui/src/i18n/resources/en/nls.provider.json +++ b/ui/src/i18n/resources/en/nls.provider.json @@ -224,10 +224,6 @@ "qingcloud": "QingCloud", "qingcloud_dns": "QingCloud - DNS", "qingcloud_lb": "QingCloud - LBC (Load Balancer Cluster)", - "qiniu": "Qiniu", - "qiniu_cdn": "Qiniu - CDN (Content Delivery Network)", - "qiniu_kodo": "Qiniu - Kodo", - "qiniu_pili": "Qiniu - Pili", "rainyun": "Rain Yun", "rainyun_rcdn": "Rain Yun - RCDN (Content Delivery Network)", "rainyun_sslcenter_upload": "Rain Yun - Upload to SSL Certificate Center", diff --git a/ui/src/i18n/resources/en/nls.workflow.nodes.json b/ui/src/i18n/resources/en/nls.workflow.nodes.json index 26ace267f..5ed440352 100644 --- a/ui/src/i18n/resources/en/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/en/nls.workflow.nodes.json @@ -457,6 +457,17 @@ } }, "form": { + "webhook_data": { + "label": "Webhook data (Optional)", + "placeholder": "Please enter Webhook data", + "help": "Notes: Leave it blank to use the default Webhook data provided by the selected credential.", + "vartips": "Supported variables:
  1. ${CERTIMATE_DEPLOYER_COMMONNAME}:
    The primary domain or IP address of the certificate.
  2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}:
    The domains or IP addresses of the certificate, separated by semicolons.
  3. ${CERTIMATE_DEPLOYER_CERTIFICATE}:
    The PEM format content of the certificate file.
  4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}:
    The PEM format content of the server certificate file.
  5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}:
    The PEM format content of the intermediate CA certificate file.
  6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}:
    The PEM format content of the private key file.
" + }, + "webhook_timeout": { + "label": "Webhook timeout (Optional)", + "placeholder": "Please enter Webhook timeout", + "unit": "seconds" + }, "certificate_output_node_id": { "label": "Certificate to deploy", "placeholder": "Please select certificate to deploy", @@ -2049,27 +2060,6 @@ "placeholder": "Please enter QingCloud LBC listener ID", "tooltip": "For more information, see https://console.qingcloud.com/pek3a/loadbalancers/" }, - "qiniu_cdn_domain": { - "label": "Qiniu CDN domain", - "placeholder": "Please enter Qiniu CDN domain name" - }, - "qiniu_kodo_bucket": { - "label": "Qiniu Kodo bucket", - "placeholder": "Please enter Qiniu Kodo bucket name" - }, - "qiniu_kodo_domain": { - "label": "Qiniu Kodo custom domain", - "placeholder": "Please enter Qiniu Kodo bucket custom domain name" - }, - "qiniu_pili_hub": { - "label": "Qiniu Pili hub", - "placeholder": "Please enter Qiniu Pili hub name", - "tooltip": "For more information, see https://portal.qiniu.com/hub" - }, - "qiniu_pili_domain": { - "label": "Qiniu Pili streaming domain", - "placeholder": "Please enter Qiniu Pili streaming domain name" - }, "rainyun_rcdn_instance_id": { "label": "Rain Yun RCDN instance ID", "placeholder": "Please enter Rain Yun RCDN instance ID", diff --git a/ui/src/i18n/resources/zh/nls.access.json b/ui/src/i18n/resources/zh/nls.access.json index 1fbf5cb43..797275e33 100644 --- a/ui/src/i18n/resources/zh/nls.access.json +++ b/ui/src/i18n/resources/zh/nls.access.json @@ -1128,16 +1128,6 @@ "placeholder": "请输入青云 SecretAccessKey", "tooltip": "这是什么?请参阅 https://console.qingcloud.com/access_keys/" }, - "qiniu_access_key": { - "label": "七牛云 AccessKey", - "placeholder": "请输入七牛云 AccessKey", - "tooltip": "这是什么?请参阅 https://portal.qiniu.com/" - }, - "qiniu_secret_key": { - "label": "七牛云 SecretKey", - "placeholder": "请输入七牛云 SecretKey", - "tooltip": "这是什么?请参阅 https://portal.qiniu.com/" - }, "rainyun_api_key": { "label": "雨云 API 密钥", "placeholder": "请输入雨云 API 密钥", diff --git a/ui/src/i18n/resources/zh/nls.provider.json b/ui/src/i18n/resources/zh/nls.provider.json index 67af99732..cb1d58b73 100644 --- a/ui/src/i18n/resources/zh/nls.provider.json +++ b/ui/src/i18n/resources/zh/nls.provider.json @@ -224,10 +224,6 @@ "qingcloud": "青云", "qingcloud_dns": "青云 - 云解析 DNS", "qingcloud_lb": "青云 - 负载均衡 LB", - "qiniu": "七牛云", - "qiniu_cdn": "七牛云 - 内容分发网络 CDN", - "qiniu_kodo": "七牛云 - 对象存储 Kodo", - "qiniu_pili": "七牛云 - 视频直播 Pili", "rainyun": "雨云", "rainyun_rcdn": "雨云 - 雨盾 CDN", "rainyun_sslcenter_upload": "雨云 - 上传到 SSL 证书中心", diff --git a/ui/src/i18n/resources/zh/nls.workflow.nodes.json b/ui/src/i18n/resources/zh/nls.workflow.nodes.json index a95704042..6d5f6a311 100644 --- a/ui/src/i18n/resources/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/zh/nls.workflow.nodes.json @@ -456,6 +456,17 @@ } }, "form": { + "webhook_data": { + "label": "Webhook 回调数据(可选)", + "placeholder": "请输入 Webhook 回调数据以覆盖默认值", + "help": "提示:不填写时,将使用所选提供商授权的默认 Webhook 回调数据。", + "vartips": "支持的变量:
  1. ${CERTIMATE_DEPLOYER_COMMONNAME}:
    证书的主域名或 IP。
  2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}:
    证书的多域名或 IP,以半角分号隔开。
  3. ${CERTIMATE_DEPLOYER_CERTIFICATE}:
    证书文件 PEM 格式内容。
  4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}:
    证书文件(仅含服务器证书)PEM 格式内容。
  5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}:
    证书文件(仅含中间证书)PEM 格式内容。
  6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}:
    私钥文件 PEM 格式内容。
" + }, + "webhook_timeout": { + "label": "Webhook 超时时间(可选)", + "placeholder": "请输入 Webhook 超时时间", + "unit": "秒" + }, "certificate_output_node_id": { "label": "待部署证书", "placeholder": "请选择待部署证书", @@ -2048,27 +2059,6 @@ "placeholder": "请输入青云 LB 监听器 ID", "tooltip": "这是什么?请参阅 https://console.qingcloud.com/pek3a/loadbalancers/" }, - "qiniu_cdn_domain": { - "label": "七牛云 CDN 加速域名", - "placeholder": "请输入七牛云 CDN 加速域名" - }, - "qiniu_kodo_bucket": { - "label": "七牛云对象存储桶名", - "placeholder": "请输入七牛云对象存储桶名" - }, - "qiniu_kodo_domain": { - "label": "七牛云对象存储自定义域名", - "placeholder": "请输入七牛云对象存储自定义域名" - }, - "qiniu_pili_hub": { - "label": "七牛云视频直播空间名", - "placeholder": "请输入七牛云视频直播空间名", - "tooltip": "这是什么?请参阅 https://portal.qiniu.com/hub" - }, - "qiniu_pili_domain": { - "label": "七牛云视频直播流域名", - "placeholder": "请输入七牛云视频直播流域名" - }, "rainyun_rcdn_instance_id": { "label": "雨云 RCDN 实例 ID", "placeholder": "请输入雨云 RCDN 实例 ID", From 063a1892d2e3589ee8b8621ea905d20a6b24ac35 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Tue, 28 Jul 2026 20:58:21 +0800 Subject: [PATCH 12/19] feat: plugin migration 1 --- go.mod | 21 -- go.sum | 64 ----- .../certmgmt/deployers/sp_dogecloud_cdn.go | 27 -- .../deployers/sp_kubernetes_secret.go | 78 ------ .../certmgr/providers/dogecloud/dogecloud.go | 95 ------- .../providers/dogecloud/dogecloud_test.go | 49 ---- .../providers/dogecloud-cdn/consts.go | 8 - .../providers/dogecloud-cdn/dogecloud_cdn.go | 187 -------------- .../dogecloud-cdn/dogecloud_cdn_test.go | 53 ---- .../providers/k8s-secret/k8s_secret.go | 231 ------------------ .../providers/k8s-secret/k8s_secret_test.go | 57 ----- pkg/sdk3rd/dogecloud/api_bind_cdn_cert.go | 36 --- pkg/sdk3rd/dogecloud/api_list_cdn_domain.go | 44 ---- pkg/sdk3rd/dogecloud/api_upload_cdn_cert.go | 41 ---- pkg/sdk3rd/dogecloud/client.go | 114 --------- pkg/sdk3rd/dogecloud/options.go | 15 -- pkg/sdk3rd/dogecloud/signer.go | 50 ---- pkg/sdk3rd/dogecloud/types.go | 29 --- .../BizDeployNodeConfigFieldsProvider.tsx | 4 - ...oyNodeConfigFieldsProviderDogeCloudCDN.tsx | 98 -------- ...deConfigFieldsProviderKubernetesSecret.tsx | 202 --------------- ui/src/domain/provider.ts | 4 - ui/src/i18n/resources/en/nls.provider.json | 2 - .../i18n/resources/en/nls.workflow.nodes.json | 57 ----- ui/src/i18n/resources/zh/nls.provider.json | 2 - .../i18n/resources/zh/nls.workflow.nodes.json | 57 ----- 26 files changed, 1625 deletions(-) delete mode 100644 internal/certmgmt/deployers/sp_dogecloud_cdn.go delete mode 100644 internal/certmgmt/deployers/sp_kubernetes_secret.go delete mode 100644 pkg/core/certmgr/providers/dogecloud/dogecloud.go delete mode 100644 pkg/core/certmgr/providers/dogecloud/dogecloud_test.go delete mode 100644 pkg/core/deployer/providers/dogecloud-cdn/consts.go delete mode 100644 pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn.go delete mode 100644 pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn_test.go delete mode 100644 pkg/core/deployer/providers/k8s-secret/k8s_secret.go delete mode 100644 pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go delete mode 100644 pkg/sdk3rd/dogecloud/api_bind_cdn_cert.go delete mode 100644 pkg/sdk3rd/dogecloud/api_list_cdn_domain.go delete mode 100644 pkg/sdk3rd/dogecloud/api_upload_cdn_cert.go delete mode 100644 pkg/sdk3rd/dogecloud/client.go delete mode 100644 pkg/sdk3rd/dogecloud/options.go delete mode 100644 pkg/sdk3rd/dogecloud/signer.go delete mode 100644 pkg/sdk3rd/dogecloud/types.go delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderDogeCloudCDN.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderKubernetesSecret.tsx diff --git a/go.mod b/go.mod index a1c3eb070..15779d538 100644 --- a/go.mod +++ b/go.mod @@ -98,9 +98,6 @@ require ( google.golang.org/api v0.288.0 google.golang.org/grpc v1.82.0 google.golang.org/protobuf v1.36.11 - k8s.io/api v0.35.3 - k8s.io/apimachinery v0.35.3 - k8s.io/client-go v0.35.3 software.sslmate.com/src/go-pkcs12 v0.7.3 ) @@ -116,23 +113,17 @@ require ( github.com/aws/aws-sdk-go-v2/service/route53 v1.62.8 // 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-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/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 @@ -147,21 +138,11 @@ require ( github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.11.1 // indirect 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.17.2 // 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 ( @@ -263,9 +244,7 @@ require ( golang.org/x/image v0.41.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.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/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect gopkg.in/ini.v1 v1.67.3 // indirect diff --git a/go.sum b/go.sum index 96d0fe879..2b1820d7f 100644 --- a/go.sum +++ b/go.sum @@ -315,8 +315,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= @@ -344,8 +342,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.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= @@ -384,34 +380,6 @@ 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= @@ -480,8 +448,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= @@ -715,8 +681,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= @@ -934,8 +898,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= @@ -1001,8 +963,6 @@ go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= 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.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= @@ -1459,12 +1419,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= @@ -1494,18 +1450,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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= @@ -1538,14 +1482,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= diff --git a/internal/certmgmt/deployers/sp_dogecloud_cdn.go b/internal/certmgmt/deployers/sp_dogecloud_cdn.go deleted file mode 100644 index 753a00e28..000000000 --- a/internal/certmgmt/deployers/sp_dogecloud_cdn.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/certmgmt/deployers/sp_kubernetes_secret.go b/internal/certmgmt/deployers/sp_kubernetes_secret.go deleted file mode 100644 index eff4e5dd2..000000000 --- a/internal/certmgmt/deployers/sp_kubernetes_secret.go +++ /dev/null @@ -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 - }) -} diff --git a/pkg/core/certmgr/providers/dogecloud/dogecloud.go b/pkg/core/certmgr/providers/dogecloud/dogecloud.go deleted file mode 100644 index d0cdaf81e..000000000 --- a/pkg/core/certmgr/providers/dogecloud/dogecloud.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/certmgr/providers/dogecloud/dogecloud_test.go b/pkg/core/certmgr/providers/dogecloud/dogecloud_test.go deleted file mode 100644 index cd92c29c5..000000000 --- a/pkg/core/certmgr/providers/dogecloud/dogecloud_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package dogecloud_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/dogecloud" - tester "github.com/certimate-go/certimate/pkg/core/certmgr/testing" -) - -var ( - fp = tester.Args("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 -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, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestUpload(t, provider, tester.TestUploadArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/core/deployer/providers/dogecloud-cdn/consts.go b/pkg/core/deployer/providers/dogecloud-cdn/consts.go deleted file mode 100644 index c468db8d8..000000000 --- a/pkg/core/deployer/providers/dogecloud-cdn/consts.go +++ /dev/null @@ -1,8 +0,0 @@ -package dogecloudcdn - -const ( - // 匹配模式:精确匹配。 - DOMAIN_MATCH_PATTERN_EXACT = "exact" - // 匹配模式:证书 SAN 匹配。 - DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" -) diff --git a/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn.go b/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn.go deleted file mode 100644 index 44bbb5e6c..000000000 --- a/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn_test.go b/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn_test.go deleted file mode 100644 index 03f6b705f..000000000 --- a/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package dogecloudcdn_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/dogecloud-cdn" - tester "github.com/certimate-go/certimate/pkg/core/deployer/testing" -) - -var ( - fp = tester.Args("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 -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, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/core/deployer/providers/k8s-secret/k8s_secret.go b/pkg/core/deployer/providers/k8s-secret/k8s_secret.go deleted file mode 100644 index 749e0d8cd..000000000 --- a/pkg/core/deployer/providers/k8s-secret/k8s_secret.go +++ /dev/null @@ -1,231 +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/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 - } - - client, err := rest.RESTClientFor(config) - if err != nil { - return nil, err - } - - return client, nil -} diff --git a/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go b/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go deleted file mode 100644 index 3c4141ad5..000000000 --- a/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package k8ssecret_test - -import ( - "testing" - - impl "github.com/certimate-go/certimate/pkg/core/deployer/providers/k8s-secret" - tester "github.com/certimate-go/certimate/pkg/core/deployer/testing" -) - -var ( - fp = tester.Args("K8SSECRET_") - fTestCertPath string - fTestKeyPath string - fNamespace string - fSecretName string - fSecretDataKeyForCrt string - fSecretDataKeyForKey string -) - -func init() { - fp.DefineString(&fTestCertPath, "TESTCERTPATH") - fp.DefineString(&fTestKeyPath, "TESTKEYPATH") - 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 -v ./k8s_secret_test.go -args \ - --K8SSECRET_TESTCERTPATH="/path/to/your-test-cert.pem" \ - --K8SSECRET_TESTKEYPATH="/path/to/your-test-key.pem" \ - --K8SSECRET_NAMESPACE="default" \ - --K8SSECRET_SECRETNAME="secret" \ - --K8SSECRET_SECRETDATAKEYFORCRT="tls.crt" \ - --K8SSECRET_SECRETDATAKEYFORKEY="tls.key" -*/ -func TestProvider(t *testing.T) { - fp.Parse() - - t.Run("Deploy", func(t *testing.T) { - provider, err := impl.NewDeployer(&impl.DeployerConfig{ - Namespace: fNamespace, - SecretName: fSecretName, - SecretDataKeyForCrt: fSecretDataKeyForCrt, - SecretDataKeyForKey: fSecretDataKeyForKey, - }) - if err != nil { - t.Errorf("err: %+v", err) - return - } - - tester.TestDeploy(t, provider, tester.TestDeployArgs{CertPath: fTestCertPath, KeyPath: fTestKeyPath}) - }) -} diff --git a/pkg/sdk3rd/dogecloud/api_bind_cdn_cert.go b/pkg/sdk3rd/dogecloud/api_bind_cdn_cert.go deleted file mode 100644 index 47ee957cd..000000000 --- a/pkg/sdk3rd/dogecloud/api_bind_cdn_cert.go +++ /dev/null @@ -1,36 +0,0 @@ -package dogecloud - -import ( - "context" - "net/http" -) - -type BindCdnCertRequest struct { - CertId int64 `json:"id"` - Domain string `json:"domain"` -} - -type BindCdnCertResponse struct { - sdkResponseBase -} - -func (c *Client) BindCdnCert(req *BindCdnCertRequest) (*BindCdnCertResponse, error) { - return c.BindCdnCertWithContext(context.Background(), req) -} - -func (c *Client) BindCdnCertWithContext(ctx context.Context, req *BindCdnCertRequest) (*BindCdnCertResponse, error) { - httpreq, err := c.newRequest(http.MethodPost, "/cdn/cert/bind.json") - if err != nil { - return nil, err - } else { - httpreq.SetBody(req) - httpreq.SetContext(ctx) - } - - result := &BindCdnCertResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/dogecloud/api_list_cdn_domain.go b/pkg/sdk3rd/dogecloud/api_list_cdn_domain.go deleted file mode 100644 index 3ff240ec5..000000000 --- a/pkg/sdk3rd/dogecloud/api_list_cdn_domain.go +++ /dev/null @@ -1,44 +0,0 @@ -package dogecloud - -import ( - "context" - "encoding/json" - "net/http" -) - -type ListCdnDomainResponse struct { - sdkResponseBase - - Data *struct { - Domains []*struct { - Id int64 `json:"id"` - Name string `json:"name"` - Cname string `json:"cname"` - ServiceType string `json:"service_type"` - Status string `json:"status"` - Source json.RawMessage `json:"source"` - CreateTime string `json:"ctime"` - CertId int64 `json:"cert_id"` - } `json:"domains"` - } `json:"data,omitempty"` -} - -func (c *Client) ListCdnDomain() (*ListCdnDomainResponse, error) { - return c.ListCdnDomainWithContext(context.Background()) -} - -func (c *Client) ListCdnDomainWithContext(ctx context.Context) (*ListCdnDomainResponse, error) { - httpreq, err := c.newRequest(http.MethodGet, "/cdn/domain/list.json") - if err != nil { - return nil, err - } else { - httpreq.SetContext(ctx) - } - - result := &ListCdnDomainResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/dogecloud/api_upload_cdn_cert.go b/pkg/sdk3rd/dogecloud/api_upload_cdn_cert.go deleted file mode 100644 index d82926fd2..000000000 --- a/pkg/sdk3rd/dogecloud/api_upload_cdn_cert.go +++ /dev/null @@ -1,41 +0,0 @@ -package dogecloud - -import ( - "context" - "net/http" -) - -type UploadCdnCertRequest struct { - Note string `json:"note"` - Certificate string `json:"cert"` - PrivateKey string `json:"private"` -} - -type UploadCdnCertResponse struct { - sdkResponseBase - - Data *struct { - Id int64 `json:"id"` - } `json:"data,omitempty"` -} - -func (c *Client) UploadCdnCert(req *UploadCdnCertRequest) (*UploadCdnCertResponse, error) { - return c.UploadCdnCertWithContext(context.Background(), req) -} - -func (c *Client) UploadCdnCertWithContext(ctx context.Context, req *UploadCdnCertRequest) (*UploadCdnCertResponse, error) { - httpreq, err := c.newRequest(http.MethodPost, "/cdn/cert/upload.json") - if err != nil { - return nil, err - } else { - httpreq.SetBody(req) - httpreq.SetContext(ctx) - } - - result := &UploadCdnCertResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/dogecloud/client.go b/pkg/sdk3rd/dogecloud/client.go deleted file mode 100644 index 344b9c572..000000000 --- a/pkg/sdk3rd/dogecloud/client.go +++ /dev/null @@ -1,114 +0,0 @@ -// A simple SDK client for DogeCloud. -// API documentation: https://docs.dogecloud.com/ -package dogecloud - -import ( - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/go-resty/resty/v2" - - "github.com/certimate-go/certimate/internal/app" -) - -type Client struct { - rc *resty.Client -} - -func NewClient(optFns ...OptionsFunc) (*Client, error) { - opts := &Options{} - for _, fn := range optFns { - fn(opts) - } - - if opts.AccessKey == "" { - return nil, fmt.Errorf("sdkerr: unset accessKey") - } - if opts.SecretKey == "" { - return nil, fmt.Errorf("sdkerr: unset secretKey") - } - - signer := &signer{ - accessKey: opts.AccessKey, - secretKey: opts.SecretKey, - } - httper := resty.New(). - SetBaseURL("https://api.dogecloud.com"). - SetHeader("Accept", "application/json"). - SetHeader("Content-Type", "application/json"). - SetHeader("User-Agent", app.AppUserAgent). - SetPreRequestHook(func(ctx *resty.Client, req *http.Request) error { - if err := signer.Sign(req); err != nil { - return fmt.Errorf("sdkerr: sign error: %w", err) - } - - return nil - }) - - return &Client{httper}, nil -} - -func (c *Client) SetTimeout(timeout time.Duration) *Client { - c.rc.SetTimeout(timeout) - return c -} - -func (c *Client) newRequest(method string, path string) (*resty.Request, error) { - if method == "" { - return nil, fmt.Errorf("sdkerr: unset method") - } - if path == "" { - return nil, fmt.Errorf("sdkerr: unset path") - } - - req := c.rc.R() - req.Method = method - req.URL = path - - // WARN: - // DO NOT CALL `req.SetResult` or `req.SetError` AGAIN! USE `doRequestWithResult` INSTEAD. - return req, nil -} - -func (c *Client) doRequest(req *resty.Request) (*resty.Response, error) { - if req == nil { - return nil, fmt.Errorf("sdkerr: nil request") - } - - resp, err := req.Send() - if err != nil { - return resp, fmt.Errorf("sdkerr: failed to send request: %w", err) - } else if resp.IsError() { - return resp, fmt.Errorf("sdkerr: unexpected status code: %d (resp: %s)", resp.StatusCode(), resp.String()) - } - - return resp, nil -} - -func (c *Client) doRequestWithResult(req *resty.Request, res sdkResponse) (*resty.Response, error) { - if req == nil { - return nil, fmt.Errorf("sdkerr: nil request") - } - - resp, err := c.doRequest(req) - if err != nil { - if resp != nil { - json.Unmarshal(resp.Body(), &res) - } - return resp, err - } - - if len(resp.Body()) != 0 { - if err := json.Unmarshal(resp.Body(), &res); err != nil { - return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String()) - } else { - if rCode := res.GetCode(); rCode != 0 && rCode != 200 { - return resp, fmt.Errorf("sdkerr: api error: code='%d', msg='%s'", rCode, res.GetMessage()) - } - } - } - - return resp, nil -} diff --git a/pkg/sdk3rd/dogecloud/options.go b/pkg/sdk3rd/dogecloud/options.go deleted file mode 100644 index 75cdd43d3..000000000 --- a/pkg/sdk3rd/dogecloud/options.go +++ /dev/null @@ -1,15 +0,0 @@ -package dogecloud - -type Options struct { - AccessKey string - SecretKey string -} - -type OptionsFunc func(*Options) - -func WithAkSk(ak, sk string) OptionsFunc { - return func(o *Options) { - o.AccessKey = ak - o.SecretKey = sk - } -} diff --git a/pkg/sdk3rd/dogecloud/signer.go b/pkg/sdk3rd/dogecloud/signer.go deleted file mode 100644 index 7078964ef..000000000 --- a/pkg/sdk3rd/dogecloud/signer.go +++ /dev/null @@ -1,50 +0,0 @@ -package dogecloud - -import ( - "bytes" - "crypto/hmac" - "crypto/sha1" - "encoding/hex" - "fmt" - "io" - "net/http" -) - -type signer struct { - accessKey string - secretKey string -} - -func (s *signer) Sign(req *http.Request) error { - // API 签名机制: - // https://docs.dogecloud.com/cdn/api-access-token - - path := req.URL.Path - queryStr := req.URL.Query().Encode() - if queryStr != "" { - path += "?" + queryStr - } - - payloadStr := "" - if req.Body != nil { - payloadb, err := io.ReadAll(req.Body) - if err != nil { - return err - } - - payloadStr = string(payloadb) - req.Body = io.NopCloser(bytes.NewReader(payloadb)) - } - - stringToSign := fmt.Sprintf("%s\n%s", path, payloadStr) - - h := hmac.New(sha1.New, []byte(s.secretKey)) - h.Write([]byte(stringToSign)) - signature := hex.EncodeToString(h.Sum(nil)) - - authorization := fmt.Sprintf("TOKEN %s:%s", s.accessKey, signature) - - req.Header.Set("Authorization", authorization) - - return nil -} diff --git a/pkg/sdk3rd/dogecloud/types.go b/pkg/sdk3rd/dogecloud/types.go deleted file mode 100644 index 8370e5260..000000000 --- a/pkg/sdk3rd/dogecloud/types.go +++ /dev/null @@ -1,29 +0,0 @@ -package dogecloud - -type sdkResponse interface { - GetCode() int - GetMessage() string -} - -type sdkResponseBase struct { - Code *int `json:"code,omitempty"` - Message *string `json:"msg,omitempty"` -} - -func (r *sdkResponseBase) GetCode() int { - if r.Code == nil { - return 0 - } - - return *r.Code -} - -func (r *sdkResponseBase) GetMessage() string { - if r.Message == nil { - return "" - } - - return *r.Message -} - -var _ sdkResponse = (*sdkResponseBase)(nil) diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index ff9a14f1d..ee02bca33 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -60,7 +60,6 @@ import BizDeployNodeConfigFieldsProviderCTCCCloudELB from "./BizDeployNodeConfig import BizDeployNodeConfigFieldsProviderCTCCCloudFaaS from "./BizDeployNodeConfigFieldsProviderCTCCCloudFaaS"; import BizDeployNodeConfigFieldsProviderCTCCCloudICDN from "./BizDeployNodeConfigFieldsProviderCTCCCloudICDN"; import BizDeployNodeConfigFieldsProviderCTCCCloudLVDN from "./BizDeployNodeConfigFieldsProviderCTCCCloudLVDN"; -import BizDeployNodeConfigFieldsProviderDogeCloudCDN from "./BizDeployNodeConfigFieldsProviderDogeCloudCDN"; import BizDeployNodeConfigFieldsProviderFlexCDN from "./BizDeployNodeConfigFieldsProviderFlexCDN"; import BizDeployNodeConfigFieldsProviderFlyIO from "./BizDeployNodeConfigFieldsProviderFlyIO"; import BizDeployNodeConfigFieldsProviderFTP from "./BizDeployNodeConfigFieldsProviderFTP"; @@ -82,7 +81,6 @@ import BizDeployNodeConfigFieldsProviderJDCloudWAF from "./BizDeployNodeConfigFi import BizDeployNodeConfigFieldsProviderKong from "./BizDeployNodeConfigFieldsProviderKong"; import BizDeployNodeConfigFieldsProviderKsyunCDN from "./BizDeployNodeConfigFieldsProviderKsyunCDN"; import BizDeployNodeConfigFieldsProviderKsyunSLB from "./BizDeployNodeConfigFieldsProviderKsyunSLB"; -import BizDeployNodeConfigFieldsProviderKubernetesSecret from "./BizDeployNodeConfigFieldsProviderKubernetesSecret"; import BizDeployNodeConfigFieldsProviderLeCDN from "./BizDeployNodeConfigFieldsProviderLeCDN"; import BizDeployNodeConfigFieldsProviderLinodeLOS from "./BizDeployNodeConfigFieldsProviderLinodeLOS"; import BizDeployNodeConfigFieldsProviderLocal from "./BizDeployNodeConfigFieldsProviderLocal"; @@ -207,7 +205,6 @@ const providerComponentMap: Partial { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { form: formInst, preserve: true }); - - return ( - <> - - ({ - label: t(`workflow_node.deploy.form.shared_domain_match_pattern.option.${s}.label`), - value: s, - }))} - /> - - - - - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z - .object({ - domainMatchPattern: z.string().nonempty().default(DOMAIN_MATCH_PATTERN_EXACT), - domain: z.string().nullish(), - }) - .superRefine((values, ctx) => { - if (values.domainMatchPattern) { - switch (values.domainMatchPattern) { - case DOMAIN_MATCH_PATTERN_EXACT: - { - const scDomain = z.string().refine((v) => isDomain(v), t("common.errmsg.domain_invalid")); - const spDomain = scDomain.safeParse(values.domain); - if (!spDomain.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spDomain.error).errors.join(), - path: ["domain"], - }); - } - } - break; - } - } - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderDogeCloudCDN, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderKubernetesSecret.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderKubernetesSecret.tsx deleted file mode 100644 index 84535aa4e..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderKubernetesSecret.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import CodeTextInput from "@/components/CodeTextInput"; - -import { useFormNestedFieldsContext } from "./_context"; - -const BizDeployNodeConfigFieldsProviderKubernetesSecret = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const handleSecretAnnotationsBlur = () => { - let value = formInst.getFieldValue([parentNamePath, "secretAnnotations"]); - value = value.trim(); - value = value.replace(/(? { - let value = formInst.getFieldValue([parentNamePath, "secretLabels"]); - value = value.trim(); - value = value.replace(/(? - } - > - - - - } - > - - - - } - > - - - - } - > - - - - } - > - - - - - - - - - - - - } - > - - - - } - > - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - namespace: "default", - secretType: "kubernetes.io/tls", - secretDataKeyForCrt: "tls.crt", - secretDataKeyForKey: "tls.key", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z.object({ - namespace: z.string().nonempty(), - secretName: z.string().nonempty(), - secretType: z.string().nonempty(), - secretDataKeyForCrt: z.string().nonempty(), - secretDataKeyForKey: z.string().nonempty(), - secretDataKeyForCrtOnlyServer: z.string().nullish(), - secretDataKeyForCrtOnlyIntermedia: z.string().nullish(), - secretAnnotations: z - .string() - .nullish() - .refine((v) => { - if (!v) return true; - - const lines = v.split(/\r?\n/); - for (const line of lines) { - if (line.split(":").length < 2) { - return false; - } - } - return true; - }, t("workflow_node.deploy.form.k8s_secret_annotations.errmsg.invalid")), - secretLabels: z - .string() - .nullish() - .refine((v) => { - if (!v) return true; - - const lines = v.split(/\r?\n/); - for (const line of lines) { - if (line.split(":").length < 2) { - return false; - } - } - return true; - }, t("workflow_node.deploy.form.k8s_secret_labels.errmsg.invalid")), - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderKubernetesSecret, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 29e744ad2..ce29cc34c 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -664,7 +664,6 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({ CTCCCLOUD_FAAS: `${ACCESS_PROVIDERS.CTCCCLOUD}-faas`, CTCCCLOUD_ICDN: `${ACCESS_PROVIDERS.CTCCCLOUD}-icdn`, CTCCCLOUD_LVDN: `${ACCESS_PROVIDERS.CTCCCLOUD}-lvdn`, - DOGECLOUD_CDN: `${ACCESS_PROVIDERS.DOGECLOUD}-cdn`, DIGITALOCEAN_CERTIFICATE: `${ACCESS_PROVIDERS.DIGITALOCEAN}-certificate`, DOKPLOY: `${ACCESS_PROVIDERS.DOKPLOY}`, FLEXCDN: `${ACCESS_PROVIDERS.FLEXCDN}`, @@ -689,7 +688,6 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({ JDCLOUD_SSL: `${ACCESS_PROVIDERS.JDCLOUD}-ssl`, JDCLOUD_WAF: `${ACCESS_PROVIDERS.JDCLOUD}-waf`, KONG: `${ACCESS_PROVIDERS.KONG}`, - KUBERNETES_SECRET: `${ACCESS_PROVIDERS.KUBERNETES}-secret`, KSYUN_CDN: `${ACCESS_PROVIDERS.KSYUN}-cdn`, KSYUN_KCM: `${ACCESS_PROVIDERS.KSYUN}-kcm`, KSYUN_SLB: `${ACCESS_PROVIDERS.KSYUN}-slb`, @@ -790,7 +788,6 @@ export const deploymentProvidersMap: Maphttps://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/" - }, - "k8s_secret_name": { - "label": "Kubernetes Secret name", - "placeholder": "Please enter Kubernetes Secret name", - "tooltip": "For more information, see https://kubernetes.io/docs/concepts/configuration/secret/" - }, - "k8s_secret_type": { - "label": "Kubernetes Secret type", - "placeholder": "Please enter Kubernetes Secret type", - "tooltip": "For more information, see https://kubernetes.io/docs/concepts/configuration/secret/" - }, - "k8s_secret_data_key_for_key": { - "label": "Kubernetes Secret data key for private key", - "placeholder": "Please enter Kubernetes Secret data key for private key", - "tooltip": "For more information, see https://kubernetes.io/docs/concepts/configuration/secret/" - }, - "k8s_secret_data_key_for_crt": { - "label": "Kubernetes Secret data key for certificate", - "placeholder": "Please enter Kubernetes Secret data key for certificate", - "tooltip": "For more information, see https://kubernetes.io/docs/concepts/configuration/secret/" - }, - "k8s_secret_data_key_for_servercrt": { - "label": "Kubernetes Secret data key for certificate (includes the end-entity parts only) (Optional)", - "placeholder": "Please enter Kubernetes Secret data key for server certificate", - "help": "" - }, - "k8s_secret_data_key_for_intermediacrt": { - "label": "Kubernetes Secret data key for certificate (includes the CA parts only) (Optional)", - "placeholder": "Please enter Kubernetes Secret data key for intermediate certificate", - "help": "" - }, - "k8s_secret_annotations": { - "label": "Kubernetes Secret annotations (Optional)", - "placeholder": "Please enter Kubernetes Secret annotations", - "help": "Notes: One key value pair per line, separated by colon.", - "errmsg": { - "invalid": "Invalid annotations" - }, - "tooltip": "Example:
environment: production
app: nginx
" - }, - "k8s_secret_labels": { - "label": "Kubernetes Secret labels (Optional)", - "placeholder": "Please enter Kubernetes Secret labels", - "help": "Notes: One key value pair per line, separated by colon.", - "errmsg": { - "invalid": "Invalid labels" - }, - "tooltip": "Example:
environment: production
app: nginx
" - }, "kong": { "guide": "Requires Kong v2.0 or higher." }, diff --git a/ui/src/i18n/resources/zh/nls.provider.json b/ui/src/i18n/resources/zh/nls.provider.json index cb1d58b73..64e92a646 100644 --- a/ui/src/i18n/resources/zh/nls.provider.json +++ b/ui/src/i18n/resources/zh/nls.provider.json @@ -142,7 +142,6 @@ "dnsla": "帝恩爱斯(DNS.LA)", "dnsmadeeasy": "DNS Made Easy", "dogecloud": "多吉云", - "dogecloud_cdn": "多吉云 - 内容分发网络 CDN", "dokploy": "Dokploy", "duckdns": "Duck DNS", "dynadot": "Dynadot", @@ -190,7 +189,6 @@ "jdcloud_waf": "京东云 - Web 应用防火墙 WAF", "kong": "Kong", "kubernetes": "Kubernetes", - "kubernetes_secret": "Kubernetes - Secret", "ksyun": "金山云", "ksyun_cdn": "金山云 - 内容分发网络 CDN", "ksyun_kcm_upload": "金山云 - 上传到 SSL 证书管理", diff --git a/ui/src/i18n/resources/zh/nls.workflow.nodes.json b/ui/src/i18n/resources/zh/nls.workflow.nodes.json index 12003693d..43330a6a8 100644 --- a/ui/src/i18n/resources/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/zh/nls.workflow.nodes.json @@ -1543,10 +1543,6 @@ "label": "天翼云 LVDN 加速域名", "placeholder": "请输入天翼云 LVDN 加速域名" }, - "dogecloud_cdn_domain": { - "label": "多吉云 CDN 加速域名", - "placeholder": "请输入多吉云 CDN 加速域名" - }, "flexcdn_deploy_target": { "option": { "certificate": { @@ -1778,59 +1774,6 @@ "label": "京东云 WAF 防护域名", "placeholder": "请输入京东云 WAF 防护域名" }, - "k8s_namespace": { - "label": "Kubernetes 命名空间", - "placeholder": "请输入 Kubernetes 命名空间", - "tooltip": "这是什么?请参阅 https://kubernetes.io/zh-cn/docs/concepts/overview/working-with-objects/namespaces/" - }, - "k8s_secret_name": { - "label": "Kubernetes Secret 名称", - "placeholder": "请输入 Kubernetes Secret 名称", - "tooltip": "这是什么?请参阅 https://kubernetes.io/zh-cn/docs/concepts/configuration/secret/" - }, - "k8s_secret_type": { - "label": "Kubernetes Secret 类型", - "placeholder": "请输入 Kubernetes Secret 类型", - "tooltip": "这是什么?请参阅 https://kubernetes.io/zh-cn/docs/concepts/configuration/secret/" - }, - "k8s_secret_data_key_for_key": { - "label": "Kubernetes Secret 数据键(用于存放私钥的字段)", - "placeholder": "请输入 Kubernetes Secret 中用于存放私钥的数据键", - "tooltip": "这是什么?请参阅 https://kubernetes.io/zh-cn/docs/concepts/configuration/secret/" - }, - "k8s_secret_data_key_for_crt": { - "label": "Kubernetes Secret 数据键之存放证书的字段", - "placeholder": "请输入 Kubernetes Secret 中用于存放证书的数据键", - "tooltip": "这是什么?请参阅 https://kubernetes.io/zh-cn/docs/concepts/configuration/secret/" - }, - "k8s_secret_data_key_for_servercrt": { - "label": "Kubernetes Secret 数据键之存放仅服务器证书的字段(可选)", - "placeholder": "请输入 Kubernetes Secret 中用于存放服务器证书的数据键", - "help": "提示:不填写时将不会保存服务器证书。" - }, - "k8s_secret_data_key_for_intermediacrt": { - "label": "Kubernetes Secret 数据键之存放仅中间证书的字段(可选)", - "placeholder": "请输入 Kubernetes Secret 中用于存放中间证书的数据键", - "help": "提示:不填写时将不会保存中间证书。" - }, - "k8s_secret_annotations": { - "label": "Kubernetes Secret 注解(可选)", - "placeholder": "请输入 Kubernetes Secret 注解", - "help": "提示:每行一个键值对,以分号分隔。", - "errmsg": { - "invalid": "无效的注解键值对" - }, - "tooltip": "示例:
environment: production
app: nginx
" - }, - "k8s_secret_labels": { - "label": "Kubernetes Secret 标签(可选)", - "placeholder": "请输入 Kubernetes Secret 标签", - "help": "提示:每行一个键值对,以分号分隔。", - "errmsg": { - "invalid": "无效的标签键值对" - }, - "tooltip": "示例:
environment: production
app: nginx
" - }, "kong": { "guide": "需要 Kong v2.0 或更高版本。" }, From 1975a9e0f57734087c1186e94fbcdee51bbc2a9a Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Wed, 29 Jul 2026 09:24:56 +0800 Subject: [PATCH 13/19] feat: plugin migration 1 --- internal/domain/access.go | 14 ---- internal/domain/provider.go | 8 --- .../forms/AccessConfigFieldsProvider.tsx | 4 -- .../AccessConfigFieldsProviderDogeCloud.tsx | 64 ------------------- .../AccessConfigFieldsProviderKubernetes.tsx | 53 --------------- ui/src/domain/provider.ts | 4 -- ui/src/i18n/resources/en/nls.provider.json | 2 - ui/src/i18n/resources/zh/nls.provider.json | 2 - 8 files changed, 151 deletions(-) delete mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderDogeCloud.tsx delete mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderKubernetes.tsx diff --git a/internal/domain/access.go b/internal/domain/access.go index 59ba16567..416d9925c 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -227,11 +227,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"` @@ -368,10 +363,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"` @@ -498,11 +489,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"` } diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 3d131a484..4ce6df0f4 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -53,7 +53,6 @@ const ( AccessProviderTypeDNSExit = AccessProviderType("dnsexit") AccessProviderTypeDNSLA = AccessProviderType("dnsla") AccessProviderTypeDNSMadeEasy = AccessProviderType("dnsmadeeasy") - AccessProviderTypeDogeCloud = AccessProviderType("dogecloud") AccessProviderTypeDokploy = AccessProviderType("dokploy") AccessProviderTypeDuckDNS = AccessProviderType("duckdns") AccessProviderTypeDynadot = AccessProviderType("dynadot") @@ -81,7 +80,6 @@ const ( AccessProviderTypeJDCloud = AccessProviderType("jdcloud") AccessProviderTypeKong = AccessProviderType("kong") AccessProviderTypeKsyun = AccessProviderType("ksyun") - AccessProviderTypeKubernetes = AccessProviderType("k8s") AccessProviderTypeLarkBot = AccessProviderType("larkbot") AccessProviderTypeLeCDN = AccessProviderType("lecdn") AccessProviderTypeLetsEncrypt = AccessProviderType("letsencrypt") @@ -104,7 +102,6 @@ const ( AccessProviderTypePorkbun = AccessProviderType("porkbun") AccessProviderTypePowerDNS = AccessProviderType("powerdns") AccessProviderTypeProxmoxVE = AccessProviderType("proxmoxve") - AccessProviderTypeQiniu = AccessProviderType("qiniu") AccessProviderTypeQingCloud = AccessProviderType("qingcloud") AccessProviderTypeRainYun = AccessProviderType("rainyun") AccessProviderTypeRatPanel = AccessProviderType("ratpanel") @@ -373,7 +370,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) @@ -397,7 +393,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") @@ -410,9 +405,6 @@ const ( DeploymentProviderTypeOracleCloudCertificatesMgmt = DeploymentProviderType(AccessProviderTypeOracleCloud + "-certificatesmgmt") 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) diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx index bc802e1da..15f848fab 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx @@ -42,7 +42,6 @@ import AccessConfigFieldsProviderDiscordBot from "./AccessConfigFieldsProviderDi import AccessConfigFieldsProviderDNSExit from "./AccessConfigFieldsProviderDNSExit"; import AccessConfigFieldsProviderDNSLA from "./AccessConfigFieldsProviderDNSLA"; import AccessConfigFieldsProviderDNSMadeEasy from "./AccessConfigFieldsProviderDNSMadeEasy"; -import AccessConfigFieldsProviderDogeCloud from "./AccessConfigFieldsProviderDogeCloud"; import AccessConfigFieldsProviderDokploy from "./AccessConfigFieldsProviderDokploy"; import AccessConfigFieldsProviderDuckDNS from "./AccessConfigFieldsProviderDuckDNS"; import AccessConfigFieldsProviderDynadot from "./AccessConfigFieldsProviderDynadot"; @@ -69,7 +68,6 @@ import AccessConfigFieldsProviderIONOS from "./AccessConfigFieldsProviderIONOS"; import AccessConfigFieldsProviderJDCloud from "./AccessConfigFieldsProviderJDCloud"; import AccessConfigFieldsProviderKong from "./AccessConfigFieldsProviderKong"; import AccessConfigFieldsProviderKsyun from "./AccessConfigFieldsProviderKsyun"; -import AccessConfigFieldsProviderKubernetes from "./AccessConfigFieldsProviderKubernetes"; import AccessConfigFieldsProviderLarkBot from "./AccessConfigFieldsProviderLarkBot"; import AccessConfigFieldsProviderLeCDN from "./AccessConfigFieldsProviderLeCDN"; import AccessConfigFieldsProviderLinode from "./AccessConfigFieldsProviderLinode"; @@ -167,7 +165,6 @@ const providerComponentMap: Partial { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> - } - > - - - - } - > - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - accessKey: "", - secretKey: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { - const { t: _ } = i18n; - - return z.object({ - accessKey: z.string().nonempty(), - secretKey: z.string().nonempty(), - }); -}; - -const _default = Object.assign(AccessConfigFormFieldsProviderDogeCloud, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderKubernetes.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderKubernetes.tsx deleted file mode 100644 index 557fbd1b9..000000000 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderKubernetes.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import FileTextInput from "@/components/FileTextInput"; - -import { useFormNestedFieldsContext } from "./_context"; - -const AccessConfigFormFieldsProviderKubernetes = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> - } - > - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return {}; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { - const { t: _ } = i18n; - - return z.object({ - kubeConfig: z.string().max(20480).nullish(), - }); -}; - -const _default = Object.assign(AccessConfigFormFieldsProviderKubernetes, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index ce29cc34c..cc9964ddd 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -57,7 +57,6 @@ export const ACCESS_PROVIDERS = Object.freeze({ DNSEXIT: "dnsexit", DNSLA: "dnsla", DNSMADEEASY: "dnsmadeeasy", - DOGECLOUD: "dogecloud", DOKPLOY: "dokploy", DUCKDNS: "duckdns", DYNADOT: "dynadot", @@ -83,7 +82,6 @@ export const ACCESS_PROVIDERS = Object.freeze({ IONOS: "ionos", JDCLOUD: "jdcloud", KONG: "kong", - KUBERNETES: "k8s", KSYUN: "ksyun", LARKBOT: "larkbot", LECDN: "lecdn", @@ -169,7 +167,6 @@ export const accessProvidersMap: Map Date: Sat, 1 Aug 2026 19:27:22 +0800 Subject: [PATCH 14/19] feat: market --- internal/pluginhost/market.go | 91 +++-------- internal/pluginhost/market_test.go | 232 +++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 68 deletions(-) create mode 100644 internal/pluginhost/market_test.go diff --git a/internal/pluginhost/market.go b/internal/pluginhost/market.go index 6bc6078c1..8e09983ec 100644 --- a/internal/pluginhost/market.go +++ b/internal/pluginhost/market.go @@ -28,6 +28,7 @@ type MarketEntry struct { type MarketService struct { marketRepo string + indexURL string pluginDir string cache []MarketEntry cachedAt time.Time @@ -39,6 +40,7 @@ type MarketService struct { type MarketConfig struct { MarketRepo string + IndexURL string PluginDir string CacheTTL time.Duration Logger *slog.Logger @@ -54,8 +56,12 @@ func NewMarketService(cfg MarketConfig) *MarketService { 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) + } return &MarketService{ marketRepo: cfg.MarketRepo, + indexURL: cfg.IndexURL, pluginDir: cfg.PluginDir, cacheTTL: cfg.CacheTTL, httpClient: &http.Client{Timeout: 30 * time.Second}, @@ -100,87 +106,43 @@ func (s *MarketService) fetchAndCache(ctx context.Context) ([]MarketEntry, error } func (s *MarketService) fetchMarketListing(ctx context.Context) ([]MarketEntry, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/contents/", s.marketRepo) + url := s.indexURL req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("market: create request: %w", err) } - req.Header.Set("Accept", "application/vnd.github+json") - s.setAuth(req) resp, err := s.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("market: fetch directory listing: %w", err) + return nil, fmt.Errorf("market: fetch index: %w", err) } defer resp.Body.Close() - if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { - return nil, fmt.Errorf("market: GitHub API rate limit reached (status %d)", resp.StatusCode) - } - if resp.StatusCode != http.StatusOK { + 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: GitHub API returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("market: index fetch returned status %d: %s", resp.StatusCode, string(body)) } - var dirEntries []struct { - Name string `json:"name"` - Type string `json:"type"` + var idx struct { + Plugins []*plugin.MarketManifest `json:"plugins"` } - if err := json.NewDecoder(resp.Body).Decode(&dirEntries); err != nil { - return nil, fmt.Errorf("market: decode directory listing: %w", err) + if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil { + return nil, fmt.Errorf("market: decode index: %w", err) } - var entries []MarketEntry - for _, de := range dirEntries { - if de.Type != "dir" { - continue - } - entry, err := s.fetchPluginManifest(ctx, de.Name) - if err != nil { - s.logger.Warn("market: skipping plugin directory", - slog.String("dir", de.Name), - slog.Any("error", err)) - continue - } - entries = append(entries, *entry) + entries := make([]MarketEntry, 0, len(idx.Plugins)) + for _, mm := range idx.Plugins { + entries = append(entries, MarketEntry{MarketManifest: mm, Status: s.computeStatus(mm)}) } - return entries, nil } -func (s *MarketService) fetchPluginManifest(ctx context.Context, dirName string) (*MarketEntry, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s/manifest.json", s.marketRepo, dirName) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("market: create manifest request for %s: %w", dirName, err) - } - req.Header.Set("Accept", "application/vnd.github.raw+json") - s.setAuth(req) - - resp, err := s.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("market: fetch manifest for %s: %w", dirName, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("market: manifest fetch for %s returned status %d", dirName, resp.StatusCode) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("market: read manifest for %s: %w", dirName, err) - } - - mm, err := plugin.ParseMarketManifest(data) - if err != nil { - return nil, fmt.Errorf("market: parse manifest for %s: %w", dirName, err) - } - - status := s.computeStatus(mm) - return &MarketEntry{MarketManifest: mm, Status: status}, nil -} - func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string { key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH) if mm.Release == nil || mm.Release.Assets[key] == "" { @@ -204,13 +166,6 @@ func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string { return "installed" } -func (s *MarketService) setAuth(req *http.Request) { - token := os.Getenv("CERTIMATE_GITHUB_TOKEN") - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } -} - func (s *MarketService) GetMarketManifest(ctx context.Context, providerType string) (*plugin.MarketManifest, error) { entries, err := s.ListMarket(ctx) if err != nil { diff --git a/internal/pluginhost/market_test.go b/internal/pluginhost/market_test.go new file mode 100644 index 000000000..30cfea416 --- /dev/null +++ b/internal/pluginhost/market_test.go @@ -0,0 +1,232 @@ +package pluginhost + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "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) + } +} From 29a83e1188203bb081ce05a2120e17c71a533317 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Sat, 1 Aug 2026 22:28:03 +0800 Subject: [PATCH 15/19] refactor: plugin market --- internal/pluginhost/jobs.go | 165 ++++++ internal/pluginhost/market.go | 686 ++++++++++++++++------ internal/pluginhost/market_test.go | 480 +++++++++++++++ internal/pluginhost/reloader.go | 35 ++ internal/rest/handlers/pluginmarket.go | 21 +- main.go | 7 +- pkg/plugin/discovery.go | 4 + pkg/plugin/semver.go | 29 +- pkg/plugin/semver_test.go | 4 + ui/src/api/pluginmarket.ts | 30 +- ui/src/i18n/resources/en/nls.plugin.json | 23 +- ui/src/i18n/resources/zh/nls.plugin.json | 23 +- ui/src/pages/settings/SettingsPlugins.tsx | 406 ++++++++++--- 13 files changed, 1628 insertions(+), 285 deletions(-) create mode 100644 internal/pluginhost/jobs.go diff --git a/internal/pluginhost/jobs.go b/internal/pluginhost/jobs.go new file mode 100644 index 000000000..ed4cf9656 --- /dev/null +++ b/internal/pluginhost/jobs.go @@ -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) +} diff --git a/internal/pluginhost/market.go b/internal/pluginhost/market.go index 8e09983ec..f19df2c94 100644 --- a/internal/pluginhost/market.go +++ b/internal/pluginhost/market.go @@ -1,6 +1,7 @@ package pluginhost import ( + "archive/zip" "context" "crypto/sha256" "encoding/hex" @@ -13,13 +14,27 @@ import ( "os" "path/filepath" "runtime" + "strconv" + "strings" "sync" "time" + "github.com/certimate-go/certimate/internal/domain" "github.com/certimate-go/certimate/pkg/plugin" ) -var ErrMissingProviderType = errors.New("market: providerType is required") +const ( + maxDownloadBytes int64 = 512 * 1024 * 1024 + maxUncompressedBytes int64 = 1024 * 1024 * 1024 + maxZipEntries = 64 +) + +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 @@ -27,23 +42,28 @@ type MarketEntry struct { } type MarketService struct { - marketRepo string - indexURL string - pluginDir string - cache []MarketEntry - cachedAt time.Time - cacheTTL time.Duration - mu sync.RWMutex - httpClient *http.Client - logger *slog.Logger + 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 + logger *slog.Logger + jobs *jobStore + ops *pluginOps } type MarketConfig struct { - MarketRepo string - IndexURL string - PluginDir string - CacheTTL time.Duration - Logger *slog.Logger + MarketRepo string + IndexURL string + DownloadMirror string + PluginDir string + CacheTTL time.Duration + Logger *slog.Logger } func NewMarketService(cfg MarketConfig) *MarketService { @@ -60,29 +80,45 @@ func NewMarketService(cfg MarketConfig) *MarketService { cfg.IndexURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/main/index.json", cfg.MarketRepo) } return &MarketService{ - marketRepo: cfg.MarketRepo, - indexURL: cfg.IndexURL, - pluginDir: cfg.PluginDir, - cacheTTL: cfg.CacheTTL, - httpClient: &http.Client{Timeout: 30 * time.Second}, - logger: cfg.Logger.With(slog.String("component", "market")), + 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{Timeout: 5 * time.Minute}, + 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 { - entries := s.cache + manifests := s.cache s.mu.RUnlock() - s.logger.Debug("market listing served from cache", slog.Int("entries", len(entries))) - return entries, nil + 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) ([]MarketEntry, error) { +func (s *MarketService) fetchAndCache(ctx context.Context) ([]*plugin.MarketManifest, error) { s.mu.Lock() defer s.mu.Unlock() @@ -90,7 +126,7 @@ func (s *MarketService) fetchAndCache(ctx context.Context) ([]MarketEntry, error return s.cache, nil } - entries, err := s.fetchMarketListing(ctx) + 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)) @@ -99,13 +135,13 @@ func (s *MarketService) fetchAndCache(ctx context.Context) ([]MarketEntry, error return nil, err } - s.cache = entries + s.cache = manifests s.cachedAt = time.Now() - s.logger.Info("market listing fetched", slog.Int("entries", len(entries))) - return entries, nil + s.logger.Info("market listing fetched", slog.Int("entries", len(manifests))) + return manifests, nil } -func (s *MarketService) fetchMarketListing(ctx context.Context) ([]MarketEntry, error) { +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 { @@ -135,12 +171,7 @@ func (s *MarketService) fetchMarketListing(ctx context.Context) ([]MarketEntry, if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil { return nil, fmt.Errorf("market: decode index: %w", err) } - - entries := make([]MarketEntry, 0, len(idx.Plugins)) - for _, mm := range idx.Plugins { - entries = append(entries, MarketEntry{MarketManifest: mm, Status: s.computeStatus(mm)}) - } - return entries, nil + return idx.Plugins, nil } func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string { @@ -167,112 +198,81 @@ func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string { } func (s *MarketService) GetMarketManifest(ctx context.Context, providerType string) (*plugin.MarketManifest, error) { - entries, err := s.ListMarket(ctx) + manifests, err := s.manifests(ctx) if err != nil { return nil, err } - for _, e := range entries { - if e.ProviderType == providerType { - return e.MarketManifest, nil + 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) Install(ctx context.Context, providerType string) (*ReloadResult, error) { +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, fmt.Errorf("market: plugin %q is already installed", providerType) + return nil, ErrAlreadyInstalled } - mm, err := s.GetMarketManifest(ctx, providerType) + 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 { - 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 binary for %s", providerType, key) - } - - downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", - mm.Release.Repo, mm.Release.Tag, assetName) - - tmpDir := filepath.Join(s.pluginDir, ".tmp-"+providerType) - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return nil, fmt.Errorf("market: create temp dir: %w", err) - } - defer func() { - if err := os.RemoveAll(tmpDir); err != nil { - s.logger.Warn("market: failed to clean up temp dir", slog.String("dir", tmpDir), slog.Any("error", err)) - } - }() - - binaryPath := filepath.Join(tmpDir, mm.Binary) - if err := s.downloadFile(ctx, downloadURL, binaryPath); err != nil { - return nil, err - } - - if err := os.Chmod(binaryPath, 0o755); err != nil { - return nil, fmt.Errorf("market: chmod binary: %w", err) - } - - computedSHA256, err := sha256File(binaryPath) - if err != nil { - return nil, fmt.Errorf("market: compute sha256: %w", err) - } - - if expected, ok := mm.Release.Checksums[key]; ok && expected != "" && expected != computedSHA256 { - return nil, fmt.Errorf("market: plugin %q checksum mismatch for %s: expected %s, got %s", providerType, key, expected, computedSHA256) - } - - localManifest := *mm - localManifest.OS = runtime.GOOS - localManifest.Arch = runtime.GOARCH - localManifest.SHA256 = computedSHA256 - - manifestData, err := json.MarshalIndent(&localManifest.Manifest, "", " ") - if err != nil { - return nil, fmt.Errorf("market: marshal local manifest: %w", err) - } - manifestPath := filepath.Join(tmpDir, "manifest.json") - if err := os.WriteFile(manifestPath, 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 + 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 { - return nil, fmt.Errorf("market: atomic rename: %w", err) + _ = os.RemoveAll(tmpDir) + job.fail(fmt.Sprintf("market: install rename: %v", err)) + return } reloader := GlobalReloader() if reloader == nil { - return nil, fmt.Errorf("market: reloader not initialized") + job.fail("market: reloader not initialized") + return } - return reloader.ReloadNow(ctx), nil + 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) { @@ -292,13 +292,17 @@ func (s *MarketService) Delete(ctx context.Context, providerType string) (*Reloa if reloader == nil { return nil, fmt.Errorf("market: reloader not initialized") } - return reloader.ReloadNow(ctx), nil + 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) { @@ -319,130 +323,426 @@ func (s *MarketService) Update(ctx context.Context, providerType string) (*Reloa return nil, fmt.Errorf("market: plugin %q is already up to date", providerType) } - 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 binary for %s", providerType, key) - } - - downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", - mm.Release.Repo, mm.Release.Tag, assetName) - - tmpDir := filepath.Join(s.pluginDir, ".tmp-"+providerType) - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return nil, fmt.Errorf("market: create temp dir: %w", err) - } - defer func() { - if err := os.RemoveAll(tmpDir); err != nil { - s.logger.Warn("market: failed to clean up temp dir", slog.String("dir", tmpDir), slog.Any("error", err)) - } - }() - - binaryPath := filepath.Join(tmpDir, mm.Binary) - if err := s.downloadFile(ctx, downloadURL, binaryPath); err != nil { - return nil, err - } - - if err := os.Chmod(binaryPath, 0o755); err != nil { - return nil, fmt.Errorf("market: chmod binary: %w", err) - } - - computedSHA256, err := sha256File(binaryPath) + tmpDir, _, err := s.installPipeline(ctx, providerType, nil) if err != nil { - return nil, fmt.Errorf("market: compute sha256: %w", err) - } - - if expected, ok := mm.Release.Checksums[key]; ok && expected != "" && expected != computedSHA256 { - return nil, fmt.Errorf("market: plugin %q checksum mismatch for %s: expected %s, got %s", providerType, key, expected, computedSHA256) - } - - localManifest := *mm - localManifest.OS = runtime.GOOS - localManifest.Arch = runtime.GOARCH - localManifest.SHA256 = computedSHA256 - - manifestData, err := json.MarshalIndent(&localManifest.Manifest, "", " ") - if err != nil { - return nil, fmt.Errorf("market: marshal local manifest: %w", err) - } - manifestPath := filepath.Join(tmpDir, "manifest.json") - if err := os.WriteFile(manifestPath, manifestData, 0o644); err != nil { - return nil, fmt.Errorf("market: write manifest: %w", err) - } - - newMeta := plugin.NewMarketMeta("official", mm.Version, mm.Version, mm.Release.Tag) - if err := plugin.WriteMarketMeta(tmpDir, ".", newMeta); 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, fmt.Errorf("market: install rename failed (%v) and rollback failed (%v)", err, rerr) + return nil, ErrUpdateCorrupted } - return nil, fmt.Errorf("market: install rename: %w", err) - } - if err := os.RemoveAll(backupDir); err != nil { - s.logger.Warn("market: failed to clean up backup dir", slog.String("dir", backupDir), slog.Any("error", err)) + return nil, ErrUpdateRolledBack } + _ = os.RemoveAll(backupDir) reloader := GlobalReloader() if reloader == nil { return nil, fmt.Errorf("market: reloader not initialized") } - return reloader.ReloadNow(ctx), nil + return reloader.ReloadWait(ctx), nil } -func (s *MarketService) downloadFile(ctx context.Context, url, dest string) error { +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)") + +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 { + 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.httpClient.Do(req) + resp, err := s.downloadClient.Do(req) if err != nil { - return fmt.Errorf("market: download %s: %w", url, err) + return err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + 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) } - f, err := os.Create(dest) - if err != nil { - return fmt.Errorf("market: create dest file: %w", err) + if total > 0 && total > maxDownloadBytes { + return fmt.Errorf("market: download size %d exceeds max %d: %w", total, maxDownloadBytes, errDownloadNotRetryable) } - defer f.Close() - if _, err := io.Copy(f, resp.Body); err != nil { - return fmt.Errorf("market: write download: %w", err) + pr := &progressReader{ + r: io.LimitReader(resp.Body, maxDownloadBytes+1), + total: total, + base: offset, + on: onProgress, + } + if _, err := io.Copy(f, pr); err != nil { + 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 sha256File(path string) (string, error) { - f, err := os.Open(path) +func isRetryableDownloadError(err error) bool { + if errors.Is(err, errDownloadNotRetryable) { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + return true +} + +func backoffFor(attempt int) time.Duration { + d := time.Duration(1< 0 { + pr.read += int64(n) + 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 f.Close() - + defer file.Close() h := sha256.New() - if _, err := io.Copy(h, f); err != nil { + 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)) + } + } + } + } +} diff --git a/internal/pluginhost/market_test.go b/internal/pluginhost/market_test.go index 30cfea416..37756445e 100644 --- a/internal/pluginhost/market_test.go +++ b/internal/pluginhost/market_test.go @@ -1,17 +1,27 @@ package pluginhost import ( + "archive/zip" + "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "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" ) @@ -230,3 +240,473 @@ func TestGetMarketManifest_FromCache(t *testing.T) { 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("")}, + }) + 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 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) + } +} diff --git a/internal/pluginhost/reloader.go b/internal/pluginhost/reloader.go index cbd5e3d11..dc6dd488e 100644 --- a/internal/pluginhost/reloader.go +++ b/internal/pluginhost/reloader.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "sync" + "time" "github.com/certimate-go/certimate/internal/certmgmt/deployers" "github.com/certimate-go/certimate/internal/domain" @@ -68,7 +69,41 @@ func (r *Reloader) ReloadNow(ctx context.Context) *ReloadResult { 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) diff --git a/internal/rest/handlers/pluginmarket.go b/internal/rest/handlers/pluginmarket.go index a26f335d6..6a5b2e841 100644 --- a/internal/rest/handlers/pluginmarket.go +++ b/internal/rest/handlers/pluginmarket.go @@ -7,15 +7,17 @@ import ( "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.ReloadResult, 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 { @@ -26,6 +28,7 @@ func NewPluginMarketHandler(rg *router.RouterGroup[*core.RequestEvent], service 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) } @@ -50,11 +53,23 @@ func (h *PluginMarketHandler) install(e *core.RequestEvent) error { if req.ProviderType == "" { return resp.Err(e, pluginhost.ErrMissingProviderType) } - result, err := h.service.Install(e.Request.Context(), req.ProviderType) + job, err := h.service.Install(e.Request.Context(), req.ProviderType) if err != nil { return resp.Err(e, err) } - return resp.Ok(e, result) + 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 { diff --git a/main.go b/main.go index 6136f06c8..e81ca69b8 100644 --- a/main.go +++ b/main.go @@ -110,6 +110,8 @@ func scanPlugins() { } 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 { @@ -121,8 +123,9 @@ func scanPlugins() { pluginhost.SetGlobalReloader(reloader) marketSvc := pluginhost.NewMarketService(pluginhost.MarketConfig{ - PluginDir: pluginDir, - Logger: logger, + PluginDir: pluginDir, + DownloadMirror: os.Getenv("CERTIMATE_PLUGIN_DOWNLOAD_MIRROR"), + Logger: logger, }) pluginhost.SetGlobalMarketService(marketSvc) diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index 5d8ef6a69..65459f01e 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" ) type DiscoveredPlugin struct { @@ -45,6 +46,9 @@ func Discover(ctx context.Context, cfg PluginConfig) ([]*DiscoveredPlugin, []Dis if !entry.IsDir() { continue } + if strings.HasPrefix(entry.Name(), ".") { + continue + } dir := filepath.Join(cfg.PluginDir, entry.Name()) dp, ferr := discoverOne(dir, cfg.CoreVersion) if ferr != nil { diff --git a/pkg/plugin/semver.go b/pkg/plugin/semver.go index 011804478..0a654e7c5 100644 --- a/pkg/plugin/semver.go +++ b/pkg/plugin/semver.go @@ -12,9 +12,20 @@ func semverLess(a, b string) bool { func semverCompare(a, b string) int { pa := semverParts(a) pb := semverParts(b) - for i := 0; i < 3; i++ { - if pa[i] != pb[i] { - if pa[i] < pb[i] { + n := len(pa) + if len(pb) > n { + n = len(pb) + } + for i := 0; i < n; i++ { + var x, y int64 + if i < len(pa) { + x = pa[i] + } + if i < len(pb) { + y = pb[i] + } + if x != y { + if x < y { return -1 } return 1 @@ -23,16 +34,14 @@ func semverCompare(a, b string) int { return 0 } -func semverParts(v string) [3]int64 { +func semverParts(v string) []int64 { v = strings.TrimPrefix(strings.TrimSpace(v), "v") - var out [3]int64 - for i, part := range strings.SplitN(v, ".", 3) { - if i >= 3 { - break - } + parts := strings.Split(v, ".") + out := make([]int64, 0, len(parts)) + for _, part := range parts { num, _, _ := strings.Cut(part, "-") n, _ := strconv.ParseInt(num, 10, 64) - out[i] = n + out = append(out, n) } return out } diff --git a/pkg/plugin/semver_test.go b/pkg/plugin/semver_test.go index 1cb85bec8..f5fa05643 100644 --- a/pkg/plugin/semver_test.go +++ b/pkg/plugin/semver_test.go @@ -14,6 +14,10 @@ func TestSemverCompare(t *testing.T) { {"v0.1.0", "0.1.0", 0}, {"0.1", "0.1.0", 0}, {"1.2.3-rc", "1.2.3", 0}, + {"1.2.3.4", "1.2.3", 1}, + {"1.2.3", "1.2.3.4", -1}, + {"1.2.3.4", "1.2.3.5", -1}, + {"1.2", "1.2.1", -1}, } for _, c := range cases { got := semverCompare(c.a, c.b) diff --git a/ui/src/api/pluginmarket.ts b/ui/src/api/pluginmarket.ts index 3f8274a7d..8a4e5122d 100644 --- a/ui/src/api/pluginmarket.ts +++ b/ui/src/api/pluginmarket.ts @@ -1,3 +1,5 @@ +import { ClientResponseError } from "pocketbase"; + import { get as httpGet, post as httpPost } from "./_api"; import { getPocketBase } from "@/repository/_pocketbase"; @@ -20,6 +22,20 @@ export interface MarketEntry { }; } +export type InstallJobState = "queued" | "downloading" | "verifying" | "extracting" | "reloading" | "installed" | "failed"; + +export interface InstallJobStatus { + providerType: string; + state: InstallJobState; + stage?: string; + error?: string; + downloaded?: number; + total?: number; + result?: { added: string[]; changed: string[]; removed: string[]; errors: string[] }; +} + +export const isTerminalState = (state: InstallJobState): boolean => state === "installed" || state === "failed"; + export const fetchMarketListing = async (): Promise => { try { const resp = await httpGet({ @@ -31,18 +47,28 @@ export const fetchMarketListing = async (): Promise => { } }; -export const installPlugin = async (providerType: string): Promise => { - const resp = await httpPost({ +export const installPlugin = async (providerType: string): Promise => { + const resp = await httpPost({ url: "/api/plugin/market/install", body: { providerType }, }); return resp.data; }; +export const getInstallStatus = async (providerType: string): Promise => { + const resp = await httpGet({ + url: `/api/plugin/market/install/status/${providerType}`, + }); + return resp.data; +}; + export const deletePlugin = async (providerType: string): Promise => { const resp = await pb.send<{ code: number; data: unknown }>(`/api/plugin/market/${providerType}`, { method: "DELETE", }); + if (resp.code !== 0) { + throw new ClientResponseError({ status: resp.code, response: resp, data: {} }); + } return resp.data; }; diff --git a/ui/src/i18n/resources/en/nls.plugin.json b/ui/src/i18n/resources/en/nls.plugin.json index aae652b0c..546b9c4ef 100644 --- a/ui/src/i18n/resources/en/nls.plugin.json +++ b/ui/src/i18n/resources/en/nls.plugin.json @@ -4,8 +4,10 @@ "market": { "nudge": "Didn't find the provider you need? Browse the plugin marketplace for more.", "browse": "Browse marketplace", + "heading": "Plugin marketplace", "empty": "No plugins available", + "empty_filtered": "No plugins match your search", "error": { "load_failed": "Failed to load plugin marketplace", "install_failed": "Failed to install plugin", @@ -20,9 +22,26 @@ "action": { "install": "Install", "delete": "Delete", - "update": "Update to", + "update": "Update", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "search_placeholder": "Search plugins", + "delete_modal_title": "Remove plugin", + "delete_modal_content": "This removes the plugin {{name}} from this server. Any access or workflow using it will stop working until it is reinstalled." + }, + "filter": { + "all": "All", + "installed": "Installed", + "not_installed": "Not installed" + }, + "job": { + "installing": "Installing…", + "queued": "Queued…", + "downloading": "Downloading…", + "verifying": "Verifying…", + "extracting": "Extracting…", + "reloading": "Reloading…", + "failed": "Install failed" }, "label": { "update_available": "Update available", diff --git a/ui/src/i18n/resources/zh/nls.plugin.json b/ui/src/i18n/resources/zh/nls.plugin.json index 01d2c9477..f526d3993 100644 --- a/ui/src/i18n/resources/zh/nls.plugin.json +++ b/ui/src/i18n/resources/zh/nls.plugin.json @@ -4,8 +4,10 @@ "market": { "nudge": "没找到你需要的提供商?前往插件市场发现更多。", "browse": "浏览插件市场", + "heading": "插件市场", "empty": "暂无可用插件", + "empty_filtered": "没有匹配的插件", "error": { "load_failed": "加载插件市场失败", "install_failed": "安装插件失败", @@ -20,9 +22,26 @@ "action": { "install": "安装", "delete": "删除", - "update": "更新到", + "update": "更新", "retry": "重试", - "refresh": "刷新" + "refresh": "刷新", + "search_placeholder": "搜索插件", + "delete_modal_title": "移除插件", + "delete_modal_content": "将从本机移除插件 {{name}}。依赖该插件的授权配置和工作流在重新安装前将无法正常工作。" + }, + "filter": { + "all": "全部", + "installed": "已安装", + "not_installed": "未安装" + }, + "job": { + "installing": "安装中…", + "queued": "排队中…", + "downloading": "下载中…", + "verifying": "校验中…", + "extracting": "解压中…", + "reloading": "重新加载中…", + "failed": "安装失败" }, "label": { "update_available": "有新版本", diff --git a/ui/src/pages/settings/SettingsPlugins.tsx b/ui/src/pages/settings/SettingsPlugins.tsx index 42dc9e6fd..2a946879f 100644 --- a/ui/src/pages/settings/SettingsPlugins.tsx +++ b/ui/src/pages/settings/SettingsPlugins.tsx @@ -1,11 +1,22 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconDownload, IconPuzzle, IconTrash, IconRefresh } from "@tabler/icons-react"; -import { Alert, Button, Card, Empty, Skeleton, Tag, App } from "antd"; +import { Alert, App, Button, Card, Empty, Input, Progress, Segmented, Skeleton, Tag, Tooltip } from "antd"; -import { fetchMarketListing, installPlugin, deletePlugin, updatePlugin, type MarketEntry } from "@/api/pluginmarket"; +import { + fetchMarketListing, + installPlugin, + deletePlugin, + updatePlugin, + getInstallStatus, + isTerminalState, + type InstallJobStatus, + type MarketEntry, +} from "@/api/pluginmarket"; import { usePluginCatalogStore } from "@/stores/pluginCatalog"; +const POLL_MS = 1000; + const statusLabel: Record = { not_installed: "plugin.market.status.not_installed", installed: "plugin.market.status.installed", @@ -14,50 +25,198 @@ const statusLabel: Record = { unsupported_platform: "plugin.market.status.unsupported_platform", }; +const statusColor: Record = { + not_installed: "bg-gray-400", + installed: "bg-green-500", + update_available: "bg-blue-500", + installed_manual: "bg-gray-400", + unsupported_platform: "bg-gray-400", +}; + +const marketIconSrc = (entry: MarketEntry): string => { + if (!entry.icon || !entry.release) { + return ""; + } + return `https://cdn.jsdelivr.net/gh/${entry.release.repo}@main/${entry.provider_type}/${entry.icon}`; +}; + +const statusToSegment = (status: string): "installed" | "not_installed" => { + if (status === "installed" || status === "update_available" || status === "installed_manual") { + return "installed"; + } + return "not_installed"; +}; + +const jobStageLabelKey = (state: InstallJobStatus["state"]): string => { + switch (state) { + case "queued": + return "plugin.market.job.queued"; + case "downloading": + return "plugin.market.job.downloading"; + case "verifying": + return "plugin.market.job.verifying"; + case "extracting": + return "plugin.market.job.extracting"; + case "reloading": + return "plugin.market.job.reloading"; + default: + return "plugin.market.job.installing"; + } +}; + +const formatBytes = (n: number): string => { + if (!n || n <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let value = n; + let i = 0; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + const digits = value >= 100 || i === 0 ? 0 : 1; + return `${value.toFixed(digits)} ${units[i]}`; +}; + const SettingsPlugins = () => { const { t } = useTranslation(); - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const catalogReload = usePluginCatalogStore((s) => s.reload); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [operating, setOperating] = useState>({}); + const [keyword, setKeyword] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | "installed" | "not_installed">("all"); + const [jobs, setJobs] = useState>({}); + const [speeds, setSpeeds] = useState>({}); + const pollingRef = useRef>(new Set()); + const lastSampleRef = useRef>({}); - const loadListing = useCallback(async () => { + const loadListing = useCallback(async (): Promise => { setLoading(true); setError(null); try { const data = await fetchMarketListing(); setEntries(data); + return data; } catch { setError(t("plugin.market.error.load_failed")); + return []; } finally { setLoading(false); } }, [t]); - useEffect(() => { - loadListing(); - }, [loadListing]); - const setOp = (pt: string, v: boolean) => { setOperating((prev) => ({ ...prev, [pt]: v })); }; + const removeJob = (pt: string) => { + setJobs((prev) => { + if (!prev[pt]) return prev; + const next = { ...prev }; + delete next[pt]; + return next; + }); + setSpeeds((prev) => { + if (!prev[pt]) return prev; + const next = { ...prev }; + delete next[pt]; + return next; + }); + delete lastSampleRef.current[pt]; + }; + + const recordSample = (pt: string, status: InstallJobStatus) => { + const prev = lastSampleRef.current[pt]; + const now = Date.now(); + if (status.state === "downloading" && prev && now > prev.t) { + const dt = (now - prev.t) / 1000; + const dd = (status.downloaded ?? 0) - prev.bytes; + if (dt > 0 && dd >= 0) { + setSpeeds((prev) => ({ ...prev, [pt]: dd / dt })); + } + } + lastSampleRef.current[pt] = { bytes: status.downloaded ?? 0, t: now }; + }; + + useEffect(() => { + let cancelled = false; + (async () => { + const data = await loadListing(); + if (cancelled) return; + for (const entry of data) { + if (entry.status !== "not_installed") continue; + getInstallStatus(entry.provider_type) + .then((st) => { + if (cancelled) return; + if (isTerminalState(st.state)) { + if (st.state === "failed") { + setJobs((prev) => ({ ...prev, [entry.provider_type]: st })); + } + return; + } + setJobs((prev) => ({ ...prev, [entry.provider_type]: st })); + pollingRef.current.add(entry.provider_type); + }) + .catch(() => {}); + } + })(); + return () => { + cancelled = true; + pollingRef.current.clear(); + }; + }, [loadListing]); + + useEffect(() => { + const id = window.setInterval(() => { + const active = Array.from(pollingRef.current); + if (active.length === 0) return; + Promise.all( + active.map(async (pt) => { + try { + const st = await getInstallStatus(pt); + setJobs((prev) => ({ ...prev, [pt]: st })); + recordSample(pt, st); + if (st.state === "installed") { + pollingRef.current.delete(pt); + removeJob(pt); + message.success(t("plugin.market.msg.installed")); + await catalogReload(); + await loadListing(); + } else if (st.state === "failed") { + pollingRef.current.delete(pt); + setSpeeds((prev) => { + if (!prev[pt]) return prev; + const next = { ...prev }; + delete next[pt]; + return next; + }); + delete lastSampleRef.current[pt]; + } + } catch { + pollingRef.current.delete(pt); + } + }) + ); + }, POLL_MS); + return () => clearInterval(id); + }, [t, message, catalogReload, loadListing]); + const handleInstall = async (entry: MarketEntry) => { const pt = entry.provider_type; - setOp(pt, true); try { - await installPlugin(pt); - message.success(t("plugin.market.msg.installed")); - await catalogReload(); - await loadListing(); - } catch (err: unknown) { + const initial = await installPlugin(pt); + setJobs((prev) => ({ ...prev, [pt]: initial })); + pollingRef.current.add(pt); + } catch (err) { + if (err && typeof err === "object" && "status" in err && err.status === 409) { + pollingRef.current.add(pt); + return; + } const msg = err instanceof Error ? err.message : t("plugin.market.error.install_failed"); message.error(msg); - } finally { - setOp(pt, false); } }; @@ -67,9 +226,10 @@ const SettingsPlugins = () => { try { await deletePlugin(pt); message.success(t("plugin.market.msg.deleted")); + removeJob(pt); await catalogReload(); await loadListing(); - } catch (err: unknown) { + } catch (err) { const msg = err instanceof Error ? err.message : t("plugin.market.error.delete_failed"); message.error(msg); } finally { @@ -77,6 +237,23 @@ const SettingsPlugins = () => { } }; + const confirmDelete = (entry: MarketEntry) => { + const pt = entry.provider_type; + const name = entry.display_name_key ? t(entry.display_name_key, pt) : pt; + modal.confirm({ + title: {t("plugin.market.action.delete_modal_title")}, + content: , + icon: ( + + + + ), + okText: t("common.button.confirm"), + okButtonProps: { danger: true }, + onOk: () => handleDelete(entry), + }); + }; + const handleUpdate = async (entry: MarketEntry) => { const pt = entry.provider_type; setOp(pt, true); @@ -85,7 +262,7 @@ const SettingsPlugins = () => { message.success(t("plugin.market.msg.updated")); await catalogReload(); await loadListing(); - } catch (err: unknown) { + } catch (err) { const msg = err instanceof Error ? err.message : t("plugin.market.error.update_failed"); message.error(msg); } finally { @@ -95,43 +272,99 @@ const SettingsPlugins = () => { const renderActions = (entry: MarketEntry) => { const pt = entry.provider_type; - const busy = operating[pt]; + const job = jobs[pt]; + const busy = !!operating[pt]; + const installing = !!job && !isTerminalState(job.state); + const failed = job?.state === "failed"; + + if (installing) { + const total = job?.total ?? 0; + const downloaded = job?.downloaded ?? 0; + const detailParts: string[] = [t(jobStageLabelKey(job!.state))]; + if (total > 0) { + const pct = Math.min(100, Math.round((downloaded / total) * 100)); + detailParts.push(`${formatBytes(downloaded)} / ${formatBytes(total)} (${pct}%)`); + } else if (downloaded > 0) { + detailParts.push(formatBytes(downloaded)); + } + const sp = speeds[pt]; + if (sp && sp > 0) { + detailParts.push(`${formatBytes(sp)}/s`); + } + const detail = detailParts.join(" · "); + + if (job!.state === "downloading" && total > 0) { + const pct = Math.min(100, Math.max(0, Math.round((downloaded / total) * 100))); + return ( + + + + ); + } + return ( + + + + {job!.state === "downloading" ? formatBytes(downloaded) : t(jobStageLabelKey(job!.state))} + + + ); + } switch (entry.status) { case "not_installed": return ( - ); case "installed": return ( - ); case "update_available": return ( -
- {t("plugin.market.label.update_available")} - -
- ); - case "installed_manual": - return ( - {t("plugin.market.label.manual")} - ); - case "unsupported_platform": - return ( - {t("plugin.market.label.unsupported_platform")} + ); default: return null; } }; + const renderStatus = (entry: MarketEntry) => { + const job = jobs[entry.provider_type]; + const installing = !!job && !isTerminalState(job.state); + const failed = job?.state === "failed"; + + if (installing) { + const total = job?.total ?? 0; + const downloaded = job?.downloaded ?? 0; + if (job!.state === "downloading" && total > 0) { + const pct = Math.min(100, Math.round((downloaded / total) * 100)); + return {t("plugin.market.job.downloading")} · {pct}%; + } + return ( + + + {t(jobStageLabelKey(job!.state))} + + ); + } + if (failed) { + return {t("plugin.market.job.failed")}; + } + return ( + + + {t(statusLabel[entry.status], entry.status)} + + ); + }; + if (loading) { return (
@@ -156,50 +389,81 @@ const SettingsPlugins = () => { ); } - if (entries.length === 0) { - return ; - } + const filtered = entries.filter((entry) => { + if (statusFilter !== "all" && statusToSegment(entry.status) !== statusFilter) { + return false; + } + const kw = keyword.trim().toLowerCase(); + if (kw) { + const name = (entry.display_name_key ? t(entry.display_name_key, entry.provider_type) : entry.provider_type).toLowerCase(); + const desc = (entry.description ?? "").toLowerCase(); + if (!name.includes(kw) && !entry.provider_type.toLowerCase().includes(kw) && !desc.includes(kw)) { + return false; + } + } + return true; + }); return (
-
-
-
- {entries.map((entry) => ( - - {entry.provider_type} + {filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((entry) => ( + +
+
+
+ {marketIconSrc(entry) ? ( + {entry.provider_type} + ) : ( + + )} +
+
+
+ {entry.display_name_key ? t(entry.display_name_key, entry.provider_type) : entry.provider_type} +
+
{entry.deploy_category}
+
+
{renderActions(entry)}
- ) : ( -
- +
{entry.description}
+
+ {renderStatus(entry)} + v{entry.version}
- ) - } - > -
- -
- v{entry.version} - {t(statusLabel[entry.status], entry.status)}
-
{renderActions(entry)}
-
- - ))} -
+ + ))} +
+ )}
); }; From f8ef9cd89c6972e90769a81137d92e77120afacf Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Mon, 3 Aug 2026 18:06:15 +0800 Subject: [PATCH 16/19] feat: refactor market --- internal/pluginhost/market.go | 93 +++++++++++++++++++++++++----- internal/pluginhost/market_test.go | 79 +++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 13 deletions(-) diff --git a/internal/pluginhost/market.go b/internal/pluginhost/market.go index f19df2c94..595b092a1 100644 --- a/internal/pluginhost/market.go +++ b/internal/pluginhost/market.go @@ -17,16 +17,20 @@ import ( "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 ( @@ -52,6 +56,7 @@ type MarketService struct { mu sync.RWMutex httpClient *http.Client downloadClient *http.Client + stallTimeout time.Duration logger *slog.Logger jobs *jobStore ops *pluginOps @@ -63,6 +68,7 @@ type MarketConfig struct { DownloadMirror string PluginDir string CacheTTL time.Duration + StallTimeout time.Duration Logger *slog.Logger } @@ -79,6 +85,11 @@ func NewMarketService(cfg MarketConfig) *MarketService { 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, @@ -86,7 +97,8 @@ func NewMarketService(cfg MarketConfig) *MarketService { pluginDir: cfg.PluginDir, cacheTTL: cfg.CacheTTL, httpClient: &http.Client{Timeout: 30 * time.Second}, - downloadClient: &http.Client{Timeout: 5 * time.Minute}, + downloadClient: &http.Client{Transport: dlTransport}, + stallTimeout: cfg.StallTimeout, logger: cfg.Logger.With(slog.String("component", "market")), jobs: newJobStore(), ops: newPluginOps(), @@ -465,7 +477,10 @@ func (s *MarketService) installPipeline(ctx context.Context, providerType string const maxDownloadRetries = 3 -var errDownloadNotRetryable = errors.New("market: download failed (non-retryable)") +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 @@ -488,6 +503,9 @@ func (s *MarketService) fetchZip(ctx context.Context, url, destPath string, onPr } 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) @@ -541,13 +559,22 @@ func (s *MarketService) downloadAttempt(ctx context.Context, url, destPath strin 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, + 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 } @@ -566,11 +593,47 @@ func (s *MarketService) downloadAttempt(ctx context.Context, url, destPath strin 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) || errors.Is(err, context.DeadlineExceeded) { + if errors.Is(err, context.Canceled) { return false } return true @@ -597,18 +660,22 @@ func parseFullSize(contentRange string) int64 { } type progressReader struct { - r io.Reader - total int64 - base int64 - read int64 - last int64 - on func(downloaded, total int64) + 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 diff --git a/internal/pluginhost/market_test.go b/internal/pluginhost/market_test.go index 37756445e..dd18868f6 100644 --- a/internal/pluginhost/market_test.go +++ b/internal/pluginhost/market_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -657,6 +658,84 @@ func TestFetchZip_ResumesAfterTruncation(t *testing.T) { } } +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) From 696e73a6bb00a813f8abca26383c7c6067beefb8 Mon Sep 17 00:00:00 2001 From: "Yoan.Liu" Date: Fri, 7 Aug 2026 14:20:38 +0800 Subject: [PATCH 17/19] feat: acme accounts --- internal/acmeaccount/service.go | 119 ++++++ internal/acmeaccount/service_test.go | 160 +++++++++ internal/certacme/account_import.go | 173 +++++++++ internal/certacme/account_import_test.go | 48 +++ internal/certacme/account_rotate.go | 91 +++++ internal/domain/dtos/acme_account.go | 45 +++ internal/repository/acme_account.go | 61 ++++ internal/rest/handlers/acme_accounts.go | 80 +++++ internal/rest/routes/routes.go | 3 + ui/src/api/acmeAccounts.ts | 59 +++ ui/src/domain/acmeAccount.ts | 16 + ui/src/i18n/resources/en/nls.settings.json | 56 +++ ui/src/i18n/resources/zh/nls.settings.json | 56 +++ ui/src/pages/settings/Settings.tsx | 3 +- .../pages/settings/SettingsAcmeAccounts.tsx | 340 ++++++++++++++++++ ui/src/routers/index.tsx | 5 + 16 files changed, 1314 insertions(+), 1 deletion(-) create mode 100644 internal/acmeaccount/service.go create mode 100644 internal/acmeaccount/service_test.go create mode 100644 internal/certacme/account_import.go create mode 100644 internal/certacme/account_import_test.go create mode 100644 internal/certacme/account_rotate.go create mode 100644 internal/domain/dtos/acme_account.go create mode 100644 internal/rest/handlers/acme_accounts.go create mode 100644 ui/src/api/acmeAccounts.ts create mode 100644 ui/src/domain/acmeAccount.ts create mode 100644 ui/src/pages/settings/SettingsAcmeAccounts.tsx diff --git a/internal/acmeaccount/service.go b/internal/acmeaccount/service.go new file mode 100644 index 000000000..c9da9d2d1 --- /dev/null +++ b/internal/acmeaccount/service.go @@ -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, + } +} diff --git a/internal/acmeaccount/service_test.go b/internal/acmeaccount/service_test.go new file mode 100644 index 000000000..b19b969cf --- /dev/null +++ b/internal/acmeaccount/service_test.go @@ -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{} +} diff --git a/internal/certacme/account_import.go b/internal/certacme/account_import.go new file mode 100644 index 000000000..2af1a2fb3 --- /dev/null +++ b/internal/certacme/account_import.go @@ -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 +} diff --git a/internal/certacme/account_import_test.go b/internal/certacme/account_import_test.go new file mode 100644 index 000000000..a08a34e1f --- /dev/null +++ b/internal/certacme/account_import_test.go @@ -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") + } +} diff --git a/internal/certacme/account_rotate.go b/internal/certacme/account_rotate.go new file mode 100644 index 000000000..bdb7115d7 --- /dev/null +++ b/internal/certacme/account_rotate.go @@ -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 +} diff --git a/internal/domain/dtos/acme_account.go b/internal/domain/dtos/acme_account.go new file mode 100644 index 000000000..40c0ec77e --- /dev/null +++ b/internal/domain/dtos/acme_account.go @@ -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"` +} diff --git a/internal/repository/acme_account.go b/internal/repository/acme_account.go index 7c9cfe6b2..8d73d9941 100644 --- a/internal/repository/acme_account.go +++ b/internal/repository/acme_account.go @@ -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 { diff --git a/internal/rest/handlers/acme_accounts.go b/internal/rest/handlers/acme_accounts.go new file mode 100644 index 000000000..c1a5dda10 --- /dev/null +++ b/internal/rest/handlers/acme_accounts.go @@ -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) +} diff --git a/internal/rest/routes/routes.go b/internal/rest/routes/routes.go index e2eaecf71..22e369ae5 100644 --- a/internal/rest/routes/routes.go +++ b/internal/rest/routes/routes.go @@ -5,6 +5,7 @@ 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" @@ -37,6 +38,7 @@ func BindRouter(router *router.Router[*core.RequestEvent]) { statisticsSvc = statistics.NewStatisticsService(statisticsRepo) notifySvc = notify.NewNotifyService(accessRepo) providerSchemaSvc = providerschema.NewProviderSchemaService(providerSchemaRepo) + acmeAccountSvc := acmeaccount.NewService(acmeAccountRepo) group := router.Group("/api") group.Bind(apis.RequireSuperuserAuth()) @@ -48,4 +50,5 @@ func BindRouter(router *router.Router[*core.RequestEvent]) { handlers.NewPluginCatalogHandler(group, pluginhost.GlobalCatalog()) handlers.NewPluginAdminHandler(group) handlers.NewPluginMarketHandler(group, pluginhost.GlobalMarketService()) + handlers.NewACMEAccountsHandler(group, acmeAccountSvc) } diff --git a/ui/src/api/acmeAccounts.ts b/ui/src/api/acmeAccounts.ts new file mode 100644 index 000000000..f4e5a1609 --- /dev/null +++ b/ui/src/api/acmeAccounts.ts @@ -0,0 +1,59 @@ +import { type ACMEAccountImportPayload, type ACMEAccountView } from "@/domain/acmeAccount"; + +import { get as httpGet, post as httpPost } from "./_api"; + +export type ListACMEAccountsParams = { + page?: number; + perPage?: number; + ca?: string; +}; + +export const list = (params?: ListACMEAccountsParams) => { + type RespData = { + items: ACMEAccountView[]; + totalItems: number; + page: number; + perPage: number; + }; + + const qs = new URLSearchParams(); + if (params?.page != null) qs.set("page", String(params.page)); + if (params?.perPage != null) qs.set("perPage", String(params.perPage)); + if (params?.ca) qs.set("ca", params.ca); + const query = qs.toString(); + + return httpGet({ + url: query ? `/api/acme-accounts?${query}` : "/api/acme-accounts", + }); +}; + +export const importAccount = (payload: ACMEAccountImportPayload) => { + type RespData = { + item: ACMEAccountView; + }; + + return httpPost({ + url: "/api/acme-accounts/import", + body: payload, + }); +}; + +export const exportAccount = (accountId: string) => { + type RespData = { + privateKeyPem: string; + }; + + return httpPost({ + url: `/api/acme-accounts/${encodeURIComponent(accountId)}/export`, + }); +}; + +export const rotateAccount = (accountId: string) => { + type RespData = { + item: ACMEAccountView; + }; + + return httpPost({ + url: `/api/acme-accounts/${encodeURIComponent(accountId)}/rotate`, + }); +}; diff --git a/ui/src/domain/acmeAccount.ts b/ui/src/domain/acmeAccount.ts new file mode 100644 index 000000000..e44f2563f --- /dev/null +++ b/ui/src/domain/acmeAccount.ts @@ -0,0 +1,16 @@ +export interface ACMEAccountView { + id: string; + ca: string; + email: string; + acmeDirUrl: string; + acmeAcctUrl: string; + created: string; + updated: string; +} + +export interface ACMEAccountImportPayload { + privateKeyPem: string; + ca: string; + acmeDirUrl?: string; + email?: string; +} diff --git a/ui/src/i18n/resources/en/nls.settings.json b/ui/src/i18n/resources/en/nls.settings.json index e584ca784..74259314f 100644 --- a/ui/src/i18n/resources/en/nls.settings.json +++ b/ui/src/i18n/resources/en/nls.settings.json @@ -100,6 +100,62 @@ "tab": "Plugins" }, + "acmeaccounts": { + "tab": "ACME accounts", + "title": "ACME account credentials", + "tips": "Manage ACME account private keys used for certificate issuance. Export enables interoperability with other ACME clients; rotate replaces the key at the CA (old key becomes invalid immediately).", + "empty": "No ACME accounts stored yet. Accounts are created automatically when certificates are issued, or you can import an existing private key.", + "filter": { + "ca_placeholder": "Filter by CA" + }, + "table": { + "ca": "Certificate authority", + "email": "Email", + "account_uri": "Account URI", + "directory": "Directory URL", + "created": "Created", + "actions": "Actions" + }, + "action": { + "import": "Import", + "export": "Export", + "rotate": "Rotate key" + }, + "import": { + "modal": { + "title": "Import ACME account" + }, + "form": { + "ca": "Certificate authority", + "ca_placeholder": "Select CA", + "directory": "ACME directory URL", + "directory_placeholder": "https://example.com/acme/directory", + "private_key": "Account private key (PEM)", + "private_key_placeholder": "-----BEGIN ... PRIVATE KEY-----", + "private_key_extra": "The Account URI is resolved automatically via the ACME server (existing account only).", + "email": "Email", + "email_placeholder": "Optional if the CA returns a contact email", + "email_extra": "Required when the CA account has no mailto contact." + } + }, + "export": { + "confirm": { + "title": "Export private key?", + "content": "The ACME account private key controls certificate issuance and revocation for this account. Only export it to a trusted environment." + }, + "modal": { + "title": "Account private key", + "tips": "Store this key securely. Anyone with this key can act as this ACME account." + } + }, + "rotate": { + "confirm": { + "title": "Rotate account key?", + "content": "The CA will invalidate the current private key immediately. Ensure no other ACME clients still use the old key. The Account URI stays the same." + } + } + }, + "sslprovider": { "tab": "Certificate authority", "ca": { diff --git a/ui/src/i18n/resources/zh/nls.settings.json b/ui/src/i18n/resources/zh/nls.settings.json index 6e5355cb9..eafa2396f 100644 --- a/ui/src/i18n/resources/zh/nls.settings.json +++ b/ui/src/i18n/resources/zh/nls.settings.json @@ -100,6 +100,62 @@ "tab": "插件" }, + "acmeaccounts": { + "tab": "ACME 账户", + "title": "ACME 账户凭据", + "tips": "管理用于签发证书的 ACME 账户私钥。导出可与其他 ACME 客户端互通;轮转会在 CA 侧更换密钥(旧密钥立即失效)。", + "empty": "尚无已存储的 ACME 账户。签发证书时会自动创建账户,也可导入已有私钥。", + "filter": { + "ca_placeholder": "按 CA 筛选" + }, + "table": { + "ca": "证书颁发机构", + "email": "邮箱", + "account_uri": "Account URI", + "directory": "Directory URL", + "created": "创建时间", + "actions": "操作" + }, + "action": { + "import": "导入", + "export": "导出", + "rotate": "轮转密钥" + }, + "import": { + "modal": { + "title": "导入 ACME 账户" + }, + "form": { + "ca": "证书颁发机构", + "ca_placeholder": "请选择 CA", + "directory": "ACME Directory URL", + "directory_placeholder": "https://example.com/acme/directory", + "private_key": "账户私钥(PEM)", + "private_key_placeholder": "-----BEGIN ... PRIVATE KEY-----", + "private_key_extra": "将通过 ACME 服务器自动探测 Account URI(仅恢复已存在账户,不会新建)。", + "email": "邮箱", + "email_placeholder": "若 CA 返回联系邮箱可留空", + "email_extra": "当 CA 账户没有 mailto 联系方式时必填。" + } + }, + "export": { + "confirm": { + "title": "导出私钥?", + "content": "ACME 账户私钥可用于签发与吊销证书。请仅导出到可信环境。" + }, + "modal": { + "title": "账户私钥", + "tips": "请妥善保管。持有该私钥即可冒充此 ACME 账户。" + } + }, + "rotate": { + "confirm": { + "title": "轮转账户密钥?", + "content": "CA 将立即作废当前私钥。请确认没有其他 ACME 客户端仍在使用旧密钥。Account URI 保持不变。" + } + } + }, + "sslprovider": { "tab": "证书颁发机构", "ca": { diff --git a/ui/src/pages/settings/Settings.tsx b/ui/src/pages/settings/Settings.tsx index b36747c12..d8f9239f2 100644 --- a/ui/src/pages/settings/Settings.tsx +++ b/ui/src/pages/settings/Settings.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; -import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconPalette, IconPlugConnected, IconPuzzle, IconUserShield } from "@tabler/icons-react"; +import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconKey, IconPalette, IconPlugConnected, IconPuzzle, IconUserShield } from "@tabler/icons-react"; import { Menu } from "antd"; const Settings = () => { @@ -14,6 +14,7 @@ const Settings = () => { ["account", "settings.account.tab", ], ["appearance", "settings.appearance.tab", ], ["ssl-provider", "settings.sslprovider.tab", ], + ["acme-accounts", "settings.acmeaccounts.tab", ], ["plugins", "settings.plugins.tab", ], ["persistence", "settings.persistence.tab", ], ["diagnostics", "settings.diagnostics.tab", ], diff --git a/ui/src/pages/settings/SettingsAcmeAccounts.tsx b/ui/src/pages/settings/SettingsAcmeAccounts.tsx new file mode 100644 index 000000000..da5114880 --- /dev/null +++ b/ui/src/pages/settings/SettingsAcmeAccounts.tsx @@ -0,0 +1,340 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconDownload, IconPlus, IconRefresh } from "@tabler/icons-react"; +import { useRequest } from "ahooks"; +import { App, Button, Empty, Form, Input, Modal, Select, Space, Table, Typography, type TableProps } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import dayjs from "dayjs"; +import { saveAs } from "file-saver"; +import { z } from "zod"; + +import { exportAccount, importAccount, list as listAcmeAccounts, rotateAccount } from "@/api/acmeAccounts"; +import Show from "@/components/Show"; +import { type ACMEAccountView } from "@/domain/acmeAccount"; +import { CA_PROVIDERS, caProvidersMap } from "@/domain/provider"; +import { useAntdForm, useAppSettings } from "@/hooks"; +import { unwrapErrMsg } from "@/utils/error"; + +const SettingsAcmeAccounts = () => { + const { t } = useTranslation(); + const { message, modal, notification } = App.useApp(); + const { appSettings: globalAppSettings } = useAppSettings(); + + const [caFilter, setCaFilter] = useState(); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(() => globalAppSettings.defaultPerPage ?? 15); + + const { + data: listData, + loading, + refresh, + } = useRequest( + async () => { + const res = await listAcmeAccounts({ + page, + perPage: pageSize, + ca: caFilter || undefined, + }); + return { + items: res.data?.items ?? [], + totalItems: res.data?.totalItems ?? 0, + }; + }, + { + refreshDeps: [page, pageSize, caFilter], + onError: (err) => { + notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) }); + }, + } + ); + + const tableData = listData?.items ?? []; + const tableTotal = listData?.totalItems ?? 0; + + const [importOpen, setImportOpen] = useState(false); + const [exportPem, setExportPem] = useState<{ id: string; pem: string } | null>(null); + + const caOptions = useMemo(() => { + return Array.from(caProvidersMap.values()).map((p) => ({ + value: p.type, + label: t(p.name), + })); + }, [t]); + + const importSchema = z + .object({ + privateKeyPem: z.string().min(1, t("common.errmsg.form_invalid")), + ca: z.string().min(1, t("common.errmsg.form_invalid")), + acmeDirUrl: z.string().optional(), + email: z.union([z.string().email(), z.literal("")]).optional(), + }) + .superRefine((val, ctx) => { + if (val.ca === CA_PROVIDERS.ACMECA && !val.acmeDirUrl?.trim()) { + ctx.addIssue({ code: "custom", message: t("common.errmsg.form_invalid"), path: ["acmeDirUrl"] }); + } + }); + const importRule = createSchemaFieldRule(importSchema); + const { + form: importForm, + formPending: importPending, + formProps: importFormProps, + } = useAntdForm>({ + onSubmit: async (values) => { + try { + await importAccount({ + privateKeyPem: values.privateKeyPem, + ca: values.ca, + acmeDirUrl: values.acmeDirUrl || undefined, + email: values.email || undefined, + }); + message.success(t("common.text.operation_succeeded")); + setImportOpen(false); + importForm.resetFields(); + setPage(1); + refresh(); + } catch (err) { + notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) }); + } + }, + }); + + const caWatch = Form.useWatch("ca", importForm); + + const handleExport = (record: ACMEAccountView) => { + modal.confirm({ + title: t("settings.acmeaccounts.export.confirm.title"), + content: t("settings.acmeaccounts.export.confirm.content"), + okText: t("common.button.confirm"), + cancelText: t("common.button.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + const res = await exportAccount(record.id); + setExportPem({ id: record.id, pem: res.data.privateKeyPem }); + } catch (err) { + notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) }); + } + }, + }); + }; + + const handleRotate = (record: ACMEAccountView) => { + modal.confirm({ + title: t("settings.acmeaccounts.rotate.confirm.title"), + content: t("settings.acmeaccounts.rotate.confirm.content"), + okText: t("common.button.confirm"), + cancelText: t("common.button.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await rotateAccount(record.id); + message.success(t("common.text.operation_succeeded")); + refresh(); + } catch (err) { + notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) }); + } + }, + }); + }; + + const handlePaginationChange = (nextPage: number, nextPageSize: number) => { + setPage(nextPage); + setPageSize(nextPageSize); + }; + + const handleCaFilterChange = (value: string | undefined) => { + setCaFilter(value); + setPage(1); + }; + + const providerLabel = (ca: string) => { + const p = caProvidersMap.get(ca); + return p ? t(p.name) : ca; + }; + + const columns: TableProps["columns"] = [ + { + key: "ca", + title: t("settings.acmeaccounts.table.ca"), + dataIndex: "ca", + width: 180, + ellipsis: true, + render: (v: string) => providerLabel(v), + }, + { + key: "email", + title: t("settings.acmeaccounts.table.email"), + dataIndex: "email", + ellipsis: true, + }, + { + key: "acmeAcctUrl", + title: t("settings.acmeaccounts.table.account_uri"), + dataIndex: "acmeAcctUrl", + ellipsis: true, + render: (v: string) => ( + + {v} + + ), + }, + { + key: "acmeDirUrl", + title: t("settings.acmeaccounts.table.directory"), + dataIndex: "acmeDirUrl", + ellipsis: true, + responsive: ["lg"], + }, + { + key: "created", + title: t("settings.acmeaccounts.table.created"), + dataIndex: "created", + width: 160, + responsive: ["xl"], + render: (v: string) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm") : "-"), + }, + { + key: "$action", + title: t("settings.acmeaccounts.table.actions"), + width: 200, + align: "end", + fixed: "right", + render: (_, record) => ( + + + + + ), + }, + ]; + + return ( +
+
+
+

{t("settings.acmeaccounts.title")}

+ + {t("settings.acmeaccounts.tips")} + +
+ + + + + + + + + + + + + + + + + + setExportPem(null)} + footer={[ + , + , + ]} + width={640} + > + {t("settings.acmeaccounts.export.modal.tips")} + + +
+ ); +}; + +export default SettingsAcmeAccounts; diff --git a/ui/src/routers/index.tsx b/ui/src/routers/index.tsx index 061132c81..4842ece2b 100644 --- a/ui/src/routers/index.tsx +++ b/ui/src/routers/index.tsx @@ -12,6 +12,7 @@ import PresetList from "@/pages/presets/PresetList"; import Settings from "@/pages/settings/Settings"; import SettingsAbout from "@/pages/settings/SettingsAbout"; import SettingsAccount from "@/pages/settings/SettingsAccount"; +import SettingsAcmeAccounts from "@/pages/settings/SettingsAcmeAccounts"; import SettingsAppearance from "@/pages/settings/SettingsAppearance"; import SettingsDiagnostics from "@/pages/settings/SettingsDiagnostics"; import SettingsPlugins from "@/pages/settings/SettingsPlugins"; @@ -86,6 +87,10 @@ export const router = createHashRouter([ path: "/settings/ssl-provider", element: , }, + { + path: "/settings/acme-accounts", + element: , + }, { path: "/settings/persistence", element: , From c0820669d4bf0c3131837779e13c7ca9a042b872 Mon Sep 17 00:00:00 2001 From: "Yoan.Liu" Date: Thu, 27 Aug 2026 20:18:31 +0800 Subject: [PATCH 18/19] feat: form input trim --- ui/src/components/MultipleSplitValueInput.tsx | 6 +- ui/src/components/access/AccessEditDrawer.tsx | 2 + .../certificate/CertificateDownloadModal.tsx | 6 +- .../workflow/WorkflowGraphImportInputBox.tsx | 2 + .../BranchBlockNodeConfigExprInputBox.tsx | 2 + .../workflow/designer/forms/_shared.tsx | 2 + ui/src/hooks/useAntdForm.ts | 28 ++++-- ui/src/pages/accesses/AccessNew.tsx | 2 + ui/src/utils/form.ts | 91 +++++++++++++++++++ 9 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 ui/src/utils/form.ts diff --git a/ui/src/components/MultipleSplitValueInput.tsx b/ui/src/components/MultipleSplitValueInput.tsx index 8055cf08a..af07edfcd 100644 --- a/ui/src/components/MultipleSplitValueInput.tsx +++ b/ui/src/components/MultipleSplitValueInput.tsx @@ -56,12 +56,12 @@ const MultipleSplitValueInput = ({ name: "componentMultipleSplitValueInput_" + nanoid(), initialValues: { value: value?.split(delimiter) }, onSubmit: (values) => { - const temp = (values.value ?? []) as string[]; + let temp = (values.value ?? []) as string[]; if (splitOptions.trimSpace) { - temp.map((e) => e.trim()); + temp = temp.map((e) => e.trim()); } if (splitOptions.removeEmpty) { - temp.filter((e) => !!e); + temp = temp.filter((e) => !!e); } setValue(temp.join(delimiter)); diff --git a/ui/src/components/access/AccessEditDrawer.tsx b/ui/src/components/access/AccessEditDrawer.tsx index 88ebdfc3c..ed95716a6 100644 --- a/ui/src/components/access/AccessEditDrawer.tsx +++ b/ui/src/components/access/AccessEditDrawer.tsx @@ -12,6 +12,7 @@ import { ACCESS_USAGES } from "@/domain/provider"; import { useTriggerElement, useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; import { unwrapErrMsg } from "@/utils/error"; +import { applyTrimmedFormValues } from "@/utils/form"; import AccessForm, { type AccessFormModes, type AccessFormProps, type AccessFormUsages } from "./AccessForm"; @@ -60,6 +61,7 @@ const AccessEditDrawer = ({ afterSubmit, mode, data, loading, trigger, usage, .. setFormPending(true); try { + applyTrimmedFormValues(formInst); formValues = await formInst.validateFields(); formValues.reserve = usage === "ca" ? "ca" : usage === "notification" ? "notif" : void 0; } catch (err) { diff --git a/ui/src/components/certificate/CertificateDownloadModal.tsx b/ui/src/components/certificate/CertificateDownloadModal.tsx index fc479969d..39c533727 100644 --- a/ui/src/components/certificate/CertificateDownloadModal.tsx +++ b/ui/src/components/certificate/CertificateDownloadModal.tsx @@ -8,6 +8,7 @@ import { saveAs } from "file-saver"; import { download as downloadCertificate } from "@/api/certificates"; import { CERTIFICATE_FORMATS, type CertificateFormatType, type CertificateModel } from "@/domain/certificate"; import { useAntdForm, useTriggerElement } from "@/hooks"; +import { applyTrimmedFormValues } from "@/utils/form"; export interface CertificateDownloadModalProps { className?: string; @@ -153,10 +154,11 @@ const CertificateDownloadModal = ({ afterClose, data, trigger, ...props }: Certi }; const handleDownloadClick = async (format: CertificateFormatType) => { - await formInst.validateFields(); + applyTrimmedFormValues(formInst); + const formValues = await formInst.validateFields(); try { - const res = await downloadCertificate(data.id, format, formInst.getFieldsValue()); + const res = await downloadCertificate(data.id, format, formValues); const bstr = atob(res.data.zipBytes); const u8arr = Uint8Array.from(bstr, (ch) => ch.charCodeAt(0)); const blob = new Blob([u8arr], { type: "application/zip" }); diff --git a/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx b/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx index 04c042a0f..8ca268be9 100644 --- a/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx +++ b/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx @@ -8,6 +8,7 @@ import { z } from "zod"; import CodeTextInput from "@/components/CodeTextInput"; import { WORKFLOW_NODE_TYPES, type WorkflowGraph, type WorkflowNode, type WorkflowNodeType } from "@/domain/workflow"; import { useAntdForm } from "@/hooks"; +import { applyTrimmedFormValues } from "@/utils/form"; export type WorkflowGraphImportInputBoxFormats = "json" | "yaml"; @@ -220,6 +221,7 @@ const WorkflowGraphImportInputBox = forwardRef { return { validate: async () => { + applyTrimmedFormValues(formInst); const formValues = await formInst.validateFields(); return deserialize(formValues.content, formValues.format); }, diff --git a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigExprInputBox.tsx b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigExprInputBox.tsx index 02d0dfebd..bb8ea5db7 100644 --- a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigExprInputBox.tsx +++ b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigExprInputBox.tsx @@ -15,6 +15,7 @@ import { type ExprValueType, } from "@/domain/workflow"; import { useAntdFormName } from "@/hooks"; +import { applyTrimmedFormValues } from "@/utils/form"; import { useNodeFormContext } from "./_context"; import { getAllPreviousNodes } from "../_util"; @@ -261,6 +262,7 @@ const BranchBlockNodeConfigExprInputBox = forwardRef { return { validate: async () => { + applyTrimmedFormValues(formInst); const formValues = await formInst.validateFields(); return formValuesToExpr(formValues); }, diff --git a/ui/src/components/workflow/designer/forms/_shared.tsx b/ui/src/components/workflow/designer/forms/_shared.tsx index 7a8fab018..3c493b970 100644 --- a/ui/src/components/workflow/designer/forms/_shared.tsx +++ b/ui/src/components/workflow/designer/forms/_shared.tsx @@ -8,6 +8,7 @@ import { isEqual } from "radash"; import Show from "@/components/Show"; import { unwrapErrMsg } from "@/utils/error"; +import { applyTrimmedFormValues } from "@/utils/form"; import { type NodeRegistry } from "../nodes/typings"; @@ -48,6 +49,7 @@ export const NodeConfigDrawer = ({ children, afterClose, anchor, footer = true, setFormPending(true); try { + applyTrimmedFormValues(formInst); formValues = await formInst.validateFields(); } catch (err) { message.warning(t("common.errmsg.form_invalid")); diff --git a/ui/src/hooks/useAntdForm.ts b/ui/src/hooks/useAntdForm.ts index f50abeba1..a9174871b 100644 --- a/ui/src/hooks/useAntdForm.ts +++ b/ui/src/hooks/useAntdForm.ts @@ -1,7 +1,9 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useDeepCompareEffect } from "ahooks"; import { Form, type FormInstance, type FormProps } from "antd"; +import { applyTrimmedFormValues, trimFormValues } from "@/utils/form"; + import useAntdFormName from "./useAntdFormName"; export interface UseAntdFormOptions = any> { @@ -64,11 +66,24 @@ const useAntdForm = = any>({ form, initialValues }; }, [formInst, initialValues]); - const onFinish = (values: T) => { + const wrappedFormInst = useMemo(() => { + const originalSubmit = formInst.submit; + return { + ...formInst, + submit: () => { + applyTrimmedFormValues(formInst); + return originalSubmit(); + }, + }; + }, [formInst]); + + const doFinish = () => { if (formPending) return Promise.reject(new Error("Form is pending")); setFormPending(true); + const values = trimFormValues(formInst.getFieldsValue(true)); + return new Promise((resolve, reject) => { formInst .validateFields() @@ -93,18 +108,19 @@ const useAntdForm = = any>({ form, initialValues }; const formProps: FormProps = { - form: formInst, + form: wrappedFormInst, initialValues: formInitialValues, name: formName, - onFinish, + onFinish: doFinish, }; return { - form: formInst, + form: wrappedFormInst, formProps: formProps, formPending: formPending, submit: () => { - return onFinish(formInst.getFieldsValue(true)); + applyTrimmedFormValues(formInst); + return doFinish(); }, }; }; diff --git a/ui/src/pages/accesses/AccessNew.tsx b/ui/src/pages/accesses/AccessNew.tsx index 5f41320e2..1cf98dde8 100644 --- a/ui/src/pages/accesses/AccessNew.tsx +++ b/ui/src/pages/accesses/AccessNew.tsx @@ -12,6 +12,7 @@ import { ACCESS_USAGES } from "@/domain/provider"; import { useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; import { unwrapErrMsg } from "@/utils/error"; +import { applyTrimmedFormValues } from "@/utils/form"; const AccessNew = () => { const navigate = useNavigate(); @@ -47,6 +48,7 @@ const AccessNew = () => { setFormPending(true); try { + applyTrimmedFormValues(formInst); formValues = await formInst.validateFields(); formValues.reserve = providerUsage === "ca" ? "ca" : providerUsage === "notification" ? "notif" : void 0; } catch (err) { diff --git a/ui/src/utils/form.ts b/ui/src/utils/form.ts new file mode 100644 index 000000000..723713edb --- /dev/null +++ b/ui/src/utils/form.ts @@ -0,0 +1,91 @@ +import type { FormInstance } from "antd"; + +export const TRIM_EXEMPT_FIELD_NAMES = new Set([ + "accesskey", + "accesskeyid", + "accesskeypassword", + "accesskeysecret", + "accesstoken", + "accesstokenid", + "apiaccesstoken", + "apikey", + "apikeysecret", + "apipassword", + "apisecret", + "apitoken", + "apitokenforzone", + "apitokensecret", + "appkey", + "applicationkey", + "applicationsecret", + "applicationtoken", + "authpassword", + "bottoken", + "clientsecret", + "clienttoken", + "confirmpassword", + "consumerkey", + "credentials", + "eabhmackey", + "httptoken", + "jkskeypass", + "jksstorepass", + "key", + "keypassphrase", + "mtlsprivatekey", + "newpassword", + "oldpassword", + "password", + "personalaccesstoken", + "pfxpassword", + "privatekey", + "privatekeypassphrase", + "secret", + "secretaccesskey", + "secretapikey", + "secretid", + "secretkey", + "serviceaccountkey", + "token", + "totpsecret", + "tsiggsspassword", + "tsigkey", + "tsigsecret", +]); + +const isPlainObject = (value: unknown): value is Record => { + if (typeof value !== "object" || value === null) { + return false; + } + + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +const trimValue = (value: unknown, key?: string): unknown => { + if (typeof value === "string") { + return key != null && TRIM_EXEMPT_FIELD_NAMES.has(key.toLowerCase()) ? value : value.trim(); + } + + if (Array.isArray(value)) { + return value.map((item) => trimValue(item)); + } + + if (isPlainObject(value)) { + const obj: Record = {}; + Object.keys(value).forEach((k) => { + obj[k] = trimValue(value[k], k); + }); + return obj; + } + + return value; +}; + +export const trimFormValues = (values: T): T => { + return trimValue(values) as T; +}; + +export const applyTrimmedFormValues = (formInst: FormInstance): void => { + formInst.setFieldsValue(trimFormValues(formInst.getFieldsValue(true))); +}; From 8fcbcd3f454d573f5504c8a3f9c2801343e9c2c7 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Fri, 28 Aug 2026 09:07:49 +0800 Subject: [PATCH 19/19] feat: Migration of Zenlayer and UpYun --- go.mod | 1 - go.sum | 2 - internal/certmgmt/deployers/sp_upyun_cdn.go | 27 -- internal/certmgmt/deployers/sp_upyun_file.go | 27 -- .../certmgmt/deployers/sp_zenlayer_cdn.go | 30 -- internal/certmgmt/deployers/sp_zenlayer_ga.go | 29 -- internal/domain/access.go | 11 - internal/domain/provider.go | 6 - .../dogecloud/dogecloud_test.go | 50 --- .../qiniu-sslcert/qiniu_sslcert_test.go | 50 --- .../upyun-ssl/upyun_ssl_test.go | 50 --- .../zenlayer-cdn/zenlayer_cdn_test.go | 50 --- .../zenlayer-ga/zenlayer_ga_test.go | 50 --- .../certmgr/providers/upyun-ssl/upyun_ssl.go | 88 ----- .../providers/zenlayer-cdn/zenlayer_cdn.go | 205 ----------- .../providers/zenlayer-ga/zenlayer_ga.go | 205 ----------- .../dogecloud-cdn/dogecloud_cdn_test.go | 54 --- .../k8s-secret/k8s_secret_test.go | 68 ---- .../qiniu-cdn/qiniu_cdn_test.go | 55 --- .../qiniu-kodo/qiniu_kodo_test.go | 58 --- .../qiniu-pili/qiniu_pili_test.go | 58 --- .../upyun-cdn/upyun_cdn_test.go | 54 --- .../upyun-file/upyun_file_test.go | 58 --- .../zenlayer-cdn/zenlayer_cdn_test.go | 71 ---- .../zenlayer-ga/zenlayer_ga_test.go | 70 ---- .../providers/k8s-secret/k8s_secret.go | 252 ------------- .../providers/k8s-secret/k8s_secret_test.go | 41 --- .../deployer/providers/upyun-cdn/consts.go | 10 - .../deployer/providers/upyun-cdn/upyun_cdn.go | 262 -------------- .../providers/upyun-file/upyun_file.go | 133 ------- .../deployer/providers/zenlayer-cdn/consts.go | 17 - .../providers/zenlayer-cdn/zenlayer_cdn.go | 332 ------------------ .../deployer/providers/zenlayer-ga/consts.go | 8 - .../providers/zenlayer-ga/zenlayer_ga.go | 202 ----------- pkg/sdk3rd/upyun/console/api_get_buckets.go | 83 ----- .../api_get_https_certificate_manager.go | 55 --- .../console/api_get_https_service_manager.go | 60 ---- .../upyun/console/api_migrate_https_domain.go | 46 --- .../api_update_https_certificate_manager.go | 48 --- .../console/api_upload_https_certificate.go | 51 --- pkg/sdk3rd/upyun/console/client.go | 170 --------- pkg/sdk3rd/upyun/console/options.go | 15 - pkg/sdk3rd/upyun/console/types.go | 41 --- pkg/sdk3rd/zenlayer/cdn/client.go | 188 ---------- pkg/sdk3rd/zenlayer/cdn/models.go | 174 --------- pkg/sdk3rd/zenlayer/zga/client.go | 107 ------ pkg/sdk3rd/zenlayer/zga/models.go | 25 -- .../forms/AccessConfigFieldsProvider.tsx | 4 - .../forms/AccessConfigFieldsProviderUpyun.tsx | 64 ---- .../AccessConfigFieldsProviderZenlayer.tsx | 75 ---- .../BizDeployNodeConfigFieldsProvider.tsx | 8 - ...DeployNodeConfigFieldsProviderUpyunCDN.tsx | 112 ------ ...eployNodeConfigFieldsProviderUpyunFile.tsx | 69 ---- ...loyNodeConfigFieldsProviderZenlayerCDN.tsx | 169 --------- ...ployNodeConfigFieldsProviderZenlayerGA.tsx | 124 ------- ui/src/domain/provider.ts | 12 - ui/src/i18n/resources/en/nls.access.json | 25 -- ui/src/i18n/resources/en/nls.provider.json | 8 +- .../i18n/resources/en/nls.workflow.nodes.json | 57 --- ui/src/i18n/resources/zh/nls.access.json | 25 -- ui/src/i18n/resources/zh/nls.provider.json | 8 +- .../i18n/resources/zh/nls.workflow.nodes.json | 57 --- 62 files changed, 2 insertions(+), 4562 deletions(-) delete mode 100644 internal/certmgmt/deployers/sp_upyun_cdn.go delete mode 100644 internal/certmgmt/deployers/sp_upyun_file.go delete mode 100644 internal/certmgmt/deployers/sp_zenlayer_cdn.go delete mode 100644 internal/certmgmt/deployers/sp_zenlayer_ga.go delete mode 100644 pkg/core/certmgr/providers-tester/dogecloud/dogecloud_test.go delete mode 100644 pkg/core/certmgr/providers-tester/qiniu-sslcert/qiniu_sslcert_test.go delete mode 100644 pkg/core/certmgr/providers-tester/upyun-ssl/upyun_ssl_test.go delete mode 100644 pkg/core/certmgr/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go delete mode 100644 pkg/core/certmgr/providers-tester/zenlayer-ga/zenlayer_ga_test.go delete mode 100644 pkg/core/certmgr/providers/upyun-ssl/upyun_ssl.go delete mode 100644 pkg/core/certmgr/providers/zenlayer-cdn/zenlayer_cdn.go delete mode 100644 pkg/core/certmgr/providers/zenlayer-ga/zenlayer_ga.go delete mode 100644 pkg/core/deployer/providers-tester/dogecloud-cdn/dogecloud_cdn_test.go delete mode 100644 pkg/core/deployer/providers-tester/k8s-secret/k8s_secret_test.go delete mode 100644 pkg/core/deployer/providers-tester/qiniu-cdn/qiniu_cdn_test.go delete mode 100644 pkg/core/deployer/providers-tester/qiniu-kodo/qiniu_kodo_test.go delete mode 100644 pkg/core/deployer/providers-tester/qiniu-pili/qiniu_pili_test.go delete mode 100644 pkg/core/deployer/providers-tester/upyun-cdn/upyun_cdn_test.go delete mode 100644 pkg/core/deployer/providers-tester/upyun-file/upyun_file_test.go delete mode 100644 pkg/core/deployer/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go delete mode 100644 pkg/core/deployer/providers-tester/zenlayer-ga/zenlayer_ga_test.go delete mode 100644 pkg/core/deployer/providers/k8s-secret/k8s_secret.go delete mode 100644 pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go delete mode 100644 pkg/core/deployer/providers/upyun-cdn/consts.go delete mode 100644 pkg/core/deployer/providers/upyun-cdn/upyun_cdn.go delete mode 100644 pkg/core/deployer/providers/upyun-file/upyun_file.go delete mode 100644 pkg/core/deployer/providers/zenlayer-cdn/consts.go delete mode 100644 pkg/core/deployer/providers/zenlayer-cdn/zenlayer_cdn.go delete mode 100644 pkg/core/deployer/providers/zenlayer-ga/consts.go delete mode 100644 pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga.go delete mode 100644 pkg/sdk3rd/upyun/console/api_get_buckets.go delete mode 100644 pkg/sdk3rd/upyun/console/api_get_https_certificate_manager.go delete mode 100644 pkg/sdk3rd/upyun/console/api_get_https_service_manager.go delete mode 100644 pkg/sdk3rd/upyun/console/api_migrate_https_domain.go delete mode 100644 pkg/sdk3rd/upyun/console/api_update_https_certificate_manager.go delete mode 100644 pkg/sdk3rd/upyun/console/api_upload_https_certificate.go delete mode 100644 pkg/sdk3rd/upyun/console/client.go delete mode 100644 pkg/sdk3rd/upyun/console/options.go delete mode 100644 pkg/sdk3rd/upyun/console/types.go delete mode 100644 pkg/sdk3rd/zenlayer/cdn/client.go delete mode 100644 pkg/sdk3rd/zenlayer/cdn/models.go delete mode 100644 pkg/sdk3rd/zenlayer/zga/client.go delete mode 100644 pkg/sdk3rd/zenlayer/zga/models.go delete mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderUpyun.tsx delete mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderZenlayer.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunCDN.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunFile.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerCDN.tsx delete mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerGA.tsx diff --git a/go.mod b/go.mod index 134a477ca..2b5bdc9b4 100644 --- a/go.mod +++ b/go.mod @@ -87,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 diff --git a/go.sum b/go.sum index 936d9465b..b160bb167 100644 --- a/go.sum +++ b/go.sum @@ -940,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= diff --git a/internal/certmgmt/deployers/sp_upyun_cdn.go b/internal/certmgmt/deployers/sp_upyun_cdn.go deleted file mode 100644 index aa74a6fe1..000000000 --- a/internal/certmgmt/deployers/sp_upyun_cdn.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/certmgmt/deployers/sp_upyun_file.go b/internal/certmgmt/deployers/sp_upyun_file.go deleted file mode 100644 index 31880b933..000000000 --- a/internal/certmgmt/deployers/sp_upyun_file.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/certmgmt/deployers/sp_zenlayer_cdn.go b/internal/certmgmt/deployers/sp_zenlayer_cdn.go deleted file mode 100644 index 4adaf7db1..000000000 --- a/internal/certmgmt/deployers/sp_zenlayer_cdn.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/certmgmt/deployers/sp_zenlayer_ga.go b/internal/certmgmt/deployers/sp_zenlayer_ga.go deleted file mode 100644 index e0c3518e2..000000000 --- a/internal/certmgmt/deployers/sp_zenlayer_ga.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/domain/access.go b/internal/domain/access.go index 7abe636bd..1af81871f 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -644,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"` @@ -698,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 } diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 74e275fb6..0f81943cc 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -127,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") @@ -137,7 +136,6 @@ const ( AccessProviderTypeWestcn = AccessProviderType("westcn") AccessProviderTypeXinnet = AccessProviderType("xinnet") AccessProviderTypeYandexCloud = AccessProviderType("yandexcloud") - AccessProviderTypeZenlayer = AccessProviderType("zenlayer") AccessProviderTypeZeroSSL = AccessProviderType("zerossl") ) @@ -447,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") @@ -466,8 +462,6 @@ const ( DeploymentProviderTypeWangsuCertificate = DeploymentProviderType(AccessProviderTypeWangsu + "-certificate") DeploymentProviderTypeWebhook = DeploymentProviderType(AccessProviderTypeWebhook) DeploymentProviderTypeYandexCloudCertificateManager = DeploymentProviderType(AccessProviderTypeYandexCloud + "-certificatemanager") - DeploymentProviderTypeZenlayerCDN = DeploymentProviderType(AccessProviderTypeZenlayer + "-cdn") - DeploymentProviderTypeZenlayerGA = DeploymentProviderType(AccessProviderTypeZenlayer + "-ga") ) type NotificationProviderType string diff --git a/pkg/core/certmgr/providers-tester/dogecloud/dogecloud_test.go b/pkg/core/certmgr/providers-tester/dogecloud/dogecloud_test.go deleted file mode 100644 index 550dcc817..000000000 --- a/pkg/core/certmgr/providers-tester/dogecloud/dogecloud_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/certmgr/providers-tester/qiniu-sslcert/qiniu_sslcert_test.go b/pkg/core/certmgr/providers-tester/qiniu-sslcert/qiniu_sslcert_test.go deleted file mode 100644 index f6355ba71..000000000 --- a/pkg/core/certmgr/providers-tester/qiniu-sslcert/qiniu_sslcert_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/certmgr/providers-tester/upyun-ssl/upyun_ssl_test.go b/pkg/core/certmgr/providers-tester/upyun-ssl/upyun_ssl_test.go deleted file mode 100644 index f865563e5..000000000 --- a/pkg/core/certmgr/providers-tester/upyun-ssl/upyun_ssl_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/certmgr/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go b/pkg/core/certmgr/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go deleted file mode 100644 index c9a5fd936..000000000 --- a/pkg/core/certmgr/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/certmgr/providers-tester/zenlayer-ga/zenlayer_ga_test.go b/pkg/core/certmgr/providers-tester/zenlayer-ga/zenlayer_ga_test.go deleted file mode 100644 index 7d5997059..000000000 --- a/pkg/core/certmgr/providers-tester/zenlayer-ga/zenlayer_ga_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/certmgr/providers/upyun-ssl/upyun_ssl.go b/pkg/core/certmgr/providers/upyun-ssl/upyun_ssl.go deleted file mode 100644 index c5a7d5e3a..000000000 --- a/pkg/core/certmgr/providers/upyun-ssl/upyun_ssl.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/certmgr/providers/zenlayer-cdn/zenlayer_cdn.go b/pkg/core/certmgr/providers/zenlayer-cdn/zenlayer_cdn.go deleted file mode 100644 index 6a08929a6..000000000 --- a/pkg/core/certmgr/providers/zenlayer-cdn/zenlayer_cdn.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/certmgr/providers/zenlayer-ga/zenlayer_ga.go b/pkg/core/certmgr/providers/zenlayer-ga/zenlayer_ga.go deleted file mode 100644 index dcece9cdb..000000000 --- a/pkg/core/certmgr/providers/zenlayer-ga/zenlayer_ga.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers-tester/dogecloud-cdn/dogecloud_cdn_test.go b/pkg/core/deployer/providers-tester/dogecloud-cdn/dogecloud_cdn_test.go deleted file mode 100644 index f6c41ac89..000000000 --- a/pkg/core/deployer/providers-tester/dogecloud-cdn/dogecloud_cdn_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/k8s-secret/k8s_secret_test.go b/pkg/core/deployer/providers-tester/k8s-secret/k8s_secret_test.go deleted file mode 100644 index 71f42b2e5..000000000 --- a/pkg/core/deployer/providers-tester/k8s-secret/k8s_secret_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/qiniu-cdn/qiniu_cdn_test.go b/pkg/core/deployer/providers-tester/qiniu-cdn/qiniu_cdn_test.go deleted file mode 100644 index 097b022b2..000000000 --- a/pkg/core/deployer/providers-tester/qiniu-cdn/qiniu_cdn_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/qiniu-kodo/qiniu_kodo_test.go b/pkg/core/deployer/providers-tester/qiniu-kodo/qiniu_kodo_test.go deleted file mode 100644 index 600e08584..000000000 --- a/pkg/core/deployer/providers-tester/qiniu-kodo/qiniu_kodo_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/qiniu-pili/qiniu_pili_test.go b/pkg/core/deployer/providers-tester/qiniu-pili/qiniu_pili_test.go deleted file mode 100644 index 15a1aa1a6..000000000 --- a/pkg/core/deployer/providers-tester/qiniu-pili/qiniu_pili_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/upyun-cdn/upyun_cdn_test.go b/pkg/core/deployer/providers-tester/upyun-cdn/upyun_cdn_test.go deleted file mode 100644 index 2fd06d97c..000000000 --- a/pkg/core/deployer/providers-tester/upyun-cdn/upyun_cdn_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/upyun-file/upyun_file_test.go b/pkg/core/deployer/providers-tester/upyun-file/upyun_file_test.go deleted file mode 100644 index 0e79bca68..000000000 --- a/pkg/core/deployer/providers-tester/upyun-file/upyun_file_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go b/pkg/core/deployer/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go deleted file mode 100644 index e6a2118e0..000000000 --- a/pkg/core/deployer/providers-tester/zenlayer-cdn/zenlayer_cdn_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers-tester/zenlayer-ga/zenlayer_ga_test.go b/pkg/core/deployer/providers-tester/zenlayer-ga/zenlayer_ga_test.go deleted file mode 100644 index 272142aa2..000000000 --- a/pkg/core/deployer/providers-tester/zenlayer-ga/zenlayer_ga_test.go +++ /dev/null @@ -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}) - }) -} diff --git a/pkg/core/deployer/providers/k8s-secret/k8s_secret.go b/pkg/core/deployer/providers/k8s-secret/k8s_secret.go deleted file mode 100644 index e9840b25c..000000000 --- a/pkg/core/deployer/providers/k8s-secret/k8s_secret.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go b/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go deleted file mode 100644 index df04a2f1f..000000000 --- a/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go +++ /dev/null @@ -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) - } -} diff --git a/pkg/core/deployer/providers/upyun-cdn/consts.go b/pkg/core/deployer/providers/upyun-cdn/consts.go deleted file mode 100644 index 6a119706b..000000000 --- a/pkg/core/deployer/providers/upyun-cdn/consts.go +++ /dev/null @@ -1,10 +0,0 @@ -package upyuncdn - -const ( - // 匹配模式:精确匹配。 - DOMAIN_MATCH_PATTERN_EXACT = "exact" - // 匹配模式:通配符匹配。 - DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" - // 匹配模式:证书 SAN 匹配。 - DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" -) diff --git a/pkg/core/deployer/providers/upyun-cdn/upyun_cdn.go b/pkg/core/deployer/providers/upyun-cdn/upyun_cdn.go deleted file mode 100644 index b894748e4..000000000 --- a/pkg/core/deployer/providers/upyun-cdn/upyun_cdn.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/upyun-file/upyun_file.go b/pkg/core/deployer/providers/upyun-file/upyun_file.go deleted file mode 100644 index 52af7c14a..000000000 --- a/pkg/core/deployer/providers/upyun-file/upyun_file.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/zenlayer-cdn/consts.go b/pkg/core/deployer/providers/zenlayer-cdn/consts.go deleted file mode 100644 index bfe31d9d9..000000000 --- a/pkg/core/deployer/providers/zenlayer-cdn/consts.go +++ /dev/null @@ -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" -) diff --git a/pkg/core/deployer/providers/zenlayer-cdn/zenlayer_cdn.go b/pkg/core/deployer/providers/zenlayer-cdn/zenlayer_cdn.go deleted file mode 100644 index 52ef633e3..000000000 --- a/pkg/core/deployer/providers/zenlayer-cdn/zenlayer_cdn.go +++ /dev/null @@ -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 -} diff --git a/pkg/core/deployer/providers/zenlayer-ga/consts.go b/pkg/core/deployer/providers/zenlayer-ga/consts.go deleted file mode 100644 index 3d362b9ce..000000000 --- a/pkg/core/deployer/providers/zenlayer-ga/consts.go +++ /dev/null @@ -1,8 +0,0 @@ -package zenlayerga - -const ( - // 部署目标:部署到指定全球网络加速器。 - DEPLOY_TARGET_ACCELERATOR = "accelerator" - // 部署目标:替换指定证书。 - DEPLOY_TARGET_CERTIFICATE = "certificate" -) diff --git a/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga.go b/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga.go deleted file mode 100644 index 0e06f561f..000000000 --- a/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga.go +++ /dev/null @@ -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 -} diff --git a/pkg/sdk3rd/upyun/console/api_get_buckets.go b/pkg/sdk3rd/upyun/console/api_get_buckets.go deleted file mode 100644 index 9f53e1b05..000000000 --- a/pkg/sdk3rd/upyun/console/api_get_buckets.go +++ /dev/null @@ -1,83 +0,0 @@ -package console - -import ( - "context" - "net/http" - - qs "github.com/google/go-querystring/query" -) - -type GetBucketsRequest struct { - BucketName string `json:"status" url:"bucket_name"` - BusinessType string `json:"business_type" url:"business_type"` - Type string `json:"type" url:"type"` - Status string `json:"state" url:"state"` - Tag string `json:"tag" url:"tag"` - IsSecurityCDN bool `json:"security_cdn" url:"security_cdn"` - WithDomains bool `json:"with_domains" url:"with_domains"` - Page int32 `json:"page" url:"page"` - PerPage int32 `json:"perPage" url:"perPage"` -} - -type GetBucketsResponse struct { - sdkResponseBase - Data *struct { - sdkResponseBaseData - Buckets []*BucketInfo `json:"buckets"` - Pager BucketPager `json:"pager"` - } `json:"data,omitempty"` -} - -type BucketInfo struct { - BucketName string `json:"bucket_name"` - BusinessType string `json:"business_type"` - Type string `json:"type"` - Status string `json:"status"` - Tag string `json:"tag"` - IsFusionCDN bool `json:"fusion_cdn"` - IsSecurityCDN bool `json:"security_cdn"` - Domains []*BucketDomain `json:"domains"` - Visible bool `json:"visible"` - CreatedAt string `json:"created_at"` -} - -type BucketDomain struct { - Domain string `json:"domain"` - Status string `json:"status"` -} - -type BucketPager struct { - Page int32 `json:"page"` - Pages int64 `json:"pages"` - Total int64 `json:"total"` -} - -func (c *Client) GetBuckets(req *GetBucketsRequest) (*GetBucketsResponse, error) { - return c.GetBucketsWithContext(context.Background(), req) -} - -func (c *Client) GetBucketsWithContext(ctx context.Context, req *GetBucketsRequest) (*GetBucketsResponse, error) { - if err := c.ensureCookies(ctx); err != nil { - return nil, err - } - - httpreq, err := c.newRequest(http.MethodGet, "/api/v2/buckets") - if err != nil { - return nil, err - } else { - values, err := qs.Values(req) - if err != nil { - return nil, err - } - - httpreq.SetQueryParamsFromValues(values) - httpreq.SetContext(ctx) - } - - result := &GetBucketsResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/upyun/console/api_get_https_certificate_manager.go b/pkg/sdk3rd/upyun/console/api_get_https_certificate_manager.go deleted file mode 100644 index 6efab934e..000000000 --- a/pkg/sdk3rd/upyun/console/api_get_https_certificate_manager.go +++ /dev/null @@ -1,55 +0,0 @@ -package console - -import ( - "context" - "fmt" - "net/http" -) - -type HttpsCertificateManagerDomain struct { - Name string `json:"name"` - Type string `json:"type"` - BucketId int64 `json:"bucket_id"` - BucketName string `json:"bucket_name"` -} - -type GetHttpsCertificateManagerResponse struct { - sdkResponseBase - - Data *struct { - sdkResponseBaseData - - AuthenticateNum int32 `json:"authenticate_num"` - AuthenticateDomains []string `json:"authenticate_domain"` - Domains []HttpsCertificateManagerDomain `json:"domains"` - } `json:"data,omitempty"` -} - -func (c *Client) GetHttpsCertificateManager(certificateId string) (*GetHttpsCertificateManagerResponse, error) { - return c.GetHttpsCertificateManagerWithContext(context.Background(), certificateId) -} - -func (c *Client) GetHttpsCertificateManagerWithContext(ctx context.Context, certificateId string) (*GetHttpsCertificateManagerResponse, error) { - if certificateId == "" { - return nil, fmt.Errorf("sdkerr: bad request: unset certificateId") - } - - if err := c.ensureCookies(ctx); err != nil { - return nil, err - } - - httpreq, err := c.newRequest(http.MethodGet, "/api/https/certificate/manager/") - if err != nil { - return nil, err - } else { - httpreq.SetQueryParam("certificate_id", certificateId) - httpreq.SetContext(ctx) - } - - result := &GetHttpsCertificateManagerResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/upyun/console/api_get_https_service_manager.go b/pkg/sdk3rd/upyun/console/api_get_https_service_manager.go deleted file mode 100644 index 132830b05..000000000 --- a/pkg/sdk3rd/upyun/console/api_get_https_service_manager.go +++ /dev/null @@ -1,60 +0,0 @@ -package console - -import ( - "context" - "fmt" - "net/http" -) - -type GetHttpsServiceManagerResponse struct { - sdkResponseBase - Data *struct { - sdkResponseBaseData - Status int32 `json:"status"` - Domains []HttpsServiceManagerDomain `json:"result"` - } `json:"data,omitempty"` -} - -type HttpsServiceManagerDomain struct { - CertificateId string `json:"certificate_id"` - CommonName string `json:"commonName"` - Https bool `json:"https"` - ForceHttps bool `json:"force_https"` - PaymentType string `json:"payment_type"` - DomainType string `json:"domain_type"` - Validity HttpsServiceManagerDomainValidity `json:"validity"` -} - -type HttpsServiceManagerDomainValidity struct { - Start int64 `json:"start"` - End int64 `json:"end"` -} - -func (c *Client) GetHttpsServiceManager(domain string) (*GetHttpsServiceManagerResponse, error) { - return c.GetHttpsServiceManagerWithContext(context.Background(), domain) -} - -func (c *Client) GetHttpsServiceManagerWithContext(ctx context.Context, domain string) (*GetHttpsServiceManagerResponse, error) { - if domain == "" { - return nil, fmt.Errorf("sdkerr: bad request: unset domain") - } - - if err := c.ensureCookies(ctx); err != nil { - return nil, err - } - - httpreq, err := c.newRequest(http.MethodGet, "/api/https/services/manager") - if err != nil { - return nil, err - } else { - httpreq.SetQueryParam("domain", domain) - httpreq.SetContext(ctx) - } - - result := &GetHttpsServiceManagerResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/upyun/console/api_migrate_https_domain.go b/pkg/sdk3rd/upyun/console/api_migrate_https_domain.go deleted file mode 100644 index 614e669f9..000000000 --- a/pkg/sdk3rd/upyun/console/api_migrate_https_domain.go +++ /dev/null @@ -1,46 +0,0 @@ -package console - -import ( - "context" - "net/http" -) - -type MigrateHttpsDomainRequest struct { - CertificateId string `json:"crt_id"` - Domain string `json:"domain_name"` -} - -type MigrateHttpsDomainResponse struct { - sdkResponseBase - - Data *struct { - sdkResponseBaseData - - Status bool `json:"status"` - } `json:"data,omitempty"` -} - -func (c *Client) MigrateHttpsDomain(req *MigrateHttpsDomainRequest) (*MigrateHttpsDomainResponse, error) { - return c.MigrateHttpsDomainWithContext(context.Background(), req) -} - -func (c *Client) MigrateHttpsDomainWithContext(ctx context.Context, req *MigrateHttpsDomainRequest) (*MigrateHttpsDomainResponse, error) { - if err := c.ensureCookies(ctx); err != nil { - return nil, err - } - - httpreq, err := c.newRequest(http.MethodPost, "/api/https/migrate/domain") - if err != nil { - return nil, err - } else { - httpreq.SetBody(req) - httpreq.SetContext(ctx) - } - - result := &MigrateHttpsDomainResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/upyun/console/api_update_https_certificate_manager.go b/pkg/sdk3rd/upyun/console/api_update_https_certificate_manager.go deleted file mode 100644 index 2a6b833e8..000000000 --- a/pkg/sdk3rd/upyun/console/api_update_https_certificate_manager.go +++ /dev/null @@ -1,48 +0,0 @@ -package console - -import ( - "context" - "net/http" -) - -type UpdateHttpsCertificateManagerRequest struct { - CertificateId string `json:"certificate_id"` - Domain string `json:"domain"` - Https bool `json:"https"` - ForceHttps bool `json:"force_https"` -} - -type UpdateHttpsCertificateManagerResponse struct { - sdkResponseBase - - Data *struct { - sdkResponseBaseData - - Status bool `json:"status"` - } `json:"data,omitempty"` -} - -func (c *Client) UpdateHttpsCertificateManager(req *UpdateHttpsCertificateManagerRequest) (*UpdateHttpsCertificateManagerResponse, error) { - return c.UpdateHttpsCertificateManagerWithContext(context.Background(), req) -} - -func (c *Client) UpdateHttpsCertificateManagerWithContext(ctx context.Context, req *UpdateHttpsCertificateManagerRequest) (*UpdateHttpsCertificateManagerResponse, error) { - if err := c.ensureCookies(ctx); err != nil { - return nil, err - } - - httpreq, err := c.newRequest(http.MethodPost, "/api/https/certificate/manager") - if err != nil { - return nil, err - } else { - httpreq.SetBody(req) - httpreq.SetContext(ctx) - } - - result := &UpdateHttpsCertificateManagerResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/upyun/console/api_upload_https_certificate.go b/pkg/sdk3rd/upyun/console/api_upload_https_certificate.go deleted file mode 100644 index 04b3188c3..000000000 --- a/pkg/sdk3rd/upyun/console/api_upload_https_certificate.go +++ /dev/null @@ -1,51 +0,0 @@ -package console - -import ( - "context" - "net/http" -) - -type UploadHttpsCertificateRequest struct { - Certificate string `json:"certificate"` - PrivateKey string `json:"private_key"` -} - -type UploadHttpsCertificateResponse struct { - sdkResponseBase - - Data *struct { - sdkResponseBaseData - - Status int32 `json:"status"` - Result struct { - CertificateId string `json:"certificate_id"` - CommonName string `json:"commonName"` - Serial string `json:"serial"` - } `json:"result"` - } `json:"data,omitempty"` -} - -func (c *Client) UploadHttpsCertificate(req *UploadHttpsCertificateRequest) (*UploadHttpsCertificateResponse, error) { - return c.UploadHttpsCertificateWithContext(context.Background(), req) -} - -func (c *Client) UploadHttpsCertificateWithContext(ctx context.Context, req *UploadHttpsCertificateRequest) (*UploadHttpsCertificateResponse, error) { - if err := c.ensureCookies(ctx); err != nil { - return nil, err - } - - httpreq, err := c.newRequest(http.MethodPost, "/api/https/certificate/") - if err != nil { - return nil, err - } else { - httpreq.SetBody(req) - httpreq.SetContext(ctx) - } - - result := &UploadHttpsCertificateResponse{} - if _, err := c.doRequestWithResult(httpreq, result); err != nil { - return result, err - } - - return result, nil -} diff --git a/pkg/sdk3rd/upyun/console/client.go b/pkg/sdk3rd/upyun/console/client.go deleted file mode 100644 index f76499048..000000000 --- a/pkg/sdk3rd/upyun/console/client.go +++ /dev/null @@ -1,170 +0,0 @@ -// A mock HTTP client for Upyun Console. -package console - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "sync" - "time" - - "github.com/go-resty/resty/v2" - - "github.com/certimate-go/certimate/internal/app" -) - -type Client struct { - username string - password string - - cookies string - cookiesMu sync.Mutex - - rc *resty.Client -} - -func NewClient(optFns ...OptionsFunc) (*Client, error) { - options := &Options{} - for _, fn := range optFns { - fn(options) - } - - if options.Username == "" { - return nil, fmt.Errorf("sdkerr: unset username") - } - if options.Password == "" { - return nil, fmt.Errorf("sdkerr: unset password") - } - - client := &Client{ - username: options.Username, - password: options.Password, - } - client.rc = resty.New(). - SetBaseURL("https://console.upyun.com"). - SetHeader("Accept", "application/json"). - SetHeader("Content-Type", "application/json"). - SetHeader("User-Agent", app.AppUserAgent). - SetPreRequestHook(func(_ *resty.Client, req *http.Request) error { - if client.cookies != "" { - req.Header.Set("Cookie", client.cookies) - } - - return nil - }) - - return client, nil -} - -func (c *Client) SetTimeout(timeout time.Duration) *Client { - c.rc.SetTimeout(timeout) - return c -} - -func (c *Client) newRequest(method string, path string) (*resty.Request, error) { - if method == "" { - return nil, fmt.Errorf("sdkerr: unset method") - } - if path == "" { - return nil, fmt.Errorf("sdkerr: unset path") - } - - req := c.rc.R() - req.Method = method - req.URL = path - - // WARN: - // DO NOT CALL `req.SetResult` or `req.SetError` AGAIN! USE `doRequestWithResult` INSTEAD. - return req, nil -} - -func (c *Client) doRequest(req *resty.Request) (*resty.Response, error) { - if req == nil { - return nil, fmt.Errorf("sdkerr: nil request") - } - - resp, err := req.Send() - if err != nil { - return resp, fmt.Errorf("sdkerr: failed to send request: %w", err) - } else if resp.IsError() { - return resp, fmt.Errorf("sdkerr: unexpected status code: %d (resp: %s)", resp.StatusCode(), resp.String()) - } - - return resp, nil -} - -func (c *Client) doRequestWithResult(req *resty.Request, res sdkResponse) (*resty.Response, error) { - if req == nil { - return nil, fmt.Errorf("sdkerr: nil request") - } - - resp, err := c.doRequest(req) - if err != nil { - if resp != nil { - json.Unmarshal(resp.Body(), &res) - } - return resp, err - } - - if len(resp.Body()) != 0 { - if err := json.Unmarshal(resp.Body(), &res); err != nil { - return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String()) - } else { - tresp := &sdkResponseBase{} - if err := json.Unmarshal(resp.Body(), &tresp); err != nil { - return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String()) - } else if tdata := tresp.GetData(); tdata == nil { - return resp, fmt.Errorf("sdkerr: received empty data") - } else if terrcode := tdata.GetErrorCode(); terrcode != 0 { - return resp, fmt.Errorf("sdkerr: api error: code='%d', message='%s'", terrcode, tdata.GetMessage()) - } - } - } - - return resp, nil -} - -func (c *Client) ensureCookies(ctx context.Context) error { - c.cookiesMu.Lock() - defer c.cookiesMu.Unlock() - if c.cookies != "" { - return nil - } - - httpreq, err := c.newRequest(http.MethodPost, "/accounts/signin/") - if err != nil { - return err - } else { - httpreq.SetBody(map[string]string{ - "username": c.username, - "password": c.password, - }) - httpreq.SetContext(ctx) - } - - type signinResponse struct { - sdkResponseBase - Data *struct { - sdkResponseBaseData - Result bool `json:"result"` - } `json:"data,omitempty"` - } - - result := &signinResponse{} - httpresp, err := c.doRequestWithResult(httpreq, result) - if err != nil { - return err - } else if !result.Data.Result { - return fmt.Errorf("sdkerr: auth error") - } else { - cookies := httpresp.Header().Get("Set-Cookie") - if cookies == "" { - return fmt.Errorf("sdkerr: auth error: received empty cookies") - } - - c.cookies = cookies - } - - return nil -} diff --git a/pkg/sdk3rd/upyun/console/options.go b/pkg/sdk3rd/upyun/console/options.go deleted file mode 100644 index 228124424..000000000 --- a/pkg/sdk3rd/upyun/console/options.go +++ /dev/null @@ -1,15 +0,0 @@ -package console - -type Options struct { - Username string - Password string -} - -type OptionsFunc func(*Options) - -func WithLogins(username, password string) OptionsFunc { - return func(o *Options) { - o.Username = username - o.Password = password - } -} diff --git a/pkg/sdk3rd/upyun/console/types.go b/pkg/sdk3rd/upyun/console/types.go deleted file mode 100644 index 4ad1cfc44..000000000 --- a/pkg/sdk3rd/upyun/console/types.go +++ /dev/null @@ -1,41 +0,0 @@ -package console - -import ( - "encoding/json" -) - -type sdkResponse interface { - GetData() *sdkResponseBaseData -} - -type sdkResponseBase struct { - Data *sdkResponseBaseData `json:"data,omitempty"` -} - -func (r *sdkResponseBase) GetData() *sdkResponseBaseData { - return r.Data -} - -var _ sdkResponse = (*sdkResponseBase)(nil) - -type sdkResponseBaseData struct { - ErrorCode json.Number `json:"error_code,omitempty"` - Message string `json:"message,omitempty"` -} - -func (r *sdkResponseBaseData) GetErrorCode() int { - if r.ErrorCode.String() == "" { - return 0 - } - - errcode, err := r.ErrorCode.Int64() - if err != nil { - return -1 - } - - return int(errcode) -} - -func (r *sdkResponseBaseData) GetMessage() string { - return r.Message -} diff --git a/pkg/sdk3rd/zenlayer/cdn/client.go b/pkg/sdk3rd/zenlayer/cdn/client.go deleted file mode 100644 index 3be87ebdb..000000000 --- a/pkg/sdk3rd/zenlayer/cdn/client.go +++ /dev/null @@ -1,188 +0,0 @@ -// An extension SDK client for ZenlayerCloud CDN service. -// Based on github.com/zenlayer/zenlayercloud-sdk-go. -package cdn - -import ( - "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common" -) - -const ( - APIVersion = "2022-11-20" - SERVICE = "cdn" -) - -type Client struct { - common.Client -} - -func NewClientWithSecretKey(secretKeyId, secretKeyPassword string) (client *Client, err error) { - return NewClient(common.NewConfig(), secretKeyId, secretKeyPassword) -} - -func NewClient(config *common.Config, secretKeyId, secretKeyPassword string) (client *Client, err error) { - client = &Client{} - - err = client.InitWithCredential(common.NewCredential(secretKeyId, secretKeyPassword)) - if err != nil { - return nil, err - } - err = client.WithConfig(config) - if err != nil { - return nil, err - } - return client, nil -} - -func NewDescribeCertificatesRequest() (request *DescribeCertificatesRequest) { - request = &DescribeCertificatesRequest{ - BaseRequest: &common.BaseRequest{}, - } - request.Init().InitWithApiInfo(SERVICE, APIVersion, "DescribeCertificates") - - return -} - -func NewDescribeCertificatesResponse() (response *DescribeCertificatesResponse) { - response = &DescribeCertificatesResponse{ - BaseResponse: &common.BaseResponse{}, - } - return -} - -func (c *Client) DescribeCertificates(request *DescribeCertificatesRequest) (response *DescribeCertificatesResponse, err error) { - response = NewDescribeCertificatesResponse() - err = c.ApiCall(request, response) - return -} - -func NewCreateCertificateRequest() (request *CreateCertificateRequest) { - request = &CreateCertificateRequest{ - BaseRequest: &common.BaseRequest{}, - } - request.Init().InitWithApiInfo(SERVICE, APIVersion, "CreateCertificate") - - return -} - -func NewCreateCertificateResponse() (response *CreateCertificateResponse) { - response = &CreateCertificateResponse{ - BaseResponse: &common.BaseResponse{}, - } - return -} - -func (c *Client) CreateCertificate(request *CreateCertificateRequest) (response *CreateCertificateResponse, err error) { - response = NewCreateCertificateResponse() - err = c.ApiCall(request, response) - return -} - -func NewModifyCertificateRequest() (request *ModifyCertificateRequest) { - request = &ModifyCertificateRequest{ - BaseRequest: &common.BaseRequest{}, - } - request.Init().InitWithApiInfo(SERVICE, APIVersion, "ModifyCertificate") - - return -} - -func NewModifyCertificateResponse() (response *ModifyCertificateResponse) { - response = &ModifyCertificateResponse{ - BaseResponse: &common.BaseResponse{}, - } - return -} - -func (c *Client) ModifyCertificate(request *ModifyCertificateRequest) (response *ModifyCertificateResponse, err error) { - response = NewModifyCertificateResponse() - err = c.ApiCall(request, response) - return -} - -func NewDeleteCertificateRequest() (request *DeleteCertificateRequest) { - request = &DeleteCertificateRequest{ - BaseRequest: &common.BaseRequest{}, - } - request.Init().InitWithApiInfo(SERVICE, APIVersion, "DeleteCertificate") - - return -} - -func NewDeleteCertificateResponse() (response *DeleteCertificateResponse) { - response = &DeleteCertificateResponse{ - BaseResponse: &common.BaseResponse{}, - } - return -} - -func (c *Client) DeleteCertificate(request *DeleteCertificateRequest) (response *DeleteCertificateResponse, err error) { - response = NewDeleteCertificateResponse() - err = c.ApiCall(request, response) - return -} - -func NewDescribeDomainsRequest() (request *DescribeDomainsRequest) { - request = &DescribeDomainsRequest{ - BaseRequest: &common.BaseRequest{}, - } - request.Init().InitWithApiInfo(SERVICE, APIVersion, "DescribeDomains") - - return -} - -func NewDescribeDomainsResponse() (response *DescribeDomainsResponse) { - response = &DescribeDomainsResponse{ - BaseResponse: &common.BaseResponse{}, - } - return -} - -func (c *Client) DescribeDomains(request *DescribeDomainsRequest) (response *DescribeDomainsResponse, err error) { - response = NewDescribeDomainsResponse() - err = c.ApiCall(request, response) - return -} - -func NewDescribeDomainCertificateRequest() (request *DescribeDomainCertificateRequest) { - request = &DescribeDomainCertificateRequest{ - BaseRequest: &common.BaseRequest{}, - } - request.Init().InitWithApiInfo(SERVICE, APIVersion, "DescribeDomainCertificate") - - return -} - -func NewDescribeDomainCertificateResponse() (response *DescribeDomainCertificateResponse) { - response = &DescribeDomainCertificateResponse{ - BaseResponse: &common.BaseResponse{}, - } - return -} - -func (c *Client) DescribeDomainCertificate(request *DescribeDomainCertificateRequest) (response *DescribeDomainCertificateResponse, err error) { - response = NewDescribeDomainCertificateResponse() - err = c.ApiCall(request, response) - return -} - -func NewModifyDomainCertificateRequest() (request *ModifyDomainCertificateRequest) { - request = &ModifyDomainCertificateRequest{ - BaseRequest: &common.BaseRequest{}, - } - request.Init().InitWithApiInfo(SERVICE, APIVersion, "ModifyDomainCertificate") - - return -} - -func NewModifyDomainCertificateResponse() (response *ModifyDomainCertificateResponse) { - response = &ModifyDomainCertificateResponse{ - BaseResponse: &common.BaseResponse{}, - } - return -} - -func (c *Client) ModifyDomainCertificate(request *ModifyDomainCertificateRequest) (response *ModifyDomainCertificateResponse, err error) { - response = NewModifyDomainCertificateResponse() - err = c.ApiCall(request, response) - return -} diff --git a/pkg/sdk3rd/zenlayer/cdn/models.go b/pkg/sdk3rd/zenlayer/cdn/models.go deleted file mode 100644 index 55187acc1..000000000 --- a/pkg/sdk3rd/zenlayer/cdn/models.go +++ /dev/null @@ -1,174 +0,0 @@ -package cdn - -import ( - "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common" -) - -type DescribeCertificatesRequest struct { - *common.BaseRequest - - CertificateIds []string `json:"certificateIds,omitempty"` - CertificateLabel string `json:"certificateLabel,omitempty"` - San string `json:"san,omitempty"` - ResourceGroupId string `json:"resourceGroupId,omitempty"` - Expired *bool `json:"expired,omitempty"` - PageSize int `json:"pageSize,omitempty"` - PageNum int `json:"pageNum,omitempty"` -} - -type DescribeCertificatesResponse struct { - *common.BaseResponse - - RequestId string `json:"requestId,omitempty"` - - Response *struct { - RequestId string `json:"requestId,omitempty"` - - TotalCount int `json:"totalCount,omitempty"` - DataSet []*CertificateInfo `json:"dataSet,omitempty"` - } `json:"response"` -} - -type CertificateInfo struct { - CertificateId string `json:"certificateId,omitempty"` - CertificateLabel string `json:"certificateLabel,omitempty"` - Common string `json:"common,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - Issuer string `json:"issuer,omitempty"` - Sans []string `json:"sans,omitempty"` - Algorithm string `json:"algorithm,omitempty"` - CreateTime string `json:"createTime,omitempty"` - StartTime string `json:"startTime,omitempty"` - EndTime string `json:"endTime,omitempty"` - Expired bool `json:"expired,omitempty"` - ResourceGroupId string `json:"resourceGroupId,omitempty"` -} - -type CreateCertificateRequest struct { - *common.BaseRequest - - CertificateContent string `json:"certificateContent,omitempty"` - CertificateKey string `json:"certificateKey,omitempty"` - CertificateLabel string `json:"certificateLabel,omitempty"` - ResourceGroupId string `json:"resourceGroupId,omitempty"` -} - -type CreateCertificateResponse struct { - *common.BaseResponse - - RequestId string `json:"requestId,omitempty"` - - Response *struct { - RequestId string `json:"requestId,omitempty"` - - CertificateId string `json:"certificateId,omitempty"` - } `json:"response"` -} - -type ModifyCertificateRequest struct { - *common.BaseRequest - - CertificateId string `json:"certificateId,omitempty"` - CertificateContent string `json:"certificateContent,omitempty"` - CertificateKey string `json:"certificateKey,omitempty"` -} - -type ModifyCertificateResponse struct { - *common.BaseResponse - - RequestId string `json:"requestId,omitempty"` - - Response struct { - RequestId string `json:"requestId,omitempty"` - CertificateId string `json:"certificateId,omitempty"` - } `json:"response"` -} - -type DeleteCertificateRequest struct { - *common.BaseRequest - - CertificateId string `json:"certificateId,omitempty"` -} - -type DeleteCertificateResponse struct { - *common.BaseResponse - - RequestId string `json:"requestId,omitempty"` - - Response struct { - RequestId string `json:"requestId,omitempty"` - } `json:"response"` -} - -type DescribeDomainsRequest struct { - *common.BaseRequest - - DomainIds []string `json:"domainIds,omitempty"` - DomainName string `json:"domainName,omitempty"` - DomainStatus string `json:"domainStatus,omitempty"` - ConfigStatus string `json:"configStatus,omitempty"` - AccelerationRegionId string `json:"accelerationRegionId,omitempty"` - BusinessTypeId string `json:"businessTypeId,omitempty"` - ResourceGroupId string `json:"resourceGroupId,omitempty"` - PageSize int `json:"pageSize,omitempty"` - PageNum int `json:"pageNum,omitempty"` -} - -type DescribeDomainsResponse struct { - *common.BaseResponse - - RequestId string `json:"requestId,omitempty"` - - Response *struct { - RequestId string `json:"requestId,omitempty"` - - TotalCount int `json:"totalCount,omitempty"` - DataSet []*DomainInfo `json:"dataSet,omitempty"` - } `json:"response"` -} - -type DomainInfo struct { - DomainId string `json:"domainId,omitempty"` - BusinessType string `json:"businessType,omitempty"` - DomainName string `json:"domainName,omitempty"` - DomainStatus string `json:"domainStatus,omitempty"` - ConfigStatus string `json:"configStatus,omitempty"` - Cname string `json:"cname,omitempty"` - CreateTime string `json:"createTime,omitempty"` - ResourceGroupId string `json:"resourceGroupId,omitempty"` -} - -type DescribeDomainCertificateRequest struct { - *common.BaseRequest - - DomainId string `json:"domainId,omitempty"` -} - -type DescribeDomainCertificateResponse struct { - *common.BaseResponse - - RequestId string `json:"requestId,omitempty"` - - Response struct { - RequestId string `json:"requestId,omitempty"` - - Certificate *CertificateInfo `json:"certificate,omitempty"` - } `json:"response"` -} - -type ModifyDomainCertificateRequest struct { - *common.BaseRequest - - DomainId string `json:"domainId,omitempty"` - CertificateId string `json:"certificateId,omitempty"` -} - -type ModifyDomainCertificateResponse struct { - *common.BaseResponse - - RequestId string `json:"requestId,omitempty"` - - Response struct { - RequestId string `json:"requestId,omitempty"` - } `json:"response"` -} diff --git a/pkg/sdk3rd/zenlayer/zga/client.go b/pkg/sdk3rd/zenlayer/zga/client.go deleted file mode 100644 index 2e7db14ee..000000000 --- a/pkg/sdk3rd/zenlayer/zga/client.go +++ /dev/null @@ -1,107 +0,0 @@ -// An extension SDK client for ZenlayerCloud CDN service. -// Based on github.com/zenlayer/zenlayercloud-sdk-go. -package zga - -import ( - "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common" - zga20230706 "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/zga20230706" -) - -const ( - APIVersion = zga20230706.APIVersion - SERVICE = zga20230706.SERVICE -) - -type Client struct { - common.Client -} - -func NewClientWithSecretKey(secretKeyId, secretKeyPassword string) (client *Client, err error) { - return NewClient(common.NewConfig(), secretKeyId, secretKeyPassword) -} - -func NewClient(config *common.Config, secretKeyId, secretKeyPassword string) (client *Client, err error) { - client = &Client{} - - err = client.InitWithCredential(common.NewCredential(secretKeyId, secretKeyPassword)) - if err != nil { - return nil, err - } - - err = client.WithConfig(config) - if err != nil { - return nil, err - } - - return client, nil -} - -func NewCreateCertificateRequest() (request *CreateCertificateRequest) { - return zga20230706.NewCreateCertificateRequest() -} - -func NewCreateCertificateResponse() (response *CreateCertificateResponse) { - return zga20230706.NewCreateCertificateResponse() -} - -func (c *Client) CreateCertificate(request *CreateCertificateRequest) (response *CreateCertificateResponse, err error) { - response = NewCreateCertificateResponse() - err = c.ApiCall(request, response) - return -} - -func NewDescribeAcceleratorsRequest() (request *DescribeAcceleratorsRequest) { - return zga20230706.NewDescribeAcceleratorsRequest() -} - -func NewDescribeAcceleratorsResponse() (response *DescribeAcceleratorsResponse) { - return zga20230706.NewDescribeAcceleratorsResponse() -} - -func (c *Client) DescribeAccelerators(request *DescribeAcceleratorsRequest) (response *DescribeAcceleratorsResponse, err error) { - response = NewDescribeAcceleratorsResponse() - err = c.ApiCall(request, response) - return -} - -func NewDescribeCertificatesRequest() (request *DescribeCertificatesRequest) { - return zga20230706.NewDescribeCertificatesRequest() -} - -func NewDescribeCertificatesResponse() (response *DescribeCertificatesResponse) { - return zga20230706.NewDescribeCertificatesResponse() -} - -func (c *Client) DescribeCertificates(request *DescribeCertificatesRequest) (response *DescribeCertificatesResponse, err error) { - response = NewDescribeCertificatesResponse() - err = c.ApiCall(request, response) - return -} - -func NewModifyAcceleratorCertificateRequest() (request *ModifyAcceleratorCertificateRequest) { - return zga20230706.NewModifyAcceleratorCertificateRequest() -} - -func NewModifyAcceleratorCertificateResponse() (response *ModifyAcceleratorCertificateResponse) { - return zga20230706.NewModifyAcceleratorCertificateResponse() -} - -func (c *Client) ModifyAcceleratorCertificate(request *ModifyAcceleratorCertificateRequest) (response *ModifyAcceleratorCertificateResponse, err error) { - response = NewModifyAcceleratorCertificateResponse() - err = c.ApiCall(request, response) - return -} - -func NewModifyCertificateRequest() (request *ModifyCertificateRequest) { - return zga20230706.NewModifyCertificateRequest() -} - -func NewModifyCertificateResponse() (response *ModifyCertificateResponse) { - return zga20230706.NewModifyCertificateResponse() -} - -func (c *Client) ModifyCertificate(request *ModifyCertificateRequest) (response *ModifyCertificateResponse, err error) { - response = NewModifyCertificateResponse() - err = c.ApiCall(request, response) - return -} diff --git a/pkg/sdk3rd/zenlayer/zga/models.go b/pkg/sdk3rd/zenlayer/zga/models.go deleted file mode 100644 index e9c1f847e..000000000 --- a/pkg/sdk3rd/zenlayer/zga/models.go +++ /dev/null @@ -1,25 +0,0 @@ -package zga - -import ( - zga20230706 "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/zga20230706" -) - -type CreateCertificateRequest = zga20230706.CreateCertificateRequest - -type CreateCertificateResponse = zga20230706.CreateCertificateResponse - -type DescribeAcceleratorsRequest = zga20230706.DescribeAcceleratorsRequest - -type DescribeAcceleratorsResponse = zga20230706.DescribeAcceleratorsResponse - -type DescribeCertificatesRequest = zga20230706.DescribeCertificatesRequest - -type DescribeCertificatesResponse = zga20230706.DescribeCertificatesResponse - -type ModifyAcceleratorCertificateRequest = zga20230706.ModifyAcceleratorCertificateRequest - -type ModifyAcceleratorCertificateResponse = zga20230706.ModifyAcceleratorCertificateResponse - -type ModifyCertificateRequest = zga20230706.ModifyCertificateRequest - -type ModifyCertificateResponse = zga20230706.ModifyCertificateResponse diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx index 3d3c45b12..4ae1918c0 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx @@ -112,7 +112,6 @@ import AccessConfigFieldsProviderTencentCloud from "./AccessConfigFieldsProvider import AccessConfigFieldsProviderTodayNIC from "./AccessConfigFieldsProviderTodayNIC"; import AccessConfigFieldsProviderUCloud from "./AccessConfigFieldsProviderUCloud"; import AccessConfigFieldsProviderUniCloud from "./AccessConfigFieldsProviderUniCloud"; -import AccessConfigFieldsProviderUpyun from "./AccessConfigFieldsProviderUpyun"; import AccessConfigFieldsProviderVercel from "./AccessConfigFieldsProviderVercel"; import AccessConfigFieldsProviderVolcEngine from "./AccessConfigFieldsProviderVolcEngine"; import AccessConfigFieldsProviderVultr from "./AccessConfigFieldsProviderVultr"; @@ -122,7 +121,6 @@ import AccessConfigFieldsProviderWeComBot from "./AccessConfigFieldsProviderWeCo import AccessConfigFieldsProviderWestcn from "./AccessConfigFieldsProviderWestcn"; import AccessConfigFieldsProviderXinnet from "./AccessConfigFieldsProviderXinnet"; import AccessConfigFieldsProviderYandexCloud from "./AccessConfigFieldsProviderYandexCloud"; -import AccessConfigFieldsProviderZenlayer from "./AccessConfigFieldsProviderZenlayer"; import AccessConfigFieldsProviderZeroSSL from "./AccessConfigFieldsProviderZeroSSL"; const providerComponentMap: Partial>> = { @@ -238,7 +236,6 @@ const providerComponentMap: Partial { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> - } - > - - - - } - > - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - username: "", - password: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { - const { t: _ } = i18n; - - return z.object({ - username: z.string().nonempty(), - password: z.string().nonempty(), - }); -}; - -const _default = Object.assign(AccessConfigFormFieldsProviderUpyun, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderZenlayer.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderZenlayer.tsx deleted file mode 100644 index 8d844adcf..000000000 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderZenlayer.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import { useFormNestedFieldsContext } from "./_context"; - -const AccessConfigFormFieldsProviderZenlayer = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> - } - > - - - - } - > - - - - } - > - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - accessKeyId: "", - accessKeyPassword: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { - const { t: _ } = i18n; - - return z.object({ - accessKeyId: z.string().nonempty(), - accessKeyPassword: z.string().nonempty(), - resourceGroupId: z.string().nullish(), - }); -}; - -const _default = Object.assign(AccessConfigFormFieldsProviderZenlayer, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index 8001027f3..c07208fa9 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -124,8 +124,6 @@ import BizDeployNodeConfigFieldsProviderUCloudUEWAF from "./BizDeployNodeConfigF import BizDeployNodeConfigFieldsProviderUCloudUPathX from "./BizDeployNodeConfigFieldsProviderUCloudUPathX"; import BizDeployNodeConfigFieldsProviderUCloudUS3 from "./BizDeployNodeConfigFieldsProviderUCloudUS3"; import BizDeployNodeConfigFieldsProviderUniCloudWebHost from "./BizDeployNodeConfigFieldsProviderUniCloudWebHost"; -import BizDeployNodeConfigFieldsProviderUpyunCDN from "./BizDeployNodeConfigFieldsProviderUpyunCDN"; -import BizDeployNodeConfigFieldsProviderUpyunFile from "./BizDeployNodeConfigFieldsProviderUpyunFile"; import BizDeployNodeConfigFieldsProviderVolcEngineALB from "./BizDeployNodeConfigFieldsProviderVolcEngineALB"; import BizDeployNodeConfigFieldsProviderVolcEngineAPIG from "./BizDeployNodeConfigFieldsProviderVolcEngineAPIG"; import BizDeployNodeConfigFieldsProviderVolcEngineCDN from "./BizDeployNodeConfigFieldsProviderVolcEngineCDN"; @@ -142,8 +140,6 @@ import BizDeployNodeConfigFieldsProviderWangsuCDNPro from "./BizDeployNodeConfig import BizDeployNodeConfigFieldsProviderWangsuCertificate from "./BizDeployNodeConfigFieldsProviderWangsuCertificate"; import BizDeployNodeConfigFieldsProviderWebhook from "./BizDeployNodeConfigFieldsProviderWebhook"; import BizDeployNodeConfigFieldsProviderYandexCloudCertificateManager from "./BizDeployNodeConfigFieldsProviderYandexCloudCertificateManager"; -import BizDeployNodeConfigFieldsProviderZenlayerCDN from "./BizDeployNodeConfigFieldsProviderZenlayerCDN"; -import BizDeployNodeConfigFieldsProviderZenlayerGA from "./BizDeployNodeConfigFieldsProviderZenlayerGA"; import SchemaConfigFields from "./SchemaConfigFields"; const providerComponentMap: Partial>> = { @@ -272,8 +268,6 @@ const providerComponentMap: Partial any; deps?: unknown[] }) => { diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunCDN.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunCDN.tsx deleted file mode 100644 index c9a7fb9f8..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunCDN.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input, Radio } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import Show from "@/components/Show"; -import Tips from "@/components/Tips"; -import { isDomain } from "@/utils/validator"; - -import { useFormNestedFieldsContext } from "./_context"; - -const DOMAIN_MATCH_PATTERN_EXACT = "exact" as const; -const DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" as const; -const DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" as const; - -const BizDeployNodeConfigFieldsProviderUpyunCDN = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { form: formInst, preserve: true }); - - return ( - <> - - } /> - - - - ) : ( - void 0 - ) - } - rules={[formRule]} - > - ({ - label: t(`workflow_node.deploy.form.shared_domain_match_pattern.option.${s}.label`), - value: s, - }))} - /> - - - - - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z - .object({ - domainMatchPattern: z.string().nonempty().default(DOMAIN_MATCH_PATTERN_EXACT), - domain: z.string().nullish(), - }) - .superRefine((values, ctx) => { - if (values.domainMatchPattern) { - switch (values.domainMatchPattern) { - case DOMAIN_MATCH_PATTERN_EXACT: - case DOMAIN_MATCH_PATTERN_WILDCARD: - { - const scDomain = z.string().refine((v) => isDomain(v, { allowWildcard: true }), t("common.errmsg.domain_invalid")); - const spDomain = scDomain.safeParse(values.domain); - if (!spDomain.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spDomain.error).errors.join(), - path: ["domain"], - }); - } - } - break; - } - } - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderUpyunCDN, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunFile.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunFile.tsx deleted file mode 100644 index 589127f7c..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderUpyunFile.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import Tips from "@/components/Tips"; -import { isDomain } from "@/utils/validator"; - -import { useFormNestedFieldsContext } from "./_context"; - -const BizDeployNodeConfigFieldsProviderUpyunFile = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const initialValues = getInitialValues(); - - return ( - <> - - } /> - - - - - - - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - bucket: "", - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z.object({ - bucket: z.string().nonempty(), - domain: z.string().refine((v) => isDomain(v), t("common.errmsg.domain_invalid")), - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderUpyunFile, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerCDN.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerCDN.tsx deleted file mode 100644 index 31586406d..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerCDN.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input, Radio, Select } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import Show from "@/components/Show"; -import { isDomain } from "@/utils/validator"; - -import { useFormNestedFieldsContext } from "./_context"; - -const DEPLOY_TARGET_DOMAIN = "domain" as const; -const DEPLOY_TARGET_CERTIFICATE = "certificate" as const; - -const DOMAIN_MATCH_PATTERN_EXACT = "exact" as const; -const DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" as const; -const DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" as const; - -const BizDeployNodeConfigFieldsProviderZenlayerCDN = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const fieldResourceType = Form.useWatch([parentNamePath, "deployTarget"], formInst); - const fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { form: formInst, preserve: true }); - - return ( - <> - - - - - - - } - > - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - deployTarget: DEPLOY_TARGET_DOMAIN, - domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, - domain: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t } = i18n; - - return z - .object({ - deployTarget: z.enum([DEPLOY_TARGET_DOMAIN, DEPLOY_TARGET_CERTIFICATE]), - domainMatchPattern: z.string().nonempty().default(DOMAIN_MATCH_PATTERN_EXACT), - domain: z.string().nullish(), - certificateId: z.string().nullish(), - }) - .superRefine((values, ctx) => { - switch (values.deployTarget) { - case DEPLOY_TARGET_DOMAIN: - { - const scDomainMatchPattern = z.string().nonempty(); - const spDomainMatchPattern = scDomainMatchPattern.safeParse(values.domainMatchPattern); - if (!spDomainMatchPattern.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spDomainMatchPattern.error).errors.join(), - path: ["domainMatchPattern"], - }); - } - - switch (values.domainMatchPattern) { - case DOMAIN_MATCH_PATTERN_EXACT: - case DOMAIN_MATCH_PATTERN_WILDCARD: - { - const scDomain = z.string().refine((v) => isDomain(v, { allowWildcard: true }), t("common.errmsg.domain_invalid")); - const spDomain = scDomain.safeParse(values.domain); - if (!spDomain.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spDomain.error).errors.join(), - path: ["domain"], - }); - } - } - break; - } - } - break; - - case DEPLOY_TARGET_CERTIFICATE: - { - const scCertificateId = z.string().nonempty(); - const spCertificateId = scCertificateId.safeParse(values.certificateId); - if (!spCertificateId.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spCertificateId.error).errors.join(), - path: ["certificateId"], - }); - } - } - break; - } - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderZenlayerCDN, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerGA.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerGA.tsx deleted file mode 100644 index cc2e506d2..000000000 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerGA.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input, Select } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import Show from "@/components/Show"; - -import { useFormNestedFieldsContext } from "./_context"; - -const DEPLOY_TARGET_ACCELERATOR = "accelerator" as const; -const DEPLOY_TARGET_CERTIFICATE = "certificate" as const; - -const BizDeployNodeConfigFieldsProviderZenlayerGA = () => { - const { i18n, t } = useTranslation(); - - const { parentNamePath } = useFormNestedFieldsContext(); - const formSchema = z.object({ - [parentNamePath]: getSchema({ i18n }), - }); - const formRule = createSchemaFieldRule(formSchema); - const formInst = Form.useFormInstance(); - const initialValues = getInitialValues(); - - const fieldResourceType = Form.useWatch([parentNamePath, "deployTarget"], formInst); - - return ( - <> - - - - - - - } - > - - - - - ); -}; - -const getInitialValues = (): Nullish>> => { - return { - deployTarget: DEPLOY_TARGET_ACCELERATOR, - acceleratorId: "", - }; -}; - -const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t: _ } = i18n; - - return z - .object({ - deployTarget: z.enum([DEPLOY_TARGET_ACCELERATOR, DEPLOY_TARGET_CERTIFICATE]), - acceleratorId: z.string().nullish(), - certificateId: z.string().nullish(), - }) - .superRefine((values, ctx) => { - switch (values.deployTarget) { - case DEPLOY_TARGET_ACCELERATOR: - { - const scAcceleratorId = z.string().nonempty(); - const spAcceleratorId = scAcceleratorId.safeParse(values.acceleratorId); - if (!spAcceleratorId.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spAcceleratorId.error).errors.join(), - path: ["acceleratorId"], - }); - } - } - break; - - case DEPLOY_TARGET_CERTIFICATE: - { - const scCertificateId = z.string().nonempty(); - const spCertificateId = scCertificateId.safeParse(values.certificateId); - if (!spCertificateId.success) { - ctx.addIssue({ - code: "custom", - message: z.treeifyError(spCertificateId.error).errors.join(), - path: ["certificateId"], - }); - } - } - break; - } - }); -}; - -const _default = Object.assign(BizDeployNodeConfigFieldsProviderZenlayerGA, { - getInitialValues, - getSchema, -}); - -export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index dab012076..09ee54df5 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -132,7 +132,6 @@ export const ACCESS_PROVIDERS = Object.freeze({ TODAYNIC: "todaynic", UCLOUD: "ucloud", UNICLOUD: "unicloud", - UPYUN: "upyun", VERCEL: "vercel", VOLCENGINE: "volcengine", VULTR: "vultr", @@ -142,7 +141,6 @@ export const ACCESS_PROVIDERS = Object.freeze({ WESTCN: "westcn", XINNET: "xinnet", YANDEXCLOUD: "yandexcloud", - ZENLAYER: "zenlayer", ZEROSSL: "zerossl", } as const); @@ -203,14 +201,12 @@ export const accessProvidersMap: Maphttps://console.upyun.com/account/subaccount/" - }, - "upyun_password": { - "label": "UPYUN subaccount password", - "placeholder": "Please enter UPYUN subaccount password", - "tooltip": "For more information, see https://console.upyun.com/account/subaccount/" - }, "vercel_api_access_token": { "label": "Vercel API access token", "placeholder": "Please enter Vercel API access token", @@ -1630,21 +1620,6 @@ "placeholder": "Please enter Yandex Cloud service account authorized key\r\n\r\n{\r\n \"id\": \"AUTHORIZED_KEY_ID\",\r\n \"service_account_id\": \"SERVICE_ACCOUNT_ID\",\r\n \"created_at\": \"DATE\",\r\n \"key_algorithm\": \"RSA_2048\",\r\n \"public_key\": \"ENCODED_PUBLIC_KEY\",\r\n \"private_key\": \"ENCODED_PRIVATE_KEY\"\r\n}", "tooltip": "For more information, see https://yandex.cloud/en/docs/iam/operations/authentication/manage-authorized-keys" }, - "zenlayer_access_key_id": { - "label": "Zenlayer AccessKeyID", - "placeholder": "Please enter Zenlayer AccessKeyID", - "tooltip": "For more information, see https://docs.console.zenlayer.com/welcome/platform/team-management/generate-an-api-access-key" - }, - "zenlayer_access_key_password": { - "label": "Zenlayer AccessKeyPassword", - "placeholder": "Please enter Zenlayer AccessKeyPassword", - "tooltip": "For more information, see https://docs.console.zenlayer.com/welcome/platform/team-management/generate-an-api-access-key" - }, - "zenlayer_resource_group_id": { - "label": "Zenlayer resource group ID (Optional)", - "placeholder": "Please enter Zenlayer resource group ID", - "tooltip": "For more information, see https://docs.console.zenlayer.com/welcome/platform/team-management/create-a-resource-group" - }, "zerossl_eab": { "guide": "Learn more about using EAB key in ZeroSSL:
https://zerossl.com/documentation/acme/" } diff --git a/ui/src/i18n/resources/en/nls.provider.json b/ui/src/i18n/resources/en/nls.provider.json index d2bbf7063..f066cb3b5 100644 --- a/ui/src/i18n/resources/en/nls.provider.json +++ b/ui/src/i18n/resources/en/nls.provider.json @@ -275,9 +275,6 @@ "todaynic": "TodayNIC.com", "unicloud": "uniCloud (DCloud)", "unicloud_webhost": "uniCloud - Web Host", - "upyun": "UPYUN", - "upyun_cdn": "UPYUN - CDN (Content Delivery Network)", - "upyun_file": "UPYUN - USS (Storage Service)", "vercel": "Vercel", "volcengine": "Volcengine", "volcengine_alb": "Volcengine - ALB (Application Load Balancer)", @@ -304,8 +301,5 @@ "yandexcloud": "Yandex Cloud", "yandexcloud_certificatemanager": "Yandex Cloud - Certificate Manager", "yandexcloud_dns": "Yandex Cloud - DNS", - "zerossl": "ZeroSSL", - "zenlayer": "Zenlayer", - "zenlayer_cdn": "Zenlayer - CDN (Content Delivery Network)", - "zenlayer_ga": "Zenlayer - ZGA (Global Accelerator)" + "zerossl": "ZeroSSL" } diff --git a/ui/src/i18n/resources/en/nls.workflow.nodes.json b/ui/src/i18n/resources/en/nls.workflow.nodes.json index a2a41ce72..b427ad163 100644 --- a/ui/src/i18n/resources/en/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/en/nls.workflow.nodes.json @@ -2661,24 +2661,6 @@ "label": "uniCloud Web host domain", "placeholder": "uniCloud Web host domain" }, - "upyun_cdn": { - "guide": "This uses webpage simulator login and does not guarantee stability. If there are any changes to the UPYUN, please create a GitHub Issue." - }, - "upyun_cdn_domain": { - "label": "UPYUN CDN domain", - "placeholder": "Please enter UPYUN CDN domain name" - }, - "upyun_file": { - "guide": "This uses webpage simulator login and does not guarantee stability. If there are any changes to the UPYUN, please create a GitHub Issue." - }, - "upyun_file_bucket": { - "label": "UPYUN USS bucket", - "placeholder": "Please enter UPYUN USS bucket name" - }, - "upyun_file_domain": { - "label": "UPYUN USS custom domain", - "placeholder": "Please enter UPYUN USS bucket custom domain name" - }, "volcengine_alb_region": { "label": "VolcEngine region", "placeholder": "Please enter VolcEngine ALB region (e.g. cn-beijing)", @@ -2890,45 +2872,6 @@ "label": "Yandex Cloud Certificate Manager certificate ID (Optional)", "placeholder": "Please enter Yandex Cloud Certificate Manager certificate ID", "help": "Notes: Leave it blank to import a new certificate; otherwise, to replace the existing one." - }, - "zenlayer_cdn_deploy_target": { - "option": { - "domain": { - "label": "Domain" - }, - "certificate": { - "label": "Certificate" - } - } - }, - "zenlayer_cdn_domain": { - "label": "Zenlayer CDN domain", - "placeholder": "Please enter Zenlayer CDN domain name" - }, - "zenlayer_cdn_certificate_id": { - "label": "Zenlayer CDN certificate ID", - "placeholder": "Please enter Zenlayer CDN certificate ID", - "tooltip": "For more information, see https://console.zenlayer.com/cdn/" - }, - "zenlayer_ga_deploy_target": { - "option": { - "accelerator": { - "label": "Accelerator" - }, - "certificate": { - "label": "Certificate" - } - } - }, - "zenlayer_ga_accelerator_id": { - "label": "Zenlayer ZGA accelerator ID", - "placeholder": "Please enter Zenlayer ZGA accelerator ID", - "tooltip": "For more information, see https://console.zenlayer.com/gia/" - }, - "zenlayer_ga_certificate_id": { - "label": "Zenlayer ZGA certificate ID", - "placeholder": "Please enter Zenlayer ZGA certificate ID", - "tooltip": "For more information, see https://console.zenlayer.com/gia/" } } }, diff --git a/ui/src/i18n/resources/zh/nls.access.json b/ui/src/i18n/resources/zh/nls.access.json index dba9735dc..c2f651d64 100644 --- a/ui/src/i18n/resources/zh/nls.access.json +++ b/ui/src/i18n/resources/zh/nls.access.json @@ -1495,16 +1495,6 @@ "label": "uniCloud 控制台密码", "placeholder": "请输入 uniCloud 控制台密码" }, - "upyun_username": { - "label": "又拍云子账号用户名", - "placeholder": "请输入又拍云子账号用户名", - "tooltip": "这是什么?请参阅 https://console.upyun.com/account/subaccount/
请关闭该账号的二次登录验证。" - }, - "upyun_password": { - "label": "又拍云子账号密码", - "placeholder": "请输入又拍云子账号密码", - "tooltip": "这是什么?请参阅 https://console.upyun.com/account/subaccount/
请关闭该账号的二次登录验证。" - }, "vercel_api_access_token": { "label": "Vercel API Access Token", "placeholder": "请输入 Vercel API Access Token", @@ -1629,21 +1619,6 @@ "placeholder": "请输入Yandex Cloud 服务账号授权密钥\r\n\r\n{\r\n \"id\": \"AUTHORIZED_KEY_ID\",\r\n \"service_account_id\": \"SERVICE_ACCOUNT_ID\",\r\n \"created_at\": \"DATE\",\r\n \"key_algorithm\": \"RSA_2048\",\r\n \"public_key\": \"ENCODED_PUBLIC_KEY\",\r\n \"private_key\": \"ENCODED_PRIVATE_KEY\"\r\n}", "tooltip": "这是什么?请参阅 https://yandex.cloud/en/docs/iam/operations/authentication/manage-authorized-keys" }, - "zenlayer_access_key_id": { - "label": "Zenlayer AccessKeyID", - "placeholder": "请输入 Zenlayer AccessKeyID", - "tooltip": "这是什么?请参阅 https://docs.console.zenlayer.com/welcome/cn/platform/team-management/generate-an-api-access-key" - }, - "zenlayer_access_key_password": { - "label": "Zenlayer AccessKeyPassword", - "placeholder": "请输入 Zenlayer AccessKeyPassword", - "tooltip": "这是什么?请参阅 https://docs.console.zenlayer.com/welcome/cn/platform/team-management/generate-an-api-access-key" - }, - "zenlayer_resource_group_id": { - "label": "Zenlayer 资源组 ID(可选)", - "placeholder": "请输入 Zenlayer 资源组 ID", - "tooltip": "这是什么?请参阅 https://docs.console.zenlayer.com/welcome/cn/platform/team-management/create-a-resource-group" - }, "zerossl_eab": { "guide": "点击下方链接了解如何获取 ZeroSSL EAB:
https://zerossl.com/documentation/acme/" } diff --git a/ui/src/i18n/resources/zh/nls.provider.json b/ui/src/i18n/resources/zh/nls.provider.json index 5ad9c91cd..b1066f6f8 100644 --- a/ui/src/i18n/resources/zh/nls.provider.json +++ b/ui/src/i18n/resources/zh/nls.provider.json @@ -275,9 +275,6 @@ "ucloud_us3": "优刻得 - 对象存储 US3", "unicloud": "uniCloud (DCloud)", "unicloud_webhost": "uniCloud - 前端网页托管", - "upyun": "又拍云", - "upyun_cdn": "又拍云 - 云分发 CDN", - "upyun_file": "又拍云 - 云存储 USS", "vercel": "Vercel", "volcengine": "火山引擎", "volcengine_alb": "火山引擎 - 应用型负载均衡 ALB", @@ -304,8 +301,5 @@ "yandexcloud": "Yandex Cloud", "yandexcloud_certificatemanager": "Yandex Cloud - 证书管理器", "yandexcloud_dns": "Yandex Cloud - DNS", - "zerossl": "ZeroSSL", - "zenlayer": "Zenlayer", - "zenlayer_cdn": "Zenlayer - 内容分发网络 CDN", - "zenlayer_ga": "Zenlayer - 全球智能加速 ZGA" + "zerossl": "ZeroSSL" } diff --git a/ui/src/i18n/resources/zh/nls.workflow.nodes.json b/ui/src/i18n/resources/zh/nls.workflow.nodes.json index ed569e283..9a6ac8756 100644 --- a/ui/src/i18n/resources/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/zh/nls.workflow.nodes.json @@ -2660,24 +2660,6 @@ "label": "uniCloud 前端网页托管网站域名", "placeholder": "请输入 uniCloud 前端网页托管网站域名" }, - "upyun_cdn": { - "guide": "由于又拍云未公开相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。" - }, - "upyun_cdn_domain": { - "label": "又拍云 CDN 加速域名", - "placeholder": "请输入又拍云 CDN 加速域名" - }, - "upyun_file": { - "guide": "由于又拍云未公开相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。" - }, - "upyun_file_bucket": { - "label": "又拍云云存储桶名", - "placeholder": "请输入又拍云云存储桶名" - }, - "upyun_file_domain": { - "label": "又拍云云存储自定义域名", - "placeholder": "请输入又拍云云存储自定义域名" - }, "volcengine_alb_region": { "label": "火山引擎服务地域", "placeholder": "请输入火山引擎 ALB 服务地域(例如:cn-beijing)", @@ -2889,45 +2871,6 @@ "label": "Yandex Cloud 证书管理器证书 ID(可选)", "placeholder": "请输入 Yandex Cloud 证书管理器证书 ID", "help": "提示:不填写时,将上传新证书;否则,将替换原证书。" - }, - "zenlayer_cdn_deploy_target": { - "option": { - "domain": { - "label": "部署到指定加速域名" - }, - "certificate": { - "label": "替换指定证书" - } - } - }, - "zenlayer_cdn_domain": { - "label": "Zenlayer CDN 加速域名", - "placeholder": "请输入 Zenlayer CDN 加速域名" - }, - "zenlayer_cdn_certificate_id": { - "label": "Zenlayer CDN 证书 ID", - "placeholder": "请输入 Zenlayer CDN 证书 ID", - "tooltip": "这是什么?请参阅 https://console.zenlayer.com/cdn/" - }, - "zenlayer_ga_deploy_target": { - "option": { - "accelerator": { - "label": "部署到指定全球网络加速器" - }, - "certificate": { - "label": "替换指定证书" - } - } - }, - "zenlayer_ga_accelerator_id": { - "label": "Zenlayer ZGA 加速器 ID", - "placeholder": "请输入 Zenlayer ZGA 加速器 ID", - "tooltip": "这是什么?请参阅 https://console.zenlayer.com/gia/" - }, - "zenlayer_ga_certificate_id": { - "label": "Zenlayer ZGA 证书 ID", - "placeholder": "请输入 Zenlayer ZGA 证书 ID", - "tooltip": "这是什么?请参阅 https://console.zenlayer.com/gia/" } } },