Split a Saga across files
A descriptor that has been running for a while is two things in one file: a structural account of the system, and a log of dated decisions about findings somebody accepted. They change at different times, for different reasons, and a developer adding a repository and a security owner accepting a CVE should not be the same review.
fragments: lets them live apart.
# draugr.saga.yaml
project: acme
release: { version: "3.1.0" }
fragments:
- path: ".draugr/exclusions/*.saga-fragment.yaml"
components:
- name: web
repositories: [{ url: "." }]
# .draugr/exclusions/test-fixtures.saga-fragment.yaml
config:
exclude:
- paths: ["test/integration/repo_scan_test.go"]
rules: ["private-key"]
reason: >-
The integration test writes a throwaway key so the secrets control has something real to
find. The material is fake and was never valid anywhere.
acceptedBy: "sec@acme.example"
What a fragment may contain#
A fragment adds scope or adds attributed suppressions. It cannot change policy.
components | yes, merged by name |
config.exclude | yes, appended |
fragments | yes, resolved relative to itself |
release, config.gate, config.controllers, publishers, reports | no |
That rule is what makes a one-line fragments: entry safe to review. Including a file cannot
lower your gate or switch a control off; the worst it can do is add suppressions, and every one
of those is attributed and counted in the report.
A monorepo serving several products#
The case fragments were designed around: one tree, many components, and products assembled from overlapping subsets of them.
services/payments/
draugr.saga-fragment.yaml # what payments is, everywhere
azure.saga-fragment.yaml # only when payments ships on Azure
gcp.saga-fragment.yaml
services/ledger/
draugr.saga-fragment.yaml
azure.saga-fragment.yaml
# azure.saga.yaml
project: acme-azure
release: { version: "3.1.0" }
fragments:
- path: "**/draugr.saga-fragment.yaml"
- path: "**/azure.saga-fragment.yaml"
# gcp.saga.yaml
project: acme-gcp
release: { version: "3.1.0" }
fragments:
- path: "**/draugr.saga-fragment.yaml"
- path: "**/gcp.saga-fragment.yaml"
Two products over one tree, and adding a component is one new directory with no edit to either product's Saga.
Name the audience in the stem#
This is the convention that makes it work. **/*.saga-fragment.yaml matches everything, including
the other cloud's parts, and expressing "all of them except the GCP ones" needs glob subtraction,
unreadable, and wrong the first time somebody adds a third cloud. Naming the audience in the file's
stem means each product globs exact stems and never has to subtract.
Components merge rather than collide#
services/payments/draugr.saga-fragment.yaml:
components:
- name: payments
exposure: public
criticality: critical
repositories: [{ url: "services/payments" }]
services/payments/azure.saga-fragment.yaml:
components:
- name: payments
images: [{ image: "acme.azurecr.io/payments:3.1.0" }]
Same name, so these are one component with the repository and the image. The GCP product gets the same component with GCP surfaces instead.
Where a component's fields conflict, the first description wins, and resolution starts from the
descriptor you opened, so the root stays authoritative. In practice that means exposure and
criticality belong in the shared fragment. If a component is genuinely more exposed on one cloud
than another, leave the classification out of the shared fragment and let each variant set it.
Fragments from another repository#
fragments:
- url: https://github.com/acme/platform.git
revision: v2.4.0
path: "components/**/draugr.saga-fragment.yaml"
revision is required. Without it, a fragment would track a moving branch and your gate could
change with no commit in your own repository. A tag is fine, the commit it resolved to is recorded,
so a tag that moves is visible afterwards.
Fragments inside a fetched repository resolve within it, so a repository that describes itself with relative paths keeps working when somebody else pulls it in.
Seeing what is actually in force#
Once a descriptor is assembled from parts, opening one file no longer answers the question.
draugr validate azure.saga.yaml --resolved
# Resolved Saga, every fragment merged. Generated by `draugr validate --resolved`.
# Valid input: comments are the provenance, so this can be scanned as it stands.
#
# root: azure.saga.yaml
# fragment: services/payments/draugr.saga-fragment.yaml
# fragment: services/payments/azure.saga-fragment.yaml
...
The output is a valid descriptor, which is what makes it worth piping:
- Flatten it to cross an air gap. Resolve where there is network, carry the result across, scan it where there is none. See running air-gapped.
- Pin it. Every remote revision is recorded at the commit it resolved to, so a flattened copy is a lockfile in all but name.
- Diff it in CI. Commit the resolved descriptor and fail when it stops matching. A one-line
fragments:change is then reviewed by its effect rather than by its cause. - Query it.
draugr validate azure.saga.yaml --resolved | yq '.components[].name'
Editor support#
Fragments are *.saga-fragment.yaml and have their own
schema. A fragment has no release:,
so checking one against the Saga's schema would report every valid fragment as broken.
# yaml-language-server: $schema=https://draugr.dev/schema/draugr.saga-fragment.schema.json
draugr schema --fragment prints the copy this build enforces, for pinning or air-gapped use.
Gotchas#
- A pattern that matches nothing is an error. Silence from a line somebody wrote on purpose is indistinguishable from a typo. If a product has no components on one cloud, do not list the pattern.
- Paths are relative to the file that names them, not to your working directory, so
../shared/exclusions.saga-fragment.yamlfrom a component's own fragment works. - A file matched by two patterns is loaded once. Overlapping globs are normal, and loading a fragment twice would count its exclusions twice.