mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user