feat(deploy): ship the canon classification vocabulary with the release
The API validates repo classification against the-custodian canon allowed values. A container has no such checkout, so every classification write failed with a 500 and classification could only ever be written from a workstation. Mounts the vocabulary as a ConfigMap and points REPO_CLASSIFICATION_ALLOWED_PATH at it. The copy is the risk, so it is owned rather than trusted: scripts/sync_classification_allowed.py regenerates it from canon and --check fails on drift. make check-classification-allowed and make railiance-state-hub-render both refuse to proceed when the copy diverges, so a release cannot silently validate against a stale vocabulary. The container volumeMounts and env blocks are merged rather than appended — a second pair would have produced duplicate YAML keys as soon as sweep was re-enabled. Refs CUST-WP-0067-T09 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
parent
05dda8d276
commit
ac21accd7a
6 changed files with 270 additions and 2 deletions
12
Makefile
12
Makefile
|
|
@ -71,7 +71,17 @@ dashboard:
|
||||||
check:
|
check:
|
||||||
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
||||||
|
|
||||||
railiance-state-hub-render:
|
# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values
|
||||||
|
# because a container has no checkout to read it from. Regenerate after any
|
||||||
|
# canon vocabulary change; the -check target fails on drift so a release cannot
|
||||||
|
# validate classification against a stale vocabulary.
|
||||||
|
sync-classification-allowed:
|
||||||
|
python3 scripts/sync_classification_allowed.py
|
||||||
|
|
||||||
|
check-classification-allowed:
|
||||||
|
python3 scripts/sync_classification_allowed.py --check
|
||||||
|
|
||||||
|
railiance-state-hub-render: check-classification-allowed
|
||||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
# GENERATED — do not edit.
|
||||||
|
# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml
|
||||||
|
# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09).
|
||||||
|
# Canon is authoritative; this copy exists only so the container has one.
|
||||||
|
# Machine-readable allowed-values for the Repo Classification Standard.
|
||||||
|
#
|
||||||
|
# Single source of truth for the standard's controlled vocabularies, derived
|
||||||
|
# from canon/standards/repo-classification-standard_v1.0.md. Consumed by:
|
||||||
|
# - the per-repo .repo-classification.yaml linter (tools/validate_repo_classification.py)
|
||||||
|
# - the State Hub registration validator (CUST-WP-0050 T04)
|
||||||
|
#
|
||||||
|
# When the standard's vocabularies change, update this file and bump `version`
|
||||||
|
# to match the standard version. CUST-WP-0050 T01.
|
||||||
|
|
||||||
|
standard: "Repo Classification Standard"
|
||||||
|
version: "1.0"
|
||||||
|
canon_id: "canon-repo-classification"
|
||||||
|
|
||||||
|
# category — exactly 1 required (§5)
|
||||||
|
categories:
|
||||||
|
- experimental
|
||||||
|
- research
|
||||||
|
- project
|
||||||
|
- tooling
|
||||||
|
- product
|
||||||
|
- business
|
||||||
|
|
||||||
|
# domain / secondary_domains — primary exactly 1; secondaries 0..n (§6)
|
||||||
|
domains:
|
||||||
|
- infotech
|
||||||
|
- financials
|
||||||
|
- communication
|
||||||
|
- consumer
|
||||||
|
- health
|
||||||
|
- industrials
|
||||||
|
- energy
|
||||||
|
- utilities
|
||||||
|
- materials
|
||||||
|
- realestate
|
||||||
|
- crypto
|
||||||
|
- agents
|
||||||
|
- space
|
||||||
|
- government
|
||||||
|
|
||||||
|
# business_stake — 0..n; 2..6 recommended (§8)
|
||||||
|
business_stake:
|
||||||
|
- execution
|
||||||
|
- intelligence
|
||||||
|
- finance
|
||||||
|
- legal
|
||||||
|
- sales
|
||||||
|
- experience
|
||||||
|
- technology
|
||||||
|
- operations
|
||||||
|
- product
|
||||||
|
- people
|
||||||
|
- procurement
|
||||||
|
- sustainability
|
||||||
|
- automation
|
||||||
|
|
||||||
|
# business_mechanics — 0..n, optional (§9)
|
||||||
|
business_mechanics:
|
||||||
|
- intention
|
||||||
|
- control
|
||||||
|
- coordination
|
||||||
|
- operation
|
||||||
|
- adaptation
|
||||||
|
|
||||||
|
# capability_tags are intentionally OPEN-ENDED (§7): lowercase kebab-case, not
|
||||||
|
# restricted to this set. The families below are the standard's recommended
|
||||||
|
# canonical tags — used to warn on likely synonyms/typos, never to reject.
|
||||||
|
capability_families:
|
||||||
|
identity_and_access:
|
||||||
|
- identity
|
||||||
|
- authentication
|
||||||
|
- authorization
|
||||||
|
- access-control
|
||||||
|
- user-management
|
||||||
|
- tenancy
|
||||||
|
knowledge_and_evidence:
|
||||||
|
- knowledge
|
||||||
|
- citations
|
||||||
|
- evidence
|
||||||
|
- source-management
|
||||||
|
- traceability
|
||||||
|
- documentation
|
||||||
|
- decision-support
|
||||||
|
platform_and_operations:
|
||||||
|
- platform
|
||||||
|
- deployment
|
||||||
|
- operations
|
||||||
|
- observability
|
||||||
|
- feature-control
|
||||||
|
- configuration
|
||||||
|
- orchestration
|
||||||
|
market_and_coordination:
|
||||||
|
- marketplace
|
||||||
|
- pricing
|
||||||
|
- monetization
|
||||||
|
- reputation
|
||||||
|
- challenges
|
||||||
|
- bounties
|
||||||
|
- collaboration
|
||||||
|
- coordination
|
||||||
|
product_and_lifecycle:
|
||||||
|
- product-development
|
||||||
|
- lifecycle
|
||||||
|
governance_and_control:
|
||||||
|
- governance
|
||||||
|
- policy
|
||||||
|
- compliance
|
||||||
|
- risk
|
||||||
|
- audit
|
||||||
|
- control
|
||||||
|
games_and_entertainment:
|
||||||
|
- game
|
||||||
|
- entertainment
|
||||||
|
|
||||||
|
# Validation guidance (advisory bounds the linter applies as warnings)
|
||||||
|
guidance:
|
||||||
|
secondary_domains_max: 3
|
||||||
|
business_stake_recommended_min: 2
|
||||||
|
business_stake_recommended_max: 6
|
||||||
|
capability_tag_pattern: "^[a-z0-9]+(-[a-z0-9]+)*$"
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{{- if .Values.classificationAllowed.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ .Values.classificationAllowed.name | quote }}
|
||||||
|
labels: {{- include "statehub.labels" . | nindent 4 }}
|
||||||
|
data:
|
||||||
|
# Generated copy of the-custodian canon allowed-values. Kept in sync by
|
||||||
|
# scripts/sync_classification_allowed.py; `--check` fails the release on drift.
|
||||||
|
repo-classification.allowed.yaml: |
|
||||||
|
{{ .Files.Get "files/repo-classification.allowed.yaml" | indent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -22,8 +22,9 @@ spec:
|
||||||
{{- with .Values.imagePullSecrets }}
|
{{- with .Values.imagePullSecrets }}
|
||||||
imagePullSecrets: {{- toYaml . | nindent 8 }}
|
imagePullSecrets: {{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.sweep.enabled }}
|
{{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled }}
|
||||||
volumes:
|
volumes:
|
||||||
|
{{- if .Values.sweep.enabled }}
|
||||||
- name: sweep-repos
|
- name: sweep-repos
|
||||||
hostPath:
|
hostPath:
|
||||||
path: {{ .Values.sweep.hostPath | quote }}
|
path: {{ .Values.sweep.hostPath | quote }}
|
||||||
|
|
@ -32,6 +33,12 @@ spec:
|
||||||
hostPath:
|
hostPath:
|
||||||
path: {{ .Values.sweep.sshHostPath | quote }}
|
path: {{ .Values.sweep.sshHostPath | quote }}
|
||||||
type: DirectoryOrCreate
|
type: DirectoryOrCreate
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.classificationAllowed.enabled }}
|
||||||
|
- name: classification-allowed
|
||||||
|
configMap:
|
||||||
|
name: {{ .Values.classificationAllowed.name | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: state-hub
|
- name: state-hub
|
||||||
|
|
@ -50,17 +57,32 @@ spec:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
- git config --global --add safe.directory '*'
|
- git config --global --add safe.directory '*'
|
||||||
|
{{- end }}
|
||||||
|
{{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
|
{{- if .Values.sweep.enabled }}
|
||||||
- name: sweep-repos
|
- name: sweep-repos
|
||||||
mountPath: {{ .Values.sweep.hostPath | quote }}
|
mountPath: {{ .Values.sweep.hostPath | quote }}
|
||||||
- name: sweep-ssh
|
- name: sweep-ssh
|
||||||
mountPath: /root/.ssh
|
mountPath: /root/.ssh
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.classificationAllowed.enabled }}
|
||||||
|
- name: classification-allowed
|
||||||
|
mountPath: {{ .Values.classificationAllowed.mountPath | quote }}
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
env:
|
env:
|
||||||
|
{{- if .Values.sweep.enabled }}
|
||||||
- name: STATE_HUB_SWEEP_HOSTNAME
|
- name: STATE_HUB_SWEEP_HOSTNAME
|
||||||
value: {{ .Values.sweep.hostname | quote }}
|
value: {{ .Values.sweep.hostname | quote }}
|
||||||
- name: GIT_SSH_COMMAND
|
- name: GIT_SSH_COMMAND
|
||||||
value: "ssh -o StrictHostKeyChecking=accept-new -F /root/.ssh/config"
|
value: "ssh -o StrictHostKeyChecking=accept-new -F /root/.ssh/config"
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.classificationAllowed.enabled }}
|
||||||
|
- name: REPO_CLASSIFICATION_ALLOWED_PATH
|
||||||
|
value: {{ printf "%s/repo-classification.allowed.yaml" .Values.classificationAllowed.mountPath | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
envFrom:
|
envFrom:
|
||||||
{{- if .Values.config.enabled }}
|
{{- if .Values.config.enabled }}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,15 @@ ingress:
|
||||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
|
||||||
|
# Classification allowed-values (CUST-WP-0067-T09). The API validates repo
|
||||||
|
# classification against the-custodian canon; a container has no such checkout,
|
||||||
|
# so the file travels with the release as a ConfigMap. Without it every
|
||||||
|
# classification write fails with a 500.
|
||||||
|
classificationAllowed:
|
||||||
|
enabled: true
|
||||||
|
name: state-hub-classification-allowed
|
||||||
|
mountPath: /etc/state-hub/classification
|
||||||
|
|
||||||
# MCP layer (CUST-WP-0067-T08). Runs from the same image with a different
|
# MCP layer (CUST-WP-0067-T08). Runs from the same image with a different
|
||||||
# command, as a stateless HTTP client over the API service. ClusterIP only —
|
# command, as a stateless HTTP client over the API service. ClusterIP only —
|
||||||
# it proxies an unauthenticated API, so it must never gain an Ingress.
|
# it proxies an unauthenticated API, so it must never gain an Ingress.
|
||||||
|
|
|
||||||
91
scripts/sync_classification_allowed.py
Executable file
91
scripts/sync_classification_allowed.py
Executable file
|
|
@ -0,0 +1,91 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sync the canon classification allowed-values into the chart (CUST-WP-0067-T09).
|
||||||
|
|
||||||
|
The State Hub validates repository classification against
|
||||||
|
``the-custodian/canon/standards/repo-classification.allowed.yaml``. That file is
|
||||||
|
canon and must stay authoritative, but a container has no the-custodian
|
||||||
|
checkout, so a copy has to travel with the release.
|
||||||
|
|
||||||
|
A copy that nobody checks is a copy that silently drifts. This script owns the
|
||||||
|
copy: ``--check`` fails when it diverges from canon, so the release can refuse
|
||||||
|
to ship a stale vocabulary rather than validate against yesterday's rules.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/sync_classification_allowed.py # write the copy
|
||||||
|
python scripts/sync_classification_allowed.py --check # verify, exit 1 on drift
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import difflib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CHART_COPY = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml"
|
||||||
|
)
|
||||||
|
CANON_CANDIDATES = (
|
||||||
|
Path("/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"),
|
||||||
|
Path("/home/tegwick/the-custodian/canon/standards/repo-classification.allowed.yaml"),
|
||||||
|
REPO_ROOT.parent / "the-custodian/canon/standards/repo-classification.allowed.yaml",
|
||||||
|
)
|
||||||
|
|
||||||
|
HEADER = (
|
||||||
|
"# GENERATED — do not edit.\n"
|
||||||
|
"# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml\n"
|
||||||
|
"# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09).\n"
|
||||||
|
"# Canon is authoritative; this copy exists only so the container has one.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_canon() -> Path:
|
||||||
|
for candidate in CANON_CANDIDATES:
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
raise SystemExit(
|
||||||
|
"ERROR: canon allowed-values not found. Looked in:\n "
|
||||||
|
+ "\n ".join(str(c) for c in CANON_CANDIDATES)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="Verify the chart copy matches canon; exit 1 on drift",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
canon = find_canon()
|
||||||
|
desired = HEADER + canon.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
if not CHART_COPY.is_file():
|
||||||
|
print(f"DRIFT: {CHART_COPY} is missing; run without --check to create it")
|
||||||
|
return 1
|
||||||
|
current = CHART_COPY.read_text(encoding="utf-8")
|
||||||
|
if current != desired:
|
||||||
|
print(f"DRIFT: chart copy differs from canon ({canon})")
|
||||||
|
sys.stdout.writelines(
|
||||||
|
difflib.unified_diff(
|
||||||
|
current.splitlines(keepends=True),
|
||||||
|
desired.splitlines(keepends=True),
|
||||||
|
fromfile="chart copy",
|
||||||
|
tofile="canon",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(f"OK: chart copy matches canon ({canon})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
CHART_COPY.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CHART_COPY.write_text(desired, encoding="utf-8")
|
||||||
|
print(f"Synced {canon} -> {CHART_COPY}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue