diff --git a/apps/mesh/src/settings/resolve-config.ts b/apps/mesh/src/settings/resolve-config.ts index cbcc023470..e34dffd5b4 100644 --- a/apps/mesh/src/settings/resolve-config.ts +++ b/apps/mesh/src/settings/resolve-config.ts @@ -228,6 +228,9 @@ export function resolveConfig( envVars.AWS_S3_TENANT_PROVISIONER_ACCESS_KEY_ID, awsS3TenantProvisionerSecretAccessKey: envVars.AWS_S3_TENANT_PROVISIONER_SECRET_ACCESS_KEY, + + decoCacheS3Bucket: envVars.DECO_CACHE_S3_BUCKET, + decoCacheS3Region: envVars.DECO_CACHE_S3_REGION, }; return { diff --git a/apps/mesh/src/settings/types.ts b/apps/mesh/src/settings/types.ts index d5b0dc03bd..e7f7c0d76b 100644 --- a/apps/mesh/src/settings/types.ts +++ b/apps/mesh/src/settings/types.ts @@ -138,6 +138,10 @@ export interface Settings { awsS3TenantRoleArn: string | undefined; awsS3TenantProvisionerAccessKeyId: string | undefined; awsS3TenantProvisionerSecretAccessKey: string | undefined; + + // deco S3 cache — pre-signed URL generation for deco-sites sandbox warm-up. + decoCacheS3Bucket: string | undefined; + decoCacheS3Region: string | undefined; } export interface CliFlags { diff --git a/apps/mesh/src/tools/sandbox/start.ts b/apps/mesh/src/tools/sandbox/start.ts index eb1422e909..eb25c445a1 100644 --- a/apps/mesh/src/tools/sandbox/start.ts +++ b/apps/mesh/src/tools/sandbox/start.ts @@ -8,6 +8,9 @@ * (user, branch) key — no stale-sandbox teardown is needed on kind change. */ +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { z } from "zod"; import type { SandboxRecord } from "@decocms/mesh-sdk"; import { @@ -66,6 +69,53 @@ const sandboxProviderKindInputSchema = z.enum([ "cluster", ]); +async function tryGenerateDecoCachePresignedUrl( + owner: string, + repo: string, +): Promise { + if (owner !== "deco-sites") return undefined; + const settings = getSettings(); + const bucket = settings.decoCacheS3Bucket; + const region = settings.decoCacheS3Region; + const roleArn = settings.awsS3TenantRoleArn; + if (!bucket || !region || !roleArn) return undefined; + try { + const accessKeyId = settings.awsS3TenantProvisionerAccessKeyId; + const secretAccessKey = settings.awsS3TenantProvisionerSecretAccessKey; + const sts = new STSClient({ + region, + ...(accessKeyId && secretAccessKey + ? { credentials: { accessKeyId, secretAccessKey } } + : {}), + }); + const { Credentials: creds } = await sts.send( + new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: "deco-cache-presign", + }), + ); + if (!creds?.AccessKeyId || !creds.SecretAccessKey) return undefined; + const s3 = new S3Client({ + region, + credentials: { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + }, + }); + return await getSignedUrl( + s3, + new GetObjectCommand({ + Bucket: bucket, + Key: `${owner}/${repo}/cache.tar.zst`, + }), + { expiresIn: 900 }, + ); + } catch { + return undefined; + } +} + export const SANDBOX_START = defineTool({ name: "SANDBOX_START", description: "Start a sandbox with the connected GitHub repo and dev server.", @@ -297,6 +347,9 @@ async function provisionSandbox( userEmail: string; branch: string; displayName: string; + owner?: string; + name?: string; + denoCachePresignedUrl?: string; } | undefined; @@ -376,6 +429,12 @@ async function provisionSandbox( userEmail: gitUserEmail, branch, displayName: `${githubRepo.owner}/${githubRepo.name}`, + owner: githubRepo.owner, + name: githubRepo.name, + denoCachePresignedUrl: await tryGenerateDecoCachePresignedUrl( + githubRepo.owner, + githubRepo.name, + ), }; } diff --git a/bun.lock b/bun.lock index d8105075e5..d651b11a99 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ }, "apps/mesh": { "name": "decocms", - "version": "3.53.0", + "version": "3.57.0", "bin": { "deco": "./dist/server/cli.js", }, @@ -211,7 +211,7 @@ }, "packages/e2e": { "name": "@decocms/e2e", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@decocms/harness": "workspace:*", "@decocms/sandbox": "workspace:*", @@ -311,8 +311,9 @@ }, "packages/sandbox": { "name": "@decocms/sandbox", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { + "@aws-sdk/client-s3": "^3.1013.0", "@decocms/harness": "workspace:*", "@decocms/std": "workspace:*", "@kubernetes/client-node": "^1.4.0", diff --git a/deploy/helm/sandbox-env/templates/_helpers.tpl b/deploy/helm/sandbox-env/templates/_helpers.tpl index f2a3ab7c61..42ca0cab31 100644 --- a/deploy/helm/sandbox-env/templates/_helpers.tpl +++ b/deploy/helm/sandbox-env/templates/_helpers.tpl @@ -146,6 +146,7 @@ atomic and gives a pointer to the right install command. {{- end }} {{- end }} + {{- define "sandbox-env.housekeeperName" -}} {{- printf "sandbox-housekeeper-%s" (include "sandbox-env.envName" .) -}} {{- end }} diff --git a/packages/sandbox/daemon/setup/install.ts b/packages/sandbox/daemon/setup/install.ts index ae07e63627..9443bbee6a 100644 --- a/packages/sandbox/daemon/setup/install.ts +++ b/packages/sandbox/daemon/setup/install.ts @@ -1,10 +1,70 @@ import { existsSync } from "node:fs"; +import { createWriteStream } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; +import { pipeline } from "node:stream/promises"; import { PACKAGE_MANAGER_DAEMON_CONFIG } from "../constants"; import { resolvePmRoot } from "../paths"; import type { Config } from "../types"; import { spawnSetupStep } from "./spawn-step"; +/** + * For Deno projects, tries to pre-populate $DENO_DIR from a pre-signed S3 URL + * provided by mesh. The URL is scoped to exactly one object (owner/repo/cache.tar.zst) + * and expires in 15 minutes — no AWS credentials needed in the sandbox pod. + * + * Non-fatal: any failure (URL expired, object not found, network error) is logged + * and the sandbox continues to start normally. + */ +export function tryWarmDenoCache(deps: InstallDeps): Promise | null { + const { config } = deps; + if (config.application?.packageManager?.name !== "deno") return null; + + const presignedUrl = config.denoCache?.presignedUrl; + if (!presignedUrl) return null; + + return (async () => { + deps.onChunk( + "setup", + "\r\n[deno cache] fetching from pre-signed URL...\r\n", + ); + + let tmpDir: string | null = null; + try { + const res = await fetch(presignedUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.body) throw new Error("empty response body"); + + tmpDir = await mkdtemp(join(tmpdir(), "deco-cache-")); + const tmpFile = join(tmpDir, "cache.tar.zst"); + const ws = createWriteStream(tmpFile); + await pipeline(res.body as unknown as NodeJS.ReadableStream, ws); + + const denoDir = + deps.env?.DENO_DIR ?? process.env.DENO_DIR ?? join(homedir(), ".deno"); + const cmd = [ + `mkdir -p "${denoDir}"`, + `zstd -d "${tmpFile}" --stdout | tar xf - -C "${denoDir}" --no-same-permissions --no-same-owner`, + `echo "[deno cache] restored from S3"`, + ].join(" && "); + + await spawnSetupStep(cmd, deps.onChunk, { + dropPrivileges: deps.dropPrivileges, + env: deps.env, + }); + } catch { + deps.onChunk( + "setup", + "[deno cache] not available — deno will fetch deps on first run\r\n", + ); + } finally { + if (tmpDir) + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + })(); +} + export interface InstallDeps { config: Config; dropPrivileges?: boolean; diff --git a/packages/sandbox/daemon/setup/orchestrator.ts b/packages/sandbox/daemon/setup/orchestrator.ts index 6795282088..ba79d5ad20 100644 --- a/packages/sandbox/daemon/setup/orchestrator.ts +++ b/packages/sandbox/daemon/setup/orchestrator.ts @@ -27,7 +27,7 @@ import type { Application, Config } from "../types"; import { autodetectApplication } from "./autodetect"; import { spawnClone } from "./clone"; import { configureGitIdentity } from "./identity"; -import { spawnInstall } from "./install"; +import { spawnInstall, tryWarmDenoCache } from "./install"; import { spawnSetupStep } from "./spawn-step"; import { installProtectedBranchHook } from "../git/protect-branch"; @@ -323,6 +323,17 @@ export class SetupOrchestrator { // present yet). Treat as success so the caller proceeds to start; mark // the install fingerprint so resume doesn't retry on every boot. if (!installPromise) { + // For deno, opportunistically pre-populate the cache from S3. + // Non-fatal: failure just means a cold first run. + const cachePromise = tryWarmDenoCache({ + config, + env: config.env, + onChunk: (_src, data) => { + this.rawChunk(data); + installTee.write(data); + }, + }); + if (cachePromise) await cachePromise; installTee.close(); this.markInstallSucceeded(config); return true; diff --git a/packages/sandbox/daemon/types.ts b/packages/sandbox/daemon/types.ts index e40c320792..0890742002 100644 --- a/packages/sandbox/daemon/types.ts +++ b/packages/sandbox/daemon/types.ts @@ -36,6 +36,10 @@ export interface GitRepository { readonly cloneUrl: string; readonly branch?: string; readonly repoName?: string; + /** GitHub org or user that owns the repo (e.g. "deco-sites"). */ + readonly owner?: string; + /** Repository name without owner (e.g. "my-site"). */ + readonly name?: string; } export interface GitConfig { @@ -67,6 +71,7 @@ export interface TenantConfig { readonly operator?: OperatorIdentity; readonly application?: Application; readonly env?: Readonly>; + readonly denoCache?: { readonly presignedUrl: string }; } /** In-memory enriched view: TenantConfig + derivations. */ diff --git a/packages/sandbox/image/Dockerfile b/packages/sandbox/image/Dockerfile index 1a0de19c34..af50f90126 100644 --- a/packages/sandbox/image/Dockerfile +++ b/packages/sandbox/image/Dockerfile @@ -9,7 +9,7 @@ ARG DENO_VERSION=v1.46.3 RUN apt-get update \ && apt-get install -y --no-install-recommends \ bash ca-certificates curl git gnupg locales python3 python3-pip \ - ripgrep unzip \ + ripgrep unzip zstd \ && sed -i 's/^#\s*en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ && locale-gen \ && curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ diff --git a/packages/sandbox/server/provider/agent-sandbox/runner.ts b/packages/sandbox/server/provider/agent-sandbox/runner.ts index 94eaa54f44..30d08b8d78 100644 --- a/packages/sandbox/server/provider/agent-sandbox/runner.ts +++ b/packages/sandbox/server/provider/agent-sandbox/runner.ts @@ -1324,6 +1324,7 @@ export class AgentSandboxProvider implements SandboxProvider { repo: opts?.repo ?? null, port: opts?.workload?.devPort ?? DEFAULT_DEV_PORT, tenant: opts?.tenant ?? undefined, + denoCachePresignedUrl: opts?.repo?.denoCachePresignedUrl, }); } diff --git a/packages/sandbox/server/provider/shared/build-config-payload.ts b/packages/sandbox/server/provider/shared/build-config-payload.ts index bf0428ad61..05b7b2e78c 100644 --- a/packages/sandbox/server/provider/shared/build-config-payload.ts +++ b/packages/sandbox/server/provider/shared/build-config-payload.ts @@ -13,6 +13,7 @@ export function buildConfigPayload(args: { port?: number; repo: NonNullable | null; tenant?: EnsureOptions["tenant"]; + denoCachePresignedUrl?: string; }): Partial | null { const repo = args.repo; const git = repo @@ -21,6 +22,8 @@ export function buildConfigPayload(args: { cloneUrl: repo.cloneUrl, repoName: repo.displayName ?? deriveRepoLabel(repo.cloneUrl), ...(repo.branch ? { branch: repo.branch } : {}), + ...(repo.owner ? { owner: repo.owner } : {}), + ...(repo.name ? { name: repo.name } : {}), }, identity: { userName: repo.userName, @@ -51,11 +54,16 @@ export function buildConfigPayload(args: { } : undefined; - if (!git && !application && !operator) return null; + const denoCache = args.denoCachePresignedUrl + ? { presignedUrl: args.denoCachePresignedUrl } + : undefined; + + if (!git && !application && !operator && !denoCache) return null; return { ...(git ? { git } : {}), ...(operator ? { operator } : {}), ...(application ? { application } : {}), + ...(denoCache ? { denoCache } : {}), }; } diff --git a/packages/sandbox/server/provider/types.ts b/packages/sandbox/server/provider/types.ts index 23afd11b77..e6e8b0b36f 100644 --- a/packages/sandbox/server/provider/types.ts +++ b/packages/sandbox/server/provider/types.ts @@ -70,6 +70,12 @@ export interface EnsureOptions { branch?: string; /** Human-readable label for logs/UI; no functional effect. */ displayName?: string; + /** GitHub org or user that owns the repo (e.g. "deco-sites"). */ + owner?: string; + /** Pre-signed URL to download the Deno module cache from S3. */ + denoCachePresignedUrl?: string; + /** Repository name without owner (e.g. "my-site"). */ + name?: string; }; /** Image override. Non-image runners MUST ignore. */ image?: string;