791cd4d6af
Build / build (push) Successful in 12m20s
Rebrand the project as Tinyforge to reflect its evolution from a Docker container watcher into a self-hosted mini CI/deployment platform. Rename covers: Go module path, Docker labels, DB/config filenames, JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend i18n, README with static sites docs, and all code comments.
264 lines
7.8 KiB
Go
264 lines
7.8 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// ImageInspector abstracts Docker image inspection for testability.
|
|
type ImageInspector interface {
|
|
InspectImage(ctx context.Context, imageRef string) (docker.ImageInfo, error)
|
|
}
|
|
|
|
// Payload is the expected JSON body for a 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"`
|
|
}
|
|
|
|
// ParsedImage holds the components extracted from a full image reference string.
|
|
type ParsedImage struct {
|
|
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
|
|
Registry string
|
|
// Owner is the namespace/org, e.g. "alexei".
|
|
Owner string
|
|
// Name is the repository name, e.g. "web-app-launcher".
|
|
Name string
|
|
// Tag is the image tag, e.g. "dev-abc123". Empty string means "latest".
|
|
Tag string
|
|
}
|
|
|
|
// FullName returns "owner/name" (the image path without registry and tag).
|
|
func (p ParsedImage) FullName() string {
|
|
if p.Owner != "" {
|
|
return p.Owner + "/" + p.Name
|
|
}
|
|
return p.Name
|
|
}
|
|
|
|
// ParseImageRef splits a full image reference into its components.
|
|
// Accepted formats:
|
|
//
|
|
// registry.example.com/owner/name:tag
|
|
// registry.example.com/owner/name
|
|
// owner/name:tag
|
|
// name:tag
|
|
func ParseImageRef(ref string) (ParsedImage, error) {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
return ParsedImage{}, fmt.Errorf("empty image reference")
|
|
}
|
|
|
|
var parsed ParsedImage
|
|
|
|
// Split off tag.
|
|
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
|
// Make sure the colon is not inside the registry host (e.g. "localhost:5000/img").
|
|
afterColon := ref[idx+1:]
|
|
if !strings.Contains(afterColon, "/") {
|
|
parsed.Tag = afterColon
|
|
ref = ref[:idx]
|
|
}
|
|
}
|
|
|
|
parts := strings.Split(ref, "/")
|
|
switch len(parts) {
|
|
case 1:
|
|
// "name"
|
|
parsed.Name = parts[0]
|
|
case 2:
|
|
// "owner/name"
|
|
parsed.Owner = parts[0]
|
|
parsed.Name = parts[1]
|
|
default:
|
|
// "registry/owner/name" or "registry/owner/sub/name" — first segment is registry.
|
|
parsed.Registry = parts[0]
|
|
parsed.Owner = strings.Join(parts[1:len(parts)-1], "/")
|
|
parsed.Name = parts[len(parts)-1]
|
|
}
|
|
|
|
if parsed.Name == "" {
|
|
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
|
|
}
|
|
|
|
return parsed, nil
|
|
}
|
|
|
|
// Handler is the HTTP handler for webhook requests.
|
|
type Handler struct {
|
|
store *store.Store
|
|
deployer DeployTriggerer
|
|
inspector ImageInspector
|
|
}
|
|
|
|
// NewHandler creates a new webhook Handler.
|
|
func NewHandler(st *store.Store, deployer DeployTriggerer, inspector ImageInspector) *Handler {
|
|
return &Handler{
|
|
store: st,
|
|
deployer: deployer,
|
|
inspector: inspector,
|
|
}
|
|
}
|
|
|
|
// Route returns a chi router with the webhook endpoint mounted.
|
|
func (h *Handler) Route() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Post("/{secret}", h.handleWebhook)
|
|
return r
|
|
}
|
|
|
|
// respondWebhookJSON writes a JSON response for webhook handlers.
|
|
func respondWebhookJSON(w http.ResponseWriter, status int, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(data) //nolint:errcheck
|
|
}
|
|
|
|
// respondWebhookError writes a JSON error response for webhook handlers.
|
|
func respondWebhookError(w http.ResponseWriter, status int, msg string) {
|
|
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
|
|
}
|
|
|
|
// handleWebhook processes an incoming webhook request.
|
|
// URL format: POST /api/webhook/{secret-uuid}
|
|
// Returns 404 for invalid secrets (no information leak).
|
|
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
secret := chi.URLParam(r, "secret")
|
|
if secret == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Validate the webhook secret against stored settings.
|
|
settings, err := h.store.GetSettings()
|
|
if err != nil {
|
|
slog.Error("webhook: failed to read settings", "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")
|
|
return
|
|
}
|
|
|
|
if payload.Image == "" {
|
|
respondWebhookError(w, http.StatusBadRequest, "missing image field")
|
|
return
|
|
}
|
|
|
|
parsed, err := ParseImageRef(payload.Image)
|
|
if err != nil {
|
|
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
|
|
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")
|
|
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)
|
|
}
|
|
|
|
// 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})
|
|
return
|
|
}
|
|
|
|
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
|
|
slog.Error("webhook: deploy trigger failed", "error", err)
|
|
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
|
|
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})
|
|
}
|
|
|
|
// 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()
|
|
if err != nil {
|
|
return "", fmt.Errorf("get settings: %w", err)
|
|
}
|
|
|
|
if settings.WebhookSecret != "" {
|
|
return settings.WebhookSecret, nil
|
|
}
|
|
|
|
settings.WebhookSecret = uuid.New().String()
|
|
if err := st.UpdateSettings(settings); err != nil {
|
|
return "", fmt.Errorf("store webhook secret: %w", err)
|
|
}
|
|
|
|
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
|
|
}
|