mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-27 12:44:16 +00:00
buildOCILayout already removes its scan dir on every error path, and syft.go
defers the stereoscope generator's Cleanup. What neither can do is clean up
after a process that dies mid-scan: the deferred call never runs, and nothing
afterwards ever looks at what was left. Every restart therefore leaks the
in-flight layout and extraction permanently, and a restart is routine — a
deploy is one.
On seamark-hold that reached 8.8 GB of orphaned scan-*, syft-scan-* and
syft-cataloger-* directories under a 20 GB disk, at which point the disk was
97% full and scans began failing on it:
failed to load OCI image: unable to populate layer cache
dir="/var/lib/seamark/scanner/tmp/syft-scan-1546187834/..."
: no space left on device
failed to download layer 5: failed to write blob:
write /var/lib/seamark/scanner/tmp/scan-4160414849/blobs/sha256/...
: no space left on device
The leaked directories cluster at the scanner's restart timestamps, which is
what identifies the killed process rather than the error paths as the source.
Manual removal reclaimed 8.8 GB and took the disk from 97% to 50%.
Startup is where this belongs: it is the one moment the previous process is
known to be gone, and it is immediately after the event that caused the leak.
The sweep runs in WorkerPool.Start after TMPDIR is set and before any worker
can dequeue, so nothing it removes can be work in progress here.
Three constraints shape what it will touch:
- Only the three per-job prefixes, only as direct children, only
directories. The Grype database lives beside the tmp dir at
<parent>/vulndb and go-getter unpacks into grype-dl underneath it; both
are state the scanner needs and neither matches a prefix. The prefixes now
have one definition each, used by both the creator and the sweeper, so
renaming a directory cannot silently take it out of the sweep's scope.
- An age threshold, vuln.sweep_max_age, default 1h. A second scanner sharing
the directory has an in-flight scan-* dir that is minutes old, and
scanner.job_timeout is 8m, so an hour clears both with room to spare. 0
disables the sweep rather than removing a peer's live work.
- Nothing is fatal. A stat or removal failure is a WARN and the sweep moves
on, so a permission problem in the tmp dir cannot keep the scanner from
starting.
The sweep only runs at startup, so a scanner that is killed twice between
deploys carries the first leak until its next restart. That is the tradeoff
for never racing a live peer; a periodic sweep would be the follow-up if
processes ever live long enough for it to matter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
145 lines
5.9 KiB
Go
145 lines
5.9 KiB
Go
// Package config provides Viper-based configuration for the scanner service.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
"atcr.io/pkg/config"
|
|
)
|
|
|
|
// Config holds all scanner configuration.
|
|
type Config struct {
|
|
Version string `yaml:"version" comment:"Configuration format version."`
|
|
LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."`
|
|
LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."`
|
|
Server ServerConfig `yaml:"server" comment:"Health endpoint settings."`
|
|
Hold HoldConfig `yaml:"hold" comment:"Hold service connection settings."`
|
|
Scanner ScannerConfig `yaml:"scanner" comment:"Worker pool settings."`
|
|
Vuln VulnConfig `yaml:"vuln" comment:"Vulnerability scanning (Grype) settings."`
|
|
}
|
|
|
|
// ServerConfig defines the health endpoint settings.
|
|
type ServerConfig struct {
|
|
// Listen address for the health endpoint.
|
|
Addr string `yaml:"addr" comment:"Listen address for the health endpoint, e.g. \":9090\"."`
|
|
}
|
|
|
|
// HoldConfig defines the hold service connection.
|
|
type HoldConfig struct {
|
|
// WebSocket URL of the hold service.
|
|
URL string `yaml:"url" comment:"WebSocket URL of the hold service (REQUIRED), e.g. \"ws://localhost:8080\"."`
|
|
|
|
// Shared secret for scanner authentication.
|
|
Secret string `yaml:"secret" comment:"Shared secret for scanner WebSocket auth (REQUIRED)."`
|
|
}
|
|
|
|
// ScannerConfig defines worker pool settings.
|
|
type ScannerConfig struct {
|
|
// Number of concurrent scan workers.
|
|
Workers int `yaml:"workers" comment:"Number of concurrent scan workers."`
|
|
|
|
// Maximum priority queue depth.
|
|
QueueSize int `yaml:"queue_size" comment:"Maximum priority queue depth."`
|
|
|
|
// Wall-clock budget for one scan job, measured from the moment a worker
|
|
// dequeues it — the same instant the "started" message goes to the hold,
|
|
// so this budget and the hold's scanning timeout measure the same
|
|
// interval.
|
|
//
|
|
// It must stay below the hold's, or the hold reclaims and re-dispatches a
|
|
// job this scanner is still working on. See the comment on
|
|
// scan.holdScanningTimeout.
|
|
JobTimeout time.Duration `yaml:"job_timeout" comment:"Wall-clock budget for one scan job, from the moment a worker picks it up. Must stay BELOW the hold's scanning timeout (10m), or the hold reclaims the job while this scanner is still running it and the image is scanned twice. 0 disables the deadline. Default: 8m."`
|
|
}
|
|
|
|
// VulnConfig defines vulnerability scanning settings.
|
|
type VulnConfig struct {
|
|
// Enable Grype vulnerability scanning.
|
|
Enabled bool `yaml:"enabled" comment:"Enable Grype vulnerability scanning."`
|
|
|
|
// Directory for the Grype vulnerability database.
|
|
DBPath string `yaml:"db_path" comment:"Directory for the Grype vulnerability database."`
|
|
|
|
// Directory for temporary layer extraction.
|
|
TmpDir string `yaml:"tmp_dir" comment:"Directory for temporary layer extraction."`
|
|
|
|
// Maximum total compressed image size in bytes. Images exceeding this are skipped. 0 = no limit.
|
|
MaxImageSize int64 `yaml:"max_image_size" comment:"Maximum total compressed image size in bytes. 0 = no limit. Default: 2 GiB."`
|
|
|
|
// Age threshold for the startup sweep of tmp_dir. A scan killed mid-flight
|
|
// (a restart, a deploy) never runs its own cleanup, so its layout and
|
|
// extraction directories stay on disk forever; the sweep reclaims the ones
|
|
// older than this. It must stay well above the longest a scan can take, so
|
|
// that a second scanner sharing the directory keeps its live work.
|
|
SweepMaxAge time.Duration `yaml:"sweep_max_age" comment:"Age threshold for the startup sweep of stale scan directories in tmp_dir (scan-*, syft-scan-*, syft-cataloger-*), which a scan interrupted by a restart leaves behind. Must stay above the longest scan so a second scanner sharing the directory keeps its live work. 0 disables the sweep. Default: 1h."`
|
|
}
|
|
|
|
// setScannerDefaults registers all default values on the given Viper instance.
|
|
func setScannerDefaults(v *viper.Viper) {
|
|
v.SetDefault("version", "0.1")
|
|
v.SetDefault("log_level", "info")
|
|
|
|
// Server defaults
|
|
v.SetDefault("server.addr", ":9090")
|
|
|
|
// Hold defaults
|
|
v.SetDefault("hold.url", "")
|
|
v.SetDefault("hold.secret", "")
|
|
|
|
// Scanner defaults
|
|
v.SetDefault("scanner.workers", 1)
|
|
v.SetDefault("scanner.queue_size", 100)
|
|
v.SetDefault("scanner.job_timeout", "8m")
|
|
|
|
// Vuln defaults
|
|
v.SetDefault("vuln.enabled", true)
|
|
v.SetDefault("vuln.db_path", "/var/lib/atcr-scanner/vulndb")
|
|
v.SetDefault("vuln.tmp_dir", "/var/lib/atcr-scanner/tmp")
|
|
v.SetDefault("vuln.max_image_size", 2*1024*1024*1024) // 2 GiB
|
|
v.SetDefault("vuln.sweep_max_age", "1h")
|
|
|
|
// Log shipper defaults
|
|
v.SetDefault("log_shipper.batch_size", 100)
|
|
v.SetDefault("log_shipper.flush_interval", "5s")
|
|
}
|
|
|
|
// DefaultConfig returns a Config populated with all default values (no validation).
|
|
func DefaultConfig() *Config {
|
|
v := config.NewViper("SCANNER", "")
|
|
setScannerDefaults(v)
|
|
|
|
cfg := &Config{}
|
|
_ = v.Unmarshal(cfg, config.UnmarshalOption())
|
|
return cfg
|
|
}
|
|
|
|
// ExampleYAML returns a fully-commented YAML configuration with default values.
|
|
func ExampleYAML() ([]byte, error) {
|
|
return config.MarshalCommentedYAML("ATCR Scanner Configuration", DefaultConfig())
|
|
}
|
|
|
|
// LoadConfig builds a complete configuration using Viper layered loading:
|
|
// defaults -> YAML file -> environment variables.
|
|
// yamlPath is optional; empty string means env-only (backward compatible).
|
|
func LoadConfig(yamlPath string) (*Config, error) {
|
|
v := config.NewViper("SCANNER", yamlPath)
|
|
setScannerDefaults(v)
|
|
|
|
cfg := &Config{}
|
|
if err := v.Unmarshal(cfg, config.UnmarshalOption()); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
|
}
|
|
|
|
if cfg.Hold.URL == "" {
|
|
return nil, fmt.Errorf("hold.url is required (env: SCANNER_HOLD_URL)")
|
|
}
|
|
if cfg.Hold.Secret == "" {
|
|
return nil, fmt.Errorf("hold.secret is required (env: SCANNER_HOLD_SECRET)")
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|