diff --git a/k8s/railiance/20-runtime.yaml b/k8s/railiance/20-runtime.yaml index ff2e172..fb36e02 100644 --- a/k8s/railiance/20-runtime.yaml +++ b/k8s/railiance/20-runtime.yaml @@ -15,6 +15,9 @@ data: ISSUE_CORE_URL: http://issue-core.issue-core.svc.cluster.local:8010 ISSUE_SINK_TYPE: "null" ACTIVITY_DEFINITION_DIRS: /etc/activity-core/external-definitions + OPS_INVENTORY_PATH: /etc/activity-core/ops/service-inventory.yml + INTER_HUB_URL: "" + OPS_HUB_WIDGET_MAPPING: "" PROMETHEUS_BIND_ADDR: 0.0.0.0:9090 ACTIVITY_CURATOR_GATE: disabled --- @@ -58,6 +61,219 @@ data: Kubernetes projection of the Custodian-owned definition in `/home/worsch/the-custodian/activity-definitions/hourly-recently-on-scope.md`. + ops-service-inventory-probes.md: | + --- + id: "40d15a87-7ff6-4d8e-992c-37df15f95110" + name: "Ops Service Inventory Probes" + type: activity-definition + version: "0.1" + enabled: false + owner: custodian + governance: custodian + status: proposed + created: "2026-06-05" + trigger: + type: cron + cron_expression: "15 * * * *" + timezone: Europe/Berlin + misfire_policy: skip + context_sources: + - type: ops-inventory + query: probe_services + required: false + params: + inventory_path: /etc/activity-core/ops/service-inventory.yml + timeout_seconds: 10 + include_kinds: + - http + - https + allow_network: true + evidence_sinks: + - type: state-hub-progress + event_type: ops_inventory_probe + author: activity-core + bind_to: context.ops_inventory_probe + --- + + # ActivityDefinition: Ops Service Inventory Probes + + Disabled Railiance projection of the Custodian-owned definition in + `/home/worsch/the-custodian/activity-definitions/ops-service-inventory-probes.md`. + Keep disabled until ops-hub Inter-Hub evidence intake is active. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: actcore-ops-service-inventory + namespace: activity-core + labels: + app.kubernetes.io/name: activity-core + app.kubernetes.io/part-of: activity-core +data: + service-inventory.yml: | + version: 1 + last_reviewed: "2026-06-05" + policy: + non_secret_inventory: true + source_of_truth: "/home/worsch/the-custodian/ops/service-inventory.yml" + projection: "Railiance activity-core ConfigMap snapshot for disabled probes" + environments: + - id: local + name: "Local Workstation" + role: "Workstation development and local operations" + lifecycle_state: observed + - id: coulombcore + name: "CoulombCore" + role: "Transitional production-like runtime" + lifecycle_state: observed + - id: railiance01 + name: "Railiance01" + role: "First ThreePhoenix foundation node" + lifecycle_state: observed + - id: threephoenix-prod + name: "ThreePhoenix Production" + role: "Target governed production topology" + lifecycle_state: planned + hosts: + - id: local-workstation + environment: local + role: "State Hub and operator workstation runtime" + - id: coulombcore + environment: coulombcore + address: "92.205.130.254" + role: "Current live production-like server" + - id: railiance01 + environment: railiance01 + address: "92.205.62.239" + role: "First ThreePhoenix foundation node" + clusters: + - id: coulombcore-k3s + environment: coulombcore + host: coulombcore + kind: k3s + lifecycle_state: observed + - id: railiance01-k3s + environment: railiance01 + host: railiance01 + kind: k3s + lifecycle_state: observed + services: + - id: gitea + name: "Gitea" + kind: application + lifecycle_state: observed + health_status: unknown + environment: coulombcore + owner_repos: + - railiance-apps + runtime: + type: k3s + cluster: coulombcore-k3s + namespace: default + endpoints: + - id: gitea-oci-registry + type: https + url: "https://gitea.coulomb.social/v2/" + expected_status: 401 + expected_signal: "OCI registry auth challenge" + widget_ref: "ops:endpoint:gitea-registry" + backing_stores: + - "database:gitea-db" + - "pvc:default/gitea-shared-storage" + access_paths: + - type: k8s + target: "coulombcore-k3s/default" + status: unknown + evidence: [] + gaps: + - "Backup and restore evidence for database and shared storage not recorded in ops inventory." + - id: state-hub + name: "State Hub" + kind: coordination-service + lifecycle_state: observed + health_status: observed_ok + environment: local + owner_repos: + - state-hub + - the-custodian + runtime: + type: local-process + host: local-workstation + endpoints: + - id: state-hub-local-api + type: http + url: "http://actcore-state-hub-bridge:8000/state/health" + expected_status: 200 + expected_signal: "health response" + backing_stores: + - "postgresql:state-hub" + access_paths: + - type: http + target: "http://actcore-state-hub-bridge:8000" + status: observed_ok + evidence: [] + gaps: + - "Future cluster deployment readiness still needs ops evidence." + - id: inter-hub + name: "Inter-Hub" + kind: governance-service + lifecycle_state: observed + health_status: unknown + environment: threephoenix-prod + owner_repos: + - inter-hub + runtime: + type: external + public_endpoint: "https://hub.coulomb.social" + endpoints: + - id: inter-hub-openapi + type: https + url: "https://hub.coulomb.social/api/v2/openapi.json" + expected_status: 200 + expected_signal: "OpenAPI document" + - id: inter-hub-ui + type: https + url: "https://hub.coulomb.social/Hubs" + expected_status: 302 + expected_signal: "login redirect when unauthenticated" + backing_stores: [] + access_paths: + - type: https + target: "https://hub.coulomb.social" + status: unknown + evidence: [] + gaps: + - "ops-hub bootstrap requires authenticated UI flow or deployment-side migration." + - id: activity-core + name: "activity-core" + kind: automation-service + lifecycle_state: observed + health_status: observed_ok + environment: railiance01 + owner_repos: + - activity-core + - the-custodian + runtime: + type: k3s + cluster: railiance01-k3s + namespace: activity-core + endpoints: + - id: activity-core-api + type: cluster-http + url: "http://actcore-api:8010/health" + expected_status: 200 + expected_signal: "db" + backing_stores: + - "postgresql:activity-core" + - "temporal:activity-core" + - "nats:railiance01" + access_paths: + - type: k8s + target: "railiance01-k3s/activity-core" + status: observed_ok + evidence: [] + gaps: + - "Add explicit ops inventory probes and evidence events." --- apiVersion: v1 kind: Service @@ -360,10 +576,16 @@ spec: - name: external-activity-definitions mountPath: /etc/activity-core/external-definitions/activity-definitions readOnly: true + - name: ops-service-inventory + mountPath: /etc/activity-core/ops + readOnly: true volumes: - name: external-activity-definitions configMap: name: actcore-external-activity-definitions + - name: ops-service-inventory + configMap: + name: actcore-ops-service-inventory --- apiVersion: apps/v1 kind: Deployment diff --git a/k8s/railiance/README.md b/k8s/railiance/README.md index d0ed4c4..ed6b160 100644 --- a/k8s/railiance/README.md +++ b/k8s/railiance/README.md @@ -16,6 +16,14 @@ name and access policy. The runtime image tag is `activity-core:railiance01-prod` and is expected to be loaded into the railiance01 K3s containerd image store. +`20-runtime.yaml` also projects the disabled Custodian-owned +`ops-service-inventory-probes.md` ActivityDefinition and a non-secret +`actcore-ops-service-inventory` ConfigMap snapshot. The source of truth for the +inventory remains `/home/worsch/the-custodian/ops/service-inventory.yml`; update +the ConfigMap projection from that file before enabling the probe schedule. +`OPS_HUB_KEY` is created only as an empty Secret placeholder until the operator +provisions the Inter-Hub ops-hub key. + ## Deploy ```bash diff --git a/k8s/railiance/bootstrap-secrets.sh b/k8s/railiance/bootstrap-secrets.sh index 53bbad6..6d1213b 100644 --- a/k8s/railiance/bootstrap-secrets.sh +++ b/k8s/railiance/bootstrap-secrets.sh @@ -36,5 +36,6 @@ if ! secret_exists actcore-runtime-secret; then kubectl -n "$NS" create secret generic actcore-runtime-secret \ --from-literal=ACTCORE_DB_URL="$ACTCORE_DB_URL" \ --from-literal=WEBHOOK_SECRET_GITEA="" \ - --from-literal=WEBHOOK_SECRET_GITHUB="" + --from-literal=WEBHOOK_SECRET_GITHUB="" \ + --from-literal=OPS_HUB_KEY="" fi diff --git a/src/activity_core/ops_evidence_sinks.py b/src/activity_core/ops_evidence_sinks.py index 1e7db24..9299280 100644 --- a/src/activity_core/ops_evidence_sinks.py +++ b/src/activity_core/ops_evidence_sinks.py @@ -158,7 +158,11 @@ def _inter_hub_result(sink: dict[str, Any]) -> dict[str, Any]: missing.append("INTER_HUB_URL") if not os.environ.get("OPS_HUB_KEY"): missing.append("OPS_HUB_KEY") - if not (sink.get("widget_mapping") or sink.get("capability_mapping")): + if not ( + sink.get("widget_mapping") + or sink.get("capability_mapping") + or os.environ.get("OPS_HUB_WIDGET_MAPPING") + ): missing.append("widget_mapping") if missing: diff --git a/tests/test_ops_evidence_sinks.py b/tests/test_ops_evidence_sinks.py index 426b660..c76d65c 100644 --- a/tests/test_ops_evidence_sinks.py +++ b/tests/test_ops_evidence_sinks.py @@ -184,6 +184,25 @@ def test_inter_hub_sink_skips_cleanly_when_config_missing(monkeypatch) -> None: ] +def test_inter_hub_sink_accepts_widget_mapping_from_env(monkeypatch) -> None: + monkeypatch.delenv("INTER_HUB_URL", raising=False) + monkeypatch.delenv("OPS_HUB_KEY", raising=False) + monkeypatch.setenv("OPS_HUB_WIDGET_MAPPING", "ops:endpoint:gitea-registry") + + result = persist_ops_inventory_evidence( + _payload([{"type": "inter-hub-interaction-event"}]) + ) + + assert result == [ + { + "type": "inter-hub-interaction-event", + "status": "skipped", + "reason": "missing_inter_hub_config", + "missing": ["INTER_HUB_URL", "OPS_HUB_KEY"], + } + ] + + def test_no_evidence_sinks_returns_no_results() -> None: payload = _payload([]) payload["context_sources"][0]["params"] = {} diff --git a/tests/test_railiance_ops_inventory_wiring.py b/tests/test_railiance_ops_inventory_wiring.py new file mode 100644 index 0000000..48dcecc --- /dev/null +++ b/tests/test_railiance_ops_inventory_wiring.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +import httpx + +from activity_core.definition_parser import parse_file +from activity_core.context_resolvers.ops_inventory import OpsInventoryContextResolver +from activity_core.ops_evidence_sinks import persist_ops_inventory_evidence + +_REPO_ROOT = Path(__file__).parent.parent +_RUNTIME_PATH = _REPO_ROOT / "k8s" / "railiance" / "20-runtime.yaml" +_BOOTSTRAP_SECRETS_PATH = _REPO_ROOT / "k8s" / "railiance" / "bootstrap-secrets.sh" + + +def _resources() -> list[dict[str, Any]]: + return [ + resource + for resource in yaml.safe_load_all(_RUNTIME_PATH.read_text(encoding="utf-8")) + if isinstance(resource, dict) + ] + + +def _by_kind_name(kind: str, name: str) -> dict[str, Any]: + for resource in _resources(): + if resource.get("kind") == kind and resource.get("metadata", {}).get("name") == name: + return resource + raise AssertionError(f"missing {kind}/{name}") + + +def test_runtime_config_has_ops_inventory_placeholders() -> None: + config = _by_kind_name("ConfigMap", "actcore-runtime-config") + + assert config["data"]["OPS_INVENTORY_PATH"] == ( + "/etc/activity-core/ops/service-inventory.yml" + ) + assert config["data"]["INTER_HUB_URL"] == "" + assert config["data"]["OPS_HUB_WIDGET_MAPPING"] == "" + + +def test_external_configmap_projects_disabled_ops_probe_definition(tmp_path) -> None: + config = _by_kind_name("ConfigMap", "actcore-external-activity-definitions") + raw_definition = config["data"]["ops-service-inventory-probes.md"] + definition_path = tmp_path / "ops-service-inventory-probes.md" + definition_path.write_text(raw_definition, encoding="utf-8") + + definition = parse_file(definition_path) + + assert definition.name == "Ops Service Inventory Probes" + assert definition.enabled is False + assert definition.trigger_config["cron_expression"] == "15 * * * *" + assert definition.context_sources == [ + { + "type": "ops-inventory", + "query": "probe_services", + "required": False, + "params": { + "inventory_path": "/etc/activity-core/ops/service-inventory.yml", + "timeout_seconds": 10, + "include_kinds": ["http", "https"], + "allow_network": True, + "evidence_sinks": [ + { + "type": "state-hub-progress", + "event_type": "ops_inventory_probe", + "author": "activity-core", + } + ], + }, + "bind_to": "context.ops_inventory_probe", + } + ] + + +def test_ops_inventory_configmap_contains_probeable_inventory() -> None: + config = _by_kind_name("ConfigMap", "actcore-ops-service-inventory") + inventory = yaml.safe_load(config["data"]["service-inventory.yml"]) + + services = {service["id"]: service for service in inventory["services"]} + + assert inventory["policy"]["non_secret_inventory"] is True + assert services["gitea"]["endpoints"][0]["id"] == "gitea-oci-registry" + assert services["state-hub"]["endpoints"][0]["url"] == ( + "http://actcore-state-hub-bridge:8000/state/health" + ) + assert services["inter-hub"]["endpoints"][0]["id"] == "inter-hub-openapi" + assert services["activity-core"]["endpoints"][0]["id"] == "activity-core-api" + + +def test_worker_mounts_ops_inventory_configmap() -> None: + deployment = _by_kind_name("Deployment", "actcore-worker") + pod_spec = deployment["spec"]["template"]["spec"] + container = pod_spec["containers"][0] + + mounts = {mount["name"]: mount for mount in container["volumeMounts"]} + volumes = {volume["name"]: volume for volume in pod_spec["volumes"]} + + assert mounts["ops-service-inventory"]["mountPath"] == "/etc/activity-core/ops" + assert mounts["ops-service-inventory"]["readOnly"] is True + assert volumes["ops-service-inventory"]["configMap"]["name"] == ( + "actcore-ops-service-inventory" + ) + + +def test_ops_hub_key_is_secret_only_placeholder() -> None: + runtime_config = _by_kind_name("ConfigMap", "actcore-runtime-config") + bootstrap = _BOOTSTRAP_SECRETS_PATH.read_text(encoding="utf-8") + + assert "OPS_HUB_KEY" not in runtime_config["data"] + assert '--from-literal=OPS_HUB_KEY=""' in bootstrap + + +def test_disabled_ops_probe_definition_can_emit_fixture_evidence( + tmp_path, + monkeypatch, +) -> None: + definition_config = _by_kind_name("ConfigMap", "actcore-external-activity-definitions") + inventory_config = _by_kind_name("ConfigMap", "actcore-ops-service-inventory") + definition_path = tmp_path / "ops-service-inventory-probes.md" + inventory_path = tmp_path / "service-inventory.yml" + definition_path.write_text( + definition_config["data"]["ops-service-inventory-probes.md"], + encoding="utf-8", + ) + inventory_path.write_text( + inventory_config["data"]["service-inventory.yml"], + encoding="utf-8", + ) + definition = parse_file(definition_path) + source = definition.context_sources[0] + source["params"]["inventory_path"] = str(inventory_path) + + def fake_endpoint_get(url: str, **kwargs: Any) -> Any: + if url.endswith("/v2/"): + return _HttpResponse(401, "OCI registry auth challenge") + if url.endswith("/state/health"): + return _HttpResponse(200, "health response") + if url.endswith("/openapi.json"): + return _HttpResponse(200, "OpenAPI document") + if url.endswith("/Hubs"): + return _HttpResponse(302, "login redirect when unauthenticated") + raise AssertionError(f"unexpected endpoint probe {url}") + + monkeypatch.setattr(httpx, "get", fake_endpoint_get) + probe = OpsInventoryContextResolver().resolve("probe_services", None, source["params"]) + + posts: list[dict[str, Any]] = [] + + def fake_progress_get(url: str, **kwargs: Any) -> _JsonResponse: + return _JsonResponse([]) + + def fake_progress_post(url: str, **kwargs: Any) -> _JsonResponse: + posts.append({"url": url, **kwargs}) + return _JsonResponse({"id": "progress-1"}) + + monkeypatch.setattr(httpx, "get", fake_progress_get) + monkeypatch.setattr(httpx, "post", fake_progress_post) + + result = persist_ops_inventory_evidence( + { + "activity_id": definition.id, + "run_id": "12345678-aaaa-bbbb-cccc-123456789abc", + "scheduled_for": "2026-06-05T10:15:00+00:00", + "version_used": 1, + "context_sources": [source], + "context": {"ops_inventory_probe": probe}, + } + ) + + assert definition.enabled is False + assert result[0]["status"] == "posted" + assert posts[0]["json"]["event_type"] == "ops_inventory_probe" + assert posts[0]["json"]["detail"]["probe"]["summary"]["ok"] == 4 + + +class _HttpResponse: + def __init__(self, status_code: int, text: str) -> None: + self.status_code = status_code + self.text = text + + +class _JsonResponse: + def __init__(self, payload: Any) -> None: + self.payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> Any: + return self.payload diff --git a/workplans/ACTIVITY-WP-0007-ops-inventory-probe-runner.md b/workplans/ACTIVITY-WP-0007-ops-inventory-probe-runner.md index e1da50c..dfb9a6a 100644 --- a/workplans/ACTIVITY-WP-0007-ops-inventory-probe-runner.md +++ b/workplans/ACTIVITY-WP-0007-ops-inventory-probe-runner.md @@ -159,7 +159,7 @@ valid and reviewable. ```task id: ACTIVITY-WP-0007-T04 -status: progress +status: done priority: medium state_hub_task_id: "45132f9f-da3c-44f1-a488-195aa0e46428" ``` @@ -184,11 +184,17 @@ it prematurely. scan a disabled `ops-service-inventory-probes.md` definition carrying an `ops-inventory` context source and explicit `state-hub-progress` evidence sink. +2026-06-05: Completed. The Railiance-projected disabled definition now uses the +`ops-inventory` resolver and explicit `state-hub-progress` evidence sink. Tests +prove the disabled definition can resolve fixture inventory data and emit one +compact `ops_inventory_probe` State Hub progress event without enabling the +production schedule. + ## Wire Railiance Runtime Inputs ```task id: ACTIVITY-WP-0007-T05 -status: todo +status: done priority: medium state_hub_task_id: "474564be-a447-4bdf-b995-168f7a93e515" ``` @@ -209,6 +215,13 @@ Scope: Done when the Railiance worker can see the disabled definition and inventory input without leaking secrets or activating the schedule early. +2026-06-05: Completed the first production wiring slice. `20-runtime.yaml` +projects the disabled ops probe definition, runtime config placeholders +(`OPS_INVENTORY_PATH`, `INTER_HUB_URL`, `OPS_HUB_WIDGET_MAPPING`), and a +non-secret `actcore-ops-service-inventory` ConfigMap snapshot. The worker mounts +the inventory at `/etc/activity-core/ops`, and `bootstrap-secrets.sh` keeps +`OPS_HUB_KEY` as an empty Secret-only placeholder until operator provisioning. + ## Close Safety And Handoff Gates ```task @@ -235,6 +248,10 @@ Acceptance criteria: This task waits on the implementation tasks above and, for final Inter-Hub activation, the operator-gated ops-hub widget/API-key path in `CUST-WP-0047`. +2026-06-05: The local implementation gates are now satisfied and tested. Live +closure remains waiting on applying the updated Railiance manifests and on the +operator-gated Inter-Hub ops-hub widget/API-key path. + ## Review Verdict activity-core should provide this as a bounded probe-and-evidence capability.