docker: make log rotation a preference, 100m x 10 by default, judged by a step before daemon.json takes it (novox/hq issue 452)
mesh/merge-gate pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh/merge-gate pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
The controller pastes a setting into daemon.json as text and the runtime is its only reader, so a bad size would surface as a runtime that does not start and a quote could add any key. A step refuses the value first and gates the module. The defaults are the one hand-set machine's, so it does not change; nothing restarts the runtime, which takes the keys at its next start.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
// The container runtime's log rotation, checked before it reaches daemon.json (novox/hq issue 452).
|
||||
//
|
||||
// **Why a step and not the file alone.** daemon.json's log-driver and log-opts are filled by the controller
|
||||
// from this module's settings log-max-size and log-max-file, pasted as text (novox/hq issue 346): the
|
||||
// controller judges nothing but that a value is one line. The runtime is the only reader of daemon.json and
|
||||
// reads log-opts only when it starts, so a bad value would be found at the runtime's next start, as a runtime
|
||||
// that does not start, and a value with a quote in it could add any key to the file. So the module's
|
||||
// manifest writes the same two values into log-rotation.conf first, and runs this as a step before daemon.json:
|
||||
// a value refused here fails the step, and the host skips the rest of the module (novox/hq ADR 0136), so
|
||||
// daemon.json keeps what it had, and the refusal is in the apply's report.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// logRotation is what log-rotation.conf says, both values as the runtime takes them: text.
|
||||
type logRotation struct{ Size, Files string }
|
||||
|
||||
var (
|
||||
// A size as the json-file driver reads it, with the unit required and in lower case, as decided on issue
|
||||
// 452: a whole number from 1 with k, m or g, at most nine digits, so no value overflows the runtime's count.
|
||||
logSize = regexp.MustCompile(`^[1-9][0-9]{0,8}[kmg]$`)
|
||||
// A count of files from 1 to 99, without a leading zero.
|
||||
logFiles = regexp.MustCompile(`^[1-9][0-9]?$`)
|
||||
)
|
||||
|
||||
// parseLogRotation reads `size=` and `files=` once each; a comment or an empty line is skipped, and any other
|
||||
// line is refused, so a value that brought a line of its own is never read past.
|
||||
func parseLogRotation(text string) (logRotation, error) {
|
||||
var got logRotation
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok || (key != "size" && key != "files") {
|
||||
return logRotation{}, fmt.Errorf("log-rotation.conf has a line that is not size= or files=: %q", line)
|
||||
}
|
||||
if seen[key] {
|
||||
return logRotation{}, fmt.Errorf("log-rotation.conf says %s twice", key)
|
||||
}
|
||||
seen[key] = true
|
||||
switch key {
|
||||
case "size":
|
||||
if !logSize.MatchString(value) {
|
||||
return logRotation{}, fmt.Errorf("log-max-size %q is not a size: a whole number from 1 with k, m or g "+
|
||||
"after it, such as 100m; daemon.json was left as it is", value)
|
||||
}
|
||||
got.Size = value
|
||||
case "files":
|
||||
if !logFiles.MatchString(value) {
|
||||
return logRotation{}, fmt.Errorf("log-max-file %q is not a count from 1 to 99; daemon.json was left "+
|
||||
"as it is", value)
|
||||
}
|
||||
got.Files = value
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"size", "files"} {
|
||||
if !seen[key] {
|
||||
return logRotation{}, fmt.Errorf("log-rotation.conf does not say %s", key)
|
||||
}
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// checkLogRotationFile is the step: the file the manifest wrote, judged.
|
||||
func checkLogRotationFile(path string) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = parseLogRotation(string(raw))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The container runtime's log rotation is a preference (novox/hq issue 452).
|
||||
|
||||
type manifestForLogs struct {
|
||||
Settings map[string]struct {
|
||||
Kind string `json:"kind"`
|
||||
Default any `json:"default"`
|
||||
Why string `json:"why"`
|
||||
} `json:"settings"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
|
||||
func readManifestForLogs(t *testing.T) manifestForLogs {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifestForLogs
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func resourceByID(m manifestForLogs, id string) (map[string]any, int) {
|
||||
for i, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r, i
|
||||
}
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
// filled is a file's content as the controller fills it from the settings' defaults alone: each
|
||||
// ${setting:<key>} replaced by the default as text, the way the controller writes a value into a file.
|
||||
func filled(t *testing.T, m manifestForLogs, content string) string {
|
||||
t.Helper()
|
||||
ref := regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
var missing []string
|
||||
out := ref.ReplaceAllStringFunc(content, func(s string) string {
|
||||
key := ref.FindStringSubmatch(s)[1]
|
||||
d, ok := m.Settings[key]
|
||||
if !ok || d.Kind != "preference" {
|
||||
missing = append(missing, key)
|
||||
return s
|
||||
}
|
||||
switch v := d.Default.(type) {
|
||||
case float64:
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
})
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("the content asks for %v, which the manifest gives no preference default", missing)
|
||||
}
|
||||
return strings.ReplaceAll(out, "${seat:mesh-artifact-store:reach}", "registry.example:5100")
|
||||
}
|
||||
|
||||
func TestTheDefaultLogRotationIsTheOneTheHandSetMachineHas(t *testing.T) {
|
||||
m := readManifestForLogs(t)
|
||||
daemon, _ := resourceByID(m, "daemon")
|
||||
if daemon == nil {
|
||||
t.Fatal("no daemon resource")
|
||||
}
|
||||
var keys map[string]any
|
||||
if err := json.Unmarshal([]byte(filled(t, m, daemon["content"].(string))), &keys); err != nil {
|
||||
t.Fatalf("daemon.json's keys with the defaults are not JSON: %v", err)
|
||||
}
|
||||
if keys["log-driver"] != "json-file" {
|
||||
t.Errorf("log-driver is %v, want json-file (the driver every machine runs today)", keys["log-driver"])
|
||||
}
|
||||
want := map[string]any{"max-size": "100m", "max-file": "10"}
|
||||
if !reflect.DeepEqual(keys["log-opts"], want) {
|
||||
t.Errorf("log-opts is %v, want %v", keys["log-opts"], want)
|
||||
}
|
||||
if keys["live-restore"] != true {
|
||||
t.Errorf("live-restore is %v", keys["live-restore"])
|
||||
}
|
||||
// The values a person set are the terminal's only (novox/hq issue 346): the file says so.
|
||||
if daemon["trusted"] != true {
|
||||
t.Errorf("daemon asks for settings and says trusted %v, want true", daemon["trusted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLogRotationIsCheckedBeforeDaemonJSONIsWritten(t *testing.T) {
|
||||
m := readManifestForLogs(t)
|
||||
conf, confAt := resourceByID(m, "log-rotation")
|
||||
check, checkAt := resourceByID(m, "log-rotation-check")
|
||||
_, daemonAt := resourceByID(m, "daemon")
|
||||
if conf == nil || check == nil {
|
||||
t.Fatal("no log-rotation file or log-rotation-check step")
|
||||
}
|
||||
// The host applies a module's resources in declared order, and a step that fails skips the rest
|
||||
// of its module (novox/hq ADR 0136): declared before daemon, a refused value never reaches it.
|
||||
if !(confAt < checkAt && checkAt < daemonAt) {
|
||||
t.Errorf("order: log-rotation %d, log-rotation-check %d, daemon %d", confAt, checkAt, daemonAt)
|
||||
}
|
||||
if check["type"] != "process" || check["run-once"] != true || check["artifact"] != "tools" {
|
||||
t.Errorf("the check is not a step of the tools bundle: %v", check)
|
||||
}
|
||||
if !reflect.DeepEqual(check["run"], []any{"./docker-tools", "check-log-rotation"}) {
|
||||
t.Errorf("run %v", check["run"])
|
||||
}
|
||||
if !reflect.DeepEqual(check["restart-on"], []any{"log-rotation"}) {
|
||||
t.Errorf("restart-on %v", check["restart-on"])
|
||||
}
|
||||
if _, asRoot := check["user"]; asRoot {
|
||||
t.Errorf("the check reads a 0600 file and must run as root")
|
||||
}
|
||||
if conf["trusted"] != true {
|
||||
t.Errorf("log-rotation says trusted %v, want true", conf["trusted"])
|
||||
}
|
||||
// The step reads the very values daemon.json takes: the same keys, filled the same way.
|
||||
values, err := parseLogRotation(filled(t, m, conf["content"].(string)))
|
||||
if err != nil {
|
||||
t.Fatalf("the defaults are refused: %v", err)
|
||||
}
|
||||
if values != (logRotation{Size: "100m", Files: "10"}) {
|
||||
t.Errorf("defaults read as %+v", values)
|
||||
}
|
||||
daemon, _ := resourceByID(m, "daemon")
|
||||
for _, key := range []string{"${setting:log-max-size}", "${setting:log-max-file}"} {
|
||||
if !strings.Contains(daemon["content"].(string), key) || !strings.Contains(conf["content"].(string), key) {
|
||||
t.Errorf("%s is not in both daemon and log-rotation", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsNeverRestartedForTheLogRotation(t *testing.T) {
|
||||
m := readManifestForLogs(t)
|
||||
runtime, _ := resourceByID(m, "runtime")
|
||||
if _, restarts := runtime["restart-on"]; restarts {
|
||||
t.Errorf("docker.service restarts on %v: a restart stops every container", runtime["restart-on"])
|
||||
}
|
||||
if !reflect.DeepEqual(runtime["reload-on"], []any{"daemon"}) {
|
||||
t.Errorf("reload-on %v", runtime["reload-on"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestABadLogRotationIsRefused(t *testing.T) {
|
||||
good := map[string]string{"size": "100m", "files": "10"}
|
||||
conf := func(over map[string]string) string {
|
||||
lines := []string{"# a comment"}
|
||||
for _, k := range []string{"size", "files"} {
|
||||
v := good[k]
|
||||
if o, ok := over[k]; ok {
|
||||
v = o
|
||||
}
|
||||
lines = append(lines, k+"="+v)
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
for _, bad := range []map[string]string{
|
||||
{"size": "100"}, {"size": "10x"}, {"size": "0m"}, {"size": "-1"}, {"size": ""}, {"size": "m"},
|
||||
{"size": "1.5m"}, {"size": "10M"}, {"size": "100m\""}, {"size": `100m", "data-root": "/tmp`},
|
||||
{"size": "100m\nfiles=3"}, {"size": " 100m"}, {"size": "99999999999m"},
|
||||
{"files": "0"}, {"files": "100"}, {"files": "-1"}, {"files": ""}, {"files": "01"}, {"files": "1\""},
|
||||
{"files": "1\nfiles=2"}, {"files": "3.0"}, {"files": "ten"},
|
||||
} {
|
||||
if v, err := parseLogRotation(conf(bad)); err == nil {
|
||||
t.Errorf("%q accepted as %+v", bad, v)
|
||||
}
|
||||
}
|
||||
for _, broken := range []string{"size=100m\n", "files=10\n", "size=100m\nfiles=10\nfiles=3\n",
|
||||
"size=100m\nfiles=10\nmode=x\n", "size=100m\nfiles\n"} {
|
||||
if v, err := parseLogRotation(broken); err == nil {
|
||||
t.Errorf("%q accepted as %+v", broken, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGoodLogRotationIsAccepted(t *testing.T) {
|
||||
for _, c := range []struct{ size, files string }{{"100m", "10"}, {"512k", "1"}, {"2g", "99"}, {"50m", "3"}} {
|
||||
got, err := parseLogRotation("# comment\nsize=" + c.size + "\nfiles=" + c.files + "\n")
|
||||
if err != nil {
|
||||
t.Errorf("%v refused: %v", c, err)
|
||||
continue
|
||||
}
|
||||
if got != (logRotation{Size: c.size, Files: c.files}) {
|
||||
t.Errorf("%v read as %+v", c, got)
|
||||
}
|
||||
}
|
||||
// And from the file the step is handed.
|
||||
path := filepath.Join(t.TempDir(), "log-rotation.conf")
|
||||
if err := os.WriteFile(path, []byte("size=50m\nfiles=3\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkLogRotationFile(path); err != nil {
|
||||
t.Errorf("a good file refused: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("size=50\nfiles=3\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkLogRotationFile(path); err == nil || !strings.Contains(err.Error(), "log-max-size") {
|
||||
t.Errorf("a bad size was not refused by its setting's name: %v", err)
|
||||
}
|
||||
if err := checkLogRotationFile(filepath.Join(t.TempDir(), "absent")); err == nil {
|
||||
t.Error("an absent file was accepted")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,20 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Started as `docker-tools check-log-rotation` it is the module's step that judges the log rotation before
|
||||
// daemon.json takes it (logrotation.go, novox/hq issue 452): run as root by the host, reading the file
|
||||
// DOCKER_LOG_ROTATION names. With no argument, the runtime's tools bundle.
|
||||
if len(os.Args) > 1 {
|
||||
if os.Args[1] != "check-log-rotation" || len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: docker-tools [check-log-rotation]")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := checkLogRotationFile(os.Getenv("DOCKER_LOG_ROTATION")); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): docker.
|
||||
if err := stdio.Serve("", tools(NewClient())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
|
||||
Reference in New Issue
Block a user