Skip to content

Commit f837bcf

Browse files
committed
feat(daemon): add durable target control plane
1 parent b362162 commit f837bcf

26 files changed

Lines changed: 1905 additions & 13 deletions

apps/daemon/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@siteops/ai-proposals": "workspace:*",
13+
"@siteops/baota-adapter": "workspace:*",
1314
"@siteops/core": "workspace:*",
1415
"@siteops/release-manifest": "workspace:*",
1516
"@siteops/site-graph": "workspace:*",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
CREATE TABLE target_profiles (
2+
target_id TEXT PRIMARY KEY REFERENCES baota_targets(id) ON DELETE CASCADE,
3+
source TEXT NOT NULL CHECK (source IN ('linux', 'simulator')),
4+
group_labels_json TEXT NOT NULL CHECK (json_valid(group_labels_json)),
5+
snapshot_id TEXT REFERENCES capability_snapshots(id),
6+
signed_snapshot_json TEXT CHECK (
7+
signed_snapshot_json IS NULL OR json_valid(signed_snapshot_json)
8+
),
9+
snapshot_path TEXT,
10+
last_checked_at TEXT
11+
) STRICT;
12+
13+
CREATE TABLE target_inventory_records (
14+
target_id TEXT NOT NULL REFERENCES baota_targets(id) ON DELETE CASCADE,
15+
resource_id TEXT NOT NULL,
16+
resource_kind TEXT NOT NULL,
17+
fingerprint TEXT NOT NULL,
18+
lifecycle TEXT NOT NULL CHECK (
19+
lifecycle IN ('active', 'stale', 'deleted')
20+
),
21+
generation INTEGER NOT NULL CHECK (generation > 0),
22+
observed_at TEXT NOT NULL,
23+
PRIMARY KEY (target_id, resource_id)
24+
) STRICT;
25+
26+
CREATE INDEX target_inventory_lifecycle_idx
27+
ON target_inventory_records(target_id, lifecycle, resource_id);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
ALTER TABLE operations ADD COLUMN impact_digest TEXT NOT NULL
2+
DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
3+
ALTER TABLE operations ADD COLUMN actor_channel TEXT NOT NULL DEFAULT 'cli'
4+
CHECK (actor_channel IN ('console', 'cli', 'mcp'));
5+
ALTER TABLE operations ADD COLUMN payload_json TEXT
6+
CHECK (payload_json IS NULL OR json_valid(payload_json));
7+
8+
ALTER TABLE approvals ADD COLUMN capability TEXT NOT NULL DEFAULT '';
9+
ALTER TABLE approvals ADD COLUMN operation_class TEXT NOT NULL DEFAULT 'reversible'
10+
CHECK (operation_class IN (
11+
'read_only',
12+
'reversible',
13+
'compensatable',
14+
'snapshot_restorable',
15+
'irreversible'
16+
));
17+
ALTER TABLE approvals ADD COLUMN impact_digest TEXT NOT NULL
18+
DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
19+
ALTER TABLE approvals ADD COLUMN state TEXT NOT NULL DEFAULT 'active'
20+
CHECK (state IN ('active', 'consumed', 'cancelled'));
21+
ALTER TABLE approvals ADD COLUMN typed_acknowledgement TEXT;
22+
23+
CREATE UNIQUE INDEX jobs_consumed_approval_uq
24+
ON jobs(approval_id)
25+
WHERE approval_id IS NOT NULL AND deleted_at IS NULL;

apps/daemon/src/db/migrations/__test__/migrations.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ describe("SQLite control-plane migrations", () => {
172172
// Given a database stamped with a schema version newer than this runner
173173
const database = new DatabaseSync(":memory:");
174174
openDatabases.push(database);
175-
database.exec("PRAGMA user_version = 5");
175+
database.exec("PRAGMA user_version = 7");
176176

177177
// When this migration runner attempts to open it
178178
const migrateStaleRunner = () => {
@@ -195,7 +195,7 @@ describe("SQLite control-plane migrations", () => {
195195
foundVersion: migrationError?.foundVersion,
196196
}).toEqual({
197197
name: "MigrationStateError",
198-
foundVersion: 5,
198+
foundVersion: 7,
199199
});
200200
});
201201
});

apps/daemon/src/db/migrations/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFileSync } from "node:fs";
22
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
33

4-
const CURRENT_SCHEMA_VERSION = 4;
4+
const CURRENT_SCHEMA_VERSION = 6;
55
const CONTROL_PLANE_SQL = readFileSync(
66
new URL("./001_control_plane.sql", import.meta.url),
77
"utf8",
@@ -18,11 +18,21 @@ const RELEASE_ORCHESTRATION_SQL = readFileSync(
1818
new URL("./004_release_orchestration.sql", import.meta.url),
1919
"utf8",
2020
);
21+
const TARGET_CONTROL_PLANE_SQL = readFileSync(
22+
new URL("./005_target_control_plane.sql", import.meta.url),
23+
"utf8",
24+
);
25+
const TARGET_JOB_EXECUTION_SQL = readFileSync(
26+
new URL("./006_target_job_execution.sql", import.meta.url),
27+
"utf8",
28+
);
2129
const MIGRATIONS = [
2230
{ version: 1, sql: CONTROL_PLANE_SQL },
2331
{ version: 2, sql: VAULT_SQL },
2432
{ version: 3, sql: DURABLE_JOBS_SQL },
2533
{ version: 4, sql: RELEASE_ORCHESTRATION_SQL },
34+
{ version: 5, sql: TARGET_CONTROL_PLANE_SQL },
35+
{ version: 6, sql: TARGET_JOB_EXECUTION_SQL },
2636
] as const;
2737

2838
export type ConstraintKind = "foreign_key" | "unique" | "check" | "other";

apps/daemon/src/http/contracts.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ const ResourceKindSchema = z.enum([
1818
"runtime_service",
1919
"data_store",
2020
]);
21+
export const OperationPayloadSchema = z.discriminatedUnion("kind", [
22+
z
23+
.object({ kind: z.literal("inventory.read") })
24+
.strict()
25+
.readonly(),
26+
z
27+
.object({
28+
kind: z.literal("site.settings.update"),
29+
siteId: z.string().min(1),
30+
priorNote: z.string(),
31+
note: z.string(),
32+
})
33+
.strict()
34+
.readonly(),
35+
]);
2136

2237
export const DraftInputSchema = z
2338
.object({
@@ -37,6 +52,7 @@ export const DraftInputSchema = z
3752
})
3853
.strict()
3954
.readonly(),
55+
payload: OperationPayloadSchema.nullable().default(null),
4056
})
4157
.strict()
4258
.readonly();

apps/daemon/src/http/execute-route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ export function registerExecuteRoute(
102102
timeoutAt: input.data.timeoutAt,
103103
now: options.now(),
104104
});
105+
if (decision.approvalId !== null) {
106+
options.database
107+
.prepare(
108+
"UPDATE approvals SET state = 'consumed', updated_at = ? WHERE id = ? AND state = 'active'",
109+
)
110+
.run(options.now(), decision.approvalId);
111+
}
105112
options.audit.append({
106113
actorId: actor.id,
107114
action: "job:queued",

apps/daemon/src/http/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import {
1313
type SyntheticProbe,
1414
} from "../observability/synthetic.js";
1515
import { TelemetryStore } from "../observability/store.js";
16+
import type { DurableTargetControlPlane } from "../targets/control-plane.js";
1617
import { AuditStore } from "./audit.js";
1718
import { registerJobRoutes } from "./jobs-routes.js";
1819
import { registerIntakeRoute } from "./intake-route.js";
1920
import { registerObservabilityRoutes } from "./observability-routes.js";
2021
import { registerReadRoutes } from "./read-routes.js";
2122
import { loopbackGuard } from "./security.js";
23+
import { registerTargetRoutes } from "./target-routes.js";
2224

2325
export interface DaemonHttpOptions {
2426
readonly database: DatabaseSync;
@@ -28,6 +30,7 @@ export interface DaemonHttpOptions {
2830
readonly intake: IntakeService;
2931
readonly telemetryStore?: TelemetryStore;
3032
readonly syntheticProbe?: SyntheticProbe;
33+
readonly targets?: DurableTargetControlPlane;
3134
}
3235

3336
export function buildDaemonHttp(options: DaemonHttpOptions): FastifyInstance {
@@ -77,6 +80,13 @@ export function buildDaemonHttp(options: DaemonHttpOptions): FastifyInstance {
7780
store: telemetryStore,
7881
synthetic,
7982
});
83+
if (options.targets !== undefined) {
84+
registerTargetRoutes(app, {
85+
auth: options.auth,
86+
audit,
87+
targets: options.targets,
88+
});
89+
}
8090
return app;
8191
}
8292

apps/daemon/src/http/jobs-routes.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type BoundApproval,
88
type PolicyOperation,
99
} from "@siteops/core";
10+
import type { z } from "zod";
1011

1112
import { ApprovalError, ApprovalService } from "../approvals/index.js";
1213
import { CAPABILITIES, type LocalAuth } from "../auth/index.js";
@@ -16,13 +17,15 @@ import {
1617
ApprovalInputSchema,
1718
DraftInputSchema,
1819
IdParamsSchema,
20+
OperationPayloadSchema,
1921
} from "./contracts.js";
2022
import { registerExecuteRoute } from "./execute-route.js";
2123
import { loadPolicyContext } from "./policy-context.js";
2224
import { authorizeRequest } from "./security.js";
2325

2426
export interface DraftRecord {
2527
readonly operation: PolicyOperation;
28+
readonly payload: z.infer<typeof OperationPayloadSchema> | null;
2629
}
2730

2831
export interface JobRouteOptions {
@@ -97,7 +100,7 @@ export function registerJobRoutes(
97100
const now = options.now();
98101
options.database
99102
.prepare(
100-
"INSERT INTO operations (id, schema_version, owner_id, generation, target_id, resource_kind, resource_id, capability, operation_class, payload_digest, state, created_at, updated_at) VALUES (?, 1, ?, 1, ?, ?, ?, ?, ?, ?, 'drafted', ?, ?)",
103+
"INSERT INTO operations (id, schema_version, owner_id, generation, target_id, resource_kind, resource_id, capability, operation_class, payload_digest, impact_digest, actor_channel, payload_json, state, created_at, updated_at) VALUES (?, 1, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'drafted', ?, ?)",
101104
)
102105
.run(
103106
operation.id,
@@ -108,10 +111,16 @@ export function registerJobRoutes(
108111
operation.capability,
109112
operation.operationClass,
110113
operation.payloadDigest,
114+
operation.declaredImpact.digest,
115+
actor.channel,
116+
input.data.payload === null ? null : JSON.stringify(input.data.payload),
111117
now,
112118
now,
113119
);
114-
drafts.set(operation.id, { operation: context.operation });
120+
drafts.set(operation.id, {
121+
operation: context.operation,
122+
payload: input.data.payload,
123+
});
115124
options.audit.append({
116125
actorId: actor.id,
117126
action: "job:drafted",
@@ -147,6 +156,27 @@ export function registerJobRoutes(
147156
input.data.expiresAt,
148157
input.data.typedAcknowledgement,
149158
);
159+
options.database
160+
.prepare(
161+
"INSERT INTO approvals (id, schema_version, owner_id, generation, operation_id, target_id, resource_id, payload_digest, capability, operation_class, impact_digest, approved_by, approved_at, expires_at, state, typed_acknowledgement, created_at, updated_at) VALUES (?, 1, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
162+
)
163+
.run(
164+
approval.id,
165+
approval.actorId,
166+
approval.operationId,
167+
approval.targetId,
168+
approval.resource.id,
169+
approval.payloadDigest,
170+
approval.capability,
171+
approval.operationClass,
172+
approval.impactDigest,
173+
actor.id,
174+
approval.approvedAt,
175+
approval.expiresAt,
176+
approval.typedAcknowledgement,
177+
approval.approvedAt,
178+
approval.approvedAt,
179+
);
150180
approved.set(approval.id, approval);
151181
options.audit.append({
152182
actorId: actor.id,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { FastifyInstance } from "fastify";
2+
import { z } from "zod";
3+
4+
import { CAPABILITIES, type LocalAuth } from "../auth/index.js";
5+
import {
6+
DurableTargetControlPlane,
7+
TargetEndpointError,
8+
TargetEnrollmentError,
9+
} from "../targets/control-plane.js";
10+
import type { AuditStore } from "./audit.js";
11+
import { IdParamsSchema } from "./contracts.js";
12+
import { authorizeRequest } from "./security.js";
13+
14+
const EnrollmentBodySchema = z
15+
.object({
16+
targetId: z.uuid(),
17+
name: z.string().min(1),
18+
endpoint: z.url(),
19+
groupLabels: z.array(z.string().min(1)).readonly(),
20+
secret: z.string().min(1).max(65_536),
21+
source: z.enum(["linux", "simulator"]),
22+
})
23+
.strict()
24+
.readonly();
25+
26+
interface TargetRouteOptions {
27+
readonly auth: LocalAuth;
28+
readonly audit: AuditStore;
29+
readonly targets: DurableTargetControlPlane;
30+
}
31+
32+
export function registerTargetRoutes(
33+
app: FastifyInstance,
34+
options: TargetRouteOptions,
35+
): void {
36+
app.post("/v1/targets", async (request, reply) => {
37+
const actor = authorizeRequest(options, {
38+
request,
39+
reply,
40+
capability: CAPABILITIES.jobsPropose,
41+
});
42+
if (actor === null) {
43+
return;
44+
}
45+
const body = EnrollmentBodySchema.safeParse(request.body);
46+
if (!body.success) {
47+
await reply.code(400).send({ error: "invalid_request" });
48+
return;
49+
}
50+
try {
51+
const target = await options.targets.enroll({
52+
...body.data,
53+
ownerId: actor.id,
54+
});
55+
options.audit.append({
56+
actorId: actor.id,
57+
action: "target:enrolled",
58+
targetId: target.id,
59+
resourceKind: "target",
60+
resourceId: target.id,
61+
});
62+
await reply.code(201).send(target);
63+
} catch (error) {
64+
if (
65+
!(error instanceof TargetEndpointError) &&
66+
!(error instanceof TargetEnrollmentError)
67+
) {
68+
throw error;
69+
}
70+
options.audit.append({
71+
actorId: actor.id,
72+
action: `denied:${error.reason}`,
73+
});
74+
await reply.code(400).send({
75+
error: "target_enrollment_rejected",
76+
reason: error.reason,
77+
});
78+
}
79+
});
80+
81+
app.get("/v1/targets/:id", async (request, reply) => {
82+
const actor = authorizeRequest(options, {
83+
request,
84+
reply,
85+
capability: CAPABILITIES.daemonRead,
86+
});
87+
if (actor === null) {
88+
return;
89+
}
90+
const params = IdParamsSchema.safeParse(request.params);
91+
if (!params.success) {
92+
await reply.code(404).send({ error: "target_not_found" });
93+
return;
94+
}
95+
try {
96+
await reply.send(options.targets.read(params.data.id));
97+
} catch (error) {
98+
if (error instanceof z.ZodError) {
99+
await reply.code(404).send({ error: "target_not_found" });
100+
return;
101+
}
102+
throw error;
103+
}
104+
});
105+
}

0 commit comments

Comments
 (0)