Enforce private-by-default rail exposure
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
parent
c6b045051f
commit
004f1c4dc1
7 changed files with 368 additions and 7 deletions
|
|
@ -24,6 +24,11 @@ bin/railiance create-overlay \
|
||||||
--init-git
|
--init-git
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The generated Service is explicitly `ClusterIP`, a default-deny ingress
|
||||||
|
NetworkPolicy is included, and Stage 2 does not emit an Ingress. Enabling an
|
||||||
|
Ingress later requires matching rapp and reef declaration files at the
|
||||||
|
Stage 2 deploy gate; an Ingress object is not itself an ADR-0008 grant.
|
||||||
|
|
||||||
Required arguments:
|
Required arguments:
|
||||||
|
|
||||||
- `--app-id`
|
- `--app-id`
|
||||||
|
|
|
||||||
133
tests/test_private_exposure.py
Normal file
133
tests/test_private_exposure.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
from exposure import ExposureError, validate_rendered_exposure # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
PRIVATE = """apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata: {name: example}
|
||||||
|
spec: {type: ClusterIP}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PUBLIC = """apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata: {name: example}
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: app.example.test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ExposureTests(unittest.TestCase):
|
||||||
|
def declarations(self, rapp: str, reef: str):
|
||||||
|
temp = tempfile.TemporaryDirectory()
|
||||||
|
root = Path(temp.name)
|
||||||
|
rapp_path = root / "rapp.yaml"
|
||||||
|
reef_path = root / "reef.yaml"
|
||||||
|
rapp_path.write_text(rapp, encoding="utf-8")
|
||||||
|
reef_path.write_text(reef, encoding="utf-8")
|
||||||
|
return rapp_path, reef_path, temp
|
||||||
|
|
||||||
|
def test_private_manifest_needs_no_declarations(self) -> None:
|
||||||
|
result = validate_rendered_exposure(PRIVATE)
|
||||||
|
self.assertEqual("private", result["posture"])
|
||||||
|
|
||||||
|
def test_public_manifest_fails_without_declarations(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ExposureError, "rapp-declaration"):
|
||||||
|
validate_rendered_exposure(PUBLIC)
|
||||||
|
|
||||||
|
def test_matching_grants_pass(self) -> None:
|
||||||
|
rapp, reef, temp = self.declarations(
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
binding_admission: production-approved
|
||||||
|
grant:
|
||||||
|
hostname: app.example.test
|
||||||
|
reason: test
|
||||||
|
approved_on: '2026-08-22'
|
||||||
|
residual_risk_owner: test
|
||||||
|
""",
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
grants:
|
||||||
|
- port: 443
|
||||||
|
reason: test
|
||||||
|
approved_on: '2026-08-22'
|
||||||
|
residual_risk_owner: test
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
result = validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef)
|
||||||
|
self.assertEqual(["app.example.test"], result["public_hosts"])
|
||||||
|
|
||||||
|
def test_unapproved_binding_fails(self) -> None:
|
||||||
|
rapp, reef, temp = self.declarations(
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
binding_admission: verified
|
||||||
|
grant: {hostname: app.example.test}
|
||||||
|
""",
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
grants: [{port: 443}]
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
with self.assertRaisesRegex(ExposureError, "production-approved"):
|
||||||
|
validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef)
|
||||||
|
|
||||||
|
def test_mismatched_hostname_fails(self) -> None:
|
||||||
|
rapp, reef, temp = self.declarations(
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
binding_admission: production-approved
|
||||||
|
grant:
|
||||||
|
hostname: other.example.test
|
||||||
|
reason: test
|
||||||
|
approved_on: '2026-08-22'
|
||||||
|
residual_risk_owner: test
|
||||||
|
""",
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
grants:
|
||||||
|
- port: 443
|
||||||
|
reason: test
|
||||||
|
approved_on: '2026-08-22'
|
||||||
|
residual_risk_owner: test
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
with self.assertRaisesRegex(ExposureError, "does not name"):
|
||||||
|
validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef)
|
||||||
|
|
||||||
|
def test_incomplete_grant_fails(self) -> None:
|
||||||
|
rapp, reef, temp = self.declarations(
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
binding_admission: production-approved
|
||||||
|
grant: {hostname: app.example.test}
|
||||||
|
""",
|
||||||
|
"""exposure:
|
||||||
|
posture: public
|
||||||
|
grants:
|
||||||
|
- port: 443
|
||||||
|
reason: test
|
||||||
|
approved_on: '2026-08-22'
|
||||||
|
residual_risk_owner: test
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
with self.assertRaisesRegex(ExposureError, "rapp grant is missing"):
|
||||||
|
validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -8,6 +8,7 @@ import json
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import tomllib
|
import tomllib
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
@ -17,6 +18,9 @@ from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from exposure import ExposureError, validate_rendered_exposure # noqa: E402
|
||||||
|
|
||||||
SUPPORTED_SCHEMA = "railiance.app.v1"
|
SUPPORTED_SCHEMA = "railiance.app.v1"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -231,6 +235,8 @@ def deploy(argv: list[str]) -> int:
|
||||||
parser.add_argument("--apply", action="store_const", const="apply", dest="mode")
|
parser.add_argument("--apply", action="store_const", const="apply", dest="mode")
|
||||||
parser.add_argument("--server-dry-run", action="store_const", const="server-dry-run", dest="mode")
|
parser.add_argument("--server-dry-run", action="store_const", const="server-dry-run", dest="mode")
|
||||||
parser.add_argument("--approval-id", help="Operator approval/progress id required before apply when declared.")
|
parser.add_argument("--approval-id", help="Operator approval/progress id required before apply when declared.")
|
||||||
|
parser.add_argument("--rapp-declaration", type=Path)
|
||||||
|
parser.add_argument("--reef-declaration", type=Path)
|
||||||
parser.add_argument("--stage1-result", help="Optional Stage 1 result JSON for same-candidate evidence.")
|
parser.add_argument("--stage1-result", help="Optional Stage 1 result JSON for same-candidate evidence.")
|
||||||
parser.add_argument("--timeout-minutes", type=int, default=10)
|
parser.add_argument("--timeout-minutes", type=int, default=10)
|
||||||
parser.add_argument("--json-out")
|
parser.add_argument("--json-out")
|
||||||
|
|
@ -242,6 +248,32 @@ def deploy(argv: list[str]) -> int:
|
||||||
context = stage2_context(app_dir, contract_path, data)
|
context = stage2_context(app_dir, contract_path, data)
|
||||||
checks = local_prechecks(app_dir, data, args.mode, args.approval_id)
|
checks = local_prechecks(app_dir, data, args.mode, args.approval_id)
|
||||||
|
|
||||||
|
if args.mode in {"server-dry-run", "apply"} and shutil.which("helm"):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="railiance-exposure-"):
|
||||||
|
render_args = [
|
||||||
|
"helm", "template", context["release"], context["chart"],
|
||||||
|
"--namespace", context["namespace"], "-f", context["values"],
|
||||||
|
]
|
||||||
|
rendered = subprocess.run(
|
||||||
|
render_args, cwd=app_dir, text=True, capture_output=True,
|
||||||
|
timeout=args.timeout_minutes * 60, check=False,
|
||||||
|
)
|
||||||
|
if rendered.returncode != 0:
|
||||||
|
checks.append(precheck("exposure-render", "failed", True, "helm template failed"))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
exposure = validate_rendered_exposure(
|
||||||
|
rendered.stdout,
|
||||||
|
rapp_declaration=args.rapp_declaration,
|
||||||
|
reef_declaration=args.reef_declaration,
|
||||||
|
)
|
||||||
|
checks.append(precheck(
|
||||||
|
"adr-0008-exposure", "passed", True,
|
||||||
|
f"posture={exposure['posture']} hosts={len(exposure['public_hosts'])}",
|
||||||
|
))
|
||||||
|
except ExposureError as exc:
|
||||||
|
checks.append(precheck("adr-0008-exposure", "failed", True, str(exc)))
|
||||||
|
|
||||||
if args.stage1_result:
|
if args.stage1_result:
|
||||||
try:
|
try:
|
||||||
stage1 = json.loads(Path(args.stage1_result).read_text(encoding="utf-8"))
|
stage1 = json.loads(Path(args.stage1_result).read_text(encoding="utf-8"))
|
||||||
|
|
|
||||||
|
|
@ -479,6 +479,7 @@ metadata:
|
||||||
annotations:
|
annotations:
|
||||||
{{ include "railiance.prometheusAnnotations" . | nindent 4 }}
|
{{ include "railiance.prometheusAnnotations" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
selector:
|
selector:
|
||||||
{{ include "railiance.selectorLabels" . | nindent 4 }}
|
{{ include "railiance.selectorLabels" . | nindent 4 }}
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -487,6 +488,30 @@ spec:
|
||||||
targetPort: http
|
targetPort: http
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
cat > "${OUT_DIR}/charts/${APP_ID}/templates/networkpolicy.yaml" <<'EOF'
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: {{ include "railiance.releaseName" . }}-default-deny
|
||||||
|
labels:
|
||||||
|
{{ include "railiance.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
{{ include "railiance.selectorLabels" . | nindent 6 }}
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector: {}
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: traefik
|
||||||
|
EOF
|
||||||
|
|
||||||
cat > "${OUT_DIR}/charts/${APP_ID}/templates/ingress.yaml" <<'EOF'
|
cat > "${OUT_DIR}/charts/${APP_ID}/templates/ingress.yaml" <<'EOF'
|
||||||
{{- if and .Values.ingress.enabled (ne .Values.railiance.traffic.mode "weighted") }}
|
{{- if and .Values.ingress.enabled (ne .Values.railiance.traffic.mode "weighted") }}
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
|
|
@ -631,8 +656,10 @@ image:
|
||||||
tag: ${UPSTREAM_REVISION}
|
tag: ${UPSTREAM_REVISION}
|
||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
# ADR-0008: Stage 2 is private unless the rapp and reef declarations carry
|
||||||
host: ${APP_ID}.local
|
# matching public grants and the binding is production-approved.
|
||||||
|
enabled: false
|
||||||
|
host: ""
|
||||||
path: /
|
path: /
|
||||||
annotations:
|
annotations:
|
||||||
railiance.coulomb.social/stage: stable
|
railiance.coulomb.social/stage: stable
|
||||||
|
|
@ -664,6 +691,7 @@ for rel in ('${APP_ID}', '${APP_ID}-canary'):
|
||||||
required_paths = [
|
required_paths = [
|
||||||
'charts/${APP_ID}/templates/deployment.yaml',
|
'charts/${APP_ID}/templates/deployment.yaml',
|
||||||
'charts/${APP_ID}/templates/service.yaml',
|
'charts/${APP_ID}/templates/service.yaml',
|
||||||
|
'charts/${APP_ID}/templates/networkpolicy.yaml',
|
||||||
'charts/${APP_ID}/templates/ingress.yaml',
|
'charts/${APP_ID}/templates/ingress.yaml',
|
||||||
'charts/${APP_ID}/templates/traefik-weighted.yaml',
|
'charts/${APP_ID}/templates/traefik-weighted.yaml',
|
||||||
'values/stage2-canary.yaml',
|
'values/stage2-canary.yaml',
|
||||||
|
|
@ -685,7 +713,9 @@ if command -v helm >/dev/null 2>&1; then
|
||||||
helm template ${APP_ID}-canary charts/${APP_ID} -f values/stage2-canary.yaml >/tmp/${APP_ID}-stage2-canary-render.yaml
|
helm template ${APP_ID}-canary charts/${APP_ID} -f values/stage2-canary.yaml >/tmp/${APP_ID}-stage2-canary-render.yaml
|
||||||
grep -q 'kind: Deployment' /tmp/${APP_ID}-stage2-canary-render.yaml
|
grep -q 'kind: Deployment' /tmp/${APP_ID}-stage2-canary-render.yaml
|
||||||
grep -q 'kind: Service' /tmp/${APP_ID}-stage2-canary-render.yaml
|
grep -q 'kind: Service' /tmp/${APP_ID}-stage2-canary-render.yaml
|
||||||
grep -q 'kind: Ingress' /tmp/${APP_ID}-stage2-canary-render.yaml
|
grep -q 'type: ClusterIP' /tmp/${APP_ID}-stage2-canary-render.yaml
|
||||||
|
grep -q 'kind: NetworkPolicy' /tmp/${APP_ID}-stage2-canary-render.yaml
|
||||||
|
! grep -q 'kind: Ingress' /tmp/${APP_ID}-stage2-canary-render.yaml
|
||||||
echo 'stage2 helm template ok'
|
echo 'stage2 helm template ok'
|
||||||
else
|
else
|
||||||
echo 'helm unavailable; verified stage2 canary scaffold files only'
|
echo 'helm unavailable; verified stage2 canary scaffold files only'
|
||||||
|
|
|
||||||
110
tools/exposure.py
Normal file
110
tools/exposure.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""ADR-0008 exposure validation for rendered Kubernetes manifests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class ExposureError(ValueError):
|
||||||
|
"""Rendered public exposure is not backed by the required declarations."""
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: Path | None, label: str) -> dict[str, Any]:
|
||||||
|
if path is None:
|
||||||
|
raise ExposureError(f"public manifest requires --{label}-declaration")
|
||||||
|
try:
|
||||||
|
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
raise ExposureError(f"cannot read {label} declaration {path}: {exc}") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ExposureError(f"{label} declaration must be a YAML object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _public_hosts(documents: list[dict[str, Any]]) -> list[str]:
|
||||||
|
hosts: set[str] = set()
|
||||||
|
public_kinds = 0
|
||||||
|
for document in documents:
|
||||||
|
kind = document.get("kind")
|
||||||
|
spec = document.get("spec") or {}
|
||||||
|
if kind == "Ingress":
|
||||||
|
public_kinds += 1
|
||||||
|
for rule in spec.get("rules") or []:
|
||||||
|
host = rule.get("host") if isinstance(rule, dict) else None
|
||||||
|
if host:
|
||||||
|
hosts.add(str(host))
|
||||||
|
elif kind == "IngressRoute":
|
||||||
|
public_kinds += 1
|
||||||
|
for route in spec.get("routes") or []:
|
||||||
|
match = str(route.get("match", "")) if isinstance(route, dict) else ""
|
||||||
|
hosts.update(re.findall(r"Host\(`([^`]+)`\)", match))
|
||||||
|
if public_kinds and not hosts:
|
||||||
|
raise ExposureError("public routing resource has no exact hostname")
|
||||||
|
return sorted(hosts)
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_matches(grant: Any, hostname: str, *, allow_port: bool) -> bool:
|
||||||
|
if not isinstance(grant, dict):
|
||||||
|
return False
|
||||||
|
if grant.get("hostname") == hostname:
|
||||||
|
return True
|
||||||
|
return allow_port and grant.get("port") in {80, 443, "80", "443"}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_grant_metadata(grant: Any, label: str) -> None:
|
||||||
|
if not isinstance(grant, dict):
|
||||||
|
raise ExposureError(f"{label} grant must be an object")
|
||||||
|
missing = [
|
||||||
|
field for field in ("reason", "approved_on", "residual_risk_owner")
|
||||||
|
if not grant.get(field)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ExposureError(f"{label} grant is missing {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_rendered_exposure(
|
||||||
|
rendered: str,
|
||||||
|
*,
|
||||||
|
rapp_declaration: Path | None = None,
|
||||||
|
reef_declaration: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate all Ingress/IngressRoute hosts against rapp and reef grants."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
documents = [item for item in yaml.safe_load_all(rendered) if isinstance(item, dict)]
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise ExposureError(f"rendered manifest is not valid YAML: {exc}") from exc
|
||||||
|
hosts = _public_hosts(documents)
|
||||||
|
if not hosts:
|
||||||
|
return {"posture": "private", "public_hosts": [], "validated": True}
|
||||||
|
|
||||||
|
rapp = _load(rapp_declaration, "rapp")
|
||||||
|
reef = _load(reef_declaration, "reef")
|
||||||
|
rapp_exposure = rapp.get("exposure") or {}
|
||||||
|
reef_exposure = reef.get("exposure") or {}
|
||||||
|
if rapp_exposure.get("posture") != "public":
|
||||||
|
raise ExposureError("rapp exposure.posture must be public")
|
||||||
|
if rapp_exposure.get("binding_admission") != "production-approved":
|
||||||
|
raise ExposureError("rapp exposure.binding_admission must be production-approved")
|
||||||
|
if reef_exposure.get("posture") != "public":
|
||||||
|
raise ExposureError("reef exposure.posture must be public")
|
||||||
|
|
||||||
|
rapp_grant = rapp_exposure.get("grant")
|
||||||
|
reef_grants = reef_exposure.get("grants") or []
|
||||||
|
_require_grant_metadata(rapp_grant, "rapp")
|
||||||
|
for hostname in hosts:
|
||||||
|
if not _grant_matches(rapp_grant, hostname, allow_port=False):
|
||||||
|
raise ExposureError(f"rapp grant does not name rendered hostname {hostname}")
|
||||||
|
matching_reef_grants = [
|
||||||
|
grant for grant in reef_grants
|
||||||
|
if _grant_matches(grant, hostname, allow_port=True)
|
||||||
|
]
|
||||||
|
if not matching_reef_grants:
|
||||||
|
raise ExposureError(f"reef has no public substrate grant for {hostname}")
|
||||||
|
for grant in matching_reef_grants:
|
||||||
|
_require_grant_metadata(grant, "reef")
|
||||||
|
return {"posture": "public", "public_hosts": hosts, "validated": True}
|
||||||
34
tools/validate_exposure.py
Executable file
34
tools/validate_exposure.py
Executable file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate a rendered manifest against ADR-0008 declarations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from exposure import ExposureError, validate_rendered_exposure
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("rendered_manifest", type=Path)
|
||||||
|
parser.add_argument("--rapp-declaration", type=Path)
|
||||||
|
parser.add_argument("--reef-declaration", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
result = validate_rendered_exposure(
|
||||||
|
args.rendered_manifest.read_text(encoding="utf-8"),
|
||||||
|
rapp_declaration=args.rapp_declaration,
|
||||||
|
reef_declaration=args.reef_declaration,
|
||||||
|
)
|
||||||
|
except (OSError, ExposureError) as exc:
|
||||||
|
print(f"exposure validation failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(json.dumps(result, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "Private-by-default networking until an exposure grant exists"
|
title: "Private-by-default networking until an exposure grant exists"
|
||||||
domain: financials
|
domain: financials
|
||||||
repo: rail-kubernetes
|
repo: rail-kubernetes
|
||||||
status: ready
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: railiance
|
topic_slug: railiance
|
||||||
created: "2026-08-15"
|
created: "2026-08-15"
|
||||||
updated: "2026-08-15"
|
updated: "2026-08-22"
|
||||||
related:
|
related:
|
||||||
- RMASTER-WP-0023
|
- RMASTER-WP-0023
|
||||||
- ADR-0008
|
- ADR-0008
|
||||||
|
|
@ -33,7 +33,7 @@ Do not define what production-safe means (ADR-0006). Do not open `6443`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-K8S-WP-0003-T01
|
id: RAIL-K8S-WP-0003-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -44,11 +44,16 @@ Document the operator/tunnel path for debug. Missing `exposure` means
|
||||||
**Done when:** a new rapp on this rail has no public listener unless a
|
**Done when:** a new rapp on this rail has no public listener unless a
|
||||||
grant exists.
|
grant exists.
|
||||||
|
|
||||||
|
**Outcome (2026-08-22):** generated overlays now render an explicit
|
||||||
|
`ClusterIP` Service, a default-deny ingress `NetworkPolicy`, and no Ingress by
|
||||||
|
default. The scaffold regression test renders the generated chart and asserts
|
||||||
|
all three properties.
|
||||||
|
|
||||||
## T02 — Gate public Ingress on the grant
|
## T02 — Gate public Ingress on the grant
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-K8S-WP-0003-T02
|
id: RAIL-K8S-WP-0003-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -57,3 +62,15 @@ valid grant. An Ingress object is not itself a grant.
|
||||||
|
|
||||||
**Done when:** an ungranted rapp cannot obtain a public Ingress from the
|
**Done when:** an ungranted rapp cannot obtain a public Ingress from the
|
||||||
paved path.
|
paved path.
|
||||||
|
|
||||||
|
**Outcome (2026-08-22):** Stage 2 renders the chart before server dry-run or
|
||||||
|
apply and validates any Ingress/IngressRoute against both the rapp and reef
|
||||||
|
declarations. Private is the fail-closed default; a public surface requires a
|
||||||
|
matching grant and `production-approved` binding admission. Regression tests
|
||||||
|
cover missing, mismatched, and valid grants.
|
||||||
|
|
||||||
|
## Completion evidence
|
||||||
|
|
||||||
|
- `python3 -m unittest discover -s tests -v`: 6 tests passed.
|
||||||
|
- Generated overlay `tests/stage2-template.sh`: passed, including Helm render.
|
||||||
|
- `bash -n tools/create_railiance_overlay_repo.sh`: passed.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue