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:} 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") } }