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.