Skip to content

Commit 8fb7507

Browse files
committed
✨ feat(api): identity-keyed container tracking for audit events and recent status
- Extract getContainerIdentityKey from store to model/container for shared use - Add containerIdentityKey to audit entries for stable cross-rename tracking - Return statusesByIdentity alongside statuses in recent-status endpoint - Update OpenAPI schema to include statusesByIdentity in response contract
1 parent 62aa405 commit 8fb7507

File tree

8 files changed

+136
-36
lines changed

8 files changed

+136
-36
lines changed

app/api/audit-events.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ describe('recordAuditEvent', () => {
2727
action: 'rollback',
2828
status: 'success',
2929
container: {
30+
agent: 'edge-a',
3031
name: 'nginx',
32+
watcher: 'docker-prod',
3133
image: { name: 'library/nginx' },
3234
},
3335
fromVersion: '1.24.0',
@@ -40,6 +42,7 @@ describe('recordAuditEvent', () => {
4042
action: 'rollback',
4143
status: 'success',
4244
containerName: 'nginx',
45+
containerIdentityKey: 'edge-a::docker-prod::nginx',
4346
containerImage: 'library/nginx',
4447
fromVersion: '1.24.0',
4548
toVersion: '1.23.0',
@@ -63,4 +66,21 @@ describe('recordAuditEvent', () => {
6366

6467
expect(mockInsertAudit).toHaveBeenCalled();
6568
});
69+
70+
test('should omit containerIdentityKey when the container has no watcher identity', () => {
71+
recordAuditEvent({
72+
action: 'container-update',
73+
status: 'success',
74+
container: {
75+
name: 'nginx',
76+
image: { name: 'library/nginx' },
77+
},
78+
});
79+
80+
expect(mockInsertAudit).toHaveBeenCalledWith(
81+
expect.not.objectContaining({
82+
containerIdentityKey: expect.any(String),
83+
}),
84+
);
85+
});
6686
});

app/api/audit-events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AuditEntry } from '../model/audit.js';
2+
import { getContainerIdentityKey } from '../model/container.js';
23
import { getAuditCounter } from '../prometheus/audit.js';
34
import * as auditStore from '../store/audit.js';
45

@@ -14,6 +15,8 @@ type RecordAuditEventArgs = {
1415
| {
1516
container: {
1617
name: AuditEntry['containerName'];
18+
watcher?: string;
19+
agent?: string;
1720
image?: { name?: AuditContainerImage };
1821
};
1922
containerName?: AuditEntry['containerName'];
@@ -42,12 +45,14 @@ export function recordAuditEvent({
4245
fromVersion,
4346
toVersion,
4447
}: RecordAuditEventArgs) {
48+
const containerIdentityKey = container ? getContainerIdentityKey(container) : undefined;
4549
const entry: AuditEntry = {
4650
id: '',
4751
timestamp: new Date().toISOString(),
4852
action,
4953
containerName,
5054
containerImage,
55+
...(containerIdentityKey !== undefined ? { containerIdentityKey } : {}),
5156
status,
5257
...(details !== undefined ? { details } : {}),
5358
...(fromVersion !== undefined ? { fromVersion } : {}),

app/api/container.test.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -753,10 +753,26 @@ describe('Container Router', () => {
753753
describe('getContainerRecentStatus', () => {
754754
test('should return the latest status per container using recent audit entries', () => {
755755
auditStore.getRecentEntries.mockReturnValue([
756-
{ containerName: 'api', action: 'update-failed' },
757-
{ containerName: 'api', action: 'update-applied' },
758-
{ containerName: 'worker', action: 'update-applied' },
759-
{ containerName: 'cache', action: 'update-available' },
756+
{
757+
containerName: 'api',
758+
containerIdentityKey: 'edge-a::docker-prod::api',
759+
action: 'update-failed',
760+
},
761+
{
762+
containerName: 'api',
763+
containerIdentityKey: 'edge-a::docker-prod::api',
764+
action: 'update-applied',
765+
},
766+
{
767+
containerName: 'worker',
768+
containerIdentityKey: 'edge-b::docker-prod::worker',
769+
action: 'update-applied',
770+
},
771+
{
772+
containerName: 'cache',
773+
containerIdentityKey: '::local::cache',
774+
action: 'update-available',
775+
},
760776
{ containerName: 'ignore-me', action: 'container-update' },
761777
]);
762778

@@ -772,7 +788,20 @@ describe('Container Router', () => {
772788
cache: 'pending',
773789
worker: 'updated',
774790
},
791+
statusesByIdentity: {
792+
'::local::cache': 'pending',
793+
'edge-a::docker-prod::api': 'failed',
794+
'edge-b::docker-prod::worker': 'updated',
795+
},
775796
});
797+
const contractValidation = validateOpenApiJsonResponse({
798+
path: '/api/containers/recent-status',
799+
method: 'get',
800+
statusCode: '200',
801+
payload: res.json.mock.calls[0][0],
802+
});
803+
expect(contractValidation.valid).toBe(true);
804+
expect(contractValidation.errors).toStrictEqual([]);
776805
});
777806

778807
test('should ignore invalid entries and empty container names', () => {
@@ -781,6 +810,16 @@ describe('Container Router', () => {
781810
{ action: 'update-failed' },
782811
{ containerName: ' ', action: 'update-failed' },
783812
{ containerName: 'trim-me', action: 'update-applied' },
813+
{
814+
containerName: 'duplicate-name',
815+
containerIdentityKey: 'edge-a::docker-a::duplicate-name',
816+
action: 'update-failed',
817+
},
818+
{
819+
containerName: 'duplicate-name',
820+
containerIdentityKey: 'edge-b::docker-b::duplicate-name',
821+
action: 'update-applied',
822+
},
784823
]);
785824

786825
const handler = getHandler('get', '/recent-status');
@@ -790,8 +829,13 @@ describe('Container Router', () => {
790829
expect(res.status).toHaveBeenCalledWith(200);
791830
expect(res.json).toHaveBeenCalledWith({
792831
statuses: {
832+
'duplicate-name': 'failed',
793833
'trim-me': 'updated',
794834
},
835+
statusesByIdentity: {
836+
'edge-a::docker-a::duplicate-name': 'failed',
837+
'edge-b::docker-b::duplicate-name': 'updated',
838+
},
795839
});
796840
});
797841

@@ -803,7 +847,7 @@ describe('Container Router', () => {
803847
handler({ query: {} }, res);
804848

805849
expect(res.status).toHaveBeenCalledWith(200);
806-
expect(res.json).toHaveBeenCalledWith({ statuses: {} });
850+
expect(res.json).toHaveBeenCalledWith({ statuses: {}, statusesByIdentity: {} });
807851
});
808852
});
809853

app/api/container.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ const router = express.Router();
5050
const RECENT_STATUS_AUDIT_LIMIT = 100;
5151

5252
type RecentContainerStatus = 'updated' | 'pending' | 'failed';
53+
type RecentContainerStatusResponse = {
54+
statuses: Record<string, RecentContainerStatus>;
55+
statusesByIdentity: Record<string, RecentContainerStatus>;
56+
};
5357

5458
function mapAuditActionToRecentStatus(action: unknown): RecentContainerStatus | null {
5559
if (action === 'update-applied') return 'updated';
@@ -58,26 +62,44 @@ function mapAuditActionToRecentStatus(action: unknown): RecentContainerStatus |
5862
return null;
5963
}
6064

61-
function buildRecentStatusByContainer(entries: unknown): Record<string, RecentContainerStatus> {
62-
if (!Array.isArray(entries)) return {};
65+
function buildRecentStatusResponse(entries: unknown): RecentContainerStatusResponse {
66+
if (!Array.isArray(entries)) {
67+
return {
68+
statuses: {},
69+
statusesByIdentity: {},
70+
};
71+
}
72+
6373
const statusByContainer: Record<string, RecentContainerStatus> = {};
74+
const statusByIdentity: Record<string, RecentContainerStatus> = {};
6475
for (const entry of entries) {
6576
if (!entry || typeof entry !== 'object') continue;
66-
const containerNameRaw = (entry as { containerName?: unknown }).containerName;
67-
const containerName = typeof containerNameRaw === 'string' ? containerNameRaw.trim() : '';
68-
if (!containerName || statusByContainer[containerName]) continue;
6977
const mappedStatus = mapAuditActionToRecentStatus((entry as { action?: unknown }).action);
7078
if (!mappedStatus) continue;
71-
statusByContainer[containerName] = mappedStatus;
79+
80+
const containerNameRaw = (entry as { containerName?: unknown }).containerName;
81+
const containerName = typeof containerNameRaw === 'string' ? containerNameRaw.trim() : '';
82+
if (containerName && !statusByContainer[containerName]) {
83+
statusByContainer[containerName] = mappedStatus;
84+
}
85+
86+
const containerIdentityKeyRaw = (entry as { containerIdentityKey?: unknown })
87+
.containerIdentityKey;
88+
const containerIdentityKey =
89+
typeof containerIdentityKeyRaw === 'string' ? containerIdentityKeyRaw.trim() : '';
90+
if (containerIdentityKey && !statusByIdentity[containerIdentityKey]) {
91+
statusByIdentity[containerIdentityKey] = mappedStatus;
92+
}
7293
}
73-
return statusByContainer;
94+
return {
95+
statuses: statusByContainer,
96+
statusesByIdentity: statusByIdentity,
97+
};
7498
}
7599

76100
function getContainerRecentStatus(_req: Request, res: Response) {
77101
const recentEntries = auditStore.getRecentEntries(RECENT_STATUS_AUDIT_LIMIT);
78-
res.status(200).json({
79-
statuses: buildRecentStatusByContainer(recentEntries),
80-
});
102+
res.status(200).json(buildRecentStatusResponse(recentEntries));
81103
}
82104

83105
/**

app/api/openapi/schemas.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,15 @@ export const openApiSchemas = {
322322
enum: ['updated', 'pending', 'failed'],
323323
},
324324
},
325+
statusesByIdentity: {
326+
type: 'object',
327+
additionalProperties: {
328+
type: 'string',
329+
enum: ['updated', 'pending', 'failed'],
330+
},
331+
},
325332
},
326-
required: ['statuses'],
333+
required: ['statuses', 'statusesByIdentity'],
327334
additionalProperties: false,
328335
},
329336
WatchContainersRequest: {

app/model/audit.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface AuditEntry {
2828
| 'auth-login'
2929
| 'env-reveal';
3030
containerName: string;
31+
containerIdentityKey?: string;
3132
containerImage?: string;
3233
fromVersion?: string;
3334
toVersion?: string;

app/model/container.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ export interface Container {
174174
resultChanged?: (otherContainer: Container | undefined) => boolean;
175175
}
176176

177+
export type ContainerIdentity = Partial<Pick<Container, 'agent' | 'watcher' | 'name'>>;
178+
177179
export interface ContainerReport {
178180
container: Container;
179181
changed: boolean;
@@ -820,6 +822,24 @@ export function flatten(container: Container) {
820822
return containerFlatten;
821823
}
822824

825+
export function getContainerIdentityKey(containerIdentity: ContainerIdentity) {
826+
if (
827+
!containerIdentity ||
828+
typeof containerIdentity.watcher !== 'string' ||
829+
containerIdentity.watcher.length === 0 ||
830+
typeof containerIdentity.name !== 'string' ||
831+
containerIdentity.name.length === 0
832+
) {
833+
return undefined;
834+
}
835+
836+
const agent =
837+
typeof containerIdentity.agent === 'string' && containerIdentity.agent.length > 0
838+
? containerIdentity.agent
839+
: '';
840+
return `${agent}::${containerIdentity.watcher}::${containerIdentity.name}`;
841+
}
842+
823843
/**
824844
* Build the business id of the container.
825845
* @param container

app/store/container.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const CONTAINER_COLLECTION_INDICES = ['data.watcher', 'data.status', 'data.updat
3131
const UNSAFE_QUERY_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
3232
const CONTAINER_QUERY_CONTROL_KEYS = new Set(['excludeRollbackContainers']);
3333
const STABLE_UNDEFINED_SENTINEL = '__undefined__';
34+
const toContainerFreshStateKey = container.getContainerIdentityKey;
3435

3536
type SecurityStateCacheEntry = {
3637
security: unknown;
@@ -54,26 +55,6 @@ function toCacheKey(watcher, name) {
5455
return `${watcher}_${name}`;
5556
}
5657

57-
function toContainerFreshStateKey(
58-
containerIdentity: Partial<Pick<container.Container, 'agent' | 'watcher' | 'name'>>,
59-
) {
60-
if (
61-
!containerIdentity ||
62-
typeof containerIdentity.watcher !== 'string' ||
63-
containerIdentity.watcher.length === 0 ||
64-
typeof containerIdentity.name !== 'string' ||
65-
containerIdentity.name.length === 0
66-
) {
67-
return undefined;
68-
}
69-
70-
const agent =
71-
typeof containerIdentity.agent === 'string' && containerIdentity.agent.length > 0
72-
? containerIdentity.agent
73-
: '';
74-
return `${agent}::${containerIdentity.watcher}::${containerIdentity.name}`;
75-
}
76-
7758
export const SECURITY_STATE_CACHE_TTL_MS = toPositiveInteger(
7859
process.env.DD_SECURITY_STATE_CACHE_TTL_MS,
7960
DEFAULT_SECURITY_STATE_CACHE_TTL_MS,

0 commit comments

Comments
 (0)