From a8ff9b8fe62f31fe78daee70653c75ca420a3963 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Mon, 20 Jul 2026 11:53:47 +0300 Subject: [PATCH 01/19] Plumb --modules-path-suffix into the new push service - `PushServiceOptions.ModulesPathSuffix` carries the flag value from `d8 mirror push` down to the push service. - `modulesSegment()` resolves the registry segment for modules: empty option keeps the default `modules`, surrounding slashes are trimmed. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/push/push.go | 5 +++-- internal/mirror/push.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index bac7993c..045d5cbc 100644 --- a/internal/mirror/cmd/push/push.go +++ b/internal/mirror/cmd/push/push.go @@ -257,8 +257,9 @@ func (p *Pusher) executeNewPush() error { svc := mirror.NewPushService( client, &mirror.PushServiceOptions{ - Packages: Packages, - WorkingDir: p.pushParams.WorkingDir, + Packages: Packages, + WorkingDir: p.pushParams.WorkingDir, + ModulesPathSuffix: p.pushParams.ModulesPathSuffix, }, logger.Named("push"), p.logger.(*log.SLogger), diff --git a/internal/mirror/push.go b/internal/mirror/push.go index 70b16198..a5535aa2 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -50,6 +50,10 @@ type PushServiceOptions struct { Packages []string // WorkingDir is the temporary directory for unpacking bundles WorkingDir string + // ModulesPathSuffix is the registry path suffix to push modules to, + // relative to the target repo. Empty value keeps the default "modules". + // Leading and trailing slashes are ignored. + ModulesPathSuffix string } // PushService handles pushing OCI layouts to registry. @@ -155,6 +159,17 @@ func (svc *PushService) Push(ctx context.Context) error { }) } +// modulesSegment returns the registry path segment for module repositories. +// Derived from ModulesPathSuffix; empty result means modules go to the repo root. +func (svc *PushService) modulesSegment() string { + // Zero value keeps the default: only an explicit "/" means the repo root. + if svc.options.ModulesPathSuffix == "" { + return internal.ModulesSegment + } + + return strings.Trim(svc.options.ModulesPathSuffix, "/") +} + // unpackAllPackages unpacks all tar packages into the unified directory. // All packages are unpacked to the same root - the structure inside each tar // should already have the correct paths. From 2a66d45779ec827a7ea0cb185a03135acd1418a7 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Mon, 20 Jul 2026 14:09:46 +0300 Subject: [PATCH 02/19] Honor --modules-path-suffix when pushing modules Rewrite the leading "modules" registry segment to the configured suffix in both the layout push and the discovery-index step. The bundle layout on disk is untouched, so the remap is registry-only and legacy module-*.tar bundles keep working. Empty suffix ("/") pushes modules to the target repo root. Signed-off-by: Roman Berezkin --- internal/mirror/push.go | 55 ++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/internal/mirror/push.go b/internal/mirror/push.go index a5535aa2..b679872b 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -23,6 +23,7 @@ import ( "io/fs" "log/slog" "os" + "path" "path/filepath" "slices" "strings" @@ -170,6 +171,38 @@ func (svc *PushService) modulesSegment() string { return strings.Trim(svc.options.ModulesPathSuffix, "/") } +// scopeToSegment scopes the client by each non-empty component of a +// slash-separated segment. An empty segment leaves the client at the target repo. +func scopeToSegment(c client.Client, segment string) client.Client { + for _, seg := range strings.Split(segment, "/") { + if seg == "" { + continue + } + + c = c.WithSegment(seg) + } + + return c +} + +// remapModulesSegment rewrites the leading "modules" component of a +// slash-separated registry segment to the configured modules suffix. +// Modules are always stored under "modules/" inside the bundle, so this +// is where --modules-path-suffix takes effect. Segments outside "modules" are +// returned unchanged. +func (svc *PushService) remapModulesSegment(segment string) string { + modulesPrefix := internal.ModulesSegment + "/" + + switch { + case segment == internal.ModulesSegment: + return svc.modulesSegment() + case strings.HasPrefix(segment, modulesPrefix): + return path.Join(svc.modulesSegment(), strings.TrimPrefix(segment, modulesPrefix)) + default: + return segment + } +} + // unpackAllPackages unpacks all tar packages into the unified directory. // All packages are unpacked to the same root - the structure inside each tar // should already have the correct paths. @@ -309,27 +342,24 @@ func (svc *PushService) pushSingleLayout(ctx context.Context, rootDir, layoutDir return nil } - // Build registry segment from relative path + // Build registry segment from relative path. Use slash form so the + // segment is registry-native and OS-independent (filepath.Rel yields + // OS separators on Windows). relPath, _ := filepath.Rel(rootDir, layoutDir) segment := "" if relPath != "." { - segment = relPath + segment = filepath.ToSlash(relPath) } // support old behavior when modules stored as "module-.tar" if strings.HasPrefix(layoutDir, "module-") { segment = internal.ModulesSegment } - // Create client with appropriate segments - targetClient := svc.client + // Rewrite the leading "modules" component to honor --modules-path-suffix. + segment = svc.remapModulesSegment(segment) - if segment != "" { - // Apply each path component as a segment - for _, seg := range strings.Split(segment, string(os.PathSeparator)) { - targetClient = targetClient.WithSegment(seg) - } - } + targetClient := scopeToSegment(svc.client, segment) svc.userLogger.Infof("Pushing %s", targetClient.GetRegistry()) @@ -391,8 +421,9 @@ func (svc *PushService) createModulesIndex(ctx context.Context, rootDir string) slices.Sort(moduleNames) svc.userLogger.Infof("Creating modules index with %d modules", len(moduleNames)) - // Get client scoped to modules repo - modulesClient := svc.client.WithSegment(internal.ModulesSegment) + // Scope the client to the modules repo, honoring --modules-path-suffix. + // An empty suffix places discovery tags directly on the target repo. + modulesClient := scopeToSegment(svc.client, svc.modulesSegment()) // Push a small random image for each module with tag = module name for _, moduleName := range moduleNames { From a102e13182f8b29ea7bd51cc8bb54a46eec801d4 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Mon, 20 Jul 2026 15:59:26 +0300 Subject: [PATCH 03/19] Add tests for mirror push modules path suffix - Cover the four `--modules-path-suffix` cases through `PushService.Push`: default and `/modules` keep `/modules/`, `/` pushes to the repo root, `/my/mods` nests deeper. - Each case checks the discovery index tag follows the modules to the same repo. - A non-default suffix must leave nothing at the default `modules/` path, so modules are moved and not copied. - A non-module `install` layout stays at `/install` for every suffix. Signed-off-by: Roman Berezkin --- internal/mirror/push_test.go | 129 +++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/internal/mirror/push_test.go b/internal/mirror/push_test.go index 2170890b..09072077 100644 --- a/internal/mirror/push_test.go +++ b/internal/mirror/push_test.go @@ -17,10 +17,26 @@ limitations under the License. package mirror import ( + "context" + "log/slog" + "os" + "path" "path/filepath" + "strings" "testing" + "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + client "github.com/deckhouse/deckhouse/pkg/registry" + upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" + + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + regimage "github.com/deckhouse/deckhouse-cli/pkg/registry/image" ) // TestPackageNameFromPath covers the .tar-only contract packageNameFromPath @@ -56,3 +72,116 @@ func TestPackageNameFromPath(t *testing.T) { }) } } + +// buildLayoutBundle writes an OCI layout with one image annotated by +// short_tag, packs it into / under the given tar prefix, and +// returns the archive path. Prefix "modules/" mimics how pull packs a +// module; a bare prefix like "install" mimics a non-module layout. +func buildLayoutBundle(t *testing.T, dir, tarName, prefix, shortTag string) string { + t.Helper() + + layoutDir := t.TempDir() + imgLayout, err := regimage.NewImageLayout(layoutDir) + require.NoError(t, err, "create OCI layout") + + img := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"`+shortTag+`"}`). + MustBuild() + require.NoError(t, imgLayout.Path().AppendImage(img, layout.WithAnnotations(map[string]string{ + regimage.AnnotationImageShortTag: shortTag, + })), "append annotated image") + + tarPath := filepath.Join(dir, tarName) + f, err := os.Create(tarPath) + require.NoError(t, err, "create bundle tar") + defer f.Close() + + require.NoError(t, bundle.PackWithPrefix(context.Background(), layoutDir, prefix, f), "pack bundle tar") + + return tarPath +} + +// scopeToRepo scopes the client to a slash-separated repo path, skipping empty +// components. An empty path leaves the client at the target repo root. +func scopeToRepo(c client.Client, repoPath string) client.Client { + for _, seg := range strings.Split(repoPath, "/") { + if seg == "" { + continue + } + + c = c.WithSegment(seg) + } + + return c +} + +// TestPushService_ModulesPathSuffix verifies that --modules-path-suffix moves +// both module images and their discovery index tag, while non-module layouts +// stay put. The default (empty / "/modules") keeps the historical layout. +func TestPushService_ModulesPathSuffix(t *testing.T) { + const ( + repoHost = "registry.example.com/deckhouse/ee" + moduleName = "test-module" + moduleTag = "v0.0.1" + installTag = "v1.76.2" + ) + + tests := []struct { + name string + suffix string + wantModule string // repo (relative to target) holding module images + wantIndex string // repo (relative to target) holding the discovery tag + }{ + {name: "empty keeps default", suffix: "", wantModule: "modules/" + moduleName, wantIndex: "modules"}, + {name: "explicit default", suffix: "/modules", wantModule: "modules/" + moduleName, wantIndex: "modules"}, + {name: "repo root", suffix: "/", wantModule: moduleName, wantIndex: ""}, + {name: "multi segment", suffix: "/my/mods", wantModule: "my/mods/" + moduleName, wantIndex: "my/mods"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bundleDir := t.TempDir() + modulePkg := buildLayoutBundle(t, bundleDir, "module-"+moduleName+".tar", path.Join("modules", moduleName), moduleTag) + // A non-module layout: it must never be affected by the suffix. + installPkg := buildLayoutBundle(t, bundleDir, "platform.tar", "install", installTag) + + reg := upfake.NewRegistry(repoHost) + destClient := pkgclient.Adapt(upfake.NewClient(reg)) + + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := NewPushService(destClient, &PushServiceOptions{ + Packages: []string{modulePkg, installPkg}, + WorkingDir: t.TempDir(), + ModulesPathSuffix: tt.suffix, + }, logger, userLogger) + require.NoError(t, svc.Push(context.Background()), "push must succeed") + + ctx := context.Background() + + // Module images land at /:. + moduleClient := scopeToRepo(destClient, tt.wantModule) + assert.NoErrorf(t, moduleClient.CheckImageExists(ctx, moduleTag), + "module image must exist at %s:%s", moduleClient.GetRegistry(), moduleTag) + + // Discovery tag lands at /:. + indexClient := scopeToRepo(destClient, tt.wantIndex) + tags, err := indexClient.ListTags(ctx) + require.NoError(t, err) + assert.Containsf(t, tags, moduleName, + "discovery tag %q must exist at %s", moduleName, indexClient.GetRegistry()) + + // A non-default suffix must MOVE modules, not copy them: the default + // modules/ path must hold nothing. + if tt.wantModule != "modules/"+moduleName { + assert.Errorf(t, scopeToRepo(destClient, "modules/"+moduleName).CheckImageExists(ctx, moduleTag), + "module must not remain at default modules/%s", moduleName) + } + + // The non-module layout is unaffected by the suffix. + assert.NoErrorf(t, scopeToRepo(destClient, "install").CheckImageExists(ctx, installTag), + "install layout must stay at /install regardless of suffix") + }) + } +} From 3437039fa4771d08332e7c31a4f313341cca7f35 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Mon, 20 Jul 2026 17:21:20 +0300 Subject: [PATCH 04/19] Fix modules-path-suffix flag help text for push - The push flag and README described it as a source-repo suffix, which is a pull concept; on push it selects the target repo path. - New text says what the flag does and how "/" pushes to the repo root. Signed-off-by: Roman Berezkin --- internal/mirror/README.MD | 2 +- internal/mirror/cmd/push/flags.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index 49b0923a..6e9d64cd 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -346,7 +346,7 @@ d8 mirror push [flags] | Flag | Description | |------|-------------| -| `--modules-path-suffix` | Suffix to append to source repo path to locate modules (default: `/modules`) | +| `--modules-path-suffix` | Registry path suffix to push modules to, relative to the target repo. Use `/` to push modules to the repo root (default: `/modules`) | ### Examples diff --git a/internal/mirror/cmd/push/flags.go b/internal/mirror/cmd/push/flags.go index cebe7de8..a67df6b8 100644 --- a/internal/mirror/cmd/push/flags.go +++ b/internal/mirror/cmd/push/flags.go @@ -61,7 +61,7 @@ func addFlags(flagSet *pflag.FlagSet) { &ModulesPathSuffix, "modules-path-suffix", "/modules", - "Suffix to append to source repo path to locate modules.", + "Registry path suffix to push modules to, relative to the target repo. Use \"/\" to push modules to the repo root.", ) flagSet.StringArrayVar( &Files, From c3a7f92085e662cd75f64cf0c9cb479041036940 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 21 Jul 2026 14:57:23 +0300 Subject: [PATCH 05/19] Make modules path segment configurable in registry service The modules registry segment was hardcoded to "modules" in the service constructor. Add a WithModulesPathSuffix option so callers can point the modules client elsewhere, defaulting to "modules" so existing behavior is unchanged. Expose GetModulesSegment for building matching references. Signed-off-by: Roman Berezkin --- pkg/registry/service/service.go | 62 ++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/pkg/registry/service/service.go b/pkg/registry/service/service.go index 9f4fd9d9..149bd559 100644 --- a/pkg/registry/service/service.go +++ b/pkg/registry/service/service.go @@ -47,14 +47,60 @@ type Service struct { security *SecurityServices installer *InstallerServices + // modulesSegment is the registry path segment where modules live, relative + // to the edition root. Defaults to "modules"; empty means the edition root. + modulesSegment string + logger *log.Logger } +// Option configures a Service at construction time. +type Option func(*Service) + +// WithModulesPathSuffix sets the registry path segment where modules live, +// relative to the edition root. Empty suffix keeps the default "modules"; +// "/" places modules at the edition root. Leading and trailing slashes are ignored. +func WithModulesPathSuffix(suffix string) Option { + return func(s *Service) { + s.modulesSegment = normalizeModulesSegment(suffix) + } +} + +// normalizeModulesSegment resolves the modules path segment from a suffix flag +// value. Empty value keeps the default; surrounding slashes are trimmed so "/" +// yields an empty segment (modules at the edition root). +func normalizeModulesSegment(suffix string) string { + if suffix == "" { + return moduleSegment + } + + return strings.Trim(suffix, "/") +} + +// scopeSegment scopes the client by each non-empty component of a +// slash-separated segment. An empty segment leaves the client unchanged. +func scopeSegment(c client.Client, segment string) client.Client { + for _, seg := range strings.Split(segment, "/") { + if seg == "" { + continue + } + + c = c.WithSegment(seg) + } + + return c +} + // NewService creates a new registry service with the given client and logger -func NewService(c client.Client, edition pkg.Edition, logger *log.Logger) *Service { +func NewService(c client.Client, edition pkg.Edition, logger *log.Logger, opts ...Option) *Service { s := &Service{ - client: c, - logger: logger, + client: c, + logger: logger, + modulesSegment: moduleSegment, + } + + for _, opt := range opts { + opt(s) } // base is scoped to the edition sub-path when an edition is specified. @@ -68,7 +114,7 @@ func NewService(c client.Client, edition pkg.Edition, logger *log.Logger) *Servi s.editionBase = base - s.modulesService = NewModulesService(base.WithSegment(moduleSegment), logger.Named("modules")) + s.modulesService = NewModulesService(scopeSegment(base, s.modulesSegment), logger.Named("modules")) s.packagesService = NewPackagesService(base.WithSegment(packageSegment), logger.Named("packages")) s.deckhouseService = NewDeckhouseService(base, logger.Named("deckhouse")) s.security = NewSecurityServices(securityServiceName, base.WithSegment(securitySegment), logger.Named("security")) @@ -99,6 +145,14 @@ func (s *Service) ModuleService() *ModulesService { return s.modulesService } +// GetModulesSegment returns the registry path segment where modules live, +// relative to the edition root (default "modules", empty when modules are at +// the edition root). Callers building module references must use it so their +// paths match the scope of ModuleService. +func (s *Service) GetModulesSegment() string { + return s.modulesSegment +} + // PackageService returns the packages service func (s *Service) PackageService() *PackagesService { return s.packagesService From 5e3f0aa9d82174b69fb03c0ce4db5ffefdce201b Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 21 Jul 2026 15:39:41 +0300 Subject: [PATCH 06/19] Honor modules path suffix in pull module references Route every source-registry module reference through moduleRegistryPath, which joins the configurable modules segment from the registry service. Discovery-index scope and these references now share one source of truth, so a non-default --modules-path-suffix points them at the same place. The bundle-internal "modules" tar layout is left untouched. Signed-off-by: Roman Berezkin --- internal/mirror/modules/modules.go | 61 ++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/internal/mirror/modules/modules.go b/internal/mirror/modules/modules.go index aa57ee2c..cd36f5ba 100644 --- a/internal/mirror/modules/modules.go +++ b/internal/mirror/modules/modules.go @@ -109,9 +109,14 @@ type Service struct { // See modulePullStat for what each field holds and when it is filled. moduleStats map[moduleName]modulePullStat - // rootURL is the base registry URL for modules images + // rootURL is the edition root registry URL (without the modules segment). rootURL string + // modulesSegment is the registry path segment where modules live, relative + // to rootURL. Matches the scope of modulesService (default "modules", empty + // when modules are at the edition root). Set from --modules-path-suffix. + modulesSegment string + // logger is for internal debug logging logger *dkplog.Logger // userLogger is for user-facing informational messages @@ -138,10 +143,9 @@ func NewService( } // 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. + // per-module references like //: resolve + // to the same path served by registryService.ModuleService(). registryService.GetRoot() + // would return the non-edition root and mismatch the actual ModulesService scope. rootURL := registryService.GetEditionRoot() return &Service{ @@ -151,12 +155,37 @@ func NewService( pullerService: puller.NewPullerService(logger, userLogger), options: options, rootURL: rootURL, + modulesSegment: registryService.GetModulesSegment(), moduleStats: make(map[moduleName]modulePullStat), logger: logger, userLogger: userLogger, } } +// moduleRegistryPath builds a source-registry reference under the modules path +// suffix, joining rootURL, the modules segment and elem with "/". An empty +// modules segment (--modules-path-suffix "/") places modules at the edition +// root. Elements are path components; append ":" to the result separately. +// +// With rootURL "registry.example.com/deckhouse/ee": +// - segment "modules" (default): +// moduleRegistryPath("csi") -> ".../deckhouse/ee/modules/csi" +// moduleRegistryPath("csi", "release") -> ".../deckhouse/ee/modules/csi/release" +// - segment "" (suffix "/"): +// moduleRegistryPath("csi") -> ".../deckhouse/ee/csi" +func (svc *Service) moduleRegistryPath(elem ...string) string { + parts := make([]string, 0, len(elem)+2) + parts = append(parts, svc.rootURL) + + if svc.modulesSegment != "" { + parts = append(parts, svc.modulesSegment) + } + + parts = append(parts, elem...) + + return strings.Join(parts, "/") +} + // PullModules pulls the Deckhouse modules // It validates access to the registry and pulls the module images func (svc *Service) PullModules(ctx context.Context) error { @@ -237,7 +266,7 @@ func (svc *Service) pullModules(ctx context.Context) error { for _, moduleName := range moduleNames { mod := &Module{ Name: moduleName, - RegistryPath: filepath.Join(svc.rootURL, "modules", moduleName), + RegistryPath: svc.moduleRegistryPath(moduleName), } if svc.options.Filter.Match(mod) { filteredModules = append(filteredModules, moduleData{ @@ -365,7 +394,7 @@ func isContextErr(err error) bool { } func (svc *Service) pullSingleModule(ctx context.Context, module moduleData) error { - downloadList := NewImageDownloadList(filepath.Join(svc.rootURL, "modules", module.name)) + downloadList := NewImageDownloadList(svc.moduleRegistryPath(module.name)) svc.modulesDownloadList.list[module.name] = downloadList channelVersions, err := svc.discoverChannelVersions(ctx, module.name, downloadList) @@ -394,7 +423,7 @@ func (svc *Service) pullSingleModule(ctx context.Context, module moduleData) err // are not resolved in dry-run (they require a real pull), so the // per-module count stays "release channels + versions". for _, version := range moduleVersions { - downloadList.Module[svc.rootURL+"/modules/"+module.name+":"+version] = nil + downloadList.Module[svc.moduleRegistryPath(module.name)+":"+version] = nil } return nil @@ -429,12 +458,12 @@ func (svc *Service) discoverChannelVersions(ctx context.Context, moduleName stri } for _, channel := range internal.GetAllDefaultReleaseChannels() { - downloadList.ModuleReleaseChannels[svc.rootURL+"/modules/"+moduleName+"/release:"+channel] = nil + downloadList.ModuleReleaseChannels[svc.moduleRegistryPath(moduleName, "release")+":"+channel] = nil } // Add LTS channel if it exists if err := svc.modulesService.Module(moduleName).ReleaseChannels().CheckImageExists(ctx, internal.LTSChannel); err == nil { - downloadList.ModuleReleaseChannels[svc.rootURL+"/modules/"+moduleName+"/release:"+internal.LTSChannel] = nil + downloadList.ModuleReleaseChannels[svc.moduleRegistryPath(moduleName, "release")+":"+internal.LTSChannel] = nil } if !svc.options.DryRun { @@ -597,7 +626,7 @@ func (svc *Service) printDryRunPlan(moduleName string, downloadList *ImageDownlo } for _, version := range versions { - svc.userLogger.InfoLn(" " + svc.rootURL + "/modules/" + moduleName + ":" + version) + svc.userLogger.InfoLn(" " + svc.moduleRegistryPath(moduleName) + ":" + version) } if len(versions) > 0 { @@ -614,7 +643,7 @@ func (svc *Service) pullModuleImages(ctx context.Context, moduleName string, ver } for _, version := range versions { - downloadList.Module[svc.rootURL+"/modules/"+moduleName+":"+version] = nil + downloadList.Module[svc.moduleRegistryPath(moduleName)+":"+version] = nil } config := puller.PullConfig{ @@ -642,8 +671,8 @@ func (svc *Service) pullReleaseVersionImages(ctx context.Context, moduleName str releaseVersionSet := make(map[string]*puller.ImageMeta) for _, version := range versions { - releaseVersionSet[svc.rootURL+"/modules/"+moduleName+"/release:"+version] = nil - downloadList.ModuleReleaseChannels[svc.rootURL+"/modules/"+moduleName+"/release:"+version] = nil + releaseVersionSet[svc.moduleRegistryPath(moduleName, "release")+":"+version] = nil + downloadList.ModuleReleaseChannels[svc.moduleRegistryPath(moduleName, "release")+":"+version] = nil } config := puller.PullConfig{ @@ -921,7 +950,7 @@ func (svc *Service) findExtraImages(ctx context.Context, moduleName string, vers } // Extra images go under: modules//extra/: - fullImagePath := svc.rootURL + "/modules/" + moduleName + "/extra/" + imageName + ":" + imageTag + fullImagePath := svc.moduleRegistryPath(moduleName, "extra", imageName) + ":" + imageTag extraImages[imageName] = append(extraImages[imageName], extraImageInfo{ Name: imageName, @@ -970,7 +999,7 @@ func (svc *Service) extractInternalDigestImages(ctx context.Context, moduleName var digestRefs []string - moduleRepo := svc.rootURL + "/modules/" + moduleName + moduleRepo := svc.moduleRegistryPath(moduleName) for _, version := range versions { // Skip digest references From 65d28a1077247d6e2830379984412786ee20c3ae Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 21 Jul 2026 15:42:20 +0300 Subject: [PATCH 07/19] Wire --modules-path-suffix into mirror pull Pass the pull flag into the registry service so module discovery and image pulls read from the configured source path. The flag was parsed but never reached discovery, which hardcoded the "modules" segment. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/pull/pull.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index 332313fe..cb0080a7 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -384,7 +384,9 @@ func (p *Puller) buildPullService() (*mirror.PullService, error) { } svc := mirror.NewPullService( - registryservice.NewService(c, edition, logger), + registryservice.NewService(c, edition, logger, + registryservice.WithModulesPathSuffix(p.params.ModulesPathSuffix), + ), pullflags.TempDir, pullflags.DeckhouseTag, &mirror.PullServiceOptions{ From 0c099d150b50a746587a5fb9f3c3352e7c9a3c7c Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 21 Jul 2026 15:59:43 +0300 Subject: [PATCH 08/19] Add pull suffix tests Signed-off-by: Roman Berezkin --- internal/mirror/modules/pull_suffix_test.go | 89 +++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 internal/mirror/modules/pull_suffix_test.go diff --git a/internal/mirror/modules/pull_suffix_test.go b/internal/mirror/modules/pull_suffix_test.go new file mode 100644 index 00000000..43e7d66e --- /dev/null +++ b/internal/mirror/modules/pull_suffix_test.go @@ -0,0 +1,89 @@ +/* +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 ( + "context" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" + + "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" +) + +// TestService_ModulesPathSuffix verifies that --modules-path-suffix moves the +// source location where pull discovers modules and builds their references. +// The default ("/modules") keeps the historical /modules layout. +func TestService_ModulesPathSuffix(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + const repoHost = "registry.example.com/deckhouse/ee" + + tests := []struct { + name string + // suffix is the --modules-path-suffix flag value. + suffix string + // sourceRepo is where modules must live in the source registry for that + // suffix (relative to repoHost). Empty means the repo root. + sourceRepo string + }{ + {name: "default", suffix: "/modules", sourceRepo: "modules"}, + {name: "empty keeps default", suffix: "", sourceRepo: "modules"}, + {name: "repo root", suffix: "/", sourceRepo: ""}, + {name: "multi segment", suffix: "/custom/mods", sourceRepo: "custom/mods"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Seed the source registry with two module discovery tags at the + // suffix-derived location and nowhere else. + reg := upfake.NewRegistry(repoHost) + placeholder := upfake.NewImageBuilder().MustBuild() + reg.MustAddImage(tt.sourceRepo, "console", placeholder) + reg.MustAddImage(tt.sourceRepo, "ingress-nginx", placeholder) + + client := pkgclient.Adapt(upfake.NewClient(reg)) + regSvc := registryservice.NewService(client, pkg.NoEdition, logger, + registryservice.WithModulesPathSuffix(tt.suffix)) + svc := NewService(regSvc, t.TempDir(), &Options{}, logger, userLogger) + + // Discovery reads from the suffix-derived repo. If the suffix were + // ignored, a non-default case would list the empty "modules" repo + // and find nothing. + names, err := svc.discoverModuleNames(context.Background()) + require.NoError(t, err) + require.ElementsMatch(t, []string{"console", "ingress-nginx"}, names) + + // References honor the suffix too, so they match the discovery scope. + wantRoot := repoHost + if tt.sourceRepo != "" { + wantRoot += "/" + tt.sourceRepo + } + + require.Equal(t, wantRoot+"/console", svc.moduleRegistryPath("console")) + require.Equal(t, wantRoot+"/console/release", svc.moduleRegistryPath("console", "release")) + }) + } +} From cf92a2b56ae91314a0235d4e4e51815ed42fe5f3 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 21 Jul 2026 16:36:45 +0300 Subject: [PATCH 09/19] Document --modules-path-suffix on pull for read side Mirror the push help wording on the read side and note that "/" reads modules from the source repo root, so the round-trip flag is symmetric and discoverable. Update the pull flag help and the README table. Signed-off-by: Roman Berezkin --- internal/mirror/README.MD | 2 +- internal/mirror/cmd/pull/flags/flags.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index 6e9d64cd..0ca72ff7 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -62,7 +62,7 @@ d8 mirror pull [flags] |------|-------|-------------| | `--include-module` | `-i` | Whitelist specific modules. Use one flag per module. Disables `--exclude-module`. See [Module Filtering](#module-filtering) for format details | | `--exclude-module` | `-e` | Blacklist specific modules. Format: `module-name[@version]`. Use one flag per module. Overridden by `--include-module` | -| `--modules-path-suffix` | | Suffix to append to source repo path to locate modules (default: `/modules`) | +| `--modules-path-suffix` | | Registry path suffix to read modules from, relative to the source repo. Use `/` to read modules from the repo root (default: `/modules`) | #### Component Selection diff --git a/internal/mirror/cmd/pull/flags/flags.go b/internal/mirror/cmd/pull/flags/flags.go index ad581dbf..9d4eb2da 100644 --- a/internal/mirror/cmd/pull/flags/flags.go +++ b/internal/mirror/cmd/pull/flags/flags.go @@ -208,7 +208,7 @@ module-name@=v1.3.0+stable → exact tag match: include only v1.3.0 and and publ &ModulesPathSuffix, "modules-path-suffix", "/modules", - "Suffix to append to source repo path to locate modules.", + "Registry path suffix to read modules from, relative to the source repo. Use \"/\" to read modules from the repo root.", ) flagSet.StringArrayVar( &PackagesWhitelist, From 5a4acfeee0676b9dabfc343702e9e23a5414e913 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 21 Jul 2026 16:49:40 +0300 Subject: [PATCH 10/19] Remove dead modules-access check with stale suffix model validateModulesAccess was never called by Execute and encoded the old suffix semantics (suffix joined to the non-edition repo), which the fix replaced. Delete it, its test, and the now-unused path import. Clarify that the modules suffix is applied in NewService, not the RegistryPath scoping above it. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/pull/pull.go | 18 ++---------------- internal/mirror/cmd/pull/pull_test.go | 25 ------------------------- 2 files changed, 2 insertions(+), 41 deletions(-) diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index cb0080a7..f1f2b3ba 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -24,7 +24,6 @@ import ( "log/slog" "os" "os/signal" - "path" "path/filepath" "sort" "strings" @@ -366,7 +365,8 @@ func (p *Puller) buildPullService() (*mirror.PullService, error) { edition = pkg.NoEdition } - // Scope to the registry path and modules suffix + // Scope to the registry path. The modules path suffix is applied in + // NewService below, via WithModulesPathSuffix. if p.params.RegistryPath != "" { c = c.WithSegment(p.params.RegistryPath) } @@ -458,20 +458,6 @@ func (p *Puller) validatePlatformAccess() error { return nil } -// validateModulesAccess validates access to the modules registry -func (p *Puller) validateModulesAccess() error { - modulesRepo := path.Join(p.params.DeckhouseRegistryRepo, p.params.ModulesPathSuffix) - - ctx, cancel := context.WithTimeout(p.cmd.Context(), 15*time.Second) - defer cancel() - - if err := p.accessValidator.ValidateListAccessForRepo(ctx, modulesRepo, p.validationOpts...); err != nil { - return fmt.Errorf("Source registry is not accessible: %w", err) - } - - return nil -} - // createModuleFilter creates the appropriate module filter based on whitelist/blacklist func (p *Puller) createModuleFilter() (*modules.Filter, error) { // Flags are mutually exclusive: diff --git a/internal/mirror/cmd/pull/pull_test.go b/internal/mirror/cmd/pull/pull_test.go index 1d74049c..8c7be955 100644 --- a/internal/mirror/cmd/pull/pull_test.go +++ b/internal/mirror/cmd/pull/pull_test.go @@ -1091,31 +1091,6 @@ func TestPullerValidatePlatformAccess(t *testing.T) { assert.Contains(t, err.Error(), "Source registry is not accessible") } -func TestPullerValidateModulesAccess(t *testing.T) { - closedAddr := closedLocalAddr(t) - - accessValidator := validation.NewRemoteRegistryAccessValidator() - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - - puller := &Puller{ - cmd: cmd, - params: ¶ms.PullParams{ - BaseParams: params.BaseParams{ - DeckhouseRegistryRepo: closedAddr + "/deckhouse/ee", - ModulesPathSuffix: "/modules", - }, - }, - accessValidator: accessValidator, - validationOpts: []validation.Option{validation.WithInsecure(true)}, - } - - err := puller.validateModulesAccess() - assert.Error(t, err) - assert.Contains(t, err.Error(), "Source registry is not accessible") -} - func TestPullerCreateModuleFilter(t *testing.T) { // Save original global variables originalWhitelist := pullflags.ModulesWhitelist From a1cb8f67bf565a8e29c5b1d61f2d91695ebcf7e5 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Tue, 21 Jul 2026 18:11:28 +0300 Subject: [PATCH 11/19] Extract shared path helper and rename modules path The scope-by-path loop was copied into push, the registry service, and a test; suffix normalization was duplicated across push and the service. Consolidate the split into pkgclient.PathToSegments (fed into the variadic WithSegment) and export registryservice.NormalizeModulesPath, so push and pull cannot drift on what a suffix means. Rename the resolved modules location from "...Segment" to "...Path" (modulesPath, GetModulesPath, NormalizeModulesPath): it holds a possibly multi-segment path like "my/mods", not a single segment, and is distinct from the raw ModulesPathSuffix flag value. Signed-off-by: Roman Berezkin --- internal/mirror/modules/modules.go | 27 +++++++------- internal/mirror/push.go | 40 ++++++-------------- internal/mirror/push_test.go | 26 +++---------- pkg/registry/client/client.go | 24 ++++++++++++ pkg/registry/service/service.go | 60 +++++++++++++----------------- 5 files changed, 81 insertions(+), 96 deletions(-) diff --git a/internal/mirror/modules/modules.go b/internal/mirror/modules/modules.go index cd36f5ba..66fb1706 100644 --- a/internal/mirror/modules/modules.go +++ b/internal/mirror/modules/modules.go @@ -112,10 +112,11 @@ type Service struct { // rootURL is the edition root registry URL (without the modules segment). rootURL string - // modulesSegment is the registry path segment where modules live, relative - // to rootURL. Matches the scope of modulesService (default "modules", empty - // when modules are at the edition root). Set from --modules-path-suffix. - modulesSegment string + // modulesPath is the registry path where modules live, relative to rootURL. + // Matches the scope of modulesService (default "modules", empty when modules + // are at the edition root, may be multi-segment like "my/mods"). Set from + // --modules-path-suffix. + modulesPath string // logger is for internal debug logging logger *dkplog.Logger @@ -155,30 +156,30 @@ func NewService( pullerService: puller.NewPullerService(logger, userLogger), options: options, rootURL: rootURL, - modulesSegment: registryService.GetModulesSegment(), + modulesPath: registryService.GetModulesPath(), moduleStats: make(map[moduleName]modulePullStat), logger: logger, userLogger: userLogger, } } -// moduleRegistryPath builds a source-registry reference under the modules path -// suffix, joining rootURL, the modules segment and elem with "/". An empty -// modules segment (--modules-path-suffix "/") places modules at the edition -// root. Elements are path components; append ":" to the result separately. +// moduleRegistryPath builds a source-registry reference under the modules path, +// joining rootURL, modulesPath and elem with "/". An empty modulesPath +// (--modules-path-suffix "/") places modules at the edition root. Elements are +// path components; append ":" to the result separately. // // With rootURL "registry.example.com/deckhouse/ee": -// - segment "modules" (default): +// - modulesPath "modules" (default): // moduleRegistryPath("csi") -> ".../deckhouse/ee/modules/csi" // moduleRegistryPath("csi", "release") -> ".../deckhouse/ee/modules/csi/release" -// - segment "" (suffix "/"): +// - modulesPath "" (suffix "/"): // moduleRegistryPath("csi") -> ".../deckhouse/ee/csi" func (svc *Service) moduleRegistryPath(elem ...string) string { parts := make([]string, 0, len(elem)+2) parts = append(parts, svc.rootURL) - if svc.modulesSegment != "" { - parts = append(parts, svc.modulesSegment) + if svc.modulesPath != "" { + parts = append(parts, svc.modulesPath) } parts = append(parts, elem...) diff --git a/internal/mirror/push.go b/internal/mirror/push.go index b679872b..af54a879 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -39,6 +39,8 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/mirror/pusher" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" "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" ) const ( @@ -160,33 +162,15 @@ func (svc *PushService) Push(ctx context.Context) error { }) } -// modulesSegment returns the registry path segment for module repositories. -// Derived from ModulesPathSuffix; empty result means modules go to the repo root. -func (svc *PushService) modulesSegment() string { - // Zero value keeps the default: only an explicit "/" means the repo root. - if svc.options.ModulesPathSuffix == "" { - return internal.ModulesSegment - } - - return strings.Trim(svc.options.ModulesPathSuffix, "/") -} - -// scopeToSegment scopes the client by each non-empty component of a -// slash-separated segment. An empty segment leaves the client at the target repo. -func scopeToSegment(c client.Client, segment string) client.Client { - for _, seg := range strings.Split(segment, "/") { - if seg == "" { - continue - } - - c = c.WithSegment(seg) - } - - return c +// modulesPath returns the registry path for module repositories, relative to +// the target repo. Derived from ModulesPathSuffix; empty result means modules +// go to the repo root. +func (svc *PushService) modulesPath() string { + return registryservice.NormalizeModulesPath(svc.options.ModulesPathSuffix) } // remapModulesSegment rewrites the leading "modules" component of a -// slash-separated registry segment to the configured modules suffix. +// slash-separated registry segment to the configured modules path. // Modules are always stored under "modules/" inside the bundle, so this // is where --modules-path-suffix takes effect. Segments outside "modules" are // returned unchanged. @@ -195,9 +179,9 @@ func (svc *PushService) remapModulesSegment(segment string) string { switch { case segment == internal.ModulesSegment: - return svc.modulesSegment() + return svc.modulesPath() case strings.HasPrefix(segment, modulesPrefix): - return path.Join(svc.modulesSegment(), strings.TrimPrefix(segment, modulesPrefix)) + return path.Join(svc.modulesPath(), strings.TrimPrefix(segment, modulesPrefix)) default: return segment } @@ -359,7 +343,7 @@ func (svc *PushService) pushSingleLayout(ctx context.Context, rootDir, layoutDir // Rewrite the leading "modules" component to honor --modules-path-suffix. segment = svc.remapModulesSegment(segment) - targetClient := scopeToSegment(svc.client, segment) + targetClient := svc.client.WithSegment(pkgclient.PathToSegments(segment)...) svc.userLogger.Infof("Pushing %s", targetClient.GetRegistry()) @@ -423,7 +407,7 @@ func (svc *PushService) createModulesIndex(ctx context.Context, rootDir string) // Scope the client to the modules repo, honoring --modules-path-suffix. // An empty suffix places discovery tags directly on the target repo. - modulesClient := scopeToSegment(svc.client, svc.modulesSegment()) + modulesClient := svc.client.WithSegment(pkgclient.PathToSegments(svc.modulesPath())...) // Push a small random image for each module with tag = module name for _, moduleName := range moduleNames { diff --git a/internal/mirror/push_test.go b/internal/mirror/push_test.go index 09072077..8b6a5655 100644 --- a/internal/mirror/push_test.go +++ b/internal/mirror/push_test.go @@ -22,7 +22,6 @@ import ( "os" "path" "path/filepath" - "strings" "testing" "github.com/google/go-containerregistry/pkg/v1/layout" @@ -30,7 +29,6 @@ import ( "github.com/stretchr/testify/require" dkplog "github.com/deckhouse/deckhouse/pkg/log" - client "github.com/deckhouse/deckhouse/pkg/registry" upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" @@ -101,20 +99,6 @@ func buildLayoutBundle(t *testing.T, dir, tarName, prefix, shortTag string) stri return tarPath } -// scopeToRepo scopes the client to a slash-separated repo path, skipping empty -// components. An empty path leaves the client at the target repo root. -func scopeToRepo(c client.Client, repoPath string) client.Client { - for _, seg := range strings.Split(repoPath, "/") { - if seg == "" { - continue - } - - c = c.WithSegment(seg) - } - - return c -} - // TestPushService_ModulesPathSuffix verifies that --modules-path-suffix moves // both module images and their discovery index tag, while non-module layouts // stay put. The default (empty / "/modules") keeps the historical layout. @@ -161,12 +145,12 @@ func TestPushService_ModulesPathSuffix(t *testing.T) { ctx := context.Background() // Module images land at /:. - moduleClient := scopeToRepo(destClient, tt.wantModule) + moduleClient := destClient.WithSegment(pkgclient.PathToSegments(tt.wantModule)...) assert.NoErrorf(t, moduleClient.CheckImageExists(ctx, moduleTag), "module image must exist at %s:%s", moduleClient.GetRegistry(), moduleTag) // Discovery tag lands at /:. - indexClient := scopeToRepo(destClient, tt.wantIndex) + indexClient := destClient.WithSegment(pkgclient.PathToSegments(tt.wantIndex)...) tags, err := indexClient.ListTags(ctx) require.NoError(t, err) assert.Containsf(t, tags, moduleName, @@ -175,12 +159,14 @@ func TestPushService_ModulesPathSuffix(t *testing.T) { // A non-default suffix must MOVE modules, not copy them: the default // modules/ path must hold nothing. if tt.wantModule != "modules/"+moduleName { - assert.Errorf(t, scopeToRepo(destClient, "modules/"+moduleName).CheckImageExists(ctx, moduleTag), + defaultRepo := destClient.WithSegment(pkgclient.PathToSegments("modules/"+moduleName)...) + assert.Errorf(t, defaultRepo.CheckImageExists(ctx, moduleTag), "module must not remain at default modules/%s", moduleName) } // The non-module layout is unaffected by the suffix. - assert.NoErrorf(t, scopeToRepo(destClient, "install").CheckImageExists(ctx, installTag), + installRepo := destClient.WithSegment(pkgclient.PathToSegments("install")...) + assert.NoErrorf(t, installRepo.CheckImageExists(ctx, installTag), "install layout must stay at /install regardless of suffix") }) } diff --git a/pkg/registry/client/client.go b/pkg/registry/client/client.go index 0518a1fb..be99bc38 100644 --- a/pkg/registry/client/client.go +++ b/pkg/registry/client/client.go @@ -1,10 +1,34 @@ package client import ( + "strings" + dkpreg "github.com/deckhouse/deckhouse/pkg/registry" regclient "github.com/deckhouse/deckhouse/pkg/registry/client" ) +// PathToSegments splits a slash-separated path into its non-empty segments, +// ready to spread into Client.WithSegment (which takes segments one by one and +// does not split slashes). Leading, trailing, and doubled slashes - and the +// empty string - drop out, so they never become a bogus empty segment. +// +// - PathToSegments("modules/csi") -> ["modules", "csi"] +// - PathToSegments("/modules/") -> ["modules"] +// - PathToSegments("") -> [] (WithSegment() then leaves the client unchanged) +func PathToSegments(path string) []string { + segments := make([]string, 0) + + for _, segment := range strings.Split(path, "/") { + if segment == "" { + continue + } + + segments = append(segments, segment) + } + + return segments +} + // adapter wraps the upstream dkpreg.Client interface and makes it satisfy // the local Client interface by overriding WithSegment to return the local type. // All other methods are promoted from the embedded dkpreg.Client. diff --git a/pkg/registry/service/service.go b/pkg/registry/service/service.go index 149bd559..949fc9e5 100644 --- a/pkg/registry/service/service.go +++ b/pkg/registry/service/service.go @@ -23,6 +23,7 @@ import ( client "github.com/deckhouse/deckhouse/pkg/registry" "github.com/deckhouse/deckhouse-cli/pkg" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" ) const ( @@ -47,9 +48,10 @@ type Service struct { security *SecurityServices installer *InstallerServices - // modulesSegment is the registry path segment where modules live, relative - // to the edition root. Defaults to "modules"; empty means the edition root. - modulesSegment string + // modulesPath is the registry path where modules live, relative to the + // edition root. Defaults to "modules"; empty means the edition root. May + // hold several segments (e.g. "my/mods"), so it is a path, not a segment. + modulesPath string logger *log.Logger } @@ -57,19 +59,21 @@ type Service struct { // Option configures a Service at construction time. type Option func(*Service) -// WithModulesPathSuffix sets the registry path segment where modules live, -// relative to the edition root. Empty suffix keeps the default "modules"; -// "/" places modules at the edition root. Leading and trailing slashes are ignored. +// WithModulesPathSuffix sets the registry path where modules live, relative to +// the edition root. Empty suffix keeps the default "modules"; "/" places +// modules at the edition root. Leading and trailing slashes are ignored. func WithModulesPathSuffix(suffix string) Option { return func(s *Service) { - s.modulesSegment = normalizeModulesSegment(suffix) + s.modulesPath = NormalizeModulesPath(suffix) } } -// normalizeModulesSegment resolves the modules path segment from a suffix flag -// value. Empty value keeps the default; surrounding slashes are trimmed so "/" -// yields an empty segment (modules at the edition root). -func normalizeModulesSegment(suffix string) string { +// NormalizeModulesPath resolves the modules registry path from a +// --modules-path-suffix value. Empty value keeps the default "modules"; +// surrounding slashes are trimmed so "/" yields an empty path (modules at the +// edition/repo root). Shared by mirror push and pull so the suffix means the +// same thing on both sides. +func NormalizeModulesPath(suffix string) string { if suffix == "" { return moduleSegment } @@ -77,26 +81,12 @@ func normalizeModulesSegment(suffix string) string { return strings.Trim(suffix, "/") } -// scopeSegment scopes the client by each non-empty component of a -// slash-separated segment. An empty segment leaves the client unchanged. -func scopeSegment(c client.Client, segment string) client.Client { - for _, seg := range strings.Split(segment, "/") { - if seg == "" { - continue - } - - c = c.WithSegment(seg) - } - - return c -} - // NewService creates a new registry service with the given client and logger func NewService(c client.Client, edition pkg.Edition, logger *log.Logger, opts ...Option) *Service { s := &Service{ - client: c, - logger: logger, - modulesSegment: moduleSegment, + client: c, + logger: logger, + modulesPath: moduleSegment, } for _, opt := range opts { @@ -114,7 +104,7 @@ func NewService(c client.Client, edition pkg.Edition, logger *log.Logger, opts . s.editionBase = base - s.modulesService = NewModulesService(scopeSegment(base, s.modulesSegment), logger.Named("modules")) + s.modulesService = NewModulesService(base.WithSegment(pkgclient.PathToSegments(s.modulesPath)...), logger.Named("modules")) s.packagesService = NewPackagesService(base.WithSegment(packageSegment), logger.Named("packages")) s.deckhouseService = NewDeckhouseService(base, logger.Named("deckhouse")) s.security = NewSecurityServices(securityServiceName, base.WithSegment(securitySegment), logger.Named("security")) @@ -145,12 +135,12 @@ func (s *Service) ModuleService() *ModulesService { return s.modulesService } -// GetModulesSegment returns the registry path segment where modules live, -// relative to the edition root (default "modules", empty when modules are at -// the edition root). Callers building module references must use it so their -// paths match the scope of ModuleService. -func (s *Service) GetModulesSegment() string { - return s.modulesSegment +// GetModulesPath returns the registry path where modules live, relative to the +// edition root (default "modules", empty when modules are at the edition root). +// Callers building module references must use it so their paths match the scope +// of ModuleService. +func (s *Service) GetModulesPath() string { + return s.modulesPath } // PackageService returns the packages service From 396d1c9fc9e29263fac819d0bec8212358b35950 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 07:09:37 +0300 Subject: [PATCH 12/19] Add RegistryLayout for summary path display Map each mirrored component to its registry path and flag a non-default modules path (--modules-path-suffix). Shared by the pull and push summaries. Signed-off-by: Roman Berezkin --- internal/mirror/registry_layout.go | 90 ++++++++++++++++++++ internal/mirror/registry_layout_test.go | 106 ++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 internal/mirror/registry_layout.go create mode 100644 internal/mirror/registry_layout_test.go diff --git a/internal/mirror/registry_layout.go b/internal/mirror/registry_layout.go new file mode 100644 index 00000000..b124b501 --- /dev/null +++ b/internal/mirror/registry_layout.go @@ -0,0 +1,90 @@ +/* +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 mirror + +import ( + "path" + "strings" + + "github.com/deckhouse/deckhouse-cli/internal" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// RegistryPathRow is one component's location in the registry, for the summary +// layout section. +type RegistryPathRow struct { + // Label is the component name, e.g. "Platform", "Modules". + Label string + // Path is the full registry path where the component lives. + Path string + // NonDefault marks a path the user moved off the standard layout. Only the + // modules path is configurable today (via --modules-path-suffix), so only it + // can be non-default; the model stays general for future configurable paths. + NonDefault bool + // DefaultPath is the standard path for this component. Filled only when + // NonDefault, so the summary can show "default: ". + DefaultPath string +} + +// RegistryLayout is where each mirrored component resolves in the registry, for +// display in the pull/push summary. Root is the edition root on pull and the +// target repo on push. +type RegistryLayout struct { + Root string + Rows []RegistryPathRow + HasOverride bool +} + +// BuildRegistryLayout maps every mirrored component to its registry path. +// +// root is the edition root (pull) or the target repo (push). modulesPathSuffix +// is the raw --modules-path-suffix value; it is normalized the same way as the +// real push/pull path so the summary matches where images actually go. Only the +// modules path can differ from the standard layout, so only it is flagged +// NonDefault. +// +// installerPath is the full registry path of the installer images, which live +// outside the edition root; pass "" to omit the Installer row (push does not +// create an installer repo). +func BuildRegistryLayout(root, modulesPathSuffix, installerPath string) RegistryLayout { + root = strings.TrimSuffix(root, "/") + + modulesPath := registryservice.NormalizeModulesPath(modulesPathSuffix) + modulesNonDefault := modulesPath != internal.ModulesSegment + + rows := []RegistryPathRow{ + {Label: "Platform", Path: root}, + { + Label: "Modules", + Path: path.Join(root, modulesPath), + NonDefault: modulesNonDefault, + DefaultPath: path.Join(root, internal.ModulesSegment), + }, + {Label: "Security", Path: path.Join(root, internal.SecuritySegment)}, + {Label: "Packages", Path: path.Join(root, internal.PackagesSegment)}, + } + + if installerPath != "" { + rows = append(rows, RegistryPathRow{Label: "Installer", Path: installerPath}) + } + + return RegistryLayout{ + Root: root, + Rows: rows, + HasOverride: modulesNonDefault, + } +} diff --git a/internal/mirror/registry_layout_test.go b/internal/mirror/registry_layout_test.go new file mode 100644 index 00000000..a1afa45a --- /dev/null +++ b/internal/mirror/registry_layout_test.go @@ -0,0 +1,106 @@ +/* +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 mirror + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func rowByLabel(t *testing.T, layout RegistryLayout, label string) RegistryPathRow { + t.Helper() + + for _, row := range layout.Rows { + if row.Label == label { + return row + } + } + + t.Fatalf("row %q not found in layout", label) + + return RegistryPathRow{} +} + +func TestBuildRegistryLayout_Modules(t *testing.T) { + const root = "registry.example.com/deckhouse/ee" + + tests := []struct { + name string + suffix string + wantPath string + wantNonDefault bool + }{ + {name: "flag default", suffix: "/modules", wantPath: root + "/modules", wantNonDefault: false}, + {name: "empty is default", suffix: "", wantPath: root + "/modules", wantNonDefault: false}, + {name: "bare modules is default", suffix: "modules", wantPath: root + "/modules", wantNonDefault: false}, + {name: "root suffix", suffix: "/", wantPath: root, wantNonDefault: true}, + {name: "custom single segment", suffix: "mymods", wantPath: root + "/mymods", wantNonDefault: true}, + {name: "custom multi segment", suffix: "my/mods", wantPath: root + "/my/mods", wantNonDefault: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + layout := BuildRegistryLayout(root, tt.suffix, "") + + modules := rowByLabel(t, layout, "Modules") + assert.Equal(t, tt.wantPath, modules.Path) + assert.Equal(t, tt.wantNonDefault, modules.NonDefault) + assert.Equal(t, root+"/modules", modules.DefaultPath) + assert.Equal(t, tt.wantNonDefault, layout.HasOverride) + }) + } +} + +func TestBuildRegistryLayout_FixedComponents(t *testing.T) { + const root = "registry.example.com/deckhouse/ee" + + layout := BuildRegistryLayout(root, "/modules", "") + + assert.Equal(t, root, layout.Root) + assert.Equal(t, root, rowByLabel(t, layout, "Platform").Path) + assert.Equal(t, root+"/security", rowByLabel(t, layout, "Security").Path) + assert.Equal(t, root+"/packages", rowByLabel(t, layout, "Packages").Path) + + // Fixed components never carry an override flag. + for _, label := range []string{"Platform", "Security", "Packages"} { + assert.False(t, rowByLabel(t, layout, label).NonDefault, "%s must be default", label) + } +} + +func TestBuildRegistryLayout_InstallerRow(t *testing.T) { + const root = "registry.example.com/deckhouse/ee" + + // Empty installer path omits the row. + layout := BuildRegistryLayout(root, "/modules", "") + for _, row := range layout.Rows { + require.NotEqual(t, "Installer", row.Label) + } + + // A supplied installer path adds the row verbatim (it lives outside root). + const installer = "registry.example.com/deckhouse/installer" + layout = BuildRegistryLayout(root, "/modules", installer) + assert.Equal(t, installer, rowByLabel(t, layout, "Installer").Path) +} + +func TestBuildRegistryLayout_TrimsRootSlash(t *testing.T) { + layout := BuildRegistryLayout("registry.example.com/deckhouse/ee/", "/", "") + + assert.Equal(t, "registry.example.com/deckhouse/ee", layout.Root) + assert.Equal(t, "registry.example.com/deckhouse/ee", rowByLabel(t, layout, "Modules").Path) +} From f4c6f381b7ca64ee84510ad13c31ae2a7840bdc5 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 07:32:18 +0300 Subject: [PATCH 13/19] Extract shared summary UI into summaryui package Move the framed-summary colours, box helpers and formatters out of the pull renderer into internal/mirror/summaryui so pull and push share one look. Add WriteRegistryLayout for the registry layout section, and document the rendered output with worked examples. Pull aliases the moved primitives to stay unchanged. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/pull/summary.go | 158 ++++++++----------- internal/mirror/summaryui/summaryui.go | 166 ++++++++++++++++++++ internal/mirror/summaryui/summaryui_test.go | 85 ++++++++++ 3 files changed, 320 insertions(+), 89 deletions(-) create mode 100644 internal/mirror/summaryui/summaryui.go create mode 100644 internal/mirror/summaryui/summaryui_test.go diff --git a/internal/mirror/cmd/pull/summary.go b/internal/mirror/cmd/pull/summary.go index f2e22526..6eebbd51 100644 --- a/internal/mirror/cmd/pull/summary.go +++ b/internal/mirror/cmd/pull/summary.go @@ -18,62 +18,45 @@ package pull import ( "fmt" - "os" "sort" "strings" - "time" - "unicode/utf8" "github.com/Masterminds/semver/v3" - "github.com/fatih/color" "github.com/deckhouse/deckhouse-cli/internal/mirror" + "github.com/deckhouse/deckhouse-cli/internal/mirror/summaryui" ) +// Framed-summary primitives live in summaryui so the pull and push summaries +// share one look. Alias them here to keep the renderer and its callers unchanged. const ( - // frameWidth is the inner width of the summary box, in runes. - frameWidth = 56 - // labelWidth aligns the category labels in the summary body. - labelWidth = 11 - // nameWidth left-aligns module names and bundle artifact names. - nameWidth = 30 - // sizeWidth right-aligns bundle artifact sizes. - sizeWidth = 10 + frameWidth = summaryui.FrameWidth + nameWidth = summaryui.NameWidth + sizeWidth = summaryui.SizeWidth ) -// Semantic accent colours for the summary. fatih/color disables them when -// stdout is not a TTY or NO_COLOR is set (the summary is logged to stdout), so -// escape codes never reach pipes or files. -// -// Apply every colour AFTER width padding (padLabel, %-30s, %10s): the codes are -// zero-width on screen but count toward fmt's field widths and break columns. var ( - cFrame = color.New(color.FgHiBlack).SprintFunc() // box borders - recede - cTitle = color.New(color.FgCyan, color.Bold).SprintFunc() // block title - cLabel = color.New(color.FgCyan).SprintFunc() // category labels (scan anchors) - cCount = color.New(color.Bold).SprintFunc() // primary numbers - cDim = color.New(color.FgHiBlack).SprintFunc() // units and secondary text - cGood = color.New(color.FgGreen).SprintFunc() // complete (e.g. 4/4 databases) - cWarn = color.New(color.FgYellow).SprintFunc() // attention (partial, not-available, dry-run) - cBad = color.New(color.FgRed).SprintFunc() // failure (cancelled) - cVEX = color.New(color.FgMagenta).SprintFunc() // VEX attestations (a distinct class) - cVersion = color.New(color.FgGreen).SprintFunc() // resolved versions (the headline) - cSize = color.New(color.FgCyan).SprintFunc() // bundle artifact sizes - cTotalSz = color.New(color.FgYellow, color.Bold).SprintFunc() // the bundle TOTAL - the action number + cFrame = summaryui.Frame + cLabel = summaryui.Label + cCount = summaryui.Count + cDim = summaryui.Dim + cGood = summaryui.Good + cWarn = summaryui.Warn + cBad = summaryui.Bad + cVEX = summaryui.VEX + cVersion = summaryui.Version + cSize = summaryui.Size + cTotalSz = summaryui.TotalSz ) -// bar returns the coloured left border of a body line. -func bar() string { return cFrame("║") } - -// configureSummaryColor re-enables colour when FORCE_COLOR / CLICOLOR_FORCE is -// set (e.g. piping to `less -R` or capturing a coloured log). NO_COLOR wins; -// otherwise fatih/color's stdout-TTY check decides. -func configureSummaryColor() { - if os.Getenv("NO_COLOR") == "" && - (os.Getenv("FORCE_COLOR") != "" || os.Getenv("CLICOLOR_FORCE") != "") { - color.NoColor = false - } -} +var ( + bar = summaryui.Bar + configureSummaryColor = summaryui.ConfigureColor + writeTopBorder = summaryui.WriteTopBorder + padLabel = summaryui.PadLabel + formatDuration = summaryui.FormatDuration + humanSize = summaryui.HumanSize +) // renderPullSummary formats a PullSummary as a single multi-line, framed block. // It is emitted through a single logger call so the structured-logging handler @@ -82,6 +65,31 @@ func configureSummaryColor() { // When verbose is true, the modules and packages sections list every entry with // its resolved versions (and VEX count, when present); otherwise they print only // the aggregate count (the category label already names what is counted). +// +// Example output (verbose pull, colour stripped): +// +// ╔══ Pull summary ═══════════════════════════════════════ +// ║ Edition: EE +// ║ Platform: v1.69.1 (5 channels) +// ║ Installer: v1.69.1 +// ║ Security: 4/4 databases +// ║ Modules: 2 · 3 VEXes +// ║ console [v1.40.0] +// ║ csi-nfs (3 VEX) [v0.6.2, v0.6.1] +// ║ Packages: 1 +// ║ deckhouse [v1.69.1] +// ║ +// ║ Bundle artifacts (3 files) +// ║ platform.tar 2.9 GiB +// ║ modules.tar 1.1 GiB (2 chunks) +// ║ TOTAL 4.0 GiB +// ║ +// ║ Elapsed: 3m12s +// ╚═══════════════════════════════════════════════════════ +// +// The Elapsed line is preceded by a state line on a non-success outcome: +// "Pull failed; ...", "Pull was cancelled; ...", or "No images were downloaded +// (dry-run).". func renderPullSummary(s *mirror.PullSummary, verbose bool) string { var b strings.Builder @@ -136,16 +144,9 @@ func summaryTitle(s *mirror.PullSummary) string { } } -func writeTopBorder(b *strings.Builder, title string) { - prefix := "╔══ " - suffix := " " - used := utf8.RuneCountInString(prefix) + utf8.RuneCountInString(title) + utf8.RuneCountInString(suffix) - - pad := max(0, frameWidth-used) - - b.WriteString(cFrame(prefix) + cTitle(title) + suffix + cFrame(strings.Repeat("═", pad)) + "\n") -} - +// writeComponent renders one platform-like category (Platform, Installer). +// e.g. `║ Platform: v1.69.1 (5 channels)`; "included" when no version is +// known; "skipped" / "not pulled" when the phase did not run. func writeComponent(b *strings.Builder, name string, c mirror.ComponentStats) { label := cLabel(padLabel(name)) @@ -208,6 +209,9 @@ func compareSemverDesc(a, b string) bool { } } +// writeSecurity renders the trivy databases line. +// e.g. `║ Security: 4/4 databases` (green; yellow on a partial "3/4 +// databases"); also "not available in this edition" / "skipped" / "not pulled". func writeSecurity(b *strings.Builder, s mirror.SecurityStats) { label := cLabel(padLabel("Security")) @@ -231,6 +235,9 @@ func writeSecurity(b *strings.Builder, s mirror.SecurityStats) { } } +// writeModules renders the modules line, and per-module detail when verbose. +// e.g. `║ Modules: 2 · 3 VEXes`, then per module (verbose) +// `║ csi-nfs (3 VEX) [v0.6.2, v0.6.1]`. func writeModules(b *strings.Builder, m mirror.ModulesStats, verbose bool) { label := cLabel(padLabel("Modules")) @@ -284,6 +291,9 @@ func writeModules(b *strings.Builder, m mirror.ModulesStats, verbose bool) { } } +// writePackages mirrors writeModules for packages. +// e.g. `║ Packages: 1`, then per package (verbose) +// `║ deckhouse [v1.69.1]`. func writePackages(b *strings.Builder, p mirror.PackagesStats, verbose bool) { label := cLabel(padLabel("Packages")) @@ -337,6 +347,13 @@ func writePackages(b *strings.Builder, p mirror.PackagesStats, verbose bool) { } } +// writeBundle renders the on-disk bundle artifact block (real pull only). +// e.g.: +// +// ║ Bundle artifacts (3 files) +// ║ platform.tar 2.9 GiB +// ║ modules.tar 1.1 GiB (2 chunks) +// ║ TOTAL 4.0 GiB func writeBundle(b *strings.Builder, bundle mirror.BundleStats) { fmt.Fprintf(b, "%s %s\n", bar(), cLabel(fmt.Sprintf("Bundle artifacts (%d files)", physicalFileCount(bundle)))) @@ -368,40 +385,3 @@ func physicalFileCount(bundle mirror.BundleStats) int { return count } - -func padLabel(name string) string { - return fmt.Sprintf("%-*s", labelWidth, name+":") -} - -// formatDuration renders an elapsed duration compactly, keeping millisecond -// precision for sub-second runs (so a fast dry-run does not report "0s"). -func formatDuration(d time.Duration) string { - if d < time.Second { - return d.Round(time.Millisecond).String() - } - - return d.Round(time.Second).String() -} - -// humanSize returns a compact human-readable size using binary (IEC) units -// (e.g. "789 B", "12.3 KiB", "2.9 GiB"). The divisor is 1024, so the labels are -// KiB/MiB/GiB rather than the decimal KB/MB/GB - this keeps the printed unit -// honest with the math, which matters when an operator sizes transfer media off -// this number. Adapted from internal/cr/internal/output.HumanSize, which is not -// importable here because it lives behind the internal/cr/internal/ barrier. -func humanSize(n int64) string { - const unit = int64(1024) - if n < unit { - return fmt.Sprintf("%d B", n) - } - - div, exp := unit, 0 - for v := n / unit; v >= unit; v /= unit { - div *= unit - exp++ - } - - units := "KMGTPE" - - return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), units[exp]) -} diff --git a/internal/mirror/summaryui/summaryui.go b/internal/mirror/summaryui/summaryui.go new file mode 100644 index 00000000..53049a9e --- /dev/null +++ b/internal/mirror/summaryui/summaryui.go @@ -0,0 +1,166 @@ +/* +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 summaryui holds the shared presentation primitives for the mirror +// pull and push summaries: the framed box, the accent colours, and the registry +// layout section. Keeping them here lets both summaries look identical. +package summaryui + +import ( + "fmt" + "os" + "strings" + "time" + "unicode/utf8" + + "github.com/fatih/color" + + "github.com/deckhouse/deckhouse-cli/internal/mirror" +) + +const ( + // FrameWidth is the inner width of the summary box, in runes. + FrameWidth = 56 + // LabelWidth aligns the category labels in the summary body. + LabelWidth = 11 + // NameWidth left-aligns module names and bundle artifact names. + NameWidth = 30 + // SizeWidth right-aligns bundle artifact sizes. + SizeWidth = 10 + // layoutNameWidth left-aligns component names in the registry layout section. + layoutNameWidth = 10 +) + +// Semantic accent colours for the summary. fatih/color disables them when +// stdout is not a TTY or NO_COLOR is set (the summary is logged to stdout), so +// escape codes never reach pipes or files. +// +// Apply every colour AFTER width padding (PadLabel, %-30s, %10s): the codes are +// zero-width on screen but count toward fmt's field widths and break columns. +var ( + Frame = color.New(color.FgHiBlack).SprintFunc() // box borders - recede + Title = color.New(color.FgCyan, color.Bold).SprintFunc() // block title + Label = color.New(color.FgCyan).SprintFunc() // category labels (scan anchors) + Count = color.New(color.Bold).SprintFunc() // primary numbers + Dim = color.New(color.FgHiBlack).SprintFunc() // units and secondary text + Good = color.New(color.FgGreen).SprintFunc() // complete (e.g. 4/4 databases) + Warn = color.New(color.FgYellow).SprintFunc() // attention (partial, not-available, non-default) + Bad = color.New(color.FgRed).SprintFunc() // failure (cancelled) + VEX = color.New(color.FgMagenta).SprintFunc() // VEX attestations (a distinct class) + Version = color.New(color.FgGreen).SprintFunc() // resolved versions (the headline) + Size = color.New(color.FgCyan).SprintFunc() // bundle artifact sizes + TotalSz = color.New(color.FgYellow, color.Bold).SprintFunc() // the bundle TOTAL - the action number +) + +// Bar returns the coloured left border of a body line. +func Bar() string { return Frame("║") } + +// ConfigureColor re-enables colour when FORCE_COLOR / CLICOLOR_FORCE is set +// (e.g. piping to `less -R` or capturing a coloured log). NO_COLOR wins; +// otherwise fatih/color's stdout-TTY check decides. +func ConfigureColor() { + if os.Getenv("NO_COLOR") == "" && + (os.Getenv("FORCE_COLOR") != "" || os.Getenv("CLICOLOR_FORCE") != "") { + color.NoColor = false + } +} + +// WriteTopBorder writes the framed-block top border with the given title. +func WriteTopBorder(b *strings.Builder, title string) { + prefix := "╔══ " + suffix := " " + used := utf8.RuneCountInString(prefix) + utf8.RuneCountInString(title) + utf8.RuneCountInString(suffix) + + pad := max(0, FrameWidth-used) + + b.WriteString(Frame(prefix) + Title(title) + suffix + Frame(strings.Repeat("═", pad)) + "\n") +} + +// PadLabel formats a category label as a fixed-width "Name:" column. +func PadLabel(name string) string { + return fmt.Sprintf("%-*s", LabelWidth, name+":") +} + +// FormatDuration renders an elapsed duration compactly, keeping millisecond +// precision for sub-second runs (so a fast dry-run does not report "0s"). +func FormatDuration(d time.Duration) string { + if d < time.Second { + return d.Round(time.Millisecond).String() + } + + return d.Round(time.Second).String() +} + +// HumanSize returns a compact human-readable size using binary (IEC) units +// (e.g. "789 B", "12.3 KiB", "2.9 GiB"). The divisor is 1024, so the labels are +// KiB/MiB/GiB rather than the decimal KB/MB/GB - this keeps the printed unit +// honest with the math, which matters when an operator sizes transfer media off +// this number. Adapted from internal/cr/internal/output.HumanSize, which is not +// importable here because it lives behind the internal/cr/internal/ barrier. +func HumanSize(n int64) string { + const unit = int64(1024) + if n < unit { + return fmt.Sprintf("%d B", n) + } + + div, exp := unit, 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + + units := "KMGTPE" + + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), units[exp]) +} + +// WriteRegistryLayout writes the registry layout section: the root registry and +// where each component lives, with any non-default path (a moved modules path) +// in yellow plus a dimmed hint of the standard path. Writes nothing when show is +// false. +// +// Example output for --modules-path-suffix mymods (colour stripped; the Modules +// path is yellow): +// +// ║ Registry: registry.deckhouse.io/deckhouse/ee +// ║ Platform registry.deckhouse.io/deckhouse/ee +// ║ Modules registry.deckhouse.io/deckhouse/ee/mymods +// ║ default: registry.deckhouse.io/deckhouse/ee/modules +// ║ Security registry.deckhouse.io/deckhouse/ee/security +// ║ Packages registry.deckhouse.io/deckhouse/ee/packages +// ║ Installer registry.deckhouse.io/deckhouse/installer +// +// With a default modules path the Modules line is plain and the "default:" hint +// is omitted. +func WriteRegistryLayout(b *strings.Builder, layout mirror.RegistryLayout, show bool) { + if !show { + return + } + + fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(PadLabel("Registry")), layout.Root) + + for _, row := range layout.Rows { + name := fmt.Sprintf("%-*s", layoutNameWidth, row.Label) + + if row.NonDefault { + fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(name), Warn(row.Path)) + fmt.Fprintf(b, "%s %s %s\n", Bar(), strings.Repeat(" ", layoutNameWidth), Dim("default: "+row.DefaultPath)) + continue + } + + fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(name), row.Path) + } +} diff --git a/internal/mirror/summaryui/summaryui_test.go b/internal/mirror/summaryui/summaryui_test.go new file mode 100644 index 00000000..9b6c3a95 --- /dev/null +++ b/internal/mirror/summaryui/summaryui_test.go @@ -0,0 +1,85 @@ +/* +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 summaryui + +import ( + "strings" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/mirror" +) + +const layoutRoot = "registry.example.com/deckhouse/ee" + +func renderLayout(t *testing.T, l mirror.RegistryLayout, show bool) string { + t.Helper() + + var b strings.Builder + WriteRegistryLayout(&b, l, show) + + return b.String() +} + +func TestWriteRegistryLayout_Hidden(t *testing.T) { + out := renderLayout(t, mirror.BuildRegistryLayout(layoutRoot, "/", ""), false) + require.Empty(t, out, "show=false writes nothing") +} + +func TestWriteRegistryLayout_Default(t *testing.T) { + orig := color.NoColor + defer func() { color.NoColor = orig }() + color.NoColor = true + + out := renderLayout(t, mirror.BuildRegistryLayout(layoutRoot, "/modules", ""), true) + + require.Contains(t, out, "Registry:") + require.Contains(t, out, layoutRoot) + for _, label := range []string{"Platform", "Modules", "Security", "Packages"} { + require.Contains(t, out, label) + } + require.Contains(t, out, layoutRoot+"/modules") + require.NotContains(t, out, "default:", "no default hint without an override") +} + +func TestWriteRegistryLayout_OverrideHint(t *testing.T) { + orig := color.NoColor + defer func() { color.NoColor = orig }() + color.NoColor = true + + // "/" places modules at the repo root; the hint points at the standard path. + out := renderLayout(t, mirror.BuildRegistryLayout(layoutRoot, "/", ""), true) + + require.Contains(t, out, "default: "+layoutRoot+"/modules") +} + +func TestWriteRegistryLayout_OverrideColor(t *testing.T) { + orig := color.NoColor + defer func() { color.NoColor = orig }() + + l := mirror.BuildRegistryLayout(layoutRoot, "mymods", "") + + color.NoColor = false + require.Contains(t, renderLayout(t, l, true), "\x1b[", + "non-default modules path must be highlighted when colour is enabled") + + color.NoColor = true + require.NotContains(t, renderLayout(t, l, true), "\x1b[", + "no ANSI escape codes when colour is disabled") +} From 5e7b6b7e15126475fa89d019e9d3d10f17557c5b Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 07:40:22 +0300 Subject: [PATCH 14/19] Show registry layout in pull summary Add a Registry section to the pull summary: the source repo and each component's path, with a moved modules path (--modules-path-suffix) highlighted. Shown under --verbose-summary or when the path differs from the default. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/pull/pull.go | 7 +++- internal/mirror/cmd/pull/summary.go | 21 +++++++++--- internal/mirror/cmd/pull/summary_test.go | 41 ++++++++++++++++++++++++ internal/mirror/summary.go | 4 +++ internal/mirror/summaryui/summaryui.go | 3 +- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index f1f2b3ba..2684f019 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -257,9 +257,14 @@ func (p *Puller) Execute(ctx context.Context) error { // Edition is parsed from the source registry path (no registry call); empty // for a custom registry, where the renderer omits the Edition line. - _, edition := registryservice.GetEditionFromRegistryPath(p.params.DeckhouseRegistryRepo) + repoNoEdition, edition := registryservice.GetEditionFromRegistryPath(p.params.DeckhouseRegistryRepo) summary.Edition = string(edition) + // Registry layout for the summary: modules honor --modules-path-suffix; the + // installer lives outside the edition root, at /installer. + installerPath := repoNoEdition + "/" + internal.InstallerSegment + summary.Registry = mirror.BuildRegistryLayout(p.params.DeckhouseRegistryRepo, p.params.ModulesPathSuffix, installerPath) + // pullErr is nil for success and for a graceful Ctrl+C (which still renders a // partial summary); non-nil for a hard failure (which also renders, then // propagates so the exit code is non-zero). diff --git a/internal/mirror/cmd/pull/summary.go b/internal/mirror/cmd/pull/summary.go index 6eebbd51..3d03552b 100644 --- a/internal/mirror/cmd/pull/summary.go +++ b/internal/mirror/cmd/pull/summary.go @@ -66,10 +66,18 @@ var ( // its resolved versions (and VEX count, when present); otherwise they print only // the aggregate count (the category label already names what is counted). // -// Example output (verbose pull, colour stripped): +// Example output (verbose pull with --modules-path-suffix mymods, colour +// stripped; the moved Modules registry path is yellow): // // ╔══ Pull summary ═══════════════════════════════════════ // ║ Edition: EE +// ║ Registry: registry.deckhouse.io/deckhouse/ee +// ║ Platform registry.deckhouse.io/deckhouse/ee +// ║ Modules registry.deckhouse.io/deckhouse/ee/mymods +// ║ default: registry.deckhouse.io/deckhouse/ee/modules +// ║ Security registry.deckhouse.io/deckhouse/ee/security +// ║ Packages registry.deckhouse.io/deckhouse/ee/packages +// ║ Installer registry.deckhouse.io/deckhouse/installer // ║ Platform: v1.69.1 (5 channels) // ║ Installer: v1.69.1 // ║ Security: 4/4 databases @@ -87,9 +95,10 @@ var ( // ║ Elapsed: 3m12s // ╚═══════════════════════════════════════════════════════ // -// The Elapsed line is preceded by a state line on a non-success outcome: -// "Pull failed; ...", "Pull was cancelled; ...", or "No images were downloaded -// (dry-run).". +// The Registry section appears only under --verbose-summary or when the modules +// path was moved. The Elapsed line is preceded by a state line on a non-success +// outcome: "Pull failed; ...", "Pull was cancelled; ...", or "No images were +// downloaded (dry-run).". func renderPullSummary(s *mirror.PullSummary, verbose bool) string { var b strings.Builder @@ -102,6 +111,10 @@ func renderPullSummary(s *mirror.PullSummary, verbose bool) string { fmt.Fprintf(&b, "%s %s %s\n", bar(), cLabel(padLabel("Edition")), cCount(strings.ToUpper(s.Edition))) } + // Registry layout: shown when the modules path was moved off the default, or + // on request via --verbose-summary. + summaryui.WriteRegistryLayout(&b, s.Registry, verbose || s.Registry.HasOverride) + writeComponent(&b, "Platform", s.Platform) writeComponent(&b, "Installer", s.Installer) writeSecurity(&b, s.Security) diff --git a/internal/mirror/cmd/pull/summary_test.go b/internal/mirror/cmd/pull/summary_test.go index 837c5faa..738619e2 100644 --- a/internal/mirror/cmd/pull/summary_test.go +++ b/internal/mirror/cmd/pull/summary_test.go @@ -420,6 +420,47 @@ func TestRenderPullSummary(t *testing.T) { notContains: []string{"Bundle artifacts", "VEX", "not pulled"}, skippedCount: 5, }, + { + name: "registry section highlights a moved modules path without verbose", + summary: &mirror.PullSummary{ + Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, + Registry: mirror.BuildRegistryLayout( + "registry.deckhouse.io/deckhouse/ee", "/", + "registry.deckhouse.io/deckhouse/installer"), + }, + verbose: false, + contains: []string{ + "Registry:", + "registry.deckhouse.io/deckhouse/ee/security", + "default: registry.deckhouse.io/deckhouse/ee/modules", + }, + skippedCount: -1, + }, + { + name: "registry section shown in verbose even at the default path", + summary: &mirror.PullSummary{ + Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, + Registry: mirror.BuildRegistryLayout( + "registry.deckhouse.io/deckhouse/ee", "/modules", + "registry.deckhouse.io/deckhouse/installer"), + }, + verbose: true, + contains: []string{"Registry:", "registry.deckhouse.io/deckhouse/ee/modules"}, + notContains: []string{"default:"}, + skippedCount: -1, + }, + { + name: "registry section hidden at the default path without verbose", + summary: &mirror.PullSummary{ + Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, + Registry: mirror.BuildRegistryLayout( + "registry.deckhouse.io/deckhouse/ee", "/modules", + "registry.deckhouse.io/deckhouse/installer"), + }, + verbose: false, + notContains: []string{"Registry:"}, + skippedCount: -1, + }, } for _, tt := range tests { diff --git a/internal/mirror/summary.go b/internal/mirror/summary.go index ab398d1e..c3004ad2 100644 --- a/internal/mirror/summary.go +++ b/internal/mirror/summary.go @@ -141,6 +141,10 @@ type PullSummary struct { // registry path. Empty for a custom registry with no edition segment, in // which case the summary omits the Edition line. Edition string + // Registry is where each component reads from in the source registry. The + // summary shows it when the modules path was moved off the default, or in + // verbose mode. Filled by the CLI. + Registry RegistryLayout // Elapsed is the wall-clock duration of the pull, filled by the CLI. Elapsed time.Duration diff --git a/internal/mirror/summaryui/summaryui.go b/internal/mirror/summaryui/summaryui.go index 53049a9e..e7d5b52c 100644 --- a/internal/mirror/summaryui/summaryui.go +++ b/internal/mirror/summaryui/summaryui.go @@ -146,7 +146,8 @@ func HumanSize(n int64) string { // With a default modules path the Modules line is plain and the "default:" hint // is omitted. func WriteRegistryLayout(b *strings.Builder, layout mirror.RegistryLayout, show bool) { - if !show { + // No rows means an unpopulated layout; never emit a bare header. + if !show || len(layout.Rows) == 0 { return } From d5c2bc6b9f16c4ca575602cb894d6740362cc3c4 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 08:52:09 +0300 Subject: [PATCH 15/19] Add push summary with registry layout Make PushService.Push return a PushSummary and collect what was pushed (platform, installer, security DBs, module and package counts). Render a framed push summary matching the pull one, always showing the registry layout so a moved modules path (--modules-path-suffix) is visible where it was applied. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/push/push.go | 41 +++++-- internal/mirror/cmd/push/summary.go | 129 +++++++++++++++++++++++ internal/mirror/cmd/push/summary_test.go | 110 +++++++++++++++++++ internal/mirror/push.go | 70 +++++++++--- internal/mirror/push_test.go | 13 ++- internal/mirror/repro_lost_image_test.go | 4 +- internal/mirror/summary.go | 29 +++++ 7 files changed, 368 insertions(+), 28 deletions(-) create mode 100644 internal/mirror/cmd/push/summary.go create mode 100644 internal/mirror/cmd/push/summary_test.go diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index 045d5cbc..b78cde2b 100644 --- a/internal/mirror/cmd/push/push.go +++ b/internal/mirror/cmd/push/push.go @@ -36,6 +36,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/mirror" "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd/push/errdetect" + "github.com/deckhouse/deckhouse-cli/internal/mirror/summaryui" "github.com/deckhouse/deckhouse-cli/internal/mirror/validation" "github.com/deckhouse/deckhouse-cli/internal/version" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/operations/params" @@ -196,6 +197,8 @@ func NewPusher() *Pusher { // Execute runs the full push process func (p *Pusher) Execute() error { + summaryui.ConfigureColor() + p.logger.Infof("d8 version: %s", version.Version) if RegistryUsername != "" { @@ -265,18 +268,38 @@ func (p *Pusher) executeNewPush() error { p.logger.(*log.SLogger), ) - err := svc.Push(ctx) - if err != nil { - // Handle context cancellation gracefully - if errors.Is(err, context.Canceled) { - p.logger.WarnLn("Operation cancelled by user") - return nil - } + start := time.Now() + // Push always returns a non-nil summary, even on error. + summary, err := svc.Push(ctx) + summary.Elapsed = time.Since(start) - return fmt.Errorf("push to registry: %w", err) + pushErr := classifyPushOutcome(summary, err) + + p.logger.InfoLn(renderPushSummary(summary)) + + if summary.Cancelled { + p.logger.WarnLn("Operation cancelled by user") } - return nil + return pushErr +} + +// classifyPushOutcome records the push's terminal state on the summary and +// returns the error Execute should propagate: +// - nil error -> success, returns nil; +// - context.Canceled -> Cancelled, returns nil (a graceful interrupt is a clean exit); +// - any other error -> Failed, returns the wrapped error. +func classifyPushOutcome(summary *mirror.PushSummary, err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, context.Canceled): + summary.Cancelled = true + return nil + default: + summary.Failed = true + return fmt.Errorf("push to registry: %w", err) + } } // validateRegistryAccess validates access to the registry diff --git a/internal/mirror/cmd/push/summary.go b/internal/mirror/cmd/push/summary.go new file mode 100644 index 00000000..e094375b --- /dev/null +++ b/internal/mirror/cmd/push/summary.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 push + +import ( + "fmt" + "strings" + + "github.com/deckhouse/deckhouse-cli/internal/mirror" + "github.com/deckhouse/deckhouse-cli/internal/mirror/summaryui" +) + +// renderPushSummary formats a PushSummary as a single multi-line, framed block, +// matching the pull summary's look. The registry layout section is always shown: +// telling the operator where each component landed is the point of the push +// summary, and it highlights a moved modules path (--modules-path-suffix). +// +// Example output for --modules-path-suffix / (colour stripped; the moved Modules +// registry path is yellow): +// +// ╔══ Push summary ═══════════════════════════════════════ +// ║ Registry: registry.example.com/deckhouse/ee +// ║ Platform registry.example.com/deckhouse/ee +// ║ Modules registry.example.com/deckhouse/ee +// ║ default: registry.example.com/deckhouse/ee/modules +// ║ Security registry.example.com/deckhouse/ee/security +// ║ Packages registry.example.com/deckhouse/ee/packages +// ║ Installer registry.example.com/deckhouse/ee/installer +// ║ Platform: pushed +// ║ Installer: not present +// ║ Security: 4 databases +// ║ Modules: 12 +// ║ Packages: 3 +// ║ +// ║ Elapsed: 2m4s +// ╚═══════════════════════════════════════════════════════ +// +// The Elapsed line is preceded by a state line when the push did not succeed: +// "Push failed; ..." or "Push was cancelled; ...". +func renderPushSummary(s *mirror.PushSummary) string { + var b strings.Builder + + b.WriteByte('\n') + summaryui.WriteTopBorder(&b, pushSummaryTitle(s)) + + summaryui.WriteRegistryLayout(&b, s.Registry, true) + + writePushPresence(&b, "Platform", s.PlatformPushed) + writePushPresence(&b, "Installer", s.InstallerPushed) + writePushSecurity(&b, s.SecurityDatabases) + writePushCount(&b, "Modules", s.Modules) + writePushCount(&b, "Packages", s.Packages) + + b.WriteString(summaryui.Bar() + "\n") + + switch { + case s.Failed: + fmt.Fprintf(&b, "%s %s\n", summaryui.Bar(), summaryui.Bad("Push failed; the above reflects what completed before the error.")) + case s.Cancelled: + fmt.Fprintf(&b, "%s %s\n", summaryui.Bar(), summaryui.Bad("Push was cancelled; the above reflects what completed.")) + } + + fmt.Fprintf(&b, "%s %s\n", summaryui.Bar(), summaryui.Dim("Elapsed: "+summaryui.FormatDuration(s.Elapsed))) + b.WriteString(summaryui.Frame("╚" + strings.Repeat("═", summaryui.FrameWidth-1))) + + return b.String() +} + +// pushSummaryTitle is the framed-block title for the push's terminal state. +func pushSummaryTitle(s *mirror.PushSummary) string { + if s.Failed { + return "Push failed" + } + + return "Push summary" +} + +// writePushPresence renders a component that is either pushed or absent. +// e.g. `║ Platform: pushed` / `║ Installer: not present`. +func writePushPresence(b *strings.Builder, name string, pushed bool) { + label := summaryui.Label(summaryui.PadLabel(name)) + + if pushed { + fmt.Fprintf(b, "%s %s %s\n", summaryui.Bar(), label, summaryui.Good("pushed")) + return + } + + fmt.Fprintf(b, "%s %s %s\n", summaryui.Bar(), label, summaryui.Dim("not present")) +} + +// writePushSecurity renders the security databases count. +// e.g. `║ Security: 4 databases` / `║ Security: not present`. +func writePushSecurity(b *strings.Builder, count int) { + label := summaryui.Label(summaryui.PadLabel("Security")) + + if count == 0 { + fmt.Fprintf(b, "%s %s %s\n", summaryui.Bar(), label, summaryui.Dim("not present")) + return + } + + fmt.Fprintf(b, "%s %s %s\n", summaryui.Bar(), label, summaryui.Good(fmt.Sprintf("%d databases", count))) +} + +// writePushCount renders a repository count (modules, packages). +// e.g. `║ Modules: 12` / `║ Modules: not present`. +func writePushCount(b *strings.Builder, name string, count int) { + label := summaryui.Label(summaryui.PadLabel(name)) + + if count == 0 { + fmt.Fprintf(b, "%s %s %s\n", summaryui.Bar(), label, summaryui.Dim("not present")) + return + } + + fmt.Fprintf(b, "%s %s %s\n", summaryui.Bar(), label, summaryui.Count(fmt.Sprint(count))) +} diff --git a/internal/mirror/cmd/push/summary_test.go b/internal/mirror/cmd/push/summary_test.go new file mode 100644 index 00000000..eea2f4cf --- /dev/null +++ b/internal/mirror/cmd/push/summary_test.go @@ -0,0 +1,110 @@ +/* +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 push + +import ( + "testing" + "time" + + "github.com/fatih/color" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/mirror" +) + +func TestRenderPushSummary(t *testing.T) { + color.NoColor = true + + const root = "registry.example.com/deckhouse/ee" + + tests := []struct { + name string + summary *mirror.PushSummary + contains []string + notContains []string + }{ + { + name: "default layout lists every component", + summary: &mirror.PushSummary{ + Registry: mirror.BuildRegistryLayout(root, "/modules", root+"/installer"), + PlatformPushed: true, + InstallerPushed: true, + SecurityDatabases: 4, + Modules: 12, + Packages: 3, + Elapsed: 2*time.Minute + 4*time.Second, + }, + contains: []string{ + "Push summary", + "Registry:", root + "/modules", + "Platform:", "pushed", + "Security:", "4 databases", + "Modules:", "12", + "Packages:", "3", + "Elapsed: 2m4s", + }, + notContains: []string{"default:", "failed", "cancelled", "not present"}, + }, + { + name: "moved modules path is highlighted with a default hint", + summary: &mirror.PushSummary{ + Registry: mirror.BuildRegistryLayout(root, "/", root+"/installer"), + PlatformPushed: true, + Modules: 5, + }, + contains: []string{ + "Registry:", + "default: " + root + "/modules", + "Installer:", "not present", // no installer.tar in this push + "Modules:", "5", + }, + }, + { + name: "failed push renders a FAILED state", + summary: &mirror.PushSummary{ + Registry: mirror.BuildRegistryLayout(root, "/modules", root+"/installer"), + Failed: true, + }, + contains: []string{"Push failed", "Push failed; the above reflects what completed"}, + }, + { + name: "cancelled push renders a cancellation state", + summary: &mirror.PushSummary{ + Registry: mirror.BuildRegistryLayout(root, "/modules", root+"/installer"), + Cancelled: true, + }, + contains: []string{"Push was cancelled; the above reflects what completed"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := renderPushSummary(tt.summary) + + require.Contains(t, out, "╔══", "missing top border") + require.Contains(t, out, "╚", "missing bottom border") + + for _, want := range tt.contains { + require.Contains(t, out, want) + } + + for _, notWant := range tt.notContains { + require.NotContains(t, out, notWant) + } + }) + } +} diff --git a/internal/mirror/push.go b/internal/mirror/push.go index af54a879..f02aa7bf 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -122,11 +122,14 @@ func NewPushService( // // The key principle: no path transformations. Whatever path the layout has // in the unpacked directory becomes its path in the registry. -func (svc *PushService) Push(ctx context.Context) error { +func (svc *PushService) Push(ctx context.Context) (*PushSummary, error) { + // Registry layout is known up front, so it is on the summary even on error. + summary := &PushSummary{Registry: svc.registryLayout()} + // Create unified directory for unpacking dirPath := filepath.Join(svc.options.WorkingDir, "unified") if err := os.MkdirAll(dirPath, dirPermissions); err != nil { - return fmt.Errorf("create unified directory: %w", err) + return summary, fmt.Errorf("create unified directory: %w", err) } defer func() { @@ -139,27 +142,41 @@ func (svc *PushService) Push(ctx context.Context) error { // Unpack all packages into unified structure if err := svc.unpackAllPackages(ctx, dirPath); err != nil { - return fmt.Errorf("unpack packages: %w", err) + return summary, fmt.Errorf("unpack packages: %w", err) } // Push all layouts recursively if err := svc.userLogger.Process("Push to registry", func() error { - return svc.pushAllLayouts(ctx, dirPath) + return svc.pushAllLayouts(ctx, dirPath, summary) }); err != nil { - return err + return summary, err } // Create modules index (deckhouse/modules: tags for discovery) if err := svc.userLogger.Process("Create modules index", func() error { - return svc.createModulesIndex(ctx, dirPath) + return svc.createModulesIndex(ctx, dirPath, summary) }); err != nil { - return err + return summary, err } // Create packages index (deckhouse/packages: tags for discovery) - return svc.userLogger.Process("Create packages index", func() error { - return svc.createPackagesIndex(ctx, dirPath) - }) + if err := svc.userLogger.Process("Create packages index", func() error { + return svc.createPackagesIndex(ctx, dirPath, summary) + }); err != nil { + return summary, err + } + + return summary, nil +} + +// registryLayout maps each component to its target registry path for the push +// summary, honoring --modules-path-suffix. The installer repo lives at +// /installer. +func (svc *PushService) registryLayout() RegistryLayout { + root := svc.client.GetRegistry() + installerPath := path.Join(root, internal.InstallerSegment) + + return BuildRegistryLayout(root, svc.options.ModulesPathSuffix, installerPath) } // modulesPath returns the registry path for module repositories, relative to @@ -258,7 +275,7 @@ func openPackage(pkgPath string) (io.ReadCloser, error) { // pushAllLayouts recursively walks the directory and pushes each OCI layout found. // The relative path from root becomes the registry segment. -func (svc *PushService) pushAllLayouts(ctx context.Context, rootDir string) error { +func (svc *PushService) pushAllLayouts(ctx context.Context, rootDir string, summary *PushSummary) error { layouts, err := svc.findLayouts(rootDir) if err != nil { return fmt.Errorf("scan layouts in %q: %w", rootDir, err) @@ -276,7 +293,7 @@ func (svc *PushService) pushAllLayouts(ctx context.Context, rootDir string) erro return err } - if err := svc.pushSingleLayout(ctx, rootDir, layoutDir); err != nil { + if err := svc.pushSingleLayout(ctx, rootDir, layoutDir, summary); err != nil { return err } } @@ -311,7 +328,7 @@ func (svc *PushService) findLayouts(rootDir string) ([]string, error) { } // pushSingleLayout pushes a single OCI layout to the registry. -func (svc *PushService) pushSingleLayout(ctx context.Context, rootDir, layoutDir string) error { +func (svc *PushService) pushSingleLayout(ctx context.Context, rootDir, layoutDir string, summary *PushSummary) error { // Check if layout has any images hasImages, err := svc.layoutHasImages(layoutDir) if err != nil { @@ -340,6 +357,9 @@ func (svc *PushService) pushSingleLayout(ctx context.Context, rootDir, layoutDir segment = internal.ModulesSegment } + // Classify by the bundle segment, before the modules remap rewrites it. + origSegment := segment + // Rewrite the leading "modules" component to honor --modules-path-suffix. segment = svc.remapModulesSegment(segment) @@ -351,9 +371,27 @@ func (svc *PushService) pushSingleLayout(ctx context.Context, rootDir, layoutDir return fmt.Errorf("push layout %q to registry %s: %w", relPath, targetClient.GetRegistry(), err) } + recordPushedComponent(summary, origSegment) + return nil } +// recordPushedComponent tallies a pushed layout into the summary by its bundle +// segment. Modules and packages are counted from their index step, so their +// layouts are ignored here. +func recordPushedComponent(summary *PushSummary, segment string) { + first, _, _ := strings.Cut(segment, "/") + + switch first { + case "", internal.InstallSegment, internal.InstallStandaloneSegment, internal.ReleaseChannelSegment: + summary.PlatformPushed = true + case internal.InstallerSegment: + summary.InstallerPushed = true + case internal.SecuritySegment: + summary.SecurityDatabases++ + } +} + // layoutHasImages checks if an OCI layout has any images to push. func (svc *PushService) layoutHasImages(layoutDir string) (bool, error) { layoutPath := layout.Path(layoutDir) @@ -374,7 +412,7 @@ func (svc *PushService) layoutHasImages(layoutDir string) (bool, error) { // createModulesIndex creates the modules index in the registry. // This pushes a small random image for each module with tag = module name // to deckhouse/modules repo, enabling module discovery via ListTags. -func (svc *PushService) createModulesIndex(ctx context.Context, rootDir string) error { +func (svc *PushService) createModulesIndex(ctx context.Context, rootDir string, summary *PushSummary) error { modulesDir := filepath.Join(rootDir, internal.ModulesSegment) // Check if modules directory exists @@ -403,6 +441,7 @@ func (svc *PushService) createModulesIndex(ctx context.Context, rootDir string) } slices.Sort(moduleNames) + summary.Modules = len(moduleNames) svc.userLogger.Infof("Creating modules index with %d modules", len(moduleNames)) // Scope the client to the modules repo, honoring --modules-path-suffix. @@ -437,7 +476,7 @@ func (svc *PushService) createModulesIndex(ctx context.Context, rootDir string) // createPackagesIndex creates the packages index in the registry. // This pushes a small random image for each package with tag = package name // to deckhouse/packages repo, enabling package discovery via ListTags. -func (svc *PushService) createPackagesIndex(ctx context.Context, rootDir string) error { +func (svc *PushService) createPackagesIndex(ctx context.Context, rootDir string, summary *PushSummary) error { packagesDir := filepath.Join(rootDir, internal.PackagesSegment) // Check if packages directory exists @@ -466,6 +505,7 @@ func (svc *PushService) createPackagesIndex(ctx context.Context, rootDir string) } slices.Sort(packageNames) + summary.Packages = len(packageNames) svc.userLogger.Infof("Creating packages index with %d packages", len(packageNames)) // Get client scoped to packages repo diff --git a/internal/mirror/push_test.go b/internal/mirror/push_test.go index 8b6a5655..25a8273e 100644 --- a/internal/mirror/push_test.go +++ b/internal/mirror/push_test.go @@ -140,7 +140,16 @@ func TestPushService_ModulesPathSuffix(t *testing.T) { WorkingDir: t.TempDir(), ModulesPathSuffix: tt.suffix, }, logger, userLogger) - require.NoError(t, svc.Push(context.Background()), "push must succeed") + summary, err := svc.Push(context.Background()) + require.NoError(t, err, "push must succeed") + + // Summary reflects what was pushed: one module and the install + // layout (counted as platform). Override tracks a moved modules path. + assert.Equal(t, 1, summary.Modules, "one module pushed") + assert.True(t, summary.PlatformPushed, "install layout counts as platform") + wantOverride := tt.wantModule != "modules/"+moduleName + assert.Equal(t, wantOverride, summary.Registry.HasOverride, + "registry override reflects a moved modules path") ctx := context.Background() @@ -159,7 +168,7 @@ func TestPushService_ModulesPathSuffix(t *testing.T) { // A non-default suffix must MOVE modules, not copy them: the default // modules/ path must hold nothing. if tt.wantModule != "modules/"+moduleName { - defaultRepo := destClient.WithSegment(pkgclient.PathToSegments("modules/"+moduleName)...) + defaultRepo := destClient.WithSegment(pkgclient.PathToSegments("modules/" + moduleName)...) assert.Errorf(t, defaultRepo.CheckImageExists(ctx, moduleTag), "module must not remain at default modules/%s", moduleName) } diff --git a/internal/mirror/repro_lost_image_test.go b/internal/mirror/repro_lost_image_test.go index aae9c01d..8cf2feb6 100644 --- a/internal/mirror/repro_lost_image_test.go +++ b/internal/mirror/repro_lost_image_test.go @@ -318,8 +318,8 @@ func TestRepro_MirrorPullThenPush_TransientBlobFailureLosesImage(t *testing.T) { Packages: []string{pkgPath}, WorkingDir: t.TempDir(), }, logger, userLogger) - require.NoError(t, pushSvc.Push(context.Background()), - "d8 mirror push must succeed") + _, pushErr := pushSvc.Push(context.Background()) + require.NoError(t, pushErr, "d8 mirror push must succeed") // Every digest the pull reported as successfully mirrored must exist in // the target registry. Digest images are pushed under their hex short_tag. diff --git a/internal/mirror/summary.go b/internal/mirror/summary.go index c3004ad2..c5789980 100644 --- a/internal/mirror/summary.go +++ b/internal/mirror/summary.go @@ -157,3 +157,32 @@ type PullSummary struct { // Bundle is populated by the CLI from the bundle directory (real pull only). Bundle BundleStats } + +// PushSummary is the end-of-push accounting handed to the renderer. Push has no +// per-image or version detail, so it reports which components the bundle carried +// and how many module/package repositories and security databases were pushed. +type PushSummary struct { + // Cancelled marks a graceful interrupt (Ctrl+C); the summary reflects what + // completed before it. + Cancelled bool + // Failed marks a hard-error abort. The summary still renders, in a FAILED + // state. Mutually exclusive with Cancelled. + Failed bool + // Registry is where each component was written in the target registry, with a + // moved modules path (--modules-path-suffix) highlighted. Filled by PushService. + Registry RegistryLayout + // Elapsed is the wall-clock duration of the push, filled by the CLI. + Elapsed time.Duration + + // PlatformPushed is true when platform layouts were pushed: the root images, + // release channels, or the install / install-standalone installers. + PlatformPushed bool + // InstallerPushed is true when the standalone installer repo was pushed. + InstallerPushed bool + // SecurityDatabases is the number of security database layouts pushed. + SecurityDatabases int + // Modules is the number of module repositories pushed. + Modules int + // Packages is the number of package repositories pushed. + Packages int +} From 439bc00144db50b7f1a7261ccd448ef23596264a Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 08:52:34 +0300 Subject: [PATCH 16/19] Document registry layout in mirror summary Note in the pull and push README sections that the summary shows a Registry layout and highlights a moved modules path. Signed-off-by: Roman Berezkin --- internal/mirror/README.MD | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index 0ca72ff7..2e799275 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -306,6 +306,12 @@ The pull command creates a bundle with the following structure: If `--gost-digest` is specified, `.gostsum` files are created alongside each `.tar` file. +### Summary + +After a pull, `d8 mirror` prints a framed summary of what was pulled (platform, installer, security databases, modules, packages) and the bundle size. + +The summary also shows a **Registry** section - the source repo and the path each component reads from - when `--modules-path-suffix` moves modules off the default `modules/`, or when `--verbose-summary` is set. A moved modules path is highlighted, with a hint of the standard path, so a non-standard layout is easy to spot. + --- ## d8 mirror push @@ -367,6 +373,12 @@ d8 mirror push /tmp/d8-bundle registry.company.com/deckhouse \ --tmp-dir /mnt/large-disk/tmp ``` +### Summary + +After a push, `d8 mirror` prints a framed summary of what was written to the target registry (platform, installer, security databases, module and package counts). + +It always shows a **Registry** section with the target repo and the path each component was written to. When `--modules-path-suffix` moves modules off the default `modules/`, the moved path is highlighted with a hint of the standard path. + ### Environment Variables The same environment variables used by `d8 mirror pull` are also supported: From 4ea4739f386c308086772ae4f5b81c385d2b48ee Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 09:55:21 +0300 Subject: [PATCH 17/19] Format with task lint Signed-off-by: Roman Berezkin --- internal/mirror/summaryui/summaryui.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/mirror/summaryui/summaryui.go b/internal/mirror/summaryui/summaryui.go index e7d5b52c..ff26ff8e 100644 --- a/internal/mirror/summaryui/summaryui.go +++ b/internal/mirror/summaryui/summaryui.go @@ -159,6 +159,7 @@ func WriteRegistryLayout(b *strings.Builder, layout mirror.RegistryLayout, show if row.NonDefault { fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(name), Warn(row.Path)) fmt.Fprintf(b, "%s %s %s\n", Bar(), strings.Repeat(" ", layoutNameWidth), Dim("default: "+row.DefaultPath)) + continue } From 2482ef4252b7a34c8bbbf396e79c4eda1b4541fc Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 10:34:26 +0300 Subject: [PATCH 18/19] Flag non-default modules path in mirror summary - Print a `Warning:` header above the Registry block when `--modules-path-suffix` moves modules off the default. - Warn in plain text, not by colour alone, so it holds up in piped logs and for colour-blind readers. - Wrap the Registry block in blank lines so it reads apart from the component stats. - Live in the shared `summaryui.WriteRegistryLayout`, so pull and push stay identical. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/pull/summary.go | 11 +++++---- internal/mirror/cmd/pull/summary_test.go | 3 ++- internal/mirror/cmd/push/summary.go | 7 ++++-- internal/mirror/cmd/push/summary_test.go | 3 ++- internal/mirror/summaryui/summaryui.go | 25 +++++++++++++++++---- internal/mirror/summaryui/summaryui_test.go | 2 ++ 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/internal/mirror/cmd/pull/summary.go b/internal/mirror/cmd/pull/summary.go index 3d03552b..3a14214c 100644 --- a/internal/mirror/cmd/pull/summary.go +++ b/internal/mirror/cmd/pull/summary.go @@ -67,10 +67,12 @@ var ( // the aggregate count (the category label already names what is counted). // // Example output (verbose pull with --modules-path-suffix mymods, colour -// stripped; the moved Modules registry path is yellow): +// stripped; the warning header and the moved Modules path are yellow): // // ╔══ Pull summary ═══════════════════════════════════════ // ║ Edition: EE +// ║ +// ║ Warning: modules use a non-default path (--modules-path-suffix) // ║ Registry: registry.deckhouse.io/deckhouse/ee // ║ Platform registry.deckhouse.io/deckhouse/ee // ║ Modules registry.deckhouse.io/deckhouse/ee/mymods @@ -78,6 +80,7 @@ var ( // ║ Security registry.deckhouse.io/deckhouse/ee/security // ║ Packages registry.deckhouse.io/deckhouse/ee/packages // ║ Installer registry.deckhouse.io/deckhouse/installer +// ║ // ║ Platform: v1.69.1 (5 channels) // ║ Installer: v1.69.1 // ║ Security: 4/4 databases @@ -96,9 +99,9 @@ var ( // ╚═══════════════════════════════════════════════════════ // // The Registry section appears only under --verbose-summary or when the modules -// path was moved. The Elapsed line is preceded by a state line on a non-success -// outcome: "Pull failed; ...", "Pull was cancelled; ...", or "No images were -// downloaded (dry-run).". +// path was moved; its Warning header only when the path was moved. The Elapsed +// line is preceded by a state line on a non-success outcome: "Pull failed; ...", +// "Pull was cancelled; ...", or "No images were downloaded (dry-run).". func renderPullSummary(s *mirror.PullSummary, verbose bool) string { var b strings.Builder diff --git a/internal/mirror/cmd/pull/summary_test.go b/internal/mirror/cmd/pull/summary_test.go index 738619e2..1d82aabd 100644 --- a/internal/mirror/cmd/pull/summary_test.go +++ b/internal/mirror/cmd/pull/summary_test.go @@ -431,6 +431,7 @@ func TestRenderPullSummary(t *testing.T) { verbose: false, contains: []string{ "Registry:", + "Warning: modules use a non-default path (--modules-path-suffix)", "registry.deckhouse.io/deckhouse/ee/security", "default: registry.deckhouse.io/deckhouse/ee/modules", }, @@ -446,7 +447,7 @@ func TestRenderPullSummary(t *testing.T) { }, verbose: true, contains: []string{"Registry:", "registry.deckhouse.io/deckhouse/ee/modules"}, - notContains: []string{"default:"}, + notContains: []string{"default:", "Warning"}, skippedCount: -1, }, { diff --git a/internal/mirror/cmd/push/summary.go b/internal/mirror/cmd/push/summary.go index e094375b..3f3d382b 100644 --- a/internal/mirror/cmd/push/summary.go +++ b/internal/mirror/cmd/push/summary.go @@ -29,10 +29,12 @@ import ( // telling the operator where each component landed is the point of the push // summary, and it highlights a moved modules path (--modules-path-suffix). // -// Example output for --modules-path-suffix / (colour stripped; the moved Modules -// registry path is yellow): +// Example output for --modules-path-suffix / (colour stripped; the warning +// header and the moved Modules path are yellow): // // ╔══ Push summary ═══════════════════════════════════════ +// ║ +// ║ Warning: modules use a non-default path (--modules-path-suffix) // ║ Registry: registry.example.com/deckhouse/ee // ║ Platform registry.example.com/deckhouse/ee // ║ Modules registry.example.com/deckhouse/ee @@ -40,6 +42,7 @@ import ( // ║ Security registry.example.com/deckhouse/ee/security // ║ Packages registry.example.com/deckhouse/ee/packages // ║ Installer registry.example.com/deckhouse/ee/installer +// ║ // ║ Platform: pushed // ║ Installer: not present // ║ Security: 4 databases diff --git a/internal/mirror/cmd/push/summary_test.go b/internal/mirror/cmd/push/summary_test.go index eea2f4cf..3f839bb8 100644 --- a/internal/mirror/cmd/push/summary_test.go +++ b/internal/mirror/cmd/push/summary_test.go @@ -57,7 +57,7 @@ func TestRenderPushSummary(t *testing.T) { "Packages:", "3", "Elapsed: 2m4s", }, - notContains: []string{"default:", "failed", "cancelled", "not present"}, + notContains: []string{"default:", "Warning", "failed", "cancelled", "not present"}, }, { name: "moved modules path is highlighted with a default hint", @@ -68,6 +68,7 @@ func TestRenderPushSummary(t *testing.T) { }, contains: []string{ "Registry:", + "Warning: modules use a non-default path (--modules-path-suffix)", "default: " + root + "/modules", "Installer:", "not present", // no installer.tar in this push "Modules:", "5", diff --git a/internal/mirror/summaryui/summaryui.go b/internal/mirror/summaryui/summaryui.go index ff26ff8e..3e6a5004 100644 --- a/internal/mirror/summaryui/summaryui.go +++ b/internal/mirror/summaryui/summaryui.go @@ -132,9 +132,14 @@ func HumanSize(n int64) string { // in yellow plus a dimmed hint of the standard path. Writes nothing when show is // false. // -// Example output for --modules-path-suffix mymods (colour stripped; the Modules -// path is yellow): +// The block is wrapped in blank lines to set it apart. A moved modules path adds +// a plain-text warning header and highlights the Modules line. // +// Example output for --modules-path-suffix mymods (colour stripped; the warning +// header and the Modules path are yellow): +// +// ║ +// ║ Warning: modules use a non-default path (--modules-path-suffix) // ║ Registry: registry.deckhouse.io/deckhouse/ee // ║ Platform registry.deckhouse.io/deckhouse/ee // ║ Modules registry.deckhouse.io/deckhouse/ee/mymods @@ -142,15 +147,25 @@ func HumanSize(n int64) string { // ║ Security registry.deckhouse.io/deckhouse/ee/security // ║ Packages registry.deckhouse.io/deckhouse/ee/packages // ║ Installer registry.deckhouse.io/deckhouse/installer +// ║ // -// With a default modules path the Modules line is plain and the "default:" hint -// is omitted. +// With a default modules path the warning header is omitted and the Modules line +// is plain. func WriteRegistryLayout(b *strings.Builder, layout mirror.RegistryLayout, show bool) { // No rows means an unpopulated layout; never emit a bare header. if !show || len(layout.Rows) == 0 { return } + // Blank lines above and below set the block apart from the summary stats. + b.WriteString(Bar() + "\n") + + // A moved modules path is unusual; call it out in plain text, not by colour + // alone (colour is lost in piped logs and to colour-blind readers). + if layout.HasOverride { + fmt.Fprintf(b, "%s %s\n", Bar(), Warn("Warning: modules use a non-default path (--modules-path-suffix)")) + } + fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(PadLabel("Registry")), layout.Root) for _, row := range layout.Rows { @@ -165,4 +180,6 @@ func WriteRegistryLayout(b *strings.Builder, layout mirror.RegistryLayout, show fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(name), row.Path) } + + b.WriteString(Bar() + "\n") } diff --git a/internal/mirror/summaryui/summaryui_test.go b/internal/mirror/summaryui/summaryui_test.go index 9b6c3a95..e6a1064a 100644 --- a/internal/mirror/summaryui/summaryui_test.go +++ b/internal/mirror/summaryui/summaryui_test.go @@ -56,6 +56,7 @@ func TestWriteRegistryLayout_Default(t *testing.T) { } require.Contains(t, out, layoutRoot+"/modules") require.NotContains(t, out, "default:", "no default hint without an override") + require.NotContains(t, out, "Warning", "no warning header without an override") } func TestWriteRegistryLayout_OverrideHint(t *testing.T) { @@ -66,6 +67,7 @@ func TestWriteRegistryLayout_OverrideHint(t *testing.T) { // "/" places modules at the repo root; the hint points at the standard path. out := renderLayout(t, mirror.BuildRegistryLayout(layoutRoot, "/", ""), true) + require.Contains(t, out, "Warning: modules use a non-default path (--modules-path-suffix)") require.Contains(t, out, "default: "+layoutRoot+"/modules") } From f7410d9368f5c4336ce8f40e180a48493678f958 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 23 Jul 2026 16:53:03 +0300 Subject: [PATCH 19/19] Warn only about a used moved modules path in mirror summary - The pull/push summary showed a full registry layout for every component; now it prints just the moved `Modules` row, in yellow with a `default:` hint. - The warning fires only when `--modules-path-suffix` actually moved the path and modules went through it, so a default path or a module-free run stays silent. - `--verbose-summary` no longer forces the section: paths shown only when there's something to act on. - Replace the `RegistryLayout` model with a slim `ModulesPathReport` and rename `WriteRegistryLayout` to `WriteModulesPathWarning`. Signed-off-by: Roman Berezkin --- internal/mirror/README.MD | 4 +- internal/mirror/cmd/pull/pull.go | 8 +- internal/mirror/cmd/pull/summary.go | 25 ++--- internal/mirror/cmd/pull/summary_test.go | 40 ++++---- internal/mirror/cmd/push/summary.go | 13 +-- internal/mirror/cmd/push/summary_test.go | 32 +++--- internal/mirror/modules_path.go | 58 +++++++++++ internal/mirror/modules_path_test.go | 59 +++++++++++ internal/mirror/push.go | 16 ++- internal/mirror/push_test.go | 8 +- internal/mirror/registry_layout.go | 90 ----------------- internal/mirror/registry_layout_test.go | 106 -------------------- internal/mirror/summary.go | 15 +-- internal/mirror/summaryui/summaryui.go | 55 ++++------ internal/mirror/summaryui/summaryui_test.go | 49 +++++---- 15 files changed, 243 insertions(+), 335 deletions(-) create mode 100644 internal/mirror/modules_path.go create mode 100644 internal/mirror/modules_path_test.go delete mode 100644 internal/mirror/registry_layout.go delete mode 100644 internal/mirror/registry_layout_test.go diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index 2e799275..5c53e00d 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -310,7 +310,7 @@ If `--gost-digest` is specified, `.gostsum` files are created alongside each `.t After a pull, `d8 mirror` prints a framed summary of what was pulled (platform, installer, security databases, modules, packages) and the bundle size. -The summary also shows a **Registry** section - the source repo and the path each component reads from - when `--modules-path-suffix` moves modules off the default `modules/`, or when `--verbose-summary` is set. A moved modules path is highlighted, with a hint of the standard path, so a non-standard layout is easy to spot. +When `--modules-path-suffix` moves modules off the default `modules/` and modules were actually pulled, the summary warns about the non-default modules path, highlighting it with a hint of the standard path so a non-standard layout is easy to spot. A default path, or a moved path that no module went through, produces no warning. --- @@ -377,7 +377,7 @@ d8 mirror push /tmp/d8-bundle registry.company.com/deckhouse \ After a push, `d8 mirror` prints a framed summary of what was written to the target registry (platform, installer, security databases, module and package counts). -It always shows a **Registry** section with the target repo and the path each component was written to. When `--modules-path-suffix` moves modules off the default `modules/`, the moved path is highlighted with a hint of the standard path. +When `--modules-path-suffix` moves modules off the default `modules/` and modules were actually pushed, the summary warns about the non-default modules path, highlighting it with a hint of the standard path. A default path, or a moved path that no module went through, produces no warning. ### Environment Variables diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index 2684f019..a254021b 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -257,13 +257,11 @@ func (p *Puller) Execute(ctx context.Context) error { // Edition is parsed from the source registry path (no registry call); empty // for a custom registry, where the renderer omits the Edition line. - repoNoEdition, edition := registryservice.GetEditionFromRegistryPath(p.params.DeckhouseRegistryRepo) + _, edition := registryservice.GetEditionFromRegistryPath(p.params.DeckhouseRegistryRepo) summary.Edition = string(edition) - // Registry layout for the summary: modules honor --modules-path-suffix; the - // installer lives outside the edition root, at /installer. - installerPath := repoNoEdition + "/" + internal.InstallerSegment - summary.Registry = mirror.BuildRegistryLayout(p.params.DeckhouseRegistryRepo, p.params.ModulesPathSuffix, installerPath) + // Modules path for the summary warning; honors --modules-path-suffix. + summary.ModulesPath = mirror.BuildModulesPathReport(p.params.DeckhouseRegistryRepo, p.params.ModulesPathSuffix) // pullErr is nil for success and for a graceful Ctrl+C (which still renders a // partial summary); non-nil for a hard failure (which also renders, then diff --git a/internal/mirror/cmd/pull/summary.go b/internal/mirror/cmd/pull/summary.go index 3a14214c..5c1a8b01 100644 --- a/internal/mirror/cmd/pull/summary.go +++ b/internal/mirror/cmd/pull/summary.go @@ -73,13 +73,8 @@ var ( // ║ Edition: EE // ║ // ║ Warning: modules use a non-default path (--modules-path-suffix) -// ║ Registry: registry.deckhouse.io/deckhouse/ee -// ║ Platform registry.deckhouse.io/deckhouse/ee // ║ Modules registry.deckhouse.io/deckhouse/ee/mymods // ║ default: registry.deckhouse.io/deckhouse/ee/modules -// ║ Security registry.deckhouse.io/deckhouse/ee/security -// ║ Packages registry.deckhouse.io/deckhouse/ee/packages -// ║ Installer registry.deckhouse.io/deckhouse/installer // ║ // ║ Platform: v1.69.1 (5 channels) // ║ Installer: v1.69.1 @@ -98,10 +93,10 @@ var ( // ║ Elapsed: 3m12s // ╚═══════════════════════════════════════════════════════ // -// The Registry section appears only under --verbose-summary or when the modules -// path was moved; its Warning header only when the path was moved. The Elapsed -// line is preceded by a state line on a non-success outcome: "Pull failed; ...", -// "Pull was cancelled; ...", or "No images were downloaded (dry-run).". +// The Warning block appears only when --modules-path-suffix moved modules off +// the default and modules were actually pulled. The Elapsed line is preceded by +// a state line on a non-success outcome: "Pull failed; ...", "Pull was +// cancelled; ...", or "No images were downloaded (dry-run).". func renderPullSummary(s *mirror.PullSummary, verbose bool) string { var b strings.Builder @@ -114,9 +109,8 @@ func renderPullSummary(s *mirror.PullSummary, verbose bool) string { fmt.Fprintf(&b, "%s %s %s\n", bar(), cLabel(padLabel("Edition")), cCount(strings.ToUpper(s.Edition))) } - // Registry layout: shown when the modules path was moved off the default, or - // on request via --verbose-summary. - summaryui.WriteRegistryLayout(&b, s.Registry, verbose || s.Registry.HasOverride) + // Warn only when the modules path was moved and modules were actually pulled. + summaryui.WriteModulesPathWarning(&b, s.ModulesPath, modulesPulled(s.Modules)) writeComponent(&b, "Platform", s.Platform) writeComponent(&b, "Installer", s.Installer) @@ -251,6 +245,13 @@ func writeSecurity(b *strings.Builder, s mirror.SecurityStats) { } } +// modulesPulled reports whether at least one module was pulled (or planned, in +// dry-run). A moved modules path is only worth warning about when modules +// actually went through it. +func modulesPulled(m mirror.ModulesStats) bool { + return len(m.Modules) > 0 +} + // writeModules renders the modules line, and per-module detail when verbose. // e.g. `║ Modules: 2 · 3 VEXes`, then per module (verbose) // `║ csi-nfs (3 VEX) [v0.6.2, v0.6.1]`. diff --git a/internal/mirror/cmd/pull/summary_test.go b/internal/mirror/cmd/pull/summary_test.go index 1d82aabd..a5bf0125 100644 --- a/internal/mirror/cmd/pull/summary_test.go +++ b/internal/mirror/cmd/pull/summary_test.go @@ -421,45 +421,47 @@ func TestRenderPullSummary(t *testing.T) { skippedCount: 5, }, { - name: "registry section highlights a moved modules path without verbose", + name: "moved modules path with modules pulled is warned about", summary: &mirror.PullSummary{ Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, - Registry: mirror.BuildRegistryLayout( - "registry.deckhouse.io/deckhouse/ee", "/", - "registry.deckhouse.io/deckhouse/installer"), + Modules: mirror.ModulesStats{ + Attempted: true, + Modules: []mirror.ModuleStat{{Name: "console", Images: 1}}, + }, + ModulesPath: mirror.BuildModulesPathReport("registry.deckhouse.io/deckhouse/ee", "/"), }, verbose: false, contains: []string{ - "Registry:", "Warning: modules use a non-default path (--modules-path-suffix)", - "registry.deckhouse.io/deckhouse/ee/security", "default: registry.deckhouse.io/deckhouse/ee/modules", }, + // Only the moved modules path: no full layout, no other component paths. + notContains: []string{"Registry:", "registry.deckhouse.io/deckhouse/ee/security"}, skippedCount: -1, }, { - name: "registry section shown in verbose even at the default path", + name: "moved modules path but no modules pulled is silent even in verbose", summary: &mirror.PullSummary{ - Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, - Registry: mirror.BuildRegistryLayout( - "registry.deckhouse.io/deckhouse/ee", "/modules", - "registry.deckhouse.io/deckhouse/installer"), + Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, + Modules: mirror.ModulesStats{Attempted: true, Modules: []mirror.ModuleStat{}}, + ModulesPath: mirror.BuildModulesPathReport("registry.deckhouse.io/deckhouse/ee", "/"), }, verbose: true, - contains: []string{"Registry:", "registry.deckhouse.io/deckhouse/ee/modules"}, - notContains: []string{"default:", "Warning"}, + notContains: []string{"Warning", "default:", "Registry:"}, skippedCount: -1, }, { - name: "registry section hidden at the default path without verbose", + name: "default modules path is silent even with modules pulled", summary: &mirror.PullSummary{ Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, - Registry: mirror.BuildRegistryLayout( - "registry.deckhouse.io/deckhouse/ee", "/modules", - "registry.deckhouse.io/deckhouse/installer"), + Modules: mirror.ModulesStats{ + Attempted: true, + Modules: []mirror.ModuleStat{{Name: "console", Images: 1}}, + }, + ModulesPath: mirror.BuildModulesPathReport("registry.deckhouse.io/deckhouse/ee", "/modules"), }, - verbose: false, - notContains: []string{"Registry:"}, + verbose: true, + notContains: []string{"Warning", "default:", "Registry:"}, skippedCount: -1, }, } diff --git a/internal/mirror/cmd/push/summary.go b/internal/mirror/cmd/push/summary.go index 3f3d382b..8be716b2 100644 --- a/internal/mirror/cmd/push/summary.go +++ b/internal/mirror/cmd/push/summary.go @@ -25,9 +25,8 @@ import ( ) // renderPushSummary formats a PushSummary as a single multi-line, framed block, -// matching the pull summary's look. The registry layout section is always shown: -// telling the operator where each component landed is the point of the push -// summary, and it highlights a moved modules path (--modules-path-suffix). +// matching the pull summary's look. It warns about a moved modules path +// (--modules-path-suffix) only when modules were actually pushed to it. // // Example output for --modules-path-suffix / (colour stripped; the warning // header and the moved Modules path are yellow): @@ -35,13 +34,8 @@ import ( // ╔══ Push summary ═══════════════════════════════════════ // ║ // ║ Warning: modules use a non-default path (--modules-path-suffix) -// ║ Registry: registry.example.com/deckhouse/ee -// ║ Platform registry.example.com/deckhouse/ee // ║ Modules registry.example.com/deckhouse/ee // ║ default: registry.example.com/deckhouse/ee/modules -// ║ Security registry.example.com/deckhouse/ee/security -// ║ Packages registry.example.com/deckhouse/ee/packages -// ║ Installer registry.example.com/deckhouse/ee/installer // ║ // ║ Platform: pushed // ║ Installer: not present @@ -60,7 +54,8 @@ func renderPushSummary(s *mirror.PushSummary) string { b.WriteByte('\n') summaryui.WriteTopBorder(&b, pushSummaryTitle(s)) - summaryui.WriteRegistryLayout(&b, s.Registry, true) + // Warn only when the modules path was moved and modules were actually pushed. + summaryui.WriteModulesPathWarning(&b, s.ModulesPath, s.Modules > 0) writePushPresence(&b, "Platform", s.PlatformPushed) writePushPresence(&b, "Installer", s.InstallerPushed) diff --git a/internal/mirror/cmd/push/summary_test.go b/internal/mirror/cmd/push/summary_test.go index 3f839bb8..f5e80f43 100644 --- a/internal/mirror/cmd/push/summary_test.go +++ b/internal/mirror/cmd/push/summary_test.go @@ -38,9 +38,9 @@ func TestRenderPushSummary(t *testing.T) { notContains []string }{ { - name: "default layout lists every component", + name: "default path shows no warning", summary: &mirror.PushSummary{ - Registry: mirror.BuildRegistryLayout(root, "/modules", root+"/installer"), + ModulesPath: mirror.BuildModulesPathReport(root, "/modules"), PlatformPushed: true, InstallerPushed: true, SecurityDatabases: 4, @@ -50,43 +50,51 @@ func TestRenderPushSummary(t *testing.T) { }, contains: []string{ "Push summary", - "Registry:", root + "/modules", "Platform:", "pushed", "Security:", "4 databases", "Modules:", "12", "Packages:", "3", "Elapsed: 2m4s", }, - notContains: []string{"default:", "Warning", "failed", "cancelled", "not present"}, + notContains: []string{"Warning", "default:", "failed", "cancelled", "not present"}, }, { - name: "moved modules path is highlighted with a default hint", + name: "moved modules path with modules pushed is warned about", summary: &mirror.PushSummary{ - Registry: mirror.BuildRegistryLayout(root, "/", root+"/installer"), + ModulesPath: mirror.BuildModulesPathReport(root, "/"), PlatformPushed: true, Modules: 5, }, contains: []string{ - "Registry:", "Warning: modules use a non-default path (--modules-path-suffix)", + "Modules", root, "default: " + root + "/modules", "Installer:", "not present", // no installer.tar in this push - "Modules:", "5", }, }, + { + name: "moved modules path with no modules pushed is silent", + summary: &mirror.PushSummary{ + ModulesPath: mirror.BuildModulesPathReport(root, "/"), + PlatformPushed: true, + Modules: 0, + }, + // Nothing went through the moved path: no warning. + notContains: []string{"Warning", "default:"}, + }, { name: "failed push renders a FAILED state", summary: &mirror.PushSummary{ - Registry: mirror.BuildRegistryLayout(root, "/modules", root+"/installer"), - Failed: true, + ModulesPath: mirror.BuildModulesPathReport(root, "/modules"), + Failed: true, }, contains: []string{"Push failed", "Push failed; the above reflects what completed"}, }, { name: "cancelled push renders a cancellation state", summary: &mirror.PushSummary{ - Registry: mirror.BuildRegistryLayout(root, "/modules", root+"/installer"), - Cancelled: true, + ModulesPath: mirror.BuildModulesPathReport(root, "/modules"), + Cancelled: true, }, contains: []string{"Push was cancelled; the above reflects what completed"}, }, diff --git a/internal/mirror/modules_path.go b/internal/mirror/modules_path.go new file mode 100644 index 00000000..0b5eae64 --- /dev/null +++ b/internal/mirror/modules_path.go @@ -0,0 +1,58 @@ +/* +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 mirror + +import ( + "path" + "strings" + + "github.com/deckhouse/deckhouse-cli/internal" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// ModulesPathReport describes where modules were mirrored, so the pull/push +// summary can warn when the path was moved off the standard layout via +// --modules-path-suffix. Only the modules path is configurable today, so it is +// the only component the summary tracks. +type ModulesPathReport struct { + // Moved is true when the modules path differs from the standard "modules" + // segment (i.e. --modules-path-suffix took effect). + Moved bool + // Path is the full registry path modules were read from (pull) or written to + // (push). + Path string + // DefaultPath is the standard modules path, shown as a "default: " hint + // next to a moved path. + DefaultPath string +} + +// BuildModulesPathReport resolves the modules registry path for the summary. +// +// root is the edition root (pull) or the target repo (push). modulesPathSuffix +// is the raw --modules-path-suffix value; it is normalized the same way as the +// real push/pull path so the summary matches where images actually go. +func BuildModulesPathReport(root, modulesPathSuffix string) ModulesPathReport { + root = strings.TrimSuffix(root, "/") + + modulesPath := registryservice.NormalizeModulesPath(modulesPathSuffix) + + return ModulesPathReport{ + Moved: modulesPath != internal.ModulesSegment, + Path: path.Join(root, modulesPath), + DefaultPath: path.Join(root, internal.ModulesSegment), + } +} diff --git a/internal/mirror/modules_path_test.go b/internal/mirror/modules_path_test.go new file mode 100644 index 00000000..69426dcc --- /dev/null +++ b/internal/mirror/modules_path_test.go @@ -0,0 +1,59 @@ +/* +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 mirror + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildModulesPathReport(t *testing.T) { + const root = "registry.example.com/deckhouse/ee" + + tests := []struct { + name string + suffix string + wantPath string + wantMoved bool + }{ + {name: "flag default", suffix: "/modules", wantPath: root + "/modules", wantMoved: false}, + {name: "empty is default", suffix: "", wantPath: root + "/modules", wantMoved: false}, + {name: "bare modules is default", suffix: "modules", wantPath: root + "/modules", wantMoved: false}, + {name: "root suffix", suffix: "/", wantPath: root, wantMoved: true}, + {name: "custom single segment", suffix: "mymods", wantPath: root + "/mymods", wantMoved: true}, + {name: "custom multi segment", suffix: "my/mods", wantPath: root + "/my/mods", wantMoved: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + report := BuildModulesPathReport(root, tt.suffix) + + assert.Equal(t, tt.wantPath, report.Path) + assert.Equal(t, tt.wantMoved, report.Moved) + assert.Equal(t, root+"/modules", report.DefaultPath) + }) + } +} + +func TestBuildModulesPathReport_TrimsRootSlash(t *testing.T) { + report := BuildModulesPathReport("registry.example.com/deckhouse/ee/", "/") + + assert.Equal(t, "registry.example.com/deckhouse/ee", report.Path) + assert.Equal(t, "registry.example.com/deckhouse/ee/modules", report.DefaultPath) + assert.True(t, report.Moved) +} diff --git a/internal/mirror/push.go b/internal/mirror/push.go index f02aa7bf..cd6c4ae9 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -123,8 +123,8 @@ func NewPushService( // The key principle: no path transformations. Whatever path the layout has // in the unpacked directory becomes its path in the registry. func (svc *PushService) Push(ctx context.Context) (*PushSummary, error) { - // Registry layout is known up front, so it is on the summary even on error. - summary := &PushSummary{Registry: svc.registryLayout()} + // The modules path is known up front, so it is on the summary even on error. + summary := &PushSummary{ModulesPath: svc.modulesPathReport()} // Create unified directory for unpacking dirPath := filepath.Join(svc.options.WorkingDir, "unified") @@ -169,14 +169,10 @@ func (svc *PushService) Push(ctx context.Context) (*PushSummary, error) { return summary, nil } -// registryLayout maps each component to its target registry path for the push -// summary, honoring --modules-path-suffix. The installer repo lives at -// /installer. -func (svc *PushService) registryLayout() RegistryLayout { - root := svc.client.GetRegistry() - installerPath := path.Join(root, internal.InstallerSegment) - - return BuildRegistryLayout(root, svc.options.ModulesPathSuffix, installerPath) +// modulesPathReport resolves the target modules path for the push summary, +// honoring --modules-path-suffix. +func (svc *PushService) modulesPathReport() ModulesPathReport { + return BuildModulesPathReport(svc.client.GetRegistry(), svc.options.ModulesPathSuffix) } // modulesPath returns the registry path for module repositories, relative to diff --git a/internal/mirror/push_test.go b/internal/mirror/push_test.go index 25a8273e..44389731 100644 --- a/internal/mirror/push_test.go +++ b/internal/mirror/push_test.go @@ -144,12 +144,12 @@ func TestPushService_ModulesPathSuffix(t *testing.T) { require.NoError(t, err, "push must succeed") // Summary reflects what was pushed: one module and the install - // layout (counted as platform). Override tracks a moved modules path. + // layout (counted as platform). Moved tracks a non-default modules path. assert.Equal(t, 1, summary.Modules, "one module pushed") assert.True(t, summary.PlatformPushed, "install layout counts as platform") - wantOverride := tt.wantModule != "modules/"+moduleName - assert.Equal(t, wantOverride, summary.Registry.HasOverride, - "registry override reflects a moved modules path") + wantMoved := tt.wantModule != "modules/"+moduleName + assert.Equal(t, wantMoved, summary.ModulesPath.Moved, + "modules path report reflects a moved modules path") ctx := context.Background() diff --git a/internal/mirror/registry_layout.go b/internal/mirror/registry_layout.go deleted file mode 100644 index b124b501..00000000 --- a/internal/mirror/registry_layout.go +++ /dev/null @@ -1,90 +0,0 @@ -/* -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 mirror - -import ( - "path" - "strings" - - "github.com/deckhouse/deckhouse-cli/internal" - registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" -) - -// RegistryPathRow is one component's location in the registry, for the summary -// layout section. -type RegistryPathRow struct { - // Label is the component name, e.g. "Platform", "Modules". - Label string - // Path is the full registry path where the component lives. - Path string - // NonDefault marks a path the user moved off the standard layout. Only the - // modules path is configurable today (via --modules-path-suffix), so only it - // can be non-default; the model stays general for future configurable paths. - NonDefault bool - // DefaultPath is the standard path for this component. Filled only when - // NonDefault, so the summary can show "default: ". - DefaultPath string -} - -// RegistryLayout is where each mirrored component resolves in the registry, for -// display in the pull/push summary. Root is the edition root on pull and the -// target repo on push. -type RegistryLayout struct { - Root string - Rows []RegistryPathRow - HasOverride bool -} - -// BuildRegistryLayout maps every mirrored component to its registry path. -// -// root is the edition root (pull) or the target repo (push). modulesPathSuffix -// is the raw --modules-path-suffix value; it is normalized the same way as the -// real push/pull path so the summary matches where images actually go. Only the -// modules path can differ from the standard layout, so only it is flagged -// NonDefault. -// -// installerPath is the full registry path of the installer images, which live -// outside the edition root; pass "" to omit the Installer row (push does not -// create an installer repo). -func BuildRegistryLayout(root, modulesPathSuffix, installerPath string) RegistryLayout { - root = strings.TrimSuffix(root, "/") - - modulesPath := registryservice.NormalizeModulesPath(modulesPathSuffix) - modulesNonDefault := modulesPath != internal.ModulesSegment - - rows := []RegistryPathRow{ - {Label: "Platform", Path: root}, - { - Label: "Modules", - Path: path.Join(root, modulesPath), - NonDefault: modulesNonDefault, - DefaultPath: path.Join(root, internal.ModulesSegment), - }, - {Label: "Security", Path: path.Join(root, internal.SecuritySegment)}, - {Label: "Packages", Path: path.Join(root, internal.PackagesSegment)}, - } - - if installerPath != "" { - rows = append(rows, RegistryPathRow{Label: "Installer", Path: installerPath}) - } - - return RegistryLayout{ - Root: root, - Rows: rows, - HasOverride: modulesNonDefault, - } -} diff --git a/internal/mirror/registry_layout_test.go b/internal/mirror/registry_layout_test.go deleted file mode 100644 index a1afa45a..00000000 --- a/internal/mirror/registry_layout_test.go +++ /dev/null @@ -1,106 +0,0 @@ -/* -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 mirror - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func rowByLabel(t *testing.T, layout RegistryLayout, label string) RegistryPathRow { - t.Helper() - - for _, row := range layout.Rows { - if row.Label == label { - return row - } - } - - t.Fatalf("row %q not found in layout", label) - - return RegistryPathRow{} -} - -func TestBuildRegistryLayout_Modules(t *testing.T) { - const root = "registry.example.com/deckhouse/ee" - - tests := []struct { - name string - suffix string - wantPath string - wantNonDefault bool - }{ - {name: "flag default", suffix: "/modules", wantPath: root + "/modules", wantNonDefault: false}, - {name: "empty is default", suffix: "", wantPath: root + "/modules", wantNonDefault: false}, - {name: "bare modules is default", suffix: "modules", wantPath: root + "/modules", wantNonDefault: false}, - {name: "root suffix", suffix: "/", wantPath: root, wantNonDefault: true}, - {name: "custom single segment", suffix: "mymods", wantPath: root + "/mymods", wantNonDefault: true}, - {name: "custom multi segment", suffix: "my/mods", wantPath: root + "/my/mods", wantNonDefault: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - layout := BuildRegistryLayout(root, tt.suffix, "") - - modules := rowByLabel(t, layout, "Modules") - assert.Equal(t, tt.wantPath, modules.Path) - assert.Equal(t, tt.wantNonDefault, modules.NonDefault) - assert.Equal(t, root+"/modules", modules.DefaultPath) - assert.Equal(t, tt.wantNonDefault, layout.HasOverride) - }) - } -} - -func TestBuildRegistryLayout_FixedComponents(t *testing.T) { - const root = "registry.example.com/deckhouse/ee" - - layout := BuildRegistryLayout(root, "/modules", "") - - assert.Equal(t, root, layout.Root) - assert.Equal(t, root, rowByLabel(t, layout, "Platform").Path) - assert.Equal(t, root+"/security", rowByLabel(t, layout, "Security").Path) - assert.Equal(t, root+"/packages", rowByLabel(t, layout, "Packages").Path) - - // Fixed components never carry an override flag. - for _, label := range []string{"Platform", "Security", "Packages"} { - assert.False(t, rowByLabel(t, layout, label).NonDefault, "%s must be default", label) - } -} - -func TestBuildRegistryLayout_InstallerRow(t *testing.T) { - const root = "registry.example.com/deckhouse/ee" - - // Empty installer path omits the row. - layout := BuildRegistryLayout(root, "/modules", "") - for _, row := range layout.Rows { - require.NotEqual(t, "Installer", row.Label) - } - - // A supplied installer path adds the row verbatim (it lives outside root). - const installer = "registry.example.com/deckhouse/installer" - layout = BuildRegistryLayout(root, "/modules", installer) - assert.Equal(t, installer, rowByLabel(t, layout, "Installer").Path) -} - -func TestBuildRegistryLayout_TrimsRootSlash(t *testing.T) { - layout := BuildRegistryLayout("registry.example.com/deckhouse/ee/", "/", "") - - assert.Equal(t, "registry.example.com/deckhouse/ee", layout.Root) - assert.Equal(t, "registry.example.com/deckhouse/ee", rowByLabel(t, layout, "Modules").Path) -} diff --git a/internal/mirror/summary.go b/internal/mirror/summary.go index c5789980..3136355a 100644 --- a/internal/mirror/summary.go +++ b/internal/mirror/summary.go @@ -141,10 +141,10 @@ type PullSummary struct { // registry path. Empty for a custom registry with no edition segment, in // which case the summary omits the Edition line. Edition string - // Registry is where each component reads from in the source registry. The - // summary shows it when the modules path was moved off the default, or in - // verbose mode. Filled by the CLI. - Registry RegistryLayout + // ModulesPath is where modules were read from in the source registry. The + // summary warns about it only when the path was moved off the default and + // modules were actually pulled. Filled by the CLI. + ModulesPath ModulesPathReport // Elapsed is the wall-clock duration of the pull, filled by the CLI. Elapsed time.Duration @@ -168,9 +168,10 @@ type PushSummary struct { // Failed marks a hard-error abort. The summary still renders, in a FAILED // state. Mutually exclusive with Cancelled. Failed bool - // Registry is where each component was written in the target registry, with a - // moved modules path (--modules-path-suffix) highlighted. Filled by PushService. - Registry RegistryLayout + // ModulesPath is where modules were written in the target registry. The + // summary warns about it only when the path was moved off the default and + // modules were actually pushed. Filled by PushService. + ModulesPath ModulesPathReport // Elapsed is the wall-clock duration of the push, filled by the CLI. Elapsed time.Duration diff --git a/internal/mirror/summaryui/summaryui.go b/internal/mirror/summaryui/summaryui.go index 3e6a5004..70fae7be 100644 --- a/internal/mirror/summaryui/summaryui.go +++ b/internal/mirror/summaryui/summaryui.go @@ -40,7 +40,7 @@ const ( NameWidth = 30 // SizeWidth right-aligns bundle artifact sizes. SizeWidth = 10 - // layoutNameWidth left-aligns component names in the registry layout section. + // layoutNameWidth left-aligns the "Modules" label in the moved-path warning. layoutNameWidth = 10 ) @@ -127,59 +127,40 @@ func HumanSize(n int64) string { return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), units[exp]) } -// WriteRegistryLayout writes the registry layout section: the root registry and -// where each component lives, with any non-default path (a moved modules path) -// in yellow plus a dimmed hint of the standard path. Writes nothing when show is -// false. +// WriteModulesPathWarning warns that modules were mirrored to a non-default +// registry path (--modules-path-suffix): the moved path in yellow plus a dimmed +// hint of the standard path. // -// The block is wrapped in blank lines to set it apart. A moved modules path adds -// a plain-text warning header and highlights the Modules line. +// It writes nothing unless the path was moved AND modules were actually +// transferred (transferred=true). A moved path is only worth flagging when +// modules went through it; a run that pulled or pushed no modules needs no +// warning. +// +// The block is wrapped in blank lines to set it apart. // // Example output for --modules-path-suffix mymods (colour stripped; the warning // header and the Modules path are yellow): // // ║ // ║ Warning: modules use a non-default path (--modules-path-suffix) -// ║ Registry: registry.deckhouse.io/deckhouse/ee -// ║ Platform registry.deckhouse.io/deckhouse/ee // ║ Modules registry.deckhouse.io/deckhouse/ee/mymods // ║ default: registry.deckhouse.io/deckhouse/ee/modules -// ║ Security registry.deckhouse.io/deckhouse/ee/security -// ║ Packages registry.deckhouse.io/deckhouse/ee/packages -// ║ Installer registry.deckhouse.io/deckhouse/installer // ║ -// -// With a default modules path the warning header is omitted and the Modules line -// is plain. -func WriteRegistryLayout(b *strings.Builder, layout mirror.RegistryLayout, show bool) { - // No rows means an unpopulated layout; never emit a bare header. - if !show || len(layout.Rows) == 0 { +func WriteModulesPathWarning(b *strings.Builder, m mirror.ModulesPathReport, transferred bool) { + if !m.Moved || !transferred { return } // Blank lines above and below set the block apart from the summary stats. b.WriteString(Bar() + "\n") - // A moved modules path is unusual; call it out in plain text, not by colour - // alone (colour is lost in piped logs and to colour-blind readers). - if layout.HasOverride { - fmt.Fprintf(b, "%s %s\n", Bar(), Warn("Warning: modules use a non-default path (--modules-path-suffix)")) - } - - fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(PadLabel("Registry")), layout.Root) + // Call it out in plain text, not by colour alone (colour is lost in piped + // logs and to colour-blind readers). + fmt.Fprintf(b, "%s %s\n", Bar(), Warn("Warning: modules use a non-default path (--modules-path-suffix)")) - for _, row := range layout.Rows { - name := fmt.Sprintf("%-*s", layoutNameWidth, row.Label) - - if row.NonDefault { - fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(name), Warn(row.Path)) - fmt.Fprintf(b, "%s %s %s\n", Bar(), strings.Repeat(" ", layoutNameWidth), Dim("default: "+row.DefaultPath)) - - continue - } - - fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(name), row.Path) - } + name := fmt.Sprintf("%-*s", layoutNameWidth, "Modules") + fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(name), Warn(m.Path)) + fmt.Fprintf(b, "%s %s %s\n", Bar(), strings.Repeat(" ", layoutNameWidth), Dim("default: "+m.DefaultPath)) b.WriteString(Bar() + "\n") } diff --git a/internal/mirror/summaryui/summaryui_test.go b/internal/mirror/summaryui/summaryui_test.go index e6a1064a..7212f278 100644 --- a/internal/mirror/summaryui/summaryui_test.go +++ b/internal/mirror/summaryui/summaryui_test.go @@ -28,60 +28,65 @@ import ( const layoutRoot = "registry.example.com/deckhouse/ee" -func renderLayout(t *testing.T, l mirror.RegistryLayout, show bool) string { +func renderWarning(t *testing.T, m mirror.ModulesPathReport, transferred bool) string { t.Helper() var b strings.Builder - WriteRegistryLayout(&b, l, show) + WriteModulesPathWarning(&b, m, transferred) return b.String() } -func TestWriteRegistryLayout_Hidden(t *testing.T) { - out := renderLayout(t, mirror.BuildRegistryLayout(layoutRoot, "/", ""), false) - require.Empty(t, out, "show=false writes nothing") +func TestWriteModulesPathWarning_DefaultPath(t *testing.T) { + orig := color.NoColor + defer func() { color.NoColor = orig }() + color.NoColor = true + + // A default modules path is never worth a warning, even when modules moved. + out := renderWarning(t, mirror.BuildModulesPathReport(layoutRoot, "/modules"), true) + require.Empty(t, out, "default path writes nothing") } -func TestWriteRegistryLayout_Default(t *testing.T) { +func TestWriteModulesPathWarning_MovedButNoModules(t *testing.T) { orig := color.NoColor defer func() { color.NoColor = orig }() color.NoColor = true - out := renderLayout(t, mirror.BuildRegistryLayout(layoutRoot, "/modules", ""), true) - - require.Contains(t, out, "Registry:") - require.Contains(t, out, layoutRoot) - for _, label := range []string{"Platform", "Modules", "Security", "Packages"} { - require.Contains(t, out, label) - } - require.Contains(t, out, layoutRoot+"/modules") - require.NotContains(t, out, "default:", "no default hint without an override") - require.NotContains(t, out, "Warning", "no warning header without an override") + // Path moved, but nothing went through it: no warning. + out := renderWarning(t, mirror.BuildModulesPathReport(layoutRoot, "/"), false) + require.Empty(t, out, "a moved path with no transferred modules writes nothing") } -func TestWriteRegistryLayout_OverrideHint(t *testing.T) { +func TestWriteModulesPathWarning_MovedWithModules(t *testing.T) { orig := color.NoColor defer func() { color.NoColor = orig }() color.NoColor = true // "/" places modules at the repo root; the hint points at the standard path. - out := renderLayout(t, mirror.BuildRegistryLayout(layoutRoot, "/", ""), true) + out := renderWarning(t, mirror.BuildModulesPathReport(layoutRoot, "/"), true) require.Contains(t, out, "Warning: modules use a non-default path (--modules-path-suffix)") + require.Contains(t, out, "Modules") require.Contains(t, out, "default: "+layoutRoot+"/modules") + + // Only the moved modules path is reported: no full layout, no other rows. + require.NotContains(t, out, "Registry:") + for _, label := range []string{"Platform", "Security", "Packages", "Installer"} { + require.NotContains(t, out, label) + } } -func TestWriteRegistryLayout_OverrideColor(t *testing.T) { +func TestWriteModulesPathWarning_Color(t *testing.T) { orig := color.NoColor defer func() { color.NoColor = orig }() - l := mirror.BuildRegistryLayout(layoutRoot, "mymods", "") + m := mirror.BuildModulesPathReport(layoutRoot, "mymods") color.NoColor = false - require.Contains(t, renderLayout(t, l, true), "\x1b[", + require.Contains(t, renderWarning(t, m, true), "\x1b[", "non-default modules path must be highlighted when colour is enabled") color.NoColor = true - require.NotContains(t, renderLayout(t, l, true), "\x1b[", + require.NotContains(t, renderWarning(t, m, true), "\x1b[", "no ANSI escape codes when colour is disabled") }