@celilo/cli 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/command-registry.ts +12 -0
- package/src/cli/commands/doctor.test.ts +36 -0
- package/src/cli/commands/doctor.ts +385 -0
- package/src/cli/commands/module-generate.ts +37 -1
- package/src/cli/commands/module-remove.ts +13 -2
- package/src/cli/completion.ts +30 -6
- package/src/cli/index.ts +8 -0
- package/src/config/paths.ts +13 -27
- package/src/db/client.ts +8 -1
- package/src/hooks/logger.ts +6 -1
- package/src/module/import.ts +97 -73
- package/src/services/deploy-validation.ts +15 -1
- package/src/services/module-build.test.ts +38 -0
- package/src/services/module-build.ts +6 -0
- package/src/services/module-deploy.ts +28 -2
- package/src/services/programmatic-responder.ts +15 -0
- package/src/services/proxmox-preflight.test.ts +63 -0
- package/src/services/proxmox-preflight.ts +100 -0
- package/src/services/responder-probe.ts +45 -0
- package/src/services/terminal-responder.ts +13 -0
package/src/db/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
@@ -166,6 +166,13 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
|
|
|
166
166
|
const dbPath = config?.path ?? getDbPath();
|
|
167
167
|
const readonly = config?.readonly ?? false;
|
|
168
168
|
|
|
169
|
+
// bun:sqlite's `create: true` makes the file but not the parent directory.
|
|
170
|
+
// First-run after a fresh install hits this — without recursive mkdir we
|
|
171
|
+
// get SQLITE_CANTOPEN before init can write its config.
|
|
172
|
+
if (!readonly) {
|
|
173
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
169
176
|
const sqlite = new Database(dbPath, {
|
|
170
177
|
readonly,
|
|
171
178
|
create: true,
|
package/src/hooks/logger.ts
CHANGED
|
@@ -72,7 +72,12 @@ export function createGaugeLogger(
|
|
|
72
72
|
// pushStep nests under the FuelGauge step that wraps the hook,
|
|
73
73
|
// so inner log calls (logger.info within the capability impl)
|
|
74
74
|
// are sub-events of this nested step.
|
|
75
|
-
|
|
75
|
+
//
|
|
76
|
+
// No `→`/`✓` glyph in the message — the display's spinner
|
|
77
|
+
// (in-progress) and green ✔ (done) already convey the state.
|
|
78
|
+
// Sticking a literal `✓` into the doneMsg produced a double-
|
|
79
|
+
// checkmark line on completion (`✔ ✓ name`).
|
|
80
|
+
display.pushStep(name, name);
|
|
76
81
|
} else {
|
|
77
82
|
// Without a display, fall back to a plain info-style marker so
|
|
78
83
|
// the line still appears in raw log output.
|
package/src/module/import.ts
CHANGED
|
@@ -429,62 +429,100 @@ async function installScriptDependencies(
|
|
|
429
429
|
export async function importModule(options: ModuleImportOptions): Promise<ModuleImportResult> {
|
|
430
430
|
const { sourcePath, targetBasePath = getDefaultTargetBase(), db = getDb(), flags = {} } = options;
|
|
431
431
|
|
|
432
|
-
|
|
432
|
+
// Directory imports route through the packager: build a temporary .netapp
|
|
433
|
+
// and re-enter through the package path. This makes the .netapp pipeline
|
|
434
|
+
// the single import path — no second flow that drifts. The packager is
|
|
435
|
+
// also the only place a manifest build runs, with CELILO_MODULE_SOURCE_DIR
|
|
436
|
+
// pointing at the unstaged source so sibling-package paths in a monorepo
|
|
437
|
+
// resolve (e.g. celilo-registry's `cd $CELILO_MODULE_SOURCE_DIR/../../packages/registry-server`).
|
|
438
|
+
// After this point the module is detached from the source tree, so a
|
|
439
|
+
// deploy-time rebuild can't work — bake the artifacts in here, once.
|
|
440
|
+
if (!sourcePath.endsWith('.netapp')) {
|
|
441
|
+
const dirError = validateModuleDirectory(sourcePath);
|
|
442
|
+
if (dirError) return { success: false, error: dirError };
|
|
443
|
+
|
|
444
|
+
const manifestResult = await readModuleManifest(sourcePath);
|
|
445
|
+
if (!manifestResult.success) return { success: false, error: manifestResult.error };
|
|
446
|
+
|
|
447
|
+
if (moduleExists(manifestResult.manifest.id, db)) {
|
|
448
|
+
return {
|
|
449
|
+
success: false,
|
|
450
|
+
error: `Module '${manifestResult.manifest.id}' already exists. Use update or remove it first.`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
455
|
+
const { tmpdir } = await import('node:os');
|
|
456
|
+
const tempPkgDir = mkdtempSync(join(tmpdir(), 'celilo-import-pkg-'));
|
|
457
|
+
const tempPkgPath = join(tempPkgDir, `${manifestResult.manifest.id}.netapp`);
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const { buildModule } = await import('./packaging/build');
|
|
461
|
+
const buildResult = await buildModule({ sourceDir: sourcePath, outputPath: tempPkgPath });
|
|
462
|
+
if (!buildResult.success) {
|
|
463
|
+
return {
|
|
464
|
+
success: false,
|
|
465
|
+
error: buildResult.error || 'Failed to package module for import',
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return await importModule({ ...options, sourcePath: tempPkgPath });
|
|
469
|
+
} finally {
|
|
470
|
+
rmSync(tempPkgDir, { recursive: true, force: true });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
433
474
|
let tempDir: string | null = null;
|
|
434
475
|
let checksums: Record<string, string> | null = null;
|
|
435
476
|
let signature: string | null = null;
|
|
436
477
|
|
|
437
478
|
try {
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
479
|
+
// sourcePath always ends with .netapp at this point — directory inputs
|
|
480
|
+
// were re-routed through the packager above and recursed back in here.
|
|
481
|
+
const extractResult = await extractPackage(sourcePath);
|
|
482
|
+
if (!extractResult.success || !extractResult.tempDir) {
|
|
483
|
+
return { success: false, error: extractResult.error || 'Failed to extract package' };
|
|
484
|
+
}
|
|
445
485
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
// Verify package integrity (unless --skip-verify)
|
|
449
|
-
const skipVerify = flags['skip-verify'] === true;
|
|
450
|
-
if (skipVerify) {
|
|
451
|
-
log.warn('Skipping package signature verification (--skip-verify)');
|
|
452
|
-
} else {
|
|
453
|
-
const verifyResult = await verifyPackageIntegrity(tempDir);
|
|
454
|
-
if (!verifyResult.success) {
|
|
455
|
-
await cleanupTempDir(tempDir);
|
|
456
|
-
return {
|
|
457
|
-
success: false,
|
|
458
|
-
error: verifyResult.error || 'Package integrity verification failed',
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
}
|
|
486
|
+
tempDir = extractResult.tempDir;
|
|
462
487
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
} catch (err) {
|
|
476
|
-
if (!skipVerify) throw err;
|
|
477
|
-
}
|
|
478
|
-
try {
|
|
479
|
-
signature = await readFile(join(tempDir, 'signature.sig'), 'utf-8');
|
|
480
|
-
} catch (err) {
|
|
481
|
-
if (!skipVerify) throw err;
|
|
488
|
+
// Verify package integrity (unless --skip-verify)
|
|
489
|
+
const skipVerify = flags['skip-verify'] === true;
|
|
490
|
+
if (skipVerify) {
|
|
491
|
+
log.warn('Skipping package signature verification (--skip-verify)');
|
|
492
|
+
} else {
|
|
493
|
+
const verifyResult = await verifyPackageIntegrity(tempDir);
|
|
494
|
+
if (!verifyResult.success) {
|
|
495
|
+
await cleanupTempDir(tempDir);
|
|
496
|
+
return {
|
|
497
|
+
success: false,
|
|
498
|
+
error: verifyResult.error || 'Package integrity verification failed',
|
|
499
|
+
};
|
|
482
500
|
}
|
|
501
|
+
}
|
|
483
502
|
|
|
484
|
-
|
|
485
|
-
|
|
503
|
+
// Read checksums and signature for database storage (both optional with --skip-verify)
|
|
504
|
+
try {
|
|
505
|
+
const checksumsJson = await readFile(join(tempDir, 'checksums.json'), 'utf-8');
|
|
506
|
+
const ChecksumsFileSchema = z.object({
|
|
507
|
+
files: z.record(z.string(), z.string()),
|
|
508
|
+
});
|
|
509
|
+
const checksumsData = parseJsonWithValidation(
|
|
510
|
+
checksumsJson,
|
|
511
|
+
ChecksumsFileSchema,
|
|
512
|
+
'package checksums.json',
|
|
513
|
+
);
|
|
514
|
+
checksums = checksumsData.files;
|
|
515
|
+
} catch (err) {
|
|
516
|
+
if (!skipVerify) throw err;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
signature = await readFile(join(tempDir, 'signature.sig'), 'utf-8');
|
|
520
|
+
} catch (err) {
|
|
521
|
+
if (!skipVerify) throw err;
|
|
486
522
|
}
|
|
487
523
|
|
|
524
|
+
const actualSourcePath = tempDir;
|
|
525
|
+
|
|
488
526
|
// Policy: Validate directory structure
|
|
489
527
|
const dirError = validateModuleDirectory(actualSourcePath);
|
|
490
528
|
if (dirError) {
|
|
@@ -639,29 +677,14 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
639
677
|
);
|
|
640
678
|
}
|
|
641
679
|
|
|
642
|
-
// Execution: Store integrity data
|
|
643
|
-
//
|
|
644
|
-
//
|
|
680
|
+
// Execution: Store integrity data from the package's checksums + signature.
|
|
681
|
+
// Directory imports go through the packager too (see top of importModule),
|
|
682
|
+
// so by the time we reach this point we always have these.
|
|
645
683
|
try {
|
|
646
|
-
let finalChecksums: Record<string, string>;
|
|
647
|
-
let finalSignature: string | null = null;
|
|
648
|
-
|
|
649
|
-
if (checksums && signature) {
|
|
650
|
-
// From .netapp package - already verified
|
|
651
|
-
finalChecksums = checksums;
|
|
652
|
-
finalSignature = signature.trim();
|
|
653
|
-
} else {
|
|
654
|
-
// From directory - calculate checksums now
|
|
655
|
-
const { computeChecksums } = await import('./packaging/build');
|
|
656
|
-
const checksumsData = await computeChecksums(targetPath);
|
|
657
|
-
finalChecksums = checksumsData.files;
|
|
658
|
-
// No signature for directory imports
|
|
659
|
-
}
|
|
660
|
-
|
|
661
684
|
const integrityData: NewModuleIntegrity = {
|
|
662
685
|
moduleId: manifest.id,
|
|
663
|
-
checksums:
|
|
664
|
-
signature:
|
|
686
|
+
checksums: checksums ?? {},
|
|
687
|
+
signature: signature?.trim() ?? null,
|
|
665
688
|
};
|
|
666
689
|
db.insert(moduleIntegrity).values(integrityData).run();
|
|
667
690
|
} catch (error) {
|
|
@@ -669,21 +692,22 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
669
692
|
console.warn('Warning: Failed to store module integrity data', error);
|
|
670
693
|
}
|
|
671
694
|
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
695
|
+
// Record a successful build entry so deploy-time validation sees the
|
|
696
|
+
// artifacts in place and skips rebuild. The packager runs the manifest
|
|
697
|
+
// build into the staged tree before tar, so any declared artifacts are
|
|
698
|
+
// already on disk under targetPath at this point.
|
|
699
|
+
if (manifest.build?.artifacts) {
|
|
700
|
+
const recordedArtifactPaths = manifest.build.artifacts
|
|
677
701
|
.map((a: string) => join(targetPath, a))
|
|
678
|
-
.filter((p: string) =>
|
|
702
|
+
.filter((p: string) => existsSync(p));
|
|
679
703
|
|
|
680
|
-
if (
|
|
704
|
+
if (recordedArtifactPaths.length > 0) {
|
|
681
705
|
const { moduleBuilds } = await import('../db/schema');
|
|
682
706
|
db.insert(moduleBuilds)
|
|
683
707
|
.values({
|
|
684
708
|
moduleId: manifest.id,
|
|
685
709
|
version: manifest.version,
|
|
686
|
-
artifacts:
|
|
710
|
+
artifacts: recordedArtifactPaths,
|
|
687
711
|
status: 'success',
|
|
688
712
|
buildLog: 'Pre-built artifacts from .netapp package',
|
|
689
713
|
})
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Validates module readiness and auto-prepares (generate/build) if needed
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
7
9
|
import { compareConsumerToProvider } from '@celilo/capabilities';
|
|
8
10
|
import { and, eq } from 'drizzle-orm';
|
|
9
11
|
import type { DbClient } from '../db/client';
|
|
@@ -123,10 +125,22 @@ export async function validateAndPrepareDeployment(
|
|
|
123
125
|
// Auto-build if required and not built
|
|
124
126
|
if (manifest.build) {
|
|
125
127
|
const buildStatus = await getModuleBuildStatus(moduleId, db);
|
|
128
|
+
// Cross-check the manifest's declared artifacts against the actual
|
|
129
|
+
// filesystem in addition to whatever's in the build record. A "success"
|
|
130
|
+
// record with an empty artifact list (e.g. from a build that exited 0
|
|
131
|
+
// but produced nothing, or a synthetic record from a partial .netapp
|
|
132
|
+
// import) would otherwise pass `verifyArtifactsExist([])` trivially and
|
|
133
|
+
// let the deploy run on without binaries — exactly how Ansible ends up
|
|
134
|
+
// failing on a missing `src:` file.
|
|
135
|
+
const declaredArtifacts = manifest.build.artifacts ?? [];
|
|
136
|
+
const declaredArtifactsOk = declaredArtifacts.every((rel) =>
|
|
137
|
+
existsSync(join(module.sourcePath, rel)),
|
|
138
|
+
);
|
|
126
139
|
const needsBuild =
|
|
127
140
|
!buildStatus ||
|
|
128
141
|
buildStatus.status !== 'success' ||
|
|
129
|
-
!verifyArtifactsExist(buildStatus.artifacts)
|
|
142
|
+
!verifyArtifactsExist(buildStatus.artifacts) ||
|
|
143
|
+
(declaredArtifacts.length > 0 && !declaredArtifactsOk);
|
|
130
144
|
|
|
131
145
|
if (needsBuild) {
|
|
132
146
|
const buildResult = await buildModuleFromSource(moduleId, db);
|
|
@@ -207,6 +207,44 @@ describe('Module Build Service', () => {
|
|
|
207
207
|
expect(buildRecord?.status).toBe('success');
|
|
208
208
|
expect(buildRecord?.environment).toBe('system'); // No flake.nix
|
|
209
209
|
}, 10000); // 10 second timeout for ansible execution
|
|
210
|
+
|
|
211
|
+
// Regression: a build.command that references $CELILO_MODULE_SOURCE_DIR
|
|
212
|
+
// (the variable celilo-registry's manifest uses to find sibling packages
|
|
213
|
+
// in the monorepo) must work the same at deploy time as at packaging
|
|
214
|
+
// time. Before the fix, executeBuildCommand didn't set the env var, so
|
|
215
|
+
// any module that imported from a local directory and relied on it
|
|
216
|
+
// would silently fail before producing artifacts.
|
|
217
|
+
test('build.command receives CELILO_MODULE_SOURCE_DIR pointing at modulePath', async () => {
|
|
218
|
+
await mkdir(TEST_MODULE_DIR, { recursive: true });
|
|
219
|
+
|
|
220
|
+
db.insert(modules)
|
|
221
|
+
.values({
|
|
222
|
+
id: 'env-test',
|
|
223
|
+
name: 'Env Test',
|
|
224
|
+
version: '1.0.0',
|
|
225
|
+
sourcePath: TEST_MODULE_DIR,
|
|
226
|
+
manifestData: {
|
|
227
|
+
id: 'env-test',
|
|
228
|
+
name: 'Env Test',
|
|
229
|
+
version: '1.0.0',
|
|
230
|
+
build: {
|
|
231
|
+
// Write the env var's value to a file. The build runs with
|
|
232
|
+
// cwd=modulePath, so a bare relative `built.marker` always
|
|
233
|
+
// lands there — but we want to prove the env var itself was
|
|
234
|
+
// set, so we fail explicitly if it's empty.
|
|
235
|
+
command:
|
|
236
|
+
'test -n "$CELILO_MODULE_SOURCE_DIR" && echo "$CELILO_MODULE_SOURCE_DIR" > built.marker',
|
|
237
|
+
artifacts: ['built.marker'],
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
.run();
|
|
242
|
+
|
|
243
|
+
const result = await buildModuleFromSource('env-test', db);
|
|
244
|
+
|
|
245
|
+
expect(result.success).toBe(true);
|
|
246
|
+
expect(existsSync(`${TEST_MODULE_DIR}/built.marker`)).toBe(true);
|
|
247
|
+
}, 10000);
|
|
210
248
|
});
|
|
211
249
|
|
|
212
250
|
describe('getModuleBuildStatus', () => {
|
|
@@ -104,6 +104,11 @@ async function executeBuildCommand(
|
|
|
104
104
|
command,
|
|
105
105
|
args: [],
|
|
106
106
|
cwd: modulePath,
|
|
107
|
+
// Match packaging/build.ts so deploy-time builds work the same as
|
|
108
|
+
// package-time builds. celilo-registry's manifest.build.command uses
|
|
109
|
+
// $CELILO_MODULE_SOURCE_DIR to find sibling packages in the monorepo;
|
|
110
|
+
// without this, that cd resolves to "/" and the build silently fails.
|
|
111
|
+
env: { CELILO_MODULE_SOURCE_DIR: modulePath },
|
|
107
112
|
});
|
|
108
113
|
|
|
109
114
|
return { success: result.success, output: result.output, error: result.error };
|
|
@@ -143,6 +148,7 @@ async function executeBuildScript(
|
|
|
143
148
|
command,
|
|
144
149
|
args,
|
|
145
150
|
cwd: modulePath,
|
|
151
|
+
env: { CELILO_MODULE_SOURCE_DIR: modulePath },
|
|
146
152
|
});
|
|
147
153
|
|
|
148
154
|
return { success: result.success, output: result.output, error: result.error };
|
|
@@ -35,6 +35,7 @@ import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
|
|
|
35
35
|
import { validateAndPrepareDeployment } from './deploy-validation';
|
|
36
36
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
37
37
|
import { findMachineForModule } from './machine-pool';
|
|
38
|
+
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
38
39
|
import { deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
|
|
39
40
|
import { buildTerraformEnvForService } from './terraform-env';
|
|
40
41
|
|
|
@@ -814,6 +815,20 @@ async function deployModuleImpl(
|
|
|
814
815
|
};
|
|
815
816
|
}
|
|
816
817
|
terraformEnvVars = await buildTerraformEnvForService(plan.infrastructure.serviceId);
|
|
818
|
+
|
|
819
|
+
// Fail fast if the proxmox API host is unreachable (e.g. VPN
|
|
820
|
+
// down). The terraform provider would otherwise stall on a
|
|
821
|
+
// SYN_SENT connect for ~60s with no visible feedback.
|
|
822
|
+
if (service.providerName === 'proxmox' && terraformEnvVars.TF_VAR_proxmox_api_url) {
|
|
823
|
+
const probe = await checkProxmoxReachable(terraformEnvVars.TF_VAR_proxmox_api_url);
|
|
824
|
+
if (!probe.reachable) {
|
|
825
|
+
return {
|
|
826
|
+
success: false,
|
|
827
|
+
error: formatProxmoxUnreachableError(probe),
|
|
828
|
+
phases,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
817
832
|
}
|
|
818
833
|
|
|
819
834
|
const terraformResult = await executeTerraform(generatedPath, phases, terraformEnvVars, {
|
|
@@ -1219,12 +1234,23 @@ async function deployModuleImpl(
|
|
|
1219
1234
|
}
|
|
1220
1235
|
}
|
|
1221
1236
|
|
|
1222
|
-
// Auto-register module hostname in internal DNS (if available)
|
|
1237
|
+
// Auto-register module hostname in internal DNS (if available).
|
|
1238
|
+
// Wrapped in a FuelGauge with a gauge logger so the capability calls
|
|
1239
|
+
// inside (`dns_internal.registerRecord`, etc.) nest as sub-events
|
|
1240
|
+
// under a single step rather than leaking to scrollback as
|
|
1241
|
+
// unindented top-level lines via cli/prompts.log.instantEvent.
|
|
1223
1242
|
let dnsRegistered = true;
|
|
1243
|
+
const dnsGauge = new FuelGauge(`Registering ${moduleId} in DNS`, {
|
|
1244
|
+
skipAnimation: !process.stdout.isTTY,
|
|
1245
|
+
});
|
|
1246
|
+
dnsGauge.start();
|
|
1224
1247
|
try {
|
|
1248
|
+
const dnsLogger = createGaugeLogger(dnsGauge, moduleId, 'auto_register_dns');
|
|
1225
1249
|
const { autoRegisterDns } = await import('./dns-auto-register');
|
|
1226
|
-
await autoRegisterDns(moduleId, db,
|
|
1250
|
+
await autoRegisterDns(moduleId, db, dnsLogger);
|
|
1251
|
+
dnsGauge.stop(true);
|
|
1227
1252
|
} catch (error) {
|
|
1253
|
+
dnsGauge.stop(false);
|
|
1228
1254
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1229
1255
|
log.warn(`DNS auto-registration failed: ${msg}`);
|
|
1230
1256
|
dnsRegistered = false;
|
|
@@ -276,6 +276,20 @@ export function startProgrammaticResponder(
|
|
|
276
276
|
answered.push({ type: event.type, key: lookupKey });
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
// Liveness probe: a non-interactive caller (e.g. `module generate`
|
|
280
|
+
// with no TTY) emits `responder.probe` to detect whether any
|
|
281
|
+
// responder is listening before calling busInterview (which waits
|
|
282
|
+
// forever). We reply with our kind so the caller's probe completes
|
|
283
|
+
// and the deploy/generate proceeds.
|
|
284
|
+
const probeWatch = bus.watch('responder.probe', async (event) => {
|
|
285
|
+
if (event.replyFor !== null) return;
|
|
286
|
+
bus.emitRaw(
|
|
287
|
+
`${event.type}.reply`,
|
|
288
|
+
{ kind: 'programmatic', emittedBy: me },
|
|
289
|
+
{ replyFor: event.id, emittedBy: me },
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
279
293
|
return {
|
|
280
294
|
lastActivityAt: () => lastActivityAt,
|
|
281
295
|
eventCount: () => answered.length + missed.length,
|
|
@@ -288,6 +302,7 @@ export function startProgrammaticResponder(
|
|
|
288
302
|
configWatch.close();
|
|
289
303
|
secretWatch.close();
|
|
290
304
|
ensureWatch.close();
|
|
305
|
+
probeWatch.close();
|
|
291
306
|
bus.close();
|
|
292
307
|
},
|
|
293
308
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { type AddressInfo, type Server, createServer } from 'node:net';
|
|
3
|
+
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
4
|
+
|
|
5
|
+
describe('checkProxmoxReachable', () => {
|
|
6
|
+
let server: Server | null = null;
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (server) {
|
|
10
|
+
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
|
11
|
+
server = null;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('returns reachable=true when the host accepts the connection', async () => {
|
|
16
|
+
server = createServer();
|
|
17
|
+
await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', () => resolve()));
|
|
18
|
+
const port = (server.address() as AddressInfo).port;
|
|
19
|
+
|
|
20
|
+
const result = await checkProxmoxReachable(`https://127.0.0.1:${port}/api2/json`, 1000);
|
|
21
|
+
expect(result.reachable).toBe(true);
|
|
22
|
+
expect(result.host).toBe('127.0.0.1');
|
|
23
|
+
expect(result.port).toBe(port);
|
|
24
|
+
expect(result.error).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns reachable=false on connection refused', async () => {
|
|
28
|
+
// Port 1 is reserved and reliably refuses on loopback.
|
|
29
|
+
const result = await checkProxmoxReachable('https://127.0.0.1:1/api2/json', 1000);
|
|
30
|
+
expect(result.reachable).toBe(false);
|
|
31
|
+
expect(result.host).toBe('127.0.0.1');
|
|
32
|
+
expect(result.port).toBe(1);
|
|
33
|
+
expect(result.error).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('returns reachable=false with timeout error on stalled connect', async () => {
|
|
37
|
+
// 192.0.2.1 is in TEST-NET-1 (RFC 5737) — guaranteed unroutable.
|
|
38
|
+
// SYN_SENT will hang until our timeout fires.
|
|
39
|
+
const result = await checkProxmoxReachable('https://192.0.2.1:8006/api2/json', 200);
|
|
40
|
+
expect(result.reachable).toBe(false);
|
|
41
|
+
expect(result.error).toMatch(/timed out/i);
|
|
42
|
+
}, 5000);
|
|
43
|
+
|
|
44
|
+
test('returns error for an invalid URL', async () => {
|
|
45
|
+
const result = await checkProxmoxReachable('not-a-url', 100);
|
|
46
|
+
expect(result.reachable).toBe(false);
|
|
47
|
+
expect(result.error).toMatch(/invalid/i);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('formatProxmoxUnreachableError', () => {
|
|
52
|
+
test('mentions VPN and the host:port in the message', () => {
|
|
53
|
+
const message = formatProxmoxUnreachableError({
|
|
54
|
+
reachable: false,
|
|
55
|
+
host: '10.0.30.102',
|
|
56
|
+
port: 8006,
|
|
57
|
+
error: 'Connection refused',
|
|
58
|
+
});
|
|
59
|
+
expect(message).toContain('10.0.30.102:8006');
|
|
60
|
+
expect(message).toContain('VPN');
|
|
61
|
+
expect(message).toContain('Connection refused');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxmox preflight reachability check.
|
|
3
|
+
*
|
|
4
|
+
* The Proxmox Terraform provider, when given an unreachable api_url,
|
|
5
|
+
* sits in TCP `SYN_SENT` until the kernel-level connect timeout fires
|
|
6
|
+
* — typically 30–75s on macOS. During that window the user sees a
|
|
7
|
+
* frozen progress display and no clue what's wrong (common cause: VPN
|
|
8
|
+
* is down). We probe the api_url's host:port with a short timeout
|
|
9
|
+
* before invoking terraform so we can fail fast with an actionable
|
|
10
|
+
* message instead of letting the provider hang.
|
|
11
|
+
*
|
|
12
|
+
* Scoped intentionally to the proxmox container-service deploy path —
|
|
13
|
+
* other terraform invocations (DigitalOcean, system audit) talk to
|
|
14
|
+
* different endpoints with their own failure modes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Socket } from 'node:net';
|
|
18
|
+
|
|
19
|
+
export interface ProxmoxReachabilityResult {
|
|
20
|
+
reachable: boolean;
|
|
21
|
+
host: string;
|
|
22
|
+
port: number;
|
|
23
|
+
/** Populated when `reachable` is false. */
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Probe `host:port` with a TCP connect attempt. Resolves on connect,
|
|
29
|
+
* timeout, or socket error — never rejects. Caller decides what to do
|
|
30
|
+
* with the result.
|
|
31
|
+
*/
|
|
32
|
+
export async function checkProxmoxReachable(
|
|
33
|
+
apiUrl: string,
|
|
34
|
+
timeoutMs = 3000,
|
|
35
|
+
): Promise<ProxmoxReachabilityResult> {
|
|
36
|
+
let host: string;
|
|
37
|
+
let port: number;
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(apiUrl);
|
|
40
|
+
host = url.hostname;
|
|
41
|
+
port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;
|
|
42
|
+
} catch {
|
|
43
|
+
return {
|
|
44
|
+
reachable: false,
|
|
45
|
+
host: apiUrl,
|
|
46
|
+
port: 0,
|
|
47
|
+
error: `Invalid Proxmox API URL: ${apiUrl}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return await new Promise<ProxmoxReachabilityResult>((resolve) => {
|
|
52
|
+
const socket = new Socket();
|
|
53
|
+
let settled = false;
|
|
54
|
+
|
|
55
|
+
const finish = (result: ProxmoxReachabilityResult) => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
socket.destroy();
|
|
59
|
+
resolve(result);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
socket.setTimeout(timeoutMs);
|
|
63
|
+
socket.once('connect', () => finish({ reachable: true, host, port }));
|
|
64
|
+
socket.once('timeout', () =>
|
|
65
|
+
finish({
|
|
66
|
+
reachable: false,
|
|
67
|
+
host,
|
|
68
|
+
port,
|
|
69
|
+
error: `Connection to ${host}:${port} timed out after ${timeoutMs}ms`,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
socket.once('error', (err) =>
|
|
73
|
+
finish({
|
|
74
|
+
reachable: false,
|
|
75
|
+
host,
|
|
76
|
+
port,
|
|
77
|
+
error: err.message,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
socket.connect(port, host);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format an unreachable result into a multi-line error message
|
|
87
|
+
* suitable for display in the deploy progress panel.
|
|
88
|
+
*/
|
|
89
|
+
export function formatProxmoxUnreachableError(result: ProxmoxReachabilityResult): string {
|
|
90
|
+
return [
|
|
91
|
+
`Proxmox unreachable at ${result.host}:${result.port}`,
|
|
92
|
+
'',
|
|
93
|
+
'Check:',
|
|
94
|
+
' - VPN connection (if Proxmox is on a private network)',
|
|
95
|
+
' - Is the Proxmox host running and reachable from this machine?',
|
|
96
|
+
` - Try: nc -zv ${result.host} ${result.port}`,
|
|
97
|
+
'',
|
|
98
|
+
`(${result.error ?? 'no further detail'})`,
|
|
99
|
+
].join('\n');
|
|
100
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responder liveness probe.
|
|
3
|
+
*
|
|
4
|
+
* busInterview waits forever for a reply (timeoutMs: 0 by design). For
|
|
5
|
+
* non-interactive callers (no TTY, no piped responder) that means a
|
|
6
|
+
* silent hang. The probe gives those callers a way to fail fast: emit
|
|
7
|
+
* a short-timeout query on the bus, return whether any responder was
|
|
8
|
+
* listening.
|
|
9
|
+
*
|
|
10
|
+
* Responders register a `responder.probe` watch and reply with their
|
|
11
|
+
* kind. As long as one is running on the same bus DB, the probe sees
|
|
12
|
+
* a reply and returns true.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
16
|
+
|
|
17
|
+
const NO_SCHEMAS = defineEvents({});
|
|
18
|
+
|
|
19
|
+
export const RESPONDER_PROBE_EVENT = 'responder.probe' as const;
|
|
20
|
+
|
|
21
|
+
export interface ResponderProbeReply {
|
|
22
|
+
kind: 'terminal' | 'programmatic' | 'daemon';
|
|
23
|
+
emittedBy: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Probe the bus for a live responder.
|
|
28
|
+
*
|
|
29
|
+
* @param busDbPath sqlite path the bus and any responder share
|
|
30
|
+
* @param timeoutMs how long to wait for a reply (default 1500ms)
|
|
31
|
+
* @returns true if at least one responder replied within the window
|
|
32
|
+
*/
|
|
33
|
+
export async function probeForResponder(busDbPath: string, timeoutMs = 1500): Promise<boolean> {
|
|
34
|
+
const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
35
|
+
try {
|
|
36
|
+
const replies = await bus.query(RESPONDER_PROBE_EVENT as never, {} as never, {
|
|
37
|
+
timeoutMs,
|
|
38
|
+
pollIntervalMs: 100,
|
|
39
|
+
expect: 'first',
|
|
40
|
+
});
|
|
41
|
+
return replies.length > 0;
|
|
42
|
+
} finally {
|
|
43
|
+
bus.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -283,11 +283,24 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
283
283
|
});
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
// Liveness probe: lets a non-interactive caller in another shell
|
|
287
|
+
// (e.g. `module generate`) detect that a terminal-responder is
|
|
288
|
+
// running here and that calling busInterview is safe.
|
|
289
|
+
const probeWatch = bus.watch('responder.probe', async (event) => {
|
|
290
|
+
if (event.replyFor !== null) return;
|
|
291
|
+
bus.emitRaw(
|
|
292
|
+
`${event.type}.reply`,
|
|
293
|
+
{ kind: 'terminal', emittedBy: me },
|
|
294
|
+
{ replyFor: event.id, emittedBy: me },
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
286
298
|
return {
|
|
287
299
|
close() {
|
|
288
300
|
configWatch.close();
|
|
289
301
|
secretWatch.close();
|
|
290
302
|
ensureWatch.close();
|
|
303
|
+
probeWatch.close();
|
|
291
304
|
bus.close();
|
|
292
305
|
},
|
|
293
306
|
};
|