diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx index f304b34..39412b8 100644 --- a/src/frontend/src/app.tsx +++ b/src/frontend/src/app.tsx @@ -111,6 +111,7 @@ export function App() { if (existing) { existing.origin = 'both' existing.remoteSource = r.source + existing.url = r.url || existing.url existing.latestVersion = r.version existing.title = r.title || existing.title existing.vendor = r.vendor || existing.vendor diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 528dbaf..f3f1a82 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -14,6 +14,7 @@ export interface RegistryItem { version: string image?: string source?: string + url?: string title?: string vendor?: string author?: string @@ -64,6 +65,7 @@ export interface Container { // Fields populated by remote Git Registry source?: string; + url?: string; version?: string; // Optional properties for update state diff --git a/src/handlers/registry/registry.go b/src/handlers/registry/registry.go index 772102a..54c3882 100644 --- a/src/handlers/registry/registry.go +++ b/src/handlers/registry/registry.go @@ -137,17 +137,13 @@ func GetRegistry(w http.ResponseWriter, r *http.Request) { pkgNameLower := strings.ToLower(pkg.Name) imageRef := fmt.Sprintf("git.dcntrlized.org/%s/%s:%s", ociOrg, pkgNameLower, pkg.Version) - // Default fallback source from Gitea REST API - source := pkg.HTMLURL - if pkg.Repository != nil && pkg.Repository.HTMLURL != "" { - source = pkg.Repository.HTMLURL - } - + // 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, - Source: source, CreatedAt: pkg.CreatedAt, } @@ -240,6 +236,17 @@ func GetRegistry(w http.ResponseWriter, r *http.Request) { } } + // 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) } @@ -248,3 +255,13 @@ func GetRegistry(w http.ResponseWriter, r *http.Request) { 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() +} diff --git a/src/lib/containers/install.go b/src/lib/containers/install.go index 1b1e623..975c473 100644 --- a/src/lib/containers/install.go +++ b/src/lib/containers/install.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -34,11 +35,14 @@ func InstallContainer(ctx context.Context, item types.RegistryContainer) error { } // 2. Fetch raw docker-compose.yaml from the Git URL - rawURL := fmt.Sprintf("%s/raw/branch/master/docker-compose.yaml", item.URL) + rawURL, err := composeRawURL(item) + if err != nil { + return fmt.Errorf("failed to resolve repository URL: %w", err) + } composePath := filepath.Join(targetDir, "docker-compose.yaml") logger.Info("Fetching compose file from %s...", rawURL) - if err := downloadFile(ctx, rawURL, composePath); err != nil { + if err := downloadCompose(ctx, rawURL, composePath); err != nil { return fmt.Errorf("failed to download docker-compose.yaml: %w", err) } @@ -347,6 +351,37 @@ func getContainerDataDir(serviceName string) (string, error) { return filepath.Join(root, serviceName), nil } +// composeRawURL resolves the raw docker-compose.yaml URL for a container, +// preferring the repository URL in item.URL (falling back to item.Source) and +// rejecting URLs without an http/https scheme. +func composeRawURL(item types.RegistryContainer) (string, error) { + base := strings.TrimRight(item.URL, "/") + if base == "" { + base = strings.TrimRight(item.Source, "/") + } + u, err := url.Parse(base) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return "", fmt.Errorf("invalid repository URL %q", base) + } + return fmt.Sprintf("%s/raw/branch/master/docker-compose.yaml", base), nil +} + +// downloadCompose fetches the compose file, retrying on the main branch when +// the repository's default branch is not master. +func downloadCompose(ctx context.Context, url, destPath string) error { + err := downloadFile(ctx, url, destPath) + if err != nil { + mainURL := strings.Replace(url, "/raw/branch/master/", "/raw/branch/main/", 1) + if mainURL != url { + if retryErr := downloadFile(ctx, mainURL, destPath); retryErr == nil { + return nil + } + } + return err + } + return nil +} + func downloadFile(ctx context.Context, url string, destPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil {