package db import ( "database/sql" "encoding/json" "errors" "fmt" "strings" "time" ) // ErrPolicyRuleNotFound is returned when a policy rule lookup finds no matching row. var ErrPolicyRuleNotFound = errors.New("db: policy rule not found") // RepoMetadata is a repository with aggregate counts for listing. type RepoMetadata struct { Name string `json:"name"` TagCount int `json:"tag_count"` ManifestCount int `json:"manifest_count"` TotalSize int64 `json:"total_size"` CreatedAt string `json:"created_at"` } // TagInfo is a tag with its manifest digest for repo detail. type TagInfo struct { Name string `json:"name"` Digest string `json:"digest"` } // ManifestInfo is a manifest summary for repo detail. type ManifestInfo struct { Digest string `json:"digest"` MediaType string `json:"media_type"` Size int64 `json:"size"` CreatedAt string `json:"created_at"` } // RepoDetail contains detailed info about a single repository. type RepoDetail struct { Name string `json:"name"` Tags []TagInfo `json:"tags"` Manifests []ManifestInfo `json:"manifests"` TotalSize int64 `json:"total_size"` CreatedAt string `json:"created_at"` } // PolicyRuleRow represents a row from the policy_rules table with parsed JSON. type PolicyRuleRow struct { ID int64 `json:"id"` Priority int `json:"priority"` Description string `json:"description"` Effect string `json:"effect"` Roles []string `json:"roles,omitempty"` AccountTypes []string `json:"account_types,omitempty"` SubjectUUID string `json:"subject_uuid,omitempty"` Actions []string `json:"actions"` Repositories []string `json:"repositories,omitempty"` Enabled bool `json:"enabled"` CreatedBy string `json:"created_by,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // ListRepositoriesWithMetadata returns all repositories with tag count, // manifest count, and total size. func (d *DB) ListRepositoriesWithMetadata(limit, offset int) ([]RepoMetadata, error) { if limit <= 0 { limit = 50 } rows, err := d.Query( `SELECT r.name, r.created_at, (SELECT COUNT(*) FROM tags t WHERE t.repository_id = r.id) AS tag_count, (SELECT COUNT(*) FROM manifests m WHERE m.repository_id = r.id) AS manifest_count, COALESCE((SELECT SUM(m.size) FROM manifests m WHERE m.repository_id = r.id), 0) AS total_size FROM repositories r ORDER BY r.name ASC LIMIT ? OFFSET ?`, limit, offset, ) if err != nil { return nil, fmt.Errorf("db: list repositories: %w", err) } defer func() { _ = rows.Close() }() var repos []RepoMetadata for rows.Next() { var r RepoMetadata if err := rows.Scan(&r.Name, &r.CreatedAt, &r.TagCount, &r.ManifestCount, &r.TotalSize); err != nil { return nil, fmt.Errorf("db: scan repository: %w", err) } repos = append(repos, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("db: iterate repositories: %w", err) } return repos, nil } // GetRepositoryDetail returns detailed information about a repository. func (d *DB) GetRepositoryDetail(name string) (*RepoDetail, error) { var repoID int64 var createdAt string err := d.QueryRow(`SELECT id, created_at FROM repositories WHERE name = ?`, name).Scan(&repoID, &createdAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrRepoNotFound } return nil, fmt.Errorf("db: get repository: %w", err) } detail := &RepoDetail{Name: name, CreatedAt: createdAt} // Tags with manifest digests. tagRows, err := d.Query( `SELECT t.name, m.digest FROM tags t JOIN manifests m ON m.id = t.manifest_id WHERE t.repository_id = ? ORDER BY t.name ASC`, repoID, ) if err != nil { return nil, fmt.Errorf("db: list repo tags: %w", err) } defer func() { _ = tagRows.Close() }() for tagRows.Next() { var ti TagInfo if err := tagRows.Scan(&ti.Name, &ti.Digest); err != nil { return nil, fmt.Errorf("db: scan tag: %w", err) } detail.Tags = append(detail.Tags, ti) } if err := tagRows.Err(); err != nil { return nil, fmt.Errorf("db: iterate tags: %w", err) } if detail.Tags == nil { detail.Tags = []TagInfo{} } // Manifests. mRows, err := d.Query( `SELECT digest, media_type, size, created_at FROM manifests WHERE repository_id = ? ORDER BY created_at DESC`, repoID, ) if err != nil { return nil, fmt.Errorf("db: list repo manifests: %w", err) } defer func() { _ = mRows.Close() }() for mRows.Next() { var mi ManifestInfo if err := mRows.Scan(&mi.Digest, &mi.MediaType, &mi.Size, &mi.CreatedAt); err != nil { return nil, fmt.Errorf("db: scan manifest: %w", err) } detail.TotalSize += mi.Size detail.Manifests = append(detail.Manifests, mi) } if err := mRows.Err(); err != nil { return nil, fmt.Errorf("db: iterate manifests: %w", err) } if detail.Manifests == nil { detail.Manifests = []ManifestInfo{} } return detail, nil } // DeleteRepository deletes a repository and all its manifests, tags, and // manifest_blobs. CASCADE handles the dependent rows. func (d *DB) DeleteRepository(name string) error { result, err := d.Exec(`DELETE FROM repositories WHERE name = ?`, name) if err != nil { return fmt.Errorf("db: delete repository: %w", err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("db: delete repository rows affected: %w", err) } if n == 0 { return ErrRepoNotFound } return nil } // CreatePolicyRule inserts a new policy rule and returns its ID. func (d *DB) CreatePolicyRule(rule PolicyRuleRow) (int64, error) { body := ruleBody{ Effect: rule.Effect, Roles: rule.Roles, AccountTypes: rule.AccountTypes, SubjectUUID: rule.SubjectUUID, Actions: rule.Actions, Repositories: rule.Repositories, } ruleJSON, err := json.Marshal(body) if err != nil { return 0, fmt.Errorf("db: marshal rule body: %w", err) } enabled := 0 if rule.Enabled { enabled = 1 } result, err := d.Exec( `INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by) VALUES (?, ?, ?, ?, ?)`, rule.Priority, rule.Description, string(ruleJSON), enabled, nullIfEmpty(rule.CreatedBy), ) if err != nil { return 0, fmt.Errorf("db: create policy rule: %w", err) } id, err := result.LastInsertId() if err != nil { return 0, fmt.Errorf("db: policy rule last insert id: %w", err) } return id, nil } // GetPolicyRule returns a single policy rule by ID. func (d *DB) GetPolicyRule(id int64) (*PolicyRuleRow, error) { var row PolicyRuleRow var ruleJSON string var enabledInt int var createdBy *string err := d.QueryRow( `SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at FROM policy_rules WHERE id = ?`, id, ).Scan(&row.ID, &row.Priority, &row.Description, &ruleJSON, &enabledInt, &createdBy, &row.CreatedAt, &row.UpdatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrPolicyRuleNotFound } return nil, fmt.Errorf("db: get policy rule: %w", err) } row.Enabled = enabledInt == 1 if createdBy != nil { row.CreatedBy = *createdBy } var body ruleBody if err := json.Unmarshal([]byte(ruleJSON), &body); err != nil { return nil, fmt.Errorf("db: parse rule_json for rule %d: %w", id, err) } row.Effect = body.Effect row.Roles = body.Roles row.AccountTypes = body.AccountTypes row.SubjectUUID = body.SubjectUUID row.Actions = body.Actions row.Repositories = body.Repositories return &row, nil } // ListPolicyRules returns all policy rules ordered by priority ascending. func (d *DB) ListPolicyRules(limit, offset int) ([]PolicyRuleRow, error) { if limit <= 0 { limit = 50 } rows, err := d.Query( `SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at FROM policy_rules ORDER BY priority ASC LIMIT ? OFFSET ?`, limit, offset, ) if err != nil { return nil, fmt.Errorf("db: list policy rules: %w", err) } defer func() { _ = rows.Close() }() var rules []PolicyRuleRow for rows.Next() { var row PolicyRuleRow var ruleJSON string var enabledInt int var createdBy *string if err := rows.Scan(&row.ID, &row.Priority, &row.Description, &ruleJSON, &enabledInt, &createdBy, &row.CreatedAt, &row.UpdatedAt); err != nil { return nil, fmt.Errorf("db: scan policy rule: %w", err) } row.Enabled = enabledInt == 1 if createdBy != nil { row.CreatedBy = *createdBy } var body ruleBody if err := json.Unmarshal([]byte(ruleJSON), &body); err != nil { return nil, fmt.Errorf("db: parse rule_json: %w", err) } row.Effect = body.Effect row.Roles = body.Roles row.AccountTypes = body.AccountTypes row.SubjectUUID = body.SubjectUUID row.Actions = body.Actions row.Repositories = body.Repositories rules = append(rules, row) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("db: iterate policy rules: %w", err) } return rules, nil } // UpdatePolicyRule performs a partial update of a policy rule. // Only non-zero/non-empty fields in the input are updated. // Always updates updated_at. func (d *DB) UpdatePolicyRule(id int64, updates PolicyRuleRow) error { // First check the rule exists. var exists int err := d.QueryRow(`SELECT COUNT(*) FROM policy_rules WHERE id = ?`, id).Scan(&exists) if err != nil { return fmt.Errorf("db: check policy rule: %w", err) } if exists == 0 { return ErrPolicyRuleNotFound } var setClauses []string var args []any if updates.Priority != 0 { setClauses = append(setClauses, "priority = ?") args = append(args, updates.Priority) } if updates.Description != "" { setClauses = append(setClauses, "description = ?") args = append(args, updates.Description) } // If any rule body fields are set, rebuild the full rule_json. // Read the current value first, apply the update, then write back. if updates.Effect != "" || updates.Actions != nil || updates.Roles != nil || updates.AccountTypes != nil || updates.Repositories != nil || updates.SubjectUUID != "" { var currentJSON string err := d.QueryRow(`SELECT rule_json FROM policy_rules WHERE id = ?`, id).Scan(¤tJSON) if err != nil { return fmt.Errorf("db: read current rule_json: %w", err) } var body ruleBody if err := json.Unmarshal([]byte(currentJSON), &body); err != nil { return fmt.Errorf("db: parse current rule_json: %w", err) } if updates.Effect != "" { body.Effect = updates.Effect } if updates.Actions != nil { body.Actions = updates.Actions } if updates.Roles != nil { body.Roles = updates.Roles } if updates.AccountTypes != nil { body.AccountTypes = updates.AccountTypes } if updates.Repositories != nil { body.Repositories = updates.Repositories } if updates.SubjectUUID != "" { body.SubjectUUID = updates.SubjectUUID } newJSON, err := json.Marshal(body) if err != nil { return fmt.Errorf("db: marshal updated rule_json: %w", err) } setClauses = append(setClauses, "rule_json = ?") args = append(args, string(newJSON)) } // Always update updated_at. setClauses = append(setClauses, "updated_at = ?") args = append(args, time.Now().UTC().Format("2006-01-02T15:04:05Z")) query := fmt.Sprintf("UPDATE policy_rules SET %s WHERE id = ?", strings.Join(setClauses, ", ")) args = append(args, id) _, err = d.Exec(query, args...) if err != nil { return fmt.Errorf("db: update policy rule: %w", err) } return nil } // SetPolicyRuleEnabled sets the enabled flag for a policy rule. func (d *DB) SetPolicyRuleEnabled(id int64, enabled bool) error { enabledInt := 0 if enabled { enabledInt = 1 } result, err := d.Exec( `UPDATE policy_rules SET enabled = ?, updated_at = ? WHERE id = ?`, enabledInt, time.Now().UTC().Format("2006-01-02T15:04:05Z"), id, ) if err != nil { return fmt.Errorf("db: set policy rule enabled: %w", err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("db: set policy rule enabled rows affected: %w", err) } if n == 0 { return ErrPolicyRuleNotFound } return nil } // DeletePolicyRule deletes a policy rule by ID. func (d *DB) DeletePolicyRule(id int64) error { result, err := d.Exec(`DELETE FROM policy_rules WHERE id = ?`, id) if err != nil { return fmt.Errorf("db: delete policy rule: %w", err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("db: delete policy rule rows affected: %w", err) } if n == 0 { return ErrPolicyRuleNotFound } return nil }