mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +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.
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
/**
|
|
* Unit tests for SelfIdentityService. Covers happy-path inspect, missing
|
|
* HOSTNAME (dev mode), Dockerode 404 (also dev mode), and the isOwn* matchers.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
|
|
|
const { mockContainer, mockDocker } = vi.hoisted(() => {
|
|
const mockContainer = {
|
|
inspect: vi.fn(),
|
|
};
|
|
const mockDocker = {
|
|
getContainer: vi.fn(() => mockContainer),
|
|
listImages: vi.fn().mockResolvedValue([]),
|
|
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
|
|
listNetworks: vi.fn().mockResolvedValue([]),
|
|
listContainers: vi.fn().mockResolvedValue([]),
|
|
};
|
|
return { mockContainer, mockDocker };
|
|
});
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getDocker: () => mockDocker,
|
|
getDefaultNodeId: () => 1,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
|
|
vi.mock('util', () => ({ promisify: () => vi.fn() }));
|
|
|
|
import SelfIdentityService from '../services/SelfIdentityService';
|
|
|
|
const FULL_CONTAINER_ID = 'a'.repeat(64);
|
|
const FULL_IMAGE_ID_HEX = 'b'.repeat(64);
|
|
const FULL_NETWORK_ID = 'c'.repeat(64);
|
|
|
|
const originalHostname = process.env.HOSTNAME;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// mockReset (not clearAllMocks) drops implementations set by prior
|
|
// mockResolvedValue/mockResolvedValueOnce calls, otherwise resolutions
|
|
// leak across tests.
|
|
mockContainer.inspect.mockReset();
|
|
SelfIdentityService.getInstance().resetForTesting();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// restoreAllMocks() puts vi.spyOn'd statics (readContainerIdFromCgroup,
|
|
// console.warn) back to their real implementations so the next test
|
|
// exercises real code paths.
|
|
vi.restoreAllMocks();
|
|
if (originalHostname === undefined) delete process.env.HOSTNAME;
|
|
else process.env.HOSTNAME = originalHostname;
|
|
});
|
|
|
|
describe('SelfIdentityService.initialize', () => {
|
|
it('populates identity when HOSTNAME is set and inspect resolves', async () => {
|
|
process.env.HOSTNAME = 'sencho-1';
|
|
mockContainer.inspect.mockResolvedValue({
|
|
Id: FULL_CONTAINER_ID,
|
|
Name: '/sencho',
|
|
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
|
|
Config: { Labels: { 'com.docker.compose.project': 'sencho' } },
|
|
NetworkSettings: {
|
|
Networks: {
|
|
sencho_mesh: { NetworkID: FULL_NETWORK_ID },
|
|
},
|
|
},
|
|
Mounts: [
|
|
{ Type: 'volume', Name: 'sencho_data' },
|
|
{ Type: 'bind', Source: '/host/path', Destination: '/app/compose' },
|
|
],
|
|
});
|
|
|
|
const svc = SelfIdentityService.getInstance();
|
|
await svc.initialize();
|
|
|
|
const id = svc.getIdentity();
|
|
expect(id.containerId).toBe(FULL_CONTAINER_ID);
|
|
expect(id.containerName).toBe('sencho');
|
|
expect(id.composeProjectName).toBe('sencho');
|
|
expect(id.imageId).toBe(FULL_IMAGE_ID_HEX);
|
|
expect(id.networkNames).toEqual(['sencho_mesh']);
|
|
expect(id.volumeNames).toEqual(['sencho_data']);
|
|
});
|
|
|
|
it('shares an in-flight initialization across concurrent callers', async () => {
|
|
process.env.HOSTNAME = 'sencho-1';
|
|
let resolveInspect: (value: unknown) => void = () => {};
|
|
mockContainer.inspect.mockReturnValue(new Promise(resolve => {
|
|
resolveInspect = resolve;
|
|
}));
|
|
|
|
const svc = SelfIdentityService.getInstance();
|
|
const first = svc.initialize();
|
|
const second = svc.initialize();
|
|
|
|
expect(mockContainer.inspect).toHaveBeenCalledTimes(1);
|
|
resolveInspect({
|
|
Id: FULL_CONTAINER_ID,
|
|
Name: '/sencho',
|
|
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
|
|
Config: { Labels: { 'com.docker.compose.project': 'sencho' } },
|
|
NetworkSettings: { Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } } },
|
|
Mounts: [{ Type: 'volume', Name: 'sencho_data' }],
|
|
});
|
|
await Promise.all([first, second]);
|
|
|
|
expect(svc.getIdentity().containerId).toBe(FULL_CONTAINER_ID);
|
|
expect(svc.getIdentity().composeProjectName).toBe('sencho');
|
|
});
|
|
|
|
it('stays empty when HOSTNAME is unset (dev mode)', async () => {
|
|
delete process.env.HOSTNAME;
|
|
const svc = SelfIdentityService.getInstance();
|
|
await svc.initialize();
|
|
expect(svc.getIdentity().containerId).toBeNull();
|
|
expect(svc.getIdentity().imageId).toBeNull();
|
|
expect(mockContainer.inspect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stays empty when HOSTNAME inspect 404s and cgroup probe has no container ID (dev mode)', async () => {
|
|
process.env.HOSTNAME = 'my-laptop';
|
|
const err: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 });
|
|
mockContainer.inspect.mockRejectedValue(err);
|
|
vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(null);
|
|
|
|
const svc = SelfIdentityService.getInstance();
|
|
await svc.initialize();
|
|
expect(svc.getIdentity().containerId).toBeNull();
|
|
});
|
|
|
|
it('falls back to /proc/self/cgroup when HOSTNAME inspect 404s (custom --hostname)', async () => {
|
|
process.env.HOSTNAME = 'my-custom-name';
|
|
const err404: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 });
|
|
// First call (HOSTNAME) 404s; second call (cgroup-resolved ID) succeeds.
|
|
mockContainer.inspect
|
|
.mockRejectedValueOnce(err404)
|
|
.mockResolvedValueOnce({
|
|
Id: FULL_CONTAINER_ID,
|
|
Name: '/sencho',
|
|
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
|
|
NetworkSettings: { Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } } },
|
|
Mounts: [{ Type: 'volume', Name: 'sencho_data' }],
|
|
});
|
|
vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(FULL_CONTAINER_ID);
|
|
|
|
const svc = SelfIdentityService.getInstance();
|
|
await svc.initialize();
|
|
expect(svc.getIdentity().containerId).toBe(FULL_CONTAINER_ID);
|
|
expect(mockDocker.getContainer).toHaveBeenNthCalledWith(1, 'my-custom-name');
|
|
expect(mockDocker.getContainer).toHaveBeenNthCalledWith(2, FULL_CONTAINER_ID);
|
|
});
|
|
|
|
it('stays empty when HOSTNAME 404s and cgroup probe resolves but inspect 404s on that ID too', async () => {
|
|
process.env.HOSTNAME = 'sencho-1';
|
|
const err404: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 });
|
|
mockContainer.inspect.mockRejectedValue(err404);
|
|
vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(FULL_CONTAINER_ID);
|
|
|
|
const svc = SelfIdentityService.getInstance();
|
|
await svc.initialize();
|
|
expect(svc.getIdentity().containerId).toBeNull();
|
|
});
|
|
|
|
it('stays empty and logs on non-404 inspect failure', async () => {
|
|
process.env.HOSTNAME = 'sencho-1';
|
|
mockContainer.inspect.mockRejectedValue(new Error('docker daemon unreachable'));
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const svc = SelfIdentityService.getInstance();
|
|
await svc.initialize();
|
|
expect(svc.getIdentity().containerId).toBeNull();
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('parses /proc/self/cgroup formats (cgroupv1 docker, cgroupv2 docker, podman libpod)', async () => {
|
|
const tmp = await import('os');
|
|
const fsp = await import('fs/promises');
|
|
const path = await import('path');
|
|
const dir = await fsp.mkdtemp(path.join(tmp.tmpdir(), 'sencho-cgroup-'));
|
|
|
|
const cgroupV1 = path.join(dir, 'cgv1');
|
|
await fsp.writeFile(cgroupV1, `12:cpuset:/docker/${FULL_CONTAINER_ID}\n11:memory:/docker/${FULL_CONTAINER_ID}\n`);
|
|
expect(await SelfIdentityService.readContainerIdFromCgroup(cgroupV1)).toBe(FULL_CONTAINER_ID);
|
|
|
|
const cgroupV2 = path.join(dir, 'cgv2');
|
|
await fsp.writeFile(cgroupV2, `0::/system.slice/docker-${FULL_CONTAINER_ID}.scope\n`);
|
|
expect(await SelfIdentityService.readContainerIdFromCgroup(cgroupV2)).toBe(FULL_CONTAINER_ID);
|
|
|
|
const podman = path.join(dir, 'podman');
|
|
await fsp.writeFile(podman, `0::/user.slice/user-1000.slice/libpod-${FULL_CONTAINER_ID}.scope\n`);
|
|
expect(await SelfIdentityService.readContainerIdFromCgroup(podman)).toBe(FULL_CONTAINER_ID);
|
|
|
|
const noMatch = path.join(dir, 'empty');
|
|
await fsp.writeFile(noMatch, '0::/system.slice/sshd.service\n');
|
|
expect(await SelfIdentityService.readContainerIdFromCgroup(noMatch)).toBeNull();
|
|
|
|
expect(await SelfIdentityService.readContainerIdFromCgroup(path.join(dir, 'does-not-exist'))).toBeNull();
|
|
|
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('is idempotent: re-initialize is a no-op', async () => {
|
|
process.env.HOSTNAME = 'sencho-1';
|
|
mockContainer.inspect.mockResolvedValue({
|
|
Id: FULL_CONTAINER_ID,
|
|
Name: '/sencho',
|
|
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
|
|
NetworkSettings: { Networks: {} },
|
|
Mounts: [],
|
|
});
|
|
|
|
const svc = SelfIdentityService.getInstance();
|
|
await svc.initialize();
|
|
await svc.initialize();
|
|
expect(mockContainer.inspect).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('SelfIdentityService matchers', () => {
|
|
beforeEach(async () => {
|
|
process.env.HOSTNAME = 'sencho-1';
|
|
mockContainer.inspect.mockResolvedValue({
|
|
Id: FULL_CONTAINER_ID,
|
|
Name: '/sencho',
|
|
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
|
|
NetworkSettings: {
|
|
Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } },
|
|
},
|
|
Mounts: [{ Type: 'volume', Name: 'sencho_data' }],
|
|
});
|
|
SelfIdentityService.getInstance().resetForTesting();
|
|
await SelfIdentityService.getInstance().initialize();
|
|
});
|
|
|
|
it('isOwnImage matches full sha256 ref, hex-only, and short prefix', () => {
|
|
const svc = SelfIdentityService.getInstance();
|
|
expect(svc.isOwnImage('sha256:' + FULL_IMAGE_ID_HEX)).toBe(true);
|
|
expect(svc.isOwnImage(FULL_IMAGE_ID_HEX)).toBe(true);
|
|
expect(svc.isOwnImage(FULL_IMAGE_ID_HEX.slice(0, 12))).toBe(true);
|
|
expect(svc.isOwnImage('d'.repeat(64))).toBe(false);
|
|
});
|
|
|
|
it('isOwnImage does not match repo:tag strings (callers should pass IDs)', () => {
|
|
const svc = SelfIdentityService.getInstance();
|
|
expect(svc.isOwnImage('ghcr.io/studio-saelix/sencho:latest')).toBe(false);
|
|
expect(svc.isOwnImage('saelix/sencho:1.0')).toBe(false);
|
|
});
|
|
|
|
it('isOwnNetwork matches by ID and by name', () => {
|
|
const svc = SelfIdentityService.getInstance();
|
|
expect(svc.isOwnNetwork(FULL_NETWORK_ID)).toBe(true);
|
|
expect(svc.isOwnNetwork(FULL_NETWORK_ID.slice(0, 12))).toBe(true);
|
|
expect(svc.isOwnNetwork('sencho_mesh')).toBe(true);
|
|
expect(svc.isOwnNetwork('bridge')).toBe(false);
|
|
});
|
|
|
|
it('isOwnNetwork does not falsely match a non-hex network name that overlaps a cached ID prefix', () => {
|
|
// The cached network ID is 64 chars of 'c'. A network named "ccc" is NOT
|
|
// a hex ID input (length below 12), so prefix matching must not fire.
|
|
const svc = SelfIdentityService.getInstance();
|
|
expect(svc.isOwnNetwork('ccc')).toBe(false);
|
|
expect(svc.isOwnNetwork('cc')).toBe(false);
|
|
});
|
|
|
|
it('isOwnVolume matches by name only', () => {
|
|
const svc = SelfIdentityService.getInstance();
|
|
expect(svc.isOwnVolume('sencho_data')).toBe(true);
|
|
expect(svc.isOwnVolume('other_volume')).toBe(false);
|
|
});
|
|
|
|
it('isOwnContainer matches full, short prefix, and name', () => {
|
|
const svc = SelfIdentityService.getInstance();
|
|
expect(svc.isOwnContainer(FULL_CONTAINER_ID)).toBe(true);
|
|
expect(svc.isOwnContainer(FULL_CONTAINER_ID.slice(0, 12))).toBe(true);
|
|
expect(svc.isOwnContainer('sencho')).toBe(true);
|
|
expect(svc.isOwnContainer('e'.repeat(64))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('SelfIdentityService matchers when empty (dev mode)', () => {
|
|
beforeEach(async () => {
|
|
delete process.env.HOSTNAME;
|
|
SelfIdentityService.getInstance().resetForTesting();
|
|
await SelfIdentityService.getInstance().initialize();
|
|
});
|
|
|
|
it('returns false for every isOwn* call so today\'s behavior is preserved', () => {
|
|
const svc = SelfIdentityService.getInstance();
|
|
expect(svc.isOwnImage('sha256:' + FULL_IMAGE_ID_HEX)).toBe(false);
|
|
expect(svc.isOwnNetwork('sencho_mesh')).toBe(false);
|
|
expect(svc.isOwnVolume('sencho_data')).toBe(false);
|
|
expect(svc.isOwnContainer(FULL_CONTAINER_ID)).toBe(false);
|
|
});
|
|
});
|