feat(docker-watcher): phase 5 - registry client & poller
Gitea registry client with tag listing and pattern matching, cron-based polling scheduler with first-poll safety, poll state persistence. DeployTriggerer interface for decoupled deploy triggering.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/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 {
|
||||
log.Printf("[webhook] image inspection failed for %s (using defaults): %v", imageRef, err)
|
||||
} else {
|
||||
port = 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
|
||||
}
|
||||
|
||||
// extractPort parses the first exposed port from Docker EXPOSE entries.
|
||||
// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found.
|
||||
func extractPort(exposedPorts []string) int {
|
||||
if len(exposedPorts) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Take the first port entry.
|
||||
raw := exposedPorts[0]
|
||||
// Strip protocol suffix (e.g. "/tcp", "/udp").
|
||||
if idx := strings.Index(raw, "/"); idx != -1 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return port
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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 {
|
||||
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
|
||||
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)
|
||||
fmt.Fprintf(w, `{"status":"accepted","deploy":false,"project":"%s","stage":"%s"}`, project.Name, 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)
|
||||
fmt.Fprintf(w, `{"status":"accepted","deploy":true,"project":"%s","stage":"%s","tag":"%s"}`, project.Name, stage.Name, 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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"github.com/alexei/docker-watcher/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) {
|
||||
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 {
|
||||
// Invalid pattern — skip this stage.
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
return stage, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return store.Stage{}, false, nil
|
||||
}
|
||||
Reference in New Issue
Block a user