feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s
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:
+33
-458
@@ -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,
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
package webhook_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/webhook"
|
||||
)
|
||||
|
||||
// signBody computes the HMAC-SHA256 hex digest used by the X-Hub-Signature-256 header.
|
||||
func signBody(secret, body string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(body))
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// doJSONSigned mirrors doJSON but adds the X-Hub-Signature-256 header.
|
||||
func doJSONSigned(t *testing.T, r chi.Router, method, path, body, signingSecret string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if signingSecret != "" {
|
||||
req.Header.Set("X-Hub-Signature-256", signBody(signingSecret, body))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp, string(b)
|
||||
}
|
||||
|
||||
// fakeDeployer records the last trigger for assertion.
|
||||
type fakeDeployer struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
lastProj string
|
||||
lastStg string
|
||||
lastTag string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDeployer) TriggerDeploy(_ context.Context, projectID, stageID, tag string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
f.lastProj = projectID
|
||||
f.lastStg = stageID
|
||||
f.lastTag = tag
|
||||
return f.err
|
||||
}
|
||||
|
||||
// fakeSiteTriggerer records Deploy calls.
|
||||
type fakeSiteTriggerer struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (f *fakeSiteTriggerer) Deploy(_ context.Context, _ string, _ bool) error {
|
||||
f.mu.Lock()
|
||||
f.calls++
|
||||
ch := f.done
|
||||
f.mu.Unlock()
|
||||
if ch != nil {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRouter(t *testing.T, h *webhook.Handler) chi.Router {
|
||||
t.Helper()
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/api/webhook", h.Route())
|
||||
return r
|
||||
}
|
||||
|
||||
func newStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
s, err := store.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, r chi.Router, method, path, body string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp, string(b)
|
||||
}
|
||||
|
||||
func TestProjectWebhook_UnknownSecretReturns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
h := webhook.NewHandler(st, &fakeDeployer{}, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/bogus-secret", `{"image":"x"}`)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWebhook_DeploysOnMatchingStage(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
|
||||
p, err := st.CreateProject(store.Project{
|
||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
stage, err := st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "dev", TagPattern: "dev-*", AutoDeploy: true, MaxInstances: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create stage: %v", err)
|
||||
}
|
||||
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
path := "/api/webhook/" + p.WebhookSecret
|
||||
resp, body := doJSON(t, r, http.MethodPost, path, `{"image":"alexei/app:dev-abc"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if dep.calls != 1 {
|
||||
t.Fatalf("expected 1 deploy call, got %d", dep.calls)
|
||||
}
|
||||
if dep.lastProj != p.ID || dep.lastStg != stage.ID || dep.lastTag != "dev-abc" {
|
||||
t.Errorf("deploy called with wrong args: proj=%s stage=%s tag=%s",
|
||||
dep.lastProj, dep.lastStg, dep.lastTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWebhook_ImageMismatchRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
p, err := st.CreateProject(store.Project{
|
||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
if _, err := st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("create stage: %v", err)
|
||||
}
|
||||
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
||||
`{"image":"otheruser/other:dev"}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 on image mismatch, got %d", resp.StatusCode)
|
||||
}
|
||||
if dep.calls != 0 {
|
||||
t.Errorf("deploy should not have been triggered on image mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWebhook_NoMatchingStageReturns200NoDeploy(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
p, err := st.CreateProject(store.Project{
|
||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
if _, err := st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "prod", TagPattern: "v*", AutoDeploy: true, MaxInstances: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("create stage: %v", err)
|
||||
}
|
||||
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
||||
`{"image":"alexei/app:dev-abc"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if dep.calls != 0 {
|
||||
t.Errorf("expected no deploy call, got %d", dep.calls)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("response is not JSON: %v", err)
|
||||
}
|
||||
if parsed["deploy"] != false {
|
||||
t.Errorf("expected deploy=false, got %v", parsed["deploy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWebhook_AutoDeployDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
p, _ := st.CreateProject(store.Project{Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}"})
|
||||
_, _ = st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: false, MaxInstances: 1,
|
||||
})
|
||||
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
||||
`{"image":"alexei/app:dev-1"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if dep.calls != 0 {
|
||||
t.Errorf("auto_deploy=false should suppress deploy call; got %d", dep.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteWebhook_UnknownSecretReturns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
h := webhook.NewHandler(st, &fakeDeployer{}, &fakeSiteTriggerer{})
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/bogus", "{}")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteWebhook_ManualTriggerShortCircuits(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
site, err := st.CreateStaticSite(store.StaticSite{
|
||||
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
||||
Branch: "main", SyncTrigger: "manual", Status: "idle",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create site: %v", err)
|
||||
}
|
||||
|
||||
ft := &fakeSiteTriggerer{}
|
||||
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSON(t, r, http.MethodPost,
|
||||
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ft.calls != 0 {
|
||||
t.Errorf("manual-trigger site must not invoke sync; got %d calls", ft.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteWebhook_PushTriggersSyncOnBranchMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
site, err := st.CreateStaticSite(store.StaticSite{
|
||||
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
||||
Branch: "main", SyncTrigger: "push", Status: "idle",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create site: %v", err)
|
||||
}
|
||||
|
||||
ft := &fakeSiteTriggerer{done: make(chan struct{}, 1)}
|
||||
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, body := doJSON(t, r, http.MethodPost,
|
||||
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Sync runs in a goroutine — wait for the signal.
|
||||
<-ft.done
|
||||
ft.mu.Lock()
|
||||
calls := ft.calls
|
||||
ft.mu.Unlock()
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 sync call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
site, _ := st.CreateStaticSite(store.StaticSite{
|
||||
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
||||
Branch: "main", SyncTrigger: "push", Status: "idle",
|
||||
})
|
||||
|
||||
ft := &fakeSiteTriggerer{}
|
||||
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSON(t, r, http.MethodPost,
|
||||
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/feature-x"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ft.calls != 0 {
|
||||
t.Errorf("non-matching branch must not trigger sync; got %d calls", ft.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// HMAC enforcement scenarios.
|
||||
|
||||
func TestProjectWebhook_HMACRequiredAndValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
p, _ := st.CreateProject(store.Project{
|
||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
||||
})
|
||||
if _, err := st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const sig = "deadbeef-signing-secret-1234567890abcdef"
|
||||
if err := st.SetProjectWebhookSigningSecret(p.ID, sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
body := `{"image":"alexei/app:dev-abc"}`
|
||||
resp, msg := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, body, sig)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 with valid sig, got %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
if dep.calls != 1 {
|
||||
t.Errorf("valid signed deploy should fire once, got %d", dep.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWebhook_HMACRequiredButMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
p, _ := st.CreateProject(store.Project{
|
||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
||||
})
|
||||
if _, err := st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetProjectWebhookSigningSecret(p.ID, "abc-signing-secret-12345678901234567890"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("missing signature must return 401 when required, got %d", resp.StatusCode)
|
||||
}
|
||||
if dep.calls != 0 {
|
||||
t.Errorf("deploy must not fire when required signature is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWebhook_HMACPresentButWrong(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
p, _ := st.CreateProject(store.Project{
|
||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
||||
})
|
||||
if _, err := st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetProjectWebhookSigningSecret(p.ID, "real-signing-secret-1234567890abcdef"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Note: require_signature stays false — but a wrong sig must still 401.
|
||||
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
|
||||
resp, _ := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
||||
`{"image":"alexei/app:dev-abc"}`, "wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxx")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong signature must 401, got %d", resp.StatusCode)
|
||||
}
|
||||
if dep.calls != 0 {
|
||||
t.Errorf("deploy must not fire on wrong signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWebhook_HMACOptionalUnsignedAccepted(t *testing.T) {
|
||||
// require_signature=false AND signing_secret="": unsigned requests pass.
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
p, _ := st.CreateProject(store.Project{
|
||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
||||
})
|
||||
if _, err := st.CreateStage(store.Stage{
|
||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dep := &fakeDeployer{}
|
||||
h := webhook.NewHandler(st, dep, nil)
|
||||
r := newRouter(t, h)
|
||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-x"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unsigned + unconfigured should pass, got %d", resp.StatusCode)
|
||||
}
|
||||
if dep.calls != 1 {
|
||||
t.Errorf("expected 1 deploy, got %d", dep.calls)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// matchStage finds the first stage of a project whose tag pattern matches the
|
||||
// given tag. Uses path.Match for glob-style matching (same as the registry poller).
|
||||
func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, error) {
|
||||
stages, err := st.GetStagesByProjectID(projectID)
|
||||
if err != nil {
|
||||
return store.Stage{}, false, fmt.Errorf("get stages: %w", err)
|
||||
}
|
||||
|
||||
for _, stage := range stages {
|
||||
pattern := stage.TagPattern
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
|
||||
matched, err := path.Match(pattern, tag)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: invalid tag pattern, skipping stage",
|
||||
"project", projectID, "stage", stage.Name, "pattern", pattern, "error", err)
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
return stage, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return store.Stage{}, false, nil
|
||||
}
|
||||
|
||||
// imageMatches reports whether an incoming image reference matches the
|
||||
// project's stored image. The registry hostname is matched case-insensitively
|
||||
// (per RFC: registry hostnames are case-insensitive); the path/owner/name are
|
||||
// matched exactly.
|
||||
func imageMatches(projectImage, incomingImage string) bool {
|
||||
if projectImage == incomingImage {
|
||||
return true
|
||||
}
|
||||
pIdx := strings.IndexByte(projectImage, '/')
|
||||
iIdx := strings.IndexByte(incomingImage, '/')
|
||||
if pIdx <= 0 || iIdx <= 0 {
|
||||
return false
|
||||
}
|
||||
pHost, pPath := projectImage[:pIdx], projectImage[pIdx:]
|
||||
iHost, iPath := incomingImage[:iIdx], incomingImage[iIdx:]
|
||||
return strings.EqualFold(pHost, iHost) && pPath == iPath
|
||||
}
|
||||
|
||||
// siteRefMatches reports whether a Git ref (e.g. "refs/heads/main" or
|
||||
// "refs/tags/v1.2.3") targets the site's configured branch or tag pattern.
|
||||
//
|
||||
// For sync_trigger = "push": the ref must be a heads/<branch> ref whose
|
||||
// branch name equals site.Branch.
|
||||
// For sync_trigger = "tag": the ref must be a tags/<tag> ref whose tag name
|
||||
// matches site.TagPattern via glob semantics.
|
||||
// Unknown triggers return false (caller should have filtered these out).
|
||||
func siteRefMatches(site store.StaticSite, ref string) bool {
|
||||
switch site.SyncTrigger {
|
||||
case "push":
|
||||
branch, ok := strings.CutPrefix(ref, "refs/heads/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if site.Branch == "" {
|
||||
return true
|
||||
}
|
||||
return branch == site.Branch
|
||||
case "tag":
|
||||
tag, ok := strings.CutPrefix(ref, "refs/tags/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
pattern := site.TagPattern
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
matched, err := path.Match(pattern, tag)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return matched
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
func TestSiteRefMatches_Push(t *testing.T) {
|
||||
t.Parallel()
|
||||
site := store.StaticSite{SyncTrigger: "push", Branch: "main"}
|
||||
cases := []struct {
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{"refs/heads/main", true},
|
||||
{"refs/heads/develop", false},
|
||||
{"refs/tags/v1.0.0", false},
|
||||
{"", false},
|
||||
{"main", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := siteRefMatches(site, tc.ref); got != tc.want {
|
||||
t.Errorf("siteRefMatches(push, %q) = %v; want %v", tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRefMatches_PushEmptyBranchAcceptsAny(t *testing.T) {
|
||||
t.Parallel()
|
||||
// When Branch is unset, any heads ref should match — tolerates the sites
|
||||
// table having blank Branch values from legacy rows.
|
||||
site := store.StaticSite{SyncTrigger: "push"}
|
||||
if !siteRefMatches(site, "refs/heads/whatever") {
|
||||
t.Error("expected empty Branch to accept any heads ref")
|
||||
}
|
||||
if siteRefMatches(site, "refs/tags/v1") {
|
||||
t.Error("empty Branch must still reject tag refs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRefMatches_Tag(t *testing.T) {
|
||||
t.Parallel()
|
||||
site := store.StaticSite{SyncTrigger: "tag", TagPattern: "v*"}
|
||||
cases := []struct {
|
||||
ref string
|
||||
want bool
|
||||
}{
|
||||
{"refs/tags/v1.0.0", true},
|
||||
{"refs/tags/v2", true},
|
||||
{"refs/tags/hotfix", false},
|
||||
{"refs/heads/main", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := siteRefMatches(site, tc.ref); got != tc.want {
|
||||
t.Errorf("siteRefMatches(tag, %q) = %v; want %v", tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRefMatches_ManualIsIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
site := store.StaticSite{SyncTrigger: "manual", Branch: "main"}
|
||||
if siteRefMatches(site, "refs/heads/main") {
|
||||
t.Error("manual trigger must never match any ref — caller short-circuits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
wantFull string
|
||||
wantTag string
|
||||
}{
|
||||
{"registry.example.com/alexei/app:v1", "alexei/app", "v1"},
|
||||
{"alexei/app:dev", "alexei/app", "dev"},
|
||||
{"app", "app", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := ParseImageRef(tc.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseImageRef(%q) unexpected error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got.FullName() != tc.wantFull || got.Tag != tc.wantTag {
|
||||
t.Errorf("ParseImageRef(%q) = %q:%q; want %q:%q",
|
||||
tc.in, got.FullName(), got.Tag, tc.wantFull, tc.wantTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageRef_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := ParseImageRef(""); err == nil {
|
||||
t.Error("expected error for empty image ref")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user