Phase 5 (OCI pull): internal/oci/ package with manifest GET/HEAD by tag/digest, blob GET/HEAD with repo membership check, tag listing with OCI pagination, catalog listing. Multi-segment repo names via parseOCIPath() right-split routing. DB query layer in internal/db/repository.go. Phase 6 (OCI push): blob uploads (monolithic and chunked) with uploadManager tracking in-progress BlobWriters, manifest push implementing full ARCHITECTURE.md §5 flow in a single SQLite transaction (create repo, upsert manifest, populate manifest_blobs, atomic tag move). Digest verification on both blob commit and manifest push-by-digest. Phase 8 (admin REST): /v1 endpoints for auth (login/logout/health), repository management (list/detail/delete), policy CRUD with engine reload, audit log listing with filters, GC trigger/status stubs. RequireAdmin middleware, platform-standard error format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
430 lines
12 KiB
Go
430 lines
12 KiB
Go
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
|
|
}
|