283 lines
9.3 KiB
Go
283 lines
9.3 KiB
Go
// Copyright (C) 2026 Dcntrlized
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published
|
|
// by the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
|
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
func GetRegistry(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Query Gitea REST API using the exact Org name
|
|
apiURL := fmt.Sprintf("https://git.dcntrlized.org/api/v1/packages/%s?type=container", os.Getenv("GITEA_ORG"))
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, apiURL, nil)
|
|
if err != nil {
|
|
logger.Error("Failed to create request: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
logger.Debug("Querying Gitea package API at %s", apiURL)
|
|
req.Header.Set("Authorization", "token "+os.Getenv("GITEA_RW_PACKAGE_TOKEN"))
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
logger.Error("Failed to reach Gitea package API: %v", err)
|
|
http.Error(w, "Failed to connect to upstream registry", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
logger.Error("Registry API returned non-200 status code: %d", resp.StatusCode)
|
|
http.Error(w, fmt.Sprintf("Registry API error: %s", resp.Status), resp.StatusCode)
|
|
return
|
|
}
|
|
|
|
var rawPackages []types.RawPackage
|
|
if err := json.NewDecoder(resp.Body).Decode(&rawPackages); err != nil {
|
|
logger.Error("Failed to decode registry response: %v", err)
|
|
http.Error(w, "Failed to decode registry response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 2. Group by Name and filter out "latest" & "sha256:"
|
|
groupedPackages := make(map[string][]types.RawPackage)
|
|
for _, pkg := range rawPackages {
|
|
if pkg.Version == "latest" || strings.HasPrefix(pkg.Version, "sha256:") {
|
|
continue
|
|
}
|
|
groupedPackages[pkg.Name] = append(groupedPackages[pkg.Name], pkg)
|
|
}
|
|
|
|
// normalizeSemver expands partial version tags to the full X.Y.Z form,
|
|
// preserving an optional pre-release suffix (v3 -> 3.0.0, v3.1 -> 3.1.0,
|
|
// v3.3.3-hotfix -> 3.3.3-hotfix).
|
|
normalizeSemver := func(v string) string {
|
|
tag := strings.TrimPrefix(v, "v")
|
|
prerelease := ""
|
|
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
|
prerelease = tag[i:]
|
|
tag = tag[:i]
|
|
}
|
|
parts := strings.Split(tag, ".")
|
|
for len(parts) < 3 {
|
|
parts = append(parts, "0")
|
|
}
|
|
return strings.Join(parts, ".") + prerelease
|
|
}
|
|
|
|
// semverSpecificity counts the dot-separated segments of a version's
|
|
// major.minor.patch part, so that among versions that normalize equal the most
|
|
// complete one wins (3.4.0 over 3.4).
|
|
semverSpecificity := func(v string) int {
|
|
tag := strings.TrimPrefix(v, "v")
|
|
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
|
tag = tag[:i]
|
|
}
|
|
return strings.Count(tag, ".")
|
|
}
|
|
|
|
// 3. For each container name, find the package entry with the highest semver
|
|
var latestPackages []types.RawPackage
|
|
for _, pkgs := range groupedPackages {
|
|
if len(pkgs) == 0 {
|
|
continue
|
|
}
|
|
|
|
sort.Slice(pkgs, func(i, j int) bool {
|
|
nI := normalizeSemver(pkgs[i].Version)
|
|
nJ := normalizeSemver(pkgs[j].Version)
|
|
vI := "v" + nI
|
|
vJ := "v" + nJ
|
|
|
|
// Compare using semver package
|
|
if semver.IsValid(vI) && semver.IsValid(vJ) {
|
|
if c := semver.Compare(vI, vJ); c != 0 {
|
|
return c > 0
|
|
}
|
|
} else if nI != nJ {
|
|
return nI > nJ
|
|
}
|
|
// Tie-break on specificity (most complete wins)
|
|
if sI, sJ := semverSpecificity(pkgs[i].Version), semverSpecificity(pkgs[j].Version); sI != sJ {
|
|
return sI > sJ
|
|
}
|
|
// Tie-break on creation time (newest wins)
|
|
return pkgs[i].CreatedAt > pkgs[j].CreatedAt
|
|
})
|
|
|
|
// First item after descending sort is the highest version
|
|
latestPackages = append(latestPackages, pkgs[0])
|
|
}
|
|
|
|
ociAuth := remote.WithAuth(&authn.Basic{
|
|
Username: os.Getenv("GITEA_USER"),
|
|
Password: os.Getenv("GITEA_RW_PACKAGE_TOKEN"),
|
|
})
|
|
|
|
ociOrg := strings.ToLower(os.Getenv("GITEA_ORG"))
|
|
var registryItems []types.RegistryContainer
|
|
|
|
// 4. Fetch OCI metadata only for the highest version of each package
|
|
for _, pkg := range latestPackages {
|
|
pkgNameLower := strings.ToLower(pkg.Name)
|
|
imageRef := fmt.Sprintf("git.dcntrlized.org/%s/%s:%s", ociOrg, pkgNameLower, pkg.Version)
|
|
|
|
// The Gitea package API does not link org-level container packages to a
|
|
// repository, so the repository URL is resolved from the image's OCI
|
|
// labels below, or derived from the image reference as a last resort.
|
|
item := types.RegistryContainer{
|
|
Name: pkg.Name,
|
|
Version: normalizeSemver(pkg.Version),
|
|
Image: imageRef,
|
|
CreatedAt: pkg.CreatedAt,
|
|
}
|
|
|
|
ref, err := name.ParseReference(imageRef)
|
|
if err != nil {
|
|
logger.Error("Failed to parse image reference %s: %v", imageRef, err)
|
|
registryItems = append(registryItems, item)
|
|
continue
|
|
}
|
|
|
|
img, err := remote.Image(ref, ociAuth, remote.WithContext(r.Context()))
|
|
if err != nil {
|
|
logger.Error("Failed to fetch OCI metadata for %s: %v", imageRef, err)
|
|
registryItems = append(registryItems, item)
|
|
continue
|
|
}
|
|
|
|
// Fetch image digest (sha256:...)
|
|
if digest, err := img.Digest(); err == nil {
|
|
item.Digest = digest.String()
|
|
}
|
|
|
|
// 1. Extract standard OCI annotations from OCI Manifest
|
|
if manifest, err := img.Manifest(); err == nil && manifest != nil {
|
|
if title, ok := manifest.Annotations["org.opencontainers.image.title"]; ok {
|
|
item.Title = title
|
|
}
|
|
if vendor, ok := manifest.Annotations["org.opencontainers.image.vendor"]; ok {
|
|
item.Vendor = vendor
|
|
}
|
|
if author, ok := manifest.Annotations["org.opencontainers.image.authors"]; ok {
|
|
item.Author = author
|
|
}
|
|
if desc, ok := manifest.Annotations["org.opencontainers.image.description"]; ok {
|
|
item.Description = desc
|
|
}
|
|
if src, ok := manifest.Annotations["org.opencontainers.image.source"]; ok && src != "" {
|
|
item.Source = src
|
|
}
|
|
if rev, ok := manifest.Annotations["org.opencontainers.image.revision"]; ok {
|
|
item.Revision = rev
|
|
}
|
|
if lic, ok := manifest.Annotations["org.opencontainers.image.licenses"]; ok {
|
|
item.Licenses = lic
|
|
}
|
|
if url, ok := manifest.Annotations["org.opencontainers.image.url"]; ok {
|
|
item.URL = url
|
|
}
|
|
if doc, ok := manifest.Annotations["org.opencontainers.image.documentation"]; ok {
|
|
item.Documentation = doc
|
|
}
|
|
if created, ok := manifest.Annotations["org.opencontainers.image.created"]; ok && created != "" {
|
|
item.CreatedAt = created
|
|
}
|
|
}
|
|
|
|
// 2. Fallback to Image Config Labels (where docker buildx puts them when targeting v2 registries)
|
|
if configFile, err := img.ConfigFile(); err == nil && configFile != nil {
|
|
labels := configFile.Config.Labels
|
|
|
|
if item.Title == "" {
|
|
item.Title = labels["org.opencontainers.image.title"]
|
|
}
|
|
if item.Vendor == "" {
|
|
item.Vendor = labels["org.opencontainers.image.vendor"]
|
|
}
|
|
if item.Author == "" {
|
|
item.Author = labels["org.opencontainers.image.authors"]
|
|
}
|
|
if item.Description == "" {
|
|
item.Description = labels["org.opencontainers.image.description"]
|
|
}
|
|
if src, ok := labels["org.opencontainers.image.source"]; ok && src != "" {
|
|
item.Source = src
|
|
}
|
|
if item.Revision == "" {
|
|
item.Revision = labels["org.opencontainers.image.revision"]
|
|
}
|
|
if item.Licenses == "" {
|
|
item.Licenses = labels["org.opencontainers.image.licenses"]
|
|
}
|
|
if item.URL == "" {
|
|
item.URL = labels["org.opencontainers.image.url"]
|
|
}
|
|
if item.Documentation == "" {
|
|
item.Documentation = labels["org.opencontainers.image.documentation"]
|
|
}
|
|
if item.CreatedAt == "" && labels["org.opencontainers.image.created"] != "" {
|
|
item.CreatedAt = labels["org.opencontainers.image.created"]
|
|
}
|
|
}
|
|
|
|
// Resolve the source repository URL: OCI source label if present, else
|
|
// derived from the image reference (host/org/repo).
|
|
if item.Source == "" {
|
|
item.Source = repoURLFromImageRef(imageRef)
|
|
}
|
|
// The compose file is fetched from the git repository, so fall back to
|
|
// the resolved source URL when the OCI url label is absent.
|
|
if item.URL == "" {
|
|
item.URL = item.Source
|
|
}
|
|
|
|
registryItems = append(registryItems, item)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(registryItems); err != nil {
|
|
logger.Error("Failed to write response: %v", err)
|
|
}
|
|
}
|
|
|
|
// repoURLFromImageRef derives the repository browse URL from a container image
|
|
// reference of the form host/org/repo:tag (https://host/org/repo).
|
|
func repoURLFromImageRef(imageRef string) string {
|
|
ref, err := name.ParseReference(imageRef)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return "https://" + ref.Context().RegistryStr() + "/" + ref.Context().RepositoryStr()
|
|
}
|