Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: E2E Smoke Tests

permissions:
contents: read

on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options:
- dev
- prod
default: dev

jobs:
e2e:
runs-on: ubuntu-slim
steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run e2e smoke tests
env:
RUNLOOP_API_KEY: ${{ inputs.environment == 'prod' && secrets.RUNLOOP_SMOKETEST_PROD_API_KEY || secrets.RUNLOOP_SMOKETEST_DEV_API_KEY }}
RUNLOOP_ENV: ${{ inputs.environment }}
run: pnpm run test:e2e
Comment thread Fixed
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ rli devbox suspend <id> # Suspend a devbox
rli devbox resume <id> # Resume a suspended devbox
rli devbox shutdown <id> # Shutdown a devbox
rli devbox ssh <id> # SSH into a devbox
rli devbox scp <id> <src> <dst> # Copy files to/from a devbox using scp
rli devbox rsync <id> <src> <dst> # Sync files to/from a devbox using rsync
rli devbox scp <src> <dst> # Copy files to/from a devbox using scp...
rli devbox rsync <src> <dst> # Sync files to/from a devbox using rsy...
rli devbox tunnel <id> <ports> # Create a port-forwarding tunnel to a ...
rli devbox read <id> # Read a file from a devbox using the API
rli devbox write <id> # Write a file to a devbox using the API
Expand Down
50 changes: 50 additions & 0 deletions jest.e2e.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { pathsToModuleNameMapper } from "ts-jest";
import { readFileSync } from "fs";

// Read and parse tsconfig.json
const tsconfig = JSON.parse(readFileSync("./tsconfig.json", "utf8"));
const compilerOptions = tsconfig.compilerOptions;

export default {
preset: "ts-jest/presets/default-esm",
testEnvironment: "node",

roots: ["<rootDir>/tests"],
testMatch: ["**/__tests__/e2e/**/*.test.ts"],

setupFilesAfterEnv: ["<rootDir>/tests/setup-e2e.ts"],

moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
...pathsToModuleNameMapper(compilerOptions.paths, {
prefix: "<rootDir>/",
useESM: true,
}),
},

transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
tsconfig: {
...compilerOptions,
rootDir: ".",
module: "ESNext",
moduleResolution: "Node",
allowSyntheticDefaultImports: true,
esModuleInterop: true,
},
},
],
},

extensionsToTreatAsEsm: [".ts"],

// e2e tests may take a while (devbox creation, transfers, etc.)
testTimeout: 180_000,

moduleFileExtensions: ["ts", "js", "json"],
clearMocks: true,
restoreMocks: true,
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
"test:components": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.components.config.js --coverage --forceExit",
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.e2e.config.js --forceExit",
"docs:commands": "pnpm run build && node scripts/generate-command-docs.js",
"prepare": "husky"
},
Expand Down
133 changes: 89 additions & 44 deletions src/commands/devbox/rsync.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,78 @@
/**
* Rsync files to/from devbox command
*
* Supports standard rsync-like syntax where the devbox ID (dbx_*) is used as a hostname:
* rli devbox rsync dbx_abc123:/remote/path ./local/path # download
* rli devbox rsync ./local/path dbx_abc123:/remote/path # upload
* rli devbox rsync root@dbx_abc123:/remote/path ./local/path # explicit user
*
* If no user is specified for a remote path, the devbox's configured user is used.
* Paths without a dbx_ hostname are treated as local paths.
*/

import { exec } from "child_process";
import { execFile } from "child_process";
import { promisify } from "util";
import { getClient } from "../../utils/client.js";
import { output, outputError } from "../../utils/output.js";
import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
import { getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
import { parseSCPPath, resolveRemote, type ParsedSCPPath } from "./scp.js";

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

interface RsyncOptions {
src: string;
dst: string;
export interface RsyncOptions {
rsyncOptions?: string;
output?: string;
}

export async function rsyncFiles(devboxId: string, options: RsyncOptions) {
/**
* Build the rsync command for a single-remote transfer (local <-> devbox).
*/
export function buildRsyncCommand(opts: {
sshInfo: { keyfilePath: string; url: string };
proxyCommand: string;
parsedSrc: ParsedSCPPath;
parsedDst: ParsedSCPPath;
defaultUser: string;
rsyncOptions?: string;
}): string[] {
// Rsync re-splits the -e value on whitespace internally, so the
// ProxyCommand (which contains spaces) must be single-quoted.
const sshTransport = `ssh -i ${opts.sshInfo.keyfilePath} -o 'ProxyCommand=${opts.proxyCommand}' -o StrictHostKeyChecking=no`;

const rsyncCommand = [
"rsync",
"-vrz", // v: verbose, r: recursive, z: compress
"-e",
sshTransport,
];

if (opts.rsyncOptions) {
rsyncCommand.push(...opts.rsyncOptions.split(" "));
}

// Build src argument
if (opts.parsedSrc.isRemote) {
const user = opts.parsedSrc.user || opts.defaultUser;
rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedSrc.path}`);
} else {
rsyncCommand.push(opts.parsedSrc.path);
}

// Build dst argument
if (opts.parsedDst.isRemote) {
const user = opts.parsedDst.user || opts.defaultUser;
rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedDst.path}`);
} else {
rsyncCommand.push(opts.parsedDst.path);
}

return rsyncCommand;
}

export async function rsyncFiles(
src: string,
dst: string,
options: RsyncOptions,
) {
try {
// Check if SSH tools are available
const sshToolsAvailable = await checkSSHTools();
Expand All @@ -27,55 +82,45 @@ export async function rsyncFiles(devboxId: string, options: RsyncOptions) {
);
}

const client = getClient();
const parsedSrc = parseSCPPath(src);
const parsedDst = parseSCPPath(dst);

// Get devbox details to determine user
const devbox = await client.devboxes.retrieve(devboxId);
const user = devbox.launch_parameters?.user_parameters?.username || "user";
if (!parsedSrc.isRemote && !parsedDst.isRemote) {
outputError(
"At least one of src or dst must be a remote devbox path (e.g. dbx_<id>:/path)",
);
}

// Get SSH key
const sshInfo = await getSSHKey(devboxId);
if (!sshInfo) {
outputError("Failed to create SSH key");
if (parsedSrc.isRemote && parsedDst.isRemote) {
outputError(
"Devbox-to-devbox rsync is not supported. Only one side can be a remote devbox path.",
);
}

const devboxId = parsedSrc.isRemote ? parsedSrc.host! : parsedDst.host!;
const remote = await resolveRemote(devboxId);
const proxyCommand = getProxyCommand();
const sshOptions = `-i ${sshInfo!.keyfilePath} -o ProxyCommand='${proxyCommand}' -o StrictHostKeyChecking=no`;

const rsyncCommand = [
"rsync",
"-vrz", // v: verbose, r: recursive, z: compress
"-e",
`"ssh ${sshOptions}"`,
];

if (options.rsyncOptions) {
rsyncCommand.push(...options.rsyncOptions.split(" "));
}

// Handle remote paths (starting with :)
if (options.src.startsWith(":")) {
rsyncCommand.push(`${user}@${sshInfo!.url}:${options.src.slice(1)}`);
rsyncCommand.push(options.dst);
} else {
rsyncCommand.push(options.src);
if (options.dst.startsWith(":")) {
rsyncCommand.push(`${user}@${sshInfo!.url}:${options.dst.slice(1)}`);
} else {
rsyncCommand.push(options.dst);
}
}
const rsyncCommand = buildRsyncCommand({
sshInfo: remote.sshInfo,
proxyCommand,
parsedSrc,
parsedDst,
defaultUser: remote.defaultUser,
rsyncOptions: options.rsyncOptions,
});

await execAsync(rsyncCommand.join(" "));
const [cmd, ...args] = rsyncCommand;
await execFileAsync(cmd, args);

// Default: just output the destination for easy scripting
if (!options.output || options.output === "text") {
console.log(options.dst);
console.log(dst);
} else {
output(
{
source: options.src,
destination: options.dst,
source: src,
destination: dst,
},
{ format: options.output, defaultFormat: "json" },
);
Expand Down
Loading
Loading