Phases 5, 6, 8: OCI pull/push paths and admin REST API
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>
This commit is contained in:
429
internal/db/admin.go
Normal file
429
internal/db/admin.go
Normal file
@@ -0,0 +1,429 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user