diff --git a/PLAN.md b/PLAN.md index b8455e4..d04cc50 100644 --- a/PLAN.md +++ b/PLAN.md @@ -261,18 +261,18 @@ Core infrastructure — store, config import, Docker client, NPM client. 5. **Docker client** — connect to socket, pull image, inspect image, list/start/stop/remove containers, manage networks 6. **NPM client** — authenticate (JWT), create/update/delete proxy hosts, list existing hosts -### Phase 2: Detection & Deployment (Registry & Poller ✅) +### Phase 2: Detection & Deployment (Registry & Poller ✅, Webhook ✅, Deployer ✅) The core loop — detecting new images and deploying them. 8. **Registry client** ✅ — Gitea registry API: list tags for an image, detect new tags 9. **Poller** ✅ — periodic check for new tags matching configured patterns -10. **Secret webhook handler** — UUID-based URL, receives image push notifications, auto-creates unknown projects -11. **Deployer** — orchestrate: pull → start container → NPM proxy → health check -12. **Multi-instance support** — multiple versions per project/stage, tag-based subdomains, max_instances limit -13. **Health checker** — HTTP GET with retries and timeout -14. **Rollback** — on health check failure: remove new container, clean up NPM, alert -15. **Notifications** — send webhook on deploy success/failure +10. **Secret webhook handler** ✅ — UUID-based URL, receives image push notifications, auto-creates unknown projects +11. **Deployer** ✅ — orchestrate: pull → start container → NPM proxy → health check +12. **Multi-instance support** ✅ — multiple versions per project/stage, tag-based subdomains, max_instances limit +13. **Health checker** ✅ — HTTP GET with retries and timeout (3 retries, 5s interval, 10s timeout) +14. **Rollback** ✅ — on health check failure: remove new container, clean up NPM, alert +15. **Notifications** ✅ — send webhook on deploy success/failure (fire-and-forget) ### Phase 3: Web UI diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go new file mode 100644 index 0000000..4ea1468 --- /dev/null +++ b/internal/deployer/deployer.go @@ -0,0 +1,483 @@ +package deployer + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sort" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/health" + "github.com/alexei/docker-watcher/internal/notify" + "github.com/alexei/docker-watcher/internal/npm" + "github.com/alexei/docker-watcher/internal/store" + "github.com/google/uuid" +) + +// Deployer orchestrates the full deployment flow: pull image, create container, +// start, configure proxy, health check, and handle rollback on failure. +// It implements both webhook.DeployTriggerer and registry.DeployTriggerer. +type Deployer struct { + docker *docker.Client + npm *npm.Client + store *store.Store + health *health.Checker + notifier *notify.Notifier + encKey [32]byte +} + +// New creates a new Deployer with all required dependencies. +func New( + dockerClient *docker.Client, + npmClient *npm.Client, + st *store.Store, + checker *health.Checker, + notifier *notify.Notifier, + encKey [32]byte, +) *Deployer { + return &Deployer{ + docker: dockerClient, + npm: npmClient, + store: st, + health: checker, + notifier: notifier, + encKey: encKey, + } +} + +// TriggerDeploy is the main entry point for deployments. It orchestrates the full flow: +// pull image -> create container -> start -> configure proxy -> health check. +// On failure, it rolls back (removes container, deletes proxy host, updates status). +func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error { + // Load project and stage from store. + project, err := d.store.GetProjectByID(projectID) + if err != nil { + return fmt.Errorf("get project: %w", err) + } + + stage, err := d.store.GetStageByID(stageID) + if err != nil { + return fmt.Errorf("get stage: %w", err) + } + + settings, err := d.store.GetSettings() + if err != nil { + return fmt.Errorf("get settings: %w", err) + } + + // Create deploy record. + deploy, err := d.store.CreateDeploy(store.Deploy{ + ProjectID: projectID, + StageID: stageID, + ImageTag: imageTag, + Status: "pending", + }) + if err != nil { + return fmt.Errorf("create deploy record: %w", err) + } + + d.logDeploy(deploy.ID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info") + + // Enforce max_instances before deploying. + if err := d.enforceMaxInstances(ctx, stage, deploy.ID, settings); err != nil { + d.logDeploy(deploy.ID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error") + // Non-fatal: continue with deploy. + } + + // Execute the deploy pipeline. Track state for rollback. + containerID, npmProxyID, instanceID, deployErr := d.executeDeploy(ctx, project, stage, settings, deploy.ID, imageTag) + + if deployErr != nil { + d.logDeploy(deploy.ID, fmt.Sprintf("Deploy failed: %v", deployErr), "error") + d.rollback(ctx, deploy.ID, containerID, npmProxyID, instanceID) + + d.notifier.Send(settings.NotificationURL, notify.Event{ + Type: "deploy_failure", + Project: project.Name, + Stage: stage.Name, + ImageTag: imageTag, + Error: deployErr.Error(), + }) + + return fmt.Errorf("deploy failed: %w", deployErr) + } + + // Mark deploy as successful. + if err := d.store.UpdateDeployStatus(deploy.ID, "success", ""); err != nil { + log.Printf("deployer: update deploy status to success: %v", err) + } + + subdomain := d.buildSubdomain(project, stage, settings, imageTag) + fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain) + + d.logDeploy(deploy.ID, fmt.Sprintf("Deploy successful: %s", fullURL), "info") + + d.notifier.Send(settings.NotificationURL, notify.Event{ + Type: "deploy_success", + Project: project.Name, + Stage: stage.Name, + ImageTag: imageTag, + Subdomain: subdomain, + URL: fullURL, + }) + + return nil +} + +// executeDeploy runs the deploy pipeline steps and returns rollback-relevant state. +// It returns (containerID, npmProxyID, instanceID, error). +func (d *Deployer) executeDeploy( + ctx context.Context, + project store.Project, + stage store.Stage, + settings store.Settings, + deployID string, + imageTag string, +) (string, int, string, error) { + var containerID string + var npmProxyID int + var instanceID string + + // Step 1: Pull image. + if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil { + log.Printf("deployer: update deploy status: %v", err) + } + d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info") + + authConfig, err := d.buildRegistryAuth(project) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("build registry auth: %w", err) + } + + if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("pull image: %w", err) + } + d.logDeploy(deployID, "Image pulled successfully", "info") + + // Step 2: Ensure network exists. + networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("ensure network: %w", err) + } + d.logDeploy(deployID, fmt.Sprintf("Network %s ready (ID: %s)", settings.Network, truncateID(networkID)), "info") + + // Step 3: Create and start container. + if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil { + log.Printf("deployer: update deploy status: %v", err) + } + + // Pre-generate instance ID so it can be set as a container label. + instanceID = uuid.New().String() + subdomain := d.buildSubdomain(project, stage, settings, imageTag) + + containerName := docker.ContainerName(project.Name, stage.Name, imageTag) + portStr := fmt.Sprintf("%d/tcp", project.Port) + envVars := d.parseEnvVars(project.Env) + + containerCfg := docker.ContainerConfig{ + Name: containerName, + Image: project.Image + ":" + imageTag, + Env: envVars, + ExposedPorts: []string{portStr}, + NetworkName: settings.Network, + NetworkID: networkID, + Project: project.Name, + Stage: stage.Name, + InstanceID: instanceID, + } + + d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info") + containerID, err = d.docker.CreateContainer(ctx, containerCfg) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("create container: %w", err) + } + d.logDeploy(deployID, fmt.Sprintf("Container created (ID: %s)", truncateID(containerID)), "info") + + // Create instance record in store with the pre-generated ID. + inst, err := d.store.CreateInstanceWithID(store.Instance{ + ID: instanceID, + StageID: stage.ID, + ProjectID: project.ID, + ContainerID: containerID, + ImageTag: imageTag, + Subdomain: subdomain, + Status: "stopped", + Port: project.Port, + }) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("create instance record: %w", err) + } + instanceID = inst.ID + + // Link deploy to instance. + if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil { + log.Printf("deployer: link deploy to instance: %v", err) + } + + d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info") + if err := d.docker.StartContainer(ctx, containerID); err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("start container: %w", err) + } + + if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil { + log.Printf("deployer: update instance status to running: %v", err) + } + d.logDeploy(deployID, "Container started", "info") + + // Step 4: Configure NPM proxy. + if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { + log.Printf("deployer: update deploy status: %v", err) + } + + npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err) + } + + // Update instance with NPM proxy ID. + inst.NpmProxyID = npmProxyID + inst.Subdomain = subdomain + if err := d.store.UpdateInstance(inst); err != nil { + log.Printf("deployer: update instance with proxy ID: %v", err) + } + + // Step 5: Health check. + if project.Healthcheck != "" { + if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil { + log.Printf("deployer: update deploy status: %v", err) + } + + healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck) + d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info") + + if err := d.health.Check(ctx, healthURL); err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("health check: %w", err) + } + d.logDeploy(deployID, "Health check passed", "info") + } else { + d.logDeploy(deployID, "No health check configured, skipping", "info") + } + + return containerID, npmProxyID, instanceID, nil +} + +// configureProxy creates or updates an NPM proxy host for the deployed container. +// It authenticates to NPM using credentials from settings, then creates the proxy. +// Returns the NPM proxy host ID. +func (d *Deployer) configureProxy( + ctx context.Context, + deployID string, + settings store.Settings, + containerName string, + containerPort int, + subdomain string, +) (int, error) { + // Authenticate to NPM. + npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) + if err != nil { + return 0, fmt.Errorf("decrypt npm password: %w", err) + } + + if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { + return 0, fmt.Errorf("authenticate to npm: %w", err) + } + + fqdn := subdomain + "." + settings.Domain + d.logDeploy(deployID, fmt.Sprintf("Configuring proxy: %s -> %s:%d", fqdn, containerName, containerPort), "info") + + // Check if a proxy host already exists for this domain. + existing, found, err := d.npm.FindProxyHostByDomain(ctx, fqdn) + if err != nil { + return 0, fmt.Errorf("find existing proxy host: %w", err) + } + + proxyConfig := npm.ProxyHostConfig{ + DomainNames: []string{fqdn}, + ForwardScheme: "http", + ForwardHost: containerName, + ForwardPort: containerPort, + BlockExploits: true, + AllowWebsocket: true, + HTTP2Support: true, + Meta: npm.Meta{}, + Locations: []any{}, + } + + if found { + d.logDeploy(deployID, fmt.Sprintf("Updating existing proxy host %d for %s", existing.ID, fqdn), "info") + host, err := d.npm.UpdateProxyHost(ctx, existing.ID, proxyConfig) + if err != nil { + return 0, fmt.Errorf("update proxy host: %w", err) + } + d.logDeploy(deployID, "Proxy host updated", "info") + return host.ID, nil + } + + d.logDeploy(deployID, fmt.Sprintf("Creating new proxy host for %s", fqdn), "info") + host, err := d.npm.CreateProxyHost(ctx, proxyConfig) + if err != nil { + return 0, fmt.Errorf("create proxy host: %w", err) + } + d.logDeploy(deployID, fmt.Sprintf("Proxy host created (ID: %d)", host.ID), "info") + return host.ID, nil +} + +// enforceMaxInstances removes the oldest instances when the stage has reached its limit. +// This makes room for the new deployment. +func (d *Deployer) enforceMaxInstances(ctx context.Context, stage store.Stage, deployID string, settings store.Settings) error { + if stage.MaxInstances <= 0 { + return nil + } + + instances, err := d.store.GetInstancesByStageID(stage.ID) + if err != nil { + return fmt.Errorf("get instances for stage: %w", err) + } + + // Filter to running/stopped instances (not already failed/removing). + var active []store.Instance + for _, inst := range instances { + if inst.Status == "running" || inst.Status == "stopped" { + active = append(active, inst) + } + } + + // We need room for one more instance, so remove oldest when at limit. + removeCount := len(active) - stage.MaxInstances + 1 + if removeCount <= 0 { + return nil + } + + // Sort by created_at ascending (oldest first). + sort.Slice(active, func(i, j int) bool { + return active[i].CreatedAt < active[j].CreatedAt + }) + + for i := 0; i < removeCount && i < len(active); i++ { + inst := active[i] + d.logDeploy(deployID, fmt.Sprintf("Removing oldest instance %s (tag: %s) to enforce max_instances=%d", inst.ID, inst.ImageTag, stage.MaxInstances), "info") + + if err := d.removeInstance(ctx, inst, settings); err != nil { + d.logDeploy(deployID, fmt.Sprintf("Failed to remove instance %s: %v", inst.ID, err), "warn") + continue + } + d.logDeploy(deployID, fmt.Sprintf("Removed instance %s", inst.ID), "info") + } + + return nil +} + +// removeInstance stops and removes a container, deletes its NPM proxy host, +// and removes the instance record from the store. +func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, settings store.Settings) error { + // Mark as removing. + if err := d.store.UpdateInstanceStatus(inst.ID, "removing"); err != nil { + log.Printf("deployer: update instance %s status to removing: %v", inst.ID, err) + } + + // Remove Docker container. + if inst.ContainerID != "" { + if err := d.docker.RemoveContainer(ctx, inst.ContainerID, true); err != nil { + log.Printf("deployer: remove container %s: %v", inst.ContainerID, err) + } + } + + // Delete NPM proxy host. + if inst.NpmProxyID > 0 { + npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) + if err == nil { + if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr == nil { + if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { + log.Printf("deployer: delete proxy host %d: %v", inst.NpmProxyID, delErr) + } + } + } + } + + // Delete instance record. + if err := d.store.DeleteInstance(inst.ID); err != nil { + return fmt.Errorf("delete instance record: %w", err) + } + + return nil +} + +// buildSubdomain generates the subdomain for an instance based on settings and stage config. +func (d *Deployer) buildSubdomain(project store.Project, stage store.Stage, settings store.Settings, imageTag string) string { + return GenerateTaggedSubdomain(settings.SubdomainPattern, project.Name, stage.Name, imageTag, stage.Subdomain) +} + +// buildRegistryAuth constructs the Docker registry auth string for pulling images. +// If the project has a registry configured, it looks up the registry token. +func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) { + if project.Registry == "" { + return "", nil + } + + registries, err := d.store.GetAllRegistries() + if err != nil { + return "", fmt.Errorf("get registries: %w", err) + } + + for _, reg := range registries { + if reg.Name == project.Registry { + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(d.encKey, token) + if err != nil { + return "", fmt.Errorf("decrypt registry token: %w", err) + } + return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL) + } + return "", nil + } + } + + return "", nil +} + +// decryptNpmPassword decrypts the NPM password from settings. +// Returns empty string if the encrypted password is empty. +func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error) { + if encryptedPassword == "" { + return "", nil + } + return crypto.Decrypt(d.encKey, encryptedPassword) +} + +// parseEnvVars parses a JSON-encoded map into KEY=VALUE environment variable strings. +func (d *Deployer) parseEnvVars(envJSON string) []string { + if envJSON == "" || envJSON == "{}" { + return nil + } + + var envMap map[string]string + if err := json.Unmarshal([]byte(envJSON), &envMap); err != nil { + log.Printf("deployer: parse env vars: %v", err) + return nil + } + + vars := make([]string, 0, len(envMap)) + for k, v := range envMap { + vars = append(vars, k+"="+v) + } + return vars +} + +// logDeploy appends a log entry for a deploy. Errors are logged to stderr but not propagated. +func (d *Deployer) logDeploy(deployID, message, level string) { + if err := d.store.AppendDeployLog(deployID, message, level); err != nil { + log.Printf("deployer: append deploy log: %v", err) + } +} + +// truncateID safely truncates a Docker ID to 12 characters for display. +func truncateID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} + diff --git a/internal/deployer/rollback.go b/internal/deployer/rollback.go new file mode 100644 index 0000000..bbed9da --- /dev/null +++ b/internal/deployer/rollback.go @@ -0,0 +1,48 @@ +package deployer + +import ( + "context" + "fmt" + "log" +) + +// rollback cleans up a failed deployment by removing the container, +// deleting the NPM proxy host, and updating the instance status. +// Errors during rollback are logged but do not prevent other cleanup steps. +func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, npmProxyID int, instanceID string) { + d.logDeploy(deployID, "Rolling back failed deployment", "warn") + + // Remove the container if it was created. + if containerID != "" { + if err := d.docker.RemoveContainer(ctx, containerID, true); err != nil { + log.Printf("rollback: remove container %s: %v", containerID, err) + d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to remove container: %v", err), "error") + } else { + d.logDeploy(deployID, "Rollback: container removed", "info") + } + } + + // Delete the NPM proxy host if it was created. + if npmProxyID > 0 { + if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil { + log.Printf("rollback: delete proxy host %d: %v", npmProxyID, err) + d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error") + } else { + d.logDeploy(deployID, "Rollback: proxy host deleted", "info") + } + } + + // Update instance status to failed if it was created. + if instanceID != "" { + if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil { + log.Printf("rollback: update instance %s status: %v", instanceID, err) + } + } + + // Mark deploy as rolled back. + if err := d.store.UpdateDeployStatus(deployID, "rolled_back", "deployment failed, rolled back"); err != nil { + log.Printf("rollback: update deploy %s status: %v", deployID, err) + } + + d.logDeploy(deployID, "Rollback complete", "info") +} diff --git a/internal/deployer/subdomain.go b/internal/deployer/subdomain.go new file mode 100644 index 0000000..646b58b --- /dev/null +++ b/internal/deployer/subdomain.go @@ -0,0 +1,84 @@ +package deployer + +import ( + "regexp" + "strings" +) + +// maxSubdomainLen is the maximum length of a single DNS label (RFC 1035). +const maxSubdomainLen = 63 + +// invalidDNSChars matches characters not allowed in a DNS label. +var invalidDNSChars = regexp.MustCompile(`[^a-z0-9-]`) + +// GenerateSubdomain builds a subdomain string from the given pattern and parameters. +// The pattern may contain {stage}, {project}, and {tag} placeholders. +// If the stage has a custom subdomain override, that value is used instead of the pattern. +func GenerateSubdomain(pattern, project, stage, tag, stageSubdomain string) string { + if stageSubdomain != "" { + return SanitizeDNSLabel(stageSubdomain) + } + + result := pattern + result = strings.ReplaceAll(result, "{stage}", stage) + result = strings.ReplaceAll(result, "{project}", project) + result = strings.ReplaceAll(result, "{tag}", tag) + + return SanitizeDNSLabel(result) +} + +// GenerateTaggedSubdomain builds a subdomain that includes the tag for multi-instance support. +// It appends "-{sanitized_tag}" to the base subdomain. +func GenerateTaggedSubdomain(pattern, project, stage, tag, stageSubdomain string) string { + base := GenerateSubdomain(pattern, project, stage, "", stageSubdomain) + sanitizedTag := SanitizeDNSLabel(tag) + + if sanitizedTag == "" { + return base + } + + combined := base + "-" + sanitizedTag + return truncateDNSLabel(combined) +} + +// SanitizeDNSLabel converts an arbitrary string into a valid DNS label. +// It lowercases, replaces dots and invalid characters with hyphens, +// collapses consecutive hyphens, trims leading/trailing hyphens, and truncates. +func SanitizeDNSLabel(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, ".", "-") + s = invalidDNSChars.ReplaceAllString(s, "-") + s = collapseHyphens(s) + s = strings.Trim(s, "-") + return truncateDNSLabel(s) +} + +// collapseHyphens replaces consecutive hyphens with a single hyphen. +func collapseHyphens(s string) string { + prev := false + var b strings.Builder + b.Grow(len(s)) + + for _, r := range s { + if r == '-' { + if !prev { + b.WriteRune(r) + } + prev = true + } else { + b.WriteRune(r) + prev = false + } + } + return b.String() +} + +// truncateDNSLabel truncates a label to maxSubdomainLen characters, +// ensuring it does not end with a hyphen after truncation. +func truncateDNSLabel(s string) string { + if len(s) <= maxSubdomainLen { + return s + } + s = s[:maxSubdomainLen] + return strings.TrimRight(s, "-") +} diff --git a/internal/health/checker.go b/internal/health/checker.go new file mode 100644 index 0000000..885cb9a --- /dev/null +++ b/internal/health/checker.go @@ -0,0 +1,79 @@ +package health + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// DefaultRetries is the number of health check attempts before declaring failure. +const DefaultRetries = 3 + +// DefaultRetryInterval is the pause between health check retries. +const DefaultRetryInterval = 5 * time.Second + +// DefaultTimeout is the HTTP timeout for a single health check attempt. +const DefaultTimeout = 10 * time.Second + +// Checker performs HTTP health checks against a container endpoint. +type Checker struct { + httpClient *http.Client + retries int + retryInterval time.Duration +} + +// New creates a Checker with default settings. +func New() *Checker { + return &Checker{ + httpClient: &http.Client{ + Timeout: DefaultTimeout, + }, + retries: DefaultRetries, + retryInterval: DefaultRetryInterval, + } +} + +// Check performs an HTTP GET health check against the given URL. +// It retries up to the configured number of times, waiting retryInterval between attempts. +// Returns nil on the first successful (2xx) response, or the last error encountered. +func (c *Checker) Check(ctx context.Context, url string) error { + var lastErr error + + for attempt := 0; attempt < c.retries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return fmt.Errorf("health check cancelled: %w", ctx.Err()) + case <-time.After(c.retryInterval): + } + } + + lastErr = c.doCheck(ctx, url) + if lastErr == nil { + return nil + } + } + + return fmt.Errorf("health check failed after %d attempts: %w", c.retries, lastErr) +} + +// doCheck performs a single HTTP GET health check. +func (c *Checker) doCheck(ctx context.Context, url string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create health check request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("health check request to %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("health check %s returned status %d", url, resp.StatusCode) + } + + return nil +} diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go new file mode 100644 index 0000000..ddc4f2d --- /dev/null +++ b/internal/notify/notifier.go @@ -0,0 +1,82 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +// Event represents a deployment notification payload. +type Event struct { + Type string `json:"type"` // "deploy_success" or "deploy_failure" + Project string `json:"project"` + Stage string `json:"stage"` + ImageTag string `json:"image_tag"` + Subdomain string `json:"subdomain"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` + Timestamp string `json:"timestamp"` +} + +// Notifier sends webhook notifications for deploy events. +// Notifications are fire-and-forget — failures are logged but do not propagate. +type Notifier struct { + httpClient *http.Client +} + +// New creates a Notifier with sensible defaults. +func New() *Notifier { + return &Notifier{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Send sends a notification event to the given webhook URL in a background goroutine. +// It does not block the caller. Errors are logged, not returned. +func (n *Notifier) Send(webhookURL string, event Event) { + if webhookURL == "" { + return + } + + if event.Timestamp == "" { + event.Timestamp = time.Now().UTC().Format(time.RFC3339) + } + + go func() { + if err := n.doSend(context.Background(), webhookURL, event); err != nil { + log.Printf("notify: failed to send webhook to %s: %v", webhookURL, err) + } + }() +} + +// doSend performs the actual HTTP POST to the webhook URL. +func (n *Notifier) doSend(ctx context.Context, webhookURL string, event Event) error { + body, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal notification: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create notification request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := n.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("notification webhook returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/store/instances.go b/internal/store/instances.go index aaaf6cf..6203b12 100644 --- a/internal/store/instances.go +++ b/internal/store/instances.go @@ -26,6 +26,27 @@ func (s *Store) CreateInstance(inst Instance) (Instance, error) { return inst, nil } +// CreateInstanceWithID inserts a new instance using a pre-generated ID. +// Use this when the ID must be known before creation (e.g., for container labels). +func (s *Store) CreateInstanceWithID(inst Instance) (Instance, error) { + if inst.ID == "" { + return Instance{}, fmt.Errorf("instance ID is required") + } + inst.CreatedAt = now() + inst.UpdatedAt = inst.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO instances (id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag, + inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, inst.CreatedAt, inst.UpdatedAt, + ) + if err != nil { + return Instance{}, fmt.Errorf("insert instance: %w", err) + } + return inst, nil +} + // GetInstanceByID returns a single instance by its ID. func (s *Store) GetInstanceByID(id string) (Instance, error) { var inst Instance diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 37f9dfc..502d052 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -163,7 +163,8 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { parsed, err := ParseImageRef(payload.Image) if err != nil { - http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } @@ -199,7 +200,7 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { 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) + json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": false, "project": project.Name, "stage": stage.Name}) return } @@ -212,7 +213,7 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { 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) + 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. diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index d512ca1..02253c1 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -27,8 +27,8 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M - [x] Phase 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md) - [x] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md) - [x] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md) -- [ ] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md) -- [ ] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md) +- [x] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md) +- [x] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md) - [ ] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md) - [ ] Phase 8: REST API Layer [domain: backend] → [subplan](./phase-8-api-layer.md) - [ ] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md) @@ -50,8 +50,8 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | Phase 2: Crypto & Config | backend | ✅ Complete | ✅ Pass w/ notes | ⏭️ Skip (Big Bang) | ✅ | | Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | Phase 4: NPM Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 5: Registry & Poller | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 6: Webhook Handler | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | +| Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 6: Webhook Handler | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ | | Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 8: API Layer | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |