From 98d30db4df294e62230f6f7044e67acc72fdcd07 Mon Sep 17 00:00:00 2001 From: Ruslan Usmanov Date: Tue, 23 Jun 2026 15:59:02 +0300 Subject: [PATCH 1/8] ++ Signed-off-by: Ruslan Usmanov --- Taskfile.yml | 49 ---------- internal/mirror/cmd/push/flags.go | 6 ++ internal/mirror/cmd/push/push.go | 7 ++ internal/mirror/cmd/push/validation.go | 118 +++++++++++++++++++++---- internal/mirror/push.go | 80 ++++++++++++----- 5 files changed, 175 insertions(+), 85 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index ce12f46b..6a2bf54c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -302,52 +302,3 @@ tasks: desc: Clean all binaries cmds: - rm -rf ./build ./dist ./d8 - - release:tag: - desc: Tag current main branch state as new release. Provide version tag as argument (eg. task {{.TASK}} -- v1.2.3). - preconditions: - - sh: test $(git rev-parse --abbrev-ref HEAD) = "main" - msg: This can only be done on main branch - - sh: '[[ "{{.CLI_ARGS}}" =~ ^v\d+.\d+.\d+$ ]] || exit 1' - msg: Provide valid semver prefixed with "v" (eg. task {{.TASK}} -- v1.2.3) to be used as version - prompt: - - "Tag current main branch state as new release? ({{.CLI_ARGS}})" - - "Have you created Github release draft for {{.CLI_ARGS}} tag?" - cmds: - - git tag -s {{.CLI_ARGS}} -m 'Signed {{.CLI_ARGS}} release' - - git push origin {{.CLI_ARGS}} - - echo "Release tag created, now publish your Github release so that CI can upload build artifacts to it." - - release:sign: - desc: "Sign last version tag + origin/main and push signatures." - deps: [checkKubectl] - preconditions: - - sh: '[[ "{{.CLI_ARGS}}" =~ ^v\d+.\d+.\d+$ ]] || exit 1' - msg: Provide valid semver prefixed with "v" (eg. task {{.TASK}} -- v1.2.3) to be used as version - cmds: - - git fetch --tags -f - - git signatures pull - - | - for ref in {{.refs | default "$(git tag --sort=v:refname | tail -n1) origin/main"}}; do - echo "Signing $ref..." - git signatures add {{.CLI_ARGS}} $ref - git signatures show {{.CLI_ARGS}} $ref - done - - git signatures push - - release:publish-trdl-channels: - desc: Publish release channels to TRDL - deps: [checkKubectl] - preconditions: - - sh: test $(git rev-parse --abbrev-ref HEAD) = "main" - msg: This can only be done on main branch - prompt: - - "Have you updated trdl_channels.yaml with new versions?" - cmds: - - git add trdl_channels.yaml - - git commit -S -m 'Signed release channels' - - git push - - git fetch - - git signatures pull - - git signatures add origin/main - - git signatures push diff --git a/internal/mirror/cmd/push/flags.go b/internal/mirror/cmd/push/flags.go index 49a83d56..cebe7de8 100644 --- a/internal/mirror/cmd/push/flags.go +++ b/internal/mirror/cmd/push/flags.go @@ -63,6 +63,12 @@ func addFlags(flagSet *pflag.FlagSet) { "/modules", "Suffix to append to source repo path to locate modules.", ) + flagSet.StringArrayVar( + &Files, + "file", + nil, + "`Path` to a tar or chunked package to push. May be repeated to push multiple packages. Can be used instead of the bundle directory argument or combined with it.", + ) } func ParseEnvironmentVariables() { diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index ef27e21d..715053f9 100644 --- a/internal/mirror/cmd/push/push.go +++ b/internal/mirror/cmd/push/push.go @@ -56,6 +56,11 @@ var ( Insecure bool TLSSkipVerify bool ImagesBundlePath string + Files []string + + // Packages holds the resolved list of tar/chunked package archive paths to push, + // assembled from the bundle directory argument and/or the --file flag. + Packages []string MirrorTimeout time.Duration = -1 ) @@ -92,6 +97,7 @@ func NewCommand() *cobra.Command { Use: "push ", Short: "Copy Deckhouse Kubernetes Platform distribution to the third-party registry", Long: pushLong, + Args: cobra.RangeArgs(1, 2), ValidArgs: []string{"images-bundle-path", "registry"}, SilenceErrors: true, SilenceUsage: true, @@ -239,6 +245,7 @@ func (p *Pusher) executeNewPush() error { client, &mirror.PushServiceOptions{ BundleDir: p.pushParams.BundleDir, + Packages: Packages, WorkingDir: p.pushParams.WorkingDir, }, logger.Named("push"), diff --git a/internal/mirror/cmd/push/validation.go b/internal/mirror/cmd/push/validation.go index 6a08ae5b..5e53aa65 100644 --- a/internal/mirror/cmd/push/validation.go +++ b/internal/mirror/cmd/push/validation.go @@ -32,12 +32,23 @@ import ( ) func parseAndValidateParameters(_ *cobra.Command, args []string) error { - if len(args) != 2 { - return errors.New("invalid number of arguments, expected 2") + // The registry is always the last argument. The bundle path is an optional + // first argument; when it is omitted, packages must be provided via --file. + var registryArg string + var bundleArg []string + + switch len(args) { + case 1: + registryArg = args[0] + case 2: + bundleArg = args[:1] + registryArg = args[1] + default: + return errors.New("invalid number of arguments, expected with an optional bundle path before it") } var err error - if err = parseAndValidateRegistryURLArg(args); err != nil { + if err = parseAndValidateRegistryURLArg(registryArg); err != nil { return err } @@ -45,15 +56,45 @@ func parseAndValidateParameters(_ *cobra.Command, args []string) error { return err } - if err = validateImagesBundlePathArg(args); err != nil { + if err = resolvePackages(bundleArg); err != nil { return err } return nil } -func validateImagesBundlePathArg(args []string) error { - ImagesBundlePath = filepath.Clean(args[0]) +// resolvePackages builds the list of package archives to push from the optional +// bundle path argument and the --file flag, then sets the default temp dir. +func resolvePackages(bundleArg []string) error { + Packages = nil + + if len(bundleArg) == 1 { + if err := collectBundlePathPackages(bundleArg[0]); err != nil { + return err + } + } + + if err := collectFilesPackages(); err != nil { + return err + } + + if len(Packages) == 0 { + return errors.New("no packages to push: specify a bundle directory before registry URL, or use --file to specify tar/chunked package") + } + + Packages = lo.Uniq(Packages) + + if TempDir == "" { + TempDir = filepath.Join(filepath.Dir(Packages[0]), ".tmp", mirror.TmpMirrorFolderName) + } + + return nil +} + +// collectBundlePathPackages resolves the bundle path argument, which may be a +// directory of packages or a single tar/chunked package, into Packages. +func collectBundlePathPackages(arg string) error { + ImagesBundlePath = filepath.Clean(arg) s, err := os.Stat(ImagesBundlePath) if err != nil { @@ -67,13 +108,19 @@ func validateImagesBundlePathArg(args []string) error { } dirEntries = lo.Filter(dirEntries, func(item os.DirEntry, _ int) bool { - ext := filepath.Ext(item.Name()) - return ext == ".tar" || ext == ".chunk" + return isPackageFile(item.Name()) }) if len(dirEntries) == 0 { return errors.New("no packages found in bundle directory") } + for _, entry := range dirEntries { + // Chunk files (.tar.NNNN.chunk) collapse to a single .tar + // package; the pusher reassembles the chunks at push time. + Packages = append(Packages, canonicalPackagePath(filepath.Join(ImagesBundlePath, entry.Name()))) + } + + // Default temp dir lives inside the bundle directory, as before. if TempDir == "" { TempDir = filepath.Join(ImagesBundlePath, ".tmp", mirror.TmpMirrorFolderName) } @@ -81,17 +128,58 @@ func validateImagesBundlePathArg(args []string) error { return nil } - if bundleExtension := filepath.Ext(ImagesBundlePath); bundleExtension == ".tar" || bundleExtension == ".chunk" { - if TempDir == "" { - TempDir = filepath.Join(filepath.Dir(ImagesBundlePath), ".tmp", mirror.TmpMirrorFolderName) - } - + if isPackageFile(ImagesBundlePath) { + Packages = append(Packages, canonicalPackagePath(ImagesBundlePath)) return nil } return fmt.Errorf("invalid images bundle: must be a directory, tar or a chunked package") } +// collectFilesPackages validates and appends the packages passed via --file. +func collectFilesPackages() error { + for _, f := range Files { + path := filepath.Clean(f) + + s, err := os.Stat(path) + if err != nil { + return fmt.Errorf("could not read package %q: %w", path, err) + } + + if s.IsDir() { + return fmt.Errorf("--file entry %q is a directory, expected a tar or chunked package", path) + } + + if !isPackageFile(path) { + return fmt.Errorf("--file entry %q is not a tar or chunked package", path) + } + + Packages = append(Packages, canonicalPackagePath(path)) + } + + return nil +} + +func isPackageFile(name string) bool { + ext := filepath.Ext(name) + return ext == ".tar" || ext == ".chunk" +} + +// canonicalPackagePath maps a chunk file (.tar.NNNN.chunk) to its canonical +// .tar path so the pusher reassembles all chunks instead of reading a single +// chunk as a whole archive. Plain .tar paths are returned unchanged. +func canonicalPackagePath(path string) string { + if idx := strings.Index(path, ".tar.chunk"); idx != -1 { + return path[:idx] + ".tar" + } + + if idx := strings.Index(path, ".tar."); idx != -1 && filepath.Ext(path) == ".chunk" { + return path[:idx] + ".tar" + } + + return path +} + func validateRegistryCredentials() error { if RegistryPassword != "" && RegistryUsername == "" { return errors.New("registry username not specified") @@ -100,8 +188,8 @@ func validateRegistryCredentials() error { return nil } -func parseAndValidateRegistryURLArg(args []string) error { - registry := strings.NewReplacer("http://", "", "https://", "").Replace(args[1]) +func parseAndValidateRegistryURLArg(registryArg string) error { + registry := strings.NewReplacer("http://", "", "https://", "").Replace(registryArg) if registry == "" { return errors.New(" argument is empty") } diff --git a/internal/mirror/push.go b/internal/mirror/push.go index 610ac165..0e628012 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -46,8 +46,12 @@ const ( // PushServiceOptions contains configuration options for PushService type PushServiceOptions struct { - // BundleDir is the directory containing the bundle to push + // BundleDir is the directory containing the bundle to push. + // Used as a fallback when Packages is empty. BundleDir string + // Packages is an explicit list of tar/chunked package archive paths to push. + // When set, it takes precedence over scanning BundleDir. + Packages []string // WorkingDir is the temporary directory for unpacking bundles WorkingDir string } @@ -155,24 +159,27 @@ func (svc *PushService) Push(ctx context.Context) error { }) } -// unpackAllPackages unpacks all tar packages from bundle directory into unified directory. +// unpackAllPackages unpacks all tar packages into the unified directory. // All packages are unpacked to the same root - the structure inside each tar // should already have the correct paths. +// +// The package archives come from the explicit Packages list when set, otherwise +// they are discovered by scanning BundleDir. func (svc *PushService) unpackAllPackages(ctx context.Context, dirPath string) error { - entries, err := os.ReadDir(svc.options.BundleDir) + packages, err := svc.resolvePackagePaths() if err != nil { - return fmt.Errorf("read bundle dir: %w", err) + return err } - packages := svc.findPackages(entries) if len(packages) == 0 { - return fmt.Errorf("no packages found in bundle directory %q", svc.options.BundleDir) + return fmt.Errorf("no packages found to push") } svc.userLogger.Infof("Found %d packages to unpack", len(packages)) - for _, pkgName := range packages { - if err := svc.unpackPackage(ctx, dirPath, pkgName); err != nil { + for _, pkgPath := range packages { + pkgName := packageNameFromPath(pkgPath) + if err := svc.unpackPackage(ctx, dirPath, pkgPath, pkgName); err != nil { // Log warning but continue with other packages svc.userLogger.Warnf("Failed to unpack %s: %v", pkgName, err) } @@ -181,7 +188,26 @@ func (svc *PushService) unpackAllPackages(ctx context.Context, dirPath string) e return nil } -// findPackages finds all package names (without .tar extension) in the bundle directory. +// resolvePackagePaths returns the list of package archive paths to push. +// It prefers the explicit Packages list; when empty it falls back to scanning +// BundleDir for tar and chunked packages. +func (svc *PushService) resolvePackagePaths() ([]string, error) { + if len(svc.options.Packages) > 0 { + packages := slices.Clone(svc.options.Packages) + slices.Sort(packages) + + return packages, nil + } + + entries, err := os.ReadDir(svc.options.BundleDir) + if err != nil { + return nil, fmt.Errorf("read bundle dir: %w", err) + } + + return svc.findPackages(entries), nil +} + +// findPackages finds all package archive paths in the bundle directory. // It handles both regular .tar files and chunked packages (.tar.chunk000). func (svc *PushService) findPackages(entries []os.DirEntry) []string { packagesSet := make(map[string]struct{}) @@ -206,7 +232,7 @@ func (svc *PushService) findPackages(entries []os.DirEntry) []string { packages := make([]string, 0, len(packagesSet)) for pkg := range packagesSet { - packages = append(packages, pkg) + packages = append(packages, filepath.Join(svc.options.BundleDir, pkg+".tar")) } slices.Sort(packages) @@ -214,10 +240,23 @@ func (svc *PushService) findPackages(entries []os.DirEntry) []string { return packages } -// unpackPackage unpacks a single package to the unified directory. -func (svc *PushService) unpackPackage(ctx context.Context, dirPath, pkgName string) error { +// packageNameFromPath derives the package name (used for legacy module detection +// during unpack) from a package archive path by stripping its directory and the +// .tar or chunked suffix. +func packageNameFromPath(pkgPath string) string { + name := filepath.Base(pkgPath) + + if idx := strings.Index(name, ".tar.chunk"); idx != -1 { + return name[:idx] + } + + return strings.TrimSuffix(name, ".tar") +} + +// unpackPackage unpacks a single package archive into the unified directory. +func (svc *PushService) unpackPackage(ctx context.Context, dirPath, pkgPath, pkgName string) error { return svc.userLogger.Process(fmt.Sprintf("Unpack %s", pkgName), func() error { - pkg, err := svc.openPackage(pkgName) + pkg, err := openPackage(pkgPath) if err != nil { return fmt.Errorf("open package: %w", err) } @@ -232,21 +271,20 @@ func (svc *PushService) unpackPackage(ctx context.Context, dirPath, pkgName stri }) } -// openPackage opens a package file, trying .tar first, then chunked format. -func (svc *PushService) openPackage(pkgName string) (io.ReadCloser, error) { - tarPath := filepath.Join(svc.options.BundleDir, pkgName+".tar") - - pkg, err := os.Open(tarPath) +// openPackage opens a package archive by path, trying the path as a plain .tar +// first, then falling back to the chunked format. +func openPackage(pkgPath string) (io.ReadCloser, error) { + pkg, err := os.Open(pkgPath) if err == nil { return pkg, nil } if !os.IsNotExist(err) { - return nil, fmt.Errorf("open tar package %q: %w", tarPath, err) + return nil, fmt.Errorf("open tar package %q: %w", pkgPath, err) } - // Try chunked format - return chunked.Open(svc.options.BundleDir, pkgName+".tar") + // Try chunked format: chunks live next to the package as .tar.NNNN.chunk + return chunked.Open(filepath.Dir(pkgPath), filepath.Base(pkgPath)) } // pushAllLayouts recursively walks the directory and pushes each OCI layout found. From 9e8e3b146d7d4cfbbcafe03d5674fab86106147a Mon Sep 17 00:00:00 2001 From: Ruslan Usmanov Date: Tue, 23 Jun 2026 16:26:49 +0300 Subject: [PATCH 2/8] ++ Signed-off-by: Ruslan Usmanov --- internal/mirror/cmd/push/validation.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/mirror/cmd/push/validation.go b/internal/mirror/cmd/push/validation.go index 5e53aa65..36050cd1 100644 --- a/internal/mirror/cmd/push/validation.go +++ b/internal/mirror/cmd/push/validation.go @@ -34,8 +34,10 @@ import ( func parseAndValidateParameters(_ *cobra.Command, args []string) error { // The registry is always the last argument. The bundle path is an optional // first argument; when it is omitted, packages must be provided via --file. - var registryArg string - var bundleArg []string + var ( + registryArg string + bundleArg []string + ) switch len(args) { case 1: From f11d7c83e5b0d9847fa083b886f3ab462c911080 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 2 Jul 2026 16:41:17 +0300 Subject: [PATCH 3/8] Restore sign and publish-trdl-channels tasks Signed-off-by: Roman Berezkin --- Taskfile.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 6a2bf54c..265cc638 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -302,3 +302,37 @@ tasks: desc: Clean all binaries cmds: - rm -rf ./build ./dist ./d8 + + release:sign: + desc: "Sign last version tag + origin/main and push signatures." + deps: [checkKubectl] + preconditions: + - sh: '[[ "{{.CLI_ARGS}}" =~ ^v\d+.\d+.\d+$ ]] || exit 1' + msg: Provide valid semver prefixed with "v" (eg. task {{.TASK}} -- v1.2.3) to be used as version + cmds: + - git fetch --tags -f + - git signatures pull + - | + for ref in {{.refs | default "$(git tag --sort=v:refname | tail -n1) origin/main"}}; do + echo "Signing $ref..." + git signatures add {{.CLI_ARGS}} $ref + git signatures show {{.CLI_ARGS}} $ref + done + - git signatures push + + release:publish-trdl-channels: + desc: Publish release channels to TRDL + deps: [checkKubectl] + preconditions: + - sh: test $(git rev-parse --abbrev-ref HEAD) = "main" + msg: This can only be done on main branch + prompt: + - "Have you updated trdl_channels.yaml with new versions?" + cmds: + - git add trdl_channels.yaml + - git commit -S -m 'Signed release channels' + - git push + - git fetch + - git signatures pull + - git signatures add origin/main + - git signatures push From 3ab79743585d246e18b6ec61acf51878f03ee526 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 2 Jul 2026 17:28:55 +0300 Subject: [PATCH 4/8] Drop unreachable BundleDir fallback from PushService - `PushServiceOptions.Packages` is now the only source of packages to push. - CLI validation already resolves it before `PushService` runs, so the old `BundleDir` scan fallback never executed. - Drops `resolvePackagePaths` and `findPackages`, about 60 lines no code path could reach. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/push/push.go | 1 - internal/mirror/push.go | 71 +++----------------------------- 2 files changed, 5 insertions(+), 67 deletions(-) diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index 715053f9..68e35511 100644 --- a/internal/mirror/cmd/push/push.go +++ b/internal/mirror/cmd/push/push.go @@ -244,7 +244,6 @@ func (p *Pusher) executeNewPush() error { svc := mirror.NewPushService( client, &mirror.PushServiceOptions{ - BundleDir: p.pushParams.BundleDir, Packages: Packages, WorkingDir: p.pushParams.WorkingDir, }, diff --git a/internal/mirror/push.go b/internal/mirror/push.go index 0e628012..db2cf320 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -46,11 +46,7 @@ const ( // PushServiceOptions contains configuration options for PushService type PushServiceOptions struct { - // BundleDir is the directory containing the bundle to push. - // Used as a fallback when Packages is empty. - BundleDir string - // Packages is an explicit list of tar/chunked package archive paths to push. - // When set, it takes precedence over scanning BundleDir. + // Packages is the list of tar/chunked package archive paths to push. Packages []string // WorkingDir is the temporary directory for unpacking bundles WorkingDir string @@ -162,19 +158,14 @@ func (svc *PushService) Push(ctx context.Context) error { // unpackAllPackages unpacks all tar packages into the unified directory. // All packages are unpacked to the same root - the structure inside each tar // should already have the correct paths. -// -// The package archives come from the explicit Packages list when set, otherwise -// they are discovered by scanning BundleDir. func (svc *PushService) unpackAllPackages(ctx context.Context, dirPath string) error { - packages, err := svc.resolvePackagePaths() - if err != nil { - return err - } - - if len(packages) == 0 { + if len(svc.options.Packages) == 0 { return fmt.Errorf("no packages found to push") } + packages := slices.Clone(svc.options.Packages) + slices.Sort(packages) + svc.userLogger.Infof("Found %d packages to unpack", len(packages)) for _, pkgPath := range packages { @@ -188,58 +179,6 @@ func (svc *PushService) unpackAllPackages(ctx context.Context, dirPath string) e return nil } -// resolvePackagePaths returns the list of package archive paths to push. -// It prefers the explicit Packages list; when empty it falls back to scanning -// BundleDir for tar and chunked packages. -func (svc *PushService) resolvePackagePaths() ([]string, error) { - if len(svc.options.Packages) > 0 { - packages := slices.Clone(svc.options.Packages) - slices.Sort(packages) - - return packages, nil - } - - entries, err := os.ReadDir(svc.options.BundleDir) - if err != nil { - return nil, fmt.Errorf("read bundle dir: %w", err) - } - - return svc.findPackages(entries), nil -} - -// findPackages finds all package archive paths in the bundle directory. -// It handles both regular .tar files and chunked packages (.tar.chunk000). -func (svc *PushService) findPackages(entries []os.DirEntry) []string { - packagesSet := make(map[string]struct{}) - - for _, entry := range entries { - name := entry.Name() - - // Handle regular tar files - if strings.HasSuffix(name, ".tar") { - pkgName := strings.TrimSuffix(name, ".tar") - packagesSet[pkgName] = struct{}{} - - continue - } - - // Handle chunked files (e.g., "platform.tar.chunk000") - if idx := strings.Index(name, ".tar.chunk"); idx != -1 { - pkgName := name[:idx] - packagesSet[pkgName] = struct{}{} - } - } - - packages := make([]string, 0, len(packagesSet)) - for pkg := range packagesSet { - packages = append(packages, filepath.Join(svc.options.BundleDir, pkg+".tar")) - } - - slices.Sort(packages) - - return packages -} - // packageNameFromPath derives the package name (used for legacy module detection // during unpack) from a package archive path by stripping its directory and the // .tar or chunked suffix. From fa059170b9a904d8988b73c24a00888f29fe7e3b Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 2 Jul 2026 17:43:18 +0300 Subject: [PATCH 5/8] Simplify chunk canonicalization and cover push validation with tests - `canonicalPackagePath` and `packageNameFromPath` detect chunks via `filepath.Ext == ".chunk"`, matching the real `.tar.NNNN.chunk` naming chunk_writer produces. - `cmd/push` had no tests before; `validation_test.go` now covers `resolvePackages`, `collectBundlePathPackages`, and `collectFilesPackages`. - Covers chunk dedup across multiple `.chunk` files and the single-archive push path (bundle arg or `--file`). - `push_test.go` checks `packageNameFromPath` stays in sync with `canonicalPackagePath`'s `.tar`-only output. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/push/validation.go | 6 +- internal/mirror/cmd/push/validation_test.go | 508 ++++++++++++++++++++ internal/mirror/push.go | 11 +- internal/mirror/push_test.go | 58 +++ 4 files changed, 572 insertions(+), 11 deletions(-) create mode 100644 internal/mirror/cmd/push/validation_test.go create mode 100644 internal/mirror/push_test.go diff --git a/internal/mirror/cmd/push/validation.go b/internal/mirror/cmd/push/validation.go index 36050cd1..b4a23cec 100644 --- a/internal/mirror/cmd/push/validation.go +++ b/internal/mirror/cmd/push/validation.go @@ -171,11 +171,11 @@ func isPackageFile(name string) bool { // .tar path so the pusher reassembles all chunks instead of reading a single // chunk as a whole archive. Plain .tar paths are returned unchanged. func canonicalPackagePath(path string) string { - if idx := strings.Index(path, ".tar.chunk"); idx != -1 { - return path[:idx] + ".tar" + if filepath.Ext(path) != ".chunk" { + return path } - if idx := strings.Index(path, ".tar."); idx != -1 && filepath.Ext(path) == ".chunk" { + if idx := strings.Index(path, ".tar."); idx != -1 { return path[:idx] + ".tar" } diff --git a/internal/mirror/cmd/push/validation_test.go b/internal/mirror/cmd/push/validation_test.go new file mode 100644 index 00000000..ba5483c9 --- /dev/null +++ b/internal/mirror/cmd/push/validation_test.go @@ -0,0 +1,508 @@ +/* +Copyright 2025 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 ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/mirror" +) + +// resetPushState clears every package-level flag var this file touches. +// Tests never rely on cobra flag defaults (addFlags is not called here), so +// resetting to the zero value is equivalent to a clean process start. +func resetPushState() { + RegistryUsername = "" + RegistryPassword = "" + TLSSkipVerify = false + Insecure = false + TempDir = "" + ModulesPathSuffix = "" + Files = nil + ImagesBundlePath = "" + Packages = nil + RegistryHost = "" + RegistryPath = "" + MirrorTimeout = -1 +} + +func TestIsPackageFile(t *testing.T) { + tests := []struct { + name string + fileName string + want bool + }{ + {name: "tar file", fileName: "platform.tar", want: true}, + {name: "chunk file", fileName: "platform.tar.0000.chunk", want: true}, + {name: "unrelated extension", fileName: "platform.txt", want: false}, + {name: "no extension", fileName: "platform", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isPackageFile(tt.fileName)) + }) + } +} + +func TestCanonicalPackagePath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "plain tar path is unchanged", + path: "/bundle/platform.tar", + want: "/bundle/platform.tar", + }, + { + name: "first chunk collapses to the canonical tar path", + path: "/bundle/platform.tar.0000.chunk", + want: "/bundle/platform.tar", + }, + { + name: "later chunk index collapses to the same canonical tar path", + path: "/bundle/platform.tar.0012.chunk", + want: "/bundle/platform.tar", + }, + { + name: "chunked module archive with dashes in the name", + path: "/bundle/module-foo.tar.0003.chunk", + want: "/bundle/module-foo.tar", + }, + { + name: "non-package path is returned unchanged", + path: "/bundle/readme.txt", + want: "/bundle/readme.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, canonicalPackagePath(tt.path)) + }) + } +} + +// TestCanonicalPackagePathConsistency documents the invariant that +// internal/mirror.packageNameFromPath relies on: every package file accepted +// by isPackageFile (plain .tar or a .chunk fragment) canonicalizes to a path +// ending in ".tar". If this ever regresses, package name derivation on the +// mirror.PushService side silently breaks for chunked archives. +func TestCanonicalPackagePathConsistency(t *testing.T) { + inputs := []string{ + "platform.tar", + "platform.tar.0000.chunk", + "platform.tar.0001.chunk", + "module-foo.tar.0012.chunk", + } + + for _, in := range inputs { + path := filepath.Join("/bundle", in) + got := canonicalPackagePath(path) + + assert.Equal(t, ".tar", filepath.Ext(got), "canonicalized path %q (from %q) must end in .tar", got, path) + assert.True(t, isPackageFile(got), "canonicalized path %q must still look like a package file", got) + } +} + +func TestCollectFilesPackages(t *testing.T) { + tempDir := t.TempDir() + tarFile := filepath.Join(tempDir, "a.tar") + chunkFile := filepath.Join(tempDir, "b.tar.0000.chunk") + txtFile := filepath.Join(tempDir, "c.txt") + subDir := filepath.Join(tempDir, "subdir") + + require.NoError(t, os.WriteFile(tarFile, []byte("x"), 0644)) + require.NoError(t, os.WriteFile(chunkFile, []byte("x"), 0644)) + require.NoError(t, os.WriteFile(txtFile, []byte("x"), 0644)) + require.NoError(t, os.MkdirAll(subDir, 0755)) + + tests := []struct { + name string + files []string + expectError bool + errorMsg string + wantPackages []string + }{ + { + name: "single tar file", + files: []string{tarFile}, + wantPackages: []string{tarFile}, + }, + { + name: "chunk file resolves to its canonical tar path", + files: []string{chunkFile}, + wantPackages: []string{filepath.Join(tempDir, "b.tar")}, + }, + { + name: "missing file", + files: []string{filepath.Join(tempDir, "missing.tar")}, + expectError: true, + errorMsg: "could not read package", + }, + { + name: "directory instead of a file", + files: []string{subDir}, + expectError: true, + errorMsg: "is a directory", + }, + { + name: "wrong extension", + files: []string{txtFile}, + expectError: true, + errorMsg: "not a tar or chunked package", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + Files = tt.files + + err := collectFilesPackages() + + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantPackages, Packages) + }) + } +} + +func TestCollectBundlePathPackages(t *testing.T) { + t.Run("directory with tar and chunked packages", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "platform.tar"), []byte("x"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "module-foo.tar.0000.chunk"), []byte("x"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "module-foo.tar.0001.chunk"), []byte("x"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("x"), 0644)) + + err := collectBundlePathPackages(dir) + require.NoError(t, err) + + // Each chunk is appended once, so the two module-foo chunks both + // resolve to the same canonical path here. Deduping is resolvePackages's + // job, not collectBundlePathPackages's - this is intentional, not a bug. + assert.ElementsMatch(t, []string{ + filepath.Join(dir, "platform.tar"), + filepath.Join(dir, "module-foo.tar"), + filepath.Join(dir, "module-foo.tar"), + }, Packages) + }) + + t.Run("directory without packages errors", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("x"), 0644)) + + err := collectBundlePathPackages(dir) + assert.ErrorContains(t, err, "no packages found in bundle directory") + }) + + t.Run("single tar file as bundle path", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + tarFile := filepath.Join(dir, "platform.tar") + require.NoError(t, os.WriteFile(tarFile, []byte("x"), 0644)) + + err := collectBundlePathPackages(tarFile) + require.NoError(t, err) + assert.Equal(t, []string{tarFile}, Packages) + }) + + t.Run("missing path errors", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + err := collectBundlePathPackages(filepath.Join(t.TempDir(), "missing")) + assert.ErrorContains(t, err, "could not read images bundle") + }) +} + +func TestResolvePackages(t *testing.T) { + t.Run("bundle dir chunks dedup into one package and temp dir lands inside it", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "platform.tar.0000.chunk"), []byte("x"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "platform.tar.0001.chunk"), []byte("x"), 0644)) + + err := resolvePackages([]string{dir}) + require.NoError(t, err) + + assert.Equal(t, []string{filepath.Join(dir, "platform.tar")}, Packages) + assert.Equal(t, filepath.Join(dir, ".tmp", mirror.TmpMirrorFolderName), TempDir) + }) + + t.Run("single archive bundle path derives temp dir next to the file", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + tarFile := filepath.Join(dir, "platform.tar") + require.NoError(t, os.WriteFile(tarFile, []byte("x"), 0644)) + + err := resolvePackages([]string{tarFile}) + require.NoError(t, err) + + assert.Equal(t, []string{tarFile}, Packages) + assert.Equal(t, filepath.Join(dir, ".tmp", mirror.TmpMirrorFolderName), TempDir) + }) + + t.Run("--file alone, no bundle path argument", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + tarFile := filepath.Join(dir, "platform.tar") + require.NoError(t, os.WriteFile(tarFile, []byte("x"), 0644)) + Files = []string{tarFile} + + err := resolvePackages(nil) + require.NoError(t, err) + assert.Equal(t, []string{tarFile}, Packages) + }) + + t.Run("bundle dir combined with --file merges both sources", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "platform.tar"), []byte("x"), 0644)) + + otherDir := t.TempDir() + extraTar := filepath.Join(otherDir, "extra.tar") + require.NoError(t, os.WriteFile(extraTar, []byte("x"), 0644)) + Files = []string{extraTar} + + err := resolvePackages([]string{dir}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{filepath.Join(dir, "platform.tar"), extraTar}, Packages) + }) + + t.Run("no bundle path and no --file errors", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + err := resolvePackages(nil) + assert.ErrorContains(t, err, "no packages to push") + }) + + t.Run("explicit temp dir is not overridden by the default", func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "platform.tar"), []byte("x"), 0644)) + TempDir = "/custom/tmp" + + err := resolvePackages([]string{dir}) + require.NoError(t, err) + assert.Equal(t, "/custom/tmp", TempDir) + }) +} + +func TestValidateRegistryCredentials(t *testing.T) { + tests := []struct { + name string + username string + password string + expectError bool + }{ + {name: "no credentials", username: "", password: ""}, + {name: "username and password", username: "user", password: "pass"}, + {name: "username only", username: "user", password: ""}, + {name: "password without username", username: "", password: "pass", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + RegistryUsername = tt.username + RegistryPassword = tt.password + + err := validateRegistryCredentials() + if tt.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestParseAndValidateRegistryURLArg(t *testing.T) { + tests := []struct { + name string + registry string + expectError bool + errorMsg string + wantHost string + wantPath string + }{ + { + name: "valid registry with host and path", + registry: "registry.example.com/deckhouse/ee", + wantHost: "registry.example.com", + wantPath: "/deckhouse/ee", + }, + { + name: "https scheme is stripped", + registry: "https://registry.example.com/deckhouse/ee", + wantHost: "registry.example.com", + wantPath: "/deckhouse/ee", + }, + { + name: "empty registry", + registry: "", + expectError: true, + errorMsg: "argument is empty", + }, + { + name: "no repository path", + registry: "registry.example.com", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + err := parseAndValidateRegistryURLArg(tt.registry) + + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantHost, RegistryHost) + assert.Equal(t, tt.wantPath, RegistryPath) + }) + } +} + +func TestParseAndValidateParameters(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "platform.tar"), []byte("x"), 0644)) + + const validRegistry = "registry.example.com/deckhouse" + + tests := []struct { + name string + args []string + setup func() + expectError bool + errorMsg string + }{ + { + name: "bundle dir + registry", + args: []string{dir, validRegistry}, + }, + { + name: "registry only, with --file set", + args: []string{validRegistry}, + setup: func() { + Files = []string{filepath.Join(dir, "platform.tar")} + }, + }, + { + name: "registry only, without --file errors", + args: []string{validRegistry}, + expectError: true, + errorMsg: "no packages to push", + }, + { + name: "no arguments", + args: []string{}, + expectError: true, + errorMsg: "invalid number of arguments", + }, + { + name: "too many arguments", + args: []string{dir, validRegistry, "extra"}, + expectError: true, + errorMsg: "invalid number of arguments", + }, + { + name: "invalid registry", + args: []string{dir, "not a valid registry"}, + expectError: true, + }, + { + name: "password without username", + args: []string{dir, validRegistry}, + setup: func() { + RegistryPassword = "secret" + }, + expectError: true, + errorMsg: "registry username not specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetPushState() + t.Cleanup(resetPushState) + + if tt.setup != nil { + tt.setup() + } + + err := parseAndValidateParameters(nil, tt.args) + + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + return + } + assert.NoError(t, err) + }) + } +} diff --git a/internal/mirror/push.go b/internal/mirror/push.go index db2cf320..5eca1c57 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -181,15 +181,10 @@ func (svc *PushService) unpackAllPackages(ctx context.Context, dirPath string) e // packageNameFromPath derives the package name (used for legacy module detection // during unpack) from a package archive path by stripping its directory and the -// .tar or chunked suffix. +// .tar suffix. Callers must pass canonical .tar paths - chunked packages are +// already collapsed to their .tar name before reaching this point. func packageNameFromPath(pkgPath string) string { - name := filepath.Base(pkgPath) - - if idx := strings.Index(name, ".tar.chunk"); idx != -1 { - return name[:idx] - } - - return strings.TrimSuffix(name, ".tar") + return strings.TrimSuffix(filepath.Base(pkgPath), ".tar") } // unpackPackage unpacks a single package archive into the unified directory. diff --git a/internal/mirror/push_test.go b/internal/mirror/push_test.go new file mode 100644 index 00000000..2170890b --- /dev/null +++ b/internal/mirror/push_test.go @@ -0,0 +1,58 @@ +/* +Copyright 2025 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/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestPackageNameFromPath covers the .tar-only contract packageNameFromPath +// relies on: cmd/push/validation.go always canonicalizes chunked archives to +// their .tar path (see canonicalPackagePath) before they reach +// PushService, so this function only ever needs to strip ".tar". +func TestPackageNameFromPath(t *testing.T) { + tests := []struct { + name string + pkgPath string + want string + }{ + { + name: "absolute tar path", + pkgPath: "/bundle/platform.tar", + want: "platform", + }, + { + name: "relative tar path", + pkgPath: "platform.tar", + want: "platform", + }, + { + name: "module tar path with dashes in the name", + pkgPath: filepath.Join("/bundle", "module-foo.tar"), + want: "module-foo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, packageNameFromPath(tt.pkgPath)) + }) + } +} From 81612dd5b619a62b754033d55fe918a91983111d Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Thu, 2 Jul 2026 17:46:21 +0300 Subject: [PATCH 6/8] Mark images-bundle-path as optional in push command usage Signed-off-by: Roman Berezkin --- internal/mirror/cmd/push/push.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index 68e35511..da3264e1 100644 --- a/internal/mirror/cmd/push/push.go +++ b/internal/mirror/cmd/push/push.go @@ -94,7 +94,7 @@ valid license for any commercial version of the Deckhouse Kubernetes Platform. func NewCommand() *cobra.Command { pushCmd := &cobra.Command{ - Use: "push ", + Use: "push [images-bundle-path] ", Short: "Copy Deckhouse Kubernetes Platform distribution to the third-party registry", Long: pushLong, Args: cobra.RangeArgs(1, 2), From 15b7bf531dd102a613adb17c446bcd5db861323c Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Fri, 3 Jul 2026 03:49:03 +0300 Subject: [PATCH 7/8] Canonicalize chunk paths using only the file name - `canonicalPackagePath` matches `.tar.` only in the base name, so a chunk under a directory like `bundle.tar.gz.d/` resolves to the correct package. - A chunk in such a directory is no longer collapsed to a missing `.tar` and silently skipped while push still exits 0. - Add regression tests: a parent-dir case in `TestCanonicalPackagePath` and a chunk inside a `.tar.`-named dir in `TestCollectFilesPackages`. Signed-off-by: Roman Berezkin --- internal/mirror/cmd/push/validation.go | 9 +++++++-- internal/mirror/cmd/push/validation_test.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/mirror/cmd/push/validation.go b/internal/mirror/cmd/push/validation.go index b4a23cec..dd0a7d98 100644 --- a/internal/mirror/cmd/push/validation.go +++ b/internal/mirror/cmd/push/validation.go @@ -170,13 +170,18 @@ func isPackageFile(name string) bool { // canonicalPackagePath maps a chunk file (.tar.NNNN.chunk) to its canonical // .tar path so the pusher reassembles all chunks instead of reading a single // chunk as a whole archive. Plain .tar paths are returned unchanged. +// +// Only the file name is inspected. A parent directory whose name contains ".tar." +// (e.g. an extraction dir "bundle.tar.gz.d") must not be collapsed into the +// package name. func canonicalPackagePath(path string) string { if filepath.Ext(path) != ".chunk" { return path } - if idx := strings.Index(path, ".tar."); idx != -1 { - return path[:idx] + ".tar" + dir, base := filepath.Split(path) + if before, _, found := strings.Cut(base, ".tar."); found { + return dir + before + ".tar" } return path diff --git a/internal/mirror/cmd/push/validation_test.go b/internal/mirror/cmd/push/validation_test.go index ba5483c9..cf0137df 100644 --- a/internal/mirror/cmd/push/validation_test.go +++ b/internal/mirror/cmd/push/validation_test.go @@ -90,6 +90,13 @@ func TestCanonicalPackagePath(t *testing.T) { path: "/bundle/module-foo.tar.0003.chunk", want: "/bundle/module-foo.tar", }, + { + // Only the file name may be collapsed: a parent directory named like + // an archive (".tar." in its name) must not be mistaken for the package. + name: "parent directory containing .tar. is not confused with the package", + path: "/backups/bundle.tar.gz.d/platform.tar.0000.chunk", + want: "/backups/bundle.tar.gz.d/platform.tar", + }, { name: "non-package path is returned unchanged", path: "/bundle/readme.txt", @@ -133,10 +140,17 @@ func TestCollectFilesPackages(t *testing.T) { txtFile := filepath.Join(tempDir, "c.txt") subDir := filepath.Join(tempDir, "subdir") + // Chunk living inside a directory whose name itself contains ".tar." - the + // canonical path must stay inside that directory, not collapse the dir name. + tarNamedDir := filepath.Join(tempDir, "bundle.tar.gz.d") + chunkInTarNamedDir := filepath.Join(tarNamedDir, "platform.tar.0000.chunk") + require.NoError(t, os.WriteFile(tarFile, []byte("x"), 0644)) require.NoError(t, os.WriteFile(chunkFile, []byte("x"), 0644)) require.NoError(t, os.WriteFile(txtFile, []byte("x"), 0644)) require.NoError(t, os.MkdirAll(subDir, 0755)) + require.NoError(t, os.MkdirAll(tarNamedDir, 0755)) + require.NoError(t, os.WriteFile(chunkInTarNamedDir, []byte("x"), 0644)) tests := []struct { name string @@ -155,6 +169,11 @@ func TestCollectFilesPackages(t *testing.T) { files: []string{chunkFile}, wantPackages: []string{filepath.Join(tempDir, "b.tar")}, }, + { + name: "chunk inside a .tar.-named directory canonicalizes within that directory", + files: []string{chunkInTarNamedDir}, + wantPackages: []string{filepath.Join(tarNamedDir, "platform.tar")}, + }, { name: "missing file", files: []string{filepath.Join(tempDir, "missing.tar")}, From 054e7e307afbb1006c1217fb917b82404ad44db6 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Fri, 3 Jul 2026 15:29:58 +0300 Subject: [PATCH 8/8] Add comments about mirror cmd specifics Signed-off-by: Roman Berezkin --- internal/mirror/cmd/push/push.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index da3264e1..bac7993c 100644 --- a/internal/mirror/cmd/push/push.go +++ b/internal/mirror/cmd/push/push.go @@ -43,7 +43,18 @@ import ( pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" ) -// CLI Parameters +// CLI Parameters. +// +// Shared state passed between the command's hooks, relying on cobra's +// PreRun -> Run -> PostRun ordering: +// - PreRunE (parseAndValidateParameters) writes RegistryHost and RegistryPath +// from the registry argument, plus Packages, TempDir and ImagesBundlePath +// via resolvePackages. +// - RunE (NewPusher().Execute()) reads them to build the push params and the +// push service options. +// - PostRunE reads TempDir to remove the temporary directory. +// +// The remaining vars are bound to flags by addFlags before Run. var ( TempDir string @@ -101,7 +112,9 @@ func NewCommand() *cobra.Command { ValidArgs: []string{"images-bundle-path", "registry"}, SilenceErrors: true, SilenceUsage: true, - PreRunE: parseAndValidateParameters, + // PreRunE fills the package-level CLI Parameters that RunE and PostRunE + // consume; see the "CLI Parameters" var block for the write/read contract. + PreRunE: parseAndValidateParameters, RunE: func(_ *cobra.Command, _ []string) error { return NewPusher().Execute() },