diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 51514fc..7d7062f 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -12,7 +12,13 @@ import { migrateLegacyOrchLayout, syncAuthArtifacts } from "../auth-sync.js" -import { findAuthorizedKeysSource, resolveAuthorizedKeysPath } from "../path-helpers.js" +import { + defaultProjectsRoot, + findAuthorizedKeysSource, + findExistingPath, + findSshPrivateKey, + resolveAuthorizedKeysPath +} from "../path-helpers.js" import { withFsPathContext } from "../runtime.js" import { resolvePathFromBase } from "./paths.js" @@ -40,6 +46,45 @@ const ensureFileReady = ( return "exists" }) +const appendKeyIfMissing = ( + fs: FileSystem.FileSystem, + resolved: string, + source: string, + desiredContents: string +): Effect.Effect => + Effect.gen(function*(_) { + const currentContents = yield* _(fs.readFileString(resolved)) + const currentLines = currentContents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + if (currentLines.includes(desiredContents)) { + return + } + + const normalizedCurrent = currentContents.trimEnd() + const nextContents = normalizedCurrent.length === 0 + ? `${desiredContents}\n` + : `${normalizedCurrent}\n${desiredContents}\n` + + yield* _(fs.writeFileString(resolved, nextContents)) + yield* _(Effect.log(`Authorized keys appended from ${source} to ${resolved}`)) + }) + +const resolveAuthorizedKeysSource = ( + fs: FileSystem.FileSystem, + path: Path.Path, + cwd: string +): Effect.Effect => + Effect.gen(function*(_) { + const sshPrivateKey = yield* _(findSshPrivateKey(fs, path, cwd)) + const matchingPublicKey = sshPrivateKey === null ? null : yield* _(findExistingPath(fs, `${sshPrivateKey}.pub`)) + return matchingPublicKey === null + ? yield* _(findAuthorizedKeysSource(fs, path, cwd)) + : matchingPublicKey + }) + const ensureAuthorizedKeys = ( baseDir: string, authorizedKeysPath: string @@ -47,6 +92,7 @@ const ensureAuthorizedKeys = ( withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { const resolved = resolveAuthorizedKeysPath(path, baseDir, authorizedKeysPath) + const managedDefaultAuthorizedKeys = path.join(defaultProjectsRoot(process.cwd()), "authorized_keys") const state = yield* _( ensureFileReady( fs, @@ -55,11 +101,8 @@ const ensureAuthorizedKeys = ( `Authorized keys was a directory, moved to ${backupPath}. Creating a file at ${resolvedPath}.` ) ) - if (state === "exists") { - return - } - const source = yield* _(findAuthorizedKeysSource(fs, path, process.cwd())) + const source = yield* _(resolveAuthorizedKeysSource(fs, path, process.cwd())) if (source === null) { yield* _( Effect.logError( @@ -69,6 +112,19 @@ const ensureAuthorizedKeys = ( return } + const desiredContents = (yield* _(fs.readFileString(source))).trim() + if (desiredContents.length === 0) { + yield* _(Effect.logWarning(`Authorized keys source ${source} is empty. Skipping SSH key sync.`)) + return + } + + if (state === "exists") { + if (resolved === managedDefaultAuthorizedKeys) { + yield* _(appendKeyIfMissing(fs, resolved, source, desiredContents)) + } + return + } + yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) yield* _(fs.copyFile(source, resolved)) yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`)) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 1c8df2a..916beed 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -23,6 +23,36 @@ const withTempDir = ( }) ) +const withPatchedEnv = ( + patch: Readonly>, + effect: Effect.Effect +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = new Map() + for (const [key, value] of Object.entries(patch)) { + previous.set(key, process.env[key]) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + }) + ) + const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({ containerName: "dg-test", serviceName: "dg-test", @@ -209,4 +239,50 @@ describe("prepareProjectFiles", () => { expect(compose).not.toContain("external: true") }) ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("appends the active public key to the managed authorized_keys file", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const homeDir = path.join(root, "home") + const projectsRoot = path.join(homeDir, ".docker-git") + const outDir = path.join(projectsRoot, "org", "repo") + const authorizedKeysPath = path.join(projectsRoot, "authorized_keys") + const sshPrivateKeyPath = path.join(homeDir, ".ssh", "id_ed25519") + const sshPublicKeyPath = `${sshPrivateKeyPath}.pub` + const staleKey = "ssh-ed25519 AAAA-stale stale@example\n" + const currentKey = "ssh-ed25519 AAAA-current current@example\n" + const globalConfig = makeGlobalConfig(projectsRoot, path) + const projectConfig = { + ...makeProjectConfig(outDir, false, path), + authorizedKeysPath: "../../authorized_keys" + } + + yield* _(fs.makeDirectory(path.dirname(authorizedKeysPath), { recursive: true })) + yield* _(fs.makeDirectory(path.dirname(sshPrivateKeyPath), { recursive: true })) + yield* _(fs.writeFileString(authorizedKeysPath, staleKey)) + yield* _(fs.writeFileString(sshPrivateKeyPath, "PRIVATE\n")) + yield* _(fs.writeFileString(sshPublicKeyPath, currentKey)) + + yield* _( + withPatchedEnv( + { + HOME: homeDir, + DOCKER_GIT_PROJECTS_ROOT: projectsRoot, + DOCKER_GIT_AUTHORIZED_KEYS: undefined, + DOCKER_GIT_SSH_KEY: undefined + }, + prepareProjectFiles(outDir, projectsRoot, globalConfig, projectConfig, { + force: false, + forceEnv: false + }) + ) + ) + + const synchronizedAuthorizedKeys = yield* _(fs.readFileString(authorizedKeysPath)) + expect(synchronizedAuthorizedKeys).toContain(staleKey.trim()) + expect(synchronizedAuthorizedKeys).toContain(currentKey.trim()) + }) + ).pipe(Effect.provide(NodeContext.layer))) })