provisioning/schemas/lib/ops_contract.ncl

117 lines
4.6 KiB
Text

# schemas/lib/ops_contract.ncl — Ops contract (ADR-037)
# NATS JetStream subject namespaces, JWT signed command structure,
# stream configuration, and workspace ops contract definition.
let OpsType =
std.contract.custom (
fun label =>
fun value =>
let valid = ["deploy", "scale", "restart", "secret_update", "drain"] in
if std.array.any (fun x => x == value) valid then
'Ok value
else
'Error {
message = "Invalid op_type '%{value}'.\nValid values: deploy | scale | restart | secret_update | drain"
}
)
in
let _StreamRetention = [| 'WorkQueue, 'Limits, 'Interest |] in
let _ScopeEntry = {
op_type | OpsType,
target_pattern | String | doc "Glob pattern for allowed op targets (e.g., 'staging-*', 'vapora')",
} in
let _JwtClaims = {
iss | String | doc "Signer identity: keeper-vm-primary | operator-<id> | gh-actions-<id>",
sub | String | doc "Requesting principal: woodpecker-job-<id> | manual-<operator>",
aud | String | doc "Target workspace name",
scopes | Array _ScopeEntry | doc "Allowed (op_type, target_pattern) tuples scoped to this signer",
seq | Number | doc "Per-issuer monotonic counter — anti-replay",
jti | String | doc "UUIDv4 idempotency key",
expected_state_version | String | doc "Optimistic concurrency token — workspace state version this op read",
exp | Number | doc "Unix timestamp: token expiry",
nbf | Number | doc "Unix timestamp: token not-valid-before",
} in
let _StreamConfig = {
name | String,
subjects | Array String,
retention | _StreamRetention | doc "JetStream retention policy" | default = 'WorkQueue,
max_age_s | Number | doc "Message TTL in seconds",
replicas | Number | doc "JetStream stream replica count" | default = 1,
max_bytes | Number | doc "Max stream storage in bytes (-1 = unlimited)" | default = -1,
} in
let _OpsSubjects = {
pending | String | doc "ops.pending.<workspace>.> — unsigned proposals from emitters",
cmd | String | doc "ops.cmd.<workspace>.> — signed commands ready to apply",
ack | String | doc "ops.ack.<workspace>.> — application result from ops-controller",
audit | String | doc "ops.audit.<workspace> — immutable audit stream",
} in
let _OpsStreams = {
pending | _StreamConfig | doc "WorkQueue, 14d — buffers unsigned proposals",
cmd | _StreamConfig | doc "WorkQueue, 24h — signed commands awaiting application",
audit | _StreamConfig | doc "Limits, 90d, replicas=3 — immutable audit record",
} in
# Workspace-level ops contract — embed in workspace infra NCL as `ops_contract`
let _OpsWorkspaceConfig = {
workspace | String,
subjects | _OpsSubjects,
streams | _OpsStreams,
authorized_signers | Array String | doc "Signer identity keys allowed to sign for this workspace" | default = [],
} in
{
OpsType = OpsType,
StreamRetention = _StreamRetention,
ScopeEntry = _ScopeEntry,
JwtClaims = _JwtClaims,
StreamConfig = _StreamConfig,
OpsSubjects = _OpsSubjects,
OpsStreams = _OpsStreams,
OpsWorkspaceConfig = _OpsWorkspaceConfig,
# Constructs a full OpsWorkspaceConfig from a workspace name.
# Stream names follow ADR-037 convention: OPS_{STREAM}_{workspace}
# (workspace name is used verbatim; uppercase normalisation is ops-controller's concern).
make_ops_config | not_exported = fun workspace => {
workspace = workspace,
subjects = {
pending = "ops.pending.%{workspace}.>",
cmd = "ops.cmd.%{workspace}.>",
ack = "ops.ack.%{workspace}.>",
audit = "ops.audit.%{workspace}",
},
streams = {
pending = {
name = "OPS_PENDING_%{workspace}",
subjects = ["ops.pending.%{workspace}.>"],
retention = 'WorkQueue,
max_age_s = 1209600,
replicas = 1,
max_bytes = -1,
},
cmd = {
name = "OPS_CMD_%{workspace}",
subjects = ["ops.cmd.%{workspace}.>"],
retention = 'WorkQueue,
max_age_s = 86400,
replicas = 1,
max_bytes = -1,
},
audit = {
name = "OPS_AUDIT_%{workspace}",
subjects = ["ops.audit.%{workspace}"],
retention = 'Limits,
max_age_s = 7776000,
replicas = 3,
max_bytes = -1,
},
},
authorized_signers = [],
},
}