generated from coulomb/repo-seed
Implement NK-WP-0013 playbook capability contract
This commit is contained in:
5
Makefile
5
Makefile
@@ -153,8 +153,11 @@ creds-emergency-reprint: ## Re-deliver emergency bundle (if lost/stolen — repr
|
||||
iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests
|
||||
python3 -m pytest tools/iam-profile-conformance/tests
|
||||
|
||||
playbook-contract-test: ## Run Playbook Capability Contract fixture tests
|
||||
python3 -m pytest tools/playbook-capability-contract/tests
|
||||
|
||||
.PHONY: help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \
|
||||
check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \
|
||||
creds-status creds-rotate \
|
||||
creds-agent-init creds-agent-status creds-emergency-reprint \
|
||||
iam-profile-conformance-test
|
||||
iam-profile-conformance-test playbook-contract-test
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://netkingdom.local/schemas/playbook-capability-declaration_v0.1.schema.json",
|
||||
"title": "NetKingdom Playbook Capability Declaration v0.1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["apiVersion", "kind", "metadata", "spec"],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"const": "netkingdom.io/playbook-capability/v0.1"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "PlaybookCapabilityDeclaration"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "name", "owner", "repo", "domain", "contract_version"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"domain": {"type": "string", "minLength": 1},
|
||||
"contract_version": {"type": "string", "const": "0.1"},
|
||||
"source_links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "path"],
|
||||
"properties": {
|
||||
"label": {"type": "string", "minLength": 1},
|
||||
"path": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playbook", "capabilities", "parameters", "responsibilities", "trust", "catalog"],
|
||||
"properties": {
|
||||
"playbook": {"type": "object"},
|
||||
"capabilities": {"type": "array", "minItems": 1},
|
||||
"parameters": {"type": "array"},
|
||||
"responsibilities": {"type": "array", "minItems": 1},
|
||||
"trust": {"type": "object"},
|
||||
"catalog": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
299
canon/standards/playbook-capability-contract_v0.1.md
Normal file
299
canon/standards/playbook-capability-contract_v0.1.md
Normal file
@@ -0,0 +1,299 @@
|
||||
---
|
||||
id: netkingdom-playbook-capability-contract
|
||||
type: standard
|
||||
title: "NetKingdom Playbook Capability Contract v0.1"
|
||||
domain: netkingdom
|
||||
status: accepted
|
||||
version: "0.1"
|
||||
created: "2026-05-22"
|
||||
updated: "2026-05-22"
|
||||
scope: meta-orchestration
|
||||
adr:
|
||||
- docs/adr/ADR-0012-playbook-capability-contract-ownership.md
|
||||
schema:
|
||||
- canon/schemas/playbook-capability-declaration_v0.1.schema.json
|
||||
validator:
|
||||
- tools/playbook-capability-contract/playbook_contract_validator.py
|
||||
---
|
||||
|
||||
# NetKingdom Playbook Capability Contract v0.1
|
||||
|
||||
## Purpose
|
||||
|
||||
The Playbook Capability Contract is the declared interface between
|
||||
NetKingdom meta-orchestration and Railiance execution playbooks.
|
||||
|
||||
It lets a playbook state:
|
||||
|
||||
- which capability it provisions;
|
||||
- which parameters it exposes, including defaults, constraints, and
|
||||
security sensitivity;
|
||||
- which resources and responsibilities it claims;
|
||||
- which trust states it requires or satisfies;
|
||||
- how it is published into a catalog.
|
||||
|
||||
NetKingdom consumes declarations to select playbooks, choose safe
|
||||
parameter overrides, sequence trust states, and build a responsibility
|
||||
map. Railiance owns the playbooks and execution mechanics.
|
||||
|
||||
## Ownership
|
||||
|
||||
NetKingdom owns this contract. Railiance publishes conformant
|
||||
declarations. Execution stays in Railiance. See ADR-0012.
|
||||
|
||||
## File Convention
|
||||
|
||||
Declaration files SHOULD live in the publishing repo at:
|
||||
|
||||
```text
|
||||
capabilities/playbooks/<declaration-id>.yaml
|
||||
```
|
||||
|
||||
Each file describes one playbook or one stable playbook entry point. A
|
||||
playbook with materially different modes may publish multiple
|
||||
declarations if those modes provide different capabilities or expose
|
||||
different security-sensitive parameters.
|
||||
|
||||
## Top-Level Shape
|
||||
|
||||
```yaml
|
||||
apiVersion: netkingdom.io/playbook-capability/v0.1
|
||||
kind: PlaybookCapabilityDeclaration
|
||||
metadata:
|
||||
id: railiance-infra.bootstrap-host
|
||||
name: Railiance S1 host bootstrap
|
||||
owner: railiance-infra
|
||||
repo: railiance-infra
|
||||
domain: railiance
|
||||
contract_version: "0.1"
|
||||
spec:
|
||||
playbook: {}
|
||||
capabilities: []
|
||||
parameters: []
|
||||
responsibilities: []
|
||||
trust: {}
|
||||
catalog: {}
|
||||
```
|
||||
|
||||
## Capability Vocabulary
|
||||
|
||||
`spec.capabilities[].id` MUST be one of the controlled vocabulary values
|
||||
below. Capability ids are stable comparison keys.
|
||||
|
||||
| Capability id | Tier | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `s1.os-baseline` | S1 | Host provisioning, OS convergence, hardening, and substrate access baseline |
|
||||
| `s1.secret-bootstrap` | S1 | Bootstrap secret material, SOPS/age handling, emergency material placement |
|
||||
| `s2.cluster-runtime` | S2 | Kubernetes runtime, ingress, networking, admission, and cluster access |
|
||||
| `s3.platform-services` | S3 | Databases, caches, object storage, brokers, and shared platform services |
|
||||
| `c0.bootstrap-identity` | C0 | Local/bootstrap identity before runtime IAM exists |
|
||||
| `c1.lightweight-sso` | C1 | key-cape lightweight SSO profile implementation |
|
||||
| `c2a.light-2fa` | C2a | Lightweight built-in second factor such as TOTP/WebAuthn |
|
||||
| `c2b.token-authority` | C2b | privacyIDEA or equivalent token authority |
|
||||
| `c3.runtime-secrets` | C3 | OpenBao or equivalent runtime secret authority |
|
||||
| `c4.fine-grained-authorization` | C4 | flex-auth and delegated PDP readiness |
|
||||
| `c5.enterprise-federation` | C5 | expanded-mode Keycloak, enterprise federation, or SAML brokering |
|
||||
| `c6.self-optimizing-audit` | C6 | audit feedback loops, drift surfacing, and continuous adaptation |
|
||||
|
||||
The `S*` entries align to Railiance stack layers. The `C*` entries align
|
||||
to the NetKingdom capability progression. A declaration may list more
|
||||
than one capability only when the same playbook entry point truly
|
||||
provides each one.
|
||||
|
||||
## Resource Kinds
|
||||
|
||||
Every capability and responsibility claim references one or more
|
||||
resource kinds:
|
||||
|
||||
| Resource kind | Meaning |
|
||||
| --- | --- |
|
||||
| `identities` | humans, service accounts, agents, groups, tenants, and assurance evidence |
|
||||
| `roles_scopes_policies` | roles, scopes, policy packages, protected-system registrations, decision records |
|
||||
| `secrets_credentials` | bootstrap material, runtime secrets, dynamic credentials, leases, credential rotations |
|
||||
| `infrastructure_resources` | hosts, runtime, networking, platform services, storage, and deployment substrate |
|
||||
|
||||
## Parameter Declarations
|
||||
|
||||
Each parameter entry has this shape:
|
||||
|
||||
```yaml
|
||||
- name: swapfile_size_mb
|
||||
type: integer
|
||||
required: false
|
||||
default: 4096
|
||||
constraints:
|
||||
minimum: 0
|
||||
maximum: 65536
|
||||
sensitivity: operational
|
||||
tuning_authority: netkingdom_tunable
|
||||
description: Swap size applied by the bootstrap playbook.
|
||||
```
|
||||
|
||||
Allowed `type` values:
|
||||
|
||||
- `string`
|
||||
- `integer`
|
||||
- `number`
|
||||
- `boolean`
|
||||
- `array`
|
||||
- `object`
|
||||
|
||||
Allowed `sensitivity` values:
|
||||
|
||||
- `public` - safe to display and tune freely.
|
||||
- `operational` - affects behavior or sizing, not secret material.
|
||||
- `security_sensitive` - affects security posture and requires platform
|
||||
review.
|
||||
- `secret_reference` - points to secret material but must not contain the
|
||||
secret value itself.
|
||||
|
||||
Allowed `tuning_authority` values:
|
||||
|
||||
- `playbook_default` - NetKingdom should rely on the playbook default.
|
||||
- `netkingdom_tunable` - NetKingdom may override for a scenario.
|
||||
- `platform_only` - only platform-control-plane authority may override.
|
||||
- `tenant_tunable` - tenant-scoped scenario owners may override within
|
||||
constraints.
|
||||
- `forbidden` - declaration exposes the value for audit only; callers may
|
||||
not override it.
|
||||
|
||||
Security-sensitive and secret-reference parameters MUST NOT be
|
||||
`tenant_tunable`. Secret-reference defaults must be references or paths,
|
||||
not plaintext secret values.
|
||||
|
||||
Supported constraints:
|
||||
|
||||
| Constraint | Applies to | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `enum` | all scalar types | value must be one of the listed values |
|
||||
| `minimum` / `maximum` | integer, number | numeric bounds |
|
||||
| `min_items` / `max_items` | array | array length bounds |
|
||||
| `pattern` | string | regular expression the value must match |
|
||||
|
||||
## Responsibility Claims
|
||||
|
||||
Responsibility entries feed the responsibility map. They do not transfer
|
||||
execution ownership to NetKingdom.
|
||||
|
||||
```yaml
|
||||
- resource_kind: infrastructure_resources
|
||||
owner: railiance-infra
|
||||
resources:
|
||||
- server:target_hosts
|
||||
- os-baseline
|
||||
repo_owns: Provisioning, convergence, and verification mechanics.
|
||||
netkingdom_orchestrates: Whether this substrate capability is selected, and which security posture is required.
|
||||
```
|
||||
|
||||
`owner` names the repo or provider that holds execution ownership.
|
||||
`repo_owns` explains the implementation responsibility.
|
||||
`netkingdom_orchestrates` explains the meta-orchestration responsibility.
|
||||
|
||||
## Trust States And Readiness
|
||||
|
||||
Declarations use the trust-state vocabulary from the platform
|
||||
architecture:
|
||||
|
||||
- `bare_host_trust`
|
||||
- `cluster_trust`
|
||||
- `bootstrap_secret_trust`
|
||||
- `bootstrap_identity_trust`
|
||||
- `runtime_secret_trust`
|
||||
- `runtime_identity_trust`
|
||||
- `runtime_authorization_trust`
|
||||
- `tenant_onboarding_trust`
|
||||
|
||||
Each declaration lists states it requires and states it satisfies:
|
||||
|
||||
```yaml
|
||||
trust:
|
||||
requires:
|
||||
- state: bare_host_trust
|
||||
readiness_checks: []
|
||||
satisfies:
|
||||
- state: bootstrap_secret_trust
|
||||
readiness_checks:
|
||||
- id: sops-age-material-present
|
||||
description: SOPS/age material is present for bootstrap secrets.
|
||||
evidence: ansible role sops_agent converged successfully
|
||||
```
|
||||
|
||||
Readiness checks are evidence obligations. The declaration names the
|
||||
check; the Railiance playbook or verification tooling performs it.
|
||||
|
||||
## Catalog And Consumption Model
|
||||
|
||||
A catalog is an index of declarations. For v0.1, the catalog mechanism is
|
||||
file-based:
|
||||
|
||||
1. Railiance repos publish declarations under
|
||||
`capabilities/playbooks/*.yaml`.
|
||||
2. NetKingdom or a future catalog job discovers those files from known
|
||||
orchestrated repos.
|
||||
3. The validator checks each declaration against this contract.
|
||||
4. A scenario states required capability ids and parameter overrides.
|
||||
5. NetKingdom selects declarations that provide the required
|
||||
capabilities.
|
||||
6. NetKingdom applies only allowed parameter overrides, rejecting
|
||||
out-of-range, tenant-forbidden, or security-unsafe overrides.
|
||||
7. NetKingdom composes the responsibility and trust-state claims into a
|
||||
scenario responsibility map and readiness sequence.
|
||||
|
||||
The declaration is not an execution plan. It is the interface that lets a
|
||||
separate playbook runner execute safely.
|
||||
|
||||
## Scenario Shape
|
||||
|
||||
The validator supports a small scenario file for conformance demos:
|
||||
|
||||
```yaml
|
||||
id: scenario:s1-host-bootstrap-reference
|
||||
authority: platform
|
||||
requires:
|
||||
capabilities:
|
||||
- s1.os-baseline
|
||||
parameter_overrides:
|
||||
railiance-infra.bootstrap-host:
|
||||
target_hosts:
|
||||
- railiance01
|
||||
swapfile_size_mb: 8192
|
||||
```
|
||||
|
||||
Allowed scenario authorities are `platform`, `netkingdom`, and `tenant`.
|
||||
Tenant authority cannot override `platform_only`,
|
||||
`security_sensitive`, or `secret_reference` parameters.
|
||||
|
||||
## Conformance
|
||||
|
||||
A declaration conforms when it passes:
|
||||
|
||||
```text
|
||||
tools/playbook-capability-contract/playbook_contract_validator.py
|
||||
```
|
||||
|
||||
The validator checks:
|
||||
|
||||
- top-level API version and kind;
|
||||
- required metadata;
|
||||
- controlled capability ids and tiers;
|
||||
- resource-kind vocabulary;
|
||||
- parameter type/default/constraint/sensitivity/tuning rules;
|
||||
- responsibility claims;
|
||||
- trust-state and readiness-check shape;
|
||||
- catalog publication metadata;
|
||||
- optional scenario selection and parameter override compatibility.
|
||||
|
||||
## Reference Adoption
|
||||
|
||||
The reference declaration for v0.1 is in:
|
||||
|
||||
```text
|
||||
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml
|
||||
```
|
||||
|
||||
It describes the existing Railiance S1 Ansible bootstrap playbook and can
|
||||
be selected by the sample scenario in:
|
||||
|
||||
```text
|
||||
examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml
|
||||
```
|
||||
112
docs/adr/ADR-0012-playbook-capability-contract-ownership.md
Normal file
112
docs/adr/ADR-0012-playbook-capability-contract-ownership.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# ADR-0012 - Playbook Capability Contract Ownership
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-22
|
||||
**Deciders:** Bernd Worsch, Codex
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0007 refined NetKingdom's orchestration role into a
|
||||
meta-orchestration layer. NetKingdom selects the services and playbooks a
|
||||
scenario needs, decides which parameters may be tuned, and holds the
|
||||
responsibility map. Railiance remains the execution-orchestration layer:
|
||||
Railiance playbooks provision and converge the actual infrastructure,
|
||||
cluster, platform services, and application layers.
|
||||
|
||||
That split requires a stable interface. If a Railiance playbook only
|
||||
describes behavior implicitly, NetKingdom cannot safely compose it into a
|
||||
scenario, compare it with another playbook, or know which parameter
|
||||
changes are safe. The IAM Profile provides the precedent: the consumer
|
||||
that needs a stable contract defines the contract, and providers conform
|
||||
to it.
|
||||
|
||||
## Decision
|
||||
|
||||
NetKingdom owns the Playbook Capability Contract schema and vocabulary.
|
||||
Railiance owns playbook implementation and publishes one conformant
|
||||
declaration per playbook.
|
||||
|
||||
The first canonical contract is
|
||||
`canon/standards/playbook-capability-contract_v0.1.md`, backed by the
|
||||
machine-readable schema in
|
||||
`canon/schemas/playbook-capability-declaration_v0.1.schema.json` and the
|
||||
validator in `tools/playbook-capability-contract/`.
|
||||
|
||||
The contract is NetKingdom-owned with Railiance co-design:
|
||||
|
||||
- NetKingdom defines the schema, controlled vocabulary, trust-state
|
||||
language, parameter-sensitivity rules, and conformance criteria.
|
||||
- Railiance authors and maintains declarations beside the playbooks they
|
||||
describe.
|
||||
- Railiance execution stays unchanged. The declaration never becomes the
|
||||
playbook runner.
|
||||
- NetKingdom meta-orchestration consumes declarations to select,
|
||||
parametrize, sequence, and build responsibility maps for scenarios.
|
||||
|
||||
ADR-0007 remains unchanged: execution stays in Railiance.
|
||||
|
||||
## Versioning
|
||||
|
||||
The contract uses explicit document versions:
|
||||
|
||||
- Patch/editorial changes clarify wording or examples without changing
|
||||
declaration semantics.
|
||||
- Minor versions add optional fields, vocabulary entries, or validator
|
||||
warnings that existing declarations can ignore.
|
||||
- Breaking versions change required fields, field meanings, allowed
|
||||
vocabulary, parameter-sensitivity semantics, trust-state semantics, or
|
||||
catalog consumption rules.
|
||||
|
||||
Declarations carry `metadata.contract_version`. A catalog may accept more
|
||||
than one contract version during a migration window, but must report the
|
||||
version used for each selected playbook.
|
||||
|
||||
## Breaking-Change Governance
|
||||
|
||||
A breaking change requires:
|
||||
|
||||
1. an ADR or ADR refinement explaining the change and migration path;
|
||||
2. a new versioned standard and schema;
|
||||
3. an updated validator;
|
||||
4. a coexistence window for the previous supported version where
|
||||
practical;
|
||||
5. notice to known declaration publishers, especially Railiance repos.
|
||||
|
||||
Breaking changes include:
|
||||
|
||||
- removing or renaming required fields;
|
||||
- changing capability ids or resource-kind vocabulary;
|
||||
- changing trust-state meanings;
|
||||
- changing which parameter sensitivities are tenant-tunable;
|
||||
- changing catalog selection or override semantics;
|
||||
- moving execution responsibility out of Railiance into NetKingdom.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Playbook declaration files live beside Railiance playbooks, normally at
|
||||
`capabilities/playbooks/*.yaml`.
|
||||
- NetKingdom can validate declarations before consuming them.
|
||||
- A playbook interface change becomes visible and versioned instead of an
|
||||
implicit break.
|
||||
- The responsibility map can be assembled from declarations, while
|
||||
Railiance keeps execution ownership.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Put The Contract In Railiance
|
||||
|
||||
Railiance owns execution, so this is tempting. But NetKingdom is the
|
||||
consumer that needs stable scenario composition and responsibility-map
|
||||
inputs. Keeping the contract in NetKingdom mirrors the IAM Profile
|
||||
pattern and keeps scenario semantics close to the responsibility map.
|
||||
|
||||
### Make Declarations Free-Form Documentation
|
||||
|
||||
Free-form docs are readable but not safely composable. NetKingdom needs a
|
||||
validator and controlled vocabulary so a playbook change cannot silently
|
||||
break a scenario.
|
||||
|
||||
### Build A Dedicated Execution-Orchestration Repo Now
|
||||
|
||||
ADR-0007 explicitly defers that. The contract is useful now and does not
|
||||
require a new runner or repo boundary.
|
||||
@@ -326,6 +326,13 @@ ADR-0007 records the current decision: keep orchestration in Railiance
|
||||
playbooks for now, with NetKingdom defining the trust-state model,
|
||||
readiness checks, OpenBao boundaries, and security semantics.
|
||||
|
||||
The playbook interface for that split is the NetKingdom Playbook
|
||||
Capability Contract (`canon/standards/playbook-capability-contract_v0.1.md`).
|
||||
Railiance playbooks publish declarations beside the playbooks; NetKingdom
|
||||
validates and consumes those declarations to select capabilities,
|
||||
parametrize allowed inputs, and assemble responsibility/trust-state
|
||||
views without taking over execution.
|
||||
|
||||
## flex-auth And Topaz Implications
|
||||
|
||||
flex-auth work must preserve the recursive boundary between platform
|
||||
|
||||
@@ -40,6 +40,10 @@ NetKingdom's role over orchestrated repos is **meta-orchestration**
|
||||
services/playbooks a scenario needs, (2) **parametrizes** them where
|
||||
tuning is warranted, and (3) holds **responsibility** for the resources
|
||||
and the security boundaries — leaving execution mechanics to the repo.
|
||||
The Playbook Capability Contract
|
||||
(`canon/standards/playbook-capability-contract_v0.1.md`) is the declared
|
||||
interface NetKingdom uses for playbook selection and safe
|
||||
parametrization.
|
||||
|
||||
---
|
||||
|
||||
|
||||
12
examples/playbook-capability-contract/README.md
Normal file
12
examples/playbook-capability-contract/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Playbook Capability Contract Examples
|
||||
|
||||
`scenario-s1-host-bootstrap.yaml` demonstrates NetKingdom selecting and
|
||||
parametrizing a Railiance playbook from its declaration alone.
|
||||
|
||||
Run it against the reference Railiance declaration:
|
||||
|
||||
```bash
|
||||
python3 tools/playbook-capability-contract/playbook_contract_validator.py \
|
||||
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml \
|
||||
--scenario examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
id: scenario:s1-host-bootstrap-reference
|
||||
authority: platform
|
||||
requires:
|
||||
capabilities:
|
||||
- s1.os-baseline
|
||||
parameter_overrides:
|
||||
railiance-infra.bootstrap-host:
|
||||
target_hosts:
|
||||
- railiance01
|
||||
swapfile_size_mb: 8192
|
||||
28
tools/playbook-capability-contract/README.md
Normal file
28
tools/playbook-capability-contract/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Playbook Capability Contract Validator
|
||||
|
||||
Executable checks for
|
||||
`canon/standards/playbook-capability-contract_v0.1.md`.
|
||||
|
||||
Runtime dependency: Python 3.11+ with `PyYAML`. Fixture tests also
|
||||
require `pytest`.
|
||||
|
||||
Validate a declaration:
|
||||
|
||||
```bash
|
||||
python3 tools/playbook-capability-contract/playbook_contract_validator.py \
|
||||
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml
|
||||
```
|
||||
|
||||
Validate and compose a sample scenario:
|
||||
|
||||
```bash
|
||||
python3 tools/playbook-capability-contract/playbook_contract_validator.py \
|
||||
../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml \
|
||||
--scenario examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tools/playbook-capability-contract/tests
|
||||
```
|
||||
@@ -0,0 +1,557 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate NetKingdom Playbook Capability Contract v0.1 declarations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
API_VERSION = "netkingdom.io/playbook-capability/v0.1"
|
||||
KIND = "PlaybookCapabilityDeclaration"
|
||||
CONTRACT_VERSION = "0.1"
|
||||
|
||||
CAPABILITIES = {
|
||||
"s1.os-baseline": "S1",
|
||||
"s1.secret-bootstrap": "S1",
|
||||
"s2.cluster-runtime": "S2",
|
||||
"s3.platform-services": "S3",
|
||||
"c0.bootstrap-identity": "C0",
|
||||
"c1.lightweight-sso": "C1",
|
||||
"c2a.light-2fa": "C2a",
|
||||
"c2b.token-authority": "C2b",
|
||||
"c3.runtime-secrets": "C3",
|
||||
"c4.fine-grained-authorization": "C4",
|
||||
"c5.enterprise-federation": "C5",
|
||||
"c6.self-optimizing-audit": "C6",
|
||||
}
|
||||
RESOURCE_KINDS = {
|
||||
"identities",
|
||||
"roles_scopes_policies",
|
||||
"secrets_credentials",
|
||||
"infrastructure_resources",
|
||||
}
|
||||
PARAM_TYPES = {"string", "integer", "number", "boolean", "array", "object"}
|
||||
SENSITIVITIES = {"public", "operational", "security_sensitive", "secret_reference"}
|
||||
TUNING_AUTHORITIES = {
|
||||
"playbook_default",
|
||||
"netkingdom_tunable",
|
||||
"platform_only",
|
||||
"tenant_tunable",
|
||||
"forbidden",
|
||||
}
|
||||
TRUST_STATES = {
|
||||
"bare_host_trust",
|
||||
"cluster_trust",
|
||||
"bootstrap_secret_trust",
|
||||
"bootstrap_identity_trust",
|
||||
"runtime_secret_trust",
|
||||
"runtime_identity_trust",
|
||||
"runtime_authorization_trust",
|
||||
"tenant_onboarding_trust",
|
||||
}
|
||||
SCENARIO_AUTHORITIES = {"platform", "netkingdom", "tenant"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
level: str
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Declaration:
|
||||
path: Path
|
||||
data: dict[str, Any]
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return str(self.data.get("metadata", {}).get("id", ""))
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
values = self.data.get("spec", {}).get("capabilities", [])
|
||||
if not isinstance(values, list):
|
||||
return set()
|
||||
return {str(item.get("id")) for item in values if isinstance(item, dict)}
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, dict[str, Any]]:
|
||||
values = self.data.get("spec", {}).get("parameters", [])
|
||||
if not isinstance(values, list):
|
||||
return {}
|
||||
return {
|
||||
str(item.get("name")): item
|
||||
for item in values
|
||||
if isinstance(item, dict) and item.get("name")
|
||||
}
|
||||
|
||||
|
||||
def issue(level: str, path: str, message: str) -> Issue:
|
||||
return Issue(level, path, message)
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("document must be a YAML object")
|
||||
return data
|
||||
|
||||
|
||||
def is_type(value: Any, declared_type: str) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
if declared_type == "string":
|
||||
return isinstance(value, str)
|
||||
if declared_type == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if declared_type == "number":
|
||||
return (isinstance(value, int) or isinstance(value, float)) and not isinstance(value, bool)
|
||||
if declared_type == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if declared_type == "array":
|
||||
return isinstance(value, list)
|
||||
if declared_type == "object":
|
||||
return isinstance(value, dict)
|
||||
return False
|
||||
|
||||
|
||||
def validate_constraints(value: Any, param: dict[str, Any], path: str) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
constraints = param.get("constraints", {})
|
||||
if constraints is None:
|
||||
return issues
|
||||
if not isinstance(constraints, dict):
|
||||
return [issue("ERROR", f"{path}.constraints", "constraints must be an object")]
|
||||
|
||||
if "enum" in constraints:
|
||||
enum = constraints["enum"]
|
||||
if not isinstance(enum, list) or not enum:
|
||||
issues.append(issue("ERROR", f"{path}.constraints.enum", "enum must be a non-empty list"))
|
||||
elif value is not None and value not in enum:
|
||||
issues.append(issue("ERROR", path, f"value {value!r} is outside enum {enum!r}"))
|
||||
|
||||
if value is None:
|
||||
return issues
|
||||
|
||||
for key, comparator in (("minimum", lambda got, want: got < want), ("maximum", lambda got, want: got > want)):
|
||||
if key in constraints:
|
||||
if not isinstance(constraints[key], (int, float)):
|
||||
issues.append(issue("ERROR", f"{path}.constraints.{key}", f"{key} must be numeric"))
|
||||
elif isinstance(value, (int, float)) and not isinstance(value, bool) and comparator(value, constraints[key]):
|
||||
issues.append(issue("ERROR", path, f"value {value!r} violates {key}={constraints[key]!r}"))
|
||||
|
||||
for key, comparator in (("min_items", lambda got, want: len(got) < want), ("max_items", lambda got, want: len(got) > want)):
|
||||
if key in constraints:
|
||||
if not isinstance(constraints[key], int):
|
||||
issues.append(issue("ERROR", f"{path}.constraints.{key}", f"{key} must be an integer"))
|
||||
elif isinstance(value, list) and comparator(value, constraints[key]):
|
||||
issues.append(issue("ERROR", path, f"value length violates {key}={constraints[key]!r}"))
|
||||
|
||||
if "pattern" in constraints:
|
||||
pattern = constraints["pattern"]
|
||||
if not isinstance(pattern, str):
|
||||
issues.append(issue("ERROR", f"{path}.constraints.pattern", "pattern must be a string"))
|
||||
elif isinstance(value, str) and re.search(pattern, value) is None:
|
||||
issues.append(issue("ERROR", path, f"value {value!r} does not match pattern {pattern!r}"))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def validate_required_object(parent: dict[str, Any], path: str, required: list[str]) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
for key in required:
|
||||
if key not in parent:
|
||||
issues.append(issue("ERROR", f"{path}.{key}", "required field missing"))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_metadata(data: dict[str, Any]) -> list[Issue]:
|
||||
metadata = data.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return [issue("ERROR", "metadata", "metadata must be an object")]
|
||||
|
||||
issues = validate_required_object(
|
||||
metadata,
|
||||
"metadata",
|
||||
["id", "name", "owner", "repo", "domain", "contract_version"],
|
||||
)
|
||||
for key in ("id", "name", "owner", "repo", "domain"):
|
||||
if key in metadata and not isinstance(metadata[key], str):
|
||||
issues.append(issue("ERROR", f"metadata.{key}", "must be a string"))
|
||||
if metadata.get("contract_version") != CONTRACT_VERSION:
|
||||
issues.append(issue("ERROR", "metadata.contract_version", f"must be {CONTRACT_VERSION!r}"))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_playbook(spec: dict[str, Any]) -> list[Issue]:
|
||||
playbook = spec.get("playbook")
|
||||
if not isinstance(playbook, dict):
|
||||
return [issue("ERROR", "spec.playbook", "playbook must be an object")]
|
||||
|
||||
issues = validate_required_object(playbook, "spec.playbook", ["path", "type", "invocation", "description"])
|
||||
for key in ("path", "type", "invocation", "description"):
|
||||
if key in playbook and not isinstance(playbook[key], str):
|
||||
issues.append(issue("ERROR", f"spec.playbook.{key}", "must be a string"))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_capabilities(spec: dict[str, Any]) -> list[Issue]:
|
||||
values = spec.get("capabilities")
|
||||
if not isinstance(values, list) or not values:
|
||||
return [issue("ERROR", "spec.capabilities", "must be a non-empty list")]
|
||||
|
||||
issues: list[Issue] = []
|
||||
seen: set[str] = set()
|
||||
for idx, item in enumerate(values):
|
||||
path = f"spec.capabilities[{idx}]"
|
||||
if not isinstance(item, dict):
|
||||
issues.append(issue("ERROR", path, "capability must be an object"))
|
||||
continue
|
||||
issues.extend(validate_required_object(item, path, ["id", "tier", "resource_kinds", "description"]))
|
||||
cap_id = item.get("id")
|
||||
tier = item.get("tier")
|
||||
if cap_id in seen:
|
||||
issues.append(issue("ERROR", f"{path}.id", f"duplicate capability id {cap_id!r}"))
|
||||
seen.add(str(cap_id))
|
||||
expected_tier = CAPABILITIES.get(cap_id)
|
||||
if expected_tier is None:
|
||||
issues.append(issue("ERROR", f"{path}.id", f"unknown capability id {cap_id!r}"))
|
||||
elif tier != expected_tier:
|
||||
issues.append(issue("ERROR", f"{path}.tier", f"tier must be {expected_tier!r} for {cap_id!r}"))
|
||||
|
||||
resource_kinds = item.get("resource_kinds")
|
||||
if not isinstance(resource_kinds, list) or not resource_kinds:
|
||||
issues.append(issue("ERROR", f"{path}.resource_kinds", "must be a non-empty list"))
|
||||
else:
|
||||
unknown = sorted(set(str(kind) for kind in resource_kinds) - RESOURCE_KINDS)
|
||||
if unknown:
|
||||
issues.append(issue("ERROR", f"{path}.resource_kinds", f"unknown resource kinds: {unknown}"))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_parameters(spec: dict[str, Any]) -> list[Issue]:
|
||||
values = spec.get("parameters")
|
||||
if not isinstance(values, list):
|
||||
return [issue("ERROR", "spec.parameters", "must be a list")]
|
||||
|
||||
issues: list[Issue] = []
|
||||
seen: set[str] = set()
|
||||
for idx, item in enumerate(values):
|
||||
path = f"spec.parameters[{idx}]"
|
||||
if not isinstance(item, dict):
|
||||
issues.append(issue("ERROR", path, "parameter must be an object"))
|
||||
continue
|
||||
issues.extend(
|
||||
validate_required_object(
|
||||
item,
|
||||
path,
|
||||
["name", "type", "required", "sensitivity", "tuning_authority", "description"],
|
||||
)
|
||||
)
|
||||
name = item.get("name")
|
||||
declared_type = item.get("type")
|
||||
if name in seen:
|
||||
issues.append(issue("ERROR", f"{path}.name", f"duplicate parameter {name!r}"))
|
||||
seen.add(str(name))
|
||||
|
||||
if declared_type not in PARAM_TYPES:
|
||||
issues.append(issue("ERROR", f"{path}.type", f"unknown parameter type {declared_type!r}"))
|
||||
if "required" in item and not isinstance(item["required"], bool):
|
||||
issues.append(issue("ERROR", f"{path}.required", "required must be boolean"))
|
||||
if item.get("sensitivity") not in SENSITIVITIES:
|
||||
issues.append(issue("ERROR", f"{path}.sensitivity", f"unknown sensitivity {item.get('sensitivity')!r}"))
|
||||
if item.get("tuning_authority") not in TUNING_AUTHORITIES:
|
||||
issues.append(issue("ERROR", f"{path}.tuning_authority", f"unknown tuning authority {item.get('tuning_authority')!r}"))
|
||||
if item.get("sensitivity") in {"security_sensitive", "secret_reference"} and item.get("tuning_authority") == "tenant_tunable":
|
||||
issues.append(issue("ERROR", path, "security-sensitive parameters cannot be tenant_tunable"))
|
||||
|
||||
if "default" in item and declared_type in PARAM_TYPES and not is_type(item["default"], declared_type):
|
||||
issues.append(issue("ERROR", f"{path}.default", f"default does not match type {declared_type!r}"))
|
||||
if declared_type in PARAM_TYPES:
|
||||
issues.extend(validate_constraints(item.get("default"), item, path))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_responsibilities(spec: dict[str, Any]) -> list[Issue]:
|
||||
values = spec.get("responsibilities")
|
||||
if not isinstance(values, list) or not values:
|
||||
return [issue("ERROR", "spec.responsibilities", "must be a non-empty list")]
|
||||
|
||||
issues: list[Issue] = []
|
||||
for idx, item in enumerate(values):
|
||||
path = f"spec.responsibilities[{idx}]"
|
||||
if not isinstance(item, dict):
|
||||
issues.append(issue("ERROR", path, "responsibility must be an object"))
|
||||
continue
|
||||
issues.extend(
|
||||
validate_required_object(
|
||||
item,
|
||||
path,
|
||||
["resource_kind", "owner", "resources", "repo_owns", "netkingdom_orchestrates"],
|
||||
)
|
||||
)
|
||||
if item.get("resource_kind") not in RESOURCE_KINDS:
|
||||
issues.append(issue("ERROR", f"{path}.resource_kind", f"unknown resource kind {item.get('resource_kind')!r}"))
|
||||
resources = item.get("resources")
|
||||
if not isinstance(resources, list) or not resources:
|
||||
issues.append(issue("ERROR", f"{path}.resources", "resources must be a non-empty list"))
|
||||
for key in ("owner", "repo_owns", "netkingdom_orchestrates"):
|
||||
if key in item and not isinstance(item[key], str):
|
||||
issues.append(issue("ERROR", f"{path}.{key}", "must be a string"))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_trust_states(spec: dict[str, Any]) -> list[Issue]:
|
||||
trust = spec.get("trust")
|
||||
if not isinstance(trust, dict):
|
||||
return [issue("ERROR", "spec.trust", "trust must be an object")]
|
||||
|
||||
issues: list[Issue] = []
|
||||
for section in ("requires", "satisfies"):
|
||||
values = trust.get(section, [])
|
||||
path = f"spec.trust.{section}"
|
||||
if not isinstance(values, list):
|
||||
issues.append(issue("ERROR", path, "must be a list"))
|
||||
continue
|
||||
for idx, item in enumerate(values):
|
||||
item_path = f"{path}[{idx}]"
|
||||
if not isinstance(item, dict):
|
||||
issues.append(issue("ERROR", item_path, "trust state entry must be an object"))
|
||||
continue
|
||||
if item.get("state") not in TRUST_STATES:
|
||||
issues.append(issue("ERROR", f"{item_path}.state", f"unknown trust state {item.get('state')!r}"))
|
||||
checks = item.get("readiness_checks", [])
|
||||
if not isinstance(checks, list):
|
||||
issues.append(issue("ERROR", f"{item_path}.readiness_checks", "must be a list"))
|
||||
continue
|
||||
if section == "satisfies" and not checks:
|
||||
issues.append(issue("ERROR", f"{item_path}.readiness_checks", "satisfied trust states require readiness checks"))
|
||||
for cidx, check in enumerate(checks):
|
||||
check_path = f"{item_path}.readiness_checks[{cidx}]"
|
||||
if not isinstance(check, dict):
|
||||
issues.append(issue("ERROR", check_path, "readiness check must be an object"))
|
||||
continue
|
||||
issues.extend(validate_required_object(check, check_path, ["id", "description", "evidence"]))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_catalog(spec: dict[str, Any]) -> list[Issue]:
|
||||
catalog = spec.get("catalog")
|
||||
if not isinstance(catalog, dict):
|
||||
return [issue("ERROR", "spec.catalog", "catalog must be an object")]
|
||||
|
||||
issues = validate_required_object(catalog, "spec.catalog", ["publish", "maturity", "consumers"])
|
||||
if "consumers" in catalog and not isinstance(catalog["consumers"], list):
|
||||
issues.append(issue("ERROR", "spec.catalog.consumers", "consumers must be a list"))
|
||||
return issues
|
||||
|
||||
|
||||
def validate_declaration(declaration: Declaration) -> list[Issue]:
|
||||
data = declaration.data
|
||||
issues: list[Issue] = []
|
||||
issues.extend(validate_required_object(data, "$", ["apiVersion", "kind", "metadata", "spec"]))
|
||||
if data.get("apiVersion") != API_VERSION:
|
||||
issues.append(issue("ERROR", "apiVersion", f"must be {API_VERSION!r}"))
|
||||
if data.get("kind") != KIND:
|
||||
issues.append(issue("ERROR", "kind", f"must be {KIND!r}"))
|
||||
issues.extend(validate_metadata(data))
|
||||
|
||||
spec = data.get("spec")
|
||||
if not isinstance(spec, dict):
|
||||
return issues + [issue("ERROR", "spec", "spec must be an object")]
|
||||
|
||||
issues.extend(validate_required_object(spec, "spec", ["playbook", "capabilities", "parameters", "responsibilities", "trust", "catalog"]))
|
||||
issues.extend(validate_playbook(spec))
|
||||
issues.extend(validate_capabilities(spec))
|
||||
issues.extend(validate_parameters(spec))
|
||||
issues.extend(validate_responsibilities(spec))
|
||||
issues.extend(validate_trust_states(spec))
|
||||
issues.extend(validate_catalog(spec))
|
||||
return issues
|
||||
|
||||
|
||||
def effective_parameter_value(param: dict[str, Any], overrides: dict[str, Any], declaration_id: str) -> tuple[bool, Any]:
|
||||
name = str(param.get("name"))
|
||||
if name in overrides:
|
||||
return True, overrides[name]
|
||||
if "default" in param:
|
||||
return False, param["default"]
|
||||
return False, None
|
||||
|
||||
|
||||
def validate_override_allowed(param: dict[str, Any], value: Any, scenario_authority: str, path: str) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
authority = param.get("tuning_authority")
|
||||
sensitivity = param.get("sensitivity")
|
||||
name = param.get("name")
|
||||
declared_type = str(param.get("type"))
|
||||
|
||||
if authority in {"forbidden", "playbook_default"}:
|
||||
issues.append(issue("ERROR", path, f"parameter {name!r} cannot be overridden"))
|
||||
if scenario_authority == "tenant" and authority in {"platform_only", "forbidden", "playbook_default"}:
|
||||
issues.append(issue("ERROR", path, f"tenant authority cannot override {authority} parameter {name!r}"))
|
||||
if scenario_authority == "tenant" and sensitivity in {"security_sensitive", "secret_reference"}:
|
||||
issues.append(issue("ERROR", path, f"tenant authority cannot override {sensitivity} parameter {name!r}"))
|
||||
|
||||
if not is_type(value, declared_type):
|
||||
issues.append(issue("ERROR", path, f"override for {name!r} does not match type {declared_type!r}"))
|
||||
issues.extend(validate_constraints(value, param, path))
|
||||
return issues
|
||||
|
||||
|
||||
def compose_scenario(declarations: list[Declaration], scenario: dict[str, Any]) -> tuple[list[Issue], dict[str, Any]]:
|
||||
issues: list[Issue] = []
|
||||
authority = scenario.get("authority", "platform")
|
||||
if authority not in SCENARIO_AUTHORITIES:
|
||||
issues.append(issue("ERROR", "scenario.authority", f"unknown authority {authority!r}"))
|
||||
authority = "platform"
|
||||
|
||||
requires = scenario.get("requires", {})
|
||||
required_caps = requires.get("capabilities", []) if isinstance(requires, dict) else []
|
||||
if not isinstance(required_caps, list) or not required_caps:
|
||||
issues.append(issue("ERROR", "scenario.requires.capabilities", "scenario must require at least one capability"))
|
||||
required_caps = []
|
||||
|
||||
overrides = scenario.get("parameter_overrides", {})
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
if not isinstance(overrides, dict):
|
||||
issues.append(issue("ERROR", "scenario.parameter_overrides", "must be an object"))
|
||||
overrides = {}
|
||||
|
||||
selected: list[Declaration] = []
|
||||
for cap_id in required_caps:
|
||||
matches = [declaration for declaration in declarations if cap_id in declaration.capabilities]
|
||||
if not matches:
|
||||
issues.append(issue("ERROR", "scenario.requires.capabilities", f"no declaration provides {cap_id!r}"))
|
||||
continue
|
||||
selected.append(matches[0])
|
||||
|
||||
# Preserve order while deduplicating declarations selected for several capabilities.
|
||||
selected_by_id: dict[str, Declaration] = {}
|
||||
for declaration in selected:
|
||||
selected_by_id.setdefault(declaration.id, declaration)
|
||||
|
||||
composed = {
|
||||
"scenario": scenario.get("id", "scenario:unnamed"),
|
||||
"authority": authority,
|
||||
"selected_declarations": [],
|
||||
}
|
||||
|
||||
for declaration_id, declaration in selected_by_id.items():
|
||||
declaration_overrides = overrides.get(declaration_id, {})
|
||||
if not isinstance(declaration_overrides, dict):
|
||||
issues.append(issue("ERROR", f"scenario.parameter_overrides.{declaration_id}", "must be an object"))
|
||||
declaration_overrides = {}
|
||||
|
||||
params_out: dict[str, Any] = {}
|
||||
for name in declaration_overrides:
|
||||
if name not in declaration.parameters:
|
||||
issues.append(issue("ERROR", f"scenario.parameter_overrides.{declaration_id}.{name}", "unknown parameter override"))
|
||||
|
||||
for param in declaration.parameters.values():
|
||||
name = str(param.get("name"))
|
||||
overridden, value = effective_parameter_value(param, declaration_overrides, declaration_id)
|
||||
if param.get("required") is True and value is None:
|
||||
issues.append(issue("ERROR", f"scenario.parameter_overrides.{declaration_id}.{name}", "required parameter has no default or override"))
|
||||
if overridden:
|
||||
issues.extend(validate_override_allowed(param, value, str(authority), f"scenario.parameter_overrides.{declaration_id}.{name}"))
|
||||
params_out[name] = {
|
||||
"value": value,
|
||||
"source": "override" if overridden else "default",
|
||||
"sensitivity": param.get("sensitivity"),
|
||||
"tuning_authority": param.get("tuning_authority"),
|
||||
}
|
||||
|
||||
composed["selected_declarations"].append(
|
||||
{
|
||||
"id": declaration_id,
|
||||
"path": str(declaration.path),
|
||||
"capabilities": sorted(declaration.capabilities),
|
||||
"parameters": params_out,
|
||||
}
|
||||
)
|
||||
|
||||
return issues, composed
|
||||
|
||||
|
||||
def print_report(issues: list[Issue], composed: dict[str, Any] | None, json_output: bool) -> None:
|
||||
if json_output:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"issues": [item.__dict__ for item in issues],
|
||||
"composition": composed,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if not issues:
|
||||
print("PASS playbook capability contract conformance")
|
||||
for item in issues:
|
||||
print(f"{item.level:5} {item.path}: {item.message}")
|
||||
if composed is not None:
|
||||
print("")
|
||||
print("Composition")
|
||||
print(json.dumps(composed, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def load_declarations(paths: list[str]) -> tuple[list[Declaration], list[Issue]]:
|
||||
declarations: list[Declaration] = []
|
||||
issues: list[Issue] = []
|
||||
for raw_path in paths:
|
||||
path = Path(raw_path)
|
||||
try:
|
||||
data = load_yaml(path)
|
||||
except Exception as exc:
|
||||
issues.append(issue("ERROR", str(path), f"failed to load declaration: {exc}"))
|
||||
continue
|
||||
declaration = Declaration(path=path, data=data)
|
||||
issues.extend(validate_declaration(declaration))
|
||||
declarations.append(declaration)
|
||||
return declarations, issues
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Validate NetKingdom Playbook Capability Contract declarations.")
|
||||
parser.add_argument("declarations", nargs="+", help="Declaration YAML files")
|
||||
parser.add_argument("--scenario", help="Optional scenario YAML to compose from declarations")
|
||||
parser.add_argument("--json", action="store_true", help="Print JSON report")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
declarations, issues = load_declarations(args.declarations)
|
||||
composed = None
|
||||
|
||||
if args.scenario:
|
||||
try:
|
||||
scenario = load_yaml(Path(args.scenario))
|
||||
except Exception as exc:
|
||||
issues.append(issue("ERROR", args.scenario, f"failed to load scenario: {exc}"))
|
||||
scenario = {}
|
||||
scenario_issues, composed = compose_scenario(declarations, scenario)
|
||||
issues.extend(scenario_issues)
|
||||
|
||||
print_report(issues, composed, args.json)
|
||||
return 1 if any(item.level == "ERROR" for item in issues) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,198 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
TOOL_PATH = Path(__file__).resolve().parents[1] / "playbook_contract_validator.py"
|
||||
SPEC = importlib.util.spec_from_file_location("playbook_contract_validator", TOOL_PATH)
|
||||
validator = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = validator
|
||||
SPEC.loader.exec_module(validator)
|
||||
|
||||
|
||||
def valid_declaration() -> dict:
|
||||
return {
|
||||
"apiVersion": validator.API_VERSION,
|
||||
"kind": validator.KIND,
|
||||
"metadata": {
|
||||
"id": "railiance-infra.bootstrap-host",
|
||||
"name": "Railiance S1 host bootstrap",
|
||||
"owner": "railiance-infra",
|
||||
"repo": "railiance-infra",
|
||||
"domain": "railiance",
|
||||
"contract_version": "0.1",
|
||||
},
|
||||
"spec": {
|
||||
"playbook": {
|
||||
"path": "ansible/playbooks/bootstrap.yaml",
|
||||
"type": "ansible",
|
||||
"invocation": "make converge",
|
||||
"description": "Converges the S1 host baseline.",
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "s1.os-baseline",
|
||||
"tier": "S1",
|
||||
"resource_kinds": ["infrastructure_resources", "secrets_credentials"],
|
||||
"description": "OS baseline and bootstrap secret handling.",
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "target_hosts",
|
||||
"type": "array",
|
||||
"required": True,
|
||||
"constraints": {"min_items": 1},
|
||||
"sensitivity": "operational",
|
||||
"tuning_authority": "netkingdom_tunable",
|
||||
"description": "Inventory hosts to converge.",
|
||||
},
|
||||
{
|
||||
"name": "swapfile_size_mb",
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"default": 4096,
|
||||
"constraints": {"minimum": 0, "maximum": 65536},
|
||||
"sensitivity": "operational",
|
||||
"tuning_authority": "netkingdom_tunable",
|
||||
"description": "Swap file size.",
|
||||
},
|
||||
{
|
||||
"name": "wireguard_enabled",
|
||||
"type": "boolean",
|
||||
"required": False,
|
||||
"default": False,
|
||||
"sensitivity": "security_sensitive",
|
||||
"tuning_authority": "platform_only",
|
||||
"description": "Enable WireGuard role.",
|
||||
},
|
||||
],
|
||||
"responsibilities": [
|
||||
{
|
||||
"resource_kind": "infrastructure_resources",
|
||||
"owner": "railiance-infra",
|
||||
"resources": ["server:target_hosts", "os-baseline"],
|
||||
"repo_owns": "Ansible convergence mechanics.",
|
||||
"netkingdom_orchestrates": "Whether S1 is selected for the scenario.",
|
||||
}
|
||||
],
|
||||
"trust": {
|
||||
"requires": [],
|
||||
"satisfies": [
|
||||
{
|
||||
"state": "bare_host_trust",
|
||||
"readiness_checks": [
|
||||
{
|
||||
"id": "os-baseline-converged",
|
||||
"description": "Ansible baseline converged.",
|
||||
"evidence": "bootstrap playbook completed",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"catalog": {
|
||||
"publish": "capabilities/playbooks/railiance-infra.bootstrap-host.yaml",
|
||||
"maturity": "reference",
|
||||
"consumers": ["netkingdom-meta-orchestration"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def declaration_from(data: dict, tmp_path: Path) -> validator.Declaration:
|
||||
path = tmp_path / "declaration.yaml"
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
||||
return validator.Declaration(path=path, data=data)
|
||||
|
||||
|
||||
def error_messages(issues):
|
||||
return [item.message for item in issues if item.level == "ERROR"]
|
||||
|
||||
|
||||
def test_valid_declaration_passes(tmp_path):
|
||||
declaration = declaration_from(valid_declaration(), tmp_path)
|
||||
|
||||
issues = validator.validate_declaration(declaration)
|
||||
|
||||
assert error_messages(issues) == []
|
||||
|
||||
|
||||
def test_unknown_capability_fails(tmp_path):
|
||||
data = valid_declaration()
|
||||
data["spec"]["capabilities"][0]["id"] = "s9.magic"
|
||||
declaration = declaration_from(data, tmp_path)
|
||||
|
||||
issues = validator.validate_declaration(declaration)
|
||||
|
||||
assert any("unknown capability id" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_tenant_tunable_secret_reference_fails(tmp_path):
|
||||
data = valid_declaration()
|
||||
data["spec"]["parameters"][2]["sensitivity"] = "secret_reference"
|
||||
data["spec"]["parameters"][2]["tuning_authority"] = "tenant_tunable"
|
||||
declaration = declaration_from(data, tmp_path)
|
||||
|
||||
issues = validator.validate_declaration(declaration)
|
||||
|
||||
assert any("security-sensitive parameters cannot be tenant_tunable" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_scenario_composition_selects_and_overrides(tmp_path):
|
||||
declaration = declaration_from(valid_declaration(), tmp_path)
|
||||
scenario = {
|
||||
"id": "scenario:s1-host-bootstrap-reference",
|
||||
"authority": "platform",
|
||||
"requires": {"capabilities": ["s1.os-baseline"]},
|
||||
"parameter_overrides": {
|
||||
"railiance-infra.bootstrap-host": {
|
||||
"target_hosts": ["railiance01"],
|
||||
"swapfile_size_mb": 8192,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
issues, composed = validator.compose_scenario([declaration], scenario)
|
||||
|
||||
assert error_messages(issues) == []
|
||||
selected = composed["selected_declarations"][0]
|
||||
assert selected["id"] == "railiance-infra.bootstrap-host"
|
||||
assert selected["parameters"]["target_hosts"]["source"] == "override"
|
||||
assert selected["parameters"]["swapfile_size_mb"]["value"] == 8192
|
||||
|
||||
|
||||
def test_tenant_authority_cannot_override_platform_only(tmp_path):
|
||||
declaration = declaration_from(valid_declaration(), tmp_path)
|
||||
scenario = {
|
||||
"id": "scenario:bad-tenant-override",
|
||||
"authority": "tenant",
|
||||
"requires": {"capabilities": ["s1.os-baseline"]},
|
||||
"parameter_overrides": {
|
||||
"railiance-infra.bootstrap-host": {
|
||||
"target_hosts": ["tenant-host"],
|
||||
"wireguard_enabled": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
issues, _ = validator.compose_scenario([declaration], scenario)
|
||||
|
||||
assert any("tenant authority cannot override platform_only" in msg for msg in error_messages(issues))
|
||||
|
||||
|
||||
def test_required_parameter_without_override_fails(tmp_path):
|
||||
declaration = declaration_from(valid_declaration(), tmp_path)
|
||||
scenario = {
|
||||
"id": "scenario:missing-required-parameter",
|
||||
"authority": "platform",
|
||||
"requires": {"capabilities": ["s1.os-baseline"]},
|
||||
"parameter_overrides": {},
|
||||
}
|
||||
|
||||
issues, _ = validator.compose_scenario([declaration], scenario)
|
||||
|
||||
assert any("required parameter has no default or override" in msg for msg in error_messages(issues))
|
||||
@@ -4,13 +4,13 @@ type: workplan
|
||||
title: "Playbook Capability Contract"
|
||||
domain: netkingdom
|
||||
repo: net-kingdom
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: worsch
|
||||
topic_slug: netkingdom
|
||||
planning_priority: high
|
||||
planning_order: 13
|
||||
created: "2026-05-21"
|
||||
updated: "2026-05-21"
|
||||
updated: "2026-05-22"
|
||||
depends_on:
|
||||
- NK-WP-0006
|
||||
state_hub_workstream_id: 32a54d8e-8633-42a6-8ec1-104842c581c1
|
||||
@@ -82,7 +82,7 @@ Out of scope:
|
||||
```task
|
||||
id: NK-WP-0013-T1
|
||||
state_hub_task_id: d40f8b29-e983-4d52-bc1f-5f1c51709e7d
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
@@ -96,7 +96,7 @@ co-design. Define contract **versioning and breaking-change governance**.
|
||||
```task
|
||||
id: NK-WP-0013-T2
|
||||
state_hub_task_id: ece4b5b1-e1c2-449d-b0f4-83b7010bc838
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
@@ -110,7 +110,7 @@ same capability are comparable.
|
||||
```task
|
||||
id: NK-WP-0013-T3
|
||||
state_hub_task_id: c956f4a8-b9fa-44ab-8174-31999b98e3b1
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
@@ -124,7 +124,7 @@ adequate" safe rather than guesswork.
|
||||
```task
|
||||
id: NK-WP-0013-T4
|
||||
state_hub_task_id: e7de05a6-528a-4213-b6db-2c2e90353996
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
@@ -137,7 +137,7 @@ responsibility map and sequence for a scenario from the declarations.
|
||||
```task
|
||||
id: NK-WP-0013-T5
|
||||
state_hub_task_id: 05a2ff7d-86c4-4de9-9ea8-39a9ad5352a8
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
@@ -151,7 +151,7 @@ the IAM Profile conformance check, NK-WP-0012-T5).
|
||||
```task
|
||||
id: NK-WP-0013-T6
|
||||
state_hub_task_id: 769ed490-c091-41c1-b2e2-e8e378470b6b
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
@@ -172,6 +172,23 @@ the rest. Cross-repo coordination item for the Railiance domain.
|
||||
NetKingdom composes and parametrizes it from that declaration alone.
|
||||
- Contract versioning and breaking-change governance is documented.
|
||||
|
||||
## Completion Notes
|
||||
|
||||
- ADR: `docs/adr/ADR-0012-playbook-capability-contract-ownership.md`
|
||||
- Canonical contract:
|
||||
`canon/standards/playbook-capability-contract_v0.1.md`
|
||||
- Machine-readable schema:
|
||||
`canon/schemas/playbook-capability-declaration_v0.1.schema.json`
|
||||
- Validator and composition demo:
|
||||
`tools/playbook-capability-contract/playbook_contract_validator.py`
|
||||
- Reference Railiance declaration:
|
||||
`../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml`
|
||||
- Sample scenario:
|
||||
`examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml`
|
||||
- Fixture tests cover valid declarations, controlled vocabulary failures,
|
||||
forbidden tenant overrides, missing required parameters, and successful
|
||||
selection/parameter composition.
|
||||
|
||||
## Dependencies & Sequencing
|
||||
|
||||
- **Realizes** the playbook-contract dependency from ADR-0007's
|
||||
@@ -184,14 +201,14 @@ the rest. Cross-repo coordination item for the Railiance domain.
|
||||
- Parallels NK-WP-0012: same consumer-defines-contract pattern, same
|
||||
conformance-check shape, applied to orchestration instead of identity.
|
||||
|
||||
## Open Questions
|
||||
## Resolved Questions
|
||||
|
||||
- Contract format and home: a net-kingdom canon standard plus a
|
||||
machine-readable schema (e.g. JSON/YAML schema) the catalog validates
|
||||
against?
|
||||
- Catalog mechanism: a file convention in each playbook repo that NetKingdom
|
||||
aggregates, or a published registry?
|
||||
- How parameter sensitivity interacts with tenant boundaries (which
|
||||
parameters a tenant-scoped scenario may set vs. platform-only).
|
||||
- Whether the conformance validator is a standalone net-kingdom tool or a
|
||||
shared library, mirroring the same open question in NK-WP-0012.
|
||||
- Contract format and home: NetKingdom canon standard plus a
|
||||
machine-readable JSON schema and standalone validator.
|
||||
- Catalog mechanism: v0.1 uses file convention
|
||||
`capabilities/playbooks/*.yaml`; a registry can be layered on later.
|
||||
- Parameter sensitivity: tenant scenarios cannot override
|
||||
`platform_only`, `forbidden`, `playbook_default`,
|
||||
`security_sensitive`, or `secret_reference` parameters.
|
||||
- Validator form: standalone NetKingdom tool for v0.1, mirroring
|
||||
NK-WP-0012's executable-contract pattern.
|
||||
|
||||
Reference in New Issue
Block a user