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
64 changes: 54 additions & 10 deletions src/orchestrators/test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@

import { OS, SpawnStatus } from 'codify-schemas';
import os from 'node:os';
import fs from 'node:fs'
import path from 'node:path';

import { PluginInitOrchestrator } from '../common/initialize-plugins.js';
import { ProcessName, ctx, SubProcessName } from '../events/context.js';
import { ProcessName, SubProcessName, ctx } from '../events/context.js';
import { Reporter } from '../ui/reporters/reporter.js';
import { StubReporter } from '../ui/reporters/stub-reporter.js';
import { FileUtils } from '../utils/file.js';
import { sleep } from '../utils/index.js';
import { spawn, spawnSafe } from '../utils/spawn.js';
import { PlanOrchestrator, PlanOrchestratorResponse } from './plan.js';
import { ValidateOrchestrator } from './validate.js';
import { OsUtils } from '../utils/os-utils.js';

export interface TestArgs {
path?: string;
Expand All @@ -20,13 +24,17 @@ export interface TestArgs {

export const TestOrchestrator = {
async run(args: TestArgs, reporter: Reporter): Promise<void> {
if (!OsUtils.isMacOS()) {
throw new Error('Only a MacOS host is supported currently for testing');
}

ctx.processStarted(ProcessName.TEST);
reporter.silent = true;

ctx.subprocessStarted(SubProcessName.TEST_INITIALIZE_AND_VALIDATE);
// Perform validation initially to ensure the project is valid
const initializationResult = await PluginInitOrchestrator.run({ ...args, noProgress: true }, new StubReporter());
await ValidateOrchestrator.run({ existing: initializationResult, noProgress: true }, new StubReporter());
const initializationResult = await PluginInitOrchestrator.run({ ...args, noProgress: true }, reporter);
await ValidateOrchestrator.run({ existing: initializationResult, noProgress: true }, reporter);
ctx.subprocessFinished(SubProcessName.TEST_INITIALIZE_AND_VALIDATE);

await this.ensureVmIsInstalled(reporter, args.vmOs);
Expand All @@ -36,8 +44,14 @@ export const TestOrchestrator = {
const vmName = this.generateVmName();
await spawnSafe(`tart clone ${baseVmName} ${vmName}`, { interactive: true });

// We want to install the latest Codify version which usually exists in ~/.local/share/codify/client/current unless it's not there.
const codifyInstall = (await FileUtils.dirExists('~/.local/share/codify/client/current'))
? '~/.local/share/codify/client/current'
: '/usr/local/lib/codify';

// Run this in the background. The user will have to manually exit the GUI to stop the test.
spawnSafe(`tart run ${vmName}`, { interactive: true })
// We bind mount the codify installation and the codify config directory. We choose not use :ro (read-only) because live changes are not supported in read-only mode.
spawnSafe(`tart run ${vmName} --dir=codify-lib:${codifyInstall}:ro --dir=codify-config:${path.dirname(initializationResult.project.codifyFiles[0])}:ro`, { interactive: true })
.finally(() => {
ctx.subprocessFinished(SubProcessName.TEST_USER_CONTINUE_ON_VM);
ctx.subprocessStarted(SubProcessName.TEST_DELETING_VM);
Expand All @@ -49,7 +63,7 @@ export const TestOrchestrator = {
console.log('VM has been killed... exiting.')
process.exit(1);
})
await sleep(10_000);
await sleep(5000);
await this.waitUntilVmIsReady(vmName);

ctx.subprocessFinished(SubProcessName.TEST_STARTING_VM);
Expand All @@ -58,12 +72,19 @@ export const TestOrchestrator = {
// Install codify on the VM
// await spawn(`tart exec ${vmName} /bin/bash -c "$(curl -fsSL https://releases.codifycli.com/install.sh)"`, { interactive: true });
const { data: ip } = await spawnSafe(`tart ip ${vmName}`, { interactive: true });
await spawn(`sshpass -p "admin" scp -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${initializationResult.project.codifyFiles[0]} admin@${ip}:~/codify.jsonc`, { interactive: true });

if (args.vmOs === OS.Darwin) {
await spawn(`tart exec ${vmName} osascript -e "tell application \\"Terminal\\" to do script \\"cd ~/ && codify apply\\""`, { interactive: true });
} else {
await spawn(`tart exec ${vmName} gnome-terminal -- bash -c "cd ~/ && codify apply"`, { interactive: true });
try {
// Add symlinks to the bind mount locations.
await spawn(`tart exec ${vmName} sudo ln -s /Volumes/My\\ Shared\\ Files/codify-lib/bin/codify /usr/local/bin/codify`, { interactive: true });
await spawn(`tart exec ${vmName} ln -s /Volumes/My\\ Shared\\ Files/codify-config/${path.basename(initializationResult.project.codifyFiles[0])} /Users/admin/codify.jsonc`, { interactive: true });

// Launch terminal and run codify apply
await (args.vmOs === OS.Darwin ? spawn(`tart exec ${vmName} osascript -e "tell application \\"Terminal\\" to do script \\"cd ~ && codify apply\\""`, { interactive: true }) : spawn(`tart exec ${vmName} gnome-terminal -- bash -c "cd ~/ && codify apply"`, { interactive: true }));

this.watchAndSyncFileChanges(initializationResult.project.codifyFiles[0], ip);

} catch (error) {
ctx.log(`Error copying files to VM: ${error}`);
}

ctx.subprocessFinished(SubProcessName.TEST_COPYING_OVER_CONFIGS_AND_OPENING_TERMINAL);
Expand Down Expand Up @@ -136,5 +157,28 @@ export const TestOrchestrator = {

await sleep(1000);
}
},

watchAndSyncFileChanges(filePath: string, ip: string): void {
const watcher = fs.watch(filePath, { persistent: false }, async (eventType) => {
if (eventType === 'change') {
ctx.log('Config file changed, syncing to VM...');
try {
// Copy the updated config file to the VM
// This command will fail but it causes the bind mount to update for some reason. (seems like a bug in Tart). Leave this here for now.
await spawn(`sshpass -p "admin" scp -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${filePath} admin@${ip}:~/codify.jsonc`, { interactive: true });
// ctx.log('Config file synced successfully');
} catch (error) {
// ctx.log(`Error syncing config file: ${error}`);
}
}
});

// Clean up the watcher when the process finishes
const cleanupWatcher = () => {
watcher.close();
};

process.once('exit', cleanupWatcher);
}
};
2 changes: 1 addition & 1 deletion src/ui/reporters/default-reporter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const ProgressLabelMapping = {
[SubProcessName.TEST_INITIALIZE_AND_VALIDATE]: 'Initializing and validating your configs',
[SubProcessName.TEST_CHECKING_VM_INSTALLED]: 'Checking if VM is installed',
[SubProcessName.TEST_STARTING_VM]: 'Starting VM',
[SubProcessName.TEST_COPYING_OVER_CONFIGS_AND_OPENING_TERMINAL]: 'Copying over configs and opening terminal',
[SubProcessName.TEST_COPYING_OVER_CONFIGS_AND_OPENING_TERMINAL]: 'Copying over configs and opening terminal (if a confirmation dialog appears within the VM, please confirm it.)',
[SubProcessName.TEST_USER_CONTINUE_ON_VM]: 'Done setup! Please continue on the VM UI',
[SubProcessName.TEST_DELETING_VM]: 'Deleting VM',
}
Expand Down
12 changes: 12 additions & 0 deletions src/utils/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ export class FileUtils {
}
}

static async dirExists(dirPath: string, throwIfExistsButNotFile = true): Promise<boolean> {
try {
const result = await fs.lstat(path.resolve(dirPath))
if (throwIfExistsButNotFile && !result.isDirectory()) {
throw new Error(`File found at ${dirPath} instead of a file`)
}
return true;
} catch(e) {
return false;
}
}

static async isDir(fileOrDir: string): Promise<boolean> {
const lstat = await fs.lstat(path.resolve(fileOrDir))
return lstat.isDirectory()
Expand Down
9 changes: 1 addition & 8 deletions src/utils/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,7 @@ export async function spawnSafe(cmd: string, options?: SpawnOptions, pluginName?
const initialCols = process.stdout.columns ?? 80;
const initialRows = process.stdout.rows ?? 24;

// Mac OS uses -SN instead of -Sn
let command;
if (OsUtils.isMacOS()) {
command = options?.requiresRoot ? `sudo -k >/dev/null 2>&1; sudo -SN <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c "${cmd.replaceAll('\'', '\\\'')}"` : cmd;
} else {
command = options?.requiresRoot ? `sudo -k >/dev/null 2>&1; sudo -S <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c '${cmd.replaceAll('\'', '\\\'')}'` : cmd;
}

const command = options?.requiresRoot ? `sudo -k >/dev/null 2>&1; sudo -S <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c "${cmd.replaceAll('"', '\\"')}"` : cmd;
const args = options?.interactive ? ['-i', '-c', command] : ['-c', command]

// Run the command in a pty for interactivity
Expand Down