mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(blueprints): write compose.yaml so first-time apply is not shadowed (#1668)
createStack scaffolds compose.yaml; Blueprint was writing docker-compose.yml, so Compose discovery ran the nginx boilerplate. Align Blueprint writes with the canonical filename, clear alternate root Compose siblings on local/modern apply, and cover the regression paths.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Regression: Blueprint apply must write canonical compose.yaml (overwriting
|
||||
* createStack's nginx scaffold) and remove alternate root Compose filenames so
|
||||
* Docker Compose discovery cannot prefer a leftover docker-compose.yml.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let BlueprintService: typeof import('../services/BlueprintService').BlueprintService;
|
||||
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
|
||||
let ComposeService: typeof import('../services/ComposeService').ComposeService;
|
||||
let StackOpLockService: typeof import('../services/StackOpLockService').StackOpLockService;
|
||||
let counter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ BlueprintService } = await import('../services/BlueprintService'));
|
||||
({ FileSystemService } = await import('../services/FileSystemService'));
|
||||
({ ComposeService } = await import('../services/ComposeService'));
|
||||
({ StackOpLockService } = await import('../services/StackOpLockService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
counter += 1;
|
||||
StackOpLockService.resetForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function seedLocalNode(): number {
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
const result = DatabaseService.getInstance().getDb().prepare(
|
||||
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
|
||||
VALUES (?, 'local', 'proxy', ?, 0, 'online', ?)`,
|
||||
).run(`bp-compose-node-${counter}`, composeDir, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function defaultNodeId(): number {
|
||||
const id = DatabaseService.getInstance().getNodes().find(n => n.is_default)?.id;
|
||||
if (id === undefined) throw new Error('default node missing');
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Point the default node at the test COMPOSE_DIR (FileSystemService.getInstance uses it). */
|
||||
function bindDefaultComposeDir(): number {
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
DatabaseService.getInstance().getDb()
|
||||
.prepare('UPDATE nodes SET compose_dir = ? WHERE is_default = 1')
|
||||
.run(composeDir);
|
||||
return defaultNodeId();
|
||||
}
|
||||
|
||||
async function expectMissing(filePath: string): Promise<void> {
|
||||
await expect(fsPromises.access(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
}
|
||||
|
||||
async function anyExists(...paths: string[]): Promise<boolean> {
|
||||
const results = await Promise.all(
|
||||
paths.map((p) => fsPromises.access(p).then(() => true, () => false)),
|
||||
);
|
||||
return results.some(Boolean);
|
||||
}
|
||||
|
||||
describe('Blueprint compose apply (real filesystem)', () => {
|
||||
it('first-time apply overwrites createStack nginx scaffold with blueprint content in compose.yaml', async () => {
|
||||
const nodeId = seedLocalNode();
|
||||
const stackName = `bp-first-${counter}`;
|
||||
const composeContent = 'services:\n web:\n image: traefik:v3\n';
|
||||
const markerContent = JSON.stringify({ blueprintId: 1, revision: 1, lastApplied: Date.now() }, null, 2);
|
||||
|
||||
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(undefined);
|
||||
|
||||
const outcome = await BlueprintService.getInstance().applyLocalUnderLock(
|
||||
nodeId,
|
||||
stackName,
|
||||
composeContent,
|
||||
markerContent,
|
||||
'/api/blueprints/test/apply',
|
||||
);
|
||||
expect(outcome).toEqual({ ran: true });
|
||||
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe(composeContent);
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
|
||||
const resolved = await FileSystemService.getInstance(nodeId).getComposeFilename(stackName);
|
||||
expect(resolved).toBe('compose.yaml');
|
||||
expect(deploySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-apply on a dual-file stack replaces compose.yaml and removes alternate root Compose files before deploy', async () => {
|
||||
const nodeId = seedLocalNode();
|
||||
const stackName = `bp-heal-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n stale:\n image: nginx:latest\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'services:\n a:\n image: a\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yaml'), 'services:\n b:\n image: b\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'services:\n c:\n image: c\n');
|
||||
|
||||
const composeContent = 'services:\n app:\n image: redis:7\n';
|
||||
const markerContent = JSON.stringify({ blueprintId: 2, revision: 3, lastApplied: Date.now() }, null, 2);
|
||||
|
||||
let alternatesPresentAtDeploy = true;
|
||||
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockImplementation(async () => {
|
||||
alternatesPresentAtDeploy = await anyExists(
|
||||
path.join(stackDir, 'compose.yml'),
|
||||
path.join(stackDir, 'docker-compose.yaml'),
|
||||
path.join(stackDir, 'docker-compose.yml'),
|
||||
);
|
||||
});
|
||||
|
||||
const outcome = await BlueprintService.getInstance().applyLocalUnderLock(
|
||||
nodeId,
|
||||
stackName,
|
||||
composeContent,
|
||||
markerContent,
|
||||
'/api/blueprints/test/apply',
|
||||
);
|
||||
expect(outcome).toEqual({ ran: true });
|
||||
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe(composeContent);
|
||||
expect(alternatesPresentAtDeploy).toBe(false);
|
||||
await expectMissing(path.join(stackDir, 'compose.yml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yaml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
expect(deploySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSystemService.removeAlternateRootComposeFiles', () => {
|
||||
it('removes all three alternate filenames and leaves compose.yaml', async () => {
|
||||
const nodeId = bindDefaultComposeDir();
|
||||
const stackName = `bp-alts-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n keep:\n image: keep\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'x');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yaml'), 'y');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'z');
|
||||
|
||||
await FileSystemService.getInstance(nodeId).removeAlternateRootComposeFiles(stackName);
|
||||
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toContain('image: keep');
|
||||
await expectMissing(path.join(stackDir, 'compose.yml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yaml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
});
|
||||
|
||||
it('treats absent alternate files as success', async () => {
|
||||
const nodeId = bindDefaultComposeDir();
|
||||
const stackName = `bp-absent-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n keep:\n image: keep\n');
|
||||
|
||||
await expect(
|
||||
FileSystemService.getInstance(nodeId).removeAlternateRootComposeFiles(stackName),
|
||||
).resolves.toBeUndefined();
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toContain('image: keep');
|
||||
});
|
||||
|
||||
it('logs and continues when a non-ENOENT unlink failure occurs', async () => {
|
||||
const nodeId = bindDefaultComposeDir();
|
||||
const stackName = `bp-eacces-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n keep:\n image: keep\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'services:\n stale:\n image: stale\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'services:\n other:\n image: other\n');
|
||||
|
||||
const originalUnlink = fsPromises.unlink.bind(fsPromises);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(fsPromises, 'unlink').mockImplementation(async (target) => {
|
||||
const asString = String(target);
|
||||
if (asString.endsWith(`${path.sep}docker-compose.yml`) || asString.endsWith('/docker-compose.yml')) {
|
||||
const err = new Error('permission denied') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return originalUnlink(target);
|
||||
});
|
||||
|
||||
await FileSystemService.getInstance(nodeId).removeAlternateRootComposeFiles(stackName);
|
||||
|
||||
expect(warnSpy.mock.calls.some((args) => {
|
||||
const label = String(args[0] ?? '');
|
||||
const detail = String(args[1] ?? '');
|
||||
return label.includes('Could not remove alternate compose file')
|
||||
&& label.includes('docker-compose.yml')
|
||||
&& detail.includes('permission denied');
|
||||
})).toBe(true);
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toContain('image: keep');
|
||||
await expectMissing(path.join(stackDir, 'compose.yml'));
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'docker-compose.yml'), 'utf-8')).toContain('image: stale');
|
||||
});
|
||||
});
|
||||
@@ -337,6 +337,9 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => {
|
||||
expect(change.action).toBe('blocked_name_conflict');
|
||||
expect(change.severity).toBe('blocker');
|
||||
expect(preview.body.summary.blocker).toBeGreaterThan(0);
|
||||
// Name-conflict detection must not mutate unmanaged alternate compose files.
|
||||
expect(fs.readFileSync(path.join(stackDir, 'docker-compose.yml'), 'utf-8'))
|
||||
.toBe('services:\n app:\n image: nginx\n');
|
||||
});
|
||||
|
||||
it('blocks rename while a non-withdrawn deployment exists', async () => {
|
||||
|
||||
@@ -120,7 +120,15 @@ describe('BlueprintService remote deploy', () => {
|
||||
expect(postSpy.mock.calls[0][0]).toMatch(/\/api\/blueprints\/apply-local$/);
|
||||
expect(postSpy.mock.calls[1][0]).toMatch(/\/api\/stacks$/);
|
||||
expect(putSpy).toHaveBeenCalledTimes(2); // compose + marker
|
||||
const composePutUrl = String(putSpy.mock.calls[0][0]);
|
||||
const markerPutUrl = String(putSpy.mock.calls[1][0]);
|
||||
expect(composePutUrl).toMatch(/[?&]path=compose\.yaml(?:&|$)/);
|
||||
expect(markerPutUrl).toMatch(/[?&]path=\.blueprint\.json(?:&|$)/);
|
||||
expect(postSpy.mock.calls[2][0]).toMatch(/\/deploy$/);
|
||||
const [composePutOrder, markerPutOrder] = putSpy.mock.invocationCallOrder;
|
||||
const deployOrder = postSpy.mock.invocationCallOrder[2];
|
||||
expect(composePutOrder).toBeLessThan(markerPutOrder);
|
||||
expect(markerPutOrder).toBeLessThan(deployOrder);
|
||||
});
|
||||
|
||||
it('maps a remote apply lock-conflict (409) to status=failed', async () => {
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
|
||||
.all(res.body.snapshotId) as Array<{ stack_name: string; filename: string; content: string; node_id: number }>;
|
||||
expect(fileRows).toHaveLength(1);
|
||||
expect(fileRows[0].stack_name).toBe(bp.name);
|
||||
expect(fileRows[0].filename).toBe('docker-compose.yml');
|
||||
expect(fileRows[0].filename).toBe('compose.yaml');
|
||||
// Content is encrypted at rest; it decrypts back to the captured compose.
|
||||
const { CryptoService } = await import('../services/CryptoService');
|
||||
expect(CryptoService.getInstance().isEncrypted(fileRows[0].content)).toBe(true);
|
||||
|
||||
@@ -407,6 +407,7 @@ describe('BlueprintService per-stack lock', () => {
|
||||
// without touching the real filesystem or Docker.
|
||||
vi.spyOn(FileSystemService.prototype, 'createStack').mockResolvedValue(undefined);
|
||||
const writeSpy = vi.spyOn(FileSystemService.prototype, 'writeStackFile').mockResolvedValue(undefined);
|
||||
const cleanupSpy = vi.spyOn(FileSystemService.prototype, 'removeAlternateRootComposeFiles').mockResolvedValue(undefined);
|
||||
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(undefined);
|
||||
|
||||
const outcome = await BlueprintService.getInstance().deployToNode(bp, node);
|
||||
@@ -416,14 +417,19 @@ describe('BlueprintService per-stack lock', () => {
|
||||
source: 'blueprint',
|
||||
actor: 'system:blueprint',
|
||||
});
|
||||
// Compose is written first, then the marker, both before the deploy.
|
||||
// Compose is written first, then the marker, both before sibling cleanup and deploy.
|
||||
expect(writeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(writeSpy.mock.calls[0][1]).toBe('compose.yaml');
|
||||
expect(writeSpy.mock.calls[0][2]).toBe(bp.compose_content);
|
||||
expect(writeSpy.mock.calls[1][1]).toBe('.blueprint.json');
|
||||
expect(writeSpy.mock.calls[1][2]).toContain('"blueprintId"');
|
||||
expect(cleanupSpy).toHaveBeenCalledWith(bp.name);
|
||||
const [composeOrder, markerOrder] = writeSpy.mock.invocationCallOrder;
|
||||
const [cleanupOrder] = cleanupSpy.mock.invocationCallOrder;
|
||||
const [deployOrder] = deploySpy.mock.invocationCallOrder;
|
||||
expect(composeOrder).toBeLessThan(markerOrder);
|
||||
expect(markerOrder).toBeLessThan(deployOrder);
|
||||
expect(markerOrder).toBeLessThan(cleanupOrder);
|
||||
expect(cleanupOrder).toBeLessThan(deployOrder);
|
||||
});
|
||||
|
||||
it('deploy skips, writes no stack files, and records failed when the stack lock is held', async () => {
|
||||
|
||||
@@ -535,7 +535,7 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
stackName: blueprint.name,
|
||||
filename: 'docker-compose.yml',
|
||||
filename: 'compose.yaml',
|
||||
content: compose,
|
||||
}]);
|
||||
} catch (snapErr) {
|
||||
|
||||
@@ -21,7 +21,8 @@ import { BlueprintAnalyzer } from './BlueprintAnalyzer';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const MARKER_FILENAME = '.blueprint.json';
|
||||
const COMPOSE_FILENAME = 'docker-compose.yml';
|
||||
/** On-disk compose name for Blueprint applies. Must match createStack scaffold and Sencho discovery priority. */
|
||||
const COMPOSE_FILENAME = 'compose.yaml';
|
||||
const REMOTE_HTTP_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isDeveloperModeEnabled(): boolean {
|
||||
@@ -447,6 +448,9 @@ export class BlueprintService {
|
||||
}
|
||||
await fs.writeStackFile(stackName, COMPOSE_FILENAME, composeContent);
|
||||
await fs.writeStackFile(stackName, MARKER_FILENAME, markerContent);
|
||||
// Clear lower-priority compose siblings so discovery cannot shadow compose.yaml.
|
||||
// Local + modern apply-local only; legacy remote has no sibling DELETE.
|
||||
await fs.removeAlternateRootComposeFiles(stackName);
|
||||
await assertPolicyGateAllows(
|
||||
stackName,
|
||||
nodeId,
|
||||
|
||||
@@ -578,6 +578,34 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove non-canonical root Compose filenames so discovery cannot shadow
|
||||
* compose.yaml. Filename set is fixed inside this method. ENOENT is success;
|
||||
* other unlink failures are logged and skipped.
|
||||
*/
|
||||
async removeAlternateRootComposeFiles(stackName: string): Promise<void> {
|
||||
const stackDir = this.resolveStackDir(stackName);
|
||||
await this.assertRealWithinBase(stackDir);
|
||||
const baseResolved = path.resolve(this.baseDir);
|
||||
for (const file of IMPORT_COMPOSE_FILENAMES) {
|
||||
if (file === 'compose.yaml') continue;
|
||||
// Containment barrier at the unlink sink (same pattern as rollback orphan removal).
|
||||
const target = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!target.startsWith(baseResolved + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
try {
|
||||
await fsPromises.unlink(target);
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
||||
console.warn(
|
||||
`[FileSystemService] Could not remove alternate compose file ${sanitizeForLog(file)} in stack ${sanitizeForLog(stackName)}:`,
|
||||
sanitizeForLog((e as Error)?.message ?? String(e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteStack(stackName: string): Promise<void> {
|
||||
const stackDir = this.resolveStackDir(stackName);
|
||||
await this.assertRealWithinBase(stackDir);
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Blueprints"
|
||||
description: "Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose, with stateless or stateful classification, drift detection, and three response modes."
|
||||
---
|
||||
|
||||
A **Blueprint** bundles a `docker-compose.yml` with a node selector and a drift policy. Every minute Sencho compares the live state on each targeted node against the blueprint's desired revision, and either reports the drift, dispatches a notification, or auto-corrects, depending on the mode you picked. You declare a stack once; Sencho handles the distribution and the reconciliation loop.
|
||||
A **Blueprint** bundles Compose YAML with a node selector and a drift policy. Every minute Sencho compares the live state on each targeted node against the blueprint's desired revision, and either reports the drift, dispatches a notification, or auto-corrects, depending on the mode you picked. You declare a stack once; Sencho handles the distribution and the reconciliation loop.
|
||||
|
||||
Blueprints live under **Fleet · Deployments**.
|
||||
|
||||
@@ -49,7 +49,7 @@ Drift detection runs on every tick for every Active deployment regardless of pol
|
||||
|---|---|
|
||||
| User role | **Admin** to create, edit, withdraw, accept, and apply. Operators and viewers can read the catalog and the detail sheet. Pinning requires admin. |
|
||||
| Nodes | At least one node that the selector resolves to. Remote nodes need a healthy proxy connection; see [Multi-node management](/features/multi-node) and [Pilot Agent](/features/pilot-agent) for enrollment. |
|
||||
| Compose YAML | Valid `docker-compose.yml`, 96 KiB or fewer. |
|
||||
| Compose YAML | Valid Compose YAML, 96 KiB or fewer. |
|
||||
| Blueprint name | 1 to 64 characters matching `^[a-z0-9][a-z0-9_-]*$`. The name doubles as the stack directory on every targeted node. Rename is blocked while any non-withdrawn deployment or guard exists. |
|
||||
| Selector | A `labels` or `nodes` selector with up to 200 entries per side. An empty resolved set is allowed but produces no deployments. |
|
||||
| Compose directory | Per-node compose directory must be writable. Remote nodes must accept the controlling instance's bearer token; this is the same channel the rest of the fleet management already uses. |
|
||||
@@ -74,7 +74,7 @@ The first time you visit the tab, the catalog is empty and a three-step explaine
|
||||
|---|---|
|
||||
| **Name** | Used as the stack directory on every targeted node (`<COMPOSE_DIR>/<blueprint-name>/`). Lowercase letters, digits, hyphens, and underscores only. Rename is allowed only when every deployment is withdrawn (or there are none); live placements and guards block rename. |
|
||||
| **Description** | Short prose for the catalog tile and the detail header. |
|
||||
| **Compose** | Standard `docker-compose.yml`. The same file ships to every targeted node. Sencho parses it on save and classifies the blueprint as stateless, stateful, or unknown. The YAML must parse successfully and stay under 96 KiB. |
|
||||
| **Compose** | Standard Compose YAML. The same content ships to every targeted node as `compose.yaml`. Sencho parses it on save and classifies the blueprint as stateless, stateful, or unknown. The YAML must parse successfully and stay under 96 KiB. |
|
||||
| **Selector** | Either label expressions (any-of plus all-of) or a list of node IDs picked by hand. |
|
||||
| **Drift policy** | Observe, Suggest, or Enforce. Drift detection always runs; only the response differs. |
|
||||
| **Reconciler enabled** | Toggle the reconciliation loop without deleting the blueprint. The **Disable** button on the detail sheet flips this toggle off; **Enable** turns it back on. Useful when you want to pause auto-deploys without losing the configuration. |
|
||||
@@ -321,7 +321,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli
|
||||
Sencho accepts valid YAML up to 96 KiB. Split very large compose files into smaller blueprints or move generated content out of the Blueprint. Do not paste secrets into the compose body; use environment files or [Fleet Secrets](/features/fleet-secrets) where appropriate.
|
||||
</Accordion>
|
||||
<Accordion title="A remote node is offline or disconnected during apply">
|
||||
The row moves to **Failed** with the remote error. Reconnect the node (see [Pilot Agent](/features/pilot-agent) and [Multi-node management](/features/multi-node)), verify whether the stack directory contains `docker-compose.yml` and `.blueprint.json`, then click **Apply now**. If the directory exists without a matching marker, Sencho treats it as a name conflict until you rename or remove the remote stack manually.
|
||||
The row moves to **Failed** with the remote error. Reconnect the node (see [Pilot Agent](/features/pilot-agent) and [Multi-node management](/features/multi-node)), verify whether the stack directory contains `compose.yaml` and `.blueprint.json`, then click **Apply now**. If the directory exists without a matching marker, Sencho treats it as a name conflict until you rename or remove the remote stack manually.
|
||||
</Accordion>
|
||||
<Accordion title="A Docker daemon or registry failure surfaced during apply">
|
||||
The deployment row moves to **Failed** and records the Docker or registry error. Resolve the daemon, socket, registry credentials, rate limit, disk, or volume-permission issue on the affected node, then click **Apply now**. Watch that node's stack activity and security scan status after retry.
|
||||
|
||||
Reference in New Issue
Block a user