From 678356bd5b10ad738fcaac97ca96d4067d62f286 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Sat, 1 Aug 2026 20:58:11 +0100 Subject: [PATCH] feat: Pin installed services to semver images and normalize registry versions --- .gitignore | 1 + docker-compose.yaml | 9 +++--- src/frontend/src/app.tsx | 2 +- src/handlers/registry/registry.go | 52 ++++++++++++++++++++++++------- src/lib/containers/start.go | 21 +++++++++++++ src/lib/containers/watcher.go | 3 ++ 6 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/docker-compose.yaml b/docker-compose.yaml index f6c0883..06c52b9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,10 +15,11 @@ services: dockerfile: Dockerfile environment: - XDG_DATA_HOME=/data - - GITEA_REGISTRY_RO_TOKEN= - - GITEA_RW_PACKAGE_TOKEN= - - GITEA_ORG= - - GITEA_USER= + - GITEA_REGISTRY_RO_TOKEN=${GITEA_REGISTRY_RO_TOKEN} + - GITEA_RW_PACKAGE_TOKEN=${GITEA_RW_PACKAGE_TOKEN} + - GITEA_ORG=${GITEA_ORG:-Dcntrlized} + - GITEA_USER=${GITEA_USER:-Admin} + - GIT_TOKEN=${GIT_TOKEN} networks: dcntrlized_net: diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx index 8ed6ec6..f304b34 100644 --- a/src/frontend/src/app.tsx +++ b/src/frontend/src/app.tsx @@ -292,7 +292,7 @@ export function App() { )} {!isRemoteOnly && container.version && (
- Installed Ver: + Installed Version: {container.version}
)} diff --git a/src/handlers/registry/registry.go b/src/handlers/registry/registry.go index 38d3402..772102a 100644 --- a/src/handlers/registry/registry.go +++ b/src/handlers/registry/registry.go @@ -63,12 +63,32 @@ func GetRegistry(w http.ResponseWriter, r *http.Request) { groupedPackages[pkg.Name] = append(groupedPackages[pkg.Name], pkg) } - // Helper function to normalize version strings for semver package (requires 'v' prefix) - formatSemver := func(v string) string { - if !strings.HasPrefix(v, "v") { - return "v" + v + // 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] } - return v + 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 @@ -79,15 +99,25 @@ func GetRegistry(w http.ResponseWriter, r *http.Request) { } sort.Slice(pkgs, func(i, j int) bool { - vI := formatSemver(pkgs[i].Version) - vJ := formatSemver(pkgs[j].Version) + 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) { - return semver.Compare(vI, vJ) > 0 + if c := semver.Compare(vI, vJ); c != 0 { + return c > 0 + } + } else if nI != nJ { + return nI > nJ } - // Fall back to string comparison if not valid semver - return pkgs[i].Version > pkgs[j].Version + // 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 @@ -115,7 +145,7 @@ func GetRegistry(w http.ResponseWriter, r *http.Request) { item := types.RegistryContainer{ Name: pkg.Name, - Version: pkg.Version, + Version: normalizeSemver(pkg.Version), Image: imageRef, Source: source, CreatedAt: pkg.CreatedAt, diff --git a/src/lib/containers/start.go b/src/lib/containers/start.go index 916ea8d..aed8a10 100644 --- a/src/lib/containers/start.go +++ b/src/lib/containers/start.go @@ -38,6 +38,7 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error { } } } + pinServiceImages(project, installedImage) ensureServiceImages(ctx, dc, project, installedImage) composeService, err := newComposeService(dc) @@ -61,6 +62,26 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error { return nil } +// pinServiceImages rewrites each compose service's image reference to the exact +// installed image (e.g. git.../testservice:3.4.0) when its base name matches, so +// containers run pinned to a concrete semver tag instead of a moving "latest" tag. +func pinServiceImages(project *composetypes.Project, installedImage string) { + if installedImage == "" { + return + } + for name, s := range project.Services { + if s.Image == "" || s.Image == installedImage { + continue + } + if baseImageName(s.Image) != baseImageName(installedImage) { + continue + } + logger.Info("Pinning service %s image %s to installed image %s", name, s.Image, installedImage) + s.Image = installedImage + project.Services[name] = s + } +} + // ensureServiceImages makes sure every image referenced by the compose project // exists locally. Registries install the image under its full reference (e.g. // git.../testservice:3.4) while the service's compose file often references it diff --git a/src/lib/containers/watcher.go b/src/lib/containers/watcher.go index 70db30f..1079a38 100644 --- a/src/lib/containers/watcher.go +++ b/src/lib/containers/watcher.go @@ -84,6 +84,9 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc // Save to Cache log.Printf("Service started. Adding container %s - %s to cache", host, containerName) existing, _ := cache.services[key] + if existing.Version != "" && existing.Version != "latest" && (ver == "" || ver == "latest") { + ver = existing.Version + } container := types.Container{ ID: containerID, ServiceName: serviceLabel,