diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index 49b0923aa..5c53e00d1 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 @@ -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. + +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. + --- ## d8 mirror push @@ -346,7 +352,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 @@ -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). + +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 The same environment variables used by `d8 mirror pull` are also supported: diff --git a/internal/mirror/cmd/pull/flags/flags.go b/internal/mirror/cmd/pull/flags/flags.go index ad581dbf7..9d4eb2da8 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, diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index 332313fee..a254021bc 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" @@ -261,6 +260,9 @@ func (p *Puller) Execute(ctx context.Context) error { _, edition := registryservice.GetEditionFromRegistryPath(p.params.DeckhouseRegistryRepo) summary.Edition = string(edition) + // 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 // propagates so the exit code is non-zero). @@ -366,7 +368,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) } @@ -384,7 +387,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{ @@ -456,20 +461,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 1d74049c8..8c7be9558 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 diff --git a/internal/mirror/cmd/pull/summary.go b/internal/mirror/cmd/pull/summary.go index f2e225267..5c1a8b016 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,38 @@ 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 with --modules-path-suffix mymods, colour +// 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) +// ║ Modules registry.deckhouse.io/deckhouse/ee/mymods +// ║ default: registry.deckhouse.io/deckhouse/ee/modules +// ║ +// ║ 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 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 @@ -94,6 +109,9 @@ func renderPullSummary(s *mirror.PullSummary, verbose bool) string { fmt.Fprintf(&b, "%s %s %s\n", bar(), cLabel(padLabel("Edition")), cCount(strings.ToUpper(s.Edition))) } + // 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) writeSecurity(&b, s.Security) @@ -136,16 +154,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 +219,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 +245,16 @@ 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]`. func writeModules(b *strings.Builder, m mirror.ModulesStats, verbose bool) { label := cLabel(padLabel("Modules")) @@ -284,6 +308,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 +364,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 +402,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/cmd/pull/summary_test.go b/internal/mirror/cmd/pull/summary_test.go index 837c5faa6..d166e44fa 100644 --- a/internal/mirror/cmd/pull/summary_test.go +++ b/internal/mirror/cmd/pull/summary_test.go @@ -420,6 +420,51 @@ func TestRenderPullSummary(t *testing.T) { notContains: []string{"Bundle artifacts", "VEX", "not pulled"}, skippedCount: 5, }, + { + name: "moved modules path with modules pulled is warned about", + summary: &mirror.PullSummary{ + Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, + Modules: mirror.ModulesStats{ + Attempted: true, + Modules: []mirror.ModuleStat{{Name: "console", Images: 1}}, + }, + ModulesPath: mirror.BuildModulesPathReport("registry.deckhouse.io/deckhouse/ee", "/"), + }, + verbose: false, + contains: []string{ + "Warning: modules use a non-default path (--modules-path-suffix)", + "Root Segment: registry.deckhouse.io/deckhouse/ee", + "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: "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"}}, + Modules: mirror.ModulesStats{Attempted: true, Modules: []mirror.ModuleStat{}}, + ModulesPath: mirror.BuildModulesPathReport("registry.deckhouse.io/deckhouse/ee", "/"), + }, + verbose: true, + notContains: []string{"Warning", "default:", "Registry:"}, + skippedCount: -1, + }, + { + name: "default modules path is silent even with modules pulled", + summary: &mirror.PullSummary{ + Platform: mirror.ComponentStats{Attempted: true, Versions: []string{"v1.69.0"}}, + Modules: mirror.ModulesStats{ + Attempted: true, + Modules: []mirror.ModuleStat{{Name: "console", Images: 1}}, + }, + ModulesPath: mirror.BuildModulesPathReport("registry.deckhouse.io/deckhouse/ee", "/modules"), + }, + verbose: true, + notContains: []string{"Warning", "default:", "Registry:"}, + skippedCount: -1, + }, } for _, tt := range tests { diff --git a/internal/mirror/cmd/push/flags.go b/internal/mirror/cmd/push/flags.go index cebe7de8c..a67df6b8d 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, diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index bac7993c1..b78cde2be 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 != "" { @@ -257,25 +260,46 @@ 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), ) - 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 000000000..8be716b2e --- /dev/null +++ b/internal/mirror/cmd/push/summary.go @@ -0,0 +1,127 @@ +/* +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. 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): +// +// ╔══ Push summary ═══════════════════════════════════════ +// ║ +// ║ Warning: modules use a non-default path (--modules-path-suffix) +// ║ Modules registry.example.com/deckhouse/ee +// ║ default: registry.example.com/deckhouse/ee/modules +// ║ +// ║ 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)) + + // 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) + 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 000000000..966134acf --- /dev/null +++ b/internal/mirror/cmd/push/summary_test.go @@ -0,0 +1,120 @@ +/* +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 path shows no warning", + summary: &mirror.PushSummary{ + ModulesPath: mirror.BuildModulesPathReport(root, "/modules"), + PlatformPushed: true, + InstallerPushed: true, + SecurityDatabases: 4, + Modules: 12, + Packages: 3, + Elapsed: 2*time.Minute + 4*time.Second, + }, + contains: []string{ + "Push summary", + "Platform:", "pushed", + "Security:", "4 databases", + "Modules:", "12", + "Packages:", "3", + "Elapsed: 2m4s", + }, + notContains: []string{"Warning", "default:", "failed", "cancelled", "not present"}, + }, + { + name: "moved modules path with modules pushed is warned about", + summary: &mirror.PushSummary{ + ModulesPath: mirror.BuildModulesPathReport(root, "/"), + PlatformPushed: true, + Modules: 5, + }, + contains: []string{ + "Warning: modules use a non-default path (--modules-path-suffix)", + "Root Segment: " + root, + "Modules", root, + "default: " + root + "/modules", + "Installer:", "not present", // no installer.tar in this push + }, + }, + { + 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{ + 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{ + ModulesPath: mirror.BuildModulesPathReport(root, "/modules"), + 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/modules/modules.go b/internal/mirror/modules/modules.go index aa57ee2ce..66fb1706e 100644 --- a/internal/mirror/modules/modules.go +++ b/internal/mirror/modules/modules.go @@ -109,9 +109,15 @@ 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 + // 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 // userLogger is for user-facing informational messages @@ -138,10 +144,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 +156,37 @@ func NewService( pullerService: puller.NewPullerService(logger, userLogger), options: options, rootURL: rootURL, + modulesPath: registryService.GetModulesPath(), moduleStats: make(map[moduleName]modulePullStat), logger: logger, userLogger: userLogger, } } +// 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": +// - modulesPath "modules" (default): +// moduleRegistryPath("csi") -> ".../deckhouse/ee/modules/csi" +// moduleRegistryPath("csi", "release") -> ".../deckhouse/ee/modules/csi/release" +// - 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.modulesPath != "" { + parts = append(parts, svc.modulesPath) + } + + 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 +267,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 +395,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 +424,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 +459,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 +627,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 +644,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 +672,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 +951,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 +1000,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 diff --git a/internal/mirror/modules/pull_suffix_test.go b/internal/mirror/modules/pull_suffix_test.go new file mode 100644 index 000000000..43e7d66ee --- /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")) + }) + } +} diff --git a/internal/mirror/modules_path.go b/internal/mirror/modules_path.go new file mode 100644 index 000000000..e7d4c6881 --- /dev/null +++ b/internal/mirror/modules_path.go @@ -0,0 +1,62 @@ +/* +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 + // Root is the registry base repo the modules path is rooted at: the edition + // root (pull) or the target repo (push), without the modules segment. + Root string + // 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, + Root: root, + 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 000000000..22f13f4bf --- /dev/null +++ b/internal/mirror/modules_path_test.go @@ -0,0 +1,61 @@ +/* +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, report.Root) + 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", report.Root) + 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 70b16198a..cd6c4ae9a 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" @@ -38,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 ( @@ -50,6 +53,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. @@ -115,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) { + // 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") 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() { @@ -132,27 +142,62 @@ 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 +} + +// 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 +// 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 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. +func (svc *PushService) remapModulesSegment(segment string) string { + modulesPrefix := internal.ModulesSegment + "/" + + switch { + case segment == internal.ModulesSegment: + return svc.modulesPath() + case strings.HasPrefix(segment, modulesPrefix): + return path.Join(svc.modulesPath(), strings.TrimPrefix(segment, modulesPrefix)) + default: + return segment + } } // unpackAllPackages unpacks all tar packages into the unified directory. @@ -226,7 +271,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) @@ -244,7 +289,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 } } @@ -279,7 +324,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 { @@ -294,27 +339,27 @@ 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 + // Classify by the bundle segment, before the modules remap rewrites it. + origSegment := segment - if segment != "" { - // Apply each path component as a segment - for _, seg := range strings.Split(segment, string(os.PathSeparator)) { - targetClient = targetClient.WithSegment(seg) - } - } + // Rewrite the leading "modules" component to honor --modules-path-suffix. + segment = svc.remapModulesSegment(segment) + + targetClient := svc.client.WithSegment(pkgclient.PathToSegments(segment)...) svc.userLogger.Infof("Pushing %s", targetClient.GetRegistry()) @@ -322,9 +367,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) @@ -345,7 +408,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 @@ -374,10 +437,12 @@ 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)) - // 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 := svc.client.WithSegment(pkgclient.PathToSegments(svc.modulesPath())...) // Push a small random image for each module with tag = module name for _, moduleName := range moduleNames { @@ -407,7 +472,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 @@ -436,6 +501,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 2170890bb..443897316 100644 --- a/internal/mirror/push_test.go +++ b/internal/mirror/push_test.go @@ -17,10 +17,24 @@ limitations under the License. package mirror import ( + "context" + "log/slog" + "os" + "path" "path/filepath" "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" + 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 +70,113 @@ 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 +} + +// 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) + 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). 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") + wantMoved := tt.wantModule != "modules/"+moduleName + assert.Equal(t, wantMoved, summary.ModulesPath.Moved, + "modules path report reflects a moved modules path") + + ctx := context.Background() + + // Module images land at /:. + 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 := destClient.WithSegment(pkgclient.PathToSegments(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 { + 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. + 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/internal/mirror/repro_lost_image_test.go b/internal/mirror/repro_lost_image_test.go index aae9c01d8..8cf2feb6a 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 ab398d1e2..3136355a6 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 + // 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 @@ -153,3 +157,33 @@ 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 + // 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 + + // 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 +} diff --git a/internal/mirror/summaryui/summaryui.go b/internal/mirror/summaryui/summaryui.go new file mode 100644 index 000000000..639b276ce --- /dev/null +++ b/internal/mirror/summaryui/summaryui.go @@ -0,0 +1,174 @@ +/* +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 the "Root Segment:" and "Modules" labels in the + // moved-path warning so their values share one column. + layoutNameWidth = 13 +) + +// 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]) +} + +// 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. +// +// 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. +// +// Root Segment names the registry base the modules path is rooted at, so the +// moved path reads as root + suffix against the default root + modules. +// +// 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) +// ║ Root Segment: registry.deckhouse.io/deckhouse/ee +// ║ Modules registry.deckhouse.io/deckhouse/ee/mymods +// ║ default: registry.deckhouse.io/deckhouse/ee/modules +// ║ +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") + + // 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)")) + + rootLabel := fmt.Sprintf("%-*s", layoutNameWidth, "Root Segment:") + fmt.Fprintf(b, "%s %s %s\n", Bar(), Label(rootLabel), m.Root) + + 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 new file mode 100644 index 000000000..b0ad3d3e5 --- /dev/null +++ b/internal/mirror/summaryui/summaryui_test.go @@ -0,0 +1,94 @@ +/* +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 renderWarning(t *testing.T, m mirror.ModulesPathReport, transferred bool) string { + t.Helper() + + var b strings.Builder + WriteModulesPathWarning(&b, m, transferred) + + return b.String() +} + +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 TestWriteModulesPathWarning_MovedButNoModules(t *testing.T) { + orig := color.NoColor + defer func() { color.NoColor = orig }() + color.NoColor = true + + // 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 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 := renderWarning(t, mirror.BuildModulesPathReport(layoutRoot, "/"), true) + + require.Contains(t, out, "Warning: modules use a non-default path (--modules-path-suffix)") + require.Contains(t, out, "Root Segment:") + require.Contains(t, out, layoutRoot) + 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 TestWriteModulesPathWarning_Color(t *testing.T) { + orig := color.NoColor + defer func() { color.NoColor = orig }() + + m := mirror.BuildModulesPathReport(layoutRoot, "mymods") + + color.NoColor = false + 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, renderWarning(t, m, true), "\x1b[", + "no ANSI escape codes when colour is disabled") +} diff --git a/pkg/registry/client/client.go b/pkg/registry/client/client.go index 0518a1fb9..be99bc38b 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 9f4fd9d9f..949fc9e59 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,14 +48,49 @@ type Service struct { security *SecurityServices installer *InstallerServices + // 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 } +// Option configures a Service at construction time. +type Option func(*Service) + +// 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.modulesPath = NormalizeModulesPath(suffix) + } +} + +// 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 + } + + return strings.Trim(suffix, "/") +} + // 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, + modulesPath: moduleSegment, + } + + for _, opt := range opts { + opt(s) } // base is scoped to the edition sub-path when an edition is specified. @@ -68,7 +104,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(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")) @@ -99,6 +135,14 @@ func (s *Service) ModuleService() *ModulesService { return s.modulesService } +// 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 func (s *Service) PackageService() *PackagesService { return s.packagesService