diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 916c0c6e..10a16176 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,12 +21,25 @@ jobs: steps: - name: Check out repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Install static analysis tools run: sudo apt-get update && sudo apt-get install -y shellcheck - name: Check shell scripts - run: shellcheck scripts/*.sh examples/project/scripts/ci/*.sh + run: shellcheck scripts/*.sh examples/project/scripts/ci/*.sh templates/config-repository/scripts/*.sh - name: Build and inspect inert prototype run: scripts/validate.sh + + - name: Validate public configuration template + run: | + python3 templates/config-repository/scripts/test_policy.py + templates/config-repository/scripts/validate.sh + templates/config-repository/scripts/validate.sh --config templates/config-repository/examples/multi-host/fleet.json + temporary_directory="$(mktemp -d)" + templates/config-repository/scripts/init.sh \ + --organization test-company \ + --project test-app \ + --output "${temporary_directory}/fleet.json" diff --git a/README.md b/README.md index de4e431b..59631f46 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Never commit credentials or real deployment configuration. See [SECURITY.md](SEC - [Architecture](docs/ARCHITECTURE.md) - [Controller decision record](docs/adr/0001-actions-scale-set-client.md) +- [Public engine / private configuration decision](docs/adr/0002-public-engine-private-configuration.md) - [Experimental deployment prototype](docs/DEPLOYMENT-PROTOTYPE.md) - [Live pilot runbook](docs/LIVE-PILOT.md) - [Host maintenance standard](docs/HOST-MAINTENANCE.md) @@ -98,6 +99,13 @@ Never commit credentials or real deployment configuration. See [SECURITY.md](SEC - [Agent instructions](AGENTS.md) - [Third-party notices](THIRD_PARTY_NOTICES.md) +### Configuration template + +- [Public configuration-repository scaffold](templates/config-repository/README.md) +- [Configuration-template milestone](https://github.com/RandomDevelopment/ci-fleet/issues/12) + +The scaffold is validated in this repository before being published as the standalone `ci-fleet-config-template` GitHub template. It contains fictional data only; generated organization configuration should normally be private. + ### Copyable examples - [Experimental read-only workflow](examples/workflows/experimental-smoke.yml.example) diff --git a/docs/adr/0002-public-engine-private-configuration.md b/docs/adr/0002-public-engine-private-configuration.md new file mode 100644 index 00000000..7faf4a62 --- /dev/null +++ b/docs/adr/0002-public-engine-private-configuration.md @@ -0,0 +1,38 @@ +# ADR 0002: Public delivery engine with private organization configuration + +- Status: Accepted +- Date: 2026-07-13 +- Tracks: [#12](https://github.com/RandomDevelopment/ci-fleet/issues/12) + +## Context + +ci-fleet is expanding from runner lifecycle management into a container delivery standard. The reusable engine benefits from public documentation, review, examples, and an Unlicense release. Real deployments contain organization-specific topology and policy that should not be published. Neither public nor private Git repositories are appropriate secret stores. + +## Decision + +Keep ci-fleet public. Publish a separate public `ci-fleet-config-template` that users generate into private configuration repositories. Random Development will generate `rd-delivery-config` from the same public template used by everyone else. + +```mermaid +flowchart LR + F[public ci-fleet engine] --> T[public config template] + T --> R[private RD configuration] + T --> U[another user's private configuration] + R --> P[private project repositories] + U --> X[their private project repositories] +``` + +The public engine owns schemas, reusable workflows, generic controller/deployment code, validation, examples, and policy. A private configuration repository owns real repository mappings, logical host groups, environment policies, capacity, image names, and internal operating notes. + +Secret values remain in GitHub Environments, host-local root-owned files, or an external secret manager. Configuration may declare required secret names but never their values. + +## Consequences + +- Public reusable workflows must be called at immutable commit SHAs. +- Public repositories never receive access to self-hosted runners. +- Examples use fictional organizations, domains, repositories, and hosts. +- Real private configuration is validated against the same public schema. +- Publishing the controller does not weaken its security boundary; credentials, runner-group policy, environment protection, and host isolation remain authoritative. + +## Rollback + +The configuration template is additive. Organizations can stop consuming it without changing runner lifecycle code. Random Development can keep its configuration private or migrate it to another configuration system while retaining the public delivery standards. diff --git a/templates/config-repository/.github/dependabot.yml b/templates/config-repository/.github/dependabot.yml new file mode 100644 index 00000000..80049834 --- /dev/null +++ b/templates/config-repository/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/templates/config-repository/.github/workflows/validate.yml b/templates/config-repository/.github/workflows/validate.yml new file mode 100644 index 00000000..ad0549a7 --- /dev/null +++ b/templates/config-repository/.github/workflows/validate.yml @@ -0,0 +1,45 @@ +name: Validate configuration + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: config-validation-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + # This public template must never receive access to self-hosted runners. + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Validate shell and Python syntax + run: | + bash -n scripts/init.sh scripts/validate.sh + python3 -m py_compile scripts/init.py scripts/validate.py scripts/test_policy.py + + - name: Prove forbidden configurations fail closed + run: python3 scripts/test_policy.py + + - name: Validate reference configurations + run: | + ./scripts/validate.sh + ./scripts/validate.sh --config examples/multi-host/fleet.json + + - name: Prove initialized configurations pass strict policy + run: | + temporary_directory="$(mktemp -d)" + ./scripts/init.sh \ + --organization test-company \ + --project test-app \ + --output "${temporary_directory}/fleet.json" diff --git a/templates/config-repository/.gitignore b/templates/config-repository/.gitignore new file mode 100644 index 00000000..5d5509a7 --- /dev/null +++ b/templates/config-repository/.gitignore @@ -0,0 +1,14 @@ +.env +.env.* +!.env.example +*.key +*.pem +*.p12 +*.pfx +*.keystore +credentials/ +secrets/ +private/ +*.local.json +__pycache__/ +*.pyc diff --git a/templates/config-repository/AGENTS.md b/templates/config-repository/AGENTS.md new file mode 100644 index 00000000..c2408e00 --- /dev/null +++ b/templates/config-repository/AGENTS.md @@ -0,0 +1,23 @@ +# Agent instructions + +## Purpose + +This repository maps projects to CI pools, deployment environments, logical host groups, and container images. It never stores secret values. + +## Required verification + +Before committing configuration changes, run: + +```bash +./scripts/validate.sh --strict +``` + +## Hard rules + +- Never add real `.env` files, credentials, tokens, private keys, cookies, or passwords. +- Do not weaken `public_repositories: false` for Docker-socket runner pools. +- Production environments must require approval and must not deploy automatically. +- CI runner hosts and application deployment hosts are separate roles. +- Image promotion uses immutable digests; do not rebuild separately for production. +- Reusable workflows must be pinned to a full reviewed commit SHA. +- Update schema and validator together. diff --git a/templates/config-repository/LICENSE b/templates/config-repository/LICENSE new file mode 100644 index 00000000..fdddb29a --- /dev/null +++ b/templates/config-repository/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/templates/config-repository/README.md b/templates/config-repository/README.md new file mode 100644 index 00000000..161ede75 --- /dev/null +++ b/templates/config-repository/README.md @@ -0,0 +1,75 @@ +# ci-fleet configuration template + +This is the public, secret-free starting point for an organization's private `ci-fleet` configuration repository. It records which trusted projects may use each CI pool, which logical deployment environments exist, and the standardized commands every project must expose. + +It does **not** contain runner registration tokens, deploy credentials, private keys, host addresses, or `.env` files. + +```mermaid +flowchart LR + E[Public ci-fleet engine] -->|pinned reusable workflow| P[Private project repos] + C[Private config created from this template] -->|logical policy| P + P -->|fast / full| R[Trusted Docker CI pool] + P -->|approved image digest| D[Development hosts] + P -->|manual approval + image digest| X[Production hosts] + S[GitHub Environments / host secret store] -. secret values .-> P + + classDef public fill:#dff4ff,stroke:#1570a6,color:#102a43 + classDef private fill:#fff3cd,stroke:#9a6700,color:#3d2b00 + class E public + class C,P,R,D,X,S private +``` + +## Start a private organization configuration + +1. Create a **private** repository from this public template. +2. Clone it and initialize the first project: + + ```bash + ./scripts/init.sh --organization your-org --project your-app + ``` + +3. Edit `fleet.json` to add the organization's real logical mappings. +4. Run the strict policy check: + + ```bash + ./scripts/validate.sh --strict + ``` + +5. Configure secret **values** in GitHub Environments or the deployment host's secret manager. The repository stores only names such as `DEPLOY_AUTH`. + +The initializer refuses to replace a configured file unless `--force` is explicit. Run `./scripts/init.sh --help` for repository, registry, label, and output options. + +## Hard rules + +- Public repositories never receive access to the trusted self-hosted runner pool. +- Every project implements exactly `./scripts/ci/run.sh fast` and `./scripts/ci/run.sh full`; each command runs the project's own Docker-defined test environment. +- CI runner pools and deployment host groups are separate trust roles. +- Production deployment is manual and requires GitHub Environment approval. +- Reusable workflows and third-party actions are pinned to immutable commits. +- Configuration contains logical identifiers only. Secret values, private host details, and credentials never enter Git. +- Promoted artifacts are container image digests; production does not rebuild a different image. + +`fleet.schema.json` provides editor completion and structural documentation. `scripts/validate.py` is the authoritative dependency-free policy check, including relationships JSON Schema cannot express clearly. + +## Repository map + +| Path | Purpose | +|---|---| +| `fleet.json` | Fictional, valid reference configuration to initialize or replace | +| `fleet.schema.json` | JSON Schema draft 2020-12 editor contract | +| `scripts/init.sh` | Safe first-project initializer | +| `scripts/validate.sh` | Structural, policy, and secret-boundary validation | +| `scripts/test_policy.py` | Regression tests proving unsafe configurations fail closed | +| `examples/multi-host/fleet.json` | Fictional two-project, multi-host topology | +| `SECURITY.md` | Secret handling and vulnerability reporting | +| `AGENTS.md` | Non-negotiable rules for humans and coding agents | + +## Public and private boundary + +| Safe in this public template | Belongs in the private config repo | Belongs only in a secret store | +|---|---|---| +| Schema, validator, fictional examples | Real repository names and logical host-group names | Tokens, passwords, private keys | +| Standard CI entrypoint names | Environment policy and allowed repository lists | Host addresses and SSH material | +| Reusable workflow references | Required secret **names** | `.env` contents and app credentials | + +The public engine and this template use the [Unlicense](LICENSE). See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) before copying third-party material into a derived repository. diff --git a/templates/config-repository/SECURITY.md b/templates/config-repository/SECURITY.md new file mode 100644 index 00000000..62a91237 --- /dev/null +++ b/templates/config-repository/SECURITY.md @@ -0,0 +1,9 @@ +# Security policy + +This repository stores deployment relationships and policy, not credentials. + +Never commit passwords, tokens, private keys, GitHub App keys, SSH keys, TLS keys, production `.env` files, database connection strings containing credentials, cookies, or cloud credentials. A private repository is not a secret manager. + +Configuration may list required secret **names**, such as `DATABASE_URL`, because their values live in GitHub Environments, root-owned host files, or an external secret manager. + +If a secret is committed, revoke or rotate it immediately before removing it from Git history. Treat deletion from the latest commit as insufficient. diff --git a/templates/config-repository/THIRD_PARTY_NOTICES.md b/templates/config-repository/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..f8978ea7 --- /dev/null +++ b/templates/config-repository/THIRD_PARTY_NOTICES.md @@ -0,0 +1,7 @@ +# Third-party notices + +No third-party source code is vendored in this template. + +The generated configuration is designed to interoperate with GitHub Actions, Docker Engine, Docker Compose, and ci-fleet. Those projects retain their respective licenses. + +Original template files are released under the Unlicense in `LICENSE`. diff --git a/templates/config-repository/examples/multi-host/fleet.json b/templates/config-repository/examples/multi-host/fleet.json new file mode 100644 index 00000000..6690e2cd --- /dev/null +++ b/templates/config-repository/examples/multi-host/fleet.json @@ -0,0 +1,74 @@ +{ + "$schema": "../../fleet.schema.json", + "schema_version": 1, + "organization": { + "slug": "sample-company", + "registry": "ghcr.io/sample-company", + "delivery_engine": "RandomDevelopment/ci-fleet", + "workflow_ref_policy": "immutable-commit" + }, + "runner_pools": { + "trusted-ci": { + "routing_labels": ["docker-ci"], + "allowed_repositories": ["sample-company/api", "sample-company/web"], + "public_repositories": false, + "max_concurrent_jobs": 4 + } + }, + "host_groups": { + "development-east": { + "role": "deployment", + "environment_class": "development" + }, + "staging-east": { + "role": "deployment", + "environment_class": "staging" + }, + "production-primary": { + "role": "deployment", + "environment_class": "production" + } + }, + "environments": { + "development": { + "host_group": "development-east", + "automatic": true, + "requires_approval": false, + "required_secret_names": ["DEPLOY_AUTH"] + }, + "staging": { + "host_group": "staging-east", + "automatic": false, + "requires_approval": true, + "required_secret_names": ["DEPLOY_AUTH"] + }, + "production": { + "host_group": "production-primary", + "automatic": false, + "requires_approval": true, + "required_secret_names": ["DEPLOY_AUTH"] + } + }, + "projects": { + "api": { + "repository": "sample-company/api", + "image": "ghcr.io/sample-company/api", + "ci_pool": "trusted-ci", + "ci_entrypoints": { + "fast": "./scripts/ci/run.sh fast", + "full": "./scripts/ci/run.sh full" + }, + "deployments": ["development", "staging", "production"] + }, + "web": { + "repository": "sample-company/web", + "image": "ghcr.io/sample-company/web", + "ci_pool": "trusted-ci", + "ci_entrypoints": { + "fast": "./scripts/ci/run.sh fast", + "full": "./scripts/ci/run.sh full" + }, + "deployments": ["development", "production"] + } + } +} diff --git a/templates/config-repository/fleet.json b/templates/config-repository/fleet.json new file mode 100644 index 00000000..36f33c65 --- /dev/null +++ b/templates/config-repository/fleet.json @@ -0,0 +1,54 @@ +{ + "$schema": "./fleet.schema.json", + "schema_version": 1, + "organization": { + "slug": "example-org", + "registry": "ghcr.io/example-org", + "delivery_engine": "RandomDevelopment/ci-fleet", + "workflow_ref_policy": "immutable-commit" + }, + "runner_pools": { + "trusted-ci": { + "routing_labels": ["docker-ci"], + "allowed_repositories": ["example-org/example-app"], + "public_repositories": false, + "max_concurrent_jobs": 1 + } + }, + "host_groups": { + "development-apps": { + "role": "deployment", + "environment_class": "development" + }, + "production-apps": { + "role": "deployment", + "environment_class": "production" + } + }, + "environments": { + "development": { + "host_group": "development-apps", + "automatic": true, + "requires_approval": false, + "required_secret_names": ["DEPLOY_AUTH"] + }, + "production": { + "host_group": "production-apps", + "automatic": false, + "requires_approval": true, + "required_secret_names": ["DEPLOY_AUTH"] + } + }, + "projects": { + "example-app": { + "repository": "example-org/example-app", + "image": "ghcr.io/example-org/example-app", + "ci_pool": "trusted-ci", + "ci_entrypoints": { + "fast": "./scripts/ci/run.sh fast", + "full": "./scripts/ci/run.sh full" + }, + "deployments": ["development", "production"] + } + } +} diff --git a/templates/config-repository/fleet.schema.json b/templates/config-repository/fleet.schema.json new file mode 100644 index 00000000..f00e36d3 --- /dev/null +++ b/templates/config-repository/fleet.schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/RandomDevelopment/ci-fleet-config-template/fleet.schema.json", + "title": "ci-fleet organization configuration", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "organization", "runner_pools", "host_groups", "environments", "projects"], + "properties": { + "$schema": {"type": "string"}, + "schema_version": {"const": 1}, + "organization": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "registry", "delivery_engine", "workflow_ref_policy"], + "properties": { + "slug": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,38}$"}, + "registry": {"type": "string", "pattern": "^[a-z0-9.-]+/[a-z0-9._/-]+$"}, + "delivery_engine": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "workflow_ref_policy": {"const": "immutable-commit"} + } + }, + "runner_pools": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"$ref": "#/$defs/runner_pool"} + }, + "host_groups": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"$ref": "#/$defs/host_group"} + }, + "environments": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"$ref": "#/$defs/environment"} + }, + "projects": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"$ref": "#/$defs/project"} + } + }, + "$defs": { + "runner_pool": { + "type": "object", + "additionalProperties": false, + "required": ["routing_labels", "allowed_repositories", "public_repositories", "max_concurrent_jobs"], + "properties": { + "routing_labels": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"}}, + "allowed_repositories": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}}, + "public_repositories": {"const": false}, + "max_concurrent_jobs": {"type": "integer", "minimum": 1} + } + }, + "host_group": { + "type": "object", + "additionalProperties": false, + "required": ["role", "environment_class"], + "properties": { + "role": {"const": "deployment"}, + "environment_class": {"enum": ["development", "staging", "production"]} + } + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": ["host_group", "automatic", "requires_approval", "required_secret_names"], + "properties": { + "host_group": {"type": "string"}, + "automatic": {"type": "boolean"}, + "requires_approval": {"type": "boolean"}, + "required_secret_names": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*$"}} + } + }, + "project": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "image", "ci_pool", "ci_entrypoints", "deployments"], + "properties": { + "repository": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "image": {"type": "string", "pattern": "^[a-z0-9.-]+/[a-z0-9._/-]+$"}, + "ci_pool": {"type": "string"}, + "ci_entrypoints": { + "type": "object", + "additionalProperties": false, + "required": ["fast", "full"], + "properties": { + "fast": {"const": "./scripts/ci/run.sh fast"}, + "full": {"const": "./scripts/ci/run.sh full"} + } + }, + "deployments": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}} + } + } + } +} diff --git a/templates/config-repository/scripts/init.py b/templates/config-repository/scripts/init.py new file mode 100755 index 00000000..4d52134a --- /dev/null +++ b/templates/config-repository/scripts/init.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Initialize a ci-fleet configuration repository for one private project.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ORG_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,38}$") +PROJECT_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--organization", required=True, help="GitHub organization slug") + parser.add_argument("--project", required=True, help="initial project slug") + parser.add_argument("--repository", help="owner/repository; defaults to ORGANIZATION/PROJECT") + parser.add_argument("--registry", help="registry namespace; defaults to ghcr.io/ORGANIZATION") + parser.add_argument("--runner-label", default="docker-ci", help="capability label for the shared CI pool") + parser.add_argument("--output", type=Path, default=ROOT / "fleet.json", help="output configuration path") + parser.add_argument("--force", action="store_true", help="replace an existing non-example output file") + return parser.parse_args() + + +def fail(message: str) -> None: + raise SystemExit(f"ERROR: {message}") + + +def main() -> int: + args = parse_args() + if not ORG_SLUG.fullmatch(args.organization): + fail("--organization must be a lowercase GitHub organization slug") + if not PROJECT_SLUG.fullmatch(args.project): + fail("--project must be a lowercase slug") + if not PROJECT_SLUG.fullmatch(args.runner_label): + fail("--runner-label must be a lowercase slug") + + repository = args.repository or f"{args.organization}/{args.project}" + registry = (args.registry or f"ghcr.io/{args.organization}").rstrip("/") + output = args.output.resolve() + if output.exists() and not args.force: + try: + current = json.loads(output.read_text(encoding="utf-8")) + example = current.get("organization", {}).get("slug") == "example-org" + except (json.JSONDecodeError, AttributeError): + example = False + if not example: + fail(f"{output} already exists; pass --force only if replacement is intentional") + + config = { + "$schema": str((ROOT / "fleet.schema.json").resolve()) if output.parent != ROOT else "./fleet.schema.json", + "schema_version": 1, + "organization": { + "slug": args.organization, + "registry": registry, + "delivery_engine": "RandomDevelopment/ci-fleet", + "workflow_ref_policy": "immutable-commit", + }, + "runner_pools": { + "trusted-ci": { + "routing_labels": [args.runner_label], + "allowed_repositories": [repository], + "public_repositories": False, + "max_concurrent_jobs": 1, + } + }, + "host_groups": { + "development-apps": {"role": "deployment", "environment_class": "development"}, + "production-apps": {"role": "deployment", "environment_class": "production"}, + }, + "environments": { + "development": { + "host_group": "development-apps", + "automatic": True, + "requires_approval": False, + "required_secret_names": ["DEPLOY_AUTH"], + }, + "production": { + "host_group": "production-apps", + "automatic": False, + "requires_approval": True, + "required_secret_names": ["DEPLOY_AUTH"], + }, + }, + "projects": { + args.project: { + "repository": repository, + "image": f"{registry}/{args.project}", + "ci_pool": "trusted-ci", + "ci_entrypoints": { + "fast": "./scripts/ci/run.sh fast", + "full": "./scripts/ci/run.sh full", + }, + "deployments": ["development", "production"], + } + }, + } + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + + subprocess.run( + [str(ROOT / "scripts" / "validate.sh"), "--strict", "--skip-path-scan", "--config", str(output)], + check=True, + ) + print(f"Initialized {output}") + print("Next: edit logical mappings, configure GitHub Environments, and keep every secret value outside Git.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/config-repository/scripts/init.sh b/templates/config-repository/scripts/init.sh new file mode 100755 index 00000000..daefea68 --- /dev/null +++ b/templates/config-repository/scripts/init.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "${script_dir}/init.py" "$@" diff --git a/templates/config-repository/scripts/test_policy.py b/templates/config-repository/scripts/test_policy.py new file mode 100755 index 00000000..5fbc3ac8 --- /dev/null +++ b/templates/config-repository/scripts/test_policy.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Regression tests for ci-fleet's non-negotiable configuration policies.""" + +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from validate import Validation, scan_secret_material, validate_config + + +ROOT = Path(__file__).resolve().parents[1] + + +def reference_config() -> dict: + return json.loads((ROOT / "fleet.json").read_text(encoding="utf-8")) + + +def errors_for(config: dict, *, strict: bool = False) -> list[str]: + validation = Validation() + scan_secret_material(config, validation) + validate_config(config, validation, strict) + return validation.errors + + +class PolicyTests(unittest.TestCase): + def assert_rejected(self, config: dict, expected: str, *, strict: bool = False) -> None: + errors = errors_for(config, strict=strict) + self.assertTrue(any(expected in error for error in errors), errors) + + def test_reference_configuration_is_valid(self) -> None: + self.assertEqual(errors_for(reference_config()), []) + + def test_public_repository_access_is_rejected(self) -> None: + config = copy.deepcopy(reference_config()) + config["runner_pools"]["trusted-ci"]["public_repositories"] = True + self.assert_rejected(config, "trusted private repositories") + + def test_automatic_production_is_rejected(self) -> None: + config = copy.deepcopy(reference_config()) + config["environments"]["production"]["automatic"] = True + self.assert_rejected(config, "production deployment must not be automatic") + + def test_unapproved_production_is_rejected(self) -> None: + config = copy.deepcopy(reference_config()) + config["environments"]["production"]["requires_approval"] = False + self.assert_rejected(config, "production deployment must require approval") + + def test_repository_must_be_in_pool_allowlist(self) -> None: + config = copy.deepcopy(reference_config()) + config["runner_pools"]["trusted-ci"]["allowed_repositories"] = ["example-org/other-app"] + self.assert_rejected(config, "explicitly allowed by its CI pool") + + def test_embedded_credential_url_is_rejected(self) -> None: + config = copy.deepcopy(reference_config()) + config["organization"]["database_url"] = "postgres://user:password@db.example.invalid/app" + self.assert_rejected(config, "probable secret material") + + def test_secret_value_key_is_rejected(self) -> None: + config = copy.deepcopy(reference_config()) + config["environments"]["development"]["token"] = "not-a-real-token" + self.assert_rejected(config, "secret values are forbidden") + + def test_strict_mode_rejects_unchanged_example(self) -> None: + self.assert_rejected(reference_config(), "replace the example organization", strict=True) + + def test_nonstandard_ci_entrypoint_is_rejected(self) -> None: + config = copy.deepcopy(reference_config()) + config["projects"]["example-app"]["ci_entrypoints"]["fast"] = "npm test" + self.assert_rejected(config, "standard fast entrypoint") + + +if __name__ == "__main__": + unittest.main() diff --git a/templates/config-repository/scripts/validate.py b/templates/config-repository/scripts/validate.py new file mode 100755 index 00000000..faecfb76 --- /dev/null +++ b/templates/config-repository/scripts/validate.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Validate a ci-fleet organization configuration without third-party packages.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") +ORG_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,38}$") +REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +IMAGE = re.compile(r"^[a-z0-9.-]+/[a-z0-9._/-]+$") +SECRET_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$") +HIGH_CONFIDENCE_SECRET_PATTERNS = ( + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), + re.compile(r"gh[opusr]_[A-Za-z0-9]{20,}"), + re.compile(r"AKIA[0-9A-Z]{16}"), + re.compile(r"(?:postgres|mysql|mongodb(?:\+srv)?|redis)://[^\s/:]+:[^\s/@]+@"), +) +FORBIDDEN_SECRET_KEYS = { + "access_token", + "api_key", + "credential", + "credentials", + "database_url", + "password", + "private_key", + "secret", + "secret_value", + "token", +} +FORBIDDEN_FILENAMES = re.compile(r"(?:^|/)\.env(?:\..+)?$|\.(?:key|pem|p12|pfx)$", re.IGNORECASE) +FORBIDDEN_DIRECTORIES = {"credentials", "private", "secrets"} + + +class Validation: + def __init__(self) -> None: + self.errors: list[str] = [] + + def require(self, condition: bool, path: str, message: str) -> None: + if not condition: + self.errors.append(f"{path}: {message}") + + def exact_keys(self, value: Any, path: str, required: set[str], optional: set[str] | None = None) -> bool: + if not isinstance(value, dict): + self.errors.append(f"{path}: must be an object") + return False + optional = optional or set() + keys = set(value) + missing = required - keys + unknown = keys - required - optional + if missing: + self.errors.append(f"{path}: missing keys: {', '.join(sorted(missing))}") + if unknown: + self.errors.append(f"{path}: unknown keys: {', '.join(sorted(unknown))}") + return not missing + + +def load_json(path: Path, validation: Validation) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + validation.errors.append(f"{path}: file not found") + except json.JSONDecodeError as exc: + validation.errors.append(f"{path}:{exc.lineno}:{exc.colno}: invalid JSON: {exc.msg}") + return None + + +def strings_in(value: Any, path: str = "$"): + if isinstance(value, dict): + for key, child in value.items(): + yield from strings_in(child, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + yield from strings_in(child, f"{path}[{index}]") + elif isinstance(value, str): + yield path, value + + +def scan_secret_material(config: Any, validation: Validation) -> None: + def scan_keys(value: Any, path: str = "$") -> None: + if isinstance(value, dict): + for key, child in value.items(): + if key.lower() in FORBIDDEN_SECRET_KEYS: + validation.errors.append( + f"{path}.{key}: secret values are forbidden; store only an uppercase secret name" + ) + scan_keys(child, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + scan_keys(child, f"{path}[{index}]") + + scan_keys(config) + for path, value in strings_in(config): + for pattern in HIGH_CONFIDENCE_SECRET_PATTERNS: + if pattern.search(value): + validation.errors.append(f"{path}: probable secret material is forbidden") + break + + +def scan_forbidden_paths(repo_root: Path, validation: Validation) -> None: + for path in repo_root.rglob("*"): + try: + relative = path.relative_to(repo_root) + except ValueError: + continue + if ".git" in relative.parts: + continue + relative_text = relative.as_posix() + if path.is_dir() and path.name.lower() in FORBIDDEN_DIRECTORIES: + validation.errors.append(f"{relative_text}/: secret-bearing directory names are forbidden") + elif path.is_file() and FORBIDDEN_FILENAMES.search(relative_text): + validation.errors.append(f"{relative_text}: secret-bearing files are forbidden") + + +def validate_config(config: Any, validation: Validation, strict: bool) -> None: + required_top = {"schema_version", "organization", "runner_pools", "host_groups", "environments", "projects"} + if not validation.exact_keys(config, "$", required_top, {"$schema"}): + return + + validation.require(config.get("schema_version") == 1, "$.schema_version", "must equal 1") + + organization = config.get("organization") + organization_keys = {"slug", "registry", "delivery_engine", "workflow_ref_policy"} + if validation.exact_keys(organization, "$.organization", organization_keys): + slug = organization.get("slug") + registry = organization.get("registry") + engine = organization.get("delivery_engine") + validation.require(isinstance(slug, str) and bool(ORG_SLUG.fullmatch(slug)), "$.organization.slug", "must be a lowercase GitHub organization slug") + validation.require(isinstance(registry, str) and bool(IMAGE.fullmatch(registry)), "$.organization.registry", "must be a registry namespace such as ghcr.io/acme") + validation.require(isinstance(engine, str) and bool(REPOSITORY.fullmatch(engine)), "$.organization.delivery_engine", "must be an owner/repository name") + validation.require(organization.get("workflow_ref_policy") == "immutable-commit", "$.organization.workflow_ref_policy", "must equal immutable-commit") + if strict: + validation.require(slug != "example-org", "$.organization.slug", "replace the example organization before use") + + pools = config.get("runner_pools") + if not isinstance(pools, dict) or not pools: + validation.errors.append("$.runner_pools: must be a non-empty object") + pools = {} + for name, pool in pools.items(): + path = f"$.runner_pools.{name}" + validation.require(bool(SLUG.fullmatch(name)), path, "pool name must be a lowercase slug") + if not validation.exact_keys(pool, path, {"routing_labels", "allowed_repositories", "public_repositories", "max_concurrent_jobs"}): + continue + labels = pool.get("routing_labels") + repos = pool.get("allowed_repositories") + validation.require(isinstance(labels, list) and bool(labels), f"{path}.routing_labels", "must be a non-empty list") + if isinstance(labels, list): + validation.require(len(labels) == len(set(labels)), f"{path}.routing_labels", "must contain unique labels") + for index, label in enumerate(labels): + validation.require(isinstance(label, str) and bool(SLUG.fullmatch(label)), f"{path}.routing_labels[{index}]", "must be a lowercase slug") + validation.require(str(label).lower() != "self-hosted", f"{path}.routing_labels[{index}]", "do not repeat GitHub's implicit self-hosted label") + validation.require(isinstance(repos, list) and bool(repos), f"{path}.allowed_repositories", "must be a non-empty list") + if isinstance(repos, list): + validation.require(len(repos) == len(set(repos)), f"{path}.allowed_repositories", "must contain unique repositories") + for index, repository in enumerate(repos): + validation.require(isinstance(repository, str) and bool(REPOSITORY.fullmatch(repository)), f"{path}.allowed_repositories[{index}]", "must be owner/repository") + validation.require(pool.get("public_repositories") is False, f"{path}.public_repositories", "must be false; this fleet is for trusted private repositories") + jobs = pool.get("max_concurrent_jobs") + validation.require(type(jobs) is int and jobs > 0, f"{path}.max_concurrent_jobs", "must be a positive integer") + + groups = config.get("host_groups") + if not isinstance(groups, dict) or not groups: + validation.errors.append("$.host_groups: must be a non-empty object") + groups = {} + for name, group in groups.items(): + path = f"$.host_groups.{name}" + validation.require(bool(SLUG.fullmatch(name)), path, "host group name must be a lowercase slug") + if validation.exact_keys(group, path, {"role", "environment_class"}): + validation.require(group.get("role") == "deployment", f"{path}.role", "must equal deployment; CI workers and deployment hosts are separate") + validation.require(group.get("environment_class") in {"development", "staging", "production"}, f"{path}.environment_class", "must be development, staging, or production") + + environments = config.get("environments") + if not isinstance(environments, dict) or not environments: + validation.errors.append("$.environments: must be a non-empty object") + environments = {} + for name, environment in environments.items(): + path = f"$.environments.{name}" + validation.require(bool(SLUG.fullmatch(name)), path, "environment name must be a lowercase slug") + if not validation.exact_keys(environment, path, {"host_group", "automatic", "requires_approval", "required_secret_names"}): + continue + host_group = environment.get("host_group") + validation.require(host_group in groups, f"{path}.host_group", "must reference a declared deployment host group") + validation.require(type(environment.get("automatic")) is bool, f"{path}.automatic", "must be a boolean") + validation.require(type(environment.get("requires_approval")) is bool, f"{path}.requires_approval", "must be a boolean") + names = environment.get("required_secret_names") + validation.require(isinstance(names, list), f"{path}.required_secret_names", "must be a list") + if isinstance(names, list): + validation.require(len(names) == len(set(names)), f"{path}.required_secret_names", "must contain unique names") + for index, secret_name in enumerate(names): + validation.require(isinstance(secret_name, str) and bool(SECRET_NAME.fullmatch(secret_name)), f"{path}.required_secret_names[{index}]", "must be an uppercase secret name, never a value") + if host_group in groups and groups[host_group].get("environment_class") == "production": + validation.require(environment.get("automatic") is False, f"{path}.automatic", "production deployment must not be automatic") + validation.require(environment.get("requires_approval") is True, f"{path}.requires_approval", "production deployment must require approval") + + projects = config.get("projects") + if not isinstance(projects, dict) or not projects: + validation.errors.append("$.projects: must be a non-empty object") + projects = {} + for name, project in projects.items(): + path = f"$.projects.{name}" + validation.require(bool(SLUG.fullmatch(name)), path, "project name must be a lowercase slug") + if not validation.exact_keys(project, path, {"repository", "image", "ci_pool", "ci_entrypoints", "deployments"}): + continue + repository = project.get("repository") + image = project.get("image") + pool_name = project.get("ci_pool") + validation.require(isinstance(repository, str) and bool(REPOSITORY.fullmatch(repository)), f"{path}.repository", "must be owner/repository") + validation.require(isinstance(image, str) and bool(IMAGE.fullmatch(image)), f"{path}.image", "must be a container image path without a mutable tag") + validation.require(pool_name in pools, f"{path}.ci_pool", "must reference a declared runner pool") + if pool_name in pools and isinstance(pools[pool_name].get("allowed_repositories"), list): + validation.require(repository in pools[pool_name]["allowed_repositories"], f"{path}.repository", "must be explicitly allowed by its CI pool") + entrypoints = project.get("ci_entrypoints") + if validation.exact_keys(entrypoints, f"{path}.ci_entrypoints", {"fast", "full"}): + validation.require(entrypoints.get("fast") == "./scripts/ci/run.sh fast", f"{path}.ci_entrypoints.fast", "must use the standard fast entrypoint") + validation.require(entrypoints.get("full") == "./scripts/ci/run.sh full", f"{path}.ci_entrypoints.full", "must use the standard full entrypoint") + deployments = project.get("deployments") + validation.require(isinstance(deployments, list) and bool(deployments), f"{path}.deployments", "must be a non-empty list") + if isinstance(deployments, list): + validation.require(len(deployments) == len(set(deployments)), f"{path}.deployments", "must contain unique environments") + for index, deployment in enumerate(deployments): + validation.require(deployment in environments, f"{path}.deployments[{index}]", "must reference a declared environment") + if strict: + validation.require(repository != "example-org/example-app", f"{path}.repository", "replace the example repository before use") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, default=ROOT / "fleet.json", help="configuration file to validate") + parser.add_argument("--strict", action="store_true", help="reject unchanged example values") + parser.add_argument("--skip-path-scan", action="store_true", help="skip repository path checks (for external fixtures)") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + validation = Validation() + config = load_json(args.config.resolve(), validation) + schema = load_json(ROOT / "fleet.schema.json", validation) + if schema is not None: + validation.require(schema.get("$schema") == "https://json-schema.org/draft/2020-12/schema", "fleet.schema.json.$schema", "must use JSON Schema draft 2020-12") + if config is not None: + scan_secret_material(config, validation) + validate_config(config, validation, args.strict) + if not args.skip_path_scan: + scan_forbidden_paths(ROOT, validation) + + if validation.errors: + for error in validation.errors: + print(f"ERROR: {error}", file=sys.stderr) + print(f"FAILED: {len(validation.errors)} validation error(s)", file=sys.stderr) + return 1 + print(f"OK: {args.config} satisfies the ci-fleet configuration contract") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/config-repository/scripts/validate.sh b/templates/config-repository/scripts/validate.sh new file mode 100755 index 00000000..9c2e7b4a --- /dev/null +++ b/templates/config-repository/scripts/validate.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "${script_dir}/validate.py" "$@"