From b30d9c5b5a5f18a9de47c9861944145ca1ae86cc Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 17:02:49 +0200 Subject: [PATCH] A container may log to the journal (hq ADR 0179) A jail reads a log; a container's output went to a file of the runtime's own under a path that changes on recreate, so no jail could read a container's service. logging: journald runs the container with the journal as its driver, named in the spec so moving it recreates it; any other place is refused. --- internal/apply/apply.go | 9 +++++ internal/apply/logging_test.go | 43 ++++++++++++++++++++++++ internal/declaration/declaration.go | 12 +++++++ internal/declaration/declaration_test.go | 21 ++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 internal/apply/logging_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 2184946..09c3fcd 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1607,6 +1607,10 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s for _, c := range r.Capabilities { b.WriteString("cap " + c + "\n") } + // And where it logs (ADR 0179): the runtime cannot move a running container's output. + if r.Logging != "" { + b.WriteString("log " + r.Logging + "\n") + } // The cadence is part of what was declared, so a changed schedule is a changed spec — the marker // moves and the install is reported "updated" and re-established. Added only when present, so no // ordinary container's or run-once step's digest moves for a field it does not set. @@ -1804,6 +1808,11 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner, for _, c := range r.Capabilities { args = append(args, "--cap-add", c) } + if r.Logging != "" { + // The journal keeps the container's name on every line (CONTAINER_NAME), which is what a + // jail matches on (novox/hq ADR 0179); `docker logs` keeps working against the journal. + args = append(args, "--log-driver", r.Logging) + } for _, d := range r.Dns { args = append(args, "--dns", d) } diff --git a/internal/apply/logging_test.go b/internal/apply/logging_test.go new file mode 100644 index 0000000..3aa0737 --- /dev/null +++ b/internal/apply/logging_test.go @@ -0,0 +1,43 @@ +package apply + +import ( + "context" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// A container declared to log to the journal is run with the journal as its log driver, and the +// place it logs is part of its spec, so moving it recreates the container (novox/hq ADR 0179). +func TestAContainerLoggingToTheJournalIsRunThatWayAndRecreatedWhenMoved(t *testing.T) { + pinned := "postgres@sha256:" + strings.Repeat("a", 64) + var ran []string + run := func(_ context.Context, cmd string, args ...string) (string, error) { + if cmd == "docker" && len(args) > 0 && args[0] == "run" { + ran = args + return "deadbeef\n", nil + } + return "", nil + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"front","type":"container","name":"front","image":"`+pinned+`","logging":"journald"} + ]}`) + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) + sent := false + for i, a := range ran { + if a == "--log-driver" && i+1 < len(ran) && ran[i+1] == "journald" { + sent = true + } + } + if !sent { + t.Fatalf("the container's output was not sent to the journal: %v", ran) + } + with := d.Resources[0].(*declaration.Container) + without := *with + without.Logging = "" + if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) { + t.Fatal("where a container logs is not part of its spec, so moving it would not recreate it") + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 0d611b0..23640c0 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -952,6 +952,14 @@ type Container struct { // 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. @@ -1057,6 +1065,10 @@ func (c *Container) validate(where string, _ bool) []string { "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 { diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 00fcb62..aebdac7 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -524,3 +524,24 @@ func TestACapabilityIsNamedOrRefused(t *testing.T) { } } } + +// 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) + } + } +}