A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

fix sql migration bug. add better error logs for auth failures. fix showing incorrect pull commands with helm charts

evan.jarrett.net e6bd4c12 7dcef54d

verified
Changed files
+121 -22
pkg
appview
auth
+2 -1
pkg/appview/db/models.go
··· 154 154 Tag 155 155 Platforms []PlatformInfo 156 156 IsMultiArch bool 157 - HasAttestations bool // true if manifest list contains attestation references 157 + HasAttestations bool // true if manifest list contains attestation references 158 + ArtifactType string // container-image, helm-chart, unknown 158 159 } 159 160 160 161 // ManifestWithMetadata extends Manifest with tags and platform information
+6 -4
pkg/appview/db/queries.go
··· 653 653 t.digest, 654 654 t.created_at, 655 655 m.media_type, 656 + m.artifact_type, 656 657 COALESCE(mr.platform_os, '') as platform_os, 657 658 COALESCE(mr.platform_architecture, '') as platform_architecture, 658 659 COALESCE(mr.platform_variant, '') as platform_variant, ··· 676 677 677 678 for rows.Next() { 678 679 var t Tag 679 - var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string 680 + var mediaType, artifactType, platformOS, platformArch, platformVariant, platformOSVersion string 680 681 var isAttestation bool 681 682 682 683 if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt, 683 - &mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil { 684 + &mediaType, &artifactType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil { 684 685 return nil, err 685 686 } 686 687 ··· 688 689 tagKey := t.Tag 689 690 if _, exists := tagMap[tagKey]; !exists { 690 691 tagMap[tagKey] = &TagWithPlatforms{ 691 - Tag: t, 692 - Platforms: []PlatformInfo{}, 692 + Tag: t, 693 + Platforms: []PlatformInfo{}, 694 + ArtifactType: artifactType, 693 695 } 694 696 tagOrder = append(tagOrder, tagKey) 695 697 }
+62 -6
pkg/appview/db/schema.go
··· 37 37 return nil, err 38 38 } 39 39 40 - // Create schema from embedded SQL file 41 - if _, err := db.Exec(schemaSQL); err != nil { 42 - return nil, err 40 + // Check if this is an existing database with migrations applied 41 + isExisting, err := hasAppliedMigrations(db) 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to check database state: %w", err) 44 + } 45 + 46 + if isExisting { 47 + // Existing database: skip schema.sql, only run pending migrations 48 + slog.Debug("Existing database detected, skipping schema.sql") 49 + } else { 50 + // Fresh database: apply schema.sql 51 + slog.Info("Fresh database detected, applying schema") 52 + if err := applySchema(db); err != nil { 53 + return nil, err 54 + } 43 55 } 44 56 45 57 // Run migrations unless skipped 58 + // For fresh databases, migrations are recorded but not executed (schema.sql is already complete) 46 59 if !skipMigrations { 47 - if err := runMigrations(db); err != nil { 60 + if err := runMigrations(db, !isExisting); err != nil { 48 61 return nil, err 49 62 } 50 63 } ··· 52 65 return db, nil 53 66 } 54 67 68 + // hasAppliedMigrations checks if this is an existing database with migrations applied 69 + func hasAppliedMigrations(db *sql.DB) (bool, error) { 70 + // Check if schema_migrations table exists 71 + var count int 72 + err := db.QueryRow(` 73 + SELECT COUNT(*) FROM sqlite_master 74 + WHERE type='table' AND name='schema_migrations' 75 + `).Scan(&count) 76 + if err != nil { 77 + return false, err 78 + } 79 + if count == 0 { 80 + return false, nil // No migrations table = fresh DB 81 + } 82 + 83 + // Table exists, check if it has entries 84 + err = db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count) 85 + if err != nil { 86 + return false, err 87 + } 88 + return count > 0, nil 89 + } 90 + 91 + // applySchema executes schema.sql for fresh databases 92 + func applySchema(db *sql.DB) error { 93 + for _, stmt := range splitSQLStatements(schemaSQL) { 94 + if _, err := db.Exec(stmt); err != nil { 95 + return fmt.Errorf("failed to apply schema: %w", err) 96 + } 97 + } 98 + return nil 99 + } 100 + 55 101 // Migration represents a database migration 56 102 type Migration struct { 57 103 Version int ··· 61 107 } 62 108 63 109 // runMigrations applies any pending database migrations 64 - func runMigrations(db *sql.DB) error { 110 + // If freshDB is true, migrations are recorded but not executed (schema.sql already includes their changes) 111 + func runMigrations(db *sql.DB, freshDB bool) error { 65 112 // Load migrations from files 66 113 migrations, err := loadMigrations() 67 114 if err != nil { ··· 86 133 continue 87 134 } 88 135 89 - // Apply migration in a transaction 136 + if freshDB { 137 + // Fresh database: schema.sql already has everything, just record the migration 138 + slog.Debug("Recording migration as applied (fresh DB)", "version", m.Version, "name", m.Name) 139 + if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil { 140 + return fmt.Errorf("failed to record migration %d: %w", m.Version, err) 141 + } 142 + continue 143 + } 144 + 145 + // Existing database: apply migration in a transaction 90 146 slog.Info("Applying migration", "version", m.Version, "name", m.Name, "description", m.Description) 91 147 92 148 tx, err := db.Begin()
+6 -3
pkg/appview/handlers/repository.go
··· 231 231 } 232 232 } 233 233 234 - // Determine dominant artifact type from manifests 234 + // Determine artifact type for header section from first tag 235 + // This is used for the "Pull this image/chart" header command 235 236 artifactType := "container-image" 236 - if len(manifests) > 0 { 237 - // Use the most recent manifest's artifact type 237 + if len(tagsWithPlatforms) > 0 { 238 + artifactType = tagsWithPlatforms[0].ArtifactType 239 + } else if len(manifests) > 0 { 240 + // Fallback to manifests if no tags 238 241 artifactType = manifests[0].ArtifactType 239 242 } 240 243
+6
pkg/appview/storage/manifest_store.go
··· 124 124 return "", fmt.Errorf("failed to create manifest record: %w", err) 125 125 } 126 126 127 + // OCI spec allows omitting mediaType from the manifest body (inferred from Content-Type header) 128 + // Helm charts typically omit it, so use the media type from the request if body is empty 129 + if manifestRecord.MediaType == "" && mediaType != "" { 130 + manifestRecord.MediaType = mediaType 131 + } 132 + 127 133 // Set the blob reference, hold DID, and hold endpoint 128 134 manifestRecord.ManifestBlob = blobRef 129 135 manifestRecord.HoldDID = s.ctx.HoldDID // Primary reference (DID)
+1 -1
pkg/appview/templates/pages/repository.html
··· 183 183 {{ end }} 184 184 </div> 185 185 </div> 186 - {{ if eq $.ArtifactType "helm-chart" }} 186 + {{ if eq .ArtifactType "helm-chart" }} 187 187 {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .Tag.Tag) }} 188 188 {{ else }} 189 189 {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }}
+23 -5
pkg/auth/session.go
··· 9 9 "crypto/sha256" 10 10 "encoding/hex" 11 11 "encoding/json" 12 + "errors" 12 13 "fmt" 13 14 "io" 14 15 "log/slog" ··· 17 18 "time" 18 19 19 20 "atcr.io/pkg/atproto" 21 + ) 22 + 23 + // Sentinel errors for authentication failures 24 + var ( 25 + // ErrIdentityResolution indicates handle/DID resolution failed 26 + ErrIdentityResolution = errors.New("identity resolution failed") 27 + // ErrInvalidCredentials indicates PDS returned 401 (bad password/app-password) 28 + ErrInvalidCredentials = errors.New("invalid credentials") 29 + // ErrPDSUnavailable indicates PDS is unreachable or returned a server error 30 + ErrPDSUnavailable = errors.New("PDS unavailable") 20 31 ) 21 32 22 33 // CachedSession represents a cached session ··· 99 110 // Resolve identifier to PDS endpoint 100 111 _, _, pds, err := atproto.ResolveIdentity(ctx, identifier) 101 112 if err != nil { 102 - return "", "", "", err 113 + return "", "", "", fmt.Errorf("%w: %v", ErrIdentityResolution, err) 103 114 } 104 115 105 116 // Create session 106 117 sessionResp, err := v.createSession(ctx, pds, identifier, password) 107 118 if err != nil { 108 - return "", "", "", fmt.Errorf("authentication failed: %w", err) 119 + // Pass through typed errors from createSession 120 + return "", "", "", err 109 121 } 110 122 111 123 // Cache the session (ATProto sessions typically last 2 hours) ··· 146 158 resp, err := v.httpClient.Do(req) 147 159 if err != nil { 148 160 slog.Debug("Session creation HTTP request failed", "error", err) 149 - return nil, fmt.Errorf("failed to create session: %w", err) 161 + return nil, fmt.Errorf("%w: %v", ErrPDSUnavailable, err) 150 162 } 151 163 defer resp.Body.Close() 152 164 ··· 155 167 if resp.StatusCode == http.StatusUnauthorized { 156 168 bodyBytes, _ := io.ReadAll(resp.Body) 157 169 slog.Debug("Session creation unauthorized", "response", string(bodyBytes)) 158 - return nil, fmt.Errorf("invalid credentials") 170 + return nil, ErrInvalidCredentials 171 + } 172 + 173 + if resp.StatusCode >= 500 { 174 + bodyBytes, _ := io.ReadAll(resp.Body) 175 + slog.Debug("PDS server error", "status", resp.StatusCode, "response", string(bodyBytes)) 176 + return nil, fmt.Errorf("%w: server returned %d", ErrPDSUnavailable, resp.StatusCode) 159 177 } 160 178 161 179 if resp.StatusCode != http.StatusOK { 162 180 bodyBytes, _ := io.ReadAll(resp.Body) 163 181 slog.Debug("Session creation failed", "status", resp.StatusCode, "response", string(bodyBytes)) 164 - return nil, fmt.Errorf("create session failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 182 + return nil, fmt.Errorf("%w: unexpected status %d: %s", ErrPDSUnavailable, resp.StatusCode, string(bodyBytes)) 165 183 } 166 184 167 185 var sessionResp SessionResponse
+15 -2
pkg/auth/token/handler.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log/slog" 8 9 "net/http" ··· 194 195 slog.Debug("Trying app password authentication", "username", username) 195 196 did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) 196 197 if err != nil { 197 - slog.Debug("App password validation failed", "error", err, "username", username) 198 - sendAuthError(w, r, "authentication failed") 198 + // Log at WARN level with specific error type 199 + if errors.Is(err, auth.ErrIdentityResolution) { 200 + slog.Warn("Identity resolution failed", "error", err, "username", username) 201 + sendAuthError(w, r, "authentication failed: could not resolve handle") 202 + } else if errors.Is(err, auth.ErrInvalidCredentials) { 203 + slog.Warn("Invalid credentials", "username", username) 204 + sendAuthError(w, r, "authentication failed: invalid credentials") 205 + } else if errors.Is(err, auth.ErrPDSUnavailable) { 206 + slog.Warn("PDS unavailable", "error", err, "username", username) 207 + sendAuthError(w, r, "authentication failed: PDS unavailable") 208 + } else { 209 + slog.Warn("Authentication failed", "error", err, "username", username) 210 + sendAuthError(w, r, "authentication failed") 211 + } 199 212 return 200 213 } 201 214