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
|
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
|
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.
|
The core loop — detecting new images and deploying them.
|
||||||
|
|
||||||
8. **Registry client** ✅ — Gitea registry API: list tags for an image, detect new tags
|
8. **Registry client** ✅ — Gitea registry API: list tags for an image, detect new tags
|
||||||
9. **Poller** ✅ — periodic check for new tags matching configured patterns
|
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
|
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
|
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
|
12. **Multi-instance support** ✅ — multiple versions per project/stage, tag-based subdomains, max_instances limit
|
||||||
13. **Health checker** — HTTP GET with retries and timeout
|
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
|
14. **Rollback** ✅ — on health check failure: remove new container, clean up NPM, alert
|
||||||
15. **Notifications** — send webhook on deploy success/failure
|
15. **Notifications** ✅ — send webhook on deploy success/failure (fire-and-forget)
|
||||||
|
|
||||||
### Phase 3: Web UI
|
### 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
|
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.
|
// GetInstanceByID returns a single instance by its ID.
|
||||||
func (s *Store) GetInstanceByID(id string) (Instance, error) {
|
func (s *Store) GetInstanceByID(id string) (Instance, error) {
|
||||||
var inst Instance
|
var inst Instance
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
parsed, err := ParseImageRef(payload.Image)
|
parsed, err := ParseImageRef(payload.Image)
|
||||||
if err != nil {
|
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
|
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)
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
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
|
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)
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
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.
|
// 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 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 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md)
|
||||||
- [x] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-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)
|
- [x] 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 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md)
|
||||||
- [ ] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.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 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)
|
- [ ] 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 2: Crypto & Config | backend | ✅ Complete | ✅ Pass w/ notes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ 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 4: NPM Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 5: Registry & Poller | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 6: Webhook Handler | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 6: Webhook Handler | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 8: API Layer | 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) | ⬜ |
|
| Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
|
|||||||
Reference in New Issue
Block a user