fix(gitops): keep an application addressable when its evidence cannot be read (#2000)

* fix(gitops): keep an application addressable when its evidence cannot be read

A remote application whose evidence this hub cannot walk answered 404, the
same as one that does not exist, so an operator's only signal was an
application that had vanished. It now answers 503 with an
`evidence_unavailable` code when the row names the application but the
projection cannot be read, and the view says evidence is unavailable and
offers a retry. A row that never named the application still answers 404,
and an unreadable row stays 404 so the two cannot be told apart.

Validating that answer meant walking the projection properly. The detail
endpoint and the portfolio now check the same evidence: an artifact
qualification the facet and its latest evidence disagree on, a blank
generation or identity standing in for a real one, and a status from a
newer node all read as unknown evidence rather than as a convergence
claim. Convergence itself is now proven from the target evidence rather
than read off the rollout status, an Inline Blueprint no longer inherits a
Blueprint's claim, and a target the intent withdrew no longer keeps an
application in the attention queue.

Reading a remote row also authorizes it against the node that owns it
rather than the node the caller is viewing, and the phone portfolio says
partial when the merge cap dropped rows, not only when a node failed.

* fix(gitops): grade unfamiliar evidence as unknown instead of unavailable

Two shape validators rejected a vocabulary this build does not recognize, which
made a whole projection unusable rather than merely unfamiliar. An unusable
projection costs the operator its row: the portfolio shows an evidence
unavailable placeholder and the detail route answers 503. That claims the
evidence is unavailable, which is a stronger statement than an unfamiliar word
supports.

A last-known-good unavailable reason is now accepted on its structure and
graded as unknown evidence instead, on both sides of the wire. The browser
refusing the reason the server had already graded would only have replaced the
honest answer with a total read failure, and the two validators would have
disagreed about the same payload.

A drift identity kind is accepted the same way, so one unfamiliar kind costs
that row its label instead of the whole application. The label names the kind
it cannot read instead of rendering nothing. The kind list is a total record
over the identity union, so adding a kind without wording for it is a compile
error, and the lookup is own-property, so an inherited key reads as unfamiliar.

A blank reason is still rejected, and the mirror consistency rules still reject
an incoherent last-known-good record.

Verified: backend and frontend tsc clean; seven backend GitOps suites pass (446
tests); full frontend suite passes (3597 tests); frontend lint reports 0 errors.

* fix(gitops): restore the recovery-required rollback scope and drop a dead import

The conflict resolution carried this branch's three-member failed-runtime set
over the four-member set on the main branch, because this branch had reordered
the same three entries and the two edits merged without a conflict marker. A
target whose recovery is restoring or compensating reports recovery_required,
so it stopped counting as a failed target and the rollback dialog stopped
offering the failed-targets scope for exactly the stacks that need recovery.

The set now matches the main branch, with the type annotation kept. The
existing test covered recovery_failed only, so the scope is now asserted for
both recovery states.

Also removes a type import the same resolution orphaned. The type checker does
not report unused imports, so this surfaced only in the backend lint job.
This commit is contained in:
Anso
2026-09-27 00:06:03 -04:00
committed by GitHub
parent aadbd05e06
commit acfcb5567b
33 changed files with 2083 additions and 149 deletions
@@ -0,0 +1,253 @@
import bcrypt from 'bcrypt';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanupTestDb, loginAsTestAdmin, setupTestDb } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
const fetchRows = vi.hoisted(() => vi.fn<(nodeId: number) => Promise<unknown[] | null | 'unsupported'>>());
vi.mock('../services/gitops/portfolioAggregator', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/gitops/portfolioAggregator')>();
return { ...actual, fetchRemoteSourceRows: fetchRows };
});
let tmpDir: string;
let app: import('express').Express;
let adminCookie: string;
let viewerCookie: string;
let remoteId: number;
function remoteRow(gitopsRevision: unknown): Record<string, unknown> {
return { stack_name: 'remote-web', stackResourcePresent: true, gitopsRevision };
}
function unusableRow(): Record<string, unknown> {
return {
stack_name: 'remote-web',
stackResourcePresent: true,
gitopsRevision: { applicationId: 'app-remote', targetMode: 'direct' },
};
}
function malformedFacetsRow(): Record<string, unknown> {
return {
stack_name: 'remote-web',
stackResourcePresent: true,
gitopsRevision: {
...readableProjection(),
facets: {},
},
};
}
function readableSource(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
status: 'source_poll_scheduled',
nextPollAt: 2000,
configuredRepoUrl: 'https://example.test/repository.git',
repoIdentity: { host: 'example.test', pathname: '/repository.git' },
configuredRef: 'main',
desiredCommitSha: null,
fetchedCommitSha: null,
candidateGenerationId: null,
acceptedGenerationId: 'generation-1',
...overrides,
};
}
function readableFacets(): Record<string, unknown> {
return {
source: readableSource(),
artifact: { status: 'not_applicable' },
placement: { status: 'unbound_direct' },
rollout: { status: 'not_applicable' },
};
}
function readableProjection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
schemaVersion: 1,
targetMode: 'direct',
applicationId: 'app-remote',
lifecycleStatus: 'active',
stackName: 'remote-web',
blueprintId: null,
rolloutGenerationId: null,
approvals: {
sourceAcceptanceRef: null,
placementApprovalRef: null,
rolloutAuthorizationRef: null,
legacyCombinedApprovalRef: null,
},
facets: readableFacets(),
targets: [],
drift: [],
limitations: [],
availableActions: [],
...overrides,
};
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
const passwordHash = await bcrypt.hash('detail-viewer-pass', 1);
DatabaseService.getInstance().addUser({
username: 'detail-viewer',
password_hash: passwordHash,
role: 'viewer',
});
const login = await request(app)
.post('/api/auth/login')
.send({ username: 'detail-viewer', password: 'detail-viewer-pass' });
const cookies = login.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
remoteId = DatabaseService.getInstance().addNode({
name: 'detail-remote',
type: 'remote',
api_url: 'http://127.0.0.1:29996',
api_token: 'tok',
compose_dir: process.env.COMPOSE_DIR!,
is_default: false,
});
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
fetchRows.mockReset();
});
describe('GET /api/gitops/applications/:id (remote Direct)', () => {
it('answers 503 evidence_unavailable for a matched row without a usable revision', async () => {
fetchRows.mockResolvedValue([unusableRow()]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('evidence_unavailable');
});
it('answers 503 evidence_unavailable when the matched projection cannot be walked', async () => {
fetchRows.mockResolvedValue([malformedFacetsRow()]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('evidence_unavailable');
});
it.each([
['schema version', (projection: Record<string, unknown>) => ({ ...projection, schemaVersion: 2 })],
['approvals', (projection: Record<string, unknown>) => ({ ...projection, approvals: {} })],
['drift identity', (projection: Record<string, unknown>) => ({
...projection,
drift: [{
class: 'source',
freshnessAt: null,
owner: 'operator',
reason: 'missing expected identity',
configuredPolicy: null,
affectedTargets: [],
action: 'fetch',
observed: { kind: 'unknown' },
}],
})],
['limitation message', (projection: Record<string, unknown>) => ({
...projection,
limitations: [{ code: 'invalid_evidence', evidence: null }],
})],
])('rejects a readable row with malformed %s as evidence_unavailable', async (_label, mutate) => {
fetchRows.mockResolvedValue([remoteRow(mutate(readableProjection()))]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('evidence_unavailable');
});
it('accepts valid missing-artifact evidence with a null expected identity', async () => {
fetchRows.mockResolvedValue([remoteRow(readableProjection({ facets: { ...readableFacets(), artifact: { status: 'artifact_unresolved', generationId: 'generation-1', expected: null, latestEvidence: null, limitation: 'artifact_pointer_missing', }, }, }))]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.projection.facets.artifact.status).toBe('artifact_unresolved');
});
it('rejects a source status without its required failure evidence', async () => {
fetchRows.mockResolvedValue([remoteRow(readableProjection({ facets: { ...readableFacets(), source: readableSource({ status: 'source_failed' }), }, }))]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('evidence_unavailable');
});
it('rejects contradictory artifact qualification evidence', async () => {
fetchRows.mockResolvedValue([remoteRow(readableProjection({ facets: { ...readableFacets(), artifact: { status: 'artifact_exact', artifactSetId: 'artifact-1', generationId: 'generation-1', evidenceVersion: 1, qualification: 'exact', freshnessAt: 1, expected: null, latestEvidence: { artifactSetId: 'artifact-1', evidenceVersion: 1, qualification: 'unavailable', identity: null, }, }, }, }))]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('evidence_unavailable');
});
it('rejects artifact identity changes without a differing expected identity', async () => {
fetchRows.mockResolvedValue([remoteRow(readableProjection({ facets: { ...readableFacets(), artifact: { status: 'artifact_identity_changed', artifactSetId: 'artifact-1', generationId: 'generation-1', evidenceVersion: 1, qualification: 'exact', freshnessAt: 1, expected: null, latestEvidence: { artifactSetId: 'artifact-1', evidenceVersion: 1, qualification: 'exact', identity: 'sha256:artifact', }, }, }, }))]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('evidence_unavailable');
});
it('rejects an exact claim whose expected identity disagrees with the latest evidence', async () => {
fetchRows.mockResolvedValue([remoteRow(readableProjection({ facets: { ...readableFacets(), artifact: { status: 'artifact_exact', artifactSetId: 'artifact-1', generationId: 'generation-1', evidenceVersion: 1, qualification: 'exact', freshnessAt: 1, expected: { artifactSetId: 'artifact-1', evidenceVersion: 1, qualification: 'exact', identity: 'sha256:something-else', }, latestEvidence: { artifactSetId: 'artifact-1', evidenceVersion: 1, qualification: 'exact', identity: 'sha256:artifact', }, }, }, }))]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('evidence_unavailable');
});
it('accepts an artifact verdict and qualification this build does not know', async () => {
fetchRows.mockResolvedValue([remoteRow(readableProjection({ facets: { ...readableFacets(), artifact: { status: 'artifact_future_verdict', artifactSetId: 'artifact-1', generationId: 'generation-1', evidenceVersion: 1, qualification: 'future_qualification', freshnessAt: 1, expected: null, latestEvidence: { artifactSetId: 'artifact-1', evidenceVersion: 1, qualification: 'future_qualification', identity: 'sha256:artifact', }, }, }, }))]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.projection.facets.artifact.status).toBe('artifact_future_verdict');
});
it('keeps 404 when no readable row names the application', async () => {
fetchRows.mockResolvedValue([{
...unusableRow(),
gitopsRevision: { applicationId: 'app-other', targetMode: 'direct' },
}]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(404);
expect(res.body.code).toBeUndefined();
});
it('keeps 404 for the same row when the caller cannot read it', async () => {
fetchRows.mockResolvedValue([{ ...unusableRow(), stack_name: null }]);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', viewerCookie);
expect(res.status).toBe(404);
expect(res.body.code).toBeUndefined();
});
it('keeps 503 node_unreachable when the owning node does not answer', async () => {
fetchRows.mockResolvedValue(null);
const res = await request(app)
.get(`/api/gitops/applications/${remoteId}:app-remote`)
.set('Cookie', adminCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('node_unreachable');
});
});
@@ -452,4 +452,18 @@ describe('attentionReasons', () => {
'target_unreachable',
]);
});
it('ignores a retired target, whose failure it can no longer be waiting on', () => {
const failing = {
connectivity: 'unreachable' as const,
runtime: runtimeAt('failed_after_mutation'),
health: healthAt('failed'),
};
// The same failure on a live target is still attention, so the retirement
// is what silences it rather than the state being unreadable.
expect(attentionReasons(liveProjection({ targets: [target(failing)] })).length).toBeGreaterThan(0);
expect(attentionReasons(liveProjection({
targets: [target({ ...failing, tombstoned: true })],
}))).toEqual([]);
});
});
@@ -328,6 +328,26 @@ describe('gitops identity proxy', () => {
expect((filtered as { nextCursor: string }).nextCursor).toBe('100.abc');
});
it('authorizes each row against the node that owns it, not the node the caller is viewing', () => {
// Every role holds global stack:read, so which node a row is authorized
// against is invisible from the permission result alone. The node handed
// to the callback is what makes it observable, and getting it wrong makes
// a future role that lacks a global read authorize against the node the
// caller happens to be viewing.
const asked: number[] = [];
const payload = [sourceRow('web', 'active', true), sourceRow('gone', 'deleted', true)];
filterRemoteIdentityPayload(
'/git-sources',
payload,
(requirement, nodeId) => {
asked.push(nodeId);
return requirement.kind === 'stack_read';
},
7,
);
expect(asked).toEqual([7, 7]);
});
it('leaves a drift payload unfiltered', () => {
// The drift routes are per-stack, authorized by name before the hop, and
// return one object rather than a cross-stack collection. Re-filtering
@@ -86,7 +86,7 @@ function createBlueprintApplication(id: string, blueprintId: number): void {
function remoteSourceRow(
applicationId: string,
stackName: string,
revision: GitOpsRevisionProjection,
revision: unknown,
stackResourcePresent = true,
): unknown[] {
return [{
@@ -124,6 +124,12 @@ function remoteSourceRow(
type LiveProjection = Extract<GitOpsRevisionProjection, { applicationId: string }>;
type LiveFacets = LiveProjection['facets'];
/**
* A projection whose `targets` are unvalidated, so a test can push a target no
* live projection could carry and assert the row degrades instead of vanishing.
*/
type RemoteProjectionFixture = Omit<LiveProjection, 'targets'> & { targets: unknown[] };
function liveProjectionFixture(input: {
applicationId: string;
targetMode: LiveProjection['targetMode'];
@@ -186,6 +192,11 @@ function bindBlueprintAuthority(projection: LiveProjection): void {
projection.facets.placement = { status: 'blueprint_bound', completion: 'unknown' };
}
/** `remoteProjection` with unvalidated `targets`, for the malformed-payload cases. */
function rawRemoteProjection(applicationId: string, stackName: string): RemoteProjectionFixture {
return { ...remoteProjection(applicationId, stackName), targets: [] };
}
function targetProjection(
connectivity: GitOpsTargetProjection['connectivity'],
rolloutGenerationId: string | null = null,
@@ -444,6 +455,32 @@ describe('aggregateGitOpsPortfolio', () => {
expect(coverage.find(candidate => candidate.nodeId === remoteId)?.state).toBe('ok');
});
it('keeps a malformed remote application addressable instead of relabeling it as legacy', async () => {
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0]!.id;
const remoteId = db.addNode({
name: 'port-remote-evidence-gap',
type: 'remote',
api_url: 'http://127.0.0.1:29995',
api_token: 'tok',
compose_dir: '/app/compose',
is_default: false,
});
const { rows } = await aggregateGitOpsPortfolio(adminReq(localNodeId), {
fetchRows: async (nodeId: number) => nodeId === remoteId ? [{
stack_name: 'remote-gap',
stackResourcePresent: true,
gitopsRevision: { applicationId: 'app-evidence-gap', targetMode: 'direct' },
}] : null,
});
const row = rows.find(candidate => candidate.id === `${remoteId}:app-evidence-gap`);
expect(row).toBeDefined();
expect(row!.evidence).toEqual({ partial: true, unreachableNodes: [], unknown: true });
expect(row!.limitations).toContain('evidence_unavailable');
});
it('keeps failures inside the merge cap, dropping settled rows first', async () => {
const db = DatabaseService.getInstance();
const localNodeId = db.getNodes()[0]!.id;
@@ -500,13 +537,16 @@ describe('aggregateGitOpsPortfolio', () => {
const localNodeId = db.getNodes()[0]!.id;
const remoteId = addRemoteNode('port-remote-new', 29996);
const newer = remoteProjection('app-remote-new', 'new-stack');
const newer = rawRemoteProjection('app-remote-new', 'new-stack');
newer.targetMode = 'blueprint';
newer.rolloutGenerationId = 'rg-1';
bindBlueprintAuthority(newer);
bindBlueprintAuthority(newer as LiveProjection);
newer.facets.rollout = { status: 'exactly_converged_healthy', rolloutGenerationId: 'rg-1' };
const target = targetProjection('reachable', 'rg-1');
// Two vocabularies this build has never heard of, on the same target: the
// runtime status and the connectivity grade.
Object.assign(target.runtime, { status: 'brand_new_status' });
Object.assign(target, { connectivity: 'from_a_newer_node' });
newer.targets = [target];
const { rows } = await aggregateGitOpsPortfolio(adminReq(localNodeId), {
fetchRows: async (nodeId: number) =>
@@ -515,6 +555,7 @@ describe('aggregateGitOpsPortfolio', () => {
const row = rows.find(candidate => candidate.id === `${remoteId}:app-remote-new`);
expect(row).toBeDefined();
expect(row!.runtimeStatus).toBe('brand_new_status');
expect(row!.targets[0].evidence).toBe('unknown');
expect(row!.evidence.unknown).toBe(true);
expect(row!.posture).toBe('unknown');
});
@@ -1004,10 +1045,14 @@ describe('aggregateGitOpsPortfolio', () => {
: null,
});
const row = rows.find(candidate => candidate.id === `${remoteId}:legacy:${stackName}`);
// The row names a real application, so it stays addressable under that
// name and says its evidence is unavailable. Relabelling it legacy would
// hide an application that exists from every surface that lists it.
const row = rows.find(candidate => candidate.id === `${remoteId}:app-${kind}`);
expect(row).toBeDefined();
expect(row!.posture).toBe('unknown');
expect(row!.evidence).toEqual({ partial: true, unreachableNodes: [], unknown: true });
expect(row!.limitations).toContain('evidence_unavailable');
expect(coverage.find(candidate => candidate.nodeId === remoteId)?.state).toBe('ok');
},
);
@@ -1609,6 +1654,168 @@ describe('postureOf', () => {
return projection;
}
/**
* A Blueprint application settled on one reachable target.
*
* The application facet and the target's artifact are the same object on
* purpose: a target reporting a different status than the application is
* exactly the disagreement this fixture must not be able to express.
*/
function settledFixture(rollout: LiveFacets['rollout']): LiveProjection {
const exact = rollout.status === 'exactly_converged_healthy';
const projection = baseFixture({ rollout });
projection.targets = [targetProjection('reachable', 'rg-1', 1, 'p-web', exact ? 'exact' : 'qualified')];
projection.facets.artifact = projection.targets[0].artifact;
return projection;
}
/** The same settled target evidence, proven by a Direct application's own source facet. */
function directSettledFixture(artifactStatus: 'artifact_exact' | 'artifact_qualified'): LiveProjection {
const projection = baseFixture();
projection.targets = [targetProjection('reachable', null, 1, 'p-web', artifactStatus === 'artifact_exact' ? 'exact' : 'qualified')];
projection.facets.artifact = projection.targets[0].artifact;
return projection;
}
it('accepts an exact observation for a qualified artifact claim', () => {
const projection = settledFixture({ status: 'configuration_converged_artifact_qualified', rolloutGenerationId: 'rg-1' });
expect(projection.facets.artifact.status).toBe('artifact_qualified');
expect(projection.targets[0].observedArtifactIdentity.kind).toBe('qualified');
projection.targets[0].observedArtifactIdentity = {
kind: 'exact',
identity: 'sha256:abc',
observedAt: 1,
};
expect(postureOf(projection)).toBe('converged_qualified');
});
it('ignores retired targets when proving convergence', () => {
const projection = settledFixture({ status: 'exactly_converged_healthy', rolloutGenerationId: 'rg-1' });
projection.targets.push({ ...projection.targets[0], nodeId: 2, tombstoned: true });
expect(postureOf(projection)).toBe('converged');
});
it('allows a health-gate-disabled target to keep a stale healthy pointer', () => {
const projection = settledFixture({ status: 'exactly_converged_healthy', rolloutGenerationId: 'rg-1' });
projection.targets[0].health = { status: 'not_applicable' };
projection.targets[0].healthyGenerationId = 'gen-old';
expect(postureOf(projection)).toBe('converged');
});
it('never claims convergence from a blank rollout generation', () => {
const projection = settledFixture({ status: 'exactly_converged_healthy', rolloutGenerationId: 'rg-1' });
projection.rolloutGenerationId = '';
projection.facets.rollout = { status: 'exactly_converged_healthy', rolloutGenerationId: '' } as typeof projection.facets.rollout;
projection.targets[0].rolloutGenerationId = '';
expect(postureOf(projection)).toBe('unknown');
});
it('never claims convergence from an artifact status this build does not know', () => {
const projection = baseFixture({
artifact: { status: 'artifact_future_unknown' } as unknown as ArtifactFacet,
});
expect(postureOf(projection)).toBe('unknown');
});
it('never claims convergence from a known artifact status carrying an unknown qualification', () => {
const projection = settledFixture({ status: 'exactly_converged_healthy', rolloutGenerationId: 'rg-1' });
const artifact = projection.facets.artifact as unknown as Record<string, unknown>;
artifact.qualification = 'future_qualification';
expect(postureOf(projection)).toBe('unknown');
});
it.each([
['the application expected artifact', (projection: LiveProjection) => {
const artifact = projection.facets.artifact as unknown as { expected: Record<string, unknown> | null };
artifact.expected = { artifactSetId: 'artifact-1', evidenceVersion: 1, qualification: 'future_qualification', identity: 'sha256:abc' };
}],
['the target expected artifact', (projection: LiveProjection) => {
const artifact = projection.targets[0].artifact as unknown as { expected: Record<string, unknown> | null };
artifact.expected = { artifactSetId: 'artifact-1', evidenceVersion: 1, qualification: 'future_qualification', identity: 'sha256:abc' };
}],
])('never claims convergence from a future qualification in %s', (_label, mutate) => {
const projection = settledFixture({ status: 'exactly_converged_healthy', rolloutGenerationId: 'rg-1' });
mutate(projection);
expect(postureOf(projection)).toBe('unknown');
});
it('claims a settled Direct application converged from its own target evidence', () => {
expect(postureOf(directSettledFixture('artifact_exact'))).toBe('converged');
expect(postureOf(directSettledFixture('artifact_qualified'))).toBe('converged_qualified');
});
it('never reads a Direct application with an unreachable target as converged', () => {
const projection = directSettledFixture('artifact_exact');
projection.targets[0].connectivity = 'unreachable';
expect(postureOf(projection)).not.toBe('converged');
expect(postureOf(projection)).not.toBe('converged_qualified');
});
it.each([
['an unknown target artifact status', (projection: LiveProjection) => {
projection.targets[0].artifact = { status: 'artifact_future_verdict' } as unknown as ArtifactFacet;
}],
['an unknown LKG status', (projection: LiveProjection) => {
projection.targets[0].lkg = { status: 'future_lkg' } as unknown as LiveProjection['targets'][number]['lkg'];
}],
['an unknown last-known-good reason', (projection: LiveProjection) => {
Object.assign(projection.targets[0], {
lkg: { status: 'unavailable' },
lkgGenerationId: null,
lkgArtifactSetId: null,
lkgUnavailableAt: 1,
lkgUnavailableReason: 'future_reason',
});
}],
['an unknown observed artifact kind', (projection: LiveProjection) => {
projection.targets[0].observedArtifactIdentity = { kind: 'future_kind', identity: 'sha256:abc', observedAt: 1 } as unknown as LiveProjection['targets'][number]['observedArtifactIdentity'];
}],
['an unknown connectivity', (projection: LiveProjection) => {
projection.targets[0].connectivity = 'future' as 'reachable';
}],
])('reports %s as unknown evidence rather than convergence', (_label, mutate) => {
const projection = directSettledFixture('artifact_exact');
mutate(projection);
expect(postureOf(projection)).toBe('unknown');
});
it('keeps a projection readable when a target carries an unfamiliar last-known-good reason', () => {
// A reason from a newer node is unfamiliar, not unusable. The row stays
// addressable and says its evidence is unknown; rejecting the projection
// would report the evidence as unavailable, which is a different and
// stronger claim than the evidence supports.
const projection = remoteProjection('app-future-lkg-reason', 'future-lkg-reason');
const target = remoteTarget(projection, 'reachable');
Object.assign(target, {
lkg: { status: 'unavailable' },
lkgGenerationId: null,
lkgArtifactSetId: null,
lkgUnavailableAt: 1,
lkgUnavailableReason: 'future_reason',
});
projection.targets = [target];
expect(isUsableRevision(projection)).toBe(true);
const row = rowForProjection(projection, 'future-lkg-reason');
expect(row.posture).toBe('unknown');
expect(row.evidence.unknown).toBe(true);
expect(row.limitations).not.toContain('evidence_unavailable');
});
it('rejects an empty last-known-good reason rather than reading it as one', () => {
const projection = remoteProjection('app-empty-lkg-reason', 'empty-lkg-reason');
const target = remoteTarget(projection, 'reachable');
Object.assign(target, {
lkg: { status: 'unavailable' },
lkgGenerationId: null,
lkgArtifactSetId: null,
lkgUnavailableAt: 1,
lkgUnavailableReason: '',
});
projection.targets = [target];
expect(isUsableRevision(projection)).toBe(false);
});
it.each(['source', 'artifact', 'placement', 'rollout'] as const)(
'does not accept a prototype key as a known %s status',
facet => {
@@ -312,6 +312,13 @@ describe('GET /api/gitops/applications', () => {
expect((partial.body as GitOpsPortfolioResponse).applications).toEqual([]);
});
it.each(['nodeId=0', 'nodeId=-1', 'blueprintId=0', 'blueprintId=-1'])('rejects non-positive %s filters', async (filter) => {
const res = await request(app)
.get(`/api/gitops/applications?${filter}`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('rejects unknown filter values instead of answering with a superset', async () => {
const res = await request(app)
.get('/api/gitops/applications?mode=cryptic')
+6 -5
View File
@@ -252,13 +252,13 @@ export function hubNodeIdFor(req: Request): number | undefined {
export function filterRemoteIdentityPayload(
pathname: string,
payload: unknown,
canRead: (requirement: GitOpsReadRequirement) => boolean,
canRead: (requirement: GitOpsReadRequirement, nodeId: number) => boolean,
nodeId: number,
): unknown {
// Only the two cross-stack collections are filtered here.
if (!/^\/git-sources(\/history)?\/?$/.test(pathname)) return payload;
const filtered = filterRows(canRead, payload);
const filtered = filterRows(canRead, payload, nodeId);
const received = countRows(payload);
const kept = countRows(filtered);
// Keeping nothing from a page that had rows is the signature of a remote
@@ -282,8 +282,9 @@ function countRows(payload: unknown): number {
}
function filterRows(
canRead: (requirement: GitOpsReadRequirement) => boolean,
canRead: (requirement: GitOpsReadRequirement, nodeId: number) => boolean,
payload: unknown,
nodeId: number,
): unknown {
return filterIdentityCollection(
payload,
@@ -291,12 +292,12 @@ function filterRows(
stackName: row.stack_name,
gitopsRevision: row.gitopsRevision,
stackResourcePresent: row.stackResourcePresent,
})),
}), nodeId),
(item) => isRecord(item) && canRead(classifyHistoryRow({
stackName: item.stackName,
applicationLifecycleStatus: item.applicationLifecycleStatus,
stackResourcePresent: item.stackResourcePresent,
})),
}), nodeId),
);
}
+1 -1
View File
@@ -394,7 +394,7 @@ export function createRemoteProxyMiddleware(): RequestHandler {
// ship every remote row unfiltered under a 200.
identity.preRewritePath,
payload,
(requirement) => satisfiesGitOpsRead(req, requirement),
requirement => satisfiesGitOpsRead(req, requirement, nodeId),
nodeId,
);
},
+35 -7
View File
@@ -161,7 +161,7 @@ function parseFilters(query: Request['query']): ParseResult {
const raw = stringParam(query[key]);
if (raw === undefined) return null;
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : 'invalid';
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 'invalid';
};
const nodeId = intParam('nodeId');
if (nodeId === 'invalid') return { ok: false, message: 'nodeId must be an integer' };
@@ -221,7 +221,14 @@ function matchesFilters(row: GitOpsPortfolioRow, filters: GitOpsPortfolioFilters
if (filters.rolloutStatus !== undefined && row.rolloutStatus !== filters.rolloutStatus) return false;
if (filters.healthStatus !== undefined && row.healthStatus !== filters.healthStatus) return false;
if (filters.driftClass !== undefined && !row.drift.classes.includes(filters.driftClass)) return false;
if (filters.evidence === 'unknown' && !row.evidence.unknown) return false;
// An unknown connectivity grade is its own evidence grade, so it belongs in
// the unknown-evidence filter even when the row itself claims otherwise. The
// grade is read from connectivity rather than from the target's `evidence`
// string, which folds `unreachable` into `unknown` and would pull every
// unreachable row into this bucket.
if (filters.evidence === 'unknown'
&& !row.evidence.unknown
&& !targets.some(target => target.connectivity === 'unknown')) return false;
if (filters.evidence === 'stale'
&& !targets.some(target => target.evidence === 'stale')
&& !row.attention.includes('target_stale')) return false;
@@ -368,10 +375,18 @@ type ParsedId =
function parsePortfolioId(raw: string): ParsedId {
const blueprint = /^bp:(\d+)$/.exec(raw);
if (blueprint) {
return { kind: 'blueprint', blueprintId: Number(blueprint[1]) };
const blueprintId = Number(blueprint[1]);
return Number.isSafeInteger(blueprintId) && blueprintId > 0
? { kind: 'blueprint', blueprintId }
: { kind: 'invalid' };
}
const direct = /^(\d+):(.+)$/.exec(raw);
if (direct) return { kind: 'direct', nodeId: Number(direct[1]), applicationId: direct[2] };
if (direct) {
const nodeId = Number(direct[1]);
return Number.isSafeInteger(nodeId) && nodeId > 0
? { kind: 'direct', nodeId, applicationId: direct[2] }
: { kind: 'invalid' };
}
return { kind: 'invalid' };
}
@@ -674,7 +689,7 @@ gitopsApplicationsRouter.get('/:id', async (req: Request, res: Response): Promis
const filtered = filterRemoteIdentityPayload(
'/git-sources',
rows,
(requirement) => satisfiesGitOpsRead(req, requirement),
(requirement, nodeId) => satisfiesGitOpsRead(req, requirement, nodeId),
parsedId.nodeId,
);
const candidates = Array.isArray(filtered) ? filtered.filter(isRecord) : [];
@@ -701,6 +716,21 @@ gitopsApplicationsRouter.get('/:id', async (req: Request, res: Response): Promis
row => isUsableRevision(row.gitopsRevision) && row.gitopsRevision.applicationId === parsedId.applicationId,
);
if (!match || !isUsableRevision(match.gitopsRevision)) {
const namesApplication = candidates.some(row => {
if (!isRecord(row.gitopsRevision) || row.gitopsRevision.applicationId !== parsedId.applicationId) return false;
const applicationId = row.gitopsRevision.applicationId;
return typeof applicationId === 'string'
&& applicationId.length > 0
&& !applicationId.startsWith('legacy:')
&& !applicationId.startsWith('bp:');
});
if (namesApplication) {
res.status(503).json({
error: 'The owning node has not reported usable evidence for this application yet',
code: 'evidence_unavailable',
});
return;
}
res.status(404).json({ error: 'Application not found' });
return;
}
@@ -717,8 +747,6 @@ gitopsApplicationsRouter.get('/:id', async (req: Request, res: Response): Promis
blueprintId: projection.blueprintId,
nodeId: parsedId.nodeId,
nodeName,
// Same freshness derivation the list uses, so one application never
// reports different activity depending on which endpoint was asked.
lastActivityAt: freshestFacetTimestamp(projection),
partialNodes: [],
nodeNames,
@@ -87,10 +87,39 @@ const RUNTIME_SEVERITY: readonly string[] = [
'tombstoned',
];
const RUNTIME_RANK = new Map(RUNTIME_SEVERITY.map((status, index) => [status, index]));
const RUNTIME_RANK: ReadonlyMap<string, number> = new Map(RUNTIME_SEVERITY.map((status, index) => [status, index]));
const HEALTH_SEVERITY: readonly string[] = ['failed', 'unknown', 'pending', 'checking', 'passed', 'unbound', 'not_applicable'];
const HEALTH_RANK = new Map(HEALTH_SEVERITY.map((status, index) => [status, index]));
const HEALTH_RANK: ReadonlyMap<string, number> = new Map(HEALTH_SEVERITY.map((status, index) => [status, index]));
/**
* Every `observedArtifactIdentity.kind` this build has vocabulary for.
*
* `unknown`, `missing`, and `unavailable` are the three "no artifact was
* observed" kinds: they are recognized vocabulary, so they do not make a
* projection's evidence unknown, and a target carrying one simply cannot prove
* a convergence claim. A kind outside this set came from a newer node.
*/
const OBSERVED_ARTIFACT_KINDS: ReadonlySet<string> = new Set([
'unknown',
'missing',
'unavailable',
'exact',
'qualified',
'stale',
'local_build_unverified',
]);
const GENERATION_BOUND_ROLLOUT_STATES: ReadonlySet<string> = new Set([
'rollout_queued',
'canary_in_progress',
'batch_in_progress',
'fully_deployed_health_pending',
'configuration_converged_artifact_qualified',
'exactly_converged_healthy',
'rollout_superseded',
]);
const ARTIFACT_QUALIFICATIONS: ReadonlySet<string> = new Set([
'unresolved',
'exact',
@@ -99,6 +128,21 @@ const ARTIFACT_QUALIFICATIONS: ReadonlySet<string> = new Set([
'unavailable',
'local_build_unverified',
]);
/**
* The one qualification each artifact status may carry, keyed by that status.
* Its value domain is exactly `ARTIFACT_QUALIFICATIONS`, so the vocabulary is
* written once: a status missing here carries no pairing requirement.
*/
const ARTIFACT_STATUS_QUALIFICATION: Readonly<Record<string, string>> = {
artifact_exact: 'exact',
artifact_qualified: 'qualified',
artifact_stale: 'stale',
artifact_unavailable: 'unavailable',
artifact_local_build_unverified: 'local_build_unverified',
artifact_resolution_pending: 'unresolved',
};
const ARTIFACT_SERVICE_SOURCES: ReadonlySet<string> = new Set(['registry', 'build', 'unsupported']);
const ARTIFACT_SERVICE_FAILURE_CLASSES: ReadonlySet<string> = new Set([
'unresolved',
@@ -124,7 +168,6 @@ const SETTLED_ROLLOUT_ARTIFACT: ReadonlyMap<string, 'artifact_exact' | 'artifact
['exactly_converged_healthy', 'artifact_exact'],
['configuration_converged_artifact_qualified', 'artifact_qualified'],
]);
const OBSERVED_ARTIFACT_KINDS: ReadonlySet<string> = new Set(['exact', 'qualified', 'stale', 'local_build_unverified']);
const AVAILABLE_ACTIONS: ReadonlySet<string> = new Set([
'fetch',
'apply',
@@ -196,14 +239,29 @@ function worstTargetStatus(
return worst ?? 'not_applicable';
}
/**
* Whether every artifact qualification this facet carries is vocabulary this
* build knows. A newer node's qualification is accepted structurally, but it
* is reported as unknown evidence rather than read as a convergence claim.
*/
function hasKnownArtifactVocabulary(artifact: { status: string } & Record<string, unknown>): boolean {
if ('qualification' in artifact && !ARTIFACT_QUALIFICATIONS.has(String(artifact.qualification))) return false;
const expected = artifact.expected;
if (isRecord(expected) && !ARTIFACT_QUALIFICATIONS.has(String(expected.qualification))) return false;
const latest = artifact.latestEvidence;
if (isRecord(latest) && !ARTIFACT_QUALIFICATIONS.has(String(latest.qualification))) return false;
return true;
}
/**
* Whether the projection carries any status this build has never heard of.
*
* Checks the four top-level facets against the canonical status registry
* (`FACET_EVIDENCE_SOURCE`, which is total over the closed unions) plus the
* per-target runtime/health/connectivity statuses, so a newer node answering
* an older hub is reported as unknown evidence rather than silently
* reinterpreted (or, worse, read as a convergence claim).
* (`FACET_EVIDENCE_SOURCE`, which is total over the closed unions), the
* per-target runtime/health/connectivity statuses, and the per-target
* last-known-good status, last-known-good reason and observed artifact kind, so
* a newer node answering an older hub is reported as unknown evidence rather
* than silently reinterpreted (or, worse, read as a convergence claim).
*/
function hasUnrecognizedStatus(
projection: GitOpsRevisionProjection,
@@ -214,11 +272,17 @@ function hasUnrecognizedStatus(
const { source, artifact, placement, rollout } = projection.facets;
if (!hasFacetStatus(FACET_EVIDENCE_SOURCE.source, source.status)) return true;
if (!hasFacetStatus(FACET_EVIDENCE_SOURCE.artifact, artifact.status)) return true;
if (!hasKnownArtifactVocabulary(artifact)) return true;
if (!hasFacetStatus(FACET_EVIDENCE_SOURCE.placement, placement.status)) return true;
if (!hasFacetStatus(FACET_EVIDENCE_SOURCE.rollout, rollout.status)) return true;
for (const target of targets) {
if (!RUNTIME_RANK.has(target.runtime.status)) return true;
if (!HEALTH_RANK.has(target.health.status)) return true;
if (!Object.hasOwn(FACET_EVIDENCE_SOURCE.artifact, target.artifact.status)) return true;
if (!hasKnownArtifactVocabulary(target.artifact)) return true;
if (!Object.hasOwn(FACET_EVIDENCE_SOURCE.lkg, target.lkg.status)) return true;
if (target.lkgUnavailableReason !== null && !LKG_UNAVAILABLE_REASONS.has(target.lkgUnavailableReason)) return true;
if (!OBSERVED_ARTIFACT_KINDS.has(target.observedArtifactIdentity.kind)) return true;
if (target.connectivity !== 'reachable' && target.connectivity !== 'unreachable' && target.connectivity !== 'stale' && target.connectivity !== 'unknown') return true;
}
return false;
@@ -483,7 +547,7 @@ export function postureOf(projection: GitOpsRevisionProjection): GitOpsPortfolio
&& currentDrift(projection).length === 0
) {
if (artifact.status === 'artifact_exact') return 'converged';
return 'converged_qualified';
if (artifact.status === 'artifact_qualified') return 'converged_qualified';
}
return 'unknown';
@@ -504,6 +568,9 @@ function repositoryOf(projection: GitOpsRevisionProjection): GitOpsPortfolioRepo
function targetSummaries(projection: GitOpsRevisionProjection, nodeNames: Map<number, string | null>): GitOpsPortfolioTargetSummary[] {
if (projection.targetMode === 'not_applicable') return [];
// Tombstoned targets stay in the summary, flagged: the audit trail needs
// them. They are excluded where a *current* answer is read, which is what
// `currentTargets` is for.
return projection.targets.map(target => ({
nodeId: target.nodeId,
nodeName: nodeNames.get(target.nodeId) ?? null,
@@ -771,7 +838,7 @@ export async function aggregateGitOpsPortfolio(req: Request, options: AggregateO
const filtered = filterRemoteIdentityPayload(
'/git-sources',
remoteRows,
(requirement) => satisfiesGitOpsRead(req, requirement),
(requirement, nodeId) => satisfiesGitOpsRead(req, requirement, nodeId),
node.id,
);
if (!Array.isArray(filtered)) {
@@ -953,13 +1020,57 @@ function isSourceFacetRecord(source: Record<string, unknown>): boolean {
if (source.status === 'source_poll_scheduled') {
return typeof source.nextPollAt === 'number' && Number.isFinite(source.nextPollAt);
}
return source.status !== 'application_generation_accepted' || typeof source.acceptedGenerationId === 'string';
if (source.status === 'application_generation_accepted') return typeof source.acceptedGenerationId === 'string';
// Every other source status names the evidence that produced it. A status
// without that evidence is a claim nobody can check, so it is rejected here
// rather than read as a settled state.
switch (source.status) {
case 'source_review_pending':
return source.reviewBlockReason === null || source.reviewBlockReason === 'stateful_withdrawal';
case 'source_superseded':
return typeof source.supersededGenerationId === 'string';
case 'applying':
return typeof source.activeOperationId === 'string' && typeof source.activeGenerationId === 'string';
case 'source_retry_scheduled':
return isFiniteNumber(source.retryAt) && isFiniteNumber(source.retryCount);
case 'source_suspended':
return isFiniteNumber(source.suspendedAt) && isNullableString(source.suspendedReason);
case 'source_failed':
return typeof source.failureStage === 'string'
&& ['fetch', 'validation', 'apply', 'create'].includes(source.failureStage)
&& typeof source.failureClass === 'string'
&& isFiniteNumber(source.failureAt)
&& isFiniteNumberOrNull(source.retryAt)
&& isFiniteNumber(source.retryCount);
case 'source_unknown':
return typeof source.interruptedStage === 'string'
&& ['fetch_started', 'apply_started'].includes(source.interruptedStage)
&& isFiniteNumber(source.interruptedAt)
&& isNullableString(source.interruptedOperationId)
&& isNullableString(source.interruptedGenerationId);
case 'recovery_required':
return isNullableString(source.recoveryRef) && isNullableString(source.recoveryGenerationId);
case 'recovery_failed':
return isNullableString(source.recoveryRef)
&& isNullableString(source.recoveryGenerationId)
&& typeof source.failureClass === 'string'
&& isFiniteNumber(source.failureAt);
case 'not_live':
return source.lifecycleStatus === 'detached' || source.lifecycleStatus === 'deleted';
default:
return true;
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
/** A database identity: a positive integer, not merely a finite number. */
function isPositiveInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
function isNullableString(value: unknown): boolean {
return typeof value === 'string' || value === null;
}
@@ -968,6 +1079,10 @@ function isFiniteNumberOrNull(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value) || value === null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isApprovalRecord(value: unknown): value is Record<string, unknown> {
return isRecord(value)
&& isNullableString(value.sourceAcceptanceRef)
@@ -1143,19 +1258,21 @@ function isTargetRecord(value: unknown): value is Record<string, unknown> {
&& isObservedArtifactIdentityRecord(value.observedArtifactIdentity)
&& isLkgRecord(value.lkg)
&& isApprovalRecord(value.approvals)
&& typeof value.nodeId === 'number'
&& Number.isFinite(value.nodeId)
&& isPositiveInteger(value.nodeId)
&& (typeof value.stackName === 'string' || value.stackName === null)
&& typeof value.tombstoned === 'boolean'
&& typeof value.connectivity === 'string'
&& TARGET_GENERATION_FIELDS.every(field => typeof value[field] === 'string' || value[field] === null)
// A generation or artifact-set pointer is an identity, not a label: a blank
// string must not satisfy the equality checks that prove convergence.
&& TARGET_GENERATION_FIELDS.every(field => value[field] === null || isNonEmptyString(value[field]))
&& (typeof value.legacyAppliedRevision === 'number' && Number.isFinite(value.legacyAppliedRevision)
|| value.legacyAppliedRevision === null)
&& isFiniteNumberOrNull(value.lkgUnavailableAt)
&& (value.lkgUnavailableReason === null || (
typeof value.lkgUnavailableReason === 'string'
&& LKG_UNAVAILABLE_REASONS.has(value.lkgUnavailableReason)
))
// A reason this build has not heard of is accepted on its structure, like
// every other unknown vocabulary, and reported as unknown evidence by
// `hasUnrecognizedStatus`. Rejecting the projection here instead would tell
// the operator the evidence is unavailable when it is merely unfamiliar.
&& (value.lkgUnavailableReason === null || isNonEmptyString(value.lkgUnavailableReason))
&& lkgMirrorsAreConsistent(value.lkg, value)
&& typeof value.runtime.status === 'string';
}
@@ -1233,34 +1350,62 @@ function isDriftItemRecord(value: unknown): value is Record<string, unknown> {
}
function isArtifactFacetRecord(artifact: Record<string, unknown>): boolean {
if (artifact.status !== 'artifact_exact' && artifact.status !== 'artifact_qualified') return true;
const qualification = artifact.status === 'artifact_exact' ? 'exact' : 'qualified';
if (
!isNonEmptyString(artifact.artifactSetId)
|| !isNonEmptyString(artifact.generationId)
|| typeof artifact.evidenceVersion !== 'number'
|| !Number.isFinite(artifact.evidenceVersion)
|| artifact.qualification !== qualification
|| typeof artifact.freshnessAt !== 'number'
|| !Number.isFinite(artifact.freshnessAt)
|| artifact.expected !== null && !isArtifactExpectedRecord(artifact.expected)
|| !isRecord(artifact.latestEvidence)
|| !isNonEmptyString(artifact.latestEvidence.artifactSetId)
|| artifact.latestEvidence.artifactSetId !== artifact.artifactSetId
|| typeof artifact.latestEvidence.evidenceVersion !== 'number'
|| !Number.isFinite(artifact.latestEvidence.evidenceVersion)
|| artifact.latestEvidence.evidenceVersion !== artifact.evidenceVersion
|| artifact.latestEvidence.qualification !== qualification
|| typeof artifact.latestEvidence.identity !== 'string'
|| artifact.latestEvidence.identity.length === 0
) return false;
if (artifact.expected === null) return true;
return !(
(artifact.expected.qualification === 'exact' || artifact.expected.qualification === 'qualified')
&& typeof artifact.expected.identity === 'string'
&& typeof artifact.latestEvidence.identity === 'string'
&& artifact.expected.identity !== artifact.latestEvidence.identity
);
const status = artifact.status;
if (typeof status !== 'string') return false;
if (status === 'not_applicable') return true;
if (!isNonEmptyString(artifact.generationId)) return false;
const expected = artifact.expected;
const expectedIsValid = expected === null || expected === undefined || isArtifactExpectedRecord(expected);
if (artifact.latestEvidence === null) {
return status === 'artifact_unresolved'
&& artifact.limitation === 'artifact_pointer_missing'
&& expectedIsValid;
}
if (!isLatestEvidenceRecord(artifact.latestEvidence)) return false;
if (!expectedIsValid) return false;
const latest = artifact.latestEvidence;
if (artifact.artifactSetId !== latest.artifactSetId || artifact.evidenceVersion !== latest.evidenceVersion) return false;
const expectedRecord = expected === null || expected === undefined ? null : expected;
// An exact or qualified verdict claims the observed artifact is the expected
// one, so it cannot be made while the observed identity is unknown. A null
// *expected* identity is not a disagreement: it means there is no expected
// identity to disagree with, which the next clause leaves alone.
const claimsAgreement = status === 'artifact_exact' || status === 'artifact_qualified' || status === 'artifact_identity_changed';
if (claimsAgreement && latest.identity === null) return false;
if ((status === 'artifact_exact' || status === 'artifact_qualified')
&& expectedRecord !== null
&& expectedRecord.identity !== null
&& expectedRecord.identity !== latest.identity) return false;
// The claim is that the observed identity changed. With no expected identity,
// or with one that matches, the status and the evidence contradict each other.
if (status === 'artifact_identity_changed' && (
expectedRecord === null
|| (expectedRecord.qualification !== 'exact' && expectedRecord.qualification !== 'qualified')
|| latest.identity === null
|| expectedRecord.identity === null
|| latest.identity === expectedRecord.identity
)) return false;
// Every status except `artifact_identity_changed` names the one qualification
// it may carry, on the facet and on the latest evidence alike.
const pairedQualification = ARTIFACT_STATUS_QUALIFICATION[status];
if (pairedQualification !== undefined
&& (artifact.qualification !== pairedQualification || latest.qualification !== pairedQualification)) return false;
if (status === 'artifact_identity_changed'
&& (artifact.qualification !== latest.qualification
|| (artifact.qualification !== 'exact' && artifact.qualification !== 'qualified'))) return false;
return isNonEmptyString(artifact.artifactSetId)
&& isFiniteNumber(artifact.evidenceVersion)
&& isNonEmptyString(artifact.qualification)
&& isFiniteNumber(artifact.freshnessAt);
}
/** The latest-evidence half of an artifact claim: an identity, or an honest null. */
function isLatestEvidenceRecord(value: unknown): value is Record<string, unknown> {
return isRecord(value)
&& isNonEmptyString(value.artifactSetId)
&& isFiniteNumber(value.evidenceVersion)
&& isNonEmptyString(value.qualification)
&& (value.identity === null || isNonEmptyString(value.identity));
}
function isSettledRolloutRecord(rollout: Record<string, unknown>, generationId: unknown): boolean {
@@ -1269,17 +1414,23 @@ function isSettledRolloutRecord(rollout: Record<string, unknown>, generationId:
}
/**
* Whether a value looks like a walkable live projection.
* Whether a value is a walkable live projection.
*
* Narrows to the live arm of the union: the sentinel variant carries no facets
* and must take the unknown-evidence path instead, so a caller can read
* `facets`/`targets` after this guard without another discriminant check.
* and is rejected here, so the caller takes the unknown-evidence path instead
* and can read `facets`/`targets` after this guard without another
* discriminant check.
*/
export function isUsableRevision(value: unknown): value is Extract<GitOpsRevisionProjection, { applicationId: string }> {
if (!isRecord(value)) return false;
const mode = value.targetMode;
if (mode !== 'direct' && mode !== 'blueprint' && mode !== 'inline_blueprint') return false;
if (value.schemaVersion !== 1 || !isNonEmptyString(value.applicationId)) return false;
// A `legacy:` or `bp:` id is a routing artifact, not a live application's own
// identity: `legacyPortfolioRow` synthesizes the first and the portfolio uses
// the second for hub-owned Blueprints. Accepting either here would let a
// synthesized row be read back as proof.
if (value.applicationId.startsWith('legacy:') || value.applicationId.startsWith('bp:')) return false;
if (
!isNonEmptyString(value.lifecycleStatus)
|| !isApprovalRecord(value.approvals)
@@ -1301,7 +1452,9 @@ export function isUsableRevision(value: unknown): value is Extract<GitOpsRevisio
|| !isRecord(facets.placement)
|| !isRecord(facets.rollout)
) return false;
if (![facets.source, facets.artifact, facets.placement, facets.rollout].every(facet => typeof facet.status === 'string')) return false;
if (![facets.source, facets.artifact, facets.placement].every(facet => typeof facet.status === 'string')) return false;
const rolloutStatus = facets.rollout.status;
if (typeof rolloutStatus !== 'string') return false;
return isSourceFacetRecord(facets.source)
&& value.targets.every(isTargetRecord)
&& isPlacementFacetRecord(facets.placement)
@@ -1314,7 +1467,13 @@ export function isUsableRevision(value: unknown): value is Extract<GitOpsRevisio
))
&& value.availableActions.every(item => typeof item === 'string' && AVAILABLE_ACTIONS.has(item))
&& isArtifactFacetRecord(facets.artifact)
&& isSettledRolloutRecord(facets.rollout, value.rolloutGenerationId);
&& isSettledRolloutRecord(facets.rollout, value.rolloutGenerationId)
// Any rollout state that names a generation must name the same one the
// application does, not only the two settled states above: a queued or
// in-flight rollout pointing at a different generation is a projection
// nobody can reason about.
&& !(GENERATION_BOUND_ROLLOUT_STATES.has(rolloutStatus)
&& facets.rollout.rolloutGenerationId !== value.rolloutGenerationId);
}
/**
@@ -1458,6 +1617,46 @@ export function withSilentNodes(
* that is present but not walkable degrades to the same unknown row instead of
* throwing into the node leg.
*/
function unavailablePortfolioRow(
nodeId: number,
nodeName: string | null,
applicationId: string,
stackName: string | null,
updatedAt: number | null,
targetMode: 'direct' | 'blueprint' | 'inline_blueprint',
blueprintId: number | null,
): GitOpsPortfolioRow {
return {
id: `${nodeId}:${applicationId}`,
targetMode,
name: stackName ?? applicationId,
stackName,
blueprintId,
nodeId,
nodeName,
repository: null,
desiredCommitSha: null,
fetchedCommitSha: null,
candidateGenerationId: null,
acceptedGenerationId: null,
sourceStatus: 'unknown',
artifactStatus: 'unknown',
artifactQualification: null,
placementStatus: 'unknown',
rolloutStatus: 'unknown',
runtimeStatus: 'unknown',
healthStatus: 'unknown',
targets: [],
drift: { count: 0, classes: [] },
attention: [],
posture: 'unknown',
availableActions: [],
limitations: ['evidence_unavailable'],
lastActivityAt: finiteTimestamp(updatedAt),
evidence: { partial: true, unreachableNodes: [], unknown: true },
};
}
function remotePortfolioRow(
row: unknown,
nodeId: number,
@@ -1465,9 +1664,34 @@ function remotePortfolioRow(
nodeNames: Map<number, string | null>,
): GitOpsPortfolioRow | null {
if (!isRecord(row)) return null;
const revision = isUsableRevision(row.gitopsRevision) ? row.gitopsRevision : null;
const rawRevision = row.gitopsRevision;
const revision = isUsableRevision(rawRevision) ? rawRevision : null;
const stackName = typeof row.stack_name === 'string' ? row.stack_name : null;
const rawTargetMode = isRecord(rawRevision)
&& (rawRevision.targetMode === 'blueprint' || rawRevision.targetMode === 'inline_blueprint')
? rawRevision.targetMode
: 'direct';
const rawBlueprintId = isRecord(rawRevision) && isPositiveInteger(rawRevision.blueprintId)
? rawRevision.blueprintId
: null;
const applicationId = isRecord(rawRevision) && typeof rawRevision.applicationId === 'string'
&& rawRevision.applicationId.length > 0
&& !rawRevision.applicationId.startsWith('legacy:')
&& !rawRevision.applicationId.startsWith('bp:')
? rawRevision.applicationId
: null;
if (revision === null) {
if (applicationId !== null) {
return unavailablePortfolioRow(
nodeId,
nodeName,
applicationId,
stackName,
typeof row.updated_at === 'number' ? row.updated_at : null,
rawTargetMode,
rawTargetMode === 'direct' ? null : rawBlueprintId,
);
}
if (stackName === null) return null;
return legacyPortfolioRow(nodeId, nodeName, stackName, typeof row.updated_at === 'number' ? row.updated_at : null);
}
@@ -88,7 +88,7 @@ export type GitOpsPortfolioRow = {
name: string;
stackName: string | null;
blueprintId: number | null;
/** Owning node for Direct applications; null on Blueprint applications. */
/** Owning node for Direct and remote Blueprint applications; null on hub-local Blueprints. */
nodeId: number | null;
nodeName: string | null;
repository: GitOpsPortfolioRepository | null;
+2 -2
View File
@@ -165,14 +165,14 @@ export function classifyHistoryRow(input: HistoryRowEvidence): GitOpsReadRequire
* Exhaustive on purpose: a requirement this function does not recognize is
* denied rather than falling through to the narrower stack check.
*/
export function satisfiesGitOpsRead(req: Request, requirement: GitOpsReadRequirement): boolean {
export function satisfiesGitOpsRead(req: Request, requirement: GitOpsReadRequirement, resourceNodeId?: number | null): boolean {
switch (requirement.kind) {
case 'admin':
return req.user?.role === 'admin';
case 'audit':
return checkPermission(req, 'system:audit');
case 'stack_read':
return checkPermission(req, 'stack:read', 'stack', requirement.stackName);
return checkPermission(req, 'stack:read', 'stack', requirement.stackName, resourceNodeId);
default: {
const unrecognized: never = requirement;
void unrecognized;
+2 -2
View File
@@ -34,7 +34,7 @@ Rows with pending or failed items are tinted so the list reads as a signal, not
## The attention queue
Above the list, the **Attention required** section groups everything currently waiting: failed reconciliations, pending reviews or authorizations, preflight blocks like an unready private registry, partial or paused rollouts, drift, stale or unreachable targets, and recovery states. Each entry names the application and states the reason in one inline line, so it is readable without hovering anything. Opening an entry shows that application's full state (see [One application](#one-application) below). Each entry also carries its next step: **Review** for a decision waiting on you (it opens the application, where the accept, approve, and authorize actions live), **Open stack** for a failed deploy or health check on a stack, and **Open Git source** for a source that failed to fetch.
Above the list, the **Attention required** section groups everything currently waiting: failed reconciliations, pending reviews or authorizations, preflight blocks like an unready private registry, partial or paused rollouts, drift, stale or unreachable targets, and recovery states. Each entry names the application and states the reason in one inline line, so it is readable without hovering anything. Opening an entry shows that application's full state (see [One application](#one-application) below). Each entry also carries its next step: **Review** for a decision waiting on you on a hub-local Git-managed Blueprint (it opens the application, where the accept, approve, and authorize actions live), **Inspect** for a node-scoped remote Blueprint, **Open stack** for a failed deploy or health check on a stack, and **Open Git source** for a source that failed to fetch.
Failure-toned entries (failures, unreachable targets, failed rollbacks or recovery) sort ahead of pending decisions.
@@ -53,7 +53,7 @@ Selecting an application name in the list, or an entry in the attention queue, o
- **Drift**: each confirmed divergence, expected versus observed, classified by class. Work that is merely in progress (a staged candidate, a queued rollout, a deployed target still awaiting its health verdict) is not drift. The classes, what each is measured against, and where each one appears are documented in [the drift model](/features/gitops-drift-model).
- Anything Sencho could not prove about the state shown, stated last.
When some targets could not be reached, a notice names them so their cards are not read as current. The view's URL carries the application, so it can be bookmarked or shared, and the browser's Back button (or **All applications**) returns to the list with its filters intact.
When some targets could not be reached, a notice names them so their cards are not read as current. The view's URL carries the application, so it can be bookmarked or shared, and the browser's Back button (or **All applications**) returns to the list with its filters intact. If the owning node reports an application without evidence this hub can read yet, the view says evidence is unavailable and offers **Retry** instead of reporting the application as missing.
To act on the application, use the outstanding authority action in the state section when one is offered, or the button in the view's header: **Open stack** takes a Direct application to its stack's Git Source sheet, and **Open Blueprint deployments** takes a Blueprint application to the Fleet deployments tab.
+3
View File
@@ -58,6 +58,9 @@ export function plainSource(
status: PlainSourceStatus,
overrides: Partial<SourceIdentityFields> = {},
): SourceFacet {
if (status === 'source_review_pending') {
return { ...sourceIdentity(overrides), status, reviewBlockReason: null };
}
return { ...sourceIdentity(overrides), status };
}
@@ -169,6 +169,13 @@ describe('GitOpsRolloutControls', () => {
expect(toast.success).toHaveBeenCalledWith('Placement review reopened');
});
it('does not offer replan for an unknown placement state', () => {
const projection = blueprintRevision({ status: 'not_applicable' });
projection.facets.placement = JSON.parse('{"status":"from_a_newer_node"}') as typeof projection.facets.placement;
renderControls(projection);
expect(screen.queryByTestId('gitops-action-replan')).toBeNull();
});
it('supersedes the live rollout through its destructive confirmation', async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
@@ -310,6 +317,21 @@ describe('GitOpsRolloutControls', () => {
expect(onChanged).toHaveBeenCalled();
});
it.each([
['a rollback already running', 'rollback_in_progress', false],
['a partially failed rollback', 'rollback_partial_failed', true],
['a target that is unreachable', 'target_unreachable', true],
])('offers rollback for %s', (_label, status, expected) => {
renderControls(
blueprintRevision({ status: status as never, rolloutGenerationId: 'rgen-1' }),
() => true,
true,
() => {},
[{ generationId: 'gen-lkg' }],
);
expect(screen.queryByTestId('gitops-action-rollback') !== null).toBe(expected);
});
it('reports a partial rollback failure truthfully', async () => {
const user = userEvent.setup();
vi.mocked(rollbackGitOpsRollout).mockResolvedValue({
@@ -341,13 +363,13 @@ describe('GitOpsRolloutControls', () => {
blueprintRevision(
{ status: 'rollout_queued', rolloutGenerationId: 'rgen-1' },
[target({
runtime: {
status: 'recovery_failed',
recoveryRef: 'rec-1',
recoveryGenerationId: 'gen-lkg',
failureClass: 'partial',
failureAt: 1,
},
runtime: {
status: 'recovery_failed',
recoveryRef: 'rec-1',
recoveryGenerationId: 'gen-lkg',
failureClass: 'partial',
failureAt: 1,
},
})],
),
() => true,
@@ -365,6 +387,43 @@ describe('GitOpsRolloutControls', () => {
}));
});
it.each(['recovery_required', 'recovery_failed'] as const)(
'offers the failed-targets scope for a target in %s',
async runtimeStatus => {
const user = userEvent.setup();
vi.mocked(rollbackGitOpsRollout).mockResolvedValue({
ok: true,
results: [{ nodeId: 1, status: 'restored' }],
});
renderControls(
blueprintRevision(
{ status: 'rollout_queued', rolloutGenerationId: 'rgen-1' },
[target({
runtime: {
status: runtimeStatus,
recoveryRef: 'rec-1',
recoveryGenerationId: 'gen-lkg',
failureClass: 'partial',
failureAt: 1,
},
})],
),
() => true,
true,
() => {},
[{ generationId: 'gen-lkg' }],
);
await user.click(screen.getByTestId('gitops-action-rollback'));
await user.click(screen.getByRole('radio', { name: 'Failed targets' }));
await user.click(screen.getByTestId('gitops-confirm-rollback'));
await waitFor(() => expect(rollbackGitOpsRollout).toHaveBeenCalledWith('bp:5', {
generationId: 'gen-lkg',
scope: { kind: 'failed' },
}));
},
);
it('scopes a rollback to one target', async () => {
const user = userEvent.setup();
vi.mocked(rollbackGitOpsRollout).mockResolvedValue({
@@ -22,7 +22,10 @@ import { HEALTH_ROLLOUT_POLICY_STATE, livePlacementFacet, liveRolloutFacet } fro
import type { PermissionAction } from '@/context/AuthContext';
import {
HEALTH_ROLLOUT_POLICIES,
type GitOpsPlacementStatus,
type GitOpsRevisionProjection,
type GitOpsRolloutStatus,
type GitOpsRuntimeStatus,
type GitOpsTargetProjection,
type HealthRolloutPolicy,
} from '@/types/gitops';
@@ -54,14 +57,14 @@ interface GitOpsRolloutControlsProps {
type PendingAction = 'pause' | 'resume' | 'health_policy';
const PAUSABLE_ROLLOUT_STATES = new Set([
const PAUSABLE_ROLLOUT_STATES: ReadonlySet<GitOpsRolloutStatus> = new Set([
'rollout_queued',
'canary_in_progress',
'batch_in_progress',
'partially_rolled_out',
]);
const SUPERSEDABLE_ROLLOUT_STATES = new Set([
const SUPERSEDABLE_ROLLOUT_STATES: ReadonlySet<GitOpsRolloutStatus> = new Set([
'rollout_queued',
'canary_in_progress',
'batch_in_progress',
@@ -69,8 +72,37 @@ const SUPERSEDABLE_ROLLOUT_STATES = new Set([
'rollout_paused',
]);
const REPLANNABLE_PLACEMENT_STATES: ReadonlySet<GitOpsPlacementStatus> = new Set([
'source_acceptance_pending',
'unknown',
'placement_review_pending',
'stateful_confirmation_required',
'rollout_authorization_pending',
'rollout_authorization_stale',
'preflight_blocked',
'blueprint_bound',
]);
const ROLLBACKABLE_ROLLOUT_STATES: ReadonlySet<GitOpsRolloutStatus> = new Set([
'rollout_not_executable',
'rollout_queued',
'canary_in_progress',
'batch_in_progress',
'rollout_paused',
'partially_rolled_out',
'fully_deployed_health_pending',
'configuration_converged_artifact_qualified',
'exactly_converged_healthy',
'target_stale',
'target_unreachable',
'rollback_partial_failed',
'recovery_required',
'completion_unknown',
'rollout_superseded',
]);
/** Runtime states that read as a failed target rather than a result. */
const FAILED_RUNTIME_STATES = new Set([
const FAILED_RUNTIME_STATES: ReadonlySet<GitOpsRuntimeStatus> = new Set([
'recovery_failed',
'recovery_required',
'failed_previous_workload_intact',
@@ -128,7 +160,7 @@ export default function GitOpsRolloutControls({
&& allowed('stack:deploy');
const canReplan = !!live
&& placement !== null
&& placement.status !== 'unbound_direct'
&& REPLANNABLE_PLACEMENT_STATES.has(placement.status)
&& allowed('stack:create');
const canSupersede = !!live
&& blueprintEnabled
@@ -141,6 +173,8 @@ export default function GitOpsRolloutControls({
);
const canRollback = !!live
&& blueprintEnabled
&& rollout !== null
&& ROLLBACKABLE_ROLLOUT_STATES.has(rollout.status)
&& generations.length > 0
&& allowed('stack:deploy');
@@ -33,6 +33,16 @@ vi.mock('../portfolio/useWorkplaceCapabilities', () => ({
useWorkplaceCapabilities: () => ({ canConnectStack: false, canCreateBlueprint: false, canOpenFleet: fleet.reachable }),
}));
// The authority boundary is what these suites check, so the two action
// components stand in as markers: whether they are mounted is the question,
// never what they render.
vi.mock('@/components/gitops/GitOpsAuthorityActions', () => ({
default: () => <div data-testid="authority-actions" />,
}));
vi.mock('@/components/gitops/GitOpsRolloutControls', () => ({
default: () => <div data-testid="rollout-controls" />,
}));
const mockFetch = vi.mocked(apiFetch);
afterEach(() => {
@@ -173,6 +183,32 @@ function ok(body: unknown): Response {
}
describe('GitOpsApplicationView', () => {
it.each([
['a Direct application', '1:app-1', () => detailResponse()],
['a Blueprint application with no local Blueprint authority', 'bp:3', () => ({
...detailResponse(blueprintRow, blueprintProjection),
blueprintEnabled: null,
})],
['an inline Blueprint application', 'bp:3', () => ({
...detailResponse({ ...blueprintRow, targetMode: 'inline_blueprint' as const }, { ...blueprintProjection, targetMode: 'inline_blueprint' as const }),
blueprintEnabled: true,
})],
])('offers no authority actions for %s', async (_label, id, build) => {
mockFetch.mockResolvedValueOnce(ok(build()));
render(<GitOpsApplicationView id={id} />);
await screen.findByTestId('gitops-application-posture');
expect(screen.queryByTestId('authority-actions')).toBeNull();
expect(screen.queryByTestId('rollout-controls')).toBeNull();
});
it('offers authority actions for a local Git-managed Blueprint', async () => {
mockFetch.mockResolvedValueOnce(ok({ ...detailResponse(blueprintRow, blueprintProjection), blueprintEnabled: true }));
render(<GitOpsApplicationView id="bp:3" />);
await screen.findByTestId('gitops-application-posture');
expect(screen.getByTestId('authority-actions')).toBeInTheDocument();
expect(screen.getByTestId('rollout-controls')).toBeInTheDocument();
});
it('hands a Direct application off to its stack Git panel', async () => {
mockFetch.mockResolvedValueOnce(ok(detailResponse()));
const listener = vi.fn();
@@ -188,7 +224,7 @@ describe('GitOpsApplicationView', () => {
});
it('hands a Blueprint application off to its own Blueprint on the Fleet deployments tab', async () => {
mockFetch.mockResolvedValueOnce(ok(detailResponse(blueprintRow, blueprintProjection)));
mockFetch.mockResolvedValueOnce(ok({ ...detailResponse(blueprintRow, blueprintProjection), blueprintEnabled: true }));
const navigate = vi.fn();
const intent = vi.fn();
window.addEventListener(SENCHO_NAVIGATE_EVENT, navigate);
@@ -206,7 +242,7 @@ describe('GitOpsApplicationView', () => {
it('offers no Blueprint hand-off to a role that cannot open Fleet', async () => {
fleet.reachable = false;
try {
mockFetch.mockResolvedValueOnce(ok(detailResponse(blueprintRow, blueprintProjection)));
mockFetch.mockResolvedValueOnce(ok({ ...detailResponse(blueprintRow, blueprintProjection), blueprintEnabled: true }));
render(<GitOpsApplicationView id="bp:3" />);
await screen.findByTestId('gitops-application-posture');
expect(screen.queryByRole('button', { name: /open blueprint/i })).toBeNull();
@@ -236,7 +272,20 @@ describe('GitOpsApplicationView', () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({ error: 'Owning node is unreachable' }) } as unknown as Response);
render(<GitOpsApplicationView id="2:a" />);
await waitFor(() => expect(screen.getByTestId('gitops-application-error')).toHaveAttribute('data-error', 'unreachable'));
mockFetch.mockResolvedValueOnce(ok(detailResponse()));
mockFetch.mockResolvedValueOnce(ok(detailResponse({ id: '2:a', nodeId: 2 }, liveRevision({ applicationId: 'a' }))));
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('heading', { name: 'bookstack' })).toBeInTheDocument();
});
it('says evidence is unavailable, not that the application is missing, and retries', async () => {
mockFetch.mockResolvedValueOnce({
ok: false, status: 503, json: async () => ({ error: 'no usable evidence', code: 'evidence_unavailable' }),
} as unknown as Response);
render(<GitOpsApplicationView id="2:a" />);
await waitFor(() => expect(screen.getByTestId('gitops-application-error')).toHaveAttribute('data-error', 'evidence_unavailable'));
expect(screen.getByText('Evidence for this application is unavailable')).toBeInTheDocument();
expect(screen.queryByText('This application is not available')).toBeNull();
mockFetch.mockResolvedValueOnce(ok(detailResponse({ id: '2:a', nodeId: 2 }, liveRevision({ applicationId: 'a' }))));
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('heading', { name: 'bookstack' })).toBeInTheDocument();
});
@@ -276,10 +325,19 @@ describe('GitOpsApplicationView', () => {
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
it('offers no hand-off when the row names no surface that could open it', async () => {
mockFetch.mockResolvedValueOnce(ok(detailResponse({ nodeId: null })));
render(<GitOpsApplicationView id="1:app-1" />);
it('hands a Blueprint row to its Blueprint, never to a stack', async () => {
mockFetch.mockResolvedValueOnce(ok({
...detailResponse(
{ id: 'bp:3', targetMode: 'blueprint', blueprintId: 3, nodeId: null, stackName: null },
liveRevision({ targetMode: 'blueprint', applicationId: 'app-bp', blueprintId: 3 }),
),
blueprintEnabled: true,
}));
render(<GitOpsApplicationView id="bp:3" />);
await screen.findByRole('heading', { name: 'bookstack' });
expect(screen.getByRole('button', { name: /open blueprint/i })).toBeInTheDocument();
// A Blueprint application spans several nodes and owns no single stack, so
// a stack hand-off would name a target the row never claimed.
expect(screen.queryByRole('button', { name: /open stack/i })).toBeNull();
});
});
@@ -37,8 +37,16 @@ export function GitOpsApplicationView({ id, className, headerActions }: {
const { canOpenFleet } = useWorkplaceCapabilities();
const isMobile = useIsMobile();
const handoff = row ? owningSurfaceHandoff(row, { canOpenBlueprint: canOpenFleet && !isMobile }) : null;
const canOperateBlueprint = data !== null
&& row !== null
&& !isMobile
&& id.startsWith('bp:')
&& row.targetMode === 'blueprint'
&& typeof data.blueprintEnabled === 'boolean';
// A posture from a newer build still renders, as an explicit unknown.
const posture = row ? (POSTURE_LABEL[row.posture] ?? { label: `unrecognized (${row.posture})`, tone: 'neutral' as const }) : null;
const posture = row
? POSTURE_LABEL[row.posture] ?? { label: `unrecognized (${row.posture})`, tone: 'neutral' as const }
: null;
return (
<div data-testid="gitops-application-view" className={cn('flex h-full min-h-0 flex-col overflow-hidden p-6', className)}>
@@ -98,7 +106,7 @@ export function GitOpsApplicationView({ id, className, headerActions }: {
</header>
<GitOpsApplicationDetail
detail={data}
actions={(
actions={canOperateBlueprint ? (
<div className="flex flex-col gap-2">
<GitOpsAuthorityActions
applicationId={id}
@@ -107,14 +115,14 @@ export function GitOpsApplicationView({ id, className, headerActions }: {
projection={data.projection}
onChanged={refresh}
can={can}
blueprintEnabled={data.blueprintEnabled ?? undefined}
blueprintEnabled={data.blueprintEnabled ?? false}
/>
<GitOpsRolloutControls
applicationId={id}
projection={data.projection}
onChanged={refresh}
can={can}
blueprintEnabled={data.blueprintEnabled ?? undefined}
blueprintEnabled={data.blueprintEnabled ?? false}
rollbackGenerations={data.rollbackCandidates}
nodeLabel={(nodeId) => {
const target = row.targets.find(entry => entry.nodeId === nodeId);
@@ -122,7 +130,7 @@ export function GitOpsApplicationView({ id, className, headerActions }: {
}}
/>
</div>
)}
) : null}
/>
</ScrollArea>
) : null}
@@ -152,6 +160,11 @@ function errorCopy(error: GitOpsApplicationError): { title: string; line: string
title: 'The owning node did not answer',
line: `The application's state is unknown until the node reports again (${error.message}).`,
};
case 'evidence_unavailable':
return {
title: 'Evidence for this application is unavailable',
line: `Its owning node reports it, but not yet with state this node can read; retry once it reports again (${error.message}).`,
};
case 'failed':
return { title: 'The application could not be read', line: error.message };
}
@@ -9,7 +9,9 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { apiFetch } from '@/lib/api';
import { useGitOpsApplication } from './useGitOpsApplication';
import { detailResponse } from './applicationFixtures';
import { liveArtifact, liveRevision } from '@/__tests__/gitopsFixtures';
import type { GitOpsPortfolioDetailResponse } from '@/types/gitopsPortfolio';
import type { ArtifactFacet } from '@/types/gitops';
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
@@ -17,7 +19,7 @@ vi.mock('@/lib/api', () => ({
const mockFetch = vi.mocked(apiFetch);
function ok(body: GitOpsPortfolioDetailResponse): Response {
function ok(body: unknown): Response {
return { ok: true, status: 200, json: async () => body } as unknown as Response;
}
@@ -37,9 +39,9 @@ afterEach(() => {
describe('useGitOpsApplication', () => {
it('reads the encoded id from the hub, never through the node proxy', async () => {
mockFetch.mockResolvedValueOnce(ok(detailResponse()));
const { result } = renderHook(() => useGitOpsApplication('2:legacy:media stack'));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledWith('/gitops/applications/2%3Alegacy%3Amedia%20stack', { localOnly: true });
expect(mockFetch).toHaveBeenCalledWith('/gitops/applications/1%3Aapp-1', { localOnly: true });
expect(result.current.data?.application.name).toBe('bookstack');
expect(result.current.error).toBeNull();
});
@@ -75,6 +77,301 @@ describe('useGitOpsApplication', () => {
expect(result.current.error).toEqual({ kind: 'unreachable', message: 'Bad Gateway' });
});
it('reports a matched application without usable evidence separately from an unreachable node', async () => {
mockFetch.mockResolvedValueOnce({
ok: false, status: 503, json: async () => ({ error: 'no usable evidence', code: 'evidence_unavailable' }),
} as unknown as Response);
const { result } = renderHook(() => useGitOpsApplication('2:app'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toEqual({ kind: 'evidence_unavailable', message: 'no usable evidence' });
expect(result.current.data).toBeNull();
});
it.each([
['application targets', (body: GitOpsPortfolioDetailResponse) => ({ ...body, application: { ...body.application, targets: null } })],
['application attention', (body: GitOpsPortfolioDetailResponse) => ({ ...body, application: { ...body.application, attention: 'x' } })],
['application evidence', (body: GitOpsPortfolioDetailResponse) => ({ ...body, application: { ...body.application, evidence: null } })],
['generated timestamp', (body: GitOpsPortfolioDetailResponse) => ({ ...body, generatedAt: undefined })],
['projection schema version', (body: GitOpsPortfolioDetailResponse) => ({ ...body, projection: { ...body.projection, schemaVersion: 2 } })],
['projection limitations', (body: GitOpsPortfolioDetailResponse) => ({ ...body, projection: { ...body.projection, limitations: null } })],
['projection facets', (body: GitOpsPortfolioDetailResponse) => ({ ...body, projection: { ...body.projection, facets: null } })],
['projection source status', (body: GitOpsPortfolioDetailResponse) => {
if (body.projection.targetMode === 'not_applicable') throw new Error('fixture must be live');
return {
...body,
projection: {
...body.projection,
facets: {
...body.projection.facets,
source: { ...body.projection.facets.source, status: 'source_failed' },
},
},
};
}],
['projection targets', (body: GitOpsPortfolioDetailResponse) => ({ ...body, projection: { ...body.projection, targets: null } })],
['projection drift', (body: GitOpsPortfolioDetailResponse) => ({ ...body, projection: { ...body.projection, drift: null } })],
['projection drift item', (body: GitOpsPortfolioDetailResponse) => ({
...body,
projection: {
...body.projection,
drift: [{
class: 'runtime',
observed: { kind: 'unknown' },
freshnessAt: null,
owner: 'operator',
reason: 'missing expected identity',
configuredPolicy: null,
affectedTargets: [],
action: 'none',
}],
},
})],
['absent projection approvals', (body: GitOpsPortfolioDetailResponse) => ({
...body,
projection: {
...body.projection,
targetMode: 'not_applicable',
applicationId: null,
facets: null,
targets: [],
drift: [],
limitations: [],
availableActions: [],
approvals: {},
},
})],
['target observed artifact identity', (body: GitOpsPortfolioDetailResponse) => ({
...body,
projection: {
...body.projection,
targets: body.projection.targets.map(target => ({
...target,
observedArtifactIdentity: { kind: 'exact' },
})),
},
})],
['rollback candidates', (body: GitOpsPortfolioDetailResponse) => ({ ...body, rollbackCandidates: {} })],
])('rejects a successful answer with malformed %s', async (_label, mutate) => {
mockFetch.mockResolvedValueOnce(ok(mutate(detailResponse())));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.error?.kind).toBe('failed'));
expect(result.current.data).toBeNull();
});
it('rejects a successful answer for another application', async () => {
mockFetch.mockResolvedValueOnce(ok(detailResponse({ id: '1:other' })));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.error?.kind).toBe('failed'));
expect(result.current.data).toBeNull();
});
it('keeps a future observed artifact kind readable for the renderer fallback', async () => {
const base = detailResponse();
const body = {
...base,
projection: {
...base.projection,
targets: base.projection.targets.map(target => ({
...target,
observedArtifactIdentity: { kind: 'future_kind' },
})),
},
};
mockFetch.mockResolvedValueOnce(ok(body));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.data).not.toBeNull();
});
it('keeps a future drift identity kind readable for the renderer fallback', async () => {
// One unfamiliar identity kind costs that one drift row its label. Failing
// the whole read would tell the operator the application could not be read
// at all, which is a much stronger claim than the evidence supports.
const base = detailResponse();
const body = {
...base,
projection: {
...base.projection,
drift: [{
class: 'runtime',
expected: { kind: 'future_kind' },
observed: { kind: 'future_kind' },
freshnessAt: null,
owner: 'operator',
reason: 'unfamiliar identity vocabulary',
configuredPolicy: null,
affectedTargets: [],
action: 'none',
}],
},
};
mockFetch.mockResolvedValueOnce(ok(body));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.data?.projection.drift[0]?.expected.kind).toBe('future_kind');
});
it('keeps an unfamiliar last-known-good reason readable', async () => {
// The server grades an unfamiliar reason as unknown evidence in the same
// response. Refusing it here would replace that with a total read failure.
const base = detailResponse();
const body = {
...base,
projection: {
...base.projection,
targets: base.projection.targets.map(target => ({
...target,
lkg: { status: 'unavailable' },
lkgGenerationId: null,
lkgArtifactSetId: null,
lkgUnavailableAt: 1,
lkgUnavailableReason: 'future_reason',
})),
},
};
mockFetch.mockResolvedValueOnce(ok(body));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.data?.projection.targets[0]?.lkgUnavailableReason).toBe('future_reason');
});
it('rejects a target whose last-known-good reason is a blank string', async () => {
const base = detailResponse();
const body = {
...base,
projection: {
...base.projection,
targets: base.projection.targets.map(target => ({
...target,
lkg: { status: 'unavailable' },
lkgGenerationId: null,
lkgArtifactSetId: null,
lkgUnavailableAt: 1,
lkgUnavailableReason: '',
})),
},
};
mockFetch.mockResolvedValueOnce(ok(body));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.error?.kind).toBe('failed'));
expect(result.current.data).toBeNull();
});
it('rejects an exact claim whose expected identity disagrees with the latest evidence', async () => {
const base = liveRevision();
const projection = liveRevision({
facets: {
...base.facets,
artifact: liveArtifact({
expected: {
artifactSetId: 'art-1',
evidenceVersion: 1,
qualification: 'exact',
identity: 'sha256:something-else',
},
}),
},
});
mockFetch.mockResolvedValueOnce(ok(detailResponse(undefined, projection)));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.error?.kind).toBe('failed'));
expect(result.current.data).toBeNull();
});
it('accepts an artifact verdict and qualification this build does not know', async () => {
const base = liveRevision();
const future = {
status: 'artifact_future_verdict',
artifactSetId: 'art-1',
generationId: 'gen-accepted',
evidenceVersion: 1,
qualification: 'future_qualification',
freshnessAt: 1,
expected: null,
latestEvidence: {
artifactSetId: 'art-1',
evidenceVersion: 1,
qualification: 'future_qualification',
identity: 'sha256:future',
},
};
const projection = liveRevision({
facets: { ...base.facets, artifact: future as unknown as ArtifactFacet },
});
mockFetch.mockResolvedValueOnce(ok(detailResponse(undefined, projection)));
const { result } = renderHook(() => useGitOpsApplication('1:app-1'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.data).not.toBeNull();
});
it('accepts a canonical rollout authorization binding', async () => {
const base = liveRevision();
const projection = liveRevision({
targetMode: 'blueprint',
applicationId: 'app-bp',
blueprintId: 3,
facets: {
...base.facets,
placement: {
status: 'rollout_authorization_pending',
rolloutAuthorizationRef: null,
binding: {
rolloutCandidateId: 'candidate-1',
acceptedGenerationId: 'generation-1',
artifactSetId: 'artifact-1',
intentRevisionId: 'intent-1',
requiredNodeIds: [1],
sourceAcceptanceRef: 'source-1',
placementApprovalRef: 'placement-1',
preflightFingerprint: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
},
},
});
const body = {
...detailResponse({ id: 'bp:3', targetMode: 'blueprint', blueprintId: 3, nodeId: null }, projection),
blueprintEnabled: true,
};
mockFetch.mockResolvedValueOnce(ok(body));
const { result } = renderHook(() => useGitOpsApplication('bp:3'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.data).not.toBeNull();
});
it('accepts an inline Blueprint identity without requiring a Git-managed enabled flag', async () => {
const projection = liveRevision({ targetMode: 'inline_blueprint', applicationId: 'app-inline', blueprintId: 3, stackName: null });
const body = detailResponse(
{ id: 'bp:3', targetMode: 'inline_blueprint', blueprintId: 3, nodeId: null, stackName: null },
projection,
);
mockFetch.mockResolvedValueOnce(ok(body));
const { result } = renderHook(() => useGitOpsApplication('bp:3'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.data).not.toBeNull();
});
it('accepts a node-scoped remote Blueprint identity', async () => {
const projection = liveRevision({ targetMode: 'blueprint', applicationId: 'app-remote-bp', blueprintId: 3, stackName: null });
const body = {
...detailResponse(
{ id: '2:app-remote-bp', targetMode: 'blueprint', blueprintId: 3, nodeId: 2, stackName: null },
projection,
),
blueprintEnabled: true,
};
mockFetch.mockResolvedValueOnce(ok(body));
const { result } = renderHook(() => useGitOpsApplication('2:app-remote-bp'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.data).not.toBeNull();
});
it('reports a server failure and a thrown first load as failed, with no data', async () => {
mockFetch.mockResolvedValueOnce(fail(500, 'boom'));
const first = renderHook(() => useGitOpsApplication('1:a'));
@@ -9,7 +9,8 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiFetch } from '@/lib/api';
import type { GitOpsPortfolioDetailResponse } from '@/types/gitopsPortfolio';
import type { FutureRolloutAuthorizationBinding } from '@/types/gitops';
import type { GitOpsPortfolioDetailResponse, GitOpsPortfolioRow, GitOpsPortfolioTargetSummary } from '@/types/gitopsPortfolio';
const INVALIDATE_DEBOUNCE_MS = 250;
@@ -22,12 +23,17 @@ const INVALIDATE_DEBOUNCE_MS = 250;
* is not a portfolio id at all (400). `unreachable` is the owning node failing to answer, and
* `unsupported` is the owning node answering that it cannot serve this read;
* neither says anything about the application itself.
* `evidence_unavailable` is the owning node answering that the application
* exists but its evidence cannot be read yet, which is a retryable answer
* rather than a missing application. `failed` is anything else, including an
* answer this build cannot parse.
*/
export type GitOpsApplicationError =
| { kind: 'not_readable' }
| { kind: 'invalid_link' }
| { kind: 'unreachable'; message: string }
| { kind: 'unsupported'; message: string }
| { kind: 'evidence_unavailable'; message: string }
| { kind: 'failed'; message: string };
/**
@@ -55,30 +61,615 @@ class ApplicationReadError extends Error {
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Enough of the shape to render without crashing; anything else is an answer this build cannot read. */
function isDetailResponse(body: unknown): body is GitOpsPortfolioDetailResponse {
return isRecord(body) && body.schemaVersion === 1 && isRecord(body.application) && isRecord(body.projection);
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNonEmptyString(value: unknown): value is string {
return isString(value) && value.length > 0;
}
function isNamedApplicationId(value: unknown): value is string {
return isString(value) && value.length > 0 && !value.startsWith('legacy:') && !value.startsWith('bp:');
}
function isErrorBody(value: unknown): value is { error?: string; code?: string } {
return isRecord(value)
&& (value.error === undefined || isString(value.error))
&& (value.code === undefined || isString(value.code));
}
function isNullableString(value: unknown): value is string | null {
return value === null || isString(value);
}
/** An identity field: absent (null) or a real identifier, never a blank string. */
function isNullableIdentifier(value: unknown): boolean {
return value === null || isNonEmptyString(value);
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isNullableNumber(value: unknown): value is number | null {
return value === null || isNumber(value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(isString);
}
function isPositiveIntegerArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every(isPositiveInteger);
}
function isRecordArray(value: unknown): value is Record<string, unknown>[] {
return Array.isArray(value) && value.every(isRecord);
}
function isRepository(value: unknown): boolean {
if (value === null) return true;
if (!isRecord(value)) return false;
return ['configuredRepoUrl', 'host', 'pathname', 'configuredRef'].every(key => isString(value[key]));
}
function isTargetSummary(value: unknown): value is GitOpsPortfolioTargetSummary {
return isRecord(value)
&& isPositiveInteger(value.nodeId)
&& isNullableString(value.nodeName)
&& isNullableString(value.stackName)
&& ['runtime', 'health', 'connectivity', 'evidence'].every(key => isString(value[key]));
}
function isIdentityRef(value: unknown): boolean {
if (!isRecord(value) || !isString(value.kind)) return false;
switch (value.kind) {
case 'none':
case 'unknown':
return true;
case 'commit':
return isString(value.sha) && isString(value.repoUrl) && isString(value.ref);
case 'generation':
case 'rollout_candidate':
case 'rollout_generation':
return isString(value.id);
case 'artifact_set':
return isString(value.id)
&& isNonEmptyString(value.qualification)
&& isNumber(value.evidenceVersion);
case 'runtime_artifact':
return isString(value.identity) && isNullableNumber(value.observedAt);
case 'intent':
return isString(value.id) && isString(value.composeContentSha256);
case 'invocation':
return isRecord(value.authored)
&& isStringArray(value.authored.composeFileOrder)
&& isNullableString(value.authored.projectName)
&& isNullableString(value.authored.projectDirectory)
&& isStringArray(value.authored.envFileOrder);
case 'health_run':
return isString(value.runId) && isNullableString(value.deployedGenerationId);
default:
// A kind this build has not heard of. It is accepted on its structure
// alone, the same as every other unknown vocabulary in this file, so one
// newer node's identity kind costs that one drift row its label instead
// of failing the whole read and telling the operator the application
// could not be read at all.
return true;
}
}
function isObservedArtifactIdentity(value: unknown): boolean {
if (!isRecord(value) || !isString(value.kind)) return false;
switch (value.kind) {
case 'unknown':
case 'missing':
case 'unavailable':
return true;
case 'exact':
case 'qualified':
case 'stale':
case 'local_build_unverified':
return isString(value.identity) && isNumber(value.observedAt);
default:
return true;
}
}
const GENERATION_BOUND_ROLLOUT_STATES: ReadonlySet<string> = new Set([
'rollout_queued',
'canary_in_progress',
'batch_in_progress',
'fully_deployed_health_pending',
'configuration_converged_artifact_qualified',
'exactly_converged_healthy',
'rollout_superseded',
]);
/** The one qualification each artifact status may carry, keyed by that status. */
const STATUS_QUALIFICATION: Readonly<Record<string, string>> = {
artifact_exact: 'exact',
artifact_qualified: 'qualified',
artifact_stale: 'stale',
artifact_unavailable: 'unavailable',
artifact_local_build_unverified: 'local_build_unverified',
artifact_resolution_pending: 'unresolved',
};
const PREFLIGHT_FINGERPRINT_RE = /^[0-9a-f]{64}$/;
function isPositiveInteger(value: unknown): value is number {
return isNumber(value) && Number.isSafeInteger(value) && value > 0;
}
function isNullablePositiveInteger(value: unknown): boolean {
return value === null || isPositiveInteger(value);
}
function isLkgUnavailableReason(value: unknown): boolean {
// A reason this build has not heard of is accepted on its structure, the same
// as every other unknown vocabulary in this file. The server already grades
// it as unknown evidence in the same response, so refusing it here would only
// replace that with a total read failure. An empty string is not a reason.
return value === null || isNonEmptyString(value);
}
function isBinding(value: unknown): value is FutureRolloutAuthorizationBinding {
return isRecord(value)
&& isString(value.acceptedGenerationId)
&& isString(value.artifactSetId)
&& isString(value.intentRevisionId)
&& isString(value.placementApprovalRef)
&& isString(value.preflightFingerprint)
&& PREFLIGHT_FINGERPRINT_RE.test(value.preflightFingerprint)
&& isString(value.rolloutCandidateId)
&& isString(value.sourceAcceptanceRef)
&& Array.isArray(value.requiredNodeIds)
&& value.requiredNodeIds.every(isPositiveInteger);
}
type ArtifactEvidenceShape = {
artifactSetId: string;
evidenceVersion: number;
qualification: string;
identity: string | null;
};
function isExpectedArtifact(value: unknown): value is ArtifactEvidenceShape | null {
return value === null || isLatestArtifact(value);
}
function isLatestArtifact(value: unknown): value is ArtifactEvidenceShape {
return isRecord(value)
&& isNonEmptyString(value.artifactSetId)
&& isNumber(value.evidenceVersion)
&& isNonEmptyString(value.qualification)
&& (value.identity === null || isNonEmptyString(value.identity));
}
function isSourceFacet(value: unknown): value is Record<string, unknown> {
if (!isRecord(value) || !isString(value.status)) return false;
if (value.status === 'not_applicable') return true;
const repoIdentity = value.repoIdentity;
if (!isRecord(repoIdentity)
|| !isString(value.configuredRepoUrl)
|| !isString(repoIdentity.host)
|| !isString(repoIdentity.pathname)
|| !isString(value.configuredRef)
|| !isNullableString(value.desiredCommitSha)
|| !isNullableString(value.fetchedCommitSha)
|| !isNullableString(value.candidateGenerationId)
|| !isNullableString(value.acceptedGenerationId)) return false;
switch (value.status) {
case 'source_review_pending':
return value.reviewBlockReason === null || value.reviewBlockReason === 'stateful_withdrawal';
case 'application_generation_accepted':
return isString(value.acceptedGenerationId);
case 'source_superseded':
return isString(value.supersededGenerationId);
case 'applying':
return isString(value.activeOperationId) && isString(value.activeGenerationId);
case 'source_retry_scheduled':
return isNumber(value.retryAt) && isNumber(value.retryCount);
case 'source_poll_scheduled':
return isNumber(value.nextPollAt);
case 'source_suspended':
return isNumber(value.suspendedAt) && isNullableString(value.suspendedReason);
case 'source_failed':
return isString(value.failureStage)
&& ['fetch', 'validation', 'apply', 'create'].includes(value.failureStage)
&& isString(value.failureClass)
&& isNumber(value.failureAt)
&& isNullableNumber(value.retryAt)
&& isNumber(value.retryCount);
case 'source_unknown':
return isString(value.interruptedStage)
&& ['fetch_started', 'apply_started'].includes(value.interruptedStage)
&& isNumber(value.interruptedAt)
&& isNullableString(value.interruptedOperationId)
&& isNullableString(value.interruptedGenerationId);
case 'recovery_required':
return isNullableString(value.recoveryRef) && isNullableString(value.recoveryGenerationId);
case 'recovery_failed':
return isNullableString(value.recoveryRef)
&& isNullableString(value.recoveryGenerationId)
&& isString(value.failureClass)
&& isNumber(value.failureAt);
case 'not_live':
return value.lifecycleStatus === 'detached' || value.lifecycleStatus === 'deleted';
default:
return true;
}
}
function isArtifactFacet(value: unknown): boolean {
if (!isRecord(value) || !isString(value.status)) return false;
if (value.status === 'not_applicable') return true;
if (!isNonEmptyString(value.generationId)) return false;
if (value.latestEvidence === null) {
return value.status === 'artifact_unresolved'
&& value.limitation === 'artifact_pointer_missing'
&& isExpectedArtifact(value.expected);
}
const latest = value.latestEvidence;
if (!isLatestArtifact(latest)) return false;
const expected = value.expected;
if (!isExpectedArtifact(expected)) return false;
if (value.artifactSetId !== latest.artifactSetId || value.evidenceVersion !== latest.evidenceVersion) return false;
if ((value.status === 'artifact_exact' || value.status === 'artifact_qualified' || value.status === 'artifact_identity_changed')
&& (latest.identity === null || (expected !== null && expected.qualification !== 'unresolved' && expected.identity === null))) return false;
// An exact or qualified verdict claims the observed artifact is the expected
// one, so a provable expected-versus-latest identity disagreement cannot
// coexist with it. An unresolved expectation carries no identity to compare.
if ((value.status === 'artifact_exact' || value.status === 'artifact_qualified')
&& expected !== null
&& expected.identity !== null
&& expected.identity !== latest.identity) return false;
if (value.status === 'artifact_identity_changed'
&& (expected === null
|| (expected.qualification !== 'exact' && expected.qualification !== 'qualified')
|| latest.identity === null
|| expected.identity === null
|| latest.identity === expected.identity)) return false;
// Every status except `artifact_identity_changed` names the one qualification
// it may carry, on the facet and on the latest evidence alike.
const pairedQualification = STATUS_QUALIFICATION[value.status];
if (pairedQualification !== undefined
&& (value.qualification !== pairedQualification || latest.qualification !== pairedQualification)) return false;
if (value.status === 'artifact_identity_changed'
&& (value.qualification !== latest.qualification
|| (value.qualification !== 'exact' && value.qualification !== 'qualified'))) return false;
return isNonEmptyString(value.artifactSetId)
&& isNumber(value.evidenceVersion)
&& isNonEmptyString(value.qualification)
&& isNumber(value.freshnessAt);
}
function isPlacementFacet(value: unknown): boolean {
if (!isRecord(value) || !isString(value.status)) return false;
switch (value.status) {
case 'not_applicable':
case 'unbound_direct':
case 'placement_review_pending':
case 'stateful_confirmation_required':
return true;
case 'blueprint_bound':
return value.completion === 'unknown';
case 'unknown':
return value.limitation === 'missing_intent';
case 'source_acceptance_pending':
return isNullableString(value.sourceAcceptanceRef) && isString(value.candidateGenerationId);
case 'rollout_authorization_pending':
return value.rolloutAuthorizationRef === null && isBinding(value.binding);
case 'rollout_authorization_stale':
return isString(value.rolloutAuthorizationRef) && isBinding(value.bound);
case 'preflight_blocked':
return isString(value.reason) && isBinding(value.binding);
default:
return true;
}
}
function isRolloutFacet(value: unknown): value is Record<string, unknown> {
if (!isRecord(value) || !isString(value.status)) return false;
if (GENERATION_BOUND_ROLLOUT_STATES.has(value.status)) return isNonEmptyString(value.rolloutGenerationId);
switch (value.status) {
case 'not_applicable':
case 'target_stale':
case 'target_unreachable':
case 'recovery_required':
case 'completion_unknown':
return true;
case 'rollout_not_executable':
return isString(value.rolloutCandidateId);
case 'rollout_paused':
return isNumber(value.pauseAt) && isNullableString(value.pauseReason);
case 'partially_rolled_out':
return Object.hasOwn(value, 'partial');
case 'rollback_in_progress':
return isString(value.recoveryRef) && isNullableString(value.recoveryGenerationId);
case 'rollback_partial_failed':
return isString(value.recoveryRef)
&& isNullableString(value.recoveryGenerationId)
&& isString(value.failureClass)
&& isNumber(value.failureAt);
default:
return true;
}
}
function isRuntimeFacet(value: unknown): boolean {
if (!isRecord(value) || !isString(value.status)) return false;
switch (value.status) {
case 'paused':
return isNumber(value.pauseAt) && isNullableString(value.pauseReason);
case 'recovery_failed':
return isNullableString(value.recoveryRef)
&& isNullableString(value.recoveryGenerationId)
&& isString(value.failureClass)
&& isNumber(value.failureAt);
case 'completion_unknown':
return isString(value.interruptedStage)
&& ['deploy_started', 'blueprint_deploy_started', 'blueprint_withdraw_started'].includes(value.interruptedStage)
&& isNumber(value.interruptedAt)
&& isNullableString(value.interruptedOperationId)
&& isNullableString(value.interruptedGenerationId)
&& isNullableString(value.interruptedIntentRevisionId)
&& isNullableString(value.interruptedRolloutCandidateId);
default:
return true;
}
}
function isHealthFacet(value: unknown): boolean {
if (!isRecord(value) || !isString(value.status)) return false;
switch (value.status) {
case 'not_applicable':
case 'unbound':
return true;
case 'pending':
return isNullableString(value.runId);
case 'checking':
case 'failed':
return isString(value.runId) && isNullableString(value.deployedGenerationId);
case 'passed':
return isString(value.runId) && isString(value.deployedGenerationId);
case 'unknown':
return isNullableString(value.runId) && value.limitation === 'health_unknown';
default:
return true;
}
}
function isLkgFacet(value: unknown): boolean {
if (!isRecord(value) || !isString(value.status)) return false;
switch (value.status) {
case 'none':
case 'unavailable':
return true;
case 'available':
return isString(value.generationId) && isNullableString(value.artifactSetId);
case 'qualified':
return isString(value.generationId) && isString(value.artifactSetId);
default:
return true;
}
}
function isConfiguredPolicy(value: unknown): boolean {
if (value === null) return true;
if (!isRecord(value) || !isString(value.kind)) return false;
if (value.kind === 'git_source') return typeof value.autoApplyOnWebhook === 'boolean' && typeof value.autoDeployOnApply === 'boolean';
if (value.kind === 'blueprint_drift') {
return isString(value.driftMode) && ['observe', 'suggest', 'enforce'].includes(value.driftMode);
}
return true;
}
function isTargetProjection(value: unknown): boolean {
return isRecord(value)
&& isPositiveInteger(value.nodeId)
&& isNullableString(value.stackName)
&& [
'desiredGenerationId',
'candidateGenerationId',
'appliedGenerationId',
'deployedGenerationId',
'healthyGenerationId',
'lkgGenerationId',
'lkgArtifactSetId',
'expectedArtifactSetId',
'latestArtifactSetId',
'intentRevisionId',
'rolloutCandidateId',
'rolloutGenerationId',
].every(key => isNullableIdentifier(value[key]))
&& isNullableNumber(value.lkgUnavailableAt)
&& isLkgUnavailableReason(value.lkgUnavailableReason)
&& isArtifactFacet(value.artifact)
&& isObservedArtifactIdentity(value.observedArtifactIdentity)
&& isApprovalRefs(value.approvals)
&& isString(value.connectivity)
&& isNullableNumber(value.legacyAppliedRevision)
&& isRuntimeFacet(value.runtime)
&& isHealthFacet(value.health)
&& isLkgFacet(value.lkg)
&& typeof value.tombstoned === 'boolean';
}
function isDriftItem(value: unknown): boolean {
return isRecord(value)
&& isString(value.class)
&& isIdentityRef(value.expected)
&& isIdentityRef(value.observed)
&& isNullableNumber(value.freshnessAt)
&& isString(value.owner)
&& isString(value.reason)
&& isConfiguredPolicy(value.configuredPolicy)
&& Array.isArray(value.affectedTargets)
&& value.affectedTargets.every(target => isRecord(target) && isNullablePositiveInteger(target.nodeId) && isNullableString(target.stackName))
&& isString(value.action);
}
function isApprovalRefs(value: unknown): boolean {
if (!isRecord(value)) return false;
return ['sourceAcceptanceRef', 'placementApprovalRef', 'rolloutAuthorizationRef', 'legacyCombinedApprovalRef']
.every(key => value[key] === null || isString(value[key]));
}
function isApplicationRow(value: unknown): value is GitOpsPortfolioRow {
return isRecord(value)
&& isString(value.id)
&& isString(value.name)
&& (value.targetMode === 'direct' || value.targetMode === 'blueprint' || value.targetMode === 'inline_blueprint')
&& isString(value.sourceStatus)
&& isString(value.artifactStatus)
&& isNullableString(value.artifactQualification)
&& isString(value.placementStatus)
&& isString(value.rolloutStatus)
&& isString(value.runtimeStatus)
&& isString(value.healthStatus)
&& isNullableString(value.stackName)
&& isNullablePositiveInteger(value.blueprintId)
&& isNullablePositiveInteger(value.nodeId)
&& isNullableString(value.nodeName)
&& isRepository(value.repository)
&& ['desiredCommitSha', 'fetchedCommitSha', 'candidateGenerationId', 'acceptedGenerationId']
.every(key => isNullableString(value[key]))
&& isRecordArray(value.targets)
&& value.targets.every(isTargetSummary)
&& isStringArray(value.attention)
&& isString(value.posture)
&& isRecord(value.drift)
&& isNumber(value.drift.count)
&& isStringArray(value.drift.classes)
&& isStringArray(value.availableActions)
&& isStringArray(value.limitations)
&& isNullableNumber(value.lastActivityAt)
&& isRecord(value.evidence)
&& typeof value.evidence.partial === 'boolean'
&& typeof value.evidence.unknown === 'boolean'
&& isPositiveIntegerArray(value.evidence.unreachableNodes);
}
function isLiveProjection(value: unknown): boolean {
if (!isRecord(value) || value.schemaVersion !== 1 || value.targetMode === 'not_applicable' || !isString(value.targetMode)) return false;
if (!isNamedApplicationId(value.applicationId)
|| !isString(value.lifecycleStatus)
|| !['active', 'creating', 'detached', 'deleted'].includes(value.lifecycleStatus)) return false;
if (!isNullableString(value.stackName) || !isNullablePositiveInteger(value.blueprintId) || !isNullableString(value.rolloutGenerationId)) return false;
const facets = value.facets;
if (!isRecord(facets)
|| !isSourceFacet(facets.source)
|| !isArtifactFacet(facets.artifact)
|| !isPlacementFacet(facets.placement)
|| !isRolloutFacet(facets.rollout)) return false;
if (typeof facets.rollout.status === 'string'
&& GENERATION_BOUND_ROLLOUT_STATES.has(facets.rollout.status)
&& facets.rollout.rolloutGenerationId !== value.rolloutGenerationId) return false;
if (facets.source.status === 'not_live' && facets.source.lifecycleStatus !== value.lifecycleStatus) return false;
if (!isApprovalRefs(value.approvals)) return false;
if (!isRecordArray(value.targets) || !value.targets.every(isTargetProjection)) return false;
if (!isRecordArray(value.drift) || !value.drift.every(isDriftItem)) return false;
if (!isRecordArray(value.limitations) || !value.limitations.every(isLimitation)) return false;
return isStringArray(value.availableActions);
}
function isAbsentProjection(value: unknown): boolean {
return isRecord(value)
&& value.schemaVersion === 1
&& value.targetMode === 'not_applicable'
&& value.applicationId === null
&& value.facets === null
&& Array.isArray(value.targets)
&& value.targets.length === 0
&& Array.isArray(value.drift)
&& value.drift.length === 0
&& Array.isArray(value.limitations)
&& value.limitations.every(isLimitation)
&& Array.isArray(value.availableActions)
&& value.availableActions.length === 0
&& value.approvals === null;
}
function isLimitation(value: unknown): boolean {
return isRecord(value)
&& isString(value.code)
&& isString(value.message)
&& Object.hasOwn(value, 'evidence');
}
function isRollbackCandidates(value: unknown): boolean {
return Array.isArray(value) && value.every(item => isRecord(item)
&& isString(item.generationId)
&& isString(item.rolloutGenerationId)
&& isNumber(item.createdAt));
}
function isDetailResponse(body: unknown, requestedId: string): body is GitOpsPortfolioDetailResponse {
if (!isRecord(body) || body.schemaVersion !== 1 || !isNumber(body.generatedAt)) return false;
const application = body.application;
if (!isApplicationRow(application) || !isRecord(body.projection)) return false;
if (application.id !== requestedId) return false;
const isBlueprintRequest = requestedId.startsWith('bp:');
const isBlueprintApplication = application.targetMode === 'blueprint' || application.targetMode === 'inline_blueprint';
if (isBlueprintRequest && !isBlueprintApplication) return false;
if (isBlueprintRequest ? application.nodeId !== null : application.nodeId === null) return false;
if (isBlueprintRequest) {
const blueprintId = Number(requestedId.slice(3));
if (!Number.isSafeInteger(blueprintId) || blueprintId <= 0 || application.blueprintId !== blueprintId) return false;
} else {
const separator = requestedId.indexOf(':');
const nodeId = Number(requestedId.slice(0, separator));
if (separator < 0 || !Number.isSafeInteger(nodeId) || nodeId <= 0 || application.nodeId !== nodeId) return false;
}
if (body.projection.targetMode === 'not_applicable') {
const applicationId = requestedId.slice(requestedId.indexOf(':') + 1);
if (isBlueprintRequest || !applicationId.startsWith('legacy:') || !isAbsentProjection(body.projection)) return false;
} else {
if (!isLiveProjection(body.projection)) return false;
if (application.targetMode !== body.projection.targetMode) return false;
if (application.blueprintId !== body.projection.blueprintId) return false;
if (isBlueprintRequest) {
const blueprintId = Number(requestedId.slice(3));
if (body.projection.blueprintId !== blueprintId) return false;
} else {
const separator = requestedId.indexOf(':');
if (body.projection.applicationId !== requestedId.slice(separator + 1)) return false;
}
}
if (body.rollbackCandidates !== undefined && !isRollbackCandidates(body.rollbackCandidates)) return false;
// A Blueprint read must state whether this hub may act on the Blueprint. The
// final return already rejects anything that is not a boolean, null, or
// absent, so only the absent case needs its own rule here.
if (isBlueprintRequest && application.targetMode === 'blueprint' && body.blueprintEnabled === undefined) return false;
return body.blueprintEnabled === undefined || body.blueprintEnabled === null || typeof body.blueprintEnabled === 'boolean';
}
async function readApplication(id: string): Promise<GitOpsPortfolioDetailResponse> {
const res = await apiFetch(`/gitops/applications/${encodeURIComponent(id)}`, { localOnly: true });
if (res.ok) {
const body: unknown = await res.json().catch(() => null);
if (!isDetailResponse(body)) {
if (!isDetailResponse(body, id)) {
throw new ApplicationReadError({ kind: 'failed', message: 'The server returned an answer this version of Sencho cannot read.' });
}
return body;
}
const body = await res.json().catch(() => null) as { error?: string; code?: string } | null;
const rawBody: unknown = await res.json().catch(() => null);
const body = isErrorBody(rawBody) ? rawBody : null;
const message = body?.error ?? `HTTP ${res.status}`;
if (res.status === 400) throw new ApplicationReadError({ kind: 'invalid_link' });
if (res.status === 404 || res.status === 403) throw new ApplicationReadError({ kind: 'not_readable' });
if (res.status === 502 && body?.code === 'node_unsupported') {
throw new ApplicationReadError({ kind: 'unsupported', message });
}
if (res.status === 503 && body?.code === 'evidence_unavailable') {
throw new ApplicationReadError({ kind: 'evidence_unavailable', message });
}
if (res.status === 502 || res.status === 503) throw new ApplicationReadError({ kind: 'unreachable', message });
throw new ApplicationReadError({ kind: 'failed', message });
}
@@ -12,7 +12,7 @@ import {
type GitOpsStateMeta,
type GitOpsTone,
} from '@/lib/gitopsState';
import { attentionLabel, countCurrentTargets, PORTFOLIO_EMPTY_COPY, POSTURE_TONE_CLASS } from '@/lib/gitopsPortfolio';
import { attentionLabel, countCurrentTargets, hasKnownPosture, PORTFOLIO_EMPTY_COPY, POSTURE_TONE_CLASS } from '@/lib/gitopsPortfolio';
import { cn, formatRelativeTime } from '@/lib/utils';
import type { GitOpsPortfolioRow } from '@/types/gitopsPortfolio';
import { openPortfolioApplication, portfolioRowActions } from './portfolioNavigation';
@@ -174,7 +174,7 @@ function ApplicationRow({ row, onOpen, canOpenFleet }: { row: GitOpsPortfolioRow
row.posture === 'in_progress' && 'bg-brand',
row.posture === 'converged' && 'bg-success',
row.posture === 'converged_qualified' && 'bg-success/70',
row.posture === 'unknown' && 'bg-stat-icon',
!hasKnownPosture(row.posture) && 'bg-stat-icon',
)}
/>
</TableCell>
@@ -70,7 +70,7 @@ export function PortfolioMasthead({ data, staleSince }: {
}) {
const unreachable = data.coverage.filter(entry => entry.state === 'unreachable');
const unsupported = data.coverage.filter(entry => entry.state === 'unsupported');
const coverageFailed = unreachable.length + unsupported.length > 0;
const coverageFailed = unreachable.length + unsupported.length > 0 || data.truncated;
const { state, tone } = portfolioMastheadState(data.summary, coverageFailed);
const config = TONE_CONFIG[tone];
const now = useTicker(SYNC_LABEL_TICK_MS);
@@ -82,6 +82,7 @@ export function PortfolioMasthead({ data, staleSince }: {
const reasons: string[] = [];
if (unreachable.length > 0) reasons.push(`unreachable: ${unreachable.map(entry => entry.nodeName ?? `node ${entry.nodeId}`).join(', ')}`);
if (unsupported.length > 0) reasons.push(`too old to answer: ${unsupported.map(entry => entry.nodeName ?? `node ${entry.nodeId}`).join(', ')}`);
if (data.truncated) reasons.push('more applications than this view can show');
return (
<div className="relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors mb-4">
@@ -105,6 +105,23 @@ describe('portfolio row actions and attention next steps', () => {
expect(captured<BlueprintIntent>(BLUEPRINT_INTENT_EVENT, open.run)).toEqual([{ kind: 'open', blueprintId: 3 }]);
});
it('does not offer a Fleet handoff for a node-scoped remote Blueprint', () => {
const remoteBlueprint = portfolioRow({ id: '2:app-remote-bp', targetMode: 'blueprint', nodeId: 2, stackName: null, blueprintId: 3 });
expect(portfolioRowActions(remoteBlueprint, { canOpenFleet: true }).map(action => action.label)).toEqual(['Open application']);
});
it('does not offer a decision action for a node-scoped remote Blueprint', () => {
const remoteBlueprint = portfolioRow({ id: '2:app-remote-bp', targetMode: 'blueprint', nodeId: 2, stackName: null, blueprintId: 3 });
expect(attentionNextStep('rollout_authorization_pending', remoteBlueprint).label).toBe('Inspect');
});
it('opens the application when inspecting a node-scoped remote Blueprint', () => {
const remoteBlueprint = portfolioRow({ id: '2:app-remote-bp', targetMode: 'blueprint', nodeId: 2, stackName: null, blueprintId: 3 });
const step = attentionNextStep('rollout_authorization_pending', remoteBlueprint);
step.run();
expect(applicationIdFromSearch(window.location.search)).toBe('2:app-remote-bp');
});
it('sends a pending decision to the application view, where the authority actions live', () => {
const step = attentionNextStep('rollout_authorization_pending', blueprint);
expect(step.label).toBe('Review');
@@ -126,7 +126,7 @@ export function owningSurfaceHandoff(row: GitOpsPortfolioRow, opts: { canOpenBlu
return { label: 'Open Git source', open: () => openDirectStack(row, 'git') };
case 'blueprint':
case 'inline_blueprint':
if (row.blueprintId === null || !opts.canOpenBlueprint) return null;
if (row.nodeId !== null || row.blueprintId === null || !opts.canOpenBlueprint) return null;
return { label: 'Open Blueprint', open: () => openBlueprint(row) };
default: {
const unhandled: never = row.targetMode;
@@ -206,7 +206,7 @@ export function portfolioRowActions(row: GitOpsPortfolioRow, opts: { canOpenFlee
actions.push({ label: 'Open stack', run: () => openDirectStack(row, 'stack') });
actions.push({ label: 'Open Git source', run: () => openDirectStack(row, 'git') });
}
if (row.targetMode !== 'direct' && row.blueprintId !== null && opts.canOpenFleet) {
if (row.targetMode !== 'direct' && row.nodeId === null && row.blueprintId !== null && opts.canOpenFleet) {
actions.push({ label: 'Open Blueprint', run: () => openBlueprint(row) });
}
return actions;
@@ -248,7 +248,10 @@ const SOURCE_FAILURE_REASONS: ReadonlySet<GitOpsAttentionReason> = new Set<GitOp
*/
export function attentionNextStep(reason: GitOpsAttentionReason, row: GitOpsPortfolioRow): PortfolioRowAction {
const review = { label: 'Review', run: () => openPortfolioApplication(row) };
if (DECISION_REASONS.has(reason)) return review;
const remoteBlueprint = row.targetMode !== 'direct' && row.nodeId !== null;
if (DECISION_REASONS.has(reason)) {
return remoteBlueprint ? { label: 'Inspect', run: review.run } : review;
}
const direct = row.targetMode === 'direct' && row.nodeId !== null && row.stackName !== null;
if (direct && RUNTIME_FAILURE_REASONS.has(reason)) return { label: 'Open stack', run: () => openDirectStack(row, 'stack') };
if (direct && SOURCE_FAILURE_REASONS.has(reason)) return { label: 'Open Git source', run: () => openDirectStack(row, 'git') };
@@ -87,8 +87,14 @@ describe('useGitOpsPortfolio', () => {
}
expect(applicationIdFromSearch(window.location.search)).toBe('1:a');
// The search still applies to the list the operator returns to.
expect(result.current.filters.q).toBe('web');
await act(async () => {
closeGitOpsApplication();
await new Promise<void>(resolve => window.addEventListener('popstate', () => resolve(), { once: true }));
});
await waitFor(() => expect(window.location.search).toBe('?q=web'));
expect(window.history.state).toMatchObject({ senchoIdx: 1 });
});
it('loads the portfolio on mount', async () => {
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { apiFetch } from '@/lib/api';
import { detailResponse } from '@/components/gitops/application/applicationFixtures';
import { detailResponse, portfolioRow } from '@/components/gitops/application/applicationFixtures';
import { MobileGitOps } from './MobileGitOps';
vi.mock('@/lib/api', () => ({
@@ -55,4 +55,29 @@ describe('MobileGitOps', () => {
expect(screen.getByTestId('gitops-application-view')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
});
it('marks a truncated portfolio partial even when every node answered', async () => {
mockFetch.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({
...emptyList,
summary: { ...emptyList.summary, applications: 1, unknown: 1 },
coverage: [{ nodeId: 1, nodeName: 'local', state: 'ok' as const }],
applications: [portfolioRow({
id: '1:app-1',
name: 'bookstack',
posture: 'unknown',
evidence: { partial: true, unreachableNodes: [], unknown: true },
})],
truncated: true,
}),
}) as unknown as Response);
render(<MobileGitOps headerActions={null} />);
// The count alone would read as a complete answer, so the omission has to
// say itself wherever the other partial signals would have said it.
expect(await screen.findByText(/partial/)).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from 'lucide-react';
import { useEffect, useState, type ReactNode } from 'react';
import { Masthead, SectionHead, StateDot } from '@/components/mobile/mobile-ui';
import { attentionLabel, countCurrentTargets, PORTFOLIO_EMPTY_COPY, portfolioMastheadState, POSTURE_TONE_CLASS } from '@/lib/gitopsPortfolio';
import { attentionLabel, countCurrentTargets, hasKnownPosture, PORTFOLIO_EMPTY_COPY, portfolioMastheadState, POSTURE_TONE_CLASS } from '@/lib/gitopsPortfolio';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/lib/utils';
import type { GitOpsPortfolioRow } from '@/types/gitopsPortfolio';
@@ -28,9 +28,12 @@ export function MobileGitOps({ headerActions }: { headerActions?: ReactNode }) {
const portfolio = useGitOpsPortfolio();
const { data, loading, error, staleSince } = portfolio;
const selectedApplication = useGitOpsApplicationSelection();
// Rows the merge cap dropped and nodes that failed to answer are the same
// fact to an operator: this list does not show everything.
const coveragePartial = data?.truncated === true || data?.coverage.some(entry => entry.state !== 'ok') === true;
const masthead = data
? portfolioMastheadState(data.summary, data.coverage.some(entry => entry.state !== 'ok'))
? portfolioMastheadState(data.summary, coveragePartial)
: { state: 'Loading', tone: 'idle' as const };
// The mobile Tone set has no neutral: an unproven portfolio reads as brand
// (data color, no urgency claim), which is also what the schedules masthead
@@ -42,7 +45,7 @@ export function MobileGitOps({ headerActions }: { headerActions?: ReactNode }) {
const meta = data
? `${data.summary.applications} apps · ${data.summary.attentionRequired} attention${
staleSince ? ' · stale' : ''}${
data.coverage.some(entry => entry.state !== 'ok') ? ' · partial' : ''}`
coveragePartial ? ' · partial' : ''}`
: '';
const filters = portfolio.filters;
@@ -229,7 +232,7 @@ export function MobileGitOps({ headerActions }: { headerActions?: ReactNode }) {
function MobileGitOpsRow({ row }: { row: GitOpsPortfolioRow }) {
const currentTargetCount = countCurrentTargets(row.targets);
const postureTone = {
const postureTone: Readonly<Record<string, 'destructive' | 'warning' | 'brand' | 'success'>> = {
failed: 'destructive',
attention: 'warning',
in_progress: 'brand',
@@ -245,8 +248,8 @@ function MobileGitOpsRow({ row }: { row: GitOpsPortfolioRow }) {
className="block w-full min-h-11 px-3 py-2.5 text-left"
>
<span className="flex items-center gap-2">
{row.posture === 'unknown'
? <span className="inline-block h-[7px] w-[7px] shrink-0 rounded-full bg-stat-icon" />
{row.posture === 'unknown' || !hasKnownPosture(row.posture)
? <span aria-label={`Unrecognized posture ${row.posture}`} className="inline-block h-[7px] w-[7px] shrink-0 rounded-full bg-stat-icon" />
: <StateDot tone={postureTone[row.posture]} size={7} glow={row.posture === 'failed'} />}
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] text-stat-value">{row.name}</span>
+5 -3
View File
@@ -36,7 +36,7 @@ import type { GitOpsLimitation } from '@/types/gitops';
* every miss as a `string` and make the fallback below look like dead code to
* anything that trusts the types.
*/
export const GITOPS_LIMITATION_COPY: Record<string, string | undefined> = {
export const GITOPS_LIMITATION_COPY: Readonly<Record<string, string | undefined>> = {
// --- derived while projecting -------------------------------------------
repo_identity_invalid:
'The stored repository identity could not be read, so this state cannot be tied back to a specific repository.',
@@ -98,8 +98,10 @@ export const GITOPS_LIMITATION_COPY: Record<string, string | undefined> = {
* outcome this affordance exists to prevent.
*/
export function limitationCaveat(limitation: GitOpsLimitation): string {
return GITOPS_LIMITATION_COPY[limitation.code]
?? `Part of this state could not be proven (${limitation.code}).`;
const known = Object.hasOwn(GITOPS_LIMITATION_COPY, limitation.code)
? GITOPS_LIMITATION_COPY[limitation.code]
: undefined;
return known ?? `Part of this state could not be proven (${limitation.code}).`;
}
/**
+11
View File
@@ -8,6 +8,7 @@
*/
import { describe, expect, it } from 'vitest';
import { attentionLabel, portfolioMastheadState } from './gitopsPortfolio';
import { limitationCaveat } from './gitopsLimitations';
describe('portfolioMastheadState', () => {
const summary = {
@@ -38,6 +39,7 @@ describe('portfolioMastheadState', () => {
it('reads unknown evidence as Partially known, not healthy', () => {
expect(portfolioMastheadState({ ...summary, unknown: 1 }, false).state).toBe('Partially known');
expect(portfolioMastheadState(summary, true).state).toBe('Partially known');
});
it('reads pending decisions before progress', () => {
@@ -66,4 +68,13 @@ describe('attentionLabel', () => {
expect(future.tone).toBe('warning');
expect(future.line).toContain('does not know');
});
it('falls back for a code that is an inherited object property rather than a known code', () => {
// `Object.hasOwn` is what keeps `toString` from resolving to a function.
const inherited = attentionLabel('toString' as never);
expect(inherited.label).toBe('toString');
expect(inherited.line).toContain('does not know');
const limitation = limitationCaveat({ code: 'constructor', message: 'x' } as never);
expect(limitation).toContain('constructor');
});
});
+22 -4
View File
@@ -18,7 +18,7 @@ export interface PortfolioLabel {
line: string;
}
export const POSTURE_LABEL: Record<GitOpsPortfolioPosture, { label: string; tone: PortfolioTone }> = {
export const POSTURE_LABEL: Readonly<Partial<Record<GitOpsPortfolioPosture, { label: string; tone: PortfolioTone }>>> = {
failed: { label: 'failed', tone: 'destructive' },
attention: { label: 'needs attention', tone: 'warning' },
in_progress: { label: 'in progress', tone: 'brand' },
@@ -27,7 +27,7 @@ export const POSTURE_LABEL: Record<GitOpsPortfolioPosture, { label: string; tone
unknown: { label: 'unknown', tone: 'neutral' },
};
export const ATTENTION_LABEL: Partial<Record<GitOpsAttentionReason, PortfolioLabel>> = {
export const ATTENTION_LABEL: Readonly<Partial<Record<GitOpsAttentionReason, PortfolioLabel>>> = {
source_failed: { label: 'source failed', tone: 'destructive', line: 'The last reconciliation of the Git source failed.' },
source_unknown_outcome: { label: 'outcome unknown', tone: 'warning', line: 'A source operation was interrupted before its outcome could be confirmed.' },
source_review_pending: { label: 'review required', tone: 'warning', line: 'A fetched commit is waiting for review before it can apply.' },
@@ -58,7 +58,25 @@ export const ATTENTION_LABEL: Partial<Record<GitOpsAttentionReason, PortfolioLab
/** Fallback rendering for a reason this build has not heard of. */
export function attentionLabel(reason: GitOpsAttentionReason): PortfolioLabel {
return ATTENTION_LABEL[reason] ?? { label: reason.replace(/_/g, ' '), tone: 'warning', line: 'This application reports an attention reason this Sencho build does not know.' };
const unrecognized: PortfolioLabel = {
label: reason.replace(/_/g, ' '),
tone: 'warning',
line: 'This application reports an attention reason this Sencho build does not know.',
};
return Object.hasOwn(ATTENTION_LABEL, reason) && ATTENTION_LABEL[reason]
? ATTENTION_LABEL[reason]
: unrecognized;
}
/**
* Whether this build has wording for a posture, own property or not.
*
* Every surface that shows a posture needs the same answer, and an inherited
* key such as `toString` has to read as unrecognized rather than resolve to
* something truthy.
*/
export function hasKnownPosture(posture: string): boolean {
return Object.hasOwn(POSTURE_LABEL, posture);
}
export const POSTURE_TONE_CLASS: Record<PortfolioTone, string> = {
@@ -92,7 +110,7 @@ export function portfolioMastheadState(summary: {
if (summary.attentionRequired > 0) return { state: 'Needs attention', tone: 'warn' };
if (summary.inProgress > 0) return { state: 'In progress', tone: 'live' };
if (summary.applications === 0) return { state: 'No applications', tone: 'idle' };
if (summary.unknown > 0) return { state: 'Partially known', tone: 'idle' };
if (summary.unknown > 0 || coverageFailed) return { state: 'Partially known', tone: 'idle' };
// Qualified convergence is not exact convergence: when nothing is provably
// exact, the verdict says so instead of borrowing the stronger word.
if (summary.converged === 0 && summary.convergedQualified > 0) {
+8
View File
@@ -406,6 +406,14 @@ describe('identityRefLabel', () => {
}),
).toBe('no compose files');
});
it('names an identity kind this build has not heard of', () => {
// The union is closed, so this value can only arrive from a newer node. It
// crossed the validator on structure alone, and it must render as something
// rather than as nothing.
const unfamiliar = { kind: 'future_kind' } as unknown as GitOpsIdentityRef;
expect(identityRefLabel(unfamiliar)).toBe('unknown (future_kind)');
});
});
describe('driftClassLabel', () => {
+43 -9
View File
@@ -630,14 +630,21 @@ export const RUNTIME_STATE: Record<GitOpsRuntimeStatus, GitOpsStateMeta> = {
* makes the miss a fact TypeScript produces, so the guard on it cannot be
* mistaken for dead code and deleted.
*
* Same objects, no copy, no cast: a total record over string-literal keys is
* assignable to a partial record over `string`.
* Each map loses its prototype, so an inherited key such as `toString` reads
* as a miss here rather than resolving to a function the caller would treat as
* metadata. The maps are module-local and read-only to every consumer, so
* mutating them once at construction is safe.
*/
export const SOURCE_STATE_LOOKUP: Partial<Record<string, GitOpsStateMeta>> = SOURCE_STATE;
export const ARTIFACT_STATE_LOOKUP: Partial<Record<string, GitOpsStateMeta>> = ARTIFACT_STATE;
export const PLACEMENT_STATE_LOOKUP: Partial<Record<string, GitOpsStateMeta>> = PLACEMENT_STATE;
export const ROLLOUT_STATE_LOOKUP: Partial<Record<string, GitOpsStateMeta>> = ROLLOUT_STATE;
export const RUNTIME_STATE_LOOKUP: Partial<Record<string, GitOpsStateMeta>> = RUNTIME_STATE;
function stateLookup<T extends Record<string, GitOpsStateMeta>>(states: T): Readonly<Partial<Record<string, GitOpsStateMeta>>> {
Object.setPrototypeOf(states, null);
return states;
}
export const SOURCE_STATE_LOOKUP = stateLookup(SOURCE_STATE);
export const ARTIFACT_STATE_LOOKUP = stateLookup(ARTIFACT_STATE);
export const PLACEMENT_STATE_LOOKUP = stateLookup(PLACEMENT_STATE);
export const ROLLOUT_STATE_LOOKUP = stateLookup(ROLLOUT_STATE);
export const RUNTIME_STATE_LOOKUP = stateLookup(RUNTIME_STATE);
/** Card copy for a placement facet. Preflight blocked uses the redacted server reason as the line. */
export function placementStateMeta(facet: PlacementFacet): GitOpsStateMeta | undefined {
@@ -769,7 +776,7 @@ export function liveCaveats(revision: GitOpsRevisionProjection): readonly GitOps
* string because the classes arrive over the wire: a class this build does not
* know is shown as itself rather than hidden.
*/
const DRIFT_CLASS_LABELS: Record<string, string> = {
const DRIFT_CLASS_LABELS: Readonly<Record<string, string>> = {
source: 'source',
managed_project: 'managed project',
invocation: 'invocation',
@@ -780,7 +787,7 @@ const DRIFT_CLASS_LABELS: Record<string, string> = {
};
export function driftClassLabel(className: string): string {
return DRIFT_CLASS_LABELS[className] ?? className;
return Object.hasOwn(DRIFT_CLASS_LABELS, className) ? DRIFT_CLASS_LABELS[className] : className;
}
/**
@@ -892,8 +899,35 @@ export const HEALTH_STOP_REASON_STATE: Record<HealthStopReason, GitOpsStateMeta>
},
};
/**
* The identity kinds this build has wording for.
*
* Typed as a total record rather than a set so the compiler rejects it the
* moment a kind is added to the union and forgotten here.
*/
const IDENTITY_REF_KINDS: Readonly<Record<GitOpsIdentityRef['kind'], true>> = {
none: true,
unknown: true,
commit: true,
generation: true,
artifact_set: true,
runtime_artifact: true,
intent: true,
rollout_candidate: true,
rollout_generation: true,
invocation: true,
health_run: true,
};
/** One short line naming what an identity reference points at, for a drift comparison row. */
export function identityRefLabel(ref: GitOpsIdentityRef): string {
// `GitOpsIdentityRef` is a closed union, so the switch below is exhaustive by
// type. The value still crossed a network boundary and was accepted on its
// structure alone, so widen the kind once and name an unfamiliar one rather
// than rendering nothing. `Object.hasOwn` so an inherited key is not a known
// kind.
const kind: string = ref.kind;
if (!Object.hasOwn(IDENTITY_REF_KINDS, kind)) return `unknown (${kind})`;
switch (ref.kind) {
case 'none':
return 'none';
+9 -6
View File
@@ -123,19 +123,22 @@ export type SourceFacet =
| 'never_reconciled'
| 'checking_fetching'
| 'application_generation_accepted'
| 'candidate_ready'
| 'source_review_pending'
| 'source_conflict_blocker'
| 'candidate_ready'
| 'source_conflict_blocker'
| 'source_reconcile_required';
})
| (SourceIdentityFields & { status: 'source_superseded'; supersededGenerationId: string })
})
| (SourceIdentityFields & {
status: 'source_review_pending';
reviewBlockReason: 'stateful_withdrawal' | null;
})
| (SourceIdentityFields & { status: 'source_superseded'; supersededGenerationId: string })
| (SourceIdentityFields & { status: 'applying'; activeOperationId: string; activeGenerationId: string })
| (SourceIdentityFields & { status: 'source_poll_scheduled'; nextPollAt: number })
| (SourceIdentityFields & { status: 'source_retry_scheduled'; retryAt: number; retryCount: number })
| (SourceIdentityFields & { status: 'source_suspended'; suspendedAt: number; suspendedReason: string | null })
| (SourceIdentityFields & {
status: 'source_failed';
failureStage: 'fetch' | 'validation' | 'apply' | 'create';
failureStage: 'fetch' | 'validation' | 'apply' | 'create';
failureClass: string;
failureAt: number;
retryAt: number | null;
+2 -8
View File
@@ -14,13 +14,7 @@ import type { GitOpsRevisionProjection } from '@/types/gitops';
/** Per-application attention reason, as classified server-side. */
export type GitOpsAttentionReason = string;
export type GitOpsPortfolioPosture =
| 'failed'
| 'attention'
| 'in_progress'
| 'converged'
| 'converged_qualified'
| 'unknown';
export type GitOpsPortfolioPosture = string;
export interface GitOpsPortfolioRepository {
configuredRepoUrl: string;
@@ -37,7 +31,7 @@ export interface GitOpsPortfolioTargetSummary {
health: string;
connectivity: string;
tombstoned: boolean;
evidence: 'fresh' | 'stale' | 'unknown';
evidence: string;
}
export interface GitOpsPortfolioRow {