package webhook import ( "context" "encoding/json" "fmt" "log" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/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 } // 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 { log.Printf("[webhook] failed to read settings: %v", err) http.NotFound(w, r) return } if settings.WebhookSecret == "" || settings.WebhookSecret != secret { http.NotFound(w, r) return } // Parse the request body. var payload Payload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, `{"error":"invalid JSON payload"}`, http.StatusBadRequest) return } if payload.Image == "" { http.Error(w, `{"error":"missing image field"}`, http.StatusBadRequest) return } parsed, err := ParseImageRef(payload.Image) if err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } // Default tag to "latest" if omitted. if parsed.Tag == "" { parsed.Tag = "latest" } log.Printf("[webhook] received push for image %s:%s", parsed.FullName(), parsed.Tag) // Look up a matching project by image name. project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed) if err != nil { log.Printf("[webhook] lookup error: %v", err) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } if !found { // Unknown project — auto-create with defaults from image inspection. log.Printf("[webhook] unknown image %s, auto-creating project", parsed.FullName()) project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed) if err != nil { log.Printf("[webhook] auto-create failed: %v", err) http.Error(w, `{"error":"failed to auto-create project"}`, http.StatusInternalServerError) return } log.Printf("[webhook] auto-created project %s (%s) with stage %s", project.Name, project.ID, stage.Name) } // Only deploy if auto_deploy is enabled for the matched stage. if !stage.AutoDeploy { log.Printf("[webhook] auto_deploy disabled for project %s stage %s, skipping deploy", project.Name, stage.Name) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": false, "project": project.Name, "stage": stage.Name}) return } if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil { log.Printf("[webhook] deploy trigger failed: %v", err) http.Error(w, `{"error":"deploy trigger failed"}`, http.StatusInternalServerError) return } log.Printf("[webhook] triggered deploy for project %s stage %s tag %s", project.Name, stage.Name, parsed.Tag) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "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) } log.Printf("[webhook] generated new webhook 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) } log.Printf("[webhook] regenerated webhook secret") return settings.WebhookSecret, nil }