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
+114
View File
@@ -0,0 +1,114 @@
import path from 'path';
import { isValidGitSourcePath, isValidRelativeStackPath } from '../utils/validation';
import { PRIMARY_COMPOSE_FILENAME, gitSourceLocalComposeFiles } from '../utils/gitComposeFiles';
/** Upper bound on how many compose files one stack can order. Generous; real
* base+override layouts use a handful. */
export const MAX_COMPOSE_FILES = 10;
const MAX_COMPOSE_PATH_LENGTH = 1024;
const MAX_CONTEXT_DIR_LENGTH = 1024;
export interface ComposeSelection {
composePaths: string[];
contextDir: string | null;
}
type ParseResult =
| { ok: true; value: ComposeSelection }
| { ok: false; error: string };
/**
* Validate and normalize the multi-file compose selection from a request body.
* Accepts `compose_paths` (ordered array) or the legacy `compose_path` (single
* string, mapped to a one-element array). Enforces the file-count cap, rejects
* duplicates and a root `compose.yaml` collision (the primary is always
* materialized there), and validates an optional `context_dir`.
*/
export function parseComposeSelection(body: unknown): ParseResult {
const b = (body ?? {}) as Record<string, unknown>;
let rawPaths: unknown = b.compose_paths;
if (rawPaths === undefined && typeof b.compose_path === 'string') {
rawPaths = [b.compose_path];
}
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return { ok: false, error: 'compose_paths must be a non-empty array of repository file paths' };
}
if (rawPaths.length > MAX_COMPOSE_FILES) {
return { ok: false, error: `compose_paths cannot exceed ${MAX_COMPOSE_FILES} files` };
}
const composePaths: string[] = [];
for (const raw of rawPaths) {
if (typeof raw !== 'string' || !raw.trim()) {
return { ok: false, error: 'each compose path must be a non-empty string' };
}
const trimmed = raw.trim();
if (trimmed.length > MAX_COMPOSE_PATH_LENGTH) {
return { ok: false, error: 'a compose path is too long' };
}
if (!isValidGitSourcePath(trimmed)) {
return { ok: false, error: `compose path must be a relative repository file path: ${trimmed}` };
}
composePaths.push(trimmed);
}
if (new Set(composePaths).size !== composePaths.length) {
return { ok: false, error: 'compose_paths cannot contain duplicate entries' };
}
// Materialized local layout: the primary (index 0) is written to the root
// compose.yaml, each additional file at its repo-relative path. Reject the
// collisions that would make materialization fail at runtime (ENOTDIR / clobber):
// an additional file equal to or nested under compose.yaml, and any file path
// that is a directory ancestor of another selected file.
const materialized = gitSourceLocalComposeFiles(composePaths);
const label = (k: number) => (k === 0 ? PRIMARY_COMPOSE_FILENAME : composePaths[k]);
for (let i = 0; i < materialized.length; i++) {
for (let j = 0; j < materialized.length; j++) {
if (i === j) continue;
if (materialized[j] === materialized[i] || materialized[j].startsWith(materialized[i] + '/')) {
return { ok: false, error: `compose file "${label(j)}" collides with "${label(i)}" once materialized to disk` };
}
}
}
let contextDir: string | null = null;
const rawCtx = b.context_dir;
if (rawCtx !== undefined && rawCtx !== null && rawCtx !== '') {
if (typeof rawCtx !== 'string') {
return { ok: false, error: 'context_dir must be a string' };
}
if (rawCtx.length > MAX_CONTEXT_DIR_LENGTH) {
return { ok: false, error: 'context_dir is too long' };
}
const ctx = rawCtx.trim().replace(/^\.\//, '').replace(/\/+$/, '');
if (ctx !== '') {
if (!isValidRelativeStackPath(ctx)) {
return { ok: false, error: 'context_dir must be a relative path within the repository' };
}
if (ctx.split('/').some(seg => seg.toLowerCase() === '.git')) {
return { ok: false, error: 'context_dir cannot target the .git directory' };
}
// The project dir is mkdir'd, so it cannot equal a compose file path or sit
// beneath one (the primary compose.yaml and any override are files, not dirs).
if (materialized.some(f => ctx === f || ctx.startsWith(f + '/'))) {
return { ok: false, error: 'context_dir cannot match or be nested under a compose file path' };
}
contextDir = ctx;
}
}
return { ok: true, value: { composePaths, contextDir } };
}
/**
* Default the env path to a sibling `.env` of the primary compose file when env
* sync is on and no explicit path is provided. Mirrors the prior single-file
* behavior, keyed off the primary (compose_paths[0]).
*/
export function defaultEnvPath(primaryComposePath: string, explicit: unknown): string {
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
const dir = path.posix.dirname(primaryComposePath.replace(/\\/g, '/')) || '.';
return path.posix.join(dir, '.env');
}