feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s

The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.

Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
  static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
  stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
  workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
  rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
  dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
  internal/stack/manager.go gone (the rest of those packages stay as
  helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
  gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
  regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
  SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
  minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
  staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
  SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
  table (projects, stages, stage_env, volumes, deploys, deploy_logs,
  poll_states, stacks, stack_revisions, stack_deploys, static_sites,
  static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
  Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
  StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
  GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
  so api + store paths share one secret-generation impl (no
  panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
  + static-site label paths; only canonical tinyforge.workload.id
  dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
  path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
  private (no external callers)

Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
  helper + types (Project, Stage, Stack, StaticSite, Deploy,
  Instance, Volume, etc.); kept Workload, Container, App, Settings,
  Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
  api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
  /deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
  listWorkloads + listContainers only; 4-card stat grid
  (workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
  ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
  proxies/+page.svelte, containers/+page.svelte all rewired to the
  workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
  SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
  volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
  instance.*, confirm.* namespaces; en/ru parity preserved (1042
  keys each)

Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):

- Sec H1: dead-end workload webhook URL handlers (would mint URLs
  that 404 the new trigger-only ingress) deleted across backend +
  frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
  store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
  field names, workloadIDRow rationale, webhook_deliveries.target_type
  enum, WebhookDeliveryLog component header

Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
  items are now shipped. Next focus is Priority 3 polish (apps.* i18n
  + codemap entries) and Priority 4 tests.

Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
  /api/webhook/sites/{secret} return 404; CI configs must repoint to
  /api/webhook/triggers/{secret} (the trigger-split boot backfill
  lifted any embedded workload secret onto a Trigger row, so the
  secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
  links replaced with /apps and /triggers.
This commit is contained in:
2026-05-16 06:00:21 +03:00
parent 234c3c711e
commit 739b67856a
101 changed files with 1116 additions and 20768 deletions
+33 -458
View File
@@ -6,13 +6,10 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
@@ -125,28 +122,12 @@ func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified
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.
@@ -155,23 +136,10 @@ type PluginDispatcher interface {
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 {
// parsedImage holds the components extracted from a full image reference
// string. Package-private — the only callers are buildInboundEvent and the
// vendor parsers in this package.
type parsedImage struct {
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
Registry string
// Owner is the namespace/org, e.g. "alexei".
@@ -182,28 +150,28 @@ type ParsedImage struct {
Tag string
}
// FullName returns "owner/name" (the image path without registry and tag).
func (p ParsedImage) FullName() 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.
// 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) {
func parseImageRef(ref string) (parsedImage, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return ParsedImage{}, fmt.Errorf("empty image reference")
return parsedImage{}, fmt.Errorf("empty image reference")
}
var parsed ParsedImage
var parsed parsedImage
// Split off tag.
if idx := strings.LastIndex(ref, ":"); idx != -1 {
@@ -232,81 +200,45 @@ func ParseImageRef(ref string) (ParsedImage, error) {
}
if parsed.Name == "" {
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
return parsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
}
return parsed, nil
}
// Handler is the HTTP handler for webhook requests.
// Handler is the HTTP handler for webhook requests. After the legacy
// project / site webhook routes were dropped, the only inbound path is
// the trigger fan-out — every project / site / stack webhook was lifted
// into a first-class Trigger row by the boot backfill.
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{}
store *store.Store
plugins PluginDispatcher // optional; nil disables /triggers/{secret}
}
// 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
// NewHandler creates a new webhook Handler bound to a store.
func NewHandler(st *store.Store) *Handler {
return &Handler{store: st}
}
// SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this
// is called the /workloads/{secret} route returns 503 — preventing partial
// is called the /triggers/{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()
}
// Drain is a no-op kept for symmetry with the previous shutdown path.
// The trigger fan-out runs synchronously inside the request goroutine,
// so there is nothing to drain at the handler level.
func (h *Handler) Drain() {}
// 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.
// Route returns a chi router with the single inbound webhook endpoint
// mounted at /triggers/{secret}. Legacy /{secret} and /sites/{secret}
// routes were removed in the hard cutover; their secrets were lifted
// into Trigger rows on boot.
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
}
@@ -322,363 +254,6 @@ 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:
//
@@ -730,14 +305,14 @@ func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, e
return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload")
}
if probe.Image != "" {
parsed, err := ParseImageRef(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(),
Repo: parsed.fullName(),
Tag: parsed.Tag,
}
return evt, nil
@@ -776,8 +351,8 @@ func toPluginWorkload(w store.Workload) plugin.Workload {
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,