feat(build): kaniko/trivy 与扫描库改用 registry 内的 mirror 副本,定时刷新并在过期时告警

This commit is contained in:
Lemon-miaow committed 2026-09-24 23:25:21 +08:00
1 parent c0643af118
commit bf18712a6c
14 files changed
+426 -82

No files matched your search

+7 -15
View File
@@ -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 &registryprune.Pruner{
Registry: &registryprune.Client{Endpoint: "http://" + cfg.Registry.URL, Token: token},
Host: cfg.Registry.URL,
+125
View File
@@ -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
}
+5 -5
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
}
+28 -26
View File
@@ -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 <command> -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
+15
View File
@@ -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.
+46
View File
@@ -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" <<EOF
[Unit]
Description=Copy the Felis build tools and Trivy's vulnerability DBs into the registry
After=network-online.target k3s.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${HOST_BIN} mirror-build-tools -endpoint ${REGISTRY_PUSH_HOST} -status ${BUILD_TOOLS_STATUS} -secrets-env ${SECRETS_ENV}
TimeoutStartSec=1h
Nice=10
PrivateTmp=yes
NoNewPrivileges=yes
ProtectSystem=full
EOF
cat > "$BUILD_TOOLS_TIMER" <<EOF
[Unit]
Description=Refresh the Felis build tools and Trivy DBs twice a day
[Timer]
OnCalendar=*-*-* 04,16:00:00
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now felis-build-tools.timer
systemctl start --no-block felis-build-tools.service
ok "build tools: kaniko, trivy and the Trivy DBs are being copied into the registry (journalctl -u felis-build-tools); refreshed twice a day"
}
# The daily database backup. The first run happens now, so a broken pipeline (pg_dump
# missing, directory unwritable) shows up in this install rather than in the first
# restore someone needs.
@@ -3426,6 +3470,8 @@ main() {
# AFTER deploy_bundle: the registry the built images are mirrored into is part
# of that bundle.
push_images_to_registry
# After deploy_bundle, like the pushes: it writes into the registry.
install_build_tools_timer
restart_existing_system_servers
# After deploy_bundle: the proxy dials felis-api's internal ClusterIP, which does not
# exist until the bundle is applied.
+15 -10
View File
@@ -234,16 +234,15 @@ type Config struct {
// shape); an install that never builds user submissions can leave it empty.
FelisImage string
// TrivyDBRepository overrides where Trivy fetches its vulnerability DB
// (--db-repository). Empty keeps Trivy's upstream default, which the build
// egress lock denies — an install with builds must point this at an internal
// mirror (see config.RegistryConfig.TrivyDBRepository).
// (--db-repository). Empty means the platform registry's copy (Tools); with no
// RegistryURL it stays empty, which keeps Trivy's upstream default.
TrivyDBRepository string
// TrivyJavaDBRepository overrides where Trivy fetches its Java DB
// (--java-db-repository), downloaded lazily for images that contain Java
// artifacts — i.e. every real modpack. Same egress story as the
// vulnerability DB (see config.RegistryConfig.TrivyJavaDBRepository).
// artifacts — i.e. every real modpack. Defaults like TrivyDBRepository.
TrivyJavaDBRepository string
// KanikoImage / TrivyImage are the executor images.
// KanikoImage / TrivyImage are the executor images. Empty means the platform
// registry's copies (Tools).
KanikoImage string
TrivyImage string
// Deadline caps a build's wall-clock (spec §16: activeDeadlineSeconds).
@@ -301,8 +300,6 @@ func (c Config) userNamespaces() bool {
const (
defaultNamespace = "felis-build"
defaultServiceAccount = "felis-build"
defaultKanikoImage = "gcr.io/kaniko-project/executor:latest"
defaultTrivyImage = "aquasec/trivy:latest"
defaultDeadline = 30 * time.Minute
defaultMaxDockerfile = 256 * 1024 // 256 KiB
defaultCPULimit = "2"
@@ -325,11 +322,19 @@ func (c Config) withDefaults() Config {
if c.ServiceAccount == "" {
c.ServiceAccount = defaultServiceAccount
}
// The executor images and the scan DBs default to the platform registry's
// copies (Tools); an explicit felis.toml value wins.
if c.KanikoImage == "" {
c.KanikoImage = defaultKanikoImage
c.KanikoImage = toolRef(c.RegistryURL, "kaniko")
}
if c.TrivyImage == "" {
c.TrivyImage = defaultTrivyImage
c.TrivyImage = toolRef(c.RegistryURL, "trivy")
}
if c.TrivyDBRepository == "" && c.RegistryURL != "" {
c.TrivyDBRepository = toolRef(c.RegistryURL, "trivy-db")
}
if c.TrivyJavaDBRepository == "" && c.RegistryURL != "" {
c.TrivyJavaDBRepository = toolRef(c.RegistryURL, "trivy-java-db")
}
if c.Deadline <= 0 {
c.Deadline = defaultDeadline
+2 -2
View File
@@ -18,8 +18,8 @@ func sampleJobParams() JobParams {
ServiceAccount: defaultServiceAccount,
RegistryURL: "registry.felis.svc:5000",
FelisImage: "felis:test",
KanikoImage: defaultKanikoImage,
TrivyImage: defaultTrivyImage,
KanikoImage: toolRef("", "kaniko"),
TrivyImage: toolRef("", "trivy"),
Deadline: 30 * time.Minute,
CPULimit: "2",
MemLimit: "4Gi",
+59
View File
@@ -0,0 +1,59 @@
package build
import "strings"
// Tool is an image or OCI artifact a build Job runs or reads: where it comes
// from upstream, and the repository:tag the platform registry keeps its copy
// under. A build pulls only the copy. The build namespace has no internet egress,
// so Trivy cannot reach the upstream DBs, and the node pulls the executor images
// from the in-cluster registry like any other image, which also survives an image
// GC. `felis mirror-build-tools` (deploy/bootstrap.sh runs it at install and from
// felis-build-tools.timer twice a day) copies each Source to its Mirror.
type Tool struct {
Name string
// Source is the upstream reference. The executor images are pinned by the
// digest of their multi-platform index, so a moved or re-pushed upstream tag
// cannot change what a build runs; the DBs follow their tag, since a fresh
// vulnerability DB is the point of refreshing them.
Source string
// Mirror is repository:tag inside the platform registry.
Mirror string
}
// Tools lists every tool a build needs. Kaniko is archived upstream (June 2025)
// and v1.24.0 is its final release; docs/troubleshooting.md §8e covers moving to a
// maintained fork.
var Tools = []Tool{
{Name: "kaniko", Source: "gcr.io/kaniko-project/executor:v1.24.0@sha256:4e7a52dd1f14872430652bb3b027405b8dfd17c4538751c620ac005741ef9698", Mirror: "mirror/kaniko-executor:v1.24.0"},
{Name: "trivy", Source: "ghcr.io/aquasecurity/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969", Mirror: "mirror/trivy:0.74.0"},
{Name: "trivy-db", Source: "mirror.gcr.io/aquasec/trivy-db:2", Mirror: "mirror/trivy-db:2"},
{Name: "trivy-java-db", Source: "mirror.gcr.io/aquasec/trivy-java-db:1", Mirror: "mirror/trivy-java-db:1"},
}
// tool returns the named entry of Tools.
func tool(name string) Tool {
for _, t := range Tools {
if t.Name == name {
return t
}
}
panic("build: unknown tool " + name)
}
// toolRef is where a build reads tool name: its copy in registry, or the upstream
// source when there is no platform registry (tests, a bare Config).
func toolRef(registry, name string) string {
t := tool(name)
if registry == "" {
return t.Source
}
return strings.TrimSuffix(registry, "/") + "/" + t.Mirror
}
// ToolRefs returns the four references a build of this Config reads, after
// defaults: the kaniko and trivy images and the two Trivy DB repositories. The
// registry pruner keeps each of them.
func (c Config) ToolRefs() []string {
c = c.withDefaults()
return []string{c.KanikoImage, c.TrivyImage, c.TrivyDBRepository, c.TrivyJavaDBRepository}
}
+46
View File
@@ -0,0 +1,46 @@
package build
import (
"strings"
"testing"
)
// A build reads every tool from the platform registry's copy by default, and
// each copy's repository lives under mirror/, which only the platform principal
// may write and the pruner keeps.
func TestToolRefsDefaultToTheRegistryCopies(t *testing.T) {
got := Config{RegistryURL: "registry.felis.svc:5000"}.ToolRefs()
want := []string{
"registry.felis.svc:5000/mirror/kaniko-executor:v1.24.0",
"registry.felis.svc:5000/mirror/trivy:0.74.0",
"registry.felis.svc:5000/mirror/trivy-db:2",
"registry.felis.svc:5000/mirror/trivy-java-db:1",
}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("ToolRefs = %v, want %v", got, want)
}
explicit := Config{RegistryURL: "r:5000", KanikoImage: "r:5000/mirror/kaniko-fork:v2", TrivyDBRepository: "r:5000/mirror/db:9"}.ToolRefs()
if explicit[0] != "r:5000/mirror/kaniko-fork:v2" || explicit[2] != "r:5000/mirror/db:9" {
t.Errorf("explicit values lost: %v", explicit)
}
// Without a registry the images are the pinned upstream sources and Trivy
// keeps its own DB default.
bare := Config{}.ToolRefs()
if !strings.Contains(bare[0], "@sha256:") || !strings.Contains(bare[1], "@sha256:") || bare[2] != "" || bare[3] != "" {
t.Errorf("bare ToolRefs = %v", bare)
}
}
func TestToolsArePinnedAndMirroredUnderMirror(t *testing.T) {
for _, tl := range Tools {
if !strings.HasPrefix(tl.Mirror, "mirror/") || !strings.Contains(tl.Mirror, ":") {
t.Errorf("%s: mirror %q must be mirror/<repo>:<tag>", 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)
}
}
}
+12 -22
View File
@@ -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.<ns>.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,
// <url>/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.<ns>.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: <url>/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
+42
View File
@@ -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).
+22
View File
@@ -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)
}
}