diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go index 021048c..80c7b0b 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -266,16 +266,21 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS } func normalizeImage(image string) string { - parts := strings.Split(image, "@") - image = parts[0] + digest := "" + if digestIndex := strings.Index(image, "@"); digestIndex != -1 { + digest = image[digestIndex:] + image = image[:digestIndex] + } image = strings.TrimPrefix(image, "docker.io/library/") image = strings.TrimPrefix(image, "docker.io/") - if !strings.Contains(image, ":") { + lastSlash := strings.LastIndex(image, "/") + lastColon := strings.LastIndex(image, ":") + if digest == "" && lastColon <= lastSlash { image = image + ":latest" } - return image + return image + digest } func (a *Agent) reconcileOne(actual *ActualState) error { diff --git a/agent/internal/container/runtime_darwin.go b/agent/internal/container/runtime_darwin.go index 59d13bc..ec068b3 100644 --- a/agent/internal/container/runtime_darwin.go +++ b/agent/internal/container/runtime_darwin.go @@ -108,6 +108,9 @@ func Deploy(config *DeployConfig) (*DeployResult, error) { "--cap-add", "SETGID", "--cap-add", "NET_BIND_SERVICE", "--cap-add", "NET_RAW", + "--log-driver", "local", + "--log-opt", "max-size=10m", + "--log-opt", "max-file=3", } args = append(args, @@ -494,15 +497,15 @@ func writeDockerConfig(registryURL, username, password string) error { } func ImagePrune() { - exec.Command("docker", "image", "prune", "-a", "-f").Run() + exec.Command("docker", "image", "prune", "-a", "-f", "--filter", "until=168h").Run() } type dockerContainer struct { - ID string `json:"ID"` - Names string `json:"Names"` - Image string `json:"Image"` - State string `json:"State"` - Labels string `json:"Labels"` + ID string `json:"ID"` + Names string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Labels string `json:"Labels"` } func List() ([]Container, error) { diff --git a/agent/internal/container/runtime_linux.go b/agent/internal/container/runtime_linux.go index 3175254..630f5fb 100644 --- a/agent/internal/container/runtime_linux.go +++ b/agent/internal/container/runtime_linux.go @@ -106,6 +106,8 @@ func Deploy(config *DeployConfig) (*DeployResult, error) { "--cap-add", "SETGID", "--cap-add", "NET_BIND_SERVICE", "--cap-add", "NET_RAW", + "--log-opt", "max-size=10m", + "--log-opt", "max-file=3", } args = append(args, @@ -494,7 +496,7 @@ func writeDockerConfig(registryURL, username, password string) error { } func ImagePrune() { - exec.Command("podman", "image", "prune", "-a", "-f").Run() + exec.Command("podman", "image", "prune", "-a", "-f", "--filter", "until=168h").Run() } type podmanContainer struct { diff --git a/cli/src/main.ts b/cli/src/main.ts index db36e98..fdd61ed 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -357,7 +357,7 @@ service: name: ${folderName} source: type: image - image: nginx:latest + image: nginx:1.27 replicas: count: 1 ports: diff --git a/deployment/README.md b/deployment/README.md index 39154eb..3c15252 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -8,9 +8,17 @@ Docker Compose setup with Traefik for SSL termination via Let's Encrypt. cp .env.example .env # Edit .env with your values -docker compose -f compose.production.yml up -d --pull always +docker compose -f compose.production.yml up -d --pull always --remove-orphans ``` +For production hosts, cap Docker logs in `/etc/docker/daemon.json` or use the +installer, which writes bounded `json-file` log settings on fresh Docker hosts. +Prefer versioned or digest-pinned image references over mutable tags when you +operate a long-lived deployment. + +Health checks in these Compose files are for visibility. Plain Compose reports +unhealthy containers but does not restart them automatically. + ## Services | Service | Endpoint | @@ -56,5 +64,5 @@ Schema is synced automatically on container startup via `drizzle-kit push`. This ```bash docker compose -f compose.production.yml ps docker compose -f compose.production.yml logs -f -docker compose -f compose.production.yml down +docker compose -f compose.production.yml down --remove-orphans ``` diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index e4d02bb..bd5d298 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -1,10 +1,28 @@ services: + docker-socket-proxy: + image: tecnativa/docker-socket-proxy:0.3.0 + environment: + CONTAINERS: 1 + EVENTS: 1 + INFO: 1 + NETWORKS: 1 + VERSION: 1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:2375/version || exit 1"] + interval: 10s + timeout: 5s + retries: 6 + restart: unless-stopped + traefik: image: traefik:v3.6 env_file: - ./.env command: - "--providers.docker=true" + - "--providers.docker.endpoint=tcp://docker-socket-proxy:2375" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" @@ -14,12 +32,20 @@ services: - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + - "--ping=true" ports: - "80:80" - "443:443" volumes: - letsencrypt:/letsencrypt - - /var/run/docker.sock:/var/run/docker.sock:ro + depends_on: + docker-socket-proxy: + condition: service_healthy + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: 30s + timeout: 5s + retries: 3 restart: unless-stopped postgres: @@ -32,6 +58,12 @@ services: - POSTGRES_DB=${POSTGRES_DB} volumes: - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s restart: unless-stopped web: @@ -61,6 +93,18 @@ services: - "traefik.http.routers.web.entrypoints=websecure" - "traefik.http.routers.web.tls.certresolver=letsencrypt" - "traefik.http.services.web.loadbalancer.server.port=3000" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s restart: unless-stopped registry: @@ -75,6 +119,11 @@ services: - "traefik.http.routers.registry.entrypoints=websecure" - "traefik.http.routers.registry.tls.certresolver=letsencrypt" - "traefik.http.services.registry.loadbalancer.server.port=5000" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:5000/v2/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 restart: unless-stopped victoria-logs: @@ -94,6 +143,11 @@ services: - "traefik.http.routers.logs.entrypoints=websecure" - "traefik.http.routers.logs.tls.certresolver=letsencrypt" - "traefik.http.services.logs.loadbalancer.server.port=9428" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:9428/health || wget -q --spider http://127.0.0.1:9428/-/healthy"] + interval: 30s + timeout: 10s + retries: 3 restart: unless-stopped inngest: @@ -110,6 +164,12 @@ services: - "start" - "--sdk-url" - "http://web:3000/api/inngest" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8288/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s restart: unless-stopped volumes: diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml index 21c8be0..bbddda8 100644 --- a/deployment/compose.production.yml +++ b/deployment/compose.production.yml @@ -1,10 +1,28 @@ services: + docker-socket-proxy: + image: tecnativa/docker-socket-proxy:0.3.0 + environment: + CONTAINERS: 1 + EVENTS: 1 + INFO: 1 + NETWORKS: 1 + VERSION: 1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:2375/version || exit 1"] + interval: 10s + timeout: 5s + retries: 6 + restart: unless-stopped + traefik: image: traefik:v3.6 env_file: - ./.env command: - "--providers.docker=true" + - "--providers.docker.endpoint=tcp://docker-socket-proxy:2375" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" @@ -15,12 +33,20 @@ services: - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=600" + - "--ping=true" ports: - "80:80" - "443:443" volumes: - letsencrypt:/letsencrypt - - /var/run/docker.sock:/var/run/docker.sock:ro + depends_on: + docker-socket-proxy: + condition: service_healthy + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: 30s + timeout: 5s + retries: 3 restart: unless-stopped web: @@ -49,6 +75,18 @@ services: - "traefik.http.routers.web.entrypoints=websecure" - "traefik.http.routers.web.tls.certresolver=letsencrypt" - "traefik.http.services.web.loadbalancer.server.port=3000" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s restart: unless-stopped registry: @@ -63,6 +101,11 @@ services: - "traefik.http.routers.registry.entrypoints=websecure" - "traefik.http.routers.registry.tls.certresolver=letsencrypt" - "traefik.http.services.registry.loadbalancer.server.port=5000" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:5000/v2/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 restart: unless-stopped victoria-logs: @@ -82,6 +125,11 @@ services: - "traefik.http.routers.logs.entrypoints=websecure" - "traefik.http.routers.logs.tls.certresolver=letsencrypt" - "traefik.http.services.logs.loadbalancer.server.port=9428" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:9428/health || wget -q --spider http://127.0.0.1:9428/-/healthy"] + interval: 30s + timeout: 10s + retries: 3 restart: unless-stopped inngest: @@ -98,6 +146,12 @@ services: - "start" - "--sdk-url" - "http://web:3000/api/inngest" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8288/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s restart: unless-stopped volumes: diff --git a/deployment/install.sh b/deployment/install.sh index dc8b993..5016aff 100755 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -13,6 +13,8 @@ BOLD='\033[1m' NC='\033[0m' ENV_FILE="" +DOCKER_LOGGING_CONFIGURED=false +DOCKER_ALREADY_INSTALLED=false while [[ $# -gt 0 ]]; do case $1 in @@ -174,6 +176,7 @@ install_docker() { log_header "Docker Installation" if command -v docker &>/dev/null; then + DOCKER_ALREADY_INSTALLED=true log_success "Docker is already installed: $(docker --version)" else log_info "Docker not found, installing..." @@ -186,8 +189,15 @@ install_docker() { log_success "Docker installed successfully" fi + configure_docker_logging + systemctl enable docker >/dev/null 2>&1 - systemctl start docker + if [[ "$DOCKER_LOGGING_CONFIGURED" == "true" && "$DOCKER_ALREADY_INSTALLED" == "true" ]]; then + log_info "Restarting Docker to apply log rotation..." + systemctl restart docker + else + systemctl start docker + fi if docker compose version &>/dev/null; then log_success "Docker Compose plugin: $(docker compose version --short)" @@ -197,6 +207,30 @@ install_docker() { fi } +configure_docker_logging() { + local daemon_config="/etc/docker/daemon.json" + + if [[ -f "$daemon_config" ]]; then + log_warn "Docker daemon config already exists at ${daemon_config}; leaving it unchanged." + log_warn "Recommended log rotation: log-driver=json-file with max-size=10m and max-file=3." + return + fi + + log_info "Configuring Docker json-file log rotation..." + install -m 0755 -d /etc/docker + cat > "$daemon_config" <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} +EOF + DOCKER_LOGGING_CONFIGURED=true + log_success "Docker log rotation configured" +} + download_compose_files() { log_header "Downloading Compose Files" @@ -367,7 +401,7 @@ build_and_start() { cd "$DEPLOY_DIR" log_info "Pulling and starting services using ${COMPOSE_FILE}..." - docker compose -f "$COMPOSE_FILE" up -d --pull always + docker compose -f "$COMPOSE_FILE" up -d --pull always --remove-orphans echo "" log_header "Deployment Complete" @@ -388,7 +422,7 @@ build_and_start() { echo "" echo -e "${YELLOW}${BOLD}IMPORTANT:${NC} Signup is enabled. After creating your account, disable it:${NC}" echo -e " 1. Edit ${DEPLOY_DIR}/.env and set ${BOLD}ALLOW_SIGNUP=false${NC}" - echo -e " 2. Run: ${BOLD}cd ${DEPLOY_DIR} && docker compose -f ${COMPOSE_FILE} up -d${NC}" + echo -e " 2. Run: ${BOLD}cd ${DEPLOY_DIR} && docker compose -f ${COMPOSE_FILE} up -d --remove-orphans${NC}" echo "" docker compose -f "$COMPOSE_FILE" ps diff --git a/docs/installation.mdx b/docs/installation.mdx index a3ee0be..7331407 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -39,15 +39,24 @@ cp .env.example .env Edit `.env` with your values (see below), then start the stack: ```bash -docker compose -f compose.production.yml up -d --pull always +docker compose -f compose.production.yml up -d --pull always --remove-orphans ``` To use the bundled PostgreSQL instead of an external database: ```bash -docker compose -f compose.postgres.yml up -d --pull always +docker compose -f compose.postgres.yml up -d --pull always --remove-orphans ``` +Production hosts should also cap Docker container logs. The installer creates +`/etc/docker/daemon.json` with `json-file` rotation on fresh hosts. If Docker is +already configured, keep your existing daemon settings and add equivalent log +rotation manually. + +The Compose files include container health checks for visibility. Plain Docker +Compose reports unhealthy containers but does not restart them automatically, so +use the common commands below when investigating a self-hosted service. + ## Environment Variables ### Required @@ -135,8 +144,12 @@ docker compose -f compose.production.yml ps docker compose -f compose.production.yml logs -f # Stop all services -docker compose -f compose.production.yml down +docker compose -f compose.production.yml down --remove-orphans -# Update to latest version -docker compose -f compose.production.yml up -d --pull always +# Update to the configured image references +docker compose -f compose.production.yml up -d --pull always --remove-orphans ``` + +Use versioned or digest-pinned image references for production updates when +possible. Mutable tags such as `latest` and `tip` are convenient, but they can +move between pulls. diff --git a/web/SELF-HOSTING.md b/web/SELF-HOSTING.md index d9c516c..62e4310 100644 --- a/web/SELF-HOSTING.md +++ b/web/SELF-HOSTING.md @@ -18,9 +18,13 @@ cp .env.example .env Edit `.env` with your values, then: ```bash -docker compose -f compose.production.yml up -d --build +docker compose -f compose.production.yml up -d --build --remove-orphans ``` +Production hosts should cap Docker container logs in `/etc/docker/daemon.json`. +For release deployments, prefer versioned or digest-pinned image references over +mutable tags such as `latest` or `tip`. + ## Services | Service | Endpoint | @@ -82,6 +86,6 @@ Escape `$` as `$$` in the `.env` file. ```bash docker compose -f compose.production.yml ps docker compose -f compose.production.yml logs -f -docker compose -f compose.production.yml down -docker compose -f compose.production.yml up -d --build +docker compose -f compose.production.yml down --remove-orphans +docker compose -f compose.production.yml up -d --build --remove-orphans ``` diff --git a/web/actions/backups.ts b/web/actions/backups.ts index d9cae6c..b027807 100644 --- a/web/actions/backups.ts +++ b/web/actions/backups.ts @@ -7,6 +7,7 @@ import { getBackupStorageConfig } from "@/db/queries"; import { servers, volumeBackups } from "@/db/schema"; import { triggerBackup } from "@/lib/backups/trigger-backup"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; import { deleteFromS3 } from "@/lib/s3"; export async function createBackup( @@ -20,15 +21,14 @@ export async function createBackup( backupTypeOverride, }); - await inngest.send({ - name: "backup/started", - data: { + await inngest.send( + inngestEvents.backupStarted.create({ backupId: result.backupId, serviceId, volumeId, serverId: result.serverId, - }, - }); + }), + ); revalidatePath(`/dashboard/projects`); return { success: true, backupId: result.backupId }; @@ -59,14 +59,13 @@ export async function restoreBackup( backupId: string, targetServerId?: string, ) { - await inngest.send({ - name: "restore/trigger", - data: { + await inngest.send( + inngestEvents.restoreTrigger.create({ serviceId, backupId, targetServerId, - }, - }); + }), + ); revalidatePath(`/dashboard/projects`); return { success: true }; diff --git a/web/actions/builds.ts b/web/actions/builds.ts index 9c6e9db..ee2f6de 100644 --- a/web/actions/builds.ts +++ b/web/actions/builds.ts @@ -4,6 +4,7 @@ import { eq, desc } from "drizzle-orm"; import { db } from "@/db"; import { builds, githubRepos, services } from "@/db/schema"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; export async function cancelBuild(buildId: string) { const [build] = await db.select().from(builds).where(eq(builds.id, buildId)); @@ -28,13 +29,12 @@ export async function cancelBuild(buildId: string) { .set({ status: "cancelled", completedAt: new Date() }) .where(eq(builds.id, buildId)); - await inngest.send({ - name: "build/cancelled", - data: { + await inngest.send( + inngestEvents.buildCancelled.create({ buildId, buildGroupId: build.buildGroupId, - }, - }); + }), + ); return { success: true }; } @@ -50,9 +50,8 @@ export async function retryBuild(buildId: string) { throw new Error(`Cannot retry build in ${build.status} status`); } - await inngest.send({ - name: "build/trigger", - data: { + await inngest.send( + inngestEvents.buildTrigger.create({ serviceId: build.serviceId, trigger: "manual", githubRepoId: build.githubRepoId ?? undefined, @@ -60,8 +59,8 @@ export async function retryBuild(buildId: string) { commitMessage: build.commitMessage ?? "Retry build", branch: build.branch, author: build.author ?? undefined, - }, - }); + }), + ); return { success: true }; } @@ -101,9 +100,8 @@ export async function triggerBuild( .orderBy(desc(builds.createdAt)) .limit(1); - await inngest.send({ - name: "build/trigger", - data: { + await inngest.send( + inngestEvents.buildTrigger.create({ serviceId, trigger, githubRepoId: githubRepo.id, @@ -111,8 +109,8 @@ export async function triggerBuild( commitMessage: latestBuild?.commitMessage || triggerMessage, branch: latestBuild?.branch || githubRepo.deployBranch || "main", author: latestBuild?.author ?? undefined, - }, - }); + }), + ); return { success: true }; } @@ -121,16 +119,15 @@ export async function triggerBuild( throw new Error("No GitHub repository linked to this service"); } - await inngest.send({ - name: "build/trigger", - data: { + await inngest.send( + inngestEvents.buildTrigger.create({ serviceId, trigger, commitSha: "HEAD", commitMessage: triggerMessage, branch: service.githubBranch || "main", - }, - }); + }), + ); return { success: true }; } diff --git a/web/actions/migrations.ts b/web/actions/migrations.ts index 21c24aa..10ea9bf 100644 --- a/web/actions/migrations.ts +++ b/web/actions/migrations.ts @@ -7,6 +7,7 @@ import { getBackupStorageConfig } from "@/db/queries"; import { detectDatabaseType } from "@/lib/database-utils"; import { revalidatePath } from "next/cache"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; export async function startMigration( serviceId: string, @@ -86,9 +87,8 @@ export async function startMigration( }) .where(eq(services.id, serviceId)); - await inngest.send({ - name: "migration/started", - data: { + await inngest.send( + inngestEvents.migrationStarted.create({ serviceId, targetServerId, sourceServerId: deployment.serverId, @@ -96,18 +96,15 @@ export async function startMigration( sourceContainerId: deployment.containerId, volumes: volumes.map((v) => ({ id: v.id, name: v.name })), isDatabase, - }, - }); + }), + ); revalidatePath(`/dashboard/projects`); return { success: true }; } export async function cancelMigration(serviceId: string) { - await inngest.send({ - name: "migration/cancelled", - data: { serviceId }, - }); + await inngest.send(inngestEvents.migrationCancelled.create({ serviceId })); await db .update(services) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 510bfa3..abeff3c 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -39,6 +39,7 @@ import { allocatePort } from "@/lib/port-allocation"; import cronstrue from "cronstrue"; import { startMigration } from "./migrations"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; function isValidImageReferencePart(reference: string): boolean { const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; @@ -624,13 +625,12 @@ export async function deployService(serviceId: string) { currentStage: "queued", }); - await inngest.send({ - name: "rollout/created", - data: { + await inngest.send( + inngestEvents.rolloutCreated.create({ rolloutId, serviceId, - }, - }); + }), + ); return { rolloutId }; } @@ -978,10 +978,11 @@ export async function abortRollout(serviceId: string) { return { success: false, error: "No in-progress rollout found" }; } - await inngest.send({ - name: "rollout/cancelled", - data: { rolloutId: inProgressRollout.id }, - }); + await inngest.send( + inngestEvents.rolloutCancelled.create({ + rolloutId: inProgressRollout.id, + }), + ); await db .update(deployments) diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx index 54d25af..c17d280 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx @@ -1,34 +1,34 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; import { - FileText, - AlertTriangle, AlertCircle, + AlertTriangle, + ArrowLeft, Box, - HardDrive, Check, - ChevronRight, ChevronLeft, - ArrowLeft, + ChevronRight, + FileText, + HardDrive, } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; import { - parseComposeFile, importCompose, + parseComposeFile, type ServiceOverride, } from "@/actions/compose"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import type { ParsedService, - ParseWarning, ParseError, + ParseWarning, } from "@/lib/compose-parser"; type Step = "upload" | "preview" | "configure" | "importing" | "complete"; @@ -230,7 +230,7 @@ export function ImportComposeForm({ placeholder={`version: "3.8" services: web: - image: nginx:latest + image: nginx:1.27 ports: - "80:80"`} className="font-mono text-sm min-h-[300px]" diff --git a/web/app/api/v1/agent/backup/complete/route.ts b/web/app/api/v1/agent/backup/complete/route.ts index aaf64fc..fa4d026 100644 --- a/web/app/api/v1/agent/backup/complete/route.ts +++ b/web/app/api/v1/agent/backup/complete/route.ts @@ -4,6 +4,7 @@ import { volumeBackups } from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { verifyAgentRequest } from "@/lib/agent-auth"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; import { revalidatePath } from "next/cache"; export async function POST(request: NextRequest) { @@ -55,26 +56,24 @@ export async function POST(request: NextRequest) { revalidatePath("/dashboard/projects"); - await inngest.send({ - name: "backup/completed", - data: { + await inngest.send( + inngestEvents.backupCompleted.create({ backupId, volumeId: backup.volumeId, serviceId: backup.serviceId, checksum, sizeBytes, isMigrationBackup: backup.isMigrationBackup ?? false, - }, - }); + }), + ); if (backup.isMigrationBackup) { - await inngest.send({ - name: "migration/backup-completed", - data: { + await inngest.send( + inngestEvents.migrationBackupCompleted.create({ backupId, serviceId: backup.serviceId, - }, - }); + }), + ); } return NextResponse.json({ ok: true }); diff --git a/web/app/api/v1/agent/backup/failed/route.ts b/web/app/api/v1/agent/backup/failed/route.ts index f43cf67..1fcb3b4 100644 --- a/web/app/api/v1/agent/backup/failed/route.ts +++ b/web/app/api/v1/agent/backup/failed/route.ts @@ -4,6 +4,7 @@ import { volumeBackups } from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { verifyAgentRequest } from "@/lib/agent-auth"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; import { revalidatePath } from "next/cache"; export async function POST(request: NextRequest) { @@ -50,26 +51,24 @@ export async function POST(request: NextRequest) { revalidatePath("/dashboard/projects"); - await inngest.send({ - name: "backup/failed", - data: { + await inngest.send( + inngestEvents.backupFailed.create({ backupId, volumeId: backup.volumeId, serviceId: backup.serviceId, error: error || "Unknown error", isMigrationBackup: backup.isMigrationBackup ?? false, - }, - }); + }), + ); if (backup.isMigrationBackup) { - await inngest.send({ - name: "migration/backup-failed", - data: { + await inngest.send( + inngestEvents.migrationBackupFailed.create({ backupId, serviceId: backup.serviceId, error: error || "Unknown error", - }, - }); + }), + ); } return NextResponse.json({ ok: true }); diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts index 6642ad3..f0e0a2f 100644 --- a/web/app/api/v1/agent/builds/[id]/status/route.ts +++ b/web/app/api/v1/agent/builds/[id]/status/route.ts @@ -14,6 +14,7 @@ import { updateGitHubDeploymentStatus } from "@/lib/github"; import { sendBuildFailureAlert } from "@/lib/email"; import { enqueueWork } from "@/lib/work-queue"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; type StatusUpdate = { status: "cloning" | "building" | "pushing" | "completed" | "failed"; @@ -30,10 +31,7 @@ type BuildCompletedEventData = { }; async function sendBuildCompletedEvent(data: BuildCompletedEventData) { - await inngest.send({ - name: "build/completed", - data, - }); + await inngest.send(inngestEvents.buildCompleted.create(data)); } export async function POST( diff --git a/web/app/api/v1/agent/restore/complete/route.ts b/web/app/api/v1/agent/restore/complete/route.ts index 60cbca3..44c3128 100644 --- a/web/app/api/v1/agent/restore/complete/route.ts +++ b/web/app/api/v1/agent/restore/complete/route.ts @@ -4,6 +4,7 @@ import { volumeBackups } from "@/db/schema"; import { eq } from "drizzle-orm"; import { verifyAgentRequest } from "@/lib/agent-auth"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; import { revalidatePath } from "next/cache"; export async function POST(request: NextRequest) { @@ -46,46 +47,42 @@ export async function POST(request: NextRequest) { revalidatePath("/dashboard/projects"); if (success) { - await inngest.send({ - name: "restore/completed", - data: { + await inngest.send( + inngestEvents.restoreCompleted.create({ backupId, volumeId: backup.volumeId, serviceId: backup.serviceId, isMigrationRestore: isMigration, - }, - }); + }), + ); if (isMigration) { - await inngest.send({ - name: "migration/restore-completed", - data: { + await inngest.send( + inngestEvents.migrationRestoreCompleted.create({ backupId, serviceId: backup.serviceId, - }, - }); + }), + ); } } else { - await inngest.send({ - name: "restore/failed", - data: { + await inngest.send( + inngestEvents.restoreFailed.create({ backupId, volumeId: backup.volumeId, serviceId: backup.serviceId, error: error || "Restore failed", isMigrationRestore: isMigration, - }, - }); + }), + ); if (isMigration) { - await inngest.send({ - name: "migration/restore-failed", - data: { + await inngest.send( + inngestEvents.migrationRestoreFailed.create({ backupId, serviceId: backup.serviceId, error: error || "Restore failed", - }, - }); + }), + ); } } diff --git a/web/app/api/v1/agent/work-queue/complete/route.ts b/web/app/api/v1/agent/work-queue/complete/route.ts index 83046e5..c82cc0c 100644 --- a/web/app/api/v1/agent/work-queue/complete/route.ts +++ b/web/app/api/v1/agent/work-queue/complete/route.ts @@ -4,6 +4,7 @@ import { workQueue } from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { verifyAgentRequest } from "@/lib/agent-auth"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; export async function POST(request: NextRequest) { const body = await request.text(); @@ -58,24 +59,22 @@ export async function POST(request: NextRequest) { if (data.status === "completed") { if (payload.serviceId && payload.finalImageUri) { - await inngest.send({ - name: "manifest/completed", - data: { + await inngest.send( + inngestEvents.manifestCompleted.create({ serviceId: payload.serviceId, buildGroupId: payload.buildGroupId || "", imageUri: payload.finalImageUri, - }, - }); + }), + ); } } else if (data.status === "failed" && payload.serviceId) { - await inngest.send({ - name: "manifest/failed", - data: { + await inngest.send( + inngestEvents.manifestFailed.create({ serviceId: payload.serviceId, buildGroupId: payload.buildGroupId || "", error: data.error || "Manifest creation failed", - }, - }); + }), + ); } } catch (error) { console.error(`[work-queue] failed to parse payload:`, error); diff --git a/web/app/api/webhooks/github/route.ts b/web/app/api/webhooks/github/route.ts index 606f8db..cbaf324 100644 --- a/web/app/api/webhooks/github/route.ts +++ b/web/app/api/webhooks/github/route.ts @@ -13,6 +13,7 @@ import { updateGitHubDeploymentStatus, } from "@/lib/github"; import { inngest } from "@/lib/inngest/client"; +import { inngestEvents } from "@/lib/inngest/events"; type InstallationPayload = { action: "created" | "deleted" | "suspend" | "unsuspend"; @@ -191,9 +192,8 @@ async function handlePushEvent(payload: PushPayload) { console.error("[webhook:push] failed to create GitHub deployment:", error); } - await inngest.send({ - name: "build/trigger", - data: { + await inngest.send( + inngestEvents.buildTrigger.create({ serviceId: githubRepo.serviceId, trigger: "push", githubRepoId: githubRepo.id, @@ -202,8 +202,8 @@ async function handlePushEvent(payload: PushPayload) { branch, author: head_commit.author.username || head_commit.author.name, githubDeploymentId, - }, - }); + }), + ); return NextResponse.json({ ok: true }); } diff --git a/web/components/service/create-service-dialog.tsx b/web/components/service/create-service-dialog.tsx index dc74668..adc10d7 100644 --- a/web/components/service/create-service-dialog.tsx +++ b/web/components/service/create-service-dialog.tsx @@ -1,26 +1,27 @@ "use client"; -import { useState } from "react"; +import { Box, Github, Plus, Upload } from "lucide-react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import { useSWRConfig } from "swr"; import { createService, validateDockerImage } from "@/actions/projects"; +import { GitHubRepoSelector } from "@/components/github/github-repo-selector"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { GitHubRepoSelector } from "@/components/github/github-repo-selector"; -import { Box, Github, Plus, Upload } from "lucide-react"; -import Link from "next/link"; +import { imageNeedsProductionPinning } from "@/lib/docker-image"; type SelectedRepo = { id?: number; @@ -184,9 +185,15 @@ export function CreateDockerServiceDialog({ setImage(e.target.value); setError(null); }} - placeholder="nginx:latest" + placeholder="nginx:1.27" /> {error &&
{error}
} + {imageNeedsProductionPinning(image.trim()) && ( ++ Use a version tag or digest for production. Unqualified images + default to latest. +
+ )}Supported: Docker Hub, GitHub Container Registry (ghcr.io), or any public registry diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx index 72504a0..08c908a 100644 --- a/web/components/service/details/pending-changes-banner.tsx +++ b/web/components/service/details/pending-changes-banner.tsx @@ -86,9 +86,9 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({
{hasChanges ? ({imageError}
)} + {imageNeedsProductionPinning(image.trim()) && ( ++ Use a version tag or digest for production. Unqualified images + default to latest. +
+ )}
Supported: Docker Hub, GitHub Container Registry (ghcr.io), or
any public registry
diff --git a/web/db/schema.ts b/web/db/schema.ts
index e8e83bb..757b6ed 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -456,6 +456,12 @@ export const deployments = pgTable(
healthStatus: text("health_status", {
enum: ["none", "starting", "healthy", "unhealthy"],
}),
+ unhealthyReportCount: integer("unhealthy_report_count")
+ .notNull()
+ .default(0),
+ autohealRestartCount: integer("autoheal_restart_count")
+ .notNull()
+ .default(0),
rolloutId: text("rollout_id"),
previousDeploymentId: text("previous_deployment_id"),
failedStage: text("failed_stage"),
diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts
index 1e451f7..e1586ed 100644
--- a/web/lib/agent-status.ts
+++ b/web/lib/agent-status.ts
@@ -12,7 +12,12 @@ import {
services,
} from "@/db/schema";
import { inngest } from "@/lib/inngest/client";
+import { inngestEvents } from "@/lib/inngest/events";
import { ingestRolloutLog } from "@/lib/victoria-logs";
+import { enqueueWork } from "@/lib/work-queue";
+
+const AUTOHEAL_UNHEALTHY_REPORTS = 3;
+const AUTOHEAL_MAX_RESTARTS = 3;
type ContainerStatus = {
deploymentId: string;
@@ -194,14 +199,13 @@ export async function applyStatusReport(
if (!hasHealthCheck) {
if (deployment.rolloutId) {
- await inngest.send({
- name: "deployment/healthy",
- data: {
+ await inngest.send(
+ inngestEvents.deploymentHealthy.create({
deploymentId: deployment.id,
rolloutId: deployment.rolloutId,
serviceId: deployment.serviceId,
- },
- });
+ }),
+ );
}
if (service?.migrationStatus === "deploying_target") {
@@ -225,6 +229,9 @@ export async function applyStatusReport(
}
const updateFields: Record