diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 419c448..f5cd34f 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -466,22 +466,18 @@ func buildConfig(cfg *config.Config) build.Config { return build.Config{ Namespace: cfg.Registry.BuildNamespace, RegistryURL: cfg.Registry.URL, - // Empty overrides fall back to the build package's defaults, so an - // install that has not imported kaniko/trivy keeps the compiled-in refs - // (and fails loudly on pull rather than silently building with the wrong - // image). + // Empty overrides fall back to the registry's copies of the tools + // (build.Tools), which felis mirror-build-tools keeps current. KanikoImage: cfg.Registry.KanikoImage, TrivyImage: cfg.Registry.TrivyImage, CPULimit: cfg.Registry.BuildCPULimit, MemLimit: cfg.Registry.BuildMemLimit, DiskLimit: cfg.Registry.BuildDiskLimit, // "auto" follows the startup probe (see probeBuildUserNamespaces). - UserNamespaces: cfg.Registry.BuildUserNamespaces, - UserNamespacesProbe: new(atomic.Bool), - RuntimeClass: cfg.Registry.BuildRuntimeClass, - MaxConcurrent: cfg.Registry.MaxConcurrentBuilds, - // Empty keeps Trivy's own default; an install with builds points this at - // the internal DB mirror (see config.RegistryConfig.TrivyDBRepository). + UserNamespaces: cfg.Registry.BuildUserNamespaces, + UserNamespacesProbe: new(atomic.Bool), + RuntimeClass: cfg.Registry.BuildRuntimeClass, + MaxConcurrent: cfg.Registry.MaxConcurrentBuilds, TrivyDBRepository: cfg.Registry.TrivyDBRepository, TrivyJavaDBRepository: cfg.Registry.TrivyJavaDBRepository, // The submit lane's derived context URLs live here; the fetch step's @@ -669,11 +665,7 @@ func registryPruner(cfg *config.Config, store imageRefStore, servers serverListe fmt.Fprintf(stderr, "felis api: registry pruner disabled (%s unset) — images nothing uses are never deleted from the registry\n", platform.RegistryPruneTokenEnv) return nil } - static := []string{ - os.Getenv("FELIS_IMAGE"), - cfg.Registry.KanikoImage, cfg.Registry.TrivyImage, - cfg.Registry.TrivyDBRepository, cfg.Registry.TrivyJavaDBRepository, - } + static := append([]string{os.Getenv("FELIS_IMAGE")}, buildConfig(cfg).ToolRefs()...) return ®istryprune.Pruner{ Registry: ®istryprune.Client{Endpoint: "http://" + cfg.Registry.URL, Token: token}, Host: cfg.Registry.URL, diff --git a/cmd/felis/mirrortools.go b/cmd/felis/mirrortools.go new file mode 100644 index 0000000..fb01046 --- /dev/null +++ b/cmd/felis/mirrortools.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "felis.lolicon.best/internal/build" + "felis.lolicon.best/internal/imagepush" + "felis.lolicon.best/internal/registrygate" +) + +// defaultBuildToolsStatus is where mirror-build-tools records its last run; the +// watchdog reads it to tell a vulnerability DB that stopped refreshing. +const defaultBuildToolsStatus = "/var/lib/felis/build-tools/status.json" + +// cmdMirrorBuildTools copies the build lane's tools (build.Tools: the kaniko and +// trivy images, Trivy's vulnerability and Java DBs) from upstream into the +// platform registry, where build Jobs pull them. deploy/bootstrap.sh runs it at +// install and from felis-build-tools.timer twice a day, which is what keeps the +// DBs fresh; a root shell can run it the same way to refresh now. +// +// It writes as the platform principal through the node's loopback hostPort, the +// same way the installer pushes, reading the token from the environment or from +// /etc/felis/secrets.env. +func cmdMirrorBuildTools(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("mirror-build-tools", flag.ContinueOnError) + fs.SetOutput(stderr) + endpoint := fs.String("endpoint", "127.0.0.1:5000", "host[:port] of the registry to write to (plain HTTP)") + only := fs.String("only", "", "comma-separated tool names to copy (default: all of "+toolNames()+")") + status := fs.String("status", defaultBuildToolsStatus, `file to record the run in ("" records nothing)`) + secrets := fs.String("secrets-env", "/etc/felis/secrets.env", "installer secrets file holding REGISTRY_PLATFORM_TOKEN, read when FELIS_REGISTRY_PASSWORD is unset") + platformFlag := fs.String("platform", "", "os/arch of the images to copy (default: this machine's)") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + tools, err := selectTools(*only) + if err != nil { + fmt.Fprintf(stderr, "felis mirror-build-tools: %v\n", err) + return 2 + } + if err := loadEnvFile(*secrets); err != nil { + fmt.Fprintf(stderr, "felis mirror-build-tools: read %s: %v\n", *secrets, err) + return 1 + } + user, pass := os.Getenv("FELIS_REGISTRY_USERNAME"), os.Getenv("FELIS_REGISTRY_PASSWORD") + if pass == "" { + user, pass = registrygate.PrincipalPlatform, os.Getenv("REGISTRY_PLATFORM_TOKEN") + } + if pass == "" { + fmt.Fprintln(stderr, "felis mirror-build-tools: no registry credential: set FELIS_REGISTRY_PASSWORD or run as root on the node (REGISTRY_PLATFORM_TOKEN in /etc/felis/secrets.env)") + return 2 + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + p := &imagepush.Pusher{Scheme: "http", Username: user, Password: pass, Log: stdout} + src := &imagepush.Source{Platform: *platformFlag} + started := time.Now() + var failed []string + for _, t := range tools { + dst := strings.TrimSuffix(*endpoint, "/") + "/" + t.Mirror + if _, err := p.Mirror(ctx, src, t.Source, dst); err != nil { + fmt.Fprintf(stderr, "felis mirror-build-tools: %s: %v\n", t.Name, err) + failed = append(failed, t.Name+": "+err.Error()) + } + } + if *status != "" { + st, err := imagepush.ReadMirrorStatus(*status) + if err != nil || st == nil { + st = &imagepush.MirrorStatus{} + } + st.LastAttempt = started + st.LastError = strings.Join(failed, "; ") + if len(failed) == 0 { + st.LastSuccess = started + } + if err := imagepush.WriteMirrorStatus(*status, *st); err != nil { + fmt.Fprintf(stderr, "felis mirror-build-tools: record %s: %v\n", *status, err) + } + } + if len(failed) > 0 { + return 1 + } + return 0 +} + +func toolNames() string { + var names []string + for _, t := range build.Tools { + names = append(names, t.Name) + } + return strings.Join(names, ",") +} + +func selectTools(only string) ([]build.Tool, error) { + if only == "" { + return build.Tools, nil + } + var out []build.Tool + for _, name := range strings.Split(only, ",") { + name = strings.TrimSpace(name) + found := false + for _, t := range build.Tools { + if t.Name == name { + out = append(out, t) + found = true + } + } + if !found { + return nil, fmt.Errorf("unknown tool %q (known: %s)", name, toolNames()) + } + } + return out, nil +} diff --git a/cmd/felis/offsite.go b/cmd/felis/offsite.go index 9dc14b0..56bfece 100644 --- a/cmd/felis/offsite.go +++ b/cmd/felis/offsite.go @@ -90,10 +90,10 @@ type offsiteEnv struct { key []byte } -// loadOffsiteEnvFile sets each KEY=VALUE of path that is not already in the -// environment, so a root shell reaches the bucket the same way the unit does. +// loadEnvFile sets each KEY=VALUE of path that is not already in the +// environment, so a root shell runs a command the same way its unit does. // A missing file is not an error. -func loadOffsiteEnvFile(path string) error { +func loadEnvFile(path string) error { if path == "" { return nil } @@ -167,7 +167,7 @@ func resolveOffsite(c config.OffsiteConfig) (*offsiteEnv, error) { // loadOffsite loads felis.toml and the env file and resolves [offsite]. func loadOffsite(cfgPath, envFile string) (*config.Config, *offsiteEnv, error) { - if err := loadOffsiteEnvFile(envFile); err != nil { + if err := loadEnvFile(envFile); err != nil { return nil, nil, fmt.Errorf("read %s: %w", envFile, err) } cfg, err := config.Load(cfgPath) @@ -454,7 +454,7 @@ func offsiteFetchDB(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) i fmt.Fprint(stderr, offsiteUsage) return 2 } - if err := loadOffsiteEnvFile(*envFile); err != nil { + if err := loadEnvFile(*envFile); err != nil { fmt.Fprintf(stderr, "felis offsite fetch-db: read %s: %v\n", *envFile, err) return 1 } diff --git a/cmd/felis/offsite_test.go b/cmd/felis/offsite_test.go index ac1e6bd..ace1c13 100644 --- a/cmd/felis/offsite_test.go +++ b/cmd/felis/offsite_test.go @@ -26,7 +26,7 @@ not a line t.Setenv("FELIS_OFFSITE_ACCESS_KEY", "from-the-shell") t.Setenv("FELIS_OFFSITE_SECRET_KEY", "") t.Setenv("FELIS_OFFSITE_KEY", "") - if err := loadOffsiteEnvFile(path); err != nil { + if err := loadEnvFile(path); err != nil { t.Fatal(err) } for k, want := range map[string]string{ @@ -38,7 +38,7 @@ not a line t.Errorf("%s = %q, want %q", k, got, want) } } - if err := loadOffsiteEnvFile(filepath.Join(t.TempDir(), "absent")); err != nil { + if err := loadEnvFile(filepath.Join(t.TempDir(), "absent")); err != nil { t.Errorf("a missing env file is not an error: %v", err) } } diff --git a/cmd/felis/run.go b/cmd/felis/run.go index 221180e..5c4c22e 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -24,6 +24,7 @@ Commands: egress-gate Hold a build pod until its egress NetworkPolicy is enforced (internal Job entrypoint) fetch-context Fetch and extract a submission's build context (internal Job entrypoint) push-image Push a scanned image tarball to the registry (internal Job entrypoint) + mirror-build-tools Copy kaniko, trivy and Trivy's DBs into the registry (run by felis-build-tools.timer) registry-gate Authorize registry writes in front of registry:2 (internal sidecar entrypoint) manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML apply Create a MinecraftServer CRD (direct K8s write; use -f server.json) @@ -47,32 +48,33 @@ Run "felis -h" for command-specific flags. // The help aliases are deliberately NOT entries: they print usage rather than run a // subcommand, and listing them would make the table disagree with the command list. var commands = map[string]func(args []string, stdout, stderr io.Writer) int{ - "migrate": cmdMigrate, - "db": cmdDB, - "offsite": cmdOffsite, - "operator": cmdOperator, - "api": cmdAPI, - "nano": cmdNano, - "reaper": cmdReaper, - "restore": cmdRestore, - "backup": cmdBackup, - "files": cmdFiles, - "egress-gate": cmdEgressGate, - "fetch-context": cmdFetchContext, - "push-image": cmdPushImage, - "registry-gate": cmdRegistryGate, - "manifests": cmdManifests, - "apply": cmdApply, - "setup": cmdSetup, - "converge": cmdConverge, - "breakGlass": cmdBreakGlass, - "bootstrap-assets": cmdBootstrapAssets, - "init-forwarding": cmdInitForwarding, - "init-volume": cmdInitVolume, - "pin-images": cmdPinImages, - "version": cmdVersion, - "update": cmdUpdate, - "watchdog": cmdWatchdog, + "migrate": cmdMigrate, + "db": cmdDB, + "offsite": cmdOffsite, + "operator": cmdOperator, + "api": cmdAPI, + "nano": cmdNano, + "reaper": cmdReaper, + "restore": cmdRestore, + "backup": cmdBackup, + "files": cmdFiles, + "egress-gate": cmdEgressGate, + "fetch-context": cmdFetchContext, + "push-image": cmdPushImage, + "mirror-build-tools": cmdMirrorBuildTools, + "registry-gate": cmdRegistryGate, + "manifests": cmdManifests, + "apply": cmdApply, + "setup": cmdSetup, + "converge": cmdConverge, + "breakGlass": cmdBreakGlass, + "bootstrap-assets": cmdBootstrapAssets, + "init-forwarding": cmdInitForwarding, + "init-volume": cmdInitVolume, + "pin-images": cmdPinImages, + "version": cmdVersion, + "update": cmdUpdate, + "watchdog": cmdWatchdog, } // run dispatches a subcommand. It is separate from main so the router is diff --git a/cmd/felis/watchdog.go b/cmd/felis/watchdog.go index c42a033..bfaa665 100644 --- a/cmd/felis/watchdog.go +++ b/cmd/felis/watchdog.go @@ -42,6 +42,7 @@ func cmdWatchdog(args []string, stdout, stderr io.Writer) int { proxyAddr := fs.String("proxy-addr", "", `game proxy address to dial, e.g. 127.0.0.1:25565 ("" skips the check)`) controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "namespace of the control plane") offsiteStatus := fs.String("offsite-status", offsite.DefaultStatusFile, "the record `felis offsite sync` leaves, checked when [offsite] is configured") + toolsStatus := fs.String("build-tools-status", defaultBuildToolsStatus, "the record `felis mirror-build-tools` leaves, checked when builds scan against the registry's DB copy") dryRun := fs.Bool("dry-run", false, "print every finding and the mail that is due; send nothing and keep the state as it was") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -107,6 +108,9 @@ func cmdWatchdog(args []string, stdout, stderr io.Writer) int { if cfg.Offsite.Enabled() { add(watchdog.OffsiteFinding(*offsiteStatus, now)) } + if usesMirroredScanDB(cfg) { + add(watchdog.ScanDBFinding(*toolsStatus, now)) + } report.Findings = append(report.Findings, watchdog.DiskFindings(splitList(*diskPaths))...) add(watchdog.MemoryFinding("/proc/meminfo")) @@ -161,6 +165,17 @@ func cmdWatchdog(args []string, stdout, stderr io.Writer) int { return save() } +// usesMirroredScanDB reports whether build scans read the vulnerability DB copy +// felis mirror-build-tools keeps in the platform registry: the default, or an +// explicit trivy_db_repository under the registry's mirror/. +func usesMirroredScanDB(cfg *config.Config) bool { + if cfg.Registry.URL == "" { + return false + } + repo := cfg.Registry.TrivyDBRepository + return repo == "" || strings.HasPrefix(repo, cfg.Registry.URL+"/mirror/") +} + // refreshSMTPPassword caches the relay password from the felis-smtp Secret, or // forgets it when the Secret is gone (a relay without AUTH). An env var named by // [smtp] password_ref, when set, wins at send time instead. diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index f680030..565f168 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -279,6 +279,9 @@ WATCHDOG_STATE="/var/lib/felis/watchdog/state.json" OFFSITE_ENV="${STATE_DIR}/offsite.env" OFFSITE_SERVICE="/etc/systemd/system/felis-offsite.service" OFFSITE_TIMER="/etc/systemd/system/felis-offsite.timer" +BUILD_TOOLS_SERVICE="/etc/systemd/system/felis-build-tools.service" +BUILD_TOOLS_TIMER="/etc/systemd/system/felis-build-tools.timer" +BUILD_TOOLS_STATUS="/var/lib/felis/build-tools/status.json" # While this marker holds a future Unix time, felis watchdog mails nothing: an install # restarts the control plane and the system servers on purpose. cleanup removes it; the # time in it is the backstop for an installer killed before its EXIT trap runs. @@ -2679,6 +2682,47 @@ EOF fi } +# The build lane's tools: kaniko and trivy (pinned by digest in internal/build/tools.go) +# and Trivy's vulnerability and Java DBs, copied into the registry's mirror/ where build +# Jobs pull them; the build namespace has no internet egress. The timer refreshes the DBs +# twice a day (upstream publishes every six hours) and the watchdog warns when three days +# pass without a clean run. The first copy starts now in the background: the Java DB +# alone is several hundred MB. +install_build_tools_timer() { + install -d -m 0755 "$(dirname "$BUILD_TOOLS_STATUS")" + cat > "$BUILD_TOOLS_SERVICE" < "$BUILD_TOOLS_TIMER" <:", tl.Name, tl.Mirror) + } + isDB := strings.HasSuffix(tl.Name, "-db") + if pinned := strings.Contains(tl.Source, "@sha256:"); pinned == isDB { + t.Errorf("%s: source %q: executor images must be pinned by digest, DBs follow their tag", tl.Name, tl.Source) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 41aa7cc..fa8ef44 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -148,13 +148,10 @@ type RegistryConfig struct { URL string `toml:"url"` BuildNamespace string `toml:"build_namespace"` // KanikoImage / TrivyImage / BuildCPULimit / BuildMemLimit override the - // build subsystem's compiled-in defaults (gcr.io/kaniko-project/executor and - // aquasec/trivy, 2 CPU / 4Gi per build container). The kubelet pulls the - // executor images over the node's network, so the defaults need a node that - // can reach those registries; an air-gapped or mirrored install points these - // at images mirrored into the in-cluster registry (docs/troubleshooting.md - // §8e). A bare node-containerd import does not survive an image GC, there is - // no pull source for it. Empty keeps the default. + // build subsystem's defaults: the kaniko and trivy copies the installer keeps + // in this registry under mirror/ (build.Tools), 2 CPU / 4Gi per build + // container. Set the images only to run another build of the tools + // (docs/troubleshooting.md §8e). Empty keeps the default. KanikoImage string `toml:"kaniko_image"` TrivyImage string `toml:"trivy_image"` BuildCPULimit string `toml:"build_cpu_limit"` @@ -173,25 +170,18 @@ type RegistryConfig struct { // Zero keeps 2; at most 6 (the build namespace's pod quota). MaxConcurrentBuilds int `toml:"max_concurrent_builds"` // TrivyDBRepository points Trivy at an OCI repository holding the - // vulnerability DB (--db-repository). Trivy's default fetches from - // mirror.gcr.io/ghcr.io, which the build egress lock denies — so on a - // default install the scan step fails closed and no build ever completes. - // The supported shape is an internal mirror: copy - // mirror.gcr.io/aquasec/trivy-db:2 into this cluster's registry (recipe in - // docs/troubleshooting.md §8) and set this to - // registry..svc:5000/mirror/trivy-db:2. The scan runs with --insecure, - // so the plain-HTTP internal registry works. Empty keeps Trivy's own - // default (only usable on an install that deliberately opens internet - // egress to the DB hosts). + // vulnerability DB (--db-repository). Trivy's own default fetches from + // mirror.gcr.io/ghcr.io, which the build egress lock denies, so the default + // here is the copy felis-build-tools.timer refreshes in this registry, + // /mirror/trivy-db:2 (build.Tools). The scan runs with --insecure, so + // the plain-HTTP internal registry works. Empty keeps that default. TrivyDBRepository string `toml:"trivy_db_repository"` // TrivyJavaDBRepository points Trivy at an OCI repository holding the Java // DB (--java-db-repository). Trivy fetches it lazily whenever the scanned // image contains Java artifacts — every real modpack image does — so on an - // egress-locked box the scan fails closed without this mirror exactly like - // the vulnerability DB. The supported shape is an internal mirror: copy - // mirror.gcr.io/aquasec/trivy-java-db:1 into this cluster's registry and - // set this to registry..svc:5000/mirror/trivy-java-db:1 (recipe in - // docs/troubleshooting.md §8e). Empty keeps Trivy's own default. + // egress-locked box it comes from this registry exactly like the + // vulnerability DB: /mirror/trivy-java-db:1 by default. Empty keeps that + // default. TrivyJavaDBRepository string `toml:"trivy_java_db_repository"` // UserUploadsContext is the object-store base under which a user-submitted // modpack's Kaniko build context is pinned. It belongs to the §16 build diff --git a/internal/watchdog/probes.go b/internal/watchdog/probes.go index b95ba6e..53f9d78 100644 --- a/internal/watchdog/probes.go +++ b/internal/watchdog/probes.go @@ -12,6 +12,7 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/dbbackup" + "felis.lolicon.best/internal/imagepush" "felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/offsite" "felis.lolicon.best/internal/platform" @@ -45,6 +46,11 @@ const ( maxBackupAge = 26 * time.Hour // maxReaperAge is the same for the daily reaper CronJob. maxReaperAge = 26 * time.Hour + // maxScanDBAge is how old the registry's copy of Trivy's vulnerability DB may + // grow. felis-build-tools.timer refreshes it twice a day and upstream publishes + // every six hours; three days of failed refreshes means scans are passing + // images against advisories that are no longer current. + maxScanDBAge = 72 * time.Hour diskLowRatio = 0.15 diskCriticalRatio = 0.05 @@ -371,6 +377,42 @@ func OffsiteFinding(statusFile string, now time.Time) *Finding { return f } +// ScanDBFinding reports the build lane's tool copies (the vulnerability DBs in +// particular) not refreshed within maxScanDBAge, going by the record +// `felis mirror-build-tools` leaves in statusFile. A build still runs and its scan +// still gates, but against an old DB: a warning. +func ScanDBFinding(statusFile string, now time.Time) *Finding { + const hint = "journalctl -u felis-build-tools -n 50; refresh now with `sudo felis mirror-build-tools` (docs/troubleshooting.md §8e)" + st, err := imagepush.ReadMirrorStatus(statusFile) + if err != nil { + return &Finding{ + Key: "scan-db", Severity: Warning, For: backupFor, + Summary: fmt.Sprintf("无法读取构建工具镜像状态 %s", statusFile), + SummaryEN: fmt.Sprintf("cannot read the build tools status %s: %v", statusFile, err), + Hint: hint, + } + } + if st != nil && !st.LastSuccess.IsZero() && now.Sub(st.LastSuccess) <= maxScanDBAge { + return nil + } + f := &Finding{ + Key: "scan-db", Severity: Warning, For: backupFor, + Summary: "漏洞库从未复制进内置 registry,构建的漏洞扫描无法运行", + SummaryEN: "the vulnerability DB was never copied into the registry; build scans cannot run", + Hint: hint, + } + if st != nil && !st.LastSuccess.IsZero() { + age := roundHours(now.Sub(st.LastSuccess)) + f.Summary = fmt.Sprintf("漏洞库已有 %s 没有刷新,构建扫描用的是过期数据", age) + f.SummaryEN = fmt.Sprintf("the vulnerability DB was last refreshed %s ago; build scans use stale advisories", age) + } + if st != nil && st.LastError != "" { + f.Summary += "(最近一次错误:" + st.LastError + ")" + f.SummaryEN += " (last error: " + st.LastError + ")" + } + return f +} + // DiskFindings reports each filesystem under paths that is running out of // space. Paths on one filesystem are reported once, under the first of them; a // path that does not exist is skipped (a feature that is not in use). diff --git a/internal/watchdog/probes_test.go b/internal/watchdog/probes_test.go index 3a7a8d9..2b343d9 100644 --- a/internal/watchdog/probes_test.go +++ b/internal/watchdog/probes_test.go @@ -12,6 +12,7 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/dbbackup" + "felis.lolicon.best/internal/imagepush" "felis.lolicon.best/internal/offsite" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" @@ -210,3 +211,24 @@ func TestDiskFindingsDedupAndSkip(t *testing.T) { t.Fatalf("findings = %v, want at most one for one filesystem", findingKeys(got)) } } + +func TestScanDBFinding(t *testing.T) { + now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + path := filepath.Join(t.TempDir(), "status.json") + if f := ScanDBFinding(path, now); f == nil || !strings.Contains(f.SummaryEN, "never copied") { + t.Errorf("no status file: %+v", f) + } + if err := imagepush.WriteMirrorStatus(path, imagepush.MirrorStatus{LastAttempt: now, LastSuccess: now.Add(-24 * time.Hour)}); err != nil { + t.Fatal(err) + } + if f := ScanDBFinding(path, now); f != nil { + t.Errorf("a day-old DB: %+v", f) + } + if err := imagepush.WriteMirrorStatus(path, imagepush.MirrorStatus{LastAttempt: now, LastSuccess: now.Add(-100 * time.Hour), LastError: "trivy-db: dial tcp: timeout"}); err != nil { + t.Fatal(err) + } + f := ScanDBFinding(path, now) + if f == nil || f.Severity != Warning || !strings.Contains(f.SummaryEN, "100h") || !strings.Contains(f.SummaryEN, "dial tcp") { + t.Errorf("stale DB: %+v", f) + } +}