diff --git a/backend/src/__tests__/gitops-application-remote-detail-route.test.ts b/backend/src/__tests__/gitops-application-remote-detail-route.test.ts new file mode 100644 index 00000000..22d760f8 --- /dev/null +++ b/backend/src/__tests__/gitops-application-remote-detail-route.test.ts @@ -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>()); + +vi.mock('../services/gitops/portfolioAggregator', async (importOriginal) => { + const actual = await importOriginal(); + 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 { + return { stack_name: 'remote-web', stackResourcePresent: true, gitopsRevision }; +} + +function unusableRow(): Record { + return { + stack_name: 'remote-web', + stackResourcePresent: true, + gitopsRevision: { applicationId: 'app-remote', targetMode: 'direct' }, + }; +} + +function malformedFacetsRow(): Record { + return { + stack_name: 'remote-web', + stackResourcePresent: true, + gitopsRevision: { + ...readableProjection(), + facets: {}, + }, + }; +} + +function readableSource(overrides: Record = {}): Record { + 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 { + return { + source: readableSource(), + artifact: { status: 'not_applicable' }, + placement: { status: 'unbound_direct' }, + rollout: { status: 'not_applicable' }, + }; +} + +function readableProjection(overrides: Record = {}): Record { + 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) => ({ ...projection, schemaVersion: 2 })], + ['approvals', (projection: Record) => ({ ...projection, approvals: {} })], + ['drift identity', (projection: Record) => ({ + ...projection, + drift: [{ + class: 'source', + freshnessAt: null, + owner: 'operator', + reason: 'missing expected identity', + configuredPolicy: null, + affectedTargets: [], + action: 'fetch', + observed: { kind: 'unknown' }, + }], + })], + ['limitation message', (projection: Record) => ({ + ...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'); + }); +}); diff --git a/backend/src/__tests__/gitops-attention.test.ts b/backend/src/__tests__/gitops-attention.test.ts index a6f43c43..114138d7 100644 --- a/backend/src/__tests__/gitops-attention.test.ts +++ b/backend/src/__tests__/gitops-attention.test.ts @@ -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([]); + }); }); diff --git a/backend/src/__tests__/gitops-identity-proxy.test.ts b/backend/src/__tests__/gitops-identity-proxy.test.ts index 267ad384..bef28c6b 100644 --- a/backend/src/__tests__/gitops-identity-proxy.test.ts +++ b/backend/src/__tests__/gitops-identity-proxy.test.ts @@ -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 diff --git a/backend/src/__tests__/gitops-portfolio-aggregator.test.ts b/backend/src/__tests__/gitops-portfolio-aggregator.test.ts index 9704c0fa..e887d9aa 100644 --- a/backend/src/__tests__/gitops-portfolio-aggregator.test.ts +++ b/backend/src/__tests__/gitops-portfolio-aggregator.test.ts @@ -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; 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 & { 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; + 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 | 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 | 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 => { diff --git a/backend/src/__tests__/gitops-portfolio-route.test.ts b/backend/src/__tests__/gitops-portfolio-route.test.ts index db1d12f3..ca5ca058 100644 --- a/backend/src/__tests__/gitops-portfolio-route.test.ts +++ b/backend/src/__tests__/gitops-portfolio-route.test.ts @@ -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') diff --git a/backend/src/proxy/gitopsIdentityProxy.ts b/backend/src/proxy/gitopsIdentityProxy.ts index 41d16d36..8d0cc054 100644 --- a/backend/src/proxy/gitopsIdentityProxy.ts +++ b/backend/src/proxy/gitopsIdentityProxy.ts @@ -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), ); } diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 027a00c4..b5119088 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -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, ); }, diff --git a/backend/src/routes/gitopsApplications.ts b/backend/src/routes/gitopsApplications.ts index 7f85de26..15b556ea 100644 --- a/backend/src/routes/gitopsApplications.ts +++ b/backend/src/routes/gitopsApplications.ts @@ -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, diff --git a/backend/src/services/gitops/portfolioAggregator.ts b/backend/src/services/gitops/portfolioAggregator.ts index ec1dedc4..ec07ba60 100644 --- a/backend/src/services/gitops/portfolioAggregator.ts +++ b/backend/src/services/gitops/portfolioAggregator.ts @@ -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 = 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 = 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 = new Set([ + 'unknown', + 'missing', + 'unavailable', + 'exact', + 'qualified', + 'stale', + 'local_build_unverified', +]); + +const GENERATION_BOUND_ROLLOUT_STATES: ReadonlySet = 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 = new Set([ 'unresolved', 'exact', @@ -99,6 +128,21 @@ const ARTIFACT_QUALIFICATIONS: ReadonlySet = 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> = { + 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 = new Set(['registry', 'build', 'unsupported']); const ARTIFACT_SERVICE_FAILURE_CLASSES: ReadonlySet = new Set([ 'unresolved', @@ -124,7 +168,6 @@ const SETTLED_ROLLOUT_ARTIFACT: ReadonlyMap = new Set(['exact', 'qualified', 'stale', 'local_build_unverified']); const AVAILABLE_ACTIONS: ReadonlySet = 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): 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): 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): 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 { return isRecord(value) && isNullableString(value.sourceAcceptanceRef) @@ -1143,19 +1258,21 @@ function isTargetRecord(value: unknown): value is Record { && 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 { } function isArtifactFacetRecord(artifact: Record): 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 { + return isRecord(value) + && isNonEmptyString(value.artifactSetId) + && isFiniteNumber(value.evidenceVersion) + && isNonEmptyString(value.qualification) + && (value.identity === null || isNonEmptyString(value.identity)); } function isSettledRolloutRecord(rollout: Record, generationId: unknown): boolean { @@ -1269,17 +1414,23 @@ function isSettledRolloutRecord(rollout: Record, 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 { 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 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 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, ): 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); } diff --git a/backend/src/services/gitops/portfolioTypes.ts b/backend/src/services/gitops/portfolioTypes.ts index 81036a61..47c6fc46 100644 --- a/backend/src/services/gitops/portfolioTypes.ts +++ b/backend/src/services/gitops/portfolioTypes.ts @@ -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; diff --git a/backend/src/services/gitops/readAuth.ts b/backend/src/services/gitops/readAuth.ts index 4b3570e9..88500bd6 100644 --- a/backend/src/services/gitops/readAuth.ts +++ b/backend/src/services/gitops/readAuth.ts @@ -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; diff --git a/docs/features/gitops-portfolio.mdx b/docs/features/gitops-portfolio.mdx index 4e4749b3..e7fbf254 100644 --- a/docs/features/gitops-portfolio.mdx +++ b/docs/features/gitops-portfolio.mdx @@ -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. diff --git a/frontend/src/__tests__/gitopsFixtures.ts b/frontend/src/__tests__/gitopsFixtures.ts index 977197ab..754a03ec 100644 --- a/frontend/src/__tests__/gitopsFixtures.ts +++ b/frontend/src/__tests__/gitopsFixtures.ts @@ -58,6 +58,9 @@ export function plainSource( status: PlainSourceStatus, overrides: Partial = {}, ): SourceFacet { + if (status === 'source_review_pending') { + return { ...sourceIdentity(overrides), status, reviewBlockReason: null }; + } return { ...sourceIdentity(overrides), status }; } diff --git a/frontend/src/components/gitops/GitOpsRolloutControls.test.tsx b/frontend/src/components/gitops/GitOpsRolloutControls.test.tsx index dcca8532..892a83c9 100644 --- a/frontend/src/components/gitops/GitOpsRolloutControls.test.tsx +++ b/frontend/src/components/gitops/GitOpsRolloutControls.test.tsx @@ -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({ diff --git a/frontend/src/components/gitops/GitOpsRolloutControls.tsx b/frontend/src/components/gitops/GitOpsRolloutControls.tsx index e5e9f35d..d77915ff 100644 --- a/frontend/src/components/gitops/GitOpsRolloutControls.tsx +++ b/frontend/src/components/gitops/GitOpsRolloutControls.tsx @@ -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 = new Set([ 'rollout_queued', 'canary_in_progress', 'batch_in_progress', 'partially_rolled_out', ]); -const SUPERSEDABLE_ROLLOUT_STATES = new Set([ +const SUPERSEDABLE_ROLLOUT_STATES: ReadonlySet = 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 = 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 = 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 = 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'); diff --git a/frontend/src/components/gitops/application/GitOpsApplicationDetail.test.tsx b/frontend/src/components/gitops/application/GitOpsApplicationDetail.test.tsx index c41dd4cd..0bea3545 100644 --- a/frontend/src/components/gitops/application/GitOpsApplicationDetail.test.tsx +++ b/frontend/src/components/gitops/application/GitOpsApplicationDetail.test.tsx @@ -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: () =>
, +})); +vi.mock('@/components/gitops/GitOpsRolloutControls', () => ({ + default: () =>
, +})); + 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(); + 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(); + 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(); 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(); 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(); + 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(); + 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(); 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(); }); }); diff --git a/frontend/src/components/gitops/application/GitOpsApplicationView.tsx b/frontend/src/components/gitops/application/GitOpsApplicationView.tsx index 4cf5a8bc..854f2c40 100644 --- a/frontend/src/components/gitops/application/GitOpsApplicationView.tsx +++ b/frontend/src/components/gitops/application/GitOpsApplicationView.tsx @@ -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 (
@@ -98,7 +106,7 @@ export function GitOpsApplicationView({ id, className, headerActions }: { { const target = row.targets.find(entry => entry.nodeId === nodeId); @@ -122,7 +130,7 @@ export function GitOpsApplicationView({ id, className, headerActions }: { }} />
- )} + ) : null} /> ) : 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 }; } diff --git a/frontend/src/components/gitops/application/useGitOpsApplication.test.ts b/frontend/src/components/gitops/application/useGitOpsApplication.test.ts index 20d58ba0..13484c67 100644 --- a/frontend/src/components/gitops/application/useGitOpsApplication.test.ts +++ b/frontend/src/components/gitops/application/useGitOpsApplication.test.ts @@ -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')); diff --git a/frontend/src/components/gitops/application/useGitOpsApplication.ts b/frontend/src/components/gitops/application/useGitOpsApplication.ts index 4559207a..72ab4eda 100644 --- a/frontend/src/components/gitops/application/useGitOpsApplication.ts +++ b/frontend/src/components/gitops/application/useGitOpsApplication.ts @@ -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 { - 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[] { + 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 = 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> = { + 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 { + 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 { + 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 { 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 }); } diff --git a/frontend/src/components/gitops/portfolio/ApplicationsTable.tsx b/frontend/src/components/gitops/portfolio/ApplicationsTable.tsx index 8c79267f..dbc89f99 100644 --- a/frontend/src/components/gitops/portfolio/ApplicationsTable.tsx +++ b/frontend/src/components/gitops/portfolio/ApplicationsTable.tsx @@ -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', )} /> diff --git a/frontend/src/components/gitops/portfolio/PortfolioMasthead.tsx b/frontend/src/components/gitops/portfolio/PortfolioMasthead.tsx index ade189b8..20a55db7 100644 --- a/frontend/src/components/gitops/portfolio/PortfolioMasthead.tsx +++ b/frontend/src/components/gitops/portfolio/PortfolioMasthead.tsx @@ -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 (
diff --git a/frontend/src/components/gitops/portfolio/portfolioNavigation.test.ts b/frontend/src/components/gitops/portfolio/portfolioNavigation.test.ts index d6e3fefe..10def2c7 100644 --- a/frontend/src/components/gitops/portfolio/portfolioNavigation.test.ts +++ b/frontend/src/components/gitops/portfolio/portfolioNavigation.test.ts @@ -105,6 +105,23 @@ describe('portfolio row actions and attention next steps', () => { expect(captured(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'); diff --git a/frontend/src/components/gitops/portfolio/portfolioNavigation.ts b/frontend/src/components/gitops/portfolio/portfolioNavigation.ts index 62350f94..a7e07edc 100644 --- a/frontend/src/components/gitops/portfolio/portfolioNavigation.ts +++ b/frontend/src/components/gitops/portfolio/portfolioNavigation.ts @@ -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 = new Set 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') }; diff --git a/frontend/src/components/gitops/portfolio/useGitOpsPortfolio.test.ts b/frontend/src/components/gitops/portfolio/useGitOpsPortfolio.test.ts index 27cab2b8..d9efb7c8 100644 --- a/frontend/src/components/gitops/portfolio/useGitOpsPortfolio.test.ts +++ b/frontend/src/components/gitops/portfolio/useGitOpsPortfolio.test.ts @@ -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(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 () => { diff --git a/frontend/src/components/mobile/MobileGitOps.test.tsx b/frontend/src/components/mobile/MobileGitOps.test.tsx index c41c011e..c9424b4f 100644 --- a/frontend/src/components/mobile/MobileGitOps.test.tsx +++ b/frontend/src/components/mobile/MobileGitOps.test.tsx @@ -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(); + + // 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(); + }); }); diff --git a/frontend/src/components/mobile/MobileGitOps.tsx b/frontend/src/components/mobile/MobileGitOps.tsx index 81c9f4dd..6e298d02 100644 --- a/frontend/src/components/mobile/MobileGitOps.tsx +++ b/frontend/src/components/mobile/MobileGitOps.tsx @@ -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> = { 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" > - {row.posture === 'unknown' - ? + {row.posture === 'unknown' || !hasKnownPosture(row.posture) + ? : } {row.name} diff --git a/frontend/src/lib/gitopsLimitations.ts b/frontend/src/lib/gitopsLimitations.ts index 815952a7..deedb985 100644 --- a/frontend/src/lib/gitopsLimitations.ts +++ b/frontend/src/lib/gitopsLimitations.ts @@ -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 = { +export const GITOPS_LIMITATION_COPY: Readonly> = { // --- 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 = { * 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}).`; } /** diff --git a/frontend/src/lib/gitopsPortfolio.test.ts b/frontend/src/lib/gitopsPortfolio.test.ts index 69343ec0..1d3ea4e4 100644 --- a/frontend/src/lib/gitopsPortfolio.test.ts +++ b/frontend/src/lib/gitopsPortfolio.test.ts @@ -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'); + }); }); diff --git a/frontend/src/lib/gitopsPortfolio.ts b/frontend/src/lib/gitopsPortfolio.ts index 9f941bf8..5e88c380 100644 --- a/frontend/src/lib/gitopsPortfolio.ts +++ b/frontend/src/lib/gitopsPortfolio.ts @@ -18,7 +18,7 @@ export interface PortfolioLabel { line: string; } -export const POSTURE_LABEL: Record = { +export const POSTURE_LABEL: Readonly>> = { 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> = { +export const ATTENTION_LABEL: Readonly>> = { 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 = { @@ -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) { diff --git a/frontend/src/lib/gitopsState.test.ts b/frontend/src/lib/gitopsState.test.ts index 0f227f21..ff661cec 100644 --- a/frontend/src/lib/gitopsState.test.ts +++ b/frontend/src/lib/gitopsState.test.ts @@ -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', () => { diff --git a/frontend/src/lib/gitopsState.ts b/frontend/src/lib/gitopsState.ts index b3e968a3..a9dea4fd 100644 --- a/frontend/src/lib/gitopsState.ts +++ b/frontend/src/lib/gitopsState.ts @@ -630,14 +630,21 @@ export const RUNTIME_STATE: Record = { * 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> = SOURCE_STATE; -export const ARTIFACT_STATE_LOOKUP: Partial> = ARTIFACT_STATE; -export const PLACEMENT_STATE_LOOKUP: Partial> = PLACEMENT_STATE; -export const ROLLOUT_STATE_LOOKUP: Partial> = ROLLOUT_STATE; -export const RUNTIME_STATE_LOOKUP: Partial> = RUNTIME_STATE; +function stateLookup>(states: T): Readonly>> { + 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 = { +const DRIFT_CLASS_LABELS: Readonly> = { source: 'source', managed_project: 'managed project', invocation: 'invocation', @@ -780,7 +787,7 @@ const DRIFT_CLASS_LABELS: Record = { }; 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 }, }; +/** + * 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> = { + 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'; diff --git a/frontend/src/types/gitops.ts b/frontend/src/types/gitops.ts index 29ff99fb..31e325b9 100644 --- a/frontend/src/types/gitops.ts +++ b/frontend/src/types/gitops.ts @@ -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; diff --git a/frontend/src/types/gitopsPortfolio.ts b/frontend/src/types/gitopsPortfolio.ts index 3455cd5d..e75abddb 100644 --- a/frontend/src/types/gitopsPortfolio.ts +++ b/frontend/src/types/gitopsPortfolio.ts @@ -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 {