Lachlan Douglas
Governed Data Service is a small, complete system: a confined step seeds a key/value store, and a read-only HTTP service serves the store's contents. It runs on a laptop with the daedal binary, python3 and podman. An engineer can read every grant in the system on one screen, resolve the system before anything runs and swap the store's substrate without editing a consumer.
The smallest solution
A solution is a directory whose root manifest declares root:. Components go in daedal.components/ beneath the root, each declared in a short manifest that names its shape and relationships. The hello-world solution in full:
# daedal.component.yaml (identifiers omitted)
label: Hello World
description: Minimal "Hello, world", with hard-coded message.
root: {}
# daedal.components/printer/daedal.component.yaml (identifiers omitted)
label: Printer
description: Prints "Hello, world!".
step: {}
The only code in the solution is the printer's step.
The system
The root declares the system's grants and binds the system's one aspect, audit. The root grants the seed step a container executor, one host program and one secret, and configures the store's log level:
# daedal.component.yaml
root:
bind:
aspects:
audit: audit
grant:
seed:
executor: container # confined: --network=none, read-only fs, fd 3 the sole egress
commands:
python3: # the one program it may proxy to the host
secrets: [SEED_TOKEN] # the one secret daedal may inject into that program
emit:
configurations:
store:
log_level: warn
The store registers on the host and emits an endpoint provision to its consumers:
# daedal.components/store/daedal.component.yaml
step:
register: $
emit:
provisions:
- endpoint
The service consumes the store's endpoint and accepts the audit offer:
# daedal.components/service/daedal.component.yaml
step:
register: $
absorb:
offers:
audit: {}
provisions:
store:
endpoint: {}
The seed data is a content: leaf. It runs no code and contributes its records at resolve time:
# daedal.components/catalog/daedal.component.yaml
content:
emit:
contributions:
seed:
records:
- {sku: A-100, name: Widget, price: 9.99}
- {sku: B-200, name: Gadget, price: 19.95}
- {sku: C-300, name: Sprocket, price: 4.50}
A second leaf, catalog-extra, contributes one more record to the same kind, and the seed step receives both sets. The seed step's code writes to the store whatever records it receives. The step absorbs the records, the store's endpoint and the audit offer, and requires python3:
# daedal.components/seed/daedal.component.yaml
step:
require:
- python3
absorb:
offers:
audit: {}
contributions:
- records
provisions:
store:
endpoint: {}
The audit aspect emits one offer. The root binds the aspect, and daedal matches the aspect's offer to every step that accepts the offer:
# daedal.components/audit/daedal.component.yaml
aspect:
emit:
offers:
- audit
Resolution
daedal show resolves the tree and draws each relationship. The glyphs mark ━ a requirement, ◇ a binding, △ a registration, ○ a configuration, ▷ a contribution, ◯ an offer, □ a consumed provision and ◌ an accepted offer:
# component descriptions omitted
governed-data-service authored
Governed Data Service
│ ├◇ audit – audit
│ └○ store
│
├ audit authored
│ Audit
│ └◯ audit
│
├ catalog authored
│ Catalog
│ └▷ seed
│
├ catalog-extra authored
│ Catalog (supplementary)
│ └▷ seed
│
├ seed authored
│ Seed
│ ├━ python3
│ ├□ store (endpoint)
│ └◌ audit
│
├ service authored
│ Service
│ ├△ $
│ ├□ store (endpoint)
│ └◌ audit
│
└ store authored
Store
└△ $
daedal steps derives the apply order from those relationships:
governed-data-service (4 steps, apply order)
1 audit aspect
2 store step
3 seed step after: catalog, catalog-extra, store, audit
4 service step after: store, audit
The two catalogs appear as dependencies of seed and have no step of their own. daedal resolves each content leaf at compile time and adds its records to the consumer's input. daedal compile shows the records in the seed step's input, attributed to their source, before anything runs:
├ seed
│ ├─ node_name: seed
│ ├─ deps: [catalog, catalog-extra, store, audit]
│ ├─ manifest:
│ │ code: step.py
│ └─ input:
│ contributions:
│ records:
│ - attributed_instance: catalog
│ value:
│ - {name: Widget, price: 9.99, sku: A-100}
│ - {name: Gadget, price: 19.95, sku: B-200}
│ - {name: Sprocket, price: 4.5, sku: C-300}
│ - attributed_instance: catalog-extra
│ value:
│ - {name: Flange, price: 12.0, sku: D-400}
That output is the normal form Free Assembly describes.
Apply
Set DAEDAL_RUNNER_PYTHON to the Python step SDK, DAEDAL_CONTAINER_RUNTIME to podman and DAEDAL_SANDBOX_IMAGE to the confinement image, then run:
SEED_TOKEN=choose-a-secret daedal apply
curl localhost:8080/data
The service returns the seeded catalog. daedal apply --down runs each step's recorded destroyers in reverse order and takes the system down.
Governance
The seed step is the one privileged component. The root's grant: declares the step's authority. With executor: container, daedal confines the step: no network, a read-only filesystem and one control channel on file descriptor 3 as its route to the host. Over that channel the step asks daedal to run a program, and daedal runs only the programs the operator has granted. The step's code tests this by asking daedal to run echo, which the operator has not granted:
# daedal.components/seed/step.py (excerpt)
denied = daedal.run(["echo", "unreachable"])
if denied.get("status") is None:
daedal.log("default-deny confirmed: proxying ungranted 'echo' was refused")
else:
return ["error", "governance breach: ungranted 'echo' was NOT refused (status {})".format(denied.get("status"))]
reply = daedal.run(["python3", "-c", poster_source(url, rows)], secrets=["SEED_TOKEN"])
The refused call returns with no exit status. For the granted call, daedal runs python3 on the host. daedal resolves SEED_TOKEN in memory and places the value in that process's environment. The confined step writes only the variable's name into the source it passes across the channel, so the token's value stays outside the step, outside every argument list and off the disk.
The substrate swap
The store emits an endpoint, and the root's binding decides which registrant realises the store. By default the store's own step launches kvstore.py as a host process. The solution also contains store-container, a registrant that runs the same kvstore.py in a podman container through an imported podman-run component. The swap is one binding and one grant in the root:
root:
bind:
aspects:
audit: audit
registrants:
store: store-container # the swap
grant:
seed:
executor: container
commands:
python3:
secrets: [SEED_TOKEN]
store-container/podman-run: # the container runner may run podman and forward the secret
commands:
podman:
secrets: [SEED_TOKEN]
With store-container bound, daedal steps lists the container runner before the store. seed and service keep their dependencies:
governed-data-service (5 steps, apply order)
1 audit aspect
2 podman-run step after: store-container/container-spec
3 store step after: store-container/podman-run
4 seed step after: catalog, catalog-extra, store, audit
5 service step after: store, audit
The service declares its need for store.endpoint and names neither the host process nor the container.
Reuse
podman-run is a published component in the catalogue, and this solution imports it. Its interface:
# podman-run
step:
register: $
require:
- podman
emit:
provisions:
- running
absorb:
contributions:
- container
A publisher marks a component as a package and projects it into the local bundle store. A consumer fetches the bundle and imports the package by name into a scope, where daedal mints a new identity and derives the wiring:
# publisher
daedal pack mybundle podman-run
daedal export podman-run
# consumer
daedal download "$SRC_URL"
daedal import podman-run /