diff --git a/src/handlers/registry/registry.go b/src/handlers/registry/registry.go index f0ea56f..c6cf84e 100644 --- a/src/handlers/registry/registry.go +++ b/src/handlers/registry/registry.go @@ -17,319 +17,24 @@ 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" + libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry" ) +// GetRegistry lists the container packages on the configured Gitea registry, +// one item per package (highest semver tag), enriched with OCI metadata. func GetRegistry(w http.ResponseWriter, r *http.Request) { - // 1. Query Gitea REST API using the exact Org name - apiURL := fmt.Sprintf("%s/api/v1/packages/%s?type=container", giteaBaseURL(), os.Getenv("GITEA_ORG")) - - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, apiURL, nil) + items, err := libregistry.ListItems(r.Context()) if err != nil { - logger.Error("Failed to create request: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) + logger.Error("Registry listing failed: %v", err) + http.Error(w, err.Error(), http.StatusBadGateway) 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("%s/%s/%s:%s", registryHost(), 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, - } - - var refOpts []name.Option - if isInsecureRegistry() { - refOpts = append(refOpts, name.Insecure) - } - ref, err := name.ParseReference(imageRef, refOpts...) - 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 - } - if hidden, ok := manifest.Annotations["org.dcntrlized.hidden"]; ok && hidden == "true" { - item.Hidden = true - } - } - - // 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"] - } - if !item.Hidden && labels["org.dcntrlized.hidden"] == "true" { - item.Hidden = true - } - } - - // Fallback for infra images published before the hidden label existed: - // the shared database is always treated as hidden infrastructure. - if strings.EqualFold(item.Name, "dcntrlized-db") { - item.Hidden = true - } - - // 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 { + if err := json.NewEncoder(w).Encode(items); 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 (scheme://host/org/repo), using the -// scheme of the configured Gitea base URL. -func repoURLFromImageRef(imageRef string) string { - ref, err := name.ParseReference(imageRef) - if err != nil { - return "" - } - return giteaScheme() + "://" + ref.Context().RegistryStr() + "/" + ref.Context().RepositoryStr() -} - -// giteaBaseURL returns the Gitea instance base URL (scheme://host[:port]). -// Production defaults to git.dcntrlized.org; dev mode (DCNTRLIZED_DEV=true) -// overrides it with GITEA_URL when set. -func giteaBaseURL() string { - if os.Getenv("DCNTRLIZED_DEV") == "true" { - if u := strings.TrimRight(os.Getenv("GITEA_URL"), "/"); u != "" { - return u - } - } - return "https://git.dcntrlized.org" -} - -// registryHost strips the scheme from the Gitea base URL, returning just the -// host[:port] used in container image references. -func registryHost() string { - base := giteaBaseURL() - if i := strings.Index(base, "://"); i >= 0 { - return base[i+3:] - } - return base -} - -// isInsecureRegistry reports whether the Gitea registry is served over plain -// HTTP, in which case image references must be parsed with name.Insecure. -func isInsecureRegistry() bool { - return strings.HasPrefix(giteaBaseURL(), "http://") -} - -// giteaScheme returns the scheme (http/https) of the configured Gitea base URL. -func giteaScheme() string { - if i := strings.Index(giteaBaseURL(), "://"); i >= 0 { - return giteaBaseURL()[:i] - } - return "https" -} diff --git a/src/lib/containers/infra.go b/src/lib/containers/infra.go index 0adc789..17cb9f8 100644 --- a/src/lib/containers/infra.go +++ b/src/lib/containers/infra.go @@ -21,6 +21,7 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" + libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -50,10 +51,11 @@ func EnsureDCNTRLIZEDNetwork(ctx context.Context, dc *docker.DockerClient) error return nil } -// EnsureDatabaseRunning ensures the shared database container is running. It -// starts the database only when it is already installed; otherwise it logs and -// leaves installation to the UI. The database is infrastructure, so this must -// never be invoked for the database service itself (StartContainer guards that). +// EnsureDatabaseRunning ensures the shared database container is running. On a +// fresh host — when the database is not installed yet — it resolves the +// database from the registry and installs it automatically before starting it. +// The database is infrastructure, so this must never be invoked for the +// database service itself (StartContainer guards that). func EnsureDatabaseRunning(ctx context.Context) error { return ensureInfraRunning(ctx, DatabaseServiceName, "shared database") } @@ -61,14 +63,17 @@ func EnsureDatabaseRunning(ctx context.Context) error { // EnsureAuthRunning ensures the shared auth service container is running. The // auth service is hidden infrastructure: every consumer proxies /auth/* to it // and validates action tokens with its key, so it must be up before services -// that depend on it start. Only starts when already installed. +// that depend on it start. Like the database, it is installed automatically +// from the registry when it is missing. func EnsureAuthRunning(ctx context.Context) error { return ensureInfraRunning(ctx, AuthServiceName, "shared auth service") } -// ensureInfraRunning starts a hidden infrastructure container (shared database, -// shared auth service) when it is installed but not running. It never installs -// infrastructure itself — the UI does that from the registry. +// ensureInfraRunning makes sure a hidden infrastructure container (shared +// database, shared auth service) is running. When the service is not installed +// yet it resolves the registry item and installs it first, so a fresh host +// boots with its infrastructure without manual UI steps. Failures are returned +// to the caller, which treats them as non-fatal warnings. func ensureInfraRunning(ctx context.Context, serviceName, label string) error { dc, err := docker.NewDockerClient() if err != nil { @@ -84,8 +89,14 @@ func ensureInfraRunning(ctx context.Context, serviceName, label string) error { return nil } if !IsInstalled(serviceName) { - logger.Info("%s not installed; install %s from the registry to enable services that need it", label, serviceName) - return nil + logger.Info("%s %s not installed; installing from the registry...", label, serviceName) + item, err := libregistry.ItemByName(ctx, serviceName) + if err != nil { + return fmt.Errorf("failed to resolve %s %s from the registry: %w", label, serviceName, err) + } + if err := InstallContainer(ctx, *item); err != nil { + return fmt.Errorf("failed to install %s %s: %w", label, serviceName, err) + } } logger.Info("Starting %s %s...", label, serviceName) diff --git a/src/lib/registry/registry.go b/src/lib/registry/registry.go new file mode 100644 index 0000000..0b8576c --- /dev/null +++ b/src/lib/registry/registry.go @@ -0,0 +1,349 @@ +// 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 . + +// Package registry resolves services from the Dcntrlized container registry +// (a Gitea package registry) into types.RegistryContainer items. It is the +// single source of truth behind the registry UI handler and the +// infrastructure bootstrap (lib/containers.ensureInfraRunning), so a shared +// database that Maestro auto-installs resolves to the exact same image and +// compose repository the UI would install. +package registry + +import ( + "context" + "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" +) + +// BaseURL returns the Gitea instance base URL (scheme://host[:port]). +// Production defaults to git.dcntrlized.org; dev mode (DCNTRLIZED_DEV=true) +// overrides it with GITEA_URL when set. +func BaseURL() string { + if os.Getenv("DCNTRLIZED_DEV") == "true" { + if u := strings.TrimRight(os.Getenv("GITEA_URL"), "/"); u != "" { + return u + } + } + return "https://git.dcntrlized.org" +} + +// RegistryHost strips the scheme from the Gitea base URL, returning just the +// host[:port] used in container image references. +func RegistryHost() string { + base := BaseURL() + if i := strings.Index(base, "://"); i >= 0 { + return base[i+3:] + } + return base +} + +// Scheme returns the scheme (http/https) of the configured Gitea base URL. +func Scheme() string { + if i := strings.Index(BaseURL(), "://"); i >= 0 { + return BaseURL()[:i] + } + return "https" +} + +// IsInsecure reports whether the Gitea registry is served over plain HTTP, in +// which case image references must be parsed with name.Insecure. +func IsInsecure() bool { + return strings.HasPrefix(BaseURL(), "http://") +} + +// RepoURLFromImageRef derives the repository browse URL from a container image +// reference of the form host/org/repo:tag (scheme://host/org/repo), using the +// scheme of the configured Gitea base URL. +func RepoURLFromImageRef(imageRef string) string { + ref, err := name.ParseReference(imageRef) + if err != nil { + return "" + } + return Scheme() + "://" + ref.Context().RegistryStr() + "/" + ref.Context().RepositoryStr() +} + +// ListItems queries the Gitea packages API for the configured org and builds a +// types.RegistryContainer for the highest semver tag of each container package, +// enriched with OCI labels (title, description, source repository, hidden flag). +func ListItems(ctx context.Context) ([]types.RegistryContainer, error) { + // 1. Query Gitea REST API using the exact Org name + apiURL := fmt.Sprintf("%s/api/v1/packages/%s?type=container", BaseURL(), os.Getenv("GITEA_ORG")) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create registry request: %w", err) + } + + 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 { + return nil, fmt.Errorf("failed to reach Gitea package API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("registry API returned non-200 status code: %d", resp.StatusCode) + } + + var rawPackages []types.RawPackage + if err := json.NewDecoder(resp.Body).Decode(&rawPackages); err != nil { + return nil, fmt.Errorf("failed to decode registry response: %w", err) + } + + // 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) + } + + // 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("%s/%s/%s:%s", RegistryHost(), 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, + } + + var refOpts []name.Option + if IsInsecure() { + refOpts = append(refOpts, name.Insecure) + } + ref, err := name.ParseReference(imageRef, refOpts...) + 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(ctx)) + 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 + } + if hidden, ok := manifest.Annotations["org.dcntrlized.hidden"]; ok && hidden == "true" { + item.Hidden = true + } + } + + // 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"] + } + if !item.Hidden && labels["org.dcntrlized.hidden"] == "true" { + item.Hidden = true + } + } + + // Fallback for infra images published before the hidden label existed: + // the shared database is always treated as hidden infrastructure. + if strings.EqualFold(item.Name, "dcntrlized-db") { + item.Hidden = true + } + + // 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) + } + + return registryItems, nil +} + +// ItemByName returns the registry item for the named service, or an error when +// the registry does not carry a container package with that name. +func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, error) { + items, err := ListItems(ctx) + if err != nil { + return nil, err + } + for i := range items { + if strings.EqualFold(items[i].Name, name) { + return &items[i], nil + } + } + return nil, fmt.Errorf("registry: service %q not found", name) +} + +// 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). +func normalizeSemver(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). +func semverSpecificity(v string) int { + tag := strings.TrimPrefix(v, "v") + if i := strings.IndexByte(tag, '-'); i >= 0 { + tag = tag[:i] + } + return strings.Count(tag, ".") +} diff --git a/src/lib/registry/registry_test.go b/src/lib/registry/registry_test.go new file mode 100644 index 0000000..cc7b14a --- /dev/null +++ b/src/lib/registry/registry_test.go @@ -0,0 +1,52 @@ +// 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 . + +package registry + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +// TestItemByNameLive resolves the infrastructure services against a live +// registry. It is opt-in (MAESTRO_REGISTRY_TEST=1) so the default test run +// stays offline; the dev VM and prod CI both set the Gitea env the function +// reads. Infrastructure services must resolve so Maestro can auto-install them +// on a fresh host (ensureInfraRunning). +func TestItemByNameLive(t *testing.T) { + if os.Getenv("MAESTRO_REGISTRY_TEST") != "1" { + t.Skip("set MAESTRO_REGISTRY_TEST=1 with GITEA_* env to run against a live registry") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for _, name := range []string{"dcntrlized-db", "dcntrlized-auth"} { + item, err := ItemByName(ctx, name) + if err != nil { + t.Fatalf("ItemByName(%q): %v", name, err) + } + if item.Image == "" { + t.Errorf("ItemByName(%q): empty image", name) + } + if !strings.HasPrefix(item.URL, "http") { + t.Errorf("ItemByName(%q): url %q not an http(s) compose source", name, item.URL) + } + t.Logf("resolved %s -> image=%s url=%s version=%s", name, item.Image, item.URL, item.Version) + } +}