mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
Completes the swap 0033 set up. layers and manifest_references move onto manifest_key and manifests.id is gone, which removes the last node-allocated identifier in the AppView schema. Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE performs an implicit DELETE FROM, so dropping manifests while layers still holds an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did exactly that; it went unnoticed because the Jetstream backfill rebuilds layers from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help: it is a no-op inside a transaction and migrations run in one. So the new children are built pointing at manifests_new, the old children are dropped first, and only then is the old manifests table dropped, by which point nothing references it. Verified both behaviors before relying on them. manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That constraint immediately caught four test helpers inserting manifests without one. Five queries used MAX(id) as "the newest manifest in this repo", which I had previously reported as absent after grepping only for ORDER BY. A derived key has no ordering, so recency now comes from created_at with manifest_key as a deterministic tiebreak. This is a real behavior change, and a fix: the two disagree whenever a manifest is indexed out of order, which the backfill does routinely, and created_at is the push time these queries always wanted. Both directions are tested, including that ties resolve the same way every run. InsertManifest and BatchInsertManifests no longer read anything back. The key is derived from (did, repository, digest), so the writer knows it before the statement runs: the select-back, its per-DID IN list, and the "manifest missing id after batch insert" branch all go away, along with the UNIQUE-conflict fallback that existed only to recover a rowid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
278 lines
8.1 KiB
Go
278 lines
8.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/holdclient"
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// LayerDetail combines OCI config history with layer metadata from the DB.
|
|
type LayerDetail struct {
|
|
Index int
|
|
Command string // Dockerfile command (from config history)
|
|
Digest string
|
|
Size int64
|
|
MediaType string
|
|
EmptyLayer bool // ENV, LABEL, etc. — no actual layer blob
|
|
}
|
|
|
|
// HelmChartContent is the data the helm-aware digest content needs: parsed
|
|
// Chart.yaml metadata + a single chart-tarball "layer" pulled from the DB.
|
|
type HelmChartContent struct {
|
|
Meta *holdclient.HelmChartMeta
|
|
Tarball *LayerDetail
|
|
MetaFetchFailed bool // hold reachable but config blob couldn't be parsed
|
|
HoldUnreachable bool
|
|
}
|
|
|
|
// buildHelmContent fetches helm chart metadata + the single chart-tarball layer.
|
|
// Returns a populated HelmChartContent even when the meta fetch fails so the
|
|
// page can still render the artifact card.
|
|
func buildHelmContent(ctx context.Context, holdURL string, digest string, dbLayers []db.Layer) *HelmChartContent {
|
|
content := &HelmChartContent{}
|
|
if holdURL == "" {
|
|
content.HoldUnreachable = true
|
|
} else {
|
|
meta, err := holdclient.FetchHelmChartMeta(ctx, holdURL, digest)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch helm chart meta", "error", err, "digest", digest)
|
|
content.MetaFetchFailed = true
|
|
} else {
|
|
content.Meta = meta
|
|
}
|
|
}
|
|
if len(dbLayers) > 0 {
|
|
// Helm charts are always single-layer (the chart tarball). If somehow
|
|
// multiple are present, pick the one with helm chart content media
|
|
// type, falling back to the first.
|
|
chosen := 0
|
|
for i, l := range dbLayers {
|
|
if strings.Contains(l.MediaType, "helm.chart.content") {
|
|
chosen = i
|
|
break
|
|
}
|
|
}
|
|
l := dbLayers[chosen]
|
|
content.Tarball = &LayerDetail{
|
|
Index: l.LayerIndex + 1,
|
|
Digest: l.Digest,
|
|
Size: l.Size,
|
|
MediaType: l.MediaType,
|
|
}
|
|
}
|
|
return content
|
|
}
|
|
|
|
// DigestDetailHandler renders the digest detail page with layers + vulnerabilities.
|
|
type DigestDetailHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
identifier := chi.URLParam(r, "handle")
|
|
// The route is /d/{handle}/*/* — first * is repo, second * is digest
|
|
// chi captures both wildcards, so we need to split the path
|
|
pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2)
|
|
if len(pathParts) < 2 {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
repository := pathParts[0]
|
|
digest := pathParts[1]
|
|
|
|
// Resolve identity
|
|
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
|
|
if err != nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
|
|
// Check for takedown labels
|
|
if taken, _ := db.IsTakenDown(h.ReadOnlyDB, did, repository); taken {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
|
|
owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
|
|
if err != nil || owner == nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
if owner.Handle != resolvedHandle {
|
|
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
|
|
owner.Handle = resolvedHandle
|
|
}
|
|
|
|
// Fetch manifest details
|
|
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repository, digest)
|
|
if err != nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
|
|
// Build layer details
|
|
var layers []LayerDetail
|
|
var vulnData *vulnDetailsData
|
|
var sbomData *sbomDetailsData
|
|
var helmContent *HelmChartContent
|
|
|
|
if manifest.IsManifestList {
|
|
// Manifest list: no layers, show platform picker
|
|
// Platforms are already populated by GetManifestDetail
|
|
} else if manifest.ArtifactType == db.ArtifactTypeHelmChart {
|
|
// Helm chart: skip OCI history / vuln / SBOM entirely. Fetch helm
|
|
// chart metadata from the same config blob and the single tarball
|
|
// layer from the DB.
|
|
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.Key)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch layers", "error", err)
|
|
}
|
|
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
|
|
holdURL := ""
|
|
if holdErr == nil {
|
|
holdURL = hold.URL
|
|
}
|
|
helmContent = buildHelmContent(r.Context(), holdURL, digest, dbLayers)
|
|
if holdErr != nil {
|
|
helmContent.HoldUnreachable = true
|
|
}
|
|
} else {
|
|
// Single manifest: fetch layers from DB
|
|
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.Key)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch layers", "error", err)
|
|
}
|
|
|
|
// Resolve hold endpoint (follow successor if migrated)
|
|
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
|
|
|
|
// Fetch OCI image config from hold for layer history (including empty layers)
|
|
if holdErr == nil {
|
|
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest)
|
|
if err == nil {
|
|
layers = buildLayerDetails(config.History, dbLayers)
|
|
} else {
|
|
slog.Warn("Failed to fetch image config", "error", err,
|
|
"holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest)
|
|
layers = buildLayerDetails(nil, dbLayers)
|
|
}
|
|
} else {
|
|
layers = buildLayerDetails(nil, dbLayers)
|
|
}
|
|
|
|
// Fetch vulnerability and SBOM details
|
|
if holdErr == nil {
|
|
vd := FetchVulnDetails(r.Context(), hold.DID, digest)
|
|
vulnData = &vd
|
|
sd := FetchSbomDetails(r.Context(), hold.DID, digest)
|
|
sbomData = &sd
|
|
}
|
|
}
|
|
|
|
// Determine selected platform for multi-arch manifests
|
|
selectedPlatform := r.URL.Query().Get("platform")
|
|
if manifest.IsManifestList && len(manifest.Platforms) > 0 {
|
|
if selectedPlatform == "" {
|
|
selectedPlatform = manifest.Platforms[0].Digest
|
|
}
|
|
}
|
|
|
|
// Build page meta
|
|
var title string
|
|
if len(manifest.Tags) > 0 {
|
|
title = strings.Join(manifest.Tags, ", ") + " - " + owner.Handle + "/" + repository + " - " + h.ClientShortName
|
|
} else {
|
|
title = truncateDigestStr(digest, 16) + " - " + owner.Handle + "/" + repository + " - " + h.ClientShortName
|
|
}
|
|
description := "Image digest " + digest + " in " + owner.Handle + "/" + repository
|
|
|
|
meta := NewPageMeta(title, description).
|
|
WithCanonical("https://" + h.SiteURL + "/d/" + owner.Handle + "/" + repository + "/" + digest).
|
|
WithSiteName(h.ClientShortName)
|
|
|
|
pageData := NewPageData(r, &h.BaseUIHandler)
|
|
data := struct {
|
|
PageData
|
|
Meta *PageMeta
|
|
Owner *db.User
|
|
Repository string
|
|
Manifest *db.ManifestWithMetadata
|
|
Layers []LayerDetail
|
|
VulnData *vulnDetailsData
|
|
SbomData *sbomDetailsData
|
|
HelmContent *HelmChartContent
|
|
SelectedPlatform string
|
|
RegistryURL string
|
|
OciClient string
|
|
}{
|
|
PageData: pageData,
|
|
Meta: meta,
|
|
Owner: owner,
|
|
Repository: repository,
|
|
Manifest: manifest,
|
|
Layers: layers,
|
|
VulnData: vulnData,
|
|
SbomData: sbomData,
|
|
HelmContent: helmContent,
|
|
SelectedPlatform: selectedPlatform,
|
|
RegistryURL: h.RegistryURL,
|
|
OciClient: pageData.OciClient,
|
|
}
|
|
|
|
if err := h.Templates.ExecuteTemplate(w, "digest", data); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// buildLayerDetails correlates OCI config history entries with layer metadata.
|
|
// History entries with empty_layer=true have no corresponding layer blob.
|
|
func buildLayerDetails(history []holdclient.OCIHistoryEntry, dbLayers []db.Layer) []LayerDetail {
|
|
var details []LayerDetail
|
|
layerIdx := 0
|
|
|
|
if len(history) == 0 {
|
|
// No config history available — just list layers from DB
|
|
for _, l := range dbLayers {
|
|
details = append(details, LayerDetail{
|
|
Index: l.LayerIndex + 1,
|
|
Digest: l.Digest,
|
|
Size: l.Size,
|
|
MediaType: l.MediaType,
|
|
})
|
|
}
|
|
return details
|
|
}
|
|
|
|
for i, h := range history {
|
|
ld := LayerDetail{
|
|
Index: i + 1,
|
|
Command: h.CreatedBy,
|
|
EmptyLayer: h.EmptyLayer,
|
|
}
|
|
|
|
if !h.EmptyLayer && layerIdx < len(dbLayers) {
|
|
ld.Digest = dbLayers[layerIdx].Digest
|
|
ld.Size = dbLayers[layerIdx].Size
|
|
ld.MediaType = dbLayers[layerIdx].MediaType
|
|
layerIdx++
|
|
}
|
|
|
|
details = append(details, ld)
|
|
}
|
|
|
|
return details
|
|
}
|
|
|
|
func truncateDigestStr(s string, length int) string {
|
|
if len(s) <= length {
|
|
return s
|
|
}
|
|
return s[:length] + "..."
|
|
}
|