diff --git a/README.md b/README.md index 5aec6c5..14bbf90 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ state root. ./rootcell select dev # use the dev instance by default ./rootcell -- pi # run pi directly ./rootcell -- nix flake update # run any command inside the agent VM +./rootcell copy ./file :/tmp/ # copy a host file into the agent VM +./rootcell copy -r :/tmp/out ./out # copy files back from the agent VM recursively ./rootcell edit env # edit the instance .env in $EDITOR ./rootcell edit http # edit the HTTPS allowlist in $EDITOR ./rootcell edit dns # edit the DNS allowlist in $EDITOR diff --git a/src/rootcell/args.ts b/src/rootcell/args.ts index 81f66dd..540fc8d 100644 --- a/src/rootcell/args.ts +++ b/src/rootcell/args.ts @@ -1,5 +1,6 @@ import yargs from "yargs/yargs"; import type { Argv, ArgumentsCamelCase } from "yargs"; +import { parseRootcellCopySpec } from "./copy.ts"; import { completeExtensionCommand } from "./extensions/commands.ts"; import { isRootcellSubcommand, ROOTCELL_SUBCOMMANDS, type RootcellSubcommand } from "./metadata.ts"; import { DEFAULT_INSTANCE, listRootcellInstanceNames, readSelectedRootcellInstance, validateInstanceName } from "./instance.ts"; @@ -10,6 +11,7 @@ import { ParsedRootcellRunArgsSchema, ParsedRootcellSelectArgsSchema, ROOTCELL_INIT_ENV_PROVIDER_TYPES, + RootcellCopyOptionsSchema, RootcellInitEnvProviderTypeSchema, SpyOptionsSchema, type ParsedRootcellArgs, @@ -40,6 +42,11 @@ interface ExtensionArgs extends GlobalArgs { readonly extensionArgs?: readonly string[]; } +interface CopyArgs extends GlobalArgs { + readonly copyArgs?: readonly string[]; + readonly recursive?: boolean; +} + interface SelectArgs extends GlobalArgs { readonly selectedInstance?: string; } @@ -212,6 +219,25 @@ function createParser(args: readonly string[]): Argv { .command(...rootcellSubcommand("provision")) .command(...rootcellSubcommand("allow")) .command(...rootcellSubcommand("pubkey")) + .command( + "copy [copyArgs..]", + subcommandDescription("copy"), + (argv: ParserArgv) => argv + .parserConfiguration({ "unknown-options-as-args": false }) + .option("recursive", { + alias: "r", + describe: "copy directories recursively", + type: "boolean", + default: false, + }) + .positional("copyArgs", { + array: true, + describe: "source path(s) and target path; guest paths use :/path", + type: "string", + }) + .demandCommand(0, 0) + .strictOptions(), + ) .command(...rootcellSubcommand("list")) .command(...rootcellSubcommand("stop")) .command(...rootcellSubcommand("remove")) @@ -265,6 +291,8 @@ function createParser(args: readonly string[]): Argv { .example("$0 edit http", "edit the HTTPS allowlist for the selected instance") .example("$0 --instance dev edit dns", "edit the DNS allowlist for the dev instance") .example("$0 --instance dev allow", "reload allowlists for the dev instance") + .example("$0 copy ./file :/tmp/", "copy a host file into the selected agent VM") + .example("$0 copy -r :/tmp/output ./output", "copy a directory from the selected agent VM") .example("$0 --instance aws-dev --init-env aws-ec2", "initialize an AWS EC2 instance environment") .example("$0 list", "list rootcell VMs and their current state") .example("$0 stop --instance dev", "stop the dev instance VMs") @@ -277,7 +305,7 @@ function createParser(args: readonly string[]): Argv { export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { rejectUnknownSpyHelpOptions(args); - const argv = createParser(args).parseSync() as ArgumentsCamelCase; + const argv = createParser(args).parseSync() as ArgumentsCamelCase; const firstToken = firstRootcellToken(args); if ( argv.help === true @@ -319,7 +347,14 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { ? [argString((argv as ArgumentsCamelCase).target)] : subcommand === "extension" ? stringArray((argv as ArgumentsCamelCase).extensionArgs) + : subcommand === "copy" + ? validatedCopyArgs(argv) : []; + const copyOptions = subcommand === "copy" + ? parseSchema(RootcellCopyOptionsSchema, { + recursive: argv.recursive ?? false, + }, "invalid copy options") + : undefined; return parseSchema(ParsedRootcellRunArgsSchema, { kind: "run", instanceName: instanceName(argv), @@ -330,6 +365,7 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { open: argv.open ?? true, }, "invalid spy options") : DEFAULT_SPY_OPTIONS, + ...(copyOptions === undefined ? {} : { copyOptions }), }, "invalid parsed rootcell args"); } @@ -358,7 +394,13 @@ function fail(message: string, error: Error): never { throw error instanceof Error ? error : new Error(message); } -function parsedSubcommand(argv: ArgumentsCamelCase): RootcellSubcommand | undefined { +function validatedCopyArgs(argv: ArgumentsCamelCase): readonly string[] { + const rest = stringArray(argv.copyArgs); + parseRootcellCopySpec(rest); + return rest; +} + +function parsedSubcommand(argv: ArgumentsCamelCase): RootcellSubcommand | undefined { const command = argv._[0]; return typeof command === "string" && isRootcellSubcommand(command) ? command : undefined; } diff --git a/src/rootcell/copy.ts b/src/rootcell/copy.ts new file mode 100644 index 0000000..8041ff6 --- /dev/null +++ b/src/rootcell/copy.ts @@ -0,0 +1,52 @@ +export interface RootcellCopySpec { + readonly sources: readonly string[]; + readonly target: string; + readonly direction: "host-to-guest" | "guest-to-host"; +} + +export function parseRootcellCopySpec(operands: readonly string[]): RootcellCopySpec { + if (operands.length < 2) { + throw new Error("copy requires at least one source and a target"); + } + for (const operand of operands) { + if (operand === ":") { + throw new Error("copy guest paths must include a path after ':'"); + } + } + + const sources = operands.slice(0, -1); + const target = operands[operands.length - 1]; + if (target === undefined) { + throw new Error("copy requires at least one source and a target"); + } + + const firstSourceIsGuest = isRootcellGuestCopyPath(sources[0] ?? ""); + if (!sources.every((source) => isRootcellGuestCopyPath(source) === firstSourceIsGuest)) { + throw new Error("copy sources must be all host paths or all guest paths"); + } + + const targetIsGuest = isRootcellGuestCopyPath(target); + if (firstSourceIsGuest === targetIsGuest) { + throw new Error("copy requires exactly one side to use guest path shorthand"); + } + + return { + sources, + target, + direction: firstSourceIsGuest ? "guest-to-host" : "host-to-guest", + }; +} + +export function isRootcellGuestCopyPath(value: string): boolean { + return value.startsWith(":"); +} + +export function rootcellGuestCopyPath(value: string): string { + if (!isRootcellGuestCopyPath(value)) { + return value; + } + if (value === ":") { + throw new Error("copy guest paths must include a path after ':'"); + } + return value.slice(1); +} diff --git a/src/rootcell/metadata.ts b/src/rootcell/metadata.ts index fcd14f5..6515f70 100644 --- a/src/rootcell/metadata.ts +++ b/src/rootcell/metadata.ts @@ -1,5 +1,5 @@ export interface SubcommandMetadata { - readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit" | "extension" | "select"; + readonly name: "provision" | "allow" | "pubkey" | "copy" | "spy" | "list" | "stop" | "remove" | "edit" | "extension" | "select"; readonly description: string; } @@ -13,6 +13,7 @@ export const ROOTCELL_SUBCOMMANDS: readonly SubcommandMetadata[] = [ { name: "provision", description: "re-copy files and rebuild both VMs" }, { name: "allow", description: "hot-reload allowlists into the firewall VM" }, { name: "pubkey", description: "print the agent VM SSH public key" }, + { name: "copy", description: "copy files between the host and the agent VM" }, { name: "spy", description: "open the browser spy through a local SSH tunnel" }, ] as const; diff --git a/src/rootcell/providers/aws-ec2.ts b/src/rootcell/providers/aws-ec2.ts index eb5f17f..142327e 100644 --- a/src/rootcell/providers/aws-ec2.ts +++ b/src/rootcell/providers/aws-ec2.ts @@ -1,7 +1,7 @@ import { ProxyJumpSshTransport, type ProxyJumpSshEndpoints } from "../transports/proxyjump-ssh.ts"; import type { CommandResult, InheritedCommandResult } from "../types.ts"; import type { RootcellConfig } from "../types.ts"; -import type { CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts"; +import type { CopyOptions, CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts"; import type { AwsEc2NetworkAttachment } from "./aws-ec2-network.ts"; import { AwsEc2TerraformProject } from "./aws-ec2-terraform.ts"; @@ -100,6 +100,10 @@ export class AwsEc2VmProvider implements VmProvider { return await this.transport.execInteractive(name, command, options); } + copy(name: string, sources: readonly string[], target: string, options: CopyOptions = {}): Promise { + return this.transport.copy(name, sources, target, options); + } + copyToGuest(name: string, hostPath: string, guestPath: string, options: CopyToGuestOptions = {}): Promise { return this.transport.copyToGuest(name, hostPath, guestPath, options); } diff --git a/src/rootcell/providers/lima.ts b/src/rootcell/providers/lima.ts index ebcd226..376efcb 100644 --- a/src/rootcell/providers/lima.ts +++ b/src/rootcell/providers/lima.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; +import { isRootcellGuestCopyPath, rootcellGuestCopyPath } from "../copy.ts"; import { resolveHostTool } from "../host-tools.ts"; import { runAsyncInherited, runCapture, runInherited } from "../process.ts"; import { NonEmptyStringSchema, parseSchema, PositiveSafeIntegerSchema } from "../schema.ts"; @@ -9,7 +10,7 @@ import { forgetKnownHost, ProxyJumpSshTransport, sshConfigValue, type ProxyJumpS import type { RootcellConfig } from "../types.ts"; import type { CommandResult, InheritedCommandResult } from "../types.ts"; import type { LimaUserV2NetworkAttachment } from "./macos-lima-user-v2-network.ts"; -import type { CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts"; +import type { CopyOptions, CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts"; const LimaProviderSchema = z.custom<"lima">((value) => value === "lima", { message: "provider mismatch" }); const LimaVmRoleSchema = z.custom( @@ -224,11 +225,22 @@ export class LimaVmProvider implements VmProvider { return await this.transport.execInteractive(name, command, options); } + copy(name: string, sources: readonly string[], target: string, options: CopyOptions = {}): Promise { + runInherited(this.ensureLimactl(), [ + "--tty=false", + "copy", + ...(options.recursive === true ? ["-r"] : []), + ...sources.map((source) => this.limaCopyOperand(name, source)), + this.limaCopyOperand(name, target), + ]); + return Promise.resolve(); + } + copyToGuest(name: string, hostPath: string, guestPath: string, options: CopyToGuestOptions = {}): Promise { if (this.shouldUseBootstrapSsh(name)) { return this.copyToGuestBootstrap(name, hostPath, guestPath, options); } - return this.transport.copyToGuest(name, hostPath, guestPath, options); + return this.copy(name, [hostPath], `:${guestPath}`, options); } forwardLocalPort(name: string, options: LocalPortForwardOptions): Promise { @@ -568,6 +580,17 @@ export class LimaVmProvider implements VmProvider { } throw new Error(`unknown rootcell VM for Lima provider: ${name}`); } + + private limaCopyOperand(name: string, operand: string): string { + if (!isRootcellGuestCopyPath(operand)) { + return operand; + } + return `${this.limaInstanceName(name)}:${rootcellGuestCopyPath(operand)}`; + } + + private limaInstanceName(name: string): string { + return this.readVmState(name)?.limaInstance ?? name; + } } export function limaYaml(input: { diff --git a/src/rootcell/providers/types.ts b/src/rootcell/providers/types.ts index ea7317a..e79a442 100644 --- a/src/rootcell/providers/types.ts +++ b/src/rootcell/providers/types.ts @@ -54,6 +54,10 @@ export interface CopyToGuestOptions { readonly recursive?: boolean; } +export interface CopyOptions { + readonly recursive?: boolean; +} + export interface LocalPortForwardOptions { readonly localHost: string; readonly localPort: number; @@ -92,6 +96,7 @@ export interface VmProvider; execCapture(name: string, command: readonly string[], options?: ExecOptions): Promise; execInteractive(name: string, command: readonly string[], options?: ExecOptions): Promise; + copy(name: string, sources: readonly string[], target: string, options?: CopyOptions): Promise; copyToGuest(name: string, hostPath: string, guestPath: string, options?: CopyToGuestOptions): Promise; forwardLocalPort(name: string, options: LocalPortForwardOptions): Promise; } diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 0d292fb..dd6fc67 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -310,6 +310,54 @@ describe("rootcell argument parsing", () => { }); }); + test("parses copy subcommands", () => { + const hostToGuest = runArgs(["copy", "./file", ":/tmp/"]); + expectRunArgs(hostToGuest); + expect(hostToGuest).toEqual({ + kind: "run", + instanceName: "default", + subcommand: "copy", + rest: ["./file", ":/tmp/"], + spyOptions: { open: true }, + copyOptions: { recursive: false }, + }); + + const recursive = runArgs(["copy", "-r", "./dir", ":/tmp/"]); + expectRunArgs(recursive); + expect(recursive).toEqual({ + kind: "run", + instanceName: "default", + subcommand: "copy", + rest: ["./dir", ":/tmp/"], + spyOptions: { open: true }, + copyOptions: { recursive: true }, + }); + + const guestToHost = runArgs(["copy", ":/tmp/file", "."]); + expectRunArgs(guestToHost); + expect(guestToHost).toEqual({ + kind: "run", + instanceName: "default", + subcommand: "copy", + rest: [":/tmp/file", "."], + spyOptions: { open: true }, + copyOptions: { recursive: false }, + }); + + const dev = runArgs(["--instance", "dev", "copy", ":relative/path", "."]); + expectRunArgs(dev); + expect(dev.instanceName).toBe("dev"); + expect(dev.rest).toEqual([":relative/path", "."]); + + expect(() => parseRootcellArgs(["copy"])).toThrow("copy requires at least one source and a target"); + expect(() => parseRootcellArgs(["copy", ":"])).toThrow("copy requires at least one source and a target"); + expect(() => parseRootcellArgs(["copy", ":", "."])).toThrow("copy guest paths must include a path after ':'"); + expect(() => parseRootcellArgs(["copy", "./a", "./b"])).toThrow("copy requires exactly one side"); + expect(() => parseRootcellArgs(["copy", ":/a", ":/b"])).toThrow("copy requires exactly one side"); + expect(() => parseRootcellArgs(["copy", "./a", ":/b", ":/tmp/"])).toThrow("copy sources must be all host paths"); + expect(() => parseRootcellArgs(["copy", "--backend=rsync", "./a", ":/tmp/"])).toThrow("Unknown argument: backend"); + }); + test("parses pass-through guest commands", () => { const explicit = runArgs(["--", "nix", "flake", "update"]); expectRunArgs(explicit); @@ -1114,6 +1162,7 @@ describe("VM and network providers", () => { exec: () => Promise.resolve({ status: 0 }), execCapture: () => Promise.resolve({ status: 0, stdout: "", stderr: "" }), execInteractive: () => Promise.resolve(0), + copy: () => Promise.resolve(), copyToGuest: () => Promise.resolve(), forwardLocalPort: (_name, options) => Promise.resolve({ ...options, @@ -1199,6 +1248,7 @@ describe("VM and network providers", () => { calls.push(`interactive:${name}`); return Promise.resolve(0); }, + copy: () => Promise.resolve(), copyToGuest: (_name, _hostPath, guestPath) => { copiedGuestPaths.push(guestPath); return Promise.resolve(); @@ -1229,6 +1279,134 @@ describe("VM and network providers", () => { } }); + test("copy runs after VM readiness without entering the agent shell or reading secrets", async () => { + const repo = makeInstanceRepo(); + try { + const env = instanceEnv(repo); + const config = buildConfig(repo, env, fakeInstance("dev", repo, env)); + mkdirSync(config.instanceDir, { recursive: true }); + mkdirSync(config.generatedDir, { recursive: true }); + mkdirSync(config.proxyDir, { recursive: true }); + writeStrictAgentCa(config.pkiDir); + writeFileSync(config.secretsPath, "AWS_ACCESS_KEY_ID=test-provider:should-not-read\n", "utf8"); + for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) { + writeFileSync(join(config.proxyDir, file), "\n", "utf8"); + } + + const attachment: VmNetworkAttachment = { kind: "fake" }; + const calls: string[] = []; + let secretReads = 0; + let copied: { + readonly name: string; + readonly sources: readonly string[]; + readonly target: string; + readonly recursive: boolean | undefined; + } | undefined; + const providers: ProviderBundle = { + network: { + id: "fake-network", + plan: () => ({ + provider: "fake-network", + guest: { + firewallIp: config.firewallIp, + agentIp: config.agentIp, + networkPrefix: 24, + agentPrivateInterface: "agent0", + firewallPrivateInterface: "firewall0", + firewallEgressInterface: "egress0", + }, + vms: { + agent: attachment, + firewall: attachment, + }, + }), + preflight: () => { + calls.push("network:preflight"); + return Promise.resolve(); + }, + stop: () => Promise.resolve(), + remove: () => Promise.resolve(), + ensureReady: (input) => { + calls.push(`network:ensure:${input.affectedVms.join(",")}`); + return Promise.resolve(); + }, + }, + vm: { + id: "fake-vm", + status: () => Promise.resolve({ state: "running" }), + stopIfRunning: () => Promise.resolve(), + forceStopIfRunning: () => Promise.resolve(), + remove: () => Promise.resolve(), + assertCompatible: (name) => { + calls.push(`assert:${name}`); + return Promise.resolve(); + }, + ensureRunning: (input) => { + calls.push(`ensure:${input.role}:${input.name}`); + return Promise.resolve({ created: false }); + }, + finalizeNetworking: (input) => { + calls.push(`finalize:${input.role}:${input.name}`); + return Promise.resolve(); + }, + exec: (name) => { + calls.push(`exec:${name}`); + return Promise.resolve({ status: 0 }); + }, + execCapture: () => Promise.resolve({ status: 0, stdout: "", stderr: "" }), + execInteractive: () => { + calls.push("interactive"); + return Promise.resolve(99); + }, + copy: (name, sources, target, options) => { + calls.push(`copy:${name}`); + copied = { + name, + sources, + target, + recursive: options?.recursive, + }; + return Promise.resolve(); + }, + copyToGuest: () => Promise.resolve(), + forwardLocalPort: (_name, options) => Promise.resolve({ + ...options, + closed: Promise.resolve(0), + close: () => Promise.resolve(), + }), + }, + secrets: { + ids: ["test-provider"], + read: () => { + secretReads += 1; + return Promise.reject(new Error("copy should not read secrets")); + }, + }, + }; + + const status = await new RootcellApp(config, providers).runAfterEnvironment( + "copy", + ["./file", ":/tmp/"], + { open: true }, + { recursive: true }, + ); + + expect(status).toBe(0); + expect(copied).toEqual({ + name: config.agentVm, + sources: ["./file"], + target: ":/tmp/", + recursive: true, + }); + expect(calls.indexOf(`ensure:agent:${config.agentVm}`)).toBeGreaterThanOrEqual(0); + expect(calls.indexOf(`copy:${config.agentVm}`)).toBeGreaterThan(calls.indexOf(`ensure:agent:${config.agentVm}`)); + expect(calls).not.toContain("interactive"); + expect(secretReads).toBe(0); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + test("spy uses the shared tunnel fallback and foreground close path", async () => { const repo = makeInstanceRepo(); const oldSpyEnabled = process.env.ROOTCELL_SPY_ENABLED; @@ -1292,6 +1470,7 @@ describe("VM and network providers", () => { exec: () => Promise.resolve({ status: 0 }), execCapture: () => Promise.resolve({ status: 0, stdout: "", stderr: "" }), execInteractive: () => Promise.resolve(0), + copy: () => Promise.resolve(), copyToGuest: () => Promise.resolve(), forwardLocalPort: (name, options) => { forwarded = { name, options }; @@ -1887,6 +2066,48 @@ describe("VM and network providers", () => { expect(bootstrapConfig).toContain(`UserKnownHostsFile "${knownHostsPath}"`); }); + test("proxyjump copy maps rootcell guest paths to scp aliases", async () => { + const dir = mkdtempSync(join(tmpdir(), "rootcell-scp-")); + const oldPath = process.env.PATH; + const oldArgsPath = process.env.ROOTCELL_FAKE_SCP_ARGS; + try { + const bin = join(dir, "bin"); + mkdirSync(bin, { recursive: true }); + const scp = join(bin, "scp"); + writeFileSync(scp, fakeScpScript(), "utf8"); + chmodSync(scp, 0o755); + process.env.PATH = `${bin}:${oldPath ?? ""}`; + + const config = buildConfig(dir, {}, fakeInstance("dev", dir)); + const transport = new ProxyJumpSshTransport(config, () => ({ + firewallHost: "127.0.0.1", + firewallPort: 60_022, + agentHost: "192.168.109.11", + identityPath: join(dir, "rootcell_control_ed25519"), + knownHostsPath: join(dir, "known_hosts"), + })); + + const hostToGuestArgs = join(dir, "host-to-guest.txt"); + process.env.ROOTCELL_FAKE_SCP_ARGS = hostToGuestArgs; + await transport.copy(config.agentVm, ["./a", "./b"], ":/tmp/", { recursive: true }); + const hostToGuest = readLines(hostToGuestArgs); + expect(hostToGuest).toContain("-F"); + expect(hostToGuest).toContain("-r"); + expect(hostToGuest.slice(-3)).toEqual(["./a", "./b", "rootcell-agent:/tmp/"]); + + const guestToHostArgs = join(dir, "guest-to-host.txt"); + process.env.ROOTCELL_FAKE_SCP_ARGS = guestToHostArgs; + await transport.copy(config.agentVm, [":relative/path"], "./out"); + const guestToHost = readLines(guestToHostArgs); + expect(guestToHost).not.toContain("-r"); + expect(guestToHost.slice(-2)).toEqual(["rootcell-agent:relative/path", "./out"]); + } finally { + process.env.PATH = oldPath; + restoreEnv("ROOTCELL_FAKE_SCP_ARGS", oldArgsPath); + rmSync(dir, { recursive: true, force: true }); + } + }); + test("proxyjump known_hosts removal clears only the rotated VM host", () => { const dir = mkdtempSync(join(tmpdir(), "rootcell-known-hosts-")); try { @@ -2102,6 +2323,35 @@ describe("VM and network providers", () => { } }); + test("Lima copy uses limactl copy with rootcell guest path translation", async () => { + const dir = mkdtempSync(join(tmpdir(), "rootcell-lima-copy-test-")); + const oldLimactl = process.env.ROOTCELL_LIMACTL; + const oldCalls = process.env.ROOTCELL_LIMACTL_CALLS; + try { + const callsPath = join(dir, "calls.txt"); + const limactl = join(dir, "limactl"); + writeFileSync(limactl, fakeLimactlCopyScript(), "utf8"); + chmodSync(limactl, 0o755); + process.env.ROOTCELL_LIMACTL = limactl; + process.env.ROOTCELL_LIMACTL_CALLS = callsPath; + + const config = buildConfig(dir, {}, fakeInstance("dev", dir)); + const provider = new LimaVmProvider(config, ignoreLog); + + await provider.copy(config.agentVm, ["./dir"], ":/tmp/", { recursive: true }); + expect(readLines(callsPath)).toEqual(["--tty=false copy -r ./dir agent-dev:/tmp/"]); + + const guestToHostCallsPath = join(dir, "guest-to-host-calls.txt"); + process.env.ROOTCELL_LIMACTL_CALLS = guestToHostCallsPath; + await provider.copy(config.agentVm, [":relative/path"], "./out"); + expect(readLines(guestToHostCallsPath)).toEqual(["--tty=false copy agent-dev:relative/path ./out"]); + } finally { + restoreEnv("ROOTCELL_LIMACTL", oldLimactl); + restoreEnv("ROOTCELL_LIMACTL_CALLS", oldCalls); + rmSync(dir, { recursive: true, force: true }); + } + }); + test("Lima transport refreshes stale firewall SSH local ports", async () => { const dir = mkdtempSync(join(tmpdir(), "rootcell-lima-port-test-")); const oldPath = process.env.PATH; @@ -3030,6 +3280,15 @@ function fakeLimactlStopScript(input: { readonly gracefulStatus: number }): stri ].join("\n"); } +function fakeLimactlCopyScript(): string { + return [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" > \"$ROOTCELL_LIMACTL_CALLS\"", + "exit 0", + "", + ].join("\n"); +} + function readLines(path: string): readonly string[] { return readFileSync(path, "utf8").trim().split("\n"); } @@ -3048,6 +3307,15 @@ function fakeForwardingSshScript(): string { ].join("\n"); } +function fakeScpScript(): string { + return [ + "#!/bin/sh", + "printf '%s\\n' \"$@\" > \"$ROOTCELL_FAKE_SCP_ARGS\"", + "exit 0", + "", + ].join("\n"); +} + function fakeInstance(name: string, repo = "/repo", env: NodeJS.ProcessEnv = {}): RootcellInstance { const paths = instancePaths(repo, name, env); return { diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index bbb5695..711d710 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -9,6 +9,7 @@ import { } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { parseRootcellArgs } from "./args.ts"; +import { parseRootcellCopySpec } from "./copy.ts"; import { loadDotEnv, nixString, parseSecretMappings } from "./env.ts"; import { runExtensionCommand } from "./extensions/commands.ts"; import type { ExtensionHostCommandContext } from "./extensions/registry.ts"; @@ -34,7 +35,7 @@ import type { NetworkPlan, ProviderBundle, VmNetworkAttachment, VmRole, VmStatus import { parseSchema } from "./schema.ts"; import { parseAwsSecretsManagerProviderConfigs } from "./secrets/aws-secrets-manager-config.ts"; import { openRoleTargetTunnel, waitForForegroundTunnel, type PortAvailabilityCheck } from "./tunnels.ts"; -import { RootcellConfigSchema, type RootcellConfig, type RootcellInstance, type SpyOptions, type VmFileSet } from "./types.ts"; +import { RootcellConfigSchema, type RootcellConfig, type RootcellCopyOptions, type RootcellInstance, type SpyOptions, type VmFileSet } from "./types.ts"; const GUEST_USER = "luser"; @@ -85,6 +86,7 @@ const SPY_ENV_DEFAULTS = { ROOTCELL_SPY_PORT: String(SPY_DEFAULT_PORT), } as const; const SPY_ENV_KEYS = Object.keys(SPY_ENV_DEFAULTS) as (keyof typeof SPY_ENV_DEFAULTS)[]; +const DEFAULT_COPY_OPTIONS: RootcellCopyOptions = { recursive: false }; const SPY_BEDROCK_SECRET_ENV_NAMES = new Set([ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", @@ -266,7 +268,12 @@ export class RootcellApp { this.networkPlan = this.providers.network.plan(); } - async runAfterEnvironment(subcommand: string, rest: readonly string[], spyOptions: SpyOptions): Promise { + async runAfterEnvironment( + subcommand: string, + rest: readonly string[], + spyOptions: SpyOptions, + copyOptions: RootcellCopyOptions = DEFAULT_COPY_OPTIONS, + ): Promise { if (subcommand === "list") { process.stdout.write(formatVmList(await this.listVms())); return 0; @@ -331,6 +338,9 @@ export class RootcellApp { } await this.ensureAgent(needsProvisionForCa); + if (subcommand === "copy") { + return await this.runCopy(rest, copyOptions); + } if (subcommand === "provision") { log("done."); return 0; @@ -450,6 +460,12 @@ export class RootcellApp { return (await this.providers.vm.exec(this.config.agentVm, ["cat", keyPath], { allowFailure: true })).status; } + private async runCopy(rest: readonly string[], options: RootcellCopyOptions): Promise { + const spec = parseRootcellCopySpec(rest); + await this.providers.vm.copy(this.config.agentVm, spec.sources, spec.target, options); + return 0; + } + private async ensureExistingVmNetworksCompatible(): Promise { await this.providers.vm.assertCompatible(this.config.firewallVm, this.networkPlan.vms.firewall); await this.providers.vm.assertCompatible(this.config.agentVm, this.networkPlan.vms.agent); @@ -1452,7 +1468,7 @@ export async function rootcellMain(args: readonly string[], importMetaPath: stri const instance = loadRootcellInstance(repoDir, instanceName, process.env); const config = buildConfig(repoDir, process.env, instance); const app = new RootcellApp(config, createProviderBundle(config, log)); - return await app.runAfterEnvironment(parsed.subcommand, parsed.rest, parsed.spyOptions); + return await app.runAfterEnvironment(parsed.subcommand, parsed.rest, parsed.spyOptions, parsed.copyOptions); } catch (error) { log(messageFromUnknown(error)); return error instanceof SelectedInstanceStateError ? error.status : 1; diff --git a/src/rootcell/transports/proxyjump-ssh.ts b/src/rootcell/transports/proxyjump-ssh.ts index b3045fb..fb6e8f5 100644 --- a/src/rootcell/transports/proxyjump-ssh.ts +++ b/src/rootcell/transports/proxyjump-ssh.ts @@ -1,9 +1,10 @@ import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { isRootcellGuestCopyPath, rootcellGuestCopyPath } from "../copy.ts"; import { runAsyncInherited, runCapture, runInherited } from "../process.ts"; import type { RootcellConfig } from "../types.ts"; -import type { CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions } from "../providers/types.ts"; +import type { CopyOptions, CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions } from "../providers/types.ts"; import type { CommandResult, InheritedCommandResult } from "../types.ts"; import type { GuestTransport } from "./types.ts"; @@ -51,18 +52,21 @@ export class ProxyJumpSshTransport implements GuestTransport { ]); } - copyToGuest(name: string, hostPath: string, guestPath: string, options: CopyToGuestOptions = {}): Promise { - const alias = this.aliasFor(name); + copy(name: string, sources: readonly string[], target: string, options: CopyOptions = {}): Promise { runInherited("scp", [ "-F", this.writeSshConfig(), ...(options.recursive === true ? ["-r"] : []), - hostPath, - `${alias}:${guestPath}`, + ...sources.map((source) => this.copyOperand(name, source)), + this.copyOperand(name, target), ]); return Promise.resolve(); } + copyToGuest(name: string, hostPath: string, guestPath: string, options: CopyToGuestOptions = {}): Promise { + return this.copy(name, [hostPath], `:${guestPath}`, options); + } + async forwardLocalPort(name: string, options: LocalPortForwardOptions): Promise { const child = spawn("ssh", [ "-F", @@ -167,6 +171,13 @@ export class ProxyJumpSshTransport implements GuestTransport { throw new Error(`unknown rootcell VM for SSH transport: ${name}`); } + private copyOperand(name: string, operand: string): string { + if (!isRootcellGuestCopyPath(operand)) { + return operand; + } + return `${this.aliasFor(name)}:${rootcellGuestCopyPath(operand)}`; + } + private writeSshConfig(): string { const endpoints = this.endpoints(); const sshDir = join(this.config.instanceDir, "ssh"); diff --git a/src/rootcell/transports/types.ts b/src/rootcell/transports/types.ts index 9099dd1..7ec6d55 100644 --- a/src/rootcell/transports/types.ts +++ b/src/rootcell/transports/types.ts @@ -1,11 +1,12 @@ import type { CommandResult, InheritedCommandResult } from "../types.ts"; -import type { CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions } from "../providers/types.ts"; +import type { CopyOptions, CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions } from "../providers/types.ts"; export interface GuestTransport { readonly id: string; exec(name: string, command: readonly string[], options?: ExecOptions): Promise; execCapture(name: string, command: readonly string[], options?: ExecOptions): Promise; execInteractive(name: string, command: readonly string[], options?: ExecOptions): Promise; + copy(name: string, sources: readonly string[], target: string, options?: CopyOptions): Promise; copyToGuest(name: string, hostPath: string, guestPath: string, options?: CopyToGuestOptions): Promise; forwardLocalPort(name: string, options: LocalPortForwardOptions): Promise; } diff --git a/src/rootcell/types.ts b/src/rootcell/types.ts index ef03509..86be31b 100644 --- a/src/rootcell/types.ts +++ b/src/rootcell/types.ts @@ -102,6 +102,12 @@ export const SpyOptionsSchema = z.object({ export type SpyOptions = Readonly>; +export const RootcellCopyOptionsSchema = z.object({ + recursive: z.boolean(), +}).strict(); + +export type RootcellCopyOptions = Readonly>; + const RootcellSubcommandOrEmptySchema = z.custom( (value) => value === "" || (typeof value === "string" && isRootcellSubcommand(value)), { message: "must be a rootcell subcommand" }, @@ -113,14 +119,16 @@ export const ParsedRootcellRunArgsSchema = z.object({ subcommand: RootcellSubcommandOrEmptySchema, rest: z.array(z.string()), spyOptions: SpyOptionsSchema, + copyOptions: RootcellCopyOptionsSchema.optional(), }); type ParsedRootcellRunArgsOutput = z.infer; export type ParsedRootcellRunArgs = Readonly< - Omit & { + Omit & { readonly rest: readonly string[]; readonly spyOptions: SpyOptions; + readonly copyOptions?: RootcellCopyOptions | undefined; } >;