- Nushell 69.4%
- Nix 30%
- Shell 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Ratification law 9, active HITL, direct fastlane+main push, freeholder-authorized. Ting/contracts APPLY.md step 5: wire this store-only estate repository to the shared `contracts` OpenSpec store instead of its own private per-repo wiki store. No local openspec/specs or openspec/changes existed to remove. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
| flakes | ||
| openspec | ||
| .gitignore | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| LICENSE | ||
| README.md | ||
nixops4-providers
Five nixops4 resource providers, written
in Nushell, each a self-contained flake under flakes/.
| provider | what it manages |
|---|---|
nixops-vpsfree |
vpsfree.cz VPS lifecycle through the vpsfree Ruby CLI (vpsfreectl), plus a state_file backend so a deployment root works without nixops4's Rust local provider |
nixops-deploy-rs |
NixOS closure activation through deploy-rs |
nixops-forgejo |
Forgejo repo conformance — orgs, repos, branches, protections, labels, webhooks |
nixops-openbao |
OpenBao's rule surface — ACL policies, engine mounts, auth methods, their config and roles |
nixops-kanidm |
kanidm service accounts and group membership — the existence half of a machine principal, never its credential |
All are driven against real APIs, not stubs: nixops-vpsfree births and
resizes actual containers at vpsfree.cz, and nixops-forgejo reconciles a live
forge.
nixops-kanidm draws a line the others do not have to: an account's EXISTENCE
is declarable and therefore declared, while its api-token is generated at mint
time and cannot be predeclared. Minting, revocation and group-drift resolution
stay with Ting/Vedanta; this provider has no resource type through which to
hold a credential, and checks.example asserts that it never grows one.
Consuming one
Each provider is a separate flake, so take only what you need:
inputs.nixops-vpsfree.url =
"git+https://jo.et0.pw/midgard/nixops4-providers?dir=flakes/nixops-vpsfree";
inputs.nixops-vpsfree.inputs.nixpkgs.follows = "nixpkgs";
inputs.nixops-vpsfree.inputs.flake-parts.follows = "flake-parts";
Use the https URL, not an ssh one. A sandboxed Nix fetch carries no
credentials and resolves no private name, so an ssh:// input makes every
consumer's build depend on the fetching machine's keys and DNS.
nixops-deploy-rs additionally wants deploy-rs.follows: the deploy binary
the provider runs and the activation package baked into a profile have to come
from one tree.
Then wire the module into your nixops4 root:
nixops4.providers.vpsfree = inputs.nixops-vpsfree.modules.nixops4Provider.default;
Resource types
nixops-vpsfree
vps(stateful) — created exactly once, updated in place where vpsAdmin allows, recreation refused. Tolerates the known haveapi-client async crash (Response#meta) and recovers the VPS id by pollingvps listfor the hostname.state_file(stateless) — append-only JSON-patch event log, envelope compatible with nixops4's ownstate_fileprovider. Included so a deployment root works without building nixops4's Rust local provider, which has no binary cache upstream.
nixops-deploy-rs
deployment(stateful) — create is the first activation; update re-activates when the closure or the address moved; an apply that moves neither runs nothing. The closure is passed in as a store path (system) rather than looked up: protocol v0 only calls a provider when its inputs change, so a looked-up closure would mean editing the NixOS configuration changed nothing nixops4 could see, and the deploy would silently never run.
nixops-forgejo
org,repository,branch,branch_protection,label,webhook— reconciled add-and-repair, never pruning. The spec is at least the declaration, never exactly it, because a repo lawfully carries labels that did not travel. Schema v0 has no destroy, which makes that structural rather than merely intended.forge_user(stateless) — a user account's identity class (full_name,pronouns), adoption only: the login must already exist, is never renamed, and is never sent in a mutation body.pronounsis a sigil prefix plus a value — only the first character is ever set, replaced or removed, and the tail is copied byte for byte, never onto nothing and never normalized.
nixops-openbao
-
policy,mount,auth_method,auth_config,auth_role— the rule surface of the estate's secret-manager (flake-ops#67), reconciled add-and-repair. Nothing here is stateful, and that is a finding rather than an omission: OpenBao addresses everything by the path it was written to, and a path is in the declaration, so the provider adopts by address instead of remembering a server-assigned id. An existing mount or auth method is left exactly as it is — remounting moves the data under it, re-enabling a method logs out everyone holding a token from it, and the protocol has no verb for what either change actually is.The split with the host is deliberate and permanent: the provider owns the rule surface, applied from the operator's seat where authority already exists (
BAO_TOKEN, same out-of-band contract asVPSADMIN_TOKENandFORGEJO_TOKEN); the on-hostopenbao-provisiononeshot keeps liveness — unseal at boot, which must converge with nobody home.
Each provider vendors its own copy of protocol.nu — the schema-v0
JSON-lines harness, plus the per-request child process that works around a
nushell bug. Self-containment is why one shared copy is three; they collapse
together when that bug is fixed.
The package.nix files are wrapped executables with nothing baked in:
VPSADMIN_TOKEN, FORGEJO_TOKEN and BAO_TOKEN come from the caller's
environment at apply time, and nix is suffixed onto the deploy provider's PATH rather
than prefixed, so the operator's own nix — the one matching their daemon and
substituters — keeps winning.
The contract
Every provider here exposes exactly two things, mirroring nixops4's own
nix/providers/local.nix:
packages.<system>.default— the provider executablemodules.nixops4Provider.default— the resource-type declarations
No provider imports across its own directory boundary, so each moves
independently and follows its consumer's nixpkgs.
Written against resource protocol schema v0. The schema is versioned with nixops4 rather than separately, so a protocol change upstream is a change here.
Checks
Each provider carries its own conformance suite, run against stub binaries
(vpsfreectl, deploy) so none of it reaches a network:
- vpsfree — 14 protocol assertions:
state_filecreate/event/read with JSON-patch replay, stateless-vps refusal, unknown-field refusal, create surviving the async client crash, second-birth refusal, in-place update, immutable-field refusal, unknown-request refusal. Plus a streaming test proving an answer arrives before stdin EOF, since nixops4 speaks synchronous per-line request/response. - deploy-rs — 15 assertions, half about what the provider ran rather
than what it answered: first deploy invokes
deploywith the address, user, ssh options andflake#node.profiletarget; a converged update deploys nothing; a moved closure and a moved address each redeploy; a failed deploy becomes anerrorResponserather than a successful output. The stub logs every invocation, so "three deploys for eleven requests" is asserted by count. - openbao — 28 plan assertions and 10 protocol ones. The plan suite is
mostly about the branch where nothing happens: a policy the store already
holds, an existing mount, a converged role, a comma-joined declaration
matching the list the store returns. Two cases pin the honesty rules — a
declared key the store does not echo (
ttl, which reads back astoken_ttlin seconds) counts as drift rather than agreement, and a config carrying a client secret can never be verified and therefore writes on every apply. The protocol suite runs withBAO_TOKENcleared, so the message an operator with an expired OIDC session gets is asserted rather than assumed. - forgejo — a schema test comparing the module's declared inputs against
what
plan.nuaccepts, taken from the module itself rather than restated so it cannot pass by agreeing with a stale copy; a plan test exercising every "which request makes the forge match this declaration" branch as a pure function; and a protocol suite checking that a refusal returns anerrorResponserather than killing the loop.
$ nix flake check ./flakes/nixops-vpsfree
$ nix flake check ./flakes/nixops-deploy-rs
$ nix flake check ./flakes/nixops-forgejo
There is deliberately no root flake: the repository is a container for three independent ones, and a root would invent a shared lock they do not want.
Provenance
Extracted from lar.ad/flake-ops, which
remains their first consumer. They live outside it because a provider is
useful to anyone driving nixops4 at the same hoster or forge, and being
vendored in one fleet's configuration repo is the thing that stops that.