Merge pull request 'The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199)' (#69) from feat/the-mesh-has-one-resolver into main
This commit was merged in pull request #69.
This commit is contained in:
+13
-23
File diff suppressed because one or more lines are too long
+37
-51
@@ -14,6 +14,8 @@ tools below are the module's own.
|
||||
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
|
||||
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
|
||||
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
|
||||
| `daemon` | `live-restore` written into `/etc/docker/daemon.json`, beside other keys | the key given back as found when the module goes |
|
||||
| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) |
|
||||
|
||||
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
|
||||
takes no volume, no container and no image a container uses, so it never touches a container the mesh
|
||||
@@ -24,62 +26,47 @@ while the machine was off happens at the next boot.
|
||||
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
|
||||
the module that installs the daemon cannot require it.
|
||||
|
||||
## The runtime's own file and service (issue 190, hq ADR 0196)
|
||||
|
||||
This module writes one key into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`.
|
||||
`dnsmasq` used to write it, beside `dns`; it no longer writes either. Under ADR 0196 a container
|
||||
copies its machine's resolvers, so no module writes `dns`.
|
||||
|
||||
- `daemon`: `{"live-restore": true}`, merged into the file beside the keys others write.
|
||||
- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when
|
||||
`daemon` changes. A restart stops every container. A reload turns `live-restore` on, and with it on
|
||||
a later restart keeps every container running.
|
||||
|
||||
In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this
|
||||
module's: `live-restore` is set again in the same apply, and the daemon is reloaded once.
|
||||
|
||||
`dns` is removed from the file then, but the daemon reads it only at its next start, and a running
|
||||
container keeps the resolvers it was created with. Each container pinned to a machine's own resolver
|
||||
is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4).
|
||||
|
||||
Still elsewhere:
|
||||
|
||||
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
|
||||
`insecure-registries`. The collision check does not see generated resources. **Later:** the
|
||||
controller hands the registry to this module as a value, and the overlay stops generating its two
|
||||
resources (issue 190, steps 2 and 5). The host merges disjoint keys correctly; the mesh-host
|
||||
`into.go` record is per resource.
|
||||
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left
|
||||
as they are until a size is chosen for every machine.
|
||||
|
||||
## What it does not declare yet, and why
|
||||
|
||||
Three things this module should own are already declared by other modules on every machine. The
|
||||
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
|
||||
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
|
||||
make the module unassignable everywhere. The refusals were checked against the controller's own
|
||||
One thing this module should own is still declared elsewhere. The controller refuses two modules
|
||||
on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`,
|
||||
mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module
|
||||
unassignable everywhere. The refusals were checked against the controller's own
|
||||
check:
|
||||
|
||||
```
|
||||
zsh and docker both declare the name "${machine:account}"
|
||||
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
|
||||
dnsmasq and docker both declare the unit "docker.service"
|
||||
```
|
||||
|
||||
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
|
||||
|
||||
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
|
||||
service:
|
||||
|
||||
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
|
||||
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
|
||||
`insecure-registries`. The collision check does not see generated resources.
|
||||
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
|
||||
|
||||
**The change proposed, in one merge:**
|
||||
|
||||
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
|
||||
2. `docker` adds the two resources below:
|
||||
|
||||
```json
|
||||
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
|
||||
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
|
||||
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
|
||||
```
|
||||
|
||||
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
|
||||
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
|
||||
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
|
||||
start keeps every container running.
|
||||
|
||||
**Why one merge, and only after this module is on every machine:**
|
||||
|
||||
- In one apply, the host first gives back the resources that are no longer declared, then applies
|
||||
the new ones (mesh-host `apply.go`).
|
||||
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
|
||||
again in the same apply. The daemon is reloaded once, after both steps.
|
||||
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
|
||||
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
|
||||
every container.
|
||||
|
||||
**Later:** the controller hands the registry to this module as a value, and the overlay stops
|
||||
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
|
||||
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
|
||||
per resource.
|
||||
|
||||
### 2. The operator account's membership of the `docker` group
|
||||
### The operator account's membership of the `docker` group
|
||||
|
||||
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
|
||||
`usermod --append` and never takes a group away.
|
||||
@@ -111,9 +98,8 @@ On the machine the mesh was first installed on, the foundation bundle declared `
|
||||
never sees them (mesh-host `store.go`).
|
||||
- So `docker.package` here is a **second record of the same package**. The apply says "already
|
||||
installed", and neither record ever uninstalls it.
|
||||
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
|
||||
1 would add a second record of that unit. Its found state is *running*, because genesis started
|
||||
it, so undeclaring this module would leave the daemon running.
|
||||
- `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*,
|
||||
because genesis started it, so undeclaring this module leaves the daemon running.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -50,6 +50,24 @@
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "file",
|
||||
"path": "/etc/docker/daemon.json",
|
||||
"mode": "0644",
|
||||
"into": "json",
|
||||
"content": "{\"live-restore\": true}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "service",
|
||||
"unit": "docker.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"reload-on": [
|
||||
"daemon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prune-service",
|
||||
"type": "file",
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
|
||||
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
|
||||
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
|
||||
//
|
||||
// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's
|
||||
// account, so the file is written through sudo without a prompt where the account is not root, as the
|
||||
// packet filter's is.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two
|
||||
// together.
|
||||
const HostsPath = "/etc/hosts"
|
||||
|
||||
// Operator is the owner of every line outside a block.
|
||||
const Operator = "operator"
|
||||
|
||||
// Runner runs one command as root and answers what it printed, so the writes can be tested without a
|
||||
// machine.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt.
|
||||
func escalated(uid int, name string, args []string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
program, argv := escalated(os.Getuid(), name, args)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||
if program == "sudo" {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
}
|
||||
if said != "" {
|
||||
return "", fmt.Errorf("%s: %s", name, said)
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||
}
|
||||
|
||||
// Line is one line of the file, as a reader sees it.
|
||||
type Line struct {
|
||||
// Text is the line exactly as it is in the file.
|
||||
Text string `json:"text"`
|
||||
// Owner is whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator".
|
||||
Owner string `json:"owner"`
|
||||
// Address and Names are an entry's; absent for a comment or a blank line.
|
||||
Address string `json:"address,omitempty"`
|
||||
Names []string `json:"names,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`)
|
||||
end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be.
|
||||
func isAddress(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Parse is every line of a hosts file, each marked whose it is.
|
||||
func Parse(text string) []Line {
|
||||
out := []Line{}
|
||||
block := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if block == "" {
|
||||
if m := begin.FindStringSubmatch(raw); m != nil {
|
||||
block = m[1]
|
||||
out = append(out, Line{Text: raw, Owner: block})
|
||||
continue
|
||||
}
|
||||
}
|
||||
owner := block
|
||||
if owner == "" {
|
||||
owner = Operator
|
||||
}
|
||||
line := Line{Text: raw, Owner: owner}
|
||||
entry, _, _ := strings.Cut(raw, "#")
|
||||
if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) {
|
||||
line.Address, line.Names = fields[0], fields[1:]
|
||||
}
|
||||
out = append(out, line)
|
||||
if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block {
|
||||
block = ""
|
||||
}
|
||||
}
|
||||
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
|
||||
if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") {
|
||||
out = out[:n-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
|
||||
// Refused input says why, so a caller is one edit from right.
|
||||
func checkAddress(address string) error {
|
||||
if !isAddress(address) {
|
||||
return fmt.Errorf("%q is not an IPv4 or IPv6 address", address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkName(name string) error {
|
||||
bad := fmt.Errorf("%q is not a host name", name)
|
||||
if len(name) < 1 || len(name) > 253 {
|
||||
return bad
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") {
|
||||
if !label.MatchString(l) {
|
||||
return bad
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAdded is the file with one address and its names added to the operator's lines; unchanged when
|
||||
// they are already there.
|
||||
func WithAdded(text, address string, names []string) (string, error) {
|
||||
if err := checkAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", errors.New("add names at least one name for the address")
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := checkName(n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner == Operator && l.Address == address {
|
||||
for _, n := range l.Names {
|
||||
have[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var missing []string
|
||||
for _, n := range names {
|
||||
if !have[n] && !slices.Contains(missing, n) {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
body := text
|
||||
if body != "" && !strings.HasSuffix(body, "\n") {
|
||||
body += "\n"
|
||||
}
|
||||
return body + address + "\t" + strings.Join(missing, " ") + "\n", nil
|
||||
}
|
||||
|
||||
// WithRemoved is the file with one name, or every line of one address, taken out of the operator's
|
||||
// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool
|
||||
// writes is refused, naming whose it is.
|
||||
func WithRemoved(text, what string) (string, int, error) {
|
||||
byAddress := isAddress(what)
|
||||
if !byAddress {
|
||||
if err := checkName(what); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
matches := func(l Line) bool {
|
||||
if byAddress {
|
||||
return l.Address == what
|
||||
}
|
||||
return slices.Contains(l.Names, what)
|
||||
}
|
||||
lines := Parse(text)
|
||||
removed := 0
|
||||
kept := []string{}
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator || l.Address == "" || !matches(l) {
|
||||
kept = append(kept, l.Text)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
if byAddress {
|
||||
continue
|
||||
}
|
||||
var rest []string
|
||||
for _, n := range l.Names {
|
||||
if n != what {
|
||||
rest = append(rest, n)
|
||||
}
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
kept = append(kept, l.Address+"\t"+strings.Join(rest, " "))
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator && matches(l) {
|
||||
return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n") + "\n", removed, nil
|
||||
}
|
||||
|
||||
// HostsFile is the machine's hosts file.
|
||||
type HostsFile struct {
|
||||
Path string
|
||||
Run Runner
|
||||
}
|
||||
|
||||
// Entries is the file's lines, each marked whose.
|
||||
type Entries struct {
|
||||
Path string `json:"path"`
|
||||
Lines []Line `json:"lines"`
|
||||
}
|
||||
|
||||
func (h HostsFile) read() (string, error) {
|
||||
b, err := os.ReadFile(h.Path)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// Entries is every line of the file, each marked whose it is.
|
||||
func (h HostsFile) Entries() (*Entries, error) {
|
||||
text, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Entries{Path: h.Path, Lines: Parse(text)}, nil
|
||||
}
|
||||
|
||||
// Added is whether add changed the file, and the line it wrote.
|
||||
type Added struct {
|
||||
Added bool `json:"added"`
|
||||
Line string `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// Add adds one address and its names to the operator's lines.
|
||||
func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := WithAdded(before, address, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if after == before {
|
||||
return &Added{Added: false}, nil
|
||||
}
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil
|
||||
}
|
||||
|
||||
// Removed is how many of the operator's lines remove touched.
|
||||
type Removed struct {
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
|
||||
// Remove takes one name, or every line of one address, out of the operator's lines.
|
||||
func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, removed, err := WithRemoved(before, what)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if removed > 0 {
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Removed{Removed: removed}, nil
|
||||
}
|
||||
|
||||
// write puts the file in place whole, so a reader never sees half of it: the content is staged in a
|
||||
// private copy, installed as root beside the file — the same directory, so the same filesystem — and
|
||||
// renamed over it.
|
||||
func (h HostsFile) write(ctx context.Context, content string) error {
|
||||
dir, err := os.MkdirTemp("", "hosts-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
staged := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hosts-tools")
|
||||
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil {
|
||||
_, _ = h.Run(ctx, "rm", "-f", beside)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
||||
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const file = "# Static table lookup for hostnames.\n" +
|
||||
"127.0.0.1\tlocaldev.example.com\n" +
|
||||
"127.0.0.1 a.example.com b.example.com\n" +
|
||||
"# BEGIN mesh hosts.own\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\tlocalhost\n" +
|
||||
"# END mesh hosts.own\n" +
|
||||
"# BEGIN other-tool\n" +
|
||||
"192.0.2.7\tproject.test\n" +
|
||||
"# END other-tool\n"
|
||||
|
||||
func blocks(text string) []string {
|
||||
var out []string
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner != Operator {
|
||||
out = append(out, l.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEveryLineSaysWhoseItIs(t *testing.T) {
|
||||
lines := Parse(file)
|
||||
if len(lines) != 10 {
|
||||
t.Fatalf("%d lines: %+v", len(lines), lines)
|
||||
}
|
||||
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
|
||||
if !reflect.DeepEqual(lines[1], want) {
|
||||
t.Errorf("%+v", lines[1])
|
||||
}
|
||||
if lines[0].Address != "" || lines[0].Owner != Operator {
|
||||
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
|
||||
}
|
||||
if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" {
|
||||
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
|
||||
}
|
||||
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
|
||||
t.Errorf("%+v", lines[8])
|
||||
}
|
||||
if lines[5].Address != "::1" {
|
||||
t.Errorf("an IPv6 entry: %+v", lines[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
|
||||
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
|
||||
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
|
||||
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
|
||||
for _, l := range lines {
|
||||
if l.Owner != "x" {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
|
||||
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(after), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
|
||||
t.Errorf("a name already there changed the file")
|
||||
}
|
||||
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
|
||||
t.Errorf("%q", some)
|
||||
}
|
||||
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
|
||||
t.Errorf("a file without a last newline: %q", ended)
|
||||
}
|
||||
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
|
||||
t.Errorf("an empty file: %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
|
||||
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
address string
|
||||
names []string
|
||||
says string
|
||||
}{
|
||||
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
|
||||
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
|
||||
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
|
||||
{"192.0.2.9", []string{}, "at least one name"},
|
||||
} {
|
||||
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%q %q: %v", c.address, c.names, err)
|
||||
}
|
||||
}
|
||||
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
|
||||
t.Errorf("an IPv6 address and a rooted name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
|
||||
one, n, err := WithRemoved(file, "a.example.com")
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
|
||||
t.Errorf("%q", one)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(one), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
all, n, err := WithRemoved(file, "127.0.0.1")
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(all), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
|
||||
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
|
||||
t.Errorf("%d %v", n, err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "bad name"); err == nil {
|
||||
t.Errorf("a name that is neither an address nor a host name is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
||||
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
}
|
||||
|
||||
// runPlain runs a command as given, unescalated: the test's file is the test's own.
|
||||
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls [][]string
|
||||
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
calls = append(calls, append([]string{name}, args...))
|
||||
return runPlain(ctx, name, args...)
|
||||
}}
|
||||
ctx := context.Background()
|
||||
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
|
||||
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
|
||||
t.Fatalf("%+v %v", added, err)
|
||||
}
|
||||
beside := filepath.Join(dir, ".hosts.hosts-tools")
|
||||
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
|
||||
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
|
||||
t.Errorf("%v", calls)
|
||||
}
|
||||
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
|
||||
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
|
||||
}
|
||||
got, err := h.Entries()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := got.Lines[len(got.Lines)-1]
|
||||
if last.Owner != Operator || last.Address != "192.0.2.9" {
|
||||
t.Errorf("add then entries shows the line as the operator's: %+v", last)
|
||||
}
|
||||
removed, err := h.Remove(ctx, "lab.test")
|
||||
if err != nil || removed.Removed != 1 {
|
||||
t.Fatalf("%+v %v", removed, err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != file {
|
||||
t.Errorf("add then remove gives the file back: %q", b)
|
||||
}
|
||||
if _, err := os.Stat(beside); !os.IsNotExist(err) {
|
||||
t.Errorf("the staged copy beside the file is left: %v", err)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode %v", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r.ID == "own" {
|
||||
found = true
|
||||
if r.Path != HostsPath {
|
||||
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no resource own")
|
||||
}
|
||||
var served []string
|
||||
for _, tool := range tools(HostsFile{}) {
|
||||
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
|
||||
if tool.Description == "" || tool.Run == nil {
|
||||
t.Errorf("%s", tool.Name)
|
||||
}
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
|
||||
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
|
||||
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{}); len(got) != 0 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// hosts-tools (novox/hq ADR 0199): the hosts file's tools. One binary, launched by the machine's tool
|
||||
// runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198): the
|
||||
// node-hosts-file seat's three verbs — the file's lines with whose each is, add an operator's line,
|
||||
// remove one. They change the machine's file and nothing else; the controller holds none of it.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-hosts-file"
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[hosts] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
var separators = regexp.MustCompile(`[\s,]+`)
|
||||
|
||||
// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list,
|
||||
// the list.
|
||||
func namesArg(a map[string]any) []string {
|
||||
var raw []string
|
||||
switch v := a["names"].(type) {
|
||||
case string:
|
||||
raw = separators.Split(v, -1)
|
||||
case []any:
|
||||
for _, n := range v {
|
||||
if s, ok := n.(string); ok {
|
||||
raw = append(raw, separators.Split(s, -1)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
names := []string{}
|
||||
for _, n := range raw {
|
||||
if n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject, as <node>/node-hosts-file.<verb>.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||
}
|
||||
|
||||
func tools(h HostsFile) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
return []stdio.Tool{
|
||||
verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
|
||||
nil, func(map[string]any) (any, error) { return h.Entries() }),
|
||||
verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
|
||||
map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")},
|
||||
func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }),
|
||||
verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||
map[string]any{"name": str("a host name, or an address to remove every line of")},
|
||||
func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module hosts
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"module": "hosts",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-hosts-file",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"entries",
|
||||
"add",
|
||||
"remove"
|
||||
]
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "own",
|
||||
"type": "file",
|
||||
"path": "/etc/hosts",
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "start",
|
||||
"content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/hosts-tools",
|
||||
"binary": "hosts-tools",
|
||||
"loads": [
|
||||
"hosts-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/resolv.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"
|
||||
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's one resolver first (novox/hq ADR 0194, 0196), by address — a machine\n# cannot resolve the name of the thing it resolves names with. It answers the\n# mesh's names itself and forwards every other name. A public resolver second,\n# asked only when the first does not answer at all — its machine or the tunnel\n# down, a captive portal holding the tunnel back — so public names keep\n# resolving then. An answer from the first, \"no such name\" included, is final,\n# so a mesh name is never asked of the public one while the mesh's answers. One\n# second and one attempt, so the wait before the fallback is short. Containers\n# copy these two lines from their machine.\nnameserver ${bound:wildcard-resolution:address}\nnameserver 1.1.1.1\noptions timeout:1 attempts:1 edns0\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/resolved.conf.d/mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"
|
||||
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# The mesh's one resolver (novox/hq ADR 0194), by its private address — a\n# machine cannot resolve the name of the thing it resolves names with.\n[Resolve]\nDNS=${bound:wildcard-resolution:address}\nDomains=~internal\n"
|
||||
},
|
||||
{
|
||||
"id": "resolved",
|
||||
|
||||
Reference in New Issue
Block a user