Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ NV72_OTEL_BRIEF.md
.internal/
tools/sync_claude_agents.py

# Internal-only documentation (team/dev notes; external docs live in docs/)
# Private inventory is ignored except for the public go/no-go contract.
docs/internal/
inventory/
inventory/*
!inventory/ci/
inventory/ci/*
!inventory/ci/go-no-go.yaml

# macOS / stray test output
.DS_Store
Expand Down Expand Up @@ -213,5 +216,3 @@ tests/popen_test.py
# De-duplicated corpus: the redundant orig/ backup tree is never committed
tests/supermicro_gb300_corpus/json_responses/orig/
bios_firmware/


53 changes: 47 additions & 6 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
# GitLab pipeline for the homelab platform. Every job runs on the self-managed `homelab-k8s` runner
# (Kubernetes executor: one disposable pod per job — the execution authority). Gates are driven from
# gates/manifest.yaml via scripts/gates/run.sh <profile>. The meta-gate (tools/gate_meta.py) enforces
# gates/manifest.yaml through scripts/check.sh. The meta-gate (tools/gate_meta.py) enforces
# that the required jobs exist, none uses allow_failure, every job carries the homelab-k8s tag, and no
# live-apply job is reachable from a merge-request pipeline.
#
# Four pipeline shapes:
# Six pipeline shapes:
# self-managed API/web -> focused-gate only (diagnostic evidence only)
# merge-request -> gate-merge (static + unit + render; NO cluster mutation, NO production creds)
# default-branch -> gate-merge + gate-integration
# scheduled -> gate-integration (cluster smoke)
# manual protected deploy -> deploy-apply (protected branch only, manual, serialized)
# protected default branch -> publish-github (GATED outbound mirror: agent files/names/secrets
# must pass the boundary gates before anything reaches public GitHub)
workflow:
rules:
- if: >-
$CI_SERVER_HOST != "gitlab.com" &&
($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api")
variables:
FOCUSED_GATE: unit.all
- when: always

stages: [validate, integration, deploy, publish]

# The shared toolbox image is the ONLY CI image. It supplies the runtime (conda,
Expand All @@ -34,24 +44,49 @@ variables:
# pre-installed toolchain in the image.
CONDA_ENV: redfish_ctl

.focused-dispatch: &focused-dispatch
if: >-
$CI_SERVER_HOST != "gitlab.com" &&
($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api") &&
$FOCUSED_GATE != null && $FOCUSED_GATE != ""

focused-gate:
stage: validate
tags: [homelab-k8s]
rules:
- *focused-dispatch
- when: never
script:
# A focused result proves only this gate at this commit. It is not merge or release evidence.
- ./scripts/check.sh --profile merge --gate "${FOCUSED_GATE:-unit.all}"
artifacts:
when: always
paths:
- reports/gate-report.sanitized.json
- reports/gates/

gate-merge:
stage: validate
tags: [homelab-k8s]
rules:
- <<: *focused-dispatch
when: never
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- ./scripts/check.sh --list
- ./scripts/gates/run.sh merge
- ./scripts/check.sh --profile merge

gate-integration:
stage: integration
tags: [homelab-k8s]
rules:
- <<: *focused-dispatch
when: never
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_PIPELINE_SOURCE == "schedule"'
script:
- ./scripts/gates/run.sh integration
- ./scripts/check.sh --profile integration

# Live cluster checks run HERE, as a pipeline job on the in-cluster runner —
# never from a workstation. The runner pod already carries a ServiceAccount, so
Expand All @@ -65,6 +100,8 @@ k8s-live-check:
stage: integration
tags: [homelab-k8s]
rules:
- <<: *focused-dispatch
when: never
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_PIPELINE_SOURCE == "schedule"'
before_script: [] # no project env needed; this probes the cluster only
Expand All @@ -87,12 +124,14 @@ deploy-apply:
name: production
resource_group: homelab-apply
rules:
- <<: *focused-dispatch
when: never
- if: '$CI_COMMIT_REF_PROTECTED == "true"'
when: manual
variables:
MUTATION_LOCK: "$CI_JOB_ID"
script:
- ./scripts/gates/run.sh deploy
- ./scripts/check.sh --profile deploy

# Gated outbound mirror: internal GitLab is the source of truth; this is the ONLY path to public GitHub.
# Runs the boundary gates (agent files/names/secrets) FIRST, then pushes on pass. Protected default
Expand All @@ -101,13 +140,15 @@ publish-github:
stage: publish
tags: [homelab-k8s]
rules:
- <<: *focused-dispatch
when: never
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_REF_PROTECTED == "true"'
before_script: [] # boundary gates need no project environment
script:
# Run the PROFILE, not a hand-listed set of scripts. A hand-listed set silently stops covering
# the boundary the moment a gate is added to the registry — which is exactly how the exact-commit,
# agent-artifact and large-artifact checks came to be missing here.
- ./scripts/gates/run.sh repository-export
- ./scripts/check.sh --profile repository-export
# The shared contract's export chain also requires a secret scan and sanitized evidence. Both are
# registered under `merge` because they run there too, and the schema allows a gate exactly one
# profile, so they are named explicitly rather than duplicated under a second id.
Expand Down
20 changes: 20 additions & 0 deletions STANDARDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Shared Engineering Standards

`redfish_ctl` consumes the shared engineering standards through
`standards-binding.yaml`. That binding defines the authority, exact version
lock, and required contract set; shared contract text is not copied into this
repository.

Before acting, read in this order:

1. `standards-binding.yaml`.
2. The shared `README.md` and `manifest.yaml` resolved from the binding's
`spec.source.localPath`.
3. Every required contract named by that manifest and the project binding.

Follow the precedence defined by the pinned shared contracts. Project rules may
add stricter constraints but may not weaken the pinned standards.

If the binding, pinned revision, or a required contract cannot be read, stop and
report a precise `BLOCKER:`. Do not infer a replacement from another project or
an old handoff.
58 changes: 15 additions & 43 deletions docs/external/gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ optional, unregistered, `allow_failure`, mis-tagged, or merge-request-reachable
Kubernetes is the execution authority. `scripts/check.sh` is the entry point:

```
./scripts/check.sh --list # enumerate every registered gate
./scripts/check.sh --profile merge # run all merge gates (in-cluster only; refuses off-cluster)
./scripts/gates/run.sh <profile> # the runner (invoked inside a homelab-k8s runner pod)
./scripts/check.sh --list # enumerate every registered gate
./scripts/check.sh --profile merge # run the full merge profile in-cluster
./scripts/check.sh --profile merge --gate unit.all # run one diagnostic gate in-cluster
```

Off-cluster, `check.sh --profile` refuses and prints the in-cluster dispatch (`make k8s-ci REF=<branch>`)
— a gate never runs on a workstation.
Off-cluster, `check.sh --profile` refuses—a gate never runs on a workstation. To request focused
validation, dispatch a `.gitlab-ci.yml` API/web pipeline at the exact branch commit with its
`FOCUSED_GATE=<gate-id>` variable. Only that file's `focused-gate` job runs. Verify the job's terminal
status and sanitized artifacts against the requested commit. A focused result proves only that gate
at that commit; it is not merge or release evidence. Full merge evidence comes from the required
full pipeline.

## Profiles

Expand All @@ -27,47 +31,15 @@ Off-cluster, `check.sh --profile` refuses and prints the in-cluster dispatch (`m
- **deploy** — live apply. Protected pipeline only, manual, serialized. Never reachable from a
merge-request pipeline.

## The gates
## Registered gates

| id | profile | mutates | what it checks | fails when |
| -- | ------- | ------- | -------------- | ---------- |
| `meta.gate-registry` | merge | no | registry is schema-valid, ids unique, commands exist+executable, mandatory present | any registry inconsistency |
| `meta.ci-runner-tags` | merge | no | every GitLab job carries the `homelab-k8s` tag | a job missing the tag |
| `meta.required-jobs` | merge | no | required jobs exist, no `allow_failure`, no live-apply in an MR pipeline | a required job missing/mis-configured |
| `repo.no-secrets` | merge | no | no committed secrets (gitleaks) | a secret is found, or the scanner is absent |
| `repo.shellcheck` | merge | no | shell scripts pass shellcheck (error severity) | a shell error, or shellcheck absent |
| `repo.format` | merge | no | ruff over files changed vs `origin/main` | a lint finding, or ruff absent |
| `repo.yaml` | merge | no | YAML lints/parses | invalid YAML |
| `repo.schemas` | merge | no | schema-backed docs validate (registry vs its JSON schema) | a schema violation |
| `repo.no-agent-names` | merge | no | no AI-agent identity in tracked content or new commit messages | an agent name appears |
| `repo.no-agent-files` | merge | no | no agent instruction/artifact file is tracked in the published mainline | an agent file is tracked |
| `unit.all` | merge | no | the offline unit suite | any test fails |
| `kubernetes.render` | merge | no | manifests + Helm chart render/parse | a render/parse error |
| `kubernetes.schema` | merge | no | manifests validate against the k8s API schemas (kubeconform) | a schema error, or kubeconform absent |
| `kubernetes.policy` | merge | no | manifest security/best-practice policy (kube-linter) | a policy violation, or the linter absent |
| `integration.namespace` | integration | no | the home cluster is reachable (fail-closed smoke) | cluster unreachable |
| `gitlab.project-token.exists` | integration | no | the CI project token authenticates | token invalid/expired |
| `gitlab.project-token.project-bound` | integration | no | the token is the project bot, bound to its project | not a project-bound bot token |
| `gitlab.project-token.api-access` | integration | no | the token carries API scope | `/version` returns 403 (no api scope) |
| `gitlab.project-token.no-cross-project-access` | integration | no | the token sees only its own project (least privilege) | it can reach other projects |
| `mutation.plan-required` | deploy | no | a plan artifact exists before apply | no plan produced |
| `mutation.protected-apply` | deploy | **yes** | apply runs only from a protected pipeline | not protected / an MR pipeline |
| `mutation.same-commit` | deploy | no | apply commit == plan commit | plan/apply commits differ |
| `mutation.serialized` | deploy | no | a mutation lock is held (no concurrent apply) | no lock held |
| `mutation.verify-required` | deploy | no | the applied module exposes a verify step | module has no `verify.sh` |
| `mutation.rollback-required` | deploy | no | the applied module exposes a rollback step | module has no `rollback.sh` |
| `evidence.sanitized` | merge | no | the evidence artifact contains no secret material | a secret-shaped token in the artifact |
`gates/manifest.yaml` is the canonical gate catalog. Run `./scripts/check.sh --list` to read each
gate's id, profile, mutation classification, and command. Keeping the catalog in the registry avoids
a second hand-maintained table drifting from required CI behavior.

## Permissions

merge/integration gates run under a **read-only** CI ServiceAccount with no production credentials.
Live apply (deploy profile) runs under a **separate, explicitly selected** apply ServiceAccount, only
from a protected pipeline. See `docs/secrets.md` for value-free credential creation and `k8s/base/` for
the ServiceAccount definitions.

## Failure behavior

Every gate exits non-zero on failure; `scripts/gates/run.sh` stops at the first failure. A gate whose
required tool is absent **fails** (a skipped gate is never an implicit pass). Required CI jobs never use
`allow_failure`, so a red gate blocks the pipeline. Do not claim a gate passed without terminal or
GitLab pipeline evidence.
from a protected pipeline. See [secret setup](secrets.md) and the
[Kubernetes base](../../k8s/base/) for the ServiceAccount definitions.
6 changes: 5 additions & 1 deletion gates/manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Gate registry — the ONE canonical source of every mandatory gate (validated by schemas/gates.schema.json).
# Each gate: id, profile (when it runs), command (executable), required, mutates (does it change a target).
# Enforced by tools/gate_meta.py (the meta-gate) + tests/gates/. Run a profile with scripts/gates/run.sh.
# Enforced by tools/gate_meta.py (the meta-gate) + tests/gates/. Run gates through scripts/check.sh.
#
# Profiles: merge (MR/pre-merge, no cluster mutation) | integration (needs cluster, no BMC mutation)
# | deploy (live apply, protected pipeline only)
Expand All @@ -15,6 +15,10 @@ required_jobs:
- gate-merge
- gate-integration

# Diagnostic jobs must exist and remain safe, but are not merge/release evidence.
diagnostic_jobs:
- focused-gate

mandatory_ids:
- meta.gate-registry
- meta.ci-runner-tags
Expand Down
111 changes: 111 additions & 0 deletions inventory/ci/go-no-go.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Public schema-backed go/no-go contract; all other inventory remains private by default.
# The schema is resolved through the exact shared revision in standards-binding.yaml.
apiVersion: standards.spyroot.dev/v1alpha1
kind: GoNoGoProfile
metadata:
name: redfish_ctl

spec:
targetBranch: main

standards:
bindingFile: standards-binding.yaml
requireExactRevision: true

environment:
manager: conda
file: environment.yml
nameFromFile: true
explicitName: ""
createOrUpdateAllowed: false

checks:
# Gate ids are defined by gates/manifest.yaml.
required:
- id: unit.all
kind: command
command:
- ./scripts/check.sh
- --profile
- merge
- --gate
- unit.all
timeoutSeconds: 1800
offline: true
warningsAllowed: 0
skippedRequiredTestsAllowed: 0

- id: repo.format
kind: command
command:
- ./scripts/check.sh
- --profile
- merge
- --gate
- repo.format
timeoutSeconds: 600
offline: true
findingsAllowed: 0

- id: meta.gate-registry
kind: command
command:
- ./scripts/check.sh
- --profile
- merge
- --gate
- meta.gate-registry
timeoutSeconds: 120
offline: true

optional: []

ci:
# The required status context is the .gitlab-ci.yml gate-merge job.
requiredForMerge: true
exactCommitRequired: true
requiredContext: gate-merge
requireStatusReadback: true
smokeRequiredFor:
- publish
- mirror
- promotion
- deploy
- apply

safety:
defaultOffline: true
forbiddenWithoutCurrentApproval:
- live-hardware
- live-cluster
- external-api
- real-credentials
- package-install
- environment-update
- deployment
- publication
- promotion
- destructive-operation

architecture:
rules: []

ownership:
authorityFiles: []
treatAuthorityAsSecretClass: true

documentation:
requiredFiles:
- README.md
- STANDARDS.md
requireSafetyWarningsForProtectedActions: true
requireSetupAndExpectedOutputForNewUserFlows: true

secrets:
requirePublicBoundaryScan: true
forbiddenTrackedGlobs:
- "*.env"
- "*.pem"
- "*.key"
- "*kubeconfig*"
- ".internal/**"
8 changes: 8 additions & 0 deletions schemas/gates.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
"minLength": 1
}
},
"diagnostic_jobs": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"mandatory_ids": {
"type": "array",
"minItems": 1,
Expand Down
Loading
Loading