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
|
||||
}
|
||||
593
internal/db/admin_test.go
Normal file
593
internal/db/admin_test.go
Normal 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
9
internal/db/errors.go
Normal 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
154
internal/db/push.go
Normal 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
278
internal/db/push_test.go
Normal 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
154
internal/db/repository.go
Normal 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
|
||||
}
|
||||
429
internal/db/repository_test.go
Normal file
429
internal/db/repository_test.go
Normal 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
80
internal/db/upload.go
Normal 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
124
internal/db/upload_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user