Self-upgrade installer: genesis pivot, rename, adopt store+broker #13
@@ -270,6 +270,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
|
|||||||
return applyUser(ctx, sys, res, run)
|
return applyUser(ctx, sys, res, run)
|
||||||
case *declaration.Archive:
|
case *declaration.Archive:
|
||||||
return applyArchive(ctx, res, previous)
|
return applyArchive(ctx, res, previous)
|
||||||
|
case *declaration.Daemon:
|
||||||
|
return applyDaemon(ctx, res, run, changed, previous)
|
||||||
case *declaration.Action:
|
case *declaration.Action:
|
||||||
return applyAction(ctx, res, run)
|
return applyAction(ctx, res, run)
|
||||||
case *declaration.Network:
|
case *declaration.Network:
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/declaration"
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Running the mesh's own code, without the module choosing how.
|
||||||
|
//
|
||||||
|
// **A daemon is an intent and this is one answer to it.** A module says what to run and the
|
||||||
|
// machine's own supervisor is how — which means the module is not writing a unit file, and not
|
||||||
|
// choosing between a container and a service before it can declare anything
|
||||||
|
// (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md).
|
||||||
|
//
|
||||||
|
// What this does, in order: fetch the bundle, refuse it unless it hashes to what was declared,
|
||||||
|
// unpack it where the mesh keeps such things, write the unit, and put it in the state asked for.
|
||||||
|
// The unit is the mesh's — an operator editing it loses the edit at the next declaration, which is
|
||||||
|
// the same rule every managed file on a machine follows (ADR 0011).
|
||||||
|
|
||||||
|
// daemonRoot is where unpacked daemons live.
|
||||||
|
//
|
||||||
|
// Under the mesh's own directory rather than somewhere a distribution owns: these are files the
|
||||||
|
// mesh puts there and replaces, and putting them where a package manager also writes is how two
|
||||||
|
// owners end up disagreeing about one path.
|
||||||
|
const daemonRoot = "/var/lib/mesh/daemons"
|
||||||
|
|
||||||
|
// unitDir is where the mesh writes the units it owns.
|
||||||
|
const unitDir = "/etc/systemd/system"
|
||||||
|
|
||||||
|
func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner,
|
||||||
|
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||||
|
out := begin(r)
|
||||||
|
out.Action = "unchanged"
|
||||||
|
|
||||||
|
body, err := fetch(ctx, r.Source)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
got := "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
if got != r.Digest {
|
||||||
|
// Refused before anything is written or started. What is at that address is not what was
|
||||||
|
// declared, and running it would be running something nobody reviewed.
|
||||||
|
return out, fmt.Errorf(
|
||||||
|
"%s was declared as %s and what arrived is %s; nothing was unpacked or started",
|
||||||
|
r.Source, r.Digest, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The identity of a daemon is its bytes AND how it is run.** Two daemons from one bundle
|
||||||
|
// differing only in their command are different daemons, and a record that tracked the digest
|
||||||
|
// alone would call the second one unchanged.
|
||||||
|
want := got + " " + unitFor(r)
|
||||||
|
at := filepath.Join(daemonRoot, r.Name)
|
||||||
|
|
||||||
|
// **Something it reads changed, so it must be restarted even though it is unchanged.** A
|
||||||
|
// running process does not re-read its configuration: replace the file, find the daemon
|
||||||
|
// already up, do nothing, and the machine keeps behaving the way it did before while every
|
||||||
|
// check passes. The same rule a service follows, for the same reason.
|
||||||
|
var because string
|
||||||
|
for _, id := range r.RestartOn {
|
||||||
|
if changed[id] {
|
||||||
|
because = id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if previous.Wrote == want && because == "" {
|
||||||
|
// Everything about it is as declared. Still asked whether it is RUNNING, because a
|
||||||
|
// declaration that is satisfied by a record rather than by the machine is how a stopped
|
||||||
|
// service reports success.
|
||||||
|
if active, err := run(ctx, "systemctl", "is-active", "--quiet", r.Name+".service"); err == nil {
|
||||||
|
_ = active
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil {
|
||||||
|
return out, fmt.Errorf("%s is installed and would not start: %w", r.Name, err)
|
||||||
|
}
|
||||||
|
out.Action = "updated"
|
||||||
|
out.Detail = "restarted a daemon that had stopped"
|
||||||
|
out.wrote = want
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
|
||||||
|
// previous version would be loaded by a runtime that walks a directory.
|
||||||
|
if err := os.RemoveAll(at); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(at, 0o755); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
written, err := unpack(body, at)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := ownAll(at, r.User); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := filepath.Join(unitDir, r.Name+".service")
|
||||||
|
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
// Enabled and restarted, in that order: enabled so it survives a reboot, restarted rather than
|
||||||
|
// started because this path is also how a new version arrives and the old one is still running.
|
||||||
|
if _, err := run(ctx, "systemctl", "enable", r.Name+".service"); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, "systemctl", "restart", r.Name+".service"); err != nil {
|
||||||
|
return out, fmt.Errorf("%s was installed and would not start: %w", r.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Action = "updated"
|
||||||
|
if previous.Wrote == "" {
|
||||||
|
out.Action = "created"
|
||||||
|
}
|
||||||
|
out.Detail = fmt.Sprintf("%d file(s), running as %s.service", written, r.Name)
|
||||||
|
if because != "" {
|
||||||
|
out.Detail += ", restarted because " + because + " changed"
|
||||||
|
}
|
||||||
|
out.wrote = want
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitFor is the unit the mesh writes for a daemon.
|
||||||
|
//
|
||||||
|
// **Generated whole and never edited in place**, the same rule as every other managed file: an
|
||||||
|
// edit survives until the next declaration and then vanishes, which is worse than not being
|
||||||
|
// allowed at all, so the file says so.
|
||||||
|
//
|
||||||
|
// Deterministic — environment sorted — because this string is half the daemon's identity, and a
|
||||||
|
// map iterated in Go's order would make every apply look like a change.
|
||||||
|
func unitFor(r *declaration.Daemon) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n")
|
||||||
|
b.WriteString("# declaration changes, and an edit would survive until then and vanish.\n")
|
||||||
|
b.WriteString("[Unit]\n")
|
||||||
|
fmt.Fprintf(&b, "Description=%s, a mesh daemon\n", r.Name)
|
||||||
|
b.WriteString("After=network-online.target\n")
|
||||||
|
b.WriteString("Wants=network-online.target\n\n")
|
||||||
|
|
||||||
|
b.WriteString("[Service]\n")
|
||||||
|
b.WriteString("Type=simple\n")
|
||||||
|
fmt.Fprintf(&b, "WorkingDirectory=%s\n", filepath.Join(daemonRoot, r.Name))
|
||||||
|
for _, file := range r.EnvFile {
|
||||||
|
fmt.Fprintf(&b, "EnvironmentFile=%s\n", file)
|
||||||
|
}
|
||||||
|
for _, key := range sortedKeys(r.Env) {
|
||||||
|
fmt.Fprintf(&b, "Environment=%s=%s\n", key, r.Env[key])
|
||||||
|
}
|
||||||
|
if r.User != "" {
|
||||||
|
fmt.Fprintf(&b, "User=%s\n", r.User)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " "))
|
||||||
|
// Restarted when it exits, because a daemon that stops is not a daemon. Delayed, so a process
|
||||||
|
// that fails at once does not spin the machine.
|
||||||
|
b.WriteString("Restart=always\nRestartSec=5\n\n")
|
||||||
|
|
||||||
|
b.WriteString("[Install]\nWantedBy=multi-user.target\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/declaration"
|
||||||
|
)
|
||||||
|
|
||||||
|
func aDaemon() *declaration.Daemon {
|
||||||
|
return &declaration.Daemon{
|
||||||
|
ID: "server", Type: declaration.TypeDaemon, Name: "greeter",
|
||||||
|
Source: "https://store.invalid/greeter/daemon",
|
||||||
|
Digest: "sha256:" + strings.Repeat("a", 64),
|
||||||
|
Run: []string{"node", "index.js"},
|
||||||
|
Env: map[string]string{"MESH_NODE": "anchor", "A_FIRST": "1"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unit the mesh writes says what it runs, where, and that it comes back.
|
||||||
|
func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) {
|
||||||
|
unit := unitFor(aDaemon())
|
||||||
|
for _, want := range []string{
|
||||||
|
"ExecStart=node index.js",
|
||||||
|
"WorkingDirectory=/var/lib/mesh/daemons/greeter",
|
||||||
|
"Restart=always",
|
||||||
|
"WantedBy=multi-user.target",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(unit, want) {
|
||||||
|
t.Fatalf("the unit does not say %q:\n%s", want, unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Generated whole and saying so.** Every managed file on a machine carries this, because an edit
|
||||||
|
// that survives until the next declaration and then vanishes is worse than one that is refused.
|
||||||
|
func TestTheUnitSaysItIsTheMeshs(t *testing.T) {
|
||||||
|
unit := unitFor(aDaemon())
|
||||||
|
if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") {
|
||||||
|
t.Fatalf("the unit does not say it is generated:\n%s", unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Deterministic, because the unit is half the daemon's identity.** Environment held in a map
|
||||||
|
// would be written in Go's iteration order, so every apply would see a different unit and call an
|
||||||
|
// unchanged daemon changed — restarting it on every declaration for ever.
|
||||||
|
func TestTheUnitIsTheSameEveryTime(t *testing.T) {
|
||||||
|
first := unitFor(aDaemon())
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if again := unitFor(aDaemon()); again != first {
|
||||||
|
t.Fatalf("two renderings of one daemon differ:\n%s\n---\n%s", first, again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And sorted, so the order is a decision rather than luck.
|
||||||
|
if strings.Index(first, "A_FIRST") > strings.Index(first, "MESH_NODE") {
|
||||||
|
t.Fatalf("environment is not in a stable order:\n%s", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Two daemons from one bundle differing only in their command are different daemons.** Tracking
|
||||||
|
// the digest alone would call the second one unchanged and leave the first one running.
|
||||||
|
func TestADaemonsIdentityIncludesHowItIsRun(t *testing.T) {
|
||||||
|
one := aDaemon()
|
||||||
|
two := aDaemon()
|
||||||
|
two.Run = []string{"node", "other.js"}
|
||||||
|
if unitFor(one) == unitFor(two) {
|
||||||
|
t.Fatal("two daemons with different commands render one unit, so a change would be missed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A daemon that runs as somebody says so, and one that does not says nothing — rather than naming
|
||||||
|
// root explicitly, which would be a claim the mesh does not need to make.
|
||||||
|
func TestADaemonRunsAsWhoItSaid(t *testing.T) {
|
||||||
|
as := aDaemon()
|
||||||
|
as.User = "greeter"
|
||||||
|
if !strings.Contains(unitFor(as), "User=greeter") {
|
||||||
|
t.Fatalf("the unit does not run as the user it named:\n%s", unitFor(as))
|
||||||
|
}
|
||||||
|
if strings.Contains(unitFor(aDaemon()), "User=") {
|
||||||
|
t.Fatalf("a daemon that named no user had one written for it:\n%s", unitFor(aDaemon()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package declaration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func aDaemon() *Daemon {
|
||||||
|
return &Daemon{
|
||||||
|
ID: "server", Type: TypeDaemon, Name: "greeter",
|
||||||
|
Source: "https://store.invalid/greeter/daemon",
|
||||||
|
Digest: "sha256:" + strings.Repeat("a", 64),
|
||||||
|
Run: []string{"node", "index.js"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A daemon is part of the vocabulary, or a declaration carrying one is refused whole.
|
||||||
|
func TestADaemonIsSomethingTheHostSpeaks(t *testing.T) {
|
||||||
|
var found bool
|
||||||
|
for _, kind := range Vocabulary() {
|
||||||
|
if kind == TypeDaemon {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("a daemon cannot be declared, so a module that declares one is refused")
|
||||||
|
}
|
||||||
|
if newOf(TypeDaemon) == 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 TestADaemonsBundleMustBePinned(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} {
|
||||||
|
d := aDaemon()
|
||||||
|
d.Digest = bad
|
||||||
|
if problems := d.validate("a daemon", false); len(problems) == 0 {
|
||||||
|
t.Fatalf("a daemon pinned by %q was accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What to run is named, never inferred. Guessing an entrypoint from which files are present makes
|
||||||
|
// a daemon change what it runs when somebody adds a file.
|
||||||
|
func TestADaemonMustSayWhatToRun(t *testing.T) {
|
||||||
|
d := aDaemon()
|
||||||
|
d.Run = nil
|
||||||
|
if problems := d.validate("a daemon", false); len(problems) == 0 {
|
||||||
|
t.Fatal("a daemon 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 TestADaemonsNameCannotEscapeItsUnit(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", "../escape", "two words", "a/b"} {
|
||||||
|
d := aDaemon()
|
||||||
|
d.Name = bad
|
||||||
|
if problems := d.validate("a daemon", false); len(problems) == 0 {
|
||||||
|
t.Fatalf("a daemon 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 := aDaemon().validate("a daemon", false); len(problems) != 0 {
|
||||||
|
t.Fatalf("a well-formed daemon was refused: %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,20 @@ const (
|
|||||||
// (04-ISSUES/026) — and leaves everything about it alone. Several modules declaring one
|
// (04-ISSUES/026) — and leaves everything about it alone. Several modules declaring one
|
||||||
// access is ordinary, because none of them owns it.
|
// access is ordinary, because none of them owns it.
|
||||||
TypeAccess Type = "access"
|
TypeAccess Type = "access"
|
||||||
|
|
||||||
|
// TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather
|
||||||
|
// than by how it is hosted.
|
||||||
|
//
|
||||||
|
// **The intent, not the mechanism.** Until this, an author decided the hosting before they
|
||||||
|
// could declare anything: code of their own meant a `container` built from an image, a script
|
||||||
|
// meant a `service` and a unit somebody else had to install. Same intent — run this and keep
|
||||||
|
// it running — expressed two unrelated ways, and the choice baked into which kind was picked.
|
||||||
|
//
|
||||||
|
// A daemon names an artifact and what to run. The mesh unpacks the artifact where it keeps
|
||||||
|
// such things, writes the unit, and puts it in the state asked for. One module may declare
|
||||||
|
// several, in different languages, because a module is one piece of software and not one
|
||||||
|
// process (novox/hq ADR 0040).
|
||||||
|
TypeDaemon Type = "daemon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resource is one thing that should be true of the machine.
|
// Resource is one thing that should be true of the machine.
|
||||||
@@ -393,6 +407,81 @@ func (a *Archive) validate(where string, _ bool) []string {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Daemon is a long-running process the mesh installs, keeps running, and owns the unit for.
|
||||||
|
//
|
||||||
|
// 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. A Daemon
|
||||||
|
// 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 a
|
||||||
|
// daemon says is what to run, and the machine's own process supervisor is how. A module whose code
|
||||||
|
// genuinely needs a container's isolation declares a container and says so.
|
||||||
|
type Daemon struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type Type `json:"type"`
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) Identity() string { return d.ID }
|
||||||
|
func (d *Daemon) Kind() Type { return TypeDaemon }
|
||||||
|
func (d *Daemon) Target() string { return d.Name }
|
||||||
|
|
||||||
|
func (d *Daemon) validate(where string, _ bool) []string {
|
||||||
|
var problems []string
|
||||||
|
if d.Name == "" {
|
||||||
|
problems = append(problems, where+": a daemon needs a name, which is what its unit is called")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(d.Name, "/ \t") {
|
||||||
|
// It becomes a unit name and a file on disk. A name with a separator in it would write
|
||||||
|
// somewhere nobody meant.
|
||||||
|
problems = append(problems, where+": a daemon's name becomes a unit name, so it cannot "+
|
||||||
|
"contain a path separator or a space")
|
||||||
|
}
|
||||||
|
if d.Source == "" {
|
||||||
|
problems = append(problems, where+": a daemon 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 daemon's bundle is pinned by digest, as sha256:<64 hex characters>")
|
||||||
|
}
|
||||||
|
if len(d.Run) == 0 {
|
||||||
|
problems = append(problems, where+": a daemon needs to say what to run")
|
||||||
|
}
|
||||||
|
for _, part := range d.Run {
|
||||||
|
if part == "" {
|
||||||
|
problems = append(problems, where+": a daemon's command has an empty element")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
// Service is a unit the host puts into a state. It does not install the unit.
|
// 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
|
// Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped
|
||||||
@@ -654,6 +743,8 @@ func newOf(t Type) Resource {
|
|||||||
return &Archive{}
|
return &Archive{}
|
||||||
case TypeAccess:
|
case TypeAccess:
|
||||||
return &Access{}
|
return &Access{}
|
||||||
|
case TypeDaemon:
|
||||||
|
return &Daemon{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -661,8 +752,8 @@ func newOf(t Type) Resource {
|
|||||||
// Vocabulary is every kind this host speaks.
|
// Vocabulary is every kind this host speaks.
|
||||||
func Vocabulary() []Type {
|
func Vocabulary() []Type {
|
||||||
return []Type{
|
return []Type{
|
||||||
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork,
|
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile,
|
||||||
TypePackage, TypeService, TypeUser,
|
TypeNetwork, TypePackage, TypeService, TypeUser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) {
|
func TestTheVocabularyIsTheElevenShapesTheMeshNeeds(t *testing.T) {
|
||||||
// Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a
|
// Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a
|
||||||
// failing test rather than a discovery during a first-node install.
|
// failing test rather than a discovery during a first-node install.
|
||||||
//
|
//
|
||||||
@@ -282,7 +282,7 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []Type{
|
for _, want := range []Type{
|
||||||
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
||||||
TypeUser, TypeArchive, TypeNetwork, TypeAccess,
|
TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeDaemon,
|
||||||
} {
|
} {
|
||||||
if !speaks[want] {
|
if !speaks[want] {
|
||||||
t.Errorf("the host no longer speaks %q", want)
|
t.Errorf("the host no longer speaks %q", want)
|
||||||
@@ -298,8 +298,19 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) {
|
|||||||
// `access` is the tenth, and novox/hq ADR 0051 is its decision: shared, pre-existing data is
|
// `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
|
// 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.
|
// tell apart from a directory precisely because it must NOT create, chown or remove it.
|
||||||
if len(speaks) != 10 {
|
//
|
||||||
t.Errorf("the vocabulary is %d shapes rather than 10; every addition widens what a compromised "+
|
// `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.
|
||||||
|
if len(speaks) != 11 {
|
||||||
|
t.Errorf("the vocabulary is %d shapes rather than 11; every addition widens what a compromised "+
|
||||||
"control plane can express, so a change here is a decision: %s",
|
"control plane can express, so a change here is a decision: %s",
|
||||||
len(speaks), vocabulary())
|
len(speaks), vocabulary())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ func everyShape() []declaration.Type {
|
|||||||
// bind mount, and a bind mount needs the container runtime a full host has. So it sits
|
// bind mount, and a bind mount needs the container runtime a full host has. So it sits
|
||||||
// here with the container it guards, not at the portable floor (novox/hq ADR 0051).
|
// here with the container it guards, not at the portable floor (novox/hq ADR 0051).
|
||||||
declaration.TypeAccess,
|
declaration.TypeAccess,
|
||||||
|
// A daemon needs a process supervisor to install a unit into, which is what separates a
|
||||||
|
// full host from the floor. It does NOT need a container runtime, which is the point of
|
||||||
|
// it: the mesh's own code runs as a process on the machine, and only software that
|
||||||
|
// genuinely needs isolation asks for a container.
|
||||||
|
declaration.TypeDaemon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user