feat(docker-watcher): phase 6 - webhook handler

Secret UUID-based webhook endpoint for CI image push notifications.
Project/stage matching via glob patterns, auto-creation of unknown
projects from image inspection. Fix JSON response injection.
This commit is contained in:
2026-03-27 21:56:18 +03:00
parent 90be636d66
commit eef60a4302
9 changed files with 812 additions and 14 deletions
+7 -7
View File
@@ -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
+483
View File
@@ -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
}
+48
View File
@@ -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")
}
+84
View File
@@ -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, "-")
}
+79
View File
@@ -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
}
+82
View File
@@ -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
}
+21
View File
@@ -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
+4 -3
View File
@@ -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.
+4 -4
View File
@@ -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) | ⬜ |