mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
1. Removing distribution/distribution from the Hold Service (biggest change) The hold service previously used distribution's StorageDriver interface for all blob operations. This replaces it with direct AWS SDK v2 calls through ATCR's own pkg/s3.S3Service: - New S3Service methods: Stat(), PutBytes(), Move(), Delete(), WalkBlobs(), ListPrefix() added to pkg/s3/types.go - Pull zone fix: Presigned URLs are now generated against the real S3 endpoint, then the host is swapped to the CDN URL post-signing (previously the CDN URL was set as the endpoint, which broke SigV4 signatures) - All hold subsystems migrated: GC, OCI uploads, XRPC handlers, profile uploads, scan broadcaster, manifest posts — all now use *s3.S3Service instead of storagedriver.StorageDriver - Config simplified: Removed configuration.Storage type and buildStorageConfigFromFields(); replaced with a simple S3Params() method - Mock expanded: MockS3Client gains an in-memory object store + 5 new methods, replacing duplicate mockStorageDriver implementations in tests (~160 lines deleted from each test file) 2. Vulnerability Scan UI in AppView (new feature) Displays scan results from the hold's PDS on the repository page: - New lexicon: io/atcr/hold/scan.json with vulnReportBlob field for storing full Grype reports - Two new HTMX endpoints: /api/scan-result (badge) and /api/vuln-details (modal with CVE table) - New templates: vuln-badge.html (severity count chips) and vuln-details.html (full CVE table with NVD/GHSA links) - Repository page: Lazy-loads scan badges per manifest via HTMX - Tests: ~590 lines of test coverage for both handlers 3. S3 Diagnostic Tool New cmd/s3-test/main.go (418 lines) — tests S3 connectivity with both SDK v1 and v2, including presigned URL generation, pull zone host swapping, and verbose signing debug output. 4. Deployment Tooling - New syncServiceUnit() for comparing/updating systemd units on servers - Update command now syncs config keys (adds missing keys from template) and service units with daemon-reload 5. DB Migration 0011_fix_captain_successor_column.yaml — rebuilds hold_captain_records to add the successor column that was missed in a previous migration. 6. Documentation - APPVIEW-UI-FUTURE.md rewritten as a status-tracked feature inventory - DISTRIBUTION.md renamed to CREDENTIAL_HELPER.md - New REMOVING_DISTRIBUTION.md — 480-line analysis of fully removing distribution from the appview side 7. go.mod aws-sdk-go v1 moved from indirect to direct (needed by cmd/s3-test).
311 lines
18 KiB
HTML
311 lines
18 KiB
HTML
{{ define "repository" }}
|
|
<!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-8">
|
|
<!-- Repository Header -->
|
|
<div class="card bg-base-100 shadow-sm p-6 space-y-6 w-full">
|
|
<div class="flex gap-4 items-start">
|
|
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
|
|
<div class="flex-1 min-w-0">
|
|
<h1 class="text-2xl font-bold">
|
|
<a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a>
|
|
<span class="text-base-content/60">/</span>
|
|
<span>{{ .Repository.Name }}</span>
|
|
</h1>
|
|
{{ if .Repository.Description }}
|
|
<p class="text-base-content/70 mt-2">{{ .Repository.Description }}</p>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Star Button, Pull Count and Metadata Row -->
|
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
<div class="flex items-center gap-4">
|
|
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }}
|
|
{{ template "pull-count" (dict "PullCount" .PullCount) }}
|
|
</div>
|
|
|
|
<!-- Metadata Section -->
|
|
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
{{ if .Repository.Version }}
|
|
<span class="badge badge-md badge-primary badge-outline" title="Version">
|
|
{{ .Repository.Version }}
|
|
</span>
|
|
{{ end }}
|
|
{{ if .Repository.Licenses }}
|
|
{{ range parseLicenses .Repository.Licenses }}
|
|
{{ if .IsValid }}
|
|
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="badge badge-md badge-soft badge-secondary" title="{{ .Name }}">
|
|
{{ .SPDXID }}
|
|
</a>
|
|
{{ else }}
|
|
<span class="badge badge-md badge-soft badge-secondary" title="Custom license: {{ .Name }}">
|
|
{{ .Name }}
|
|
</span>
|
|
{{ end }}
|
|
{{ end }}
|
|
{{ end }}
|
|
{{ if .Repository.SourceURL }}
|
|
<a href="{{ .Repository.SourceURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View source code (opens in new tab)">
|
|
Source
|
|
</a>
|
|
{{ end }}
|
|
{{ if .Repository.DocumentationURL }}
|
|
<a href="{{ .Repository.DocumentationURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View documentation (opens in new tab)">
|
|
Documentation
|
|
</a>
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<div class="divider my-2"></div>
|
|
|
|
<!-- Pull Command -->
|
|
<div class="space-y-2">
|
|
{{ if eq .ArtifactType "helm-chart" }}
|
|
<p class="font-semibold">Pull this chart</p>
|
|
{{ if .Tags }}
|
|
{{ $firstTag := index .Tags 0 }}
|
|
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " $firstTag.Tag.Tag) }}
|
|
{{ else }}
|
|
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name) }}
|
|
{{ end }}
|
|
{{ else }}
|
|
<p class="font-semibold">Pull this image</p>
|
|
{{ if .Tags }}
|
|
{{ $firstTag := index .Tags 0 }}
|
|
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }}
|
|
{{ else }}
|
|
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
|
|
{{ end }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- README and Tags/Manifests Layout -->
|
|
{{ if .ReadmeHTML }}
|
|
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-8">
|
|
<!-- README Section (Left) -->
|
|
<div class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
|
|
<h2 class="text-xl font-semibold">Overview</h2>
|
|
<div class="prose prose-sm max-w-none">
|
|
{{ .ReadmeHTML }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags and Manifests (Right) -->
|
|
<div class="space-y-8 min-w-0">
|
|
{{ end }}
|
|
|
|
<!-- Tags Section -->
|
|
<div class="card bg-base-100 shadow-sm p-6 space-y-4">
|
|
<h2 class="text-xl font-semibold">Tags</h2>
|
|
{{ if .Tags }}
|
|
<div class="space-y-4">
|
|
{{ range .Tags }}
|
|
<div class="bg-base-200 rounded-lg p-4 space-y-3" id="tag-{{ sanitizeID .Tag.Tag }}">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="font-mono font-semibold text-lg">{{ .Tag.Tag }}</span>
|
|
{{ if eq .ArtifactType "helm-chart" }}
|
|
<span class="badge badge-md badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm chart</span>
|
|
{{ else if .IsMultiArch }}
|
|
<span class="badge badge-md badge-soft badge-accent">Multi-arch</span>
|
|
{{ end }}
|
|
{{ if .HasAttestations }}
|
|
<span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attestations</span>
|
|
{{ end }}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<time class="text-sm text-base-content/60" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
|
{{ timeAgo .Tag.CreatedAt }}
|
|
</time>
|
|
{{ if $.IsOwner }}
|
|
<button class="btn btn-ghost btn-sm text-error"
|
|
hx-ext="json-enc"
|
|
hx-delete="/api/tags"
|
|
hx-vals='{"repo": "{{ $.Repository.Name }}", "tag": "{{ .Tag.Tag }}"}'
|
|
hx-confirm="Delete tag {{ .Tag.Tag }}?"
|
|
hx-target="#tag-{{ sanitizeID .Tag.Tag }}"
|
|
hx-swap="outerHTML"
|
|
aria-label="Delete tag {{ .Tag.Tag }}">
|
|
{{ icon "trash-2" "size-4" }}
|
|
</button>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
<div class="text-sm">
|
|
<div class="flex flex-wrap justify-between items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
|
|
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Tag.Digest }}')" aria-label="Copy tag digest to clipboard">{{ icon "copy" "size-3" }}</button>
|
|
</div>
|
|
{{ if .Platforms }}
|
|
<div class="flex flex-wrap gap-1">
|
|
{{ range .Platforms }}
|
|
<span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
{{ if eq .ArtifactType "helm-chart" }}
|
|
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .Tag.Tag) }}
|
|
{{ else }}
|
|
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }}
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
{{ else }}
|
|
<p class="text-base-content/60">No tags available</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<!-- Manifests Section -->
|
|
<div class="card bg-base-100 shadow-sm p-6 space-y-4">
|
|
<div class="flex flex-wrap justify-between items-center gap-4">
|
|
<h2 class="text-xl font-semibold">Manifests</h2>
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input type="checkbox" class="checkbox checkbox-sm" id="show-offline-toggle" onchange="toggleOfflineManifests()">
|
|
<span>Show offline images</span>
|
|
</label>
|
|
</div>
|
|
{{ if .Manifests }}
|
|
<div class="space-y-4">
|
|
{{ range .Manifests }}
|
|
<div class="bg-base-200 rounded-lg p-4 space-y-3" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
|
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
<div class="space-y-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
{{ if .IsManifestList }}
|
|
<span class="flex items-center gap-1 font-medium">{{ icon "package" "size-5" }} Multi-arch</span>
|
|
{{ else if eq .ArtifactType "helm-chart" }}
|
|
<span class="flex items-center gap-1 font-medium text-helm">{{ icon "helm" "size-5" }} Helm Chart</span>
|
|
{{ else }}
|
|
<span class="flex items-center gap-1 font-medium">{{ icon "box" "size-5" }} Image</span>
|
|
{{ end }}
|
|
{{ if .HasAttestations }}
|
|
<span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attestations</span>
|
|
{{ end }}
|
|
{{ if .Pending }}
|
|
<span class="badge badge-sm badge-info"
|
|
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
|
|
hx-trigger="load delay:2s"
|
|
hx-swap="outerHTML">
|
|
{{ icon "refresh-ccw" "size-3" }} Checking...
|
|
</span>
|
|
{{ else if not .Reachable }}
|
|
<span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span>
|
|
{{ end }}
|
|
{{/* Vulnerability scan badge (lazy-loaded from hold) */}}
|
|
{{ if and (not .IsManifestList) .Manifest.HoldEndpoint }}
|
|
<span hx-get="/api/scan-result?digest={{ .Manifest.Digest | urlquery }}&holdEndpoint={{ .Manifest.HoldEndpoint | urlquery }}"
|
|
hx-trigger="load delay:1s"
|
|
hx-swap="outerHTML">
|
|
</span>
|
|
{{ end }}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
|
|
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy manifest digest to clipboard">{{ icon "copy" "size-3" }}</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<time class="text-sm text-base-content/60" datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
|
{{ timeAgo .Manifest.CreatedAt }}
|
|
</time>
|
|
{{ if $.IsOwner }}
|
|
<button class="btn btn-ghost btn-sm text-error"
|
|
onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')"
|
|
aria-label="Delete manifest {{ truncateDigest .Manifest.Digest 16 }}">
|
|
{{ icon "trash-2" "size-4" }}
|
|
</button>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
<div class="text-sm">
|
|
<div class="flex flex-wrap justify-between items-center gap-2">
|
|
<div>
|
|
{{ if .Tags }}
|
|
<span class="text-base-content/60">Tags:</span>
|
|
{{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }}
|
|
{{ else }}
|
|
<span class="text-base-content/50">(untagged)</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ if .IsManifestList }}
|
|
{{ if .Platforms }}
|
|
<div class="flex flex-wrap gap-1">
|
|
{{ range .Platforms }}
|
|
<span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
{{ else }}
|
|
<p class="text-base-content/60">No manifests available</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
{{ if .ReadmeHTML }}
|
|
</div><!-- Close sidebar -->
|
|
</div><!-- Close grid layout -->
|
|
{{ end }}
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Modal container for HTMX -->
|
|
<div id="modal"></div>
|
|
|
|
<!-- Manifest Delete Confirmation Modal -->
|
|
<dialog id="manifest-delete-modal" class="modal">
|
|
<div class="modal-box">
|
|
<h3 class="text-lg font-bold">Confirm Deletion</h3>
|
|
<p id="manifest-delete-message" class="py-2">This manifest has associated tags that will also be deleted:</p>
|
|
<ul id="manifest-delete-tags" class="list-disc list-inside text-sm space-y-1"></ul>
|
|
<p class="font-bold py-2 text-error">This action cannot be undone.</p>
|
|
<div class="modal-action">
|
|
<button class="btn" onclick="closeManifestDeleteModal()">Cancel</button>
|
|
<button class="btn btn-error" id="confirm-manifest-delete-btn">Delete All</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button onclick="closeManifestDeleteModal()">close</button>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Vulnerability Details Modal -->
|
|
<dialog id="vuln-detail-modal" class="modal">
|
|
<div class="modal-box max-w-4xl">
|
|
<h3 class="text-lg font-bold">Vulnerability Scan Results</h3>
|
|
<div id="vuln-modal-body" class="py-4">
|
|
<span class="loading loading-spinner loading-md"></span>
|
|
</div>
|
|
<div class="modal-action">
|
|
<form method="dialog"><button class="btn">Close</button></form>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
|
|
{{ template "footer" . }}
|
|
</body>
|
|
</html>
|
|
{{ end }}
|