diff --git a/web/.env.example b/web/.env.example index 67fac89..74dbcc3 100644 --- a/web/.env.example +++ b/web/.env.example @@ -9,7 +9,7 @@ BETTER_AUTH_SECRET=your-secret-key-here ENCRYPTION_KEY=your-64-character-hex-string # Public URL -APP_URL=https://your-domain.com +APP_URL=http://localhost:3000 # Logging (optional) VICTORIA_LOGS_URL=http://username:password@victoria-logs:9428 @@ -25,10 +25,11 @@ VM_RETENTION=30d # Docker Registry for builds (optional) REGISTRY_HOST=registry.example.com -# Inngest (required for production) -INNGEST_BASE_URL=http://inngest:8288 -INNGEST_SIGNING_KEY=signkey-xxx -INNGEST_EVENT_KEY=xxx +# Inngest (local dev via ../compose.dev.yml) +INNGEST_BASE_URL=http://localhost:8288 +INNGEST_DEV=1 +INNGEST_SIGNING_KEY= +INNGEST_EVENT_KEY= # GitHub App Integration (optional) GITHUB_APP_ID=your-github-app-id diff --git a/web/README.md b/web/README.md index 5c32c69..7bcb0a3 100644 --- a/web/README.md +++ b/web/README.md @@ -6,10 +6,13 @@ Next.js-based control plane for Techulus Cloud container deployment platform. ```bash pnpm install +cp .env.example .env +docker compose -f ../compose.dev.yml up -d pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) to access the control plane. +Open [http://localhost:8288](http://localhost:8288) to access the Inngest dev server. ## Stack diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts index 7aeb140..055c7dc 100644 --- a/web/lib/inngest/functions/rollout-helpers.ts +++ b/web/lib/inngest/functions/rollout-helpers.ts @@ -202,7 +202,7 @@ export async function cleanupTerminalDeployments( export async function cleanupExistingDeployments( serviceId: string, -): Promise { +): Promise<{ deletedCount: number }> { const existingDeployments = await db .select() .from(deployments) @@ -214,35 +214,55 @@ export async function cleanupExistingDeployments( .where(eq(deploymentPorts.deploymentId, dep.id)); await db.delete(deployments).where(eq(deployments.id, dep.id)); } + + return { deletedCount: existingDeployments.length }; } +export type CertificateProvisioningResult = { + domains: string[]; + existingDomains: string[]; + issuedDomains: string[]; + failedDomains: string[]; +}; + export async function issueCertificatesForService( serviceId: string, -): Promise { +): Promise { const servicePortsList = await db .select() .from(servicePorts) .where(eq(servicePorts.serviceId, serviceId)); - const domainsNeedingCerts = servicePortsList - .filter((p) => p.isPublic && p.domain) - .map((p) => p.domain as string); + const domainsNeedingCerts = Array.from( + new Set( + servicePortsList + .filter((p) => p.isPublic && p.domain) + .map((p) => (p.domain as string).trim()) + .filter(Boolean), + ), + ); + const existingDomains: string[] = []; + const issuedDomains: string[] = []; const failedDomains: string[] = []; for (const domain of domainsNeedingCerts) { const existingCert = await getCertificate(domain); - if (!existingCert) { - try { - await issueCertificate(domain); - console.log(`[deploy] issued certificate for ${domain}`); - } catch (error) { - console.error( - `[deploy] failed to issue certificate for ${domain}:`, - error, - ); - failedDomains.push(domain); - } + if (existingCert) { + existingDomains.push(domain); + continue; + } + + try { + await issueCertificate(domain); + console.log(`[deploy] issued certificate for ${domain}`); + issuedDomains.push(domain); + } catch (error) { + console.error( + `[deploy] failed to issue certificate for ${domain}:`, + error, + ); + failedDomains.push(domain); } } @@ -251,6 +271,13 @@ export async function issueCertificatesForService( `Certificate provisioning failed for: ${failedDomains.join(", ")}`, ); } + + return { + domains: domainsNeedingCerts, + existingDomains, + issuedDomains, + failedDomains, + }; } export async function createDeploymentRecords( diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts index 07ae61b..3747e1b 100644 --- a/web/lib/inngest/functions/rollout-workflow.ts +++ b/web/lib/inngest/functions/rollout-workflow.ts @@ -178,13 +178,15 @@ export const rolloutWorkflow = inngest.createFunction( }); } else { await step.run("cleanup-existing", async () => { - await cleanupExistingDeployments(serviceId); - await ingestRolloutLog( - rolloutId, - serviceId, - "preparing", - "Cleaned up existing deployments", - ); + const { deletedCount } = await cleanupExistingDeployments(serviceId); + if (deletedCount > 0) { + await ingestRolloutLog( + rolloutId, + serviceId, + "preparing", + `Cleaned up ${deletedCount} existing deployment(s)`, + ); + } }); } @@ -194,13 +196,15 @@ export const rolloutWorkflow = inngest.createFunction( .set({ currentStage: "certificates" }) .where(eq(rollouts.id, rolloutId)); try { - await issueCertificatesForService(serviceId); - await ingestRolloutLog( - rolloutId, - serviceId, - "certificates", - "Certificates issued", - ); + const result = await issueCertificatesForService(serviceId); + if (result.issuedDomains.length > 0) { + await ingestRolloutLog( + rolloutId, + serviceId, + "certificates", + `Certificates issued for ${result.issuedDomains.length} domain(s)`, + ); + } return { success: true as const }; } catch (error) { const message = @@ -452,7 +456,7 @@ export const rolloutWorkflow = inngest.createFunction( if (isRollingUpdate) { await step.run("stop-old-deployments", async () => { - await db + const stoppedDeployments = await db .update(deployments) .set({ status: "stopping", desired: false }) .where( @@ -460,13 +464,17 @@ export const rolloutWorkflow = inngest.createFunction( eq(deployments.serviceId, serviceId), eq(deployments.status, "draining"), ), + ) + .returning({ id: deployments.id }); + + if (stoppedDeployments.length > 0) { + await ingestRolloutLog( + rolloutId, + serviceId, + "dns_sync", + `Stopping ${stoppedDeployments.length} old deployment(s) after DNS sync`, ); - await ingestRolloutLog( - rolloutId, - serviceId, - "dns_sync", - "Stopping old deployments after DNS sync", - ); + } }); } diff --git a/web/lib/s3.ts b/web/lib/s3.ts index bf0ba75..eba6502 100644 --- a/web/lib/s3.ts +++ b/web/lib/s3.ts @@ -1,7 +1,7 @@ import { DeleteObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getBackupStorageConfig } from "@/db/queries"; -const DEFAULT_S3_DELETE_TIMEOUT_MS = 15000; +const DEFAULT_S3_DELETE_TIMEOUT_MS = 60000; let cachedClient: S3Client | null = null; let cachedConfigHash: string | null = null; @@ -10,8 +10,18 @@ function hashConfig(config: { region: string; endpoint: string | null; accessKey: string; + secretKey: string; }): string { - return `${config.region}-${config.endpoint}-${config.accessKey}`; + return `${config.region}-${config.endpoint}-${config.accessKey}-${config.secretKey}`; +} + +function getS3DeleteTimeoutMs(): number { + const configuredTimeout = Number(process.env.S3_DELETE_TIMEOUT_MS); + if (Number.isFinite(configuredTimeout) && configuredTimeout > 0) { + return configuredTimeout; + } + + return DEFAULT_S3_DELETE_TIMEOUT_MS; } async function getS3Client(): Promise { @@ -44,7 +54,7 @@ async function getS3Client(): Promise { export async function deleteFromS3( bucket: string, key: string, - timeoutMs = DEFAULT_S3_DELETE_TIMEOUT_MS, + timeoutMs = getS3DeleteTimeoutMs(), ): Promise { const client = await getS3Client(); if (!client) { diff --git a/web/next.config.ts b/web/next.config.ts index 42b02f4..398b0d5 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,13 +1,7 @@ import type { NextConfig } from "next"; -const allowedDevOrigins = (process.env.ALLOWED_DEV_ORIGINS ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const nextConfig: NextConfig = { output: "standalone", - allowedDevOrigins, }; export default nextConfig; diff --git a/web/package.json b/web/package.json index 638ba82..c07d2f1 100644 --- a/web/package.json +++ b/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "portless cloud --app-port 3000 next dev", + "dev": "next dev", "build": "next build", "start": "next start", "test": "vitest run", @@ -58,7 +58,6 @@ "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.2.9", - "portless": "^0.13.0", "tailwindcss": "^4", "tsx": "^4.19.2", "typescript": "^5", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1af7703..29eed22 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -145,9 +145,6 @@ importers: eslint-config-next: specifier: 16.2.9 version: 16.2.9(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - portless: - specifier: ^0.13.0 - version: 0.13.1 tailwindcss: specifier: ^4 version: 4.3.1 @@ -4958,12 +4955,6 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} - portless@0.13.1: - resolution: {integrity: sha512-1B+8Xd4tB1gOIkRHOUEi/LCOp8PIxUsL3ACBX5G2UkKw25TSRNUqAlQcBGxweJgdXb9DPQFX5utKLRUzPFhMnA==} - engines: {node: '>=24'} - os: [darwin, linux, win32] - hasBin: true - possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -10690,8 +10681,6 @@ snapshots: dependencies: find-up: 3.0.0 - portless@0.13.1: {} - possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.4: