package webhook import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strings" "sync" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // Local aliases — keep the interface surface small and avoid leaking // plugin types into every consumer of the webhook package's API. type ( pluginWorkload = plugin.Workload pluginIntent = plugin.DeploymentIntent pluginDeps = plugin.Deps ) // signatureHeader is the canonical Gitea/GitHub-compatible header name for // HMAC-SHA256 signatures over the raw request body. Tinyforge accepts the // same header so existing CI integrations work unchanged. const signatureHeader = "X-Hub-Signature-256" // signature verification states recorded in the webhook delivery log. const ( sigStateUnconfigured = "unconfigured" sigStateMissing = "missing" sigStateInvalid = "invalid" sigStateValid = "valid" ) // outcome values for the delivery log. Stable identifiers — frontend keys // off these for badge colouring + i18n. const ( outcomeDeploy = "deploy" outcomeSkip = "skip" outcomeRejected = "rejected" outcomeNotFound = "not_found" outcomeBadRequest = "bad_request" outcomeError = "error" ) // signatureStateFor classifies the HMAC verification result for the delivery // log: distinguishes "no signing secret configured" from "secret configured // but caller sent nothing" so users can spot mis-configured CIs. func signatureStateFor(signingSecret, header string, verified, attempted bool) string { if signingSecret == "" { return sigStateUnconfigured } if header == "" { return sigStateMissing } if attempted && verified { return sigStateValid } return sigStateInvalid } // recordDelivery persists a single inbound webhook delivery as a best-effort // audit record. Errors are logged but never propagate — the user-visible // response must not be affected by audit-log churn. func (h *Handler) recordDelivery(d store.WebhookDelivery) { if err := h.store.InsertWebhookDelivery(d); err != nil { slog.Warn("webhook: record delivery", "error", err) } } // clientIP returns the most-trusted source IP for logging. Strips the // Forwarded-For chain to its first hop and falls back to RemoteAddr. func clientIP(r *http.Request) string { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { if i := strings.IndexByte(fwd, ','); i >= 0 { return strings.TrimSpace(fwd[:i]) } return strings.TrimSpace(fwd) } return r.RemoteAddr } // verifyHMAC validates the X-Hub-Signature-256 header against the raw body // using HMAC-SHA256. The function does the comparison in constant time. // // Behavior: // - signingSecret == "": signing not configured for this entity. The // function returns (false, false) — the caller decides whether to // enforce based on the require_signature flag. // - header missing: returns (false, true) — caller-decided. // - header malformed or signature mismatch: returns (false, true). // - signature valid: returns (true, true). // // First return: whether the signature was successfully verified. // Second return: whether the verification was attempted (i.e., a header was // present or signing is configured). The caller uses this to distinguish // "no signature submitted" from "wrong signature submitted". func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified, attempted bool) { if signingSecret == "" { return false, false } if headerValue == "" { return false, false } const prefix = "sha256=" if !strings.HasPrefix(headerValue, prefix) { return false, true } provided, err := hex.DecodeString(headerValue[len(prefix):]) if err != nil { return false, true } mac := hmac.New(sha256.New, []byte(signingSecret)) mac.Write(body) expected := mac.Sum(nil) return hmac.Equal(provided, expected), true } // maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by // webhooks. Above this limit, requests are rejected with 503. const maxSiteConcurrentSyncs = 4 // maxWebhookBodyBytes caps the request body size for webhook payloads. The // /api routes already wrap the body with MaxBytesReader, but the webhook // router relies on its own limit so changes to the parent middleware can't // silently increase the cap. const maxWebhookBodyBytes = 256 * 1024 // 256 KiB // DeployTriggerer is called when a webhook determines a deploy should happen. // Same interface as registry.DeployTriggerer — kept separate to avoid import cycles. type DeployTriggerer interface { TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error } // SiteSyncTriggerer is called when a static-site webhook determines a sync // should happen. The manager handles the actual git-pull + redeploy. type SiteSyncTriggerer interface { Deploy(ctx context.Context, siteID string, force bool) error } // PluginDispatcher is what the plugin-workload webhook handler needs from // the deployer: the canonical Source-dispatch entry point plus access to // the same Deps bundle so Trigger.Match can read store / crypto. type PluginDispatcher interface { DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) error PluginDeps() pluginDeps } // Payload is the expected JSON body for a project webhook request. type Payload struct { // Image is the full image reference including tag, e.g. // "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123". Image string `json:"image"` } // SitePayload is the expected JSON body for a static-site webhook request. // Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref // matters for branch filtering. Body is optional — an empty body triggers // a sync using the site's configured branch. type SitePayload struct { Ref string `json:"ref"` // e.g. "refs/heads/main"; optional } // ParsedImage holds the components extracted from a full image reference string. type ParsedImage struct { // Registry is the hostname, e.g. "git.dolgolyov-family.by". Registry string // Owner is the namespace/org, e.g. "alexei". Owner string // Name is the repository name, e.g. "web-app-launcher". Name string // Tag is the image tag, e.g. "dev-abc123". Empty string means "latest". Tag string } // FullName returns "owner/name" (the image path without registry and tag). func (p ParsedImage) FullName() string { if p.Owner != "" { return p.Owner + "/" + p.Name } return p.Name } // ParseImageRef splits a full image reference into its components. // Accepted formats: // // registry.example.com/owner/name:tag // registry.example.com/owner/name // owner/name:tag // name:tag func ParseImageRef(ref string) (ParsedImage, error) { ref = strings.TrimSpace(ref) if ref == "" { return ParsedImage{}, fmt.Errorf("empty image reference") } var parsed ParsedImage // Split off tag. if idx := strings.LastIndex(ref, ":"); idx != -1 { // Make sure the colon is not inside the registry host (e.g. "localhost:5000/img"). afterColon := ref[idx+1:] if !strings.Contains(afterColon, "/") { parsed.Tag = afterColon ref = ref[:idx] } } parts := strings.Split(ref, "/") switch len(parts) { case 1: // "name" parsed.Name = parts[0] case 2: // "owner/name" parsed.Owner = parts[0] parsed.Name = parts[1] default: // "registry/owner/name" or "registry/owner/sub/name" — first segment is registry. parsed.Registry = parts[0] parsed.Owner = strings.Join(parts[1:len(parts)-1], "/") parsed.Name = parts[len(parts)-1] } if parsed.Name == "" { return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref) } return parsed, nil } // Handler is the HTTP handler for webhook requests. type Handler struct { store *store.Store deployer DeployTriggerer sites SiteSyncTriggerer plugins PluginDispatcher // optional; nil disables /workloads/{secret} // Site sync coordination — webhooks fire syncs in the background; Drain // blocks until those goroutines finish, so a graceful shutdown does not // kill an in-flight git fetch + container rebuild. siteSyncCtx context.Context siteSyncCancel context.CancelFunc siteSyncWG sync.WaitGroup siteSyncSem chan struct{} } // NewHandler creates a new webhook Handler. The sites triggerer is optional // and may be nil (site webhooks will return 404). func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler { ctx, cancel := context.WithCancel(context.Background()) return &Handler{ store: st, deployer: deployer, sites: sites, siteSyncCtx: ctx, siteSyncCancel: cancel, siteSyncSem: make(chan struct{}, maxSiteConcurrentSyncs), } } // SetSiteSyncTriggerer injects the static-site manager after construction. // The site manager depends on the store + docker client, which are wired up // in the same startup path as the handler; this setter lets callers defer the // dependency if needed. func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) { h.sites = s } // SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this // is called the /workloads/{secret} route returns 503 — preventing partial // initialization from silently dropping deploys. func (h *Handler) SetPluginDispatcher(d PluginDispatcher) { h.plugins = d } // Drain cancels in-flight site syncs and waits for their goroutines to exit. // Safe to call from a graceful-shutdown path. func (h *Handler) Drain() { h.siteSyncCancel() h.siteSyncWG.Wait() } // Route returns a chi router with the webhook endpoints mounted. // // Routes: // // POST /{secret} — per-project deploy trigger (legacy) // POST /sites/{secret} — per-site sync trigger (legacy) // POST /triggers/{secret} — first-class trigger fan-out to all bound workloads // // The legacy POST /workloads/{secret} route was dropped in the // trigger-split refactor. Existing inbound webhook secrets were lifted // into trigger rows by the boot backfill, so the same secret value // works at /triggers/{secret} after the upgrade. func (h *Handler) Route() chi.Router { r := chi.NewRouter() r.Post("/sites/{secret}", h.handleSiteWebhook) r.Post("/triggers/{secret}", h.handleTriggerWebhook) r.Post("/{secret}", h.handleWebhook) return r } // respondWebhookJSON writes a JSON response for webhook handlers. func respondWebhookJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) //nolint:errcheck } // respondWebhookError writes a JSON error response for webhook handlers. func respondWebhookError(w http.ResponseWriter, status int, msg string) { respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg}) } // handleWebhook processes an incoming project webhook request. // // URL: POST /api/webhook/{secret} // // The secret identifies exactly one project. Stage routing is delegated to // the project's configured stages (tag_pattern match). Returns 404 for // unknown secrets (no information leak). func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Build the audit record incrementally; record on every return path so // users can debug "why didn't my deploy fire?" without grepping logs. delivery := store.WebhookDelivery{ TargetType: "project", SourceIP: clientIP(r), SignatureState: sigStateUnconfigured, StatusCode: http.StatusOK, Outcome: outcomeSkip, } defer func() { h.recordDelivery(delivery) }() secret := chi.URLParam(r, "secret") if secret == "" { delivery.StatusCode = http.StatusNotFound delivery.Outcome = outcomeNotFound http.NotFound(w, r) return } // Resolve the secret via the workload row only. The project's own // webhook_secret column is the source of truth, but lookups go through // workloads.webhook_secret which is kept in lock-step by the // transactional sync in the project CRUD path. Reading from workloads // alone closes the rotation-durability gap: any rotation that didn't // commit also didn't update the workload row, so an old secret // surfaces here as 404 rather than being silently accepted. var ( project store.Project err error ) wl, wErr := h.store.GetWorkloadByWebhookSecret(secret) if wErr == nil && wl.Kind == string(store.WorkloadKindProject) { project, err = h.store.GetProjectByID(wl.RefID) } else { err = store.ErrNotFound } if err != nil { if errors.Is(err, store.ErrNotFound) { delivery.StatusCode = http.StatusNotFound delivery.Outcome = outcomeNotFound delivery.Detail = "unknown webhook secret" http.NotFound(w, r) return } slog.Error("webhook: project lookup failed", "error", err) delivery.StatusCode = http.StatusNotFound delivery.Outcome = outcomeError delivery.Detail = "lookup failed" http.NotFound(w, r) return } delivery.TargetID = project.ID delivery.TargetName = project.Name // Read body once so we can both verify HMAC and decode JSON. body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) if err != nil { delivery.StatusCode = http.StatusBadRequest delivery.Outcome = outcomeBadRequest delivery.Detail = "failed to read request body" respondWebhookError(w, http.StatusBadRequest, "failed to read request body") return } delivery.BodySize = len(body) // HMAC enforcement: a configured signing secret + the require_signature // flag together produce a hard reject on missing/invalid signatures. // When the flag is off we still verify any submitted signature so a // CI misconfiguration surfaces as a 401 rather than silent acceptance. header := r.Header.Get(signatureHeader) verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, header) delivery.SignatureState = signatureStateFor(project.WebhookSigningSecret, header, verified, attempted) if project.WebhookRequireSignature && !verified { slog.Warn("webhook: signature required but invalid/missing", "project", project.Name) delivery.StatusCode = http.StatusUnauthorized delivery.Outcome = outcomeRejected delivery.Detail = "invalid or missing signature" respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") return } if attempted && !verified { slog.Warn("webhook: bad signature", "project", project.Name) delivery.StatusCode = http.StatusUnauthorized delivery.Outcome = outcomeRejected delivery.Detail = "invalid signature" respondWebhookError(w, http.StatusUnauthorized, "invalid signature") return } var payload Payload if err := json.Unmarshal(body, &payload); err != nil { delivery.StatusCode = http.StatusBadRequest delivery.Outcome = outcomeBadRequest delivery.Detail = "invalid JSON payload" respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload") return } if payload.Image == "" { delivery.StatusCode = http.StatusBadRequest delivery.Outcome = outcomeBadRequest delivery.Detail = "missing image field" respondWebhookError(w, http.StatusBadRequest, "missing image field") return } parsed, err := ParseImageRef(payload.Image) if err != nil { delivery.StatusCode = http.StatusBadRequest delivery.Outcome = outcomeBadRequest delivery.Detail = "invalid image reference" respondWebhookError(w, http.StatusBadRequest, "invalid image reference") return } if parsed.Tag == "" { parsed.Tag = "latest" } if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) { slog.Warn("webhook: image mismatch", "project", project.Name, "expected", project.Image, "received", parsed.FullName()) delivery.StatusCode = http.StatusBadRequest delivery.Outcome = outcomeBadRequest delivery.Detail = fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image) respondWebhookError(w, http.StatusBadRequest, delivery.Detail) return } slog.Info("webhook: received push", "project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag) stage, found, err := matchStage(h.store, project.ID, parsed.Tag) if err != nil { slog.Error("webhook: stage match failed", "project", project.Name, "error", err) delivery.StatusCode = http.StatusInternalServerError delivery.Outcome = outcomeError delivery.Detail = "stage match failed" respondWebhookError(w, http.StatusInternalServerError, "internal error") return } if !found { slog.Info("webhook: no stage matches tag", "project", project.Name, "tag", parsed.Tag) delivery.Detail = fmt.Sprintf("no stage matches tag %q", parsed.Tag) respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "deploy": false, "project": project.Name, "reason": "no stage pattern matched tag", }) return } if !stage.AutoDeploy { slog.Info("webhook: auto_deploy disabled, skipping", "project", project.Name, "stage", stage.Name) delivery.Detail = fmt.Sprintf("stage %q has auto_deploy disabled", stage.Name) respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "deploy": false, "project": project.Name, "stage": stage.Name, }) return } if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil { slog.Error("webhook: deploy trigger failed", "error", err) delivery.StatusCode = http.StatusInternalServerError delivery.Outcome = outcomeError delivery.Detail = "deploy trigger failed: " + err.Error() respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed") return } slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", parsed.Tag) delivery.Outcome = outcomeDeploy delivery.Detail = fmt.Sprintf("stage=%s tag=%s", stage.Name, parsed.Tag) respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag, }) } // handleSiteWebhook processes an incoming static-site webhook request. // // URL: POST /api/webhook/sites/{secret} // // The secret identifies exactly one static site. If the payload includes a // ref (Git push event), it must match the site's configured branch (when the // site's sync_trigger is "push"). For tag-based sync, the ref must match the // stored tag pattern. Manual-trigger sites ignore webhooks entirely. func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() delivery := store.WebhookDelivery{ TargetType: "site", SourceIP: clientIP(r), SignatureState: sigStateUnconfigured, StatusCode: http.StatusOK, Outcome: outcomeSkip, } defer func() { h.recordDelivery(delivery) }() if h.sites == nil { delivery.StatusCode = http.StatusNotFound delivery.Outcome = outcomeNotFound delivery.Detail = "static site manager not configured" http.NotFound(w, r) return } secret := chi.URLParam(r, "secret") if secret == "" { delivery.StatusCode = http.StatusNotFound delivery.Outcome = outcomeNotFound http.NotFound(w, r) return } // Workload-only lookup, mirroring the project handler. Reading from // workloads.webhook_secret keeps rotation-durability honest — a // rotation that didn't commit doesn't update the workload row, so the // stale secret returns 404 instead of being silently accepted. var ( site store.StaticSite err error ) wl, wErr := h.store.GetWorkloadByWebhookSecret(secret) if wErr == nil && wl.Kind == string(store.WorkloadKindSite) { site, err = h.store.GetStaticSiteByID(wl.RefID) } else { err = store.ErrNotFound } if err != nil { if errors.Is(err, store.ErrNotFound) { delivery.StatusCode = http.StatusNotFound delivery.Outcome = outcomeNotFound delivery.Detail = "unknown webhook secret" http.NotFound(w, r) return } slog.Error("webhook: site lookup failed", "error", err) delivery.StatusCode = http.StatusNotFound delivery.Outcome = outcomeError delivery.Detail = "lookup failed" http.NotFound(w, r) return } delivery.TargetID = site.ID delivery.TargetName = site.Name if site.SyncTrigger == "manual" { slog.Info("webhook: site sync_trigger=manual, skipping", "site", site.Name) delivery.Detail = "sync_trigger=manual" respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "sync": false, "site": site.Name, "reason": "sync_trigger is manual", }) return } var payload SitePayload body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) if err != nil { delivery.StatusCode = http.StatusBadRequest delivery.Outcome = outcomeBadRequest delivery.Detail = "failed to read request body" respondWebhookError(w, http.StatusBadRequest, "failed to read request body") return } delivery.BodySize = len(body) header := r.Header.Get(signatureHeader) verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header) delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted) if site.WebhookRequireSignature && !verified { slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name) delivery.StatusCode = http.StatusUnauthorized delivery.Outcome = outcomeRejected delivery.Detail = "invalid or missing signature" respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") return } if attempted && !verified { slog.Warn("webhook: site bad signature", "site", site.Name) delivery.StatusCode = http.StatusUnauthorized delivery.Outcome = outcomeRejected delivery.Detail = "invalid signature" respondWebhookError(w, http.StatusUnauthorized, "invalid signature") return } if len(body) > 0 { if err := json.Unmarshal(body, &payload); err != nil { delivery.StatusCode = http.StatusBadRequest delivery.Outcome = outcomeBadRequest delivery.Detail = "invalid JSON payload" respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload") return } } if payload.Ref != "" && !siteRefMatches(site, payload.Ref) { slog.Info("webhook: site ref does not match configured branch/tag", "site", site.Name, "ref", payload.Ref, "branch", site.Branch, "tag_pattern", site.TagPattern, "trigger", site.SyncTrigger) delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref) respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "sync": false, "site": site.Name, "reason": "ref does not match configured branch or tag pattern", }) return } select { case h.siteSyncSem <- struct{}{}: default: delivery.StatusCode = http.StatusServiceUnavailable delivery.Outcome = outcomeError delivery.Detail = "site sync queue full" respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full") return } h.siteSyncWG.Add(1) go func(siteID, siteName string) { defer h.siteSyncWG.Done() defer func() { <-h.siteSyncSem }() if err := h.sites.Deploy(h.siteSyncCtx, siteID, false); err != nil { slog.Error("webhook: site sync failed", "site", siteName, "error", err) } }(site.ID, site.Name) _ = ctx slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref) delivery.Outcome = outcomeDeploy if payload.Ref != "" { delivery.Detail = fmt.Sprintf("ref=%s", payload.Ref) } else { delivery.Detail = "no ref filter" } respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "sync": true, "site": site.Name, }) } // buildInboundEvent normalizes the incoming webhook body into the // plugin.InboundEvent shape. The dispatch order is: // // 1. Empty body → manual event (used by the test-trigger UI button). // 2. Vendor-specific parsers (Gitea package, GitHub registry_package, // GitHub/Gitea/GitLab push) — short-circuit on a recognized // X-*-Event header. Vendor parsers can fully populate richer fields // (image digest, vendor tag, branch) the generic parser cannot. // 3. Generic simple-body parser: top-level `image` for registry pushes, // top-level `ref` for git pushes. This is what the legacy webhook // CIs already send and what the operator-facing API surface // documents. // // RawBody and Headers are always attached so trigger plugins can do // their own vendor-specific parsing if they need fields outside this // normalized envelope. func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, error) { evt := plugin.InboundEvent{ RawBody: body, Headers: headers, } if len(body) == 0 { evt.Kind = "manual" evt.Manual = &plugin.ManualEvent{Actor: "webhook"} return evt, nil } // Try vendor-specific parsers first. A vendor parser claiming the // request (ok=true) is authoritative — we don't fall through even // if it returned an error, because the operator's CI is sending a // known vendor payload and silently re-parsing as generic would // hide the real cause. if res := tryVendorParsers(body, headers); res.ok { if res.err != nil { return plugin.InboundEvent{}, res.err } res.event.RawBody = body res.event.Headers = headers return res.event, nil } // Generic simple-body fallback: covers the canonical `{image: ...}` // and `{ref: ..., repository: {...}}` payloads documented in // docs/webhooks.md. var probe struct { Image string `json:"image"` } if err := json.Unmarshal(body, &probe); err != nil { return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload") } if probe.Image != "" { parsed, err := ParseImageRef(probe.Image) if err != nil { return plugin.InboundEvent{}, fmt.Errorf("invalid image reference") } evt.Kind = "image-push" evt.Image = &plugin.ImagePushEvent{ Registry: parsed.Registry, Repo: parsed.FullName(), Tag: parsed.Tag, } return evt, nil } gitEvt, err := parseGenericGitPush(body) if err != nil { // "missing ref" here means we got JSON with neither `image` nor // `ref` — surface the operator-facing message documented in // docs/webhooks.md rather than the lower-level parser error. if strings.Contains(err.Error(), "missing ref") { return plugin.InboundEvent{}, fmt.Errorf("payload must include either 'image' or 'ref'") } return plugin.InboundEvent{}, err } gitEvt.RawBody = body gitEvt.Headers = headers return gitEvt, nil } // toPluginWorkload mirrors the api-layer converter but kept local so the // webhook package does not depend on internal/api. Inlining is cheap and // avoids elevating that converter to a shared package. func toPluginWorkload(w store.Workload) plugin.Workload { var faces []plugin.PublicFace if w.PublicFaces != "" { _ = json.Unmarshal([]byte(w.PublicFaces), &faces) } return plugin.Workload{ ID: w.ID, Name: w.Name, GroupID: w.AppID, ParentWorkloadID: w.ParentWorkloadID, SourceKind: w.SourceKind, SourceConfig: json.RawMessage(w.SourceConfig), TriggerKind: w.TriggerKind, TriggerConfig: json.RawMessage(w.TriggerConfig), PublicFaces: faces, NotificationURL: w.NotificationURL, NotificationSecret: w.NotificationSecret, WebhookSecret: w.WebhookSecret, WebhookSigningSecret: w.WebhookSigningSecret, WebhookRequireSignature: w.WebhookRequireSignature, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, } }