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:
Anso
2026-06-17 13:24:55 -04:00
committed by GitHub
parent 7ce045accb
commit f23b7e1bac
34 changed files with 2299 additions and 385 deletions
+43 -16
View File
@@ -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,
+95 -8
View File
@@ -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);
}
+22 -9
View File
@@ -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
+385 -134
View File
@@ -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) {
+75 -6
View File
@@ -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;
+37 -12
View File
@@ -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> = {};