feat: scaffold deployment-policies monorepo
OPA/Rego deployment gateway policies: 5 rules (valid-environment, require-approvals, no-self-approval, block-weekends, freeze-window), a deploy-gate combining them, JSON schemas, scenario-driven tests, Gitea Actions CI (verify + publish to generic registry), changesets versioning, Makefile and scripts.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
# deployment-policies
|
||||
|
||||
Small, composable OPA/Rego policies that act as **deployment gateways**. Each rule decides one
|
||||
narrow question about a deployment request (environment validity, approvals, weekends, freeze
|
||||
windows…), and gates bundle the rules you want to enforce together.
|
||||
|
||||
- **Language:** Rego (OPA 1.x, `rego.v1` syntax)
|
||||
- **Hosting:** Gitea (`gitea.devegamoreno.com`), repo `jdevega/deployment-policies`
|
||||
- **CI/CD:** Gitea Actions (verify + build on every push/PR, publish on `main`)
|
||||
- **Distribution:** Gitea generic package registry (rules and gates as OPA bundles)
|
||||
- **Versioning:** actions/Changesets over npm workspaces
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
policies/
|
||||
rules/
|
||||
<rule>/ # one deployable policy = one bundle
|
||||
policy.rego # module in package rules.<name>; exposes allow + violations
|
||||
policy_test.rego # tests driven by scenarios.json
|
||||
scenarios.json # input/data/expectation fixtures (loaded at data.scenarios)
|
||||
schema/ # JSON Schemas for input and data (opa check --schema)
|
||||
package.json # version + identity
|
||||
gates/
|
||||
<gate>/ # one deployable gate = bundle of the rules it depends on
|
||||
gate_test.rego # integration tests over the combined rules
|
||||
scenarios.json
|
||||
schema/
|
||||
package.json # lists rule packages as dependencies
|
||||
scripts/ # install-opa, check, build, publish
|
||||
.gitea/workflows/ci.yml # Gitea Actions pipeline
|
||||
Makefile # convenience targets
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js ≥ 22.11 (workspaces + changesets v3)
|
||||
- `make`, `curl` (or `wget`)
|
||||
- OPA binary installed via `make install` (pinned release, no Docker)
|
||||
- `regal` on `PATH` for linting (optional, skipped if absent)
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
make install # install OPA v1.20.2 to tools/opa
|
||||
npm ci # install changesets + oxfmt
|
||||
make check # format check + regal lint + schema check + tests
|
||||
make build # build rule and gate bundles into dist/
|
||||
```
|
||||
|
||||
## Rego conventions
|
||||
|
||||
Every policy module targets **separate evaluation** and follows the same contract:
|
||||
|
||||
- `package rules.<name>` — one package per rule, no cross-file imports between rules.
|
||||
- `allow` (boolean, `default false`) — this rule alone permits the deployment.
|
||||
- `violations` (set of `{"code", "message"}`) — the reasons for denial.
|
||||
- Input is read from `input.deployment` / `input.approvals`.
|
||||
- Environment configuration and freeze windows are read from `data.deploygate.*`
|
||||
(`config.<env>.min_approvals`, `config.<env>.block_weekends`, `freeze_windows[]`).
|
||||
|
||||
Combining policies is done **at runtime** by the consumer: load one or more bundles, evaluate
|
||||
each rule under `data.rules.<name>` separately, and fold the `violations`/`allow` results
|
||||
together. Gates simply ship several rules in one bundle for convenience.
|
||||
|
||||
## Input contract
|
||||
|
||||
| Field | Type | Used by |
|
||||
| ----------------------------- | -------- | ---------------------------------- |
|
||||
| `input.deployment.environment`| string | all rules |
|
||||
| `input.deployment.service` | string | (informational) |
|
||||
| `input.deployment.version` | string | (informational) |
|
||||
| `input.deployment.requested_by`| string | no-self-approval |
|
||||
| `input.deployment.created_at` | RFC3339 | block-weekends, freeze-window |
|
||||
| `input.approvals[].by` | string | no-self-approval |
|
||||
| `input.approvals[].active` | boolean | require-approvals, no-self-approval|
|
||||
| `input.approvals[].at` | RFC3339 | (informational) |
|
||||
|
||||
## Data contract
|
||||
|
||||
| Field | Type | Read by |
|
||||
| ---------------------------------------- | ------ | ------------------------------- |
|
||||
| `data.deploygate.config.<env>.min_approvals` | int | require-approvals |
|
||||
| `data.deploygate.config.<env>.block_weekends`| bool | block-weekends |
|
||||
| `data.deploygate.freeze_windows[].environment` | string | freeze-window |
|
||||
| `data.deploygate.freeze_windows[].start` | RFC3339 | freeze-window |
|
||||
| `data.deploygate.freeze_windows[].end` | RFC3339 | freeze-window |
|
||||
|
||||
Config lives in the data document (not in the bundle), so rules never embed environment-specific
|
||||
settings. Bundles scope their roots to `rules`, letting callers merge `data.deploygate.*`.
|
||||
|
||||
## Available rules
|
||||
|
||||
| Rule | Package | Denies when |
|
||||
| ----------------- | ------------------------ | ----------- |
|
||||
| `valid-environment`| `rules.valid_environment`| environment is not in `data.deploygate.config` |
|
||||
| `require-approvals`| `rules.require_approvals`| fewer active approvals than `min_approvals` |
|
||||
| `no-self-approval` | `rules.no_self_approval` | an active approver is the requestor |
|
||||
| `block-weekends` | `rules.block_weekends` | `created_at` is Sat/Sun and env has `block_weekends` |
|
||||
| `freeze-window` | `rules.freeze_window` | deploy falls inside a matching freeze window |
|
||||
|
||||
## Available gates
|
||||
|
||||
| Gate | Package | Includes |
|
||||
| ------------- | -------------------- | -------- |
|
||||
| `deploy-gate` | `gate.deploy_gate_test` | all 5 rules |
|
||||
|
||||
A gate's `package.json` lists its rules under `dependencies`; changesets bumps the gate when any
|
||||
of its rules change, and `scripts/build.sh` bundles exactly those rule modules into the gate.
|
||||
|
||||
## Testing
|
||||
|
||||
Each rule carries `scenarios.json` with `{ scenarios: { <name>: { input, data, expect } } }`.
|
||||
`opa test` loads it at `data.scenarios` (top-level key, not filename). Tests assert both the
|
||||
expected `violations` codes and `allow`. See `scripts/check.sh`; run with `make test`.
|
||||
|
||||
## Building and publishing
|
||||
|
||||
```sh
|
||||
make build # dist/rules/<name>/<version>/bundle.tar.gz and dist/gates/<name>/<version>/bundle.tar.gz
|
||||
make publish # upload all bundles to the Gitea generic registry
|
||||
```
|
||||
|
||||
`publish.sh` needs:
|
||||
|
||||
- `GITEA_TOKEN` (API token with `write:package`)
|
||||
- `GITEA_URL` (default `https://gitea.devegamoreno.com`)
|
||||
- `GITEA_OWNER` (default `jdevega`)
|
||||
|
||||
Artifacts land at:
|
||||
|
||||
```
|
||||
/api/packages/{owner}/generic/rule-<rule>/<version>/bundle.tar.gz
|
||||
/api/packages/{owner}/generic/gate-<gate>/<version>/bundle.tar.gz
|
||||
```
|
||||
|
||||
Bundles are built fresh in CI (`publish` job on `main`) and uploaded with `curl`.
|
||||
|
||||
## Versioning
|
||||
|
||||
On a new commit, run `make changeset`, select the changed packages and bump types, and commit the
|
||||
generated changeset. Later run `make version` to apply them. See
|
||||
[`.changeset/README.md`](.changeset/README.md).
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/ci.yml`:
|
||||
|
||||
1. `verify` — `npm ci`, install OPA, `scripts/check.sh`, `scripts/build.sh` (every push/PR).
|
||||
2. `publish` — on `main` only, rebuilds and uploads bundles with `GITEA_TOKEN`.
|
||||
|
||||
Create the `GITEA_TOKEN` repository secret on Gitea with at least `write:package` scope.
|
||||
|
||||
## Evaluating a bundle
|
||||
|
||||
```sh
|
||||
opa eval \
|
||||
--bundle dist/rules/valid-environment/0.0.0/bundle.tar.gz \
|
||||
--data deploygate.json \
|
||||
--input request.json \
|
||||
'data.rules.valid_environment'
|
||||
```
|
||||
|
||||
Or load a gate bundle and fuse the rule results yourself:
|
||||
|
||||
```sh
|
||||
opa eval --bundle dist/gates/deploy-gate/<v>/bundle.tar.gz --data deploygate.json \
|
||||
--input request.json \
|
||||
'{codes: {c | some r in [data.rules.valid_environment, data.rules.require_approvals]; some v in r.violations; c := v.code}}'
|
||||
```
|
||||
|
||||
See the input/data contracts above for `deploygate.json` and `request.json` shapes.
|
||||
Reference in New Issue
Block a user