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:
2026-03-19 18:25:18 -07:00
parent f5e67bd4aa
commit dddc66f31b
40 changed files with 6832 additions and 7 deletions

429
internal/db/admin.go Normal file
View 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(&currentJSON)
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
}

593
internal/db/admin_test.go Normal file
View File

@@ -0,0 +1,593 @@
package db
import (
"errors"
"testing"
)
// seedAdminRepo inserts a repository with manifests, tags, and blobs for admin tests.
func seedAdminRepo(t *testing.T, d *DB, name string, tagNames []string) int64 {
t.Helper()
_, err := d.Exec(`INSERT INTO repositories (name) VALUES (?)`, name)
if err != nil {
t.Fatalf("insert repo %q: %v", name, err)
}
var repoID int64
if err := d.QueryRow(`SELECT id FROM repositories WHERE name = ?`, name).Scan(&repoID); err != nil {
t.Fatalf("select repo id: %v", err)
}
_, err = d.Exec(
`INSERT INTO manifests (repository_id, digest, media_type, content, size)
VALUES (?, ?, 'application/vnd.oci.image.manifest.v1+json', '{}', 1024)`,
repoID, "sha256:aaa-"+name,
)
if err != nil {
t.Fatalf("insert manifest for %q: %v", name, err)
}
var manifestID int64
if err := d.QueryRow(`SELECT id FROM manifests WHERE repository_id = ?`, repoID).Scan(&manifestID); err != nil {
t.Fatalf("select manifest id: %v", err)
}
for _, tag := range tagNames {
_, err := d.Exec(`INSERT INTO tags (repository_id, name, manifest_id) VALUES (?, ?, ?)`,
repoID, tag, manifestID)
if err != nil {
t.Fatalf("insert tag %q: %v", tag, err)
}
}
return repoID
}
func TestListRepositoriesWithMetadata(t *testing.T) {
d := migratedTestDB(t)
seedAdminRepo(t, d, "alpha/app", []string{"latest", "v1.0"})
seedAdminRepo(t, d, "bravo/lib", []string{"latest"})
repos, err := d.ListRepositoriesWithMetadata(50, 0)
if err != nil {
t.Fatalf("ListRepositoriesWithMetadata: %v", err)
}
if len(repos) != 2 {
t.Fatalf("repo count: got %d, want 2", len(repos))
}
// Ordered by name ASC.
if repos[0].Name != "alpha/app" {
t.Fatalf("first repo name: got %q, want %q", repos[0].Name, "alpha/app")
}
if repos[0].TagCount != 2 {
t.Fatalf("alpha/app tag count: got %d, want 2", repos[0].TagCount)
}
if repos[0].ManifestCount != 1 {
t.Fatalf("alpha/app manifest count: got %d, want 1", repos[0].ManifestCount)
}
if repos[0].TotalSize != 1024 {
t.Fatalf("alpha/app total size: got %d, want 1024", repos[0].TotalSize)
}
if repos[0].CreatedAt == "" {
t.Fatal("alpha/app created_at: expected non-empty")
}
if repos[1].Name != "bravo/lib" {
t.Fatalf("second repo name: got %q, want %q", repos[1].Name, "bravo/lib")
}
if repos[1].TagCount != 1 {
t.Fatalf("bravo/lib tag count: got %d, want 1", repos[1].TagCount)
}
}
func TestListRepositoriesWithMetadataEmpty(t *testing.T) {
d := migratedTestDB(t)
repos, err := d.ListRepositoriesWithMetadata(50, 0)
if err != nil {
t.Fatalf("ListRepositoriesWithMetadata: %v", err)
}
if repos != nil {
t.Fatalf("expected nil repos, got %v", repos)
}
}
func TestListRepositoriesWithMetadataPagination(t *testing.T) {
d := migratedTestDB(t)
seedAdminRepo(t, d, "alpha/app", []string{"latest"})
seedAdminRepo(t, d, "bravo/lib", []string{"latest"})
seedAdminRepo(t, d, "charlie/svc", []string{"latest"})
repos, err := d.ListRepositoriesWithMetadata(2, 0)
if err != nil {
t.Fatalf("ListRepositoriesWithMetadata page 1: %v", err)
}
if len(repos) != 2 {
t.Fatalf("page 1 count: got %d, want 2", len(repos))
}
if repos[0].Name != "alpha/app" {
t.Fatalf("page 1 first: got %q, want %q", repos[0].Name, "alpha/app")
}
repos, err = d.ListRepositoriesWithMetadata(2, 2)
if err != nil {
t.Fatalf("ListRepositoriesWithMetadata page 2: %v", err)
}
if len(repos) != 1 {
t.Fatalf("page 2 count: got %d, want 1", len(repos))
}
if repos[0].Name != "charlie/svc" {
t.Fatalf("page 2 first: got %q, want %q", repos[0].Name, "charlie/svc")
}
}
func TestGetRepositoryDetail(t *testing.T) {
d := migratedTestDB(t)
seedAdminRepo(t, d, "myorg/myapp", []string{"latest", "v1.0"})
detail, err := d.GetRepositoryDetail("myorg/myapp")
if err != nil {
t.Fatalf("GetRepositoryDetail: %v", err)
}
if detail.Name != "myorg/myapp" {
t.Fatalf("name: got %q, want %q", detail.Name, "myorg/myapp")
}
if detail.CreatedAt == "" {
t.Fatal("created_at: expected non-empty")
}
if len(detail.Tags) != 2 {
t.Fatalf("tag count: got %d, want 2", len(detail.Tags))
}
// Tags ordered by name ASC.
if detail.Tags[0].Name != "latest" {
t.Fatalf("first tag: got %q, want %q", detail.Tags[0].Name, "latest")
}
if detail.Tags[0].Digest == "" {
t.Fatal("first tag digest: expected non-empty")
}
if detail.Tags[1].Name != "v1.0" {
t.Fatalf("second tag: got %q, want %q", detail.Tags[1].Name, "v1.0")
}
if len(detail.Manifests) != 1 {
t.Fatalf("manifest count: got %d, want 1", len(detail.Manifests))
}
if detail.Manifests[0].Size != 1024 {
t.Fatalf("manifest size: got %d, want 1024", detail.Manifests[0].Size)
}
if detail.TotalSize != 1024 {
t.Fatalf("total size: got %d, want 1024", detail.TotalSize)
}
}
func TestGetRepositoryDetailNotFound(t *testing.T) {
d := migratedTestDB(t)
_, err := d.GetRepositoryDetail("nonexistent/repo")
if !errors.Is(err, ErrRepoNotFound) {
t.Fatalf("expected ErrRepoNotFound, got %v", err)
}
}
func TestGetRepositoryDetailEmptyRepo(t *testing.T) {
d := migratedTestDB(t)
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('empty/repo')`)
if err != nil {
t.Fatalf("insert repo: %v", err)
}
detail, err := d.GetRepositoryDetail("empty/repo")
if err != nil {
t.Fatalf("GetRepositoryDetail: %v", err)
}
if len(detail.Tags) != 0 {
t.Fatalf("tags: got %d, want 0", len(detail.Tags))
}
if len(detail.Manifests) != 0 {
t.Fatalf("manifests: got %d, want 0", len(detail.Manifests))
}
if detail.TotalSize != 0 {
t.Fatalf("total size: got %d, want 0", detail.TotalSize)
}
}
func TestDeleteRepository(t *testing.T) {
d := migratedTestDB(t)
seedAdminRepo(t, d, "myorg/myapp", []string{"latest"})
if err := d.DeleteRepository("myorg/myapp"); err != nil {
t.Fatalf("DeleteRepository: %v", err)
}
// Verify it's gone.
_, err := d.GetRepositoryDetail("myorg/myapp")
if !errors.Is(err, ErrRepoNotFound) {
t.Fatalf("expected ErrRepoNotFound after delete, got %v", err)
}
// Verify cascade: manifests and tags should be gone.
var manifestCount int
if err := d.QueryRow(`SELECT COUNT(*) FROM manifests`).Scan(&manifestCount); err != nil {
t.Fatalf("count manifests: %v", err)
}
if manifestCount != 0 {
t.Fatalf("manifests after delete: got %d, want 0", manifestCount)
}
var tagCount int
if err := d.QueryRow(`SELECT COUNT(*) FROM tags`).Scan(&tagCount); err != nil {
t.Fatalf("count tags: %v", err)
}
if tagCount != 0 {
t.Fatalf("tags after delete: got %d, want 0", tagCount)
}
}
func TestDeleteRepositoryNotFound(t *testing.T) {
d := migratedTestDB(t)
err := d.DeleteRepository("nonexistent/repo")
if !errors.Is(err, ErrRepoNotFound) {
t.Fatalf("expected ErrRepoNotFound, got %v", err)
}
}
func TestCreatePolicyRule(t *testing.T) {
d := migratedTestDB(t)
rule := PolicyRuleRow{
Priority: 50,
Description: "allow CI push",
Effect: "allow",
Roles: []string{"ci"},
Actions: []string{"registry:push", "registry:pull"},
Repositories: []string{"production/*"},
Enabled: true,
CreatedBy: "admin-uuid",
}
id, err := d.CreatePolicyRule(rule)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
if id == 0 {
t.Fatal("expected non-zero ID")
}
got, err := d.GetPolicyRule(id)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.Priority != 50 {
t.Fatalf("priority: got %d, want 50", got.Priority)
}
if got.Description != "allow CI push" {
t.Fatalf("description: got %q, want %q", got.Description, "allow CI push")
}
if got.Effect != "allow" {
t.Fatalf("effect: got %q, want %q", got.Effect, "allow")
}
if len(got.Roles) != 1 || got.Roles[0] != "ci" {
t.Fatalf("roles: got %v, want [ci]", got.Roles)
}
if len(got.Actions) != 2 {
t.Fatalf("actions: got %d, want 2", len(got.Actions))
}
if len(got.Repositories) != 1 || got.Repositories[0] != "production/*" {
t.Fatalf("repositories: got %v, want [production/*]", got.Repositories)
}
if !got.Enabled {
t.Fatal("enabled: got false, want true")
}
if got.CreatedBy != "admin-uuid" {
t.Fatalf("created_by: got %q, want %q", got.CreatedBy, "admin-uuid")
}
if got.CreatedAt == "" {
t.Fatal("created_at: expected non-empty")
}
if got.UpdatedAt == "" {
t.Fatal("updated_at: expected non-empty")
}
}
func TestCreatePolicyRuleDisabled(t *testing.T) {
d := migratedTestDB(t)
rule := PolicyRuleRow{
Priority: 10,
Description: "disabled rule",
Effect: "deny",
Actions: []string{"registry:delete"},
Enabled: false,
}
id, err := d.CreatePolicyRule(rule)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
got, err := d.GetPolicyRule(id)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.Enabled {
t.Fatal("enabled: got true, want false")
}
}
func TestGetPolicyRuleNotFound(t *testing.T) {
d := migratedTestDB(t)
_, err := d.GetPolicyRule(9999)
if !errors.Is(err, ErrPolicyRuleNotFound) {
t.Fatalf("expected ErrPolicyRuleNotFound, got %v", err)
}
}
func TestListPolicyRules(t *testing.T) {
d := migratedTestDB(t)
// Insert rules with different priorities (out of order).
rule1 := PolicyRuleRow{
Priority: 50,
Description: "rule A",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
}
rule2 := PolicyRuleRow{
Priority: 10,
Description: "rule B",
Effect: "deny",
Actions: []string{"registry:delete"},
Enabled: true,
}
rule3 := PolicyRuleRow{
Priority: 30,
Description: "rule C",
Effect: "allow",
Actions: []string{"registry:push"},
Enabled: false,
}
if _, err := d.CreatePolicyRule(rule1); err != nil {
t.Fatalf("CreatePolicyRule 1: %v", err)
}
if _, err := d.CreatePolicyRule(rule2); err != nil {
t.Fatalf("CreatePolicyRule 2: %v", err)
}
if _, err := d.CreatePolicyRule(rule3); err != nil {
t.Fatalf("CreatePolicyRule 3: %v", err)
}
rules, err := d.ListPolicyRules(50, 0)
if err != nil {
t.Fatalf("ListPolicyRules: %v", err)
}
if len(rules) != 3 {
t.Fatalf("rule count: got %d, want 3", len(rules))
}
// Should be ordered by priority ASC: 10, 30, 50.
if rules[0].Priority != 10 {
t.Fatalf("first rule priority: got %d, want 10", rules[0].Priority)
}
if rules[0].Description != "rule B" {
t.Fatalf("first rule description: got %q, want %q", rules[0].Description, "rule B")
}
if rules[1].Priority != 30 {
t.Fatalf("second rule priority: got %d, want 30", rules[1].Priority)
}
if rules[2].Priority != 50 {
t.Fatalf("third rule priority: got %d, want 50", rules[2].Priority)
}
// Verify enabled flags.
if !rules[0].Enabled {
t.Fatal("rule B enabled: got false, want true")
}
if rules[1].Enabled {
t.Fatal("rule C enabled: got true, want false")
}
}
func TestListPolicyRulesEmpty(t *testing.T) {
d := migratedTestDB(t)
rules, err := d.ListPolicyRules(50, 0)
if err != nil {
t.Fatalf("ListPolicyRules: %v", err)
}
if rules != nil {
t.Fatalf("expected nil rules, got %v", rules)
}
}
func TestUpdatePolicyRule(t *testing.T) {
d := migratedTestDB(t)
rule := PolicyRuleRow{
Priority: 50,
Description: "original",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
}
id, err := d.CreatePolicyRule(rule)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
// Update priority and description.
updates := PolicyRuleRow{
Priority: 25,
Description: "updated",
}
if err := d.UpdatePolicyRule(id, updates); err != nil {
t.Fatalf("UpdatePolicyRule: %v", err)
}
got, err := d.GetPolicyRule(id)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.Priority != 25 {
t.Fatalf("priority: got %d, want 25", got.Priority)
}
if got.Description != "updated" {
t.Fatalf("description: got %q, want %q", got.Description, "updated")
}
// Effect should be unchanged.
if got.Effect != "allow" {
t.Fatalf("effect: got %q, want %q (unchanged)", got.Effect, "allow")
}
// Actions should be unchanged.
if len(got.Actions) != 1 || got.Actions[0] != "registry:pull" {
t.Fatalf("actions: got %v, want [registry:pull] (unchanged)", got.Actions)
}
}
func TestUpdatePolicyRuleBody(t *testing.T) {
d := migratedTestDB(t)
rule := PolicyRuleRow{
Priority: 50,
Description: "test",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
}
id, err := d.CreatePolicyRule(rule)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
// Update rule body fields.
updates := PolicyRuleRow{
Effect: "deny",
Actions: []string{"registry:push", "registry:delete"},
Roles: []string{"ci"},
}
if err := d.UpdatePolicyRule(id, updates); err != nil {
t.Fatalf("UpdatePolicyRule: %v", err)
}
got, err := d.GetPolicyRule(id)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.Effect != "deny" {
t.Fatalf("effect: got %q, want %q", got.Effect, "deny")
}
if len(got.Actions) != 2 {
t.Fatalf("actions: got %d, want 2", len(got.Actions))
}
if len(got.Roles) != 1 || got.Roles[0] != "ci" {
t.Fatalf("roles: got %v, want [ci]", got.Roles)
}
}
func TestUpdatePolicyRuleNotFound(t *testing.T) {
d := migratedTestDB(t)
err := d.UpdatePolicyRule(9999, PolicyRuleRow{Description: "nope"})
if !errors.Is(err, ErrPolicyRuleNotFound) {
t.Fatalf("expected ErrPolicyRuleNotFound, got %v", err)
}
}
func TestSetPolicyRuleEnabled(t *testing.T) {
d := migratedTestDB(t)
rule := PolicyRuleRow{
Priority: 50,
Description: "test",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
}
id, err := d.CreatePolicyRule(rule)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
// Disable the rule.
if err := d.SetPolicyRuleEnabled(id, false); err != nil {
t.Fatalf("SetPolicyRuleEnabled(false): %v", err)
}
got, err := d.GetPolicyRule(id)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.Enabled {
t.Fatal("enabled: got true, want false")
}
// Re-enable.
if err := d.SetPolicyRuleEnabled(id, true); err != nil {
t.Fatalf("SetPolicyRuleEnabled(true): %v", err)
}
got, err = d.GetPolicyRule(id)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if !got.Enabled {
t.Fatal("enabled: got false, want true")
}
}
func TestSetPolicyRuleEnabledNotFound(t *testing.T) {
d := migratedTestDB(t)
err := d.SetPolicyRuleEnabled(9999, true)
if !errors.Is(err, ErrPolicyRuleNotFound) {
t.Fatalf("expected ErrPolicyRuleNotFound, got %v", err)
}
}
func TestDeletePolicyRule(t *testing.T) {
d := migratedTestDB(t)
rule := PolicyRuleRow{
Priority: 50,
Description: "to delete",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
}
id, err := d.CreatePolicyRule(rule)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
if err := d.DeletePolicyRule(id); err != nil {
t.Fatalf("DeletePolicyRule: %v", err)
}
// Verify it's gone.
_, err = d.GetPolicyRule(id)
if !errors.Is(err, ErrPolicyRuleNotFound) {
t.Fatalf("expected ErrPolicyRuleNotFound after delete, got %v", err)
}
}
func TestDeletePolicyRuleNotFound(t *testing.T) {
d := migratedTestDB(t)
err := d.DeletePolicyRule(9999)
if !errors.Is(err, ErrPolicyRuleNotFound) {
t.Fatalf("expected ErrPolicyRuleNotFound, got %v", err)
}
}

9
internal/db/errors.go Normal file
View File

@@ -0,0 +1,9 @@
package db
import "errors"
var (
ErrRepoNotFound = errors.New("db: repository not found")
ErrManifestNotFound = errors.New("db: manifest not found")
ErrBlobNotFound = errors.New("db: blob not found")
)

154
internal/db/push.go Normal file
View File

@@ -0,0 +1,154 @@
package db
import (
"database/sql"
"errors"
"fmt"
)
// GetOrCreateRepository returns the repository ID for the given name,
// creating it if it does not exist (implicit creation on first push).
func (d *DB) GetOrCreateRepository(name string) (int64, error) {
var id int64
err := d.QueryRow(`SELECT id FROM repositories WHERE name = ?`, name).Scan(&id)
if err == nil {
return id, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("db: get repository: %w", err)
}
result, err := d.Exec(`INSERT INTO repositories (name) VALUES (?)`, name)
if err != nil {
return 0, fmt.Errorf("db: create repository: %w", err)
}
id, err = result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("db: repository last insert id: %w", err)
}
return id, nil
}
// BlobExists checks whether a blob with the given digest exists in the blobs table.
func (d *DB) BlobExists(digest string) (bool, error) {
var count int
err := d.QueryRow(`SELECT COUNT(*) FROM blobs WHERE digest = ?`, digest).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: blob exists: %w", err)
}
return count > 0, nil
}
// InsertBlob inserts a blob row if it does not already exist.
// Returns without error if the blob already exists (content-addressed dedup).
func (d *DB) InsertBlob(digest string, size int64) error {
_, err := d.Exec(
`INSERT OR IGNORE INTO blobs (digest, size) VALUES (?, ?)`,
digest, size,
)
if err != nil {
return fmt.Errorf("db: insert blob: %w", err)
}
return nil
}
// PushManifestParams holds the parameters for a manifest push operation.
type PushManifestParams struct {
RepoName string
Digest string
MediaType string
Content []byte
Size int64
Tag string // empty if push-by-digest
BlobDigests []string // referenced blob digests
}
// PushManifest executes the full manifest push in a single transaction per
// ARCHITECTURE.md §5. It creates the repository if needed, inserts/updates
// the manifest, populates manifest_blobs, and updates the tag if provided.
func (d *DB) PushManifest(p PushManifestParams) error {
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("db: begin push manifest: %w", err)
}
// Step a: create repository if not exists.
var repoID int64
err = tx.QueryRow(`SELECT id FROM repositories WHERE name = ?`, p.RepoName).Scan(&repoID)
if errors.Is(err, sql.ErrNoRows) {
result, insertErr := tx.Exec(`INSERT INTO repositories (name) VALUES (?)`, p.RepoName)
if insertErr != nil {
_ = tx.Rollback()
return fmt.Errorf("db: create repository: %w", insertErr)
}
repoID, err = result.LastInsertId()
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: repository last insert id: %w", err)
}
} else if err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: get repository: %w", err)
}
// Step b: insert or update manifest.
// Use INSERT OR REPLACE on the UNIQUE(repository_id, digest) constraint.
result, err := tx.Exec(
`INSERT INTO manifests (repository_id, digest, media_type, content, size)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(repository_id, digest) DO UPDATE SET
media_type = excluded.media_type,
content = excluded.content,
size = excluded.size`,
repoID, p.Digest, p.MediaType, p.Content, p.Size,
)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: insert manifest: %w", err)
}
manifestID, err := result.LastInsertId()
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: manifest last insert id: %w", err)
}
// Step c: populate manifest_blobs join table.
// Delete existing entries for this manifest first (in case of re-push).
_, err = tx.Exec(`DELETE FROM manifest_blobs WHERE manifest_id = ?`, manifestID)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: clear manifest_blobs: %w", err)
}
for _, blobDigest := range p.BlobDigests {
_, err = tx.Exec(
`INSERT INTO manifest_blobs (manifest_id, blob_id)
SELECT ?, id FROM blobs WHERE digest = ?`,
manifestID, blobDigest,
)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: insert manifest_blob: %w", err)
}
}
// Step d: if reference is a tag, insert or update tag row.
if p.Tag != "" {
_, err = tx.Exec(
`INSERT INTO tags (repository_id, name, manifest_id)
VALUES (?, ?, ?)
ON CONFLICT(repository_id, name) DO UPDATE SET
manifest_id = excluded.manifest_id,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')`,
repoID, p.Tag, manifestID,
)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: upsert tag: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: commit push manifest: %w", err)
}
return nil
}

278
internal/db/push_test.go Normal file
View File

@@ -0,0 +1,278 @@
package db
import "testing"
func TestGetOrCreateRepositoryNew(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
id, err := d.GetOrCreateRepository("newrepo")
if err != nil {
t.Fatalf("GetOrCreateRepository: %v", err)
}
if id <= 0 {
t.Fatalf("id: got %d, want > 0", id)
}
// Second call should return the same ID.
id2, err := d.GetOrCreateRepository("newrepo")
if err != nil {
t.Fatalf("GetOrCreateRepository (second): %v", err)
}
if id2 != id {
t.Fatalf("id mismatch: got %d, want %d", id2, id)
}
}
func TestGetOrCreateRepositoryExisting(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('existing')`)
if err != nil {
t.Fatalf("insert repo: %v", err)
}
id, err := d.GetOrCreateRepository("existing")
if err != nil {
t.Fatalf("GetOrCreateRepository: %v", err)
}
if id <= 0 {
t.Fatalf("id: got %d, want > 0", id)
}
}
func TestBlobExists(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err := d.Exec(`INSERT INTO blobs (digest, size) VALUES ('sha256:aaa', 100)`)
if err != nil {
t.Fatalf("insert blob: %v", err)
}
exists, err := d.BlobExists("sha256:aaa")
if err != nil {
t.Fatalf("BlobExists: %v", err)
}
if !exists {
t.Fatal("expected blob to exist")
}
exists, err = d.BlobExists("sha256:nonexistent")
if err != nil {
t.Fatalf("BlobExists (nonexistent): %v", err)
}
if exists {
t.Fatal("expected blob to not exist")
}
}
func TestInsertBlob(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
if err := d.InsertBlob("sha256:bbb", 200); err != nil {
t.Fatalf("InsertBlob: %v", err)
}
exists, err := d.BlobExists("sha256:bbb")
if err != nil {
t.Fatalf("BlobExists: %v", err)
}
if !exists {
t.Fatal("expected blob to exist after insert")
}
// Insert again — should be a no-op (INSERT OR IGNORE).
if err := d.InsertBlob("sha256:bbb", 200); err != nil {
t.Fatalf("InsertBlob (dup): %v", err)
}
}
func TestPushManifestByTag(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Insert blobs first.
if err := d.InsertBlob("sha256:config111", 50); err != nil {
t.Fatalf("insert config blob: %v", err)
}
if err := d.InsertBlob("sha256:layer111", 1000); err != nil {
t.Fatalf("insert layer blob: %v", err)
}
content := []byte(`{"schemaVersion":2}`)
params := PushManifestParams{
RepoName: "myrepo",
Digest: "sha256:manifest111",
MediaType: "application/vnd.oci.image.manifest.v1+json",
Content: content,
Size: int64(len(content)),
Tag: "latest",
BlobDigests: []string{"sha256:config111", "sha256:layer111"},
}
if err := d.PushManifest(params); err != nil {
t.Fatalf("PushManifest: %v", err)
}
// Verify repository was created.
repoID, err := d.GetRepositoryByName("myrepo")
if err != nil {
t.Fatalf("GetRepositoryByName: %v", err)
}
if repoID <= 0 {
t.Fatalf("repo id: got %d, want > 0", repoID)
}
// Verify manifest exists.
m, err := d.GetManifestByDigest(repoID, "sha256:manifest111")
if err != nil {
t.Fatalf("GetManifestByDigest: %v", err)
}
if m.MediaType != "application/vnd.oci.image.manifest.v1+json" {
t.Fatalf("media type: got %q", m.MediaType)
}
if m.Size != int64(len(content)) {
t.Fatalf("size: got %d, want %d", m.Size, len(content))
}
// Verify tag points to manifest.
m2, err := d.GetManifestByTag(repoID, "latest")
if err != nil {
t.Fatalf("GetManifestByTag: %v", err)
}
if m2.Digest != "sha256:manifest111" {
t.Fatalf("tag digest: got %q", m2.Digest)
}
// Verify manifest_blobs join table.
var mbCount int
if err := d.QueryRow(`SELECT COUNT(*) FROM manifest_blobs WHERE manifest_id = ?`, m.ID).Scan(&mbCount); err != nil {
t.Fatalf("count manifest_blobs: %v", err)
}
if mbCount != 2 {
t.Fatalf("manifest_blobs count: got %d, want 2", mbCount)
}
}
func TestPushManifestByDigest(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
content := []byte(`{"schemaVersion":2}`)
params := PushManifestParams{
RepoName: "myrepo",
Digest: "sha256:manifest222",
MediaType: "application/vnd.oci.image.manifest.v1+json",
Content: content,
Size: int64(len(content)),
Tag: "", // push by digest — no tag
}
if err := d.PushManifest(params); err != nil {
t.Fatalf("PushManifest: %v", err)
}
// Verify no tag was created.
var tagCount int
if err := d.QueryRow(`SELECT COUNT(*) FROM tags`).Scan(&tagCount); err != nil {
t.Fatalf("count tags: %v", err)
}
if tagCount != 0 {
t.Fatalf("tag count: got %d, want 0", tagCount)
}
}
func TestPushManifestTagMove(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Push first manifest with tag "latest".
content1 := []byte(`{"schemaVersion":2,"v":"1"}`)
if err := d.PushManifest(PushManifestParams{
RepoName: "myrepo",
Digest: "sha256:first",
MediaType: "application/vnd.oci.image.manifest.v1+json",
Content: content1,
Size: int64(len(content1)),
Tag: "latest",
}); err != nil {
t.Fatalf("PushManifest (first): %v", err)
}
// Push second manifest with same tag "latest" — should atomically move tag.
content2 := []byte(`{"schemaVersion":2,"v":"2"}`)
if err := d.PushManifest(PushManifestParams{
RepoName: "myrepo",
Digest: "sha256:second",
MediaType: "application/vnd.oci.image.manifest.v1+json",
Content: content2,
Size: int64(len(content2)),
Tag: "latest",
}); err != nil {
t.Fatalf("PushManifest (second): %v", err)
}
repoID, err := d.GetRepositoryByName("myrepo")
if err != nil {
t.Fatalf("GetRepositoryByName: %v", err)
}
m, err := d.GetManifestByTag(repoID, "latest")
if err != nil {
t.Fatalf("GetManifestByTag: %v", err)
}
if m.Digest != "sha256:second" {
t.Fatalf("tag should point to second manifest, got %q", m.Digest)
}
}
func TestPushManifestIdempotent(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
content := []byte(`{"schemaVersion":2}`)
params := PushManifestParams{
RepoName: "myrepo",
Digest: "sha256:manifest333",
MediaType: "application/vnd.oci.image.manifest.v1+json",
Content: content,
Size: int64(len(content)),
Tag: "latest",
}
// Push twice — should not fail.
if err := d.PushManifest(params); err != nil {
t.Fatalf("PushManifest (first): %v", err)
}
if err := d.PushManifest(params); err != nil {
t.Fatalf("PushManifest (second): %v", err)
}
// Verify only one manifest exists.
var mCount int
if err := d.QueryRow(`SELECT COUNT(*) FROM manifests`).Scan(&mCount); err != nil {
t.Fatalf("count manifests: %v", err)
}
if mCount != 1 {
t.Fatalf("manifest count: got %d, want 1", mCount)
}
}

154
internal/db/repository.go Normal file
View File

@@ -0,0 +1,154 @@
package db
import (
"database/sql"
"errors"
"fmt"
)
// ManifestRow represents a manifest as stored in the database.
type ManifestRow struct {
ID int64
RepositoryID int64
Digest string
MediaType string
Content []byte
Size int64
}
// GetRepositoryByName returns the repository ID for the given name.
func (d *DB) GetRepositoryByName(name string) (int64, error) {
var id int64
err := d.QueryRow(`SELECT id FROM repositories WHERE name = ?`, name).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrRepoNotFound
}
return 0, fmt.Errorf("db: get repository by name: %w", err)
}
return id, nil
}
// GetManifestByTag returns the manifest associated with the given tag in a repository.
func (d *DB) GetManifestByTag(repoID int64, tag string) (*ManifestRow, error) {
var m ManifestRow
err := d.QueryRow(
`SELECT m.id, m.repository_id, m.digest, m.media_type, m.content, m.size
FROM manifests m
JOIN tags t ON t.manifest_id = m.id
WHERE t.repository_id = ? AND t.name = ?`,
repoID, tag,
).Scan(&m.ID, &m.RepositoryID, &m.Digest, &m.MediaType, &m.Content, &m.Size)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrManifestNotFound
}
return nil, fmt.Errorf("db: get manifest by tag: %w", err)
}
return &m, nil
}
// GetManifestByDigest returns the manifest with the given digest in a repository.
func (d *DB) GetManifestByDigest(repoID int64, digest string) (*ManifestRow, error) {
var m ManifestRow
err := d.QueryRow(
`SELECT id, repository_id, digest, media_type, content, size
FROM manifests
WHERE repository_id = ? AND digest = ?`,
repoID, digest,
).Scan(&m.ID, &m.RepositoryID, &m.Digest, &m.MediaType, &m.Content, &m.Size)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrManifestNotFound
}
return nil, fmt.Errorf("db: get manifest by digest: %w", err)
}
return &m, nil
}
// BlobExistsInRepo checks whether a blob with the given digest exists and is
// referenced by at least one manifest in the given repository.
func (d *DB) BlobExistsInRepo(repoID int64, digest string) (bool, error) {
var count int
err := d.QueryRow(
`SELECT COUNT(*) FROM blobs b
JOIN manifest_blobs mb ON mb.blob_id = b.id
JOIN manifests m ON m.id = mb.manifest_id
WHERE m.repository_id = ? AND b.digest = ?`,
repoID, digest,
).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: blob exists in repo: %w", err)
}
return count > 0, nil
}
// ListTags returns tag names for a repository, ordered alphabetically.
// Pagination is cursor-based: after is the last tag name from the previous page,
// limit is the maximum number of tags to return.
func (d *DB) ListTags(repoID int64, after string, limit int) ([]string, error) {
var query string
var args []any
if after != "" {
query = `SELECT name FROM tags WHERE repository_id = ? AND name > ? ORDER BY name ASC LIMIT ?`
args = []any{repoID, after, limit}
} else {
query = `SELECT name FROM tags WHERE repository_id = ? ORDER BY name ASC LIMIT ?`
args = []any{repoID, limit}
}
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("db: list tags: %w", err)
}
defer func() { _ = rows.Close() }()
var tags []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("db: scan tag: %w", err)
}
tags = append(tags, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate tags: %w", err)
}
return tags, nil
}
// ListRepositoryNames returns repository names ordered alphabetically.
// Pagination is cursor-based: after is the last repo name from the previous page,
// limit is the maximum number of names to return.
func (d *DB) ListRepositoryNames(after string, limit int) ([]string, error) {
var query string
var args []any
if after != "" {
query = `SELECT name FROM repositories WHERE name > ? ORDER BY name ASC LIMIT ?`
args = []any{after, limit}
} else {
query = `SELECT name FROM repositories ORDER BY name ASC LIMIT ?`
args = []any{limit}
}
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("db: list repository names: %w", err)
}
defer func() { _ = rows.Close() }()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("db: scan repository name: %w", err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate repository names: %w", err)
}
return names, nil
}

View File

@@ -0,0 +1,429 @@
package db
import (
"errors"
"testing"
)
// seedTestRepo inserts a repository, manifest, tag, blob, and manifest_blob
// link for use in repository query tests. It returns the repository ID.
func seedTestRepo(t *testing.T, d *DB) int64 {
t.Helper()
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('myorg/myapp')`)
if err != nil {
t.Fatalf("insert repo: %v", err)
}
var repoID int64
if err := d.QueryRow(`SELECT id FROM repositories WHERE name = 'myorg/myapp'`).Scan(&repoID); err != nil {
t.Fatalf("select repo id: %v", err)
}
_, err = d.Exec(
`INSERT INTO manifests (repository_id, digest, media_type, content, size)
VALUES (?, 'sha256:aaaa', 'application/vnd.oci.image.manifest.v1+json', '{"layers":[]}', 15)`,
repoID,
)
if err != nil {
t.Fatalf("insert manifest: %v", err)
}
var manifestID int64
if err := d.QueryRow(`SELECT id FROM manifests WHERE digest = 'sha256:aaaa'`).Scan(&manifestID); err != nil {
t.Fatalf("select manifest id: %v", err)
}
_, err = d.Exec(
`INSERT INTO tags (repository_id, name, manifest_id) VALUES (?, 'latest', ?)`,
repoID, manifestID,
)
if err != nil {
t.Fatalf("insert tag: %v", err)
}
_, err = d.Exec(`INSERT INTO blobs (digest, size) VALUES ('sha256:bbbb', 2048)`)
if err != nil {
t.Fatalf("insert blob: %v", err)
}
var blobID int64
if err := d.QueryRow(`SELECT id FROM blobs WHERE digest = 'sha256:bbbb'`).Scan(&blobID); err != nil {
t.Fatalf("select blob id: %v", err)
}
_, err = d.Exec(
`INSERT INTO manifest_blobs (manifest_id, blob_id) VALUES (?, ?)`,
manifestID, blobID,
)
if err != nil {
t.Fatalf("insert manifest_blob: %v", err)
}
return repoID
}
func TestGetRepositoryByName_Found(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
seedTestRepo(t, d)
id, err := d.GetRepositoryByName("myorg/myapp")
if err != nil {
t.Fatalf("GetRepositoryByName: %v", err)
}
if id == 0 {
t.Fatal("expected non-zero repository ID")
}
}
func TestGetRepositoryByName_NotFound(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err := d.GetRepositoryByName("nonexistent")
if !errors.Is(err, ErrRepoNotFound) {
t.Fatalf("expected ErrRepoNotFound, got %v", err)
}
}
func TestGetManifestByTag_Found(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
m, err := d.GetManifestByTag(repoID, "latest")
if err != nil {
t.Fatalf("GetManifestByTag: %v", err)
}
if m.Digest != "sha256:aaaa" {
t.Fatalf("digest: got %q, want %q", m.Digest, "sha256:aaaa")
}
if m.MediaType != "application/vnd.oci.image.manifest.v1+json" {
t.Fatalf("media type: got %q, want OCI manifest", m.MediaType)
}
if m.Size != 15 {
t.Fatalf("size: got %d, want 15", m.Size)
}
if string(m.Content) != `{"layers":[]}` {
t.Fatalf("content: got %q, want {\"layers\":[]}", string(m.Content))
}
}
func TestGetManifestByTag_NotFound(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
_, err := d.GetManifestByTag(repoID, "v0.0.0-nonexistent")
if !errors.Is(err, ErrManifestNotFound) {
t.Fatalf("expected ErrManifestNotFound, got %v", err)
}
}
func TestGetManifestByDigest_Found(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
m, err := d.GetManifestByDigest(repoID, "sha256:aaaa")
if err != nil {
t.Fatalf("GetManifestByDigest: %v", err)
}
if m.Digest != "sha256:aaaa" {
t.Fatalf("digest: got %q, want %q", m.Digest, "sha256:aaaa")
}
if m.RepositoryID != repoID {
t.Fatalf("repository_id: got %d, want %d", m.RepositoryID, repoID)
}
}
func TestGetManifestByDigest_NotFound(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
_, err := d.GetManifestByDigest(repoID, "sha256:nonexistent")
if !errors.Is(err, ErrManifestNotFound) {
t.Fatalf("expected ErrManifestNotFound, got %v", err)
}
}
func TestBlobExistsInRepo_Exists(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
exists, err := d.BlobExistsInRepo(repoID, "sha256:bbbb")
if err != nil {
t.Fatalf("BlobExistsInRepo: %v", err)
}
if !exists {
t.Fatal("expected blob to exist in repo")
}
}
func TestBlobExistsInRepo_NotInThisRepo(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
seedTestRepo(t, d) // creates blob sha256:bbbb in myorg/myapp
// Create a second repo with no manifests linking the blob.
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('other/repo')`)
if err != nil {
t.Fatalf("insert other repo: %v", err)
}
var otherRepoID int64
if err := d.QueryRow(`SELECT id FROM repositories WHERE name = 'other/repo'`).Scan(&otherRepoID); err != nil {
t.Fatalf("select other repo id: %v", err)
}
exists, err := d.BlobExistsInRepo(otherRepoID, "sha256:bbbb")
if err != nil {
t.Fatalf("BlobExistsInRepo: %v", err)
}
if exists {
t.Fatal("expected blob to NOT exist in other repo")
}
}
func TestBlobExistsInRepo_BlobDoesNotExist(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
exists, err := d.BlobExistsInRepo(repoID, "sha256:nonexistent")
if err != nil {
t.Fatalf("BlobExistsInRepo: %v", err)
}
if exists {
t.Fatal("expected blob to not exist")
}
}
func TestListTags_WithTags(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
// Add more tags pointing to the same manifest.
var manifestID int64
if err := d.QueryRow(`SELECT id FROM manifests WHERE repository_id = ?`, repoID).Scan(&manifestID); err != nil {
t.Fatalf("select manifest id: %v", err)
}
for _, tag := range []string{"v1.0", "v2.0", "beta"} {
_, err := d.Exec(`INSERT INTO tags (repository_id, name, manifest_id) VALUES (?, ?, ?)`,
repoID, tag, manifestID)
if err != nil {
t.Fatalf("insert tag %q: %v", tag, err)
}
}
tags, err := d.ListTags(repoID, "", 100)
if err != nil {
t.Fatalf("ListTags: %v", err)
}
// Expect alphabetical: beta, latest, v1.0, v2.0
want := []string{"beta", "latest", "v1.0", "v2.0"}
if len(tags) != len(want) {
t.Fatalf("tags count: got %d, want %d", len(tags), len(want))
}
for i, tag := range tags {
if tag != want[i] {
t.Fatalf("tags[%d]: got %q, want %q", i, tag, want[i])
}
}
}
func TestListTags_Pagination(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
repoID := seedTestRepo(t, d)
var manifestID int64
if err := d.QueryRow(`SELECT id FROM manifests WHERE repository_id = ?`, repoID).Scan(&manifestID); err != nil {
t.Fatalf("select manifest id: %v", err)
}
for _, tag := range []string{"v1.0", "v2.0", "beta"} {
_, err := d.Exec(`INSERT INTO tags (repository_id, name, manifest_id) VALUES (?, ?, ?)`,
repoID, tag, manifestID)
if err != nil {
t.Fatalf("insert tag %q: %v", tag, err)
}
}
// First page: 2 tags starting from beginning.
tags, err := d.ListTags(repoID, "", 2)
if err != nil {
t.Fatalf("ListTags page 1: %v", err)
}
if len(tags) != 2 {
t.Fatalf("page 1 count: got %d, want 2", len(tags))
}
if tags[0] != "beta" || tags[1] != "latest" {
t.Fatalf("page 1: got %v, want [beta, latest]", tags)
}
// Second page: after "latest".
tags, err = d.ListTags(repoID, "latest", 2)
if err != nil {
t.Fatalf("ListTags page 2: %v", err)
}
if len(tags) != 2 {
t.Fatalf("page 2 count: got %d, want 2", len(tags))
}
if tags[0] != "v1.0" || tags[1] != "v2.0" {
t.Fatalf("page 2: got %v, want [v1.0, v2.0]", tags)
}
// Third page: after "v2.0" — no more tags.
tags, err = d.ListTags(repoID, "v2.0", 2)
if err != nil {
t.Fatalf("ListTags page 3: %v", err)
}
if len(tags) != 0 {
t.Fatalf("page 3 count: got %d, want 0", len(tags))
}
}
func TestListTags_Empty(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Create a repo with no tags.
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('empty/repo')`)
if err != nil {
t.Fatalf("insert repo: %v", err)
}
var repoID int64
if err := d.QueryRow(`SELECT id FROM repositories WHERE name = 'empty/repo'`).Scan(&repoID); err != nil {
t.Fatalf("select repo id: %v", err)
}
tags, err := d.ListTags(repoID, "", 100)
if err != nil {
t.Fatalf("ListTags: %v", err)
}
if tags != nil {
t.Fatalf("expected nil tags, got %v", tags)
}
}
func TestListRepositoryNames_WithRepos(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
for _, name := range []string{"charlie/app", "alpha/lib", "bravo/svc"} {
_, err := d.Exec(`INSERT INTO repositories (name) VALUES (?)`, name)
if err != nil {
t.Fatalf("insert repo %q: %v", name, err)
}
}
names, err := d.ListRepositoryNames("", 100)
if err != nil {
t.Fatalf("ListRepositoryNames: %v", err)
}
want := []string{"alpha/lib", "bravo/svc", "charlie/app"}
if len(names) != len(want) {
t.Fatalf("names count: got %d, want %d", len(names), len(want))
}
for i, n := range names {
if n != want[i] {
t.Fatalf("names[%d]: got %q, want %q", i, n, want[i])
}
}
}
func TestListRepositoryNames_Pagination(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
for _, name := range []string{"charlie/app", "alpha/lib", "bravo/svc"} {
_, err := d.Exec(`INSERT INTO repositories (name) VALUES (?)`, name)
if err != nil {
t.Fatalf("insert repo %q: %v", name, err)
}
}
// First page: 2.
names, err := d.ListRepositoryNames("", 2)
if err != nil {
t.Fatalf("ListRepositoryNames page 1: %v", err)
}
if len(names) != 2 {
t.Fatalf("page 1 count: got %d, want 2", len(names))
}
if names[0] != "alpha/lib" || names[1] != "bravo/svc" {
t.Fatalf("page 1: got %v", names)
}
// Second page: after "bravo/svc".
names, err = d.ListRepositoryNames("bravo/svc", 2)
if err != nil {
t.Fatalf("ListRepositoryNames page 2: %v", err)
}
if len(names) != 1 {
t.Fatalf("page 2 count: got %d, want 1", len(names))
}
if names[0] != "charlie/app" {
t.Fatalf("page 2: got %v", names)
}
}
func TestListRepositoryNames_Empty(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
names, err := d.ListRepositoryNames("", 100)
if err != nil {
t.Fatalf("ListRepositoryNames: %v", err)
}
if names != nil {
t.Fatalf("expected nil names, got %v", names)
}
}

80
internal/db/upload.go Normal file
View File

@@ -0,0 +1,80 @@
package db
import (
"database/sql"
"errors"
"fmt"
)
// ErrUploadNotFound indicates the requested upload UUID does not exist.
var ErrUploadNotFound = errors.New("db: upload not found")
// UploadRow represents a row in the uploads table.
type UploadRow struct {
ID int64
UUID string
RepositoryID int64
ByteOffset int64
}
// CreateUpload inserts a new upload row and returns its ID.
func (d *DB) CreateUpload(uuid string, repoID int64) error {
_, err := d.Exec(
`INSERT INTO uploads (uuid, repository_id) VALUES (?, ?)`,
uuid, repoID,
)
if err != nil {
return fmt.Errorf("db: create upload: %w", err)
}
return nil
}
// GetUpload returns the upload with the given UUID.
func (d *DB) GetUpload(uuid string) (*UploadRow, error) {
var u UploadRow
err := d.QueryRow(
`SELECT id, uuid, repository_id, byte_offset FROM uploads WHERE uuid = ?`, uuid,
).Scan(&u.ID, &u.UUID, &u.RepositoryID, &u.ByteOffset)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUploadNotFound
}
return nil, fmt.Errorf("db: get upload: %w", err)
}
return &u, nil
}
// UpdateUploadOffset sets the byte_offset for an upload.
func (d *DB) UpdateUploadOffset(uuid string, offset int64) error {
result, err := d.Exec(
`UPDATE uploads SET byte_offset = ? WHERE uuid = ?`,
offset, uuid,
)
if err != nil {
return fmt.Errorf("db: update upload offset: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: update upload offset rows affected: %w", err)
}
if n == 0 {
return ErrUploadNotFound
}
return nil
}
// DeleteUpload removes the upload row with the given UUID.
func (d *DB) DeleteUpload(uuid string) error {
result, err := d.Exec(`DELETE FROM uploads WHERE uuid = ?`, uuid)
if err != nil {
return fmt.Errorf("db: delete upload: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: delete upload rows affected: %w", err)
}
if n == 0 {
return ErrUploadNotFound
}
return nil
}

124
internal/db/upload_test.go Normal file
View File

@@ -0,0 +1,124 @@
package db
import (
"errors"
"testing"
)
func TestCreateAndGetUpload(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Create a repository for the upload.
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`)
if err != nil {
t.Fatalf("insert repo: %v", err)
}
if err := d.CreateUpload("test-uuid-1", 1); err != nil {
t.Fatalf("CreateUpload: %v", err)
}
u, err := d.GetUpload("test-uuid-1")
if err != nil {
t.Fatalf("GetUpload: %v", err)
}
if u.UUID != "test-uuid-1" {
t.Fatalf("uuid: got %q, want %q", u.UUID, "test-uuid-1")
}
if u.RepositoryID != 1 {
t.Fatalf("repo id: got %d, want 1", u.RepositoryID)
}
if u.ByteOffset != 0 {
t.Fatalf("byte offset: got %d, want 0", u.ByteOffset)
}
}
func TestGetUploadNotFound(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err := d.GetUpload("nonexistent")
if !errors.Is(err, ErrUploadNotFound) {
t.Fatalf("err: got %v, want ErrUploadNotFound", err)
}
}
func TestUpdateUploadOffset(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`)
if err != nil {
t.Fatalf("insert repo: %v", err)
}
if err := d.CreateUpload("test-uuid-2", 1); err != nil {
t.Fatalf("CreateUpload: %v", err)
}
if err := d.UpdateUploadOffset("test-uuid-2", 1024); err != nil {
t.Fatalf("UpdateUploadOffset: %v", err)
}
u, err := d.GetUpload("test-uuid-2")
if err != nil {
t.Fatalf("GetUpload: %v", err)
}
if u.ByteOffset != 1024 {
t.Fatalf("byte offset: got %d, want 1024", u.ByteOffset)
}
}
func TestUpdateUploadOffsetNotFound(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
err := d.UpdateUploadOffset("nonexistent", 100)
if !errors.Is(err, ErrUploadNotFound) {
t.Fatalf("err: got %v, want ErrUploadNotFound", err)
}
}
func TestDeleteUpload(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`)
if err != nil {
t.Fatalf("insert repo: %v", err)
}
if err := d.CreateUpload("test-uuid-3", 1); err != nil {
t.Fatalf("CreateUpload: %v", err)
}
if err := d.DeleteUpload("test-uuid-3"); err != nil {
t.Fatalf("DeleteUpload: %v", err)
}
_, err = d.GetUpload("test-uuid-3")
if !errors.Is(err, ErrUploadNotFound) {
t.Fatalf("after delete: got %v, want ErrUploadNotFound", err)
}
}
func TestDeleteUploadNotFound(t *testing.T) {
d := openTestDB(t)
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
err := d.DeleteUpload("nonexistent")
if !errors.Is(err, ErrUploadNotFound) {
t.Fatalf("err: got %v, want ErrUploadNotFound", err)
}
}