Files
Evan JarrettandClaude Opus 5 454a6bad3d db: key manifests by manifest_key and drop the rowid
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>
2026-08-11 22:44:41 -05:00

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] + "..."
}