diff --git a/internal/mirror/installer/installer_edition_test.go b/internal/mirror/installer/installer_edition_test.go new file mode 100644 index 00000000..64b2dabd --- /dev/null +++ b/internal/mirror/installer/installer_edition_test.go @@ -0,0 +1,113 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// TestInstallerService_RootURL_StaysAtBareRoot_WithEdition is a regression test +// guarding the asymmetry in the registry service: while deckhouse, modules and +// security live UNDER the edition segment, the installer (and plugins) live +// OUTSIDE it (at /installer). The fix that introduced GetEditionRoot() +// must NOT spill over into the installer wiring — installer.NewService must +// keep using registryService.GetRoot() (the bare root) for its downloadList. +// +// Without this regression test it would be easy to "uniformly" switch every +// service to GetEditionRoot() and silently break installer paths +// (//installer:tag does not exist). +func TestInstallerService_RootURL_StaysAtBareRoot_WithEdition(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.deckhouse.ru/deckhouse" + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + const tag = "v1.75.7" + svc.downloadList.FillInstallerImages([]string{tag}) + + expected := bareRoot + "/" + internal.InstallerSegment + ":" + tag + _, ok := svc.downloadList.Installer[expected] + assert.Truef(t, ok, + "installer downloadList must contain %q (bare-root, no edition); actual keys: %v", + expected, svc.downloadList.Installer) + + // The forbidden shape: //installer:tag. The installer + // repository does not exist under the edition sub-tree, so any such key + // would point at a 404 in production. + forbidden := bareRoot + "/fe/" + internal.InstallerSegment + ":" + tag + _, badFound := svc.downloadList.Installer[forbidden] + assert.Falsef(t, badFound, + "installer downloadList must NOT contain edition-scoped key %q; "+ + "installer lives at /installer regardless of edition", + forbidden) + + for key := range svc.downloadList.Installer { + assert.Falsef(t, + strings.HasPrefix(key, bareRoot+"/fe/"), + "installer downloadList key %q must not start with the edition-scoped prefix", key) + } +} + +// TestInstallerService_RootURL_NoEdition pins down the unchanged behaviour +// when edition is not set: installer paths still sit at /installer. +func TestInstallerService_RootURL_NoEdition(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.example.com/deckhouse" + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, pkg.NoEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + const tag = "v1.75.7" + svc.downloadList.FillInstallerImages([]string{tag}) + + expected := bareRoot + "/" + internal.InstallerSegment + ":" + tag + _, ok := svc.downloadList.Installer[expected] + assert.Truef(t, ok, + "installer downloadList must contain %q; actual keys: %v", + expected, svc.downloadList.Installer) +} diff --git a/internal/mirror/modules/modules.go b/internal/mirror/modules/modules.go index 9794f39b..736a02bf 100644 --- a/internal/mirror/modules/modules.go +++ b/internal/mirror/modules/modules.go @@ -121,7 +121,12 @@ func NewService( options.Filter = filter } - rootURL := registryService.GetRoot() + // rootURL must include the edition segment (e.g. .../deckhouse/fe) so that + // per-module references like /modules/: resolve to the + // same path served by registryService.ModuleService(). registryService.GetRoot() + // would return the non-edition root and produce /modules/:, + // which mismatches the actual ModulesService scope. + rootURL := registryService.GetEditionRoot() return &Service{ workingDir: workingDir, diff --git a/internal/mirror/modules/modules_edition_test.go b/internal/mirror/modules/modules_edition_test.go new file mode 100644 index 00000000..b14be92f --- /dev/null +++ b/internal/mirror/modules/modules_edition_test.go @@ -0,0 +1,129 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package modules + +import ( + "log/slog" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// TestModulesService_RootURL_UsesEditionSegment is the regression for the +// "missing edition segment in release-channel URLs" report applied to the +// modules service: the rootURL used to compose per-module registry paths +// must include the edition segment so that lookups land on the same path +// served by the edition-scoped ModulesService. +func TestModulesService_RootURL_UsesEditionSegment(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.deckhouse.ru/deckhouse" + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + const editionRoot = bareRoot + "/fe" + + assert.Equal(t, editionRoot, svc.rootURL, + "modules Service.rootURL must be the edition-scoped root so that "+ + "per-module references resolve under //modules//...") + + // pullSingleModule and the public Module model both compose paths as + // filepath.Join(svc.rootURL, "modules", ). + // Verify that the composed path carries the edition segment. + const moduleName = "console" + composed := filepath.Join(svc.rootURL, "modules", moduleName) + assert.Equal(t, editionRoot+"/modules/"+moduleName, composed, + "per-module registry path must live under the edition sub-tree") +} + +// TestModulesService_RootURL_NoEdition pins down that with no edition both +// roots collapse to the bare host — so the new GetEditionRoot() plumbing is +// a true superset of the previous behaviour, not a different one. +func TestModulesService_RootURL_NoEdition(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.example.com/deckhouse" + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, pkg.NoEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + assert.Equal(t, bareRoot, svc.rootURL, + "modules Service.rootURL must equal the bare root when no edition is set") +} + +// TestModulesService_RootURL_CoversAllEditions sweeps every Edition value +// so that adding a new edition to pkg.Edition without wiring it through +// registryservice.NewService surfaces here as an explicit mismatch. +func TestModulesService_RootURL_CoversAllEditions(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.deckhouse.ru/deckhouse" + + editions := []pkg.Edition{ + pkg.FEEdition, + pkg.EEEdition, + pkg.SEEdition, + pkg.SEPlusEdition, + pkg.BEEdition, + pkg.CEEdition, + } + + for _, edition := range editions { + t.Run("edition="+edition.String(), func(t *testing.T) { + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, edition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + expected := bareRoot + "/" + edition.String() + assert.Equal(t, expected, svc.rootURL, + "modules Service.rootURL must include the %s edition segment", edition) + }) + } +} diff --git a/internal/mirror/platform/platform.go b/internal/mirror/platform/platform.go index 1a6147f9..36cfc250 100644 --- a/internal/mirror/platform/platform.go +++ b/internal/mirror/platform/platform.go @@ -115,7 +115,13 @@ func NewService( } } - rootURL := registryService.GetRoot() + // rootURL must point at the edition sub-tree so that references inside + // downloadList (e.g. release-channel:) line up with the actual + // repository served by deckhouseService.ReleaseChannels(). Using the + // bare registryService.GetRoot() here would drop the edition segment for + // FE/EE/SE sources and cause duplicate /release-channel: + // entries to be enqueued alongside the correct //release-channel:. + rootURL := registryService.GetEditionRoot() return &Service{ deckhouseService: registryService.DeckhouseService(), diff --git a/internal/mirror/platform/pull_platform_test.go b/internal/mirror/platform/pull_platform_test.go index dd7867d4..8f9beb7a 100644 --- a/internal/mirror/platform/pull_platform_test.go +++ b/internal/mirror/platform/pull_platform_test.go @@ -489,6 +489,166 @@ func ltsOnlySourceStub(ver string) localreg.Client { return pkgclient.Adapt(upfake.NewClient(reg)) } +// TestPullPlatform_FEEdition_DownloadListUsesEditionRoot is the regression for +// the report that `d8 mirror pull --source=.../deckhouse/fe` enqueues entries +// like "registry.deckhouse.ru/deckhouse/release-channel:" (without the +// "fe" segment) alongside the correct +// "registry.deckhouse.ru/deckhouse/fe/release-channel:" ones. +// +// The root cause was platform.NewService seeding the downloadList rootURL +// from registryService.GetRoot() (non-edition root) while +// getReleaseChannelVersionFromRegistry fed the same map with edition-scoped +// keys obtained through deckhouseService. The fix is to seed the rootURL +// with registryService.GetEditionRoot() so every key in the downloadList +// carries the edition segment. +// +// We exercise the contract directly at the platform.NewService boundary so +// the test does not depend on the fake registry's WithSegment semantics +// (the in-memory fake intentionally exposes only the host via GetRegistry, +// which would mask both the bug and the fix). +func TestPullPlatform_FEEdition_DownloadListUsesEditionRoot(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + c := pkgclient.NewFromOptions("registry.deckhouse.ru/deckhouse") + regSvc := registryservice.NewService(c, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + const editionRoot = "registry.deckhouse.ru/deckhouse/fe" + + // The downloadList rootURL must point at the edition sub-tree. We probe it + // through FillDeckhouseImages, which uses rootURL to build the entry keys. + const probe = "v1.69.0" + svc.downloadList.FillDeckhouseImages([]string{probe}) + + assert.Contains(t, svc.downloadList.Deckhouse, editionRoot+":"+probe, + "FE main Deckhouse entry must live under the edition sub-tree") + assert.Contains(t, svc.downloadList.DeckhouseInstall, + editionRoot+"/install:"+probe, + "FE install entry must live under the edition sub-tree") + assert.Contains(t, svc.downloadList.DeckhouseInstallStandalone, + editionRoot+"/install-standalone:"+probe, + "FE standalone install entry must live under the edition sub-tree") + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + editionRoot+"/release-channel:"+probe, + "FE release-channel version tag must live under the edition sub-tree") + + const bareRoot = "registry.deckhouse.ru/deckhouse" + // Spot-check that no key was written under the bare (non-edition) root. + // These are the exact shapes that surfaced as duplicate pulls in the bug report. + assert.NotContains(t, svc.downloadList.Deckhouse, bareRoot+":"+probe) + assert.NotContains(t, svc.downloadList.DeckhouseInstall, bareRoot+"/install:"+probe) + assert.NotContains(t, svc.downloadList.DeckhouseReleaseChannel, + bareRoot+"/release-channel:"+probe) + + // FillForChannels populates channel aliases — same rootURL must apply. + svc.downloadList.FillForChannels([]string{"stable", "alpha"}) + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + editionRoot+"/release-channel:stable", + "FE release-channel alias must live under the edition sub-tree") + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + editionRoot+"/release-channel:alpha", + "FE release-channel alias must live under the edition sub-tree") + assert.NotContains(t, svc.downloadList.DeckhouseReleaseChannel, + bareRoot+"/release-channel:stable", + "non-edition-scoped duplicate must not be enqueued") +} + +// TestPullPlatform_NoEdition_DownloadListUsesBareRoot is the companion of +// TestPullPlatform_FEEdition_DownloadListUsesEditionRoot for the NoEdition +// case. When no edition is configured GetEditionRoot must collapse to the +// bare root so the downloadList keeps producing the original key shape used +// by community / CSE sources. +func TestPullPlatform_NoEdition_DownloadListUsesBareRoot(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.example.com/deckhouse" + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, pkg.NoEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + const probe = "v1.69.0" + svc.downloadList.FillDeckhouseImages([]string{probe}) + + assert.Contains(t, svc.downloadList.Deckhouse, bareRoot+":"+probe, + "with NoEdition the main Deckhouse entry must live at the bare root") + assert.Contains(t, svc.downloadList.DeckhouseInstall, + bareRoot+"/install:"+probe, + "with NoEdition install entries must live under the bare root") + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + bareRoot+"/release-channel:"+probe, + "with NoEdition release-channel entries must live under the bare root") +} + +// TestPullPlatform_AllEditions_DownloadListUsesEditionRoot sweeps every +// concrete edition so that any future addition to pkg.Edition that is not +// wired through to platform.NewService surfaces here as an explicit failure. +func TestPullPlatform_AllEditions_DownloadListUsesEditionRoot(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.deckhouse.ru/deckhouse" + editions := []pkg.Edition{ + pkg.FEEdition, + pkg.EEEdition, + pkg.SEEdition, + pkg.SEPlusEdition, + pkg.BEEdition, + pkg.CEEdition, + } + + const probe = "v1.69.0" + + for _, edition := range editions { + t.Run("edition="+edition.String(), func(t *testing.T) { + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, edition, logger) + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + svc.downloadList.FillDeckhouseImages([]string{probe}) + svc.downloadList.FillForChannels([]string{"stable"}) + + editionRoot := bareRoot + "/" + edition.String() + assert.Contains(t, svc.downloadList.Deckhouse, editionRoot+":"+probe) + assert.Contains(t, svc.downloadList.DeckhouseInstall, + editionRoot+"/install:"+probe) + assert.Contains(t, svc.downloadList.DeckhouseInstallStandalone, + editionRoot+"/install-standalone:"+probe) + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + editionRoot+"/release-channel:"+probe) + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + editionRoot+"/release-channel:stable") + + assert.NotContains(t, svc.downloadList.Deckhouse, bareRoot+":"+probe, + "%s edition must not leak a duplicate at the bare root", edition) + assert.NotContains(t, svc.downloadList.DeckhouseReleaseChannel, + bareRoot+"/release-channel:stable", + "%s edition must not leak a duplicate channel alias at the bare root", edition) + }) + } +} + // TestPullPlatform_LTSPull_ChannelAliasesLiveOnlyInReleaseChannel pins down the // shape of the bundle produced by `d8 mirror pull --deckhouse-tag ` against // a CSE-like (LTS-only) registry. The invariant we lock in here is: diff --git a/internal/mirror/pusher/pusher.go b/internal/mirror/pusher/pusher.go index 7a0fbd2c..8c0122bd 100644 --- a/internal/mirror/pusher/pusher.go +++ b/internal/mirror/pusher/pusher.go @@ -25,6 +25,7 @@ import ( "path/filepath" "time" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/layout" dkplog "github.com/deckhouse/deckhouse/pkg/log" @@ -34,6 +35,7 @@ import ( "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/retry" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/retry/task" + regimage "github.com/deckhouse/deckhouse-cli/pkg/registry/image" ) const ( @@ -84,14 +86,15 @@ func (s *Service) PushLayout(ctx context.Context, layoutPath layout.Path, client return nil } - s.userLogger.Infof("Pushing %d images", len(indexManifest.Manifests)) + manifests := dedupManifestsByShortTag(indexManifest.Manifests, s.logger) + if len(manifests) == 0 { + return nil + } - for i, manifest := range indexManifest.Manifests { - tag := manifest.Annotations["io.deckhouse.image.short_tag"] - if tag == "" { - s.logger.Warn("Skipping image without short_tag annotation", slog.String("digest", manifest.Digest.String())) - continue - } + s.userLogger.Infof("Pushing %d images", len(manifests)) + + for i, manifest := range manifests { + tag := manifest.Annotations[regimage.AnnotationImageShortTag] img, err := index.Image(manifest.Digest) if err != nil { @@ -102,7 +105,7 @@ func (s *Service) PushLayout(ctx context.Context, layoutPath layout.Path, client err = retry.RunTask( ctx, s.userLogger, - fmt.Sprintf("[%d / %d] Pushing %s", i+1, len(indexManifest.Manifests), imageReferenceString), + fmt.Sprintf("[%d / %d] Pushing %s", i+1, len(manifests), imageReferenceString), task.WithConstantRetries(pushRetryAttempts, pushRetryDelay, func(ctx context.Context) error { if err := client.PushImage(ctx, tag, img); err != nil { return fmt.Errorf("write %s:%s to registry: %w", client.GetRegistry(), tag, err) @@ -117,6 +120,48 @@ func (s *Service) PushLayout(ctx context.Context, layoutPath layout.Path, client return nil } +// dedupManifestsByShortTag filters and deduplicates manifests for pushing. +// +// Descriptors without the io.deckhouse.image.short_tag annotation are skipped. +// When several descriptors carry the same short_tag (which can happen because +// layout.AppendDescriptor in libmirror layouts/images appends a new descriptor +// instead of updating one in place), only the last one is kept. That matches +// what the registry would store today: the loop used to push every duplicate, +// and each subsequent push of the same tag silently overwrote the previous +// one. Deduplicating here makes the push log accurate and avoids redundant +// network work. +func dedupManifestsByShortTag(descriptors []v1.Descriptor, logger *dkplog.Logger) []v1.Descriptor { + if len(descriptors) == 0 { + return nil + } + + indexByTag := make(map[string]int, len(descriptors)) + result := make([]v1.Descriptor, 0, len(descriptors)) + + for _, manifest := range descriptors { + tag := manifest.Annotations[regimage.AnnotationImageShortTag] + if tag == "" { + logger.Warn("Skipping image without short_tag annotation", + slog.String("digest", manifest.Digest.String())) + continue + } + + if idx, ok := indexByTag[tag]; ok { + logger.Warn("Duplicate short_tag in OCI layout, keeping last descriptor", + slog.String("tag", tag), + slog.String("previous_digest", result[idx].Digest.String()), + slog.String("current_digest", manifest.Digest.String())) + result[idx] = manifest + continue + } + + indexByTag[tag] = len(result) + result = append(result, manifest) + } + + return result +} + // OpenPackage opens a package file, trying .tar first, then chunked func (s *Service) OpenPackage(bundleDir, pkgName string) (io.ReadCloser, error) { p := filepath.Join(bundleDir, pkgName+".tar") diff --git a/internal/mirror/pusher/pusher_test.go b/internal/mirror/pusher/pusher_test.go index b1444616..f80990c1 100644 --- a/internal/mirror/pusher/pusher_test.go +++ b/internal/mirror/pusher/pusher_test.go @@ -195,6 +195,54 @@ func TestPushLayout_EmptyLayout(t *testing.T) { assert.NoError(t, err, "PushLayout on an empty layout must not error") } +// TestPushLayout_DeduplicatesByShortTag verifies that when an OCI layout +// contains several descriptors with the same io.deckhouse.image.short_tag +// annotation (which the libmirror layouts can produce by appending a new +// descriptor instead of editing in place), PushLayout pushes that tag once +// and ends up with the LAST descriptor in the registry. Pushing twice would +// be wasteful and would produce a misleading "[1/N] ... v1.73.2" / "[K/N] +// ... v1.73.2" sequence in the log. +func TestPushLayout_DeduplicatesByShortTag(t *testing.T) { + const dupTag = "v1.73.2" + + dir := t.TempDir() + imgLayout, err := regimage.NewImageLayout(dir) + require.NoError(t, err) + lp := imgLayout.Path() + + imgA := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"`+dupTag+`","build":"A"}`). + MustBuild() + imgB := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"`+dupTag+`","build":"B"}`). + MustBuild() + + require.NoError(t, lp.AppendImage(imgA, layout.WithAnnotations(map[string]string{ + regimage.AnnotationImageShortTag: dupTag, + }))) + require.NoError(t, lp.AppendImage(imgB, layout.WithAnnotations(map[string]string{ + regimage.AnnotationImageShortTag: dupTag, + }))) + + digestB, err := imgB.Digest() + require.NoError(t, err) + + reg := upfake.NewRegistry("push.example.io") + destClient := pkgclient.Adapt(upfake.NewClient(reg)) + + svc := newTestService(t) + require.NoError(t, svc.PushLayout(context.Background(), lp, destClient)) + + require.NoError(t, destClient.CheckImageExists(context.Background(), dupTag), + "tag %q must exist in destination after PushLayout", dupTag) + + pushedDigest, err := destClient.GetDigest(context.Background(), dupTag) + require.NoError(t, err, "duplicated tag must be present in the destination registry") + require.NotNil(t, pushedDigest) + assert.Equal(t, digestB.String(), pushedDigest.String(), + "last descriptor for a duplicated short_tag must win") +} + // TestPushLayout_MultipleImages verifies that all annotated images in a layout // are pushed to the destination. func TestPushLayout_MultipleImages(t *testing.T) { diff --git a/internal/mirror/security/security.go b/internal/mirror/security/security.go index 93819cf8..30923e10 100644 --- a/internal/mirror/security/security.go +++ b/internal/mirror/security/security.go @@ -92,11 +92,14 @@ func NewService( return &Service{ securityService: registryService.Security(), layout: layout, - downloadList: NewImageDownloadList(registryService.GetRoot()), - pullerService: puller.NewPullerService(logger, userLogger), - options: options, - logger: logger, - userLogger: userLogger, + // Security images live under the edition sub-tree (...//security/...), + // so the downloadList key prefix must include the edition segment to match the + // repository scope served by registryService.Security(). + downloadList: NewImageDownloadList(registryService.GetEditionRoot()), + pullerService: puller.NewPullerService(logger, userLogger), + options: options, + logger: logger, + userLogger: userLogger, } } diff --git a/internal/mirror/security/security_edition_test.go b/internal/mirror/security/security_edition_test.go new file mode 100644 index 00000000..d805490a --- /dev/null +++ b/internal/mirror/security/security_edition_test.go @@ -0,0 +1,137 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package security + +import ( + "log/slog" + "path" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// TestSecurityService_RootURL_UsesEditionSegment is the regression for the +// "missing edition segment in release-channel URLs" report: it asserts that +// the security downloadList is seeded with the edition-scoped root so that +// every FillSecurityImages key sits under //security/... +// rather than the bare /security/... path. +// +// We exercise the contract directly at the security.NewService boundary so +// the test stays independent of the in-memory fake registry's +// GetRegistry semantics (the upfake intentionally exposes only the host, +// which would mask the bug). +func TestSecurityService_RootURL_UsesEditionSegment(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.deckhouse.ru/deckhouse" + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + svc.downloadList.FillSecurityImages() + + const editionRoot = bareRoot + "/fe" + + expectedDatabases := []struct { + name string + tag string + }{ + {internal.SecurityTrivyDBSegment, "2"}, + {internal.SecurityTrivyBDUSegment, "1"}, + {internal.SecurityTrivyJavaDBSegment, "1"}, + {internal.SecurityTrivyChecksSegment, "0"}, + } + + for _, db := range expectedDatabases { + imageSet, ok := svc.downloadList.Security[db.name] + assert.Truef(t, ok, + "downloadList.Security must contain an entry for database %q", db.name) + if !ok { + continue + } + + expectedRef := path.Join(editionRoot, internal.SecuritySegment, db.name) + ":" + db.tag + _, refOK := imageSet[expectedRef] + assert.Truef(t, refOK, + "downloadList.Security[%q] must contain edition-scoped ref %q; actual keys: %v", + db.name, expectedRef, imageSet) + + // No key may be written under the bare (non-edition) root — that would + // be the duplicate that caused the user-visible + // "registry.deckhouse.ru/deckhouse/security/..." pulls. + bareRef := path.Join(bareRoot, internal.SecuritySegment, db.name) + ":" + db.tag + _, bareFound := imageSet[bareRef] + assert.Falsef(t, bareFound, + "downloadList.Security[%q] must NOT contain non-edition-scoped ref %q", + db.name, bareRef) + + // Cross-check: every key in the set is edition-scoped. + for key := range imageSet { + assert.Truef(t, strings.HasPrefix(key, editionRoot+"/"), + "downloadList.Security[%q] key %q must start with edition-scoped root %q", + db.name, key, editionRoot) + } + } +} + +// TestSecurityService_RootURL_NoEdition pins down that when no edition is +// configured the security downloadList stays at the bare root — i.e. the +// new GetEditionRoot() plumbing must not introduce a phantom segment when +// edition == NoEdition. +func TestSecurityService_RootURL_NoEdition(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const bareRoot = "registry.example.com/deckhouse" + c := pkgclient.NewFromOptions(bareRoot) + regSvc := registryservice.NewService(c, pkg.NoEdition, logger) + + svc := NewService( + regSvc, + t.TempDir(), + &Options{BundleDir: t.TempDir(), DryRun: true}, + logger, + userLogger, + ) + + svc.downloadList.FillSecurityImages() + + for dbName, imageSet := range svc.downloadList.Security { + for key := range imageSet { + assert.Truef(t, strings.HasPrefix(key, bareRoot+"/"+internal.SecuritySegment+"/"), + "downloadList.Security[%q] key %q must start with the bare root + security/ when no edition is set", + dbName, key) + } + } +} diff --git a/internal/system/cmd/queue/operatequeue/operatequeue.go b/internal/system/cmd/queue/operatequeue/operatequeue.go index 91cb9e30..1da0c280 100644 --- a/internal/system/cmd/queue/operatequeue/operatequeue.go +++ b/internal/system/cmd/queue/operatequeue/operatequeue.go @@ -26,13 +26,22 @@ func OperateQueue(config *rest.Config, kubeCl *kubernetes.Clientset, pathFromOpt } func executeQueueCommand(config *rest.Config, kubeCl *kubernetes.Clientset, pathFromOption string) error { + out, err := fetchQueue(config, kubeCl, pathFromOption) + if err != nil { + return err + } + + fmt.Printf("%s\n", out) + return nil +} + +func fetchQueue(config *rest.Config, kubeCl *kubernetes.Clientset, pathFromOption string) (string, error) { const ( apiProtocol = "http" apiEndpoint = "127.0.0.1" apiPort = "9652" queuePath = "queue" - labelSelector = "leader=true" namespace = "d8-system" containerName = "deckhouse" ) @@ -41,53 +50,78 @@ func executeQueueCommand(config *rest.Config, kubeCl *kubernetes.Clientset, path getAPI := []string{"curl", fullEndpointURL} podName, err := utilk8s.GetDeckhousePod(kubeCl) if err != nil { - return err + return "", err } executor, err := utilk8s.ExecInPod(config, kubeCl, getAPI, podName, namespace, containerName) if err != nil { - return err + return "", err } var stdout bytes.Buffer var stderr bytes.Buffer - if err = executor.StreamWithContext( + if err := executor.StreamWithContext( context.Background(), remotecommand.StreamOptions{ Stdout: &stdout, Stderr: &stderr, }); err != nil { - return err + return "", err } - fmt.Printf("%s\n", stdout.String()) - return nil + return stdout.String(), nil } func watchQueueCommand(config *rest.Config, kubeCl *kubernetes.Clientset, pathFromOption string) error { signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() output := termenv.DefaultOutput() + output.AltScreen() + output.HideCursor() + defer func() { + output.ShowCursor() + output.ExitAltScreen() + }() + + // Render frames into a single buffer and write them in one syscall so the + // terminal never has a chance to display a half-cleared screen. The + // "cursor home, write, clear to end of screen" pattern overwrites the + // previous frame in place instead of wiping it first, which is what was + // causing the visible blinking. + render := func() { + body, fetchErr := fetchQueue(config, kubeCl, pathFromOption) + + var frame bytes.Buffer + // Move cursor to the top-left corner. + frame.WriteString("\x1b[H") + fmt.Fprintf(&frame, "Watching queue - %s (press Ctrl+C to stop)\n\n", time.Now().Format("15:04:05")) + if fetchErr != nil { + fmt.Fprintf(&frame, "Error fetching queue: %v\n", fetchErr) + } else { + frame.WriteString(body) + if len(body) == 0 || body[len(body)-1] != '\n' { + frame.WriteByte('\n') + } + } + // Erase everything from the cursor to the end of the screen so that + // leftover content from a longer previous frame is cleaned up. + frame.WriteString("\x1b[J") - fmt.Println("Watching queue (press Ctrl+C to stop)...") + _, _ = os.Stdout.Write(frame.Bytes()) + } + + render() for { select { case <-signals: - fmt.Println("\nWatch stopped.") return nil case <-ticker.C: - output.ClearScreen() - output.MoveCursor(1, 1) - fmt.Printf("Watching queue - %s\n\n", time.Now().Format("15:04:05")) - - err := executeQueueCommand(config, kubeCl, pathFromOption) - if err != nil { - fmt.Printf("Error fetching queue: %v\n", err) - } + render() } } } diff --git a/pkg/registry/image/layout.go b/pkg/registry/image/layout.go index cb3203b6..e9b77a52 100644 --- a/pkg/registry/image/layout.go +++ b/pkg/registry/image/layout.go @@ -97,12 +97,37 @@ func (l *ImageLayout) Path() layout.Path { return l.wrapped } +// AddImage stores img in the layout under tag. +// +// AddImage is idempotent for the (tag, digest) pair: if a previous call already +// stored an image with the same tag and the same manifest digest, the second +// call is a no-op. This matters because layout.Path.AppendImage always appends +// a new descriptor to index.json — it has no notion of "update in place" — so +// without this guard, callers that legitimately iterate over the same image +// set more than once (for example, pulling the deckhouse image set, then +// merging extra digests discovered from an installer and pulling the union +// again) would silently produce duplicate descriptors with the same +// io.deckhouse.image.short_tag annotation. Those duplicates later surface in +// `mirror push` as the same tag being pushed multiple times. +// +// When the tag already exists but with a different digest, AddImage falls +// through to AppendImage to preserve the previous re-tag behaviour; the push +// pipeline deduplicates such cases with last-wins semantics. func (l *ImageLayout) AddImage(img pkg.RegistryImage, tag string) error { meta, err := img.GetMetadata() if err != nil { return fmt.Errorf("get image tag reference: %w", err) } + newDigest := meta.GetDigest() + if existing, ok := l.metaByTag[tag]; ok { + if existingDigest := existing.GetDigest(); existingDigest != nil && + newDigest != nil && + *existingDigest == *newDigest { + return nil + } + } + // TODO: support nesting tags in image l.metaByTag[tag] = meta.(*ImageMeta) diff --git a/pkg/registry/image/layout_test.go b/pkg/registry/image/layout_test.go new file mode 100644 index 00000000..70de197b --- /dev/null +++ b/pkg/registry/image/layout_test.go @@ -0,0 +1,135 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package image_test + +import ( + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" + + regimage "github.com/deckhouse/deckhouse-cli/pkg/registry/image" +) + +// wrapImage wraps a v1.Image as a *regimage.Image with metadata populated +// from its digest, mirroring how the puller assembles images before calling +// ImageLayout.AddImage. The returned image carries TagReference=tagRef and +// Digest=img.Digest(). +func wrapImage(t *testing.T, img v1.Image, tagRef string) *regimage.Image { + t.Helper() + wrapped, err := regimage.NewImage(img, regimage.WithFetchingMetadata(tagRef)) + require.NoError(t, err, "wrap v1.Image into regimage.Image") + return wrapped +} + +// indexDescriptorCount reads index.json of the OCI layout and returns the +// number of descriptors currently recorded. It is the source of truth for +// "what mirror push will see". +func indexDescriptorCount(t *testing.T, l *regimage.ImageLayout) int { + t.Helper() + index, err := l.Path().ImageIndex() + require.NoError(t, err, "read image index") + manifest, err := index.IndexManifest() + require.NoError(t, err, "parse index manifest") + return len(manifest.Manifests) +} + +// TestAddImage_AppendsOnceForNewTag is a sanity check that AddImage does what +// the previous behaviour did when called with a fresh tag. +func TestAddImage_AppendsOnceForNewTag(t *testing.T) { + l, err := regimage.NewImageLayout(t.TempDir()) + require.NoError(t, err) + + v1img := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"v1.0.0"}`). + MustBuild() + + require.NoError(t, l.AddImage(wrapImage(t, v1img, "example.io/repo:v1.0.0"), "v1.0.0")) + + assert.Equal(t, 1, indexDescriptorCount(t, l), + "single AddImage call must produce exactly one descriptor") +} + +// TestAddImage_IdempotentForSameTagAndDigest is the regression test for the +// duplicate-tag bug observed in `mirror push` (e.g. "[1 / 337] ... cse:v1.73.2" +// followed by "[125 / 337] ... cse:v1.73.2"). The duplicates originated in +// the puller, which iterates the same image set more than once during platform +// pulls; the second pass calls AddImage with the same image again. Before the +// idempotency guard each call appended a new descriptor to index.json. +// +// We assert here that calling AddImage twice with the same (tag, image) +// produces exactly one descriptor, so the layout itself can no longer host +// the duplicate. +func TestAddImage_IdempotentForSameTagAndDigest(t *testing.T) { + l, err := regimage.NewImageLayout(t.TempDir()) + require.NoError(t, err) + + v1img := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"v1.73.2"}`). + MustBuild() + + const tag = "v1.73.2" + + require.NoError(t, l.AddImage(wrapImage(t, v1img, "example.io/repo:"+tag), tag), + "first AddImage must succeed") + require.NoError(t, l.AddImage(wrapImage(t, v1img, "example.io/repo:"+tag), tag), + "second AddImage with same tag+digest must be a no-op, not an error") + + assert.Equal(t, 1, indexDescriptorCount(t, l), + "AddImage must not append a second descriptor for the same (tag, digest)") +} + +// TestAddImage_NewDescriptorForSameTagDifferentDigest pins the deliberate +// fall-through path: when the same tag points to a different image digest, +// AddImage still appends a new descriptor and updates the in-memory metadata +// to the latest one. This preserves "re-tag" semantics that callers may rely +// on; the pusher then deduplicates by short_tag with last-wins semantics. +func TestAddImage_NewDescriptorForSameTagDifferentDigest(t *testing.T) { + l, err := regimage.NewImageLayout(t.TempDir()) + require.NoError(t, err) + + const tag = "v1.73.2" + + imgA := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"v1.73.2","build":"A"}`). + MustBuild() + imgB := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"v1.73.2","build":"B"}`). + MustBuild() + + digestA, err := imgA.Digest() + require.NoError(t, err) + digestB, err := imgB.Digest() + require.NoError(t, err) + require.NotEqual(t, digestA.String(), digestB.String(), + "the two builder outputs must differ so we are actually exercising the conflict path") + + require.NoError(t, l.AddImage(wrapImage(t, imgA, "example.io/repo:"+tag), tag)) + require.NoError(t, l.AddImage(wrapImage(t, imgB, "example.io/repo:"+tag), tag)) + + assert.Equal(t, 2, indexDescriptorCount(t, l), + "different digests under the same tag must remain visible in the index") + + meta, err := l.GetMeta(tag) + require.NoError(t, err) + require.NotNil(t, meta.GetDigest()) + assert.Equal(t, digestB.String(), meta.GetDigest().String(), + "in-memory metadata for the tag must reflect the latest AddImage call") +} diff --git a/pkg/registry/service/service.go b/pkg/registry/service/service.go index f9d34be4..9e91f39e 100644 --- a/pkg/registry/service/service.go +++ b/pkg/registry/service/service.go @@ -36,6 +36,10 @@ const ( // Service provides high-level registry operations using a registry client type Service struct { client client.Client + // editionBase is the client scoped to the edition sub-path (or equal to + // client when no edition is configured). Services that live under the + // edition path (deckhouse, modules, security) are built on top of it. + editionBase client.Client modulesService *ModulesService pluginService *PluginService @@ -61,6 +65,7 @@ func NewService(c client.Client, edition pkg.Edition, logger *log.Logger) *Servi } else { base = c.WithSegment(edition.String()) } + s.editionBase = base s.modulesService = NewModulesService(base.WithSegment(moduleSegment), logger.Named("modules")) s.deckhouseService = NewDeckhouseService(base, logger.Named("deckhouse")) @@ -73,11 +78,21 @@ func NewService(c client.Client, edition pkg.Edition, logger *log.Logger) *Servi return s } -// GetRoot gets path of the registry root +// GetRoot gets path of the registry root, without the edition segment. +// This is the non-edition-scoped root (e.g. "registry.deckhouse.ru/deckhouse") +// used for services that live outside the edition sub-tree (installer, plugins). func (s *Service) GetRoot() string { return s.client.GetRegistry() } +// GetEditionRoot returns the registry root scoped to the edition sub-path +// (e.g. "registry.deckhouse.ru/deckhouse/fe"). When no edition is configured +// the result equals GetRoot(). Use this when building references for services +// that live under the edition sub-tree (deckhouse, modules, security). +func (s *Service) GetEditionRoot() string { + return s.editionBase.GetRegistry() +} + // ModuleService returns the module service func (s *Service) ModuleService() *ModulesService { return s.modulesService diff --git a/pkg/registry/service/service_test.go b/pkg/registry/service/service_test.go new file mode 100644 index 00000000..b48adbe6 --- /dev/null +++ b/pkg/registry/service/service_test.go @@ -0,0 +1,196 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/pkg" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// TestService_GetRoot_GetEditionRoot pins down the contract of the two root +// accessors on the registry Service: +// +// - GetRoot() — bare host + base path (no edition segment). +// - GetEditionRoot() — host + base path + edition segment. +// +// Callers building references for services that live under the edition +// sub-tree (deckhouse, modules, security) must use GetEditionRoot so that +// the resulting URLs match the actual repository scope. Mismatched roots +// were the source of duplicate /release-channel: pulls +// observed for `d8 mirror pull` against FE/EE sources. +// +// The table covers every valid Edition value plus NoEdition so any future +// edition added to pkg.Edition without updating Service.NewService is caught +// by an explicit failure here. +func TestService_GetRoot_GetEditionRoot(t *testing.T) { + logger := log.NewNop() + + const host = "registry.deckhouse.ru/deckhouse" + + tests := []struct { + name string + host string + edition pkg.Edition + expectedRoot string + expectedEditionRoot string + }{ + { + name: "no edition leaves both roots equal", + host: "registry.example.com/deckhouse", + edition: pkg.NoEdition, + expectedRoot: "registry.example.com/deckhouse", + expectedEditionRoot: "registry.example.com/deckhouse", + }, + { + name: "FE edition appends fe segment only to edition root", + host: host, + edition: pkg.FEEdition, + expectedRoot: host, + expectedEditionRoot: host + "/fe", + }, + { + name: "EE edition appends ee segment only to edition root", + host: host, + edition: pkg.EEEdition, + expectedRoot: host, + expectedEditionRoot: host + "/ee", + }, + { + name: "SE edition appends se segment only to edition root", + host: host, + edition: pkg.SEEdition, + expectedRoot: host, + expectedEditionRoot: host + "/se", + }, + { + name: "SE-Plus edition appends se-plus segment only to edition root", + host: host, + edition: pkg.SEPlusEdition, + expectedRoot: host, + expectedEditionRoot: host + "/se-plus", + }, + { + name: "BE edition appends be segment only to edition root", + host: host, + edition: pkg.BEEdition, + expectedRoot: host, + expectedEditionRoot: host + "/be", + }, + { + name: "CE edition appends ce segment only to edition root", + host: host, + edition: pkg.CEEdition, + expectedRoot: host, + expectedEditionRoot: host + "/ce", + }, + { + name: "host with trailing slash is normalised by underlying client", + host: "registry.example.com/deckhouse/", + edition: pkg.FEEdition, + expectedRoot: "registry.example.com/deckhouse", + expectedEditionRoot: "registry.example.com/deckhouse/fe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use the real upstream client (not the upfake one): we only inspect + // GetRegistry() values here, no network is touched. The upfake client + // intentionally exposes only the host via GetRegistry and would mask + // the segment composition this test pins down. + c := pkgclient.NewFromOptions(tt.host) + + svc := registryservice.NewService(c, tt.edition, logger) + + assert.Equal(t, tt.expectedRoot, svc.GetRoot(), + "GetRoot must return the non-edition-scoped root") + assert.Equal(t, tt.expectedEditionRoot, svc.GetEditionRoot(), + "GetEditionRoot must return the edition-scoped root") + }) + } +} + +// TestService_GetEditionRoot_AlignsWithDeckhouseService asserts that the +// edition root exposed by Service matches the root the DeckhouseService is +// scoped to. They must agree, otherwise the platform downloadList keys (built +// from GetEditionRoot) would not line up with the repository scope used to +// resolve digests through DeckhouseService.ReleaseChannels(). +func TestService_GetEditionRoot_AlignsWithDeckhouseService(t *testing.T) { + logger := log.NewNop() + + c := pkgclient.NewFromOptions("registry.deckhouse.ru/deckhouse") + + svc := registryservice.NewService(c, pkg.FEEdition, logger) + + assert.Equal(t, + svc.DeckhouseService().GetRoot(), + svc.GetEditionRoot(), + "DeckhouseService root must equal Service.GetEditionRoot — they share the edition base") +} + +// TestService_SubServiceScoping locks the scope each sub-service is built on: +// +// - deckhouse, modules, security live UNDER the edition segment. +// - plugins and installer live OUTSIDE the edition segment. +// +// A regression in either direction silently routes pulls to the wrong path, +// which is exactly the class of bug that surfaced as duplicate +// /release-channel: pulls for FE sources. +func TestService_SubServiceScoping(t *testing.T) { + logger := log.NewNop() + + const host = "registry.deckhouse.ru/deckhouse" + c := pkgclient.NewFromOptions(host) + svc := registryservice.NewService(c, pkg.FEEdition, logger) + + t.Run("DeckhouseService is edition-scoped", func(t *testing.T) { + assert.Equal(t, host+"/fe", svc.DeckhouseService().GetRoot(), + "DeckhouseService must be scoped to /") + }) + + t.Run("plugin service is NOT edition-scoped", func(t *testing.T) { + // Plugins live at /plugins regardless of edition. We assert it via + // the absence of the edition segment in the plugin service root. + // PluginService does not expose GetRoot, but its scope is reflected by + // Service.GetRoot — Service.GetRoot stays at the bare root so that the + // installer and plugin tree references stay correct. + assert.Equal(t, host, svc.GetRoot(), + "Service.GetRoot must remain non-edition-scoped — plugins and installer rely on it") + }) + + t.Run("no edition keeps both roots equal across all sub-services", func(t *testing.T) { + noEditionSvc := registryservice.NewService( + pkgclient.NewFromOptions(host), + pkg.NoEdition, + logger, + ) + + assert.Equal(t, host, noEditionSvc.GetRoot()) + assert.Equal(t, host, noEditionSvc.GetEditionRoot()) + assert.Equal(t, noEditionSvc.GetRoot(), noEditionSvc.GetEditionRoot(), + "with NoEdition the two roots must coincide") + assert.Equal(t, host, noEditionSvc.DeckhouseService().GetRoot(), + "DeckhouseService root must equal the bare root when no edition is set") + }) +}