Skip to content

Commit 26cfda2

Browse files
committed
feat: Added codify test command that can spawn a tart vm to test codify configs
1 parent aeb6219 commit 26cfda2

File tree

10 files changed

+224
-10
lines changed

10 files changed

+224
-10
lines changed

src/commands/test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Args, Flags } from '@oclif/core'
2+
import chalk from 'chalk';
3+
4+
import { BaseCommand } from '../common/base-command.js';
5+
import { ApplyOrchestrator } from '../orchestrators/apply.js';
6+
import { TestOrchestrator } from '../orchestrators/test.js';
7+
8+
export default class Apply extends BaseCommand {
9+
static description =
10+
`Install or update resources on the system based on a codify.jsonc file.
11+
12+
Codify first generates a plan to determine the necessary execution steps. See
13+
${chalk.bold.bgMagenta(' codify plan --help ')} for more details.
14+
The execution plan will be presented and approval will be asked before Codify applies
15+
any changes.
16+
17+
For scripts: use ${chalk.bold.bgMagenta(' --output json ')} which will skip approval and
18+
apply changes directly.
19+
20+
For more information, visit: https://docs.codifycli.com/commands/apply
21+
`
22+
23+
static flags = {
24+
'sudoPassword': Flags.string({
25+
optional: true,
26+
description: 'Automatically use this password for any handlers that require elevated permissions.',
27+
char: 'S'
28+
}),
29+
}
30+
31+
static args = {
32+
pathArgs: Args.string(),
33+
}
34+
35+
static examples = [
36+
'<%= config.bin %> <%= command.id %>',
37+
'<%= config.bin %> <%= command.id %> --path ~',
38+
'<%= config.bin %> <%= command.id %> -o json',
39+
'<%= config.bin %> <%= command.id %> -S <sudo password>',
40+
]
41+
42+
async init(): Promise<void> {
43+
console.log('Running Codify apply...')
44+
return super.init();
45+
}
46+
47+
public async run(): Promise<void> {
48+
const { flags, args } = await this.parse(Apply)
49+
50+
if (flags.path && args.pathArgs) {
51+
throw new Error('Cannot specify both --path and path argument');
52+
}
53+
54+
await TestOrchestrator.run({
55+
path: flags.path ?? args.pathArgs,
56+
verbosityLevel: flags.debug ? 3 : 0,
57+
// secure: flags.secure,
58+
}, this.reporter);
59+
60+
process.exit(0);
61+
}
62+
}

src/common/base-command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export abstract class BaseCommand extends Command {
6767
process.stdin.setRawMode(true);
6868
}
6969

70-
const result = await spawnSafe(data.command, pluginName, data.options, password)
70+
const result = await spawnSafe(data.command, data.options, pluginName, password)
7171
ctx.commandRequestCompleted(pluginName, result);
7272

7373
// This listener is outside of the base-command callstack. We have to manually catch the error.

src/common/errors.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,24 @@ export class UnauthorizedError extends CodifyError {
156156
}
157157
}
158158

159+
export class SpawnError extends CodifyError {
160+
name = 'SpawnError'
161+
command: string;
162+
exitCode: number;
163+
data: string;
164+
165+
constructor(command: string, exitCode: number, data: string) {
166+
super(`Command "${command}" failed with exit code ${exitCode}`)
167+
this.command = command;
168+
this.exitCode = exitCode;
169+
this.data = data;
170+
}
171+
172+
formattedMessage(): string {
173+
return `Spawn error: ${this.message}\n\n${this.data}`
174+
}
175+
}
176+
159177
export function prettyPrintError(error: unknown): void {
160178
if (error instanceof CodifyError) {
161179
return console.error(chalk.red(error.formattedMessage()));

src/common/initialize-plugins.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Config } from 'codify-schemas';
12
import * as fs from 'node:fs/promises'
23
import * as path from 'node:path'
34
import { validate } from 'uuid';
@@ -18,6 +19,7 @@ export interface InitializeArgs {
1819
transformProject?: (project: Project) => Project | Promise<Project>;
1920
allowEmptyProject?: boolean;
2021
forceEmptyProject?: boolean;
22+
codifyConfigs?: Config[];
2123
}
2224

2325
export interface InitializationResult {
@@ -52,6 +54,10 @@ export class PluginInitOrchestrator {
5254
return Project.empty();
5355
}
5456

57+
if (args.codifyConfigs) {
58+
return CodifyParser.parseJson(args.codifyConfigs);
59+
}
60+
5561
const codifyPath = await PluginInitOrchestrator.resolveCodifyRootPath(args, reporter);
5662
ctx.subprocessStarted(SubProcessName.PARSE);
5763

src/orchestrators/plan.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Config } from 'codify-schemas';
2+
13
import { PluginInitOrchestrator } from '../common/initialize-plugins.js';
24
import { Plan } from '../entities/plan.js';
35
import { Project } from '../entities/project.js';
@@ -6,12 +8,12 @@ import { PluginManager } from '../plugins/plugin-manager.js';
68
import { Reporter } from '../ui/reporters/reporter.js';
79
import { createStartupShellScriptsIfNotExists } from '../utils/file.js';
810
import { ValidateOrchestrator } from './validate.js';
9-
import { LoginHelper } from '../connect/login-helper.js';
1011

1112
export interface PlanArgs {
1213
path?: string;
1314
secureMode?: boolean;
1415
verbosityLevel?: number;
16+
codifyConfigs?: Config[];
1517
}
1618

1719
export interface PlanOrchestratorResponse {

src/orchestrators/test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
2+
import { SpawnStatus } from 'codify-schemas';
3+
4+
import { PluginInitOrchestrator } from '../common/initialize-plugins.js';
5+
import { ProcessName, ctx } from '../events/context.js';
6+
import { Reporter } from '../ui/reporters/reporter.js';
7+
import { sleep } from '../utils/index.js';
8+
import { spawn, spawnSafe } from '../utils/spawn.js';
9+
import { PlanOrchestrator } from './plan.js';
10+
import { ValidateOrchestrator } from './validate.js';
11+
12+
export interface TestArgs {
13+
path?: string;
14+
secure?: boolean;
15+
verbosityLevel?: number;
16+
}
17+
18+
export const TestOrchestrator = {
19+
async run(args: TestArgs, reporter: Reporter): Promise<void> {
20+
21+
// Perform validation initially to ensure the project is valid
22+
const initializationResult = await PluginInitOrchestrator.run(args, reporter);
23+
await ValidateOrchestrator.run({ existing: initializationResult }, reporter);
24+
25+
const planResult = await PlanOrchestrator.run({
26+
codifyConfigs: [{
27+
type: 'project',
28+
plugins: { default: '/Users/kevinwang/Projects/codify-homebrew-plugin/src/index.ts' }
29+
}, {
30+
type: 'homebrew',
31+
formulae: ['sshpass']
32+
}, {
33+
type: 'tart',
34+
clone: [{ sourceName: 'ghcr.io/cirruslabs/macos-tahoe-base:latest', name: 'codify-test-vm' }],
35+
}],
36+
}, reporter);
37+
38+
// Short circuit and exit if every change is NOOP
39+
if (!planResult.plan.isEmpty()) {
40+
const confirm = await reporter.promptConfirmation('The following resources will need to be installed (Tart VM - 25gb). Do you want to continue?')
41+
if (!confirm) {
42+
return process.exit(0);
43+
}
44+
45+
const { plan, pluginManager, project } = planResult;
46+
const filteredPlan = plan.filterNoopResources()
47+
48+
ctx.processStarted(ProcessName.APPLY);
49+
await pluginManager.apply(project, filteredPlan);
50+
ctx.processFinished(ProcessName.APPLY);
51+
}
52+
53+
const vmName = this.generateVmName();
54+
await spawnSafe(`tart clone codify-test-vm ${vmName}`, { interactive: true });
55+
56+
// Run this in the background. The user will have to manually exit the GUI to stop the test.
57+
spawnSafe(`tart run ${vmName}`, { interactive: true })
58+
.finally(() => {
59+
console.log('VM has been killed... exiting.')
60+
process.exit(1);
61+
})
62+
await sleep(10_000);
63+
await this.waitUntilVmIsReady(vmName);
64+
65+
// Install codify on the VM
66+
// await spawn(`tart exec ${vmName} /bin/bash -c "$(curl -fsSL https://releases.codifycli.com/install.sh)"`, { interactive: true });
67+
const { data: ip } = await spawnSafe(`tart ip ${vmName}`, { interactive: true });
68+
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 });
69+
// await spawn(`tart exec ${vmName} codify apply`, undefined, { interactive: true });
70+
71+
await spawn(`tart exec ${vmName} osascript -e "tell application \\"Terminal\\" to do script \\"cd ~/ && codify apply\\""`)
72+
73+
await sleep(1_000_000_000);
74+
},
75+
76+
generateVmName(): string {
77+
return `codify-test-vm-${Date.now()}`;
78+
},
79+
80+
async waitUntilVmIsReady(vmName: string): Promise<void> {
81+
while (true) {
82+
const result = await spawnSafe(`tart exec ${vmName} pwd`, { interactive: true, timeout: 5000 })
83+
if (result.status === SpawnStatus.SUCCESS) {
84+
return;
85+
}
86+
87+
await sleep(1000);
88+
}
89+
}
90+
};

src/parser/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Config } from 'codify-schemas';
12
import * as fs from 'node:fs/promises';
23
import path from 'node:path';
34
import { validate } from 'uuid'
@@ -39,6 +40,17 @@ class Parser {
3940
return Project.create(configs, codifyFiles, sourceMaps);
4041
}
4142

43+
async parseJson(configs: Config[]): Promise<Project> {
44+
const sourceMaps = new SourceMapCache()
45+
46+
const configBlocks = this.createConfigBlocks(configs
47+
.map((c) => ({ contents: c, sourceMapKey: '' })),
48+
sourceMaps
49+
)
50+
51+
return Project.create(configBlocks, [], sourceMaps);
52+
}
53+
4254
private async getFilePaths(dirOrFile: string): Promise<string[]> {
4355
// A cloud file is represented as an uuid. Skip file checks if it's a cloud file;
4456
if (validate(dirOrFile)) {

src/parser/source-maps.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import chalk from 'chalk';
2+
import { default as YamlSourceMap } from 'js-yaml-source-map';
13
import { JsonSourceMap } from 'json-source-map';
2-
import { default as YamlSourceMap, SourceLocation as YamlSourceLocation } from 'js-yaml-source-map';
4+
35
import { FileType, InMemoryFile } from './entities.js';
4-
import { InternalError } from '../common/errors.js';
5-
import chalk from 'chalk';
66
import { JsonSourceMapAdapter } from './json/source-map.js';
77
import { YamlSourceMapAdapter } from './yaml/source-map.js';
88

@@ -81,7 +81,7 @@ export class SourceMapCache {
8181
sourceMapKey: string,
8282
addAdditionalContextLines = true,
8383
addLineNumbers = true,
84-
): string | null {
84+
): null | string {
8585
const inContextSourceMap = this.getSourceMap(sourceMapKey);
8686
if (!inContextSourceMap) {
8787
return null;

src/ui/reporters/reporter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DebugReporter } from './debug-reporter.js';
99
import { DefaultReporter } from './default-reporter.js';
1010
import { JsonReporter } from './json-reporter.js';
1111
import { PlainReporter } from './plain-reporter.js';
12+
import { StubReporter } from './stub-reporter.js';
1213

1314
export enum RenderEvent {
1415
LOG = 'log',

src/utils/spawn.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
22
import { SpawnStatus } from 'codify-schemas';
33
import stripAnsi from 'strip-ansi';
44

5+
import { SpawnError } from '../common/errors.js';
56
import { ctx } from '../events/context.js';
67
import { Shell, ShellUtils } from './shell.js';
78

@@ -17,9 +18,20 @@ export interface SpawnOptions {
1718
interactive?: boolean,
1819
requiresRoot?: boolean,
1920
stdin?: boolean,
21+
timeout?: number,
2022
}
2123

22-
export function spawnSafe(cmd: string, pluginName?: string, options?: SpawnOptions, password?: string): Promise<SpawnResult> {
24+
export async function spawn(cmd: string, options?: SpawnOptions, pluginName?: string, password?: string): Promise<SpawnResult> {
25+
const spawnResult = await spawnSafe(cmd, options, pluginName, password);
26+
27+
if (spawnResult.status !== 'success') {
28+
throw new SpawnError(Array.isArray(cmd) ? cmd.join('\n') : cmd, spawnResult.exitCode, spawnResult.data);
29+
}
30+
31+
return spawnResult;
32+
}
33+
34+
export async function spawnSafe(cmd: string, options?: SpawnOptions, pluginName?: string, password?: string): Promise<SpawnResult> {
2335
if (options?.requiresRoot && !password) {
2436
throw new Error('Password must be specified!');
2537
}
@@ -31,7 +43,7 @@ export function spawnSafe(cmd: string, pluginName?: string, options?: SpawnOptio
3143
if (pluginName) {
3244
ctx.pluginStdout(pluginName, `Running command: ${options?.requiresRoot ? 'sudo' : ''} ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''))
3345
} else {
34-
process.stdout.write(`Running command: ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''));
46+
ctx.log(`Running command: ${cmd}` + (options?.cwd ? `(${options?.cwd})` : '') + '\n');
3547
}
3648

3749
return new Promise((resolve) => {
@@ -67,7 +79,7 @@ export function spawnSafe(cmd: string, pluginName?: string, options?: SpawnOptio
6779
if (pluginName && !options?.stdin) {
6880
ctx.pluginStdout(pluginName, data)
6981
} else {
70-
process.stdout.write(data);
82+
ctx.log(data);
7183
}
7284

7385
output.push(data.toString());
@@ -100,6 +112,17 @@ export function spawnSafe(cmd: string, pluginName?: string, options?: SpawnOptio
100112
exitCode: result.exitCode,
101113
data: stripAnsi(output.join('\n').trim()),
102114
})
103-
})
115+
});
116+
117+
if (options?.timeout) {
118+
setTimeout(() => {
119+
mPty.kill();
120+
resolve({
121+
status: SpawnStatus.ERROR,
122+
exitCode: -1,
123+
data: '',
124+
});
125+
}, options.timeout);
126+
}
104127
})
105128
}

0 commit comments

Comments
 (0)