mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-04 07:57:54 +00:00
f23b7e1bac
* 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.
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import DockerController from './DockerController';
|
|
import { FileSystemService } from './FileSystemService';
|
|
import { RegistryService } from './RegistryService';
|
|
import {
|
|
extractServiceImagesFromCompose,
|
|
loadDotEnv,
|
|
loadEffectiveServiceImages,
|
|
type ComposeServiceImage,
|
|
} from './ImageUpdateService';
|
|
import {
|
|
parseImageRef,
|
|
getRemoteDigest,
|
|
listRegistryTags,
|
|
type RegistryCredentials,
|
|
} from './registry-api';
|
|
|
|
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
|
|
|
export interface UpdatePreviewImage {
|
|
service: string;
|
|
image: string;
|
|
current_tag: string;
|
|
next_tag: string | null;
|
|
has_update: boolean;
|
|
semver_bump: SemverBump;
|
|
}
|
|
|
|
export type UpdateKind = 'tag' | 'digest' | 'none';
|
|
|
|
export interface UpdatePreviewSummary {
|
|
has_update: boolean;
|
|
primary_image: string | null;
|
|
current_tag: string | null;
|
|
next_tag: string | null;
|
|
semver_bump: SemverBump;
|
|
/**
|
|
* Distinguishes a "new tag is available" update from a "same tag, new
|
|
* digest" rebuild. The UI renders the rebuild case differently because
|
|
* showing "10.11 -> 10.11" reads as a bug even though it is technically
|
|
* accurate (the tag did not change, only the immutable digest behind it).
|
|
*/
|
|
update_kind: UpdateKind;
|
|
blocked: boolean;
|
|
blocked_reason: string | null;
|
|
}
|
|
|
|
export interface UpdatePreview {
|
|
stack_name: string;
|
|
images: UpdatePreviewImage[];
|
|
summary: UpdatePreviewSummary;
|
|
rollback_target: string | null;
|
|
changelog: string | null;
|
|
}
|
|
|
|
interface SemverParts {
|
|
prefix: string;
|
|
major: number;
|
|
minor: number;
|
|
patch: number;
|
|
suffix: string;
|
|
raw: string;
|
|
}
|
|
|
|
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
|
|
|
|
export function parseSemverTag(tag: string): SemverParts | null {
|
|
const m = tag.match(SEMVER_RE);
|
|
if (!m) return null;
|
|
return {
|
|
prefix: m[1] ?? '',
|
|
major: Number(m[2]),
|
|
minor: Number(m[3]),
|
|
patch: Number(m[4]),
|
|
suffix: m[5] ?? '',
|
|
raw: tag,
|
|
};
|
|
}
|
|
|
|
function compareSemver(a: SemverParts, b: SemverParts): number {
|
|
if (a.major !== b.major) return a.major - b.major;
|
|
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
return a.patch - b.patch;
|
|
}
|
|
|
|
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
|
|
const current = parseSemverTag(currentTag);
|
|
if (!current) return null;
|
|
let best: SemverParts | null = null;
|
|
for (const tag of availableTags) {
|
|
const parsed = parseSemverTag(tag);
|
|
if (!parsed) continue;
|
|
if (parsed.prefix !== current.prefix) continue;
|
|
if (parsed.suffix !== current.suffix) continue;
|
|
if (compareSemver(parsed, current) <= 0) continue;
|
|
if (!best || compareSemver(parsed, best) > 0) best = parsed;
|
|
}
|
|
return best ? best.raw : null;
|
|
}
|
|
|
|
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
|
|
if (!nextTag) return 'none';
|
|
if (nextTag === currentTag) return 'patch';
|
|
const current = parseSemverTag(currentTag);
|
|
const next = parseSemverTag(nextTag);
|
|
if (!current || !next) return 'unknown';
|
|
if (next.major > current.major) return 'major';
|
|
if (next.minor > current.minor) return 'minor';
|
|
if (next.patch > current.patch) return 'patch';
|
|
return 'none';
|
|
}
|
|
|
|
function maxBump(a: SemverBump, b: SemverBump): SemverBump {
|
|
// Ranking: none < unknown < patch < minor < major.
|
|
// unknown ranks below real semver bumps so a single unparseable tag never masks
|
|
// a genuine major bump elsewhere in the stack.
|
|
const order: SemverBump[] = ['none', 'unknown', 'patch', 'minor', 'major'];
|
|
const rank = (x: SemverBump) => order.indexOf(x);
|
|
return rank(a) >= rank(b) ? a : b;
|
|
}
|
|
|
|
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> = {};
|
|
try {
|
|
const envContent = await fs.getEnvContent(stackName);
|
|
envVars = loadDotEnv(envContent);
|
|
} catch {
|
|
// No env file - fall back to process.env only
|
|
}
|
|
const merged: Record<string, string> = { ...envVars };
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) merged[k] = v;
|
|
}
|
|
return extractServiceImagesFromCompose(composeContent, merged);
|
|
}
|
|
|
|
export interface ComputePreviewDeps {
|
|
getLocalDigest: (imageRef: string) => Promise<string | null>;
|
|
getRemoteDigest: typeof getRemoteDigest;
|
|
listRegistryTags: typeof listRegistryTags;
|
|
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
|
|
}
|
|
|
|
export async function computeImagePreview(
|
|
service: string,
|
|
imageRef: string,
|
|
deps: ComputePreviewDeps,
|
|
): Promise<UpdatePreviewImage> {
|
|
const parsed = parseImageRef(imageRef);
|
|
if (!parsed) {
|
|
return {
|
|
service,
|
|
image: imageRef,
|
|
current_tag: 'unknown',
|
|
next_tag: null,
|
|
has_update: false,
|
|
semver_bump: 'none',
|
|
};
|
|
}
|
|
|
|
const credentials = await deps.getCredentials(parsed.registry);
|
|
|
|
// Digest-based: is a new build of the SAME tag available?
|
|
const [localDigest, remoteDigest] = await Promise.all([
|
|
deps.getLocalDigest(imageRef),
|
|
deps.getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials),
|
|
]);
|
|
const digestUpdate = Boolean(localDigest && remoteDigest && localDigest !== remoteDigest);
|
|
|
|
// Tag-based: is a higher semver tag available?
|
|
const tags = await deps.listRegistryTags(parsed.registry, parsed.repo, credentials);
|
|
const nextTag = findNextTag(parsed.tag, tags);
|
|
|
|
const hasUpdate = digestUpdate || nextTag !== null;
|
|
let semverBump: SemverBump = 'none';
|
|
let resolvedNext: string | null = null;
|
|
if (nextTag) {
|
|
resolvedNext = nextTag;
|
|
semverBump = computeSemverBump(parsed.tag, nextTag);
|
|
} else if (digestUpdate) {
|
|
resolvedNext = parsed.tag;
|
|
semverBump = 'patch';
|
|
}
|
|
|
|
return {
|
|
service,
|
|
image: imageRef,
|
|
current_tag: parsed.tag,
|
|
next_tag: resolvedNext,
|
|
has_update: hasUpdate,
|
|
semver_bump: semverBump,
|
|
};
|
|
}
|
|
|
|
function buildRollbackTarget(image: string, currentTag: string): string | null {
|
|
const parsed = parseImageRef(image);
|
|
if (!parsed) return null;
|
|
// Reconstruct without the library/ prefix Docker Hub uses internally,
|
|
// so "library/nginx" renders as "nginx:1.0.0" not "registry-1.docker.io/library/nginx:1.0.0".
|
|
const isDockerHub = parsed.registry === 'registry-1.docker.io';
|
|
const repo = isDockerHub && parsed.repo.startsWith('library/')
|
|
? parsed.repo.slice('library/'.length)
|
|
: parsed.repo;
|
|
const base = isDockerHub ? repo : `${parsed.registry}/${repo}`;
|
|
return `${base}:${currentTag}`;
|
|
}
|
|
|
|
export function buildSummary(stackName: string, images: UpdatePreviewImage[]): UpdatePreview {
|
|
const updated = images.filter(i => i.has_update);
|
|
const hasUpdate = updated.length > 0;
|
|
const primary = updated[0] ?? images[0] ?? null;
|
|
const overallBump = updated.reduce<SemverBump>(
|
|
(acc, img) => maxBump(acc, img.semver_bump),
|
|
'none',
|
|
);
|
|
const blocked = overallBump === 'major';
|
|
// 'tag' means at least one image has a strictly newer tag; 'digest' means
|
|
// the only updates available are same-tag rebuilds (digest changed); 'none'
|
|
// means there is nothing to apply.
|
|
const updateKind: UpdateKind = !hasUpdate
|
|
? 'none'
|
|
: updated.some(i => i.next_tag !== null && i.next_tag !== i.current_tag)
|
|
? 'tag'
|
|
: 'digest';
|
|
return {
|
|
stack_name: stackName,
|
|
images,
|
|
summary: {
|
|
has_update: hasUpdate,
|
|
primary_image: primary ? primary.image : null,
|
|
current_tag: primary ? primary.current_tag : null,
|
|
next_tag: primary ? primary.next_tag : null,
|
|
semver_bump: overallBump,
|
|
update_kind: updateKind,
|
|
blocked,
|
|
blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null,
|
|
},
|
|
rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null,
|
|
changelog: null,
|
|
};
|
|
}
|
|
|
|
export class UpdatePreviewService {
|
|
private static instance: UpdatePreviewService;
|
|
|
|
public static getInstance(): UpdatePreviewService {
|
|
if (!UpdatePreviewService.instance) {
|
|
UpdatePreviewService.instance = new UpdatePreviewService();
|
|
}
|
|
return UpdatePreviewService.instance;
|
|
}
|
|
|
|
public async getPreview(nodeId: number, stackName: string): Promise<UpdatePreview> {
|
|
const stackImages = await loadStackImages(nodeId, stackName);
|
|
if (stackImages.length === 0) {
|
|
return buildSummary(stackName, []);
|
|
}
|
|
|
|
const docker = DockerController.getInstance(nodeId);
|
|
const deps: ComputePreviewDeps = {
|
|
getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry),
|
|
getRemoteDigest,
|
|
listRegistryTags,
|
|
getLocalDigest: async (imageRef: string) => {
|
|
try {
|
|
const inspect = await docker.getDocker().getImage(imageRef).inspect();
|
|
const repoDigests: string[] = inspect.RepoDigests ?? [];
|
|
for (const rd of repoDigests) {
|
|
if (!rd.includes('@sha256:')) continue;
|
|
const [, digest] = rd.split('@');
|
|
return digest;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
const results = await Promise.all(
|
|
stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)),
|
|
);
|
|
return buildSummary(stackName, results);
|
|
}
|
|
}
|