From 6e90fc939898e7b648f7e0d23a08cdb6e89f04dd Mon Sep 17 00:00:00 2001 From: Techulus Agent Date: Tue, 23 Jun 2026 22:21:30 +1000 Subject: [PATCH 01/10] add react doctor action --- .github/workflows/react-doctor.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/react-doctor.yml diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 0000000..a54ea68 --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,26 @@ +name: React Doctor + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +concurrency: + group: react-doctor-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: millionco/react-doctor@v2 From dc2e0997146a86d5a96678a143960da885d7b8fc Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:30:31 +1000 Subject: [PATCH 02/10] Fix React Doctor high severity issues Gate exported server actions behind session checks, move state-changing GET handlers to POST, update the agent build claim client, and add pnpm supply-chain hardening. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019ef472-35a5-714b-959d-c18c6afcceed --- agent/internal/agent/handlers.go | 4 +- agent/internal/http/client.go | 8 +-- web/actions/backups.ts | 5 ++ web/actions/builds.ts | 4 ++ web/actions/compose.ts | 10 +-- web/actions/migrations.ts | 4 ++ web/actions/projects.ts | 26 ++++++++ web/actions/secrets.ts | 3 + web/actions/servers.ts | 11 +++- web/actions/settings.ts | 6 ++ web/app/api/github/setup/route.ts | 72 +++++++++++++++++++-- web/app/api/projects/[id]/services/route.ts | 12 ++-- web/app/api/v1/agent/builds/[id]/route.ts | 2 +- web/lib/auth.ts | 23 +++++++ web/pnpm-workspace.yaml | 3 + 15 files changed, 165 insertions(+), 28 deletions(-) diff --git a/agent/internal/agent/handlers.go b/agent/internal/agent/handlers.go index 9cba7a6..dad56d9 100644 --- a/agent/internal/agent/handlers.go +++ b/agent/internal/agent/handlers.go @@ -127,9 +127,9 @@ func (a *Agent) ProcessBuild(item agenthttp.WorkQueueItem) error { a.buildMutex.Unlock() }() - buildDetails, err := a.Client.GetBuild(payload.BuildID) + buildDetails, err := a.Client.ClaimBuild(payload.BuildID) if err != nil { - return fmt.Errorf("failed to get build details: %w", err) + return fmt.Errorf("failed to claim build: %w", err) } timeoutMinutes := buildDetails.TimeoutMinutes diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go index 838ebc9..2806949 100644 --- a/agent/internal/http/client.go +++ b/agent/internal/http/client.go @@ -286,8 +286,8 @@ type BuildDetails struct { TargetPlatforms []string `json:"targetPlatforms"` } -func (c *Client) GetBuild(buildID string) (*BuildDetails, error) { - req, err := http.NewRequest("GET", c.baseURL+"/api/v1/agent/builds/"+buildID, nil) +func (c *Client) ClaimBuild(buildID string) (*BuildDetails, error) { + req, err := http.NewRequest("POST", c.baseURL+"/api/v1/agent/builds/"+buildID, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -296,13 +296,13 @@ func (c *Client) GetBuild(buildID string) (*BuildDetails, error) { resp, err := c.client.Do(req) if err != nil { - return nil, fmt.Errorf("failed to get build: %w", err) + return nil, fmt.Errorf("failed to claim build: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("get build failed with status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("claim build failed with status %d: %s", resp.StatusCode, string(body)) } var result BuildDetails diff --git a/web/actions/backups.ts b/web/actions/backups.ts index 28657cc..978cdf2 100644 --- a/web/actions/backups.ts +++ b/web/actions/backups.ts @@ -5,12 +5,14 @@ import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { getBackupStorageConfig } from "@/db/queries"; import { servers, volumeBackups } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; 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(serviceId: string, volumeId: string) { + await requireAuth(); const result = await triggerBackup({ serviceId, volumeId, @@ -30,6 +32,7 @@ export async function createBackup(serviceId: string, volumeId: string) { } export async function listBackups(serviceId: string) { + await requireAuth(); const backups = await db .select({ id: volumeBackups.id, @@ -54,6 +57,7 @@ export async function restoreBackup( backupId: string, targetServerId?: string, ) { + await requireAuth(); await inngest.send( inngestEvents.restoreTrigger.create({ serviceId, @@ -70,6 +74,7 @@ export async function deleteBackup( backupId: string, options: { revalidate?: boolean } = {}, ) { + await requireAuth(); const backup = await db .select({ status: volumeBackups.status, diff --git a/web/actions/builds.ts b/web/actions/builds.ts index d480fed..cd45708 100644 --- a/web/actions/builds.ts +++ b/web/actions/builds.ts @@ -3,10 +3,12 @@ import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/db"; import { builds, githubRepos, services } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; export async function cancelBuild(buildId: string) { + await requireAuth(); const [build] = await db.select().from(builds).where(eq(builds.id, buildId)); if (!build) { @@ -40,6 +42,7 @@ export async function cancelBuild(buildId: string) { } export async function retryBuild(buildId: string) { + await requireAuth(); const [build] = await db.select().from(builds).where(eq(builds.id, buildId)); if (!build) { @@ -78,6 +81,7 @@ export async function triggerBuild( serviceId: string, trigger: "manual" | "scheduled" = "manual", ) { + await requireAuth(); const [service] = await db .select() .from(services) diff --git a/web/actions/compose.ts b/web/actions/compose.ts index 515b2de..ec4c39b 100644 --- a/web/actions/compose.ts +++ b/web/actions/compose.ts @@ -1,17 +1,17 @@ "use server"; -import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { db } from "@/db"; import { services } from "@/db/schema"; -import { parseComposeYaml, type ParsedService } from "@/lib/compose-parser"; +import { requireAuth } from "@/lib/auth"; +import { parseComposeYaml } from "@/lib/compose-parser"; import { + addServiceVolume, createService, - validateDockerImage, updateServiceHealthCheck, updateServiceResourceLimits, updateServiceStartCommand, - addServiceVolume, + validateDockerImage, } from "./projects"; import { createSecretsBatch } from "./secrets"; @@ -39,12 +39,14 @@ export type ImportComposeResult = { }; export async function parseComposeFile(yaml: string) { + await requireAuth(); return parseComposeYaml(yaml); } export async function importCompose( input: ImportComposeInput, ): Promise { + await requireAuth(); const { projectId, environmentId, yaml, serviceOverrides = {} } = input; const parseResult = parseComposeYaml(yaml); diff --git a/web/actions/migrations.ts b/web/actions/migrations.ts index 0a69e32..f7b6353 100644 --- a/web/actions/migrations.ts +++ b/web/actions/migrations.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { getBackupStorageConfig } from "@/db/queries"; import { deployments, services, serviceVolumes } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; @@ -12,6 +13,7 @@ export async function startMigration( serviceId: string, targetServerId: string, ) { + await requireAuth(); const storageConfig = await getBackupStorageConfig(); if (!storageConfig) { throw new Error( @@ -99,6 +101,7 @@ export async function startMigration( } export async function cancelMigration(serviceId: string) { + await requireAuth(); await inngest.send(inngestEvents.migrationCancelled.create({ serviceId })); await db @@ -116,6 +119,7 @@ export async function cancelMigration(serviceId: string) { } export async function getMigrationStatus(serviceId: string) { + await requireAuth(); const service = await db .select({ migrationStatus: services.migrationStatus, diff --git a/web/actions/projects.ts b/web/actions/projects.ts index e2ad7ad..d982079 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -28,6 +28,7 @@ import { volumeBackups, workQueue, } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; @@ -114,6 +115,7 @@ function parseImageReference(image: string): { export async function validateDockerImage( image: string, ): Promise<{ valid: boolean; error?: string }> { + await requireAuth(); try { const { registry, namespace, repository, tag, digest } = parseImageReference(image); @@ -223,6 +225,7 @@ export async function validateDockerImage( } export async function createProject(name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); const id = randomUUID(); @@ -252,6 +255,7 @@ export async function createProject(name: string) { } export async function deleteProject(id: string) { + await requireAuth(); const projectServices = await db .select() .from(services) @@ -304,6 +308,7 @@ async function deleteBackupsForServices(serviceIds: string[]) { } export async function updateProjectName(projectId: string, name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); @@ -322,6 +327,7 @@ export async function updateProjectName(projectId: string, name: string) { } export async function updateProjectSlug(projectId: string, slug: string) { + await requireAuth(); const sanitized = slugify(slug); if (!sanitized) { throw new Error("Invalid slug"); @@ -345,6 +351,7 @@ export async function updateProjectSlug(projectId: string, slug: string) { } export async function createEnvironment(projectId: string, name: string) { + await requireAuth(); const sanitizedName = slugify(name); if (!sanitizedName) { throw new Error("Invalid environment name"); @@ -375,6 +382,7 @@ export async function createEnvironment(projectId: string, name: string) { } export async function deleteEnvironment(environmentId: string) { + await requireAuth(); const env = await getEnvironment(environmentId); if (!env) { @@ -414,6 +422,7 @@ type CreateServiceInput = { }; export async function createService(input: CreateServiceInput) { + await requireAuth(); const { projectId, environmentId, name, image, github } = input; const resourceLimits = input.resourceLimits ?? DEFAULT_RESOURCE_LIMITS; const env = await getEnvironment(environmentId); @@ -548,6 +557,7 @@ async function hardDeleteService(serviceId: string) { } export async function deleteService(serviceId: string) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -652,6 +662,7 @@ export async function deleteService(serviceId: string) { } export async function restoreDeletedService(serviceId: string) { + await requireAuth(); const service = await db .select() .from(services) @@ -769,6 +780,7 @@ export async function updateServiceHostname( serviceId: string, hostname: string, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -802,6 +814,7 @@ export async function updateServiceGithubRepo( branch: string, rootDir?: string, ) { + await requireAuth(); try { const service = await getService(serviceId); if (!service) { @@ -845,6 +858,7 @@ export async function updateServiceGithubRepo( } export async function deployService(serviceId: string) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -904,6 +918,7 @@ export async function deployService(serviceId: string) { } export async function deleteDeployments(serviceId: string) { + await requireAuth(); await db.delete(deployments).where(eq(deployments.serviceId, serviceId)); return { success: true }; } @@ -920,6 +935,7 @@ export async function updateServiceHealthCheck( serviceId: string, config: HealthCheckConfig, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -943,6 +959,7 @@ export async function updateServiceStartCommand( serviceId: string, startCommand: string | null, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -976,6 +993,7 @@ export async function updateServiceResourceLimits( serviceId: string, limits: { cpuCores: number | null; memoryMb: number | null }, ) { + await requireAuth(); const validated = resourceLimitsSchema.parse(limits); const service = await getService(serviceId); @@ -998,6 +1016,7 @@ export async function updateServiceSchedule( serviceId: string, schedule: string | null, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -1030,6 +1049,7 @@ export async function updateServiceConfig( serviceId: string, config: ServiceConfigUpdate, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -1168,6 +1188,7 @@ export async function updateServiceConfig( } export async function stopService(serviceId: string) { + await requireAuth(); const runningDeployments = await db .select() .from(deployments) @@ -1196,6 +1217,7 @@ export async function stopService(serviceId: string) { } export async function restartService(serviceId: string) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -1225,6 +1247,7 @@ export async function restartService(serviceId: string) { } export async function abortRollout(serviceId: string) { + await requireAuth(); const updatedRollouts = await db .update(rollouts) .set({ @@ -1332,6 +1355,7 @@ export async function addServiceVolume( name: string, containerPath: string, ) { + await requireAuth(); try { const validatedName = volumeNameSchema.parse(name); const validatedPath = containerPathSchema.parse(containerPath); @@ -1396,6 +1420,7 @@ export async function addServiceVolume( } export async function removeServiceVolume(volumeId: string) { + await requireAuth(); const volume = await db .select() .from(serviceVolumes) @@ -1451,6 +1476,7 @@ export async function updateServiceBackupSettings( backupEnabled: boolean, backupSchedule: string | null, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); diff --git a/web/actions/secrets.ts b/web/actions/secrets.ts index 5870270..36dac57 100644 --- a/web/actions/secrets.ts +++ b/web/actions/secrets.ts @@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm"; import { ZodError } from "zod"; import { db } from "@/db"; import { secrets, services } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { encryptSecret } from "@/lib/crypto"; import { secretItemArraySchema } from "@/lib/schemas"; import { getZodErrorMessage } from "@/lib/utils"; @@ -13,6 +14,7 @@ export async function createSecretsBatch( serviceId: string, items: { key: string; value: string }[], ) { + await requireAuth(); if (items.length === 0) { return { created: 0, updated: 0 }; } @@ -78,6 +80,7 @@ export async function createSecretsBatch( } export async function deleteSecretsBatch(secretIds: string[]) { + await requireAuth(); if (secretIds.length === 0) { return { deleted: 0 }; } diff --git a/web/actions/servers.ts b/web/actions/servers.ts index 5a0ea49..5802add 100644 --- a/web/actions/servers.ts +++ b/web/actions/servers.ts @@ -1,10 +1,11 @@ "use server"; -import { db } from "@/db"; -import { servers } from "@/db/schema"; -import { eq } from "drizzle-orm"; import { randomBytes } from "node:crypto"; +import { eq } from "drizzle-orm"; import { ZodError } from "zod"; +import { db } from "@/db"; +import { servers } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { nameSchema } from "@/lib/schemas"; import { getZodErrorMessage } from "@/lib/utils"; @@ -17,6 +18,7 @@ function generateToken(): string { } export async function createServer(name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); const id = generateId(); @@ -45,14 +47,17 @@ export async function createServer(name: string) { } export async function deleteServer(id: string) { + await requireAuth(); await db.delete(servers).where(eq(servers.id, id)); } export async function approveServer(id: string) { + await requireAuth(); await db.update(servers).set({ status: "pending" }).where(eq(servers.id, id)); } export async function updateServerName(id: string, name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); await db diff --git a/web/actions/settings.ts b/web/actions/settings.ts index 4ff7530..0f05a08 100644 --- a/web/actions/settings.ts +++ b/web/actions/settings.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import isEmail from "validator/es/lib/isEmail"; import { ZodError } from "zod"; import { setSetting } from "@/db/queries"; +import { requireAuth } from "@/lib/auth"; import { buildTimeoutSchema } from "@/lib/schemas"; import { type EmailAlertsConfig, @@ -13,12 +14,14 @@ import { import { getZodErrorMessage } from "@/lib/utils"; export async function updateBuildServers(serverIds: string[]) { + await requireAuth(); await setSetting(SETTING_KEYS.SERVERS_ALLOWED_FOR_BUILDS, serverIds); revalidatePath("/dashboard/settings"); return { success: true }; } export async function updateBuildTimeout(minutes: number) { + await requireAuth(); try { const validatedMinutes = buildTimeoutSchema.parse(minutes); await setSetting(SETTING_KEYS.BUILD_TIMEOUT_MINUTES, validatedMinutes); @@ -33,6 +36,7 @@ export async function updateBuildTimeout(minutes: number) { } export async function updateAcmeEmail(email: string) { + await requireAuth(); const trimmed = email.trim(); if (trimmed && !isEmail(trimmed)) { throw new Error("Invalid email address"); @@ -43,6 +47,7 @@ export async function updateAcmeEmail(email: string) { } export async function updateProxyDomain(domain: string) { + await requireAuth(); const trimmed = domain.trim(); await setSetting(SETTING_KEYS.PROXY_DOMAIN, trimmed || null); revalidatePath("/dashboard/settings"); @@ -50,6 +55,7 @@ export async function updateProxyDomain(domain: string) { } export async function updateEmailAlertsConfig(config: EmailAlertsConfig) { + await requireAuth(); try { const validated = emailAlertsConfigSchema.parse(config); await setSetting(SETTING_KEYS.EMAIL_ALERTS_CONFIG, validated); diff --git a/web/app/api/github/setup/route.ts b/web/app/api/github/setup/route.ts index e3652f0..740fbb4 100644 --- a/web/app/api/github/setup/route.ts +++ b/web/app/api/github/setup/route.ts @@ -1,11 +1,11 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; +import { createPrivateKey, randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { SignJWT } from "jose"; import { headers } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; import { githubInstallations } from "@/db/schema"; -import { randomUUID, createPrivateKey } from "node:crypto"; -import { eq } from "drizzle-orm"; -import { SignJWT } from "jose"; +import { auth } from "@/lib/auth"; async function getInstallationDetails(installationId: number): Promise<{ account: { login: string; type: "User" | "Organization" }; @@ -59,7 +59,7 @@ export async function GET(request: NextRequest) { if (!session) { const loginUrl = new URL("/auth/login", request.url); loginUrl.searchParams.set("redirect", request.url); - return NextResponse.redirect(loginUrl); + return NextResponse.redirect(loginUrl, 303); } const searchParams = request.nextUrl.searchParams; @@ -74,9 +74,64 @@ export async function GET(request: NextRequest) { const installationId = parseInt(installationIdParam, 10); - if (isNaN(installationId)) { + if (Number.isNaN(installationId)) { + return NextResponse.redirect( + new URL("/dashboard?error=invalid_installation_id", request.url), + ); + } + + if (setupAction !== "install" && setupAction !== "update") { + return NextResponse.redirect( + new URL(`/dashboard?github_connected=true`, request.url), + ); + } + + return new NextResponse( + ` + + +
+ + + +
+ + +`, + { + headers: { "content-type": "text/html; charset=utf-8" }, + }, + ); +} + +export async function POST(request: NextRequest) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + const loginUrl = new URL("/auth/login", request.url); + loginUrl.searchParams.set("redirect", request.url); + return NextResponse.redirect(loginUrl, 303); + } + + const formData = await request.formData(); + const installationIdParam = formData.get("installation_id"); + const setupAction = formData.get("setup_action"); + + if (typeof installationIdParam !== "string") { + return NextResponse.redirect( + new URL("/dashboard?error=missing_installation_id", request.url), + 303, + ); + } + + const installationId = parseInt(installationIdParam, 10); + + if (Number.isNaN(installationId)) { return NextResponse.redirect( new URL("/dashboard?error=invalid_installation_id", request.url), + 303, ); } @@ -90,6 +145,7 @@ export async function GET(request: NextRequest) { if (existingInstallation) { return NextResponse.redirect( new URL(`/dashboard?github_connected=true`, request.url), + 303, ); } @@ -98,6 +154,7 @@ export async function GET(request: NextRequest) { if (!installation) { return NextResponse.redirect( new URL("/dashboard?error=github_fetch_failed", request.url), + 303, ); } @@ -116,5 +173,6 @@ export async function GET(request: NextRequest) { return NextResponse.redirect( new URL(`/dashboard?github_connected=true`, request.url), + 303, ); } diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts index ec7ca69..0aef17b 100644 --- a/web/app/api/projects/[id]/services/route.ts +++ b/web/app/api/projects/[id]/services/route.ts @@ -163,18 +163,16 @@ export async function GET( ) .orderBy(desc(volumeBackups.createdAt)); - const latestByVolume = new Map(); + const latestByVolume: Record = {}; for (const backup of completedBackups) { - if (!latestByVolume.has(backup.volumeId)) { - latestByVolume.set( - backup.volumeId, - backup.completedAt ?? backup.createdAt, - ); + if (!latestByVolume[backup.volumeId]) { + latestByVolume[backup.volumeId] = + backup.completedAt ?? backup.createdAt; } } const latestBackupTimes = volumes - .map((volume) => latestByVolume.get(volume.id) ?? null) + .map((volume) => latestByVolume[volume.id] ?? null) .filter((value): value is Date | string => value !== null); deletionBackupFallback = { diff --git a/web/app/api/v1/agent/builds/[id]/route.ts b/web/app/api/v1/agent/builds/[id]/route.ts index dc6f1e0..be1d4d1 100644 --- a/web/app/api/v1/agent/builds/[id]/route.ts +++ b/web/app/api/v1/agent/builds/[id]/route.ts @@ -17,7 +17,7 @@ import { SETTING_KEYS, } from "@/lib/settings-keys"; -export async function GET( +export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 36cf7a5..4bbe995 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -1,6 +1,7 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { apiKey, bearer, deviceAuthorization } from "better-auth/plugins"; +import { headers } from "next/headers"; import { db } from "@/db"; import * as schema from "@/db/schema"; @@ -30,3 +31,25 @@ export const auth = betterAuth({ bearer(), ], }); + +export async function requireAuth() { + let requestHeaders: Headers; + + try { + requestHeaders = await headers(); + } catch { + // Server actions are also reused by trusted background jobs where no + // request context exists; browser-invoked actions still require a session. + return null; + } + + const session = await auth.api.getSession({ + headers: requestHeaders, + }); + + if (!session) { + throw new Error("Unauthorized"); + } + + return session; +} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 0b6f68d..93ffcbc 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +minimumReleaseAge: 10080 +trustPolicy: no-downgrade + allowBuilds: esbuild: false msw: false From dd708c03d3e337c393b347cbe751767f067e9456 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 24 Jun 2026 19:47:57 +1000 Subject: [PATCH 03/10] Improve CLI auth logs and reconciliation Amp-Thread-ID: https://ampcode.com/threads/T-019ef67c-3b8f-77ae-a778-76c429d70c35 Co-authored-by: Amp --- .gitignore | 1 + agent/internal/agent/drift.go | 445 +- agent/internal/agent/handlers.go | 12 +- agent/internal/agent/workqueue.go | 4 +- cli/package.json | 10 +- cli/pnpm-lock.yaml | 348 + cli/src/main.ts | 326 +- web/actions/projects.ts | 7 +- .../dashboard/servers/[id]/loading.tsx | 180 + web/app/(dashboard)/layout-client.tsx | 38 +- web/app/api/v1/agent/expected-state/route.ts | 308 +- web/app/api/v1/manifest/logs/route.ts | 75 + web/app/page.tsx | 5 +- web/components/auth/device-approval-page.tsx | 66 +- web/components/auth/sign-in-page.tsx | 34 +- .../dashboard/dashboard-page-skeleton.tsx | 96 + web/components/service/service-canvas.tsx | 16 +- web/components/settings/api-key-settings.tsx | 300 + web/components/settings/global-settings.tsx | 17 +- web/db/schema.ts | 13 +- web/lib/agent-status.ts | 107 +- web/lib/agent/expected-state.ts | 484 ++ web/lib/api-auth.ts | 43 +- web/lib/auth-client.ts | 6 +- web/lib/auth.ts | 8 +- web/lib/autoheal-policy.ts | 84 + web/lib/cli-service.ts | 22 +- web/lib/deployment-status.ts | 42 + .../inngest/functions/migration-workflow.ts | 3 +- web/lib/inngest/functions/rollout-helpers.ts | 2 +- web/lib/inngest/functions/rollout-utils.ts | 3 +- web/lib/inngest/functions/rollout-workflow.ts | 2 +- .../functions/service-deletion-workflow.ts | 3 +- web/lib/victoria-logs.ts | 6 +- web/lib/work-queue.ts | 51 +- web/package.json | 143 +- web/pnpm-lock.yaml | 7318 +++++++++-------- web/pnpm-workspace.yaml | 6 +- web/tests/autoheal-policy.test.ts | 75 + web/tests/expected-state.test.ts | 117 + web/vitest.config.ts | 10 + 41 files changed, 6561 insertions(+), 4275 deletions(-) create mode 100644 cli/pnpm-lock.yaml create mode 100644 web/app/(dashboard)/dashboard/servers/[id]/loading.tsx create mode 100644 web/app/api/v1/manifest/logs/route.ts create mode 100644 web/components/dashboard/dashboard-page-skeleton.tsx create mode 100644 web/components/settings/api-key-settings.tsx create mode 100644 web/lib/agent/expected-state.ts create mode 100644 web/lib/autoheal-policy.ts create mode 100644 web/lib/deployment-status.ts create mode 100644 web/tests/autoheal-policy.test.ts create mode 100644 web/tests/expected-state.test.ts create mode 100644 web/vitest.config.ts diff --git a/.gitignore b/.gitignore index 9cb7e70..6aff4e3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # misc .DS_Store +.idea/ *.pem # debug diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go index 80c7b0b..6a19215 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -15,6 +15,32 @@ import ( "techulus/cloud-agent/internal/wireguard" ) +type reconcileActionKind string + +const ( + actionStopOrphanNoDeploymentID reconcileActionKind = "stop_orphan_no_deployment_id" + actionRemoveOrphanNoDeploymentID reconcileActionKind = "remove_orphan_no_deployment_id" + actionStopUnexpectedContainer reconcileActionKind = "stop_unexpected_container" + actionRemoveUnexpectedContainer reconcileActionKind = "remove_unexpected_container" + actionDeployMissingContainer reconcileActionKind = "deploy_missing_container" + actionStartContainer reconcileActionKind = "start_container" + actionRedeployContainer reconcileActionKind = "redeploy_container" + actionUpdateDNS reconcileActionKind = "update_dns" + actionUpdateTraefik reconcileActionKind = "update_traefik" + actionUpdateCertificates reconcileActionKind = "update_certificates" + actionWriteChallengeRoute reconcileActionKind = "write_challenge_route" + actionUpdateWireGuard reconcileActionKind = "update_wireguard" + actionStartWireGuard reconcileActionKind = "start_wireguard" +) + +type reconcileAction struct { + Kind reconcileActionKind + Description string + DeploymentID string + Expected *agenthttp.ExpectedContainer + Actual *container.Container +} + func (a *Agent) Tick() { switch a.GetState() { case StateIdle: @@ -60,7 +86,7 @@ func (a *Agent) transitionToIdle() { a.SetState(StateIdle) if a.consumeExpectedStateRefresh() { log.Printf("[processing] fetching latest expected state after pending refresh") - // A deploy wake can arrive while processing a previous snapshot. Run one + // A reconcile wake can arrive while processing a previous snapshot. Run one // immediate idle pass after processing to pick up the latest expected state. a.handleIdle() } @@ -85,11 +111,11 @@ func (a *Agent) handleIdle() { a.updateDnsInSync(expected, actual) - changes := a.detectChanges(expected, actual) - if len(changes) > 0 { - log.Printf("[idle] drift detected, %d change(s) to apply:", len(changes)) - for _, change := range changes { - log.Printf(" → %s", change) + actions := a.planReconcile(expected, actual) + if len(actions) > 0 { + log.Printf("[idle] drift detected, %d change(s) to apply:", len(actions)) + for _, action := range actions { + log.Printf(" → %s", action.Description) } log.Printf("[idle] transitioning to PROCESSING") a.expectedState = expected @@ -97,7 +123,6 @@ func (a *Agent) handleIdle() { a.SetState(StateProcessing) return } - } func (a *Agent) handleProcessing() { @@ -115,15 +140,15 @@ func (a *Agent) handleProcessing() { } a.updateDnsInSync(a.expectedState, actual) + actions := a.planReconcile(a.expectedState, actual) - if len(a.detectChanges(a.expectedState, actual)) == 0 { + if len(actions) == 0 { log.Printf("[processing] state converged, transitioning to IDLE") a.transitionToIdle() return } - err = a.reconcileOne(actual) - if err != nil { + if err := a.applyReconcileAction(actions[0]); err != nil { log.Printf("[processing] reconciliation failed: %v, transitioning to IDLE", err) a.transitionToIdle() return @@ -165,8 +190,8 @@ func (a *Agent) getActualState() (*ActualState, error) { return state, nil } -func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualState) []string { - var changes []string +func (a *Agent) planReconcile(expected *agenthttp.ExpectedState, actual *ActualState) []reconcileAction { + var actions []reconcileAction expectedMap := make(map[string]agenthttp.ExpectedContainer) for _, c := range expected.Containers { @@ -180,32 +205,86 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS } } - for _, c := range actual.Containers { - if c.DeploymentID == "" { - changes = append(changes, fmt.Sprintf("STOP orphan container %s (no deployment ID)", c.Name)) + for i := range actual.Containers { + act := &actual.Containers[i] + if act.DeploymentID == "" { + if act.State == "running" { + actions = append(actions, reconcileAction{ + Kind: actionStopOrphanNoDeploymentID, + Description: fmt.Sprintf("STOP orphan container %s (no deployment ID)", act.Name), + Actual: act, + }) + } else { + actions = append(actions, reconcileAction{ + Kind: actionRemoveOrphanNoDeploymentID, + Description: fmt.Sprintf("REMOVE orphan container %s (no deployment ID)", act.Name), + Actual: act, + }) + } } } for id, act := range actualMap { if _, exists := expectedMap[id]; !exists { - changes = append(changes, fmt.Sprintf("STOP orphan container %s (deployment %s not in expected state)", act.Name, id[:8])) + actualContainer := act + if act.State == "running" { + actions = append(actions, reconcileAction{ + Kind: actionStopUnexpectedContainer, + Description: fmt.Sprintf("STOP orphan container %s (deployment %s not in expected state)", act.Name, id[:8]), + DeploymentID: id, + Actual: &actualContainer, + }) + } else { + actions = append(actions, reconcileAction{ + Kind: actionRemoveUnexpectedContainer, + Description: fmt.Sprintf("REMOVE orphan container %s (deployment %s not in expected state)", act.Name, id[:8]), + DeploymentID: id, + Actual: &actualContainer, + }) + } } } for id, exp := range expectedMap { if _, exists := actualMap[id]; !exists { - changes = append(changes, fmt.Sprintf("DEPLOY %s (%s)", exp.Name, exp.Image)) + expectedContainer := exp + actions = append(actions, reconcileAction{ + Kind: actionDeployMissingContainer, + Description: fmt.Sprintf("DEPLOY %s (%s)", exp.Name, exp.Image), + DeploymentID: id, + Expected: &expectedContainer, + }) } } for id, exp := range expectedMap { if act, exists := actualMap[id]; exists { + expectedContainer := exp + actualContainer := act if act.State == "created" || act.State == "exited" { - changes = append(changes, fmt.Sprintf("START %s (state: %s)", exp.Name, act.State)) + actions = append(actions, reconcileAction{ + Kind: actionStartContainer, + Description: fmt.Sprintf("START %s (state: %s)", exp.Name, act.State), + DeploymentID: id, + Expected: &expectedContainer, + Actual: &actualContainer, + }) } else if act.State != "running" { - changes = append(changes, fmt.Sprintf("RESTART %s (state: %s)", exp.Name, act.State)) + actions = append(actions, reconcileAction{ + Kind: actionRedeployContainer, + Description: fmt.Sprintf("REDEPLOY %s (state: %s)", exp.Name, act.State), + DeploymentID: id, + Expected: &expectedContainer, + Actual: &actualContainer, + }) } else if normalizeImage(exp.Image) != normalizeImage(act.Image) { - changes = append(changes, fmt.Sprintf("REDEPLOY %s (image: %s → %s)", exp.Name, act.Image, exp.Image)) + actions = append(actions, reconcileAction{ + Kind: actionRedeployContainer, + Description: fmt.Sprintf("REDEPLOY %s (image: %s → %s)", exp.Name, act.Image, exp.Image), + DeploymentID: id, + Expected: &expectedContainer, + Actual: &actualContainer, + }) } } } @@ -217,7 +296,10 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS } expectedDnsHash := dns.HashRecords(expectedDnsRecords) if expectedDnsHash != actual.DnsConfigHash { - changes = append(changes, fmt.Sprintf("UPDATE DNS (%d records)", len(expected.Dns.Records))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateDNS, + Description: fmt.Sprintf("UPDATE DNS (%d records)", len(expected.Dns.Records)), + }) } } @@ -225,14 +307,20 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS expectedHttpRoutes := ConvertToHttpRoutes(expected.Traefik.HttpRoutes) expectedTraefikHash := traefik.HashRoutesWithServerName(expectedHttpRoutes, expected.ServerName) if expectedTraefikHash != actual.TraefikConfigHash { - changes = append(changes, fmt.Sprintf("UPDATE Traefik HTTP (%d routes)", len(expected.Traefik.HttpRoutes))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateTraefik, + Description: fmt.Sprintf("UPDATE Traefik HTTP (%d routes)", len(expected.Traefik.HttpRoutes)), + }) } tcpRoutes := ConvertToTCPRoutes(expected.Traefik.TCPRoutes) udpRoutes := ConvertToUDPRoutes(expected.Traefik.UDPRoutes) expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes) if expectedL4Hash != actual.L4ConfigHash { - changes = append(changes, fmt.Sprintf("UPDATE Traefik L4 (%d TCP, %d UDP routes)", len(tcpRoutes), len(udpRoutes))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateTraefik, + Description: fmt.Sprintf("UPDATE Traefik L4 (%d TCP, %d UDP)", len(tcpRoutes), len(udpRoutes)), + }) } expectedCerts := make([]traefik.Certificate, len(expected.Traefik.Certificates)) @@ -241,11 +329,17 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS } expectedCertsHash := traefik.HashCertificates(expectedCerts) if expectedCertsHash != actual.CertificatesHash { - changes = append(changes, fmt.Sprintf("UPDATE Certificates (%d certs)", len(expected.Traefik.Certificates))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateCertificates, + Description: fmt.Sprintf("UPDATE Certificates (%d certs)", len(expected.Traefik.Certificates)), + }) } if expected.Traefik.ChallengeRoute != nil && !actual.ChallengeRouteWritten { - changes = append(changes, "WRITE Challenge Route") + actions = append(actions, reconcileAction{ + Kind: actionWriteChallengeRoute, + Description: "WRITE Challenge Route", + }) } } @@ -257,12 +351,21 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS Endpoint: p.Endpoint, } } - expectedWgHash := wireguard.HashPeers(expectedWgPeers) - if expectedWgHash != actual.WireguardHash { - changes = append(changes, fmt.Sprintf("UPDATE WireGuard (%d peers)", len(expected.Wireguard.Peers))) + if wireguard.HashPeers(expectedWgPeers) != actual.WireguardHash { + actions = append(actions, reconcileAction{ + Kind: actionUpdateWireGuard, + Description: fmt.Sprintf("UPDATE WireGuard (%d peers)", len(expected.Wireguard.Peers)), + }) + } + + if !wireguard.IsUp(wireguard.DefaultInterface) { + actions = append(actions, reconcileAction{ + Kind: actionStartWireGuard, + Description: "START WireGuard", + }) } - return changes + return actions } func normalizeImage(image string) string { @@ -283,204 +386,172 @@ func normalizeImage(image string) string { return image + digest } -func (a *Agent) reconcileOne(actual *ActualState) error { - expectedMap := make(map[string]agenthttp.ExpectedContainer) - for _, c := range a.expectedState.Containers { - expectedMap[c.DeploymentID] = c - } +func (a *Agent) applyReconcileAction(action reconcileAction) error { + log.Printf("[reconcile] %s", action.Description) - actualMap := make(map[string]container.Container) - for _, c := range actual.Containers { - if c.DeploymentID != "" { - actualMap[c.DeploymentID] = c + switch action.Kind { + case actionStopOrphanNoDeploymentID, actionStopUnexpectedContainer: + if action.Actual == nil { + return fmt.Errorf("missing actual container for %s", action.Kind) } - } + if err := container.Stop(action.Actual.ID); err != nil { + return fmt.Errorf("failed to stop orphan container: %w", err) + } + return nil - for _, act := range actual.Containers { - if act.DeploymentID == "" { - if act.State == "running" { - log.Printf("[reconcile] stopping orphan container %s (no deployment ID)", act.ID) - if err := container.Stop(act.ID); err != nil { - return fmt.Errorf("failed to stop orphan container: %w", err) - } - return nil - } else { - log.Printf("[reconcile] removing orphan container %s (no deployment ID)", act.ID) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - err := retry.WithBackoff(ctx, retry.ForceRemoveBackoff, func() (bool, error) { - if err := container.ForceRemove(act.ID); err != nil { - log.Printf("[reconcile] remove attempt failed: %v, retrying...", err) - return false, err - } - return true, nil - }) - cancel() - if err != nil { - log.Printf("[reconcile] warning: failed to remove orphan container after retries: %v", err) - } - return nil + case actionRemoveOrphanNoDeploymentID: + if action.Actual == nil { + return fmt.Errorf("missing actual container for %s", action.Kind) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + err := retry.WithBackoff(ctx, retry.ForceRemoveBackoff, func() (bool, error) { + if err := container.ForceRemove(action.Actual.ID); err != nil { + log.Printf("[reconcile] remove attempt failed: %v, retrying...", err) + return false, err } + return true, nil + }) + cancel() + if err != nil { + log.Printf("[reconcile] warning: failed to remove orphan container after retries: %v", err) } - } + return nil - for id, act := range actualMap { - if _, exists := expectedMap[id]; !exists { - if act.State == "running" { - log.Printf("[reconcile] stopping orphan container %s (deployment %s not in expected state)", act.Name, id[:8]) - if err := container.Stop(act.ID); err != nil { - return fmt.Errorf("failed to stop orphan container: %w", err) - } - return nil - } else { - log.Printf("[reconcile] removing orphan container %s (deployment %s not in expected state)", act.Name, id[:8]) - if err := container.ForceRemove(act.ID); err != nil { - log.Printf("[reconcile] warning: failed to remove orphan: %v", err) - } - return nil - } + case actionRemoveUnexpectedContainer: + if action.Actual == nil { + return fmt.Errorf("missing actual container for %s", action.Kind) } - } + if err := container.ForceRemove(action.Actual.ID); err != nil { + log.Printf("[reconcile] warning: failed to remove orphan: %v", err) + } + return nil - for id, exp := range expectedMap { - if _, exists := actualMap[id]; !exists { - log.Printf("[reconcile] deploying missing container for deployment %s", id) - if err := a.Reconciler.Deploy(exp); err != nil { - return fmt.Errorf("failed to deploy container: %w", err) - } - return nil + case actionDeployMissingContainer: + if action.Expected == nil { + return fmt.Errorf("missing expected container for %s", action.Kind) } - } + if err := a.Reconciler.Deploy(*action.Expected); err != nil { + return fmt.Errorf("failed to deploy container: %w", err) + } + return nil - for id, exp := range expectedMap { - if act, exists := actualMap[id]; exists { - if act.State == "created" || act.State == "exited" { - log.Printf("[reconcile] starting %s container %s for deployment %s", act.State, act.ID, id) - if err := container.Start(act.ID); err != nil { - log.Printf("[reconcile] start failed, will redeploy: %v", err) - if err := container.Stop(act.ID); err != nil { - log.Printf("[reconcile] warning: failed to stop old container: %v", err) - } - if err := a.Reconciler.Deploy(exp); err != nil { - return fmt.Errorf("failed to redeploy container: %w", err) - } - } - return nil + case actionStartContainer: + if action.Actual == nil || action.Expected == nil { + return fmt.Errorf("missing container state for %s", action.Kind) + } + if err := container.Start(action.Actual.ID); err != nil { + log.Printf("[reconcile] start failed, will redeploy: %v", err) + if err := container.Stop(action.Actual.ID); err != nil { + log.Printf("[reconcile] warning: failed to stop old container: %v", err) } - if act.State != "running" || normalizeImage(exp.Image) != normalizeImage(act.Image) { - log.Printf("[reconcile] redeploying container for deployment %s (state=%s)", id, act.State) - if err := container.Stop(act.ID); err != nil { - log.Printf("[reconcile] warning: failed to stop old container: %v", err) - } - if err := a.Reconciler.Deploy(exp); err != nil { - return fmt.Errorf("failed to redeploy container: %w", err) - } - return nil + if err := a.Reconciler.Deploy(*action.Expected); err != nil { + return fmt.Errorf("failed to redeploy container: %w", err) } } - } + return nil - if !a.DisableDNS { + case actionRedeployContainer: + if action.Actual == nil || action.Expected == nil { + return fmt.Errorf("missing container state for %s", action.Kind) + } + if err := container.Stop(action.Actual.ID); err != nil { + log.Printf("[reconcile] warning: failed to stop old container: %v", err) + } + if err := a.Reconciler.Deploy(*action.Expected); err != nil { + return fmt.Errorf("failed to redeploy container: %w", err) + } + return nil + + case actionUpdateDNS: expectedDnsRecords := make([]dns.DnsRecord, len(a.expectedState.Dns.Records)) for i, r := range a.expectedState.Dns.Records { expectedDnsRecords[i] = dns.DnsRecord{Name: r.Name, Ips: r.Ips} } - if dns.HashRecords(expectedDnsRecords) != actual.DnsConfigHash { - log.Printf("[reconcile] updating DNS records") - if err := dns.UpdateDnsRecords(expectedDnsRecords); err != nil { - return fmt.Errorf("failed to update DNS: %w", err) - } - return nil + if err := dns.UpdateDnsRecords(expectedDnsRecords); err != nil { + return fmt.Errorf("failed to update DNS: %w", err) } - } - - if a.IsProxy { - expectedHttpRoutes := ConvertToHttpRoutes(a.expectedState.Traefik.HttpRoutes) - tcpRoutes := ConvertToTCPRoutes(a.expectedState.Traefik.TCPRoutes) - udpRoutes := ConvertToUDPRoutes(a.expectedState.Traefik.UDPRoutes) - - httpDrift := traefik.HashRoutesWithServerName(expectedHttpRoutes, a.expectedState.ServerName) != actual.TraefikConfigHash - expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes) - l4Drift := expectedL4Hash != actual.L4ConfigHash - - if httpDrift || l4Drift { - var tcpPorts, udpPorts []int - for _, r := range tcpRoutes { - tcpPorts = append(tcpPorts, r.ExternalPort) - } - for _, r := range udpRoutes { - udpPorts = append(udpPorts, r.ExternalPort) - } - - needsRestart := false - if len(tcpPorts) > 0 || len(udpPorts) > 0 { - log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(tcpPorts), len(udpPorts)) - var err error - needsRestart, err = traefik.EnsureEntryPoints(tcpPorts, udpPorts) - if err != nil { - return fmt.Errorf("failed to ensure entry points: %w", err) - } - } - - log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes)) - if err := traefik.UpdateHttpRoutesWithL4(expectedHttpRoutes, tcpRoutes, udpRoutes, a.expectedState.ServerName); err != nil { - return fmt.Errorf("failed to update Traefik: %w", err) - } + return nil - if needsRestart { - log.Printf("[reconcile] restarting Traefik to apply new entry points") - if err := traefik.ReloadTraefik(); err != nil { - return fmt.Errorf("failed to restart Traefik: %w", err) - } - } - return nil - } + case actionUpdateTraefik: + return a.updateTraefik() + case actionUpdateCertificates: expectedCerts := make([]traefik.Certificate, len(a.expectedState.Traefik.Certificates)) for i, c := range a.expectedState.Traefik.Certificates { expectedCerts[i] = traefik.Certificate{Domain: c.Domain, Certificate: c.Certificate, CertificateKey: c.CertificateKey} } - if traefik.HashCertificates(expectedCerts) != actual.CertificatesHash { - log.Printf("[reconcile] updating certificates") - if err := traefik.UpdateCertificates(expectedCerts); err != nil { - return fmt.Errorf("failed to update certificates: %w", err) - } - return nil + if err := traefik.UpdateCertificates(expectedCerts); err != nil { + return fmt.Errorf("failed to update certificates: %w", err) } + return nil - if a.expectedState.Traefik.ChallengeRoute != nil && !actual.ChallengeRouteWritten { - log.Printf("[reconcile] writing challenge route") - if err := traefik.WriteChallengeRoute(a.expectedState.Traefik.ChallengeRoute.ControlPlaneUrl); err != nil { - return fmt.Errorf("failed to write challenge route: %w", err) - } + case actionWriteChallengeRoute: + if a.expectedState.Traefik.ChallengeRoute == nil { return nil } - } - - expectedWgPeers := make([]wireguard.Peer, len(a.expectedState.Wireguard.Peers)) - for i, p := range a.expectedState.Wireguard.Peers { - expectedWgPeers[i] = wireguard.Peer{ - PublicKey: p.PublicKey, - AllowedIPs: p.AllowedIPs, - Endpoint: p.Endpoint, + if err := traefik.WriteChallengeRoute(a.expectedState.Traefik.ChallengeRoute.ControlPlaneUrl); err != nil { + return fmt.Errorf("failed to write challenge route: %w", err) } - } - wgPeersChanged := wireguard.HashPeers(expectedWgPeers) != actual.WireguardHash - wgIsUp := wireguard.IsUp(wireguard.DefaultInterface) + return nil - if wgPeersChanged { - log.Printf("[reconcile] updating WireGuard peers") + case actionUpdateWireGuard: + expectedWgPeers := make([]wireguard.Peer, len(a.expectedState.Wireguard.Peers)) + for i, p := range a.expectedState.Wireguard.Peers { + expectedWgPeers[i] = wireguard.Peer{ + PublicKey: p.PublicKey, + AllowedIPs: p.AllowedIPs, + Endpoint: p.Endpoint, + } + } if err := a.reconcileWireguard(expectedWgPeers); err != nil { return fmt.Errorf("failed to update WireGuard: %w", err) } return nil - } - if !wgIsUp { - log.Printf("[reconcile] WireGuard interface is down, bringing it up") + case actionStartWireGuard: if err := wireguard.Up(wireguard.DefaultInterface); err != nil { return fmt.Errorf("failed to bring up WireGuard: %w", err) } return nil + + default: + return fmt.Errorf("unknown reconcile action: %s", action.Kind) + } +} + +func (a *Agent) updateTraefik() error { + expectedHttpRoutes := ConvertToHttpRoutes(a.expectedState.Traefik.HttpRoutes) + tcpRoutes := ConvertToTCPRoutes(a.expectedState.Traefik.TCPRoutes) + udpRoutes := ConvertToUDPRoutes(a.expectedState.Traefik.UDPRoutes) + + var tcpPorts, udpPorts []int + for _, r := range tcpRoutes { + tcpPorts = append(tcpPorts, r.ExternalPort) + } + for _, r := range udpRoutes { + udpPorts = append(udpPorts, r.ExternalPort) + } + + needsRestart := false + if len(tcpPorts) > 0 || len(udpPorts) > 0 { + log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(tcpPorts), len(udpPorts)) + var err error + needsRestart, err = traefik.EnsureEntryPoints(tcpPorts, udpPorts) + if err != nil { + return fmt.Errorf("failed to ensure entry points: %w", err) + } + } + + log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes)) + if err := traefik.UpdateHttpRoutesWithL4(expectedHttpRoutes, tcpRoutes, udpRoutes, a.expectedState.ServerName); err != nil { + return fmt.Errorf("failed to update Traefik: %w", err) + } + + if needsRestart { + log.Printf("[reconcile] restarting Traefik to apply new entry points") + if err := traefik.ReloadTraefik(); err != nil { + return fmt.Errorf("failed to restart Traefik: %w", err) + } } return nil diff --git a/agent/internal/agent/handlers.go b/agent/internal/agent/handlers.go index dad56d9..5dd68cb 100644 --- a/agent/internal/agent/handlers.go +++ b/agent/internal/agent/handlers.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "log" "os" @@ -67,16 +68,25 @@ func (a *Agent) ProcessForceCleanup(item agenthttp.WorkQueueItem) error { log.Printf("[force_cleanup] cleaning up %d containers for service %s", len(payload.ContainerIDs), Truncate(payload.ServiceID, 8)) + var cleanupErrors []error for _, containerID := range payload.ContainerIDs { if err := container.Stop(containerID); err != nil { log.Printf("[force_cleanup] failed to stop %s: %v", Truncate(containerID, 12), err) + cleanupErrors = append( + cleanupErrors, + fmt.Errorf("stop %s: %w", Truncate(containerID, 12), err), + ) } if err := container.ForceRemove(containerID); err != nil { log.Printf("[force_cleanup] failed to remove %s: %v", Truncate(containerID, 12), err) + cleanupErrors = append( + cleanupErrors, + fmt.Errorf("remove %s: %w", Truncate(containerID, 12), err), + ) } } - return nil + return errors.Join(cleanupErrors...) } func (a *Agent) ProcessCleanupVolumes(item agenthttp.WorkQueueItem) error { diff --git a/agent/internal/agent/workqueue.go b/agent/internal/agent/workqueue.go index 6aa1690..8eb02fa 100644 --- a/agent/internal/agent/workqueue.go +++ b/agent/internal/agent/workqueue.go @@ -113,8 +113,8 @@ func (a *Agent) ProcessWorkItem(item agenthttp.WorkQueueItem) error { return a.ProcessRestart(item) case "stop": return a.ProcessStop(item) - case "deploy": - a.RequestReconcile("deploy work item " + Truncate(item.ID, 8)) + case "deploy", "reconcile": + a.RequestReconcile("reconcile work item " + Truncate(item.ID, 8)) return nil case "force_cleanup": return a.ProcessForceCleanup(item) diff --git a/cli/package.json b/cli/package.json index 1b8a94f..6cefbb9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -5,11 +5,11 @@ "type": "module", "scripts": { "dev": "node --import tsx src/main.ts", - "build": "bun build src/main.ts --compile --outfile dist/tcloud", - "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/tcloud-linux-x64", - "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/tcloud-linux-arm64", - "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/tcloud-darwin-x64", - "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/tcloud-darwin-arm64", + "build": "bun build src/main.ts --compile --outfile dist/tc", + "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/tc-linux-x64", + "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/tc-linux-arm64", + "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/tc-darwin-x64", + "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/tc-darwin-arm64", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml new file mode 100644 index 0000000..db827ad --- /dev/null +++ b/cli/pnpm-lock.yaml @@ -0,0 +1,348 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + yaml: + specifier: ^2.8.2 + version: 2.9.0 + zod: + specifier: ^4.3.5 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^22.17.0 + version: 22.20.0 + tsx: + specifier: ^4.19.2 + version: 4.22.4 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + yaml@2.9.0: {} + + zod@4.4.3: {} diff --git a/cli/src/main.ts b/cli/src/main.ts index 9481465..61bfca3 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -14,6 +14,8 @@ import { const CLI_VERSION = "0.1.0"; const CLI_CLIENT_ID = "techulus-cli"; +const DEFAULT_LOG_TAIL = 100; +const LOG_POLL_INTERVAL_MS = 2000; type JsonRequestOptions = { method?: string; @@ -21,6 +23,12 @@ type JsonRequestOptions = { body?: unknown; }; +type ErrorResponse = { + error?: string; + message?: string; + code?: string; +}; + type LinkServiceTarget = { id: string; name: string; @@ -43,6 +51,13 @@ type LinkProjectTarget = { environments: LinkEnvironmentTarget[]; }; +type ServiceLog = { + deploymentId: string | undefined; + stream: string; + message: string; + timestamp: string; +}; + function normalizeHost(host: string) { const trimmed = host.trim().replace(/\/$/, ""); if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { @@ -63,13 +78,32 @@ async function requestJson(url: string, options: JsonRequestOptions = {}) { }); const text = await response.text(); - const data = text ? (JSON.parse(text) as T | { error?: string }) : null; + const data = text ? (JSON.parse(text) as T | ErrorResponse) : null; if (!response.ok) { - const message = - data && typeof data === "object" && "error" in data && data.error - ? data.error - : `Request failed with ${response.status}`; + const apiMessage = + data && typeof data === "object" + ? "message" in data && data.message + ? data.message + : "error" in data && data.error + ? data.error + : null + : null; + const code = + data && typeof data === "object" && "code" in data && data.code + ? ` (${data.code})` + : ""; + const message = apiMessage + ? `${apiMessage}${code}` + : `Request failed with ${response.status}`; + + if (response.status === 401 || response.status === 403) { + const host = normalizeHost(new URL(url).origin); + throw new Error( + `${message}\n\nYour CLI session is not authorized. Run:\n tc auth login --host ${host}`, + ); + } + throw new Error(message); } @@ -80,6 +114,35 @@ async function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function shortId(id: string) { + if (id.length <= 16) return id; + return `${id.slice(0, 8)}…${id.slice(-4)}`; +} + +function formatStatus(value: string) { + return value.replace(/_/g, " "); +} + +function formatTimestamp(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toISOString(); +} + +function printSection(title: string) { + console.log(`\n${title}`); + console.log("─".repeat(title.length)); +} + +function printField(label: string, value: string | number) { + console.log(` ${label.padEnd(10)} ${value}`); +} + +function printNext(command: string) { + printSection("Next"); + printField("Run", command); +} + function parseOption(args: string[], name: string) { const index = args.indexOf(name); if (index === -1) { @@ -94,16 +157,34 @@ function parseOption(args: string[], name: string) { return value; } +function parseLogLineLimit(args: string[]) { + const rawTail = parseOption(args, "-n") ?? parseOption(args, "--tail"); + if (!rawTail) return null; + + if (!/^\d+$/.test(rawTail)) { + throw new Error("log line count must be a positive integer"); + } + + const tail = Number.parseInt(rawTail, 10); + if (tail < 1 || tail > 1000) { + throw new Error("log line count must be between 1 and 1000"); + } + + return tail; +} + function printUsage() { console.log(`Usage: - tcloud auth login --host - tcloud auth logout - tcloud auth whoami - tcloud init - tcloud link [--force] - tcloud apply - tcloud deploy - tcloud status`); + tc auth login --host + tc auth logout + tc auth whoami + tc init + tc link [--force] + tc apply + tc deploy + tc logs + tc logs -n + tc status`); } async function pathExists(filePath: string) { @@ -140,7 +221,7 @@ async function selectFromList( } if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error("tcloud link requires an interactive terminal."); + throw new Error("tc link requires an interactive terminal."); } const rl = createInterface({ input, output }); @@ -181,7 +262,7 @@ async function ensureManifest(cwd: string) { } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { throw new Error( - "No techulus.yml found in the current directory. Run `tcloud init` to create one.", + "No techulus.yml found in the current directory. Run `tc init` to create one.", ); } throw new Error( @@ -201,7 +282,7 @@ function authHeaders(apiKey: string) { async function requireConfig() { const config = await readConfig(); if (!config) { - throw new Error("Not logged in. Run `tcloud auth login --host ` first."); + throw new Error("Not logged in. Run `tc auth login --host ` first."); } return config; @@ -232,9 +313,14 @@ async function commandAuthLogin(args: string[]) { }, }); - console.log(`Visit ${deviceCode.verification_uri}`); - console.log(`Enter code: ${deviceCode.user_code}`); - console.log("Open the verification URL in your browser to continue."); + const verificationUrl = + deviceCode.verification_uri_complete || deviceCode.verification_uri; + + printSection("Device login"); + printField("Host", host); + printField("URL", verificationUrl); + printField("Code", deviceCode.user_code); + console.log("\nOpen the verification URL in your browser to continue."); let accessToken = ""; let intervalMs = deviceCode.interval * 1000; @@ -288,7 +374,7 @@ async function commandAuthLogin(args: string[]) { } } - console.log("\nDevice login approved. Creating a CLI API key..."); + console.log("\n\nDevice approved. Creating a CLI API key..."); const machineName = os.hostname(); const platform = `${process.platform}/${process.arch}`; @@ -317,12 +403,17 @@ async function commandAuthLogin(args: string[]) { user: exchange.user, }); - console.log(`Signed in as ${exchange.user.email}`); + printSection("Signed in"); + printField("User", exchange.user.email); + printField("Name", exchange.user.name); + printField("Host", host); + printField("Key", exchange.keyId ? shortId(exchange.keyId) : "created"); } async function commandAuthLogout() { await deleteConfig(); - console.log("Signed out."); + printSection("Signed out"); + printField("Config", "removed"); } async function commandAuthWhoAmI() { @@ -333,9 +424,10 @@ async function commandAuthWhoAmI() { headers: authHeaders(config.apiKey), }); - console.log(`Signed in as ${whoami.user.email}`); - console.log(`Name: ${whoami.user.name}`); - console.log(`Host: ${config.host}`); + printSection("Account"); + printField("User", whoami.user.email); + printField("Name", whoami.user.name); + printField("Host", config.host); } async function commandInit(cwd: string) { @@ -369,7 +461,9 @@ service: `; await writeFile(manifestPath, manifest, "utf8"); - console.log(`Created ${manifestPath}`); + printSection("Manifest"); + printField("Created", manifestPath); + printNext("tc apply"); } async function commandLink(cwd: string, args: string[]) { @@ -379,7 +473,7 @@ async function commandLink(cwd: string, args: string[]) { if ((await pathExists(manifestPath)) && !force) { throw new Error( - "techulus.yml already exists. Run `tcloud link --force` to replace it.", + "techulus.yml already exists. Run `tc link --force` to replace it.", ); } @@ -459,11 +553,13 @@ async function commandLink(cwd: string, args: string[]) { await writeFile(manifestPath, stringifyManifest(result.manifest), "utf8"); - console.log( - `Linked ${result.service.project}/${result.service.environment}/${result.service.name}`, + printSection("Linked"); + printField( + "Service", + `${result.service.project}/${result.service.environment}/${result.service.name}`, ); - console.log(`Wrote ${manifestPath}`); - console.log("Next: run `tcloud status` or `tcloud apply`."); + printField("Manifest", manifestPath); + printNext("tc status or tc apply"); } function printApplyResult(result: { @@ -471,17 +567,20 @@ function printApplyResult(result: { serviceId: string; changes: Array<{ field: string; from: string; to: string }>; }) { - console.log(`Action: ${result.action}`); - console.log(`Service ID: ${result.serviceId}`); + printSection("Apply"); + printField("Action", result.action); + printField("Service", shortId(result.serviceId)); if (result.changes.length === 0) { - console.log("No changes."); + printField("Changes", "none"); return; } - console.log("Changes:"); + printSection(`Changes (${result.changes.length})`); for (const change of result.changes) { - console.log(`- ${change.field}: ${change.from} -> ${change.to}`); + console.log(` • ${change.field}`); + printField("From", change.from); + printField("To", change.to); } } @@ -514,11 +613,13 @@ async function commandDeploy(cwd: string) { body: manifest, }); - console.log(`Service ID: ${result.serviceId}`); - console.log(`Status: ${result.status}`); + printSection("Deploy"); + printField("Service", shortId(result.serviceId)); + printField("Status", formatStatus(result.status)); if (result.rolloutId) { - console.log(`Rollout ID: ${result.rolloutId}`); + printField("Rollout", shortId(result.rolloutId)); } + printNext("tc status"); } async function commandStatus(cwd: string) { @@ -550,26 +651,148 @@ async function commandStatus(cwd: string) { headers: authHeaders(config.apiKey), }); - console.log(`Service ID: ${status.service.id}`); - console.log(`Image: ${status.service.image}`); - console.log(`Hostname: ${status.service.hostname ?? "(none)"}`); - console.log(`Replicas: ${status.service.replicas}`); + console.log(`${manifest.project}/${manifest.environment}/${manifest.service.name}`); + + printSection("Service"); + printField("ID", shortId(status.service.id)); + printField("Image", status.service.image); + printField("Hostname", status.service.hostname ?? "none"); + printField("Replicas", status.service.replicas); + + printSection("Rollout"); if (status.latestRollout) { - console.log( - `Latest rollout: ${status.latestRollout.id} (${status.latestRollout.status}${status.latestRollout.currentStage ? `, ${status.latestRollout.currentStage}` : ""})`, + printField("ID", shortId(status.latestRollout.id)); + printField("Status", formatStatus(status.latestRollout.status)); + printField( + "Stage", + status.latestRollout.currentStage + ? formatStatus(status.latestRollout.currentStage) + : "none", ); } else { - console.log("Latest rollout: none"); + printField("Latest", "none"); } - console.log(`Deployments: ${status.deployments.length}`); + + printSection(`Deployments (${status.deployments.length})`); + if (status.deployments.length === 0) { + printField("Current", "none"); + return; + } + for (const deployment of status.deployments) { - console.log(`- ${deployment.id}: ${deployment.status} on ${deployment.serverId}`); + console.log(` • ${shortId(deployment.id)}`); + printField("Status", formatStatus(deployment.status)); + printField("Server", shortId(deployment.serverId)); + } +} + +function printLogs(logs: ServiceLog[]) { + for (const log of logs) { + const stream = `[${log.stream || "stdout"}]`.padEnd(9); + const message = log.message.replace(/\n+$/, ""); + console.log(`${formatTimestamp(log.timestamp)} ${stream} ${message}`); + } +} + +function getLogCursor(logs: ServiceLog[]) { + return logs.reduce((latest, log) => { + if (!latest) return log.timestamp; + return new Date(log.timestamp).getTime() > new Date(latest).getTime() + ? log.timestamp + : latest; + }, null); +} + +function getLogKey(log: ServiceLog) { + return `${log.timestamp}:${log.stream}:${log.deploymentId ?? ""}:${log.message}`; +} + +async function fetchManifestLogs( + config: Awaited>, + manifest: TechulusManifest, + options: { tail: number; after?: string | null }, +) { + const params = new URLSearchParams({ + project: manifest.project, + environment: manifest.environment, + service: manifest.service.name, + tail: String(options.tail), + }); + if (options.after) { + params.set("after", options.after); + } + + return requestJson<{ + loggingEnabled: boolean; + logs: ServiceLog[]; + }>(`${config.host}/api/v1/manifest/logs?${params.toString()}`, { + headers: authHeaders(config.apiKey), + }); +} + +async function commandLogs(cwd: string, args: string[]) { + const lineLimit = parseLogLineLimit(args); + const config = await requireConfig(); + const { manifest } = await ensureManifest(cwd); + const result = await fetchManifestLogs(config, manifest, { + tail: lineLimit ?? DEFAULT_LOG_TAIL, + }); + + console.log(`${manifest.project}/${manifest.environment}/${manifest.service.name}`); + + if (!result.loggingEnabled) { + printSection("Logs"); + printField("Status", "disabled"); + return; + } + + if (lineLimit && result.logs.length === 0) { + printSection("Logs"); + printField("Lines", "none"); + return; + } + + if (lineLimit) { + printSection(`Logs (${result.logs.length})`); + printLogs(result.logs); + return; + } + + printSection("Logs"); + if (result.logs.length > 0) { + printLogs(result.logs); + } else { + printField("Waiting", "new log lines"); + } + + let after = getLogCursor(result.logs) ?? new Date().toISOString(); + const seen = new Set(result.logs.map(getLogKey)); + + while (true) { + await sleep(LOG_POLL_INTERVAL_MS); + const next = await fetchManifestLogs(config, manifest, { + tail: DEFAULT_LOG_TAIL, + after, + }); + const logs = next.logs.filter((log) => !seen.has(getLogKey(log))); + if (logs.length === 0) continue; + + printLogs(logs); + for (const log of logs) { + seen.add(getLogKey(log)); + } + after = getLogCursor(logs) ?? after; } } async function main() { - const [command, subcommand, ...rest] = process.argv.slice(2); - const cwd = process.cwd(); + const argv = process.argv.slice(2); + if (argv[0] === "--") { + argv.shift(); + } + + const [command, subcommand, ...rest] = argv; + const cwd = process.env.INIT_CWD || process.cwd(); if (!command) { printUsage(); @@ -604,6 +827,9 @@ async function main() { case "deploy": await commandDeploy(cwd); return; + case "logs": + await commandLogs(cwd, [subcommand, ...rest].filter(Boolean)); + return; case "status": await commandStatus(cwd); return; diff --git a/web/actions/projects.ts b/web/actions/projects.ts index d982079..b07053e 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -30,6 +30,7 @@ import { } from "@/db/schema"; import { requireAuth } from "@/lib/auth"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; +import { markDeploymentUndesired } from "@/lib/deployment-status"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import { allocatePort } from "@/lib/port-allocation"; @@ -512,7 +513,7 @@ async function hardDeleteService(serviceId: string) { ) { await db .update(deployments) - .set({ status: "stopping" }) + .set(markDeploymentUndesired("stopping")) .where(eq(deployments.id, dep.id)); await enqueueWork(dep.serverId, "stop", { @@ -1204,7 +1205,7 @@ export async function stopService(serviceId: string) { await db .update(deployments) - .set({ status: "stopping" }) + .set(markDeploymentUndesired("stopping")) .where(eq(deployments.id, dep.id)); await enqueueWork(dep.serverId, "stop", { @@ -1323,7 +1324,7 @@ export async function abortRollout(serviceId: string) { .where( and( eq(workQueue.status, "pending"), - eq(workQueue.type, "deploy"), + inArray(workQueue.type, ["deploy", "reconcile"]), inArray(workQueue.serverId, [...serverContainers.keys()]), ), ); diff --git a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx new file mode 100644 index 0000000..f42210a --- /dev/null +++ b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx @@ -0,0 +1,180 @@ +import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +function ServerDetailsSkeleton() { + return ( + + + Server Details + + +
+ {Array.from({ length: 9 }).map((_, index) => ( +
+ + +
+ ))} +
+
+
+ ); +} + +function HealthMetricSkeleton() { + return ( +
+
+ + +
+ + +
+ ); +} + +function HealthStatusSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +function SystemHealthSkeleton() { + return ( + + + System Health + + +
+ + + +
+
+
+ + + +
+
+
+
+ ); +} + +function RunningServicesSkeleton() { + return ( + + + + + + + + + + + +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ ); +} + +function AgentLogsSkeleton() { + return ( +
+

Agent Logs

+
+
+ + +
+
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+
+ ); +} + +function DangerZoneSkeleton() { + return ( + + + + + + + + + + + + + + ); +} + +export default function Loading() { + return ( + <> + + +
+ Loading server details +
+ + ); +} diff --git a/web/app/(dashboard)/layout-client.tsx b/web/app/(dashboard)/layout-client.tsx index a081f5d..12aa1b6 100644 --- a/web/app/(dashboard)/layout-client.tsx +++ b/web/app/(dashboard)/layout-client.tsx @@ -9,6 +9,7 @@ import { BreadcrumbDataProvider, useBreadcrumbs, } from "@/components/core/breadcrumb-data"; +import { DashboardPageSkeleton } from "@/components/dashboard/dashboard-page-skeleton"; import { OfflineServersBanner } from "@/components/server/offline-servers-banner"; import { Button } from "@/components/ui/button"; import { @@ -26,6 +27,10 @@ import { signOut, useSession } from "@/lib/auth-client"; function DashboardHeader({ email }: { email: string }) { const router = useRouter(); const breadcrumbs = useBreadcrumbs(); + const getBreadcrumbKey = ( + crumb: (typeof breadcrumbs)[number], + index: number, + ) => `${crumb.href}:${crumb.label}:${index}`; const mobileBreadcrumbs = breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs; @@ -48,7 +53,10 @@ function DashboardHeader({ email }: { email: string }) { <>