Guides

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.

componentsyes — merged by name
config.excludeyes — appended
fragmentsyes — resolved relative to itself
release, config.gate, config.controllers, publishers, reportsno

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.yaml from 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.