diff --git a/README.md b/README.md index 3f760a5..fd4e736 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ without its neighbour checked out is a repository nobody can build. ``` mesh-controller: ./build/mesh-controller plan --json > /tmp/d.json -mesh-host: MESH_EMITTED=/tmp/d.json go test ./internal/declaration/ -v +mesh-sdk: MESH_EMITTED=/tmp/d.json go test ./declaration/ -v (run in mesh-sdk's go/ directory) ``` **What this node says when it joins, read by the mesh:** diff --git a/cmd/mesh-host/filling.go b/cmd/mesh-host/filling.go index 22f6dcc..ff84109 100644 --- a/cmd/mesh-host/filling.go +++ b/cmd/mesh-host/filling.go @@ -6,8 +6,8 @@ import ( "errors" "path/filepath" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/filling" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" diff --git a/cmd/mesh-host/judge_directories.go b/cmd/mesh-host/judge_directories.go index c8a9070..c65d19f 100644 --- a/cmd/mesh-host/judge_directories.go +++ b/cmd/mesh-host/judge_directories.go @@ -4,8 +4,8 @@ import ( "errors" "fmt" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/store" ) diff --git a/cmd/mesh-host/libraries.go b/cmd/mesh-host/libraries.go index f0addb1..040eb2c 100644 --- a/cmd/mesh-host/libraries.go +++ b/cmd/mesh-host/libraries.go @@ -11,8 +11,8 @@ import ( "sync/atomic" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/store" ) diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go index 49ab1a3..2f3aafb 100644 --- a/cmd/mesh-host/libraries_test.go +++ b/cmd/mesh-host/libraries_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/link" ) diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index f8691fa..4e0ff61 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -29,10 +29,10 @@ import ( "text/tabwriter" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/bundle" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/firewall" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/inventory" diff --git a/cmd/mesh-host/main_test.go b/cmd/mesh-host/main_test.go index b860dc6..1aaf8bf 100644 --- a/cmd/mesh-host/main_test.go +++ b/cmd/mesh-host/main_test.go @@ -12,9 +12,9 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/bundle" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/store" diff --git a/cmd/mesh-host/module_facts_test.go b/cmd/mesh-host/module_facts_test.go index 79b485a..fe8f810 100644 --- a/cmd/mesh-host/module_facts_test.go +++ b/cmd/mesh-host/module_facts_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" ) // 1132/AC1, AC3: the report carries one fact per module the declaration names, with the build the controller sent for diff --git a/cmd/mesh-host/network_test.go b/cmd/mesh-host/network_test.go index 670702e..8f9dcef 100644 --- a/cmd/mesh-host/network_test.go +++ b/cmd/mesh-host/network_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/liveness" "github.com/novox/mesh-host/internal/network" ) diff --git a/cmd/mesh-host/probe.go b/cmd/mesh-host/probe.go index 64934f4..da5214b 100644 --- a/cmd/mesh-host/probe.go +++ b/cmd/mesh-host/probe.go @@ -6,8 +6,8 @@ import ( "os/exec" "sort" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/cmd/mesh-host/probe_test.go b/cmd/mesh-host/probe_test.go index d26e156..6433fd2 100644 --- a/cmd/mesh-host/probe_test.go +++ b/cmd/mesh-host/probe_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/system" ) diff --git a/cmd/mesh-host/replay233_test.go b/cmd/mesh-host/replay233_test.go index c46cf04..9cc0277 100644 --- a/cmd/mesh-host/replay233_test.go +++ b/cmd/mesh-host/replay233_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index b079b1c..22ce465 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/accounts" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/liveness" "github.com/novox/mesh-host/internal/units" diff --git a/cmd/mesh-host/vaultbox_nats_test.go b/cmd/mesh-host/vaultbox_nats_test.go index 74be00e..ddb83c6 100644 --- a/cmd/mesh-host/vaultbox_nats_test.go +++ b/cmd/mesh-host/vaultbox_nats_test.go @@ -19,8 +19,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/seal" "github.com/nats-io/nats.go" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/store" diff --git a/examples/bundle_test.go b/examples/bundle_test.go index ca3a346..355fb48 100644 --- a/examples/bundle_test.go +++ b/examples/bundle_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) func bundle(t *testing.T) *declaration.Declaration { diff --git a/go.mod b/go.mod index 37f75e4..e8898e6 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 1bb95c5..3a08c17 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26 h1:oT1l3ZdjyF82MK14QMLKzoH2+iOFH/+UEYdlK3T2f7k= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= diff --git a/internal/accounts/accounts.go b/internal/accounts/accounts.go index 34efd9f..5ad7e39 100644 --- a/internal/accounts/accounts.go +++ b/internal/accounts/accounts.go @@ -45,7 +45,7 @@ import ( "sync" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // The states, in the words liveness says them (the controller reads them alike). diff --git a/internal/accounts/accounts_test.go b/internal/accounts/accounts_test.go index 12c32d5..b1556bf 100644 --- a/internal/accounts/accounts_test.go +++ b/internal/accounts/accounts_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // The account judge (novox/hq ADR 0252, "how it is checked"): an account in a group its running session diff --git a/internal/accounts/root_test.go b/internal/accounts/root_test.go index 387a90c..bb128e0 100644 --- a/internal/accounts/root_test.go +++ b/internal/accounts/root_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // An account declared never to become root without a person (novox/hq ADR 0266, "how it is checked"): each diff --git a/internal/apply/agent_home_guard.go b/internal/apply/agent_home_guard.go index 960d1dc..fb4cd0f 100644 --- a/internal/apply/agent_home_guard.go +++ b/internal/apply/agent_home_guard.go @@ -14,7 +14,7 @@ import ( "fmt" "path/filepath" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // AgentHomeRefusedError is an archive or a written-into file the engine will not place below an agent's home. diff --git a/internal/apply/agent_home_guard_test.go b/internal/apply/agent_home_guard_test.go index d263ed3..01d5f58 100644 --- a/internal/apply/agent_home_guard_test.go +++ b/internal/apply/agent_home_guard_test.go @@ -6,7 +6,7 @@ package apply import ( "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) func TestNothingIsUnpackedOrWrittenIntoBelowAnAgentsHome(t *testing.T) { diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 5eaa28b..f723022 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -27,7 +27,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/firewall" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 35f27eb..ccefba0 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/archive.go b/internal/apply/archive.go index 87fcb1e..13c9bd3 100644 --- a/internal/apply/archive.go +++ b/internal/apply/archive.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/block.go b/internal/apply/block.go index ca025cc..7b1208f 100644 --- a/internal/apply/block.go +++ b/internal/apply/block.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/busy.go b/internal/apply/busy.go index 04a23b2..063fc55 100644 --- a/internal/apply/busy.go +++ b/internal/apply/busy.go @@ -7,7 +7,7 @@ import ( "time" "unicode/utf8" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/busy_test.go b/internal/apply/busy_test.go index 5644b7e..481a6c0 100644 --- a/internal/apply/busy_test.go +++ b/internal/apply/busy_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/filled.go b/internal/apply/filled.go index 6759e9f..e010323 100644 --- a/internal/apply/filled.go +++ b/internal/apply/filled.go @@ -5,7 +5,7 @@ import ( "fmt" "slices" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/filled_apply_test.go b/internal/apply/filled_apply_test.go index de1be82..86c76a1 100644 --- a/internal/apply/filled_apply_test.go +++ b/internal/apply/filled_apply_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/filled_library_test.go b/internal/apply/filled_library_test.go index a378c93..6b8633c 100644 --- a/internal/apply/filled_library_test.go +++ b/internal/apply/filled_library_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/filled_test.go b/internal/apply/filled_test.go index 2f7ff74..3d5fbd3 100644 --- a/internal/apply/filled_test.go +++ b/internal/apply/filled_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/groups.go b/internal/apply/groups.go index 308fa31..e134425 100644 --- a/internal/apply/groups.go +++ b/internal/apply/groups.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/health_check_test.go b/internal/apply/health_check_test.go index ab0d561..593af6b 100644 --- a/internal/apply/health_check_test.go +++ b/internal/apply/health_check_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/hold.go b/internal/apply/hold.go index 72a4011..b06c6ab 100644 --- a/internal/apply/hold.go +++ b/internal/apply/hold.go @@ -13,7 +13,7 @@ import ( "syscall" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/hold_test.go b/internal/apply/hold_test.go index 868ff1e..c0195d3 100644 --- a/internal/apply/hold_test.go +++ b/internal/apply/hold_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/homes_other.go b/internal/apply/homes_other.go index 87cc3ec..8dd0f0b 100644 --- a/internal/apply/homes_other.go +++ b/internal/apply/homes_other.go @@ -9,7 +9,7 @@ import ( "os" "path/filepath" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) func chmodUnder(home, path string, mode os.FileMode) error { diff --git a/internal/apply/identities.go b/internal/apply/identities.go index eeb453e..ef07d19 100644 --- a/internal/apply/identities.go +++ b/internal/apply/identities.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/identities_test.go b/internal/apply/identities_test.go index 43af034..ffce951 100644 --- a/internal/apply/identities_test.go +++ b/internal/apply/identities_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/into.go b/internal/apply/into.go index a02e7da..6121f17 100644 --- a/internal/apply/into.go +++ b/internal/apply/into.go @@ -10,7 +10,7 @@ import ( "slices" "sort" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/into_handover_test.go b/internal/apply/into_handover_test.go index 640be24..3028351 100644 --- a/internal/apply/into_handover_test.go +++ b/internal/apply/into_handover_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/into_test.go b/internal/apply/into_test.go index c5f1362..536ab6d 100644 --- a/internal/apply/into_test.go +++ b/internal/apply/into_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/keep.go b/internal/apply/keep.go index 19420f9..af444a7 100644 --- a/internal/apply/keep.go +++ b/internal/apply/keep.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/left_out_test.go b/internal/apply/left_out_test.go index ac9175d..a7b11a5 100644 --- a/internal/apply/left_out_test.go +++ b/internal/apply/left_out_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/library.go b/internal/apply/library.go index 36a53b3..005b0c2 100644 --- a/internal/apply/library.go +++ b/internal/apply/library.go @@ -11,7 +11,7 @@ import ( "sync" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/library_linux.go b/internal/apply/library_linux.go index 3a06073..ba8a251 100644 --- a/internal/apply/library_linux.go +++ b/internal/apply/library_linux.go @@ -8,7 +8,7 @@ import ( "fmt" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/library_test.go b/internal/apply/library_test.go index 29ce381..11349a2 100644 --- a/internal/apply/library_test.go +++ b/internal/apply/library_test.go @@ -16,7 +16,7 @@ import ( "golang.org/x/sys/unix" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/logging_test.go b/internal/apply/logging_test.go index 3aa0737..8324d1e 100644 --- a/internal/apply/logging_test.go +++ b/internal/apply/logging_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/maintenance_window_test.go b/internal/apply/maintenance_window_test.go index 239f967..acacdcc 100644 --- a/internal/apply/maintenance_window_test.go +++ b/internal/apply/maintenance_window_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/mesh_dirs.go b/internal/apply/mesh_dirs.go index adb8ffa..c550240 100644 --- a/internal/apply/mesh_dirs.go +++ b/internal/apply/mesh_dirs.go @@ -30,7 +30,7 @@ import ( "strconv" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // LinkInPathError is a mesh directory refused because a link in its path is one another account could replace. diff --git a/internal/apply/mesh_dirs_apply_linux.go b/internal/apply/mesh_dirs_apply_linux.go index af56f41..5e62b60 100644 --- a/internal/apply/mesh_dirs_apply_linux.go +++ b/internal/apply/mesh_dirs_apply_linux.go @@ -14,7 +14,7 @@ import ( "golang.org/x/sys/unix" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // meshDirsByDescriptor says this system can open a directory component by component without following a link. diff --git a/internal/apply/mesh_dirs_test.go b/internal/apply/mesh_dirs_test.go index b84abea..53df207 100644 --- a/internal/apply/mesh_dirs_test.go +++ b/internal/apply/mesh_dirs_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/mesh_names_test.go b/internal/apply/mesh_names_test.go index 794911f..ec9fa12 100644 --- a/internal/apply/mesh_names_test.go +++ b/internal/apply/mesh_names_test.go @@ -3,7 +3,7 @@ package apply import ( "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // **A container's mesh names are part of what it is** (novox/hq 04-ISSUES/135). diff --git a/internal/apply/mode.go b/internal/apply/mode.go index 1d9bcf1..e73359e 100644 --- a/internal/apply/mode.go +++ b/internal/apply/mode.go @@ -3,7 +3,7 @@ package apply import ( "fmt" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/module_results.go b/internal/apply/module_results.go index 372ac51..affe5fb 100644 --- a/internal/apply/module_results.go +++ b/internal/apply/module_results.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // failuresOf is every failure of an apply as the error carries it: the first among them, then the rest, so a module diff --git a/internal/apply/module_results_test.go b/internal/apply/module_results_test.go index 2e232be..79d4dfb 100644 --- a/internal/apply/module_results_test.go +++ b/internal/apply/module_results_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) func twoModules(t *testing.T) *declaration.Declaration { diff --git a/internal/apply/move_out.go b/internal/apply/move_out.go index 0deaf35..5dcfbc1 100644 --- a/internal/apply/move_out.go +++ b/internal/apply/move_out.go @@ -19,7 +19,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/opening.go b/internal/apply/opening.go index bc34789..7b4e715 100644 --- a/internal/apply/opening.go +++ b/internal/apply/opening.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/firewall" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/opening_test.go b/internal/apply/opening_test.go index 91c701f..f632299 100644 --- a/internal/apply/opening_test.go +++ b/internal/apply/opening_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/placement_guard.go b/internal/apply/placement_guard.go index e23ef45..ef26e43 100644 --- a/internal/apply/placement_guard.go +++ b/internal/apply/placement_guard.go @@ -56,7 +56,7 @@ import ( "sync" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // protectedRoots are paths no directory, access or mount source may be, nor hold. diff --git a/internal/apply/plan.go b/internal/apply/plan.go index d41b161..4d31464 100644 --- a/internal/apply/plan.go +++ b/internal/apply/plan.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/firewall" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/plan_test.go b/internal/apply/plan_test.go index f08619a..7ad86fb 100644 --- a/internal/apply/plan_test.go +++ b/internal/apply/plan_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/process.go b/internal/apply/process.go index bc5a3bf..cbf4e06 100644 --- a/internal/apply/process.go +++ b/internal/apply/process.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/witness" ) diff --git a/internal/apply/process_test.go b/internal/apply/process_test.go index 71530e5..20b1cec 100644 --- a/internal/apply/process_test.go +++ b/internal/apply/process_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/reads_test.go b/internal/apply/reads_test.go index be2d8fe..ea2a5a4 100644 --- a/internal/apply/reads_test.go +++ b/internal/apply/reads_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/replay358_test.go b/internal/apply/replay358_test.go index 4f3eef7..a9ba1c7 100644 --- a/internal/apply/replay358_test.go +++ b/internal/apply/replay358_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/root_moved.go b/internal/apply/root_moved.go index b246c0a..1b05a9e 100644 --- a/internal/apply/root_moved.go +++ b/internal/apply/root_moved.go @@ -4,7 +4,7 @@ import ( "path/filepath" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // rootPaths are the files and directories through which an account comes to root, or by which it is told who it diff --git a/internal/apply/runonce_test.go b/internal/apply/runonce_test.go index 3cfaed1..ec82a65 100644 --- a/internal/apply/runonce_test.go +++ b/internal/apply/runonce_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/schedule.go b/internal/apply/schedule.go index 2d8e969..9b7146b 100644 --- a/internal/apply/schedule.go +++ b/internal/apply/schedule.go @@ -26,7 +26,7 @@ import ( "sync" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Clock is the source of "now", injected so scheduled steps can be tested without waiting on the diff --git a/internal/apply/schedule_test.go b/internal/apply/schedule_test.go index 01b6b2e..3f4b8e5 100644 --- a/internal/apply/schedule_test.go +++ b/internal/apply/schedule_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/sealed_test.go b/internal/apply/sealed_test.go index 77df034..388a7ea 100644 --- a/internal/apply/sealed_test.go +++ b/internal/apply/sealed_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/step_failed_test.go b/internal/apply/step_failed_test.go index 821df39..aa73c89 100644 --- a/internal/apply/step_failed_test.go +++ b/internal/apply/step_failed_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/store_away_test.go b/internal/apply/store_away_test.go index 497f141..e21d19e 100644 --- a/internal/apply/store_away_test.go +++ b/internal/apply/store_away_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/strays.go b/internal/apply/strays.go index f3a0f68..d3be73a 100644 --- a/internal/apply/strays.go +++ b/internal/apply/strays.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/takeover.go b/internal/apply/takeover.go index 0669746..5544663 100644 --- a/internal/apply/takeover.go +++ b/internal/apply/takeover.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" "github.com/novox/mesh-host/internal/tunnel" diff --git a/internal/apply/takeover_test.go b/internal/apply/takeover_test.go index 8b6c24e..8c8a325 100644 --- a/internal/apply/takeover_test.go +++ b/internal/apply/takeover_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/user.go b/internal/apply/user.go index 4891e74..bfc784e 100644 --- a/internal/apply/user.go +++ b/internal/apply/user.go @@ -10,7 +10,7 @@ import ( "strconv" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/apply/vault_sealed_test.go b/internal/apply/vault_sealed_test.go index 0652c4a..17166db 100644 --- a/internal/apply/vault_sealed_test.go +++ b/internal/apply/vault_sealed_test.go @@ -13,7 +13,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/seal" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/vocabulary_test.go b/internal/apply/vocabulary_test.go index 5fc8257..bf2bcce 100644 --- a/internal/apply/vocabulary_test.go +++ b/internal/apply/vocabulary_test.go @@ -17,7 +17,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/apply/walk_other.go b/internal/apply/walk_other.go index df8b725..ab21745 100644 --- a/internal/apply/walk_other.go +++ b/internal/apply/walk_other.go @@ -6,7 +6,7 @@ import ( "context" "errors" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // The node-engine runs on Linux: a library is kept only by descriptor, which this system cannot. Everything refuses, diff --git a/internal/bootstrap/adopt_in_place_test.go b/internal/bootstrap/adopt_in_place_test.go index 27a80d1..4e89ec0 100644 --- a/internal/bootstrap/adopt_in_place_test.go +++ b/internal/bootstrap/adopt_in_place_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/bootstrap/adopted.go b/internal/bootstrap/adopted.go index 33e77cb..fdb5583 100644 --- a/internal/bootstrap/adopted.go +++ b/internal/bootstrap/adopted.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // What an adopted genesis changes about the foundation (novox/hq ADR 0100). diff --git a/internal/bootstrap/adopted_test.go b/internal/bootstrap/adopted_test.go index 4e35738..040f8db 100644 --- a/internal/bootstrap/adopted_test.go +++ b/internal/bootstrap/adopted_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Defends novox/hq ADR 0100: an adopted genesis loads no table that drops by default or holds an diff --git a/internal/bootstrap/apply.go b/internal/bootstrap/apply.go index b88e559..f056c6d 100644 --- a/internal/bootstrap/apply.go +++ b/internal/bootstrap/apply.go @@ -8,8 +8,8 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/bootstrap/apply_test.go b/internal/bootstrap/apply_test.go index 780a00c..67766cb 100644 --- a/internal/bootstrap/apply_test.go +++ b/internal/bootstrap/apply_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/bootstrap/bus_adoption.go b/internal/bootstrap/bus_adoption.go index 4f26ea0..2e7ca7a 100644 --- a/internal/bootstrap/bus_adoption.go +++ b/internal/bootstrap/bus_adoption.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Adopting the bus is the mesh's planned step for replacing it (novox/hq ADR 0333 §3, ADR 0236). diff --git a/internal/bootstrap/control.go b/internal/bootstrap/control.go index a0c509b..0a13a4c 100644 --- a/internal/bootstrap/control.go +++ b/internal/bootstrap/control.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // storeFileSuffix is how a manifest asks for a store connection in a file rather than in the diff --git a/internal/bootstrap/genesis_handover_test.go b/internal/bootstrap/genesis_handover_test.go index ba7c3c1..ec4a477 100644 --- a/internal/bootstrap/genesis_handover_test.go +++ b/internal/bootstrap/genesis_handover_test.go @@ -14,8 +14,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/bootstrap/inuse.go b/internal/bootstrap/inuse.go index c3cf613..1ef695e 100644 --- a/internal/bootstrap/inuse.go +++ b/internal/bootstrap/inuse.go @@ -6,7 +6,7 @@ import ( "net" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/reachable" "github.com/novox/mesh-host/internal/store" ) diff --git a/internal/bootstrap/phase3.go b/internal/bootstrap/phase3.go index ae45e01..b4d171a 100644 --- a/internal/bootstrap/phase3.go +++ b/internal/bootstrap/phase3.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Phase three — nothing is special after installation (novox/hq issue 051). diff --git a/internal/bootstrap/phase3_test.go b/internal/bootstrap/phase3_test.go index d0eafc1..c597a92 100644 --- a/internal/bootstrap/phase3_test.go +++ b/internal/bootstrap/phase3_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // aCatalogue is a catalogue checkout holding these modules' manifests. diff --git a/internal/bootstrap/ports.go b/internal/bootstrap/ports.go index dbedf50..f271c78 100644 --- a/internal/bootstrap/ports.go +++ b/internal/bootstrap/ports.go @@ -11,7 +11,7 @@ import ( "strconv" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/reachable" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/tunnel" diff --git a/internal/bootstrap/ports_test.go b/internal/bootstrap/ports_test.go index 661fdce..ed5bbb9 100644 --- a/internal/bootstrap/ports_test.go +++ b/internal/bootstrap/ports_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/reachable" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/tunnel" diff --git a/internal/bootstrap/preflight.go b/internal/bootstrap/preflight.go index 0e4ca46..eba0701 100644 --- a/internal/bootstrap/preflight.go +++ b/internal/bootstrap/preflight.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/image" "github.com/novox/mesh-host/internal/profile" ) diff --git a/internal/bootstrap/preflight_test.go b/internal/bootstrap/preflight_test.go index 9dcc644..4c4745b 100644 --- a/internal/bootstrap/preflight_test.go +++ b/internal/bootstrap/preflight_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // An installed package is not a capability (novox/hq 04-ISSUES/007). The daemon is asked, and a diff --git a/internal/bootstrap/retire.go b/internal/bootstrap/retire.go index 71bb495..9c34a19 100644 --- a/internal/bootstrap/retire.go +++ b/internal/bootstrap/retire.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/bootstrap/retire_test.go b/internal/bootstrap/retire_test.go index 2a86992..202d73e 100644 --- a/internal/bootstrap/retire_test.go +++ b/internal/bootstrap/retire_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Retirement is destruction by omission, which is the host's ordinary behaviour: it owns what it diff --git a/internal/bootstrap/rewrite.go b/internal/bootstrap/rewrite.go index a3ede6a..4433510 100644 --- a/internal/bootstrap/rewrite.go +++ b/internal/bootstrap/rewrite.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // ControlPlaneID is the resource the installer replaces the image of. diff --git a/internal/bootstrap/rewrite_test.go b/internal/bootstrap/rewrite_test.go index d9963e2..bb4e6ad 100644 --- a/internal/bootstrap/rewrite_test.go +++ b/internal/bootstrap/rewrite_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Each test names the decision it defends (novox/hq ADR 0017). diff --git a/internal/bootstrap/rootsecrets.go b/internal/bootstrap/rootsecrets.go index 9f1fa1e..30ddd11 100644 --- a/internal/bootstrap/rootsecrets.go +++ b/internal/bootstrap/rootsecrets.go @@ -12,7 +12,7 @@ import ( "golang.org/x/crypto/bcrypt" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // The mesh's root credentials, made at genesis rather than copied from the template. diff --git a/internal/bootstrap/rootsecrets_test.go b/internal/bootstrap/rootsecrets_test.go index 29c61e1..8cc764e 100644 --- a/internal/bootstrap/rootsecrets_test.go +++ b/internal/bootstrap/rootsecrets_test.go @@ -7,7 +7,7 @@ import ( "golang.org/x/crypto/bcrypt" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // testCredentials are made values with a real bcrypt hash of the bus password, as RootSecrets makes. diff --git a/internal/bootstrap/store_layout_test.go b/internal/bootstrap/store_layout_test.go index 809ded7..1fe597f 100644 --- a/internal/bootstrap/store_layout_test.go +++ b/internal/bootstrap/store_layout_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Defends novox/hq ADR 0338 (issue 609): genesis raises its store as the postgres module's server, so diff --git a/internal/bootstrap/trust.go b/internal/bootstrap/trust.go index ebe1efc..4734ef8 100644 --- a/internal/bootstrap/trust.go +++ b/internal/bootstrap/trust.go @@ -10,8 +10,8 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/bootstrap/trust_test.go b/internal/bootstrap/trust_test.go index 8aecaa2..5e406b8 100644 --- a/internal/bootstrap/trust_test.go +++ b/internal/bootstrap/trust_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/bootstrap/verify.go b/internal/bootstrap/verify.go index 5f5ff66..6b9634d 100644 --- a/internal/bootstrap/verify.go +++ b/internal/bootstrap/verify.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // controlPlaneBinary is where the control plane's program lives in its own image. diff --git a/internal/bootstrap/verify_test.go b/internal/bootstrap/verify_test.go index ddd37ab..3c7cb83 100644 --- a/internal/bootstrap/verify_test.go +++ b/internal/bootstrap/verify_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) func foundation(t *testing.T) *declaration.Declaration { diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index 329f10f..78e7e3d 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -15,7 +15,7 @@ import ( "fmt" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // One bundle per operating system, because its CONTENTS are per system even though its diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index 232971d..386f837 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) func TestADefaultBuildCarriesNothingAndSaysSo(t *testing.T) { diff --git a/internal/declaration/adoption_test.go b/internal/declaration/adoption_test.go deleted file mode 100644 index e422516..0000000 --- a/internal/declaration/adoption_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// Defends novox/hq ADR 0100: every declaration says whether the node is adopted and which of its -// modules are taken, and the host refuses one it cannot read that from unambiguously. - -const adoptedResources = `"resources":[ - {"id":"hello-web.page","type":"file","path":"/var/lib/hello-web/index.html","content":"a\n"}, - {"id":"hello-web.server","type":"container","name":"hello-web","image":"sha256:` + sixtyFour + `"}, - {"id":"hello-web.data","type":"directory","path":"/var/lib/hello-web"} - ]` - -const sixtyFour = "0000000000000000000000000000000000000000000000000000000000000000" - -func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) { - d, err := Parse([]byte(`{"adoption":{"taken":["postgres"],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}}, - "declaration":1,` + adoptedResources + `}`)) - if err != nil { - t.Fatal(err) - } - if d.Adoption == nil { - t.Fatal("the adoption was dropped") - } - if len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "postgres" { - t.Errorf("taken read as %v", d.Adoption.Taken) - } - if module, ok := d.Adoption.UntakenModuleOf("hello-web.server"); !ok || module != "hello-web" { - t.Errorf("the container's untaken module read as %q, %v", module, ok) - } - if _, ok := d.Adoption.UntakenModuleOf("hello-web.data"); ok { - t.Error("a resource the adoption does not name was said to be untaken") - } -} - -func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,` + adoptedResources + `}`)) - if err != nil { - t.Fatal(err) - } - if d.Adoption != nil { - t.Errorf("a declaration saying nothing about adoption read as adopted: %+v", d.Adoption) - } - if _, ok := d.Adoption.UntakenModuleOf("hello-web.page"); ok { - t.Error("a converged node has an untaken module") - } -} - -func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) { - refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.missing"]}}, - "declaration":1,`+adoptedResources+`}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "hello-web.missing") { - t.Errorf("the unknown id was not named: %v", refusal.Problems) - } -} - -func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) { - // A directory, a service or an action can reach what was found as surely as a file can, so - // the controller lists every resource of an untaken module (novox/hq ADR 0103). - d, err := Parse([]byte(`{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.data"]}}, - "declaration":1,` + adoptedResources + `}`)) - if err != nil { - t.Fatalf("a directory of an untaken module was refused: %v", err) - } - if module, ok := d.Adoption.UntakenModuleOf("hello-web.data"); !ok || module != "hello-web" { - t.Errorf("the directory is not its module's: %q %v", module, ok) - } -} - -func TestAnIDUnderTwoModulesIsRefused(t *testing.T) { - refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"a":["hello-web.page"],"b":["hello-web.page"]}}, - "declaration":1,`+adoptedResources+`}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both") { - t.Errorf("an id under two modules was accepted: %v", refusal.Problems) - } -} - -func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) { - refusal := refusalFor(t, `{"adoption":{"taken":["hello-web"],"untaken":{"hello-web":["hello-web.page"]}}, - "declaration":1,`+adoptedResources+`}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both taken and untaken") { - t.Errorf("a module both taken and untaken was accepted: %v", refusal.Problems) - } -} - -func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) { - refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"x":["adoption.guard"]}}, - "declaration":1,"resources":[ - {"id":"adoption.guard","type":"file","path":"/etc/mesh/guard.nft","content":"x"}]}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "belongs to no module") { - t.Errorf("an adoption. id was accepted as untaken: %v", refusal.Problems) - } -} - -func TestAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) { - refusalFor(t, `{"adoption":{"taken":[],"held":["x"]},"declaration":1,`+adoptedResources+`}`) -} - -func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) { - _, err := ParseTrusted([]byte(`{"adoption":{"taken":[]},"declaration":1,` + adoptedResources + `}`)) - if err == nil || !strings.Contains(err.Error(), "only the mesh can say") { - t.Fatalf("a bundle claiming adoption was not refused: %v", err) - } -} - -// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node. -func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) { - adoptedWith := func(service string) error { - _, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`)) - return err - } - good := `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running", - "takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}` - if err := adoptedWith(good); err != nil { - t.Fatalf("a whole takeover on an adopted node was refused: %v", err) - } - for name, bad := range map[string]string{ - "its own unit": `{"id":"up","type":"service","unit":"wg-quick@wg0","state":"running", - "takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`, - "no config": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running", - "takes-over":{"interface":"wg0","unit":"wg-quick@wg0"}}`, - "a stopped service": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"stopped", - "takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`, - } { - if err := adoptedWith(bad); err == nil { - t.Errorf("a takeover naming %s was accepted", name) - } - } -} diff --git a/internal/declaration/block_test.go b/internal/declaration/block_test.go deleted file mode 100644 index 1512a00..0000000 --- a/internal/declaration/block_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// Defends novox/hq issue 128: a file written into a block carries only its lines, in content, -// never a line the host keeps as its own marker, and says where a new region goes only as a block. - -func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) { - for name, c := range map[string]struct{ resource, refusal string }{ - "a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"}, - "an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"}, - "a marker with a CR": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"# BEGIN mesh x\r\n"}`, "marker"}, - "sealed": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","sealed":"abc"}`, "not sealed"}, - "bytes": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","bytes":"YQ=="}`, "not sealed, bytes"}, - "secrets": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"${secret:s}","secrets":{"s":"abc"}}`, "secrets"}, - "create-once": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","create-once":true}`, "create-once"}, - "at on a whole file": {`{"id":"f","type":"file","path":"/etc/hosts","content":"a","at":"start"}`, `at "start"`}, - "at on a JSON file": {`{"id":"f","type":"file","path":"/etc/x.json","into":"json","content":"{}","at":"end"}`, `at "end"`}, - "at somewhere else": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","at":"middle"}`, `"start" or "end"`}, - "an id ending in a space": {`{"id":"f ","type":"file","path":"/etc/hosts","into":"block","content":"a"}`, "whitespace"}, - "an unknown format": {`{"id":"f","type":"file","path":"/etc/hosts","into":"lines","content":"a"}`, `"json" or "block"`}, - } { - _, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`)) - if err == nil || !strings.Contains(err.Error(), c.refusal) { - t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) - } - } -} - -func TestAFileWrittenIntoABlockIsRead(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"}, - {"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"}, - {"id":"empty","type":"file","path":"/etc/x","into":"block","content":"","at":"end"} - ]}`)) - if err != nil { - t.Fatal(err) - } - if f := d.Resources[0].(*File); f.Into != IntoBlock || f.At != "" { - t.Errorf("read as into %q at %q", f.Into, f.At) - } - if f := d.Resources[1].(*File); f.At != AtStart { - t.Errorf("at was read as %q", f.At) - } - if begin, end := BlockMarkers("mesh-wireguard.fact-node-names"); begin != "# BEGIN mesh mesh-wireguard.fact-node-names" || - end != "# END mesh mesh-wireguard.fact-node-names" { - t.Errorf("the markers are %q and %q", begin, end) - } -} diff --git a/internal/declaration/busy.go b/internal/declaration/busy.go deleted file mode 100644 index 3b73485..0000000 --- a/internal/declaration/busy.go +++ /dev/null @@ -1,87 +0,0 @@ -package declaration - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "time" -) - -// Busy is how a long-running container says it holds work that would be lost if it were stopped now -// (novox/hq ADR 0335 §1): a command run inside it, by its shell, as an exec health check is, that exits 0 -// while it is busy and non-zero when it is idle. Being asked is also the container's notice that it is -// about to be replaced: from the first ask it takes no new work, so its answer ends once the work in hand -// ends. -// -// **Not part of what the container is.** Busy is not in the container's spec: a changed command changes -// when the container is replaced, never whether — a container is not recreated for its busy command alone. -type Busy struct { - Command string `json:"command"` - // Timeout is how long one ask may take; empty is BusyTimeoutDefault. - Timeout string `json:"timeout,omitempty"` -} - -// The bounds of an ask (novox/hq ADR 0335). -const ( - BusyTimeoutDefault = 10 * time.Second - // BusyTimeoutCeiling is the longest one ask may take: an ask runs inside an apply, and an apply waits - // for nothing longer than a look. - BusyTimeoutCeiling = time.Minute - // BusyBound is how long a container that says busy is left running after the first apply that would - // have replaced it (ADR 0335 §3). The node-engine's, not the module's: no module holds its own - // replacement for ever. - BusyBound = 15 * time.Minute -) - -// UnmarshalJSON reads a busy strictly, as everything a declaration carries is read. -func (b *Busy) UnmarshalJSON(raw []byte) error { - type plain Busy - var p plain - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.DisallowUnknownFields() - if err := dec.Decode(&p); err != nil { - return fmt.Errorf("busy: %w", err) - } - *b = Busy(p) - return nil -} - -// Within is how long one ask may take. Validated on arrival, so an unreadable timeout is the default. -func (b *Busy) Within() time.Duration { - if d, err := time.ParseDuration(b.Timeout); err == nil && d > 0 { - return d - } - return BusyTimeoutDefault -} - -// problems holds a container's busy to its shape: a one-line command, a timeout within its bounds, and only -// on a container that stays up. -func (b *Busy) problems(where string, longRunning bool) []string { - if b == nil { - return nil - } - var problems []string - say := func(format string, args ...any) { - problems = append(problems, where+": "+fmt.Sprintf(format, args...)) - } - if !longRunning { - say("busy is asked of a container that stays up; a step or a scheduled run is never replaced mid-run") - } - if strings.TrimSpace(b.Command) == "" { - say("busy needs a command") - } - if strings.ContainsAny(b.Command, "\n\r") { - say("busy's command is one line") - } - if b.Timeout != "" { - d, err := time.ParseDuration(b.Timeout) - switch { - case err != nil: - say("busy's timeout is a duration, not %q", b.Timeout) - case d <= 0 || d > BusyTimeoutCeiling: - say("an ask of busy takes more than nothing and at most %s, not %s", BusyTimeoutCeiling, d) - } - } - return problems -} diff --git a/internal/declaration/cron.go b/internal/declaration/cron.go deleted file mode 100644 index 69b97df..0000000 --- a/internal/declaration/cron.go +++ /dev/null @@ -1,182 +0,0 @@ -package declaration - -// A minimal five-field cron, enough to say when a scheduled step is due (novox/hq ADR 0053). -// -// **Written rather than pulled in.** The host depends on nothing it does not have to -// (novox/hq ADR 0005), and a scheduled step needs exactly two questions answered — *is this minute -// a match* and *when is the next one* — over the ordinary five fields (minute, hour, day-of-month, -// month, day-of-week) with `*`, lists (`,`), ranges (`-`) and steps (`/`). That is small enough to -// keep in the vocabulary the host already owns, and a cron library would be a dependency carried -// for the parts of it nobody here uses. -// -// **The host both validates and evaluates.** The control plane refuses a malformed cron near its -// author (novox/hq ADR 0053), and the host refuses it again on arrival for the same near-versus-far -// reason every other field is checked here: a declaration the host does not fully understand is -// refused whole rather than half-applied. The evaluation half — `Next` — is what the scheduler -// fires on. - -import ( - "fmt" - "strconv" - "strings" - "time" -) - -// Cron is a parsed five-field expression, each field held as a bitset of the values it permits. -// -// The two day fields carry a flag for whether they were written as a bare `*`, because standard -// cron gives them a special rule: when day-of-month and day-of-week are *both* restricted, a day -// matches if *either* does; when one is `*`, only the other is consulted. Getting that wrong is the -// classic cron surprise, so the flag is kept rather than rediscovered. -type Cron struct { - minute uint64 - hour uint64 - dom uint64 - month uint64 - dow uint64 - domStar bool - dowStar bool -} - -// ParseCron reads a five-field cron expression, or says why it is not one. -func ParseCron(expr string) (*Cron, error) { - fields := strings.Fields(expr) - if len(fields) != 5 { - return nil, fmt.Errorf( - "a schedule is a five-field cron expression (minute hour day-of-month month "+ - "day-of-week), and %q has %d field(s)", expr, len(fields)) - } - - minute, _, err := parseCronField(fields[0], 0, 59) - if err != nil { - return nil, fmt.Errorf("schedule minute field: %w", err) - } - hour, _, err := parseCronField(fields[1], 0, 23) - if err != nil { - return nil, fmt.Errorf("schedule hour field: %w", err) - } - dom, domStar, err := parseCronField(fields[2], 1, 31) - if err != nil { - return nil, fmt.Errorf("schedule day-of-month field: %w", err) - } - month, _, err := parseCronField(fields[3], 1, 12) - if err != nil { - return nil, fmt.Errorf("schedule month field: %w", err) - } - // Day-of-week is 0-6 with Sunday at 0, and 7 is also accepted for Sunday — the convention every - // cron keeps, so a crontab copied from elsewhere is not refused for saying 7. - dow, dowStar, err := parseCronField(fields[4], 0, 7) - if err != nil { - return nil, fmt.Errorf("schedule day-of-week field: %w", err) - } - if dow&(1<<7) != 0 { - dow |= 1 << 0 - dow &^= 1 << 7 - } - - return &Cron{ - minute: minute, hour: hour, dom: dom, month: month, dow: dow, - domStar: domStar, dowStar: dowStar, - }, nil -} - -// parseCronField turns one field into the bitset of values it permits, and reports whether it was a -// bare `*` (which the day fields treat specially). -func parseCronField(spec string, min, max int) (uint64, bool, error) { - if spec == "" { - return 0, false, fmt.Errorf("is empty") - } - star := spec == "*" - - var bits uint64 - for _, part := range strings.Split(spec, ",") { - if part == "" { - return 0, false, fmt.Errorf("%q has an empty element between commas", spec) - } - - // A step may follow either `*` or a range: `*/15`, `0-30/5`. - step := 1 - rangePart := part - if slash := strings.IndexByte(part, '/'); slash >= 0 { - rangePart = part[:slash] - n, err := strconv.Atoi(part[slash+1:]) - if err != nil || n < 1 { - return 0, false, fmt.Errorf("step in %q is not a positive number", part) - } - step = n - } - - lo, hi := min, max - switch { - case rangePart == "*": - // Full range, already set. - case strings.IndexByte(rangePart, '-') >= 0: - dash := strings.IndexByte(rangePart, '-') - a, errA := strconv.Atoi(rangePart[:dash]) - b, errB := strconv.Atoi(rangePart[dash+1:]) - if errA != nil || errB != nil { - return 0, false, fmt.Errorf("range %q is not two numbers", rangePart) - } - lo, hi = a, b - default: - n, err := strconv.Atoi(rangePart) - if err != nil { - return 0, false, fmt.Errorf("%q is not a number", rangePart) - } - lo, hi = n, n - } - - if lo < min || hi > max || lo > hi { - return 0, false, fmt.Errorf( - "%q is outside the allowed range %d-%d", part, min, max) - } - for v := lo; v <= hi; v += step { - bits |= 1 << uint(v) - } - } - return bits, star, nil -} - -// Matches reports whether a scheduled step is due at this minute. -func (c *Cron) Matches(t time.Time) bool { - if c.minute&(1< 0 && unknown == "" { - problems = append(problems, where+": knows is given without unknown, and says nothing without it") - } - seen := map[string]bool{} - for _, k := range knows { - switch { - case k == "": - problems = append(problems, where+": knows holds an empty entry") - continue - case strings.HasPrefix(k, "/"): - problems = append(problems, fmt.Sprintf("%s: knows entry %q is absolute; it is relative to the path", where, k)) - continue - } - bad := false - for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") { - if c == "" || c == "." || c == ".." { - bad = true - } - } - if bad { - problems = append(problems, fmt.Sprintf("%s: knows entry %q holds an empty, . or .. component", where, k)) - continue - } - if seen[k] { - problems = append(problems, fmt.Sprintf("%s: knows entry %q is repeated", where, k)) - } - seen[k] = true - } - return problems -} - -func (d *Directory) Identity() string { return d.ID } -func (d *Directory) Kind() Type { return TypeDirectory } -func (d *Directory) Target() string { return d.Path } - -func (d *Directory) validate(where string, _ bool) []string { - var problems []string - if d.Path == "" { - problems = append(problems, where+": a directory needs a path") - } - // A directory's mode may carry the setgid bit (2xxx), so what is made beneath it keeps its group (novox/hq ADR - // 0334 §1); never setuid or sticky, which mean nothing the mesh asks of a directory it owns. - mode := d.Mode - if len(mode) == 4 && mode[0] == '2' { - mode = "0" + mode[1:] - } - problems = append(problems, checkUnknown(where, "directory", d.Unknown, d.Knows)...) - return append(problems, checkMode(where, mode)...) -} - -// File is a file with literal content. The host renders nothing. -type File struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Path string `json:"path"` - Content string `json:"content"` - Mode string `json:"mode,omitempty"` - - // CreateOnce says the content is a seed: written when the file is absent, and left alone — - // content, mode and owner — whenever it is present. - // - // **Two intentions had one vocabulary** (novox/hq issue 035, ADR 0087). "This file has this - // content, for ever" is what an ordinary file says, and the host holds the machine to it. A - // module that needs a file to exist before a program first starts — an access list the - // program then persists into, a bootstrap configuration it rewrites — needs the other thing, - // and with only the first available, every reconcile restored the seed behind the running - // program and erased what had grown in it, reporting success. What grows in a seeded file - // is somebody else's work the mesh asked for; the mesh removes nothing it did not create - // (ADR 0030), and it does not overwrite that either. - CreateOnce bool `json:"create-once,omitempty"` - - // Into says the file is shared with software the mesh did not install, and the content is - // the mesh's part of it: written into what is there, never over it (novox/hq ADR 0102). Only - // "json" is spoken — the content is a JSON object whose keys the host sets in the file's - // object, keeping every other key as it found it and recording what each of its keys held - // before, so undeclaring the file gives those back. - // - // "block" is the same idea for a file that is not structured (novox/hq issue 128): the - // content is the mesh's lines, and the host owns only the region between `# BEGIN mesh ` - // and `# END mesh `, keeping every line outside it byte for byte. The machine's hosts file - // is the case that needed it — on a workstation the distribution, a local development tool and - // the operator all write into it, and the mesh writing it whole took their lines away at the - // next change to the mesh's names, silently. Marked blocks are the shape the other tools in - // that file already use, and `#` is the comment character of every file this serves. - // - // Written into, in either format, the file's mode and owner are the machine's: a declared mode - // and owner apply only to a file the host creates, and a file that was there keeps its own. - Into string `json:"into,omitempty"` - - // At is where a file written into a block has its region added when the file does not hold - // one yet: "end", the default, or "start". A region already there stays where it is, whatever - // this says — moving it would move the lines around it, and those are the machine's. - // - // **Some files give a line its meaning by what stands above it.** dhcpcd's configuration scopes - // every line after `interface X` to that interface, and a real one ends with exactly that — an - // interface and its static address. A region added at the end would make the mesh's global - // options (`nohook resolv.conf`, `denyinterfaces mesh0`) options of one interface, and dhcpcd - // would read them without complaint. At the start, nothing stands above them. - At string `json:"at,omitempty"` - - // Sealed is content encrypted to this node's sealing key, for a file the mesh must deliver - // without being able to read. - // - // The one thing here the host cannot simply write. Everything else in a declaration is - // visible to whatever carried it — the broker relays the message, and the message is signed - // so it cannot be forged, but signing does not make it unreadable. A password travelling in - // `content` would be a password the broker sees, which is the transitive trust the design - // refuses everywhere else (novox/hq ADR 0004). - // - // Exclusive with Content: a file is one or the other, so that "was this secret" is answerable - // by looking rather than by knowing which field won. - Sealed string `json:"sealed,omitempty"` - - // VaultSealed is content the vault sealed to this node's sealing key, in the mesh's sealed box (novox/hq ADR - // 0206): a core module's own credential, which the controller carries in the declaration and cannot open - // (ADR 0349 §16, issue 755). The same promise as Sealed, from the vault rather than the controller; sent only to - // an engine that states VaultBoxContract. Exclusive with Content, Sealed and Bytes. - VaultSealed *seal.Box `json:"vault-sealed,omitempty"` - - // Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq - // issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration. - // Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one - // rolled back to — never costs a node its whole declaration. Not part of the file's digest. - Trusted *bool `json:"trusted,omitempty"` - - // Secrets are sealed values put into Content where it says `${secret:name}`. - // - // **The one place a secret and a configuration meet, and it happens on the machine.** A - // program that wants its token inside a JSON document cannot be given a file that is entirely - // a token, and the mesh cannot compose the document itself — it discarded the value - // (novox/hq ADR 0024). So the module supplies the document with a hole in it, the mesh - // delivers the value sealed, and the host is the only thing that ever sees both. - // - // **Substitution is textual and the host learns no formats.** That is deliberate: a mechanism - // that understood JSON would be asked to understand YAML next, and then INI, which is how the - // arrangement this replaces became something nobody could hold in their head. The module knows - // its own format, because it wrote the rest of the file. - // - // The sharp edge, stated rather than discovered: a value containing a quote or a backslash - // will not be escaped for whatever syntax surrounds it. - Secrets map[string]string `json:"secrets,omitempty"` - - // Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon. - // - // A third way of saying what is in a file, and the three are exclusive. It would have been - // tempting to let Content carry base64 and add a flag, and then "what is in this file" would - // depend on a field somewhere else. - Bytes string `json:"bytes,omitempty"` - - // Owner is the user this belongs to, by name. Absent means root. - Owner string `json:"owner,omitempty"` - - // OwnerAtRuntime says who owns the file once it is placed (novox/hq ADR 0349 §5): "owned", the mesh's - // whole, written back whenever it differs; "seed", written once and then the program's; or "managed", - // only its named keys or its marked block the mesh's, the program keeping the rest. - // - // **Three words for what the forms above already do.** Owned is an ordinary file, seed is create-once - // and managed is a file written into, so the word changes no write: a file that says it is applied - // exactly as its old form is, and one that does not say is owned by its old form (Ownership). The word - // is what the provisioner's files say of themselves, which the old forms only implied. Seed is enough - // alone; managed still needs `into`, which says the format. A word that contradicts the form is refused. - OwnerAtRuntime string `json:"owner-at-runtime,omitempty"` -} - -// The words a file says who owns it at runtime with (novox/hq ADR 0349 §5). -const ( - RuntimeOwned = "owned" - RuntimeSeed = "seed" - RuntimeManaged = "managed" -) - -// Ownership is who owns the file at runtime: the word it says, or the one its old form implies — a file -// written into is managed, one created once is a seed, and any other is owned. -func (f *File) Ownership() string { - switch { - case f.OwnerAtRuntime != "": - return f.OwnerAtRuntime - case f.Into != "": - return RuntimeManaged - case f.CreateOnce: - return RuntimeSeed - default: - return RuntimeOwned - } -} - -// Seeded reports whether the file is written only when absent: created once, by create-once or by the -// word seed. -func (f *File) Seeded() bool { return f.CreateOnce || f.OwnerAtRuntime == RuntimeSeed } - -// Secret reports whether this file arrived sealed, which is what decides both that it must be -// opened before writing and that its contents must never appear in a report. -func (f *File) Secret() bool { return f.Sealed != "" || f.VaultSealed != nil } - -func (f *File) Identity() string { return f.ID } -func (f *File) Kind() Type { return TypeFile } -func (f *File) Target() string { return f.Path } - -// placeholder is what Content says where a sealed value belongs: ${secret:name}. -var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`) - -// SecretsUsed are the names Content asks for, in the order they first appear. -func (f *File) SecretsUsed() []string { - var used []string - seen := map[string]bool{} - for _, m := range placeholder.FindAllStringSubmatch(f.Content, -1) { - if !seen[m[1]] { - seen[m[1]] = true - used = append(used, m[1]) - } - } - return used -} - -func (f *File) validate(where string, _ bool) []string { - var problems []string - if f.Path == "" { - problems = append(problems, where+": a file needs a path") - } - switch f.Into { - case "": - case IntoJSON: - var object map[string]json.RawMessage - if err := json.Unmarshal([]byte(f.Content), &object); err != nil || object == nil { - problems = append(problems, where+ - ": a file written into JSON carries a JSON object of the keys it sets") - } - case IntoBlock: - // The markers are how the host finds its region again. A marker in the content would be - // a second region, or the end of this one, the next time the file is read — and the host - // would then rewrite, or on undeclare take out, lines that were never the mesh's. - for _, line := range strings.Split(f.Content, "\n") { - if strings.HasPrefix(line, BlockBegin) || strings.HasPrefix(line, BlockEnd) { - problems = append(problems, fmt.Sprintf( - "%s: a file written into a block carries the mesh's lines, and %q is a marker the "+ - "host keeps for itself", where, strings.TrimRight(line, "\r"))) - break - } - } - // The id is written into the markers, so it has to stay on one line — and whitespace at - // either end of it is whitespace the host would have to match exactly in a line some - // editor may trim. - if strings.ContainsAny(f.ID, "\r\n") { - problems = append(problems, where+ - ": a file written into a block names its region by its id, and this id spans lines") - } else if strings.TrimSpace(f.ID) != f.ID { - problems = append(problems, where+ - ": a file written into a block names its region by its id, and this id begins or ends in whitespace") - } - default: - problems = append(problems, fmt.Sprintf( - "%s: into %q; a file is written into \"json\" or \"block\", or omits it to be written whole", - where, f.Into)) - } - switch { - case f.At == "": - case f.Into != IntoBlock: - problems = append(problems, fmt.Sprintf( - "%s: at %q; only a file written into a block has a place its region is added", where, f.At)) - case f.At != AtStart && f.At != AtEnd: - problems = append(problems, fmt.Sprintf( - "%s: at %q; a block is added at \"start\" or \"end\", or omits it to be added at the end", - where, f.At)) - } - if f.Into == IntoJSON || f.Into == IntoBlock { - if f.Secret() || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce { - problems = append(problems, where+ - ": a file written into says only its part, in content — not sealed, bytes, "+ - "secrets or create-once") - } - } - problems = append(problems, f.checkOwnership(where)...) - var said []string - for name, value := range map[string]string{ - "content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes, - } { - if value != "" { - said = append(said, name) - } - } - if f.VaultSealed != nil { - said = append(said, "vault-sealed") - if f.VaultSealed.V != seal.Version { - problems = append(problems, fmt.Sprintf("%s: vault-sealed is a sealed box of version %d, and this "+ - "node-engine opens version %d", where, f.VaultSealed.V, seal.Version)) - } - if len(f.Secrets) > 0 { - problems = append(problems, where+": a file the vault sealed whole has no holes for secrets") - } - } - if len(said) > 1 { - sort.Strings(said) - problems = append(problems, where+ - ": a file says what is in it exactly once, and this says it as "+ - strings.Join(said, " and ")+ - " — otherwise nobody can tell by looking which one landed on the machine") - } - // A file whose content names a secret must be given exactly the secrets it names. - // - // **Both directions, and both are refusals rather than warnings.** A placeholder with nothing - // to fill it would write `${secret:x}` into a configuration file, which the program reads as - // a value and fails on somewhere unrelated. A secret nobody uses means whoever wrote this - // believes a credential is in a file where it is not. - if len(f.Secrets) > 0 && f.Content == "" { - problems = append(problems, where+ - ": secrets were given and there is no content to put them in") - } - used := f.SecretsUsed() - for _, name := range used { - if f.Secrets[name] == "" { - problems = append(problems, fmt.Sprintf( - "%s: the content asks for the secret %q and none was given", where, name)) - } - } - for name := range f.Secrets { - if !slices.Contains(used, name) { - problems = append(problems, fmt.Sprintf( - "%s: the secret %q was given and the content never asks for it", where, name)) - } - } - return append(problems, checkMode(where, f.Mode)...) -} - -// checkOwnership refuses a word that contradicts the file's form (novox/hq ADR 0349 §5): owned is a whole -// file written back, so not created once nor written into; a seed is a whole file created once, so not -// written into; managed is a file written into, which says its format. Managed and created once together -// is already refused by the rule that a file written into is not created once. -func (f *File) checkOwnership(where string) []string { - switch f.OwnerAtRuntime { - case "": - case RuntimeOwned: - var also []string - if f.CreateOnce { - also = append(also, "create-once") - } - if f.Into != "" { - also = append(also, "into") - } - if len(also) > 0 { - return []string{fmt.Sprintf("%s: owned at runtime is the mesh's whole file, written back whenever it "+ - "differs, and this also says %s", where, strings.Join(also, " and "))} - } - case RuntimeSeed: - if f.Into != "" { - return []string{where + ": a seed is a whole file written once, and this says into; a file the " + - "program shares with the mesh is managed"} - } - case RuntimeManaged: - if f.Into == "" { - return []string{where + ": a managed file names the format its keys or block are written into, " + - "with into \"json\" or \"block\""} - } - default: - return []string{fmt.Sprintf("%s: owner-at-runtime %q; a placed file is %q, %q or %q, or omits it to be "+ - "owned by its form", where, f.OwnerAtRuntime, RuntimeOwned, RuntimeSeed, RuntimeManaged)} - } - return nil -} - -// User is a login on the machine. -// -// The thing that makes a shell, a chat client or a desktop expressible at all: each is a package -// plus configuration in somebody's home, and until this the mesh could only own /etc. -// -// It also makes "zsh is my login shell" **declared state** rather than an action. `chsh` is a -// command, the link may not carry one (novox/hq ADR 0005), and a shell that could only be set by -// hand would be a shell the mesh cannot manage — which is most of the reason to manage a machine -// at all. -type User struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Name string `json:"name"` - - // Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is - // there — the same rule Service.Boot follows, for the same reason: a field that always - // asserts cannot express "I do not care". - Shell string `json:"shell,omitempty"` - - // Groups this user must be in. Additive: the host puts the user in these and does not remove - // it from others, because a machine's own groups are not the mesh's to know about. - Groups []string `json:"groups,omitempty"` - - // Home directory. Absent means the system's default for a new user, and is not changed for - // one that exists — moving somebody's home is not something a declaration should do quietly. - Home string `json:"home,omitempty"` - - // Linger is whether the account's own service manager runs with nobody logged in (novox/hq ADR - // 0177). A user-scoped unit lives in that manager, and the manager runs only from the account's - // first login to its last logout — so a server's user unit, where nobody ever logs in, never - // runs without it, and a workstation's runs only while its person is there, which on a desktop - // is what is wanted. Absent asserts nothing, as Shell's does: true has the account linger, false - // has it not. On the account rather than on the unit, because it is the account's: two units of - // one account cannot disagree about it, and undeclaring one of them must not stop the other. - Linger *bool `json:"linger,omitempty"` - - // Root says whether this account may become root without a person (novox/hq ADR 0266). "never" - // is the agents' own account: the login an agent session runs as on a machine where it must not - // reach root by itself. Empty asserts nothing, as Shell's does. - // - // **A statement the engine judges, never one it acts on.** The apply gives an account it creates - // no password, no sudo rule and no group beyond those declared, as it always has, and takes none - // away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a - // declaration that silently stripped them would be the mesh deciding what a person's machine - // grants. What "never" adds is the look: on every look the engine reads whether the account can - // become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh - // placed that it can read — and says it unhealthy while it can (internal/accounts), so the - // controller can tell a machine where it holds from one where it does not. - Root string `json:"root,omitempty"` -} - -// RootNever is the one value Root takes besides empty: the account never becomes root without a person. -const RootNever = "never" - -// Network is a named network on this machine. -// -// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a -// module would have to know about the machine it lands on, and a module naming a subnet is a -// module that collides with whatever else chose the same one. The runtime picks; the mesh names -// (novox/hq ADR 0029). -type Network struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Name string `json:"name"` -} - -func (n *Network) Identity() string { return n.ID } -func (n *Network) Kind() Type { return TypeNetwork } -func (n *Network) Target() string { return n.Name } - -func (n *Network) validate(where string, _ bool) []string { - var problems []string - if n.Name == "" { - problems = append(problems, where+": a network needs a name") - } - // The runtimes accept more than this, and the mesh does not: a name with a slash or a colon - // in it reads as a reference to something else entirely wherever it is later printed. - for _, r := range n.Name { - if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && - r != '-' && r != '_' && r != '.' { - problems = append(problems, where+ - ": a network name is letters, digits, dashes, underscores and dots, and "+ - n.Name+" is not") - break - } - } - return problems -} - -// Modes an access may be granted at. Plain words, not the octal a directory's mode is: an access -// is not a thing the host chmods, it is a statement of how this module reaches what the operator -// owns. -const ( - AccessRead = "read" - AccessReadWrite = "read-write" -) - -// Access is a pre-existing, operator-owned path this module is granted use of but does not own. -// -// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own — -// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A -// media library, a download spool is the operator's: it existed before the mesh, several modules -// read and write it at once, and the mesh must not create, chown, chmod or remove it. The host -// confirms it is there and mounts it; nothing else. Library data is a library's (novox/hq ADR 0328), -// not an access. -type Access struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Path string `json:"path"` - // Mode is how this module reaches the path: read or read-write. Absent narrows to read. - Mode string `json:"mode,omitempty"` - - // Users and Taken are what a controller speaking ADR 0325 sends (the identities that use the folder, and - // that the operator's warrant was carried out): accepted, so its declaration is not refused whole, and not - // acted on since ADR 0328 superseded ADR 0325 — no ACL entry is kept. - Users []AccessUser `json:"users,omitempty"` - Taken bool `json:"taken,omitempty"` - - // Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted - // so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5). - Grant json.RawMessage `json:"grant,omitempty"` -} - -// The uses an access's user has (ADR 0051 §3), and where it runs (ADR 0325 §4). -const ( - FromContainer = "container" - FromToolRunner = "tool-runner" -) - -// AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its -// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name. -type AccessUser struct { - Identity string `json:"identity"` - UID int `json:"uid,omitempty"` - Use string `json:"use"` - From string `json:"from"` -} - -// UnmarshalJSON reads a user as strictly as a resource is read. -func (u *AccessUser) UnmarshalJSON(raw []byte) error { - type plain AccessUser - var p plain - if err := strictDecode(raw, &p); err != nil { - return fmt.Errorf("an access's user: %w", err) - } - *u = AccessUser(p) - return nil -} - -func (a *Access) Identity() string { return a.ID } -func (a *Access) Kind() Type { return TypeAccess } -func (a *Access) Target() string { return a.Path } - -func (a *Access) validate(where string, _ bool) []string { - var problems []string - if !strings.HasPrefix(a.Path, "/") { - problems = append(problems, where+": an access needs an absolute path, and "+ - a.Path+" is not one") - } - switch a.Mode { - case "", AccessRead, AccessReadWrite: - default: - problems = append(problems, fmt.Sprintf( - "%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode)) - } - for _, u := range a.Users { - switch { - case u.Identity == "" || !accountName.MatchString(u.Identity): - problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one", - where, u.Identity)) - case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner): - // Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a - // number below it is no account. A tool runner's account alone may come without its number: the - // controller cannot know the operator's account's, and the engine reads it by name - // (apply.ResolveRunners), refusing one that is root's. - problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+ - "root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID)) - } - if u.Use != AccessRead && u.Use != AccessReadWrite { - problems = append(problems, fmt.Sprintf("%s: the user %s uses it %q, and a use is %q or %q", where, - u.Identity, u.Use, AccessRead, AccessReadWrite)) - } - if u.Use == AccessReadWrite && a.Mode != AccessReadWrite { - problems = append(problems, fmt.Sprintf("%s: the user %s writes an access declared %s", where, - u.Identity, orRead(a.Mode))) - } - if u.From != FromContainer && u.From != FromToolRunner { - problems = append(problems, fmt.Sprintf("%s: the user %s runs from %q, and that is %q or %q", where, - u.Identity, u.From, FromContainer, FromToolRunner)) - } - } - return problems -} - -// orRead is an access's mode as said: absent narrows to read. -func orRead(mode string) string { - if mode == "" { - return AccessRead - } - return mode -} - -func (u *User) Identity() string { return u.ID } -func (u *User) Kind() Type { return TypeUser } -func (u *User) Target() string { return u.Name } - -func (u *User) validate(where string, _ bool) []string { - var problems []string - if u.Name == "" { - problems = append(problems, where+": a user needs a name") - } - if u.Shell != "" && !strings.HasPrefix(u.Shell, "/") { - problems = append(problems, where+ - ": a login shell is an absolute path, and "+u.Shell+" is not one") - } - if u.Home != "" && !strings.HasPrefix(u.Home, "/") { - problems = append(problems, where+": a home directory is an absolute path") - } - if u.Root != "" && u.Root != RootNever { - problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root)) - } - return problems -} - -// Archive is a set of files, fetched by digest and unpacked. -// -// For the case inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of -// files inlined would make every declaration enormous and rewrite all of it when one changed. -// -// **Pinned by digest, and the digest is checked before anything is unpacked.** The same discipline -// the bootstrap uses for images, and for the same reason — this is fetched over a network the -// mesh does not control, and a reference that can be made to point elsewhere is not a reference. -type Archive struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - // Source is where to fetch it from. - Source string `json:"source"` - // Digest is sha256 of the archive, as "sha256:". - Digest string `json:"digest"` - // Path is the directory it is unpacked into. - Path string `json:"path"` - // Owner is the user the unpacked files belong to. Absent means root. - Owner string `json:"owner,omitempty"` - // Unknown and Knows are as a directory's (issue 1024, ADR 0422). - Unknown string `json:"unknown,omitempty"` - Knows []string `json:"knows,omitempty"` -} - -func (a *Archive) Identity() string { return a.ID } -func (a *Archive) Kind() Type { return TypeArchive } -func (a *Archive) Target() string { return a.Path } - -func (a *Archive) validate(where string, _ bool) []string { - var problems []string - if a.Source == "" { - problems = append(problems, where+": an archive needs somewhere to fetch it from") - } - if a.Path == "" { - problems = append(problems, where+": an archive needs somewhere to unpack into") - } - if !strings.HasPrefix(a.Digest, "sha256:") || len(a.Digest) != len("sha256:")+64 { - // Refused rather than fetched and trusted. Everything else pinned in this vocabulary is - // pinned by digest, and an archive that was not would be the one way in. - problems = append(problems, where+ - ": an archive is pinned by digest, as sha256:<64 hex characters>") - } - problems = append(problems, checkUnknown(where, "archive", a.Unknown, a.Knows)...) - return problems -} - -// Process is the module's own code, run on the machine, in one of three modes. -// -// The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state -// and deliberately does not install one, which is right for software that ships its own. This is -// the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and -// nothing else will. -// -// The difference from Container is the hosting, and a module should not have to choose: what this -// says is what to run, and the machine's own supervisor is how. Code that genuinely needs a -// container's isolation declares a container and says so. -// -// **Three modes, matching a container's**, because they are the same thing at different cadences: -// stays up, runs once, runs on a schedule. A module's scheduled task, its run-once migration, its -// health check and its tool host are all this — and each being its own resource kind would be four -// entries in a vocabulary where every entry widens what a compromised control plane can express. -type Process struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - // Name is what the unit is called, and what an operator will see in the process table. - Name string `json:"name"` - // Source is where to fetch the bundle from, and Digest is what it must hash to. The same - // discipline as an archive, for the same reason: this crosses a network the mesh does not - // control. - Source string `json:"source"` - Digest string `json:"digest"` - // Run is the command, relative to the unpacked bundle. The first element is the program. - // - // **Named by the module, never inferred.** Guessing an entrypoint from which files exist makes - // a daemon change what it runs when somebody adds a file. - Run []string `json:"run"` - // Env and EnvFile are what it runs with. A file rather than inline values is how a credential - // reaches a daemon without passing through the declaration. - Env map[string]string `json:"env,omitempty"` - EnvFile []string `json:"env-file,omitempty"` - // User is who it runs as. Absent means root, which is what the mesh's own modules need for - // the things they do to a machine. - User string `json:"user,omitempty"` - // RestartOn names resources whose change means this must be restarted — the same rule a - // service follows, and for the same reason: a running process does not re-read its - // configuration, so replacing a file and finding the process already up leaves the machine - // behaving the way it did before while every check passes. - RestartOn []string `json:"restart-on,omitempty"` - - // RunOnce marks code the host runs to completion rather than leaves running: a migration, a - // seed, a first-boot step. What follows it is gated on it finishing, because a step that did - // not make the machine ready must not be followed by the thing that needed it. - RunOnce bool `json:"run-once,omitempty"` - - // Schedule runs it on a cadence — a five-field cron expression (novox/hq ADR 0053). The - // recurring twin of RunOnce: the same code, run again rather than left running. - // - // Exclusive with RunOnce and with RestartOn, for the same reason a container's is: something - // that runs once does not run on a schedule, and something that is not running cannot be - // restarted when a file changes. - Schedule string `json:"schedule,omitempty"` - - // Replaces names resources this declaration no longer declares that this process takes the - // place of (novox/hq issue 213). Such a resource is not removed with the other orphans, before - // anything is applied: it is removed only once this process is applied and still running a - // moment later, and kept when it is not. So the thing being replaced answers until the thing - // replacing it does — the controller moving from its container to a process is the case: the - // container removed first left nothing answering the mesh's verbs for as long as fetching, - // unpacking and starting the process took, and for ever if the process did not start. - // - // For a process that stays up; a step or a scheduled run is not running a moment later by - // design, so there is nothing to hand over to. - Replaces []string `json:"replaces,omitempty"` - - // Witness is how the node-engine judges a new build of this process, and restores the build - // before it when the new one is not healthy in bound (novox/hq to-be 45 §8): "lease" — the - // controller this machine started holds the controller's lease; "ping" — this machine's runtime - // answers the services protocol's PING; "none". Absent is the default for the process's name: - // the mesh's two core processes are judged, nothing else is. For a process that stays up. - Witness string `json:"witness,omitempty"` - - // NotReversible says why this build may not be rolled back, when it may not: the build before it - // would run against what this one changes — a migration it runs that the older build cannot read - // (to-be 45 §8, rule 8). A build so declared that is not healthy in bound is left running and said - // as urgent; the build before it is never started against the newer data. - NotReversible string `json:"not-reversible,omitempty"` - - // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its - // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. - Health *Health `json:"health,omitempty"` -} - -func (d *Process) Identity() string { return d.ID } -func (d *Process) Kind() Type { return TypeProcess } -func (d *Process) Target() string { return d.Name } - -// ProcessNameProblem says what is wrong with a process name, or nothing. -// -// The name becomes a unit name, a file under the unit directory and a directory under the mesh's -// own — the one removing the process deletes, whole (novox/hq ADR 0118). So a name that is not one -// plain path element is refused: with a separator it writes somewhere nobody meant, and "." or -// ".." IS the mesh's directory or its parent — removing a process named ".." would delete every -// bundle the mesh has, and more. One with a leading dash is read by the service manager as an -// option, not a unit. Exported because the removal checks the recorded name again: a record is -// what the host wrote, and a host of an older version wrote it under looser rules. -func ProcessNameProblem(name string) string { - switch { - case name == "": - return "a process needs a name, which is what its unit is called" - case strings.ContainsAny(name, "/ \t"): - return "a process name becomes a unit name, so it cannot contain a path separator or a space" - case name == "." || name == "..": - return fmt.Sprintf("a process name becomes a directory under the mesh's own, and %q would be "+ - "that directory or its parent", name) - case strings.HasPrefix(name, "-"): - return "a process name cannot begin with a dash: the service manager would read it as an option" - } - return "" -} - -func (d *Process) validate(where string, _ bool) []string { - problems := d.Health.problems(where, false, !d.RunOnce && d.Schedule == "") - if problem := ProcessNameProblem(d.Name); problem != "" { - problems = append(problems, where+": "+problem) - } - if d.Source == "" { - problems = append(problems, where+": a process needs somewhere to fetch its bundle from") - } - if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 { - // The same rule an archive follows, and for the same reason: this crosses a network the - // mesh does not control, and a reference that can be made to point elsewhere is not one. - problems = append(problems, where+ - ": a process bundle is pinned by digest, as sha256:<64 hex characters>") - } - if len(d.Run) == 0 { - problems = append(problems, where+": a process needs to say what to run") - } - switch d.Witness { - case "", "lease", "ping", "none": - default: - problems = append(problems, fmt.Sprintf("%s: witness %q is not one this host keeps: lease, ping or none", - where, d.Witness)) - } - if d.Witness != "" && d.Witness != "none" && (d.RunOnce || d.Schedule != "") { - problems = append(problems, where+": a witness judges a process that stays up; a step or a "+ - "scheduled run is not running between its runs") - } - if strings.ContainsAny(d.NotReversible, "\n\r") { - problems = append(problems, where+": not-reversible is one line") - } - for _, part := range d.Run { - if part == "" { - problems = append(problems, where+": a process command has an empty element") - break - } - } - // **A newline cannot be represented in a unit's environment, so it is refused rather than - // mangled.** Everything else a unit file reinterprets — a percent specifier, whitespace - // splitting assignments, a quote ending one early — can be escaped. A newline cannot: it ends - // the line, and what follows is read as a unit DIRECTIVE. A value carrying one could write - // ExecStart= and have the machine run something nobody declared. - // - // Refused here, near whoever wrote it, rather than at the far end of a declaration. - for key, value := range d.Env { - if strings.ContainsAny(value, "\n\r") { - problems = append(problems, fmt.Sprintf( - "%s: the value of %s contains a line break, which cannot be written into a unit's "+ - "environment — what followed it would be read as a unit directive", where, key)) - } - if key == "" { - problems = append(problems, where+": an environment value with no name") - } - } - - if d.Schedule != "" { - if d.RunOnce { - problems = append(problems, where+ - ": a process runs once or on a schedule, not both") - } - if len(d.RestartOn) > 0 { - problems = append(problems, where+ - ": a scheduled process is not running between its fires, so there is nothing to "+ - "restart when something it reads changes") - } - if _, err := ParseCron(d.Schedule); err != nil { - problems = append(problems, where+": "+err.Error()) - } - } - if len(d.Replaces) > 0 && (d.RunOnce || d.Schedule != "") { - problems = append(problems, where+ - ": only a process that stays up replaces something — a step or a scheduled run is not "+ - "running a moment later, so what it replaced would be removed with nothing in its place, "+ - "or never") - } - for _, id := range d.Replaces { - switch { - case strings.TrimSpace(id) == "": - problems = append(problems, where+": replaces names an empty id") - case id == d.ID: - problems = append(problems, where+": a process cannot replace itself") - } - } - return problems -} - -// Service is a unit the host puts into a state. It does not install the unit. -// -// Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped -// (it will come back at boot), or disabled and running (started by hand, gone after a reboot). -// Folding them into one field would make the second expressible only by accident. -type Service struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Unit string `json:"unit"` - // State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the - // machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules - // declare the machine's own network manager this way: the mesh writes into its configuration - // and needs it to read that again, and nothing more. Stated, the host would start the manager - // on a machine that uses another one — two managers fighting over the same links — and, when - // the module was unassigned, stop it: the machine's network, the channel the mesh itself - // arrives on, gone at the moment of a routine change. So a service without a state is never - // started, stopped, enabled or disabled, is reloaded or restarted only when a trigger changed - // and it is already running, and undeclared is simply forgotten. It says nothing unless it - // names a trigger, and it may not say boot or takes-over, which are both lifecycle. - State string `json:"state,omitempty"` - // Boot is "enabled" or "disabled" — whether the unit starts at boot. Optional: absent means - // the host asserts nothing about it and leaves whatever is there. - // - // Without this the host could start a unit and not make it survive a reboot, which is a - // declaration that reports success and stops being true at the next power cut. - Boot string `json:"boot,omitempty"` - - // Scope is whose service manager the unit belongs to: "system" (absent means system), or - // "user" — the operator account's own manager (novox/hq ADR 0177). A workstation's per-user - // daemons — a window manager's reload watcher, an audio mask, a memory guard — are units in - // that manager, and until this they had no form the mesh could send. A user-scoped unit names - // its User; the host talks to that account's manager and never starts a system unit by the - // same name. - Scope string `json:"scope,omitempty"` - // User is the account whose manager a user-scoped unit lives in. Required with scope "user", - // refused otherwise; a module names it ${machine:account} and never the person. - User string `json:"user,omitempty"` - // RestartOn names resources whose change means this service must be restarted. - // - // Because a running service does not re-read its configuration. Replace the file, find the - // service already running, do nothing, and the machine keeps behaving the way it did before — - // while every check passes, because the file is right and the service is up. That is not - // hypothetical: it is how a third node joining a mesh left the first two carrying a network - // that no longer existed, and every part of it reported success. - // - // This is declared state rather than a command. The declaration says the running service must - // reflect these files; the host works out that it does not and acts. A *command* to restart - // would be an action, and the link may not carry one (novox/hq ADR 0005) — so this is not a - // way around that rule, it is the shape the rule leaves. - RestartOn []string `json:"restart-on,omitempty"` - - // ReloadOn names resources whose change means this service must be reloaded — for a service - // that re-reads its configuration when told to, where a restart would stop what it runs: the - // container runtime, whose restart stops every container on the machine (novox/hq ADR 0102). - // A change that is also in RestartOn restarts it, which covers a reload. - ReloadOn []string `json:"reload-on,omitempty"` - - // TakesOver names the found tunnel this service replaces (novox/hq ADR 0105): before this unit - // is started, the named unit is stopped and disabled — never flushed — and its configuration - // file is kept like any held file — until the take is proven by a peer's handshake, and then - // retired, its original staying kept (novox/hq ADR 0119). Only on an adopted node, and only - // said by the controller, which knows the found tunnel's key is this node's own: without that, - // starting this unit on the found one's port would drop every peer's packets. - TakesOver *TakeOver `json:"takes-over,omitempty"` - - // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its - // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. - Health *Health `json:"health,omitempty"` -} - -// TakeOver is a found tunnel a service replaces: its interface, the unit that raised it, and its -// configuration file. -type TakeOver struct { - Interface string `json:"interface"` - Unit string `json:"unit"` - Config string `json:"config"` -} - -// Stateless reports whether the unit's lifecycle is the machine's, and the mesh only reflects the -// service's triggers (novox/hq ADR 0117). -func (s *Service) Stateless() bool { return s.State == "" } - -func (s *Service) Identity() string { return s.ID } -func (s *Service) Kind() Type { return TypeService } -func (s *Service) Target() string { return s.Unit } - -// ScopeSystem and ScopeUser are the two managers a unit may belong to (novox/hq ADR 0177). -const ( - ScopeSystem = "system" - ScopeUser = "user" -) - -// UserScoped is whether this unit lives in an account's own service manager. -func (s *Service) UserScoped() bool { return s.Scope == ScopeUser } - -func (s *Service) validate(where string, _ bool) []string { - problems := s.Health.problems(where, false, s.State == "running") - if s.Unit == "" { - problems = append(problems, where+": a service needs a unit") - } - switch s.Scope { - case "", ScopeSystem: - if s.User != "" { - problems = append(problems, where+": a system unit names no user; only a user-scoped unit does") - } - case ScopeUser: - if s.User == "" { - problems = append(problems, where+": a user-scoped unit names the account whose manager it lives in") - } - default: - problems = append(problems, fmt.Sprintf( - "%s: scope %q; a unit is in the \"system\" manager or the operator account's \"user\" one", where, s.Scope)) - } - switch { - case s.State == "running" || s.State == "stopped": - case s.State != "": - problems = append(problems, fmt.Sprintf( - "%s: state %q; a service is \"running\" or \"stopped\", or omits state to leave the "+ - "unit's lifecycle to the machine", where, s.State)) - case s.Boot != "" || s.TakesOver != nil: - problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+ - "to the machine, and boot and takes-over are both its lifecycle") - case len(s.RestartOn) == 0 && len(s.ReloadOn) == 0: - problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+ - "to the machine, and names no restart-on or reload-on — it declares nothing") - } - if s.Boot != "" && s.Boot != "enabled" && s.Boot != "disabled" { - problems = append(problems, fmt.Sprintf( - "%s: boot %q; a service is \"enabled\" or \"disabled\" at boot, or omits it to "+ - "leave the machine's own setting alone", where, s.Boot)) - } - if t := s.TakesOver; t != nil { - switch { - case t.Unit == "" || t.Config == "" || t.Interface == "": - problems = append(problems, where+": takes-over names the found tunnel's interface, unit "+ - "and config, and this leaves one out") - case t.Unit == s.Unit: - problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit", - where, t.Unit)) - case s.State != "running" && s.State != "": - problems = append(problems, where+": a service that takes over a tunnel is running — stopping "+ - "the found one for a service that will not run would leave the peers with nothing") - } - } - return problems -} - -// What a file is written into (novox/hq ADR 0102): a JSON object whose keys the mesh sets, or a -// text file in which the mesh owns one marked block of lines (novox/hq issue 128). -const ( - IntoJSON = "json" - IntoBlock = "block" -) - -// The lines that delimit the mesh's region in a file written into a block, each followed by the -// resource's id. Exact lines, never patterns: another tool's `# BEGIN …` block in the same file is -// that tool's, and a marker that merely resembled the mesh's must not be taken for it. -const ( - BlockBegin = "# BEGIN mesh " - BlockEnd = "# END mesh " -) - -// Where a file written into a block has its region added, when it has none yet. -const ( - AtStart = "start" - AtEnd = "end" -) - -// BlockMarkers are the two lines, without their line ends, that delimit a resource's region. -func BlockMarkers(id string) (begin, end string) { - return BlockBegin + id, BlockEnd + id -} - -// Opening is a port reachable on an adopted node, from where, and on which path. -// -// **From** is everywhere or mesh — the private network, by its interface. **Path** is incoming, -// for something listening on the machine, or forwarded, for a published container port: the found -// firewall sees a published port after the runtime has translated it, so a forwarded opening names -// the container's own port in To as well as the machine's in Port. -type Opening struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Port int `json:"port"` - Protocol string `json:"protocol"` - From string `json:"from"` - Path string `json:"path"` - To int `json:"to,omitempty"` -} - -// Where an opening admits from, and the path it is on. -const ( - FromEverywhere = "everywhere" - FromMesh = "mesh" - PathIncoming = "incoming" - PathForwarded = "forwarded" -) - -func (o *Opening) Identity() string { return o.ID } -func (o *Opening) Kind() Type { return TypeOpening } - -func (o *Opening) Target() string { - if o.Path == PathForwarded { - return fmt.Sprintf("%s/%d forwarded to %d from %s", o.Protocol, o.Port, o.To, o.From) - } - return fmt.Sprintf("%s/%d %s from %s", o.Protocol, o.Port, o.Path, o.From) -} - -func (o *Opening) validate(where string, _ bool) []string { - var problems []string - if o.Port < 1 || o.Port > 65535 { - problems = append(problems, fmt.Sprintf("%s: an opening's port is 1-65535, not %d", where, o.Port)) - } - if o.Protocol != "tcp" && o.Protocol != "udp" { - problems = append(problems, fmt.Sprintf("%s: an opening is tcp or udp, not %q", where, o.Protocol)) - } - if o.From != FromEverywhere && o.From != FromMesh { - problems = append(problems, fmt.Sprintf( - "%s: an opening is from %q or %q, not %q", where, FromEverywhere, FromMesh, o.From)) - } - switch o.Path { - case PathIncoming: - if o.To != 0 { - problems = append(problems, where+ - ": an incoming opening names no container port; only a forwarded one does") - } - case PathForwarded: - if o.To < 1 || o.To > 65535 { - problems = append(problems, where+ - ": a forwarded opening names the container's port it reaches, as to, 1-65535") - } - default: - problems = append(problems, fmt.Sprintf( - "%s: an opening's path is %q or %q, not %q", where, PathIncoming, PathForwarded, o.Path)) - } - if !strings.HasPrefix(o.ID, AdoptionPrefix) { - problems = append(problems, fmt.Sprintf( - "%s: an opening is the mesh's own, so its id starts %q", where, AdoptionPrefix)) - } - return problems -} - -// Package is a package that should be present. -// -// Present is the whole of what it asserts, never a version: version is the package manager's -// business and the mesh does not hold a second opinion about it. -type Package struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Package string `json:"package"` - // Absent declares that the package is NOT installed (novox/hq ADR 0180): the host removes it - // when it is, and leaves a machine that never had it alone. For the one case a module replaces - // software the machine was found with and the operator has decided it does not come back — the - // firewall front end a converged machine's filter module retired. Nothing to undo when the - // declaration drops it: the host does not install what a declaration stopped saying is absent. - Absent bool `json:"absent,omitempty"` -} - -func (p *Package) Identity() string { return p.ID } -func (p *Package) Kind() Type { return TypePackage } -func (p *Package) Target() string { return p.Package } - -func (p *Package) validate(where string, _ bool) []string { - if p.Package == "" { - return []string{where + ": a package needs a package name"} - } - return nil -} - -// Container is a container that should be running, from an image pinned by digest. -type Container struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Name string `json:"name"` - // Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not. - Image string `json:"image"` - Env map[string]string `json:"env,omitempty"` - // EnvFile names files the runtime reads environment from, in order. - // - // **Because a secret may not travel in Env.** A declaration reaches a node over the broker, - // and `env` is plain text in it — so a password there is a password the broker sees, which is - // the transitive trust refused everywhere else (novox/hq ADR 0004). A sealed file reaches the - // machine unreadable, the host writes it, and the runtime reads it: the mesh never holds it - // and neither does anything between them. - // - // It is also simply how third-party software takes credentials. Nothing that ships in a - // container will read a path the mesh invented; every one of them reads its environment. - EnvFile []string `json:"env-file,omitempty"` - Ports []string `json:"ports,omitempty"` - Volumes []string `json:"volumes,omitempty"` - Args []string `json:"args,omitempty"` - // Names this container can reach, as `name:address`. - // - // **Because a container does not inherit the machine's names.** It gets its own `/etc/hosts` - // holding only its own hostname, so every internal name the mesh wrote for this machine is - // invisible to the thing the machine is running. That was hit for real: a database client on - // one node could not resolve another node, on a mesh where both names were correct and - // present on both machines. - // - // **A file rather than a resolver, which is the decision the mesh already made about names** - // and this extends rather than overturns: it works on every runtime, needs no package, and - // has no failure mode of its own. A resolver becomes necessary when names are wanted that are - // not one-per-node — service names, wildcards — and that is still not true. - // - // Set by the mesh, not by a module: which machines exist is a fact about the mesh, and a - // module that listed them would be a module that goes stale when one joins. - Hosts []string `json:"hosts,omitempty"` - - // Network is the container's network, passed to the runtime unchanged. - // - // Needed because the control plane must reach the store and the broker on the machine it was - // raised on, before there is any mesh to arrange that. The alternative was publishing ports - // and guessing an address that works from inside a container, which is the same thing with a - // worse failure mode. - Network string `json:"network,omitempty"` - - // Dns is the resolvers this container asks, passed to the runtime unchanged. - // - // **Because some software refuses to run behind the runtime's forwarding resolver.** A mail - // server's admin demands a DNSSEC-validating resolver, and the runtime's own (127.0.0.11) - // forwards to whatever the machine has — so a module that ships its own validating resolver - // must be able to point its other containers at it. Addresses, not names: the runtime's flag - // takes only addresses, which is also why IP below exists — the resolver has to be somewhere - // its siblings can name before any of them can resolve anything. - Dns []string `json:"dns,omitempty"` - - // Capabilities are the Linux capabilities this container is granted beyond the runtime's - // default set, by name (novox/hq ADR 0170): a holder's runtime that changes the machine's packet - // filter asks for NET_ADMIN. Exactly these, named in the spec so a change recreates the - // container; a privileged container stays undeclarable. - Capabilities []string `json:"capabilities,omitempty"` - - // Logging names where the runtime sends this container's output: "journald" sends it to the - // machine's journal, under the container's name, where what reads the machine's logs — its - // intrusion prevention first of all (novox/hq ADR 0179) — can read it the way it reads the - // machine's own services. Empty keeps the runtime's default, which is a file of the runtime's - // own that nothing but the runtime reads. Part of the spec: a container that logs elsewhere - // is a different container, and the runtime cannot change a running one's driver. - Logging string `json:"logging,omitempty"` - - // Networks are networks this container also joins once created, by name — a found network a - // per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a - // neighbour that resolves it there keeps resolving it until the neighbour is taken too. - // Joined after creation, because a runtime starts a container on one network; part of the - // container's spec, so a network kept or let go recreates it. - Networks []string `json:"networks,omitempty"` - - // IP is this container's address on its network, passed to the runtime unchanged. - // - // Only meaningful on a user-defined network, and refused by the runtime elsewhere. Exists for - // exactly one shape: a container others must reach *before* name resolution works — a - // module's own DNS resolver being the case that forced it (see Dns). - IP string `json:"ip,omitempty"` - - // RestartOn names resources whose change means this container must be recreated — the same - // field a service has, for the same reason (novox/hq 04-ISSUES/009). A container reads a - // mounted file once at start; a changed file leaves the running process holding the old value, - // while every check passes because the file on disk is right. The host recreates the container - // when one of these resources changed this pass, even if the spec matches. - // - // What a running container reads at creation — its env-files, and a file mounted into it - // directly — is part of its spec by content since novox/hq 04-ISSUES/103, and needs no naming - // here. A directory mounted into it is NOT looked inside, not even for files the host wrote - // there: whether a service reads such a file once or watches it live is the service's, and - // RestartOn is how a module says "once, at start" — a config the host renders under the - // module's state directory, a step whose result it consumes. On a run-once step it means *run - // again*: a step that fetches a fact from a provider names the binding it reads, and is run - // again when the provider moved (novox/hq ADR 0099). - RestartOn []string `json:"restart-on,omitempty"` - - // RunOnce marks a container the host runs to completion rather than leaves running: a step, - // not a service (novox/hq ADR 0052). The host runs it, requires it to exit 0, and records that - // it did — and because the declaration is applied in order and a failed step halts the apply, - // whatever is declared after a run-once container starts only once the step has finished. It is - // how a module runs its own code at first boot — seed a store, migrate, health-gate — under its - // own account (ADR 0047), before the container that depends on it. The record that it ran is - // the digest of this declaration, so a re-apply does not re-run it unless the declaration - // changed. - RunOnce bool `json:"run-once,omitempty"` - - // Schedule marks a container the host runs on a recurring cadence — a five-field cron - // expression (novox/hq ADR 0053). It is the recurring twin of RunOnce: the same container, run - // to completion, but again and again on the clock rather than once. Installing it does not run - // it — the schedule is state that is present, like a running service, so the apply is current as - // soon as it is recorded and does NOT gate what follows. The host's scheduler fires the - // container when the cron is due, re-established from this declaration each apply because the - // declaration is the source of truth (ADR 0018). A run that exits non-zero is recorded and never - // fails the apply or flips the node's state; a run still going when the next is due is skipped - // rather than stacked. It is exclusive with RunOnce and with restart-on: a container runs once - // and gates, runs on a cadence, or stays up — never two of these. - Schedule string `json:"schedule,omitempty"` - - // WhileStopped names resources of the same module — containers — that must be held still for - // the duration of this step's run (novox/hq ADR 0189). The host stops each before the run and - // starts each again after it, **whatever the step did**: a step that failed must leave the - // service running, because the one real risk of this field is a window that never closes. - // - // **For the work a service cannot have done underneath it.** The artifact store's collector - // walks the storage and requires every writer stopped; a run-once step runs beside containers - // and a scheduled one is the same container again, so until this there was no way for a module - // to say it. The predecessor said it with a shell script, which is how the mesh inherited a - // store that has never collected anything. - // - // **Its own module's containers, and only on a schedule.** A module that could quiesce a - // neighbour could stop the mesh. And at apply time the host already has a window — the - // declaration is applied in order and a run-once step gates what follows — so a one-time - // offline job says *before*, not *instead of*; a recurring window is the case order cannot - // express, and the only one this serves. - WhileStopped []string `json:"while-stopped,omitempty"` - - // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its - // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. - Health *Health `json:"health,omitempty"` - - // Busy is how this container says it holds work a replacement would lose (novox/hq ADR 0335): asked - // before it is replaced, and while it says busy it is left running, within the bound. Absent: never - // busy, and replaced at once, as before. - Busy *Busy `json:"busy,omitempty"` - - // RunAs is who the container runs as, resolved by the controller from the module's identity and the - // image's declared interface (novox/hq ADR 0325 §2, §3): told by environment (the image reads PUID and - // PGID, say) or by the runtime's own user option. Part of the spec, so a change recreates it. Absent: the - // container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because - // a resource's Identity is its id. - RunAs *ContainerIdentity `json:"identity,omitempty"` -} - -// The ways an image is told who to run as (novox/hq ADR 0325 §2). -const ( - IdentityByEnv = "env" - IdentityByRun = "run" -) - -// ContainerIdentity is who a container runs as, and how its image is told. -type ContainerIdentity struct { - // Mode is IdentityByEnv or IdentityByRun. - Mode string `json:"mode"` - // UserEnv and GroupEnv are the variables the image reads the user's and the group's number from, for - // IdentityByEnv: "PUID", "PGID". - UserEnv string `json:"user_env,omitempty"` - GroupEnv string `json:"group_env,omitempty"` - UID int `json:"uid"` - GID int `json:"gid"` -} - -// UnmarshalJSON reads an identity as strictly as a resource is read. -func (i *ContainerIdentity) UnmarshalJSON(raw []byte) error { - type plain ContainerIdentity - var p plain - if err := strictDecode(raw, &p); err != nil { - return fmt.Errorf("a container's identity: %w", err) - } - *i = ContainerIdentity(p) - return nil -} - -// envName is a variable's name as a runtime takes it. -var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -func (i *ContainerIdentity) problems(where string, env map[string]string) []string { - if i == nil { - return nil - } - var problems []string - switch i.Mode { - case IdentityByEnv: - if !envName.MatchString(i.UserEnv) || (i.GroupEnv != "" && !envName.MatchString(i.GroupEnv)) { - problems = append(problems, fmt.Sprintf("%s: an identity told by environment names the variables "+ - "it is told in, and %q and %q are not both names", where, i.UserEnv, i.GroupEnv)) - } - for _, name := range []string{i.UserEnv, i.GroupEnv} { - if _, set := env[name]; set && name != "" { - problems = append(problems, fmt.Sprintf("%s: env sets %s, which the identity sets; one value "+ - "for one variable", where, name)) - } - } - case IdentityByRun: - if i.UserEnv != "" || i.GroupEnv != "" { - problems = append(problems, where+": an identity told by the runtime's user option names no variable") - } - default: - problems = append(problems, fmt.Sprintf("%s: an identity is told by %q or %q, not %q", where, - IdentityByEnv, IdentityByRun, i.Mode)) - } - // Never root (novox/hq ADR 0325 §11): refused here, so nothing of the declaration is made or started. - if i.UID <= 0 || i.GID <= 0 { - problems = append(problems, fmt.Sprintf("%s: the identity is uid %d, gid %d, and a module's identity is "+ - "never root, nor a number below it (novox/hq ADR 0325 §11)", where, i.UID, i.GID)) - } - return problems -} - -func (c *Container) Identity() string { return c.ID } -func (c *Container) Kind() Type { return TypeContainer } -func (c *Container) Target() string { return c.Name } - -func (c *Container) validate(where string, _ bool) []string { - problems := c.Health.problems(where, true, !c.RunOnce && c.Schedule == "") - problems = append(problems, c.Busy.problems(where, !c.RunOnce && c.Schedule == "")...) - if c.Name == "" { - problems = append(problems, where+": a container needs a name") - } - // A run-once step may name what it reads under restart-on. For a step the word means *run - // again*: what a container reads is part of its digest, so a step whose named resource changed - // is a different step and runs again (novox/hq ADR 0099). Not refused. - // A container runs once and gates, on a cadence, or stays up — never two of these - // (novox/hq ADR 0053). run-once and schedule are the two "runs to completion" lifecycles and - // contradict each other, and a scheduled step does not stay running to be brought back by - // restart-on either. Refused here on arrival, as the control plane refuses it near its author. - if c.RunOnce && c.Schedule != "" { - problems = append(problems, where+": a container is run-once or scheduled, not both; "+ - "run-once runs once and gates what follows, a schedule runs it again on a cadence") - } - if c.Schedule != "" && len(c.RestartOn) > 0 { - problems = append(problems, where+": a scheduled container cannot also declare restart-on; "+ - "it runs to completion on its cadence rather than staying running to be restarted") - } - if c.Schedule != "" { - if _, err := ParseCron(c.Schedule); err != nil { - problems = append(problems, where+": "+err.Error()) - } - } - // A maintenance window belongs to a recurring step (novox/hq ADR 0189). Refused on anything - // else here, where the field is; that it names containers of the same module, and not itself, - // is judged against the whole declaration (see whileStoppedNames). - if len(c.WhileStopped) > 0 && c.Schedule == "" { - problems = append(problems, where+": while-stopped needs a schedule; at apply the host "+ - "already has a window — the declaration is applied in order and a run-once step gates "+ - "what follows — so a one-time offline job is declared before what it works on") - } - for _, id := range c.WhileStopped { - if id == c.ID { - problems = append(problems, where+": while-stopped names "+strconv.Quote(id)+ - ", which is this step itself") - } - } - // The runtime's flags take addresses, and a name here would be handed to it verbatim and - // refused at create — after the old container was already removed. Refused on arrival instead. - for _, d := range c.Dns { - if net.ParseIP(d) == nil { - problems = append(problems, where+": dns "+strconv.Quote(d)+" is not an address; "+ - "the runtime's resolver flag takes only addresses") - } - } - if c.IP != "" { - if net.ParseIP(c.IP) == nil { - problems = append(problems, where+": ip "+strconv.Quote(c.IP)+" is not an address") - } - if c.Network == "" { - problems = append(problems, where+": an ip needs a network; the runtime refuses a "+ - "static address anywhere but a user-defined one") - } - } - for _, cap := range c.Capabilities { - if !capabilityName.MatchString(cap) { - problems = append(problems, where+": capabilities names "+strconv.Quote(cap)+", which is not a "+ - "capability's name (CAP_NET_ADMIN or NET_ADMIN)") - } - } - if c.Logging != "" && c.Logging != "journald" { - problems = append(problems, where+": logging is "+strconv.Quote(c.Logging)+", and the only place a "+ - "container's output can be sent besides the runtime's own file is \"journald\"") - } - for _, n := range c.Networks { - problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...) - if n == c.Network { - problems = append(problems, where+": networks names "+n+", which is already the container's network") - } - } - if len(c.Networks) > 0 && (c.RunOnce || c.Schedule != "") { - problems = append(problems, where+": networks is for a container that keeps running; a step "+ - "runs and exits, and joins nothing afterwards") - } - problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...) - return append(problems, checkImage(where, c.Image)...) -} - -// Action runs something the bundle declared, and the host never learns what it means. -type Action struct { - ID string `json:"id"` - Type Type `json:"type"` - // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent - // is an older controller, and the module is then guessed from the id (ModuleGuess). - Module string `json:"module,omitempty"` - Command []string `json:"command"` - // Verify is not optional and is not a courtesy. It is the read-back AND the idempotency - // check: the host does not know what a database is, so "is it already there" is a question - // only the declaration can ask (novox/hq ADR 0005). - Verify []string `json:"verify"` - // In names a container to run inside. Empty means the machine itself. - In string `json:"in,omitempty"` -} - -func (a *Action) Identity() string { return a.ID } -func (a *Action) Kind() Type { return TypeAction } - -func (a *Action) Target() string { - target := strings.Join(a.Command, " ") - if a.In != "" { - return "in " + a.In + ": " + target - } - return target -} - -func (a *Action) validate(where string, allowActions bool) []string { - // The bound the whole security argument rests on (novox/hq ADR 0005). - if !allowActions { - return []string{where + - ": an action arrived over the link, and the link may not carry one. The host " + - "applies declarations of known shape; a command to run is not one. A bundle may " + - "carry an action because it arrives with the binary — anyone able to put a " + - "hostile action there could have put it in the host itself"} - } - var problems []string - if len(a.Command) == 0 { - problems = append(problems, where+": an action needs a command") - } - if len(a.Verify) == 0 { - problems = append(problems, where+ - ": an action needs a verify. An action that runs and reports success without "+ - "reading anything back is the fault this host exists to prevent, and verify is "+ - "also how the host knows whether the action is already done") - } - return problems -} - -// newOf returns an empty resource of a kind, or nil if the kind is unknown. -// -// This is the whole vocabulary, in one place. A kind that is not here cannot be declared. -func newOf(t Type) Resource { - switch t { - case TypeDirectory: - return &Directory{} - case TypeFile: - return &File{} - case TypeService: - return &Service{} - case TypePackage: - return &Package{} - case TypeContainer: - return &Container{} - case TypeAction: - return &Action{} - case TypeNetwork: - return &Network{} - case TypeUser: - return &User{} - case TypeArchive: - return &Archive{} - case TypeAccess: - return &Access{} - case TypeProcess: - return &Process{} - case TypeOpening: - return &Opening{} - } - return nil -} - -// Vocabulary is every kind this host speaks. -func Vocabulary() []Type { - return []Type{ - TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, - TypeNetwork, TypeOpening, TypePackage, TypeProcess, TypeService, TypeUser, - } -} - -// Declaration is what a machine should be, in the order it should be made so. -type Declaration struct { - Version int - // For names the node this is meant for. A host with an identity refuses one addressed - // elsewhere; a host without one — the first node, applying the bundle it carries — has - // nothing to check against. - For string - // Resources, in the order they are applied. The host does not sort them: ordering is a - // decision, and deciding is not what the host does (novox/hq ADR 0005). - Resources []Resource - - // Adoption says this node is adopted, and which of its modules have been taken. Nil is a - // converged node — which is every node the mesh raised before adoption existed, and so the - // only form an older controller ever sends (novox/hq ADR 0100). - Adoption *Adoption - - // Sequence orders this declaration against every other the mesh has sent this node: each - // send is one higher than the last, assigned under the control plane's hold on the node - // (novox/hq 04-ISSUES/107). Zero is a declaration that carries no order — every one an older - // controller sent, and the bundle genesis applies — and a host makes no ordering claim about - // one of those. - // - // **The one property a declaration needs that its signature does not give it.** A signature - // says the mesh sent this; it cannot say the mesh sent it AFTER the one the host is holding. - // Before this, "older" was inferred from arrival within a batch and a 750ms window, and a - // backlog longer than the batch, or a slow broker, applied a declaration the mesh had already - // superseded. - Sequence int64 - - // Epoch is the controller's lease epoch this declaration was sent under (novox/hq to-be 45 §6): - // the revision at which the sending controller took the lease. A controller that lost its lease - // and goes on sending sends an older epoch than the holder's, and the node-engine refuses what - // is older than what it applied. Zero is a declaration from a controller without a lease — every - // one sent before the lease existed — and carries no claim. - // - // Inside what is signed, beside the sequence, so a message cannot be given a newer epoch than - // the controller gave it. - Epoch int64 - - // LeftOut names the modules of this machine's set the mesh left out of this declaration, - // because a setting stored for one cannot compose with its definition (novox/hq ADR 0163, - // rule 6). A machine is told everything or nothing about what it IS told; this is what it is - // not told, said. The host keeps what it holds for a left-out module and touches none of - // what it wrote for it — its resources are absent from the declaration, and absence would - // otherwise read as removal. - LeftOut []string - - // Generation is the assignment generation this declaration was composed from: the controller's - // counter, raised in the same transaction as every change to what is assigned where (novox/hq - // issue 234). The sequence orders arrival and cannot tell a later send that carries an older view - // of the assignments; this can. A node-engine refuses a declaration composed from a generation - // older than the highest it applied — unless it is a put-back — because applying it would - // undeclare what the mesh still assigns. Zero is a declaration from a controller that claims none, - // and is applied as before. - Generation int64 - - // PutBack says this declaration is a gate putting a machine back (novox/hq issue 234): it carries - // the generation of what it puts back, which may be older than what the machine applied, and is - // not refused for it. - PutBack bool - - // Identities is the identity of every module assigned to this machine (novox/hq ADR 0328 §4): an account - // and a group `mesh-` with the numbers the controller allocated once for the module, the same on - // every machine. The engine makes each before any resource is applied, and removes what it made of one no - // longer declared. Absent is a controller older than ADR 0325. - Identities []Identity - - // Groups is the group of every library this machine is reached by (novox/hq ADR 0328 §3): `mesh-` - // with the number the controller allocated once, mesh-wide. The engine makes each, and removes what it made of - // one no longer declared. Absent is a controller older than ADR 0328. - Groups []Group - - // Libraries is every library this machine is reached by (ADR 0328 §1, §6, §9): its root here, its group, whether - // this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for, - // and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328. - Libraries []Library - - // Filled is the paths of the files the provisioner fills on this machine (novox/hq ADR 0349 §4, §5): each a - // file this declaration declares, which says who owns it at runtime. The provisioner's client has the - // node-engine write only these. Absent is a controller that hands no file to the provisioner, and every - // file is written from the declaration as before. - Filled []string - - // Provisioner is the account the provisioner's client runs as on this machine, by name (novox/hq ADR 0349 §4): - // the only account whose write of a filled file the node-engine's local write socket accepts. Absent is a - // controller that names none, and every such write is refused. - Provisioner string - - // Builds is the build id of every module this declaration names, by module (novox/hq ADR 0429 §4, issue 1132). - Builds map[string]string - - // declared is what each resource says of its module, by id; nil on a declaration built by hand. - declared map[string]string -} - -// ModuleGuess is the module a resource id names when the declaration does not say: everything before its last dot. -// -// The mesh composes a module's resource ids as `.`, and **a module's name may contain a dot** -// (`novox.be` is one on this mesh) while a resource's own id never does, so the owner is everything before the *last* -// dot. False for what the mesh declares in its own right: the foundation's resources carry no dot, and the -// adoption's are named for the mesh. This is the guess an older controller's declaration is read by; a declaration -// that says `module` is read by ModuleOfID, which never guesses for a resource that says (issue 1132). -func ModuleGuess(id string) (string, bool) { - if strings.HasPrefix(id, AdoptionPrefix) { - return "", false - } - at := strings.LastIndex(id, ".") - if at <= 0 { - return "", false - } - return id[:at], true -} - -// ModuleOfID is the module a resource of this declaration belongs to: the module it says (novox/hq ADR 0429 §4, -// issue 1132), and otherwise the guess from its id (ModuleGuess), so a declaration from an older controller reads as -// it always did. False for what belongs to no module. -func (d *Declaration) ModuleOfID(id string) (string, bool) { - if d != nil { - if d.declared == nil { - for _, r := range d.Resources { - if r.Identity() == id && r.DeclaredModule() != "" { - return r.DeclaredModule(), true - } - } - } else if m, ok := d.declared[id]; ok { - return m, true - } - } - return ModuleGuess(id) -} - -// indexModules keeps what each resource says of its module, so ModuleOfID is a lookup. -func (d *Declaration) indexModules() { - d.declared = map[string]string{} - for _, r := range d.Resources { - if m := r.DeclaredModule(); m != "" { - d.declared[r.Identity()] = m - } - } -} - -// checkProvisioner refuses a provisioner client's account that is no account name, or is root: the account whose -// writes the node-engine accepts must be one that could not write the file without it. -func checkProvisioner(name string) []string { - switch { - case name == "": - return nil - case !accountName.MatchString(name): - return []string{fmt.Sprintf("provisioner_account %q is not an account name", name)} - case name == "root": - return []string{"provisioner_account is root; the provisioner's client runs as an account of its own"} - } - return nil -} - -// Fills reports whether the provisioner fills the file at path on this machine. -func (d *Declaration) Fills(path string) bool { return slices.Contains(d.Filled, path) } - -// checkFilled refuses a filled path that is not a declared file saying who owns it at runtime, or that is named -// twice or empty. Every file declared at the path must say it: the provisioner writes the path, and a file there -// the mesh writes some other way would be a second writer of it (ADR 0349 §5). -func checkFilled(filled []string, resources []Resource) []string { - var problems []string - files := map[string][]*File{} - for _, r := range resources { - if f, ok := r.(*File); ok { - files[f.Path] = append(files[f.Path], f) - } - } - seen := map[string]bool{} - for _, path := range filled { - switch { - case path == "": - problems = append(problems, "filled_files names a file with no path") - continue - case seen[path]: - problems = append(problems, fmt.Sprintf("filled_files names %q twice", path)) - continue - } - seen[path] = true - if len(files[path]) == 0 { - problems = append(problems, fmt.Sprintf("filled_files names %q, and this declaration declares no "+ - "file there", path)) - continue - } - for _, f := range files[path] { - if f.OwnerAtRuntime == "" { - problems = append(problems, fmt.Sprintf("resource %q: the provisioner fills %q, and a file it "+ - "fills says who owns it at runtime with owner-at-runtime", f.ID, path)) - } - } - } - return problems -} - -// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-` with -// uid, its own group of the same name and number, its primary group gid (its own, or its first library's group), -// and the groups of its other libraries as added groups. -type Identity struct { - Module string `json:"module"` - Name string `json:"name"` - UID int `json:"uid"` - GID int `json:"gid"` - Groups []int `json:"groups,omitempty"` - // Runner is the tool runner's account, put in the module's own group (novox/hq ADR 0334 §2) when its tools run - // on this machine. Absent for a module that declares no tools here, and from a controller older than ADR 0334. - Runner string `json:"runner,omitempty"` -} - -// Group is one library's group, as the controller allocated it (ADR 0328 §3). -type Group struct { - Name string `json:"name"` - GID int `json:"gid"` -} - -// GroupPrefix starts every library group's name, as IdentityPrefix starts every module's. -const GroupPrefix = "mesh-" - -// LibraryAccessPrefix starts the access a library's wait and its take act name: `library:`. -const LibraryAccessPrefix = "library:" - -// Library is one library as this machine sees it (ADR 0328). -type Library struct { - Library string `json:"library"` - // Root is the library's root on this machine: on its home the data itself, elsewhere the mount point the mounts - // module mounts it at. - Root string `json:"root"` - Group string `json:"group"` - GID int `json:"gid"` - // Home is that the library's data lives on this machine: only there does the engine keep it. - Home bool `json:"home"` - // Taken is the controller saying the operator's warrant to take it was carried out (§7). - Taken bool `json:"taken,omitempty"` - // Paths are the sub-paths its modules ask for, relative to the root. - Paths []string `json:"paths,omitempty"` - // Runner is the tool runner's account, put in the library's group (§11). Absent when no module's tools use it. - Runner string `json:"runner,omitempty"` -} - -// checkLibraries judges the groups and libraries on arrival: every library's group declared, named for it, never -// root's number; paths that stay below the root; a runner that is an account's name; and no two roots that nest -// (§13). -func checkLibraries(groups []Group, libs []Library) []string { - var problems []string - byName := map[string]int{} - numbers := map[int]string{} - for _, g := range groups { - where := fmt.Sprintf("group %q", g.Name) - if !strings.HasPrefix(g.Name, GroupPrefix) || !accountName.MatchString(g.Name) { - problems = append(problems, fmt.Sprintf("%s: a library's group is named %s", where, GroupPrefix)) - } - if g.GID <= 0 { - problems = append(problems, fmt.Sprintf("%s: gid %d, and a library's group is never root, nor a number "+ - "below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, g.GID)) - } - if _, twice := byName[g.Name]; twice { - problems = append(problems, where+": declared twice") - } - if other, used := numbers[g.GID]; used { - problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, g.GID, other)) - } - byName[g.Name], numbers[g.GID] = g.GID, g.Name - } - seen := map[string]bool{} - for _, l := range libs { - where := fmt.Sprintf("library %q", l.Library) - switch { - case l.Library == "" || strings.ContainsAny(l.Library, "/ \t\n"): - problems = append(problems, where+": a library is named by one word") - case seen[l.Library]: - problems = append(problems, where+": declared twice") - } - seen[l.Library] = true - if !filepath.IsAbs(l.Root) || filepath.Clean(l.Root) != l.Root || l.Root == "/" { - problems = append(problems, fmt.Sprintf("%s: its root %q is not an absolute, clean path below /", where, - l.Root)) - } - if gid, ok := byName[l.Group]; !ok || gid != l.GID { - problems = append(problems, fmt.Sprintf("%s: its group %s (gid %d) is not one this declaration makes", - where, l.Group, l.GID)) - } - if l.Group != GroupPrefix+l.Library { - problems = append(problems, fmt.Sprintf("%s: its group is %s%s, and %s is not", where, GroupPrefix, - l.Library, l.Group)) - } - for _, p := range l.Paths { - if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || p == ".." || strings.HasPrefix(p, "../") { - problems = append(problems, fmt.Sprintf("%s: the path %q is not a clean path below its root", where, p)) - } - } - if l.Runner != "" && !accountName.MatchString(l.Runner) { - problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, l.Runner)) - } - } - for i, a := range libs { - for _, b := range libs[i+1:] { - if a.Root != "" && b.Root != "" && (a.Root == b.Root || strings.HasPrefix(a.Root, b.Root+"/") || - strings.HasPrefix(b.Root, a.Root+"/")) { - problems = append(problems, fmt.Sprintf("library %q: its root and library %q's nest, and libraries "+ - "never overlap (novox/hq ADR 0328 §13)", a.Library, b.Library)) - } - } - } - return problems -} - -// IdentityPrefix starts every module identity's name. -const IdentityPrefix = "mesh-" - -// checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number -// twice — two modules with one account would write as each other. -func checkIdentities(ids []Identity, groups []Group) []string { - var problems []string - names, uids := map[string]bool{}, map[int]string{} - libraryGroups := map[int]bool{} - for _, g := range groups { - libraryGroups[g.GID] = true - } - for _, id := range ids { - where := fmt.Sprintf("identity %q", id.Name) - if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) { - problems = append(problems, fmt.Sprintf("%s: a module's identity is named %s, and this one is "+ - "for the module %q", where, IdentityPrefix, id.Module)) - } - if id.UID <= 0 || id.GID <= 0 { - problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+ - "nor a number below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, - id.UID, id.GID)) - } - if names[id.Name] { - problems = append(problems, where+": declared twice") - } - if other, used := uids[id.UID]; used { - problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other)) - } - // Its primary group is its own (gid == uid) or a library's this declaration makes (ADR 0328 §4); so is - // every added group. A module's own number is never a library group's. - if id.GID != id.UID && !libraryGroups[id.GID] { - problems = append(problems, fmt.Sprintf("%s: its primary group gid %d is neither its own (%d) nor a "+ - "library's this declaration makes", where, id.GID, id.UID)) - } - if libraryGroups[id.UID] { - problems = append(problems, fmt.Sprintf("%s: uid %d is a library group's number too", where, id.UID)) - } - for _, g := range id.Groups { - if !libraryGroups[g] { - problems = append(problems, fmt.Sprintf("%s: its added group gid %d is not a library's this "+ - "declaration makes", where, g)) - } - } - if id.Runner != "" && !accountName.MatchString(id.Runner) { - problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, id.Runner)) - } - names[id.Name], uids[id.UID] = true, id.Name - } - return problems -} - -// LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the -// module's name, a dot, and the module's own id for it. A module's name may contain a dot, so the -// longest left-out name that prefixes the id wins; a false match keeps a thing an apply would -// otherwise remove, which is the conservative mistake. -func (d *Declaration) LeftOutModuleOf(id string) (string, bool) { - best := "" - for _, m := range d.LeftOut { - if strings.HasPrefix(id, m+".") && len(m) > len(best) { - best = m - } - } - return best, best != "" -} - -// Adoption is a node's mode, as the controller records it: the node is adopted, and these are -// the modules taken on it so far (novox/hq ADR 0100). -// -// **Authoritative, and only ever stated by the controller.** A host does not work out whether it -// is adopted; it is told, in every declaration, so a host restarted from the declaration it kept -// is in the same mode it was in before. -// -// Untaken names, per module assigned here and not yet taken, the ids of its resources. Any kind -// may be listed: a file, a directory, a service's unit or a container can already be on the -// machine, and an action run inside a held container reaches what was found (novox/hq ADR 0103); -// the host decides per kind what can be held. The host cannot split a resource id into its -// module, because module names may contain dots, so the controller says which ids belong to which -// module rather than leaving the host to guess. -type Adoption struct { - Taken []string `json:"taken"` - Untaken map[string][]string `json:"untaken,omitempty"` -} - -// capabilityName is what a Linux capability is called: upper case, underscores, an optional CAP_ -// prefix. The runtime accepts either spelling. -var capabilityName = regexp.MustCompile(`^(CAP_)?[A-Z][A-Z0-9_]*$`) - -// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted — -// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held. -const AdoptionPrefix = "adoption." - -// UntakenModuleOf says which untaken module declares a resource, if any. -func (a *Adoption) UntakenModuleOf(id string) (string, bool) { - if a == nil { - return "", false - } - for module, ids := range a.Untaken { - if slices.Contains(ids, id) { - return module, true - } - } - return "", false -} - -// checkAdoption holds what an adoption says against the resources beside it. Every problem is a -// refusal: a host that misread which module is untaken would replace a predecessor's service the -// operator never took. -func checkAdoption(a *Adoption, resources []Resource, allowActions bool) []string { - if a == nil { - return nil - } - if allowActions { - // The bundle is carried with the binary and raises a foundation before any mesh exists. - // Whether a node is adopted is the controller's record, and a bundle that claimed it would - // be the host deciding its own mode (novox/hq ADR 0100). - return []string{"a carried bundle says the node is adopted, and only the mesh can say " + - "that: a node's mode is the controller's record, sent in every declaration"} - } - kinds := map[string]Type{} - for _, r := range resources { - kinds[r.Identity()] = r.Kind() - } - var problems []string - for _, module := range a.Taken { - if _, both := a.Untaken[module]; both { - problems = append(problems, fmt.Sprintf( - "adoption: the module %q is said to be both taken and untaken", module)) - } - } - owner := map[string]string{} - modules := make([]string, 0, len(a.Untaken)) - for module := range a.Untaken { - modules = append(modules, module) - } - sort.Strings(modules) - for _, module := range modules { - for _, id := range a.Untaken[module] { - if strings.HasPrefix(id, AdoptionPrefix) { - problems = append(problems, fmt.Sprintf( - "adoption: %q is the mesh's own and belongs to no module, so it cannot be untaken", id)) - continue - } - if first, twice := owner[id]; twice { - problems = append(problems, fmt.Sprintf( - "adoption: %q is said to belong to both %q and %q", id, first, module)) - continue - } - owner[id] = module - if _, declared := kinds[id]; !declared { - problems = append(problems, fmt.Sprintf( - "adoption: %q of the untaken module %q is not in this declaration", id, module)) - } - } - } - return problems -} - -// RefusalError refuses a whole declaration, naming every problem at once. -// -// Every problem rather than the first: a caller fixing one at a time learns the next only by -// running again, and a declaration is generated, so a person reading this is debugging the -// generator. -type RefusalError struct { - Problems []string -} - -func (e *RefusalError) Error() string { - return fmt.Sprintf( - "this declaration is refused, and none of it was applied:\n - %s\n\n"+ - "A host that applied the parts it understood would leave a machine that looks "+ - "configured and is not.", - strings.Join(e.Problems, "\n - ")) -} - -// Parse reads a declaration that arrived over the link, and refuses anything it does not fully -// understand — including any action, which the link may not carry (novox/hq ADR 0005). -func Parse(raw []byte) (*Declaration, error) { return parse(raw, false) } - -// ParseTrusted reads a declaration from a source already as privileged as the host itself: the -// bundle it carries, or a file handed to it by someone who is running it as root. -// -// Actions are permitted here and nowhere else. The asymmetry is deliberate and is the entire -// content of ADR 0005: refusing actions from the bundle buys nothing, because whoever built the -// bundle built the binary; refusing them from the link buys the bound on what a compromised -// control plane can express. -func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) } - -// envelope is the declaration with its resources still unread. -// -// Two passes, because which fields are legal depends on the "type" inside each resource. The -// first pass takes the envelope and each resource's bytes; the second decodes each one into -// the struct for its kind, strictly. -type envelope struct { - Version int `json:"declaration"` - For string `json:"for,omitempty"` - Adoption *Adoption `json:"adoption,omitempty"` - // OwnsNothing is the control plane saying, explicitly, that this node's declaration is empty - // on purpose — it owns nothing the mesh put there (novox/hq issue 127). Without it an empty - // resources list is refused as a likely mistake; with it the node applies the empty - // declaration and drops what it last held. The two are distinguished because a truncated or - // mis-composed body arrives as empty too, and a host that could not tell them apart would let - // a bug quietly strip a machine. - OwnsNothing bool `json:"owns_nothing,omitempty"` - Resources []json.RawMessage `json:"resources"` - // Sequence is optional on the wire, so a controller that does not send one is still - // understood: absent reads as zero, which is "no ordering claimed" rather than "first". - Sequence int64 `json:"sequence,omitempty"` - // Epoch is optional on the wire as the sequence is: absent is a controller without a lease. - // **An older host refuses this key**, decoding strictly; a controller sends it only to a host - // whose reports carry a report sequence, which a host that reads it does. - Epoch int64 `json:"epoch,omitempty"` - // LeftOut is optional on the wire too, and absent when nothing was left out (ADR 0163). - LeftOut []string `json:"left_out,omitempty"` - // Generation and PutBack are optional on the wire too (novox/hq issue 234): absent is a controller - // that claims no generation. **An older host refuses these keys**, decoding strictly; a controller - // sends them only to a host whose reports say `reads_generation`. - Generation int64 `json:"generation,omitempty"` - PutBack bool `json:"put_back,omitempty"` - // Identities is optional on the wire too (novox/hq ADR 0325 §3, ADR 0328 §4). **An older host refuses this key**; a - // controller sends it only to a host whose health says link.IdentityContract. - Identities []Identity `json:"identities,omitempty"` - // Groups and Libraries are optional too (novox/hq ADR 0328). **An older host refuses these keys**; a controller - // sends them only to a host whose health says link.LibraryContract. - Groups []Group `json:"groups,omitempty"` - Libraries []Library `json:"libraries,omitempty"` - // Filled is optional too (novox/hq ADR 0349 §5), as is a file's `owner-at-runtime`. **An older host refuses - // these keys**; a controller sends them only to a host whose health says link.OwnershipContract. - Filled []string `json:"filled_files,omitempty"` - // Provisioner is optional too (novox/hq ADR 0349 §4). **An older host refuses this key**; a controller sends it - // only to a host whose health says link.ProvisionerContract. - Provisioner string `json:"provisioner_account,omitempty"` - // Builds is optional too (novox/hq ADR 0429 §4, issue 1132): the build id of each module the declaration names, and - // every resource's `module`. **An older host refuses these keys**; a controller sends them only to a host whose - // reports say `reads_modules`. - Builds map[string]string `json:"builds,omitempty"` -} - -func parse(raw []byte, allowActions bool) (*Declaration, error) { - var env envelope - if err := strictDecode(raw, &env); err != nil { - return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}} - } - - if env.Version != Version { - // Everything below assumes the vocabulary, so there is nothing further to say. - return nil, &RefusalError{Problems: []string{fmt.Sprintf( - "declaration version %d; this host speaks version %d. Refused whole rather than "+ - "partly, so a newer vocabulary is never half-applied by an older host", - env.Version, Version)}} - } - - d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence, - Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack, - Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries, Filled: env.Filled, - Provisioner: env.Provisioner, Builds: env.Builds} - problems := checkIdentities(env.Identities, env.Groups) - problems = append(problems, checkProvisioner(env.Provisioner)...) - problems = append(problems, checkLibraries(env.Groups, env.Libraries)...) - if env.Generation < 0 { - // Below zero would read as "none claimed" and pass every refusal of an older generation. - problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+ - "mesh assigns", env.Generation)) - } - if env.PutBack && allowActions { - // A put-back is a gate's, sent by the mesh; a carried bundle puts nothing back. - problems = append(problems, "a carried bundle says it is a put-back, and only the mesh's gate can say that") - } - if env.Sequence < 0 || env.Epoch < 0 { - // Below zero is no order any controller assigns, and read as "none claimed" it would let the - // declaration past every refusal of what is older. - problems = append(problems, fmt.Sprintf("an order below zero (epoch %d, sequence %d) is not "+ - "one the mesh assigns", env.Epoch, env.Sequence)) - } - if len(env.LeftOut) > 0 && allowActions { - // The bundle is carried with the binary and leaves nothing out: which module a setting - // stopped composing for is the mesh's record (ADR 0163). - problems = append(problems, "a carried bundle says modules were left out, and only the "+ - "mesh can say that") - } - for _, m := range env.LeftOut { - if strings.TrimSpace(m) == "" { - problems = append(problems, "left_out names a module with no name") - } - } - - if len(env.Resources) == 0 && !env.OwnsNothing { - problems = append(problems, "no resources. An empty declaration is a mistake, not a "+ - "machine with nothing on it — say so with an explicit empty list if that is meant") - } - - seen := map[string]int{} - for i, rawResource := range env.Resources { - // Peek, leniently. This pass only needs to know which struct to decode into; reading - // strictly here would report an unknown field before knowing which fields are known. - var head struct { - ID string `json:"id"` - Type Type `json:"type"` - } - _ = json.Unmarshal(rawResource, &head) - - where := fmt.Sprintf("resource %d", i) - if head.ID != "" { - where = fmt.Sprintf("resource %q", head.ID) - } - - if head.ID == "" { - problems = append(problems, where+": no id. Identity is what lets the host know "+ - "this is the same resource it applied last time") - } else if first, ok := seen[head.ID]; ok { - problems = append(problems, fmt.Sprintf( - "%s: id already used by resource %d. Two resources with one identity cannot "+ - "both be tracked", where, first)) - } else { - seen[head.ID] = i - } - - resource := newOf(head.Type) - if resource == nil { - problems = append(problems, fmt.Sprintf( - "%s: unknown type %q. This host understands %s", where, head.Type, vocabulary())) - continue - } - - // A field the kind does not have is refused, and the struct is what says so — there - // is no list of exclusions for anyone to keep current. - // - // Asked separately rather than taken from the decoder's error, because the decoder - // stops at the first unknown field and this record promises every problem at once. A - // caller fixing one field at a time learns the next only by running again. - if unknown := unknownFields(rawResource, resource); len(unknown) > 0 { - for _, field := range unknown { - problems = append(problems, fmt.Sprintf( - "%s: a %s does not use %q, and it is set. Refused rather than ignored", - where, head.Type, field)) - } - continue - } - if err := json.Unmarshal(rawResource, resource); err != nil { - problems = append(problems, fmt.Sprintf("%s: %s", where, err)) - continue - } - - problems = append(problems, resource.validate(where, allowActions)...) - d.Resources = append(d.Resources, resource) - } - problems = append(problems, checkWhileStopped(d.Resources)...) - problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...) - problems = append(problems, checkReplaces(d.Resources)...) - problems = append(problems, checkFilled(env.Filled, d.Resources)...) - if env.Adoption == nil { - for _, r := range d.Resources { - if r.Kind() == TypeOpening { - // On a converged node the mesh's own filter admits what is declared, and the - // found firewall is retired; an opening there would be a rule in a firewall the - // mesh has disabled (novox/hq ADR 0100). - problems = append(problems, fmt.Sprintf( - "resource %q: an opening is for an adopted node, and this declaration does not "+ - "say the node is adopted", r.Identity())) - } - if svc, ok := r.(*Service); ok && svc.TakesOver != nil { - // A tunnel is taken over on an adopted node, where what is found is kept: on a - // converged one there is nothing found to take over, and stopping a unit the - // mesh did not declare would be the host deciding (novox/hq ADR 0105). - problems = append(problems, fmt.Sprintf( - "resource %q: taking over a tunnel is for an adopted node, and this declaration "+ - "does not say the node is adopted", r.Identity())) - } - } - } - - if len(problems) > 0 { - return nil, &RefusalError{Problems: problems} - } - d.indexModules() - return d, nil -} - -// checkWhileStopped judges a maintenance window against the whole declaration (novox/hq ADR 0189). -// -// A step may hold still only a container that is **here** — in this same declaration, which is to -// say on this machine and placed by the mesh. That is what makes it the module's own: a node's -// declaration carries one module's resources beside another's, so the id must also be a container -// and not a file or a directory, which there would be nothing to stop. -// -// Refused on arrival rather than discovered at the first fire. A window that names something the -// host cannot stop is a window that opens at 03:00 and reports nothing until somebody reads a log. -func checkWhileStopped(resources []Resource) []string { - containers := map[string]bool{} - for _, r := range resources { - if r.Kind() == TypeContainer { - containers[r.Identity()] = true - } - } - var problems []string - for _, r := range resources { - c, ok := r.(*Container) - if !ok { - continue - } - for _, id := range c.WhileStopped { - if containers[id] { - continue - } - problems = append(problems, fmt.Sprintf( - "resource %q: while-stopped names %q, and this declaration has no container by "+ - "that id. A step may hold still only a container placed on this machine "+ - "beside it", c.ID, id)) - } - } - return problems -} - -// accountName is what an identity's or an access user's name may be: the portable account name the user tools -// take, optionally ending in `$`, never starting with a dash — it is handed to them as an argument. -var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`) - -func strictDecode(raw []byte, into any) error { - // DisallowUnknownFields is the whole point rather than strictness for its own sake: a - // field the host does not know is a thing the control plane believes it asked for. - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.DisallowUnknownFields() - if err := dec.Decode(into); err != nil { - return err - } - // And **nothing after it**. A decoder reads one value and stops, so a file holding a - // declaration followed by anything at all — a truncated rewrite, two declarations - // concatenated, a stray line from whatever wrote the file — parses as the first value and the - // rest is never looked at. - // - // That is the same fault this host refuses everywhere else, in its quietest form: the machine - // applies something, reports success, and what it applied is not what the file says. Found - // when a test harness appended a line to a bundle by accident and every apply kept working. - if _, err := dec.Token(); err != io.EOF { - return fmt.Errorf( - "there is more in this file after the declaration ends. Refused whole: a file with " + - "something after it may be a truncated rewrite or two declarations run together, " + - "and applying the first would be applying something nobody wrote") - } - return nil -} - -// unknownFields names every JSON key the kind's struct has no field for. -// -// The struct's own tags are the list of what is legal, so adding a field to a kind is the -// whole of adding it — there is nowhere else that has to agree. -func unknownFields(raw []byte, into Resource) []string { - var got map[string]json.RawMessage - if err := json.Unmarshal(raw, &got); err != nil { - return nil // not an object; the decode below will say so properly - } - - known := map[string]bool{} - t := reflect.TypeOf(into).Elem() - for i := 0; i < t.NumField(); i++ { - name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") - if name != "" && name != "-" { - known[name] = true - } - } - - var unknown []string - for field := range got { - if !known[field] { - unknown = append(unknown, field) - } - } - sort.Strings(unknown) - return unknown -} - -func checkMode(where, mode string) []string { - if mode == "" { - return nil - } - if len(mode) != 4 || mode[0] != '0' { - return []string{fmt.Sprintf( - "%s: mode %q; write it as four octal digits such as \"0644\", so it means the "+ - "same thing here as it does in the manifest it came from", where, mode)} - } - for _, c := range mode[1:] { - if c < '0' || c > '7' { - return []string{fmt.Sprintf("%s: mode %q is not octal", where, mode)} - } - } - return nil -} - -// checkImage insists on content, not on a name. -// -// A tag moves and a digest does not. The bundle's whole claim is that what it names is exact -// (novox/hq ADR 0006), and a bundle pinning `postgres:17` pins nothing — it names whatever -// that tag points at on the day the host happens to run. -// -// **Two forms say something exact, and only one of them needs a registry.** `name@sha256:…` is a -// manifest digest, which a registry assigns on push. A bare `sha256:…` is an image the machine -// already holds, addressed by the digest of its own configuration — equally immutable, equally -// unforgeable, and requiring nothing to have served it. -// -// That second form is what a first machine needs. The mesh's own control plane exists in no public -// registry and never will: it is built from source, and until this mesh has a registry of its own -// there is nowhere to push it to and therefore no manifest digest to name it by. Insisting on one -// would mean a registry has to exist before the thing that lets a mesh have a registry can start — -// which is not a pin, it is a dependency the rule accidentally created. A machine that built an -// image, or was handed one, can name it by what it is. -func checkImage(where, image string) []string { - if image == "" { - return []string{where + ": a container needs an image"} - } - // An image this machine holds, named by the digest of its own configuration. - if strings.HasPrefix(image, "sha256:") { - if len(image) != len("sha256:")+64 { - return []string{fmt.Sprintf( - "%s: image id %q is not a sha256 digest", where, image)} - } - return nil - } - name, digest, found := strings.Cut(image, "@") - if !found || name == "" { - return []string{fmt.Sprintf( - "%s: image %q is not pinned. Write it as name@sha256:… — or as sha256:… for an image "+ - "this machine already holds. A tag moves, and a bundle that pinned a tag would "+ - "not be pinned", where, image)} - } - if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 { - return []string{fmt.Sprintf( - "%s: image digest %q is not a sha256 digest", where, digest)} - } - return nil -} - -func vocabulary() string { - kinds := Vocabulary() - names := make([]string, 0, len(kinds)) - for _, t := range kinds { - names = append(names, string(t)) - } - sort.Strings(names) - return strings.Join(names, ", ") -} - -// ParseFileTrusted reads a declaration from a file somebody handed this host. -// -// The same as ParseTrusted, and it allows whole-line `//` comments first. A pinned, hand-authored -// artefact that nobody can annotate is one nobody can review — the foundation bundle is mostly -// explanation of why each digest is what it is. -// -// **Only for a file, never for the link.** Over the link the format stays exactly JSON, because -// a wire format with a second thing to strip is a wire format with a second thing to disagree -// about. -// -// It exists because there were two readers for one file: the bundle stripped comments and `apply` -// did not, so the example bundle in this repository could be built into a binary and not applied -// from disk. The failure was `invalid character '/'`, which names the symptom and not the cause. -func ParseFileTrusted(raw []byte) (*Declaration, error) { - return ParseTrusted(stripComments(raw)) -} - -// stripComments removes whole lines beginning with `//`. -// -// Only whole lines: anything cleverer would need to know where strings begin and end, and a -// parser that half-understands its input is worse than one that does not try. A `//` inside a -// value — every image reference has one — is untouched. -func stripComments(raw []byte) []byte { - var kept []string - for _, line := range strings.Split(string(raw), "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "//") { - continue - } - kept = append(kept, line) - } - return []byte(strings.Join(kept, "\n")) -} - -// checkReplaces refuses a `replaces` naming something this same declaration still declares, or -// named by two processes (novox/hq issue 213). What is replaced is what the declaration no longer -// says — a resource still declared is applied, not handed over, and one handed to two replacements -// would go when the first of them came up, whatever became of the second. -func checkReplaces(resources []Resource) []string { - declared := map[string]bool{} - for _, r := range resources { - declared[r.Identity()] = true - } - var problems []string - by := map[string]string{} - for _, r := range resources { - p, ok := r.(*Process) - if !ok { - continue - } - for _, id := range p.Replaces { - if declared[id] { - problems = append(problems, fmt.Sprintf( - "resource %q: replaces %q, which this declaration still declares — a process "+ - "replaces what the declaration no longer says", p.ID, id)) - } - if other, taken := by[id]; taken && other != p.ID { - problems = append(problems, fmt.Sprintf( - "resource %q: replaces %q, which %q replaces too — one thing has one replacement", - p.ID, id, other)) - } - by[id] = p.ID - } - } - return problems -} diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go deleted file mode 100644 index 36063d4..0000000 --- a/internal/declaration/declaration_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package declaration - -import ( - "errors" - "strings" - "testing" -) - -// Each test names the decision it defends (novox/hq ADR 0017). The decision here is ADR 0005, -// and the property it turns on is that unknown is REFUSED, never skipped. - -func valid() string { - return `{"declaration":1,"resources":[ - {"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"}, - {"id":"conf","type":"file","path":"/etc/mesh/host.conf","content":"a\n","mode":"0640"}, - {"id":"svc","type":"service","unit":"mesh-host.service","state":"running"} - ]}` -} - -func refusalFor(t *testing.T, raw string) *RefusalError { - t.Helper() - _, err := Parse([]byte(raw)) - if err == nil { - t.Fatal("expected a refusal") - } - var refusal *RefusalError - if !errors.As(err, &refusal) { - t.Fatalf("expected a RefusalError, got %T: %v", err, err) - } - return refusal -} - -func TestAValidDeclarationParsesInOrder(t *testing.T) { - d, err := Parse([]byte(valid())) - if err != nil { - t.Fatalf("unexpected refusal: %v", err) - } - // Order is stated, not derived. The host must not sort. - got := []string{d.Resources[0].Identity(), d.Resources[1].Identity(), d.Resources[2].Identity()} - want := []string{"etc", "conf", "svc"} - for i := range want { - if got[i] != want[i] { - t.Fatalf("resources reordered: %v, want %v", got, want) - } - } -} - -func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) { - // The property everything else rests on. A host that skipped what it did not understand - // would apply most of a declaration and report success — a node that looks configured and - // is not, which is 04-ISSUES/003 with the declaration on the other side of the wire. - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"ok","type":"directory","path":"/etc/mesh"}, - {"id":"what","type":"blockchain","path":"/etc/mesh"} - ]}`) - - joined := strings.Join(refusal.Problems, "\n") - if !strings.Contains(joined, "blockchain") { - t.Errorf("the unknown type was not named: %v", refusal.Problems) - } - // And it must say what IS understood, or the reader goes to the source to find out. - if !strings.Contains(joined, "directory") || !strings.Contains(joined, "service") { - t.Errorf("the refusal does not say what this host understands: %v", refusal.Problems) - } -} - -func TestAnUnknownFieldIsRefused(t *testing.T) { - // A field the host does not know is a thing the control plane believes it asked for. - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true} - ]}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "immutable") { - t.Errorf("the unknown field was not named: %v", refusal.Problems) - } -} - -func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) { - // The same fault in miniature: set and ignored means the control plane believes it asked - // for something the host will never do. - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"svc","type":"service","unit":"a.service","state":"running","path":"/etc/x"} - ]}`) - joined := strings.Join(refusal.Problems, "\n") - if !strings.Contains(joined, "path") || !strings.Contains(joined, "Refused rather than ignored") { - t.Errorf("a field a service does not use was accepted: %v", refusal.Problems) - } -} - -func TestAnUnknownVersionIsRefusedWhole(t *testing.T) { - // An older host handed a newer vocabulary must not quietly do half of it. - refusal := refusalFor(t, `{"declaration":99,"resources":[ - {"id":"a","type":"directory","path":"/etc/mesh"} - ]}`) - joined := strings.Join(refusal.Problems, "\n") - if !strings.Contains(joined, "99") || !strings.Contains(joined, "version 1") { - t.Errorf("the version mismatch was not stated plainly: %v", refusal.Problems) - } - // Nothing else is reported, because everything else assumes a vocabulary this host does - // not have — a list of complaints derived from the wrong grammar is noise. - if len(refusal.Problems) != 1 { - t.Errorf("expected only the version problem, got: %v", refusal.Problems) - } -} - -func TestEveryProblemIsReportedAtOnce(t *testing.T) { - // A declaration is generated, so a person reading a refusal is debugging the generator. - // Fixing one problem at a time and re-running to find the next wastes their afternoon. - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"","type":"directory","path":"/a"}, - {"id":"b","type":"file"}, - {"id":"c","type":"service","unit":"x.service","state":"dancing"} - ]}`) - if len(refusal.Problems) < 3 { - t.Errorf("expected every problem at once, got: %v", refusal.Problems) - } -} - -func TestIdentityIsRequiredAndUnique(t *testing.T) { - // Identity is what lets the store know this is the same resource it applied last time, - // which is what makes removal possible at all. - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"same","type":"directory","path":"/a"}, - {"id":"same","type":"directory","path":"/b"} - ]}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "already used") { - t.Errorf("a duplicate identity was accepted: %v", refusal.Problems) - } -} - -func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) { - // "644" and "0644" differ, and the one that looks right in a manifest is the four-digit - // form. Accepting both would make a mode mean two things. - for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} { - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"f","type":"file","path":"/a","mode":"`+mode+`"} - ]}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "mode") { - t.Errorf("mode %q was accepted: %v", mode, refusal.Problems) - } - } - if _, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"f","type":"file","path":"/a","mode":"0644"} - ]}`)); err != nil { - t.Errorf("a well-formed mode was refused: %v", err) - } -} - -func TestARefusalSaysNothingWasApplied(t *testing.T) { - // The reader's first question is whether the machine was left half-changed. - refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`) - if !strings.Contains(refusal.Error(), "none of it was applied") { - t.Errorf("the refusal does not say the machine is untouched: %s", refusal.Error()) - } -} - -func TestAnEmptyDeclarationIsAMistake(t *testing.T) { - refusalFor(t, `{"declaration":1,"resources":[]}`) -} - -// --- the vocabulary the foundation bootstrap needs (novox/hq 07-the-foundation.md) --- - -func TestAnActionOverTheLinkIsRefused(t *testing.T) { - // novox/hq ADR 0005. The link may push declarations of known shape and never a command to - // run. This is the boundary the whole security argument rests on, so it is asserted - // directly rather than inferred from the type list. - raw := []byte(`{"declaration":1,"resources":[ - {"id":"schema","type":"action","command":["psql","-f","x.sql"],"verify":["psql","-c","select 1"]} - ]}`) - - if _, err := Parse(raw); err == nil { - t.Fatal("an action arriving over the link was accepted") - } else if !strings.Contains(err.Error(), "the link may not carry one") { - t.Errorf("refused for the wrong reason: %v", err) - } - - // And the same bytes from the bundle are fine — the asymmetry IS the decision. - if _, err := ParseTrusted(raw); err != nil { - t.Errorf("the bundle may carry an action, and this one was refused: %v", err) - } -} - -func TestAnActionWithoutVerifyIsRefused(t *testing.T) { - // An action that runs and reports success without reading anything back is the fault this - // host exists to prevent. Verify is also the idempotency check, so an action without one - // cannot be applied twice safely either. - _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"schema","type":"action","command":["psql","-f","x.sql"]} - ]}`)) - if err == nil { - t.Fatal("an action with no verify was accepted") - } - if !strings.Contains(err.Error(), "needs a verify") { - t.Errorf("refused for the wrong reason: %v", err) - } -} - -func TestAnImageMustBePinnedByDigest(t *testing.T) { - // novox/hq ADR 0006: reproducibility comes from pinning the identity of a thing. A bundle - // naming a tag pins nothing — it names whatever that tag points at on the day it runs. - for _, image := range []string{ - "postgres:17", - "postgres", - "postgres@sha256:short", - "@sha256:0000000000000000000000000000000000000000000000000000000000000000", - } { - _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"store","type":"container","name":"store","image":"` + image + `"} - ]}`)) - if err == nil { - t.Errorf("image %q was accepted and is not pinned", image) - } - } - - good := "postgres@sha256:" + strings.Repeat("a", 64) - if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"store","type":"container","name":"store","image":"` + good + `"} - ]}`)); err != nil { - t.Errorf("a properly pinned image was refused: %v", err) - } -} - -// An image the machine already holds, addressed by the digest of its own configuration. -// -// **The form a first machine needs.** A manifest digest is assigned by a registry on push, so -// insisting on one means a registry has to exist before the thing that lets a mesh have a registry -// can start. The mesh's own control plane is built from source and lives in no public registry; a -// machine that built it, or was handed it, names it by what it is — a content address, not a name, -// and immutable in exactly the way the rule asks for. -func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) { - held := "sha256:" + strings.Repeat("b", 64) - if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"control","type":"container","name":"mesh-controller","image":"` + held + `"} - ]}`)); err != nil { - t.Errorf("an image named by its own digest was refused: %v", err) - } - - // Still a digest, though. A truncated one names several images, and which one ran would be - // whichever the runtime happened to match first. - for _, bad := range []string{"sha256:abc", "sha256:", "sha256:" + strings.Repeat("b", 63)} { - if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"control","type":"container","name":"mesh-controller","image":"` + bad + `"} - ]}`)); err == nil { - t.Errorf("image id %q was accepted and is not a digest", bad) - } - } -} - -func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { - // The field-set check must cover the types added last, not only the three it was written - // for. A package that carries a `content` is a control plane believing it asked for - // something that will never happen. - for _, body := range []string{ - `{"id":"p","type":"package","package":"docker","content":"x"}`, - `{"id":"p","type":"package","package":"docker","image":"x"}`, - `{"id":"c","type":"container","name":"n","image":"i@sha256:` + strings.Repeat("a", 64) + `","unit":"x.service"}`, - `{"id":"a","type":"action","command":["x"],"verify":["y"],"path":"/tmp/x"}`, - } { - _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[` + body + `]}`)) - if err == nil { - t.Errorf("a resource carrying a field its type does not use was accepted: %s", body) - continue - } - if !strings.Contains(err.Error(), "Refused rather than ignored") { - t.Errorf("refused for the wrong reason: %v", err) - } - } -} - -func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) { - // Six of them the bootstrap uses (novox/hq 07-the-foundation.md), and removing one is a - // failing test rather than a discovery during a first-node install. - // - // Two were added on 2026-08-30 and the count is asserted precisely because adding one is a - // decision. `user` and `archive` exist because most of what a person installs is not a - // service: a shell, a chat client, a desktop are a package plus configuration **in - // somebody's home**, and a mesh with no user can only own /etc. `archive` is for the case - // inlining cannot serve — a theme is hundreds of files, and inlining them would rewrite all - // of them whenever one changed. - speaks := map[Type]bool{} - for _, t := range Vocabulary() { - speaks[t] = true - } - for _, want := range []Type{ - TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, - TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeProcess, TypeOpening, - } { - if !speaks[want] { - t.Errorf("the host no longer speaks %q", want) - } - if newOf(want) == nil { - t.Errorf("%q is in the vocabulary and cannot be constructed", want) - } - } - // `network` is the ninth, and novox/hq ADR 0029 is the decision that made it one: an action - // could create a network and nothing could remove it, because an action leaves no footprint - // the host can undo — so the network would outlive every module that was ever unassigned. - // - // `access` is the tenth, and novox/hq ADR 0051 is its decision: shared, pre-existing data is - // the operator's, and a module is granted use of it without owning it — a shape the host must - // tell apart from a directory precisely because it must NOT create, chown or remove it. - // - // `daemon` is the eleventh, and it exists because the mechanism was leaking into every module. - // Running code of one's own meant a `container` and therefore an image; running a script meant - // a `service` and a unit somebody else had to install. One intent — run this and keep it - // running — expressed two unrelated ways, with the hosting chosen before anything could be - // declared. A daemon says what to run; the machine's own supervisor is how, and the mesh owns - // the unit because it is the mesh's own code (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md). - // - // It is a full-host shape rather than a portable one: it needs a process supervisor to install - // into. It does NOT need a container runtime, which is the point — only software that - // genuinely needs isolation asks for a container. - // - // `opening` is the twelfth, and novox/hq ADR 0100 is its decision: on an adopted node the - // firewall found there stays in force, and what the mesh needs reachable is converged through - // it as a state the host marks as the mesh's — never a command, which the link may not carry. - if len(speaks) != 12 { - t.Errorf("the vocabulary is %d shapes rather than 12; every addition widens what a compromised "+ - "control plane can express, so a change here is a decision: %s", - len(speaks), vocabulary()) - } -} - -func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) { - // The bundle in this repository is mostly explanation of why each digest is what it is, and - // it could be built into a binary and not applied from disk — two readers for one file. The - // failure was `invalid character '/'`, which names the symptom and not the cause. - raw := []byte(`// why this exists -{ - "declaration": 1, - // and why this resource is here - "resources": [ - {"id": "f", "type": "file", "path": "/etc/x", "content": "hello"} - ] -}`) - d, err := ParseFileTrusted(raw) - if err != nil { - t.Fatalf("a file with comments was refused: %v", err) - } - if len(d.Resources) != 1 { - t.Fatalf("got %d resources", len(d.Resources)) - } - // And the wire format is untouched: over the link it is exactly JSON, because a format with - // a second thing to strip is a format with a second thing to disagree about. - if _, err := Parse(raw); err == nil { - t.Fatal("the link accepted a declaration with comments in it") - } -} - -func TestSomethingInsideAValueIsNotAComment(t *testing.T) { - // Every image reference has a `//` in it somewhere near. Only whole lines are dropped. - d, err := ParseFileTrusted([]byte(`{"declaration":1,"resources":[ - {"id":"f","type":"file","path":"/etc/x","content":"see https://example.invalid/ for why"}]}`)) - if err != nil { - t.Fatal(err) - } - file, ok := d.Resources[0].(*File) - if !ok { - t.Fatalf("got %T", d.Resources[0]) - } - if !strings.Contains(file.Content, "https://example.invalid/") { - t.Fatalf("a value was mangled: %q", file.Content) - } -} - -// A declaration followed by anything at all is refused whole. -// -// A JSON decoder reads one value and stops, so a file holding a declaration and then a stray line -// parses as the declaration and the rest is never looked at. The machine applies something, -// reports success, and what it applied is not what the file says. -// -// Not hypothetical: a test harness appended a line to the foundation bundle by accident, every -// apply kept working, and nothing said so for the entire time it was wrong. -func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) { - good := `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a",` + - `"content":"x","mode":"0644"}]}` - if _, err := ParseFileTrusted([]byte(good)); err != nil { - t.Fatalf("an ordinary declaration was refused: %v", err) - } - for _, after := range []string{ - "MESHBUNDLE 2>&1; echo \"__exit=$?\"", - good, - "garbage", - } { - _, err := ParseFileTrusted([]byte(good + "\n" + after)) - if err == nil { - t.Fatalf("a file with %q after the declaration was accepted", after) - } - } - // Trailing whitespace is not "something after it", and refusing it would make every file - // written by an editor unusable. - if _, err := ParseFileTrusted([]byte(good + "\n\n \n")); err != nil { - t.Fatalf("a declaration with a trailing newline was refused: %v", err) - } -} - -// A name that would read as a reference to something else is refused before it reaches a runtime. -func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) { - for _, name := range []string{"", "mail/private", "host:mail", "a b"} { - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"private","type":"network","name":"`+name+`"} - ]}`) - if len(refusal.Problems) == 0 { - t.Errorf("a network named %q was accepted", name) - } - } -} - -// A placeholder with nothing to fill it is refused before anything is written. -// -// The alternative is a configuration file containing the literal `${secret:x}`, which the program -// reads as a value and fails on somewhere with no connection to this. -func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) { - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"} - ]}`) - if len(refusal.Problems) == 0 { - t.Fatal("a file naming a secret nobody gave it was accepted") - } -} - -// And a secret nobody asked for, because somebody believes it is in a file where it is not. -func TestASecretTheContentNeverUsesIsRefused(t *testing.T) { - refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}} - ]}`) - if len(refusal.Problems) == 0 { - t.Fatal("a secret the content never mentions was accepted") - } -} - -// The runtime's resolver and address flags take only addresses; a name would be refused at -// create, after the old container was already gone. Refused on arrival instead — and an address -// without a user-defined network is refused for the same reason. -func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) { - refused := func(body string) []string { - _, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`)) - if err == nil { - return nil - } - return []string{err.Error()} - } - base := `"id":"c","type":"container","name":"x",` + - `"image":"a@sha256:0000000000000000000000000000000000000000000000000000000000000000"` - if p := refused(`{` + base + `,"network":"m","dns":["resolver.local"]}`); len(p) == 0 { - t.Error("a resolver named by name was accepted; the runtime takes only addresses") - } - if p := refused(`{` + base + `,"ip":"192.168.203.7"}`); len(p) == 0 { - t.Error("a static address with no network was accepted; the runtime refuses it") - } - if p := refused(`{` + base + `,"network":"m","dns":["192.168.203.254"],"ip":"192.168.203.7"}`); len(p) != 0 { - t.Errorf("a well-formed resolver and address were refused: %v", p) - } -} - -func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) { - // A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so - // the node drops what it last held — distinct from an accidental empty body, which is refused. - if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil { - t.Fatalf("an explicitly-empty declaration must be accepted: %v", err) - } - // Without the marker, an empty declaration is still refused as a likely mistake. - _, err := Parse([]byte(`{"declaration":1,"resources":[]}`)) - if err == nil || !strings.Contains(err.Error(), "no resources") { - t.Fatalf("an unmarked empty declaration must still be refused; got %v", err) - } -} - -// A container's kept networks are names, not its own network, and not for a step (novox/hq ADR -// 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not. -func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) { - pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64) - d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[ - {"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]} - ]}`)) - if err != nil { - t.Fatal(err) - } - if got := d.Resources[0].(*Container).Networks; len(got) != 1 || got[0] != "predecessor_default" { - t.Fatalf("the kept network was not read: %v", got) - } - if m, left := d.LeftOutModuleOf("web.server"); !left || m != "web" { - t.Fatalf("web.server is not web's: %q %v", m, left) - } - if _, left := d.LeftOutModuleOf("webapp.server"); left { - t.Fatal("webapp.server was taken for web's") - } - for name, raw := range map[string]string{ - "a bad network name": `{"declaration":1,"resources":[ - {"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["a/b"]}]}`, - "its own network": `{"declaration":1,"resources":[ - {"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","network":"own","networks":["own"]}]}`, - "a step": `{"declaration":1,"resources":[ - {"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","run-once":true,"networks":["x"]}]}`, - "a nameless module": `{"declaration":1,"left_out":[""],"resources":[ - {"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`, - } { - if _, err := Parse([]byte(raw)); err == nil { - t.Errorf("%s was accepted", name) - } - } - if _, err := ParseTrusted([]byte(`{"declaration":1,"left_out":["web"],"resources":[ - {"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`)); err == nil || - !strings.Contains(err.Error(), "only the mesh can say that") { - t.Fatalf("a carried bundle leaving modules out was accepted: %v", err) - } -} - -// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170). -func TestACapabilityIsNamedOrRefused(t *testing.T) { - image := "postgres@sha256:" + strings.Repeat("a", 64) - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]} - ]}`)) - if err != nil { - t.Fatal(err) - } - if got := d.Resources[0].(*Container).Capabilities; len(got) != 2 || got[0] != "NET_ADMIN" { - t.Fatalf("capabilities read as %v", got) - } - for _, bad := range []string{`"net_admin"`, `"ALL;rm -rf /"`, `"privileged"`} { - if _, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"fw","type":"container","name":"fw","image":"` + image + `","capabilities":[` + bad + `]}]}`)); err == nil { - t.Errorf("%s was accepted as a capability", bad) - } - } -} - -// A container may send its output to the machine's journal, and nowhere else but the runtime's own -// file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too. -func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) { - image := "postgres@sha256:" + strings.Repeat("a", 64) - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"} - ]}`)) - if err != nil { - t.Fatal(err) - } - if got := d.Resources[0].(*Container).Logging; got != "journald" { - t.Fatalf("logging read as %q", got) - } - for _, bad := range []string{`"syslog"`, `"none"`, `"json-file"`} { - if _, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"front","type":"container","name":"front","image":"` + image + `","logging":` + bad + `}]}`)); err == nil { - t.Errorf("%s was accepted as a place to log", bad) - } - } -} - -// Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write. - -func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]}, - {"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"} - ]}`)) - if err != nil { - t.Fatalf("refused: %v", err) - } - dir := d.Resources[0].(*Directory) - if dir.Unknown != UnknownMoveOut || len(dir.Knows) != 2 { - t.Errorf("directory: %+v", dir) - } - if ar := d.Resources[1].(*Archive); ar.Unknown != UnknownModule { - t.Errorf("archive: %+v", ar) - } - for _, v := range []string{UnknownMoveOut, UnknownModule, UnknownProgram} { - if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/x","unknown":"` + v + `"}]}`)); err != nil { - t.Errorf("%s refused: %v", v, err) - } - } -} - -func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) { - digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"` - cases := map[string]string{ - "bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`, - "empty entry": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":[""]}`, - "absolute": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["/etc"]}`, - "empty comp": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a//b"]}`, - "dot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["./a"]}`, - "dotdot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a/../b"]}`, - "repeat": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a","a"]}`, - "no unknown": `{"id":"d","type":"directory","path":"/x","knows":["a"]}`, - "archive bad": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","unknown":"nope"}`, - "archive knws": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","knows":["a"]}`, - } - for name, res := range cases { - t.Run(name, func(t *testing.T) { - refusal := refusalFor(t, `{"declaration":1,"resources":[`+res+`]}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), `resource "d"`) { - t.Errorf("resource not named: %v", refusal.Problems) - } - }) - } -} diff --git a/internal/declaration/emitted_check_test.go b/internal/declaration/emitted_check_test.go deleted file mode 100644 index 948dca5..0000000 --- a/internal/declaration/emitted_check_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package declaration - -import ( - "os" - "testing" -) - -// The control plane's actual output, put through the host's actual parser. -// -// Skipped unless MESH_EMITTED names a file, so this is a check somebody runs deliberately rather -// than a dependency between two repositories. It exists because "the host needs no new -// vocabulary" is the load-bearing claim of every computed and contributed resource, and the only -// honest way to know it is to hand the host one and see. -func TestWhatTheControlPlaneEmittedIsSomethingThisHostAccepts(t *testing.T) { - path := os.Getenv("MESH_EMITTED") - if path == "" { - t.Skip("set MESH_EMITTED to a declaration the control plane produced") - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - d, err := Parse(raw) - if err != nil { - t.Fatalf("the host refuses what the control plane sends:\n%v", err) - } - if len(d.Resources) == 0 { - t.Fatal("parsed, and empty") - } - for _, r := range d.Resources { - t.Logf("accepted %s %s → %s", r.Kind(), r.Identity(), r.Target()) - } -} diff --git a/internal/declaration/health.go b/internal/declaration/health.go deleted file mode 100644 index d6395bf..0000000 --- a/internal/declaration/health.go +++ /dev/null @@ -1,188 +0,0 @@ -package declaration - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "time" -) - -// Health is how a long-running resource is ready, as the controller composed it from the module's -// `health` (novox/hq ADR 0240 rule 2, to-be 48 §2–§3, Phase B): one kind and its timing, the endpoint -// already the port this machine published it on. -// -// **The node-engine runs every kind and owns every verdict.** http and tcp it makes itself, from the -// machine to the port; unit it reads from the service manager it already reads; exec and runtime it hands -// to the container runtime as the container's own check, with this timing, and reads the state; tool it -// asks of its own node tools. Nothing else on the machine sets a container's check. -// -// Refused here as the controller refuses it near the author, in the same bounds: an engine that took a -// check it could not judge would say a module ready that nothing looked at. -type Health struct { - Kind string `json:"kind"` - // Endpoint is the module's name for what Port is: for the words a verdict is said in. - Endpoint string `json:"endpoint,omitempty"` - Port int `json:"port,omitempty"` - Path string `json:"path,omitempty"` - Status int `json:"status,omitempty"` - Body string `json:"body,omitempty"` - Scheme string `json:"scheme,omitempty"` - Command string `json:"command,omitempty"` - Tool string `json:"tool,omitempty"` - Interval string `json:"interval"` - Timeout string `json:"timeout"` - Looks int `json:"looks"` - Grace string `json:"grace"` - // Needs is the provision the check exercises (to-be 48 §6): said with every verdict, so the - // controller can hold what it finds under the provider's own condition. - Needs string `json:"needs,omitempty"` -} - -// UnmarshalJSON reads a health strictly, as everything a declaration carries is read: a field this host -// does not know is a part of the check the controller believes it asked for, and nothing would look at it. -func (h *Health) UnmarshalJSON(raw []byte) error { - type plain Health - var p plain - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.DisallowUnknownFields() - if err := dec.Decode(&p); err != nil { - return fmt.Errorf("health: %w", err) - } - *h = Health(p) - return nil -} - -// The kinds. -const ( - HealthRuntime = "runtime" - HealthHTTP = "http" - HealthTCP = "tcp" - HealthExec = "exec" - HealthUnit = "unit" - HealthTool = "tool" -) - -// The bounds (ADR 0240 rule 2) — the controller's, held again here. -const ( - HealthIntervalFloor = 10 * time.Second - HealthLooksFloor = 2 - HealthWithin = 5 * time.Minute -) - -// Every, Within and GraceOf are the timing, read. Validated on arrival, so a parse error here is -// impossible on a declaration that was accepted; it reads as zero. -func (h *Health) Every() time.Duration { d, _ := time.ParseDuration(h.Interval); return d } -func (h *Health) Within() time.Duration { d, _ := time.ParseDuration(h.Timeout); return d } -func (h *Health) GraceOf() time.Duration { d, _ := time.ParseDuration(h.Grace); return d } - -// RunByRuntime says the container runtime runs this check as the container's own: exec and runtime. -func (h *Health) RunByRuntime() bool { - return h != nil && (h.Kind == HealthExec || h.Kind == HealthRuntime) -} - -// Words is the check in a few words, as a verdict is said: "http /healthz on web". -func (h *Health) Words() string { - switch h.Kind { - case HealthHTTP: - return "http " + h.Path + " on " + orPort(h.Endpoint, h.Port) - case HealthTCP: - return "tcp on " + orPort(h.Endpoint, h.Port) - case HealthTool: - return "its tool " + h.Tool - case HealthRuntime: - return "its image's own check" - case HealthExec: - return "its command" - case HealthUnit: - return "its unit" - } - return h.Kind -} - -func orPort(endpoint string, port int) string { - if endpoint != "" { - return endpoint - } - return fmt.Sprint(port) -} - -// problems holds a resource's health to its kind and bounds. container says whether the resource is a -// container; longRunning whether it stays up. -func (h *Health) problems(where string, container, longRunning bool) []string { - if h == nil { - return nil - } - var problems []string - say := func(format string, args ...any) { - problems = append(problems, where+": "+fmt.Sprintf(format, args...)) - } - if !longRunning { - say("health is judged on what stays up; a step or a scheduled run is judged by its own outcome") - } - switch h.Kind { - case HealthHTTP, HealthTCP: - if h.Port < 1 || h.Port > 65535 { - say("a %s check needs the port it looks at", h.Kind) - } - case HealthExec: - if !container { - say("an exec check runs inside a container") - } - if strings.TrimSpace(h.Command) == "" { - say("an exec check needs a command") - } - case HealthRuntime: - if !container { - say("a runtime check is a container image's own") - } - case HealthUnit: - if container { - say("a unit check is a service's or a process's own") - } - case HealthTool: - if strings.TrimSpace(h.Tool) == "" { - say("a tool check names the tool") - } - default: - say("health of kind %q; it is runtime, http, tcp, exec, unit or tool", h.Kind) - } - if h.Kind == HealthHTTP { - if !strings.HasPrefix(h.Path, "/") { - say("an http check asks a path starting with /") - } - if h.Status != 0 && (h.Status < 100 || h.Status > 599) { - say("an http check expects status %d, which is not one", h.Status) - } - if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" { - say("an http check is over http or https, not %q", h.Scheme) - } - } - if strings.ContainsAny(h.Command, "\n\r") { - say("an exec check's command is one line") - } - every, everyErr := time.ParseDuration(h.Interval) - within, withinErr := time.ParseDuration(h.Timeout) - grace, graceErr := time.ParseDuration(h.Grace) - switch { - case everyErr != nil || withinErr != nil || graceErr != nil: - say("health's interval, timeout and grace are durations") - default: - if every < HealthIntervalFloor { - say("a check looks no more often than every %s, not every %s", HealthIntervalFloor, every) - } - if within <= 0 || within >= every { - say("a look takes more than nothing and less than its interval") - } - if grace < 0 { - say("a grace is not negative") - } - if h.Looks >= HealthLooksFloor && grace+time.Duration(h.Looks)*every > HealthWithin { - say("a grace and the failing looks take at most %s", HealthWithin) - } - } - if h.Looks < HealthLooksFloor { - say("a check is unhealthy after at least %d failing looks, not %d", HealthLooksFloor, h.Looks) - } - return problems -} diff --git a/internal/declaration/health_test.go b/internal/declaration/health_test.go deleted file mode 100644 index 000d53a..0000000 --- a/internal/declaration/health_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// A resource's health is held to its kind and bounds on arrival, as the controller holds it near the -// author (novox/hq ADR 0240 rule 2): an engine that took a check it could not judge would say a module ready -// that nothing looked at. -func TestAHealthIsHeldToItsKindAndBounds(t *testing.T) { - pinned := "postgres@sha256:" + strings.Repeat("a", 64) - parse := func(resource string) error { - _, err := Parse([]byte(`{"declaration":1,"resources":[` + resource + `]}`)) - return err - } - container := func(health string, more string) string { - return `{"id":"m.db","type":"container","name":"db","image":"` + pinned + `"` + more + `,"health":` + health + `}` - } - timing := `"interval":"30s","timeout":"5s","looks":3,"grace":"1m0s"` - for _, ok := range []string{ - container(`{"kind":"http","endpoint":"web","port":31001,"path":"/",`+timing+`}`, ""), - container(`{"kind":"exec","command":"pg_isready",`+timing+`}`, ""), - container(`{"kind":"runtime",`+timing+`}`, ""), - `{"id":"m.d","type":"service","unit":"d.service","state":"running","health":{"kind":"unit",` + timing + `}}`, - } { - if err := parse(ok); err != nil { - t.Errorf("refused %s: %v", ok, err) - } - } - for _, c := range []struct{ resource, says string }{ - {container(`{"kind":"http","path":"/",`+timing+`}`, ""), "needs the port"}, - {container(`{"kind":"exec",`+timing+`}`, ""), "needs a command"}, - {container(`{"kind":"unit",`+timing+`}`, ""), "a service's or a process's own"}, - {container(`{"kind":"ping",`+timing+`}`, ""), `of kind "ping"`}, - {container(`{"kind":"tcp","port":1,"interval":"5s","timeout":"1s","looks":3,"grace":"0s"}`, ""), "no more often than every 10s"}, - {container(`{"kind":"tcp","port":1,"interval":"30s","timeout":"30s","looks":3,"grace":"0s"}`, ""), "less than its interval"}, - {container(`{"kind":"tcp","port":1,"interval":"30s","timeout":"5s","looks":1,"grace":"0s"}`, ""), "at least 2 failing looks"}, - {container(`{"kind":"tcp","port":1,"interval":"60s","timeout":"5s","looks":3,"grace":"3m"}`, ""), "at most 5m0s"}, - {container(`{"kind":"runtime",`+timing+`}`, `,"run-once":true`), "judged on what stays up"}, - {`{"id":"m.d","type":"service","unit":"d.service","state":"running","health":{"kind":"runtime",` + timing + `}}`, "a container image's own"}, - {container(`{"kind":"tcp","port":1,`+timing+`,"retries":2}`, ""), "retries"}, - } { - err := parse(c.resource) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: want a refusal saying %q, got %v", c.resource, c.says, err) - } - } -} diff --git a/internal/declaration/identity_test.go b/internal/declaration/identity_test.go deleted file mode 100644 index e97225f..0000000 --- a/internal/declaration/identity_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -const anImage = "docker.io/library/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - -// The fields ADR 0325 adds, read as the controller sends them (novox/hq ADR 0325 §2, §3, §5, issue 567). -func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1, - "identities":[{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}], - "resources":[ - {"id":"radarr.app","type":"container","name":"radarr","image":"` + anImage + `", - "identity":{"mode":"env","user_env":"PUID","group_env":"PGID","uid":61001,"gid":61001}}, - {"id":"plex.app","type":"container","name":"plex","image":"` + anImage + `","identity":{"mode":"run","uid":61002,"gid":61002}}, - {"id":"radarr.movies","type":"access","path":"/srv/media/movies","mode":"read-write","taken":true, - "users":[{"identity":"mesh-radarr","uid":61001,"use":"read-write","from":"container"}, - {"identity":"nox","uid":61003,"use":"read","from":"tool-runner"}]}, - {"id":"old.movies","type":"access","path":"/srv/media/movies","mode":"read-write","grant":{"account":"nox"}}]}`)) - if err != nil { - t.Fatal(err) - } - if len(d.Identities) != 1 || d.Identities[0].UID != 61001 { - t.Fatalf("identities: %+v", d.Identities) - } - if c := d.Resources[0].(*Container); c.RunAs == nil || c.RunAs.UserEnv != "PUID" { - t.Fatalf("a container's identity: %+v", c.RunAs) - } - if a := d.Resources[2].(*Access); !a.Taken || len(a.Users) != 2 || a.Users[1].From != FromToolRunner { - t.Fatalf("an access's users: %+v", a) - } -} - -// What the engine refuses on arrival: a root number, an identity not named for its module, a field it does not -// know, a user that writes a read access, a variable set twice. -func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) { - for want, raw := range map[string]string{ - "never root": `"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]`, - "named mesh-": `"identities":[{"module":"m","name":"m","uid":61001,"gid":61001}],` + - `"resources":[]`, - "is mesh-a's too": `"identities":[{"module":"a","name":"mesh-a","uid":61001,"gid":61001},` + - `{"module":"b","name":"mesh-b","uid":61001,"gid":61002}],"resources":[]`, - "unknown field": `"identities":[{"module":"m","name":"mesh-m","uid":61001,"gid":61001,"home":"/"}],` + - `"resources":[]`, - "writes an access declared read": `"resources":[{"id":"m.a","type":"access","path":"/srv/x","mode":"read",` + - `"users":[{"identity":"mesh-m","uid":61001,"use":"read-write","from":"container"}]}]`, - "uid 0": `"resources":[{"id":"m.a","type":"access","path":"/srv/x","users":[{"identity":"root","uid":0,` + - `"use":"read","from":"container"}]}]`, - "one value": `"resources":[{"id":"m.c","type":"container","name":"c","image":"` + anImage + `",` + - `"env":{"PUID":"1000"},"identity":{"mode":"env","user_env":"PUID","uid":61001,"gid":61001}}]`, - `"env" or "run"`: `"resources":[{"id":"m.c","type":"container","name":"c","image":"` + anImage + `",` + - `"identity":{"mode":"sudo","uid":61001,"gid":61001}}]`, - } { - _, err := Parse([]byte(`{"declaration":1,` + raw + `}`)) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("want a refusal saying %q, got %v", want, err) - } - } -} diff --git a/internal/declaration/into_test.go b/internal/declaration/into_test.go deleted file mode 100644 index 531e015..0000000 --- a/internal/declaration/into_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// Defends novox/hq ADR 0102: a file written into carries only a JSON object of its keys, in a -// format the host speaks, and a service may name what it is reloaded on. - -func TestAFileWrittenIntoIsRefusedUnlessItIsAnObjectOfKeys(t *testing.T) { - for name, c := range map[string]struct{ resource, refusal string }{ - "another format": {`{"id":"f","type":"file","path":"/etc/x","into":"toml","content":"a = 1"}`, `into "toml"`}, - "not an object": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"[1,2]"}`, "JSON object"}, - "with create-once": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","create-once":true}`, "create-once"}, - } { - _, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`)) - if err == nil || !strings.Contains(err.Error(), c.refusal) { - t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) - } - } - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"f","type":"file","path":"/etc/x","into":"json","content":"{\"k\":1}"}, - {"id":"s","type":"service","unit":"docker.service","state":"running","reload-on":["f"]} - ]}`)) - if err != nil { - t.Fatal(err) - } - if f := d.Resources[0].(*File); f.Into != IntoJSON { - t.Errorf("into was read as %q", f.Into) - } - if s := d.Resources[1].(*Service); len(s.ReloadOn) != 1 || s.ReloadOn[0] != "f" { - t.Errorf("reload-on was read as %v", s.ReloadOn) - } -} diff --git a/internal/declaration/module_test.go b/internal/declaration/module_test.go deleted file mode 100644 index cdd4b02..0000000 --- a/internal/declaration/module_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package declaration - -import "testing" - -// 1132/AC1: a resource says its module, and the declaration says each module's build; a resource that does -// not say gets today's guess, so an older controller still works. -func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1, - "builds":{"novox.be":"b-9","web":"b-4"}, - "resources":[ - {"id":"novox.be.data","type":"directory","path":"/srv/a","module":"novox.be"}, - {"id":"web.conf","type":"file","path":"/etc/w","content":"x","module":"web"}, - {"id":"old.dir","type":"directory","path":"/srv/old"}, - {"id":"base","type":"directory","path":"/srv/base"}]}`)) - if err != nil { - t.Fatal(err) - } - for id, want := range map[string]string{"novox.be.data": "novox.be", "web.conf": "web", "old.dir": "old"} { - got, ok := d.ModuleOfID(id) - if !ok || got != want { - t.Errorf("%s belongs to %q (%v), want %q", id, got, ok, want) - } - } - if m, ok := d.ModuleOfID("base"); ok { - t.Errorf("a resource with no module and no dot belongs to %q, want none", m) - } - if d.Builds["web"] != "b-4" || d.Builds["novox.be"] != "b-9" || len(d.Builds) != 2 { - t.Errorf("builds read as %v", d.Builds) - } -} - -// 1132/AC1: the module the declaration gives wins over the guess, even when the id would guess another. -func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"a.b.c","type":"directory","path":"/srv/a","module":"a.b"}, - {"id":"x.y","type":"directory","path":"/srv/x","module":"other"}]}`)) - if err != nil { - t.Fatal(err) - } - if m, _ := d.ModuleOfID("x.y"); m != "other" { - t.Errorf("x.y belongs to %q, want the declared %q", m, "other") - } -} - -// 1132/AC1: every kind accepts the field. -func TestEveryKindAcceptsAModule(t *testing.T) { - for _, typ := range []Type{TypeDirectory, TypeFile, TypeUser, TypeNetwork, TypeAccess, TypeArchive, TypeProcess, - TypeService, TypeOpening, TypePackage, TypeContainer, TypeAction} { - r := newOf(typ) - if r == nil { - t.Fatalf("no kind %s", typ) - } - if _, ok := r.(interface{ DeclaredModule() string }); !ok { - t.Errorf("%s does not say its module", typ) - } - if unknown := unknownFields([]byte(`{"id":"m.x","type":"`+string(typ)+`","module":"m"}`), r); len(unknown) != 0 { - t.Errorf("%s refuses %v", typ, unknown) - } - } -} diff --git a/internal/declaration/order_test.go b/internal/declaration/order_test.go deleted file mode 100644 index 4d2c93d..0000000 --- a/internal/declaration/order_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// A declaration says where it stands — the controller's lease epoch and its sequence — inside what is -// signed (novox/hq to-be 45 §6). Absent is no claim; below zero is no order the mesh assigns. - -func withOrder(order string) string { - return `{"declaration":1,` + order + `"resources":[{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"}]}` -} - -func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) { - d, err := Parse([]byte(withOrder(`"epoch":57,"sequence":3,`))) - if err != nil { - t.Fatalf("a declaration carrying its order was refused: %v", err) - } - if d.Epoch != 57 || d.Sequence != 3 { - t.Fatalf("read epoch %d, sequence %d", d.Epoch, d.Sequence) - } - // An older controller's: no order, and no claim. - d, err = Parse([]byte(withOrder(``))) - if err != nil || d.Epoch != 0 || d.Sequence != 0 { - t.Fatalf("a declaration with no order read as %+v, %v", d, err) - } -} - -func TestAnOrderBelowZeroIsRefused(t *testing.T) { - for _, order := range []string{`"epoch":-1,`, `"sequence":-4,`} { - refusal := refusalFor(t, withOrder(order)) - if !strings.Contains(refusal.Error(), "below zero") { - t.Errorf("%s: refused for something else: %v", order, refusal) - } - } -} - -// **The assignment generation it was composed from, and whether it is a put-back** (novox/hq issue 234): -// absent is no claim; below zero is none the mesh assigns; a carried bundle puts nothing back. -func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) { - d, err := Parse([]byte(withOrder(`"sequence":3,"generation":41,"put_back":true,`))) - if err != nil { - t.Fatalf("a declaration carrying its generation was refused: %v", err) - } - if d.Generation != 41 || !d.PutBack { - t.Fatalf("read generation %d, put-back %v", d.Generation, d.PutBack) - } - if d, err = Parse([]byte(withOrder(``))); err != nil || d.Generation != 0 || d.PutBack { - t.Fatalf("a declaration with no generation read as %+v, %v", d, err) - } - if refusal := refusalFor(t, withOrder(`"generation":-2,`)); !strings.Contains(refusal.Error(), "below zero") { - t.Errorf("a generation below zero was refused for something else: %v", refusal) - } - if _, err := ParseTrusted([]byte(withOrder(`"generation":3,"put_back":true,`))); err == nil || - !strings.Contains(err.Error(), "put-back") { - t.Errorf("a carried bundle said it was a put-back and was not refused for it: %v", err) - } -} diff --git a/internal/declaration/ownership_test.go b/internal/declaration/ownership_test.go deleted file mode 100644 index 76b0674..0000000 --- a/internal/declaration/ownership_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// Defends novox/hq ADR 0349 §5 (issue 667): a file the provisioner places says who owns it at runtime -// — owned, seed or managed — and the declaration names the files the provisioner fills. The words are -// read and judged here before any controller sends them. - -func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"o","type":"file","path":"/etc/o","content":"a","owner-at-runtime":"owned"}, - {"id":"s","type":"file","path":"/etc/s","content":"a","owner-at-runtime":"seed"}, - {"id":"s2","type":"file","path":"/etc/s2","content":"a","owner-at-runtime":"seed","create-once":true}, - {"id":"mj","type":"file","path":"/etc/mj","into":"json","content":"{\"k\":1}","owner-at-runtime":"managed"}, - {"id":"mb","type":"file","path":"/etc/mb","into":"block","content":"a\n","owner-at-runtime":"managed"} - ]}`)) - if err != nil { - t.Fatal(err) - } - want := map[string]string{"o": RuntimeOwned, "s": RuntimeSeed, "s2": RuntimeSeed, "mj": RuntimeManaged, - "mb": RuntimeManaged} - for _, r := range d.Resources { - f := r.(*File) - if f.OwnerAtRuntime != want[f.ID] || f.Ownership() != want[f.ID] { - t.Errorf("%s: read as %q, ownership %q; want %q", f.ID, f.OwnerAtRuntime, f.Ownership(), want[f.ID]) - } - } -} - -// A file that does not say is owned by its old form, so the word means exactly what that form does. -func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"o","type":"file","path":"/etc/o","content":"a"}, - {"id":"s","type":"file","path":"/etc/s","content":"a","create-once":true}, - {"id":"mj","type":"file","path":"/etc/mj","into":"json","content":"{}"}, - {"id":"mb","type":"file","path":"/etc/mb","into":"block","content":"a\n"} - ]}`)) - if err != nil { - t.Fatal(err) - } - want := map[string]string{"o": RuntimeOwned, "s": RuntimeSeed, "mj": RuntimeManaged, "mb": RuntimeManaged} - for _, r := range d.Resources { - f := r.(*File) - if f.OwnerAtRuntime != "" || f.Ownership() != want[f.ID] { - t.Errorf("%s: said %q, ownership %q; want unsaid and %q", f.ID, f.OwnerAtRuntime, f.Ownership(), want[f.ID]) - } - if f.Seeded() != (want[f.ID] == RuntimeSeed) { - t.Errorf("%s: seeded %v", f.ID, f.Seeded()) - } - } -} - -func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) { - for name, c := range map[string]struct{ resource, refusal string }{ - "another word": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"mine"}`, - `owner-at-runtime "mine"`}, - "owned and create-once": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"owned","create-once":true}`, - "create-once"}, - "owned and into": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","owner-at-runtime":"owned"}`, - "into"}, - "seed and into": {`{"id":"f","type":"file","path":"/etc/x","into":"block","content":"a","owner-at-runtime":"seed"}`, - "into"}, - "managed and whole": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"managed"}`, - "into"}, - "managed and create-once": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","owner-at-runtime":"managed","create-once":true}`, - "create-once"}, - // The rule that was there before stays: a file written into is never sealed or created once. - "managed and sealed": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","sealed":"x","owner-at-runtime":"managed"}`, - "sealed"}, - } { - _, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`)) - if err == nil || !strings.Contains(err.Error(), c.refusal) { - t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) - } - } -} - -func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"filled_files":["/etc/a","/etc/b"],"resources":[ - {"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}, - {"id":"b","type":"file","path":"/etc/b","into":"block","content":"b\n","owner-at-runtime":"managed"}, - {"id":"c","type":"file","path":"/etc/c","content":"c"} - ]}`)) - if err != nil { - t.Fatal(err) - } - if len(d.Filled) != 2 || d.Filled[0] != "/etc/a" || d.Filled[1] != "/etc/b" { - t.Fatalf("filled files read as %v", d.Filled) - } - if !d.Fills("/etc/a") || !d.Fills("/etc/b") || d.Fills("/etc/c") { - t.Errorf("Fills: a %v, b %v, c %v", d.Fills("/etc/a"), d.Fills("/etc/b"), d.Fills("/etc/c")) - } - - // A declaration that names none fills none, as every declaration before this did. - d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"c","type":"file","path":"/etc/c","content":"c"}]}`)) - if err != nil { - t.Fatal(err) - } - if d.Filled != nil || d.Fills("/etc/c") { - t.Errorf("a declaration naming no filled files fills %v", d.Filled) - } -} - -func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) { - for name, c := range map[string]struct{ filled, resources, refusal string }{ - "not declared": {`["/etc/none"]`, - `{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`, - `"/etc/none"`}, - "a directory, not a file": {`["/etc/d"]`, - `{"id":"d","type":"directory","path":"/etc/d"}`, - `"/etc/d"`}, - "says no owner": {`["/etc/a"]`, - `{"id":"a","type":"file","path":"/etc/a","content":"a"}`, - "owner-at-runtime"}, - "named twice": {`["/etc/a","/etc/a"]`, - `{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`, - "twice"}, - "no path": {`[""]`, - `{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`, - "no path"}, - } { - _, err := Parse([]byte(`{"declaration":1,"filled_files":` + c.filled + `,"resources":[` + c.resources + `]}`)) - if err == nil || !strings.Contains(err.Error(), c.refusal) { - t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) - } - } -} diff --git a/internal/declaration/process_test.go b/internal/declaration/process_test.go deleted file mode 100644 index 9a7c7e9..0000000 --- a/internal/declaration/process_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -func aProcess() *Process { - return &Process{ - ID: "server", Type: TypeProcess, Name: "greeter", - Source: "https://store.invalid/greeter/daemon", - Digest: "sha256:" + strings.Repeat("a", 64), - Run: []string{"node", "index.js"}, - } -} - -// A process is part of the vocabulary, or a declaration carrying one is refused whole. -func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) { - var found bool - for _, kind := range Vocabulary() { - if kind == TypeProcess { - found = true - } - } - if !found { - t.Fatal("a process cannot be declared, so a module that declares one is refused") - } - if newOf(TypeProcess) == nil { - t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind") - } -} - -// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a -// reference somebody can repoint is not pinned, and it is the one thing on a machine that would -// then be running code nobody reviewed. -func TestAProcesssBundleMustBePinned(t *testing.T) { - for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { - d := aProcess() - d.Digest = bad - if problems := d.validate("a process", false); len(problems) == 0 { - t.Fatalf("a process pinned by %q was accepted", bad) - } - } -} - -// What to run is named, never inferred. Guessing an entrypoint from which files are present makes -// a process change what it runs when somebody adds a file. -func TestAProcessMustSayWhatToRun(t *testing.T) { - d := aProcess() - d.Run = nil - if problems := d.validate("a process", false); len(problems) == 0 { - t.Fatal("a process with no command was accepted") - } -} - -// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. -func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) { - // "." and ".." are one path element each, and the mesh's own bundle directory and its parent: - // removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR - // 0118). A leading dash is an option to the service manager, not a unit. - for _, bad := range []string{"", "../escape", "two words", "a/b", ".", "..", "-", "--now"} { - d := aProcess() - d.Name = bad - if problems := d.validate("a process", false); len(problems) == 0 { - t.Fatalf("a process called %q was accepted", bad) - } - } -} - -// And a well-formed one is accepted, or the tests above prove only that everything is refused. -func TestAWellFormedDaemonIsAccepted(t *testing.T) { - if problems := aProcess().validate("a process", false); len(problems) != 0 { - t.Fatalf("a well-formed daemon was refused: %v", problems) - } -} - -// **The modes are exclusive, and saying so is the point of having one kind.** Something that runs -// once does not run on a schedule; something not running between fires cannot be restarted when a -// file changes. A container's modes carry the same rule, and this is the same rule because it is -// the same thing hosted differently. -func TestTheModesAreExclusive(t *testing.T) { - both := aProcess() - both.RunOnce = true - both.Schedule = "0 3 * * *" - if problems := both.validate("a process", false); len(problems) == 0 { - t.Fatal("a process that runs once and on a schedule was accepted") - } - - watching := aProcess() - watching.Schedule = "0 3 * * *" - watching.RestartOn = []string{"some-file"} - if problems := watching.validate("a process", false); len(problems) == 0 { - t.Fatal("a scheduled process was given something to restart on, and it is never running") - } -} - -// A cadence that is not a cadence is refused near its author, rather than by a machine at the far -// end of a declaration. -func TestAScheduleMustBeACadence(t *testing.T) { - for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} { - p := aProcess() - p.Schedule = bad - if problems := p.validate("a process", false); len(problems) == 0 { - t.Fatalf("a process scheduled %q was accepted", bad) - } - } -} - -// And each mode on its own is accepted, or the tests above prove only that everything is refused. -func TestEachModeOnItsOwnIsAccepted(t *testing.T) { - once := aProcess() - once.RunOnce = true - if problems := once.validate("a process", false); len(problems) != 0 { - t.Fatalf("a step was refused: %v", problems) - } - every := aProcess() - every.Schedule = "0 3 * * *" - if problems := every.validate("a process", false); len(problems) != 0 { - t.Fatalf("a scheduled process was refused: %v", problems) - } -} - -// **The one that cannot be escaped, only refused.** -// -// Everything else a unit file reinterprets can be escaped: a percent specifier doubled, whitespace -// quoted, a quote backslashed. A newline cannot — it ends the line, and what follows is read as a -// unit DIRECTIVE. A value carrying one could write ExecStart= and have the machine run something -// nobody declared. -// -// So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of -// a declaration. -func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) { - for _, bad := range []string{ - "safe\nExecStart=/usr/bin/whatever", - "carriage\rreturn", - } { - p := aProcess() - p.Env = map[string]string{"X": bad} - problems := p.validate("a process", false) - if len(problems) == 0 { - t.Fatalf("a value containing %q was accepted", bad) - } - var said bool - for _, problem := range problems { - if strings.Contains(problem, "line break") { - said = true - } - } - if !said { - t.Fatalf("refused for some other reason, which would stop being true: %v", problems) - } - } -} - -// And ordinary awkward values are accepted, because escaping is what handles those — refusing them -// too would make a module unable to hold a generated password. -func TestOrdinaryAwkwardValuesAreAccepted(t *testing.T) { - p := aProcess() - p.Env = map[string]string{"PASSWORD": `a%H b"c\d`} - if problems := p.validate("a process", false); len(problems) != 0 { - t.Fatalf("a password containing the characters passwords contain was refused: %v", problems) - } -} diff --git a/internal/declaration/provisioner_test.go b/internal/declaration/provisioner_test.go deleted file mode 100644 index 233aa40..0000000 --- a/internal/declaration/provisioner_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// Defends novox/hq ADR 0349 §4 (issue 668): the declaration names the account the provisioner's client runs as, -// the only one whose write of a filled file the node-engine accepts. Read here before any controller sends it. - -func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":["/etc/a"], - "resources":[{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}]}`)) - if err != nil { - t.Fatal(err) - } - if d.Provisioner != "mesh-node-provisioner" { - t.Errorf("the provisioner's account read as %q", d.Provisioner) - } - d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`)) - if err != nil { - t.Fatal(err) - } - if d.Provisioner != "" { - t.Errorf("a declaration naming no account names %q", d.Provisioner) - } -} - -func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) { - for name, account := range map[string]string{"root": "root", "not a name": "a b", "a path": "../x"} { - _, err := Parse([]byte(`{"declaration":1,"provisioner_account":"` + account + `", - "resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`)) - if err == nil || !strings.Contains(err.Error(), "provisioner_account") { - t.Errorf("%s: want a refusal naming provisioner_account, got %v", name, err) - } - } -} diff --git a/internal/declaration/replaces_test.go b/internal/declaration/replaces_test.go deleted file mode 100644 index c470505..0000000 --- a/internal/declaration/replaces_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// novox/hq issue 213: a process says what it takes the place of, so the host can keep the old one -// answering until the new one does. What it may name is narrow, and each refusal is said here. - -const aReplacingProcess = `{"id":"m.controller","type":"process","name":"m","source":"https://store.invalid/m", - "digest":"sha256:` + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + `","run":["./m","serve"]%s}` - -func replacing(extra string) string { - return `{"declaration":1,"resources":[` + strings.Replace(aReplacingProcess, "%s", extra, 1) + `]}` -} - -func TestAProcessMayNameWhatItReplaces(t *testing.T) { - d, err := Parse([]byte(replacing(`,"replaces":["m.server"]`))) - if err != nil { - t.Fatalf("a process replacing an undeclared container was refused: %v", err) - } - if p := d.Resources[0].(*Process); len(p.Replaces) != 1 || p.Replaces[0] != "m.server" { - t.Fatalf("replaces was not read: %+v", p) - } -} - -// What is replaced is what the declaration no longer says. A resource still declared is applied, -// and handing it over as well would remove something the same declaration asks to keep. -func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) { - raw := `{"declaration":1,"resources":[ - {"id":"m.server","type":"directory","path":"/tmp/x"}, - ` + strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + `]}` - if _, err := Parse([]byte(raw)); err == nil || !strings.Contains(err.Error(), "still declares") { - t.Fatalf("a process replacing a declared resource was accepted: %v", err) - } -} - -func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) { - for _, mode := range []string{`,"run-once":true`, `,"schedule":"0 3 * * *"`} { - if _, err := Parse([]byte(replacing(mode + `,"replaces":["m.server"]`))); err == nil { - t.Errorf("a process with %s was allowed to replace something", mode) - } - } -} - -func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) { - for _, bad := range []string{`,"replaces":["m.controller"]`, `,"replaces":[""]`} { - if _, err := Parse([]byte(replacing(bad))); err == nil { - t.Errorf("replaces %s was accepted", bad) - } - } -} - -func TestOneThingHasOneReplacement(t *testing.T) { - second := strings.NewReplacer(`"id":"m.controller"`, `"id":"m.other"`, `"name":"m"`, `"name":"other"`). - Replace(strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1)) - raw := `{"declaration":1,"resources":[` + - strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + "," + second + `]}` - if _, err := Parse([]byte(raw)); err == nil { - t.Fatal("two processes replacing one resource were accepted") - } -} diff --git a/internal/declaration/root_test.go b/internal/declaration/root_test.go deleted file mode 100644 index cd2908c..0000000 --- a/internal/declaration/root_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// A user's root (novox/hq ADR 0266): "never" or absent, and nothing else. -func TestAUsersRootIsNeverOrAbsent(t *testing.T) { - for _, c := range []struct { - root string - ok bool - }{{`,"root":"never"`, true}, {``, true}, {`,"root":"always"`, false}, {`,"root":"no"`, false}} { - d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"claude-code.agent","type":"user","name":"agent"` + - c.root + `}]}`)) - if c.ok != (err == nil) { - t.Errorf("%s: err %v", c.root, err) - } - if err != nil && !strings.Contains(err.Error(), `"never"`) { - t.Errorf("%s: the refusal does not name the allowed value: %v", c.root, err) - } - if c.ok && c.root != "" && d.Resources[0].(*User).Root != RootNever { - t.Errorf("%s: read as %+v", c.root, d.Resources[0]) - } - } -} diff --git a/internal/declaration/schedule_declaration_test.go b/internal/declaration/schedule_declaration_test.go deleted file mode 100644 index d87d1fb..0000000 --- a/internal/declaration/schedule_declaration_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// The host refuses a schedule it does not fully understand, on arrival, for the same reason the -// control plane refuses it near its author (novox/hq ADR 0053): a declaration half-understood is -// refused whole rather than half-applied. - -func schedulePinned() string { return "registry.example/runtime@sha256:" + strings.Repeat("a", 64) } - -func TestAScheduledContainerCarriesTheCronField(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"0 3 * * *"} - ]}`)) - if err != nil { - t.Fatalf("a valid scheduled container was refused: %v", err) - } - c, ok := d.Resources[0].(*Container) - if !ok { - t.Fatalf("the scheduled resource is not a container: %T", d.Resources[0]) - } - if c.Schedule != "0 3 * * *" { - t.Errorf("the schedule did not survive parsing: %q", c.Schedule) - } -} - -func TestAMalformedScheduleIsRefused(t *testing.T) { - _, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"every night"} - ]}`)) - if err == nil { - t.Fatal("a container with a malformed schedule was accepted") - } - if !strings.Contains(err.Error(), "cron") && !strings.Contains(err.Error(), "field") { - t.Errorf("refused for the wrong reason: %v", err) - } -} - -func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { - // A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053). - _, err := Parse([]byte(`{"declaration":1,"resources":[ - {"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","run-once":true,"schedule":"0 3 * * *"} - ]}`)) - if err == nil { - t.Fatal("a container that was both run-once and scheduled was accepted") - } - if !strings.Contains(err.Error(), "run-once") && !strings.Contains(err.Error(), "scheduled") { - t.Errorf("refused for the wrong reason: %v", err) - } -} diff --git a/internal/declaration/stateless_test.go b/internal/declaration/stateless_test.go deleted file mode 100644 index 3338908..0000000 --- a/internal/declaration/stateless_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// Defends novox/hq ADR 0117: a service may leave its unit's lifecycle to the machine, and then -// says nothing but its triggers. -func TestAServiceWithoutAStateSaysOnlyItsTriggers(t *testing.T) { - for name, c := range map[string]struct{ resource, refusal string }{ - "no trigger": {`{"id":"s","type":"service","unit":"NetworkManager.service"}`, "declares nothing"}, - "with boot": {`{"id":"s","type":"service","unit":"NetworkManager.service","boot":"enabled","reload-on":["f"]}`, "boot and takes-over"}, - "with takes-over": {`{"id":"s","type":"service","unit":"a.service","reload-on":["f"],"takes-over":{"interface":"wg0","unit":"b.service","config":"/etc/x"}}`, "boot and takes-over"}, - "an unknown state": {`{"id":"s","type":"service","unit":"a.service","state":"paused"}`, "omits state to leave the unit's lifecycle to the machine"}, - } { - _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},` + c.resource + `]}`)) - if err == nil || !strings.Contains(err.Error(), c.refusal) { - t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) - } - } - d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"}, - {"id":"s","type":"service","unit":"NetworkManager.service","restart-on":["f"]}]}`)) - if err != nil { - t.Fatal(err) - } - if s := d.Resources[1].(*Service); !s.Stateless() { - t.Error("a service without a state was not read as stateless") - } -} diff --git a/internal/declaration/trusted_test.go b/internal/declaration/trusted_test.go deleted file mode 100644 index c533334..0000000 --- a/internal/declaration/trusted_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package declaration - -import "testing" - -// `trusted` is the catalogue's word on a file (novox/hq issue 339): a controller takes it out before it sends a -// declaration, and one that does not — an older one, or one rolled back to — must not cost the node its whole -// declaration. So a file accepts it and the engine does nothing with it; any other resource still refuses it. -func TestAFileAcceptsAndIgnoresTrusted(t *testing.T) { - for _, v := range []string{"true", "false"} { - d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.f","type":"file","path":"/etc/m.conf",` + - `"content":"x","trusted":` + v + `}]}`)) - if err != nil { - t.Fatalf("trusted %s: %v", v, err) - } - if f, ok := d.Resources[0].(*File); !ok || f.Content != "x" { - t.Fatalf("trusted %s: %+v", v, d.Resources[0]) - } - } - if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.d","type":"directory","path":"/srv/m",` + - `"trusted":true}]}`)); err == nil { - t.Fatal("a directory took trusted") - } -} diff --git a/internal/declaration/userscope_test.go b/internal/declaration/userscope_test.go deleted file mode 100644 index fdce635..0000000 --- a/internal/declaration/userscope_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -// novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names -// the account, a system one may not, and any other word is refused. -func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) { - cases := []struct{ scope, user, wants string }{ - {"user", "ops", ""}, - {"", "", ""}, - {"system", "", ""}, - {"user", "", "names the account"}, - {"", "ops", "names no user"}, - {"session", "ops", "scope \"session\""}, - } - for _, c := range cases { - s := &Service{ID: "m.u", Type: TypeService, Unit: "u.service", State: "running", Scope: c.scope, User: c.user} - problems := s.validate("m.u", false) - got := strings.Join(problems, "; ") - if c.wants == "" && len(problems) != 0 { - t.Fatalf("scope %q user %q refused: %s", c.scope, c.user, got) - } - if c.wants != "" && !strings.Contains(got, c.wants) { - t.Fatalf("scope %q user %q: wanted a refusal saying %q, got %q", c.scope, c.user, c.wants, got) - } - } -} - -// novox/hq ADR 0177: lingering is a field of the account, absent meaning nothing asserted. -func TestAnAccountMayBeDeclaredToLinger(t *testing.T) { - d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops","linger":true}]}`)) - if err != nil { - t.Fatal(err) - } - u, ok := d.Resources[0].(*User) - if !ok || u.Linger == nil || !*u.Linger { - t.Fatalf("linger not read: %+v", d.Resources[0]) - } - d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops"}]}`)) - if err != nil { - t.Fatal(err) - } - if u := d.Resources[0].(*User); u.Linger != nil { - t.Fatal("an account that said nothing about lingering asserts it") - } -} diff --git a/internal/filling/filling.go b/internal/filling/filling.go index fc26c5e..02cd904 100644 --- a/internal/filling/filling.go +++ b/internal/filling/filling.go @@ -33,7 +33,7 @@ import ( "sync/atomic" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/meshcli" ) diff --git a/internal/filling/filling_test.go b/internal/filling/filling_test.go index 0e7cff3..6761e52 100644 --- a/internal/filling/filling_test.go +++ b/internal/filling/filling_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Defends novox/hq ADR 0349 §4 (issue 668): the node-engine writes a filled file on request only for the provisioner diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index e178b3c..30cac1f 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -18,7 +18,7 @@ import ( "strconv" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/system" ) diff --git a/internal/firewall/firewall_test.go b/internal/firewall/firewall_test.go index acee5d2..a64cfd6 100644 --- a/internal/firewall/firewall_test.go +++ b/internal/firewall/firewall_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Defends novox/hq ADR 0100: the firewall found on an adopted node stays in force, the mesh opens diff --git a/internal/liveness/liveness.go b/internal/liveness/liveness.go index 51cb868..2ae6c9e 100644 --- a/internal/liveness/liveness.go +++ b/internal/liveness/liveness.go @@ -39,8 +39,8 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/health" - "github.com/novox/mesh-host/internal/declaration" ) // The bounds of ADR 0240 rule 1 and to-be 48 §1. Grace is the default a declaration will override in diff --git a/internal/liveness/liveness_test.go b/internal/liveness/liveness_test.go index aa39729..589dd16 100644 --- a/internal/liveness/liveness_test.go +++ b/internal/liveness/liveness_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // The judge, over a fake runtime and service manager (novox/hq ADR 0240, "how it is checked", rule 1): diff --git a/internal/liveness/readiness.go b/internal/liveness/readiness.go index d8f62b6..a259720 100644 --- a/internal/liveness/readiness.go +++ b/internal/liveness/readiness.go @@ -14,8 +14,8 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/health" - "github.com/novox/mesh-host/internal/declaration" ) // Readiness, declared (novox/hq ADR 0240 rules 2 and 3, to-be 48 §2–§3, Phase B). diff --git a/internal/liveness/readiness_test.go b/internal/liveness/readiness_test.go index dc0ca3c..d36361b 100644 --- a/internal/liveness/readiness_test.go +++ b/internal/liveness/readiness_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/health" - "github.com/novox/mesh-host/internal/declaration" ) // Readiness, declared (novox/hq ADR 0240 rules 2 and 3, Phase B): starting until its check passed, healthy diff --git a/internal/liveness/replay_test.go b/internal/liveness/replay_test.go index a5ab295..77c1eca 100644 --- a/internal/liveness/replay_test.go +++ b/internal/liveness/replay_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/link" ) diff --git a/internal/liveness/waiting_test.go b/internal/liveness/waiting_test.go index df367ba..29a5378 100644 --- a/internal/liveness/waiting_test.go +++ b/internal/liveness/waiting_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/health" - "github.com/novox/mesh-host/internal/declaration" ) // A module's tool check may say it waits for the operator (novox/hq ADR 0283, issue 386): a named secret or diff --git a/internal/system/alpine.go b/internal/system/alpine.go index bbdc833..c9e2b11 100644 --- a/internal/system/alpine.go +++ b/internal/system/alpine.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // alpine is apk and OpenRC. diff --git a/internal/system/android.go b/internal/system/android.go index a26c3fd..1c3bb3d 100644 --- a/internal/system/android.go +++ b/internal/system/android.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // android is a partial host, and being partial is the point. diff --git a/internal/system/arch.go b/internal/system/arch.go index 6ab34ea..34bf0aa 100644 --- a/internal/system/arch.go +++ b/internal/system/arch.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // arch is pacman and systemd. diff --git a/internal/system/shapes_test.go b/internal/system/shapes_test.go index 2305929..7be473b 100644 --- a/internal/system/shapes_test.go +++ b/internal/system/shapes_test.go @@ -3,7 +3,7 @@ package system import ( "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Every shape in the vocabulary is implemented by a host that claims to do everything. diff --git a/internal/system/system.go b/internal/system/system.go index e361dff..6a10c72 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -26,7 +26,7 @@ import ( "strconv" "strings" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // Runner executes a command. The real one runs a process; tests pass one that records what was diff --git a/internal/system/system_test.go b/internal/system/system_test.go index 41702ee..3d8ccda 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // recorder answers a fixed map of commands and remembers what it was asked. diff --git a/internal/units/units.go b/internal/units/units.go index 3abb2be..8d3c00c 100644 --- a/internal/units/units.go +++ b/internal/units/units.go @@ -39,7 +39,7 @@ import ( "sync" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // The managers a unit is in. diff --git a/internal/units/units_test.go b/internal/units/units_test.go index e5b6adb..fa308d0 100644 --- a/internal/units/units_test.go +++ b/internal/units/units_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/novox/mesh-host/internal/declaration" + "git.novox.be/novox/mesh-sdk/go/declaration" ) // The reading of failed units (novox/hq issue 315, "how it is checked"): a failed unit a module states, diff --git a/validate/validate.go b/validate/validate.go deleted file mode 100644 index aa14089..0000000 --- a/validate/validate.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package validate is the node-engine's own judgement of a declaration, for whoever must know before -// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate). -// -// **One validator.** The controller composed declarations the host then refused whole — a manifest -// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's -// names (issue 263) — because the only validator was the one the host runs as it applies. A second, -// written in the controller from the host's rules, would drift from them the first time either -// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and -// the merge gate run is what every machine runs. -// -// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them -// from the link (novox/hq ADR 0005). Nothing here touches a machine. -package validate - -import ( - "errors" - - "github.com/novox/mesh-host/internal/declaration" -) - -// Declaration is every problem the node-engine would refuse a declaration for, each in its own words; -// nil when it would take it. The body is the declaration as the controller composes it — the bytes a -// signature is made over — not the signed envelope. -func Declaration(raw []byte) []string { - _, err := declaration.Parse(raw) - if err == nil { - return nil - } - var refused *declaration.RefusalError - if errors.As(err, &refused) { - return refused.Problems - } - return []string{err.Error()} -} - -// Version is the declaration vocabulary this validator speaks: a declaration of another version is -// refused whole by Declaration, as by the host. -const Version = declaration.Version diff --git a/validate/validate_test.go b/validate/validate_test.go deleted file mode 100644 index 51ade73..0000000 --- a/validate/validate_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package validate - -import ( - "strings" - "testing" -) - -// **The validator a controller imports is the host's**: a declaration the host takes passes, and one -// it refuses is refused with the host's own words, every problem at once. -func TestTheValidatorIsTheHosts(t *testing.T) { - good := `{"declaration": 1, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - if problems := Declaration([]byte(good)); problems != nil { - t.Fatalf("a declaration the host takes was refused: %v", problems) - } - // And one carrying the lease epoch and sequence the controller sends it under (novox/hq to-be 45 - // §6): a controller validating with this package may send them. - ordered := `{"declaration": 1, "epoch": 57, "sequence": 3, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - if problems := Declaration([]byte(ordered)); problems != nil { - t.Fatalf("a declaration carrying its order was refused: %v", problems) - } - // And one carrying a file's owner-at-runtime and the files the provisioner fills (novox/hq ADR 0349 §5): the - // reader takes them before any controller sends them. - owned := `{"declaration": 1, "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` - if problems := Declaration([]byte(owned)); problems != nil { - t.Fatalf("a declaration saying who owns a file at runtime was refused: %v", problems) - } - // And one naming the provisioner client's account (novox/hq ADR 0349 §4). - named := `{"declaration": 1, "provisioner_account": "mesh-node-provisioner", "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` - if problems := Declaration([]byte(named)); problems != nil { - t.Fatalf("a declaration naming the provisioner client's account was refused: %v", problems) - } - for _, bad := range []string{ - `{"declaration": 1, "provisioner_account": "root", "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a"}]}`, - `{"declaration": 1, "filled_files": ["/etc/none"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`, - `{"declaration": 1, "resources": []}`, - `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`, - `{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`, - `not json`, - } { - problems := Declaration([]byte(bad)) - if len(problems) == 0 { - t.Errorf("a declaration the host refuses was passed: %s", bad) - } - for _, p := range problems { - if strings.TrimSpace(p) == "" { - t.Errorf("a problem said nothing, for %s", bad) - } - } - } -}