Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,28 @@ func parseQueryParamBool(r *http.Request, key string) *bool {
return &boolValue
}

// parseRepositoryListInUseQuery resolves the repository list in-use filter.
// If "in_use" is present it wins; otherwise "in_use_mode" is used (legacy / UI compatibility).
// For repository listing, in_use_newer is equivalent to in_use (semver applies to tag lists only).
func parseRepositoryListInUseQuery(r *http.Request) *bool {
if _, ok := r.URL.Query()["in_use"]; ok {
return parseQueryParamBool(r, "in_use")
}
mode := strings.ToLower(strings.TrimSpace(parseQueryParam(r, "in_use_mode")))
switch mode {
case "", "all":
return nil
case "in_use", "in-use", "in_use_newer", "in-use-newer":
v := true
return &v
case "not_in_use", "not-in-use", "out":
v := false
return &v
default:
return nil
}
}

// handleRootRedirect redirects / to /swagger/
func (s *APIServer) handleRootRedirect(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
Expand Down
4 changes: 3 additions & 1 deletion internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,8 @@ func (s *APIServer) handleGenerateKyvernoPolicy(w http.ResponseWriter, r *http.R
// @Param search query string false "Filter by repository name"
// @Param max_age query int false "Maximum age of last scan in seconds (e.g., 86400 for last 24 hours)"
// @Param sort_by query string false "Sort order: age_desc (default), age_asc, name_asc, name_desc, status_asc, status_desc" Enums(age_desc,age_asc,name_asc,name_desc,status_asc,status_desc)
// @Param in_use query bool false "When set, filter by runtime in-use (true) or not in-use (false). Takes precedence over in_use_mode when both are sent."
// @Param in_use_mode query string false "Legacy filter: all (default), in_use, not_in_use, in_use_newer. For this endpoint in_use_newer is treated like in_use (semver applies to tag lists only). Ignored when in_use is present." Enums(all,in_use,not_in_use,in_use_newer)
// @Param limit query int false "Maximum number of results" default(100)
// @Param offset query int false "Pagination offset" default(0)
// @Success 200 {object} map[string]interface{} "List of repositories with aggregated data"
Expand All @@ -1106,7 +1108,7 @@ func (s *APIServer) handleListRepositories(w http.ResponseWriter, r *http.Reques
}

// Parse query parameters
inUse := parseQueryParamBool(r, "in_use")
inUse := parseRepositoryListInUseQuery(r)
filter := statestore.RepositoryFilter{
Search: parseQueryParam(r, "search"),
PolicyStatus: parseQueryParam(r, "policy_status"),
Expand Down
49 changes: 49 additions & 0 deletions internal/api/repository_list_in_use_query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package api

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestParseRepositoryListInUseQuery(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantNil bool
wantTrue bool
}{
{name: "no_query", raw: "", wantNil: true},
{name: "mode_all", raw: "?in_use_mode=all", wantNil: true},
{name: "mode_in_use", raw: "?in_use_mode=in_use", wantTrue: true},
{name: "mode_in_use_upper", raw: "?in_use_mode=IN_USE", wantTrue: true},
{name: "mode_in_use_newer", raw: "?in_use_mode=in_use_newer", wantTrue: true},
{name: "mode_in_use_newer_hyphen", raw: "?in_use_mode=in-use-newer", wantTrue: true},
{name: "mode_not_in_use", raw: "?in_use_mode=not_in_use", wantTrue: false},
{name: "mode_out", raw: "?in_use_mode=out", wantTrue: false},
{name: "in_use_wins_false", raw: "?in_use=false&in_use_mode=in_use", wantTrue: false},
{name: "in_use_wins_zero", raw: "?in_use=0&in_use_mode=in_use", wantTrue: false},
{name: "in_use_wins_true", raw: "?in_use=true&in_use_mode=not_in_use", wantTrue: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/repositories"+tc.raw, nil)
got := parseRepositoryListInUseQuery(req)
if tc.wantNil {
if got != nil {
t.Fatalf("expected nil, got %v", *got)
}
return
}
if got == nil {
t.Fatal("expected non-nil")
}
if *got != tc.wantTrue {
t.Fatalf("expected %v, got %v", tc.wantTrue, *got)
}
})
}
}
31 changes: 31 additions & 0 deletions internal/statestore/sqlite.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package statestore

import (
"context"
"database/sql"
"fmt"
"time"
Expand Down Expand Up @@ -69,6 +70,11 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
return nil, errors.NewPermanentf("failed to initialize schema: %w", err)
}

if err := store.ensureRepositorySummaries(context.Background()); err != nil {
db.Close()
return nil, errors.NewPermanentf("failed to ensure repository summaries: %w", err)
}

return store, nil
}

Expand Down Expand Up @@ -187,6 +193,31 @@ func (s *SQLiteStore) initSchema() error {
created_at INTEGER NOT NULL DEFAULT (cast(strftime('%s', 'now') as integer))
);

CREATE TABLE IF NOT EXISTS repository_summary (
repository_id INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
artifact_count INTEGER NOT NULL DEFAULT 0,
last_scan_time INTEGER,
max_critical INTEGER NOT NULL DEFAULT 0,
max_high INTEGER NOT NULL DEFAULT 0,
max_medium INTEGER NOT NULL DEFAULT 0,
max_low INTEGER NOT NULL DEFAULT 0,
policy_passed INTEGER NOT NULL DEFAULT 1,
policy_status TEXT NOT NULL DEFAULT 'passed',
runtime_used INTEGER NOT NULL DEFAULT 0,
whitelisted INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS idx_repository_summary_last_scan ON repository_summary(last_scan_time);
CREATE INDEX IF NOT EXISTS idx_repository_summary_list ON repository_summary(runtime_used, whitelisted, last_scan_time);
CREATE INDEX IF NOT EXISTS idx_repository_summary_policy ON repository_summary(policy_status, last_scan_time);

CREATE TRIGGER IF NOT EXISTS tr_repositories_ai_repository_summary
AFTER INSERT ON repositories
BEGIN
INSERT OR IGNORE INTO repository_summary (repository_id) VALUES (NEW.id);
END;

CREATE INDEX IF NOT EXISTS idx_artifacts_repository ON artifacts(repository_id);
CREATE INDEX IF NOT EXISTS idx_artifacts_digest ON artifacts(digest);
CREATE INDEX IF NOT EXISTS idx_artifacts_next_scan ON artifacts(next_scan_at);
Expand Down
64 changes: 51 additions & 13 deletions internal/statestore/sqlite_cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import (
)

func (s *SQLiteStore) CleanupArtifactScans(ctx context.Context, digest string) error {
return s.executeCleanup(ctx, func(tx *sql.Tx) error {
var repoIDsToRefresh []int64
seenRefresh := make(map[int64]struct{})

err := s.executeCleanup(ctx, func(tx *sql.Tx) error {
// First, get all artifact IDs and repository IDs for this digest
rows, err := tx.QueryContext(ctx, `
SELECT a.id, a.repository_id
Expand Down Expand Up @@ -93,11 +96,25 @@ func (s *SQLiteStore) CleanupArtifactScans(ctx context.Context, digest string) e
if err != nil {
return errors.NewTransientf("failed to delete empty repository: %w", err)
}
} else {
if _, ok := seenRefresh[repositoryID]; !ok {
seenRefresh[repositoryID] = struct{}{}
repoIDsToRefresh = append(repoIDsToRefresh, repositoryID)
}
}
}

return nil
})
if err != nil {
return err
}
for _, id := range repoIDsToRefresh {
if err := s.refreshRepositorySummary(ctx, id); err != nil {
return err
}
}
return nil
}

// executeCleanup is a helper method for transaction management in cleanup operations
Expand Down Expand Up @@ -180,7 +197,9 @@ func (s *SQLiteStore) CleanupExcessScans(ctx context.Context, digest string, max
return errors.NewPermanentf("maxScansToKeep must be positive, got %d", maxScansToKeep)
}

return s.executeCleanup(ctx, func(tx *sql.Tx) error {
repoSeen := make(map[int64]struct{})

err := s.executeCleanup(ctx, func(tx *sql.Tx) error {
// Get all artifact IDs for this digest
rows, err := tx.QueryContext(ctx, `
SELECT id FROM artifacts WHERE digest = ?
Expand Down Expand Up @@ -209,17 +228,35 @@ func (s *SQLiteStore) CleanupExcessScans(ctx context.Context, digest string, max

// Clean up excess scans for each artifact
for _, artifactID := range artifactIDs {
if err := s.cleanupExcessScansForArtifact(tx, ctx, artifactID, maxScansToKeep); err != nil {
changed, err := s.cleanupExcessScansForArtifact(tx, ctx, artifactID, maxScansToKeep)
if err != nil {
return err
}
if changed {
var repoID int64
if err := tx.QueryRowContext(ctx, `SELECT repository_id FROM artifacts WHERE id = ?`, artifactID).Scan(&repoID); err != nil {
return errors.NewTransientf("failed to read repository_id for artifact after excess scan cleanup: %w", err)
}
repoSeen[repoID] = struct{}{}
}
}

return nil
})
if err != nil {
return err
}
for id := range repoSeen {
if err := s.refreshRepositorySummary(ctx, id); err != nil {
return err
}
}
return nil
}

// cleanupExcessScansForArtifact is a helper to clean up scans for a single artifact
func (s *SQLiteStore) cleanupExcessScansForArtifact(tx *sql.Tx, ctx context.Context, artifactID int64, maxScansToKeep int) error {
// cleanupExcessScansForArtifact is a helper to clean up scans for a single artifact.
// It returns whether any scan rows were deleted (last_scan_id may have changed).
func (s *SQLiteStore) cleanupExcessScansForArtifact(tx *sql.Tx, ctx context.Context, artifactID int64, maxScansToKeep int) (bool, error) {
// Get scan IDs to keep (most recent N scans)
rows, err := tx.QueryContext(ctx, `
SELECT id FROM scan_records
Expand All @@ -228,26 +265,26 @@ func (s *SQLiteStore) cleanupExcessScansForArtifact(tx *sql.Tx, ctx context.Cont
LIMIT ?
`, artifactID, maxScansToKeep)
if err != nil {
return errors.NewTransientf("failed to query scans to keep: %w", err)
return false, errors.NewTransientf("failed to query scans to keep: %w", err)
}
defer rows.Close()

var keepScanIDs []int64
for rows.Next() {
var scanID int64
if err := rows.Scan(&scanID); err != nil {
return errors.NewTransientf("failed to scan keep scan ID: %w", err)
return false, errors.NewTransientf("failed to scan keep scan ID: %w", err)
}
keepScanIDs = append(keepScanIDs, scanID)
}

if err := rows.Err(); err != nil {
return errors.NewTransientf("error iterating keep scan IDs: %w", err)
return false, errors.NewTransientf("error iterating keep scan IDs: %w", err)
}

// If we have fewer scans than the limit, nothing to clean up
if len(keepScanIDs) < maxScansToKeep {
return nil
return false, nil
}

// Build placeholders for the IN clause
Expand All @@ -267,12 +304,12 @@ func (s *SQLiteStore) cleanupExcessScansForArtifact(tx *sql.Tx, ctx context.Cont

result, err := tx.ExecContext(ctx, deleteQuery, args...)
if err != nil {
return errors.NewTransientf("failed to delete excess scan records: %w", err)
return false, errors.NewTransientf("failed to delete excess scan records: %w", err)
}

deletedCount, err := result.RowsAffected()
if err != nil {
return errors.NewTransientf("failed to get deleted rows count: %w", err)
return false, errors.NewTransientf("failed to get deleted rows count: %w", err)
}

if deletedCount > 0 {
Expand All @@ -284,10 +321,11 @@ func (s *SQLiteStore) cleanupExcessScansForArtifact(tx *sql.Tx, ctx context.Cont
WHERE id = ?
`, keepScanIDs[0], artifactID) // keepScanIDs[0] is the most recent
if err != nil {
return errors.NewTransientf("failed to update artifact last_scan_id: %w", err)
return false, errors.NewTransientf("failed to update artifact last_scan_id: %w", err)
}
}
return true, nil
}

return nil
return false, nil
}
Loading
Loading