Replace the single global webhook secret with entity-scoped secrets stored
on each project and static site. Webhook-driven project autocreate is
removed — projects must exist before their URL can trigger deploys.
Also wires static-site webhooks (sync_trigger=push|tag), turning the
previously inert "push" trigger into a functional one: POST the site's
webhook URL from a Git provider and Tinyforge re-syncs on matching refs.
- Adds webhook_secret columns + unique indexes to projects and static_sites
- Per-entity GET/regenerate endpoints under /api/projects/{id}/webhook
and /api/sites/{id}/webhook (admin-only)
- Removes /api/settings/webhook-url and the global webhook panel
- Reusable WebhookPanel Svelte component on both detail pages, i18n in en/ru
- Tests for matcher (siteRefMatches, ParseImageRef) and handler (project
match/mismatch/404 and site push/manual/branch-skip)
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// AutoCreateProject creates a new project and a default "dev" stage from an
|
||||
// unknown image. It inspects the Docker image to extract defaults (EXPOSE port,
|
||||
// healthcheck, labels).
|
||||
//
|
||||
// The auto-created project uses:
|
||||
// - Name: derived from image name (e.g. "web-app-launcher")
|
||||
// - Image: full owner/name path
|
||||
// - Port: first EXPOSE port from the image, or 0 if none
|
||||
// - Healthcheck: from image HEALTHCHECK instruction, if present
|
||||
// - A single "dev" stage with auto_deploy=true and tag_pattern="*"
|
||||
func AutoCreateProject(
|
||||
ctx context.Context,
|
||||
st *store.Store,
|
||||
inspector ImageInspector,
|
||||
parsed ParsedImage,
|
||||
) (store.Project, store.Stage, error) {
|
||||
// Build the full image ref for inspection (registry/owner/name:tag).
|
||||
imageRef := buildImageRef(parsed)
|
||||
|
||||
var port int
|
||||
var healthcheck string
|
||||
|
||||
// Attempt to inspect the image for metadata. If inspection fails (image
|
||||
// not pulled locally), proceed with zero defaults.
|
||||
if inspector != nil {
|
||||
info, err := inspector.InspectImage(ctx, imageRef)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: image inspection failed, using defaults", "image", imageRef, "error", err)
|
||||
} else {
|
||||
port = docker.ExtractPort(info.ExposedPorts)
|
||||
healthcheck = info.Healthcheck
|
||||
}
|
||||
}
|
||||
|
||||
project, err := st.CreateProject(store.Project{
|
||||
Name: parsed.Name,
|
||||
Registry: parsed.Registry,
|
||||
Image: parsed.FullName(),
|
||||
Port: port,
|
||||
Healthcheck: healthcheck,
|
||||
Env: "{}",
|
||||
Volumes: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
return store.Project{}, store.Stage{}, fmt.Errorf("create project: %w", err)
|
||||
}
|
||||
|
||||
stage, err := st.CreateStage(store.Stage{
|
||||
ProjectID: project.ID,
|
||||
Name: "dev",
|
||||
TagPattern: "*",
|
||||
AutoDeploy: true,
|
||||
MaxInstances: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return store.Project{}, store.Stage{}, fmt.Errorf("create default stage: %w", err)
|
||||
}
|
||||
|
||||
return project, stage, nil
|
||||
}
|
||||
|
||||
// buildImageRef reconstructs a pullable image reference from parsed components.
|
||||
func buildImageRef(parsed ParsedImage) string {
|
||||
var parts []string
|
||||
if parsed.Registry != "" {
|
||||
parts = append(parts, parsed.Registry)
|
||||
}
|
||||
if parsed.Owner != "" {
|
||||
parts = append(parts, parsed.Owner)
|
||||
}
|
||||
parts = append(parts, parsed.Name)
|
||||
|
||||
ref := strings.Join(parts, "/")
|
||||
if parsed.Tag != "" {
|
||||
ref += ":" + parsed.Tag
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
+153
-81
@@ -2,17 +2,15 @@ package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
@@ -22,18 +20,27 @@ type DeployTriggerer interface {
|
||||
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
||||
}
|
||||
|
||||
// ImageInspector abstracts Docker image inspection for testability.
|
||||
type ImageInspector interface {
|
||||
InspectImage(ctx context.Context, imageRef string) (docker.ImageInfo, 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
|
||||
}
|
||||
|
||||
// Payload is the expected JSON body for a webhook request.
|
||||
// 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".
|
||||
@@ -104,23 +111,34 @@ func ParseImageRef(ref string) (ParsedImage, error) {
|
||||
|
||||
// Handler is the HTTP handler for webhook requests.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
deployer DeployTriggerer
|
||||
inspector ImageInspector
|
||||
store *store.Store
|
||||
deployer DeployTriggerer
|
||||
sites SiteSyncTriggerer
|
||||
}
|
||||
|
||||
// NewHandler creates a new webhook Handler.
|
||||
func NewHandler(st *store.Store, deployer DeployTriggerer, inspector ImageInspector) *Handler {
|
||||
return &Handler{
|
||||
store: st,
|
||||
deployer: deployer,
|
||||
inspector: inspector,
|
||||
}
|
||||
// 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 {
|
||||
return &Handler{store: st, deployer: deployer, sites: sites}
|
||||
}
|
||||
|
||||
// Route returns a chi router with the webhook endpoint mounted.
|
||||
// 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
|
||||
}
|
||||
|
||||
// Route returns a chi router with the webhook endpoints mounted.
|
||||
//
|
||||
// Routes:
|
||||
//
|
||||
// POST /{secret} — per-project deploy trigger
|
||||
// POST /sites/{secret} — per-site sync trigger
|
||||
func (h *Handler) Route() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/sites/{secret}", h.handleSiteWebhook)
|
||||
r.Post("/{secret}", h.handleWebhook)
|
||||
return r
|
||||
}
|
||||
@@ -137,9 +155,13 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) {
|
||||
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
|
||||
}
|
||||
|
||||
// handleWebhook processes an incoming webhook request.
|
||||
// URL format: POST /api/webhook/{secret-uuid}
|
||||
// Returns 404 for invalid secrets (no information leak).
|
||||
// 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()
|
||||
|
||||
@@ -149,20 +171,17 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the webhook secret against stored settings.
|
||||
settings, err := h.store.GetSettings()
|
||||
project, err := h.store.GetProjectByWebhookSecret(secret)
|
||||
if err != nil {
|
||||
slog.Error("webhook: failed to read settings", "error", err)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("webhook: project lookup failed", "error", err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.WebhookSecret == "" || subtle.ConstantTimeCompare([]byte(settings.WebhookSecret), []byte(secret)) != 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the request body.
|
||||
var payload Payload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
||||
@@ -180,37 +199,48 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Default tag to "latest" if omitted.
|
||||
if parsed.Tag == "" {
|
||||
parsed.Tag = "latest"
|
||||
}
|
||||
|
||||
slog.Info("webhook: received push", "image", parsed.FullName(), "tag", parsed.Tag)
|
||||
|
||||
// Look up a matching project by image name.
|
||||
project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed)
|
||||
if err != nil {
|
||||
slog.Error("webhook: lookup error", "error", err)
|
||||
respondWebhookError(w, http.StatusInternalServerError, "internal error")
|
||||
// Guardrail: refuse payloads whose image doesn't match the project's
|
||||
// configured image. Not a security control (the secret already scopes
|
||||
// access) — just a misconfiguration check that prevents accidental
|
||||
// cross-project deploys from a misaimed CI pipeline.
|
||||
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
|
||||
slog.Warn("webhook: image mismatch",
|
||||
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
|
||||
respondWebhookError(w, http.StatusBadRequest,
|
||||
fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image))
|
||||
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)
|
||||
respondWebhookError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
// Unknown project — auto-create with defaults from image inspection.
|
||||
slog.Info("webhook: unknown image, auto-creating project", "image", parsed.FullName())
|
||||
project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed)
|
||||
if err != nil {
|
||||
slog.Error("webhook: auto-create failed", "error", err)
|
||||
respondWebhookError(w, http.StatusInternalServerError, "failed to auto-create project")
|
||||
return
|
||||
}
|
||||
slog.Info("webhook: auto-created project", "project", project.Name, "id", project.ID, "stage", stage.Name)
|
||||
slog.Info("webhook: no stage matches tag",
|
||||
"project", project.Name, "tag", parsed.Tag)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": false, "project": project.Name,
|
||||
"reason": "no stage pattern matched tag",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Only deploy if auto_deploy is enabled for the matched stage.
|
||||
if !stage.AutoDeploy {
|
||||
slog.Info("webhook: auto_deploy disabled, skipping", "project", project.Name, "stage", stage.Name)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": false, "project": project.Name, "stage": stage.Name})
|
||||
slog.Info("webhook: auto_deploy disabled, skipping",
|
||||
"project", project.Name, "stage", stage.Name)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": false,
|
||||
"project": project.Name, "stage": stage.Name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -220,44 +250,86 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag})
|
||||
slog.Info("webhook: triggered deploy",
|
||||
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": true,
|
||||
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
|
||||
})
|
||||
}
|
||||
|
||||
// EnsureWebhookSecret checks whether a webhook secret exists in settings.
|
||||
// If not, it generates a new UUID and stores it. Returns the current secret.
|
||||
func EnsureWebhookSecret(st *store.Store) (string, error) {
|
||||
settings, err := st.GetSettings()
|
||||
// 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()
|
||||
|
||||
if h.sites == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
secret := chi.URLParam(r, "secret")
|
||||
if secret == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
site, err := h.store.GetStaticSiteByWebhookSecret(secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get settings: %w", err)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("webhook: site lookup failed", "error", err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.WebhookSecret != "" {
|
||||
return settings.WebhookSecret, nil
|
||||
// Manual sites do not auto-sync via webhook. Return success but skip.
|
||||
if site.SyncTrigger == "manual" {
|
||||
slog.Info("webhook: site sync_trigger=manual, skipping",
|
||||
"site", site.Name)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "sync": false, "site": site.Name,
|
||||
"reason": "sync_trigger is manual",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settings.WebhookSecret = uuid.New().String()
|
||||
if err := st.UpdateSettings(settings); err != nil {
|
||||
return "", fmt.Errorf("store webhook secret: %w", err)
|
||||
// Body is optional — decode best-effort.
|
||||
var payload SitePayload
|
||||
if r.ContentLength > 0 {
|
||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||
}
|
||||
|
||||
slog.Info("webhook: generated new secret")
|
||||
return settings.WebhookSecret, nil
|
||||
}
|
||||
|
||||
// RegenerateWebhookSecret generates a new webhook secret UUID, replacing and
|
||||
// invalidating the old one. Returns the new secret.
|
||||
func RegenerateWebhookSecret(st *store.Store) (string, error) {
|
||||
settings, err := st.GetSettings()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
|
||||
settings.WebhookSecret = uuid.New().String()
|
||||
if err := st.UpdateSettings(settings); err != nil {
|
||||
return "", fmt.Errorf("store webhook secret: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("webhook: regenerated secret")
|
||||
return settings.WebhookSecret, nil
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// Fire and forget — sync may take a while (git fetch + container rebuild).
|
||||
go func(siteID, siteName string) {
|
||||
if err := h.sites.Deploy(context.Background(), 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)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "sync": true, "site": site.Name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package webhook_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
+45
-55
@@ -1,67 +1,13 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// FindProjectAndStage searches for a project whose image matches the parsed
|
||||
// image reference, then finds the stage whose tag pattern matches the incoming
|
||||
// tag. Returns (project, stage, found, error).
|
||||
//
|
||||
// Matching logic:
|
||||
// 1. Iterate all projects.
|
||||
// 2. Compare the project's Image field against the parsed image's FullName().
|
||||
// 3. For the matched project, iterate its stages and find one whose TagPattern
|
||||
// matches the incoming tag using path.Match (glob semantics).
|
||||
// 4. If multiple stages match, the first match wins (stages are ordered by name).
|
||||
func FindProjectAndStage(ctx context.Context, st *store.Store, parsed ParsedImage) (store.Project, store.Stage, bool, error) {
|
||||
projects, err := st.GetAllProjects()
|
||||
if err != nil {
|
||||
return store.Project{}, store.Stage{}, false, fmt.Errorf("get projects: %w", err)
|
||||
}
|
||||
|
||||
imageName := parsed.FullName()
|
||||
|
||||
for _, project := range projects {
|
||||
if !imageMatches(project.Image, imageName) {
|
||||
continue
|
||||
}
|
||||
|
||||
stage, found, err := matchStage(st, project.ID, parsed.Tag)
|
||||
if err != nil {
|
||||
return store.Project{}, store.Stage{}, false, fmt.Errorf("match stage for project %s: %w", project.Name, err)
|
||||
}
|
||||
if found {
|
||||
return project, stage, true, nil
|
||||
}
|
||||
|
||||
// Project matches but no stage pattern matches this tag.
|
||||
// Return project with empty stage — caller can decide what to do.
|
||||
// For now, we treat it as "not found" so auto-create doesn't fire
|
||||
// for known projects with no matching stage.
|
||||
return store.Project{}, store.Stage{}, false, nil
|
||||
}
|
||||
|
||||
return store.Project{}, store.Stage{}, false, nil
|
||||
}
|
||||
|
||||
// imageMatches checks if a project's stored image name matches the parsed
|
||||
// image name. The comparison is case-sensitive and supports the project image
|
||||
// being stored as either "owner/name" or just "name".
|
||||
func imageMatches(projectImage, incomingImage string) bool {
|
||||
if projectImage == incomingImage {
|
||||
return true
|
||||
}
|
||||
// Also match if the incoming image has an owner prefix but the project
|
||||
// only stores the bare name (or vice versa). This handles registries
|
||||
// that include or omit the owner segment.
|
||||
return false
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -88,3 +34,47 @@ func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, erro
|
||||
|
||||
return store.Stage{}, false, nil
|
||||
}
|
||||
|
||||
// imageMatches reports whether an incoming image reference matches the
|
||||
// project's stored image. The comparison is case-sensitive and exact.
|
||||
func imageMatches(projectImage, incomingImage string) bool {
|
||||
return projectImage == incomingImage
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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