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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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, "-")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) | ⬜ |
|
||||
|
||||
Reference in New Issue
Block a user