mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat: ordered multi-file Compose for Git sources (#1380)
* feat: ordered multi-file Compose for Git sources
Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.
- Pick and reorder compose files from the repository tree (drag to reorder on
desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
start/stop/restart/down, image scans, Compose Doctor) and the container
lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
source does not change deploy args until the pull is applied, and apply
materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
before, and existing rows keep working via the single-path fallback.
Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).
* fix: harden multi-file Git source (hash, unlink, collisions, node id)
- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
stack is not flagged as locally edited: create/apply hash the fetched files
(repo paths) while pull hashes the on-disk files (materialized paths), which
previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
spec lives on the source row, so removing it would silently revert deploys to
root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
file equal to or nested under compose.yaml, an ancestor/descendant overlap
between selected files, and a project directory nested under a compose file
(previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
and passes its node id to the authored prefix, instead of the process default.
* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)
- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
trim() is optional-chained, so a reusable field component tolerates partial
props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
picker's per-file "Remove <path>" buttons no longer collide with the broad
/remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
clearing the js/path-injection alert. The containment check is equivalent and
contextDir is also validated upstream.
* test: update Git source E2E spec for the multi-file compose picker
The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:
- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
"Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
Enter, then remove the default compose.yaml).
* test: match the footer Remove button with an exact Playwright name
Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
This commit is contained in:
@@ -16,6 +16,7 @@ import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { authoredComposeFileArgs } from '../utils/authoredComposeArgs';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export class ComposeRollbackError extends Error {
|
||||
@@ -86,14 +87,24 @@ export class ComposeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `docker compose` argument prefix for a stack, splicing in the
|
||||
* Sencho Mesh override file if the stack is opted into the mesh. When no
|
||||
* override applies, returns args without `-f` so docker compose's built-in
|
||||
* file discovery resolves the stack's actual compose filename. The user's
|
||||
* source compose file is never mutated.
|
||||
* Build the authored `docker compose` argument list for a stack: the validated
|
||||
* multi-file deploy prefix (ordered `-f` files + `-p <stackName>` +
|
||||
* `--project-directory`) for a Git source with an applied multi-file spec, then
|
||||
* the Sencho Mesh override file last (highest `-f` precedence) when the stack is
|
||||
* opted into the mesh, then the action. Single-file / non-git stacks get no file
|
||||
* prefix, so docker compose's built-in discovery resolves the root compose.yaml,
|
||||
* byte-identical to the pre-multi-file behavior. The user's source files are
|
||||
* never mutated. Lifecycle commands (deploy, update, stop/start/restart/down)
|
||||
* route through this method, so they share one file prefix plus the mesh override.
|
||||
* Image scans (listStackImages) and the Compose Doctor (renderConfig) reuse the
|
||||
* same `authoredComposeFileArgs` prefix directly but intentionally omit the mesh
|
||||
* override, rendering the user's authored model without mesh injection.
|
||||
*/
|
||||
private async composeArgs(stackName: string, action: string[]): Promise<string[]> {
|
||||
private async authoredComposeArgs(stackName: string, action: string[]): Promise<string[]> {
|
||||
const args: string[] = ['compose'];
|
||||
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
|
||||
args.push(...filePrefix);
|
||||
|
||||
let overridePath: string | null = null;
|
||||
try {
|
||||
overridePath = await MeshService.getInstance().ensureStackOverride(this.nodeId, stackName);
|
||||
@@ -101,8 +112,13 @@ export class ComposeService {
|
||||
console.warn('[ComposeService] mesh override skipped:', sanitizeForLog((err as Error).message));
|
||||
}
|
||||
if (overridePath) {
|
||||
const baseFilename = await FileSystemService.getInstance(this.nodeId).getComposeFilename(stackName);
|
||||
args.push('-f', baseFilename, '-f', overridePath);
|
||||
if (filePrefix.length === 0) {
|
||||
// Single-file stack: passing any -f disables auto-discovery, so name the
|
||||
// base file explicitly before layering the override on top of it.
|
||||
const baseFilename = await FileSystemService.getInstance(this.nodeId).getComposeFilename(stackName);
|
||||
args.push('-f', baseFilename);
|
||||
}
|
||||
args.push('-f', overridePath);
|
||||
}
|
||||
args.push(...action);
|
||||
return args;
|
||||
@@ -323,7 +339,7 @@ export class ComposeService {
|
||||
const fsSvc = FileSystemService.getInstance(this.nodeId);
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env);
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env);
|
||||
}, sendOutput);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
return true;
|
||||
@@ -342,7 +358,7 @@ export class ComposeService {
|
||||
|
||||
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
await this.execute('docker', ['compose', action], stackDir, ws);
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
@@ -371,7 +387,7 @@ export class ComposeService {
|
||||
}
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Deploy Health Probe
|
||||
@@ -548,10 +564,10 @@ export class ComposeService {
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Update Health Probe
|
||||
@@ -611,7 +627,7 @@ export class ComposeService {
|
||||
public async downStack(stackName: string): Promise<void> {
|
||||
const stackPath = path.join(this.baseDir, stackName);
|
||||
try {
|
||||
await this.execute('docker', ['compose', 'down', '--volumes', '--remove-orphans'], stackPath, undefined, false);
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['down', '--volumes', '--remove-orphans']), stackPath, undefined, false);
|
||||
} catch (error) {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${sanitizeForLog(stackName)}`);
|
||||
}
|
||||
@@ -634,7 +650,10 @@ export class ComposeService {
|
||||
if (!isPathWithinBase(stackDir, this.baseDir) || path.resolve(this.baseDir) === stackDir) {
|
||||
throw new Error('Invalid stack path');
|
||||
}
|
||||
const stdout = await this.captureCompose(['config', '--images'], stackDir);
|
||||
// Use the authored multi-file model (no mesh override) so override-only image
|
||||
// refs are scanned by the policy gate; single-file stacks get an empty prefix.
|
||||
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
|
||||
const stdout = await this.captureCompose([...filePrefix, 'config', '--images'], stackDir);
|
||||
const seen = new Set<string>();
|
||||
const images: string[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
@@ -705,7 +724,15 @@ export class ComposeService {
|
||||
if (!stackDir.startsWith(baseResolved + path.sep)) {
|
||||
return Promise.reject(new Error('Invalid stack path'));
|
||||
}
|
||||
const child = spawn('docker', ['compose', 'config', '--format', 'json'], {
|
||||
// Render the authored multi-file model (no mesh override) so the Compose Doctor
|
||||
// sees every override file; single-file stacks get an empty prefix.
|
||||
let filePrefix: string[];
|
||||
try {
|
||||
filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
|
||||
} catch (err) {
|
||||
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
const child = spawn('docker', ['compose', ...filePrefix, 'config', '--format', 'json'], {
|
||||
cwd: stackDir,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -221,12 +221,28 @@ export interface Webhook {
|
||||
|
||||
export type GitSourceAuthType = 'none' | 'token';
|
||||
|
||||
/**
|
||||
* The ordered set of local compose files actually materialized on disk for a
|
||||
* stack, plus the optional project directory. This is the deploy-time source of
|
||||
* truth: the deploy/lifecycle path, the mesh service, and the image-update path all
|
||||
* read it (never the configured `compose_paths`), so a saved-but-not-yet-applied config change does
|
||||
* not alter runtime until apply writes the files. `null` (the default) means a
|
||||
* plain single-file stack that uses docker compose auto-discovery, byte-identical
|
||||
* to pre-multi-file behavior.
|
||||
*/
|
||||
export interface GitSourceAppliedSpec {
|
||||
files: string[]; // ordered local relative compose filenames (files[0] is always compose.yaml)
|
||||
contextDir: string | null; // optional project dir within the stack, passed as --project-directory
|
||||
}
|
||||
|
||||
export interface StackGitSource {
|
||||
id?: number;
|
||||
stack_name: string;
|
||||
repo_url: string;
|
||||
branch: string;
|
||||
compose_path: string;
|
||||
compose_path: string; // primary repo path; kept in sync with compose_paths[0] for back-compat readers
|
||||
compose_paths: string[]; // ordered repo-relative compose paths; normalized to [compose_path] for legacy rows
|
||||
context_dir: string | null; // configured project dir within the repo
|
||||
sync_env: boolean;
|
||||
env_path: string | null;
|
||||
auth_type: GitSourceAuthType;
|
||||
@@ -240,6 +256,7 @@ export interface StackGitSource {
|
||||
pending_env_content: string | null;
|
||||
pending_fetched_at: number | null;
|
||||
last_debounce_at: number | null;
|
||||
applied_deploy_spec: GitSourceAppliedSpec | null; // deploy-time materialized file set; null = single-file auto-discovery
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -786,6 +803,7 @@ export class DatabaseService {
|
||||
this.migrateAutoHealNodeId();
|
||||
this.migrateFleetSyncStickyError();
|
||||
this.migrateStackDossierHashes();
|
||||
this.migrateGitSourceMultiFile();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -1195,6 +1213,8 @@ export class DatabaseService {
|
||||
repo_url TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
compose_path TEXT NOT NULL,
|
||||
compose_paths TEXT,
|
||||
context_dir TEXT,
|
||||
sync_env INTEGER NOT NULL DEFAULT 0,
|
||||
env_path TEXT,
|
||||
auth_type TEXT NOT NULL DEFAULT 'none',
|
||||
@@ -1208,6 +1228,7 @@ export class DatabaseService {
|
||||
pending_env_content TEXT,
|
||||
pending_fetched_at INTEGER,
|
||||
last_debounce_at INTEGER,
|
||||
applied_deploy_spec TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -1649,6 +1670,21 @@ export class DatabaseService {
|
||||
this.tryAddColumn('stack_dossiers', 'rendered_hash', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateGitSourceMultiFile(): void {
|
||||
this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'context_dir', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'applied_deploy_spec', 'TEXT');
|
||||
// Backfill legacy rows so compose_paths is always a JSON array. parseGitSource
|
||||
// also normalizes on read, so this is belt-and-suspenders for any direct readers.
|
||||
try {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET compose_paths = json_array(compose_path) WHERE compose_paths IS NULL`
|
||||
).run();
|
||||
} catch (e) {
|
||||
console.warn('[DatabaseService] git-source compose_paths backfill skipped:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private migrateScanPolicyFleetColumns(): void {
|
||||
this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''");
|
||||
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
|
||||
@@ -3736,14 +3772,51 @@ export class DatabaseService {
|
||||
|
||||
// --- Stack Git Sources ---
|
||||
|
||||
/**
|
||||
* Normalize the stored compose_paths column to a non-empty ordered array.
|
||||
* Legacy rows (column null), empty arrays, or malformed JSON all fall back
|
||||
* to the single compose_path so every consumer can rely on a usable list.
|
||||
*/
|
||||
private normalizeComposePaths(raw: unknown, composePath: string): string[] {
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const paths = parsed.filter((p): p is string => typeof p === 'string' && p.trim().length > 0);
|
||||
if (paths.length > 0) return paths;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the single-path fallback
|
||||
}
|
||||
}
|
||||
return [composePath];
|
||||
}
|
||||
|
||||
private parseAppliedDeploySpec(raw: unknown): GitSourceAppliedSpec | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<GitSourceAppliedSpec>;
|
||||
if (!parsed || !Array.isArray(parsed.files)) return null;
|
||||
const files = parsed.files.filter((f): f is string => typeof f === 'string' && f.trim().length > 0);
|
||||
if (files.length === 0) return null;
|
||||
return { files, contextDir: typeof parsed.contextDir === 'string' ? parsed.contextDir : null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseGitSource(row: Record<string, unknown> | undefined): StackGitSource | undefined {
|
||||
if (!row) return undefined;
|
||||
const composePath = row.compose_path as string;
|
||||
return {
|
||||
id: row.id as number,
|
||||
stack_name: row.stack_name as string,
|
||||
repo_url: row.repo_url as string,
|
||||
branch: row.branch as string,
|
||||
compose_path: row.compose_path as string,
|
||||
compose_path: composePath,
|
||||
compose_paths: this.normalizeComposePaths(row.compose_paths, composePath),
|
||||
context_dir: (row.context_dir as string | null) ?? null,
|
||||
applied_deploy_spec: this.parseAppliedDeploySpec(row.applied_deploy_spec),
|
||||
sync_env: Number(row.sync_env) === 1,
|
||||
env_path: (row.env_path as string | null) ?? null,
|
||||
auth_type: row.auth_type as GitSourceAuthType,
|
||||
@@ -3772,19 +3845,21 @@ export class DatabaseService {
|
||||
return rows.map(r => this.parseGitSource(r)!);
|
||||
}
|
||||
|
||||
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at'>): number {
|
||||
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at' | 'applied_deploy_spec'>): number {
|
||||
const now = Date.now();
|
||||
const existing = this.getGitSource(source.stack_name);
|
||||
const composePathsJson = JSON.stringify(source.compose_paths ?? [source.compose_path]);
|
||||
if (existing) {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET
|
||||
repo_url = ?, branch = ?, compose_path = ?, sync_env = ?, env_path = ?,
|
||||
repo_url = ?, branch = ?, compose_path = ?, compose_paths = ?, context_dir = ?,
|
||||
sync_env = ?, env_path = ?,
|
||||
auth_type = ?, encrypted_token = ?,
|
||||
auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(
|
||||
source.repo_url, source.branch, source.compose_path,
|
||||
source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
||||
source.sync_env ? 1 : 0, source.env_path,
|
||||
source.auth_type, source.encrypted_token,
|
||||
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||
@@ -3794,12 +3869,12 @@ export class DatabaseService {
|
||||
}
|
||||
const result = this.db.prepare(
|
||||
`INSERT INTO stack_git_sources
|
||||
(stack_name, repo_url, branch, compose_path, sync_env, env_path,
|
||||
(stack_name, repo_url, branch, compose_path, compose_paths, context_dir, sync_env, env_path,
|
||||
auth_type, encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
source.stack_name, source.repo_url, source.branch, source.compose_path,
|
||||
source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
||||
source.sync_env ? 1 : 0, source.env_path,
|
||||
source.auth_type, source.encrypted_token,
|
||||
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||
@@ -3808,6 +3883,18 @@ export class DatabaseService {
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (or clear) the deploy-time materialized compose file set. Called by
|
||||
* the Git apply/create paths after files land on disk; the deploy/lifecycle
|
||||
* path, the mesh service, and the image-update path read it back via
|
||||
* getGitSource(). Passing null resets the stack to single-file auto-discovery.
|
||||
*/
|
||||
public setGitSourceAppliedSpec(stackName: string, spec: GitSourceAppliedSpec | null): void {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET applied_deploy_spec = ?, updated_at = ? WHERE stack_name = ?`
|
||||
).run(spec ? JSON.stringify(spec) : null, Date.now(), stackName);
|
||||
}
|
||||
|
||||
public deleteGitSource(stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_git_sources WHERE stack_name = ?').run(stackName);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Docker from 'dockerode';
|
||||
import WebSocket from 'ws';
|
||||
import { exec } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
@@ -13,8 +13,9 @@ import { isPathWithinBase } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { authoredComposeFileArgs } from '../utils/authoredComposeArgs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
||||
|
||||
/** Canonical compose file name variants, checked in priority order. */
|
||||
@@ -1277,16 +1278,28 @@ class DockerController {
|
||||
}
|
||||
|
||||
public async getContainersByStack(stackName: string) {
|
||||
const stackDir = path.join(COMPOSE_DIR, stackName);
|
||||
// Resolve the compose dir and the authored prefix for THIS controller's node,
|
||||
// not the process default, so a non-default local node sees its own stack dir
|
||||
// and deploy spec.
|
||||
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('docker compose ps --format json -a', {
|
||||
cwd: stackDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
// Splice the authored multi-file prefix (-f files + -p + --project-directory)
|
||||
// so a Git stack's override-only services are listed; single-file stacks get an
|
||||
// empty prefix and behave exactly as before. execFile avoids shell quoting on
|
||||
// the absolute --project-directory path.
|
||||
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
'docker',
|
||||
['compose', ...filePrefix, 'ps', '--format', 'json', '-a'],
|
||||
{
|
||||
cwd: stackDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// Robust JSON parsing - handle both JSON array and newline-separated JSON objects
|
||||
// Docker Compose v2 may return either format depending on version
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './DatabaseService';
|
||||
import { DatabaseService, type StackGitSource, type GitSourceAuthType, type GitSourceAppliedSpec } from './DatabaseService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { HealthGateService } from './HealthGateService';
|
||||
@@ -13,7 +13,8 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { gitSourceLocalComposeFiles, PRIMARY_COMPOSE_FILENAME } from '../utils/gitComposeFiles';
|
||||
import type { GitHttpRequest, GitHttpResponse, HttpClient } from 'isomorphic-git/http/node';
|
||||
|
||||
// isomorphic-git is the heaviest dependency in the backend (~5 MB) and only
|
||||
@@ -170,17 +171,23 @@ export class GitSourceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A single compose file fetched from a repo, keyed by its repo-relative path. */
|
||||
export interface ComposeFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface FetchParams {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composePath: string;
|
||||
composePaths: string[];
|
||||
envPath?: string | null;
|
||||
token?: string | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FetchResult {
|
||||
composeContent: string;
|
||||
composeFiles: ComposeFile[];
|
||||
envContent: string | null;
|
||||
commitSha: string;
|
||||
/**
|
||||
@@ -195,7 +202,8 @@ export interface UpsertInput {
|
||||
stackName: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composePath: string;
|
||||
composePaths: string[];
|
||||
contextDir: string | null;
|
||||
syncEnv: boolean;
|
||||
envPath: string | null;
|
||||
authType: GitSourceAuthType;
|
||||
@@ -208,7 +216,8 @@ export interface CreateStackFromGitInput {
|
||||
stackName: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composePath: string;
|
||||
composePaths: string[];
|
||||
contextDir: string | null;
|
||||
syncEnv: boolean;
|
||||
envPath: string | null;
|
||||
authType: GitSourceAuthType;
|
||||
@@ -240,6 +249,8 @@ export interface PublicGitSource {
|
||||
repo_url: string;
|
||||
branch: string;
|
||||
compose_path: string;
|
||||
compose_paths: string[];
|
||||
context_dir: string | null;
|
||||
sync_env: boolean;
|
||||
env_path: string | null;
|
||||
auth_type: GitSourceAuthType;
|
||||
@@ -536,6 +547,8 @@ export class GitSourceService {
|
||||
repo_url: src.repo_url,
|
||||
branch: src.branch,
|
||||
compose_path: src.compose_path,
|
||||
compose_paths: src.compose_paths,
|
||||
context_dir: src.context_dir,
|
||||
sync_env: src.sync_env,
|
||||
env_path: src.env_path,
|
||||
auth_type: src.auth_type,
|
||||
@@ -583,23 +596,39 @@ export class GitSourceService {
|
||||
throw new GitSourceError('GIT_ERROR', 'Auto-deploy requires auto-apply-on-webhook to be enabled.');
|
||||
}
|
||||
|
||||
// Dry-run reachability check before persisting.
|
||||
// Dry-run reachability check before persisting. Fetches every configured
|
||||
// file so a bad path in the ordered list is caught at save time.
|
||||
const token = encryptedToken ? this.crypto.decrypt(encryptedToken) : null;
|
||||
await this.fetchFromGit({
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch,
|
||||
composePath: input.composePath,
|
||||
composePaths: input.composePaths,
|
||||
envPath: input.syncEnv ? input.envPath : null,
|
||||
token,
|
||||
});
|
||||
|
||||
const resolvedEnvPath = input.syncEnv ? input.envPath : null;
|
||||
// A pending pull captured the files/contextDir for the previous config. If
|
||||
// any of those change, that pending blob would apply the wrong files, so
|
||||
// clear it; the user re-pulls against the new config.
|
||||
const configChanged = !!existing && (
|
||||
existing.repo_url !== input.repoUrl ||
|
||||
existing.branch !== input.branch ||
|
||||
JSON.stringify(existing.compose_paths) !== JSON.stringify(input.composePaths) ||
|
||||
existing.sync_env !== input.syncEnv ||
|
||||
(existing.env_path ?? null) !== resolvedEnvPath ||
|
||||
(existing.context_dir ?? null) !== input.contextDir
|
||||
);
|
||||
|
||||
db.upsertGitSource({
|
||||
stack_name: input.stackName,
|
||||
repo_url: input.repoUrl,
|
||||
branch: input.branch,
|
||||
compose_path: input.composePath,
|
||||
compose_path: input.composePaths[0],
|
||||
compose_paths: input.composePaths,
|
||||
context_dir: input.contextDir,
|
||||
sync_env: input.syncEnv,
|
||||
env_path: input.syncEnv ? input.envPath : null,
|
||||
env_path: resolvedEnvPath,
|
||||
auth_type: input.authType,
|
||||
encrypted_token: encryptedToken,
|
||||
auto_apply_on_webhook: input.autoApplyOnWebhook,
|
||||
@@ -613,6 +642,10 @@ export class GitSourceService {
|
||||
last_debounce_at: existing?.last_debounce_at ?? null,
|
||||
});
|
||||
|
||||
if (configChanged) {
|
||||
db.clearGitSourcePending(input.stackName);
|
||||
}
|
||||
|
||||
return this.get(input.stackName)!;
|
||||
}
|
||||
|
||||
@@ -622,29 +655,23 @@ export class GitSourceService {
|
||||
|
||||
// ─── Fetch ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
|
||||
const { repoUrl, branch, composePath, envPath, token } = params;
|
||||
/**
|
||||
* Clone a repo into a throwaway temp dir, run `fn` against the checkout, and
|
||||
* always clean up. Centralizes the clone timeout, size cap, commit-sha read,
|
||||
* and submodule warning so both fetchFromGit (reads compose/env files) and
|
||||
* listRepoTree (lists the working tree) share one hardened clone path.
|
||||
*/
|
||||
private async withClonedRepo<T>(
|
||||
params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number },
|
||||
fn: (dir: string, commitSha: string, warnings: string[]) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { repoUrl, branch, token } = params;
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
|
||||
// Reject any compose/env target that resolves inside the `.git`
|
||||
// metadata directory BEFORE we spin up a clone. This blocks a
|
||||
// caller from reading `.git/config` (which leaks the remote URL
|
||||
// and any mis-configured inline credentials) via the fetch path.
|
||||
assertNotGitMeta(composePath, 'compose_path');
|
||||
if (envPath) assertNotGitMeta(envPath, 'env_path');
|
||||
|
||||
const dir = await createTempDir();
|
||||
const startedAt = Date.now();
|
||||
const diag = isDebugEnabled();
|
||||
if (diag) {
|
||||
console.log(
|
||||
`[GitSource:diag] fetch start host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} compose=${sanitizeForLog(composePath)} envSync=${envPath ? 'true' : 'false'} timeoutMs=${timeoutMs}`
|
||||
);
|
||||
}
|
||||
|
||||
// isomorphic-git's onAuth callback hands credentials to the HTTP
|
||||
// layer without them touching the URL string, which keeps tokens
|
||||
// out of any error messages generated during the clone.
|
||||
// isomorphic-git's onAuth callback hands credentials to the HTTP layer
|
||||
// without them touching the URL string, keeping tokens out of any error
|
||||
// messages generated during the clone.
|
||||
const onAuth = token
|
||||
? () => ({ username: 'x-access-token', password: token })
|
||||
: undefined;
|
||||
@@ -698,38 +725,6 @@ export class GitSourceService {
|
||||
}
|
||||
const commitSha = log[0].oid;
|
||||
|
||||
const composeContent = await readRepoFile(dir, composePath, 'Compose path');
|
||||
if (isLfsPointer(composeContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
);
|
||||
}
|
||||
|
||||
let envContent: string | null = null;
|
||||
if (envPath) {
|
||||
try {
|
||||
envContent = await readRepoFile(dir, envPath, 'Env path');
|
||||
} catch (e) {
|
||||
if (e instanceof GitSourceError && e.code === 'FILE_NOT_FOUND' && e.message.startsWith('File not found')) {
|
||||
// A missing sibling .env is legitimate (repo may not carry one
|
||||
// in the requested directory). Return null so the caller can
|
||||
// decide whether to warn.
|
||||
envContent = null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (envContent !== null && isLfsPointer(envContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(envPath)}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Submodule detection: non-fatal, surfaced as a warning. isomorphic-git
|
||||
// does not recursively clone submodules, so any path that lives inside
|
||||
// a submodule directory will be empty after apply. Users need to know.
|
||||
@@ -739,12 +734,75 @@ export class GitSourceService {
|
||||
warnings.push(SUBMODULE_WARNING);
|
||||
}
|
||||
|
||||
if (diag) {
|
||||
console.log(
|
||||
`[GitSource:diag] fetch ok host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
|
||||
);
|
||||
}
|
||||
return { composeContent, envContent, commitSha, warnings };
|
||||
return await fn(dir, commitSha, warnings);
|
||||
} finally {
|
||||
await removeTempDir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
|
||||
const { repoUrl, branch, composePaths, envPath, token } = params;
|
||||
|
||||
// Reject any compose/env target that resolves inside the `.git`
|
||||
// metadata directory BEFORE we spin up a clone. This blocks a
|
||||
// caller from reading `.git/config` (which leaks the remote URL
|
||||
// and any mis-configured inline credentials) via the fetch path.
|
||||
for (const composePath of composePaths) assertNotGitMeta(composePath, 'compose_path');
|
||||
if (envPath) assertNotGitMeta(envPath, 'env_path');
|
||||
|
||||
const startedAt = Date.now();
|
||||
const diag = isDebugEnabled();
|
||||
if (diag) {
|
||||
console.log(
|
||||
`[GitSource:diag] fetch start host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} files=${composePaths.length} envSync=${envPath ? 'true' : 'false'}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.withClonedRepo({ repoUrl, branch, token, timeoutMs: params.timeoutMs }, async (dir, commitSha, warnings) => {
|
||||
const composeFiles: ComposeFile[] = [];
|
||||
for (const composePath of composePaths) {
|
||||
const content = await readRepoFile(dir, composePath, 'Compose path');
|
||||
if (isLfsPointer(content)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
);
|
||||
}
|
||||
composeFiles.push({ path: composePath, content });
|
||||
}
|
||||
|
||||
let envContent: string | null = null;
|
||||
if (envPath) {
|
||||
try {
|
||||
envContent = await readRepoFile(dir, envPath, 'Env path');
|
||||
} catch (e) {
|
||||
if (e instanceof GitSourceError && e.code === 'FILE_NOT_FOUND' && e.message.startsWith('File not found')) {
|
||||
// A missing sibling .env is legitimate (repo may not carry one
|
||||
// in the requested directory). Return null so the caller can
|
||||
// decide whether to warn.
|
||||
envContent = null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (envContent !== null && isLfsPointer(envContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(envPath)}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (diag) {
|
||||
console.log(
|
||||
`[GitSource:diag] fetch ok host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} sha=${commitSha.slice(0, 7)} files=${composeFiles.length} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
|
||||
);
|
||||
}
|
||||
return { composeFiles, envContent, commitSha, warnings };
|
||||
});
|
||||
} catch (err) {
|
||||
if (diag) {
|
||||
const msg = err instanceof GitSourceError ? `${err.code}: ${err.message}` : (err as Error).message;
|
||||
@@ -753,11 +811,56 @@ export class GitSourceService {
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await removeTempDir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a repo and list its working-tree files (POSIX-relative, `.git`
|
||||
* skipped) for the "browse repository" compose-file picker. Bounded by the
|
||||
* same clone size/timeout guards as fetch, plus a file-count cap.
|
||||
*/
|
||||
public async listRepoTree(
|
||||
params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number },
|
||||
): Promise<{ files: string[]; truncated: boolean; commitSha: string; warnings: string[] }> {
|
||||
return this.withClonedRepo(params, async (dir, commitSha, warnings) => {
|
||||
const { files, truncated } = await this.walkRepoFiles(dir);
|
||||
return { files, truncated, commitSha, warnings };
|
||||
});
|
||||
}
|
||||
|
||||
private async walkRepoFiles(rootDir: string): Promise<{ files: string[]; truncated: boolean }> {
|
||||
const MAX_FILES = 2000;
|
||||
const files: string[] = [];
|
||||
let truncated = false;
|
||||
const walk = async (relDir: string): Promise<void> => {
|
||||
if (truncated) return;
|
||||
let entries: import('fs').Dirent[];
|
||||
try {
|
||||
entries = await fsPromises.readdir(path.join(rootDir, relDir), { withFileTypes: true });
|
||||
} catch (e) {
|
||||
// A readdir failure on a just-cloned subtree silently drops it from the
|
||||
// picker; log so a partial listing is traceable (the user can still
|
||||
// add paths manually).
|
||||
console.warn(`[GitSource] repo walk skipped ${sanitizeForLog(relDir || '.')}:`, (e as Error).message);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (truncated) return;
|
||||
if (entry.name === '.git') continue;
|
||||
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(rel);
|
||||
} else if (entry.isFile()) {
|
||||
if (files.length >= MAX_FILES) { truncated = true; return; }
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk('');
|
||||
files.sort();
|
||||
return { files, truncated };
|
||||
}
|
||||
|
||||
private mapGitError(err: Error, hasToken: boolean, host = 'unknown'): GitSourceError {
|
||||
const raw = scrubCredentials(err.message || String(err));
|
||||
const code = (err as Error & { code?: string }).code;
|
||||
@@ -833,26 +936,55 @@ export class GitSourceService {
|
||||
* errors, invalid `include:` references, etc., which a shallow schema
|
||||
* check would miss.
|
||||
*/
|
||||
public async validateCompose(composeContent: string, envContent: string | null): Promise<{ ok: boolean; error?: string }> {
|
||||
// Cheap syntax pre-check
|
||||
try {
|
||||
const parsed = YAML.parse(composeContent);
|
||||
if (parsed === null || parsed === undefined) {
|
||||
return { ok: false, error: 'Compose file is empty.' };
|
||||
public async validateCompose(composeFiles: ComposeFile[], envContent: string | null, contextDir: string | null): Promise<{ ok: boolean; error?: string }> {
|
||||
if (composeFiles.length === 0) return { ok: false, error: 'No compose files provided.' };
|
||||
|
||||
// Cheap syntax pre-check per file
|
||||
for (const file of composeFiles) {
|
||||
try {
|
||||
const parsed = YAML.parse(file.content);
|
||||
if (parsed === null || parsed === undefined) {
|
||||
return { ok: false, error: `Compose file ${file.path} is empty.` };
|
||||
}
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { ok: false, error: `Compose file ${file.path} must be a YAML mapping.` };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: `YAML parse error in ${file.path}: ${(e as Error).message}` };
|
||||
}
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'Compose file must be a YAML mapping.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: `YAML parse error: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// Semantic check via `docker compose config`
|
||||
// Semantic check via `docker compose config` over the ordered set, written
|
||||
// in the same local layout the deploy materializes (primary -> compose.yaml,
|
||||
// additional files under their repo-relative paths), with each path segment
|
||||
// re-sanitized for this throwaway dir. So the merge order, project directory,
|
||||
// and relative cross-references resolve from the same base the real deploy uses.
|
||||
const dir = await createTempDir();
|
||||
try {
|
||||
const composeFile = path.join(dir, 'compose.yaml');
|
||||
await fsPromises.writeFile(composeFile, composeContent, 'utf-8');
|
||||
const args = ['compose', '-f', composeFile];
|
||||
const localFiles = gitSourceLocalComposeFiles(composeFiles.map(f => f.path));
|
||||
const args = ['compose'];
|
||||
for (let i = 0; i < composeFiles.length; i++) {
|
||||
const safeRel = localFiles[i].replace(/\\/g, '/').split('/').map(s => path.basename(s)).join('/');
|
||||
const abs = path.resolve(dir, safeRel);
|
||||
if (!isPathWithinBase(abs, dir)) {
|
||||
return { ok: false, error: `Compose path escapes the validation dir: ${composeFiles[i].path}` };
|
||||
}
|
||||
await fsPromises.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fsPromises.writeFile(abs, composeFiles[i].content, 'utf-8');
|
||||
args.push('-f', safeRel);
|
||||
}
|
||||
if (contextDir) {
|
||||
// Inline path-injection barrier at the mkdir sink. CodeQL does not
|
||||
// credit the wrapped isPathWithinBase helper, so resolve against a
|
||||
// known-safe base and check containment with startsWith right here.
|
||||
const baseResolved = path.resolve(dir);
|
||||
const ctxAbs = path.resolve(baseResolved, contextDir);
|
||||
if (!ctxAbs.startsWith(baseResolved + path.sep)) {
|
||||
return { ok: false, error: 'Context directory escapes the validation dir.' };
|
||||
}
|
||||
await fsPromises.mkdir(ctxAbs, { recursive: true });
|
||||
args.push('--project-directory', ctxAbs);
|
||||
}
|
||||
if (envContent !== null) {
|
||||
const envFile = path.join(dir, '.env');
|
||||
await fsPromises.writeFile(envFile, envContent, 'utf-8');
|
||||
@@ -891,21 +1023,90 @@ export class GitSourceService {
|
||||
|
||||
// ─── Hashing + diff ──────────────────────────────────────────────────────
|
||||
|
||||
public hashContent(compose: string, env: string | null): string {
|
||||
return crypto.createHash('sha256')
|
||||
.update(compose)
|
||||
.update('\x00')
|
||||
.update(env ?? '')
|
||||
.digest('hex');
|
||||
public hashContent(files: ComposeFile[], env: string | null): string {
|
||||
// Hash the ordered file CONTENTS (NUL-separated) plus env. Paths are
|
||||
// deliberately excluded: create/apply hash the fetched files (repo paths)
|
||||
// while pull hashes the on-disk files (materialized paths, primary ->
|
||||
// compose.yaml), so including paths would make the two disagree and flag a
|
||||
// clean multi-file stack as locally edited. Content order already changes
|
||||
// the hash on reorder, and a reorder is a config change that re-applies.
|
||||
// Single-file keeps the legacy (content + env) shape, byte-stable on upgrade.
|
||||
const h = crypto.createHash('sha256');
|
||||
if (files.length === 1) {
|
||||
h.update(files[0].content);
|
||||
} else {
|
||||
for (const f of files) {
|
||||
h.update(f.content);
|
||||
h.update('\x00');
|
||||
}
|
||||
}
|
||||
h.update('\x00');
|
||||
h.update(env ?? '');
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
private async readDiskContent(stackName: string, syncEnv: boolean): Promise<{ compose: string; env: string | null }> {
|
||||
/** Combine an ordered file set into a single path-headed preview for the diff UI. */
|
||||
private combinedComposePreview(files: ComposeFile[]): string {
|
||||
if (files.length <= 1) return files[0]?.content ?? '';
|
||||
return files.map(f => `# ── ${f.path} ──\n${f.content}`).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The deploy-time spec for an ordered file set. Single-file stacks with no
|
||||
* context dir get `null`, so runtime stays plain `docker compose` auto-discovery.
|
||||
*/
|
||||
private deriveAppliedSpec(composePaths: string[], contextDir: string | null): GitSourceAppliedSpec | null {
|
||||
if (composePaths.length <= 1 && !contextDir) return null;
|
||||
return { files: gitSourceLocalComposeFiles(composePaths), contextDir: contextDir ?? null };
|
||||
}
|
||||
|
||||
/** Encrypt the ordered compose file set as the v2 pending blob (carries contextDir). */
|
||||
private encodePendingCompose(files: ComposeFile[], contextDir: string | null): string {
|
||||
return this.crypto.encrypt(JSON.stringify({ v: 2, files, contextDir }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored pending compose blob into its ordered file set + contextDir.
|
||||
* Detects the v2 marker; anything else is a legacy single-file plaintext string.
|
||||
*/
|
||||
private decodePendingCompose(stored: string): { files: ComposeFile[]; contextDir: string | null } {
|
||||
const raw = this.crypto.decrypt(stored);
|
||||
if (raw.startsWith('{"v":2')) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { v: number; files?: ComposeFile[]; contextDir?: string | null };
|
||||
if (Array.isArray(parsed.files) && parsed.files.length > 0) {
|
||||
return { files: parsed.files, contextDir: parsed.contextDir ?? null };
|
||||
}
|
||||
} catch (e) {
|
||||
// The v2 marker proves this was written as multi-file, so a parse
|
||||
// failure signals a corrupt pending blob, not a legacy row. Log it
|
||||
// so the misleading downstream validation error is traceable; the
|
||||
// re-validate in apply still blocks deploying the garbled content.
|
||||
console.error('[GitSource] pending compose blob carried the v2 marker but failed to parse; treating as legacy:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
return { files: [{ path: PRIMARY_COMPOSE_FILENAME, content: raw }], contextDir: null };
|
||||
}
|
||||
|
||||
private async readDiskContent(stackName: string, syncEnv: boolean, relFiles: string[]): Promise<{ files: ComposeFile[]; env: string | null }> {
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
let compose: string;
|
||||
try {
|
||||
compose = await fsSvc.getStackContent(stackName);
|
||||
} catch {
|
||||
compose = '';
|
||||
const files: ComposeFile[] = [];
|
||||
for (let i = 0; i < relFiles.length; i++) {
|
||||
const rel = relFiles[i];
|
||||
try {
|
||||
// The primary uses compose discovery (compose.yaml / docker-compose.yml);
|
||||
// additional files are read at their materialized relative path.
|
||||
const content = i === 0
|
||||
? await fsSvc.getStackContent(stackName)
|
||||
: (await fsSvc.readStackFile(stackName, rel)).content ?? '';
|
||||
files.push({ path: rel, content });
|
||||
} catch (e) {
|
||||
// Empty-on-error is a defensible default (a prior-spec file may have
|
||||
// been removed by a concurrent edit), but log it so an unexpected
|
||||
// "local changes detected" can be traced to an unreadable file.
|
||||
console.warn(`[GitSource] could not read ${sanitizeForLog(rel)} for ${sanitizeForLog(stackName)} diff:`, (e as Error).message);
|
||||
files.push({ path: rel, content: '' });
|
||||
}
|
||||
}
|
||||
let env: string | null = null;
|
||||
if (syncEnv) {
|
||||
@@ -915,7 +1116,57 @@ export class GitSourceService {
|
||||
env = null;
|
||||
}
|
||||
}
|
||||
return { compose, env };
|
||||
return { files, env };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an ordered compose file set to a stack on disk: the primary to the
|
||||
* root compose.yaml, each additional file to its repo-relative path. Creates
|
||||
* the context dir when set, writes the env file when syncing, removes files
|
||||
* that the previous applied spec materialized but the new set drops, and
|
||||
* returns the deploy spec to persist.
|
||||
*/
|
||||
private async materialize(
|
||||
stackName: string,
|
||||
composeFiles: ComposeFile[],
|
||||
contextDir: string | null,
|
||||
syncEnv: boolean,
|
||||
envContent: string | null,
|
||||
prevSpec: GitSourceAppliedSpec | null,
|
||||
): Promise<GitSourceAppliedSpec | null> {
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
const localFiles = gitSourceLocalComposeFiles(composeFiles.map(f => f.path));
|
||||
|
||||
await fsSvc.saveStackContent(stackName, composeFiles[0].content);
|
||||
for (let i = 1; i < composeFiles.length; i++) {
|
||||
await fsSvc.writeStackFile(stackName, localFiles[i], composeFiles[i].content);
|
||||
}
|
||||
|
||||
if (contextDir) {
|
||||
await fsSvc.mkdirStackPath(stackName, contextDir);
|
||||
}
|
||||
|
||||
if (syncEnv && envContent !== null) {
|
||||
await fsSvc.saveEnvContent(stackName, envContent);
|
||||
}
|
||||
|
||||
// Stale cleanup: remove additional files the previous apply wrote that are
|
||||
// no longer in the set. Re-validate each as a safe relative path and never
|
||||
// touch the primary compose.yaml.
|
||||
if (prevSpec) {
|
||||
const keep = new Set(localFiles);
|
||||
for (const old of prevSpec.files) {
|
||||
if (old === PRIMARY_COMPOSE_FILENAME || keep.has(old)) continue;
|
||||
if (!isValidRelativeStackPath(old) || old === '') continue;
|
||||
try {
|
||||
await fsSvc.deleteStackPath(stackName, old);
|
||||
} catch (e) {
|
||||
console.warn(`[GitSource] stale file cleanup skipped ${sanitizeForLog(old)} for ${sanitizeForLog(stackName)}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.deriveAppliedSpec(composeFiles.map(f => f.path), contextDir);
|
||||
}
|
||||
|
||||
// ─── Pull / apply ────────────────────────────────────────────────────────
|
||||
@@ -949,24 +1200,25 @@ export class GitSourceService {
|
||||
const fetched = await this.fetchFromGit({
|
||||
repoUrl: src.repo_url,
|
||||
branch: src.branch,
|
||||
composePath: src.compose_path,
|
||||
composePaths: src.compose_paths,
|
||||
envPath: src.sync_env ? src.env_path : null,
|
||||
token,
|
||||
});
|
||||
|
||||
const validation = await this.validateCompose(fetched.composeContent, fetched.envContent);
|
||||
const disk = await this.readDiskContent(stackName, src.sync_env);
|
||||
const currentHash = this.hashContent(disk.compose, disk.env);
|
||||
const validation = await this.validateCompose(fetched.composeFiles, fetched.envContent, src.context_dir);
|
||||
const appliedFiles = src.applied_deploy_spec?.files ?? [PRIMARY_COMPOSE_FILENAME];
|
||||
const disk = await this.readDiskContent(stackName, src.sync_env, appliedFiles);
|
||||
const currentHash = this.hashContent(disk.files, disk.env);
|
||||
const hasLocalChanges = src.last_applied_content_hash !== null
|
||||
&& src.last_applied_content_hash !== currentHash;
|
||||
|
||||
// Store pending so a subsequent apply doesn't re-fetch. Compose files
|
||||
// routinely contain secrets inlined as env interpolations or passwords,
|
||||
// so encrypt the pending buffers at rest.
|
||||
// so the v2 blob (ordered files + contextDir) is encrypted at rest.
|
||||
db.setGitSourcePending(
|
||||
stackName,
|
||||
fetched.commitSha,
|
||||
this.crypto.encrypt(fetched.composeContent),
|
||||
this.encodePendingCompose(fetched.composeFiles, src.context_dir),
|
||||
fetched.envContent !== null ? this.crypto.encrypt(fetched.envContent) : null,
|
||||
);
|
||||
|
||||
@@ -977,9 +1229,9 @@ export class GitSourceService {
|
||||
|
||||
return {
|
||||
commitSha: fetched.commitSha,
|
||||
incomingCompose: fetched.composeContent,
|
||||
incomingCompose: this.combinedComposePreview(fetched.composeFiles),
|
||||
incomingEnv: fetched.envContent,
|
||||
currentCompose: disk.compose,
|
||||
currentCompose: this.combinedComposePreview(disk.files),
|
||||
currentEnv: disk.env,
|
||||
validation,
|
||||
hasLocalChanges,
|
||||
@@ -1025,28 +1277,29 @@ export class GitSourceService {
|
||||
throw new GitSourceError('GIT_ERROR', 'Pending commit has changed since this pull was fetched. Please review the latest diff.');
|
||||
}
|
||||
|
||||
// Pending buffers are stored encrypted; decrypt is a no-op for any
|
||||
// legacy plaintext rows (isEncrypted check inside CryptoService).
|
||||
const composeContent = this.crypto.decrypt(src.pending_compose_content);
|
||||
// Materialize from the pending blob (its files + contextDir), never the
|
||||
// live config: a config edit between pull and apply must not change what
|
||||
// gets written. The v2 blob is decoded here; legacy plaintext is treated
|
||||
// as a single compose.yaml.
|
||||
const pending = this.decodePendingCompose(src.pending_compose_content);
|
||||
const envContent = src.pending_env_content !== null
|
||||
? this.crypto.decrypt(src.pending_env_content)
|
||||
: null;
|
||||
|
||||
// Re-validate before writing.
|
||||
const validation = await this.validateCompose(composeContent, envContent);
|
||||
const validation = await this.validateCompose(pending.files, envContent, pending.contextDir);
|
||||
if (!validation.ok) {
|
||||
if (diag) console.log(`[GitSource:diag] apply validation fail stack=${stackName}`);
|
||||
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
await fsSvc.saveStackContent(stackName, composeContent);
|
||||
if (src.sync_env && envContent !== null) {
|
||||
await fsSvc.saveEnvContent(stackName, envContent);
|
||||
}
|
||||
const appliedSpec = await this.materialize(
|
||||
stackName, pending.files, pending.contextDir, src.sync_env, envContent, src.applied_deploy_spec,
|
||||
);
|
||||
|
||||
const hash = this.hashContent(composeContent, envContent);
|
||||
const hash = this.hashContent(pending.files, envContent);
|
||||
db.markGitSourceApplied(stackName, commitSha, hash);
|
||||
db.setGitSourceAppliedSpec(stackName, appliedSpec);
|
||||
|
||||
const shouldDeploy = opts.deploy ?? src.auto_deploy_on_apply;
|
||||
if (diag) console.log('[GitSource:diag] apply wrote stack=%s sha=%s deploy=%s', sanitizeForLog(stackName), sanitizeForLog(commitSha.slice(0, 7)), sanitizeForLog(shouldDeploy));
|
||||
@@ -1110,31 +1363,29 @@ export class GitSourceService {
|
||||
const fetched = await this.fetchFromGit({
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch,
|
||||
composePath: input.composePath,
|
||||
composePaths: input.composePaths,
|
||||
envPath: input.syncEnv ? input.envPath : null,
|
||||
token: input.token,
|
||||
});
|
||||
|
||||
// 2. Validate against the same `docker compose config` check the
|
||||
// apply path uses. Reject before creating anything on disk.
|
||||
const validation = await this.validateCompose(fetched.composeContent, fetched.envContent);
|
||||
const validation = await this.validateCompose(fetched.composeFiles, fetched.envContent, input.contextDir);
|
||||
if (!validation.ok) {
|
||||
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
// 3. Create directory + boilerplate, then overwrite with the
|
||||
// fetched content. createStack() throws if the directory
|
||||
// already exists, so a name collision is caught here.
|
||||
// 3. Create directory + boilerplate, then materialize the fetched
|
||||
// files. createStack() throws if the directory already exists, so a
|
||||
// name collision is caught here.
|
||||
let stackCreated = false;
|
||||
try {
|
||||
await fsSvc.createStack(input.stackName);
|
||||
stackCreated = true;
|
||||
await fsSvc.saveStackContent(input.stackName, fetched.composeContent);
|
||||
let envWritten = false;
|
||||
if (input.syncEnv && fetched.envContent !== null) {
|
||||
await fsSvc.saveEnvContent(input.stackName, fetched.envContent);
|
||||
envWritten = true;
|
||||
}
|
||||
const appliedSpec = await this.materialize(
|
||||
input.stackName, fetched.composeFiles, input.contextDir, input.syncEnv, fetched.envContent, null,
|
||||
);
|
||||
const envWritten = input.syncEnv && fetched.envContent !== null;
|
||||
|
||||
// 4. Insert the git-source row, then mark it applied so future
|
||||
// pulls diff against the fetched commit rather than treating
|
||||
@@ -1142,11 +1393,14 @@ export class GitSourceService {
|
||||
const encryptedToken = input.authType === 'token' && input.token
|
||||
? this.crypto.encrypt(input.token)
|
||||
: null;
|
||||
const hash = this.hashContent(fetched.composeFiles, fetched.envContent);
|
||||
db.upsertGitSource({
|
||||
stack_name: input.stackName,
|
||||
repo_url: input.repoUrl,
|
||||
branch: input.branch,
|
||||
compose_path: input.composePath,
|
||||
compose_path: input.composePaths[0],
|
||||
compose_paths: input.composePaths,
|
||||
context_dir: input.contextDir,
|
||||
sync_env: input.syncEnv,
|
||||
env_path: input.syncEnv ? input.envPath : null,
|
||||
auth_type: input.authType,
|
||||
@@ -1154,18 +1408,15 @@ export class GitSourceService {
|
||||
auto_apply_on_webhook: input.autoApplyOnWebhook,
|
||||
auto_deploy_on_apply: input.autoDeployOnApply,
|
||||
last_applied_commit_sha: fetched.commitSha,
|
||||
last_applied_content_hash: this.hashContent(fetched.composeContent, fetched.envContent),
|
||||
last_applied_content_hash: hash,
|
||||
pending_commit_sha: null,
|
||||
pending_compose_content: null,
|
||||
pending_env_content: null,
|
||||
pending_fetched_at: null,
|
||||
last_debounce_at: null,
|
||||
});
|
||||
db.markGitSourceApplied(
|
||||
input.stackName,
|
||||
fetched.commitSha,
|
||||
this.hashContent(fetched.composeContent, fetched.envContent),
|
||||
);
|
||||
db.markGitSourceApplied(input.stackName, fetched.commitSha, hash);
|
||||
db.setGitSourceAppliedSpec(input.stackName, appliedSpec);
|
||||
|
||||
const source = this.get(input.stackName);
|
||||
if (!source) {
|
||||
|
||||
@@ -105,6 +105,54 @@ export function extractImagesFromCompose(
|
||||
return extractServiceImagesFromCompose(yamlContent, envVars).map(e => e.image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract service images from a `docker compose config --format json` render.
|
||||
* The render is already merged + interpolated, so no env substitution is needed.
|
||||
*/
|
||||
export function extractServiceImagesFromRenderedConfig(renderedJson: string): ComposeServiceImage[] {
|
||||
let parsed: { services?: Record<string, { image?: unknown }> };
|
||||
try {
|
||||
parsed = JSON.parse(renderedJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!parsed?.services || typeof parsed.services !== 'object') return [];
|
||||
const out: ComposeServiceImage[] = [];
|
||||
for (const [service, svc] of Object.entries(parsed.services)) {
|
||||
const raw = svc?.image;
|
||||
if (!raw || typeof raw !== 'string') continue;
|
||||
const ref = raw.trim();
|
||||
if (!ref || ref.startsWith('sha256:')) continue;
|
||||
out.push({ service, image: ref });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service-name -> image refs for a stack. For a Git stack with an applied
|
||||
* multi-file / context-dir spec, this comes from the effective merged model
|
||||
* (docker compose config), so a service/image declared only in an override file
|
||||
* is included. Returns null for single-file / non-git stacks (and on a render
|
||||
* failure) so the caller falls back to its existing single-file compose parse.
|
||||
*/
|
||||
export async function loadEffectiveServiceImages(nodeId: number, stackName: string): Promise<ComposeServiceImage[] | null> {
|
||||
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
|
||||
if (!spec || spec.files.length === 0) return null;
|
||||
// Lazy import to avoid a static module cycle (ComposeService is a heavy hub).
|
||||
const { ComposeService } = await import('./ComposeService');
|
||||
const rendered = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (!rendered.rendered) {
|
||||
// The effective render failed (unset var, bad include, timeout, output cap).
|
||||
// Falling back to the root-compose parse misses override-only images, so log
|
||||
// the reason; without this the degradation is invisible to the operator.
|
||||
console.warn(
|
||||
`[ImageUpdateService] effective image render failed for "${sanitizeForLog(stackName)}" (code=${rendered.code} timedOut=${rendered.timedOut}); falling back to root-compose parse: ${sanitizeForLog(rendered.stderr)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return extractServiceImagesFromRenderedConfig(rendered.rendered);
|
||||
}
|
||||
|
||||
// ─── Service ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ImageUpdateService {
|
||||
@@ -348,6 +396,14 @@ export class ImageUpdateService {
|
||||
// Phase 2: Parse compose files for image refs
|
||||
for (const stackName of stacks) {
|
||||
try {
|
||||
// Multi-file / context-dir Git stacks resolve images from the
|
||||
// effective merged model so override-only images are captured.
|
||||
const effective = await loadEffectiveServiceImages(nodeId, stackName);
|
||||
if (effective) {
|
||||
for (const e of effective) stackImages.get(stackName)?.add(e.image);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await withTimeout(fs.getStackContent(stackName), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getStackContent');
|
||||
|
||||
// Load .env for variable resolution (best-effort)
|
||||
@@ -384,13 +440,26 @@ export class ImageUpdateService {
|
||||
try {
|
||||
const containers = await withTimeout(docker.getAllContainers(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getAllContainers');
|
||||
for (const c of containers) {
|
||||
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
|
||||
if (!workingDir) continue;
|
||||
// Prefer the pinned project label (== stackName for Sencho-deployed
|
||||
// stacks, including multi-file / context-dir ones where
|
||||
// --project-directory would otherwise change the working-dir
|
||||
// basename). Fall back to the working-dir basename for legacy /
|
||||
// non-Sencho containers.
|
||||
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
|
||||
let stackName: string | null = null;
|
||||
if (project && stackImages.has(project)) {
|
||||
stackName = project;
|
||||
} else {
|
||||
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
|
||||
if (workingDir) {
|
||||
const resolved = path.resolve(workingDir);
|
||||
if (resolved === composeDir || resolved.startsWith(composeDir + path.sep)) {
|
||||
stackName = path.basename(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stackName) continue;
|
||||
|
||||
const resolved = path.resolve(workingDir);
|
||||
if (resolved !== composeDir && !resolved.startsWith(composeDir + path.sep)) continue;
|
||||
|
||||
const stackName = path.basename(resolved);
|
||||
const imageRef: string = c.Image ?? '';
|
||||
if (!imageRef || imageRef.startsWith('sha256:')) continue;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { lookupContainerIp } from '../mesh/containerLookup';
|
||||
import { STREAM_PENDING_DATA_MAX_BYTES } from '../pilot/protocol';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { isPathWithinBase, isValidStackName, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { PORT as SENCHO_LISTEN_PORT } from '../helpers/constants';
|
||||
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
|
||||
|
||||
@@ -2080,18 +2080,23 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
const targetNodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||
try {
|
||||
const fsSvc = FileSystemService.getInstance(targetNodeId);
|
||||
const filename = await fsSvc.getComposeFilename(stackName);
|
||||
const baseDir = fsSvc.getBaseDir();
|
||||
// path.basename strips any directory component as defense-in-depth
|
||||
// on top of isValidStackName + isPathWithinBase. Recognized by
|
||||
// CodeQL's path-injection model.
|
||||
const composePath = path.join(baseDir, path.basename(stackName), filename);
|
||||
if (!isPathWithinBase(composePath, baseDir)) return [];
|
||||
const content = await fs.readFile(composePath, 'utf8');
|
||||
const parsed = YAML.parse(content) as { services?: Record<string, unknown> } | null;
|
||||
const services = parsed?.services && typeof parsed.services === 'object' ? parsed.services : null;
|
||||
if (!services) return [];
|
||||
return Object.keys(services).filter((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name));
|
||||
// For a multi-file Git stack, read every materialized compose file and
|
||||
// union their service names, so a service declared only in an override
|
||||
// file is still attached to the mesh. Single-file stacks read the one
|
||||
// resolved compose file, byte-identical to the prior behavior.
|
||||
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
|
||||
const relFiles = spec && spec.files.length > 0
|
||||
? spec.files
|
||||
: [await fsSvc.getComposeFilename(stackName)];
|
||||
const names = new Set<string>();
|
||||
for (const relFile of relFiles) {
|
||||
if (relFile === '' || !isValidRelativeStackPath(relFile)) continue;
|
||||
for (const name of await this.readComposeServiceNames(baseDir, stackName, relFile)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return Array.from(names);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[MeshService] getDeclaredStackServiceNames failed:',
|
||||
@@ -2101,6 +2106,26 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one compose file under a stack directory and return its declared
|
||||
* service names. The stack segment uses path.basename as defense-in-depth and
|
||||
* the resolved path is re-checked against the base dir; the relative file is
|
||||
* validated by the caller. Returns [] when the file is missing or unparseable.
|
||||
*/
|
||||
private async readComposeServiceNames(baseDir: string, stackName: string, relFile: string): Promise<string[]> {
|
||||
const composePath = path.join(baseDir, path.basename(stackName), relFile);
|
||||
if (!isPathWithinBase(composePath, baseDir)) return [];
|
||||
try {
|
||||
const content = await fs.readFile(composePath, 'utf8');
|
||||
const parsed = YAML.parse(content) as { services?: Record<string, unknown> } | null;
|
||||
const services = parsed?.services && typeof parsed.services === 'object' ? parsed.services : null;
|
||||
if (!services) return [];
|
||||
return Object.keys(services).filter((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an existing mesh override file and extract the service names
|
||||
* it already lists. Used by the defensive fallback so a transient
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RegistryService } from './RegistryService';
|
||||
import {
|
||||
extractServiceImagesFromCompose,
|
||||
loadDotEnv,
|
||||
loadEffectiveServiceImages,
|
||||
type ComposeServiceImage,
|
||||
} from './ImageUpdateService';
|
||||
import {
|
||||
@@ -121,6 +122,12 @@ async function loadStackImages(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
): Promise<ComposeServiceImage[]> {
|
||||
// A multi-file / context-dir Git stack resolves images from the effective
|
||||
// merged model so override-only services are included; single-file stacks
|
||||
// fall through to the root-compose parse below.
|
||||
const effective = await loadEffectiveServiceImages(nodeId, stackName);
|
||||
if (effective) return effective;
|
||||
|
||||
const fs = FileSystemService.getInstance(nodeId);
|
||||
const composeContent = await fs.getStackContent(stackName);
|
||||
let envVars: Record<string, string> = {};
|
||||
|
||||
Reference in New Issue
Block a user