diff --git a/pkg/appview/handlers/home.go b/pkg/appview/handlers/home.go index b5cb53a..d409c82 100644 --- a/pkg/appview/handlers/home.go +++ b/pkg/appview/handlers/home.go @@ -31,8 +31,8 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } db.SetRegistryURL(featuredCards, h.RegistryURL) - // Fetch recently updated repositories (top 18 by last push - 6 rows) - recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 18, currentUserDID, db.SortByLastUpdate) + // Fetch recently updated repositories (top 24 by last push - 6 rows at 4-col xl) + recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 24, currentUserDID, db.SortByLastUpdate) if err != nil { log.Printf("Error fetching recent repos: %v", err) recentCards = []db.RepoCardData{} diff --git a/pkg/appview/public/icons.svg b/pkg/appview/public/icons.svg index ea32d1a..2425fb4 100644 --- a/pkg/appview/public/icons.svg +++ b/pkg/appview/public/icons.svg @@ -43,6 +43,7 @@ + diff --git a/pkg/appview/templates/components/card-grid.html b/pkg/appview/templates/components/card-grid.html index 36008c0..8ca696c 100644 --- a/pkg/appview/templates/components/card-grid.html +++ b/pkg/appview/templates/components/card-grid.html @@ -15,7 +15,7 @@ - .HasMore: bool - whether to show Load More button */}} {{ if .Repositories }} -
+
{{ range .Repositories }} {{ template "repo-card" . }} {{ end }} diff --git a/pkg/appview/templates/components/docker-command.html b/pkg/appview/templates/components/docker-command.html index 2026494..c195013 100644 --- a/pkg/appview/templates/components/docker-command.html +++ b/pkg/appview/templates/components/docker-command.html @@ -13,3 +13,22 @@
{{ end }} + +{{ define "image-ref" }} +{{/* + Image reference component - shows a short image reference with a copy button + that copies the full pull command. Used in dense card layouts where the full + command would truncate. + + Expects: dict with + - Display: string - short form shown in the UI (e.g. "alice.bsky.social/myapp:v1.2.3") + - Copy: string - full command copied to clipboard (e.g. "docker pull atcr.io/alice.bsky.social/myapp:v1.2.3") +*/}} +
+ {{ icon "package" "size-4 shrink-0 text-base-content/60" }} + {{ .Display }} + +
+{{ end }} diff --git a/pkg/appview/templates/components/repo-card.html b/pkg/appview/templates/components/repo-card.html index 137221b..582ea73 100644 --- a/pkg/appview/templates/components/repo-card.html +++ b/pkg/appview/templates/components/repo-card.html @@ -17,7 +17,7 @@ - LastUpdated: time.Time (optional) - Last push time - RegistryURL: string - Registry URL for docker commands (e.g., "atcr.io") */}} -
+
{{ if .IconURL }} {{ .Repository }} @@ -47,19 +47,27 @@
{{ if eq .ArtifactType "helm-chart" }} {{ if .Tag }} - {{ template "docker-command" (printf "helm pull oci://%s/%s/%s --version %s" .RegistryURL .OwnerHandle .Repository .Tag) }} + {{ template "image-ref" (dict + "Display" (printf "%s/%s:%s" .OwnerHandle .Repository .Tag) + "Copy" (printf "helm pull oci://%s/%s/%s --version %s" .RegistryURL .OwnerHandle .Repository .Tag)) }} {{ else }} - {{ template "docker-command" (printf "helm pull oci://%s/%s/%s" .RegistryURL .OwnerHandle .Repository) }} + {{ template "image-ref" (dict + "Display" (printf "%s/%s" .OwnerHandle .Repository) + "Copy" (printf "helm pull oci://%s/%s/%s" .RegistryURL .OwnerHandle .Repository)) }} {{ end }} {{ else }} {{ if .Tag }} - {{ template "docker-command" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag) }} + {{ template "image-ref" (dict + "Display" (printf "%s/%s:%s" .OwnerHandle .Repository .Tag) + "Copy" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag)) }} {{ else }} - {{ template "docker-command" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository) }} + {{ template "image-ref" (dict + "Display" (printf "%s/%s" .OwnerHandle .Repository) + "Copy" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository)) }} {{ end }} {{ end }}
-
+
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }} {{ template "pull-count" (dict "PullCount" .PullCount) }} diff --git a/pkg/appview/templates/pages/home.html b/pkg/appview/templates/pages/home.html index 3a911c1..9ea590b 100644 --- a/pkg/appview/templates/pages/home.html +++ b/pkg/appview/templates/pages/home.html @@ -45,7 +45,7 @@ {{ if .RecentRepos }}

What's New

- {{ template "card-grid" (dict "Repositories" .RecentRepos) }} + {{ template "card-grid" (dict "Repositories" .RecentRepos "Columns" 4) }}
{{ end }}
diff --git a/pkg/appview/templates/pages/user.html b/pkg/appview/templates/pages/user.html index a047111..4e937b4 100644 --- a/pkg/appview/templates/pages/user.html +++ b/pkg/appview/templates/pages/user.html @@ -48,7 +48,7 @@
{{ else }}
- {{ template "card-grid" (dict "Repositories" .Repositories "EmptyMessage" "No images yet.") }} + {{ template "card-grid" (dict "Repositories" .Repositories "Columns" 4 "EmptyMessage" "No images yet.") }}
{{ end }}
diff --git a/pkg/appview/templates/partials/search-results.html b/pkg/appview/templates/partials/search-results.html index eaed618..071a1f5 100644 --- a/pkg/appview/templates/partials/search-results.html +++ b/pkg/appview/templates/partials/search-results.html @@ -1,6 +1,7 @@ {{/* Search results partial - renders repository cards in a grid */}} {{ template "card-grid" (dict "Repositories" .Repositories + "Columns" 4 "EmptyIcon" "search-x" "EmptyMessage" "No repositories found matching your search." "EmptySubtext" "Try a different search term or browse the homepage." diff --git a/pkg/hold/admin/public/icons.svg b/pkg/hold/admin/public/icons.svg index ea32d1a..2425fb4 100644 --- a/pkg/hold/admin/public/icons.svg +++ b/pkg/hold/admin/public/icons.svg @@ -43,6 +43,7 @@ + diff --git a/scanner/internal/scan/grype.go b/scanner/internal/scan/grype.go index 7b4a6dc..ab0063b 100644 --- a/scanner/internal/scan/grype.go +++ b/scanner/internal/scan/grype.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "sync" "sync/atomic" "time" @@ -126,9 +125,23 @@ func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) ( return reportJSON, digest, summary, nil } +// grypeDBConfig returns the distribution and installation configs used for +// all Grype DB load/update calls. Kept in one place so both the initial load +// and the periodic reload see identical settings. +func grypeDBConfig(vulnDBPath string) (distribution.Config, installation.Config) { + return distribution.DefaultConfig(), installation.Config{ + DBRootDir: vulnDBPath, + ValidateAge: true, + ValidateChecksum: true, + MaxAllowedBuiltAge: 14 * 24 * time.Hour, // tolerates upstream publish gaps + } +} + // loadVulnDatabase loads the Grype vulnerability database with caching and // automatic refresh. The cached DB is returned if loaded less than -// vulnDBRefreshAge ago. On a stale or missing DB, it downloads a fresh copy. +// vulnDBRefreshAge ago. On a stale or missing DB, Grype downloads a fresh copy +// in the same call (update=true) — a single curator handles everything so +// there is no chance of a double-curator update+load seeing different state. func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Provider, error) { vulnDBLock.RLock() if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge { @@ -155,40 +168,32 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro } } - slog.Info("Loading Grype vulnerability database", "path", vulnDBPath) + slog.Info("Loading Grype vulnerability database", "path", vulnDBPath, "tmpdir", os.Getenv("TMPDIR")) - if err := os.MkdirAll(vulnDBPath, 0755); err != nil { + if err := os.MkdirAll(vulnDBPath, 0o755); err != nil { return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err) } - distConfig := distribution.DefaultConfig() - installConfig := installation.Config{ - DBRootDir: vulnDBPath, - ValidateAge: true, - ValidateChecksum: true, - MaxAllowedBuiltAge: 14 * 24 * time.Hour, // 2 weeks — tolerates upstream publish gaps - } + distConfig, installConfig := grypeDBConfig(vulnDBPath) - // Try loading existing DB first (no network) - store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false) + // update=true: a single grype curator checks the upstream feed, downloads + // if needed, activates, and then opens the reader — all in one call. If + // the upstream is unreachable but the on-disk DB is still valid, it falls + // back to serving the existing DB. + store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, true) if err != nil { - slog.Warn("Vulnerability database load failed, attempting update", "error", err) - - // Download fresh DB - if updateErr := updateVulnDatabase(vulnDBPath); updateErr != nil { - return nil, fmt.Errorf("failed to update vulnerability database: %w (original: %w)", updateErr, err) - } - - // Retry loading after update - store, status, err = grype.LoadVulnerabilityDB(distConfig, installConfig, false) - if err != nil { - return nil, fmt.Errorf("failed to load vulnerability database after update (status=%v): %w", status, err) - } + return nil, fmt.Errorf("failed to load vulnerability database: %w", err) } + age := "unknown" + if !status.Built.IsZero() { + age = time.Since(status.Built).Round(time.Minute).String() + } slog.Info("Vulnerability database loaded", "built", status.Built, - "schemaVersion", status.SchemaVersion) + "age", age, + "schemaVersion", status.SchemaVersion, + "path", status.Path) if vulnDB != nil { vulnDB.Close() @@ -198,67 +203,13 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro return vulnDB, nil } -// initializeVulnDatabase ensures a fresh vulnerability database exists on startup. -func initializeVulnDatabase(vulnDBPath, tmpDir string) error { +// initializeVulnDatabase primes the in-memory DB cache on startup so the first +// scan doesn't pay the download cost. Caller is responsible for TMPDIR being +// set to a path on the same filesystem as vulnDBPath — see WorkerPool.Start. +func initializeVulnDatabase(vulnDBPath string) error { slog.Info("Initializing vulnerability database", "path", vulnDBPath) - - grpeTmpDir := filepath.Join(tmpDir, "grype-dl") - if err := os.MkdirAll(grpeTmpDir, 0755); err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - - oldTmpDir := os.Getenv("TMPDIR") - os.Setenv("TMPDIR", grpeTmpDir) - defer func() { - if oldTmpDir != "" { - os.Setenv("TMPDIR", oldTmpDir) - } else { - os.Unsetenv("TMPDIR") - } - }() - - return updateVulnDatabase(vulnDBPath) -} - -// updateVulnDatabase downloads a fresh vulnerability database if needed. -// The curator internally checks whether an update is necessary (DB missing, -// stale, or update-check cooldown expired) so this is safe to call often. -func updateVulnDatabase(vulnDBPath string) error { - if err := os.MkdirAll(vulnDBPath, 0755); err != nil { - return fmt.Errorf("failed to create database directory: %w", err) - } - - distConfig := distribution.DefaultConfig() - installConfig := installation.Config{ - DBRootDir: vulnDBPath, - ValidateAge: true, - ValidateChecksum: true, - MaxAllowedBuiltAge: 14 * 24 * time.Hour, - } - - downloader, err := distribution.NewClient(distConfig) - if err != nil { - return fmt.Errorf("failed to create database downloader: %w", err) - } - - curator, err := installation.NewCurator(installConfig, downloader) - if err != nil { - return fmt.Errorf("failed to create database curator: %w", err) - } - - slog.Info("Checking vulnerability database for updates...") - updated, err := curator.Update() - if err != nil { - return fmt.Errorf("failed to update vulnerability database: %w", err) - } - - if updated { - slog.Info("Vulnerability database updated successfully") - } else { - slog.Info("Vulnerability database is up to date") - } - - return nil + _, err := loadVulnDatabase(context.Background(), vulnDBPath) + return err } func countVulnerabilitiesBySeverity(matches match.Matches) scanner.VulnerabilitySummary { diff --git a/scanner/internal/scan/worker.go b/scanner/internal/scan/worker.go index f70f60e..bedec63 100644 --- a/scanner/internal/scan/worker.go +++ b/scanner/internal/scan/worker.go @@ -37,20 +37,29 @@ func NewWorkerPool(cfg *config.Config, q *queue.JobQueue, c *client.HoldClient) // Start launches worker goroutines func (wp *WorkerPool) Start(ctx context.Context) { + // Point TMPDIR at the configured tmp dir so Grype's DB download + // (go-getter zstd decompression can be 1 GB+) and stereoscope's layer + // extraction both land on the same partition as the scanner volume — + // NOT on /tmp, which is typically tmpfs with ~400 MB and would silently + // fail mid-extract. This must be set before any scanner/grype goroutine + // starts and must never be restored to a smaller default mid-process. + if wp.cfg.Vuln.TmpDir != "" { + if err := os.MkdirAll(wp.cfg.Vuln.TmpDir, 0o755); err != nil { + slog.Warn("Failed to create scanner tmp dir", "path", wp.cfg.Vuln.TmpDir, "error", err) + } + os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir) + } + // Initialize vuln database on startup if enabled if wp.cfg.Vuln.Enabled { go func() { - if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath, wp.cfg.Vuln.TmpDir); err != nil { + if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath); err != nil { slog.Error("Failed to initialize vulnerability database", "error", err) slog.Warn("Vulnerability scanning will be disabled until database is available") } }() } - // Point TMPDIR at the configured tmp dir so stereoscope's internal - // layer extraction uses the same partition (not /tmp which may be small) - os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir) - for i := 0; i < wp.cfg.Scanner.Workers; i++ { wp.wg.Add(1) go wp.worker(ctx, i)