diff --git a/scripts/artifact-registry-ssh-timeout-contract-test.ts b/scripts/artifact-registry-ssh-timeout-contract-test.ts index 76a7ebb6..b29ac5ef 100644 --- a/scripts/artifact-registry-ssh-timeout-contract-test.ts +++ b/scripts/artifact-registry-ssh-timeout-contract-test.ts @@ -12,6 +12,8 @@ assertCondition(source.includes("downloadRemoteFile(options, remoteArchive, loca assertCondition(source.includes("runRemoteScriptBackground(options, remoteScript"), "remote docker save must run as a background job"); assertCondition(source.includes('runRemoteScriptBackground(options, deployScript, Math.max(options.timeoutMs, 420_000), "d601-k3s-deploy")'), "D601 k3s deploy must use background polling"); assertCondition(source.includes('"ssh",\n options.providerId,\n "download"'), "download helper must route through UniDesk ssh download"); +assertCondition(source.includes('"--chunk-bytes",\n "96000"'), "artifact ssh download must use the largest bounded chunk size"); +assertCondition(source.includes("UNIDESK_SSH_CLIENT_TOKEN") && source.includes("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST"), "dev frontend artifact deploy must sync scoped ssh runtime keys"); console.log(JSON.stringify({ ok: true, @@ -19,6 +21,8 @@ console.log(JSON.stringify({ assertions: [ "no docker-save stdout stream over ssh", "compose artifact uses verified ssh download", - "remote docker save and k3s deploy use background polling" + "remote docker save and k3s deploy use background polling", + "artifact downloads use the largest bounded ssh chunk size", + "dev frontend artifact deploy syncs scoped ssh runtime keys" ] }, null, 2)); diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 95013de3..e8a701db 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -17,6 +17,7 @@ import { type DeployJsonServiceContract, } from "./deploy-json-contract"; import { d601K3sGuardShellLines } from "./d601-k3s-guard"; +import { composeRuntimeEnvValue } from "./runtime-env"; export type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service"; type ArtifactDeployEnvironment = "prod" | "dev"; @@ -1676,6 +1677,8 @@ function downloadRemoteFile(options: ArtifactRegistryOptions, remotePath: string "ssh", options.providerId, "download", + "--chunk-bytes", + "96000", remotePath, localPath, ], repoRoot, { timeoutMs }); @@ -2455,13 +2458,18 @@ function verifyLocalArtifactLabels( } function d601DevFrontendAuthPatchScript(config: UniDeskConfig): string { + const sshClientToken = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_TOKEN"); + if (sshClientToken === null) throw new Error("UNIDESK_SSH_CLIENT_TOKEN must be present in .state/docker-compose.env before deploying dev frontend"); + const sshClientRouteAllowlist = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST") ?? "G14,G14:*,D601,D601:*"; const secretData = { AUTH_USERNAME: base64(config.auth.username), AUTH_PASSWORD: base64(config.auth.password), SESSION_SECRET: base64(config.auth.sessionSecret), + UNIDESK_SSH_CLIENT_TOKEN: base64(sshClientToken), }; const configData = { SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds), + UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: sshClientRouteAllowlist, }; return [ d601K3sGuardScript(), diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 32104bc5..7c7345db 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -10,6 +10,7 @@ import { startJob } from "./jobs"; import { coreInternalFetch } from "./microservices"; import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "./code-queue-source-guard"; import { d601K3sGuardShellLines, d601NativeKubeconfig } from "./d601-k3s-guard"; +import { composeRuntimeEnvValue } from "./runtime-env"; import { compareDeployJsonExecutorMirrors, deployJsonCommitImage, @@ -1513,13 +1514,18 @@ function devK3sPrepullImages(service: UniDeskMicroserviceConfig): string[] { } function syncDevFrontendAuthScript(config: UniDeskConfig): string { + const sshClientToken = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_TOKEN"); + if (sshClientToken === null) throw new Error("UNIDESK_SSH_CLIENT_TOKEN must be present in .state/docker-compose.env before deploying dev frontend"); + const sshClientRouteAllowlist = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST") ?? "G14,G14:*,D601,D601:*"; const data = { AUTH_USERNAME: Buffer.from(config.auth.username, "utf8").toString("base64"), AUTH_PASSWORD: Buffer.from(config.auth.password, "utf8").toString("base64"), SESSION_SECRET: Buffer.from(config.auth.sessionSecret, "utf8").toString("base64"), + UNIDESK_SSH_CLIENT_TOKEN: Buffer.from(sshClientToken, "utf8").toString("base64"), }; const runtimeConfig = { SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds), + UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: sshClientRouteAllowlist, }; return [ "set -euo pipefail", diff --git a/scripts/src/runtime-env.ts b/scripts/src/runtime-env.ts new file mode 100644 index 00000000..5e744b28 --- /dev/null +++ b/scripts/src/runtime-env.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from "node:fs"; +import { rootPath } from "./config"; + +export function canonicalComposeEnvFile(): string { + return rootPath(".state", "docker-compose.env"); +} + +function unquoteEnvValue(value: string): string { + const trimmed = value.trim(); + if (trimmed.length < 2) return trimmed; + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first !== "\"" && first !== "'") || last !== first) return trimmed; + return trimmed.slice(1, -1); +} + +export function readEnvFileValues(path: string): Map { + const values = new Map(); + if (!existsSync(path)) return values; + for (const line of readFileSync(path, "utf8").split(/\r?\n/u)) { + if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; + const index = line.indexOf("="); + if (index <= 0) continue; + const key = line.slice(0, index).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue; + values.set(key, unquoteEnvValue(line.slice(index + 1))); + } + return values; +} + +export function composeRuntimeEnvValue(key: string, env: NodeJS.ProcessEnv = process.env): string | null { + const fromProcess = env[key]?.trim(); + if (fromProcess !== undefined && fromProcess.length > 0) return fromProcess; + const fromFile = readEnvFileValues(canonicalComposeEnvFile()).get(key)?.trim(); + return fromFile === undefined || fromFile.length === 0 ? null : fromFile; +}