mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user