From e4f14cf6cc8762e88a9d7c60e3fd197e9a242887 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 20 Apr 2026 07:01:28 -0700 Subject: [PATCH] feat(rgds): scaffold platform-rgds delivery lane Add the first Platform RGD bundle, a CUE-based render and validate pipeline, and the CI and release wiring needed to publish platform-rgds independently from bootstrap charts. Keep the initial Platform API intentionally narrow with only dns.zone and tls.clusterIssuer and no rendered resources yet. --- .github/workflows/ci.yml | 6 + .github/workflows/publish-rgds.yml | 85 +++++ .gitignore | 3 + .release-please-manifest.json | 3 +- Justfile | 14 +- cue.mod/module.cue | 4 + moon.yml | 7 +- release-please-config.json | 10 + rgds/platform/CHANGELOG.md | 1 + rgds/platform/VERSION | 1 + rgds/platform/bundle.cue | 14 + rgds/platform/capabilities/dns/schema.cue | 5 + rgds/platform/capabilities/tls/schema.cue | 5 + rgds/platform/platform.cue | 27 ++ rgds/platform/render/platform-rgds.yaml | 24 ++ scripts/render_rgds.py | 358 ++++++++++++++++++++++ 16 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/publish-rgds.yml create mode 100644 cue.mod/module.cue create mode 100644 rgds/platform/CHANGELOG.md create mode 100644 rgds/platform/VERSION create mode 100644 rgds/platform/bundle.cue create mode 100644 rgds/platform/capabilities/dns/schema.cue create mode 100644 rgds/platform/capabilities/tls/schema.cue create mode 100644 rgds/platform/platform.cue create mode 100644 rgds/platform/render/platform-rgds.yaml create mode 100644 scripts/render_rgds.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57a31b5..7067288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,12 +33,18 @@ jobs: version: '0.11.0' cache-dependency-glob: | scripts/render_bootstrap.py + scripts/render_rgds.py - name: Setup Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 with: version: v4.0.4 + - name: Setup CUE + uses: cue-lang/setup-cue@a93fa358375740cd8b0078f76355512b9208acb1 # v1.0.1 + with: + version: v0.16.1 + - name: Setup Moon Toolchain uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 # v0.6.4 with: diff --git a/.github/workflows/publish-rgds.yml b/.github/workflows/publish-rgds.yml new file mode 100644 index 0000000..4d3788b --- /dev/null +++ b/.github/workflows/publish-rgds.yml @@ -0,0 +1,85 @@ +name: Publish RGD Bundles + +on: + push: + tags: + - 'platform-rgds-v*' + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Setup ORAS + uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1 + with: + version: 1.3.2 + + - name: Resolve Release Target + id: target + shell: bash + run: | + set -euo pipefail + + tag="${GITHUB_REF_NAME}" + case "${tag}" in + platform-rgds-v*) bundle="platform" ;; + *) + echo "unsupported tag: ${tag}" >&2 + exit 1 + ;; + esac + + version="${tag#platform-rgds-v}" + bundle_dir="rgds/${bundle}" + file_version="$(tr -d '\n' < "${bundle_dir}/VERSION")" + + if [ "${version}" != "${file_version}" ]; then + echo "Tag version ${version} does not match VERSION ${file_version}" >&2 + exit 1 + fi + + { + echo "bundle=${bundle}" + echo "bundle_name=platform-rgds" + echo "version=${version}" + echo "bundle_dir=${bundle_dir}" + echo "artifact=ghcr.io/gilmanlab/platform/rgds/platform-rgds" + } >> "${GITHUB_OUTPUT}" + + - name: Login to GHCR + shell: bash + run: | + set -euo pipefail + echo "${{ secrets.GITHUB_TOKEN }}" | \ + oras login ghcr.io \ + --username "${{ github.actor }}" \ + --password-stdin + + - name: Push Bundle Artifact + working-directory: ${{ steps.target.outputs.bundle_dir }}/render + shell: bash + run: | + set -euo pipefail + oras push \ + --annotation "org.opencontainers.image.title=${{ steps.target.outputs.bundle_name }}" \ + --annotation "org.opencontainers.image.description=GilmanLab platform RGD bundle" \ + --annotation "org.opencontainers.image.version=${{ steps.target.outputs.version }}" \ + --annotation "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ + --annotation "org.opencontainers.image.revision=${{ github.sha }}" \ + "${{ steps.target.outputs.artifact }}:${{ steps.target.outputs.version }}" \ + . diff --git a/.gitignore b/.gitignore index ae21876..03a4281 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ docs/.docusaurus/ bootstrap/*/.state/ bootstrap/*/charts/ bootstrap/**/*.tgz +cue.mod/gen/ +cue.mod/pkg/ +cue.mod/usr/ # OS / editor .DS_Store diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 456ebdd..b76d0fb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,6 @@ { "bootstrap/cilium": "1.2.0", "bootstrap/argocd": "1.1.0", - "bootstrap/kro": "1.1.0" + "bootstrap/kro": "1.1.0", + "rgds/platform": "0.1.0" } diff --git a/Justfile b/Justfile index c61f5d9..56c1e44 100644 --- a/Justfile +++ b/Justfile @@ -10,13 +10,25 @@ render: render-all: uv run scripts/render_bootstrap.py render +# Render the tracked RGD bundle artifacts into their committed paths. +render-rgds: + uv run scripts/render_rgds.py render + # Lint bootstrap charts and verify tracked bootstrap artifacts are in sync and free of embedded secret material. validate: uv run scripts/render_bootstrap.py validate +# Verify tracked RGD bundle artifacts are in sync and internally consistent. +validate-rgds: + uv run scripts/render_rgds.py validate + # Render tracked artifacts and verify they are current. -check: render validate +check: render render-rgds validate validate-rgds # Show the configured bootstrap components and render lanes. list: uv run scripts/render_bootstrap.py list + +# Show the configured RGD bundles and tracked renders. +list-rgds: + uv run scripts/render_rgds.py list diff --git a/cue.mod/module.cue b/cue.mod/module.cue new file mode 100644 index 0000000..7c89c75 --- /dev/null +++ b/cue.mod/module.cue @@ -0,0 +1,4 @@ +module: "github.com/gilmanlab/platform" +language: { + version: "v0.16.0" +} diff --git a/moon.yml b/moon.yml index 5e1ae36..8363179 100644 --- a/moon.yml +++ b/moon.yml @@ -13,11 +13,16 @@ workspace: tasks: check: - command: 'uv run scripts/render_bootstrap.py validate' + script: | + uv run scripts/render_bootstrap.py validate + uv run scripts/render_rgds.py validate toolchains: 'system' inputs: - 'bootstrap/**/*' + - 'cue.mod/**/*' + - 'rgds/**/*' - 'scripts/render_bootstrap.py' + - 'scripts/render_rgds.py' options: cache: false runInCI: true diff --git a/release-please-config.json b/release-please-config.json index 9ddd70c..d67e002 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -54,6 +54,16 @@ "CHANGELOG.md", "VERSION" ] + }, + "rgds/platform": { + "component": "platform-rgds", + "include-component-in-tag": true, + "changelog-path": "CHANGELOG.md", + "version-file": "VERSION", + "exclude-paths": [ + "CHANGELOG.md", + "VERSION" + ] } }, "changelog-sections": [ diff --git a/rgds/platform/CHANGELOG.md b/rgds/platform/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/rgds/platform/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/rgds/platform/VERSION b/rgds/platform/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/rgds/platform/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/rgds/platform/bundle.cue b/rgds/platform/bundle.cue new file mode 100644 index 0000000..0debc17 --- /dev/null +++ b/rgds/platform/bundle.cue @@ -0,0 +1,14 @@ +package platformrgds + +bundle: { + name: "platform-rgds" + package: "rgds/platform" + artifact: "ghcr.io/gilmanlab/platform/rgds/platform-rgds" + rgdName: "platform" + api: { + group: "platform.gilman.io" + version: "v1alpha1" + kind: "Platform" + scope: "Cluster" + } +} diff --git a/rgds/platform/capabilities/dns/schema.cue b/rgds/platform/capabilities/dns/schema.cue new file mode 100644 index 0000000..5d4495a --- /dev/null +++ b/rgds/platform/capabilities/dns/schema.cue @@ -0,0 +1,5 @@ +package dns + +Spec: { + zone: "string | required=true | description=\"Authoritative DNS zone for platform-managed records.\"" +} diff --git a/rgds/platform/capabilities/tls/schema.cue b/rgds/platform/capabilities/tls/schema.cue new file mode 100644 index 0000000..30a7ebd --- /dev/null +++ b/rgds/platform/capabilities/tls/schema.cue @@ -0,0 +1,5 @@ +package tls + +Spec: { + clusterIssuer: "string | required=true | description=\"cert-manager ClusterIssuer used for platform-managed certificates.\"" +} diff --git a/rgds/platform/platform.cue b/rgds/platform/platform.cue new file mode 100644 index 0000000..1fa8684 --- /dev/null +++ b/rgds/platform/platform.cue @@ -0,0 +1,27 @@ +package platformrgds + +import ( + dnscap "github.com/gilmanlab/platform/rgds/platform/capabilities/dns" + tlscap "github.com/gilmanlab/platform/rgds/platform/capabilities/tls" +) + +output: { + apiVersion: "kro.run/v1alpha1" + kind: "ResourceGraphDefinition" + metadata: { + name: bundle.rgdName + } + spec: { + schema: { + apiVersion: bundle.api.version + kind: bundle.api.kind + group: bundle.api.group + scope: bundle.api.scope + spec: { + dns: dnscap.Spec + tls: tlscap.Spec + } + } + resources: [] + } +} diff --git a/rgds/platform/render/platform-rgds.yaml b/rgds/platform/render/platform-rgds.yaml new file mode 100644 index 0000000..6e318b3 --- /dev/null +++ b/rgds/platform/render/platform-rgds.yaml @@ -0,0 +1,24 @@ +# Generated by scripts/render_rgds.py; do not edit by hand. +# Bundle: platform-rgds +# Package: rgds/platform +# Artifact: ghcr.io/gilmanlab/platform/rgds/platform-rgds@0.1.0 +# API: Platform.platform.gilman.io/v1alpha1 +--- +apiVersion: kro.run/v1alpha1 +kind: ResourceGraphDefinition +metadata: + name: platform +spec: + schema: + apiVersion: v1alpha1 + kind: Platform + group: platform.gilman.io + scope: Cluster + spec: + dns: + zone: string | required=true | description="Authoritative DNS zone for platform-managed + records." + tls: + clusterIssuer: string | required=true | description="cert-manager ClusterIssuer + used for platform-managed certificates." + resources: [] diff --git a/scripts/render_rgds.py b/scripts/render_rgds.py new file mode 100644 index 0000000..a788cf2 --- /dev/null +++ b/scripts/render_rgds.py @@ -0,0 +1,358 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "PyYAML>=6.0,<7", +# ] +# /// +"""Render and validate tracked RGD bundles from repo conventions.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import shutil +import subprocess +import sys +from typing import Any + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED_TOOLS = ("cue",) +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + + +class RgdError(RuntimeError): + """Raised when RGD inputs or rendering steps are invalid.""" + + +@dataclass(frozen=True) +class BundleMetadata: + name: str + package: str + artifact: str + rgd_name: str + api_group: str + api_version: str + api_kind: str + api_scope: str + + +@dataclass(frozen=True) +class Bundle: + key: str + directory: Path + version_path: Path + changelog_path: Path + render_output: Path + metadata_input: Path + metadata_expression: str + output_expression: str + version: str + metadata: BundleMetadata + + +BUNDLE_METADATA: dict[str, dict[str, str]] = { + "platform": { + "directory": "rgds/platform", + "metadata_input": "bundle.cue", + "render_output": "render/platform-rgds.yaml", + "metadata_expression": "bundle", + "output_expression": "output", + }, +} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="Show configured RGD bundles") + list_parser.add_argument("--bundle", help="Restrict output to one bundle") + + render_parser = subparsers.add_parser("render", help="Render tracked RGD bundles") + render_parser.add_argument("--bundle", help="Restrict output to one bundle") + + validate_parser = subparsers.add_parser( + "validate", help="Verify tracked RGD bundles are current and internally consistent" + ) + validate_parser.add_argument("--bundle", help="Restrict validation to one bundle") + + args = parser.parse_args() + + try: + ensure_tools() + bundles = load_bundles() + selected = select_bundles(bundles, args.bundle) + + if args.command == "list": + list_bundles(selected) + elif args.command == "render": + render_bundles(selected) + elif args.command == "validate": + validate_bundles(selected) + else: + raise RgdError(f"unsupported command: {args.command}") + except RgdError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + return 0 + + +def ensure_tools() -> None: + missing = [tool for tool in REQUIRED_TOOLS if shutil.which(tool) is None] + if missing: + raise RgdError(f"missing required tool(s): {', '.join(missing)}") + + +def run(cmd: list[str], *, cwd: Path | None = None) -> str: + try: + result = subprocess.run(cmd, check=True, text=True, capture_output=True, cwd=cwd) + except subprocess.CalledProcessError as exc: + message = exc.stderr.strip() or exc.stdout.strip() or "command failed" + raise RgdError(f"{' '.join(cmd)}: {message}") from exc + + return result.stdout + + +def load_bundles() -> dict[str, Bundle]: + bundles: dict[str, Bundle] = {} + for key, config in sorted(BUNDLE_METADATA.items()): + directory = ROOT / config["directory"] + version_path = directory / "VERSION" + changelog_path = directory / "CHANGELOG.md" + metadata_input = directory / config["metadata_input"] + render_output = directory / config["render_output"] + + if not directory.is_dir(): + raise RgdError(f"bundle directory does not exist: {directory}") + if not version_path.is_file(): + raise RgdError(f"missing VERSION file: {version_path}") + if not changelog_path.is_file(): + raise RgdError(f"missing CHANGELOG.md: {changelog_path}") + + version = version_path.read_text(encoding="utf-8").strip() + if not SEMVER_RE.match(version): + raise RgdError(f"{version_path} must contain a semantic version") + + changelog_header = changelog_path.read_text(encoding="utf-8").splitlines() + if not changelog_header or changelog_header[0] != "# Changelog": + raise RgdError(f"{changelog_path} must begin with '# Changelog'") + + metadata = load_bundle_metadata(metadata_input, config["metadata_expression"]) + + bundles[key] = Bundle( + key=key, + directory=directory, + version_path=version_path, + changelog_path=changelog_path, + render_output=render_output, + metadata_input=metadata_input, + metadata_expression=config["metadata_expression"], + output_expression=config["output_expression"], + version=version, + metadata=metadata, + ) + + return bundles + + +def load_bundle_metadata(metadata_input: Path, expression: str) -> BundleMetadata: + raw = run( + [ + "cue", + "export", + f"./{metadata_input.relative_to(ROOT)}", + "--out", + "yaml", + "--expression", + expression, + ], + cwd=ROOT, + ) + document = load_yaml_text(raw) + + try: + name = str(document["name"]) + package = str(document["package"]) + artifact = str(document["artifact"]) + rgd_name = str(document["rgdName"]) + api = document["api"] + if not isinstance(api, dict): + raise TypeError("bundle.api must be a mapping") + api_group = str(api["group"]) + api_version = str(api["version"]) + api_kind = str(api["kind"]) + api_scope = str(api["scope"]) + except (KeyError, TypeError) as exc: + raise RgdError(f"bundle metadata in {metadata_input} is incomplete: {exc}") from exc + + return BundleMetadata( + name=name, + package=package, + artifact=artifact, + rgd_name=rgd_name, + api_group=api_group, + api_version=api_version, + api_kind=api_kind, + api_scope=api_scope, + ) + + +def load_yaml_text(text: str) -> dict[str, Any]: + try: + document = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise RgdError(f"failed to parse YAML output: {exc}") from exc + if not isinstance(document, dict): + raise RgdError("expected YAML mapping output") + return document + + +def select_bundles(bundles: dict[str, Bundle], key: str | None) -> list[Bundle]: + if key is None: + return [bundles[name] for name in sorted(bundles)] + if key not in bundles: + raise RgdError(f"unknown bundle: {key}") + return [bundles[key]] + + +def list_bundles(bundles: list[Bundle]) -> None: + for bundle in bundles: + metadata = bundle.metadata + print( + f"{bundle.key}\t{metadata.package}@{bundle.version}\t" + f"{metadata.api_kind}.{metadata.api_group}/{metadata.api_version}\t" + f"{metadata.artifact}" + ) + print(f" - render: tracked -> {bundle.render_output.relative_to(bundle.directory)}") + + +def render_bundles(bundles: list[Bundle]) -> None: + for bundle in bundles: + text = render_bundle_text(bundle) + bundle.render_output.parent.mkdir(parents=True, exist_ok=True) + bundle.render_output.write_text(text, encoding="utf-8") + print(f"rendered {bundle.key}: {bundle.render_output.relative_to(ROOT)}") + + +def validate_bundles(bundles: list[Bundle]) -> None: + for bundle in bundles: + expected = render_bundle_text(bundle) + if not bundle.render_output.is_file(): + raise RgdError(f"tracked render is missing: {bundle.render_output}") + + current = bundle.render_output.read_text(encoding="utf-8") + if current != expected: + raise RgdError( + f"tracked render is out of date: {bundle.render_output.relative_to(ROOT)}; " + "run `just render-rgds`" + ) + + documents = [doc for doc in yaml.safe_load_all(current) if doc is not None] + if len(documents) != 1: + raise RgdError( + f"tracked render must contain exactly one document: " + f"{bundle.render_output.relative_to(ROOT)}" + ) + + validate_rendered_document(bundle, documents[0]) + print(f"validated {bundle.key}: {bundle.render_output.relative_to(ROOT)}") + + +def render_bundle_text(bundle: Bundle) -> str: + raw = run( + [ + "cue", + "export", + f"./{bundle.directory.relative_to(ROOT)}", + "--out", + "yaml", + "--expression", + bundle.output_expression, + ], + cwd=ROOT, + ) + + try: + documents = [doc for doc in yaml.safe_load_all(raw) if doc is not None] + except yaml.YAMLError as exc: + raise RgdError(f"failed to parse rendered YAML for {bundle.key}: {exc}") from exc + + if not documents: + raise RgdError(f"rendered output is empty for {bundle.key}") + + header = [ + "# Generated by scripts/render_rgds.py; do not edit by hand.", + f"# Bundle: {bundle.metadata.name}", + f"# Package: {bundle.metadata.package}", + f"# Artifact: {bundle.metadata.artifact}@{bundle.version}", + ( + f"# API: {bundle.metadata.api_kind}." + f"{bundle.metadata.api_group}/{bundle.metadata.api_version}" + ), + "", + ] + body = yaml.safe_dump_all( + documents, + explicit_start=True, + sort_keys=False, + default_flow_style=False, + ) + return "\n".join(header) + body + + +def validate_rendered_document(bundle: Bundle, document: dict[str, Any]) -> None: + metadata = bundle.metadata + + if document.get("apiVersion") != "kro.run/v1alpha1": + raise RgdError(f"{bundle.key} render must use apiVersion kro.run/v1alpha1") + if document.get("kind") != "ResourceGraphDefinition": + raise RgdError(f"{bundle.key} render must use kind ResourceGraphDefinition") + + rendered_metadata = document.get("metadata") + if not isinstance(rendered_metadata, dict): + raise RgdError(f"{bundle.key} render must include metadata") + if rendered_metadata.get("name") != metadata.rgd_name: + raise RgdError(f"{bundle.key} render must set metadata.name={metadata.rgd_name}") + + spec = document.get("spec") + if not isinstance(spec, dict): + raise RgdError(f"{bundle.key} render must include spec") + + schema = spec.get("schema") + if not isinstance(schema, dict): + raise RgdError(f"{bundle.key} render must include spec.schema") + + if schema.get("apiVersion") != metadata.api_version: + raise RgdError(f"{bundle.key} render must set spec.schema.apiVersion={metadata.api_version}") + if schema.get("group") != metadata.api_group: + raise RgdError(f"{bundle.key} render must set spec.schema.group={metadata.api_group}") + if schema.get("kind") != metadata.api_kind: + raise RgdError(f"{bundle.key} render must set spec.schema.kind={metadata.api_kind}") + if schema.get("scope") != metadata.api_scope: + raise RgdError(f"{bundle.key} render must set spec.schema.scope={metadata.api_scope}") + + schema_spec = schema.get("spec") + if not isinstance(schema_spec, dict): + raise RgdError(f"{bundle.key} render must include spec.schema.spec") + + dns = schema_spec.get("dns") + tls = schema_spec.get("tls") + if not isinstance(dns, dict) or "zone" not in dns: + raise RgdError(f"{bundle.key} render must include spec.schema.spec.dns.zone") + if not isinstance(tls, dict) or "clusterIssuer" not in tls: + raise RgdError(f"{bundle.key} render must include spec.schema.spec.tls.clusterIssuer") + + resources = spec.get("resources") + if not isinstance(resources, list): + raise RgdError(f"{bundle.key} render must include spec.resources") + + +if __name__ == "__main__": + raise SystemExit(main())