mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
b1c5fe8391
* fix: harden deploy enforcement paths * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories * fix(test): add execFile to child_process mock in compose-images test * fix: resolve merge conflicts with main * fix: resolve merge conflicts with main * fix: resolve merge conflicts with main
569 lines
26 KiB
TypeScript
569 lines
26 KiB
TypeScript
import path from 'path';
|
|
import { promises as fsPromises } from 'fs';
|
|
import axios, { AxiosError } from 'axios';
|
|
import {
|
|
DatabaseService,
|
|
type Blueprint,
|
|
type BlueprintDeployment,
|
|
type BlueprintDeploymentStatus,
|
|
type Node,
|
|
} from './DatabaseService';
|
|
import { ComposeService } from './ComposeService';
|
|
import { FileSystemService } from './FileSystemService';
|
|
import { NodeRegistry } from './NodeRegistry';
|
|
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
|
|
import { LicenseService } from './LicenseService';
|
|
import { assertPolicyGateAllows, buildSystemPolicyGateOptions, triggerPostDeployScan } from '../helpers/policyGate';
|
|
import { enforcePolicyForImageRefs } from './PolicyEnforcement';
|
|
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
|
|
const MARKER_FILENAME = '.blueprint.json';
|
|
const COMPOSE_FILENAME = 'docker-compose.yml';
|
|
const REMOTE_HTTP_TIMEOUT_MS = 30_000;
|
|
|
|
function isDeveloperModeEnabled(): boolean {
|
|
try {
|
|
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function diagnosticLog(message: string, fields: Record<string, string | number | boolean | null | undefined>): void {
|
|
if (!isDeveloperModeEnabled()) return;
|
|
const safeFields = Object.fromEntries(
|
|
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? sanitizeForLog(value) : value]),
|
|
);
|
|
console.info(`[BlueprintService:diag] ${message}`, safeFields);
|
|
}
|
|
|
|
export interface BlueprintMarker {
|
|
blueprintId: number;
|
|
revision: number;
|
|
lastApplied: number;
|
|
}
|
|
|
|
export interface DeployOutcome {
|
|
status: BlueprintDeploymentStatus;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* BlueprintService is the orchestration layer between the reconciler and the
|
|
* concrete deploy/withdraw primitives. It owns:
|
|
* - per-target marker-file management (writes, reads, validates ownership)
|
|
* - name-conflict guard (refuses to touch a stack directory missing the marker)
|
|
* - local deploy via ComposeService + FileSystemService
|
|
* - remote deploy via direct HTTP calls to the remote Sencho instance
|
|
* - per-(blueprint,node) concurrency lock so overlapping ticks don't collide
|
|
*
|
|
* The reconciler decides *what* needs to happen; this service performs it.
|
|
*/
|
|
export class BlueprintService {
|
|
private static instance: BlueprintService | null = null;
|
|
private readonly inflight = new Set<string>();
|
|
|
|
static getInstance(): BlueprintService {
|
|
if (!BlueprintService.instance) {
|
|
BlueprintService.instance = new BlueprintService();
|
|
}
|
|
return BlueprintService.instance;
|
|
}
|
|
|
|
private constructor() { /* singleton */ }
|
|
|
|
private lockKey(blueprintId: number, nodeId: number): string {
|
|
return `${blueprintId}:${nodeId}`;
|
|
}
|
|
|
|
private acquireLock(blueprintId: number, nodeId: number): boolean {
|
|
const key = this.lockKey(blueprintId, nodeId);
|
|
if (this.inflight.has(key)) return false;
|
|
this.inflight.add(key);
|
|
return true;
|
|
}
|
|
|
|
private releaseLock(blueprintId: number, nodeId: number): void {
|
|
this.inflight.delete(this.lockKey(blueprintId, nodeId));
|
|
}
|
|
|
|
private buildMarker(blueprint: Blueprint): BlueprintMarker {
|
|
return {
|
|
blueprintId: blueprint.id,
|
|
revision: blueprint.revision,
|
|
lastApplied: Date.now(),
|
|
};
|
|
}
|
|
|
|
private setStatus(
|
|
blueprintId: number,
|
|
nodeId: number,
|
|
status: BlueprintDeploymentStatus,
|
|
extras: Partial<{
|
|
applied_revision: number | null;
|
|
last_deployed_at: number | null;
|
|
last_drift_at: number | null;
|
|
drift_summary: string | null;
|
|
last_error: string | null;
|
|
}> = {},
|
|
): BlueprintDeployment {
|
|
return DatabaseService.getInstance().upsertDeployment({
|
|
blueprint_id: blueprintId,
|
|
node_id: nodeId,
|
|
status,
|
|
last_checked_at: Date.now(),
|
|
...extras,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read the marker file from a target node. Returns null when missing,
|
|
* malformed, or unreadable. The reconciler treats null as "we do not
|
|
* own this directory" and refuses to touch it.
|
|
*/
|
|
async readMarker(blueprintName: string, node: Node): Promise<BlueprintMarker | null> {
|
|
try {
|
|
if (node.type === 'local') {
|
|
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
|
|
const markerPath = path.resolve(baseDir, blueprintName, MARKER_FILENAME);
|
|
if (!markerPath.startsWith(path.resolve(baseDir))) return null;
|
|
const content = await fsPromises.readFile(markerPath, 'utf-8');
|
|
return BlueprintService.parseMarker(content);
|
|
}
|
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
|
if (!target) return null;
|
|
const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/files/content?path=${encodeURIComponent(MARKER_FILENAME)}`;
|
|
const res = await axios.get(url, {
|
|
headers: this.remoteHeaders(target.apiToken),
|
|
timeout: REMOTE_HTTP_TIMEOUT_MS,
|
|
validateStatus: () => true,
|
|
});
|
|
if (res.status !== 200) return null;
|
|
const body = res.data;
|
|
const content = typeof body === 'string' ? body : (typeof body?.content === 'string' ? body.content : null);
|
|
if (content == null) return null;
|
|
return BlueprintService.parseMarker(content);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true when a stack directory by this name exists on the target
|
|
* node but does not carry our marker file. The reconciler must not
|
|
* deploy in that case: there is a real user-authored stack with the
|
|
* same name and we must not overwrite it.
|
|
*/
|
|
async hasNameConflict(blueprintName: string, node: Node): Promise<boolean> {
|
|
try {
|
|
if (node.type === 'local') {
|
|
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
|
|
const stackDir = path.resolve(baseDir, blueprintName);
|
|
if (!stackDir.startsWith(path.resolve(baseDir))) return true;
|
|
try {
|
|
const stat = await fsPromises.stat(stackDir);
|
|
if (!stat.isDirectory()) return false;
|
|
} catch {
|
|
return false; // directory doesn't exist → no conflict
|
|
}
|
|
const markerPath = path.join(stackDir, MARKER_FILENAME);
|
|
try {
|
|
await fsPromises.stat(markerPath);
|
|
return false; // marker present → ours
|
|
} catch {
|
|
return true; // directory exists but no marker → conflict
|
|
}
|
|
}
|
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
|
if (!target) return false;
|
|
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
|
const listUrl = `${baseUrl}/api/stacks`;
|
|
const listRes = await axios.get(listUrl, {
|
|
headers: this.remoteHeaders(target.apiToken),
|
|
timeout: REMOTE_HTTP_TIMEOUT_MS,
|
|
validateStatus: () => true,
|
|
});
|
|
if (listRes.status !== 200) return false;
|
|
const stacks = Array.isArray(listRes.data) ? listRes.data as Array<{ name?: string }> : [];
|
|
const exists = stacks.some(s => s?.name === blueprintName);
|
|
if (!exists) return false;
|
|
const marker = await this.readMarker(blueprintName, node);
|
|
return marker == null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deploy this blueprint to the given target node. Caller must have already
|
|
* resolved that the target should receive this blueprint (selector match
|
|
* passed, no state-review pending, etc.). This method handles the
|
|
* name-conflict guard and the local/remote dispatch.
|
|
*/
|
|
async deployToNode(blueprint: Blueprint, node: Node): Promise<DeployOutcome> {
|
|
if (!this.acquireLock(blueprint.id, node.id)) {
|
|
return { status: 'pending' };
|
|
}
|
|
const started = Date.now();
|
|
console.info('[BlueprintService] deploy start blueprint=%s node=%s type=%s revision=%s',
|
|
sanitizeForLog(blueprint.name), node.id, node.type, blueprint.revision);
|
|
diagnosticLog('deploy inputs', {
|
|
blueprintId: blueprint.id,
|
|
blueprintName: blueprint.name,
|
|
nodeId: node.id,
|
|
nodeType: node.type,
|
|
revision: blueprint.revision,
|
|
classification: blueprint.classification,
|
|
driftMode: blueprint.drift_mode,
|
|
});
|
|
try {
|
|
this.setStatus(blueprint.id, node.id, 'deploying');
|
|
if (await this.hasNameConflict(blueprint.name, node)) {
|
|
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
|
last_error: `A stack named "${blueprint.name}" already exists on this node and is not managed by Sencho.`,
|
|
});
|
|
console.warn('[BlueprintService] deploy name conflict blueprint=%s node=%s durationMs=%s',
|
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
|
return { status: 'name_conflict', error: 'name_conflict' };
|
|
}
|
|
const marker = this.buildMarker(blueprint);
|
|
if (node.type === 'local') {
|
|
diagnosticLog('deploy branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'local' });
|
|
await this.deployLocal(blueprint, node, marker);
|
|
} else {
|
|
diagnosticLog('deploy branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'remote' });
|
|
await this.deployRemote(blueprint, node, marker);
|
|
}
|
|
this.setStatus(blueprint.id, node.id, 'active', {
|
|
applied_revision: blueprint.revision,
|
|
last_deployed_at: Date.now(),
|
|
last_drift_at: null,
|
|
drift_summary: null,
|
|
last_error: null,
|
|
});
|
|
console.info('[BlueprintService] deploy complete blueprint=%s node=%s durationMs=%s',
|
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
|
return { status: 'active' };
|
|
} catch (err) {
|
|
const message = BlueprintService.formatError(err);
|
|
this.setStatus(blueprint.id, node.id, 'failed', { last_error: message });
|
|
console.error('[BlueprintService] deploy failed blueprint=%s node=%s durationMs=%s error=%s',
|
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started, sanitizeForLog(message));
|
|
return { status: 'failed', error: message };
|
|
} finally {
|
|
this.releaseLock(blueprint.id, node.id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Withdraw a blueprint from the target node: docker compose down, delete
|
|
* the directory. Caller must have already cleared the eviction guard
|
|
* (stateful blueprints require explicit operator confirmation).
|
|
*/
|
|
async withdrawFromNode(blueprint: Blueprint, node: Node): Promise<DeployOutcome> {
|
|
if (!this.acquireLock(blueprint.id, node.id)) {
|
|
return { status: 'pending' };
|
|
}
|
|
const started = Date.now();
|
|
console.info('[BlueprintService] withdraw start blueprint=%s node=%s type=%s',
|
|
sanitizeForLog(blueprint.name), node.id, node.type);
|
|
diagnosticLog('withdraw inputs', {
|
|
blueprintId: blueprint.id,
|
|
blueprintName: blueprint.name,
|
|
nodeId: node.id,
|
|
nodeType: node.type,
|
|
classification: blueprint.classification,
|
|
});
|
|
try {
|
|
this.setStatus(blueprint.id, node.id, 'withdrawing');
|
|
// Refuse to withdraw a directory we do not own
|
|
const marker = await this.readMarker(blueprint.name, node);
|
|
if (marker && marker.blueprintId !== blueprint.id) {
|
|
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
|
last_error: `Marker on this node points to a different blueprint (id=${marker.blueprintId}); refusing to withdraw.`,
|
|
});
|
|
return { status: 'name_conflict' };
|
|
}
|
|
if (node.type === 'local') {
|
|
diagnosticLog('withdraw branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'local' });
|
|
await this.withdrawLocal(blueprint, node);
|
|
} else {
|
|
diagnosticLog('withdraw branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'remote' });
|
|
await this.withdrawRemote(blueprint, node);
|
|
}
|
|
DatabaseService.getInstance().deleteDeployment(blueprint.id, node.id);
|
|
console.info('[BlueprintService] withdraw complete blueprint=%s node=%s durationMs=%s',
|
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
|
return { status: 'withdrawn' };
|
|
} catch (err) {
|
|
const message = BlueprintService.formatError(err);
|
|
this.setStatus(blueprint.id, node.id, 'failed', { last_error: `withdraw failed: ${message}` });
|
|
console.error('[BlueprintService] withdraw failed blueprint=%s node=%s durationMs=%s error=%s',
|
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started, sanitizeForLog(message));
|
|
return { status: 'failed', error: message };
|
|
} finally {
|
|
this.releaseLock(blueprint.id, node.id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inspect the actual state of a deployment on its node and report
|
|
* whether it has drifted from the desired state. The reconciler decides
|
|
* what to do with the result based on drift_mode.
|
|
*/
|
|
async checkForDrift(blueprint: Blueprint, node: Node): Promise<{ drifted: boolean; reason?: string }> {
|
|
try {
|
|
const marker = await this.readMarker(blueprint.name, node);
|
|
if (!marker) {
|
|
return { drifted: true, reason: 'marker file missing on node' };
|
|
}
|
|
if (marker.blueprintId !== blueprint.id) {
|
|
return { drifted: true, reason: 'marker references a different blueprint' };
|
|
}
|
|
if (marker.revision !== blueprint.revision) {
|
|
return { drifted: true, reason: `revision drift (node has ${marker.revision}, blueprint is ${blueprint.revision})` };
|
|
}
|
|
// Check container state
|
|
const containerState = await this.containerHealth(blueprint.name, node);
|
|
if (!containerState.allRunning) {
|
|
return { drifted: true, reason: containerState.detail };
|
|
}
|
|
return { drifted: false };
|
|
} catch (err) {
|
|
return { drifted: true, reason: BlueprintService.formatError(err) };
|
|
}
|
|
}
|
|
|
|
private async containerHealth(blueprintName: string, node: Node): Promise<{ allRunning: boolean; detail: string }> {
|
|
try {
|
|
// Docker Compose normalizes the project name to lowercase. Match the same canonical form.
|
|
const projectName = blueprintName.toLowerCase();
|
|
if (node.type === 'local') {
|
|
const docker = NodeRegistry.getInstance().getDocker(node.id);
|
|
const containers = await docker.listContainers({
|
|
all: true,
|
|
filters: { label: [`com.docker.compose.project=${projectName}`] },
|
|
});
|
|
if (containers.length === 0) return { allRunning: false, detail: 'no containers running for this blueprint' };
|
|
const notRunning = containers.filter(c => c.State !== 'running');
|
|
if (notRunning.length > 0) {
|
|
const first = notRunning[0];
|
|
return { allRunning: false, detail: `container "${first.Names[0] ?? first.Id.slice(0, 12)}" is ${first.State}` };
|
|
}
|
|
return { allRunning: true, detail: '' };
|
|
}
|
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
|
if (!target) return { allRunning: false, detail: 'remote node not reachable (no proxy target)' };
|
|
const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/containers`;
|
|
const res = await axios.get(url, {
|
|
headers: this.remoteHeaders(target.apiToken),
|
|
timeout: REMOTE_HTTP_TIMEOUT_MS,
|
|
validateStatus: () => true,
|
|
});
|
|
if (res.status !== 200) {
|
|
return { allRunning: false, detail: `remote stack lookup returned HTTP ${res.status}` };
|
|
}
|
|
const list = Array.isArray(res.data) ? res.data as Array<{ State?: string; Names?: string[]; Id?: string }> : [];
|
|
if (list.length === 0) return { allRunning: false, detail: 'remote stack has no containers' };
|
|
const notRunning = list.filter(c => (c.State ?? '') !== 'running');
|
|
if (notRunning.length > 0) {
|
|
const first = notRunning[0];
|
|
return { allRunning: false, detail: `remote container "${first.Names?.[0] ?? first.Id?.slice(0, 12)}" is ${first.State}` };
|
|
}
|
|
return { allRunning: true, detail: '' };
|
|
} catch (err) {
|
|
return { allRunning: false, detail: BlueprintService.formatError(err) };
|
|
}
|
|
}
|
|
|
|
// ---- local primitives ----
|
|
|
|
private async stackDirExists(node: Node, blueprintName: string): Promise<boolean> {
|
|
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
|
|
const stackDir = path.resolve(baseDir, blueprintName);
|
|
if (!stackDir.startsWith(path.resolve(baseDir))) return false;
|
|
try {
|
|
const stat = await fsPromises.stat(stackDir);
|
|
return stat.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async deployLocal(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise<void> {
|
|
const imageRefs = BlueprintAnalyzer.extractImageRefs(blueprint.compose_content);
|
|
const gate = await enforcePolicyForImageRefs(blueprint.name, node.id, imageRefs, {
|
|
bypass: false,
|
|
actor: 'blueprint-reconciler',
|
|
auditMethod: 'POST',
|
|
auditPath: `/api/blueprints/${blueprint.id}/apply`,
|
|
}, undefined, true);
|
|
if (!gate.ok) {
|
|
throw new Error(`Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`);
|
|
}
|
|
|
|
const fs = FileSystemService.getInstance(node.id);
|
|
if (!(await this.stackDirExists(node, blueprint.name))) {
|
|
await fs.createStack(blueprint.name);
|
|
}
|
|
await fs.writeStackFile(blueprint.name, COMPOSE_FILENAME, blueprint.compose_content);
|
|
await fs.writeStackFile(blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2));
|
|
await assertPolicyGateAllows(
|
|
blueprint.name,
|
|
node.id,
|
|
buildSystemPolicyGateOptions('blueprint', {
|
|
auditPath: `/api/blueprints/${blueprint.id}/deployments/${node.id}`,
|
|
}),
|
|
);
|
|
await ComposeService.getInstance(node.id).deployStack(blueprint.name, undefined, false);
|
|
triggerPostDeployScan(blueprint.name, node.id).catch(err => {
|
|
console.error('[BlueprintService] post-deploy scan failed for "%s" on node %s: %s',
|
|
sanitizeForLog(blueprint.name), node.id, sanitizeForLog(BlueprintService.formatError(err)));
|
|
});
|
|
}
|
|
|
|
private async withdrawLocal(blueprint: Blueprint, node: Node): Promise<void> {
|
|
try {
|
|
await ComposeService.getInstance(node.id).downStack(blueprint.name);
|
|
} catch (err) {
|
|
// best-effort: continue to delete the directory even if down fails
|
|
console.warn(`[BlueprintService] downStack failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`);
|
|
}
|
|
if (await this.stackDirExists(node, blueprint.name)) {
|
|
await FileSystemService.getInstance(node.id).deleteStack(blueprint.name);
|
|
}
|
|
}
|
|
|
|
// ---- remote primitives ----
|
|
|
|
private remoteHeaders(apiToken: string): Record<string, string> {
|
|
const proxy = LicenseService.getInstance().getProxyHeaders();
|
|
return {
|
|
Authorization: `Bearer ${apiToken}`,
|
|
[PROXY_TIER_HEADER]: proxy.tier,
|
|
[PROXY_VARIANT_HEADER]: proxy.variant ?? '',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
private async deployRemote(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise<void> {
|
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
|
if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`);
|
|
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
|
const headers = this.remoteHeaders(target.apiToken);
|
|
|
|
// 1. Ensure stack exists. POST returns 409 when already exists; we treat that as success.
|
|
const createRes = await axios.post(`${baseUrl}/api/stacks`,
|
|
{ stackName: blueprint.name },
|
|
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
|
|
);
|
|
if (createRes.status >= 400 && createRes.status !== 409) {
|
|
throw new Error(`create stack: HTTP ${createRes.status} ${BlueprintService.extractApiError(createRes.data)}`);
|
|
}
|
|
|
|
// 2. Write the compose file
|
|
await this.remotePutFile(baseUrl, headers, blueprint.name, COMPOSE_FILENAME, blueprint.compose_content);
|
|
|
|
// 3. Write the marker (last so a partial failure leaves us in name_conflict-recoverable state)
|
|
await this.remotePutFile(baseUrl, headers, blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2));
|
|
|
|
// 4. Deploy
|
|
const deployRes = await axios.post(
|
|
`${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/deploy`,
|
|
{},
|
|
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
|
|
);
|
|
if (deployRes.status >= 400) {
|
|
throw new Error(`deploy: HTTP ${deployRes.status} ${BlueprintService.extractApiError(deployRes.data)}`);
|
|
}
|
|
}
|
|
|
|
private async withdrawRemote(blueprint: Blueprint, node: Node): Promise<void> {
|
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
|
if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`);
|
|
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
|
const headers = this.remoteHeaders(target.apiToken);
|
|
|
|
// down (best-effort)
|
|
try {
|
|
await axios.post(
|
|
`${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/down`,
|
|
{},
|
|
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
|
|
);
|
|
} catch (err) {
|
|
console.warn(`[BlueprintService] remote down failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`);
|
|
}
|
|
|
|
// delete the stack directory entirely
|
|
const delRes = await axios.delete(
|
|
`${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}`,
|
|
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
|
|
);
|
|
if (delRes.status >= 400 && delRes.status !== 404) {
|
|
throw new Error(`remote delete: HTTP ${delRes.status} ${BlueprintService.extractApiError(delRes.data)}`);
|
|
}
|
|
}
|
|
|
|
private async remotePutFile(
|
|
baseUrl: string,
|
|
headers: Record<string, string>,
|
|
stackName: string,
|
|
relPath: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
const url = `${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/files/content?path=${encodeURIComponent(relPath)}`;
|
|
const res = await axios.put(url,
|
|
{ content },
|
|
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
|
|
);
|
|
if (res.status >= 400) {
|
|
throw new Error(`PUT ${relPath}: HTTP ${res.status} ${BlueprintService.extractApiError(res.data)}`);
|
|
}
|
|
}
|
|
|
|
static parseMarker(content: string): BlueprintMarker | null {
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
if (parsed && typeof parsed === 'object'
|
|
&& typeof parsed.blueprintId === 'number'
|
|
&& typeof parsed.revision === 'number') {
|
|
return {
|
|
blueprintId: parsed.blueprintId,
|
|
revision: parsed.revision,
|
|
lastApplied: typeof parsed.lastApplied === 'number' ? parsed.lastApplied : 0,
|
|
};
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static formatError(err: unknown): string {
|
|
if (axios.isAxiosError(err)) {
|
|
const ax = err as AxiosError<{ error?: string; message?: string }>;
|
|
if (ax.response?.data) {
|
|
const body = ax.response.data;
|
|
if (body && typeof body === 'object') {
|
|
if (typeof body.error === 'string') return body.error;
|
|
if (typeof body.message === 'string') return body.message;
|
|
}
|
|
}
|
|
if (ax.code) return `${ax.code}: ${ax.message}`;
|
|
return ax.message;
|
|
}
|
|
if (err instanceof Error) return err.message;
|
|
return String(err);
|
|
}
|
|
|
|
static extractApiError(body: unknown): string {
|
|
if (!body || typeof body !== 'object') return '';
|
|
const obj = body as Record<string, unknown>;
|
|
if (typeof obj.error === 'string') return obj.error;
|
|
if (typeof obj.message === 'string') return obj.message;
|
|
return '';
|
|
}
|
|
}
|