Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a8ff9b8
Plumb --modules-path-suffix into the new push service
Glitchy-Sheep Jul 20, 2026
2a66d45
Honor --modules-path-suffix when pushing modules
Glitchy-Sheep Jul 20, 2026
a102e13
Add tests for mirror push modules path suffix
Glitchy-Sheep Jul 20, 2026
3437039
Fix modules-path-suffix flag help text for push
Glitchy-Sheep Jul 20, 2026
c3a7f92
Make modules path segment configurable in registry service
Glitchy-Sheep Jul 21, 2026
5e3f0aa
Honor modules path suffix in pull module references
Glitchy-Sheep Jul 21, 2026
65d28a1
Wire --modules-path-suffix into mirror pull
Glitchy-Sheep Jul 21, 2026
0c099d1
Add pull suffix tests
Glitchy-Sheep Jul 21, 2026
cf92a2b
Document --modules-path-suffix on pull for read side
Glitchy-Sheep Jul 21, 2026
5a4acfe
Remove dead modules-access check with stale suffix model
Glitchy-Sheep Jul 21, 2026
a1cb8f6
Extract shared path helper and rename modules path
Glitchy-Sheep Jul 21, 2026
396d1c9
Add RegistryLayout for summary path display
Glitchy-Sheep Jul 23, 2026
f4c6f38
Extract shared summary UI into summaryui package
Glitchy-Sheep Jul 23, 2026
5e7b6b7
Show registry layout in pull summary
Glitchy-Sheep Jul 23, 2026
d5c2bc6
Add push summary with registry layout
Glitchy-Sheep Jul 23, 2026
439bc00
Document registry layout in mirror summary
Glitchy-Sheep Jul 23, 2026
4ea4739
Format with task lint
Glitchy-Sheep Jul 23, 2026
2482ef4
Flag non-default modules path in mirror summary
Glitchy-Sheep Jul 23, 2026
f7410d9
Warn only about a used moved modules path in mirror summary
Glitchy-Sheep Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions internal/mirror/README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ d8 mirror pull <images-bundle-path> [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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -346,7 +352,7 @@ d8 mirror push <images-bundle-path> <registry> [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

Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion internal/mirror/cmd/pull/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 8 additions & 17 deletions internal/mirror/cmd/pull/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"log/slog"
"os"
"os/signal"
"path"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
}
Expand All @@ -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{
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 0 additions & 25 deletions internal/mirror/cmd/pull/pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: &params.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
Expand Down
175 changes: 86 additions & 89 deletions internal/mirror/cmd/pull/summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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"))

Expand All @@ -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"))

Expand Down Expand Up @@ -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"))

Expand Down Expand Up @@ -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))))

Expand Down Expand Up @@ -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])
}
Loading
Loading