Commits
Tekton v1beta1 PipelineRun objects[1] had the service account specified at
the top level of spec, eg:
```yaml
spec:
serviceAccountName: tack
```
However Tekton v1 PipelineRun objects have the service account inside a
`taskRunTemplate` object:
```yaml
spec:
taskRunTemplate:
serviceAccountName: tack
```
This change properly places the service account name in the right place
for Tekton v1 PipelineRun objects so that Tack can properly annotate CI
pipelines with the right service account.
[1]: https://tekton.dev/docs/pipelines/pipelineruns/#mapping-serviceaccount-credentials-to-tasks
Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: Xe Iaso <me@xeiaso.net>
User-defined params (from workflow YAML) were silently replacing the
built-in commit/branch/actor params. Now both are merged into a single
sorted slice, with user params taking precedence on name collisions so
callers can still override the defaults when needed.
A workspace with multiple sources set (e.g., both storage and pvc)
was silently picking whichever matched first in the switch. Now
parseTektonWorkflowConfig rejects workspaces with zero or multiple
sources, and workspaces with empty names, at parse time with clear
error messages.
Fixes #3
Add a community Dockerfile for tack and cover it in Buildkite so it
does not drift unnoticed. The Docker build uses the Go version pinned
by go.mod, installs CA certificates in a slim Debian runtime image, and
keeps common local artifacts out of the Docker build context.
The new Buildkite step builds the image and runs `tack -h`, matching the
existing Nix package smoke test without requiring runtime configuration,
credentials, or external services.
Previously the docs only required builds.sr.ht/JOBS:RW, but the
log endpoints under /query/log/<id>[/<task>]/log require
builds.sr.ht/LOGS:RO. A token with the documented scope would
submit jobs and poll status correctly, then 403 on every log fetch.
The provider's streamStep suppressed all non-404 errors at debug
level and emitted empty steps, so operators following the docs got
green builds whose logs were silently blank.
Update the docs to require both scopes and explain why. Add an
ErrUnauthorized sentinel to the sourcehut client and wrap 401/403
responses from GetTaskLog with it. In the provider, probe the
master log up front in Logs() so a systemic auth failure surfaces
as a Logs() error (5xx via the HTTP layer) rather than as an empty
stream. As defense in depth, streamStep now aborts the stream and
logs at error level on ErrUnauthorized instead of swallowing it.
Submits jobs via GraphQL `submit`, polls for status, streams per-task
logs from /query/log/ (the path that bypasses sourcehut's anti-bot
proxy). Workflows opt in via `tack.sourcehut.manifest`; tack injects
TACK_* env vars into the manifest before submission.
Signed-off-by: Jes Olson <j3s@c3f.net>
Signed-off-by: Xe Iaso <me@xeiaso.net>
This allows you to create a workspace PVC for cloning a git repo so you
can run go test in it.
Signed-off-by: Xe Iaso <me@xeiaso.net>
This allows you to do tests against the individual commit being operated
against instead of just the most recent commit on HEAD. These are a
no-op when pipelines do not use these parameters.
Example usage:
```yaml
steps:
- name: git-clone
image: reg.xeiaso.net/xe/x/git:latest
script: |
set -euo pipefail
git clone $(params.url) /workspace/repo-data/repo
cd /workspace/repo-data/repo
git checkout $(params.commit)
```
Signed-off-by: Xe Iaso <me@xeiaso.net>
The Logs path for a still-running PipelineRun called StreamPodLogs
without follow=true, so the apiserver returned a snapshot of the
container log and EOF'd at the current tail. The provider then
emitted StepStatusEnd and moved on, which made still-running steps
look complete and dropped any subsequent log bytes unless the client
reconnected. The same one-shot pass also took a single snapshot of
TaskRuns, so any TaskRun spawned later in the PipelineRun (sequential
runAfter, finally, retries) was silently ignored.
StreamPodLogs now takes a LogOptions{Follow bool}. The non-terminal
path uses Follow=true so the read only EOFs once the container
actually terminates; the terminal path keeps the snapshot read since
the log is already complete. The Logs goroutine is replaced with a
poll loop that re-lists TaskRuns and re-checks the PipelineRun's
terminal state each iteration, only closing the channel once the
PipelineRun is terminal AND no new TaskRuns appeared in the last
pass. The fake k8s client mirrors the apiserver's follow semantics
by holding the reader open until ctx is cancelled.
Logs returned ErrLogsNotFound whenever the store had a mapping for the
(knot, pipelineRkey, workflow) tuple but Tekton had not yet scheduled
any TaskRuns. The HTTP /logs handler translates ErrLogsNotFound into a
real 404 before upgrading to a WebSocket, so a freshly-spawned or
still-queueing PipelineRun appeared nonexistent to the appview. That
contradicts the provider contract, which reserves ErrLogsNotFound for
the case where the workflow never ran on this provider at all.
Logs now only returns ErrLogsNotFound when the store mapping is
missing. Once the mapping is found the call always returns an open
channel and defers TaskRun discovery to the producer goroutine, which
polls via a new waitForTaskRuns helper until a TaskRun appears, the
PipelineRun reaches a terminal state with no TaskRuns scheduled, or
ctx is cancelled. The terminal-with-no-TaskRuns and ctx-cancelled
cases close the channel cleanly without sending anything, so the
goroutine cannot leak across a disconnecting client.
Bump tangled.org/core to a version that exposes
eventconsumer.Consumer.RemoveSource, and replace the placeholder
no-op body of knotConsumer.RemoveKnot with a real call.
Previously, when a repo revoked our spindle membership we logged the intent
but left the underlying websocket dialing the now-untrusted knot until
tack restarted; reconciliation could only ever add sources, never
remove them. RemoveKnot now tears the source down immediately, and the
tracking note in KNOWN_ISSUES.md plus the workaround comments referencing the
upstream PR are no longer accurate, so they are removed.
The consumer code in jetstream.go mixed Tack-specific concerns
(collections, applyCommit, store cursor) with generic firehose
mechanics: configuring the upstream client, looping reconnects,
rewinding time-based cursors, persisting cursor progress, and
distinguishing permanent bad-record failures from transient
handler failures. None of those are specific to Tack and they are
the bits most likely to be reused for future jetstream consumers.
Move the generic mechanics into a new internal/jetstream package.
Previously, observing a `sh.tangled.repo` record whose spindle field
matched our hostname was enough to enroll a new knot subscription, no
matter who published the record.
`reconcileKnot` now consults a new `IsAuthorizedActor` helper before
calling `AddKnot`: the repo's publisher DID must be the spindle owner
or have been vouched for by a `sh.tangled.spindle.member` record
whose own publisher is the owner.
VerifySignature previously accepted any cryptographically valid
"timestamp=<unix>,signature=<hex>" header regardless of how old the
timestamp was. An attacker who captured a single signed delivery
could replay it indefinitely, creating duplicate status events and
unbounded growth in the events table.
Reject signatures whose timestamp is more than MaxSignatureAge (5
minutes) from the local clock in either direction. The symmetric
bound also defeats implausibly future-dated stamps that would
otherwise mint a long replay window. The clock is read through a
package-level timeNow var so tests can pin it deterministically; the
existing fixed-timestamp test now stubs the clock and a new stale
case covers the rejection path.
Previously `knot.go` executed every `sh.tangled.pipeline` event the
moment it arrived, ignoring the `spindle_members` and `repos`
tables that `jetstream.go` has been mirroring from the AT Proto
firehose.
The knot consumer now consults `store.AuthorizePipelineActor`
before dispatching a trigger. The check has two gates: the
triggers repo must have published a `sh.tangled.repo` record
naming us as its spindle on the knot the event arrived from, and
the publisher of that repo record must be either the spindle
owner or a subject the owner vouched for via
`sh.tangled.spindle.member`.
Buildkite webhooks can land before Spawn's goroutine has persisted
the build UUID to (knot, pipeline_rkey, workflow) mapping. The
window is small but real: CreateBuild returns, Buildkite fires
build.scheduled, the webhook handler runs LookupBuildkiteBuildByUUID
and gets nothing, and the event is dropped on the floor forever.
HandleWebhook now reconstructs the ref from the build's meta_data
when the lookup misses. We already attach tack:knot, tack:pipeline_rkey,
and tack:workflow at CreateBuild time, so a Buildkite-originated
webhook for one of our builds always carries enough information to
recover the tuple. Org and pipeline slug come from the payload's
top-level organization and embedded build.pipeline objects.
The reconstructed ref is opportunistically inserted via the existing
ON CONFLICT DO UPDATE, so subsequent webhooks and any /logs request
hit the cache instead of redoing the meta_data dance. If the
authoritative Spawn-side insert lands afterwards it just refreshes
the row.
Builds without our tack:* meta_data still no-op, preserving the
'foreign build sharing this webhook URL' behavior. WebhookPayload
gains an Organization field so the org slug is available without
poking at Build.Pipeline.
Previously, `handleJetstreamEvent` saved the time-based cursor after
every event regardless of whether `applyCommit` succeeded. That is fine
for permanently bad records (malformed JSON, schema violations) where
replaying achieves nothing, but wrong for transient infra failures
(SQLite busy, store closed during shutdown, disk full): the cursor
would advance past a perfectly good event and silently drop the
membership or repo row that backs it, with no way to recover short of
a manual replay.
`applyCommit` now distinguishes the two classes via a new
`badRecordError` wrapper. JSON decode failures in `applySpindleMember`,
`applyRepo`, and `applyRepoCollaborator` are wrapped with
`badRecord(...)` so they remain cursor-advancing. Everything else
returned from `applyCommit` is treated as transient:
`handleJetstreamEvent` logs it, returns the error to the scheduler, and
skips `SaveCursor` so the next reconnect (which already rewinds by
`jetstreamRewind`) will redeliver and retry.
LookupBuildkiteBuildByTuple sorted on created_at, an RFC3339Nano
text column. Lexical comparison of nanosecond timestamps is not
reliable: time.Format trims trailing zeros, so an instant on the
exact second renders as '...:00Z' while one nanosecond later
renders as '...:00.000000001Z' and lex-sorts before it. The
practical effect was that /logs could resolve the wrong run for
a workflow that had been triggered more than once.
Add a created_unix_ns INTEGER column to buildkite_builds, populate
it from time.Now().UnixNano() on insert, and switch the lookup to
ORDER BY created_unix_ns DESC with created_at and build_number as
deterministic tiebreakers for legacy rows that pre-date the column.
The migration path is covered: an additive ALTER widens existing
databases, and a one-shot Go-side backfill parses each row's
created_at and writes the corresponding UnixNano. Rows whose text
fails to parse are left at the default 0 so a single corrupt row
cannot wedge startup. New tests in store_migrate_test.go open a
hand-crafted pre-migration database through openStore and assert
the upgrade is correct, idempotent, and tolerant of bad data.
Spawn previously stored cfg.Org on the buildkite_builds row, leaving
empty when the workflow YAML didn't set tack.buildkite.org and
relying on the read path to fall back to the provider's defaultOrg.
That coupling let historical lookups drift: if defaultOrg ever
changed, log fetches and webhook joins for older builds would silently
target the wrong organisation.
Jetstream cursors are time-based and the upstream docs explicitly note
that exact-boundary replay across a disconnect is not guaranteed
gapless. Resuming from the precise saved TimeUS could therefore drop
events that straddle the reconnect window.
On every (re)connect, subtract a fixed jetstreamRewind (5s) from the
loaded cursor before handing it to ConnectAndRead, clamping at zero so
a tiny saved cursor can't go negative. The replayed events are safe to
re-apply: applyCommit dispatches only to UPSERTs and DELETEs keyed on
(did, rkey), so duplicates collapse into the same row state.
The /logs and /events handlers wrote frames with conn.WriteMessage
and never set a write deadline. A client that stopped reading but
kept the TCP connection open could fill the kernel send buffer and
park the handler goroutine on a write forever, leaking the request
context, the broker subscription, and the log producer.
Add a wsWriteWait constant (10s) and call SetWriteDeadline before
every WriteMessage in the logs drain loop and in streamEvents. The
keep-alive ping and the closing close-frame already used WriteControl,
which takes a deadline argument directly; raise their bounds from 1s
to wsWriteWait for consistency. A stuck peer now fails the next write
within ~10s and the handler unwinds cleanly.
A workflow can override the spindle's default Buildkite organisation
via `tack.buildkite.org`, but `BuildkiteBuildRef` didn't carry the org
field. Spawn used the override for `CreateBuild` and then dropped it,
so `Logs` always recomputed org := p.defaultOrg and any cross-org
workflow's /logs request 404'd against the wrong organisation.
Extend TestBuildkiteSpawnWorkflowConfig to assert the org survives
the round-trip via LookupBuildkiteBuildByTuple.
Adds an `extraServiceConfig` option to the NixOS module that is
merged into the systemd service's `serviceConfig` after the
module's defaults. This lets operators set arbitrary `[Service]`
settings, most notably resource limits like `MemoryMax` and
`CPUQuota`, without needing to fork the module, and also lets
them override any of the defaults we set out of the box (e.g.
to relax a sandboxing knob).
Implemented as `attrsOf unspecified` merged with `//` so the
user's attrs win on conflict.
Tekton v1beta1 PipelineRun objects[1] had the service account specified at
the top level of spec, eg:
```yaml
spec:
serviceAccountName: tack
```
However Tekton v1 PipelineRun objects have the service account inside a
`taskRunTemplate` object:
```yaml
spec:
taskRunTemplate:
serviceAccountName: tack
```
This change properly places the service account name in the right place
for Tekton v1 PipelineRun objects so that Tack can properly annotate CI
pipelines with the right service account.
[1]: https://tekton.dev/docs/pipelines/pipelineruns/#mapping-serviceaccount-credentials-to-tasks
Signed-off-by: Xe Iaso <me@xeiaso.net>
Fixes #3
Add a community Dockerfile for tack and cover it in Buildkite so it
does not drift unnoticed. The Docker build uses the Go version pinned
by go.mod, installs CA certificates in a slim Debian runtime image, and
keeps common local artifacts out of the Docker build context.
The new Buildkite step builds the image and runs `tack -h`, matching the
existing Nix package smoke test without requiring runtime configuration,
credentials, or external services.
Previously the docs only required builds.sr.ht/JOBS:RW, but the
log endpoints under /query/log/<id>[/<task>]/log require
builds.sr.ht/LOGS:RO. A token with the documented scope would
submit jobs and poll status correctly, then 403 on every log fetch.
The provider's streamStep suppressed all non-404 errors at debug
level and emitted empty steps, so operators following the docs got
green builds whose logs were silently blank.
Update the docs to require both scopes and explain why. Add an
ErrUnauthorized sentinel to the sourcehut client and wrap 401/403
responses from GetTaskLog with it. In the provider, probe the
master log up front in Logs() so a systemic auth failure surfaces
as a Logs() error (5xx via the HTTP layer) rather than as an empty
stream. As defense in depth, streamStep now aborts the stream and
logs at error level on ErrUnauthorized instead of swallowing it.
This allows you to do tests against the individual commit being operated
against instead of just the most recent commit on HEAD. These are a
no-op when pipelines do not use these parameters.
Example usage:
```yaml
steps:
- name: git-clone
image: reg.xeiaso.net/xe/x/git:latest
script: |
set -euo pipefail
git clone $(params.url) /workspace/repo-data/repo
cd /workspace/repo-data/repo
git checkout $(params.commit)
```
Signed-off-by: Xe Iaso <me@xeiaso.net>
The Logs path for a still-running PipelineRun called StreamPodLogs
without follow=true, so the apiserver returned a snapshot of the
container log and EOF'd at the current tail. The provider then
emitted StepStatusEnd and moved on, which made still-running steps
look complete and dropped any subsequent log bytes unless the client
reconnected. The same one-shot pass also took a single snapshot of
TaskRuns, so any TaskRun spawned later in the PipelineRun (sequential
runAfter, finally, retries) was silently ignored.
StreamPodLogs now takes a LogOptions{Follow bool}. The non-terminal
path uses Follow=true so the read only EOFs once the container
actually terminates; the terminal path keeps the snapshot read since
the log is already complete. The Logs goroutine is replaced with a
poll loop that re-lists TaskRuns and re-checks the PipelineRun's
terminal state each iteration, only closing the channel once the
PipelineRun is terminal AND no new TaskRuns appeared in the last
pass. The fake k8s client mirrors the apiserver's follow semantics
by holding the reader open until ctx is cancelled.
Logs returned ErrLogsNotFound whenever the store had a mapping for the
(knot, pipelineRkey, workflow) tuple but Tekton had not yet scheduled
any TaskRuns. The HTTP /logs handler translates ErrLogsNotFound into a
real 404 before upgrading to a WebSocket, so a freshly-spawned or
still-queueing PipelineRun appeared nonexistent to the appview. That
contradicts the provider contract, which reserves ErrLogsNotFound for
the case where the workflow never ran on this provider at all.
Logs now only returns ErrLogsNotFound when the store mapping is
missing. Once the mapping is found the call always returns an open
channel and defers TaskRun discovery to the producer goroutine, which
polls via a new waitForTaskRuns helper until a TaskRun appears, the
PipelineRun reaches a terminal state with no TaskRuns scheduled, or
ctx is cancelled. The terminal-with-no-TaskRuns and ctx-cancelled
cases close the channel cleanly without sending anything, so the
goroutine cannot leak across a disconnecting client.
Bump tangled.org/core to a version that exposes
eventconsumer.Consumer.RemoveSource, and replace the placeholder
no-op body of knotConsumer.RemoveKnot with a real call.
Previously, when a repo revoked our spindle membership we logged the intent
but left the underlying websocket dialing the now-untrusted knot until
tack restarted; reconciliation could only ever add sources, never
remove them. RemoveKnot now tears the source down immediately, and the
tracking note in KNOWN_ISSUES.md plus the workaround comments referencing the
upstream PR are no longer accurate, so they are removed.
The consumer code in jetstream.go mixed Tack-specific concerns
(collections, applyCommit, store cursor) with generic firehose
mechanics: configuring the upstream client, looping reconnects,
rewinding time-based cursors, persisting cursor progress, and
distinguishing permanent bad-record failures from transient
handler failures. None of those are specific to Tack and they are
the bits most likely to be reused for future jetstream consumers.
Move the generic mechanics into a new internal/jetstream package.
Previously, observing a `sh.tangled.repo` record whose spindle field
matched our hostname was enough to enroll a new knot subscription, no
matter who published the record.
`reconcileKnot` now consults a new `IsAuthorizedActor` helper before
calling `AddKnot`: the repo's publisher DID must be the spindle owner
or have been vouched for by a `sh.tangled.spindle.member` record
whose own publisher is the owner.
VerifySignature previously accepted any cryptographically valid
"timestamp=<unix>,signature=<hex>" header regardless of how old the
timestamp was. An attacker who captured a single signed delivery
could replay it indefinitely, creating duplicate status events and
unbounded growth in the events table.
Reject signatures whose timestamp is more than MaxSignatureAge (5
minutes) from the local clock in either direction. The symmetric
bound also defeats implausibly future-dated stamps that would
otherwise mint a long replay window. The clock is read through a
package-level timeNow var so tests can pin it deterministically; the
existing fixed-timestamp test now stubs the clock and a new stale
case covers the rejection path.
Previously `knot.go` executed every `sh.tangled.pipeline` event the
moment it arrived, ignoring the `spindle_members` and `repos`
tables that `jetstream.go` has been mirroring from the AT Proto
firehose.
The knot consumer now consults `store.AuthorizePipelineActor`
before dispatching a trigger. The check has two gates: the
triggers repo must have published a `sh.tangled.repo` record
naming us as its spindle on the knot the event arrived from, and
the publisher of that repo record must be either the spindle
owner or a subject the owner vouched for via
`sh.tangled.spindle.member`.
Buildkite webhooks can land before Spawn's goroutine has persisted
the build UUID to (knot, pipeline_rkey, workflow) mapping. The
window is small but real: CreateBuild returns, Buildkite fires
build.scheduled, the webhook handler runs LookupBuildkiteBuildByUUID
and gets nothing, and the event is dropped on the floor forever.
HandleWebhook now reconstructs the ref from the build's meta_data
when the lookup misses. We already attach tack:knot, tack:pipeline_rkey,
and tack:workflow at CreateBuild time, so a Buildkite-originated
webhook for one of our builds always carries enough information to
recover the tuple. Org and pipeline slug come from the payload's
top-level organization and embedded build.pipeline objects.
The reconstructed ref is opportunistically inserted via the existing
ON CONFLICT DO UPDATE, so subsequent webhooks and any /logs request
hit the cache instead of redoing the meta_data dance. If the
authoritative Spawn-side insert lands afterwards it just refreshes
the row.
Builds without our tack:* meta_data still no-op, preserving the
'foreign build sharing this webhook URL' behavior. WebhookPayload
gains an Organization field so the org slug is available without
poking at Build.Pipeline.
Previously, `handleJetstreamEvent` saved the time-based cursor after
every event regardless of whether `applyCommit` succeeded. That is fine
for permanently bad records (malformed JSON, schema violations) where
replaying achieves nothing, but wrong for transient infra failures
(SQLite busy, store closed during shutdown, disk full): the cursor
would advance past a perfectly good event and silently drop the
membership or repo row that backs it, with no way to recover short of
a manual replay.
`applyCommit` now distinguishes the two classes via a new
`badRecordError` wrapper. JSON decode failures in `applySpindleMember`,
`applyRepo`, and `applyRepoCollaborator` are wrapped with
`badRecord(...)` so they remain cursor-advancing. Everything else
returned from `applyCommit` is treated as transient:
`handleJetstreamEvent` logs it, returns the error to the scheduler, and
skips `SaveCursor` so the next reconnect (which already rewinds by
`jetstreamRewind`) will redeliver and retry.
LookupBuildkiteBuildByTuple sorted on created_at, an RFC3339Nano
text column. Lexical comparison of nanosecond timestamps is not
reliable: time.Format trims trailing zeros, so an instant on the
exact second renders as '...:00Z' while one nanosecond later
renders as '...:00.000000001Z' and lex-sorts before it. The
practical effect was that /logs could resolve the wrong run for
a workflow that had been triggered more than once.
Add a created_unix_ns INTEGER column to buildkite_builds, populate
it from time.Now().UnixNano() on insert, and switch the lookup to
ORDER BY created_unix_ns DESC with created_at and build_number as
deterministic tiebreakers for legacy rows that pre-date the column.
The migration path is covered: an additive ALTER widens existing
databases, and a one-shot Go-side backfill parses each row's
created_at and writes the corresponding UnixNano. Rows whose text
fails to parse are left at the default 0 so a single corrupt row
cannot wedge startup. New tests in store_migrate_test.go open a
hand-crafted pre-migration database through openStore and assert
the upgrade is correct, idempotent, and tolerant of bad data.
Spawn previously stored cfg.Org on the buildkite_builds row, leaving
empty when the workflow YAML didn't set tack.buildkite.org and
relying on the read path to fall back to the provider's defaultOrg.
That coupling let historical lookups drift: if defaultOrg ever
changed, log fetches and webhook joins for older builds would silently
target the wrong organisation.
Jetstream cursors are time-based and the upstream docs explicitly note
that exact-boundary replay across a disconnect is not guaranteed
gapless. Resuming from the precise saved TimeUS could therefore drop
events that straddle the reconnect window.
On every (re)connect, subtract a fixed jetstreamRewind (5s) from the
loaded cursor before handing it to ConnectAndRead, clamping at zero so
a tiny saved cursor can't go negative. The replayed events are safe to
re-apply: applyCommit dispatches only to UPSERTs and DELETEs keyed on
(did, rkey), so duplicates collapse into the same row state.
The /logs and /events handlers wrote frames with conn.WriteMessage
and never set a write deadline. A client that stopped reading but
kept the TCP connection open could fill the kernel send buffer and
park the handler goroutine on a write forever, leaking the request
context, the broker subscription, and the log producer.
Add a wsWriteWait constant (10s) and call SetWriteDeadline before
every WriteMessage in the logs drain loop and in streamEvents. The
keep-alive ping and the closing close-frame already used WriteControl,
which takes a deadline argument directly; raise their bounds from 1s
to wsWriteWait for consistency. A stuck peer now fails the next write
within ~10s and the handler unwinds cleanly.
A workflow can override the spindle's default Buildkite organisation
via `tack.buildkite.org`, but `BuildkiteBuildRef` didn't carry the org
field. Spawn used the override for `CreateBuild` and then dropped it,
so `Logs` always recomputed org := p.defaultOrg and any cross-org
workflow's /logs request 404'd against the wrong organisation.
Extend TestBuildkiteSpawnWorkflowConfig to assert the org survives
the round-trip via LookupBuildkiteBuildByTuple.
Adds an `extraServiceConfig` option to the NixOS module that is
merged into the systemd service's `serviceConfig` after the
module's defaults. This lets operators set arbitrary `[Service]`
settings, most notably resource limits like `MemoryMax` and
`CPUQuota`, without needing to fork the module, and also lets
them override any of the defaults we set out of the box (e.g.
to relax a sandboxing knob).
Implemented as `attrsOf unspecified` merged with `//` so the
user's attrs win on conflict.