mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-24 02:10:35 +00:00
add new files for getting image configs from hold etc
This commit is contained in:
37
lexicons/io/atcr/hold/getLayersForManifest.json
Normal file
37
lexicons/io/atcr/hold/getLayersForManifest.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.getLayersForManifest",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "query",
|
||||
"description": "Returns layer records for a specific manifest AT-URI.",
|
||||
"parameters": {
|
||||
"type": "params",
|
||||
"required": ["manifest"],
|
||||
"properties": {
|
||||
"manifest": {
|
||||
"type": "string",
|
||||
"format": "at-uri",
|
||||
"description": "AT-URI of the manifest to get layers for"
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["layers"],
|
||||
"properties": {
|
||||
"layers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "ref",
|
||||
"ref": "io.atcr.hold.layer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lexicons/io/atcr/hold/image/config.json
Normal file
32
lexicons/io/atcr/hold/image/config.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.image.config",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"key": "any",
|
||||
"description": "OCI image configuration for a container manifest. Stored in the hold's embedded PDS. Record key is the manifest digest hex without the 'sha256:' prefix (deterministic, one per manifest). Contains the full OCI config JSON including history (Dockerfile commands), environment variables, entrypoint, labels, etc.",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["manifest", "configJson", "createdAt"],
|
||||
"properties": {
|
||||
"manifest": {
|
||||
"type": "string",
|
||||
"format": "at-uri",
|
||||
"description": "AT-URI of the manifest this config belongs to"
|
||||
},
|
||||
"configJson": {
|
||||
"type": "string",
|
||||
"description": "Raw OCI image config JSON blob",
|
||||
"maxLength": 65536
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "RFC3339 timestamp of when the config was stored"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
lexicons/io/atcr/hold/image/getConfig.json
Normal file
28
lexicons/io/atcr/hold/image/getConfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.image.getConfig",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "query",
|
||||
"description": "Returns the OCI image config record for a specific manifest digest.",
|
||||
"parameters": {
|
||||
"type": "params",
|
||||
"required": ["digest"],
|
||||
"properties": {
|
||||
"digest": {
|
||||
"type": "string",
|
||||
"description": "Manifest digest (e.g., sha256:abc123...)",
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "ref",
|
||||
"ref": "io.atcr.hold.image.config"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
pkg/appview/handlers/digest.go
Normal file
178
pkg/appview/handlers/digest.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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.ReadOnlyDB, 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
|
||||
|
||||
if manifest.IsManifestList {
|
||||
// Manifest list: no layers, show platform picker
|
||||
// Platforms are already populated by GetManifestDetail
|
||||
} else {
|
||||
// Single manifest: fetch layers from DB
|
||||
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID)
|
||||
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 details
|
||||
if holdErr == nil {
|
||||
vd := FetchVulnDetails(r.Context(), hold.DID, digest)
|
||||
vulnData = &vd
|
||||
}
|
||||
}
|
||||
|
||||
// Build page meta
|
||||
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)
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
Owner *db.User
|
||||
Repository string
|
||||
Manifest *db.ManifestWithMetadata
|
||||
Layers []LayerDetail
|
||||
VulnData *vulnDetailsData
|
||||
}{
|
||||
PageData: NewPageData(r, &h.BaseUIHandler),
|
||||
Meta: meta,
|
||||
Owner: owner,
|
||||
Repository: repository,
|
||||
Manifest: manifest,
|
||||
Layers: layers,
|
||||
VulnData: vulnData,
|
||||
}
|
||||
|
||||
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] + "..."
|
||||
}
|
||||
143
pkg/appview/handlers/digest_test.go
Normal file
143
pkg/appview/handlers/digest_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/holdclient"
|
||||
)
|
||||
|
||||
func TestBuildLayerDetails_NoHistory(t *testing.T) {
|
||||
dbLayers := []db.Layer{
|
||||
{Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
|
||||
{Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
|
||||
}
|
||||
|
||||
details := buildLayerDetails(nil, dbLayers)
|
||||
|
||||
if len(details) != 2 {
|
||||
t.Fatalf("Expected 2 layer details, got %d", len(details))
|
||||
}
|
||||
if details[0].Command != "" {
|
||||
t.Errorf("Expected empty command, got %q", details[0].Command)
|
||||
}
|
||||
if details[0].Digest != "sha256:aaa" {
|
||||
t.Errorf("Expected digest sha256:aaa, got %q", details[0].Digest)
|
||||
}
|
||||
if details[0].Index != 1 {
|
||||
t.Errorf("Expected Index 1, got %d", details[0].Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLayerDetails_WithHistory(t *testing.T) {
|
||||
history := []holdclient.OCIHistoryEntry{
|
||||
{CreatedBy: "ENV PATH=/usr/local/bin", EmptyLayer: true},
|
||||
{CreatedBy: "RUN apt-get install curl", EmptyLayer: false},
|
||||
{CreatedBy: "COPY . /app", EmptyLayer: false},
|
||||
}
|
||||
|
||||
dbLayers := []db.Layer{
|
||||
{Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
|
||||
{Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
|
||||
}
|
||||
|
||||
details := buildLayerDetails(history, dbLayers)
|
||||
|
||||
if len(details) != 3 {
|
||||
t.Fatalf("Expected 3 details (1 empty + 2 real), got %d", len(details))
|
||||
}
|
||||
|
||||
// First entry is empty layer (ENV)
|
||||
if !details[0].EmptyLayer {
|
||||
t.Error("Expected first detail to be empty layer")
|
||||
}
|
||||
if details[0].Command != "ENV PATH=/usr/local/bin" {
|
||||
t.Errorf("Expected ENV command, got %q", details[0].Command)
|
||||
}
|
||||
if details[0].Digest != "" {
|
||||
t.Errorf("Expected empty digest for empty layer, got %q", details[0].Digest)
|
||||
}
|
||||
|
||||
// Second entry is real layer with digest
|
||||
if details[1].EmptyLayer {
|
||||
t.Error("Expected second detail to not be empty layer")
|
||||
}
|
||||
if details[1].Digest != "sha256:aaa" {
|
||||
t.Errorf("Expected digest sha256:aaa, got %q", details[1].Digest)
|
||||
}
|
||||
if details[1].Command != "RUN apt-get install curl" {
|
||||
t.Errorf("Expected RUN command, got %q", details[1].Command)
|
||||
}
|
||||
|
||||
// Third entry is real layer
|
||||
if details[2].Digest != "sha256:bbb" {
|
||||
t.Errorf("Expected digest sha256:bbb, got %q", details[2].Digest)
|
||||
}
|
||||
if details[2].Command != "COPY . /app" {
|
||||
t.Errorf("Expected COPY command, got %q", details[2].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLayerDetails_DistrolessWithUserLayers(t *testing.T) {
|
||||
// Simulates distroless base (many empty layers) + user COPY layers
|
||||
history := []holdclient.OCIHistoryEntry{
|
||||
{CreatedBy: "bazel build ...", EmptyLayer: false}, // distroless base layer
|
||||
{CreatedBy: "LABEL maintainer=distroless", EmptyLayer: true}, // metadata
|
||||
{CreatedBy: "ENV SSL_CERT_DIR=/etc/ssl/certs", EmptyLayer: true},
|
||||
{CreatedBy: "COPY /app /app # buildkit", EmptyLayer: false}, // user layer
|
||||
}
|
||||
|
||||
dbLayers := []db.Layer{
|
||||
{Digest: "sha256:base", Size: 5000000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
|
||||
{Digest: "sha256:user", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
|
||||
}
|
||||
|
||||
details := buildLayerDetails(history, dbLayers)
|
||||
|
||||
if len(details) != 4 {
|
||||
t.Fatalf("Expected 4 details (2 real + 2 empty), got %d", len(details))
|
||||
}
|
||||
|
||||
// Real layer
|
||||
if details[0].EmptyLayer || details[0].Digest != "sha256:base" {
|
||||
t.Errorf("Layer 0: expected real layer with sha256:base, got empty=%v digest=%q", details[0].EmptyLayer, details[0].Digest)
|
||||
}
|
||||
|
||||
// Empty layers
|
||||
if !details[1].EmptyLayer || details[1].Command != "LABEL maintainer=distroless" {
|
||||
t.Errorf("Layer 1: expected empty LABEL layer")
|
||||
}
|
||||
if !details[2].EmptyLayer || details[2].Command != "ENV SSL_CERT_DIR=/etc/ssl/certs" {
|
||||
t.Errorf("Layer 2: expected empty ENV layer")
|
||||
}
|
||||
|
||||
// User layer
|
||||
if details[3].EmptyLayer || details[3].Digest != "sha256:user" {
|
||||
t.Errorf("Layer 3: expected real layer with sha256:user, got empty=%v digest=%q", details[3].EmptyLayer, details[3].Digest)
|
||||
}
|
||||
if details[3].Command != "COPY /app /app # buildkit" {
|
||||
t.Errorf("Layer 3: Command = %q, want COPY command", details[3].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLayerDetails_AllEmptyLayers(t *testing.T) {
|
||||
// Edge case: all history entries are empty layers (scratch-based with only metadata)
|
||||
history := []holdclient.OCIHistoryEntry{
|
||||
{CreatedBy: "ENV FOO=bar", EmptyLayer: true},
|
||||
{CreatedBy: "LABEL version=1.0", EmptyLayer: true},
|
||||
}
|
||||
|
||||
details := buildLayerDetails(history, nil)
|
||||
|
||||
if len(details) != 2 {
|
||||
t.Fatalf("Expected 2 details, got %d", len(details))
|
||||
}
|
||||
for i, d := range details {
|
||||
if !d.EmptyLayer {
|
||||
t.Errorf("Layer %d: expected empty layer", i)
|
||||
}
|
||||
if d.Digest != "" {
|
||||
t.Errorf("Layer %d: expected empty digest, got %q", i, d.Digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
pkg/appview/holdclient/config.go
Normal file
69
pkg/appview/holdclient/config.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package holdclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// OCIHistoryEntry represents a single entry in the OCI config history.
|
||||
type OCIHistoryEntry struct {
|
||||
Created string `json:"created"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
EmptyLayer bool `json:"empty_layer"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// OCIConfig represents the parsed OCI image config with fields useful for display.
|
||||
type OCIConfig struct {
|
||||
History []OCIHistoryEntry `json:"history"`
|
||||
}
|
||||
|
||||
// FetchImageConfig fetches the OCI image config record from the hold's
|
||||
// getImageConfig XRPC endpoint. holdURL should be a resolved HTTP(S) URL.
|
||||
// Returns the parsed OCI config with history entries.
|
||||
func FetchImageConfig(ctx context.Context, holdURL, manifestDigest string) (*OCIConfig, error) {
|
||||
reqURL := fmt.Sprintf("%s%s?digest=%s",
|
||||
strings.TrimSuffix(holdURL, "/"),
|
||||
atproto.HoldGetImageConfig,
|
||||
url.QueryEscape(manifestDigest),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch image config: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hold returned status %d for %s", resp.StatusCode, reqURL)
|
||||
}
|
||||
|
||||
var record struct {
|
||||
ConfigJSON string `json:"configJson"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&record); err != nil {
|
||||
return nil, fmt.Errorf("parse image config response: %w", err)
|
||||
}
|
||||
|
||||
var config OCIConfig
|
||||
if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("parse OCI config JSON: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
215
pkg/appview/templates/pages/digest.html
Normal file
215
pkg/appview/templates/pages/digest.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{{ define "digest" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li>
|
||||
<li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li>
|
||||
<li><code class="font-mono text-xs">{{ truncateDigest .Manifest.Digest 24 }}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Digest Header -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-xl font-bold font-mono break-all">{{ .Manifest.Digest }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{ if .Manifest.Tags }}
|
||||
{{ range .Manifest.Tags }}
|
||||
<span class="badge badge-md badge-primary">{{ . }}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .Manifest.IsManifestList }}
|
||||
<span class="badge badge-md badge-soft badge-accent">Multi-arch</span>
|
||||
{{ else if eq .Manifest.ArtifactType "helm-chart" }}
|
||||
<span class="badge badge-md badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm</span>
|
||||
{{ end }}
|
||||
{{ if .Manifest.HasAttestations }}
|
||||
<span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attested</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base-content text-sm flex items-center gap-1" title="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-4" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .Manifest.IsManifestList }}
|
||||
<!-- Platform Picker for Manifest Lists -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold">Platforms</h2>
|
||||
<p class="text-sm">This is a multi-architecture manifest list. Select a platform to view layers and vulnerabilities.</p>
|
||||
<div class="space-y-2">
|
||||
{{ range .Manifest.Platforms }}
|
||||
<a href="/d/{{ $.Owner.Handle }}/{{ $.Repository }}/{{ .Digest }}" class="flex items-center justify-between p-4 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
|
||||
<code class="font-mono text-xs text-base-content" title="{{ .Digest }}">{{ truncateDigest .Digest 32 }}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{{ if .CompressedSize }}
|
||||
<span class="text-sm">{{ humanizeBytes .CompressedSize }}</span>
|
||||
{{ end }}
|
||||
{{ icon "chevron-right" "size-4" }}
|
||||
</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<!-- Layers + Vulnerabilities Side by Side -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Layers (Left) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" id="show-empty-layers" onchange="toggleEmptyLayers(this.checked)">
|
||||
<span>Show empty layers</span>
|
||||
</label>
|
||||
</div>
|
||||
{{ if .Layers }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full" id="layers-table">
|
||||
<thead>
|
||||
<tr class="text-xs">
|
||||
<th class="w-8">#</th>
|
||||
<th>Command</th>
|
||||
<th class="text-right w-24">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Layers }}
|
||||
<tr data-empty="{{ .EmptyLayer }}" data-no-command="{{ and (not .Command) (not .EmptyLayer) }}">
|
||||
<td class="font-mono text-xs">{{ .Index }}</td>
|
||||
<td>
|
||||
{{ if .Command }}
|
||||
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
// Toggle empty layers (ENV, LABEL, ENTRYPOINT, etc.)
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
var checkbox = document.getElementById('show-empty-layers');
|
||||
if (checkbox) checkbox.checked = showEmpty;
|
||||
|
||||
window.toggleEmptyLayers = function(show) {
|
||||
localStorage.setItem('showEmptyLayers', show);
|
||||
applyLayerVisibility();
|
||||
};
|
||||
|
||||
// Collapse consecutive no-command, non-empty layers (distroless base)
|
||||
function collapseNoHistoryLayers() {
|
||||
var tbody = document.querySelector('#layers-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
var rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
var i = 0;
|
||||
while (i < rows.length) {
|
||||
if (rows[i].dataset.noCommand === 'true') {
|
||||
// Find consecutive no-command rows
|
||||
var start = i;
|
||||
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
|
||||
rows[i].classList.add('no-history-row', 'hidden');
|
||||
i++;
|
||||
}
|
||||
var count = i - start;
|
||||
if (count > 1) {
|
||||
// Sum sizes from the collapsed rows
|
||||
var totalBytes = 0;
|
||||
for (var k = start; k < start + count; k++) {
|
||||
var sizeCell = rows[k].querySelector('td:last-child');
|
||||
if (sizeCell) {
|
||||
var txt = sizeCell.textContent.trim();
|
||||
var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
|
||||
if (match) {
|
||||
var val = parseFloat(match[1]);
|
||||
var unit = match[2].toUpperCase();
|
||||
var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776};
|
||||
totalBytes += val * (multipliers[unit] || 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
var sizeStr = '';
|
||||
if (totalBytes < 1024) sizeStr = totalBytes + ' B';
|
||||
else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB';
|
||||
else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB';
|
||||
else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB';
|
||||
|
||||
// Insert summary row
|
||||
var startIdx = rows[start].querySelector('td').textContent.trim();
|
||||
var endIdx = rows[i - 1].querySelector('td').textContent.trim();
|
||||
var summary = document.createElement('tr');
|
||||
summary.className = 'no-history-summary cursor-pointer hover:bg-base-200';
|
||||
summary.innerHTML = '<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx + ' contain no history <span class="text-xs ml-2">(' + count + ' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>';
|
||||
summary.onclick = function() {
|
||||
summary.remove();
|
||||
for (var j = start; j < start + count; j++) {
|
||||
rows[j].classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
tbody.insertBefore(summary, rows[start]);
|
||||
} else {
|
||||
// Single no-command row, just show it
|
||||
rows[start].classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyLayerVisibility() {
|
||||
var show = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('#layers-table tr[data-empty="true"]').forEach(function(row) {
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
collapseNoHistoryLayers();
|
||||
applyLayerVisibility();
|
||||
})();
|
||||
</script>
|
||||
{{ else }}
|
||||
<p class="text-base-content">No layer information available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities (Right) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<h2 class="text-lg font-semibold">Vulnerabilities</h2>
|
||||
{{ if .VulnData }}
|
||||
{{ template "vuln-details" .VulnData }}
|
||||
{{ else }}
|
||||
<p class="text-base-content">No vulnerability scan data available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
177
pkg/appview/templates/partials/repo-tags.html
Normal file
177
pkg/appview/templates/partials/repo-tags.html
Normal file
@@ -0,0 +1,177 @@
|
||||
{{ define "artifact-entry-markup" }}
|
||||
<div class="artifact-entry p-6" data-tag="{{ .Entry.Label }}" data-created="{{ .Entry.CreatedAt.Unix }}">
|
||||
<!-- Entry Header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono font-semibold{{ if not .Entry.IsTagged }} text-sm{{ end }}">{{ .Entry.Label }}</span>
|
||||
{{ if eq .Entry.ArtifactType "helm-chart" }}
|
||||
<span class="badge badge-xs badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm</span>
|
||||
{{ else if .Entry.IsMultiArch }}
|
||||
<span class="badge badge-xs badge-soft badge-accent">Multi-arch</span>
|
||||
{{ end }}
|
||||
{{ if .Entry.HasAttestations }}
|
||||
<button class="badge badge-xs badge-soft badge-success cursor-pointer hover:opacity-80"
|
||||
hx-get="/api/attestation-details?digest={{ .Entry.Digest | urlquery }}&did={{ .OwnerDID | urlquery }}&repo={{ .RepoName | urlquery }}"
|
||||
hx-target="#attestation-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('attestation-detail-modal').showModal()">
|
||||
{{ icon "shield-check" "size-3" }} Attested
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ if eq .Entry.ArtifactType "helm-chart" }}
|
||||
{{ if .Entry.IsTagged }}
|
||||
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Entry.Label) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{ if .Entry.IsTagged }}
|
||||
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<span class="text-base-content text-sm flex items-center gap-1" title="{{ .Entry.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Entry.CreatedAt }}</span>
|
||||
{{ if .IsOwner }}
|
||||
{{ if .Entry.IsTagged }}
|
||||
<button class="btn btn-ghost btn-sm text-error"
|
||||
hx-ext="json-enc"
|
||||
hx-delete="/api/tags"
|
||||
hx-vals='{"repo": "{{ .RepoName }}", "tag": "{{ .Entry.Label }}"}'
|
||||
hx-confirm="Delete tag {{ .Entry.Label }}?"
|
||||
hx-target="closest .artifact-entry"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Delete tag {{ .Entry.Label }}">
|
||||
{{ icon "trash-2" "size-4" }}
|
||||
</button>
|
||||
{{ else }}
|
||||
<button class="btn btn-ghost btn-sm text-error"
|
||||
onclick="deleteManifest('{{ .RepoName }}', '{{ .Entry.Digest }}', '')"
|
||||
aria-label="Delete manifest">
|
||||
{{ icon "trash-2" "size-4" }}
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manifest Details Table -->
|
||||
<table class="table table-xs w-full table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-5/12">
|
||||
<col class="w-3/12">
|
||||
<col class="w-2/12">
|
||||
<col class="w-2/12">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-base-content text-xs">
|
||||
<th>Digest</th>
|
||||
<th>Vulnerabilities</th>
|
||||
<th>OS/Arch</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Entry.Platforms }}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-1">
|
||||
<a href="/d/{{ $.OwnerHandle }}/{{ $.RepoName }}/{{ .Digest }}" class="font-mono text-xs link link-primary" title="{{ .Digest }}">{{ truncateDigest .Digest 32 }}</a>
|
||||
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-3" }}</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ if .HoldEndpoint }}
|
||||
<a href="/d/{{ $.OwnerHandle }}/{{ $.RepoName }}/{{ .Digest }}" class="hover:opacity-80 transition-opacity">
|
||||
<span id="scan-badge-{{ trimPrefix "sha256:" .Digest }}"></span>
|
||||
</a>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
{{ if .OS }}{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}{{ else }}-{{ end }}
|
||||
</td>
|
||||
<td class="text-sm text-base-content">{{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "load-more-button" }}
|
||||
{{ if .HasMore }}
|
||||
<div id="load-more-container" class="p-6 text-center border-t border-base-200">
|
||||
<button class="btn btn-outline"
|
||||
hx-get="/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}?offset={{ .NextOffset }}"
|
||||
hx-target="#tags-list"
|
||||
hx-swap="beforeend"
|
||||
hx-on::before-request="document.getElementById('load-more-container').remove()">
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "scan-batch-triggers" }}
|
||||
{{ range .ScanBatchParams }}
|
||||
<div hx-get="/api/scan-results?{{ . }}"
|
||||
hx-trigger="load delay:500ms"
|
||||
hx-swap="none"
|
||||
style="display:none"></div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "repo-tags" }}
|
||||
<div class="space-y-4">
|
||||
{{ if .Entries }}
|
||||
<!-- Filter/Sort Controls -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium whitespace-nowrap">Sort by</span>
|
||||
<select id="tag-sort" class="select select-sm select-bordered min-w-28" onchange="sortTags(this.value)">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="az">A-Z</option>
|
||||
<option value="za">Z-A</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 max-w-xs">
|
||||
<input type="text" id="tag-filter" class="input input-sm input-bordered w-full" placeholder="Filter artifacts..." oninput="filterTags(this.value)">
|
||||
</div>
|
||||
{{ if $.IsOwner }}
|
||||
<div class="flex-none ml-auto">
|
||||
<button class="btn btn-ghost btn-sm text-error"
|
||||
onclick="document.getElementById('untagged-delete-modal').showModal()"
|
||||
aria-label="Delete all untagged manifests">
|
||||
{{ icon "trash-2" "size-4" }} Delete untagged
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Manifest Entries -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300">
|
||||
<div class="divide-y divide-base-200" id="tags-list">
|
||||
{{ range .Entries }}
|
||||
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "load-more-button" . }}
|
||||
</div>
|
||||
|
||||
{{ template "scan-batch-triggers" . }}
|
||||
{{ else }}
|
||||
<p class="text-base-content">No artifacts available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ define "repo-tags-page" }}
|
||||
{{ range .Entries }}
|
||||
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
|
||||
{{ end }}
|
||||
{{ template "load-more-button" . }}
|
||||
{{ template "scan-batch-triggers" . }}
|
||||
{{ end }}
|
||||
53
pkg/hold/pds/imageconfig.go
Normal file
53
pkg/hold/pds/imageconfig.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/ipfs/go-cid"
|
||||
)
|
||||
|
||||
// CreateImageConfigRecord creates or updates an OCI image config record in the hold's PDS.
|
||||
// Uses a deterministic rkey based on the manifest digest, so re-pushes upsert.
|
||||
func (p *HoldPDS) CreateImageConfigRecord(ctx context.Context, record *atproto.ImageConfigRecord, manifestDigest string) (string, cid.Cid, error) {
|
||||
if record.Type != atproto.ImageConfigCollection {
|
||||
return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type)
|
||||
}
|
||||
|
||||
if record.Manifest == "" {
|
||||
return "", cid.Undef, fmt.Errorf("manifest AT-URI is required")
|
||||
}
|
||||
|
||||
rkey := atproto.ScanRecordKey(manifestDigest)
|
||||
|
||||
rpath, recordCID, _, err := p.repomgr.UpsertRecord(
|
||||
ctx,
|
||||
p.uid,
|
||||
atproto.ImageConfigCollection,
|
||||
rkey,
|
||||
record,
|
||||
)
|
||||
if err != nil {
|
||||
return "", cid.Undef, fmt.Errorf("failed to upsert image config record: %w", err)
|
||||
}
|
||||
|
||||
return rpath, recordCID, nil
|
||||
}
|
||||
|
||||
// GetImageConfigRecord retrieves an OCI image config record by manifest digest.
|
||||
func (p *HoldPDS) GetImageConfigRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ImageConfigRecord, error) {
|
||||
rkey := atproto.ScanRecordKey(manifestDigest)
|
||||
|
||||
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ImageConfigCollection, rkey, cid.Undef)
|
||||
if err != nil {
|
||||
return cid.Undef, nil, fmt.Errorf("failed to get image config record: %w", err)
|
||||
}
|
||||
|
||||
configRecord, ok := val.(*atproto.ImageConfigRecord)
|
||||
if !ok {
|
||||
return cid.Undef, nil, fmt.Errorf("unexpected type for image config record: %T", val)
|
||||
}
|
||||
|
||||
return recordCID, configRecord, nil
|
||||
}
|
||||
Reference in New Issue
Block a user