summaryrefslogtreecommitdiff
path: root/svc/doc/design.md
diff options
context:
space:
mode:
Diffstat (limited to 'svc/doc/design.md')
-rw-r--r--svc/doc/design.md437
1 files changed, 437 insertions, 0 deletions
diff --git a/svc/doc/design.md b/svc/doc/design.md
new file mode 100644
index 0000000..343d23a
--- /dev/null
+++ b/svc/doc/design.md
@@ -0,0 +1,437 @@
+# init
+
+**init is the service manager. It provides svcfs if you want to interact with services.**
+
+Status: design. Nothing implemented yet.
+
+## Why
+
+A machine is a set of running services. That's all it is. 9front instead has
+three half-mechanisms that each know a piece of that and never talk to each
+other.
+
+`service=` in `plan9.ini` picks between `/rc/bin/termrc` and `/rc/bin/cpurc`.
+One word decides "what kind of machine is this."
+
+`/rc/bin/service/` is a directory of scripts named by port, where the enable bit
+is a `!` prefix on the filename:
+
+ /rc/bin/service/!tcp23 disabled
+ /rc/bin/service/tcp17019 enabled
+
+Everything else is a bare line in a shell script — `plumber`, `webfs`, `ndb/cs`,
+`ndb/dns`, `aux/timesync` — started once, owned by nobody, restarted never.
+
+So: nothing is supervised, nothing has state, nothing has logs, nothing has
+dependencies, and nothing can be managed remotely without a shell and an editor.
+
+A real example from this project. The serial control shell was one line in
+`termrc`, non-interactive:
+
+ rc </dev/eia0 >/dev/eia0 >[2=1] &
+
+One malformed command produced a syntax error, rc exited, and the machine was
+unreachable over serial for the rest of the session. The fix was to make it
+interactive and wrap it in a restart loop:
+
+ while(){ rc -i </dev/eia0 >/dev/eia0 >[2=1]; sleep 1 }&
+
+That line is a service manager for exactly one service. Every daemon on the box
+wants the same thing and none of them get it.
+
+## Roles are vocabulary
+
+"Terminal", "cpu server", "auth server", "file server" are not kernel concepts.
+The kernel does not know which one it is running. Each is a bundle of userspace
+programs:
+
+| Role | What it actually is |
+|---|---|
+| auth server | `auth/keyfs` + `auth/authsrv` on 567 |
+| cpu server | a listener on 17010, plus `exportfs` |
+| file server | `hjfs`/`cwfs`/`gefs` + a 9P listener on 564 |
+| terminal | a window system, if you want one |
+
+Nothing ever stopped one machine being all four. The words are the obstacle:
+they imply a machine *is* one thing, and that exactly one machine holds each
+role in a single administrative domain.
+
+We delete the words. Role is emergent and plural.
+
+## How it works
+
+`init` runs services. It:
+
+1. reads service definitions from `/lib/svc`
+2. starts the enabled ones in dependency order
+3. supervises them, restarting per policy
+4. serves `svcfs` — posted at `/srv/svc`, mounted at `/mnt/svcs`
+5. handles `reset` and `reexec`
+
+None of that depends on how it was started, and an instance can be run by
+anyone over any directory of services in its own namespace. Run as the program
+that brings a machine up, it does four more things: builds the base namespace,
+offers a rescue console, halts the root file server last, and carries out
+`halt` and `reboot`.
+
+**init is not pid 1 on 9front today.** Pid 1 is `rc` running `/bin/bootrc`,
+sitting in `Await`; init is an ordinary child — pid 143 on the machine this was
+checked against. Since the boot chain is being replaced anyway, whether init
+runs as pid 1 is a decision rather than an inherited fact, and nothing in this
+design requires it.
+
+`svcfs` is a namespace, not a program. There is no second daemon. It's what init
+looks like from outside, the same way `/proc` is what the kernel's process table
+looks like from outside.
+
+**The control plane is optional.** Services start at boot whether or not anything
+ever mounts `/mnt/svcs`. If posting the filesystem fails, init logs it and keeps
+supervising. A bug in the 9P layer must never be a boot failure.
+
+## A service
+
+`/lib/svc/dns`, in ndb format — the parser already exists and it's the same
+shape as factotum keys and `/lib/ndb/local`:
+
+ svc=dns
+ exec=/bin/ndb/dns
+ args=-r
+ needs=cs
+ ready=srv:dns
+ restart=always
+ enable=yes
+
+Ten attributes exist in total: `svc exec args needs ready restart ns user
+enable stop`. There are deliberately no tuning knobs — restart delays and
+readiness timeouts are constants in the code until something proves it needs to
+be configurable.
+
+An unknown attribute is an **error**, not a warning. ndb has no schema, so
+`exce=/bin/ndb/dns` parses fine as ndb and means nothing. Ignoring unknowns is
+how a typo silently does nothing forever.
+
+## Three shapes of service
+
+Not everything is a daemon that stays in the foreground, and the difference is
+declared rather than guessed:
+
+| Shape | Liveness is | On exit |
+|---|---|---|
+| foreground | the pid | restart per policy |
+| detaching | the `/srv` file | not a failure — don't restart |
+| oneshot | nothing, it's done | never restart |
+
+Detaching matters because the `postmountsrv` idiom forks a server proc and lets
+the original exit *on purpose*. A supervisor that assumes exit means death will
+restart-loop something that is running fine.
+
+Which is why **readiness and liveness are separate questions**. `ready=srv:dns`
+answers "is it up yet"; the same file answers "is it still up" for a service
+whose pid is already gone.
+
+## Namespaces and identity
+
+Each service gets `rfork(RFNAMEG)` and a namespace built from its `ns=` file via
+`newns` — the same machinery `cpu` already uses.
+
+This is the part that isn't a systemd transliteration. Two services on one
+machine can hold completely different views of the filesystem: different `/net`,
+different `/srv`, different roots. That is what would let one box participate in
+two grids at once, each with its own `/mnt/factotum` and its own identity —
+impossible today, because a machine has one hostowner, one `authdom` and one
+namespace.
+
+Identity comes from the capability device (`/dev/caphash`, `/dev/capuse`), the
+path `cpu` and `rx` already use, since Plan 9 has no setuid. That means init runs
+as hostowner and is security-critical. Not a detail to discover later.
+
+**Services consume namespaces; they never construct the shared one.** A service
+that runs `bind` inside its own namespace group affects nobody, and would appear
+to work while doing nothing. Base namespace construction belongs to init, before
+any service starts.
+
+## Talking to it
+
+The filesystem is the mechanism, commands are what people type. Both.
+
+ svc list services, their state, and why
+ svc start dns
+ svc restart dns
+ svc log dns
+ halt stop services, halt the fs, power off
+ shutdown alias for halt
+ reboot ... and restart the kernel
+ reset stop services, then start them again
+
+Commands are pure translation — a few lines that open a file and write a word.
+If `svc restart dns` can ever do something a write to `/mnt/svcs/dns/ctl`
+cannot, we've built two interfaces and they will drift.
+
+`reset` is worth naming because it's the cheap one: it's the stopping half
+followed by the starting half, both of which already exist. No new machinery.
+
+## Ctl files say what they accept
+
+The worst thing about Plan 9 ctl files is that they're write-only channels for
+magic strings. `/net/tcp/0/ctl`, `#S/sdctl`, `/dev/mousectl` — the only way to
+learn the verbs is to read source.
+
+So every ctl file here reads back its own command list:
+
+ % cat /mnt/svcs/dns/ctl
+ start
+ stop
+ restart
+ note <string> post a note to the process
+
+and every rejected write names the alternatives:
+
+ % echo frobnicate >/mnt/svcs/ctl
+ echo: write error: unknown command "frobnicate"; try
+ halt reboot reset start stop restart enable disable reexec
+
+This deviates from convention — most Plan 9 ctl files return nothing or return
+state on read. We can afford to spend the read on documentation because `status`
+carries the state. Given that the convention is the complaint, it's the right
+one to break.
+
+## State is ndb too
+
+`/proc/mdstat` on Linux is the canonical disease: positional, bracket-encoded,
+with an ASCII progress bar inside the data. Plan 9 does it too —
+`/net/ipifc/0/status` opens with genuine attribute/value pairs (`maxtu 1514
+sendra 0`) and then collapses into unlabelled positional columns the moment
+there's a list.
+
+So status output is ndb, not columns:
+
+ svc=dns state=running pid=231
+ svc=authsrv state=failed restarts=3 exit='cannot open /adm/keys'
+ svc=listen state=waiting needs=cs
+
+Self-describing, quoting handles the exit string, and fields can be added
+without breaking readers. `svc` with no arguments prints aligned columns for
+humans; the file stays ndb for programs.
+
+Nothing in the tree currently serves ndb — all 15 libndb consumers are readers
+of static config. This would be the first, which also means it shouldn't be
+called a convention until a second, genuinely different server carries it.
+
+## Configuration goes one way
+
+`/lib/svc` is the only source of truth. The filesystem accepts *verbs*, not
+configuration. No writing `exec=` into `/mnt/svcs/dns/args` and having init
+persist it back — that's two paths to one state, and they will disagree. It's
+also how the system already behaves: you configure an interface by writing
+`/net/ipifc/0/ctl`, not by editing `status`.
+
+`reload` re-reads `/lib/svc`, applies what parses, and reports what doesn't:
+
+ % echo reload >/mnt/svcs/ctl
+ echo: write error: /lib/svc/dns:4: unknown attribute "exce"
+
+One bad file never costs you the whole machine — same behaviour at boot.
+
+## Permissions
+
+init is the file server, so it checks the 9P attach identity itself. One
+mechanism covers local writes and remote mounts; there's no separate "is this
+allowed remotely" path to get wrong later.
+
+ /mnt/svcs/ctl 0644 hostowner halt, reboot, reset
+ /mnt/svcs/<n>/ctl 0664 hostowner:user start, stop, restart
+ /mnt/svcs/<n>/status 0444 anyone
+ /mnt/svcs/<n>/log 0640
+
+Note the ctl files are world *readable*. Restricting reads was the first
+thing tried and it immediately broke the rule above — reading a ctl file is
+how you learn what it accepts, which is no use if only one user may do it.
+Writing is the thing that wants restricting.
+
+This isn't cosmetic: if `/srv/svc` is exported, anyone who can mount it can halt
+the machine.
+
+## Shutdown
+
+`fshalt` is the proof that this is needed. It spends ninety lines rediscovering
+the system at shutdown — a hardcoded glob for every file server 9front has
+shipped (`cwfs*cmd`, `hjfs*cmd`, `ext4*cmd`, `gefs*cmd`, `fscons*`), plus
+hardcoded knowledge of each one's stop protocol (`echo halt` for most,
+`echo fsys all halt` and a `sleep 2` for fscons).
+
+Then the strangest part:
+
+ ramfs
+ cp /bin/echo /tmp
+ cp /bin/rc /tmp
+ # put this in a shell function so this rc script doesn't get read
+ # when it's no longer accessible
+ fn x { ... }
+
+It copies its own tools into a ramfs and hides its body in a function so rc
+won't re-read the script — because the shutdown logic lives *on the filesystem
+it is shutting down*. init has none of that problem: it's resident, it's process
+1, and it re-reads nothing.
+
+So shutdown is:
+
+1. stop services in reverse dependency order — `hangup` note, wait, then `kill`
+2. halt the adopted file server
+3. power off or reboot
+
+A wedged service must never stall shutdown, because the thing being protected is
+a clean filesystem. Timeout, hard kill, proceed.
+
+**Adopted services.** The root file server is started by `bootrc` before init
+exists. init isn't its parent, can't `await` it, can't `kill` it — it can only
+stop it the way fshalt does, by writing its `/srv` ctl file. So it's a second
+category: known but not owned, stop-only, always last. Each file server declares
+its own `stop=` rather than init carrying a table of how to kill everyone else.
+
+`fshalt` goes away.
+
+## Logging
+
+Service output is appended to `/log/<name>`. `/mnt/svcs/<name>/log` is a view of
+that file, and a blocking read at EOF gives live tailing.
+
+**Rotation is a non-goal.** Appending is twenty lines; rotation is a subsystem,
+and it isn't going in init. Files grow until something else deals with them.
+
+Two places this doesn't work, both the same problem: there's no filesystem yet,
+or there won't be one shortly. Early boot messages go to console. Once teardown
+starts, logging to disk stops and the rest goes to console.
+
+## Testing without booting
+
+Nothing about supervising services requires being process 1:
+
+ init -d /tmp/svc.test -s svc.test -m /mnt/svcs.test
+
+runs the real init as an ordinary user, supervising toy services, serving a test
+filesystem. No reboot, no risk.
+
+That only works if it's designed in, so it's a rule: **no hardcoded paths, no
+assumption of being pid 1, every path a flag.** The only parts needing a real
+boot are base namespace construction, the rescue console, and adopted-fs
+shutdown — which is the argument for keeping those three thin and separable.
+
+This is where recoverability actually comes from, not from splitting init into
+two programs.
+
+## Implementation
+
+C, and it isn't close: the program that brings a machine up should need nothing but libc at runtime.
+
+- **lib9p** — the file server. `threadpostmountsrv`, fill in a `Srv`.
+- **libndb** — service definitions and status output.
+- **libauth** — `newns`.
+- **libthread** — `threadwaitchan()` hands you a channel of `Waitmsg*`, so the
+ main loop is literally a select over "a child exited" and "a 9P request
+ arrived." The concurrency model matches the problem.
+
+Roughly 1500–2500 lines. Small enough to hold in your head, which for
+something this hard to restart is the point.
+
+Services written in rc are just `exec=/bin/rc args=/bin/foo`. **An rc service
+script must `exec` its final program, not background it** — otherwise init
+supervises rc, rc exits immediately, and the real work is orphaned.
+
+## Getting there
+
+Staged because you need a working machine tomorrow, not because the end state
+keeps any of it. First: init starts `svcfs` and supervises a handful of services
+alongside the existing `termrc`, fully reversible. Then service definitions
+replace `/rc/bin/service/` and the loose daemon lines. Then `termrc`, `cpurc`
+and `service=` are deleted.
+
+## Open questions
+
+- **`reexec` state handover.** Init replacing itself without dropping services
+ means supervision state survives the exec — handed over in memory, or
+ re-derived from `/proc` and `/srv`. The genuinely hard part.
+- **Detaching liveness.** Plan 9 has no file-change notification, so noticing
+ that a detached service's `/srv` file vanished means polling, or only
+ noticing on a `status` read.
+- **Who restarts init?** If init dies, nothing catches it. Confirm `bootrc`
+ honours `init=` in `plan9.ini` — that's the escape hatch for a bad init.
+- **A machine-readable interface description.** An ndb file next to `ctl`
+ describing fields, actions, and which files block, so a generic client can
+ render a UI over any 9P server. Not part of init, but init is a reasonable
+ first carrier — and it shouldn't be frozen until a second server carries it
+ too.
+
+## Namespaces are the capability set
+
+This started as a federation feature and turned out to be the privilege
+mechanism, which is a better justification and changes the defaults.
+
+In Plan 9 there is no access control for devices beyond what is in your
+namespace. If `#S` is not bound, you cannot touch storage — not "you get
+permission denied", but there is no name to open. That is stronger than
+Unix, where `/dev/sda` exists for everyone and mode bits are the only guard.
+
+But termrc binds all nine kernel devices into `/dev` for everything:
+
+ for(i in P S f æ t L A J '$')
+ bind -qa '#'^$i /dev
+
+So today `timesync` — which talks to an NTP server — can reach raw disk and
+PCI config space. `#p` is worse: every process can walk `/proc`, read other
+processes' memory, and post notes to them, so any service can kill init.
+
+### Consequences for the design
+
+**No default namespace.** A service with no namespace declaration is a load
+error, like an unknown attribute. Inheriting by default means granting
+everything by default to services that asked for nothing.
+
+**Ship profiles so explicit is not verbose.** The base is ~40 lines and most
+services want most of it; hand-written namespaces per service would be
+copy-pasted and would drift.
+
+ /lib/ns/net #I #c #e #d — dials out, no disk, no /proc
+ /lib/ns/disk net plus #S
+ /lib/ns/draw net plus #i #m
+ /lib/ns/full everything termrc currently binds
+
+A service says `nsfile=/lib/ns/net` plus optional inline `ns=bind …` lines.
+
+**`full` will be abused, and that is survivable, because it is greppable.**
+`grep -l ns/full /lib/services/*` is a list of over-privileged services, which
+is a work queue. Inheritance produces no such list.
+
+### /srv is a hole
+
+Binding `#s` undoes most of the above. A service that can see `/srv` can
+`mount /srv/boot` and get the whole root filesystem back, write
+`/srv/hjfs.cmd` to halt the machine, or mount factotum and reach keys.
+
+Three layers, in order of strength:
+
+1. **Do not bind `#s`.** The only airtight one — the channels are not nameable.
+2. **`rfork(RFNOMNT)` before exec.** Plan 9's pledge: a one-way drop after
+ which the process cannot mount, bind or unmount. Default on; `nsmount=yes`
+ is the visible opt-out for services that genuinely mount things.
+3. **`user=`.** File permissions on whatever remains.
+
+RFNOMNT is not a wall. It blocks the `mount` call, not a process that opens
+`/srv/foo` and speaks 9P down the fd by hand. It stops accidents and casual
+escalation; only omitting `#s` stops intent.
+
+### Unresolved: how this interacts with everything else
+
+- **Providers of namespaces need mount privilege.** A service that imports a
+ remote `/net` for others must mount, and must post to `/srv` so dependents
+ can reach it — so the exception to rule 1 and the exception to rule 2 are
+ the same services. Circular and not thought through.
+- **Namespace changes do not propagate.** A provider's mount dies with its
+ process group, so `/srv` is the only rendezvous, which is the thing we are
+ trying to restrict.
+- **init's own bootstrap** predates all of this: it needs `/lib/services` and
+ `#s` before it can read a profile.
+- **`user=` ordering.** The capability device must still be in the namespace
+ at the moment we drop privilege, so profiles cannot omit `#¤` blindly.
+- **Adopted services** were started by bootrc in a namespace we never chose.
+- **Migration.** `cs`, `dns` and `timesync` work today with no namespace
+ declaration. Making it mandatory breaks them until profiles exist.