mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-09 17:05:51 +00:00
chore: merge main into feat/sidebar-resize
This commit is contained in:
+17
-16
@@ -93,7 +93,7 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
||||
# Stage 4a: Build Docker CLI from source against Go 1.26.3
|
||||
#
|
||||
# CLI v29.4.1 ships otel/sdk v1.43.0, resolving CVE-2026-39883 (BSD kenv) and
|
||||
# CVE-2026-39882 (OTLP response OOM). Building against grpc v1.83.1 below
|
||||
# CVE-2026-39882 (OTLP response OOM). Building against grpc v1.83.2 below
|
||||
# transitively resolves otel to v1.44.0, which additionally clears
|
||||
# CVE-2026-41178 (baggage header parsing dropped its raw-length cap, allowing
|
||||
# resource exhaustion via an oversized header, present through v1.43.0). It
|
||||
@@ -105,8 +105,8 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
||||
# TARGET. The fetch pulls only the v29.4.1 commit, minimising transfer size.
|
||||
# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid
|
||||
# SemVer compliance requirements. We copy vendor.mod -> go.mod, drop the
|
||||
# committed vendor tree, bump golang.org/x/net to v0.56.0, golang.org/x/text
|
||||
# to v0.39.0, google.golang.org/grpc to v1.83.1, and
|
||||
# committed vendor tree, bump golang.org/x/net to v0.58.0, golang.org/x/text
|
||||
# to v0.41.0, google.golang.org/grpc to v1.83.2, and
|
||||
# github.com/moby/go-archive to v0.3.0, and build with -mod=mod so the patched
|
||||
# modules are resolved from the module proxy. x/net v0.53.0 is flagged for six
|
||||
# HIGH advisories (CVE-2026-25680, -25681, -27136, -39821, -42502, -42506;
|
||||
@@ -144,9 +144,9 @@ RUN mkdir -p /build
|
||||
|
||||
RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
|
||||
rm -rf vendor && \
|
||||
go get golang.org/x/net@v0.56.0 \
|
||||
golang.org/x/text@v0.39.0 \
|
||||
google.golang.org/grpc@v1.83.1 \
|
||||
go get golang.org/x/net@v0.58.0 \
|
||||
golang.org/x/text@v0.41.0 \
|
||||
google.golang.org/grpc@v1.83.2 \
|
||||
github.com/moby/go-archive@v0.3.0 && \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-mod=mod \
|
||||
@@ -175,7 +175,7 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
|
||||
# CVE-2026-41178 (baggage header parsing dropped its raw-length cap, allowing
|
||||
# resource exhaustion via an oversized header, present through v1.43.0) so
|
||||
# that the compose binary scans completely clean; v1.44.0 is also grpc
|
||||
# v1.83.1's minimum below. It also bumps github.com/moby/go-archive to v0.3.0
|
||||
# v1.83.2's minimum below. It also bumps github.com/moby/go-archive to v0.3.0
|
||||
# for CVE-2026-17106 (HIGH), where a crafted tar archive can write outside the
|
||||
# extraction directory; compose pulls the same archive code in transitively
|
||||
# through buildkit.
|
||||
@@ -190,14 +190,14 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
|
||||
# daemon-side (containerd's CRI service) and is not reached by compose at all,
|
||||
# so this is defense-in-depth rather than a live exposure.
|
||||
#
|
||||
# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.83.1 to
|
||||
# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.83.2 to
|
||||
# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues)
|
||||
# and CVE-2026-84304 (unauthenticated peer OOM via fragmented HTTP/2 DATA
|
||||
# frames buffered per-message), golang.org/x/text from v0.38.0 to v0.41.0 to
|
||||
# clear CVE-2026-56852 (norm.Iter infinite loop on crafted input) and satisfy
|
||||
# x/crypto's minimum, golang.org/x/net from v0.55.0 to v0.57.0 to clear
|
||||
# CVE-2026-46600 (dnsmessage denial of service) and satisfy x/crypto's minimum
|
||||
# version, and golang.org/x/crypto from v0.53.0 to v0.55.0 to clear
|
||||
# x/crypto's minimum, golang.org/x/net from v0.55.0 to v0.58.0 to clear
|
||||
# CVE-2026-46600 (dnsmessage denial of service) and satisfy grpc v1.83.2's
|
||||
# minimum version, and golang.org/x/crypto from v0.53.0 to v0.55.0 to clear
|
||||
# CVE-2026-56854 (SSH host-key verification bypass).
|
||||
# Base image pinned by digest (same image as cli-builder above) so both
|
||||
# source builds share an identical, immutable Go toolchain.
|
||||
@@ -222,10 +222,11 @@ RUN mkdir -p /build
|
||||
|
||||
# Patch otel/sdk and exporters from v1.42.0 → v1.44.0 to clear CVE-2026-39883,
|
||||
# CVE-2026-39882, and CVE-2026-41178 (present through v1.43.0; v1.44.0 is also
|
||||
# grpc v1.83.1's minimum below), bump containerd/v2 from v2.2.3 → v2.2.5 to
|
||||
# grpc v1.83.2's minimum below), bump containerd/v2 from v2.2.3 → v2.2.5 to
|
||||
# clear CVE-2026-46680 plus the CVE-2026-53488 / 53489 / 53492 cluster, bump
|
||||
# google.golang.org/grpc to v1.83.1 to clear GHSA-hrxh-6v49-42gf and
|
||||
# CVE-2026-84304, bump golang.org/x/net to v0.57.0 to clear CVE-2026-46600,
|
||||
# google.golang.org/grpc to v1.83.2 to clear GHSA-hrxh-6v49-42gf,
|
||||
# CVE-2026-84304, and CVE-2026-84445, bump golang.org/x/net to v0.58.0 to clear
|
||||
# CVE-2026-46600,
|
||||
# and bump golang.org/x/crypto to v0.55.0 to clear CVE-2026-56854. The
|
||||
# containerd bump is patch-level; the otel, grpc, x/net, and x/crypto bumps
|
||||
# are minor security releases. None introduce breaking API changes.
|
||||
@@ -241,9 +242,9 @@ RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.44.0 \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.44.0 \
|
||||
github.com/containerd/containerd/v2@v2.2.5 \
|
||||
google.golang.org/grpc@v1.83.1 \
|
||||
google.golang.org/grpc@v1.83.2 \
|
||||
golang.org/x/text@v0.41.0 \
|
||||
golang.org/x/net@v0.57.0 \
|
||||
golang.org/x/net@v0.58.0 \
|
||||
golang.org/x/crypto@v0.55.0 \
|
||||
github.com/moby/go-archive@v0.3.0 && \
|
||||
go mod tidy
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let runGitOpsSourceRecovery: typeof import('../bootstrap/startup').runGitOpsSourceRecovery;
|
||||
let GitSourceService: typeof import('../services/GitSourceService').GitSourceService;
|
||||
let gitSourceServiceModule: typeof import('../services/GitSourceService');
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ runGitOpsSourceRecovery } = await import('../bootstrap/startup'));
|
||||
gitSourceServiceModule = await import('../services/GitSourceService');
|
||||
({ GitSourceService } = gitSourceServiceModule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('runGitOpsSourceRecovery', () => {
|
||||
it('recovers unsettled reconcile attempts before sweeping the managed area', async () => {
|
||||
const order: string[] = [];
|
||||
// Recovery yields before recording itself: if the sweep were ever
|
||||
// started concurrently instead of strictly after recovery resolves,
|
||||
// the sweep's own synchronous push would land first and this would
|
||||
// catch it, rather than merely proving call order at invocation
|
||||
// time.
|
||||
vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts')
|
||||
.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
order.push('recover');
|
||||
});
|
||||
vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans')
|
||||
.mockImplementation(async () => { order.push('sweep'); });
|
||||
|
||||
await runGitOpsSourceRecovery();
|
||||
|
||||
expect(order).toEqual(['recover', 'sweep']);
|
||||
});
|
||||
|
||||
it('still runs the sweep when recovery itself throws, tolerating the failure', async () => {
|
||||
const order: string[] = [];
|
||||
vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts')
|
||||
.mockImplementation(async () => { throw new Error('simulated recovery failure'); });
|
||||
vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans')
|
||||
.mockImplementation(async () => { order.push('sweep'); });
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(runGitOpsSourceRecovery()).resolves.toBeUndefined();
|
||||
|
||||
expect(order).toEqual(['sweep']);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still resolves when the sweep itself throws, tolerating the failure rather than aborting startup', async () => {
|
||||
const recoverSpy = vi.spyOn(GitSourceService.getInstance(), 'recoverUnsettledReconcileAttempts')
|
||||
.mockImplementation(async () => {});
|
||||
vi.spyOn(gitSourceServiceModule, 'sweepGitManifestOrphans')
|
||||
.mockImplementation(async () => { throw new Error('simulated sweep failure'); });
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await expect(runGitOpsSourceRecovery()).resolves.toBeUndefined();
|
||||
|
||||
expect(recoverSpy).toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startServer source ordering', () => {
|
||||
it('calls runGitOpsSourceRecovery before starting SourceController, so recovery and the sweep always precede the controller poll loop', () => {
|
||||
// A structural check, not a behavioral one: startServer drives ~25
|
||||
// unrelated services and isn't practical to run end to end in a
|
||||
// test. What matters here is the one property a future edit could
|
||||
// silently break: SourceController must never start before
|
||||
// recovery and the sweep have both awaited to completion. Comments
|
||||
// are stripped first so a mention of either symbol in prose can't
|
||||
// satisfy the match, and matching is whitespace/chaining-tolerant
|
||||
// so a harmless reformat (line-wrapped method chain, a `const`
|
||||
// extracted for the controller instance) doesn't false-fail this.
|
||||
const source = fs.readFileSync(path.join(__dirname, '../bootstrap/startup.ts'), 'utf-8');
|
||||
const startServerStart = source.indexOf('export async function startServer');
|
||||
// startServer is the last top-level declaration in this file today;
|
||||
// if that ever changes, bound this slice to its closing brace
|
||||
// instead of running to end of file.
|
||||
const startServerBody = source.slice(startServerStart);
|
||||
const code = startServerBody.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||
const reconcileCallIndex = code.search(/await\s+runGitOpsSourceRecovery\s*\(/);
|
||||
const controllerStartIndex = code.search(/SourceController\s*\.\s*getInstance\s*\(\s*\)\s*\.\s*start\s*\(/);
|
||||
expect(reconcileCallIndex).toBeGreaterThan(-1);
|
||||
expect(controllerStartIndex).toBeGreaterThan(-1);
|
||||
expect(reconcileCallIndex).toBeLessThan(controllerStartIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Route coverage for the canonical build identity:
|
||||
*
|
||||
* - GET /api/build-info is proxy-exempt (always served by the control
|
||||
* instance), requires a signed-in human session (rejects machine / API-token
|
||||
* credentials), redacts hardened image references to non-admins via
|
||||
* `restricted: true`, and never mislabels a redacted field "Unknown".
|
||||
* - GET /api/meta exposes only the bounded `buildChannel` enum on the public
|
||||
* surface and never leaks the running image reference.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let adminAuth: string;
|
||||
let viewerAuth: string;
|
||||
let machineAuth: string;
|
||||
let remoteNodeId: number;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let SelfIdentityService: typeof import('../services/SelfIdentityService').default;
|
||||
|
||||
const IMAGE_ID = 'b'.repeat(64);
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
|
||||
function mockBuildInfo(over: Record<string, unknown> = {}) {
|
||||
const svc = SelfIdentityService.getInstance();
|
||||
vi.spyOn(svc, 'getBuildInfo').mockReturnValue({
|
||||
version: '0.97.1',
|
||||
channel: 'dev',
|
||||
imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234',
|
||||
imageId: IMAGE_ID,
|
||||
revision: 'dev-abc1234',
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
SelfIdentityService = (await import('../services/SelfIdentityService')).default;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
remoteNodeId = db.addNode({
|
||||
name: 'build-info-remote',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: 'http://127.0.0.1:1',
|
||||
api_token: 'build-info-remote-token',
|
||||
});
|
||||
// A signed-in non-admin human session (role resolved from the DB row).
|
||||
db.addUser({
|
||||
username: 'build-info-viewer',
|
||||
password_hash: await bcrypt.hash('pw', 1),
|
||||
role: 'viewer',
|
||||
});
|
||||
|
||||
adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||
viewerAuth = `Bearer ${jwt.sign({ username: 'build-info-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||
// node_proxy machine credential: authMiddleware maps it to role admin with
|
||||
// userId 0, so requireUserSession must reject it as not a human session.
|
||||
machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('GET /api/build-info auth', () => {
|
||||
it('requires authentication', async () => {
|
||||
mockBuildInfo();
|
||||
const res = await request(app).get('/api/build-info');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects node_proxy machine credentials (not a human session)', async () => {
|
||||
mockBuildInfo();
|
||||
const res = await request(app).get('/api/build-info').set('Authorization', machineAuth);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('SESSION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/build-info is proxy-exempt', () => {
|
||||
it('serves locally even when x-node-id targets a remote node', async () => {
|
||||
mockBuildInfo();
|
||||
// The remote node's api_url is a closed loopback port. A 502 would mean the
|
||||
// proxy intercepted the request; anything else proves the local handler
|
||||
// matched, exactly as the existing /api/nodes proxy-exempt test asserts.
|
||||
const res = await withLoopbackTargetProtection(() => request(app)
|
||||
.get('/api/build-info')
|
||||
.set('Authorization', adminAuth)
|
||||
.set('x-node-id', String(remoteNodeId)));
|
||||
expect(res.status).not.toBe(502);
|
||||
expect(res.body.channel).toBe('dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/build-info as admin', () => {
|
||||
it('returns the full dev identity for a dev image (regression: semver still previous stable)', async () => {
|
||||
mockBuildInfo();
|
||||
const res = await request(app).get('/api/build-info').set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.version).toBe('0.97.1');
|
||||
expect(res.body.channel).toBe('dev');
|
||||
expect(res.body.imageChannel).toBe('community');
|
||||
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
|
||||
expect(res.body.imageId).toBe(IMAGE_ID);
|
||||
expect(res.body.revision).toBe('dev-abc1234');
|
||||
expect(res.body.restricted).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the bounded imageChannel for a hardened image', async () => {
|
||||
mockBuildInfo({
|
||||
channel: 'stable',
|
||||
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1',
|
||||
});
|
||||
const res = await request(app).get('/api/build-info').set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.channel).toBe('stable');
|
||||
expect(res.body.imageChannel).toBe('hardened');
|
||||
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-hardened:0.97.1');
|
||||
expect(res.body.restricted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/build-info as a non-admin', () => {
|
||||
it('redacts a hardened image reference and revision to a non-admin via restricted:true', async () => {
|
||||
mockBuildInfo({
|
||||
channel: 'stable',
|
||||
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1',
|
||||
revision: `sha256:${DIGEST}`,
|
||||
});
|
||||
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
|
||||
expect(res.status).toBe(200);
|
||||
// The build channel stays stable; the procurement channel is what gates
|
||||
// redaction. Both reference fields are nulled with restricted:true so the
|
||||
// UI can label them "Restricted", never "Unknown".
|
||||
expect(res.body.channel).toBe('stable');
|
||||
expect(res.body.imageChannel).toBe('hardened');
|
||||
expect(res.body.imageRef).toBeNull();
|
||||
expect(res.body.revision).toBeNull();
|
||||
expect(res.body.restricted).toBe(true);
|
||||
// The image ID is not a registry reference and is always returned.
|
||||
expect(res.body.imageId).toBe(IMAGE_ID);
|
||||
});
|
||||
|
||||
it('does not redact a community image for a non-admin', async () => {
|
||||
mockBuildInfo();
|
||||
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
|
||||
expect(res.body.revision).toBe('dev-abc1234');
|
||||
expect(res.body.restricted).toBe(false);
|
||||
});
|
||||
|
||||
it('reports unknown procurement channel on bare metal without a running reference', async () => {
|
||||
mockBuildInfo({ channel: 'unknown', imageRef: null, revision: null });
|
||||
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
|
||||
expect(res.status).toBe(200);
|
||||
// No reference means no classification, and no redaction can apply.
|
||||
expect(res.body.channel).toBe('unknown');
|
||||
expect(res.body.imageChannel).toBe('unknown');
|
||||
expect(res.body.imageRef).toBeNull();
|
||||
expect(res.body.revision).toBeNull();
|
||||
expect(res.body.restricted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/build-info awaits revision enrichment', () => {
|
||||
it('blocks the response until the enrichment settle promise resolves', async () => {
|
||||
mockBuildInfo();
|
||||
const svc = SelfIdentityService.getInstance();
|
||||
let release!: () => void;
|
||||
vi.spyOn(svc, 'whenRevisionResolved').mockImplementation(
|
||||
() => new Promise<void>((res) => { release = res; }),
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const pending = request(app)
|
||||
.get('/api/build-info')
|
||||
.set('Authorization', adminAuth)
|
||||
.then((res) => { settled = true; return res; });
|
||||
|
||||
// Give the route a tick to reach the await. It must not have responded yet,
|
||||
// proving a transient null is never the settled value of a success.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
release();
|
||||
const res = await pending;
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.revision).toBe('dev-abc1234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/meta buildChannel', () => {
|
||||
it('exposes the bounded build channel on the public endpoint', async () => {
|
||||
mockBuildInfo();
|
||||
const res = await request(app).get('/api/meta');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.buildChannel).toBe('dev');
|
||||
});
|
||||
|
||||
it('never leaks the running image reference on the public endpoint', async () => {
|
||||
mockBuildInfo();
|
||||
const res = await request(app).get('/api/meta');
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain('ghcr.io/studio-saelix/sencho-dev');
|
||||
expect(body).not.toContain('dev-abc1234');
|
||||
});
|
||||
|
||||
it('omits buildChannel when the running image reference is unknown', async () => {
|
||||
mockBuildInfo({ imageRef: null, channel: 'unknown', revision: null });
|
||||
const res = await request(app).get('/api/meta');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.buildChannel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Truth table for classifyBuildChannel, the canonical build-identity classifier.
|
||||
* Answers "is this a dev, preview, or stable build" from the image reference
|
||||
* alone, independent of the packaged semver.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyBuildChannel } from '../helpers/selfUpdateCompose';
|
||||
|
||||
describe('classifyBuildChannel', () => {
|
||||
it('classifies the dev repository as dev regardless of tag', () => {
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev')).toBe('dev');
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev-abc1234')).toBe('dev');
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:latest')).toBe('dev');
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev@sha256:abc')).toBe('dev');
|
||||
});
|
||||
|
||||
it('classifies stable-repo preview tags as preview', () => {
|
||||
expect(classifyBuildChannel('saelix/sencho:pr-42')).toBe('preview');
|
||||
expect(classifyBuildChannel('saelix/sencho:preview-abc1234')).toBe('preview');
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:pr-7')).toBe('preview');
|
||||
});
|
||||
|
||||
it('classifies stable-repo release/floating tags as stable', () => {
|
||||
expect(classifyBuildChannel('saelix/sencho:0.97.1')).toBe('stable');
|
||||
expect(classifyBuildChannel('saelix/sencho:latest')).toBe('stable');
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:v1.2.3')).toBe('stable');
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe('stable');
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:latest')).toBe('stable');
|
||||
});
|
||||
|
||||
it('treats a dev-<sha> tag as preview-matching-tolerant (still dev repo wins)', () => {
|
||||
// dev repo takes precedence over the preview/stable tag classification
|
||||
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:pr-42')).toBe('dev');
|
||||
});
|
||||
|
||||
it('classifies unknown repositories as unknown', () => {
|
||||
expect(classifyBuildChannel('ubuntu:22.04')).toBe('unknown');
|
||||
expect(classifyBuildChannel('registry.example.com/private/app:1.0')).toBe('unknown');
|
||||
expect(classifyBuildChannel('')).toBe('unknown');
|
||||
expect(classifyBuildChannel(' ')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@ function makeClone(files: Record<string, string>): string {
|
||||
}
|
||||
|
||||
const REPO = { repo_url: 'https://github.com/example/repo.git', branch: 'main' };
|
||||
const NO_CANDIDATE_CLAIMS = { complete: true as const, dirs: new Set<string>() };
|
||||
|
||||
function seedGitSource(stackName: string): void {
|
||||
DatabaseService.getInstance().upsertGitSource({
|
||||
@@ -731,6 +732,59 @@ describe('promoteGeneration', () => {
|
||||
});
|
||||
|
||||
describe('sweepManagedArea (crash recovery)', () => {
|
||||
it('does not delete a candidate when its completion marker cannot be inspected', async () => {
|
||||
const svc = GitProjectManifestService.getInstance();
|
||||
const stackName = 'sweep-candidate-marker-io';
|
||||
const candidateAbs = path.join(tmpDir, 'git-managed', '1', stackName, 'generations', 'candidate-marker-io');
|
||||
const markerPath = path.join(candidateAbs, CANDIDATE_COMPLETE_MARKER);
|
||||
fs.mkdirSync(candidateAbs, { recursive: true });
|
||||
fs.writeFileSync(markerPath, 'complete');
|
||||
const originalAccess = fs.promises.access.bind(fs.promises);
|
||||
const accessSpy = vi.spyOn(fs.promises, 'access').mockImplementation(async (...args: Parameters<typeof fs.promises.access>) => {
|
||||
if (String(args[0]) === markerPath) {
|
||||
throw Object.assign(new Error('candidate marker permission denied'), { code: 'EACCES' });
|
||||
}
|
||||
return originalAccess(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(svc.sweepManagedArea(stackName, {
|
||||
repoUrl: REPO.repo_url,
|
||||
branch: REPO.branch,
|
||||
stackExists: true,
|
||||
candidateClaims: NO_CANDIDATE_CLAIMS,
|
||||
})).rejects.toThrow(/candidate marker permission denied/);
|
||||
expect(fs.existsSync(candidateAbs)).toBe(true);
|
||||
} finally {
|
||||
accessSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces a generations-directory read failure', async () => {
|
||||
const svc = GitProjectManifestService.getInstance();
|
||||
const stackName = 'sweep-generations-read-io';
|
||||
const generationsDir = path.join(tmpDir, 'git-managed', '1', stackName, 'generations');
|
||||
fs.mkdirSync(generationsDir, { recursive: true });
|
||||
const originalReaddir = fs.promises.readdir.bind(fs.promises);
|
||||
const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (...args: Parameters<typeof fs.promises.readdir>) => {
|
||||
if (String(args[0]) === generationsDir) {
|
||||
throw Object.assign(new Error('generations directory unavailable'), { code: 'EIO' });
|
||||
}
|
||||
return originalReaddir(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(svc.sweepManagedArea(stackName, {
|
||||
repoUrl: REPO.repo_url,
|
||||
branch: REPO.branch,
|
||||
stackExists: true,
|
||||
candidateClaims: NO_CANDIDATE_CLAIMS,
|
||||
})).rejects.toThrow(/generations directory unavailable/);
|
||||
} finally {
|
||||
readdirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the previous applied generation when the marker matches the stack dir', async () => {
|
||||
const svc = GitProjectManifestService.getInstance();
|
||||
const stackName = 'sweep-restore';
|
||||
@@ -763,7 +817,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
affected: ['app.env', 'compose.yaml'],
|
||||
});
|
||||
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
|
||||
expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n');
|
||||
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
|
||||
@@ -817,7 +871,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
affected: ['compose.yaml'],
|
||||
});
|
||||
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
|
||||
expect(readStackFile(stackName, 'compose.yaml')).toBe('OPERATOR FIXED ME\n');
|
||||
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
|
||||
@@ -852,7 +906,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
affected: ['compose.yaml'],
|
||||
});
|
||||
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
|
||||
expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n');
|
||||
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
|
||||
@@ -887,7 +941,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
affected: ['compose.yaml'],
|
||||
});
|
||||
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
|
||||
expect(readStackFile(stackName, 'compose.yaml')).toBe('NEW\n');
|
||||
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
|
||||
@@ -927,7 +981,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
affected: ['compose.yaml'],
|
||||
});
|
||||
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
|
||||
expect(readStackFile(stackName, 'compose.yaml')).toBe('PRIOR\n');
|
||||
const row = DatabaseService.getInstance().getGitSource(stackName);
|
||||
@@ -969,7 +1023,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
affected: ['app.env', 'compose.yaml'],
|
||||
});
|
||||
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
|
||||
expect(readStackFile(stackName, 'compose.yaml')).toBe('NEW\n');
|
||||
expect(readStackFile(stackName, 'app.env')).toBe('OPERATOR\n');
|
||||
@@ -981,7 +1035,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
const svc = GitProjectManifestService.getInstance();
|
||||
const stackName = 'sweep-orphan';
|
||||
await svc.writeManifest(stackName, buildManifest(stackName, [managedEntry({ materializedPath: 'compose.yaml' })]));
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: false });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: false, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
expect(await svc.readManifest(stackName, REPO.repo_url, REPO.branch)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1059,7 +1113,7 @@ describe('detach crash recovery', () => {
|
||||
writeStackFile(stackName, 'compose.yaml', 'services:\n web:\n image: nginx:new\n');
|
||||
expect(await svc.stageManagedAreaForDetach(stackName)).toBe(true);
|
||||
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
|
||||
expect(readStackFile(stackName, 'compose.yaml')).toBe(original.toString('utf8'));
|
||||
const restored = await svc.readManifest(stackName, REPO.repo_url, REPO.branch);
|
||||
@@ -1303,7 +1357,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
|
||||
});
|
||||
fs.mkdirSync(path.join(tmpDir, 'git-managed', '1', stackName), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER), '{"v":3 torn', 'utf8');
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true });
|
||||
await svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS });
|
||||
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
|
||||
expect(DatabaseService.getInstance().getGitSource(stackName)?.manifest_state).toBe('migration_required');
|
||||
});
|
||||
@@ -1325,7 +1379,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
|
||||
}), 'utf8');
|
||||
const stateSpy = vi.spyOn(DatabaseService.getInstance(), 'setGitSourceManifestState');
|
||||
try {
|
||||
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).resolves.toBeUndefined();
|
||||
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).resolves.toBeUndefined();
|
||||
expect(stateSpy).toHaveBeenCalledWith(stackName, null, 'migration_required', null);
|
||||
} finally {
|
||||
stateSpy.mockRestore();
|
||||
@@ -1343,7 +1397,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
|
||||
throw new Error('database unavailable');
|
||||
});
|
||||
try {
|
||||
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).rejects.toThrow(/database unavailable/);
|
||||
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).rejects.toThrow(/database unavailable/);
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
} finally {
|
||||
stateSpy.mockRestore();
|
||||
@@ -1371,7 +1425,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
|
||||
return originalAccess(...args);
|
||||
});
|
||||
try {
|
||||
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true })).rejects.toThrow(/permission denied/);
|
||||
await expect(svc.sweepManagedArea(stackName, { repoUrl: REPO.repo_url, branch: REPO.branch, stackExists: true, candidateClaims: NO_CANDIDATE_CLAIMS })).rejects.toThrow(/permission denied/);
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
} finally {
|
||||
accessSpy.mockRestore();
|
||||
|
||||
@@ -13,6 +13,38 @@ const mockMarkReconciling = vi.fn().mockReturnValue(true);
|
||||
const mockMarkImmediateVerified = vi.fn().mockReturnValue(true);
|
||||
const mockGet = vi.fn();
|
||||
const mockCompensate = vi.fn();
|
||||
const mockGitOpsApplication = {
|
||||
id: 'gitops-app',
|
||||
lifecycle_status: 'active',
|
||||
stack_name: 'app',
|
||||
candidate_generation_id: null,
|
||||
};
|
||||
const mockGitOpsStore = {
|
||||
getLiveDirectApplication: vi.fn().mockReturnValue(mockGitOpsApplication),
|
||||
getApplication: vi.fn().mockReturnValue(mockGitOpsApplication),
|
||||
getGeneration: vi.fn().mockReturnValue(undefined),
|
||||
getSettledAttempt: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
const mockGitOpsTransitions = {
|
||||
allocateReconcileAttempt: vi.fn().mockReturnValue({ operationId: 'gitops-app:attempt:1', reserved: true }),
|
||||
settleReconcileAttempt: vi.fn().mockReturnValue({ settled: true }),
|
||||
};
|
||||
|
||||
vi.mock('../services/gitops/store', () => ({
|
||||
GitOpsStore: {
|
||||
getInstance: () => mockGitOpsStore,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/gitops/transitions', async () => {
|
||||
const actual = await vi.importActual<typeof import('../services/gitops/transitions')>('../services/gitops/transitions');
|
||||
return {
|
||||
...actual,
|
||||
GitOpsTransitions: {
|
||||
getInstance: () => mockGitOpsTransitions,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/StackUpdateRecoveryService', () => ({
|
||||
StackUpdateRecoveryService: {
|
||||
@@ -129,10 +161,6 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
setGitSourceLastPlan: mockSetGitSourceLastPlan,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
getStackProjectEnvFiles: vi.fn().mockReturnValue([]),
|
||||
// The apply path now asks whether this stack has a GitOps application.
|
||||
// These fixtures predate the revision-state model, so the lookup finds
|
||||
// nothing and every GitOps producer stays a no-op, which is exactly the
|
||||
// behavior an install with pre-existing Git stacks gets.
|
||||
getDb: () => ({
|
||||
prepare: () => ({ get: () => undefined, all: () => [], run: () => ({ changes: 0 }) }),
|
||||
transaction: (fn: () => unknown) => () => fn(),
|
||||
@@ -197,7 +225,7 @@ describe('git-source apply recovery (R1)', () => {
|
||||
v: 4,
|
||||
files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' },
|
||||
contextDir: null,
|
||||
candidateRelPath: 'generations/cand',
|
||||
candidateRelPath: 'generations/candidate-abc1234deadbeef',
|
||||
inventory: {
|
||||
inputs: [],
|
||||
refusals: [],
|
||||
@@ -246,7 +274,7 @@ describe('git-source apply recovery (R1)', () => {
|
||||
version: 4,
|
||||
files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }],
|
||||
contextDir: null,
|
||||
candidateRelPath: 'generations/cand',
|
||||
candidateRelPath: 'generations/candidate-abc1234deadbeef',
|
||||
inventory: { inputs: [], refusals: [], buildContexts: [] },
|
||||
planFingerprint: 'fp-test',
|
||||
planSchemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION,
|
||||
|
||||
@@ -26,11 +26,13 @@ import { ComposeService } from '../services/ComposeService';
|
||||
import { GitSourceService, GitSourceError } from '../services/GitSourceService';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { GitOpsTransitions } from '../services/gitops/transitions';
|
||||
import { deliveryKey } from '../services/gitops/triggers';
|
||||
import { insertHistory } from '../services/gitops/history';
|
||||
import type { GitOpsApplicationRow } from '../services/gitops/types';
|
||||
import { PROXY_DEPLOY_ACTOR_HEADER, PROXY_DEPLOY_SOURCE_HEADER } from '../services/license-headers';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
import { directApplicationFixture } from './helpers/gitopsFixtures';
|
||||
import { ROLE_PERMISSIONS } from '../middleware/permissions';
|
||||
|
||||
// ── Hoisted mocks (must come before importing the app) ─────────────────
|
||||
|
||||
@@ -75,8 +77,17 @@ function adminToken(): string {
|
||||
return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
}
|
||||
|
||||
function viewerToken(): string {
|
||||
return jwt.sign({ username: 'viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
}
|
||||
|
||||
function nodeAdminToken(): string {
|
||||
return jwt.sign({ username: 'node-admin', role: 'node-admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
DatabaseService.getInstance().addUser({ username: 'node-admin', password_hash: 'test', role: 'node-admin' });
|
||||
({ app } = await import('../index'));
|
||||
|
||||
// Seed a real stack directory so the PUT handler's existence guard is satisfied
|
||||
@@ -646,6 +657,253 @@ describe('POST /api/stacks/:stackName/git-source/webhook-pull status codes', ()
|
||||
expect(res.status).toBe(200);
|
||||
pullSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('passes a remote webhook delivery id into the durable pull path', async () => {
|
||||
seedGitSource('webhook-delivery-id');
|
||||
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull')
|
||||
.mockResolvedValue({ status: 'success', message: 'Pending update ready at abc1234.' });
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/webhook-delivery-id/git-source/webhook-pull')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ deliveryId: 'webhook:42:provider-delivery-1' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pullSpy).toHaveBeenCalledWith('webhook-delivery-id', true, 'webhook:42:provider-delivery-1');
|
||||
pullSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('requires stack:deploy when a redelivery carries a persisted deploy intent', async () => {
|
||||
const stackName = 'webhook-delivery-deploy-auth';
|
||||
const applicationId = 'webhook-delivery-deploy-auth-app';
|
||||
const deliveryId = 'webhook:control:42:provider-delivery-deploy';
|
||||
seedGitSource(stackName);
|
||||
GitOpsStore.getInstance().insertApplication(directApplicationFixture(applicationId, stackName));
|
||||
GitOpsTransitions.getInstance().reserveReconcileAttempt(
|
||||
applicationId,
|
||||
{
|
||||
operationId: deliveryKey('webhook', 'fetch', deliveryId),
|
||||
actor: 'system:webhook',
|
||||
trigger: 'webhook',
|
||||
at: Date.now(),
|
||||
},
|
||||
undefined,
|
||||
{ autoApply: true, deploy: true },
|
||||
);
|
||||
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull');
|
||||
const originalPermissions = ROLE_PERMISSIONS['node-admin'];
|
||||
ROLE_PERMISSIONS['node-admin'] = originalPermissions.filter((permission) => permission !== 'stack:deploy');
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${stackName}/git-source/webhook-pull`)
|
||||
.set('Authorization', `Bearer ${nodeAdminToken()}`)
|
||||
.send({ deliveryId });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
expect(pullSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ROLE_PERMISSIONS['node-admin'] = originalPermissions;
|
||||
pullSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('requires stack:deploy when a first delivery is configured to auto-apply and deploy', async () => {
|
||||
const stackName = 'webhook-first-delivery-deploy-auth';
|
||||
seedGitSource(stackName);
|
||||
DatabaseService.getInstance().getDb()
|
||||
.prepare('UPDATE stack_git_sources SET auto_apply_on_webhook = 1, auto_deploy_on_apply = 1 WHERE stack_name = ?')
|
||||
.run(stackName);
|
||||
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull');
|
||||
const originalPermissions = ROLE_PERMISSIONS['node-admin'];
|
||||
ROLE_PERMISSIONS['node-admin'] = originalPermissions.filter((permission) => permission !== 'stack:deploy');
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${stackName}/git-source/webhook-pull`)
|
||||
.set('Authorization', `Bearer ${nodeAdminToken()}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
expect(pullSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ROLE_PERMISSIONS['node-admin'] = originalPermissions;
|
||||
pullSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an object', { nested: true }],
|
||||
['a blank string', ' '],
|
||||
['a string over 512 characters', 'x'.repeat(513)],
|
||||
])('rejects %s as a remote webhook delivery id', async (_caseName, deliveryId) => {
|
||||
seedGitSource('webhook-delivery-id-invalid');
|
||||
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull');
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/webhook-delivery-id-invalid/git-source/webhook-pull')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ deliveryId });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(pullSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
pullSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:stackName/git-source/suspend', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/stacks/existing-stack/git-source/suspend');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid stack name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/..%2fescape/git-source/suspend')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('passes the reason through and returns the normalized result', async () => {
|
||||
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend')
|
||||
.mockResolvedValue({ outcome: 'suspended', reason: 'Reconciliation is suspended: maintenance', nextAction: 'resume' });
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/suspend')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ reason: 'maintenance' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.outcome).toBe('suspended');
|
||||
expect(suspendSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ reason: 'maintenance' }));
|
||||
suspendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('omits reason when none is given in the body', async () => {
|
||||
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend')
|
||||
.mockResolvedValue({ outcome: 'suspended', reason: 'Reconciliation is suspended.', nextAction: 'resume' });
|
||||
await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/suspend')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect(suspendSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ reason: undefined }));
|
||||
suspendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('maps a refused suspend to 409', async () => {
|
||||
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend')
|
||||
.mockRejectedValue(new GitSourceError('OPERATION_IN_FLIGHT', 'Cannot suspend existing-stack: source is not live'));
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/suspend')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(409);
|
||||
suspendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('denies without the stack:edit permission', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/suspend')
|
||||
.set('Authorization', `Bearer ${viewerToken()}`)
|
||||
.send({});
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('rejects an oversized reason with 400', async () => {
|
||||
const suspendSpy = vi.spyOn(GitSourceService.getInstance(), 'suspend');
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/suspend')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ reason: 'x'.repeat(513) });
|
||||
expect(res.status).toBe(400);
|
||||
expect(suspendSpy).not.toHaveBeenCalled();
|
||||
suspendSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:stackName/git-source/resume', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/stacks/existing-stack/git-source/resume');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid stack name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/..%2fescape/git-source/resume')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('returns the normalized result', async () => {
|
||||
const resumeSpy = vi.spyOn(GitSourceService.getInstance(), 'resume')
|
||||
.mockResolvedValue({ outcome: 'no_source_change', reason: 'ok', nextAction: 'none' });
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/resume')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.outcome).toBe('no_source_change');
|
||||
expect(resumeSpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ actor: expect.any(String) }));
|
||||
resumeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('denies without the stack:edit permission', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/resume')
|
||||
.set('Authorization', `Bearer ${viewerToken()}`)
|
||||
.send({});
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:stackName/git-source/retry', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/stacks/existing-stack/git-source/retry');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid stack name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/..%2fescape/git-source/retry')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('returns the normalized result', async () => {
|
||||
const retrySpy = vi.spyOn(GitSourceService.getInstance(), 'retry')
|
||||
.mockResolvedValue({ outcome: 'candidate_already_fetched', reason: 'ok', nextAction: 'none' });
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/retry')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.outcome).toBe('candidate_already_fetched');
|
||||
expect(retrySpy).toHaveBeenCalledWith('existing-stack', expect.objectContaining({ actor: expect.any(String) }));
|
||||
retrySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('maps an unexpected failure to 500', async () => {
|
||||
const retrySpy = vi.spyOn(GitSourceService.getInstance(), 'retry')
|
||||
.mockRejectedValue(new Error('unexpected'));
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/retry')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(500);
|
||||
retrySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('denies without the stack:edit permission', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/existing-stack/git-source/retry')
|
||||
.set('Authorization', `Bearer ${viewerToken()}`)
|
||||
.send({});
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/stacks/:stackName/git-source, detach/export contract', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -309,6 +309,10 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -351,6 +355,12 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Failure classification and retry-delay coverage for the GitOps source
|
||||
* controller. classifyFailure is a compile-enforced total map: adding a new
|
||||
* TransportFailureReason or GitSourceErrorCode without updating the lookup
|
||||
* tables here fails the build, not just these tests.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
classifyFailure,
|
||||
nextRetryAt,
|
||||
DEFAULT_TRANSIENT_CEILING,
|
||||
LOW_TRANSIENT_CEILING,
|
||||
type FailureEvidence,
|
||||
} from '../services/gitops/backoff';
|
||||
|
||||
function gitSourceError(code: string, transportReason?: string): FailureEvidence {
|
||||
return { kind: 'git_source_error', code: code as never, transportReason: transportReason as never };
|
||||
}
|
||||
|
||||
describe('classifyFailure', () => {
|
||||
it('classifies a tip-changed race as supersession, not backoff', () => {
|
||||
expect(classifyFailure(gitSourceError('GIT_ERROR', 'tip-changed'))).toEqual({ class: 'supersession' });
|
||||
});
|
||||
|
||||
it('classifies a standalone timeout reason as transient', () => {
|
||||
expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'timeout')))
|
||||
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
|
||||
});
|
||||
|
||||
it('classifies DNS resolution failure (target-unresolved) as transient', () => {
|
||||
expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'target-unresolved')))
|
||||
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
|
||||
});
|
||||
|
||||
it('classifies an exit-coded network timeout as transient', () => {
|
||||
expect(classifyFailure(gitSourceError('NETWORK_TIMEOUT', 'exit')))
|
||||
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
|
||||
});
|
||||
|
||||
it('classifies an exit-coded rate limit as transient', () => {
|
||||
expect(classifyFailure(gitSourceError('RATE_LIMITED', 'exit')))
|
||||
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
|
||||
});
|
||||
|
||||
it('classifies an exit-coded unrecognized git error with a low retry ceiling', () => {
|
||||
expect(classifyFailure(gitSourceError('GIT_ERROR', 'exit')))
|
||||
.toEqual({ class: 'transient', retryCeiling: LOW_TRANSIENT_CEILING });
|
||||
});
|
||||
|
||||
it.each(['invalid-url', 'unsafe-target', 'invalid-ref', 'redirect-scope'])(
|
||||
'classifies %s as permanent configuration',
|
||||
(reason) => {
|
||||
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['git-missing', 'git-old'])('classifies %s as permanent environment', (reason) => {
|
||||
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
|
||||
});
|
||||
|
||||
it('classifies a repository over the size cap as permanent', () => {
|
||||
expect(classifyFailure(gitSourceError('GIT_ERROR', 'size'))).toEqual({ class: 'permanent' });
|
||||
});
|
||||
|
||||
it.each(['ssh-auth-required'])('classifies %s as permanent authorization', (reason) => {
|
||||
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
|
||||
});
|
||||
|
||||
it.each(['AUTH_FAILED', 'SSH_HOST_KEY_FAILED'])('classifies %s (no transport reason) as permanent', (code) => {
|
||||
expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'permanent' });
|
||||
});
|
||||
|
||||
it.each(['ref-not-found', 'unsupported-ref'])('classifies %s as permanent configuration', (reason) => {
|
||||
expect(classifyFailure(gitSourceError('GIT_ERROR', reason))).toEqual({ class: 'permanent' });
|
||||
});
|
||||
|
||||
it.each(['REPO_NOT_FOUND', 'REF_NOT_FOUND', 'REF_DELETED', 'UNSUPPORTED_REF'])(
|
||||
'classifies %s (no transport reason) as permanent',
|
||||
(code) => {
|
||||
expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'permanent' });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['STALE_PLAN', 'PLAN_BLOCKED', 'PLAN_FINGERPRINT_REQUIRED', 'LEGACY_PENDING', 'PLAN_UNAVAILABLE', 'FILE_NOT_FOUND'])(
|
||||
'classifies %s as requiring operator action',
|
||||
(code) => {
|
||||
expect(classifyFailure(gitSourceError(code))).toEqual({ class: 'operator_action_required' });
|
||||
},
|
||||
);
|
||||
|
||||
it('classifies a conflicting in-flight operation for reconciliation, not blind retry', () => {
|
||||
expect(classifyFailure(gitSourceError('OPERATION_IN_FLIGHT'))).toEqual({ class: 'reconcile' });
|
||||
});
|
||||
|
||||
it('classifies an unavailable policy scanner as degraded', () => {
|
||||
expect(classifyFailure({ kind: 'policy_unavailable' })).toEqual({ class: 'degraded' });
|
||||
});
|
||||
|
||||
it('classifies unavailable persistence as transient with no source-stage progress', () => {
|
||||
expect(classifyFailure({ kind: 'persistence_unavailable' }))
|
||||
.toEqual({ class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING });
|
||||
});
|
||||
|
||||
it('classifies an invalid target binding as permanent at the target level', () => {
|
||||
expect(classifyFailure({ kind: 'target_binding_invalid' })).toEqual({ class: 'target_permanent' });
|
||||
});
|
||||
|
||||
it('classifies a temporarily unavailable target as transient at the target level', () => {
|
||||
expect(classifyFailure({ kind: 'target_unavailable' })).toEqual({ class: 'target_transient' });
|
||||
});
|
||||
|
||||
it('classifies a deploy/health failure after a successful apply as its own class, never refetch or reapply', () => {
|
||||
expect(classifyFailure({ kind: 'target_mutation_failed' })).toEqual({ class: 'target_mutation_failed' });
|
||||
});
|
||||
|
||||
it('classifies unavailable Blueprint evaluation as blocked, not retried', () => {
|
||||
expect(classifyFailure({ kind: 'blueprint_unavailable' })).toEqual({ class: 'blocked' });
|
||||
});
|
||||
|
||||
it('classifies an interrupted or unknown-completion operation for reconciliation', () => {
|
||||
expect(classifyFailure({ kind: 'interrupted' })).toEqual({ class: 'reconcile' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextRetryAt', () => {
|
||||
it('computes the base delay with up to +-10% jitter on the first attempt', () => {
|
||||
const now = 1_000_000;
|
||||
const at = nextRetryAt(now, 0);
|
||||
expect(at).toBeGreaterThanOrEqual(now + 54_000);
|
||||
expect(at).toBeLessThanOrEqual(now + 66_000);
|
||||
});
|
||||
|
||||
it('doubles the delay per retry count', () => {
|
||||
const now = 1_000_000;
|
||||
const at = nextRetryAt(now, 3); // 60s * 2^3 = 480s
|
||||
expect(at).toBeGreaterThanOrEqual(now + 432_000);
|
||||
expect(at).toBeLessThanOrEqual(now + 528_000);
|
||||
});
|
||||
|
||||
it('caps the delay at one hour regardless of retry count', () => {
|
||||
const now = 1_000_000;
|
||||
const at = nextRetryAt(now, 20);
|
||||
expect(at).toBeLessThanOrEqual(now + 3_600_000 * 1.1);
|
||||
});
|
||||
|
||||
it('honors a provider retry floor larger than the computed delay', () => {
|
||||
const now = 1_000_000;
|
||||
const at = nextRetryAt(now, 0, 10_000_000);
|
||||
expect(at).toBe(now + 10_000_000);
|
||||
});
|
||||
|
||||
it('ignores a provider retry floor smaller than the computed delay', () => {
|
||||
const now = 1_000_000;
|
||||
const at = nextRetryAt(now, 5, 1_000);
|
||||
expect(at).toBeGreaterThan(now + 1_000);
|
||||
});
|
||||
});
|
||||
@@ -609,6 +609,10 @@ function inlineApp(id: string, blueprintId: number): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
|
||||
@@ -441,6 +441,10 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -483,6 +487,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -707,6 +707,10 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -749,6 +753,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -340,6 +340,10 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -382,6 +386,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -963,6 +963,10 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -1010,6 +1014,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -644,7 +644,7 @@ describe('Direct Git producers drive the revision state', () => {
|
||||
expect(recovered.active_operation_stage).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a stack with no GitOps application untouched', async () => {
|
||||
it('refuses to fetch when a configured stack has no GitOps application', async () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
const store = GitOpsStore.getInstance();
|
||||
const stackName = 'producers-legacy';
|
||||
@@ -681,9 +681,12 @@ describe('Direct Git producers drive the revision state', () => {
|
||||
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
|
||||
|
||||
stageRepo(COMPOSE_V2, 'fffffff6');
|
||||
await svc.pull(stackName, { actor: 'tester' });
|
||||
await expect(svc.pull(stackName, { actor: 'tester' })).rejects.toMatchObject({
|
||||
code: 'GIT_ERROR',
|
||||
message: expect.stringContaining('GitOps tracking is unavailable'),
|
||||
});
|
||||
|
||||
// The pull succeeded operationally and wrote no GitOps rows.
|
||||
// No untracked fetch or GitOps history was written.
|
||||
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
|
||||
const historyRows = (await import('../services/DatabaseService')).DatabaseService
|
||||
.getInstance().getDb()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The accepted-generation contract: a portable, content-only projection of
|
||||
* a gitops_generations row, plus the target-dispatch boundary.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildAcceptedGeneration,
|
||||
BlueprintTargetAdapter,
|
||||
type AcceptedGeneration,
|
||||
} from '../services/gitops/handoff';
|
||||
import type { GitOpsGenerationRow } from '../services/gitops/types';
|
||||
|
||||
function baseRow(overrides: Partial<GitOpsGenerationRow> = {}): GitOpsGenerationRow {
|
||||
return {
|
||||
id: 'gen-1',
|
||||
application_id: 'app-1',
|
||||
commit_sha: 'a'.repeat(40),
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
configured_ref: 'main',
|
||||
resolved_ref_kind: 'branch',
|
||||
repo_identity_json: '{"host":"github.com","pathname":"/example/repo.git"}',
|
||||
manifest_version: 4,
|
||||
candidate_dir: 'generations/candidate-a',
|
||||
applied_dir: 'generations/applied-a-0',
|
||||
expected_invocation_json: '{}',
|
||||
materialization_fingerprint: 'f'.repeat(64),
|
||||
validation_ok: 1,
|
||||
plan_blocked: 0,
|
||||
change_plan_fingerprint: 'fp-1',
|
||||
operation_id: 'op-1',
|
||||
trigger: 'manual',
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAcceptedGeneration', () => {
|
||||
it('decodes identity and lineage fields directly from the row', () => {
|
||||
const gen = buildAcceptedGeneration(baseRow());
|
||||
expect(gen.contractVersion).toBe(1);
|
||||
expect(gen.generationId).toBe('gen-1');
|
||||
expect(gen.applicationId).toBe('app-1');
|
||||
expect(gen.repoIdentity).toEqual({ host: 'github.com', pathname: '/example/repo.git' });
|
||||
expect(gen.configuredRef).toBe('main');
|
||||
expect(gen.commitSha).toBe('a'.repeat(40));
|
||||
expect(gen.resolvedRefKind).toBe('branch');
|
||||
expect(gen.validationOk).toBe(true);
|
||||
expect(gen.trigger).toBe('manual');
|
||||
expect(gen.operationId).toBe('op-1');
|
||||
});
|
||||
|
||||
it('records an explicit limitation for each missing portable field on a legacy row, never inventing evidence', () => {
|
||||
const gen = buildAcceptedGeneration(baseRow());
|
||||
expect(gen.portableManifest).toBeNull();
|
||||
expect(gen.composeInputs).toBeNull();
|
||||
expect(gen.sourcePolicyEvidence).toBeNull();
|
||||
expect(gen.limitations).toEqual(expect.arrayContaining([
|
||||
'portable_manifest_missing',
|
||||
'compose_inputs_missing',
|
||||
'source_policy_evidence_missing',
|
||||
'security_policy_evidence_missing',
|
||||
'support_requirements_missing',
|
||||
'compatibility_requirements_missing',
|
||||
]));
|
||||
});
|
||||
|
||||
it('decodes real evidence when the row carries it, recording no limitation for that field', () => {
|
||||
const gen = buildAcceptedGeneration(baseRow({
|
||||
portable_manifest_json: '{"files":[]}',
|
||||
compose_inputs_json: '{"composeFileOrder":["compose.yaml"]}',
|
||||
}));
|
||||
expect(gen.portableManifest).toEqual({ files: [] });
|
||||
expect(gen.composeInputs).toEqual({ composeFileOrder: ['compose.yaml'] });
|
||||
expect(gen.limitations).not.toContain('portable_manifest_missing');
|
||||
expect(gen.limitations).not.toContain('compose_inputs_missing');
|
||||
});
|
||||
|
||||
it('refuses to build a contract from an unparseable repo identity', () => {
|
||||
expect(() => buildAcceptedGeneration(baseRow({ repo_identity_json: 'not json' }))).toThrow();
|
||||
});
|
||||
|
||||
it('never populates secretCapability with a value, only its absence as capability metadata', () => {
|
||||
const gen = buildAcceptedGeneration(baseRow());
|
||||
expect(gen.secretCapability).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A future field on AcceptedGeneration named like a target-mode concept
|
||||
// (selector, frozen target set, node id, rollout batch, target project
|
||||
// name, local path, or secret value) must fail this compile, not merely
|
||||
// this test run: the contract stays structurally content-only.
|
||||
type AssertNoTargetModeFields<T> = T extends Record<
|
||||
'selector' | 'targetSet' | 'nodeId' | 'nodeIds' | 'rolloutBatch' | 'projectName' | 'candidateDir' | 'secretValue',
|
||||
unknown
|
||||
> ? never : true;
|
||||
const _structurallyContentOnly: AssertNoTargetModeFields<AcceptedGeneration> = true;
|
||||
void _structurallyContentOnly;
|
||||
|
||||
describe('BlueprintTargetAdapter', () => {
|
||||
it('always returns a durable blocked result, never inspecting selectors or placement', async () => {
|
||||
const adapter = new BlueprintTargetAdapter();
|
||||
const gen = buildAcceptedGeneration(baseRow());
|
||||
const result = await adapter.dispatch(gen, { targetMode: 'blueprint', nodeId: null, bindingRevision: null });
|
||||
expect(result.status).toBe('blocked');
|
||||
});
|
||||
});
|
||||
@@ -531,6 +531,10 @@ function application(): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
|
||||
@@ -167,6 +167,10 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Normalized reconcile-outcome coverage. outcomeFromSourceFacet derives from
|
||||
* the existing SourceFacet projection rather than inventing a second status
|
||||
* source, so "no source change" and "converged" cannot silently collapse
|
||||
* into the same result.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { outcomeFromSourceFacet } from '../services/gitops/outcomes';
|
||||
import type { SourceFacet } from '../services/gitops/types';
|
||||
|
||||
const identity = {
|
||||
configuredRepoUrl: 'https://github.com/example/repo.git',
|
||||
repoIdentity: { host: 'github.com', pathname: '/example/repo.git' },
|
||||
configuredRef: 'main',
|
||||
desiredCommitSha: 'a'.repeat(40),
|
||||
fetchedCommitSha: 'a'.repeat(40),
|
||||
candidateGenerationId: null,
|
||||
acceptedGenerationId: 'gen-1',
|
||||
};
|
||||
|
||||
describe('outcomeFromSourceFacet', () => {
|
||||
it('reports no_source_change for an accepted generation, never converged on SHA alone', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'application_generation_accepted' };
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('no_source_change');
|
||||
expect(result.commitSha).toBe('a'.repeat(40));
|
||||
});
|
||||
|
||||
it('reports candidate_already_fetched for a ready candidate', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'candidate_ready' };
|
||||
expect(outcomeFromSourceFacet(facet).outcome).toBe('candidate_already_fetched');
|
||||
});
|
||||
|
||||
it('reports pending_review with a review next action', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'source_review_pending' };
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('pending_review');
|
||||
expect(result.nextAction).toBe('review');
|
||||
});
|
||||
|
||||
it('reports blocked with a resolve_conflict next action for a source conflict', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'source_conflict_blocker' };
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('blocked');
|
||||
expect(result.nextAction).toBe('resolve_conflict');
|
||||
});
|
||||
|
||||
it('reports superseded for a candidate a newer revision replaced', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'source_superseded', supersededGenerationId: 'gen-old' };
|
||||
expect(outcomeFromSourceFacet(facet).outcome).toBe('superseded');
|
||||
});
|
||||
|
||||
it('reports retry_scheduled with the retry time surfaced', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'source_retry_scheduled', retryAt: 12345, retryCount: 2 };
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('retry_scheduled');
|
||||
expect(result.retryAt).toBe(12345);
|
||||
expect(result.nextAction).toBe('none');
|
||||
});
|
||||
|
||||
it('reports suspended with a resume next action', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'source_suspended', suspendedAt: 999, suspendedReason: 'operator paused sync' };
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('suspended');
|
||||
expect(result.nextAction).toBe('resume');
|
||||
expect(result.reason).toContain('operator paused sync');
|
||||
});
|
||||
|
||||
it('reports failed_previous_intact for a source failure, with a retry next action when a retry is scheduled', () => {
|
||||
const facet: SourceFacet = {
|
||||
...identity,
|
||||
status: 'source_failed',
|
||||
failureStage: 'fetch',
|
||||
failureClass: 'permanent',
|
||||
failureAt: 100,
|
||||
retryAt: 200,
|
||||
retryCount: 1,
|
||||
};
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('failed_previous_intact');
|
||||
expect(result.nextAction).toBe('retry');
|
||||
expect(result.retryAt).toBe(200);
|
||||
});
|
||||
|
||||
it('reports failed_previous_intact with no retry next action when no retry is scheduled', () => {
|
||||
const facet: SourceFacet = {
|
||||
...identity,
|
||||
status: 'source_failed',
|
||||
failureStage: 'fetch',
|
||||
failureClass: 'permanent',
|
||||
failureAt: 100,
|
||||
retryAt: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('failed_previous_intact');
|
||||
expect(result.nextAction).toBe('configure_credentials');
|
||||
});
|
||||
|
||||
it('reports recovery_required for an interrupted operation', () => {
|
||||
const facet: SourceFacet = {
|
||||
...identity,
|
||||
status: 'source_unknown',
|
||||
interruptedStage: 'fetch_started',
|
||||
interruptedAt: 100,
|
||||
interruptedOperationId: 'op-1',
|
||||
interruptedGenerationId: null,
|
||||
};
|
||||
expect(outcomeFromSourceFacet(facet).outcome).toBe('recovery_required');
|
||||
});
|
||||
|
||||
it('reports recovery_required with a view_target_results next action when recovery is outstanding', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'recovery_required', recoveryRef: 'rec-1', recoveryGenerationId: 'gen-1' };
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('recovery_required');
|
||||
expect(result.nextAction).toBe('view_target_results');
|
||||
});
|
||||
|
||||
it('reports recovery_required when recovery itself failed, distinguishing that in the reason', () => {
|
||||
const facet: SourceFacet = {
|
||||
...identity,
|
||||
status: 'recovery_failed',
|
||||
recoveryRef: 'rec-1',
|
||||
recoveryGenerationId: 'gen-1',
|
||||
failureClass: 'io_error',
|
||||
failureAt: 100,
|
||||
};
|
||||
const result = outcomeFromSourceFacet(facet);
|
||||
expect(result.outcome).toBe('recovery_required');
|
||||
expect(result.reason).toMatch(/recovery/i);
|
||||
});
|
||||
|
||||
it('reports unknown for an application that is no longer live', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'not_live', lifecycleStatus: 'detached' };
|
||||
expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown');
|
||||
});
|
||||
|
||||
it.each(['not_applicable', 'never_reconciled', 'checking_fetching', 'source_reconcile_required'] as const)(
|
||||
'reports unknown for %s, which has no settled outcome yet',
|
||||
(status) => {
|
||||
const facet = status === 'not_applicable'
|
||||
? ({ status } as SourceFacet)
|
||||
: ({ ...identity, status } as SourceFacet);
|
||||
expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown');
|
||||
},
|
||||
);
|
||||
|
||||
it('reports unknown while an operation is in flight (applying)', () => {
|
||||
const facet: SourceFacet = { ...identity, status: 'applying', activeOperationId: 'op-1', activeGenerationId: 'gen-1' };
|
||||
expect(outcomeFromSourceFacet(facet).outcome).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Durable reconcile-attempt reservation and settlement: a bare history
|
||||
* insert in its own transaction, never through mutateApp, so a reservation
|
||||
* writes no application-row state and can be safely repeated.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import type { GitOpsApplicationRow } from '../services/gitops/types';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
GitOpsStore.resetForTests();
|
||||
GitOpsTransitions.resetForTests();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('reconcile attempt reservation and settlement', () => {
|
||||
it('reserves an attempt without touching application state', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-res', 'res-web'), nodeId: 1, envelope: env('op-act-res') });
|
||||
const before = store.getApplication('app-res')!;
|
||||
|
||||
const result = tx.reserveReconcileAttempt('app-res', env('op-res-1'));
|
||||
|
||||
expect(result.reserved).toBe(true);
|
||||
const after = store.getApplication('app-res')!;
|
||||
expect(after.updated_at).toBe(before.updated_at);
|
||||
expect(after.desired_commit_sha).toBe(before.desired_commit_sha);
|
||||
});
|
||||
|
||||
it('returns reserved: false on a repeated reservation for the same operation', () => {
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-res2', 'res2-web'), nodeId: 1, envelope: env('op-act-res2') });
|
||||
|
||||
const first = tx.reserveReconcileAttempt('app-res2', env('op-res2-1'));
|
||||
const second = tx.reserveReconcileAttempt('app-res2', env('op-res2-1'));
|
||||
|
||||
expect(first.reserved).toBe(true);
|
||||
expect(second.reserved).toBe(false);
|
||||
});
|
||||
|
||||
it('allows two different operations to each reserve their own attempt', () => {
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-res3', 'res3-web'), nodeId: 1, envelope: env('op-act-res3') });
|
||||
|
||||
const first = tx.reserveReconcileAttempt('app-res3', env('op-res3-a'));
|
||||
const second = tx.reserveReconcileAttempt('app-res3', env('op-res3-b'));
|
||||
|
||||
expect(first.reserved).toBe(true);
|
||||
expect(second.reserved).toBe(true);
|
||||
});
|
||||
|
||||
it('settles a reserved attempt and finds it by operation id afterward', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-settle', 'settle-web'), nodeId: 1, envelope: env('op-act-settle') });
|
||||
tx.reserveReconcileAttempt('app-settle', env('op-settle-1'));
|
||||
|
||||
const settleResult = tx.settleReconcileAttempt('app-settle', env('op-settle-1'), {
|
||||
outcome: 'no_source_change',
|
||||
reason: 'Nothing new to fetch.',
|
||||
nextAction: 'none',
|
||||
});
|
||||
|
||||
expect(settleResult.settled).toBe(true);
|
||||
const settled = store.getSettledAttempt('app-settle', 'op-settle-1');
|
||||
expect(settled).toBeDefined();
|
||||
});
|
||||
|
||||
it('settling twice for the same operation is a no-op the second time', () => {
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-settle2', 'settle2-web'), nodeId: 1, envelope: env('op-act-settle2') });
|
||||
tx.reserveReconcileAttempt('app-settle2', env('op-settle2-1'));
|
||||
|
||||
const first = tx.settleReconcileAttempt('app-settle2', env('op-settle2-1'), {
|
||||
outcome: 'no_source_change',
|
||||
reason: 'first',
|
||||
nextAction: 'none',
|
||||
});
|
||||
const second = tx.settleReconcileAttempt('app-settle2', env('op-settle2-1'), {
|
||||
outcome: 'no_source_change',
|
||||
reason: 'second, must not overwrite',
|
||||
nextAction: 'none',
|
||||
});
|
||||
|
||||
expect(first.settled).toBe(true);
|
||||
expect(second.settled).toBe(false);
|
||||
});
|
||||
|
||||
it('has no settled attempt for a reservation that was never settled', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-orphan', 'orphan-web'), nodeId: 1, envelope: env('op-act-orphan') });
|
||||
tx.reserveReconcileAttempt('app-orphan', env('op-orphan-1'));
|
||||
|
||||
expect(store.getSettledAttempt('app-orphan', 'op-orphan-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists an unsettled reservation but not one that has settled', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-unsettled', 'unsettled-web'), nodeId: 1, envelope: env('op-act-unsettled') });
|
||||
tx.reserveReconcileAttempt('app-unsettled', env('op-unsettled-orphan'));
|
||||
tx.reserveReconcileAttempt('app-unsettled', env('op-unsettled-done'));
|
||||
tx.settleReconcileAttempt('app-unsettled', env('op-unsettled-done'), {
|
||||
outcome: 'no_source_change',
|
||||
reason: 'done',
|
||||
nextAction: 'none',
|
||||
});
|
||||
|
||||
const unsettled = store.listUnsettledReconcileAttempts();
|
||||
const operationIds = unsettled.filter((r) => r.application_id === 'app-unsettled').map((r) => r.operation_id);
|
||||
expect(operationIds).toContain('op-unsettled-orphan');
|
||||
expect(operationIds).not.toContain('op-unsettled-done');
|
||||
});
|
||||
|
||||
it('gets the started row for one exact attempt, or undefined when it was never reserved', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-started', 'started-web'), nodeId: 1, envelope: env('op-act-started') });
|
||||
tx.reserveReconcileAttempt('app-started', env('op-started-1'));
|
||||
|
||||
expect(store.getStartedAttempt('app-started', 'op-started-1')).toBeDefined();
|
||||
expect(store.getStartedAttempt('app-started', 'op-never-reserved')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pages past a permanently unsettled row instead of returning it forever on every call with the same cursor', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-cursor', 'cursor-web'), nodeId: 1, envelope: env('op-act-cursor') });
|
||||
tx.reserveReconcileAttempt('app-cursor', { operationId: 'op-cursor-stuck', actor: 'tester', trigger: 'manual', at: 1_000 });
|
||||
tx.reserveReconcileAttempt('app-cursor', { operationId: 'op-cursor-next', actor: 'tester', trigger: 'manual', at: 2_000 });
|
||||
|
||||
const firstPage = store.listUnsettledReconcileAttempts(1);
|
||||
expect(firstPage.map((r) => r.operation_id)).toEqual(['op-cursor-stuck']);
|
||||
const cursor = { createdAt: firstPage[0].created_at, id: firstPage[0].id };
|
||||
// Simulate op-cursor-stuck being permanently unrecoverable: it is never
|
||||
// settled, so a caller must page past it using the cursor rather than
|
||||
// seeing it again on the next call.
|
||||
const secondPage = store.listUnsettledReconcileAttempts(1, cursor);
|
||||
expect(secondPage.map((r) => r.operation_id)).toEqual(['op-cursor-next']);
|
||||
});
|
||||
|
||||
it('allocates a fresh attemptSeq-derived operation id and reserves it in one transaction', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-alloc', 'alloc-web'), nodeId: 1, envelope: env('op-act-alloc') });
|
||||
const before = store.getApplication('app-alloc')!;
|
||||
|
||||
const first = tx.allocateReconcileAttempt('app-alloc', 'tester', 'manual', Date.now());
|
||||
const second = tx.allocateReconcileAttempt('app-alloc', 'tester', 'manual', Date.now());
|
||||
|
||||
expect(first.reserved).toBe(true);
|
||||
expect(second.reserved).toBe(true);
|
||||
expect(first.operationId).not.toBe(second.operationId);
|
||||
const after = store.getApplication('app-alloc')!;
|
||||
expect(after.attempt_seq).toBe(before.attempt_seq + 2);
|
||||
});
|
||||
|
||||
it('rolls back the allocated sequence when reservation insertion fails', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-alloc-rollback', 'alloc-rollback-web'), nodeId: 1, envelope: env('op-act-alloc-rollback') });
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
const before = store.getApplication('app-alloc-rollback')!;
|
||||
db.exec(`
|
||||
CREATE TRIGGER fail_reconcile_reservation
|
||||
BEFORE INSERT ON gitops_history
|
||||
WHEN NEW.stage = 'source_reconcile_started'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'simulated reservation insert failure');
|
||||
END
|
||||
`);
|
||||
|
||||
try {
|
||||
expect(() => tx.allocateReconcileAttempt('app-alloc-rollback', 'tester', 'manual', Date.now()))
|
||||
.toThrow('simulated reservation insert failure');
|
||||
expect(store.getApplication('app-alloc-rollback')!.attempt_seq).toBe(before.attempt_seq);
|
||||
expect(store.listUnsettledReconcileAttempts().some((row) => row.application_id === 'app-alloc-rollback')).toBe(false);
|
||||
} finally {
|
||||
db.exec('DROP TRIGGER fail_reconcile_reservation');
|
||||
}
|
||||
});
|
||||
|
||||
it('records a follower link on a reservation made on behalf of a coalesced request', () => {
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-follower', 'follower-web'), nodeId: 1, envelope: env('op-act-follower') });
|
||||
|
||||
const leader = tx.allocateReconcileAttempt('app-follower', 'tester', 'manual', Date.now());
|
||||
const follower = tx.allocateReconcileAttempt('app-follower', 'tester', 'manual', Date.now(), leader.operationId);
|
||||
|
||||
expect(follower.reserved).toBe(true);
|
||||
const started = DatabaseService.getInstance().getDb()
|
||||
.prepare("SELECT after_json FROM gitops_history WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_started'")
|
||||
.get('app-follower', follower.operationId) as { after_json: string };
|
||||
expect(JSON.parse(started.after_json)).toEqual({ followerOf: leader.operationId });
|
||||
});
|
||||
|
||||
it('records the original webhook delivery intent on its stable reservation', () => {
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-delivery-intent', 'delivery-intent-web'), nodeId: 1, envelope: env('op-act-delivery-intent') });
|
||||
|
||||
tx.reserveReconcileAttempt(
|
||||
'app-delivery-intent',
|
||||
env('webhook:fetch:delivery-intent'),
|
||||
undefined,
|
||||
{ autoApply: true, deploy: false },
|
||||
);
|
||||
|
||||
const started = GitOpsStore.getInstance().getStartedAttempt('app-delivery-intent', 'webhook:fetch:delivery-intent')!;
|
||||
expect(JSON.parse(started.after_json)).toEqual({
|
||||
deliveryIntent: { autoApply: true, deploy: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the most recently settled attempt even when both share the same millisecond timestamp', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const tx = GitOpsTransitions.getInstance();
|
||||
tx.activateDirect({ application: app('app-latest', 'latest-web'), nodeId: 1, envelope: env('op-act-latest') });
|
||||
// Same `at` on purpose: created_at alone cannot tell these two apart,
|
||||
// so the query must break the tie by insertion order (rowid), not the
|
||||
// id column, which is a random UUID unrelated to recency.
|
||||
const sameInstant = 5_000;
|
||||
tx.reserveReconcileAttempt('app-latest', { operationId: 'op-latest-1', actor: 'tester', trigger: 'manual', at: sameInstant });
|
||||
tx.settleReconcileAttempt(
|
||||
'app-latest',
|
||||
{ operationId: 'op-latest-1', actor: 'tester', trigger: 'manual', at: sameInstant },
|
||||
{ outcome: 'no_source_change', reason: 'first', nextAction: 'none' },
|
||||
);
|
||||
tx.reserveReconcileAttempt('app-latest', { operationId: 'op-latest-2', actor: 'tester', trigger: 'manual', at: sameInstant });
|
||||
tx.settleReconcileAttempt(
|
||||
'app-latest',
|
||||
{ operationId: 'op-latest-2', actor: 'tester', trigger: 'manual', at: sameInstant },
|
||||
{ outcome: 'retry_scheduled', reason: 'second', nextAction: 'none' },
|
||||
);
|
||||
|
||||
const latest = store.latestSettledAttempt('app-latest');
|
||||
expect(latest?.operation_id).toBe('op-latest-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll and retry eligibility queries', () => {
|
||||
it('lists a source whose next_poll_at has arrived', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({ ...app('app-poll-due', 'poll-due-web'), next_poll_at: 1_000 });
|
||||
const due = store.listSourcesDueForPoll(1_000);
|
||||
expect(due.map((a) => a.id)).toContain('app-poll-due');
|
||||
});
|
||||
|
||||
it('excludes a source whose next_poll_at has not arrived yet', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({ ...app('app-poll-future', 'poll-future-web'), next_poll_at: 5_000 });
|
||||
const due = store.listSourcesDueForPoll(1_000);
|
||||
expect(due.map((a) => a.id)).not.toContain('app-poll-future');
|
||||
});
|
||||
|
||||
it('excludes a suspended source even when its poll time has arrived', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({ ...app('app-poll-susp', 'poll-susp-web'), next_poll_at: 1_000, suspended_at: 500 });
|
||||
const due = store.listSourcesDueForPoll(1_000);
|
||||
expect(due.map((a) => a.id)).not.toContain('app-poll-susp');
|
||||
});
|
||||
|
||||
it('excludes a source with an operation already in flight', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({
|
||||
...app('app-poll-busy', 'poll-busy-web'),
|
||||
next_poll_at: 1_000,
|
||||
active_operation_stage: 'fetch_started',
|
||||
});
|
||||
const due = store.listSourcesDueForPoll(1_000);
|
||||
expect(due.map((a) => a.id)).not.toContain('app-poll-busy');
|
||||
});
|
||||
|
||||
it('excludes a Blueprint-mode application from polling', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({
|
||||
...app('app-poll-bp', 'unused-bp'),
|
||||
stack_name: null,
|
||||
blueprint_id: 42,
|
||||
target_mode: 'blueprint',
|
||||
configured_repo_url: 'https://github.com/org/repo.git',
|
||||
next_poll_at: 1_000,
|
||||
});
|
||||
const due = store.listSourcesDueForPoll(1_000);
|
||||
expect(due.map((a) => a.id)).not.toContain('app-poll-bp');
|
||||
});
|
||||
|
||||
it('lists an application whose retry_at has arrived', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({ ...app('app-retry-due', 'retry-due-web'), retry_at: 1_000 });
|
||||
const due = store.listApplicationsDueForRetry(1_000);
|
||||
expect(due.map((a) => a.id)).toContain('app-retry-due');
|
||||
});
|
||||
|
||||
it('excludes an application with no retry scheduled', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication(app('app-retry-none', 'retry-none-web'));
|
||||
const due = store.listApplicationsDueForRetry(1_000);
|
||||
expect(due.map((a) => a.id)).not.toContain('app-retry-none');
|
||||
});
|
||||
|
||||
it('excludes a suspended application even when its retry time has arrived', () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({ ...app('app-retry-susp', 'retry-susp-web'), retry_at: 1_000, suspended_at: 500 });
|
||||
const due = store.listApplicationsDueForRetry(1_000);
|
||||
expect(due.map((a) => a.id)).not.toContain('app-retry-susp');
|
||||
});
|
||||
});
|
||||
|
||||
function env(operationId: string): EventEnvelope {
|
||||
return { operationId, actor: 'tester', trigger: 'manual', at: Date.now() };
|
||||
}
|
||||
|
||||
function app(id: string, stackName: string): GitOpsApplicationRow {
|
||||
return {
|
||||
id,
|
||||
lifecycle_key: `direct:${stackName}`,
|
||||
lifecycle_status: 'active',
|
||||
target_mode: 'direct',
|
||||
stack_name: stackName,
|
||||
blueprint_id: null,
|
||||
configured_repo_url: 'https://github.com/org/repo.git',
|
||||
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
|
||||
configured_ref: 'main',
|
||||
compose_paths_json: '["compose.yml"]',
|
||||
context_dir: null,
|
||||
sync_env: 0,
|
||||
env_path: null,
|
||||
materialization_fingerprint: 'a'.repeat(64),
|
||||
desired_commit_sha: null,
|
||||
fetched_commit_sha: null,
|
||||
fetched_resolved_ref_kind: null,
|
||||
candidate_generation_id: null,
|
||||
accepted_generation_id: null,
|
||||
candidate_plan_blocked: 0,
|
||||
review_required: 0,
|
||||
artifact_set_id: null,
|
||||
latest_artifact_set_id: null,
|
||||
intent_revision_id: null,
|
||||
rollout_candidate_id: null,
|
||||
rollout_generation_id: null,
|
||||
source_acceptance_ref: null,
|
||||
placement_approval_ref: null,
|
||||
rollout_authorization_ref: null,
|
||||
legacy_combined_approval_ref: null,
|
||||
preflight_fingerprint: null,
|
||||
latest_operation_id: null,
|
||||
active_operation_id: null,
|
||||
active_operation_stage: null,
|
||||
active_operation_at: null,
|
||||
active_generation_id: null,
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
failure_at: null,
|
||||
retry_at: null,
|
||||
retry_count: 0,
|
||||
suspended_at: null,
|
||||
recovery_ref: null,
|
||||
recovery_phase: null,
|
||||
interruption_stage: null,
|
||||
interruption_at: null,
|
||||
interruption_operation_id: null,
|
||||
interruption_generation_id: null,
|
||||
evidence_fresh_at: null,
|
||||
evidence_limitations_json: null,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
}
|
||||
@@ -147,6 +147,10 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -189,6 +193,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,6 +461,10 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -503,6 +507,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,6 +239,61 @@ describe('gitops schema', () => {
|
||||
expect(row?.configured_ref).toBe('v1');
|
||||
});
|
||||
|
||||
it('round-trips the portable generation contract fields, defaulting to null for legacy rows', async () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication(directApp('app-portable', 'portable-web'));
|
||||
store.insertGeneration({
|
||||
...generation('gen-portable-legacy', 'app-portable'),
|
||||
});
|
||||
const legacy = store.getGeneration('gen-portable-legacy');
|
||||
expect(legacy?.portable_manifest_json).toBeNull();
|
||||
expect(legacy?.compose_inputs_json).toBeNull();
|
||||
expect(legacy?.source_policy_evidence_json).toBeNull();
|
||||
expect(legacy?.security_policy_evidence_json).toBeNull();
|
||||
expect(legacy?.support_requirements_json).toBeNull();
|
||||
expect(legacy?.compatibility_requirements_json).toBeNull();
|
||||
|
||||
store.insertGeneration({
|
||||
...generation('gen-portable-new', 'app-portable'),
|
||||
portable_manifest_json: '{"files":[]}',
|
||||
compose_inputs_json: '{"composeFileOrder":["compose.yaml"]}',
|
||||
source_policy_evidence_json: '{"policy":"manual"}',
|
||||
security_policy_evidence_json: '{"status":"allowed"}',
|
||||
support_requirements_json: '{}',
|
||||
compatibility_requirements_json: '{}',
|
||||
});
|
||||
const populated = store.getGeneration('gen-portable-new');
|
||||
expect(populated?.portable_manifest_json).toBe('{"files":[]}');
|
||||
expect(populated?.compose_inputs_json).toBe('{"composeFileOrder":["compose.yaml"]}');
|
||||
expect(populated?.source_policy_evidence_json).toBe('{"policy":"manual"}');
|
||||
});
|
||||
|
||||
it('defaults controller-owned columns to manual, off, and zero on a fresh application', async () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication(directApp('app-ctrl', 'ctrl-web'));
|
||||
const app = store.getApplication('app-ctrl');
|
||||
expect(app?.source_policy).toBe('manual');
|
||||
expect(app?.poll_interval_secs).toBeNull();
|
||||
expect(app?.next_poll_at).toBeNull();
|
||||
expect(app?.attempt_seq).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips a configured poll interval and policy', async () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication({
|
||||
...directApp('app-ctrl2', 'ctrl2-web'),
|
||||
source_policy: 'automatic',
|
||||
poll_interval_secs: 120,
|
||||
next_poll_at: 5000,
|
||||
attempt_seq: 3,
|
||||
});
|
||||
const app = store.getApplication('app-ctrl2');
|
||||
expect(app?.source_policy).toBe('automatic');
|
||||
expect(app?.poll_interval_secs).toBe(120);
|
||||
expect(app?.next_poll_at).toBe(5000);
|
||||
expect(app?.attempt_seq).toBe(3);
|
||||
});
|
||||
|
||||
it('round-trips fetched_resolved_ref_kind on application fetch transitions', async () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication(directApp('app-fetch-kind', 'fetch-web'));
|
||||
@@ -294,6 +349,10 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -362,6 +421,12 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -813,6 +813,10 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -855,6 +859,12 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
||||
actor: 'tester',
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Pure trigger normalization and coalescing-key coverage for the GitOps
|
||||
* source controller. No DB, no store: these are the identity/joining rules
|
||||
* a controller submission goes through before anything durable happens.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { coalesceKey, deliveryKey, type ReconcileRequest } from '../services/gitops/triggers';
|
||||
|
||||
function fetchRequest(overrides: Partial<Extract<ReconcileRequest, { intent: 'fetch' }>> = {}): ReconcileRequest {
|
||||
return {
|
||||
intent: 'fetch',
|
||||
applicationId: 'app-1',
|
||||
stackName: 'web',
|
||||
trigger: 'manual',
|
||||
actor: 'tester',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function applyRequest(overrides: Partial<Extract<ReconcileRequest, { intent: 'apply' }>> = {}): ReconcileRequest {
|
||||
return {
|
||||
intent: 'apply',
|
||||
applicationId: 'app-1',
|
||||
stackName: 'web',
|
||||
trigger: 'manual',
|
||||
actor: 'tester',
|
||||
commitSha: 'a'.repeat(40),
|
||||
planFingerprint: 'fp-1',
|
||||
deploy: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('coalesceKey', () => {
|
||||
it('joins two fetch requests for the same application', () => {
|
||||
expect(coalesceKey(fetchRequest())).toBe(coalesceKey(fetchRequest({ trigger: 'poll' })));
|
||||
});
|
||||
|
||||
it('does not join fetch requests for different applications', () => {
|
||||
expect(coalesceKey(fetchRequest({ applicationId: 'app-1' })))
|
||||
.not.toBe(coalesceKey(fetchRequest({ applicationId: 'app-2' })));
|
||||
});
|
||||
|
||||
it('joins two apply requests with identical commit, fingerprint, and deploy flag', () => {
|
||||
expect(coalesceKey(applyRequest())).toBe(coalesceKey(applyRequest({ trigger: 'webhook' })));
|
||||
});
|
||||
|
||||
it('does not join two applies with different plan fingerprints', () => {
|
||||
expect(coalesceKey(applyRequest({ planFingerprint: 'fp-1' })))
|
||||
.not.toBe(coalesceKey(applyRequest({ planFingerprint: 'fp-2' })));
|
||||
});
|
||||
|
||||
it('does not join two applies with different commits', () => {
|
||||
expect(coalesceKey(applyRequest({ commitSha: 'a'.repeat(40) })))
|
||||
.not.toBe(coalesceKey(applyRequest({ commitSha: 'b'.repeat(40) })));
|
||||
});
|
||||
|
||||
it('does not join two applies that differ only in deploy', () => {
|
||||
expect(coalesceKey(applyRequest({ deploy: false })))
|
||||
.not.toBe(coalesceKey(applyRequest({ deploy: true })));
|
||||
});
|
||||
|
||||
it('never joins a fetch and an apply for the same application', () => {
|
||||
expect(coalesceKey(fetchRequest())).not.toBe(coalesceKey(applyRequest()));
|
||||
});
|
||||
|
||||
it('does not join two fetches for the same applicationId but different stack names', () => {
|
||||
expect(coalesceKey(fetchRequest({ stackName: 'web' })))
|
||||
.not.toBe(coalesceKey(fetchRequest({ stackName: 'other-stack' })));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliveryKey', () => {
|
||||
it('namespaces the same delivery id differently per trigger', () => {
|
||||
expect(deliveryKey('webhook', 'fetch', 'delivery-1')).not.toBe(deliveryKey('api', 'fetch', 'delivery-1'));
|
||||
});
|
||||
|
||||
it('namespaces the same delivery id differently per intent', () => {
|
||||
expect(deliveryKey('webhook', 'fetch', 'delivery-1')).not.toBe(deliveryKey('webhook', 'apply', 'delivery-1'));
|
||||
});
|
||||
|
||||
it('is stable for the same trigger, intent, and delivery id', () => {
|
||||
expect(deliveryKey('webhook', 'fetch', 'delivery-1')).toBe(deliveryKey('webhook', 'fetch', 'delivery-1'));
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,10 @@ export function directApplicationFixture(id: string, stackName: string): GitOpsA
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
|
||||
@@ -1621,6 +1621,41 @@ describe('MonitorService - Sencho dev build check', () => {
|
||||
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
|
||||
});
|
||||
|
||||
// C1: update eligibility is compose-declared. A dev *running* identity must
|
||||
// not make a stable-declared pin eligible for the dev-build detector, and an
|
||||
// unknown running identity must still gate before any registry call. These
|
||||
// two disagreement directions pin that the running identity surfaced by
|
||||
// SelfIdentityService never changes update behavior.
|
||||
it('does not treat a stable-declared pin as eligible even when the running identity is a dev build', async () => {
|
||||
// beforeEach() already mints a dev running imageId; the declared pin is stable.
|
||||
// Reset the version-update inputs: an earlier suppression test queues a
|
||||
// once-value that a stable-pin run would otherwise drain into the version
|
||||
// path, which is not what this test observes.
|
||||
mockGetLatestVersionInfo.mockReset();
|
||||
mockGetPinInfo.mockResolvedValue(STABLE_PIN);
|
||||
|
||||
await runEvaluate();
|
||||
|
||||
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
|
||||
expect(devBuildCalls()).toHaveLength(0);
|
||||
expect(mockSetSystemState).not.toHaveBeenCalledWith('sencho_dev_build_available_digest', expect.anything());
|
||||
});
|
||||
|
||||
it('still gates the registry comparison on a known running image id for an eligible dev-declared pin', async () => {
|
||||
mockGetPinInfo.mockResolvedValue(DEV_PIN);
|
||||
mockGetIdentity.mockReturnValue({
|
||||
containerId: null, containerName: null, composeProjectName: null,
|
||||
imageId: null, networkNames: [], volumeNames: [],
|
||||
});
|
||||
|
||||
await runEvaluate();
|
||||
|
||||
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
|
||||
expect(devBuildCalls()).toHaveLength(0);
|
||||
// Unknown running id takes the retry-sooner path, not the detector path.
|
||||
expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-container parallel fan-out ────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Unit tests for SelfIdentityService.getBuildInfo(): the canonical runtime
|
||||
* build identity (version, channel, imageRef, imageId, revision), the detached
|
||||
* bounded revision enrichment, and the failure-isolation guarantee.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockContainer, mockDocker, mockInspectImage, mockGetSenchoVersion } = vi.hoisted(() => {
|
||||
const mockContainer = { inspect: vi.fn() };
|
||||
const mockDocker = {
|
||||
getContainer: vi.fn(() => mockContainer),
|
||||
getImage: vi.fn(),
|
||||
listImages: vi.fn().mockResolvedValue([]),
|
||||
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
|
||||
listNetworks: vi.fn().mockResolvedValue([]),
|
||||
listContainers: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
return {
|
||||
mockContainer,
|
||||
mockDocker,
|
||||
mockInspectImage: vi.fn(),
|
||||
mockGetSenchoVersion: vi.fn(() => '0.97.1'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDocker: () => mockDocker,
|
||||
getDefaultNodeId: () => 1,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Replace defaultInspectImage so enrichment is deterministic and isolated.
|
||||
vi.mock('../services/selfDevBuildDetect', () => ({
|
||||
defaultInspectImage: (...args: unknown[]) => mockInspectImage(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../services/CapabilityRegistry', () => ({
|
||||
getSenchoVersion: () => mockGetSenchoVersion(),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
|
||||
vi.mock('util', () => ({ promisify: () => vi.fn() }));
|
||||
|
||||
import SelfIdentityService from '../services/SelfIdentityService';
|
||||
|
||||
const FULL_IMAGE_ID_HEX = 'b'.repeat(64);
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
|
||||
const originalHostname = process.env.HOSTNAME;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockContainer.inspect.mockReset();
|
||||
mockDocker.getImage.mockReset();
|
||||
mockInspectImage.mockReset();
|
||||
mockGetSenchoVersion.mockReturnValue('0.97.1');
|
||||
SelfIdentityService.getInstance().resetForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalHostname === undefined) delete process.env.HOSTNAME;
|
||||
else process.env.HOSTNAME = originalHostname;
|
||||
});
|
||||
|
||||
async function initWith(configImage: string | undefined): Promise<SelfIdentityService> {
|
||||
process.env.HOSTNAME = 'sencho-1';
|
||||
mockContainer.inspect.mockResolvedValue({
|
||||
Id: 'a'.repeat(64),
|
||||
Name: '/sencho',
|
||||
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
|
||||
...(configImage !== undefined ? { Config: { Image: configImage } } : {}),
|
||||
NetworkSettings: { Networks: {} },
|
||||
Mounts: [],
|
||||
});
|
||||
const svc = SelfIdentityService.getInstance();
|
||||
await svc.initialize();
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** Enrichment runs detached; poll until the condition holds so assertions are stable. */
|
||||
async function until(assert: () => void): Promise<void> {
|
||||
await vi.waitFor(assert, { timeout: 2000 });
|
||||
}
|
||||
|
||||
describe('SelfIdentityService.getBuildInfo', () => {
|
||||
it('identifies a dev image as DEV even when the packaged semver matches the previous stable', async () => {
|
||||
// The regression case from the ticket: dev image, version still 0.97.1.
|
||||
// The inspect mock must be set before initialize(): enrichment fires
|
||||
// detached during initialize(), before the awaited call returns.
|
||||
mockInspectImage.mockResolvedValue({
|
||||
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
|
||||
Os: 'linux',
|
||||
Architecture: 'amd64',
|
||||
});
|
||||
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`));
|
||||
|
||||
const info = svc.getBuildInfo();
|
||||
expect(info.version).toBe('0.97.1');
|
||||
expect(info.channel).toBe('dev');
|
||||
expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
|
||||
});
|
||||
|
||||
it('derives the revision from a pinned dev-<sha> tag without an image inspect', async () => {
|
||||
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
|
||||
await until(() => expect(svc.getBuildInfo().revision).toBe('dev-abc1234'));
|
||||
|
||||
const info = svc.getBuildInfo();
|
||||
expect(info.channel).toBe('dev');
|
||||
expect(mockInspectImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('classifies a stable image as stable', async () => {
|
||||
mockInspectImage.mockResolvedValue({
|
||||
RepoDigests: [`ghcr.io/studio-saelix/sencho@sha256:${DIGEST}`],
|
||||
Os: 'linux',
|
||||
Architecture: 'amd64',
|
||||
});
|
||||
const svc = await initWith('ghcr.io/studio-saelix/sencho:0.97.1');
|
||||
await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`));
|
||||
|
||||
expect(svc.getBuildInfo().channel).toBe('stable');
|
||||
});
|
||||
|
||||
it('reads unknown for partial metadata (no running image reference)', async () => {
|
||||
const svc = await initWith(undefined);
|
||||
|
||||
const info = svc.getBuildInfo();
|
||||
expect(info.imageRef).toBeNull();
|
||||
expect(info.channel).toBe('unknown');
|
||||
expect(info.revision).toBeNull();
|
||||
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
|
||||
});
|
||||
|
||||
it('keeps the dev channel but null revision when image inspection fails', async () => {
|
||||
mockInspectImage.mockRejectedValue(new Error('docker daemon unreachable'));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
await until(() => expect(warnSpy).toHaveBeenCalled());
|
||||
|
||||
const info = svc.getBuildInfo();
|
||||
expect(info.channel).toBe('dev');
|
||||
expect(info.revision).toBeNull();
|
||||
// C2: a failed enrichment must not corrupt the already-captured core identity.
|
||||
expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
|
||||
});
|
||||
|
||||
it('never inspects the image again on repeated reads', async () => {
|
||||
mockInspectImage.mockResolvedValue({
|
||||
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
|
||||
Os: 'linux',
|
||||
Architecture: 'amd64',
|
||||
});
|
||||
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
await until(() => expect(mockInspectImage).toHaveBeenCalledTimes(1));
|
||||
|
||||
for (let i = 0; i < 5; i++) svc.getBuildInfo();
|
||||
expect(mockInspectImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exposes revision only after enrichment settles, and whenRevisionResolved awaits that', async () => {
|
||||
let resolveInspect!: (v: { RepoDigests: string[]; Os: string; Architecture: string }) => void;
|
||||
mockInspectImage.mockReturnValue(new Promise((res) => { resolveInspect = res; }));
|
||||
|
||||
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
// initialize() returned without awaiting the detached enrichment, so the
|
||||
// revision is still transiently null and the settle promise is pending.
|
||||
expect(svc.getBuildInfo().revision).toBeNull();
|
||||
|
||||
// A reader that awaits the settle promise (the build-info route) blocks
|
||||
// until enrichment lands, then observes the resolved digest, so a single
|
||||
// successful read never freezes a transient null.
|
||||
const settled = svc.whenRevisionResolved();
|
||||
resolveInspect({
|
||||
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
|
||||
Os: 'linux',
|
||||
Architecture: 'amd64',
|
||||
});
|
||||
await settled;
|
||||
expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`);
|
||||
});
|
||||
|
||||
it('resolves whenRevisionResolved immediately when no enrichment ever started', async () => {
|
||||
process.env.HOSTNAME = undefined;
|
||||
const svc = SelfIdentityService.getInstance();
|
||||
await svc.whenRevisionResolved();
|
||||
expect(svc.getBuildInfo().revision).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* SourceController: the background driver for unattended reconciliation.
|
||||
* GitOpsStore's due-queries and GitSourceService.reconcile() are mocked so
|
||||
* these tests exercise only the timer/coalescing behavior, not real fetch
|
||||
* or apply mechanics (already covered by git-source-service.test.ts).
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { directApplicationFixture } from './helpers/gitopsFixtures';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { GitSourceService } from '../services/GitSourceService';
|
||||
import { SourceController } from '../services/gitops/SourceController';
|
||||
import type { GitOpsApplicationRow } from '../services/gitops/types';
|
||||
import type { ReconcileResult } from '../services/gitops/outcomes';
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
const okResult: ReconcileResult = { outcome: 'no_source_change', reason: 'ok', nextAction: 'none' };
|
||||
|
||||
let tmpDir: string;
|
||||
let controller: SourceController;
|
||||
|
||||
/** Point both due-queries at fixed rows; the scan reads nothing else. */
|
||||
function mockDue(duePoll: GitOpsApplicationRow[], dueRetry: GitOpsApplicationRow[] = []): void {
|
||||
vi.spyOn(GitOpsStore.getInstance(), 'listSourcesDueForPoll').mockReturnValue(duePoll);
|
||||
vi.spyOn(GitOpsStore.getInstance(), 'listApplicationsDueForRetry').mockReturnValue(dueRetry);
|
||||
}
|
||||
|
||||
function spyOnReconcile() {
|
||||
return vi.spyOn(GitSourceService.getInstance(), 'reconcile');
|
||||
}
|
||||
|
||||
/** Run the next scheduled tick and let the evaluations it fires settle. */
|
||||
async function advanceOneTick(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(TICK_MS);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
GitOpsStore.resetForTests();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
SourceController.resetForTests();
|
||||
controller = SourceController.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.stop();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('SourceController', () => {
|
||||
it('evaluates a source whose poll interval is due', async () => {
|
||||
mockDue([directApplicationFixture('app-poll', 'poll-web')]);
|
||||
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
intent: 'fetch',
|
||||
applicationId: 'app-poll',
|
||||
stackName: 'poll-web',
|
||||
trigger: 'poll',
|
||||
}));
|
||||
});
|
||||
|
||||
it('evaluates an application whose retry_at has arrived, tagged as a retry trigger', async () => {
|
||||
const app = { ...directApplicationFixture('app-retry', 'retry-web'), retry_at: Date.now() - 1_000 };
|
||||
mockDue([], [app]);
|
||||
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
applicationId: 'app-retry',
|
||||
trigger: 'retry',
|
||||
}));
|
||||
});
|
||||
|
||||
it('evaluates an application due for both poll and retry exactly once', async () => {
|
||||
const app = { ...directApplicationFixture('app-both', 'both-web'), retry_at: Date.now() - 1_000 };
|
||||
mockDue([app], [app]);
|
||||
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not re-evaluate an application still in flight from a previous tick', async () => {
|
||||
mockDue([directApplicationFixture('app-slow', 'slow-web')]);
|
||||
let settleFirstCall!: (result: ReconcileResult) => void;
|
||||
const firstCall = new Promise<ReconcileResult>((resolve) => { settleFirstCall = resolve; });
|
||||
const reconcile = spyOnReconcile().mockReturnValue(firstCall);
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second tick fires while the first evaluation is still pending.
|
||||
await advanceOneTick();
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
|
||||
settleFirstCall(okResult);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Now that the first evaluation has settled, a later tick may pick it up again.
|
||||
await advanceOneTick();
|
||||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('recovers on the next tick after a store query throws, rather than dying permanently', async () => {
|
||||
mockDue([directApplicationFixture('app-recovers', 'recovers-web')]);
|
||||
vi.spyOn(GitOpsStore.getInstance(), 'listSourcesDueForPoll').mockImplementationOnce(() => {
|
||||
throw new Error('database is locked');
|
||||
});
|
||||
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
|
||||
await advanceOneTick();
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases the in-flight slot for an application whose reconcile rejects', async () => {
|
||||
mockDue([directApplicationFixture('app-rejects', 'rejects-web')]);
|
||||
const reconcile = spyOnReconcile().mockRejectedValue(new Error('boom'));
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not evaluate anything after stop', async () => {
|
||||
mockDue([directApplicationFixture('app-stopped', 'stopped-web')]);
|
||||
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
|
||||
|
||||
controller.start();
|
||||
controller.stop();
|
||||
await advanceOneTick();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not double-arm when start() is called reentrantly from within an in-flight evaluation', async () => {
|
||||
mockDue([directApplicationFixture('app-reentrant-start', 'reentrant-start-web')]);
|
||||
// tick() nulls `timer` before scanning, so a start() call landing
|
||||
// synchronously during that scan must not see a false "not running"
|
||||
// reading and arm a second timer.
|
||||
spyOnReconcile().mockImplementation(() => {
|
||||
controller.start();
|
||||
return Promise.resolve(okResult);
|
||||
});
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('logs rather than silently skipping an application with no stack_name', async () => {
|
||||
mockDue([{ ...directApplicationFixture('app-no-stack', 'no-stack-web'), stack_name: null }]);
|
||||
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('app-no-stack'));
|
||||
});
|
||||
|
||||
it('restartPolling never leaves two timers running', () => {
|
||||
controller.start();
|
||||
controller.restartPolling();
|
||||
controller.restartPolling();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('start is a no-op when already running', async () => {
|
||||
mockDue([directApplicationFixture('app-double-start', 'double-start-web')]);
|
||||
const reconcile = spyOnReconcile().mockResolvedValue(okResult);
|
||||
|
||||
controller.start();
|
||||
controller.start();
|
||||
await advanceOneTick();
|
||||
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
@@ -237,4 +238,142 @@ describe('node-aware Git source webhooks', () => {
|
||||
expect(history[0].status).toBe('success');
|
||||
expect(history[0].error).toMatch(/debounced/i);
|
||||
});
|
||||
|
||||
it('forwards a provider delivery id to a remote node under the webhook namespace', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteNodeId = db.addNode({
|
||||
name: 'remote-delivery-id-webhook',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: 'http://remote-delivery.example',
|
||||
api_token: 'remote-token',
|
||||
});
|
||||
const webhookId = db.addWebhook({
|
||||
node_id: remoteNodeId,
|
||||
name: 'delivery id remote git',
|
||||
stack_name: 'remote-stack',
|
||||
action: 'git-pull',
|
||||
secret: WebhookService.getInstance().generateSecret(),
|
||||
enabled: true,
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'success', message: 'Fetched.' }), { status: 200 }),
|
||||
);
|
||||
|
||||
const webhook = db.getWebhook(webhookId)!;
|
||||
const deliverySourceId = db.getGlobalSettings().delivery_source_id;
|
||||
const result = await WebhookService.getInstance().execute(
|
||||
webhook,
|
||||
'git-pull',
|
||||
'test',
|
||||
undefined,
|
||||
'provider-delivery-1',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'http://remote-delivery.example/api/stacks/remote-stack/git-source/webhook-pull',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining(`"deliveryId":"webhook:${deliverySourceId}:${webhookId}:provider-delivery-1"`),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the same producer-scoped delivery identity to a local Git source', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id;
|
||||
const webhookId = db.addWebhook({
|
||||
node_id: nodeId,
|
||||
name: 'delivery id local git',
|
||||
stack_name: 'local-delivery-stack',
|
||||
action: 'git-pull',
|
||||
secret: WebhookService.getInstance().generateSecret(),
|
||||
enabled: true,
|
||||
});
|
||||
const webhook = db.getWebhook(webhookId)!;
|
||||
const deliverySourceId = db.getGlobalSettings().delivery_source_id;
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
const { GitSourceService } = await import('../services/GitSourceService');
|
||||
const stacksSpy = vi.spyOn(FileSystemService.prototype, 'getStacks')
|
||||
.mockResolvedValue(['local-delivery-stack']);
|
||||
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'handleWebhookPull')
|
||||
.mockResolvedValue({ status: 'success', message: 'Fetched.' });
|
||||
|
||||
try {
|
||||
const result = await WebhookService.getInstance().execute(
|
||||
webhook,
|
||||
'git-pull',
|
||||
'test',
|
||||
undefined,
|
||||
'provider-delivery-2',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, duration_ms: expect.any(Number) });
|
||||
expect(pullSpy).toHaveBeenCalledWith(
|
||||
'local-delivery-stack',
|
||||
true,
|
||||
`webhook:${deliverySourceId}:${webhookId}:provider-delivery-2`,
|
||||
);
|
||||
|
||||
const secondWebhookId = db.addWebhook({
|
||||
node_id: nodeId,
|
||||
name: 'second delivery id local git',
|
||||
stack_name: 'local-delivery-stack',
|
||||
action: 'git-pull',
|
||||
secret: webhook.secret,
|
||||
enabled: true,
|
||||
});
|
||||
pullSpy.mockClear();
|
||||
await WebhookService.getInstance().execute(
|
||||
db.getWebhook(secondWebhookId)!,
|
||||
'git-pull',
|
||||
'test',
|
||||
undefined,
|
||||
'provider-delivery-2',
|
||||
);
|
||||
expect(pullSpy).toHaveBeenCalledWith(
|
||||
'local-delivery-stack',
|
||||
true,
|
||||
`webhook:${deliverySourceId}:${secondWebhookId}:provider-delivery-2`,
|
||||
);
|
||||
|
||||
db.updateGlobalSetting('delivery_source_id', 'second-control-source');
|
||||
pullSpy.mockClear();
|
||||
await WebhookService.getInstance().execute(
|
||||
webhook,
|
||||
'git-pull',
|
||||
'test',
|
||||
undefined,
|
||||
'provider-delivery-2',
|
||||
);
|
||||
expect(pullSpy).toHaveBeenCalledWith(
|
||||
'local-delivery-stack',
|
||||
true,
|
||||
`webhook:second-control-source:${webhookId}:provider-delivery-2`,
|
||||
);
|
||||
db.updateGlobalSetting('delivery_source_id', deliverySourceId!);
|
||||
|
||||
const oversizedDeliveryId = 'x'.repeat(300);
|
||||
const boundedId = crypto.createHash('sha256').update(oversizedDeliveryId).digest('hex');
|
||||
pullSpy.mockClear();
|
||||
await WebhookService.getInstance().execute(
|
||||
webhook,
|
||||
'git-pull',
|
||||
'test',
|
||||
undefined,
|
||||
oversizedDeliveryId,
|
||||
);
|
||||
expect(pullSpy).toHaveBeenCalledWith(
|
||||
'local-delivery-stack',
|
||||
true,
|
||||
`webhook:${deliverySourceId}:${webhookId}:sha256:${boundedId}`,
|
||||
);
|
||||
} finally {
|
||||
if (deliverySourceId) db.updateGlobalSetting('delivery_source_id', deliverySourceId);
|
||||
stacksSpy.mockRestore();
|
||||
pullSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,6 +251,55 @@ describe('POST /api/webhooks/:id/trigger: authenticated happy path', () => {
|
||||
expect(res.body).toMatchObject({ action: 'start' });
|
||||
});
|
||||
|
||||
it('extracts a recognized provider delivery header and passes it through to execute', async () => {
|
||||
const { id, secret } = createWebhook({ action: 'stop' });
|
||||
const body = '{}';
|
||||
const executeSpy = vi.spyOn(WebhookService.getInstance(), 'execute').mockResolvedValue({ success: true, duration_ms: 0 });
|
||||
|
||||
try {
|
||||
await request(app)
|
||||
.post(`/api/webhooks/${id}/trigger`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Webhook-Signature', sign(body, secret))
|
||||
.set('X-GitHub-Delivery', 'gh-delivery-123')
|
||||
.send(body);
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id }),
|
||||
'stop',
|
||||
expect.anything(),
|
||||
true,
|
||||
'gh-delivery-123',
|
||||
);
|
||||
} finally {
|
||||
executeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes no delivery id through when the caller sends no recognized header', async () => {
|
||||
const { id, secret } = createWebhook({ action: 'stop' });
|
||||
const body = '{}';
|
||||
const executeSpy = vi.spyOn(WebhookService.getInstance(), 'execute').mockResolvedValue({ success: true, duration_ms: 0 });
|
||||
|
||||
try {
|
||||
await request(app)
|
||||
.post(`/api/webhooks/${id}/trigger`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Webhook-Signature', sign(body, secret))
|
||||
.send(body);
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id }),
|
||||
'stop',
|
||||
expect.anything(),
|
||||
true,
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
executeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown action override with 400 after the signature passes (L2)', async () => {
|
||||
const { id, secret } = createWebhook();
|
||||
const body = '{"action":"nuke-the-cluster"}';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FleetSyncRetryService } from '../services/FleetSyncRetryService';
|
||||
import { SuppressionRetractionRetryService } from '../services/SuppressionRetractionRetryService';
|
||||
import { DockerEventManager } from '../services/DockerEventManager';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import { SourceController } from '../services/gitops/SourceController';
|
||||
import { SchedulerService } from '../services/SchedulerService';
|
||||
import { MfaService } from '../services/MfaService';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
@@ -49,6 +50,9 @@ export function installShutdownHandlers(server: Server): void {
|
||||
try { ImageUpdateService.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] ImageUpdateService cleanup failed:', (e as Error).message);
|
||||
}
|
||||
try { SourceController.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] SourceController cleanup failed:', (e as Error).message);
|
||||
}
|
||||
try { SchedulerService.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] SchedulerService cleanup failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
@@ -28,10 +28,11 @@ import { applyPilotModeCapabilityFilter } from '../services/CapabilityRegistry';
|
||||
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
||||
import { PilotMetrics } from '../services/PilotMetrics';
|
||||
import { invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
|
||||
import { sweepStaleTempDirs as sweepStaleGitTempDirs, sweepGitManifestOrphans } from '../services/GitSourceService';
|
||||
import { GitSourceService, sweepStaleTempDirs as sweepStaleGitTempDirs, sweepGitManifestOrphans } from '../services/GitSourceService';
|
||||
import { assertCreatesSettled, reclassifyInterruptedOperations, resolveInterruptedCreates } from '../services/gitops/createRecovery';
|
||||
import { loadMigrationManifests, migrateDirectGitStacks, migrateInlineBlueprints } from '../services/gitops/migrate';
|
||||
import { setGitOpsEventSink } from '../services/gitops/publish';
|
||||
import { SourceController } from '../services/gitops/SourceController';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { PORT } from '../helpers/constants';
|
||||
@@ -98,6 +99,33 @@ function clearSelfContainerNotificationRouting(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile-attempt recovery, then the managed-area sweep, in that fixed
|
||||
* order: an attempt reserved but never settled (a crash between the two)
|
||||
* must be resolved from durable state before the sweep or
|
||||
* SourceController's own timer (started later in startServer, after
|
||||
* registry delivery recovery settles per AUD-36) can race a recovery pass
|
||||
* over the same attempts. Exported so this ordering is directly testable
|
||||
* without driving the rest of startServer's unrelated service
|
||||
* initialization.
|
||||
*/
|
||||
export async function runGitOpsSourceRecovery(): Promise<void> {
|
||||
try {
|
||||
await GitSourceService.getInstance().recoverUnsettledReconcileAttempts();
|
||||
} catch (err) {
|
||||
console.error('[GitSource] Reconcile-attempt recovery failed:', err instanceof Error ? err.stack ?? err.message : String(err));
|
||||
}
|
||||
|
||||
// The managed-area sweep follows. It preserves anything whose ownership it
|
||||
// cannot prove, so a failure here can only leave files behind, never remove
|
||||
// the wrong ones, and retrying next boot is safe.
|
||||
try {
|
||||
await sweepGitManifestOrphans();
|
||||
} catch (err) {
|
||||
console.warn('[GitManifest] Managed-area sweep failed:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the startup sequence: stack-directory migration, service initialization,
|
||||
* background watchdogs, then bind the HTTP server. The caller passes the
|
||||
@@ -233,14 +261,9 @@ export async function startServer(server: Server): Promise<void> {
|
||||
console.error('[GitOps] Migration of pre-existing blueprints failed:', err instanceof Error ? err.stack ?? err.message : String(err));
|
||||
}
|
||||
|
||||
// The managed-area sweep follows. It preserves anything whose ownership it
|
||||
// cannot prove, so a failure here can only leave files behind, never remove
|
||||
// the wrong ones, and retrying next boot is safe.
|
||||
try {
|
||||
await sweepGitManifestOrphans();
|
||||
} catch (err) {
|
||||
console.warn('[GitManifest] Managed-area sweep failed:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
// Both steps must settle before SourceController starts below; see that
|
||||
// function's own doc comment for why.
|
||||
await runGitOpsSourceRecovery();
|
||||
|
||||
// Registry delivery recovery sweeps must settle before any mutation-capable
|
||||
// producer starts (AUD-36).
|
||||
@@ -287,6 +310,7 @@ export async function startServer(server: Server): Promise<void> {
|
||||
FleetSyncRetryService.getInstance().start();
|
||||
SuppressionRetractionRetryService.getInstance().start();
|
||||
ImageUpdateService.getInstance().start();
|
||||
SourceController.getInstance().start();
|
||||
SchedulerService.getInstance().start();
|
||||
MfaService.getInstance().start();
|
||||
MeshService.getInstance().start().catch((err) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PROXY_EXEMPT_PREFIXES: readonly string[] = [
|
||||
'/api/fleet/',
|
||||
'/api/webhooks',
|
||||
'/api/meta',
|
||||
'/api/build-info',
|
||||
];
|
||||
|
||||
/** Returns true when the path should bypass the remote proxy (handled locally). */
|
||||
|
||||
@@ -104,13 +104,50 @@ export function isSenchoDevFloatingTag(imageRef: string): boolean {
|
||||
// A digest pin disqualifies the reference (e.g., `@sha256:...`)
|
||||
if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return false;
|
||||
|
||||
// Extract the tag using the same logic as classifyImagePin.
|
||||
const tag = extractTagFromRef(ref);
|
||||
return tag === 'dev';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build channel of a running or declared image, derived from the image
|
||||
* reference alone. This is the canonical build identity classifier used by
|
||||
* `/api/build-info` and the shell/About surfaces: it answers "is this a dev,
|
||||
* preview, or stable build" from the ref, independent of the packaged semver
|
||||
* (a dev image carries the last released version, so version alone cannot
|
||||
* identify it).
|
||||
*
|
||||
* - dev repo (`ghcr.io/studio-saelix/sencho-dev`): always `'dev'`. An
|
||||
* arbitrary `:dev-<sha>` tag still reads `dev` (a deliberate operator
|
||||
* choice); the immutable revision is surfaced separately from the digest.
|
||||
* - stable repos (`ghcr.io/studio-saelix/sencho-hardened`, `saelix/sencho`,
|
||||
* `ghcr.io/studio-saelix/sencho`): `pr-<n>` and `preview-<sha>` tags are
|
||||
* `'preview'` (CI builds on the stable repo that are not releases);
|
||||
* everything else is `'stable'`.
|
||||
* - any other repository: `'unknown'`.
|
||||
*/
|
||||
export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown';
|
||||
|
||||
export function classifyBuildChannel(imageRef: string): BuildChannel {
|
||||
const repository = normalizeImageRepository(imageRef);
|
||||
if (repository === 'ghcr.io/studio-saelix/sencho-dev') return 'dev';
|
||||
if (
|
||||
repository === 'ghcr.io/studio-saelix/sencho-hardened' ||
|
||||
repository === 'saelix/sencho' ||
|
||||
repository === 'ghcr.io/studio-saelix/sencho'
|
||||
) {
|
||||
const tag = extractTagFromRef(imageRef.trim());
|
||||
if (tag && (/^pr-\d+$/.test(tag) || /^preview-[0-9a-f]{7,40}$/.test(tag))) return 'preview';
|
||||
return 'stable';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Extract the tag portion of an image ref (`.../repo:tag`), or '' when absent. */
|
||||
function extractTagFromRef(ref: string): string {
|
||||
const lastSlash = ref.lastIndexOf('/');
|
||||
const lastColon = ref.lastIndexOf(':');
|
||||
// A colon after the last slash is a tag separator; before it is a registry port.
|
||||
const tag = lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
|
||||
|
||||
return tag === 'dev';
|
||||
return lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import { mfaRouter } from './routes/mfa';
|
||||
import { ssoRouter } from './routes/sso';
|
||||
import { licenseRouter, systemUpdateRouter } from './routes/license';
|
||||
import { imageChannelRouter } from './routes/imageChannel';
|
||||
import { buildInfoRouter } from './routes/buildInfo';
|
||||
import { webhooksRouter } from './routes/webhooks';
|
||||
import { usersRouter } from './routes/users';
|
||||
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
|
||||
@@ -116,6 +117,7 @@ app.use('/api/', createRemoteProxyMiddleware());
|
||||
|
||||
app.use('/api/license', licenseRouter);
|
||||
app.use('/api/license/image-channel', imageChannelRouter);
|
||||
app.use('/api/build-info', buildInfoRouter);
|
||||
app.use('/api/system', systemUpdateRouter);
|
||||
app.use('/api/permissions', permissionsRouter);
|
||||
app.use('/api/convert', convertRouter);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { requireUserSession } from '../middleware/tierGates';
|
||||
import { classifyImageChannel, type ImageChannel } from '../helpers/imageChannel';
|
||||
import type { BuildChannel } from '../helpers/selfUpdateCompose';
|
||||
import SelfIdentityService from '../services/SelfIdentityService';
|
||||
|
||||
export const buildInfoRouter = Router();
|
||||
|
||||
/** Wire shape of GET /api/build-info. `restricted: true` implies `imageRef` and
|
||||
* `revision` are nulled for a hardened image viewed by a non-admin. */
|
||||
interface BuildInfoResponse {
|
||||
version: string | null;
|
||||
channel: BuildChannel;
|
||||
imageChannel: ImageChannel;
|
||||
imageRef: string | null;
|
||||
imageId: string | null;
|
||||
revision: string | null;
|
||||
restricted: boolean;
|
||||
}
|
||||
|
||||
// Canonical runtime build identity of the control instance. Proxy-exempt (see
|
||||
// helpers/proxyExemptPaths.ts) so it is always served by the local hub, never
|
||||
// forwarded to a remote node. The running image reference can carry a private
|
||||
// registry/repository name, so the endpoint requires a human session and
|
||||
// redacts hardened-image references to non-admins via `restricted: true` (the
|
||||
// UI shows "Restricted", never "Unknown", when set).
|
||||
buildInfoRouter.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
const service = SelfIdentityService.getInstance();
|
||||
// Await the detached revision enrichment so a transient null is never the
|
||||
// settled value of a successful response. Bounded by the inspect timeout and
|
||||
// never rejects, so this adds at most a short wait on the first read.
|
||||
await service.whenRevisionResolved();
|
||||
const identity = service.getBuildInfo();
|
||||
const isAdmin = req.user?.role === 'admin';
|
||||
const imageChannel = identity.imageRef ? classifyImageChannel(identity.imageRef) : 'unknown';
|
||||
const restricted = !isAdmin && imageChannel === 'hardened';
|
||||
res.json({
|
||||
version: identity.version,
|
||||
channel: identity.channel,
|
||||
imageChannel,
|
||||
imageRef: restricted ? null : identity.imageRef,
|
||||
imageId: identity.imageId,
|
||||
revision: restricted ? null : identity.revision,
|
||||
restricted,
|
||||
} satisfies BuildInfoResponse);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { GitProjectManifestService } from '../services/GitProjectManifestService
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { CryptoService } from '../services/CryptoService';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { checkPermission, requirePermission } from '../middleware/permissions';
|
||||
import { classifySourceRow, satisfiesGitOpsRead } from '../services/gitops/readAuth';
|
||||
import { NOT_APPLICABLE_REVISION, projectStackRevision, stackResourceSet } from '../helpers/gitopsResponse';
|
||||
import { respondWithHistory } from '../helpers/gitopsHistoryPage';
|
||||
@@ -27,6 +27,8 @@ import { assertSafeOutboundHostname, resolveSafeOutboundHostname, UnsafeOutbound
|
||||
const MAX_BRANCH_LENGTH = REF_MAX_LEN;
|
||||
const MAX_ENV_PATH_LENGTH = 1024;
|
||||
const MAX_TOKEN_LENGTH = 8192;
|
||||
const MAX_SUSPEND_REASON_LENGTH = 512;
|
||||
const MAX_WEBHOOK_DELIVERY_ID_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* Shared handler for the "browse repository" compose-file picker: validate the
|
||||
@@ -584,14 +586,28 @@ stackGitSourceRouter.post('/:stackName/git-source/webhook-pull', async (req: Req
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const deliveryId = req.body?.deliveryId;
|
||||
if (
|
||||
deliveryId !== undefined
|
||||
&& (typeof deliveryId !== 'string' || !deliveryId.trim() || deliveryId.length > MAX_WEBHOOK_DELIVERY_ID_LENGTH)
|
||||
) {
|
||||
res.status(400).json({ error: 'deliveryId must be a non-empty string of at most 512 characters' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const source = GitSourceService.getInstance().get(stackName);
|
||||
const service = GitSourceService.getInstance();
|
||||
const source = service.get(stackName);
|
||||
if (!source) {
|
||||
res.status(404).json({ error: 'No Git source configured for this stack', status: 'error' });
|
||||
return;
|
||||
}
|
||||
if (source.auto_apply_on_webhook && source.auto_deploy_on_apply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
const result = await GitSourceService.getInstance().handleWebhookPull(stackName);
|
||||
const normalizedDeliveryId = deliveryId?.trim();
|
||||
const deployAuthorized = checkPermission(req, 'stack:deploy', 'stack', stackName);
|
||||
if (service.webhookDeliveryRequiresDeploy(stackName, normalizedDeliveryId) && !deployAuthorized) {
|
||||
requirePermission(req, res, 'stack:deploy', 'stack', stackName);
|
||||
return;
|
||||
}
|
||||
const result = await service.handleWebhookPull(stackName, deployAuthorized, normalizedDeliveryId);
|
||||
// Map the outcome to a real HTTP status so a Git provider sees a 4xx on
|
||||
// failure instead of a 200 with an error body (which it would read as
|
||||
// "delivered fine, stop retrying").
|
||||
@@ -616,6 +632,64 @@ stackGitSourceRouter.post('/:stackName/git-source/dismiss-pending', async (req:
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/suspend', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const { reason: rawReason } = req.body ?? {};
|
||||
const reason = typeof rawReason === 'string' ? rawReason : undefined;
|
||||
if (reason !== undefined && reason.length > MAX_SUSPEND_REASON_LENGTH) {
|
||||
res.status(400).json({ error: 'reason is too long' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await GitSourceService.getInstance().suspend(stackName, {
|
||||
actor: req.user?.username ?? 'unknown',
|
||||
reason,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/resume', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const result = await GitSourceService.getInstance().resume(stackName, {
|
||||
actor: req.user?.username ?? 'unknown',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
stackGitSourceRouter.post('/:stackName/git-source/retry', async (req: Request, res: Response): Promise<void> => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
try {
|
||||
const result = await GitSourceService.getInstance().retry(stackName, {
|
||||
actor: req.user?.username ?? 'unknown',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Edit-mode repo browse for an existing stack: gated by stack:edit so a user who
|
||||
// can edit (but not create) stacks can re-pick files, and reuses the stored token
|
||||
// when the request omits one.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { getActiveCapabilities, getSenchoVersion } from '../services/CapabilityRegistry';
|
||||
import { classifyImageChannel } from '../helpers/imageChannel';
|
||||
import { isRepinBlocked } from '../helpers/selfUpdateCompose';
|
||||
import { classifyBuildChannel, isRepinBlocked } from '../helpers/selfUpdateCompose';
|
||||
import { MeshService } from '../services/MeshService';
|
||||
import SelfUpdateService from '../services/SelfUpdateService';
|
||||
import SelfIdentityService from '../services/SelfIdentityService';
|
||||
|
||||
// Captured at boot. Exposed via /api/health and /api/meta so the Fleet update
|
||||
// overlay can distinguish a brand-new process from the old one still mid-pull.
|
||||
@@ -41,6 +42,7 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
|
||||
const updateError = selfUpdate.getLastError();
|
||||
const pin = await selfUpdate.getPinInfo({ cacheOnly: true });
|
||||
const updateBlocked = pin ? isRepinBlocked(pin.pinKind) : false;
|
||||
const runningRef = SelfIdentityService.getInstance().getBuildInfo().imageRef;
|
||||
res.json({
|
||||
version: getSenchoVersion(),
|
||||
capabilities: getActiveCapabilities(),
|
||||
@@ -50,6 +52,10 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
|
||||
imagePinKind: pin.pinKind,
|
||||
imageChannel: classifyImageChannel(pin.composeImageRef),
|
||||
} : {}),
|
||||
// Bounded build channel of the RUNNING image (stable|dev|preview|unknown).
|
||||
// Like imagePinKind, this is a non-sensitive enum; no image reference is
|
||||
// ever exposed on this public endpoint.
|
||||
...(runningRef ? { buildChannel: classifyBuildChannel(runningRef) } : {}),
|
||||
updateBlocked,
|
||||
...(updateError ? { updateError: 'update_failed' } : {}),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,34 @@ function isWebhookAction(value: unknown): value is WebhookAction {
|
||||
return typeof value === 'string' && (VALID_WEBHOOK_ACTIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// Recognized per-delivery identity headers, in priority order. This
|
||||
// endpoint is a generic HMAC-signed trigger, not a provider-specific
|
||||
// receiver, so a well-known provider header is the only delivery identity
|
||||
// available, and only when the caller happens to send one. The value is a
|
||||
// stable delivery identity available. WebhookService namespaces the value by
|
||||
// control instance and configured webhook before it reaches GitSourceService,
|
||||
// so two producers cannot collide on the same provider-assigned id.
|
||||
//
|
||||
// Each header is still the provider's actual per-delivery identity rather
|
||||
// than a webhook- or connection-level id that stays constant across every
|
||||
// delivery from that source: GitHub's X-GitHub-Delivery GUID changes per
|
||||
// delivery (it is stable only across redeliveries of the same delivery);
|
||||
// GitLab's is Webhook-ID, the modern name for its Idempotency-Key, not
|
||||
// X-Gitlab-Event-UUID, which tracks recursive-trigger chains and can
|
||||
// repeat across genuinely distinct events; Bitbucket's is X-Request-UUID,
|
||||
// not X-Hook-UUID, which identifies the webhook configuration itself.
|
||||
// Picking the wrong one would deduplicate genuinely distinct pushes.
|
||||
const DELIVERY_ID_HEADERS = ['x-github-delivery', 'webhook-id', 'idempotency-key', 'x-request-uuid', 'x-webhook-delivery-id'] as const;
|
||||
|
||||
function deliveryIdFromHeaders(headers: Request['headers']): string | undefined {
|
||||
for (const name of DELIVERY_ID_HEADERS) {
|
||||
const value = headers[name];
|
||||
const first = Array.isArray(value) ? value[0] : value;
|
||||
if (first) return first;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const webhooksRouter = Router();
|
||||
|
||||
webhooksRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -193,6 +221,7 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request,
|
||||
action = overrideAction;
|
||||
}
|
||||
const triggerSource = req.headers['user-agent'] || req.ip || null;
|
||||
const deliveryId = deliveryIdFromHeaders(req.headers);
|
||||
|
||||
// Execute asynchronously; return 202 immediately.
|
||||
res.status(202).json({ message: 'Webhook accepted', action });
|
||||
@@ -202,7 +231,7 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request,
|
||||
// dispatch the action still completes and recordExecution swallows the
|
||||
// FK error from the CASCADE. atomic is unconditionally true, so the
|
||||
// deploy/pull paths always run in atomic mode here.
|
||||
svc.execute(webhook, action, triggerSource, true).catch(err => {
|
||||
svc.execute(webhook, action, triggerSource, true, deliveryId).catch(err => {
|
||||
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1987,6 +1987,24 @@ export class DatabaseService {
|
||||
// existing installs already have. New installs get it from the
|
||||
// CREATE TABLE; older DBs need the additive column here.
|
||||
maybeAddCol('gitops_applications', 'source_suspended_reason', 'TEXT NULL');
|
||||
// Portable accepted-generation contract fields. Additive and
|
||||
// nullable: existing generation rows decode these as an explicit
|
||||
// limitation rather than invented evidence.
|
||||
maybeAddCol('gitops_generations', 'portable_manifest_json', 'TEXT NULL');
|
||||
maybeAddCol('gitops_generations', 'compose_inputs_json', 'TEXT NULL');
|
||||
maybeAddCol('gitops_generations', 'source_policy_evidence_json', 'TEXT NULL');
|
||||
maybeAddCol('gitops_generations', 'security_policy_evidence_json', 'TEXT NULL');
|
||||
maybeAddCol('gitops_generations', 'support_requirements_json', 'TEXT NULL');
|
||||
maybeAddCol('gitops_generations', 'compatibility_requirements_json', 'TEXT NULL');
|
||||
// Controller-owned bookkeeping (source policy, poll cadence, attempt
|
||||
// sequence). New installs get these from the CREATE TABLE; older DBs
|
||||
// need the additive columns here. Existing installations must not
|
||||
// start unattended polling, so poll_interval_secs and next_poll_at
|
||||
// stay NULL until an operator (or the migration below) sets one.
|
||||
maybeAddCol('gitops_applications', 'source_policy', "TEXT NOT NULL DEFAULT 'manual' CHECK (source_policy IN ('manual','review','automatic'))");
|
||||
maybeAddCol('gitops_applications', 'poll_interval_secs', 'INTEGER NULL');
|
||||
maybeAddCol('gitops_applications', 'next_poll_at', 'INTEGER NULL');
|
||||
maybeAddCol('gitops_applications', 'attempt_seq', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Distributed API model columns
|
||||
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
|
||||
|
||||
@@ -51,6 +51,10 @@ export const MANAGED_ROOT_NAME = 'git-managed';
|
||||
export const MANIFEST_FILENAME = 'manifest.v1.json';
|
||||
export const PROMOTION_MARKER = 'promotion.json';
|
||||
export const CANDIDATE_COMPLETE_MARKER = '.candidate-complete';
|
||||
|
||||
type CandidateClaims =
|
||||
| { complete: true; dirs: ReadonlySet<string> }
|
||||
| { complete: false };
|
||||
export const GENERATIONS_DIR = 'generations';
|
||||
const DETACH_RECOVERY_MARKER = 'detach-recovery.v1.json';
|
||||
|
||||
@@ -421,10 +425,19 @@ export class GitProjectManifestService {
|
||||
|
||||
/** Atomic manifest write (tmp + rename). */
|
||||
async writeManifest(stackName: string, manifest: GitProjectManifest): Promise<void> {
|
||||
const dir = this.managedRoot(stackName);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const target = path.join(dir, MANIFEST_FILENAME);
|
||||
const tmp = path.join(dir, `${MANIFEST_FILENAME}.tmp`);
|
||||
// Inline barrier at the mkdir/write/rename sinks (CodeQL path-injection):
|
||||
// confine the resolved managed directory to the managed area before any
|
||||
// filesystem call touches it, then confine the filenames joined onto it.
|
||||
const root = path.resolve(this.managedRoot(stackName));
|
||||
if (!root.startsWith(managedAreaBase() + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes the managed area'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
const target = path.resolve(root, MANIFEST_FILENAME);
|
||||
const tmp = path.resolve(root, `${MANIFEST_FILENAME}.tmp`);
|
||||
if (!target.startsWith(root + path.sep) || !tmp.startsWith(root + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes managed project directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
await fs.promises.mkdir(root, { recursive: true });
|
||||
await fs.promises.writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf8');
|
||||
await fs.promises.rename(tmp, target);
|
||||
}
|
||||
@@ -1268,12 +1281,20 @@ export class GitProjectManifestService {
|
||||
* is finalized; an uncommitted promotion restores the prior generation.
|
||||
* A third state is treated as an operator edit, so recovery declines and
|
||||
* flags migration_required. Interrupted detach snapshots are restored first.
|
||||
* Complete candidate claims hold the directory basenames that durable state
|
||||
* still references. Incomplete claims preserve every candidate because
|
||||
* ownership is uncertain.
|
||||
*/
|
||||
async sweepManagedArea(
|
||||
stackName: string,
|
||||
opts: { repoUrl: string; branch: string; stackExists: boolean },
|
||||
opts: {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
stackExists: boolean;
|
||||
candidateClaims: CandidateClaims;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { repoUrl, branch, stackExists } = opts;
|
||||
const { repoUrl, branch, stackExists, candidateClaims } = opts;
|
||||
if (!stackExists) {
|
||||
await this.deleteManagedArea(stackName);
|
||||
return;
|
||||
@@ -1359,44 +1380,64 @@ export class GitProjectManifestService {
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan candidates: incomplete or stale.
|
||||
if (!candidateClaims.complete) {
|
||||
await this.flagRecoveryRequired(
|
||||
stackName,
|
||||
`candidate ownership for ${sanitizeForLog(stackName)} could not be established`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// With a complete claim inventory, reap candidates that are incomplete
|
||||
// or stale and unclaimed.
|
||||
const dir = this.generationsDir(stackName);
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
const areaBase = managedAreaBase();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('candidate-')) continue;
|
||||
const abs = path.resolve(dir, entry.name);
|
||||
// Inline containment barrier at the removal sink (see
|
||||
// `managedAreaBase`): the analyzer credits this literal
|
||||
// comparison, not the positional check below it.
|
||||
if (!abs.startsWith(areaBase + path.sep)) {
|
||||
console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it resolves outside the managed area`);
|
||||
continue;
|
||||
}
|
||||
// Same positional barrier as generation pruning: the boot sweep
|
||||
// reaps candidate directories nobody claims, which is precisely
|
||||
// the kind of unattended delete a planted link would steer.
|
||||
if (!await isRealPathAtManagedLocation(abs)) {
|
||||
console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it is not at its own location in the managed area`);
|
||||
continue;
|
||||
}
|
||||
const complete = await fs.promises
|
||||
.access(path.join(abs, CANDIDATE_COMPLETE_MARKER))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!complete) {
|
||||
await fs.promises.rm(abs, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
const st = await fs.promises.stat(abs);
|
||||
if (now - st.mtimeMs > ORPHAN_CANDIDATE_AGE_MS) {
|
||||
await fs.promises.rm(abs, { recursive: true, force: true });
|
||||
}
|
||||
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;
|
||||
throw e;
|
||||
}
|
||||
const now = Date.now();
|
||||
const areaBase = managedAreaBase();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('candidate-')) continue;
|
||||
// A claimed candidate is still needed regardless of age or
|
||||
// completeness. Claims come from application pointers, the
|
||||
// source pending record, and generation rows linked to
|
||||
// unsettled attempts. Together they are the durable ownership
|
||||
// record that makes recovery before cleanup safe.
|
||||
if (candidateClaims.dirs.has(entry.name)) continue;
|
||||
const abs = path.resolve(dir, entry.name);
|
||||
// Inline containment barrier at the removal sink (see
|
||||
// `managedAreaBase`): the analyzer credits this literal
|
||||
// comparison, not the positional check below it.
|
||||
if (!abs.startsWith(areaBase + path.sep)) {
|
||||
console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it resolves outside the managed area`);
|
||||
continue;
|
||||
}
|
||||
// Same positional barrier as generation pruning: the boot sweep
|
||||
// reaps candidate directories nobody claims, which is precisely
|
||||
// the kind of unattended delete a planted link would steer.
|
||||
if (!await isRealPathAtManagedLocation(abs)) {
|
||||
console.warn(`[GitManifest] refusing to reap orphan candidate ${sanitizeForLog(entry.name)} for ${sanitizeForLog(stackName)}: it is not at its own location in the managed area`);
|
||||
continue;
|
||||
}
|
||||
let complete = true;
|
||||
try {
|
||||
await fs.promises.access(path.join(abs, CANDIDATE_COMPLETE_MARKER));
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
||||
complete = false;
|
||||
}
|
||||
if (!complete) {
|
||||
await fs.promises.rm(abs, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
const st = await fs.promises.stat(abs);
|
||||
if (now - st.mtimeMs > ORPHAN_CANDIDATE_AGE_MS) {
|
||||
await fs.promises.rm(abs, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// no generations dir yet
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
||||
import fs from 'fs/promises';
|
||||
import DockerController from './DockerController';
|
||||
import { classifyBuildChannel, isSenchoDevRepository, type BuildChannel } from '../helpers/selfUpdateCompose';
|
||||
import { defaultInspectImage } from './selfDevBuildDetect';
|
||||
import { parseImageRef, selectLocalRepoDigest } from './registry-api';
|
||||
import { getSenchoVersion } from './CapabilityRegistry';
|
||||
import { withTimeout } from '../utils/withTimeout';
|
||||
|
||||
/**
|
||||
* Identifies the Docker resources that belong to the running Sencho container
|
||||
@@ -20,17 +25,32 @@ import DockerController from './DockerController';
|
||||
* stays in its empty state, every `isOwn*()` returns false, and today's
|
||||
* behavior is preserved.
|
||||
*/
|
||||
/** Canonical runtime build identity of the running Sencho container. */
|
||||
export interface BuildInfo {
|
||||
version: string | null;
|
||||
channel: BuildChannel;
|
||||
/** The image reference the running container was started with, null when unknown. */
|
||||
imageRef: string | null;
|
||||
/** Running image sha256 hex (no prefix), null when unknown. */
|
||||
imageId: string | null;
|
||||
/** Validated registry digest or pinned `dev-<sha>` tag, null when unknown. */
|
||||
revision: string | null;
|
||||
}
|
||||
|
||||
class SelfIdentityService {
|
||||
private static instance: SelfIdentityService;
|
||||
private containerId: string | null = null;
|
||||
private containerName: string | null = null;
|
||||
private composeProjectName: string | null = null;
|
||||
private imageIdHex: string | null = null;
|
||||
private imageRef: string | null = null;
|
||||
private revision: string | null = null;
|
||||
private networkIds = new Set<string>();
|
||||
private networkNames = new Set<string>();
|
||||
private volumeNames = new Set<string>();
|
||||
private initialized = false;
|
||||
private initializePromise: Promise<void> | null = null;
|
||||
private enrichmentPromise: Promise<void> | null = null;
|
||||
|
||||
public static getInstance(): SelfIdentityService {
|
||||
if (!SelfIdentityService.instance) {
|
||||
@@ -58,6 +78,14 @@ class SelfIdentityService {
|
||||
this.containerName = (info.Name || '').replace(/^\//, '') || null;
|
||||
this.composeProjectName = info.Config?.Labels?.['com.docker.compose.project'] ?? null;
|
||||
this.imageIdHex = SelfIdentityService.stripSha(info.Image ?? '') || null;
|
||||
this.imageRef = info.Config?.Image ?? null;
|
||||
// Bounded revision enrichment runs detached so it never blocks the callers
|
||||
// awaiting initialize() (Docker event monitoring, resources discovery). Core
|
||||
// identity above is already captured; enrichment only adds the registry
|
||||
// digest / pinned dev-<sha> and is failure-isolated. The promise is retained
|
||||
// so a reader that needs the settled revision can await it (see
|
||||
// whenRevisionResolved) instead of observing a transient null.
|
||||
this.enrichmentPromise = this.enrichRevision(this.imageRef, this.imageIdHex);
|
||||
|
||||
const nets = info.NetworkSettings?.Networks ?? {};
|
||||
for (const [name, net] of Object.entries(nets)) {
|
||||
@@ -118,6 +146,64 @@ class SelfIdentityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical runtime build identity. All fields are captured fields or derived
|
||||
* synchronously from them; this read never triggers a Docker call. `revision`
|
||||
* is populated by the detached enrichment step fired during initialize() and
|
||||
* reads null until that resolves (or if it fails).
|
||||
*/
|
||||
getBuildInfo(): BuildInfo {
|
||||
return {
|
||||
version: getSenchoVersion(),
|
||||
channel: this.imageRef ? classifyBuildChannel(this.imageRef) : 'unknown',
|
||||
imageRef: this.imageRef,
|
||||
imageId: this.imageIdHex,
|
||||
revision: this.revision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the detached revision enrichment has settled (success or
|
||||
* failure), or immediately when none was started. Awaiting cannot hang or
|
||||
* throw (enrichment is bounded and failure-isolated). A reader that needs
|
||||
* the final `revision` awaits this before getBuildInfo() so a successful
|
||||
* response never freezes a transient null.
|
||||
*/
|
||||
async whenRevisionResolved(): Promise<void> {
|
||||
if (this.enrichmentPromise) await this.enrichmentPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the immutable revision from the running image. For a dev-repo image
|
||||
* carrying a pinned `dev-<sha>` tag, the tag itself is the revision. Otherwise
|
||||
* the running image's `RepoDigests` are inspected for a digest matching the
|
||||
* running reference. Any failure (inspect rejection, timeout, no matching
|
||||
* digest) leaves `revision` null; enrichment never throws to the caller.
|
||||
*/
|
||||
private async enrichRevision(imageRef: string | null, imageIdHex: string | null): Promise<void> {
|
||||
try {
|
||||
let revision: string | null = null;
|
||||
if (imageRef && isSenchoDevRepository(imageRef)) {
|
||||
const tag = parseImageRef(imageRef)?.tag;
|
||||
if (tag && /^dev-[0-9a-f]{7,40}$/.test(tag)) revision = tag;
|
||||
}
|
||||
if (!revision && imageRef && imageIdHex) {
|
||||
revision = await this.resolveDigestRevision(imageRef, imageIdHex);
|
||||
}
|
||||
this.revision = revision;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[SelfIdentity] build revision enrichment failed:', message);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveDigestRevision(imageRef: string, imageIdHex: string): Promise<string | null> {
|
||||
const parsed = parseImageRef(imageRef);
|
||||
if (!parsed) return null;
|
||||
const inspected = await withTimeout(defaultInspectImage(imageIdHex), 2000, 'build revision inspect');
|
||||
return selectLocalRepoDigest(inspected.RepoDigests ?? [], parsed);
|
||||
}
|
||||
|
||||
/** True when the given container ID or name matches the running Sencho container. Accepts short or full IDs. */
|
||||
isOwnContainer(idOrName: string): boolean {
|
||||
if (!idOrName) return false;
|
||||
@@ -205,11 +291,14 @@ class SelfIdentityService {
|
||||
this.containerName = null;
|
||||
this.composeProjectName = null;
|
||||
this.imageIdHex = null;
|
||||
this.imageRef = null;
|
||||
this.revision = null;
|
||||
this.networkIds.clear();
|
||||
this.networkNames.clear();
|
||||
this.volumeNames.clear();
|
||||
this.initialized = false;
|
||||
this.initializePromise = null;
|
||||
this.enrichmentPromise = null;
|
||||
}
|
||||
|
||||
private static stripSha(s: string): string {
|
||||
|
||||
@@ -20,6 +20,7 @@ type ExecutionResult = { success: boolean; error?: string; duration_ms: number }
|
||||
type ExecutionStatus = 'success' | 'failure';
|
||||
|
||||
const REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MAX_PROVIDER_DELIVERY_ID_LENGTH = 256;
|
||||
|
||||
// Maps a webhook lifecycle action to the per-stack lock action. 'pull' updates,
|
||||
// so it locks as 'update'; 'git-pull' is excluded (it locks inside GitSourceService).
|
||||
@@ -96,6 +97,7 @@ export class WebhookService {
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
atomic?: boolean,
|
||||
deliveryId?: string,
|
||||
): Promise<ExecutionResult> {
|
||||
if (webhook.id === undefined) {
|
||||
throw new Error('Webhook must be loaded from the database before execution');
|
||||
@@ -110,11 +112,34 @@ export class WebhookService {
|
||||
return { success: false, error, duration_ms: 0 };
|
||||
}
|
||||
|
||||
const scopedDeliveryId = action === 'git-pull'
|
||||
? WebhookService.scopedDeliveryId(
|
||||
DatabaseService.getInstance().getGlobalSettings().delivery_source_id,
|
||||
webhookId,
|
||||
deliveryId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (node.type === 'remote') {
|
||||
return this.executeRemote(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic);
|
||||
return this.executeRemote(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic, scopedDeliveryId);
|
||||
}
|
||||
|
||||
return this.executeLocal(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic);
|
||||
return this.executeLocal(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic, scopedDeliveryId);
|
||||
}
|
||||
|
||||
/** Stable external identity, isolated to one configured webhook producer. */
|
||||
private static scopedDeliveryId(
|
||||
deliverySourceId: string | undefined,
|
||||
webhookId: number,
|
||||
deliveryId: string | undefined,
|
||||
): string | undefined {
|
||||
const normalized = deliveryId?.trim();
|
||||
if (!normalized) return undefined;
|
||||
if (!deliverySourceId) throw new Error('Webhook delivery source identity is not configured');
|
||||
const bounded = normalized.length <= MAX_PROVIDER_DELIVERY_ID_LENGTH
|
||||
? normalized
|
||||
: `sha256:${crypto.createHash('sha256').update(normalized).digest('hex')}`;
|
||||
return `webhook:${deliverySourceId}:${webhookId}:${bounded}`;
|
||||
}
|
||||
|
||||
public maskSecret(secret: string): string {
|
||||
@@ -129,6 +154,7 @@ export class WebhookService {
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
atomic?: boolean,
|
||||
deliveryId?: string,
|
||||
): Promise<ExecutionResult> {
|
||||
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
if (!stacks.includes(stackName)) {
|
||||
@@ -142,7 +168,7 @@ export class WebhookService {
|
||||
// git-pull pulls then deploys through GitSourceService, which holds
|
||||
// the per-stack lock itself; locking here too would self-conflict.
|
||||
if (action === 'git-pull') {
|
||||
return this.executeLocalGitPull(webhookId, stackName, action, triggerSource, startTime);
|
||||
return this.executeLocalGitPull(webhookId, stackName, action, triggerSource, startTime, deliveryId);
|
||||
}
|
||||
const lockAction = WEBHOOK_LOCK_ACTION[action];
|
||||
if (!lockAction) throw new Error(`Unknown action: ${action}`);
|
||||
@@ -225,8 +251,9 @@ export class WebhookService {
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
startTime: number,
|
||||
deliveryId?: string,
|
||||
): Promise<ExecutionResult> {
|
||||
const result = await GitSourceService.getInstance().handleWebhookPull(stackName);
|
||||
const result = await GitSourceService.getInstance().handleWebhookPull(stackName, true, deliveryId);
|
||||
const durationMs = Date.now() - startTime;
|
||||
if (result.status === 'error') {
|
||||
this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, result.message);
|
||||
@@ -255,6 +282,7 @@ export class WebhookService {
|
||||
action: string,
|
||||
triggerSource: string | null,
|
||||
atomic?: boolean,
|
||||
deliveryId?: string,
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
@@ -263,7 +291,9 @@ export class WebhookService {
|
||||
: action === 'pull'
|
||||
? 'update'
|
||||
: action;
|
||||
const body = atomic === undefined ? undefined : { atomic };
|
||||
const body = action === 'git-pull'
|
||||
? { ...(atomic === undefined ? {} : { atomic }), ...(deliveryId ? { deliveryId } : {}) }
|
||||
: atomic === undefined ? undefined : { atomic };
|
||||
const response = await this.remoteStackRequest(nodeId, stackName, endpoint, 'POST', body);
|
||||
const durationMs = Date.now() - startTime;
|
||||
const payload = await response.json().catch(() => ({})) as { error?: string; message?: string; status?: string };
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { GitOpsStore } from './store';
|
||||
import { GitSourceService } from '../GitSourceService';
|
||||
import type { GitOpsApplicationRow } from './types';
|
||||
import { sanitizeForLog } from '../../utils/safeLog';
|
||||
|
||||
/**
|
||||
* Background driver for unattended GitOps reconciliation: polls sources on
|
||||
* their configured interval and re-evaluates applications whose retry_at
|
||||
* has arrived, driving each through GitSourceService.reconcile().
|
||||
*
|
||||
* Scope for this delivery: a tick only issues a fetch-intent reconcile, the
|
||||
* same "detect and stage a candidate" step a manual pull performs. It does
|
||||
* not evaluate source policy or drive automatic acceptance/dispatch for a
|
||||
* newly staged candidate, and it does not yet resume a failure at the
|
||||
* specific stage it failed at (fetch vs. dispatch); every retry re-issues
|
||||
* a fetch. Both are real gaps against the source policy design, not silent
|
||||
* omissions: automatic-policy acceptance and stage-aware retry are follow-on
|
||||
* work once dispatchAcceptedGeneration() has a caller that decides when a
|
||||
* staged candidate should be accepted.
|
||||
*
|
||||
* One self-rescheduling timer drives the scan, matching ImageUpdateService.
|
||||
* The re-arm always runs, even when a scan throws, so one bad tick (a
|
||||
* locked database, a transient store error) never permanently stops the
|
||||
* driver. The per-application in-flight set, not the timer, is what keeps
|
||||
* one busy application from blocking another: the tick never awaits any
|
||||
* evaluation before rescheduling, so a slow application only pauses itself.
|
||||
*/
|
||||
export class SourceController {
|
||||
private static instance: SourceController;
|
||||
|
||||
private static readonly TICK_INTERVAL_MS = 60_000;
|
||||
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private polling = false;
|
||||
// Bumped by cancelPending(), so by stop() and restartPolling(). tick() has
|
||||
// no internal await point today, so nothing can currently call either one
|
||||
// mid-tick; this is a second, currently-redundant line of defense against a
|
||||
// stale timer firing, kept cheap on purpose for when stage-aware retry (see
|
||||
// above) gives evaluate() a real yield point.
|
||||
private scheduleGeneration = 0;
|
||||
private readonly inFlight = new Set<string>();
|
||||
|
||||
private constructor() { }
|
||||
|
||||
static getInstance(): SourceController {
|
||||
if (!SourceController.instance) {
|
||||
SourceController.instance = new SourceController();
|
||||
}
|
||||
return SourceController.instance;
|
||||
}
|
||||
|
||||
/** Test-only: replace the singleton so timer/in-flight state never leaks between tests. */
|
||||
static resetForTests(): void {
|
||||
SourceController.instance = new SourceController();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Guards on `polling`, not `timer`: tick() nulls `timer` before it
|
||||
// scans (so a stale timer reference can never block a restart), which
|
||||
// would otherwise let a start() call landing during that scan see a
|
||||
// false "not running" reading and arm a second timer.
|
||||
if (this.polling) return;
|
||||
this.polling = true;
|
||||
this.armNext();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.cancelPending();
|
||||
this.polling = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule the next tick without restarting: always clears any pending
|
||||
* timer first, so calling this any number of times in a row never leaves
|
||||
* more than one timer armed.
|
||||
*/
|
||||
restartPolling(): void {
|
||||
this.cancelPending();
|
||||
if (this.polling) {
|
||||
this.armNext();
|
||||
}
|
||||
}
|
||||
|
||||
isPolling(): boolean {
|
||||
return this.polling;
|
||||
}
|
||||
|
||||
/** Clear any armed timer and invalidate the tick it would have run. */
|
||||
private cancelPending(): void {
|
||||
this.scheduleGeneration++;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private armNext(): void {
|
||||
const gen = this.scheduleGeneration;
|
||||
this.timer = setTimeout(() => { void this.tick(gen); }, SourceController.TICK_INTERVAL_MS);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private async tick(gen: number): Promise<void> {
|
||||
if (!this.polling || gen !== this.scheduleGeneration) return;
|
||||
this.timer = null;
|
||||
try {
|
||||
this.scan();
|
||||
} catch (e) {
|
||||
console.error('[SourceController] scan failed:', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (this.polling && gen === this.scheduleGeneration) {
|
||||
this.armNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an evaluation for every due application without waiting for any
|
||||
* of them. An application already in the in-flight set is left for a
|
||||
* later tick instead of being queued behind its own still-running
|
||||
* evaluation. A row due for both poll and retry is evaluated once.
|
||||
*/
|
||||
private scan(): void {
|
||||
const now = Date.now();
|
||||
const store = GitOpsStore.getInstance();
|
||||
const due = new Map<string, GitOpsApplicationRow>();
|
||||
for (const app of store.listSourcesDueForPoll(now)) due.set(app.id, app);
|
||||
for (const app of store.listApplicationsDueForRetry(now)) due.set(app.id, app);
|
||||
|
||||
for (const app of due.values()) {
|
||||
if (this.inFlight.has(app.id)) continue;
|
||||
this.inFlight.add(app.id);
|
||||
this.evaluate(app).finally(() => this.inFlight.delete(app.id));
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluate(app: GitOpsApplicationRow): Promise<void> {
|
||||
if (!app.stack_name) {
|
||||
console.warn(`[SourceController] Skipping ${sanitizeForLog(app.id)}: direct-mode application has no stack_name.`);
|
||||
return;
|
||||
}
|
||||
const isRetry = app.retry_at !== null && app.retry_at <= Date.now();
|
||||
try {
|
||||
await GitSourceService.getInstance().reconcile({
|
||||
intent: 'fetch',
|
||||
applicationId: app.id,
|
||||
stackName: app.stack_name,
|
||||
trigger: isRetry ? 'retry' : 'poll',
|
||||
actor: 'system:source-controller',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[SourceController] evaluation failed for ${sanitizeForLog(app.id)}:`,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { GitSourceErrorCode } from '../GitSourceService';
|
||||
import type { TransportFailureReason } from '../git/errors';
|
||||
|
||||
/**
|
||||
* Everything a controller attempt can fail with. `git_source_error` covers
|
||||
* every GitSourceError the fetch/apply path can throw, transport-classified
|
||||
* or not. The remaining kinds cover dispatch-stage failures that have no
|
||||
* GitSourceError at all (target binding, target availability, a deploy or
|
||||
* health failure after a successful apply, Blueprint evaluation, and an
|
||||
* interrupted or unknown-completion operation).
|
||||
*/
|
||||
export type FailureEvidence =
|
||||
| { kind: 'git_source_error'; code: GitSourceErrorCode; transportReason?: TransportFailureReason }
|
||||
| { kind: 'policy_unavailable' }
|
||||
| { kind: 'persistence_unavailable' }
|
||||
| { kind: 'target_binding_invalid' }
|
||||
| { kind: 'target_unavailable' }
|
||||
| { kind: 'target_mutation_failed' }
|
||||
| { kind: 'blueprint_unavailable' }
|
||||
| { kind: 'interrupted' };
|
||||
|
||||
export type FailureDisposition =
|
||||
/** A newer revision replaced the one being worked on; re-resolve, not backoff. */
|
||||
| { class: 'supersession' }
|
||||
/** Retryable. retryCeiling bounds how many attempts before it escalates to permanent. */
|
||||
| { class: 'transient'; retryCeiling: number }
|
||||
/** Will never succeed by retrying; needs a configuration, environment, or credential change. */
|
||||
| { class: 'permanent' }
|
||||
/** A human decision is required (review a plan, resolve a conflict); not retried automatically. */
|
||||
| { class: 'operator_action_required' }
|
||||
/** Evidence could not be produced (e.g. scanner unavailable); held for review, not retried blind. */
|
||||
| { class: 'degraded' }
|
||||
/** The target itself will never accept this generation without a configuration change. */
|
||||
| { class: 'target_permanent' }
|
||||
/** The target is temporarily unreachable; retryable at the target/dispatch stage only. */
|
||||
| { class: 'target_transient' }
|
||||
/** Applied but deploy or health failed: never refetch or reapply, only redeploy. */
|
||||
| { class: 'target_mutation_failed' }
|
||||
/** Blocked pending a capability this program does not yet provide (e.g. Blueprint rollout). */
|
||||
| { class: 'blocked' }
|
||||
/** Ambiguous or interrupted; reconcile from durable state, never blind retry. */
|
||||
| { class: 'reconcile' };
|
||||
|
||||
export const DEFAULT_TRANSIENT_CEILING = 8;
|
||||
export const LOW_TRANSIENT_CEILING = 3;
|
||||
|
||||
const TRANSIENT_DEFAULT: FailureDisposition = { class: 'transient', retryCeiling: DEFAULT_TRANSIENT_CEILING };
|
||||
const TRANSIENT_LOW: FailureDisposition = { class: 'transient', retryCeiling: LOW_TRANSIENT_CEILING };
|
||||
const PERMANENT: FailureDisposition = { class: 'permanent' };
|
||||
|
||||
/**
|
||||
* Total over every TransportFailureReason except `exit`, which is generic
|
||||
* and needs the classified GitSourceErrorCode (see CODE_DISPOSITION) to
|
||||
* tell a transient network condition from a rate limit from an
|
||||
* unrecognized error. Adding a new reason to the source union without
|
||||
* adding it here fails the build.
|
||||
*/
|
||||
const REASON_DISPOSITION: Record<Exclude<TransportFailureReason, 'exit'>, FailureDisposition> = {
|
||||
'tip-changed': { class: 'supersession' },
|
||||
timeout: TRANSIENT_DEFAULT,
|
||||
'target-unresolved': TRANSIENT_DEFAULT,
|
||||
'invalid-url': PERMANENT,
|
||||
'unsafe-target': PERMANENT,
|
||||
'invalid-ref': PERMANENT,
|
||||
'redirect-scope': PERMANENT,
|
||||
'git-missing': PERMANENT,
|
||||
'git-old': PERMANENT,
|
||||
size: PERMANENT,
|
||||
'ssh-auth-required': PERMANENT,
|
||||
'ref-not-found': PERMANENT,
|
||||
'unsupported-ref': PERMANENT,
|
||||
};
|
||||
|
||||
/**
|
||||
* Total over every GitSourceErrorCode. Used directly when there is no
|
||||
* transport reason (a plan/validation/file/operation-conflict error), and
|
||||
* as the exit-reason fallback (RATE_LIMITED, NETWORK_TIMEOUT, and GIT_ERROR
|
||||
* only ever arise from an `exit` transport reason). Adding a new code
|
||||
* without adding it here fails the build.
|
||||
*/
|
||||
const CODE_DISPOSITION: Record<GitSourceErrorCode, FailureDisposition> = {
|
||||
REPO_NOT_FOUND: PERMANENT,
|
||||
AUTH_FAILED: PERMANENT,
|
||||
REF_NOT_FOUND: PERMANENT,
|
||||
REF_DELETED: PERMANENT,
|
||||
UNSUPPORTED_REF: PERMANENT,
|
||||
SSH_HOST_KEY_FAILED: PERMANENT,
|
||||
FILE_NOT_FOUND: { class: 'operator_action_required' },
|
||||
RATE_LIMITED: TRANSIENT_DEFAULT,
|
||||
NETWORK_TIMEOUT: TRANSIENT_DEFAULT,
|
||||
GIT_ERROR: TRANSIENT_LOW,
|
||||
STALE_PLAN: { class: 'operator_action_required' },
|
||||
PLAN_FINGERPRINT_REQUIRED: { class: 'operator_action_required' },
|
||||
PLAN_BLOCKED: { class: 'operator_action_required' },
|
||||
LEGACY_PENDING: { class: 'operator_action_required' },
|
||||
PLAN_UNAVAILABLE: { class: 'operator_action_required' },
|
||||
OPERATION_IN_FLIGHT: { class: 'reconcile' },
|
||||
};
|
||||
|
||||
export function classifyFailure(evidence: FailureEvidence): FailureDisposition {
|
||||
switch (evidence.kind) {
|
||||
case 'git_source_error':
|
||||
if (evidence.transportReason && evidence.transportReason !== 'exit') {
|
||||
return REASON_DISPOSITION[evidence.transportReason];
|
||||
}
|
||||
return CODE_DISPOSITION[evidence.code];
|
||||
case 'policy_unavailable':
|
||||
return { class: 'degraded' };
|
||||
case 'persistence_unavailable':
|
||||
return TRANSIENT_DEFAULT;
|
||||
case 'target_binding_invalid':
|
||||
return { class: 'target_permanent' };
|
||||
case 'target_unavailable':
|
||||
return { class: 'target_transient' };
|
||||
case 'target_mutation_failed':
|
||||
return { class: 'target_mutation_failed' };
|
||||
case 'blueprint_unavailable':
|
||||
return { class: 'blocked' };
|
||||
case 'interrupted':
|
||||
return { class: 'reconcile' };
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_DELAY_MS = 60_000;
|
||||
const MAX_DELAY_MS = 3_600_000;
|
||||
const JITTER_RATIO = 0.1;
|
||||
|
||||
/**
|
||||
* Bounded exponential backoff with jitter: 60s * 2^retryCount, capped at one
|
||||
* hour, with up to +-10% jitter so many sources retrying at once do not
|
||||
* all land on the same second. A provider-supplied retry floor (e.g. a
|
||||
* rate-limit Retry-After) takes precedence whenever it is larger than the
|
||||
* computed delay.
|
||||
*/
|
||||
export function nextRetryAt(now: number, retryCount: number, providerFloorMs?: number): number {
|
||||
const capped = Math.min(BASE_DELAY_MS * 2 ** retryCount, MAX_DELAY_MS);
|
||||
const jittered = capped + capped * JITTER_RATIO * (Math.random() * 2 - 1);
|
||||
const delay = providerFloorMs !== undefined ? Math.max(jittered, providerFloorMs) : jittered;
|
||||
return now + delay;
|
||||
}
|
||||
@@ -341,7 +341,7 @@ export function commitBlueprintDelete(blueprintId: number, actor: string | null)
|
||||
}
|
||||
|
||||
/** A Blueprint application before anything has been asked of it. */
|
||||
export function blankInlineApplication(id: string, blueprintId: number, at: number) {
|
||||
export function blankInlineApplication(id: string, blueprintId: number, at: number): GitOpsApplicationRow {
|
||||
return {
|
||||
id,
|
||||
lifecycle_key: `blueprint:${blueprintId}`,
|
||||
@@ -382,6 +382,10 @@ export function blankInlineApplication(id: string, blueprintId: number, at: numb
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
|
||||
@@ -157,6 +157,10 @@ export function buildDirectApplicationRow(args: {
|
||||
pause_at: null,
|
||||
pause_reason: null,
|
||||
source_suspended_reason: null,
|
||||
source_policy: 'manual',
|
||||
poll_interval_secs: null,
|
||||
next_poll_at: null,
|
||||
attempt_seq: 0,
|
||||
partial_json: null,
|
||||
failure_stage: null,
|
||||
failure_class: null,
|
||||
@@ -217,6 +221,12 @@ export function buildGenerationRow(args: {
|
||||
actor: args.actor,
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: args.at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { RepoIdentity } from './repoIdentity';
|
||||
import type { RefKind } from '../git/types';
|
||||
import type { GitOpsGenerationRow } from './types';
|
||||
|
||||
/**
|
||||
* A portable projection of the managed-project manifest: authored file set
|
||||
* and per-file content digests, without node id, stack name, or generation
|
||||
* directory. The real GitProjectManifest carries those identity fields;
|
||||
* this is deliberately narrower.
|
||||
*/
|
||||
export type PortableManifest = {
|
||||
files: Array<{ path: string; role: string; contentSha256?: string | null }>;
|
||||
};
|
||||
|
||||
/** Authored Compose invocation shape, without a target project name. */
|
||||
export type ComposeInputs = {
|
||||
composeFileOrder: string[];
|
||||
profiles?: string[];
|
||||
contextDir?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One accepted generation, described purely by what it contains. Direct and
|
||||
* Blueprint dispatch consume the exact same shape; current target mode,
|
||||
* binding revision, and any execution-local path travel separately in
|
||||
* DispatchContext, re-read under the dispatch lock rather than carried
|
||||
* here, so a stale acceptance can never authorize a routing decision made
|
||||
* after it.
|
||||
*/
|
||||
export type AcceptedGeneration = {
|
||||
contractVersion: 1;
|
||||
generationId: string;
|
||||
applicationId: string;
|
||||
repoIdentity: RepoIdentity;
|
||||
configuredRef: string;
|
||||
commitSha: string;
|
||||
resolvedRefKind: RefKind | null;
|
||||
manifestVersion: number;
|
||||
portableManifest: PortableManifest | null;
|
||||
composeInputs: ComposeInputs | null;
|
||||
materializationFingerprint: string;
|
||||
changePlanFingerprint: string | null;
|
||||
validationOk: boolean;
|
||||
sourcePolicyEvidence: unknown | null;
|
||||
securityPolicyEvidence: unknown | null;
|
||||
supportRequirements: unknown | null;
|
||||
compatibilityRequirements: unknown | null;
|
||||
/** Capability metadata only; never a secret value. Not yet populated by any producer. */
|
||||
secretCapability: unknown | null;
|
||||
trigger: string;
|
||||
actor: string | null;
|
||||
operationId: string;
|
||||
previousGenerationId: string | null;
|
||||
/** Why some field above could not be proven, recorded honestly rather than guessed. */
|
||||
limitations: string[];
|
||||
};
|
||||
|
||||
function parseOptionalJson<T>(raw: string | null, limitationLabel: string, limitations: string[]): T | null {
|
||||
if (raw === null) {
|
||||
limitations.push(limitationLabel);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
limitations.push(`${limitationLabel}_unparseable`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the portable accepted-generation contract from a persisted
|
||||
* generation row. A legacy row predating one of the portable-contract
|
||||
* columns decodes that field as null with an explicit limitation recorded,
|
||||
* never as invented evidence.
|
||||
*/
|
||||
export function buildAcceptedGeneration(row: GitOpsGenerationRow): AcceptedGeneration {
|
||||
let repoIdentity: RepoIdentity;
|
||||
try {
|
||||
repoIdentity = JSON.parse(row.repo_identity_json) as RepoIdentity;
|
||||
} catch {
|
||||
throw new Error(`Generation ${row.id} has an unparseable repo_identity_json; refusing to build an accepted-generation contract from corrupt evidence.`);
|
||||
}
|
||||
|
||||
const limitations: string[] = JSON.parse(row.redacted_limitations_json) as string[];
|
||||
const portableManifest = parseOptionalJson<PortableManifest>(row.portable_manifest_json, 'portable_manifest_missing', limitations);
|
||||
const composeInputs = parseOptionalJson<ComposeInputs>(row.compose_inputs_json, 'compose_inputs_missing', limitations);
|
||||
const sourcePolicyEvidence = parseOptionalJson<unknown>(row.source_policy_evidence_json, 'source_policy_evidence_missing', limitations);
|
||||
const securityPolicyEvidence = parseOptionalJson<unknown>(row.security_policy_evidence_json, 'security_policy_evidence_missing', limitations);
|
||||
const supportRequirements = parseOptionalJson<unknown>(row.support_requirements_json, 'support_requirements_missing', limitations);
|
||||
const compatibilityRequirements = parseOptionalJson<unknown>(row.compatibility_requirements_json, 'compatibility_requirements_missing', limitations);
|
||||
|
||||
return {
|
||||
contractVersion: 1,
|
||||
generationId: row.id,
|
||||
applicationId: row.application_id,
|
||||
repoIdentity,
|
||||
configuredRef: row.configured_ref,
|
||||
commitSha: row.commit_sha,
|
||||
resolvedRefKind: row.resolved_ref_kind,
|
||||
manifestVersion: row.manifest_version,
|
||||
portableManifest,
|
||||
composeInputs,
|
||||
materializationFingerprint: row.materialization_fingerprint,
|
||||
changePlanFingerprint: row.change_plan_fingerprint,
|
||||
validationOk: row.validation_ok === 1,
|
||||
sourcePolicyEvidence,
|
||||
securityPolicyEvidence,
|
||||
supportRequirements,
|
||||
compatibilityRequirements,
|
||||
secretCapability: null,
|
||||
trigger: row.trigger,
|
||||
actor: row.actor,
|
||||
operationId: row.operation_id,
|
||||
previousGenerationId: row.previous_generation_id,
|
||||
limitations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Current target mode and binding, re-read under the dispatch lock rather
|
||||
* than carried on AcceptedGeneration, so a routing decision is always made
|
||||
* from the current state, never a value an earlier acceptance froze.
|
||||
*/
|
||||
export type DispatchContext = {
|
||||
targetMode: 'direct' | 'blueprint';
|
||||
nodeId: number | null;
|
||||
bindingRevision: string | null;
|
||||
};
|
||||
|
||||
export type DispatchResult =
|
||||
| { status: 'dispatched' }
|
||||
| { status: 'blocked'; reason: string };
|
||||
|
||||
export interface TargetAdapter {
|
||||
dispatch(generation: AcceptedGeneration, context: DispatchContext): Promise<DispatchResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed until Blueprint rollout orchestration exists. Never
|
||||
* inspects selectors, target sets, or placement: an accepted generation
|
||||
* for a Blueprint-mode application is evaluated the same as Direct, but
|
||||
* dispatch stops here.
|
||||
*/
|
||||
export class BlueprintTargetAdapter implements TargetAdapter {
|
||||
async dispatch(_generation: AcceptedGeneration, _context: DispatchContext): Promise<DispatchResult> {
|
||||
return { status: 'blocked', reason: 'Blueprint rollout orchestration is not yet implemented.' };
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ export type GitOpsHistoryStage =
|
||||
| 'rollout_unpaused'
|
||||
| 'source_accepted'
|
||||
| 'source_conflict_blocker'
|
||||
| 'source_reconcile_started'
|
||||
| 'source_reconcile_settled'
|
||||
| 'source_retry_scheduled'
|
||||
| 'source_suspended'
|
||||
| 'source_unsuspended'
|
||||
|
||||
@@ -226,6 +226,12 @@ function migrateAccepted(
|
||||
actor: envelope.actor,
|
||||
previous_generation_id: null,
|
||||
redacted_limitations_json: '[]',
|
||||
portable_manifest_json: null,
|
||||
compose_inputs_json: null,
|
||||
source_policy_evidence_json: null,
|
||||
security_policy_evidence_json: null,
|
||||
support_requirements_json: null,
|
||||
compatibility_requirements_json: null,
|
||||
created_at: envelope.at,
|
||||
};
|
||||
store.insertGeneration(generation);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { SourceFacet } from './types';
|
||||
|
||||
/**
|
||||
* Normalized reconcile outcomes. Silence is not an acceptable GitOps
|
||||
* result: every attempt settles into exactly one of these, never a bare
|
||||
* success/failure boolean.
|
||||
*
|
||||
* `converged` is deliberately never produced by outcomeFromSourceFacet: it
|
||||
* requires target and health evidence this source-only projection does not
|
||||
* have, and "no source change" is not proof of full convergence. A later
|
||||
* composition over source + target + health facets is what may report it.
|
||||
*/
|
||||
export type ReconcileOutcome =
|
||||
| 'converged'
|
||||
| 'no_source_change'
|
||||
| 'candidate_already_fetched'
|
||||
| 'pending_review'
|
||||
| 'suspended'
|
||||
| 'retry_scheduled'
|
||||
| 'blocked'
|
||||
| 'superseded'
|
||||
| 'failed_previous_intact'
|
||||
| 'recovery_required'
|
||||
| 'unknown';
|
||||
|
||||
export type NextAction =
|
||||
| 'none'
|
||||
| 'review'
|
||||
| 'resume'
|
||||
| 'retry'
|
||||
| 'resolve_conflict'
|
||||
| 'configure_credentials'
|
||||
| 'view_target_results';
|
||||
|
||||
export type ReconcileResult = {
|
||||
outcome: ReconcileOutcome;
|
||||
reason: string;
|
||||
nextAction: NextAction;
|
||||
retryAt?: number;
|
||||
commitSha?: string;
|
||||
};
|
||||
|
||||
/** Every ReconcileOutcome member, for runtime validation of a value read back from storage. */
|
||||
const RECONCILE_OUTCOMES: ReadonlySet<string> = new Set<ReconcileOutcome>([
|
||||
'converged',
|
||||
'no_source_change',
|
||||
'candidate_already_fetched',
|
||||
'pending_review',
|
||||
'suspended',
|
||||
'retry_scheduled',
|
||||
'blocked',
|
||||
'superseded',
|
||||
'failed_previous_intact',
|
||||
'recovery_required',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
/** Every NextAction member, for runtime validation of a value read back from storage. */
|
||||
const NEXT_ACTIONS: ReadonlySet<string> = new Set<NextAction>([
|
||||
'none',
|
||||
'review',
|
||||
'resume',
|
||||
'retry',
|
||||
'resolve_conflict',
|
||||
'configure_credentials',
|
||||
'view_target_results',
|
||||
]);
|
||||
|
||||
export function isReconcileOutcome(value: unknown): value is ReconcileOutcome {
|
||||
return typeof value === 'string' && RECONCILE_OUTCOMES.has(value);
|
||||
}
|
||||
|
||||
export function isNextAction(value: unknown): value is NextAction {
|
||||
return typeof value === 'string' && NEXT_ACTIONS.has(value);
|
||||
}
|
||||
|
||||
function commitShaOf(facet: Extract<SourceFacet, { desiredCommitSha: unknown }>): string | undefined {
|
||||
return facet.desiredCommitSha ?? facet.fetchedCommitSha ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the normalized outcome of a settled source reconcile attempt from
|
||||
* the existing source-facet projection, rather than re-deriving status from
|
||||
* raw application-row fields. Keeps the outcome vocabulary and the
|
||||
* projection's own status vocabulary from silently drifting apart.
|
||||
*/
|
||||
export function outcomeFromSourceFacet(facet: SourceFacet): ReconcileResult {
|
||||
switch (facet.status) {
|
||||
case 'not_applicable':
|
||||
return { outcome: 'unknown', reason: 'No GitOps application exists for this stack.', nextAction: 'none' };
|
||||
|
||||
case 'never_reconciled':
|
||||
return { outcome: 'unknown', reason: 'The source has never been reconciled.', nextAction: 'none' };
|
||||
|
||||
case 'checking_fetching':
|
||||
case 'applying':
|
||||
return {
|
||||
outcome: 'unknown',
|
||||
reason: 'A reconcile operation is currently in flight; no settled result yet.',
|
||||
nextAction: 'none',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_reconcile_required':
|
||||
return {
|
||||
outcome: 'unknown',
|
||||
reason: 'The source has advanced but reconciliation has not evaluated it yet.',
|
||||
nextAction: 'none',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'application_generation_accepted':
|
||||
return {
|
||||
outcome: 'no_source_change',
|
||||
reason: 'The configured ref still resolves to the accepted generation. This is not proof of full convergence.',
|
||||
nextAction: 'none',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'candidate_ready':
|
||||
return {
|
||||
outcome: 'candidate_already_fetched',
|
||||
reason: 'A candidate generation is already staged and awaiting acceptance.',
|
||||
nextAction: 'none',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_review_pending':
|
||||
return {
|
||||
outcome: 'pending_review',
|
||||
reason: 'A candidate is staged and requires explicit review before acceptance.',
|
||||
nextAction: 'review',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_conflict_blocker':
|
||||
return {
|
||||
outcome: 'blocked',
|
||||
reason: 'A local conflict is blocking the candidate from being accepted.',
|
||||
nextAction: 'resolve_conflict',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_superseded':
|
||||
return {
|
||||
outcome: 'superseded',
|
||||
reason: 'A newer revision superseded this candidate before it was accepted.',
|
||||
nextAction: 'none',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_retry_scheduled':
|
||||
return {
|
||||
outcome: 'retry_scheduled',
|
||||
reason: `A previous attempt failed transiently; retry ${facet.retryCount + 1} is scheduled.`,
|
||||
nextAction: 'none',
|
||||
retryAt: facet.retryAt,
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_suspended':
|
||||
return {
|
||||
outcome: 'suspended',
|
||||
reason: facet.suspendedReason
|
||||
? `Reconciliation is suspended: ${facet.suspendedReason}`
|
||||
: 'Reconciliation is suspended.',
|
||||
nextAction: 'resume',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_failed':
|
||||
return {
|
||||
outcome: 'failed_previous_intact',
|
||||
reason: `The ${facet.failureStage} stage failed (${facet.failureClass}). The previously accepted generation is unchanged.`,
|
||||
nextAction: facet.retryAt !== null ? 'retry' : 'configure_credentials',
|
||||
retryAt: facet.retryAt ?? undefined,
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'source_unknown':
|
||||
return {
|
||||
outcome: 'recovery_required',
|
||||
reason: `An operation was interrupted at ${facet.interruptedStage} and its outcome is unproven.`,
|
||||
nextAction: 'view_target_results',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'recovery_required':
|
||||
return {
|
||||
outcome: 'recovery_required',
|
||||
reason: 'Recovery from an earlier failed mutation is still outstanding.',
|
||||
nextAction: 'view_target_results',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'recovery_failed':
|
||||
return {
|
||||
outcome: 'recovery_required',
|
||||
reason: `Recovery itself failed (${facet.failureClass}); this needs operator attention.`,
|
||||
nextAction: 'view_target_results',
|
||||
commitSha: commitShaOf(facet),
|
||||
};
|
||||
|
||||
case 'not_live':
|
||||
return {
|
||||
outcome: 'unknown',
|
||||
reason: `The application is ${facet.lifecycleStatus}, not live; there is nothing to reconcile.`,
|
||||
nextAction: 'none',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,16 @@ CREATE TABLE IF NOT EXISTS gitops_applications (
|
||||
-- target rows, so suspending a source can never clobber an unrelated
|
||||
-- rollout pause reason (or the reverse).
|
||||
source_suspended_reason TEXT NULL,
|
||||
-- Controller-owned bookkeeping. NULL poll_interval_secs inherits the
|
||||
-- global default; 0 disables polling for this application. next_poll_at
|
||||
-- is the durable scheduling cursor. attempt_seq is allocated
|
||||
-- transactionally per submission lacking a stable external delivery id.
|
||||
source_policy TEXT NOT NULL DEFAULT 'manual' CHECK (
|
||||
source_policy IN ('manual','review','automatic')
|
||||
),
|
||||
poll_interval_secs INTEGER NULL,
|
||||
next_poll_at INTEGER NULL,
|
||||
attempt_seq INTEGER NOT NULL DEFAULT 0,
|
||||
partial_json TEXT NULL,
|
||||
failure_stage TEXT NULL CHECK (
|
||||
failure_stage IS NULL OR failure_stage IN (
|
||||
@@ -177,6 +187,17 @@ CREATE TABLE IF NOT EXISTS gitops_generations (
|
||||
actor TEXT NULL,
|
||||
previous_generation_id TEXT NULL,
|
||||
redacted_limitations_json TEXT NOT NULL DEFAULT '[]',
|
||||
-- Portable accepted-generation contract (content only: no node id, local
|
||||
-- path, target mode, or secret value). Additive and nullable so existing
|
||||
-- rows decode as an explicit limitation rather than invented evidence; a
|
||||
-- legacy pending candidate lacking these must be re-evaluated before it
|
||||
-- can be accepted or dispatched.
|
||||
portable_manifest_json TEXT NULL,
|
||||
compose_inputs_json TEXT NULL,
|
||||
source_policy_evidence_json TEXT NULL,
|
||||
security_policy_evidence_json TEXT NULL,
|
||||
support_requirements_json TEXT NULL,
|
||||
compatibility_requirements_json TEXT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitops_gen_app_created
|
||||
@@ -453,6 +474,10 @@ CREATE INDEX IF NOT EXISTS idx_gitops_history_node ON gitops_history(node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitops_history_trigger ON gitops_history(trigger);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitops_history_actor ON gitops_history(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitops_history_outcome ON gitops_history(outcome);
|
||||
-- listUnsettledReconcileAttempts filters on stage and orders by created_at;
|
||||
-- without this, that query (run on every startup, ahead of the server
|
||||
-- listening) scans and sorts the whole table.
|
||||
CREATE INDEX IF NOT EXISTS idx_gitops_history_stage_created ON gitops_history(stage, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitops_history_repo_ref
|
||||
ON gitops_history(repo_url, configured_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_gitops_history_stack_created
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../DatabaseService';
|
||||
import type { GitOpsHistoryCursor } from './history';
|
||||
import {
|
||||
decodeArtifactEvidenceJson,
|
||||
decodeGitOpsApprovedTargetEffectJson,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
GitOpsCreateCheckpointRow,
|
||||
GitOpsCreatePhase,
|
||||
GitOpsGenerationRow,
|
||||
GitOpsHistoryRow,
|
||||
GitOpsIntentRevisionRow,
|
||||
GitOpsRolloutCandidateRow,
|
||||
GitOpsTargetCurrentRow,
|
||||
@@ -131,6 +133,29 @@ export class GitOpsStore {
|
||||
).get(stackName) as GitOpsApplicationRow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a detached Direct application exists for this stack name,
|
||||
* distinct from "no application was ever created" (the legitimate
|
||||
* pre-migration case, where a stack's git-source config predates
|
||||
* GitOps tracking entirely). `detached` only, matching
|
||||
* getDetachedDirectApplication above and for the same reason:
|
||||
* `deleted` is not a safe signal here. Two production paths tombstone
|
||||
* an application as `deleted` while deliberately preserving its
|
||||
* git-source row so a future upsert or migration can rebuild from it
|
||||
* (`gitops/createRecovery.ts`'s checkpointless-create sweep, and
|
||||
* `gitops/migrate.ts`'s `tombstoned_missing_stack` outcome) --
|
||||
* treating that state as a refusal would be permanent and
|
||||
* unrecoverable, since neither upsert() nor migration currently mints
|
||||
* a fresh application once a matching migration checkpoint exists.
|
||||
* `detach()` itself deletes the git-source row in the same transaction
|
||||
* as tombstoning (`detached`), so the window this method exists to
|
||||
* catch (tracking removed, config surviving) is a crash between those
|
||||
* two writes, not routine operation.
|
||||
*/
|
||||
hasDetachedDirectApplication(stackName: string): boolean {
|
||||
return this.getDetachedDirectApplication(stackName) !== undefined;
|
||||
}
|
||||
|
||||
/** Direct applications that never reached their success boundary. */
|
||||
listCreatingDirectApplications(): GitOpsApplicationRow[] {
|
||||
return this.db().prepare(
|
||||
@@ -144,6 +169,25 @@ export class GitOpsStore {
|
||||
return this.db().prepare('SELECT * FROM gitops_generations WHERE id = ?').get(id) as GitOpsGenerationRow | undefined;
|
||||
}
|
||||
|
||||
/** Generations whose creating reconcile attempt has not durably settled. */
|
||||
listGenerationsClaimedByUnsettledAttempts(applicationId: string): GitOpsGenerationRow[] {
|
||||
return this.db().prepare(
|
||||
`SELECT DISTINCT generation.*
|
||||
FROM gitops_generations generation
|
||||
JOIN gitops_history started
|
||||
ON started.application_id = generation.application_id
|
||||
AND started.operation_id = generation.operation_id
|
||||
AND started.stage = 'source_reconcile_started'
|
||||
WHERE generation.application_id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gitops_history settled
|
||||
WHERE settled.application_id = started.application_id
|
||||
AND settled.operation_id = started.operation_id
|
||||
AND settled.stage = 'source_reconcile_settled'
|
||||
)`,
|
||||
).all(applicationId) as GitOpsGenerationRow[];
|
||||
}
|
||||
|
||||
getArtifactSet(id: string): GitOpsArtifactSetRow | undefined {
|
||||
return this.db().prepare('SELECT * FROM gitops_artifact_sets WHERE id = ?').get(id) as GitOpsArtifactSetRow | undefined;
|
||||
}
|
||||
@@ -195,6 +239,126 @@ export class GitOpsStore {
|
||||
).all() as GitOpsApplicationRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The settled result for one exact reconcile attempt, or undefined when
|
||||
* that attempt has not settled (or never existed). Used to recover a
|
||||
* reservation that already completed rather than repeating the work.
|
||||
*/
|
||||
getSettledAttempt(applicationId: string, operationId: string): GitOpsHistoryRow | undefined {
|
||||
return this.db().prepare(
|
||||
`SELECT * FROM gitops_history
|
||||
WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_settled'
|
||||
LIMIT 1`,
|
||||
).get(applicationId, operationId) as GitOpsHistoryRow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reservation row for one exact reconcile attempt, or undefined when
|
||||
* it was never reserved. Used to recover this attempt's own recorded
|
||||
* follower link (if any), so a follower can be settled from its
|
||||
* leader's actual result rather than derived independently of it.
|
||||
*/
|
||||
getStartedAttempt(applicationId: string, operationId: string): GitOpsHistoryRow | undefined {
|
||||
return this.db().prepare(
|
||||
`SELECT * FROM gitops_history
|
||||
WHERE application_id = ? AND operation_id = ? AND stage = 'source_reconcile_started'
|
||||
LIMIT 1`,
|
||||
).get(applicationId, operationId) as GitOpsHistoryRow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reservation with no matching settled row, oldest first: an
|
||||
* attempt that started but never recorded a result, most likely because
|
||||
* the process crashed between reservation and settlement. Startup
|
||||
* recovery reconciles these from durable stage evidence rather than
|
||||
* leaving them silently open forever.
|
||||
*
|
||||
* `after` pages strictly forward by (created_at, id), the same cursor
|
||||
* shape `queryHistoryRows` uses, and is load-bearing rather than
|
||||
* cosmetic: a row a caller cannot settle (its application vanished, a DB
|
||||
* error) stays unsettled forever by definition, so without a cursor it
|
||||
* would occupy the same "oldest N" window on every future call and hide
|
||||
* every genuinely recoverable row behind it once the backlog exceeds one
|
||||
* page.
|
||||
*/
|
||||
listUnsettledReconcileAttempts(limit = 200, after?: GitOpsHistoryCursor): GitOpsHistoryRow[] {
|
||||
const clauses = ["started.stage = 'source_reconcile_started'"];
|
||||
const params: Array<string | number> = [];
|
||||
if (after) {
|
||||
clauses.push('(started.created_at > ? OR (started.created_at = ? AND started.id > ?))');
|
||||
params.push(after.createdAt, after.createdAt, after.id);
|
||||
}
|
||||
params.push(limit);
|
||||
return this.db().prepare(
|
||||
`SELECT started.* FROM gitops_history started
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gitops_history settled
|
||||
WHERE settled.application_id = started.application_id
|
||||
AND settled.operation_id = started.operation_id
|
||||
AND settled.stage = 'source_reconcile_settled'
|
||||
)
|
||||
ORDER BY started.created_at ASC, started.id ASC
|
||||
LIMIT ?`,
|
||||
).all(...params) as GitOpsHistoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recently settled attempt for an application, for API and UI
|
||||
* projection. Distinct from getSettledAttempt, which looks up one exact
|
||||
* operation rather than the newest one.
|
||||
*/
|
||||
latestSettledAttempt(applicationId: string): GitOpsHistoryRow | undefined {
|
||||
// rowid (SQLite's implicit insertion-order key), not the id column: id
|
||||
// is a random UUID and does not sort by recency the way rowid does, so
|
||||
// it cannot break a created_at tie between two attempts settled within
|
||||
// the same millisecond.
|
||||
return this.db().prepare(
|
||||
`SELECT * FROM gitops_history
|
||||
WHERE application_id = ? AND stage = 'source_reconcile_settled'
|
||||
ORDER BY created_at DESC, rowid DESC
|
||||
LIMIT 1`,
|
||||
).get(applicationId) as GitOpsHistoryRow | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct sources whose poll time has arrived: active, not suspended, no
|
||||
* operation in flight. Blueprint-mode applications are never polled here
|
||||
* -- source evaluation for them is blocked at the evaluation boundary
|
||||
* until an application-keyed source engine exists for that mode.
|
||||
*/
|
||||
listSourcesDueForPoll(now: number, limit = 200): GitOpsApplicationRow[] {
|
||||
return this.db().prepare(
|
||||
`SELECT * FROM gitops_applications
|
||||
WHERE target_mode = 'direct'
|
||||
AND lifecycle_status = 'active'
|
||||
AND suspended_at IS NULL
|
||||
AND active_operation_stage IS NULL
|
||||
AND next_poll_at IS NOT NULL
|
||||
AND next_poll_at <= ?
|
||||
ORDER BY next_poll_at ASC
|
||||
LIMIT ?`,
|
||||
).all(now, limit) as GitOpsApplicationRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applications with a scheduled retry that has come due: not suspended,
|
||||
* no operation in flight. Poll eligibility and retry eligibility are
|
||||
* deliberately separate queries, since a retry can be due on an
|
||||
* application whose poll cadence would not otherwise select it yet.
|
||||
*/
|
||||
listApplicationsDueForRetry(now: number, limit = 200): GitOpsApplicationRow[] {
|
||||
return this.db().prepare(
|
||||
`SELECT * FROM gitops_applications
|
||||
WHERE retry_at IS NOT NULL
|
||||
AND retry_at <= ?
|
||||
AND suspended_at IS NULL
|
||||
AND active_operation_stage IS NULL
|
||||
ORDER BY retry_at ASC
|
||||
LIMIT ?`,
|
||||
).all(now, limit) as GitOpsApplicationRow[];
|
||||
}
|
||||
|
||||
/** Every live target on one node, across all applications. */
|
||||
listActiveTargetsForNode(nodeId: number): GitOpsTargetCurrentRow[] {
|
||||
return this.db().prepare(
|
||||
@@ -365,12 +529,13 @@ export class GitOpsStore {
|
||||
intent_revision_id, rollout_candidate_id, rollout_generation_id, source_acceptance_ref,
|
||||
placement_approval_ref, rollout_authorization_ref, legacy_combined_approval_ref,
|
||||
preflight_fingerprint, latest_operation_id, active_operation_id, active_operation_stage,
|
||||
active_operation_at, active_generation_id, pause_at, pause_reason, source_suspended_reason, partial_json,
|
||||
active_operation_at, active_generation_id, pause_at, pause_reason, source_suspended_reason,
|
||||
source_policy, poll_interval_secs, next_poll_at, attempt_seq, partial_json,
|
||||
failure_stage, failure_class, failure_at, retry_at, retry_count, suspended_at,
|
||||
recovery_ref, recovery_phase, interruption_stage, interruption_at,
|
||||
interruption_operation_id, interruption_generation_id, evidence_fresh_at,
|
||||
evidence_limitations_json, created_at, updated_at
|
||||
) VALUES (${Array(56).fill('?').join(', ')})`,
|
||||
) VALUES (${Array(60).fill('?').join(', ')})`,
|
||||
).run(
|
||||
row.id, row.lifecycle_key, row.lifecycle_status, row.target_mode, row.stack_name, row.blueprint_id,
|
||||
row.configured_repo_url, row.repo_identity_json, row.configured_ref, row.compose_paths_json,
|
||||
@@ -380,7 +545,8 @@ export class GitOpsStore {
|
||||
row.intent_revision_id, row.rollout_candidate_id, row.rollout_generation_id, row.source_acceptance_ref,
|
||||
row.placement_approval_ref, row.rollout_authorization_ref, row.legacy_combined_approval_ref,
|
||||
row.preflight_fingerprint, row.latest_operation_id, row.active_operation_id, row.active_operation_stage,
|
||||
row.active_operation_at, row.active_generation_id, row.pause_at, row.pause_reason, row.source_suspended_reason, row.partial_json,
|
||||
row.active_operation_at, row.active_generation_id, row.pause_at, row.pause_reason, row.source_suspended_reason,
|
||||
row.source_policy, row.poll_interval_secs, row.next_poll_at, row.attempt_seq, row.partial_json,
|
||||
row.failure_stage, row.failure_class, row.failure_at, row.retry_at, row.retry_count, row.suspended_at,
|
||||
row.recovery_ref, row.recovery_phase, row.interruption_stage, row.interruption_at,
|
||||
row.interruption_operation_id, row.interruption_generation_id, row.evidence_fresh_at,
|
||||
@@ -394,13 +560,18 @@ export class GitOpsStore {
|
||||
id, application_id, commit_sha, repo_url, configured_ref, resolved_ref_kind, repo_identity_json,
|
||||
manifest_version, candidate_dir, applied_dir, expected_invocation_json,
|
||||
materialization_fingerprint, validation_ok, plan_blocked, change_plan_fingerprint,
|
||||
operation_id, trigger, actor, previous_generation_id, redacted_limitations_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
operation_id, trigger, actor, previous_generation_id, redacted_limitations_json,
|
||||
portable_manifest_json, compose_inputs_json, source_policy_evidence_json,
|
||||
security_policy_evidence_json, support_requirements_json, compatibility_requirements_json,
|
||||
created_at
|
||||
) VALUES (${Array(27).fill('?').join(', ')})`,
|
||||
).run(
|
||||
row.id, row.application_id, row.commit_sha, row.repo_url, row.configured_ref, row.resolved_ref_kind, row.repo_identity_json,
|
||||
row.manifest_version, row.candidate_dir, row.applied_dir, row.expected_invocation_json,
|
||||
row.materialization_fingerprint, row.validation_ok, row.plan_blocked, row.change_plan_fingerprint,
|
||||
row.operation_id, row.trigger, row.actor, row.previous_generation_id, row.redacted_limitations_json,
|
||||
row.portable_manifest_json, row.compose_inputs_json, row.source_policy_evidence_json,
|
||||
row.security_policy_evidence_json, row.support_requirements_json, row.compatibility_requirements_json,
|
||||
row.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export type EventEnvelope = {
|
||||
at: number;
|
||||
};
|
||||
|
||||
export type ReconcileDeliveryIntent =
|
||||
| { autoApply: false; deploy: false }
|
||||
| { autoApply: true; deploy: boolean };
|
||||
|
||||
export type AppliedArgs = {
|
||||
applicationId: string;
|
||||
generationId: string;
|
||||
@@ -893,6 +897,102 @@ export class GitOpsTransitions {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a durable attempt before any side effect: a bare history
|
||||
* insert in its own transaction, deliberately not through mutateApp, so
|
||||
* nothing about the application row changes. A `reserved: false` return
|
||||
* means this exact (application, operation) already reserved -- the
|
||||
* caller reconstructs from durable state rather than repeating work.
|
||||
*
|
||||
* `followerOf` records that this reservation joined another running
|
||||
* attempt. Recovery uses the link to settle the follower from its leader's
|
||||
* result. `deliveryIntent` preserves the original webhook apply and deploy
|
||||
* decision so redelivery cannot change behavior with later settings.
|
||||
*/
|
||||
reserveReconcileAttempt(
|
||||
applicationId: string,
|
||||
envelope: EventEnvelope,
|
||||
followerOf?: string,
|
||||
deliveryIntent?: ReconcileDeliveryIntent,
|
||||
): { reserved: boolean } {
|
||||
return this.raw().transaction(() => ({
|
||||
reserved: this.insertReconcileReservation(this.requireApp(applicationId), envelope, followerOf, deliveryIntent),
|
||||
}))();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the next attemptSeq for a submission with no stable external
|
||||
* delivery identity, and reserve its durable attempt in the same
|
||||
* transaction, so two concurrent submissions can never mint the same
|
||||
* operation id. Unlike reserveReconcileAttempt, this does write one
|
||||
* column of application state (attempt_seq) -- allocation is the one
|
||||
* thing here that is not a bare history insert, since a fresh id has to
|
||||
* come from somewhere durable. Only the allocated id's uniqueness is
|
||||
* load-bearing; its embedded sequence number is for traceability.
|
||||
*/
|
||||
allocateReconcileAttempt(
|
||||
applicationId: string,
|
||||
actor: string | null,
|
||||
trigger: string,
|
||||
at: number,
|
||||
followerOf?: string,
|
||||
): { operationId: string; reserved: boolean } {
|
||||
return this.raw().transaction(() => {
|
||||
const app = this.requireApp(applicationId);
|
||||
const seq = app.attempt_seq + 1;
|
||||
this.raw().prepare('UPDATE gitops_applications SET attempt_seq = ? WHERE id = ?').run(seq, applicationId);
|
||||
const operationId = `${applicationId}:attempt:${seq}`;
|
||||
const envelope: EventEnvelope = { operationId, actor, trigger, at };
|
||||
return { operationId, reserved: this.insertReconcileReservation(app, envelope, followerOf) };
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* The reservation row itself, shared by reserveReconcileAttempt and
|
||||
* allocateReconcileAttempt: a bare history insert whose dedupe index is
|
||||
* what makes a repeat reservation report false rather than recording a
|
||||
* second attempt.
|
||||
*/
|
||||
private insertReconcileReservation(
|
||||
app: GitOpsApplicationRow,
|
||||
envelope: EventEnvelope,
|
||||
followerOf: string | undefined,
|
||||
deliveryIntent?: ReconcileDeliveryIntent,
|
||||
): boolean {
|
||||
return this.history(app, envelope, {
|
||||
stage: 'source_reconcile_started',
|
||||
outcome: 'committed',
|
||||
before: {},
|
||||
after: {
|
||||
...(followerOf ? { followerOf } : {}),
|
||||
...(deliveryIntent ? { deliveryIntent } : {}),
|
||||
},
|
||||
}) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a reserved attempt with its normalized outcome, the same way:
|
||||
* a bare history insert, not a state mutation. Safe to call more than
|
||||
* once for the same operation; a repeat is a no-op via the history
|
||||
* dedupe index, so the first settled result is never overwritten.
|
||||
*/
|
||||
settleReconcileAttempt(
|
||||
applicationId: string,
|
||||
envelope: EventEnvelope,
|
||||
result: { outcome: string; reason: string; nextAction: string; retryAt?: number; commitSha?: string },
|
||||
): { settled: boolean } {
|
||||
return this.raw().transaction(() => {
|
||||
const app = this.requireApp(applicationId);
|
||||
const historyId = this.history(app, envelope, {
|
||||
stage: 'source_reconcile_settled',
|
||||
outcome: 'committed',
|
||||
before: {},
|
||||
after: { ...result },
|
||||
});
|
||||
return { settled: historyId !== null };
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a rollout, application-wide or on one target.
|
||||
*
|
||||
@@ -2319,7 +2419,8 @@ export class GitOpsTransitions {
|
||||
legacy_combined_approval_ref=?, preflight_fingerprint=?,
|
||||
latest_operation_id=?, active_operation_id=?,
|
||||
active_operation_stage=?, active_operation_at=?, active_generation_id=?,
|
||||
pause_at=?, pause_reason=?, source_suspended_reason=?, partial_json=?,
|
||||
pause_at=?, pause_reason=?, source_suspended_reason=?,
|
||||
source_policy=?, poll_interval_secs=?, next_poll_at=?, attempt_seq=?, partial_json=?,
|
||||
failure_stage=?, failure_class=?, failure_at=?, retry_at=?, retry_count=?,
|
||||
suspended_at=?, recovery_ref=?, recovery_phase=?,
|
||||
interruption_stage=?, interruption_at=?, interruption_operation_id=?,
|
||||
@@ -2336,7 +2437,8 @@ export class GitOpsTransitions {
|
||||
app.legacy_combined_approval_ref, app.preflight_fingerprint,
|
||||
app.latest_operation_id, app.active_operation_id,
|
||||
app.active_operation_stage, app.active_operation_at, app.active_generation_id,
|
||||
app.pause_at, app.pause_reason, app.source_suspended_reason, app.partial_json,
|
||||
app.pause_at, app.pause_reason, app.source_suspended_reason,
|
||||
app.source_policy, app.poll_interval_secs, app.next_poll_at, app.attempt_seq, app.partial_json,
|
||||
app.failure_stage, app.failure_class, app.failure_at, app.retry_at, app.retry_count,
|
||||
app.suspended_at, app.recovery_ref, app.recovery_phase,
|
||||
app.interruption_stage, app.interruption_at, app.interruption_operation_id,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Normalized reconciliation triggers for the GitOps source controller.
|
||||
*
|
||||
* A trigger only authorizes evaluation; it is not proof anything changed.
|
||||
* `manual`, `webhook`, `poll`, and `retry` have current execution producers.
|
||||
* The remaining values are typed ahead of later deliveries so those callers
|
||||
* extend this union instead of inventing a parallel one.
|
||||
*/
|
||||
export type ReconcileTrigger =
|
||||
| 'manual'
|
||||
| 'api'
|
||||
| 'webhook'
|
||||
| 'poll'
|
||||
| 'retry'
|
||||
| 'config_change'
|
||||
| 'startup'
|
||||
| 'resume'
|
||||
| 'provider_event'
|
||||
| 'schedule'
|
||||
| 'binding_change';
|
||||
|
||||
/**
|
||||
* One normalized submission to the controller. `dismiss` is deliberately
|
||||
* not a reconcile intent: it changes candidate state but does not
|
||||
* authorize source evaluation.
|
||||
*/
|
||||
export type ReconcileRequest =
|
||||
| {
|
||||
intent: 'fetch';
|
||||
applicationId: string;
|
||||
stackName: string;
|
||||
trigger: ReconcileTrigger;
|
||||
actor: string;
|
||||
deliveryId?: string;
|
||||
}
|
||||
| {
|
||||
intent: 'apply';
|
||||
applicationId: string;
|
||||
stackName: string;
|
||||
trigger: ReconcileTrigger;
|
||||
actor: string;
|
||||
commitSha: string;
|
||||
planFingerprint: string;
|
||||
deploy: boolean;
|
||||
deliveryId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The in-process joining key for concurrent evaluations of the same work.
|
||||
* A fetch has only one live outcome per application regardless of trigger,
|
||||
* so any two fetch submissions for the same application and stack join. An
|
||||
* apply is identified by exactly what it would do: two applies join only
|
||||
* when they target the same commit, the same plan fingerprint, and the
|
||||
* same deploy choice. Two applies that differ in any of those must never
|
||||
* join, or one request could silently receive another request's result.
|
||||
*
|
||||
* Both the fetch and the apply form carry the stack name alongside the
|
||||
* applicationId, so a caller that pairs a live applicationId with the
|
||||
* wrong stackName can never join a leader evaluating the right one.
|
||||
*/
|
||||
export function coalesceKey(request: ReconcileRequest): string {
|
||||
if (request.intent === 'fetch') {
|
||||
return `${request.applicationId}:${request.stackName}:fetch`;
|
||||
}
|
||||
return `${request.applicationId}:${request.stackName}:apply:${request.commitSha}:${request.planFingerprint}:${request.deploy}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A producer-namespaced key for an external delivery, so the same delivery
|
||||
* id from two different trigger sources is never treated as one delivery.
|
||||
* Also namespaced by intent: a webhook that both fetches and applies under
|
||||
* one delivery id must reserve two distinct attempts, not have the apply's
|
||||
* reservation collide with the fetch's and silently never run.
|
||||
*/
|
||||
export function deliveryKey(trigger: ReconcileTrigger, intent: ReconcileRequest['intent'], deliveryId: string): string {
|
||||
return `${trigger}:${intent}:${deliveryId}`;
|
||||
}
|
||||
@@ -73,6 +73,11 @@ export type GitOpsApplicationRow = {
|
||||
pause_reason: string | null;
|
||||
/** sourceSuspended/sourceUnsuspended's own reason field; independent of pause_reason. */
|
||||
source_suspended_reason: string | null;
|
||||
/** Controller-owned. See gitops/SourceController.ts. */
|
||||
source_policy: 'manual' | 'review' | 'automatic';
|
||||
poll_interval_secs: number | null;
|
||||
next_poll_at: number | null;
|
||||
attempt_seq: number;
|
||||
partial_json: string | null;
|
||||
failure_stage: ApplicationFailureStage | null;
|
||||
failure_class: string | null;
|
||||
@@ -161,6 +166,13 @@ export type GitOpsGenerationRow = {
|
||||
actor: string | null;
|
||||
previous_generation_id: string | null;
|
||||
redacted_limitations_json: string;
|
||||
/** Portable accepted-generation contract fields. See gitops/handoff.ts. */
|
||||
portable_manifest_json: string | null;
|
||||
compose_inputs_json: string | null;
|
||||
source_policy_evidence_json: string | null;
|
||||
security_policy_evidence_json: string | null;
|
||||
support_requirements_json: string | null;
|
||||
compatibility_requirements_json: string | null;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,13 +27,17 @@ export type SelfDevBuildDetectResult =
|
||||
| { kind: 'inconclusive'; reason: string };
|
||||
|
||||
/** The subset of `docker image inspect` output the detector reads. */
|
||||
interface InspectedImage {
|
||||
export interface InspectedImage {
|
||||
RepoDigests: string[];
|
||||
Os: string;
|
||||
Architecture: string;
|
||||
}
|
||||
|
||||
async function defaultInspectImage(imageId: string): Promise<InspectedImage> {
|
||||
/** Bounded read of `docker image inspect` (RepoDigests, OS, architecture) for a
|
||||
* resolved image. Reused by `SelfIdentityService` for revision enrichment;
|
||||
* callers wrap it so any rejection stays isolated from the fields already
|
||||
* captured. */
|
||||
export async function defaultInspectImage(imageId: string): Promise<InspectedImage> {
|
||||
const inspect = await DockerController.getInstance().getDocker().getImage(`sha256:${imageId}`).inspect();
|
||||
return { RepoDigests: inspect.RepoDigests ?? [], Os: inspect.Os, Architecture: inspect.Architecture };
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ The **Plan** card lists:
|
||||
- **License** (Community only): a link to view the AGPLv3 source on GitHub.
|
||||
- **Recovery Vault** (Admiral only): confirms the entitlement is included in your subscription.
|
||||
- **Hardened Build** (Admiral only): a **Switch to Hardened** button; see [Switching to Hardened Build](#switching-to-hardened-build) below.
|
||||
- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running.
|
||||
- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running. These rows reflect the running build, not the configured target, so a compose edit does not change them until the container is recreated. A hardened image is shown as `Restricted` to non-administrators.
|
||||
- **Customer**, **Product**, and **License key** (active licenses only): purchase metadata and your key masked to its last four characters (`****-****-****-XXXX`). The full key is never re-displayed after activation.
|
||||
|
||||
## Switching to Hardened Build
|
||||
|
||||
@@ -154,3 +154,17 @@ Maintainers publish these from open PRs for external validation. They are unsign
|
||||
|---|---|---|
|
||||
| `pr-<N>` | `saelix/sencho:pr-1526` | Each re-run of the preview workflow for that PR |
|
||||
| `preview-<sha>` | `saelix/sencho:preview-abc1234` | Never (immutable per build) |
|
||||
|
||||
## Reading your running build
|
||||
|
||||
Sencho surfaces the build it is actually running in **Settings → About → Build** (and in the **Channel** and **Current image** rows of **Settings → Admiral Account**). These fields come from the running container's identity, not from the compose file, so a compose edit does not change them until the container is recreated.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| **Version** | The packaged semantic version of the build. A dev build keeps the last stable version here, which is why the Channel row matters. |
|
||||
| **Channel** | The build track of the running image: `Dev`, `Preview`, `Stable`, or `Unknown`. A `dev` or `dev-<sha>` image reads `Dev`; a `pr-<N>` or `preview-<sha>` image reads `Preview`; a release image reads `Stable`. |
|
||||
| **Current image** | The image reference the container was started with, for example `ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d`. |
|
||||
| **Revision** | The immutable digest this build resolves to, or the pinned `dev-<sha>` tag when the running image is on the integration track. |
|
||||
| **Image ID** | The first twelve characters of the running image's sha256 identifier; click to copy the full id. |
|
||||
|
||||
These fields describe the **control plane** instance you are logged in to, not remote nodes. When identity metadata cannot be determined, the reference fields read `Unknown` rather than guessing. A hardened image viewed by a non-administrator shows `Restricted` for the reference fields instead of `Unknown`.
|
||||
|
||||
@@ -169,8 +169,8 @@ Activate, view, or deactivate the license for this Sencho control plane, and see
|
||||
| **Sencho Admiral** | The active license on this control plane, with a tier badge. Community instances see an upgrade prompt here instead. |
|
||||
| **Recovery Vault** | Whether the current subscription includes Recovery Vault entitlement. |
|
||||
| **Hardened Build** | Switches this control plane between the Community image channel and the Admiral Hardened Build channel. Review entitlement and registry access before switching; see [Plans](/features/licensing#feature-breakdown) for what Hardened Build changes. |
|
||||
| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. |
|
||||
| **Channel** | The active image channel (Community or Hardened). |
|
||||
| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. A hardened image is shown as `Restricted` to non-administrators. |
|
||||
| **Channel** | The running image channel this control plane is on: Community, Hardened, or Unknown. Reflects the running build, not the configured target. |
|
||||
| **Customer** | The customer name on file with Lemon Squeezy (paid plans only). |
|
||||
| **Product** | The product (paid plans only). |
|
||||
| **License key** | The active key, masked to the last four characters. |
|
||||
@@ -753,9 +753,15 @@ Displays instance information at a glance.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Version** | Current Sencho version. |
|
||||
| **Version** | The semantic version of this control plane instance. |
|
||||
| **Channel** | The build track this control plane is running: `Dev`, `Preview`, `Stable`, or `Unknown`. A dev build is identified here even when its packaged version still matches the previous stable release. |
|
||||
| **Current image** | The image reference this control plane was started with. A compose edit changes the configured target until the container is recreated; this row always shows the running image, never the configured one. |
|
||||
| **Revision** | The immutable digest or pinned `dev-<sha>` tag this build resolves to, or `Unknown` when it cannot be determined. |
|
||||
| **Image ID** | The first twelve characters of the running image's sha256 identifier. Click to copy the full id. Hidden when no image identity is available. |
|
||||
| **Tier** | Community or Admiral badge. |
|
||||
| **Plan Status** | active, trial, expired, or community (Admiral entitlement state, not the AGPL software license). |
|
||||
| **Instance ID** | First eight characters of the unique identifier for this Sencho control plane (used by the license server to identify it). |
|
||||
|
||||
The Build rows describe the **control plane** instance (the Sencho you are logged in to), not remote nodes. When identity metadata is unavailable, the reference rows read `Unknown` rather than inferring a value. For a hardened image seen by a non-administrator, the reference fields read `Restricted` instead. See [Verifying images](/operations/verifying-images) for how the build tracks and immutable tags relate.
|
||||
|
||||
The **Links** section contains Source code, AGPLv3 License, Licensing documentation, and Changelog links.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { retryDomain } from './lib/preferences/syncBus';
|
||||
import { toast } from './components/ui/toast-store';
|
||||
import { NodeProvider } from './context/NodeContext';
|
||||
import { LicenseProvider } from './context/LicenseContext';
|
||||
import { BuildInfoProvider } from './context/BuildInfoProvider';
|
||||
import { Login } from './components/Login';
|
||||
import { Setup } from './components/Setup';
|
||||
import EditorLayout from './components/EditorLayout';
|
||||
@@ -103,11 +104,13 @@ function AppContent() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<EditorLayout />
|
||||
{/* Portal lives inside LicenseProvider so the editor surface and its
|
||||
portalled overlays can read license state via useLicense().
|
||||
Outer DeployFeedbackProvider is still an ancestor through App. */}
|
||||
<DeployFeedbackPortal />
|
||||
<BuildInfoProvider>
|
||||
<EditorLayout />
|
||||
{/* Portal lives inside LicenseProvider so the editor surface and its
|
||||
portalled overlays can read license state via useLicense().
|
||||
Outer DeployFeedbackProvider is still an ancestor through App. */}
|
||||
<DeployFeedbackPortal />
|
||||
</BuildInfoProvider>
|
||||
</LicenseProvider>
|
||||
</NodeProvider>
|
||||
</MotionProvider>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useOverlayState } from './EditorLayout/hooks/useOverlayState';
|
||||
import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions';
|
||||
import { useSelectedStackLiveRefresh } from './EditorLayout/hooks/useSelectedStackLiveRefresh';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useBuildInfo } from '@/hooks/useBuildInfo';
|
||||
import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch';
|
||||
import { useNotifications } from './EditorLayout/hooks/useNotifications';
|
||||
import { useContainerStats } from './EditorLayout/hooks/useContainerStats';
|
||||
@@ -455,6 +456,7 @@ export default function EditorLayout() {
|
||||
const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill);
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
const { buildInfo } = useBuildInfo();
|
||||
|
||||
// ---- Mobile shell (below md) ---------------------------------------------
|
||||
// Desktop renders the persistent sidebar + workspace untouched. On a phone we
|
||||
@@ -977,6 +979,7 @@ export default function EditorLayout() {
|
||||
const sidebarEl = (
|
||||
<StackSidebar
|
||||
isDarkMode={isDarkMode}
|
||||
buildInfo={buildInfo}
|
||||
nodeSwitcherSlot={
|
||||
<NodeSwitcher
|
||||
onManageNodes={() => openSettings('nodes')}
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Home, Radar, Clock } from 'lucide-react';
|
||||
import { MobileTabBar } from './MobileTabBar';
|
||||
import { useBuildInfo } from '@/hooks/useBuildInfo';
|
||||
import type { BuildInfo } from '@/context/BuildInfoProvider';
|
||||
import type { NavItem } from './EditorLayout/hooks/useViewNavigationState';
|
||||
|
||||
vi.mock('@/hooks/useBuildInfo', () => ({
|
||||
useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })),
|
||||
}));
|
||||
|
||||
const mockUseBuildInfo = vi.mocked(useBuildInfo);
|
||||
|
||||
const allItems: NavItem[] = [
|
||||
{ value: 'dashboard', label: 'Home', icon: Home },
|
||||
{ value: 'fleet', label: 'Fleet', icon: Radar },
|
||||
{ value: 'scheduled-ops', label: 'Schedules', icon: Clock },
|
||||
];
|
||||
|
||||
function buildInfo(channel: BuildInfo['channel']): BuildInfo {
|
||||
return {
|
||||
version: '0.97.1',
|
||||
channel,
|
||||
imageChannel: 'community',
|
||||
imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1',
|
||||
imageId: 'a'.repeat(64),
|
||||
revision: null,
|
||||
restricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const noPill = { buildInfo: null, status: 'ready' as const, retry: vi.fn() };
|
||||
|
||||
function renderBar(over: Partial<React.ComponentProps<typeof MobileTabBar>> = {}) {
|
||||
const props: React.ComponentProps<typeof MobileTabBar> = {
|
||||
navItems: allItems,
|
||||
@@ -76,3 +98,52 @@ describe('MobileTabBar', () => {
|
||||
expect(screen.getByRole('button', { name: 'Stacks' })).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileTabBar build-identity pill', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBuildInfo.mockReturnValue(noPill);
|
||||
});
|
||||
|
||||
it('shows a text DEV pill for a dev build, not an interactive control', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() });
|
||||
renderBar();
|
||||
expect(screen.getByText('DEV')).toBeInTheDocument();
|
||||
// The pill is a plain span: it adds no competing tap target in the tab row.
|
||||
expect(screen.queryByRole('button', { name: 'DEV' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a text PREVIEW pill for a preview build', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('preview'), status: 'ready', retry: vi.fn() });
|
||||
renderBar();
|
||||
expect(screen.getByText('PREVIEW')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'PREVIEW' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no pill for a stable build', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('stable'), status: 'ready', retry: vi.fn() });
|
||||
renderBar();
|
||||
expect(screen.queryByText('DEV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the tab touch targets present alongside the pill', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() });
|
||||
renderBar();
|
||||
expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lays the pill in-flow as a non-overlapping sibling of the tabs', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() });
|
||||
renderBar();
|
||||
const pill = screen.getByText('DEV');
|
||||
// In-flow (flex sibling), not absolutely positioned over the Settings tab.
|
||||
expect(pill).not.toHaveClass('absolute');
|
||||
expect(pill).toHaveClass('self-center', 'shrink-0');
|
||||
// Sibling of the tab buttons inside the nav, so flexbox reserves its own
|
||||
// region rather than letting it overlap the rightmost tab.
|
||||
const settingsTab = screen.getByRole('button', { name: 'Settings' });
|
||||
expect(pill.parentElement).toBe(settingsTab.parentElement);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Home, Layers, Radar, Clock, Settings as SettingsIcon } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useBuildInfo } from '@/hooks/useBuildInfo';
|
||||
import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState';
|
||||
import type { MobileView } from './EditorLayout/mobile-surface';
|
||||
|
||||
@@ -45,7 +46,12 @@ export function MobileTabBar({
|
||||
onNavigate,
|
||||
onSettings,
|
||||
}: MobileTabBarProps) {
|
||||
const { buildInfo } = useBuildInfo();
|
||||
|
||||
const has = (value: ActiveView) => navItems.some(i => i.value === value);
|
||||
const channel = buildInfo?.channel;
|
||||
const showPill = channel === 'dev' || channel === 'preview';
|
||||
const pillLabel = channel === 'dev' ? 'DEV' : 'PREVIEW';
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'home', label: 'Home', icon: Home },
|
||||
@@ -79,7 +85,7 @@ export function MobileTabBar({
|
||||
aria-label="Primary mobile"
|
||||
data-sn-glass="mobile-tabbar"
|
||||
className={cn(
|
||||
'md:hidden flex shrink-0 items-stretch',
|
||||
'md:hidden relative flex shrink-0 items-stretch',
|
||||
'border-t border-hairline',
|
||||
'bg-[color-mix(in_oklch,var(--card)_70%,transparent)] backdrop-blur-md backdrop-saturate-150',
|
||||
'pb-[max(8px,env(safe-area-inset-bottom))]',
|
||||
@@ -108,6 +114,17 @@ export function MobileTabBar({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{showPill ? (
|
||||
<span
|
||||
className={
|
||||
channel === 'dev'
|
||||
? 'shrink-0 self-center mr-2 pointer-events-none font-mono text-[8px] leading-none uppercase tracking-[0.16em] px-1 py-0.5 rounded bg-warning/15 text-warning border border-warning/30'
|
||||
: 'shrink-0 self-center mr-2 pointer-events-none font-mono text-[8px] leading-none uppercase tracking-[0.16em] px-1 py-0.5 rounded bg-brand/15 text-brand border border-brand/30'
|
||||
}
|
||||
>
|
||||
{pillLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useBuildInfo } from '@/hooks/useBuildInfo';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { useWhatsNewPreference } from '@/hooks/useWhatsNewPreference';
|
||||
import { whatsNewEntries } from '@/whats-new/entries';
|
||||
@@ -15,16 +21,110 @@ import {
|
||||
const linkClassName =
|
||||
'font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-brand hover:text-brand/80 transition-colors';
|
||||
|
||||
const mono = 'font-mono text-sm text-stat-value';
|
||||
|
||||
function BuildChannelChip({ label }: { label: string }) {
|
||||
if (label === 'Dev') {
|
||||
return (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
|
||||
<FlaskConical className="w-2.5 h-2.5 mr-0.5" strokeWidth={1.5} /> Dev
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (label === 'Preview') {
|
||||
return (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-brand/15 text-brand border-brand/30 shrink-0">
|
||||
Preview
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <span className={mono}>{label}</span>;
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
const { license } = useLicense();
|
||||
const { buildInfo, status } = useBuildInfo();
|
||||
const { enabled: whatsNewEnabled, setEnabled: setWhatsNewEnabled } = useWhatsNewPreference();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Loading surfaces a placeholder and error surfaces "Unknown" (truthful);
|
||||
// the reference and revision fields below read "Restricted" for a redacted
|
||||
// hardened image, before their null-check.
|
||||
const channelLabel = (() => {
|
||||
if (status === 'loading') return '…';
|
||||
if (status === 'error' || !buildInfo) return 'Unknown';
|
||||
switch (buildInfo.channel) {
|
||||
case 'dev': return 'Dev';
|
||||
case 'preview': return 'Preview';
|
||||
case 'stable': return 'Stable';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
})();
|
||||
const resolveLabel = (value: string | null | undefined) =>
|
||||
buildInfo?.restricted
|
||||
? 'Restricted'
|
||||
: status === 'loading'
|
||||
? '…'
|
||||
: status === 'error'
|
||||
? 'Unknown'
|
||||
: value ?? 'Unknown';
|
||||
|
||||
const imageRefLabel = resolveLabel(buildInfo?.imageRef);
|
||||
const revisionLabel = resolveLabel(buildInfo?.revision);
|
||||
const imageIdLabel =
|
||||
status === 'loading' ? '…'
|
||||
: status === 'error' || !buildInfo?.imageId ? 'Unknown'
|
||||
: `sha256:${buildInfo.imageId.slice(0, 12)}`;
|
||||
|
||||
const copyImageId = async () => {
|
||||
if (!buildInfo?.imageId) return;
|
||||
try {
|
||||
await copyToClipboard(`sha256:${buildInfo.imageId}`);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error('Could not copy the image id.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SettingsSection title="Build">
|
||||
<SettingsField label="Version">
|
||||
<span className="font-mono text-sm text-stat-value">v{__APP_VERSION__}</span>
|
||||
<span className={mono}>v{buildInfo?.version ?? __APP_VERSION__}</span>
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Channel"
|
||||
helper="The build track this control plane instance is running."
|
||||
>
|
||||
<BuildChannelChip label={channelLabel} />
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Current image"
|
||||
helper="The image this control plane was started with. A compose edit changes the configured target until the container is recreated."
|
||||
>
|
||||
<span className={`${mono} break-all`}>{imageRefLabel}</span>
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Revision"
|
||||
helper="The immutable digest or pinned dev commit this build resolves to."
|
||||
>
|
||||
<span className={`${mono} break-all`}>{revisionLabel}</span>
|
||||
</SettingsField>
|
||||
{imageIdLabel !== 'Unknown' && imageIdLabel !== '…' ? (
|
||||
<SettingsField
|
||||
label="Image ID"
|
||||
helper="The sha256 identifier of the running image. Click to copy the full id."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyImageId()}
|
||||
className="text-xs font-mono bg-muted px-2 py-1 rounded cursor-pointer text-stat-value hover:text-brand transition-colors"
|
||||
>
|
||||
{copied ? 'Copied' : imageIdLabel}
|
||||
</button>
|
||||
</SettingsField>
|
||||
) : null}
|
||||
<SettingsField label="Tier">
|
||||
<TierBadge />
|
||||
</SettingsField>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useBuildInfo } from '@/hooks/useBuildInfo';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import {
|
||||
Crown, CheckCircle, XCircle, Clock, ExternalLink,
|
||||
@@ -47,8 +48,8 @@ function formatChannel(channel: ImageChannel): string {
|
||||
return 'Community';
|
||||
case 'hardened':
|
||||
return 'Hardened';
|
||||
default:
|
||||
return 'Custom';
|
||||
case 'unknown':
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ function getTierMastheadValue(tier?: string): string {
|
||||
|
||||
export function LicenseSection() {
|
||||
const { license, isPaid, activate, deactivate } = useLicense();
|
||||
const { buildInfo, status: buildInfoStatus } = useBuildInfo();
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState('');
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||
@@ -253,16 +255,28 @@ export function LicenseSection() {
|
||||
) : null}
|
||||
<SettingsField
|
||||
label="Current image"
|
||||
helper={channelStatus?.channel === 'hardened' && !channelStatus.composeImageRef
|
||||
helper={buildInfo?.restricted
|
||||
? 'Hardened image details are available to administrators only.'
|
||||
: 'Current image channel for this control plane.'}
|
||||
>
|
||||
<span className="font-mono text-xs text-stat-value break-all">
|
||||
{channelStatus?.composeImageRef ?? formatChannel(channelStatus?.channel ?? 'unknown')}
|
||||
{buildInfo?.restricted
|
||||
? 'Restricted'
|
||||
: buildInfoStatus === 'loading'
|
||||
? '…'
|
||||
: buildInfoStatus === 'error'
|
||||
? 'Unknown'
|
||||
: buildInfo?.imageRef ?? 'Unknown'}
|
||||
</span>
|
||||
</SettingsField>
|
||||
<SettingsField label="Channel">
|
||||
<span className="text-sm text-stat-value">{formatChannel(channelStatus?.channel ?? 'unknown')}</span>
|
||||
<span className="text-sm text-stat-value">
|
||||
{buildInfoStatus === 'loading'
|
||||
? '…'
|
||||
: buildInfoStatus === 'error' || !buildInfo
|
||||
? 'Unknown'
|
||||
: formatChannel(buildInfo.imageChannel)}
|
||||
</span>
|
||||
</SettingsField>
|
||||
{channelStatus?.operation?.state === 'failed' ? (
|
||||
<SettingsField
|
||||
|
||||
@@ -42,6 +42,36 @@ vi.mock('@/hooks/useWhatsNewPreference', () => ({
|
||||
useWhatsNewPreference: () => ({ enabled: true, setEnabled: mockSetEnabled, hasUnseen: false, markSeen: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useBuildInfo', () => ({
|
||||
useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })),
|
||||
}));
|
||||
import { useBuildInfo } from '@/hooks/useBuildInfo';
|
||||
import type { BuildInfo } from '@/context/BuildInfoProvider';
|
||||
|
||||
const { mockCopyToClipboard, mockToastError } = vi.hoisted(() => ({
|
||||
mockCopyToClipboard: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: mockCopyToClipboard }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: mockToastError, success: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockUseBuildInfo = vi.mocked(useBuildInfo);
|
||||
|
||||
function buildInfo(over: Partial<BuildInfo> = {}): BuildInfo {
|
||||
return {
|
||||
version: '0.97.1',
|
||||
channel: 'dev',
|
||||
imageChannel: 'community',
|
||||
imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev',
|
||||
imageId: 'a'.repeat(64),
|
||||
revision: 'dev-abc1234',
|
||||
restricted: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// The shipped entries.json is empty, so populate it here; the empty state has its own file.
|
||||
vi.mock('@/whats-new/entries', () => ({
|
||||
whatsNewEntries: [{ id: 'entry-a', title: 'A feature', blurb: 'Does a thing.' }],
|
||||
@@ -91,3 +121,49 @@ describe('AboutSection Preferences', () => {
|
||||
expect(mockSetEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AboutSection Build identity', () => {
|
||||
it('shows the runtime channel, current image, revision and version', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() });
|
||||
render(<AboutSection />);
|
||||
expect(screen.getByText('Dev')).toBeTruthy();
|
||||
expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toBeTruthy();
|
||||
expect(screen.getByText('dev-abc1234')).toBeTruthy();
|
||||
expect(screen.getByText('v0.97.1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('labels redacted hardened reference fields Restricted, not Unknown', () => {
|
||||
mockUseBuildInfo.mockReturnValue({
|
||||
buildInfo: buildInfo({ channel: 'stable', restricted: true, imageRef: null, revision: null }),
|
||||
status: 'ready',
|
||||
retry: vi.fn(),
|
||||
});
|
||||
render(<AboutSection />);
|
||||
expect(screen.getByText('Stable')).toBeTruthy();
|
||||
expect(screen.getAllByText('Restricted').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText('Unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Unknown for reference fields when build info is unavailable', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: null, status: 'error', retry: vi.fn() });
|
||||
render(<AboutSection />);
|
||||
expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('wraps long image and revision tokens so they do not overflow', () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() });
|
||||
render(<AboutSection />);
|
||||
expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toHaveClass('break-all');
|
||||
expect(screen.getByText('dev-abc1234')).toHaveClass('break-all');
|
||||
});
|
||||
|
||||
it('surfaces an error toast when copying the image id fails', async () => {
|
||||
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() });
|
||||
mockCopyToClipboard.mockRejectedValue(new Error('clipboard blocked'));
|
||||
render(<AboutSection />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /sha256:/ }));
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(`sha256:${'a'.repeat(64)}`);
|
||||
expect(mockToastError).toHaveBeenCalledWith('Could not copy the image id.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,10 @@ vi.mock('@/hooks/useWhatsNewPreference', () => ({
|
||||
useWhatsNewPreference: () => ({ enabled: true, setEnabled: vi.fn(), hasUnseen: false, markSeen: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useBuildInfo', () => ({
|
||||
useBuildInfo: () => ({ buildInfo: null, status: 'ready', retry: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe("AboutSection with no What's New entries authored", () => {
|
||||
it('hides the Preferences section entirely, so no toggle describes an absent icon', () => {
|
||||
render(<AboutSection />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { LicenseInfo } from '@/context/LicenseContext';
|
||||
import type { BuildInfoContextType } from '@/context/BuildInfoProvider';
|
||||
|
||||
const useLicenseMock = vi.fn();
|
||||
|
||||
@@ -12,6 +13,12 @@ vi.mock('../MastheadStatsContext', () => ({
|
||||
useMastheadStats: () => {},
|
||||
}));
|
||||
|
||||
const useBuildInfoMock = vi.fn<() => BuildInfoContextType>(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() }));
|
||||
|
||||
vi.mock('@/hooks/useBuildInfo', () => ({
|
||||
useBuildInfo: () => useBuildInfoMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/TierBadge', () => ({
|
||||
TierBadge: () => <span data-testid="tier-badge">tier</span>,
|
||||
}));
|
||||
@@ -135,3 +142,72 @@ describe('LicenseSection pricing link', () => {
|
||||
expect(screen.getByText('See pricing')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LicenseSection build rows (running identity consistency)', () => {
|
||||
beforeEach(() => {
|
||||
useLicenseMock.mockReset();
|
||||
useBuildInfoMock.mockReset();
|
||||
useBuildInfoMock.mockReturnValue({ buildInfo: null, status: 'ready', retry: vi.fn() });
|
||||
mockLicense(baseLicense());
|
||||
});
|
||||
|
||||
it('renders the running Community channel and image ref, regardless of configured target', () => {
|
||||
useBuildInfoMock.mockReturnValue({
|
||||
buildInfo: {
|
||||
version: '0.97.1',
|
||||
channel: 'stable',
|
||||
imageChannel: 'community',
|
||||
imageRef: 'ghcr.io/studio-saelix/sencho:0.97.1',
|
||||
imageId: 'a'.repeat(64),
|
||||
revision: null,
|
||||
restricted: false,
|
||||
},
|
||||
status: 'ready',
|
||||
retry: vi.fn(),
|
||||
});
|
||||
render(<LicenseSection />);
|
||||
// The configured/compose target is hardened, but the running build is
|
||||
// Community: the row must show the running image, never the target.
|
||||
expect(screen.getByText('ghcr.io/studio-saelix/sencho:0.97.1')).toBeTruthy();
|
||||
expect(screen.queryByText('Hardened')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a hardened running image as Hardened channel and Restricted image, not Unknown', () => {
|
||||
useBuildInfoMock.mockReturnValue({
|
||||
buildInfo: {
|
||||
version: '0.97.1',
|
||||
channel: 'stable',
|
||||
imageChannel: 'hardened',
|
||||
imageRef: null,
|
||||
imageId: 'b'.repeat(64),
|
||||
revision: null,
|
||||
restricted: true,
|
||||
},
|
||||
status: 'ready',
|
||||
retry: vi.fn(),
|
||||
});
|
||||
render(<LicenseSection />);
|
||||
expect(screen.getByText('Hardened')).toBeTruthy();
|
||||
expect(screen.getByText('Restricted')).toBeTruthy();
|
||||
expect(screen.queryByText('Unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('labels an unclassifiable running image Channel Unknown, never Custom', () => {
|
||||
useBuildInfoMock.mockReturnValue({
|
||||
buildInfo: {
|
||||
version: '0.97.1',
|
||||
channel: 'unknown',
|
||||
imageChannel: 'unknown',
|
||||
imageRef: null,
|
||||
imageId: null,
|
||||
revision: null,
|
||||
restricted: false,
|
||||
},
|
||||
status: 'ready',
|
||||
retry: vi.fn(),
|
||||
});
|
||||
render(<LicenseSection />);
|
||||
expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Custom')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SidebarBrand } from './SidebarBrand';
|
||||
import { chipDetail } from './chipDetail';
|
||||
import type { BuildInfo } from '@/context/BuildInfoProvider';
|
||||
|
||||
function info(channel: BuildInfo['channel']): BuildInfo {
|
||||
return {
|
||||
version: '0.97.1',
|
||||
channel,
|
||||
imageChannel: 'community',
|
||||
imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1',
|
||||
imageId: 'a'.repeat(64),
|
||||
revision: null,
|
||||
restricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SidebarBrand build-identity chip', () => {
|
||||
it('shows a DEV text chip for a dev build', () => {
|
||||
render(<SidebarBrand isDarkMode={false} buildInfo={info('dev')} />);
|
||||
const chip = screen.getByText('DEV');
|
||||
expect(chip).toBeInTheDocument();
|
||||
// Text is the cue, not color alone.
|
||||
expect(chip.tagName).toBe('SPAN');
|
||||
expect(chip.textContent).toContain('DEV');
|
||||
});
|
||||
|
||||
it('shows a PREVIEW text chip for a preview build', () => {
|
||||
render(<SidebarBrand isDarkMode={false} buildInfo={info('preview')} />);
|
||||
expect(screen.getByText('PREVIEW')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no chip for a stable build', () => {
|
||||
render(<SidebarBrand isDarkMode={false} buildInfo={info('stable')} />);
|
||||
expect(screen.queryByText('DEV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no chip when build info is unavailable', () => {
|
||||
render(<SidebarBrand isDarkMode={false} buildInfo={null} />);
|
||||
expect(screen.queryByText('DEV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers the runtime version when available', () => {
|
||||
render(<SidebarBrand isDarkMode={false} buildInfo={info('stable')} />);
|
||||
expect(screen.getByText('v0.97.1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chipDetail', () => {
|
||||
it('reads Restricted for a redacted hardened reference', () => {
|
||||
const b: BuildInfo = { ...info('stable'), restricted: true, imageRef: null, revision: null };
|
||||
expect(chipDetail(b)).toBe('Restricted');
|
||||
});
|
||||
|
||||
it('combines the reference and revision when both are present', () => {
|
||||
const b: BuildInfo = { ...info('dev'), revision: 'dev-abc1234' };
|
||||
expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev · dev-abc1234');
|
||||
});
|
||||
|
||||
it('reads the reference alone when the revision is unknown', () => {
|
||||
const b: BuildInfo = { ...info('dev'), revision: null };
|
||||
expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
});
|
||||
|
||||
it('reads Unknown when the reference is absent and not restricted', () => {
|
||||
const b: BuildInfo = { ...info('dev'), imageRef: null };
|
||||
expect(chipDetail(b)).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,17 @@
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { BuildInfo } from '@/context/BuildInfoProvider';
|
||||
import { chipDetail } from './chipDetail';
|
||||
|
||||
interface SidebarBrandProps {
|
||||
isDarkMode: boolean;
|
||||
buildInfo?: BuildInfo | null;
|
||||
}
|
||||
|
||||
export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
|
||||
export function SidebarBrand({ isDarkMode, buildInfo }: SidebarBrandProps) {
|
||||
const channel = buildInfo?.channel;
|
||||
const showChip = channel === 'dev' || channel === 'preview';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 px-4 h-14 border-b border-glass-border">
|
||||
<img
|
||||
@@ -10,11 +19,32 @@ export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
|
||||
alt=""
|
||||
className="w-9 h-9 shrink-0"
|
||||
/>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-display italic text-[28px] leading-none text-foreground">Sencho</span>
|
||||
<span className="font-mono text-[10px] tracking-[0.18em] uppercase text-stat-subtitle">
|
||||
v{__APP_VERSION__}
|
||||
v{buildInfo?.version ?? __APP_VERSION__}
|
||||
</span>
|
||||
{showChip ? (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={
|
||||
channel === 'dev'
|
||||
? 'inline-flex items-center font-mono text-[9px] leading-3 uppercase tracking-[0.16em] px-1.5 py-0.5 rounded bg-warning/15 text-warning border border-warning/30'
|
||||
: 'inline-flex items-center font-mono text-[9px] leading-3 uppercase tracking-[0.16em] px-1.5 py-0.5 rounded bg-brand/15 text-brand border border-brand/30'
|
||||
}
|
||||
>
|
||||
{channel === 'dev' ? <FlaskConical className="w-2 h-2 mr-1" strokeWidth={1.5} /> : null}
|
||||
{channel === 'dev' ? 'DEV' : 'PREVIEW'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<span className="font-mono text-[10px]">{chipDetail(buildInfo)}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,10 +11,12 @@ import { StackList, type StackListProps } from './StackList';
|
||||
import type { FilterChip } from './sidebar-types';
|
||||
import type { BulkAction } from '@/hooks/useBulkStackActions';
|
||||
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
|
||||
import type { BuildInfo } from '@/context/BuildInfoProvider';
|
||||
import { isStacksListSettled } from './stacksLoadUi';
|
||||
|
||||
export interface StackSidebarProps {
|
||||
isDarkMode: boolean;
|
||||
buildInfo?: BuildInfo | null;
|
||||
nodeSwitcherSlot: ReactNode;
|
||||
createStackSlot: ReactNode | null;
|
||||
onScan: () => void;
|
||||
@@ -46,7 +48,7 @@ export interface StackSidebarProps {
|
||||
|
||||
export function StackSidebar(props: StackSidebarProps) {
|
||||
const {
|
||||
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
|
||||
isDarkMode, buildInfo, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
|
||||
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
|
||||
list, activitySummary, onActivityAction,
|
||||
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
|
||||
@@ -80,7 +82,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
its kicker chip), so the in-sidebar brand and node rows are redundant
|
||||
there and hidden to save vertical space. */}
|
||||
<div className="max-md:hidden">
|
||||
<SidebarBrand isDarkMode={isDarkMode} />
|
||||
<SidebarBrand isDarkMode={isDarkMode} buildInfo={buildInfo} />
|
||||
</div>
|
||||
<div className="max-md:hidden px-4 pt-2 pb-0">{nodeSwitcherSlot}</div>
|
||||
{canCreate && createStackSlot !== null && (
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { BuildInfo } from '@/context/BuildInfoProvider';
|
||||
|
||||
/** Detail shown under the DEV/PREVIEW chip, or the truthful Unknown / Restricted
|
||||
* states when the running reference is unavailable or redacted for this user. */
|
||||
export function chipDetail(buildInfo: BuildInfo | null | undefined): string {
|
||||
if (buildInfo?.restricted) return 'Restricted';
|
||||
if (buildInfo?.imageRef) {
|
||||
return buildInfo.revision ? `${buildInfo.imageRef} · ${buildInfo.revision}` : buildInfo.imageRef;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { BuildInfoProvider } from './BuildInfoProvider';
|
||||
import { useBuildInfo } from '@/hooks/useBuildInfo';
|
||||
|
||||
const { currentUserRef } = vi.hoisted(() => ({
|
||||
currentUserRef: { value: null as { username: string; role: string } | null },
|
||||
}));
|
||||
|
||||
vi.mock('./AuthContext', () => ({
|
||||
useAuth: () => ({ user: currentUserRef.value }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
const restrictedViewer = {
|
||||
version: '0.97.1',
|
||||
channel: 'stable',
|
||||
imageChannel: 'hardened',
|
||||
imageRef: null,
|
||||
imageId: 'b'.repeat(64),
|
||||
revision: null,
|
||||
restricted: true,
|
||||
};
|
||||
|
||||
const adminCommunity = {
|
||||
version: '0.97.1',
|
||||
channel: 'dev',
|
||||
imageChannel: 'community',
|
||||
imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev',
|
||||
imageId: 'a'.repeat(64),
|
||||
revision: null,
|
||||
restricted: false,
|
||||
};
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), { status });
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const { buildInfo, status } = useBuildInfo();
|
||||
return <div data-testid="s">{status}:{buildInfo?.imageRef ?? 'none'}</div>;
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <BuildInfoProvider>{children}</BuildInfoProvider>;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('BuildInfoProvider', () => {
|
||||
beforeEach(() => {
|
||||
currentUserRef.value = null;
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
|
||||
it('clears permission-filtered state and refetches when the auth identity changes', async () => {
|
||||
currentUserRef.value = { username: 'admin', role: 'admin' };
|
||||
vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity));
|
||||
const { rerender } = render(<Harness />, { wrapper });
|
||||
await flush();
|
||||
expect(screen.getByTestId('s').textContent).toContain('ready');
|
||||
expect(screen.getByTestId('s').textContent).toContain('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
|
||||
// A viewer session must never see the admin-cached ref: the provider
|
||||
// clears immediately and rejects the stale in-flight completion.
|
||||
currentUserRef.value = { username: 'viewer', role: 'viewer' };
|
||||
vi.mocked(apiFetch).mockResolvedValue(json(restrictedViewer));
|
||||
rerender(<Harness />);
|
||||
// Cleared synchronously on the identity change, before the refetch lands.
|
||||
expect(screen.getByTestId('s').textContent).toBe('loading:none');
|
||||
await flush();
|
||||
expect(screen.getByTestId('s').textContent).toBe('ready:none');
|
||||
});
|
||||
|
||||
it('rejects a stale in-flight completion from a prior identity', async () => {
|
||||
currentUserRef.value = { username: 'admin', role: 'admin' };
|
||||
let resolveAdmin!: (r: Response) => void;
|
||||
vi.mocked(apiFetch).mockReturnValue(new Promise((res) => { resolveAdmin = res; }));
|
||||
const { rerender } = render(<Harness />, { wrapper });
|
||||
|
||||
currentUserRef.value = { username: 'viewer', role: 'viewer' };
|
||||
vi.mocked(apiFetch).mockResolvedValue(json(restrictedViewer));
|
||||
rerender(<Harness />);
|
||||
await flush();
|
||||
expect(screen.getByTestId('s').textContent).toBe('ready:none');
|
||||
|
||||
// The old admin request resolving later must not surface its ref.
|
||||
await act(async () => { resolveAdmin(json(adminCommunity)); });
|
||||
expect(screen.getByTestId('s').textContent).toBe('ready:none');
|
||||
});
|
||||
|
||||
it('shares one fetch across two consumers', async () => {
|
||||
currentUserRef.value = { username: 'admin', role: 'admin' };
|
||||
vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity));
|
||||
render(
|
||||
<BuildInfoProvider>
|
||||
<Harness />
|
||||
<Harness />
|
||||
</BuildInfoProvider>,
|
||||
);
|
||||
await flush();
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch).toHaveBeenCalledWith('/build-info', expect.objectContaining({ localOnly: true }));
|
||||
expect(screen.getAllByTestId('s')).toHaveLength(2);
|
||||
for (const el of screen.getAllByTestId('s')) {
|
||||
expect(el.textContent).toContain('ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces error as Unknown and retries on focus', async () => {
|
||||
currentUserRef.value = { username: 'admin', role: 'admin' };
|
||||
vi.mocked(apiFetch).mockRejectedValue(new Error('down'));
|
||||
render(<Harness />, { wrapper });
|
||||
await flush();
|
||||
expect(screen.getByTestId('s').textContent).toBe('error:none');
|
||||
|
||||
vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity));
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId('s').textContent).toBe('ready:ghcr.io/studio-saelix/sencho-dev:dev');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createContext, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown';
|
||||
export type ImageChannel = 'community' | 'hardened' | 'unknown';
|
||||
export type BuildInfoStatus = 'loading' | 'ready' | 'error';
|
||||
|
||||
/** Canonical runtime build identity of the control instance. Mirrors the
|
||||
* /api/build-info response: imageRef and revision are nulled for hardened
|
||||
* images when the viewer is not an admin, with `restricted` marking that
|
||||
* redaction (the UI shows "Restricted", never "Unknown", when set).
|
||||
* `restricted === true` implies `imageRef === null && revision === null`. */
|
||||
export interface BuildInfo {
|
||||
version: string | null;
|
||||
channel: BuildChannel;
|
||||
imageChannel: ImageChannel;
|
||||
imageRef: string | null;
|
||||
imageId: string | null;
|
||||
revision: string | null;
|
||||
restricted: boolean;
|
||||
}
|
||||
|
||||
export interface BuildInfoContextType {
|
||||
buildInfo: BuildInfo | null;
|
||||
status: BuildInfoStatus;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const BuildInfoContext = createContext<BuildInfoContextType | undefined>(undefined);
|
||||
|
||||
/** Single owner of the control-instance build identity. Mounted inside the
|
||||
* authenticated subtree. One shared fetch (localOnly, so it always targets the
|
||||
* local hub); permission-filtered state is cleared the moment the auth identity
|
||||
* or role changes, and stale in-flight completions from a prior session are
|
||||
* rejected by a generation counter. Focus retries an error so it never sticks
|
||||
* failed; consumers show a placeholder while loading and "Unknown" on error. */
|
||||
export function BuildInfoProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [buildInfo, setBuildInfo] = useState<BuildInfo | null>(null);
|
||||
const [status, setStatus] = useState<BuildInfoStatus>('loading');
|
||||
|
||||
const generationRef = useRef(0);
|
||||
const statusRef = useRef<BuildInfoStatus>('loading');
|
||||
const identityRef = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const gen = ++generationRef.current;
|
||||
setStatus('loading');
|
||||
try {
|
||||
const res = await apiFetch('/build-info', { localOnly: true });
|
||||
if (gen !== generationRef.current) return;
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as BuildInfo;
|
||||
if (gen !== generationRef.current) return;
|
||||
setBuildInfo(data);
|
||||
setStatus('ready');
|
||||
} else {
|
||||
console.error(`[BuildInfo] fetch returned ${res.status}`);
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
if (gen !== generationRef.current) return;
|
||||
console.error('[BuildInfo] fetch failed', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reload whenever the signed-in identity or role changes. Clearing state
|
||||
// synchronously (before the replacement fetch resolves) guarantees a
|
||||
// hardened admin's reference never surfaces in a viewer session.
|
||||
useEffect(() => {
|
||||
const identity = user ? `${user.username}:${user.role}` : null;
|
||||
if (identity !== identityRef.current) {
|
||||
identityRef.current = identity;
|
||||
setBuildInfo(null);
|
||||
setStatus('loading');
|
||||
void load();
|
||||
}
|
||||
}, [user, load]);
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status;
|
||||
}, [status]);
|
||||
|
||||
// A transient failure (auth expiry, hub restart) should not stick: retry the
|
||||
// moment the tab regains focus.
|
||||
useEffect(() => {
|
||||
const onFocus = () => {
|
||||
if (statusRef.current === 'error') void load();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<BuildInfoContext.Provider value={{ buildInfo, status, retry: () => void load() }}>
|
||||
{children}
|
||||
</BuildInfoContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
import { BuildInfoContext, type BuildInfoContextType } from '@/context/BuildInfoProvider';
|
||||
|
||||
/** Consumer of the single shared control-instance build identity. The shell,
|
||||
* About, and Admiral Account all read the same fetched BuildInfo by reference
|
||||
* instead of issuing their own fetches. */
|
||||
export function useBuildInfo(): BuildInfoContextType {
|
||||
const context = useContext(BuildInfoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useBuildInfo must be used within a BuildInfoProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user