diff --git a/cmd/d8/root.go b/cmd/d8/root.go index 3581426e..141936bb 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -40,6 +40,7 @@ import ( "github.com/deckhouse/deckhouse-cli/cmd/plugins" "github.com/deckhouse/deckhouse-cli/cmd/plugins/flags" backup "github.com/deckhouse/deckhouse-cli/internal/backup/cmd" + cr "github.com/deckhouse/deckhouse-cli/internal/cr/cmd" data "github.com/deckhouse/deckhouse-cli/internal/data/cmd" mirror "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd" "github.com/deckhouse/deckhouse-cli/internal/network" @@ -106,6 +107,7 @@ func (r *RootCommand) registerCommands() { r.cmd.AddCommand(backup.NewCommand()) r.cmd.AddCommand(data.NewCommand()) r.cmd.AddCommand(mirror.NewCommand()) + r.cmd.AddCommand(cr.NewCommand()) r.cmd.AddCommand(status.NewCommand()) r.cmd.AddCommand(useroperation.NewCommand()) r.cmd.AddCommand(network.NewCommand()) diff --git a/internal/cr/cmd/basic/catalog.go b/internal/cr/cmd/basic/catalog.go new file mode 100644 index 00000000..6e0e0d87 --- /dev/null +++ b/internal/cr/cmd/basic/catalog.go @@ -0,0 +1,59 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package basic + +import ( + "context" + "fmt" + "io" + "path" + + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func NewCatalogCmd(opts *registry.Options) *cobra.Command { + var fullRef bool + cmd := &cobra.Command{ + Use: "catalog REGISTRY", + Short: "List the repositories in a registry", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.RegistryHost(), + RunE: func(cmd *cobra.Command, args []string) error { + return runCatalog(cmd.Context(), cmd.OutOrStdout(), args[0], fullRef, opts) + }, + } + cmd.Flags().BoolVar(&fullRef, "full-ref", false, "Print the full repository reference (registry/repo)") + return cmd +} + +func runCatalog(ctx context.Context, w io.Writer, src string, fullRef bool, opts *registry.Options) error { + return registry.ListCatalog(ctx, src, opts, func(repos []string) error { + for _, repo := range repos { + line := repo + if fullRef { + line = path.Join(src, repo) + } + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/cr/cmd/basic/config.go b/internal/cr/cmd/basic/config.go new file mode 100644 index 00000000..97bec5e4 --- /dev/null +++ b/internal/cr/cmd/basic/config.go @@ -0,0 +1,43 @@ +/* +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 basic + +import ( + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func NewConfigCmd(opts *registry.Options) *cobra.Command { + return &cobra.Command{ + Use: "config IMAGE", + Short: "Print the config of an image", + Long: `Print the raw config JSON of an image to stdout. Multi-arch indices are +resolved to a single image via --platform.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ImageRef(), + RunE: func(cmd *cobra.Command, args []string) error { + data, err := registry.FetchConfig(cmd.Context(), args[0], opts) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } +} diff --git a/internal/cr/cmd/basic/digest.go b/internal/cr/cmd/basic/digest.go new file mode 100644 index 00000000..ead7d6df --- /dev/null +++ b/internal/cr/cmd/basic/digest.go @@ -0,0 +1,98 @@ +/* +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 basic + +import ( + "context" + "errors" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imageio" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func NewDigestCmd(opts *registry.Options) *cobra.Command { + var ( + tarballPath string + fullRef bool + ) + cmd := &cobra.Command{ + Use: "digest [IMAGE]", + Short: "Print the digest of an image", + Long: `By default, fetches the digest of IMAGE from the registry. With --tarball, +reads it from a local tarball instead; IMAGE then becomes optional and +selects an entry by tag (the first entry is used if omitted). + +--full-ref is incompatible with --tarball.`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completion.ImageRef(), + RunE: func(cmd *cobra.Command, args []string) error { + if fullRef && tarballPath != "" { + return errors.New("--full-ref cannot be combined with --tarball") + } + if tarballPath == "" && len(args) == 0 { + return errors.New("image reference required when --tarball is not used") + } + + digest, err := resolveDigest(cmd.Context(), tarballPath, args, opts) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + if !fullRef { + _, err = fmt.Fprintln(w, digest) + return err + } + // fullRef branch is reachable only when tarballPath == "" (rejected above) + // and len(args) > 0 (rejected above when tarballPath is also empty). + ref, err := name.ParseReference(args[0], opts.Name...) + if err != nil { + return fmt.Errorf("parse reference %q: %w", args[0], err) + } + _, err = fmt.Fprintln(w, ref.Context().Digest(digest)) + return err + }, + } + cmd.Flags().StringVar(&tarballPath, "tarball", "", "Read the digest from a local tarball instead of the registry") + cmd.Flags().BoolVar(&fullRef, "full-ref", false, "Print the full image reference with digest (registry/repo@sha256:...); incompatible with --tarball") + return cmd +} + +func resolveDigest(ctx context.Context, tarballPath string, args []string, opts *registry.Options) (string, error) { + if tarballPath == "" { + return registry.FetchDigest(ctx, args[0], opts) + } + + tag := "" + if len(args) > 0 { + tag = args[0] + } + img, err := imageio.LoadTarball(tarballPath, tag) + if err != nil { + return "", err + } + d, err := img.Digest() + if err != nil { + return "", fmt.Errorf("compute digest: %w", err) + } + return d.String(), nil +} diff --git a/internal/cr/cmd/basic/export.go b/internal/cr/cmd/basic/export.go new file mode 100644 index 00000000..16c846ea --- /dev/null +++ b/internal/cr/cmd/basic/export.go @@ -0,0 +1,144 @@ +/* +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 basic + +import ( + "fmt" + "io" + "os" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +// NewExportCmd mirrors crane export: writes the merged filesystem of IMAGE +// as a verbatim tar stream to TARBALL (or stdout when TARBALL is "-" or omitted). +// +// Verbatim semantics: linknames are preserved as recorded in layers (absolute +// targets stay absolute), whiteouts are filtered via mutate.Extract's reverse +// iteration. For a sanitized direct-to-disk variant use `cr fs extract -o DIR`. +func NewExportCmd(opts *registry.Options) *cobra.Command { + return &cobra.Command{ + Use: "export IMAGE [TARBALL]", + Short: "Export the filesystem of an image as a tarball", + Long: `Export writes the merged filesystem of IMAGE as a tar stream to TARBALL +(default: "-" = stdout). Output is byte-for-byte equivalent to crane export: +linknames are not rewritten, whiteouts are filtered. + +For a directory-target extraction with symlink/path-traversal safety checks, +use "d8 cr fs extract". + +Examples: + d8 cr export alpine:3.19 - # stream to stdout + d8 cr export alpine:3.19 fs.tar # write to file + d8 cr export alpine:3.19 - | tar tf - # list contents +`, + Args: cobra.RangeArgs(1, 2), + ValidArgsFunction: completion.ImageThenPath(), + RunE: func(cmd *cobra.Command, args []string) error { + dst := "-" + if len(args) > 1 { + dst = args[1] + } + return runExport(cmd, args[0], dst, opts) + }, + } +} + +func runExport(cmd *cobra.Command, src, dst string, opts *registry.Options) error { + img, err := registry.Fetch(cmd.Context(), src, opts) + if err != nil { + return err + } + + w, closeFn, err := openExportSink(cmd, dst) + if err != nil { + return err + } + + exportErr := exportImage(img, w) + closeErr := closeFn() + if exportErr != nil { + // File-sink target is now a half-written tar that callers would + // likely consume by mistake (`tar tf` happily reads short streams). + // Remove it so the user sees a clean failure, not a corrupt artifact. + if dst != "-" { + _ = os.Remove(dst) + } + return exportErr + } + return closeErr +} + +// exportImage mirrors crane's pkg/crane.Export +// (https://pkg.go.dev/github.com/google/go-containerregistry/pkg/crane#Export): +// for single-layer images whose only layer is non-OCI media (e.g. arbitrary +// blob wrappers), it dumps the uncompressed contents directly. Otherwise it +// streams the merged filesystem via mutate.Extract. +// +// Functionally identical to upstream; we keep our own copy so the export +// command stays in our domain layer (no pkg/crane dependency) and to ensure +// rc.Close() is honoured in both branches. +func exportImage(img v1.Image, w io.Writer) error { + layers, err := img.Layers() + if err != nil { + return fmt.Errorf("get layers: %w", err) + } + if len(layers) == 1 { + mt, err := layers[0].MediaType() + if err != nil { + return fmt.Errorf("media type: %w", err) + } + if !mt.IsLayer() { + rc, err := layers[0].Uncompressed() + if err != nil { + return fmt.Errorf("uncompress: %w", err) + } + defer rc.Close() + if _, err := io.Copy(w, rc); err != nil { + return fmt.Errorf("copy: %w", err) + } + return nil + } + } + rc := mutate.Extract(img) + defer rc.Close() + if _, err := io.Copy(w, rc); err != nil { + return fmt.Errorf("copy: %w", err) + } + return nil +} + +func openExportSink(cmd *cobra.Command, dst string) (io.Writer, func() error, error) { + if dst == "-" { + return cmd.OutOrStdout(), func() error { return nil }, nil + } + f, err := os.Create(dst) + if err != nil { + return nil, nil, fmt.Errorf("create %s: %w", dst, err) + } + return f, func() error { + if err := f.Close(); err != nil { + return fmt.Errorf("close %s: %w", dst, err) + } + return nil + }, nil +} diff --git a/internal/cr/cmd/basic/ls.go b/internal/cr/cmd/basic/ls.go new file mode 100644 index 00000000..4fbafcad --- /dev/null +++ b/internal/cr/cmd/basic/ls.go @@ -0,0 +1,78 @@ +/* +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 basic + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +const digestTagPrefix = "sha256-" + +func NewLsCmd(opts *registry.Options) *cobra.Command { + var ( + fullRef bool + omitDigestTags bool + ) + cmd := &cobra.Command{ + Use: "ls REPO", + Short: "List the tags in a repository", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.RepoRef(), + RunE: func(cmd *cobra.Command, args []string) error { + return runLs(cmd.Context(), cmd.OutOrStdout(), args[0], fullRef, omitDigestTags, opts) + }, + } + cmd.Flags().BoolVar(&fullRef, "full-ref", false, "Print the full image reference (registry/repo:tag)") + cmd.Flags().BoolVarP(&omitDigestTags, "omit-digest-tags", "O", false, "Skip digest-based tags (sha256-*) created by signing tools") + return cmd +} + +func runLs(ctx context.Context, w io.Writer, src string, fullRef, omitDigestTags bool, opts *registry.Options) error { + var repo name.Repository + if fullRef { + r, err := name.NewRepository(src, opts.Name...) + if err != nil { + return fmt.Errorf("parse repository %q: %w", src, err) + } + repo = r + } + + return registry.ListTags(ctx, src, opts, func(tags []string) error { + for _, tag := range tags { + if omitDigestTags && strings.HasPrefix(tag, digestTagPrefix) { + continue + } + line := tag + if fullRef { + line = repo.Tag(tag).String() + } + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/cr/cmd/basic/manifest.go b/internal/cr/cmd/basic/manifest.go new file mode 100644 index 00000000..fd347527 --- /dev/null +++ b/internal/cr/cmd/basic/manifest.go @@ -0,0 +1,43 @@ +/* +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 basic + +import ( + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func NewManifestCmd(opts *registry.Options) *cobra.Command { + return &cobra.Command{ + Use: "manifest IMAGE", + Short: "Print the manifest of an image", + Long: `Print the raw manifest bytes of an image to stdout, exactly as the +registry returned them. Suitable for piping to jq or for signature verification.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ImageRef(), + RunE: func(cmd *cobra.Command, args []string) error { + data, err := registry.FetchManifest(cmd.Context(), args[0], opts) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } +} diff --git a/internal/cr/cmd/basic/pull.go b/internal/cr/cmd/basic/pull.go new file mode 100644 index 00000000..cbcc3aee --- /dev/null +++ b/internal/cr/cmd/basic/pull.go @@ -0,0 +1,116 @@ +/* +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 basic + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/image" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imageio" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func NewPullCmd(opts *registry.Options) *cobra.Command { + var ( + cachePath string + format string + ) + cmd := &cobra.Command{ + Use: "pull IMAGE... PATH", + Short: "Pull one or more remote images to a local path", + Long: `Pull one or more images and save them to PATH. PATH is a tarball file +for formats "tarball"/"legacy", or a directory for format "oci". + +Formats: + tarball (default) docker-compatible multi-image tarball + legacy single-image format compatible with "docker load" (tags only, digests not preserved) + oci OCI image-layout directory; keeps all platforms unless --platform is set + +When to use: + tarball - default. Best for shipping one or more images as a single + file: "docker load" / "podman load" reads it natively. + Multi-arch indices flatten to one platform - pin it with + --platform to avoid an ambiguity error. + oci - prefer when the destination is OCI-aware tooling (skopeo, + crane, buildkit, another registry via "cr push --index"). + Preserves the full multi-arch index without --platform. + Resumable: re-running the same pull skips already-downloaded + blobs. + legacy - last-resort compatibility with very old "docker load" or + tooling that rejects newer manifests. Lossy: digests are + not preserved, single-image only. Avoid unless you have a + specific consumer that fails on tarball. + +On interruption (Ctrl+C): + --format oci Layer-level resume. Rerun the same pull; existing valid blobs are skipped + and in-flight layer temp-files are cleaned up automatically. + --format tarball| No resume. Rerun replaces the partial archive from scratch. + legacy + --cache-path Layer cache is self-healing: corrupt partial entries are detected on + next access and re-downloaded. +`, + Args: cobra.MinimumNArgs(2), + ValidArgsFunction: completion.ImageRef(), + RunE: func(cmd *cobra.Command, args []string) error { + return runPull(cmd, args, format, cachePath, opts) + }, + } + cmd.Flags().StringVarP(&cachePath, "cache-path", "c", "", "Cache image layers under this directory (reused between pulls)") + cmd.Flags().StringVar(&format, "format", imageio.PullFormatTarball, + fmt.Sprintf("Output format (one of: %s, %s, %s)", imageio.PullFormatTarball, imageio.PullFormatLegacy, imageio.PullFormatOCI)) + _ = cmd.RegisterFlagCompletionFunc("format", completion.Static(completion.PullFormats()...)) + return cmd +} + +func runPull(cmd *cobra.Command, args []string, format, cachePath string, opts *registry.Options) error { + if err := validatePullFormat(format); err != nil { + return err + } + + srcList, dst := args[:len(args)-1], args[len(args)-1] + + keepIndex := format == imageio.PullFormatOCI + resolved, err := image.Resolve(cmd.Context(), srcList, keepIndex, cachePath, opts) + if err != nil { + return err + } + + switch format { + case imageio.PullFormatTarball: + return imageio.SaveTarball(dst, resolved.Images) + case imageio.PullFormatLegacy: + return imageio.SaveLegacy(dst, resolved.Images) + case imageio.PullFormatOCI: + return imageio.SaveOCI(dst, resolved.Images, resolved.Indices) + default: + // Pre-validated via validatePullFormat. + return fmt.Errorf("unsupported --format %q", format) + } +} + +func validatePullFormat(format string) error { + switch format { + case imageio.PullFormatTarball, imageio.PullFormatLegacy, imageio.PullFormatOCI: + return nil + default: + return fmt.Errorf("invalid --format %q (valid: %q, %q, %q)", + format, imageio.PullFormatTarball, imageio.PullFormatLegacy, imageio.PullFormatOCI) + } +} diff --git a/internal/cr/cmd/basic/pull_test.go b/internal/cr/cmd/basic/pull_test.go new file mode 100644 index 00000000..28868adb --- /dev/null +++ b/internal/cr/cmd/basic/pull_test.go @@ -0,0 +1,35 @@ +/* +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 basic + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRunPull_InvalidFormatFailsFast(t *testing.T) { + cmd := &cobra.Command{} + err := runPull(cmd, []string{"repo/image:tag", "/tmp/out.tar"}, "invalid", "", nil) + if err == nil { + t.Fatalf("expected invalid format error") + } + if !strings.Contains(err.Error(), "invalid --format") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cr/cmd/basic/push.go b/internal/cr/cmd/basic/push.go new file mode 100644 index 00000000..7ba1e0c3 --- /dev/null +++ b/internal/cr/cmd/basic/push.go @@ -0,0 +1,85 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package basic + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imageio" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func NewPushCmd(opts *registry.Options) *cobra.Command { + var ( + asIndex bool + imageRefsPath string + ) + cmd := &cobra.Command{ + Use: "push PATH IMAGE", + Short: "Push a local image to a registry", + Long: `A directory PATH is read as an OCI image layout; a file is treated as a +docker-style tarball. Multi-manifest OCI layouts must be pushed with --index. + +Prints the pushed reference (with digest) to stdout; use --image-refs to also +write it to a file. --image-refs overwrites the target file if it exists.`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.PathThenImage(), + RunE: func(cmd *cobra.Command, args []string) error { + return runPush(cmd.Context(), cmd.OutOrStdout(), args[0], args[1], asIndex, imageRefsPath, opts) + }, + } + cmd.Flags().BoolVar(&asIndex, "index", false, "Push a multi-manifest OCI layout as an index (OCI layout dirs only)") + cmd.Flags().StringVar(&imageRefsPath, "image-refs", "", "Persist the pushed reference (with digest) to this file") + return cmd +} + +func runPush(ctx context.Context, w io.Writer, path, tagRef string, asIndex bool, imageRefsPath string, opts *registry.Options) error { + // Validate tagRef before reading any OCI layout from disk - layouts can + // be tens of GB, and a typo in the destination ref should not require + // loading the source first. + parsed, err := name.ParseReference(tagRef, opts.Name...) + if err != nil { + return fmt.Errorf("parse reference %q: %w", tagRef, err) + } + + obj, err := imageio.LoadLocal(path, asIndex) + if err != nil { + return err + } + + digest, err := registry.Push(ctx, tagRef, obj, opts) + if err != nil { + return err + } + + fullRef := parsed.Context().Digest(digest.String()).String() + + if imageRefsPath != "" { + if err := os.WriteFile(imageRefsPath, []byte(fullRef), 0o600); err != nil { + return fmt.Errorf("write image refs %s: %w", imageRefsPath, err) + } + } + _, err = fmt.Fprintln(w, fullRef) + return err +} diff --git a/internal/cr/cmd/complete_smoke_test.go b/internal/cr/cmd/complete_smoke_test.go new file mode 100644 index 00000000..621b778e --- /dev/null +++ b/internal/cr/cmd/complete_smoke_test.go @@ -0,0 +1,92 @@ +/* +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 cr_test + +import ( + "bytes" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + + cr "github.com/deckhouse/deckhouse-cli/internal/cr/cmd" +) + +// completionLineValues parses cobra __complete output into the leading +// values of each suggestion line. Cobra prints "\t" per +// suggestion and finishes the stream with a ":" line - keeping +// only the first column makes assertions exact (no false matches against +// the human-readable description text). +func completionLineValues(out string) []string { + var values []string + for line := range strings.SplitSeq(out, "\n") { + if line == "" || strings.HasPrefix(line, ":") { + continue + } + values = append(values, strings.SplitN(line, "\t", 2)[0]) + } + return values +} + +// End-to-end checks of cobra's hidden __complete subcommand against the cr +// tree. These verify the wiring (ValidArgsFunction / RegisterFlagCompletionFunc) +// at the cobra layer - the unit-level coverage of the completion functions +// themselves lives in internal/cr/cmd/completion/. +// +// Two invocation styles are exercised: +// +// - Direct: cmd := cr.NewCommand(); cmd.Execute() - the public API any +// embedder (including d8) is expected to use. +// - Nested: a synthetic d8-style root with cr added as a subcommand. This +// guards against a regression where cobra would fail to reach our leaf +// ValidArgsFunctions through one extra command level. + +func TestComplete_DirectRoot(t *testing.T) { + cmd := cr.NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"__complete", "pull", "--format", ""}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v\noutput: %s", err, out.String()) + } + values := completionLineValues(out.String()) + for _, w := range []string{"tarball", "legacy", "oci"} { + if !slices.Contains(values, w) { + t.Errorf("--format completion missing %q; values=%v\noutput:\n%s", w, values, out.String()) + } + } +} + +func TestComplete_NestedUnderD8Root(t *testing.T) { + root := &cobra.Command{Use: "d8", Run: func(cmd *cobra.Command, _ []string) { _ = cmd.Help() }} + root.AddCommand(cr.NewCommand()) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"__complete", "cr", "pull", "--format", ""}) + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v\noutput: %s", err, out.String()) + } + values := completionLineValues(out.String()) + for _, w := range []string{"tarball", "legacy", "oci"} { + if !slices.Contains(values, w) { + t.Errorf("--format completion missing %q; values=%v\noutput:\n%s", w, values, out.String()) + } + } +} diff --git a/internal/cr/cmd/completion/completion.go b/internal/cr/cmd/completion/completion.go new file mode 100644 index 00000000..e10a76f8 --- /dev/null +++ b/internal/cr/cmd/completion/completion.go @@ -0,0 +1,403 @@ +/* +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 completion provides shell-completion helpers for the `d8 cr` +// subtree. The command files in basic/ and fs/ wire these in via +// ValidArgsFunction and RegisterFlagCompletionFunc. +// +// Completion-time network calls (ListCatalog/ListTags) are bounded by +// completionTimeout and degrade silently to an empty result on any +// failure - completion must never surface a stack trace into the +// user's terminal. +package completion + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/rootflagnames" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imageio" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +// PullFormats returns the static enum used for `cr pull --format`. +// Forwarded from imageio so that the cobra command and completion read +// from the same source of truth. +func PullFormats() []string { return imageio.PullFormats() } + +const ( + // Shell typically aborts a completion at ~2-3s. We cap shorter so a + // slow registry returns an empty list instead of a truncated frame. + completionTimeout = 2 * time.Second + + // Cap on the suggestion list. ListTags for a popular repo (e.g. nginx) + // can return thousands of pages - more than the user can scan anyway. + completionMaxItems = 200 +) + +// errStopPagination breaks ListTags/ListCatalog iteration once we have +// enough items. Treated as a clean stop, not an error. +var errStopPagination = errors.New("stop pagination") + +// refKind classifies what the user is currently typing in an IMAGE/REPO +// argument so the completer knows what to suggest. +type refKind int + +const ( + kindEmpty refKind = iota // "" + kindHost // "docker.io" - hostname (no '/' yet) + kindHostSlash // "docker.io/" - registry chosen, list repos + kindRepoPath // "docker.io/lib" - typing repo path + kindRepoColon // "docker.io/lib/nginx:" - typing tag + kindRepoDigest // "docker.io/lib/nginx@sha256:..." - typing digest +) + +type refParts struct { + kind refKind + host string + repoPath string + tagPart string +} + +// parseRef classifies toComplete. Tag separator detection is anchored to +// the part AFTER the first '/', so "localhost:5000/repo:tag" stays parseable +// (the ':' before 5000 is the port, not a tag separator). Digest refs +// ("repo@sha256:...") are detected before the tag-separator scan so the +// ':' inside "sha256:hex" is not misread as a tag boundary - otherwise +// completion would synthesize an invalid repo path "repo@sha256" and +// silently fan out a doomed ListTags request. +func parseRef(s string) refParts { + if s == "" { + return refParts{kind: kindEmpty} + } + host, pathPart, found := strings.Cut(s, "/") + if !found { + return refParts{kind: kindHost, host: s} + } + if pathPart == "" { + return refParts{kind: kindHostSlash, host: host} + } + if before, _, found := strings.Cut(pathPart, "@"); found { + return refParts{ + kind: kindRepoDigest, + host: host, + repoPath: strings.TrimRight(before, "/"), + } + } + if i := strings.LastIndex(pathPart, ":"); i != -1 { + return refParts{ + kind: kindRepoColon, + host: host, + repoPath: pathPart[:i], + tagPart: pathPart[i+1:], + } + } + // Trailing slashes are noise - "host/repo/" and "host/repo" address the + // same repository for completion purposes. Storing the slash would only + // matter if a future caller used parts.repoPath to build a request and + // produced "host//repo". + return refParts{kind: kindRepoPath, host: host, repoPath: strings.TrimRight(pathPart, "/")} +} + +// ImageRef completes IMAGE arguments (host, host/repo, host/repo:tag). +func ImageRef() cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeRefValue(cmd, toComplete, true) + } +} + +// RepoRef completes REPO arguments (cr ls): host, host/repo - never tags. +func RepoRef() cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeRefValue(cmd, toComplete, false) + } +} + +// RegistryHost completes REGISTRY (cr catalog) from ~/.docker/config.json. +// No network call - just local credential config. +func RegistryHost() cobra.CompletionFunc { + return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return filterByPrefix(loadDockerConfigRegistries(), toComplete), cobra.ShellCompDirectiveNoFileComp + } +} + +// Static completes a flag value from a fixed enum. +func Static(values ...string) cobra.CompletionFunc { + return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return filterByPrefix(values, toComplete), cobra.ShellCompDirectiveNoFileComp + } +} + +// PathThenImage handles `push PATH IMAGE`-style commands: file completion +// for the first positional, image completion for the rest. +func PathThenImage() cobra.CompletionFunc { + imgFn := ImageRef() + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveDefault + } + return imgFn(cmd, args, toComplete) + } +} + +// ImageThenPath handles `export IMAGE [TARBALL]`-style commands: image +// completion for the first positional, file completion for the rest. +func ImageThenPath() cobra.CompletionFunc { + imgFn := ImageRef() + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return imgFn(cmd, args, toComplete) + } + return nil, cobra.ShellCompDirectiveDefault + } +} + +// ImageThenInImagePath handles `cr fs ls/cat/tree/info IMAGE PATH`-style +// commands. First positional is IMAGE (network completion); subsequent +// positionals are in-image paths which are intentionally NOT completed +// (would require fetching layers per TAB - too expensive). NoFileComp is +// returned to avoid misleading the user with local file suggestions. +func ImageThenInImagePath() cobra.CompletionFunc { + imgFn := ImageRef() + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return imgFn(cmd, args, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + } +} + +// completeRefValue is the shared implementation for IMAGE/REPO completion. +// withTags=false short-circuits the kindRepoColon branch (cr ls REPO does +// not accept tags - if the user typed ':' anyway, we silently offer nothing). +func completeRefValue(cmd *cobra.Command, toComplete string, withTags bool) ([]string, cobra.ShellCompDirective) { + parts := parseRef(toComplete) + + switch parts.kind { + case kindEmpty, kindHost: + // No registry selected yet - suggest hosts from docker config so + // the user does not have to remember them. NoSpace because the + // user must continue typing '/repo' after picking a host. + return filterByPrefix(loadDockerConfigRegistries(), toComplete), + cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + + case kindHostSlash, kindRepoPath: + repos := tryListCatalog(cmd, parts.host) + if len(repos) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + suggestions := make([]string, 0, len(repos)) + for _, r := range repos { + suggestions = append(suggestions, parts.host+"/"+r) + } + // NoSpace: user may want to keep typing ':tag' after a repo match. + return filterByPrefix(suggestions, toComplete), + cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + + case kindRepoDigest: + // Once the user typed '@', they have committed to pinning a digest + // (a 64-char sha256 hex). Suggesting anything is unhelpful - the + // registry exposes no API to enumerate blobs by prefix - and trying + // would make doomed network calls under the 2s completion budget. + return nil, cobra.ShellCompDirectiveNoFileComp + + case kindRepoColon: + if !withTags { + return nil, cobra.ShellCompDirectiveNoFileComp + } + repoFull := parts.host + "/" + parts.repoPath + tags := tryListTags(cmd, repoFull) + if len(tags) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + suggestions := make([]string, 0, len(tags)) + for _, t := range tags { + suggestions = append(suggestions, repoFull+":"+t) + } + return filterByPrefix(suggestions, toComplete), cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp +} + +// tryListCatalog calls ListCatalog with a bounded context. Errors (404 from +// registries that do not implement /v2/_catalog, timeouts, auth failures) +// turn into an empty list - completion stays silent. +func tryListCatalog(cmd *cobra.Command, host string) []string { + ctx, cancel := completionContext(cmd) + defer cancel() + opts := buildCompletionOpts(cmd) + + var items []string + err := registry.ListCatalog(ctx, host, opts, func(repos []string) error { + items = append(items, repos...) + if len(items) >= completionMaxItems { + return errStopPagination + } + return nil + }) + if err != nil && !errors.Is(err, errStopPagination) { + return nil + } + if len(items) > completionMaxItems { + items = items[:completionMaxItems] + } + return items +} + +// tryListTags is the ListTags counterpart to tryListCatalog. +func tryListTags(cmd *cobra.Command, repoRef string) []string { + ctx, cancel := completionContext(cmd) + defer cancel() + opts := buildCompletionOpts(cmd) + + var items []string + err := registry.ListTags(ctx, repoRef, opts, func(tags []string) error { + items = append(items, tags...) + if len(items) >= completionMaxItems { + return errStopPagination + } + return nil + }) + if err != nil && !errors.Is(err, errStopPagination) { + return nil + } + if len(items) > completionMaxItems { + items = items[:completionMaxItems] + } + return items +} + +func completionContext(cmd *cobra.Command) (context.Context, context.CancelFunc) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(ctx, completionTimeout) +} + +// buildCompletionOpts builds a fresh *registry.Options for completion-time +// network calls. PersistentPreRunE is NOT invoked during shell completion, +// so the cr persistent flags (--insecure, --platform) have not been applied +// to the shared opts. We re-read them off cmd here so completion respects +// the same flags the eventual RunE would. +func buildCompletionOpts(cmd *cobra.Command) *registry.Options { + opts := registry.New() + if insecure, err := cmd.Flags().GetBool(rootflagnames.Insecure); err == nil && insecure { + opts.WithInsecure().WithTransport(registry.InsecureTransport()) + } + if platform, err := cmd.Flags().GetString(rootflagnames.Platform); err == nil && platform != "" { + if p, err := v1.ParsePlatform(platform); err == nil { + opts.WithPlatform(p) + } + } + return opts +} + +// dockerConfigDir mirrors Docker's canonical resolution: $DOCKER_CONFIG +// (used by CI containers, rootless docker, and multi-profile setups) wins +// over $HOME/.docker. authn.DefaultKeychain (which `cr` uses at runtime) +// follows the same rule, so completion must too - otherwise a logged-in +// user sees an empty host list. +func dockerConfigDir() string { + if d := os.Getenv("DOCKER_CONFIG"); d != "" { + return d + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".docker") +} + +// loadDockerConfigRegistries returns the registry hosts the user has logged +// into, parsed from /config.json. Both `auths` and +// `credHelpers` are considered. Hosts are normalized: +// "https://index.docker.io/v1/" -> "index.docker.io". Returns nil on any +// read/parse error - completion degrades to "no suggestions". +func loadDockerConfigRegistries() []string { + dir := dockerConfigDir() + if dir == "" { + return nil + } + data, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + return nil + } + var cfg struct { + Auths map[string]json.RawMessage `json:"auths"` + CredHelpers map[string]string `json:"credHelpers"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return nil + } + seen := make(map[string]struct{}, len(cfg.Auths)+len(cfg.CredHelpers)) + var hosts []string + add := func(raw string) { + h := normalizeHost(raw) + if h == "" { + return + } + if _, dup := seen[h]; dup { + return + } + seen[h] = struct{}{} + hosts = append(hosts, h) + } + for k := range cfg.Auths { + add(k) + } + for k := range cfg.CredHelpers { + add(k) + } + sort.Strings(hosts) + return hosts +} + +// normalizeHost strips scheme and any trailing path so "https://index.docker.io/v1/" +// and "index.docker.io" collapse to the same key. +func normalizeHost(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "https://") + s = strings.TrimPrefix(s, "http://") + if i := strings.Index(s, "/"); i != -1 { + s = s[:i] + } + return s +} + +// filterByPrefix returns items whose value starts with prefix. An empty +// prefix returns the items unchanged. +func filterByPrefix(items []string, prefix string) []string { + if prefix == "" { + return items + } + out := make([]string, 0, len(items)) + for _, item := range items { + if strings.HasPrefix(item, prefix) { + out = append(out, item) + } + } + return out +} diff --git a/internal/cr/cmd/completion/completion_test.go b/internal/cr/cmd/completion/completion_test.go new file mode 100644 index 00000000..0c59d81b --- /dev/null +++ b/internal/cr/cmd/completion/completion_test.go @@ -0,0 +1,342 @@ +/* +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 completion + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/spf13/cobra" +) + +// ---------- parseRef ---------- + +func TestParseRef(t *testing.T) { + cases := []struct { + in string + want refParts + }{ + {"", refParts{kind: kindEmpty}}, + {"docker.io", refParts{kind: kindHost, host: "docker.io"}}, + {"docker.io/", refParts{kind: kindHostSlash, host: "docker.io"}}, + {"docker.io/library", refParts{kind: kindRepoPath, host: "docker.io", repoPath: "library"}}, + {"docker.io/library/nginx", refParts{kind: kindRepoPath, host: "docker.io", repoPath: "library/nginx"}}, + {"docker.io/library/nginx:", refParts{kind: kindRepoColon, host: "docker.io", repoPath: "library/nginx"}}, + {"docker.io/library/nginx:latest", refParts{kind: kindRepoColon, host: "docker.io", repoPath: "library/nginx", tagPart: "latest"}}, + // Port in hostname must not be confused with tag separator. + {"localhost:5000", refParts{kind: kindHost, host: "localhost:5000"}}, + {"localhost:5000/", refParts{kind: kindHostSlash, host: "localhost:5000"}}, + {"localhost:5000/repo", refParts{kind: kindRepoPath, host: "localhost:5000", repoPath: "repo"}}, + {"localhost:5000/repo:tag", refParts{kind: kindRepoColon, host: "localhost:5000", repoPath: "repo", tagPart: "tag"}}, + // Digest refs ('@sha256:...') must not be misclassified as tag-typing: + // otherwise "repo@sha256" becomes the synthetic repo path and ListTags + // goes hunting for it. + {"docker.io/library/nginx@", refParts{kind: kindRepoDigest, host: "docker.io", repoPath: "library/nginx"}}, + {"docker.io/library/nginx@sha256:", refParts{kind: kindRepoDigest, host: "docker.io", repoPath: "library/nginx"}}, + {"docker.io/library/nginx@sha256:abcdef", refParts{kind: kindRepoDigest, host: "docker.io", repoPath: "library/nginx"}}, + {"localhost:5000/repo@sha256:abc", refParts{kind: kindRepoDigest, host: "localhost:5000", repoPath: "repo"}}, + // Trailing slash is noise - parseRef must collapse it so downstream + // suggestion building cannot end up with "host//repo". + {"docker.io/library/", refParts{kind: kindRepoPath, host: "docker.io", repoPath: "library"}}, + {"docker.io/library/nginx/", refParts{kind: kindRepoPath, host: "docker.io", repoPath: "library/nginx"}}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got := parseRef(tc.in) + if got != tc.want { + t.Fatalf("parseRef(%q):\n got %+v\n want %+v", tc.in, got, tc.want) + } + }) + } +} + +// ---------- filterByPrefix / normalizeHost ---------- + +func TestFilterByPrefix(t *testing.T) { + in := []string{"alpha", "alpaca", "beta"} + if got := filterByPrefix(in, ""); !slices.Equal(got, in) { + t.Fatalf("empty prefix: got %v want %v", got, in) + } + if got := filterByPrefix(in, "alp"); !slices.Equal(got, []string{"alpha", "alpaca"}) { + t.Fatalf("alp prefix: got %v", got) + } + if got := filterByPrefix(in, "z"); len(got) != 0 { + t.Fatalf("z prefix: got %v want empty", got) + } +} + +func TestNormalizeHost(t *testing.T) { + cases := map[string]string{ + "https://index.docker.io/v1/": "index.docker.io", + "http://localhost:5000": "localhost:5000", + " ghcr.io ": "ghcr.io", + "registry.example.com": "registry.example.com", + "registry.example.com/path": "registry.example.com", + } + for in, want := range cases { + if got := normalizeHost(in); got != want { + t.Errorf("normalizeHost(%q) = %q, want %q", in, got, want) + } + } +} + +// ---------- network completion against in-memory registry ---------- + +func newTestCmd() *cobra.Command { + // completion functions read --insecure off cmd.Flags(); a leaf cobra.Command + // with that flag declared is enough for the tests. + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Bool("insecure", true, "") + cmd.Flags().String("platform", "", "") + _ = cmd.ParseFlags([]string{"--insecure"}) + return cmd +} + +// pushImage uploads an empty image to the test registry under refStr. +func pushImage(t *testing.T, refStr string) { + t.Helper() + ref, err := name.ParseReference(refStr, name.Insecure) + if err != nil { + t.Fatalf("parse %s: %v", refStr, err) + } + if err := remote.Write(ref, empty.Image); err != nil { + t.Fatalf("push %s: %v", refStr, err) + } +} + +func TestImageRef_Tags(t *testing.T) { + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + pushImage(t, host+"/foo/bar:v1") + pushImage(t, host+"/foo/bar:v2") + pushImage(t, host+"/foo/bar:latest") + + cmd := newTestCmd() + got, dir := ImageRef()(cmd, nil, host+"/foo/bar:") + + want := []string{ + host + "/foo/bar:latest", + host + "/foo/bar:v1", + host + "/foo/bar:v2", + } + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("tags:\n got %v\n want %v", got, want) + } + if dir&cobra.ShellCompDirectiveNoFileComp == 0 { + t.Errorf("expected NoFileComp directive, got %v", dir) + } +} + +func TestImageRef_TagPrefix(t *testing.T) { + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + pushImage(t, host+"/foo:v1") + pushImage(t, host+"/foo:v2") + pushImage(t, host+"/foo:rc") + + cmd := newTestCmd() + got, _ := ImageRef()(cmd, nil, host+"/foo:v") + + want := []string{host + "/foo:v1", host + "/foo:v2"} + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("v-prefixed tags:\n got %v\n want %v", got, want) + } +} + +func TestRepoRef_Catalog(t *testing.T) { + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + pushImage(t, host+"/alpha:1") + pushImage(t, host+"/beta:1") + + cmd := newTestCmd() + got, dir := RepoRef()(cmd, nil, host+"/") + + want := []string{host + "/alpha", host + "/beta"} + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("catalog:\n got %v\n want %v", got, want) + } + if dir&cobra.ShellCompDirectiveNoSpace == 0 { + t.Errorf("expected NoSpace directive, got %v", dir) + } +} + +func TestRepoRef_NoTagsForLs(t *testing.T) { + // `cr ls REPO` does not accept a tag - if the user typed ':', + // completion must offer nothing rather than tags. + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + pushImage(t, host+"/foo:v1") + + cmd := newTestCmd() + got, _ := RepoRef()(cmd, nil, host+"/foo:") + if len(got) != 0 { + t.Errorf("RepoRef must not offer tags, got %v", got) + } +} + +// Once the user types '@' the completer must short-circuit: no suggestions, +// no doomed network calls. The handler counts incoming requests so the +// "no network call" half of the contract is verified directly, not by +// inferring it from an empty result. +func TestImageRef_DigestRefMakesNoNetworkCall(t *testing.T) { + var calls int32 + inner := registry.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + inner.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + cmd := newTestCmd() + got, dir := ImageRef()(cmd, nil, host+"/repo@sha256:") + + if len(got) != 0 { + t.Errorf("digest ref should yield no suggestions, got: %v", got) + } + if dir&cobra.ShellCompDirectiveNoFileComp == 0 { + t.Errorf("expected NoFileComp, got %v", dir) + } + if c := atomic.LoadInt32(&calls); c != 0 { + t.Errorf("digest ref must not contact the registry, got %d HTTP calls", c) + } +} + +func TestImageRef_UnreachableHostNoCrash(t *testing.T) { + // An unreachable registry must degrade silently to no suggestions + // (not a stack trace in the user's terminal). We point at a closed + // port and assert the call returns without panicking. + srv := httptest.NewServer(registry.New()) + srv.Close() // immediately close - subsequent calls will fail to connect + host := strings.TrimPrefix(srv.URL, "http://") + + cmd := newTestCmd() + got, dir := ImageRef()(cmd, nil, host+"/anything:") + if len(got) != 0 { + t.Errorf("expected empty suggestions on unreachable registry, got %v", got) + } + if dir&cobra.ShellCompDirectiveNoFileComp == 0 { + t.Errorf("expected NoFileComp on failure, got %v", dir) + } +} + +// ---------- static / position-aware completers ---------- + +func TestStatic(t *testing.T) { + got, dir := Static("a", "ab", "b")(nil, nil, "a") + want := []string{"a", "ab"} + if !slices.Equal(got, want) { + t.Errorf("got %v want %v", got, want) + } + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("got dir %v want NoFileComp", dir) + } +} + +func TestPathThenImage(t *testing.T) { + cmd := newTestCmd() + // First positional - file completion (default directive, empty list). + got, dir := PathThenImage()(cmd, nil, "") + if len(got) != 0 { + t.Errorf("first arg should be file-completed (empty list), got %v", got) + } + if dir != cobra.ShellCompDirectiveDefault { + t.Errorf("first arg dir = %v, want Default", dir) + } + // Second positional - falls through to ImageRef which (with empty + // toComplete) suggests known registries from docker config; we only + // assert the directive class here. + _, dir = PathThenImage()(cmd, []string{"some-path"}, "") + if dir&cobra.ShellCompDirectiveNoFileComp == 0 { + t.Errorf("second arg dir should set NoFileComp, got %v", dir) + } +} + +func TestImageThenInImagePath(t *testing.T) { + cmd := newTestCmd() + // Once IMAGE is in args, in-image PATH completion is intentionally + // suppressed (NoFileComp, empty list) - we do NOT want misleading + // local-file suggestions for an in-image path. + got, dir := ImageThenInImagePath()(cmd, []string{"some/img:tag"}, "/etc") + if len(got) != 0 { + t.Errorf("in-image PATH must not offer suggestions, got %v", got) + } + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("in-image PATH dir = %v, want NoFileComp", dir) + } +} + +// loadDockerConfigRegistries must honor $DOCKER_CONFIG just like +// authn.DefaultKeychain does at runtime - otherwise CI containers and +// rootless setups (which override $DOCKER_CONFIG) would see empty host +// suggestions while `cr` itself authenticates fine. +func TestLoadDockerConfigRegistries_HonorsDOCKER_CONFIG(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + + const cfg = `{ + "auths": { + "https://index.docker.io/v1/": {}, + "ghcr.io": {} + }, + "credHelpers": { + "registry.example.com": "store" + } + }` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0o600); err != nil { + t.Fatalf("write config.json: %v", err) + } + + got := loadDockerConfigRegistries() + want := []string{"ghcr.io", "index.docker.io", "registry.example.com"} + slices.Sort(got) + if !slices.Equal(got, want) { + t.Errorf("hosts:\n got %v\n want %v", got, want) + } +} + +func TestLoadDockerConfigRegistries_MissingConfigDegrades(t *testing.T) { + t.Setenv("DOCKER_CONFIG", t.TempDir()) // empty dir, no config.json + if got := loadDockerConfigRegistries(); len(got) != 0 { + t.Errorf("expected empty list when config.json is missing, got %v", got) + } +} diff --git a/internal/cr/cmd/cr.go b/internal/cr/cmd/cr.go new file mode 100644 index 00000000..53db05bc --- /dev/null +++ b/internal/cr/cmd/cr.go @@ -0,0 +1,67 @@ +/* +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 cr wires the `d8 cr` subtree. The root command lives here, the +// subcommands live in the basic/ and fs/ subpackages. All of them share a +// single *registry.Options populated at PersistentPreRunE time. +package cr + +import ( + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/basic" + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/fs" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +const ( + cmdShort = "Work with container registries" + + cmdLong = `Work with container images in OCI/Docker registries: inspect metadata, +transfer (pull/push), and browse contents. + +Authentication uses the Docker config (~/.docker/config.json) - run +"d8 login" first if the registry requires credentials. +` +) + +// NewCommand returns the `d8 cr` cobra subtree. +func NewCommand() *cobra.Command { + opts := registry.New() + + cr := &cobra.Command{ + Use: "cr", + Short: cmdShort, + Long: cmdLong, + SilenceUsage: true, + SilenceErrors: true, + } + + setupRootFlags(cr, opts) + cr.AddCommand( + basic.NewPullCmd(opts), + basic.NewPushCmd(opts), + basic.NewExportCmd(opts), + basic.NewLsCmd(opts), + basic.NewCatalogCmd(opts), + basic.NewManifestCmd(opts), + basic.NewConfigCmd(opts), + basic.NewDigestCmd(opts), + fs.NewCommand(opts), + ) + + return cr +} diff --git a/internal/cr/cmd/fs/cat.go b/internal/cr/cmd/fs/cat.go new file mode 100644 index 00000000..29aa1923 --- /dev/null +++ b/internal/cr/cmd/fs/cat.go @@ -0,0 +1,55 @@ +/* +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 fs + +import ( + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imagefs" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func newCatCmd(opts *registry.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "cat IMAGE PATH", + Short: "Print a file from a container image", + Long: `Print a file from a container image to stdout. + +Only regular files are supported. Directories, symlinks, hardlinks and +other non-regular entries return an error. Reads the merged filesystem - +files deleted by upper layers are reported as not-found.`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.ImageThenInImagePath(), + RunE: func(cmd *cobra.Command, args []string) error { + ref, filePath := args[0], args[1] + + img, err := registry.Fetch(cmd.Context(), ref, opts) + if err != nil { + return err + } + + content, err := imagefs.ReadFile(img, filePath) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(content) + return err + }, + } + return cmd +} diff --git a/internal/cr/cmd/fs/extract.go b/internal/cr/cmd/fs/extract.go new file mode 100644 index 00000000..6f94c2ae --- /dev/null +++ b/internal/cr/cmd/fs/extract.go @@ -0,0 +1,68 @@ +/* +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 fs + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imagefs" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/output" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func newExtractCmd(opts *registry.Options) *cobra.Command { + var outputDir string + cmd := &cobra.Command{ + Use: "extract IMAGE", + Short: "Extract a container image filesystem to a local directory", + Long: `Extract the merged filesystem of a container image into --output. To +inspect or copy a single file, use "fs cat"; to list a subpath, use +"fs ls IMAGE PATH". An extraction summary is printed when finished. + +On interruption (Ctrl+C): partially-written files stay in --output for inspection. +Rerun extract to overwrite them; there is no per-file skip, the full filesystem is +re-materialized from layer 0.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ImageRef(), + RunE: func(cmd *cobra.Command, args []string) error { + img, err := registry.Fetch(cmd.Context(), args[0], opts) + if err != nil { + return err + } + + stats, err := imagefs.ExtractMerged(cmd.Context(), img, outputDir) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Extracted to %s\n", outputDir) + fmt.Fprintf(w, " files: %d\n", stats.Files) + fmt.Fprintf(w, " dirs: %d\n", stats.Dirs) + fmt.Fprintf(w, " symlinks: %d\n", stats.Symlinks) + fmt.Fprintf(w, " hardlinks: %d\n", stats.Hardlinks) + fmt.Fprintf(w, " total: %s (%d bytes)\n", output.HumanSize(stats.TotalSize), stats.TotalSize) + return nil + }, + } + cmd.Flags().StringVarP(&outputDir, "output", "o", "", "Write the filesystem into this directory") + _ = cmd.MarkFlagRequired("output") + return cmd +} diff --git a/internal/cr/cmd/fs/fs.go b/internal/cr/cmd/fs/fs.go new file mode 100644 index 00000000..8d865266 --- /dev/null +++ b/internal/cr/cmd/fs/fs.go @@ -0,0 +1,58 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package fs implements the `d8 cr fs` subtree - filesystem inspection of +// container images. Kept separate from crane-style `ls` (which lists tags) +// to avoid the semantic collision between crane and artship/CEK tooling. +package fs + +import ( + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +// NewCommand returns the `d8 cr fs` subtree. All subcommands share opts +// populated by the root command's PersistentPreRunE. +func NewCommand(opts *registry.Options) *cobra.Command { + fsCmd := &cobra.Command{ + Use: "fs", + Short: "Inspect or extract files inside a container image", + Long: `Inspect or extract files inside a container image without running it. + +Subcommands show the merged filesystem - what a running container would see, +with deleted files hidden. + +Path conventions: + - Input PATH is tolerant: "/etc/nginx", "etc/nginx", and "./etc/nginx" + are all accepted and refer to the same entry. + - Output paths are tar-relative (no leading "/"), matching the convention + of "crane export IMAGE - | tar tf -" and POSIX tar archives. JSON output + uses the same tar-relative form.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + fsCmd.AddCommand( + newLsCmd(opts), + newCatCmd(opts), + newTreeCmd(opts), + newExtractCmd(opts), + ) + + return fsCmd +} diff --git a/internal/cr/cmd/fs/ls.go b/internal/cr/cmd/fs/ls.go new file mode 100644 index 00000000..c447eee5 --- /dev/null +++ b/internal/cr/cmd/fs/ls.go @@ -0,0 +1,63 @@ +/* +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 fs + +import ( + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imagefs" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/output" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +func newLsCmd(opts *registry.Options) *cobra.Command { + var longForm bool + cmd := &cobra.Command{ + Use: "ls IMAGE [PATH]", + Short: "List files inside a container image", + Long: `List files inside a container image. + +PATH limits output to that path and its descendants. Leading "./" or "/" is +stripped, so "/etc" and "etc" are equivalent. Output paths are tar-relative +(no leading "/"), matching "crane export | tar tf -".`, + Args: cobra.RangeArgs(1, 2), + ValidArgsFunction: completion.ImageThenInImagePath(), + RunE: func(cmd *cobra.Command, args []string) error { + ref := args[0] + subpath := "" + if len(args) == 2 { + subpath = args[1] + } + + img, err := registry.Fetch(cmd.Context(), ref, opts) + if err != nil { + return err + } + + entries, err := imagefs.MergedFS(img) + if err != nil { + return err + } + entries = imagefs.FilterBySubpath(entries, subpath) + + return output.WriteEntriesText(cmd.OutOrStdout(), entries, longForm) + }, + } + cmd.Flags().BoolVarP(&longForm, "long", "l", false, "Long format with mode and size") + return cmd +} diff --git a/internal/cr/cmd/fs/tree.go b/internal/cr/cmd/fs/tree.go new file mode 100644 index 00000000..40cfe69e --- /dev/null +++ b/internal/cr/cmd/fs/tree.go @@ -0,0 +1,199 @@ +/* +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 fs + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imagefs" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/output" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +type treeNode struct { + name string + entry imagefs.Entry + isDir bool + children map[string]*treeNode +} + +func newTreeCmd(opts *registry.Options) *cobra.Command { + var ( + maxDepth int + dirsFirst bool + showSize bool + ) + cmd := &cobra.Command{ + Use: "tree IMAGE [PATH]", + Short: "Show the filesystem of a container image as a tree", + Long: `Render the filesystem of a container image as a tree. + +PATH (if given) becomes the tree root and is normalized the same way as in +"fs ls" (leading "./" or "/" stripped, "..", trailing "/" cleaned). + +The merged filesystem is rendered.`, + Args: cobra.RangeArgs(1, 2), + ValidArgsFunction: completion.ImageThenInImagePath(), + RunE: func(cmd *cobra.Command, args []string) error { + ref := args[0] + root := "" + if len(args) == 2 { + root = imagefs.NormalizeScopePath(args[1]) + if root == "." { + root = "" + } + } + + img, err := registry.Fetch(cmd.Context(), ref, opts) + if err != nil { + return err + } + + entries, err := imagefs.MergedFS(img) + if err != nil { + return err + } + + tree := buildTree(entries, root) + rootLabel := "/" + if root != "" { + rootLabel = "/" + root + } + return writeTreeText(cmd.OutOrStdout(), tree, rootLabel, maxDepth, dirsFirst, showSize) + }, + } + cmd.Flags().IntVarP(&maxDepth, "depth", "L", 0, "Max depth to descend (0 = unlimited)") + cmd.Flags().BoolVar(&dirsFirst, "dirsfirst", false, "List directories before files") + cmd.Flags().BoolVar(&showSize, "size", false, "Show file sizes (human-readable: B / KB / MB)") + return cmd +} + +func buildTree(entries []imagefs.Entry, root string) *treeNode { + tree := &treeNode{name: root, isDir: true, children: make(map[string]*treeNode)} + for _, e := range entries { + if e.Type == imagefs.TypeWhiteout { + continue + } + rel := e.Path + if root != "" { + if rel == root { + tree.entry = e + continue + } + prefix := root + "/" + if !strings.HasPrefix(rel, prefix) { + continue + } + rel = strings.TrimPrefix(rel, prefix) + } + insert(tree, rel, e) + } + return tree +} + +func insert(parent *treeNode, rel string, entry imagefs.Entry) { + parts := strings.Split(rel, "/") + cur := parent + for i, part := range parts { + if part == "" { + continue + } + child, ok := cur.children[part] + if !ok { + child = &treeNode{name: part, children: make(map[string]*treeNode)} + cur.children[part] = child + } + // isDir grows monotonically: a node that has been observed as a + // directory (either via a Dir entry, or because it has descendants) + // must stay a directory even if a later, conflicting entry with the + // same path claims to be a regular file. Without this guard, an + // unsorted input or a malformed tar carrying both "etc" (file) and + // "etc/passwd" (file under it) could flip "etc" back to non-dir + // after its children were registered, hiding the subtree. + isDirNow := i < len(parts)-1 || entry.IsDir() + child.isDir = child.isDir || isDirNow + if i == len(parts)-1 { + child.entry = entry + } + cur = child + } +} + +func writeTreeText(w io.Writer, tree *treeNode, rootLabel string, maxDepth int, dirsFirst, showSize bool) error { + if _, err := fmt.Fprintln(w, rootLabel); err != nil { + return err + } + return writeSubtree(w, tree, "", 1, maxDepth, dirsFirst, showSize) +} + +func writeSubtree(w io.Writer, node *treeNode, prefix string, depth, maxDepth int, dirsFirst, showSize bool) error { + if maxDepth > 0 && depth > maxDepth { + return nil + } + names := make([]string, 0, len(node.children)) + for n := range node.children { + names = append(names, n) + } + sortChildren(names, node.children, dirsFirst) + + for i, name := range names { + child := node.children[name] + isLast := i == len(names)-1 + branch := "├── " + nextPrefix := prefix + "│ " + if isLast { + branch = "└── " + nextPrefix = prefix + " " + } + displayName := name + if child.isDir { + displayName += "/" + } + line := prefix + branch + displayName + if !child.isDir && showSize { + line += " " + output.HumanSize(child.entry.Size) + } + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + if child.isDir { + if err := writeSubtree(w, child, nextPrefix, depth+1, maxDepth, dirsFirst, showSize); err != nil { + return err + } + } + } + return nil +} + +func sortChildren(names []string, children map[string]*treeNode, dirsFirst bool) { + sort.Slice(names, func(i, j int) bool { + ni, nj := names[i], names[j] + if dirsFirst { + di, dj := children[ni].isDir, children[nj].isDir + if di != dj { + return di + } + } + return ni < nj + }) +} diff --git a/internal/cr/cmd/fs/tree_test.go b/internal/cr/cmd/fs/tree_test.go new file mode 100644 index 00000000..67052ed0 --- /dev/null +++ b/internal/cr/cmd/fs/tree_test.go @@ -0,0 +1,74 @@ +/* +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 fs + +import ( + "io/fs" + "testing" + + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imagefs" +) + +// buildTree relies on sorted parent-before-children ordering from +// imagefs.MergedFS. The defensive guard in insert() keeps the tree +// consistent even when that ordering assumption is violated (unsorted +// caller, malformed tar carrying both "etc" as a file and "etc/passwd" +// as a file under it). Without the guard, the file entry for "etc" +// arriving after its descendants would flip child.isDir back to false +// and hide the subtree from the rendered output. +func TestBuildTree_IsDirIsMonotonicAcrossUnsortedInput(t *testing.T) { + // Note: deliberately unsorted - "etc/passwd" before "etc" - to mimic + // the worst case where the parent entry is observed last. + entries := []imagefs.Entry{ + {Path: "etc/passwd", Type: imagefs.TypeFile, Mode: 0o644}, + {Path: "etc", Type: imagefs.TypeFile, Mode: 0o644}, + } + tree := buildTree(entries, "") + + etc, ok := tree.children["etc"] + if !ok { + t.Fatalf("etc node missing from tree: %+v", tree.children) + } + if !etc.isDir { + t.Fatalf("etc.isDir collapsed to false; subtree would be hidden") + } + if _, ok := etc.children["passwd"]; !ok { + t.Fatalf("passwd descendant missing under etc: %+v", etc.children) + } +} + +// Sorted, well-formed input must keep its natural directory/file flags. +// This is the path actually exercised in production by `fs tree`. +func TestBuildTree_SortedInputKeepsNaturalIsDir(t *testing.T) { + entries := []imagefs.Entry{ + {Path: "etc", Type: imagefs.TypeDir, Mode: fs.ModeDir | 0o755}, + {Path: "etc/passwd", Type: imagefs.TypeFile, Mode: 0o644}, + } + tree := buildTree(entries, "") + + etc, ok := tree.children["etc"] + if !ok || !etc.isDir { + t.Fatalf("etc must be a directory: ok=%v isDir=%v", ok, etc != nil && etc.isDir) + } + passwd, ok := etc.children["passwd"] + if !ok { + t.Fatalf("passwd must live under etc: %+v", etc.children) + } + if passwd.isDir { + t.Fatalf("passwd must remain a regular file, got isDir=true") + } +} diff --git a/internal/cr/cmd/integration_test.go b/internal/cr/cmd/integration_test.go new file mode 100644 index 00000000..9831dd3e --- /dev/null +++ b/internal/cr/cmd/integration_test.go @@ -0,0 +1,588 @@ +/* +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. +*/ + +// Hermetic integration tests for `d8 cr` against an in-memory OCI registry +// (httptest.NewServer + pkg/registry). No docker, no network. +package cr_test + +import ( + "archive/tar" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" + + cr "github.com/deckhouse/deckhouse-cli/internal/cr/cmd" +) + +var sha256Re = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + +// ---------- env ---------- + +type testEnv struct { + Host string + Alpine string + Alpine2 string + Busybox string +} + +func setupEnv(t *testing.T) *testEnv { + t.Helper() + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + env := &testEnv{ + Host: host, + Alpine: host + "/alpine:3.19", + Alpine2: host + "/alpine:3.18", + Busybox: host + "/busybox:1.36", + } + pushImage(t, env.Alpine, alpineImage(t, "3.19")) + pushImage(t, env.Alpine2, alpineImage(t, "3.18")) + pushImage(t, env.Busybox, busyboxImage(t)) + return env +} + +func pushImage(t *testing.T, refStr string, img v1.Image) { + t.Helper() + ref, err := name.ParseReference(refStr, name.Insecure) + if err != nil { + t.Fatalf("parse %s: %v", refStr, err) + } + if err := remote.Write(ref, img); err != nil { + t.Fatalf("push %s: %v", refStr, err) + } +} + +// ---------- runner ---------- + +func runCmd(t *testing.T, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + cmd := cr.NewCommand() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(append([]string{"--insecure"}, args...)) + err := cmd.Execute() + return out.String(), err +} + +func mustRun(t *testing.T, args ...string) string { + t.Helper() + out, err := runCmd(t, args...) + if err != nil { + t.Fatalf("d8 cr %s\nerror: %v\noutput:\n%s", strings.Join(args, " "), err, out) + } + return out +} + +func mustFail(t *testing.T, args ...string) { + t.Helper() + if out, err := runCmd(t, args...); err == nil { + t.Fatalf("expected failure from: d8 cr %s\noutput:\n%s", strings.Join(args, " "), out) + } +} + +// ---------- image builders ---------- + +type tarFile struct { + name string + typeflag byte + content []byte + linkname string + mode int64 +} + +func tarLayer(t *testing.T, files []tarFile) v1.Layer { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, f := range files { + mode := f.mode + if mode == 0 { + if f.typeflag == tar.TypeDir { + mode = 0o755 + } else { + mode = 0o644 + } + } + if err := tw.WriteHeader(&tar.Header{ + Name: f.name, Typeflag: f.typeflag, Size: int64(len(f.content)), + Linkname: f.linkname, Mode: mode, + }); err != nil { + t.Fatalf("tar header %s: %v", f.name, err) + } + if len(f.content) > 0 { + if _, err := tw.Write(f.content); err != nil { + t.Fatalf("tar write %s: %v", f.name, err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + data := buf.Bytes() + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }) + if err != nil { + t.Fatalf("layer: %v", err) + } + return layer +} + +func buildImage(t *testing.T, layers [][]tarFile) v1.Image { + t.Helper() + img := empty.Image + for _, files := range layers { + var err error + img, err = mutate.AppendLayers(img, tarLayer(t, files)) + if err != nil { + t.Fatalf("append layer: %v", err) + } + } + return img +} + +// alpineImage: small alpine-like rootfs with /etc/os-release, /etc/passwd, +// /bin/busybox, and absolute symlinks /bin/sh and /usr/bin/awk → /bin/busybox. +// The absolute symlinks exercise the extractor's rewrite-to-relative path. +func alpineImage(t *testing.T, version string) v1.Image { + osRelease := fmt.Sprintf("NAME=\"Alpine Linux\"\nID=alpine\nVERSION_ID=%s\nPRETTY_NAME=\"Alpine Linux v%s\"\n", version, version) + return buildImage(t, [][]tarFile{{ + {name: "bin/", typeflag: tar.TypeDir}, + {name: "etc/", typeflag: tar.TypeDir}, + {name: "usr/", typeflag: tar.TypeDir}, + {name: "usr/bin/", typeflag: tar.TypeDir}, + {name: "bin/busybox", typeflag: tar.TypeReg, content: []byte("#!busybox"), mode: 0o755}, + {name: "etc/os-release", typeflag: tar.TypeReg, content: []byte(osRelease)}, + {name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("root:x:0:0:root:/root:/bin/sh\n")}, + {name: "bin/sh", typeflag: tar.TypeSymlink, linkname: "/bin/busybox"}, + {name: "usr/bin/awk", typeflag: tar.TypeSymlink, linkname: "/bin/busybox"}, + }}) +} + +func busyboxImage(t *testing.T) v1.Image { + return buildImage(t, [][]tarFile{{ + {name: "bin/", typeflag: tar.TypeDir}, + {name: "bin/busybox", typeflag: tar.TypeReg, content: []byte("#!busybox"), mode: 0o755}, + }}) +} + +// ---------- file helpers ---------- + +func isTar(t *testing.T, path string) bool { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + // Require at least one successful header read - an empty file would + // otherwise return io.EOF on the first Next() call and falsely pass + // as a "valid tar". + if _, err := tar.NewReader(f).Next(); err != nil { + return false + } + return true +} + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var v map[string]any + if err := json.Unmarshal(b, &v); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return v +} + +func parseJSON(t *testing.T, raw string) map[string]any { + t.Helper() + var v map[string]any + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("parse json: %v\nraw: %s", err, raw) + } + return v +} + +// ===================================================================== +// catalog / ls / manifest / config / digest +// ===================================================================== + +func TestIntegration_Registry(t *testing.T) { + env := setupEnv(t) + + t.Run("catalog lists seeded repos", func(t *testing.T) { + out := mustRun(t, "catalog", env.Host) + for _, repo := range []string{"alpine", "busybox"} { + if !strings.Contains(out, repo+"\n") { + t.Fatalf("missing %q in: %s", repo, out) + } + } + }) + t.Run("ls returns tag", func(t *testing.T) { + if out := mustRun(t, "ls", env.Host+"/alpine"); !strings.Contains(out, "3.19\n") { + t.Fatalf("missing 3.19 in: %s", out) + } + }) + t.Run("ls --full-ref formats host/repo:tag", func(t *testing.T) { + if out := mustRun(t, "ls", "--full-ref", env.Host+"/alpine"); !strings.Contains(out, env.Host+"/alpine:") { + t.Fatalf("missing full ref in: %s", out) + } + }) + t.Run("manifest is valid JSON with mediaType", func(t *testing.T) { + if _, ok := parseJSON(t, mustRun(t, "manifest", env.Alpine))["mediaType"]; !ok { + t.Fatal("manifest has no mediaType") + } + }) + t.Run("config has architecture", func(t *testing.T) { + if _, ok := parseJSON(t, mustRun(t, "config", env.Alpine))["architecture"]; !ok { + t.Fatal("config has no architecture") + } + }) + t.Run("digest is sha256:64hex", func(t *testing.T) { + if d := strings.TrimSpace(mustRun(t, "digest", env.Alpine)); !sha256Re.MatchString(d) { + t.Fatalf("bad digest: %q", d) + } + }) + t.Run("digest --full-ref returns repo@sha256:...", func(t *testing.T) { + if out := mustRun(t, "digest", "--full-ref", env.Alpine); !strings.Contains(out, env.Host+"/alpine@sha256:") { + t.Fatalf("missing full ref in: %s", out) + } + }) +} + +// ===================================================================== +// pull (tarball / legacy / oci / multi) +// ===================================================================== + +func TestIntegration_Pull(t *testing.T) { + env := setupEnv(t) + work := t.TempDir() + + t.Run("tarball (default)", func(t *testing.T) { + dst := filepath.Join(work, "alpine.tar") + mustRun(t, "pull", env.Alpine, dst) + if !isTar(t, dst) { + t.Fatalf("%s is not a tar", dst) + } + }) + t.Run("legacy", func(t *testing.T) { + dst := filepath.Join(work, "alpine-legacy.tar") + mustRun(t, "pull", "--format", "legacy", env.Alpine, dst) + if !isTar(t, dst) { + t.Fatalf("%s is not a tar", dst) + } + }) + t.Run("oci layout", func(t *testing.T) { + dst := filepath.Join(work, "oci") + mustRun(t, "pull", "--format", "oci", env.Alpine, dst) + if _, err := os.Stat(filepath.Join(dst, "oci-layout")); err != nil { + t.Fatalf("missing oci-layout marker: %v", err) + } + if _, ok := readJSON(t, filepath.Join(dst, "index.json"))["manifests"]; !ok { + t.Fatal("index.json has no manifests") + } + }) + t.Run("two images into one tarball", func(t *testing.T) { + dst := filepath.Join(work, "multi.tar") + mustRun(t, "pull", env.Alpine, env.Busybox, dst) + if !isTar(t, dst) { + t.Fatal("not a tar") + } + }) + t.Run("two images into oci layout", func(t *testing.T) { + dst := filepath.Join(work, "multi-oci") + mustRun(t, "pull", "--format", "oci", env.Alpine, env.Alpine2, dst) + idx := readJSON(t, filepath.Join(dst, "index.json")) + manifests, ok := idx["manifests"].([]any) + if !ok { + t.Fatalf("manifests is not an array: %T (full index: %v)", idx["manifests"], idx) + } + if len(manifests) != 2 { + t.Fatalf("expected 2 manifests, got %d", len(manifests)) + } + }) +} + +// ===================================================================== +// export (crane-compatible tar stream) +// ===================================================================== + +func TestIntegration_Export(t *testing.T) { + env := setupEnv(t) + work := t.TempDir() + + t.Run("default destination is stdout", func(t *testing.T) { + out := mustRun(t, "export", env.Alpine) + // Output is a tar stream — first entry must be readable. + tr := tar.NewReader(strings.NewReader(out)) + hdr, err := tr.Next() + if err != nil { + t.Fatalf("tar reader: %v", err) + } + if hdr == nil || hdr.Name == "" { + t.Fatalf("empty header: %+v", hdr) + } + }) + + t.Run("explicit '-' writes to stdout", func(t *testing.T) { + out := mustRun(t, "export", env.Alpine, "-") + if _, err := tar.NewReader(strings.NewReader(out)).Next(); err != nil { + t.Fatalf("not a tar: %v", err) + } + }) + + t.Run("file destination writes valid tar", func(t *testing.T) { + dst := filepath.Join(work, "fs.tar") + mustRun(t, "export", env.Alpine, dst) + if !isTar(t, dst) { + t.Fatalf("%s is not a tar archive", dst) + } + }) + + // On any error path the destination must not be left as a half-written + // or empty tar that could be picked up by mistake (`tar tf` happily + // reads truncated streams). Fetch failure is the easiest way to trigger + // the error path through the CLI surface; the corresponding cleanup is + // in runExport. + t.Run("file destination is removed on Fetch error", func(t *testing.T) { + dst := filepath.Join(work, "should-not-exist.tar") + if _, err := runCmd(t, "export", env.Host+"/no-such-image:tag", dst); err == nil { + t.Fatal("expected fetch failure") + } + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Errorf("dst must not exist after error, Stat err=%v", err) + } + }) + + t.Run("verbatim symlinks (absolute targets preserved)", func(t *testing.T) { + // crane semantics: linknames are NOT rewritten. /bin/sh stays as + // "/bin/busybox" (vs. our `fs extract` which rewrites to relative). + dst := filepath.Join(work, "verbatim.tar") + mustRun(t, "export", env.Alpine, dst) + + f, err := os.Open(dst) + if err != nil { + t.Fatalf("open: %v", err) + } + defer f.Close() + + tr := tar.NewReader(f) + var found bool + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("tar.Next: %v", err) + } + if hdr.Typeflag == tar.TypeSymlink && strings.Contains(hdr.Name, "bin/sh") { + if hdr.Linkname != "/bin/busybox" { + t.Fatalf("expected verbatim '/bin/busybox', got %q", hdr.Linkname) + } + found = true + } + } + if !found { + t.Fatal("bin/sh symlink not found in export tar") + } + }) +} + +// ===================================================================== +// digest --tarball +// ===================================================================== + +func TestIntegration_DigestTarball(t *testing.T) { + env := setupEnv(t) + work := t.TempDir() + tarPath := filepath.Join(work, "alpine.tar") + mustRun(t, "pull", env.Alpine, tarPath) + + if d := strings.TrimSpace(mustRun(t, "digest", "--tarball", tarPath, env.Alpine)); !sha256Re.MatchString(d) { + t.Fatalf("bad digest: %q", d) + } +} + +// ===================================================================== +// fs ls / cat / tree / info / extract +// ===================================================================== + +func TestIntegration_FS(t *testing.T) { + env := setupEnv(t) + work := t.TempDir() + + t.Run("ls merged is non-empty", func(t *testing.T) { + if out := mustRun(t, "fs", "ls", env.Alpine); strings.TrimSpace(out) == "" { + t.Fatal("empty stdout") + } + }) + t.Run("ls etc lists passwd", func(t *testing.T) { + if !strings.Contains(mustRun(t, "fs", "ls", env.Alpine, "etc"), "passwd") { + t.Fatal("missing passwd") + } + }) + t.Run("ls strict path-scope etc/passwd", func(t *testing.T) { + if !strings.Contains(mustRun(t, "fs", "ls", env.Alpine, "etc/passwd"), "passwd") { + t.Fatal("missing passwd") + } + }) + t.Run("cat /etc/os-release returns Alpine", func(t *testing.T) { + if !strings.Contains(mustRun(t, "fs", "cat", env.Alpine, "/etc/os-release"), "Alpine") { + t.Fatal("missing 'Alpine'") + } + }) + t.Run("tree -L 1", func(t *testing.T) { + if out := mustRun(t, "fs", "tree", env.Alpine, "etc", "-L", "1"); strings.TrimSpace(out) == "" { + t.Fatal("empty tree") + } + }) + t.Run("extract -o materializes /etc/os-release", func(t *testing.T) { + dst := filepath.Join(work, "rootfs") + mustRun(t, "fs", "extract", env.Alpine, "-o", dst) + if _, err := os.Stat(filepath.Join(dst, "etc/os-release")); err != nil { + t.Fatalf("missing /etc/os-release: %v", err) + } + }) + t.Run("extract rewrites abs symlinks (alpine /bin/sh → busybox)", func(t *testing.T) { + dst := filepath.Join(work, "rootfs-symlinks") + mustRun(t, "fs", "extract", env.Alpine, "-o", dst) + got, err := os.Readlink(filepath.Join(dst, "bin/sh")) + if err != nil { + t.Fatalf("readlink: %v", err) + } + if filepath.IsAbs(got) { + t.Fatalf("expected relative target, got absolute %q", got) + } + }) +} + +// ===================================================================== +// error paths +// ===================================================================== + +func TestIntegration_Errors(t *testing.T) { + env := setupEnv(t) + work := t.TempDir() + tarPath := filepath.Join(work, "any.tar") + mustRun(t, "pull", env.Alpine, tarPath) + + cases := []struct { + name string + args []string + }{ + {"pull --format invalid", []string{"pull", "--format", "invalid", env.Alpine, filepath.Join(work, "x.tar")}}, + {"pull without PATH", []string{"pull", env.Alpine}}, + {"fs cat missing file", []string{"fs", "cat", env.Alpine, "/no/such/file"}}, + {"fs extract without -o", []string{"fs", "extract", env.Alpine}}, + {"digest without IMAGE", []string{"digest"}}, + {"digest --full-ref + --tarball", []string{"digest", "--full-ref", "--tarball", tarPath}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { mustFail(t, c.args...) }) + } + + // `digest --full-ref` without IMAGE and without --tarball must report the + // missing-IMAGE problem, not a misleading "cannot be combined with --tarball" + // message - the order of validation checks in digest.go matters here. + t.Run("digest --full-ref without IMAGE blames the missing reference", func(t *testing.T) { + out, err := runCmd(t, "digest", "--full-ref") + if err == nil { + t.Fatalf("expected failure, got nil") + } + msg := err.Error() + "\n" + out + if strings.Contains(msg, "cannot be combined with --tarball") { + t.Fatalf("error mentions --tarball even though it was not passed: %s", msg) + } + if !strings.Contains(msg, "image reference required") { + t.Fatalf("expected 'image reference required' in error, got: %s", msg) + } + }) +} + +// ===================================================================== +// push round-trip +// ===================================================================== + +func TestIntegration_PushRoundTrip(t *testing.T) { + env := setupEnv(t) + work := t.TempDir() + tarPath := filepath.Join(work, "alpine.tar") + mustRun(t, "pull", env.Alpine, tarPath) + + t.Run("push tarball", func(t *testing.T) { + mustRun(t, "push", tarPath, env.Host+"/alpine:copied") + out := mustRun(t, "ls", env.Host+"/alpine") + if !strings.Contains(out, "copied\n") { + t.Fatalf("copied tag missing in: %q", out) + } + }) + t.Run("round-trip digest matches source", func(t *testing.T) { + src := mustRun(t, "digest", env.Alpine) + dst := mustRun(t, "digest", env.Host+"/alpine:copied") + if src != dst { + t.Fatalf("digest mismatch:\n src: %q\n dst: %q", src, dst) + } + }) + t.Run("push OCI layout (single image)", func(t *testing.T) { + oci := filepath.Join(work, "oci-single") + mustRun(t, "pull", "--format", "oci", env.Alpine, oci) + mustRun(t, "push", oci, env.Host+"/alpine:from-oci") + }) + t.Run("push --image-refs writes file", func(t *testing.T) { + refs := filepath.Join(work, "refs.txt") + mustRun(t, "push", "--image-refs", refs, tarPath, env.Host+"/alpine:withrefs") + info, err := os.Stat(refs) + if err != nil || info.Size() == 0 { + t.Fatalf("refs file empty/missing: %v", err) + } + }) + t.Run("push multi-image OCI without --index fails", func(t *testing.T) { + multi := filepath.Join(work, "oci-multi") + mustRun(t, "pull", "--format", "oci", env.Alpine, env.Alpine2, multi) + mustFail(t, "push", multi, env.Host+"/alpine:multi") + }) + t.Run("push multi-image OCI with --index produces index manifest", func(t *testing.T) { + multi := filepath.Join(work, "oci-multi-ok") + mustRun(t, "pull", "--format", "oci", env.Alpine, env.Alpine2, multi) + mustRun(t, "push", "--index", multi, env.Host+"/alpine:multi") + mt, _ := parseJSON(t, mustRun(t, "manifest", env.Host+"/alpine:multi"))["mediaType"].(string) + if !strings.Contains(mt, "index") && !strings.Contains(mt, "manifest.list") { + t.Fatalf("expected index/manifest.list, got %q", mt) + } + }) +} diff --git a/internal/cr/cmd/rootflagnames/names.go b/internal/cr/cmd/rootflagnames/names.go new file mode 100644 index 00000000..382546da --- /dev/null +++ b/internal/cr/cmd/rootflagnames/names.go @@ -0,0 +1,30 @@ +/* +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 rootflagnames holds the persistent-flag names of the `d8 cr` +// root command in one place. Both the producer (cmd/rootflags.go, where +// the flags are registered) and the consumers (e.g. cmd/completion, which +// re-reads them at completion time because PersistentPreRunE does not run +// during shell completion) must agree on the literal name strings - +// keeping them here prevents silent breakage on future renames. +package rootflagnames + +const ( + Verbose = "verbose" + Insecure = "insecure" + AllowNondistributable = "allow-nondistributable-artifacts" + Platform = "platform" +) diff --git a/internal/cr/cmd/rootflags.go b/internal/cr/cmd/rootflags.go new file mode 100644 index 00000000..0ab5d484 --- /dev/null +++ b/internal/cr/cmd/rootflags.go @@ -0,0 +1,93 @@ +/* +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 cr + +import ( + "fmt" + "io" + "os" + + "github.com/google/go-containerregistry/pkg/logs" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/cmd/rootflagnames" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +// setupRootFlags installs the four persistent flags on cmd and wires a +// PersistentPreRunE that feeds them into opts. Running order matters: cobra +// invokes PersistentPreRunE on the root before any subcommand's RunE, so by +// the time RunE reads from opts the struct is fully populated. +// +// Idempotent: PersistentPreRunE resets opts to a fresh state on each call, +// so a second invocation (test harness, embedder) cannot stack duplicate +// remote/name options. +func setupRootFlags(cmd *cobra.Command, opts *registry.Options) { + var ( + verbose bool + insecure bool + ndLayers bool + platform string + ) + + flags := cmd.PersistentFlags() + flags.BoolVarP(&verbose, rootflagnames.Verbose, "v", false, "Enable debug logs on stderr") + flags.BoolVar(&insecure, rootflagnames.Insecure, false, "Allow plain HTTP and skip TLS verification (localhost and RFC1918 hosts already auto-allow HTTP)") + flags.BoolVar(&ndLayers, rootflagnames.AllowNondistributable, false, "Include non-distributable (foreign) layers when pushing") + flags.StringVar(&platform, rootflagnames.Platform, "", "Resolve images to platform os/arch[/variant][:osversion] (image-level commands only)") + // No completion for --platform on purpose: the set of platforms a given + // image actually serves depends on its manifest list, which we cannot + // know at flag-completion time (the IMAGE arg may not even be typed + // yet). A static list of "common platforms" would mislead users into + // thinking those values are guaranteed to work, so we leave it free-form. + + cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error { + // Reset before applying, so a re-entry (test harness re-invoking + // the same command, an embedder running PersistentPreRunE twice) + // can never double-append to opts.Remote / opts.Name. + *opts = *registry.New() + opts.WithContext(c.Context()) + applyVerbose(verbose) + if insecure { + opts.WithInsecure().WithTransport(registry.InsecureTransport()) + } + if ndLayers { + opts.WithNondistributable() + } + if platform != "" { + p, err := v1.ParsePlatform(platform) + if err != nil { + return fmt.Errorf("parse --platform: %w", err) + } + opts.WithPlatform(p) + } + return nil + } +} + +// applyVerbose toggles go-containerregistry's debug logger. logs.Debug is a +// package-level *log.Logger, so we must explicitly route to io.Discard when +// verbose is off - otherwise a previous "-v" run in the same process would +// keep leaking debug output. +func applyVerbose(verbose bool) { + if verbose { + logs.Debug.SetOutput(os.Stderr) + return + } + logs.Debug.SetOutput(io.Discard) +} diff --git a/internal/cr/cmd/rootflags_test.go b/internal/cr/cmd/rootflags_test.go new file mode 100644 index 00000000..dcbacec5 --- /dev/null +++ b/internal/cr/cmd/rootflags_test.go @@ -0,0 +1,104 @@ +/* +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 cr + +import ( + "reflect" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +// runPreRun builds a fresh root with setupRootFlags, parses the given args, +// and invokes PersistentPreRunE. Returns the resulting *Options so each test +// can inspect the side-effects of the flag. +func runPreRun(t *testing.T, args []string) *registry.Options { + t.Helper() + opts := registry.New() + cmd := &cobra.Command{Use: "cr"} + setupRootFlags(cmd, opts) + if err := cmd.ParseFlags(args); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if err := cmd.PersistentPreRunE(cmd, nil); err != nil { + t.Fatalf("PersistentPreRunE: %v", err) + } + return opts +} + +// hasInsecureName looks for the exact name.Insecure marker in opts.Name. +// name.Option is a func type, so values are not directly comparable - we +// compare function pointers via reflect. This is more precise than counting +// slice length, which would silently break if a future default option were +// added to New(). +func hasInsecureName(opts *registry.Options) bool { + want := reflect.ValueOf(name.Insecure).Pointer() + for _, opt := range opts.Name { + if reflect.ValueOf(opt).Pointer() == want { + return true + } + } + return false +} + +func TestInsecureFlag_Off(t *testing.T) { + opts := runPreRun(t, nil) + if hasInsecureName(opts) { + t.Fatalf("expected insecure off when --insecure is not passed") + } +} + +func TestInsecureFlag_On(t *testing.T) { + opts := runPreRun(t, []string{"--insecure"}) + if !hasInsecureName(opts) { + t.Fatalf("expected insecure on when --insecure is passed") + } +} + +// PersistentPreRunE must reset opts before applying flag-driven mutators, +// so re-entry (test harness, embedder, retry) cannot double-append to +// opts.Remote / opts.Name and end up with a malformed merge of options. +func TestPersistentPreRunE_IsIdempotent(t *testing.T) { + opts := registry.New() + cmd := &cobra.Command{Use: "cr"} + setupRootFlags(cmd, opts) + if err := cmd.ParseFlags([]string{"--insecure"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + + if err := cmd.PersistentPreRunE(cmd, nil); err != nil { + t.Fatalf("first PersistentPreRunE: %v", err) + } + firstName := len(opts.Name) + firstRemote := len(opts.Remote) + + if err := cmd.PersistentPreRunE(cmd, nil); err != nil { + t.Fatalf("second PersistentPreRunE: %v", err) + } + if got := len(opts.Name); got != firstName { + t.Errorf("opts.Name grew on re-entry: was %d, now %d", firstName, got) + } + if got := len(opts.Remote); got != firstRemote { + t.Errorf("opts.Remote grew on re-entry: was %d, now %d", firstRemote, got) + } + if !hasInsecureName(opts) { + t.Errorf("insecure flag was lost across re-entry") + } +} diff --git a/internal/cr/internal/image/doc.go b/internal/cr/internal/image/doc.go new file mode 100644 index 00000000..c6b73988 --- /dev/null +++ b/internal/cr/internal/image/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package image holds pure operations over v1.Image / v1.ImageIndex values +// that stand on their own (multi-arch resolution, future diff helpers, ...). +// Side-effecting I/O belongs in registry (remote) or imageio (disk). +package image diff --git a/internal/cr/internal/image/resolve.go b/internal/cr/internal/image/resolve.go new file mode 100644 index 00000000..f1aaceec --- /dev/null +++ b/internal/cr/internal/image/resolve.go @@ -0,0 +1,107 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package image + +import ( + "context" + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/cache" + + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +// ResolvedSources holds per-reference results of resolving a list of remote +// sources. For indices that need to stay multi-arch (OCI layout without +// --platform) we store them in Indices; everything else ends up in Images +// already resolved to a single manifest. +type ResolvedSources struct { + Images map[string]v1.Image + Indices map[string]v1.ImageIndex +} + +// Resolve fetches each reference in srcList and classifies the result. +// +// keepMultiArchIndex = true tells the resolver to keep an OCI index as an +// index when no --platform is pinned (pull-to-OCI layout wants full indices). +// Otherwise every source resolves to a single v1.Image for the current or +// pinned platform. +// +// cachePath, when non-empty, wraps each returned image with a filesystem +// cache so layers re-used across pulls are kept on disk. +// +// Duplicate srcList entries return an error rather than collapsing into +// a single map slot - otherwise downstream tarballs/layouts would silently +// drop one copy and surprise the user (`cr pull foo:1 foo:1 dst.tar` would +// yield a single-image tar, not two). +func Resolve(ctx context.Context, srcList []string, keepMultiArchIndex bool, cachePath string, opts *registry.Options) (*ResolvedSources, error) { + if opts == nil { + return nil, fmt.Errorf("resolve: registry options must not be nil") + } + + seen := make(map[string]struct{}, len(srcList)) + for _, src := range srcList { + if _, dup := seen[src]; dup { + return nil, fmt.Errorf("duplicate source reference %q", src) + } + seen[src] = struct{}{} + } + + out := &ResolvedSources{ + Images: map[string]v1.Image{}, + Indices: map[string]v1.ImageIndex{}, + } + + // Build the cache once and reuse it - NewFilesystemCache opens the + // directory each call, no need to pay that per source. + var fsCache cache.Cache + if cachePath != "" { + fsCache = cache.NewFilesystemCache(cachePath) + } + + for _, src := range srcList { + desc, err := registry.FetchDescriptor(ctx, src, opts) + if err != nil { + return nil, err + } + + if keepMultiArchIndex && desc.MediaType.IsIndex() && opts.Platform == nil { + idx, err := desc.ImageIndex() + if err != nil { + return nil, fmt.Errorf("read index %s: %w", src, err) + } + if fsCache != nil { + // Without this, --cache-path was a no-op for OCI pulls of + // multi-arch images (the most common shape, e.g. alpine). + idx = cache.ImageIndex(idx, fsCache) + } + out.Indices[src] = idx + continue + } + + img, err := desc.Image() + if err != nil { + return nil, fmt.Errorf("read image %s: %w", src, err) + } + if fsCache != nil { + img = cache.Image(img, fsCache) + } + out.Images[src] = img + } + return out, nil +} diff --git a/internal/cr/internal/image/resolve_test.go b/internal/cr/internal/image/resolve_test.go new file mode 100644 index 00000000..34ca1bb3 --- /dev/null +++ b/internal/cr/internal/image/resolve_test.go @@ -0,0 +1,297 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package image_test + +import ( + "context" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + regsrv "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/image" + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry" +) + +// resolveTestEnv stands up an in-memory registry with a single-arch image and +// a multi-arch index already pushed under fixed tags. +type resolveTestEnv struct { + imageRef string // host/app:single - simple manifest + indexRef string // host/app:multi - OCI index with linux/amd64 + linux/arm64 +} + +func setupResolveEnv(t *testing.T) *resolveTestEnv { + t.Helper() + srv := httptest.NewServer(regsrv.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + img, err := random.Image(64, 1) + if err != nil { + t.Fatalf("random.Image: %v", err) + } + imgRef, err := name.ParseReference(host+"/app:single", name.Insecure) + if err != nil { + t.Fatalf("parse image ref: %v", err) + } + if err := remote.Write(imgRef, img); err != nil { + t.Fatalf("push image: %v", err) + } + + imgAmd, err := random.Image(32, 1) + if err != nil { + t.Fatalf("random.Image amd64: %v", err) + } + imgArm, err := random.Image(32, 1) + if err != nil { + t.Fatalf("random.Image arm64: %v", err) + } + idx := mutate.AppendManifests( + mutate.IndexMediaType(empty.Index, types.OCIImageIndex), + mutate.IndexAddendum{ + Add: imgAmd, + Descriptor: v1.Descriptor{ + Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}, + }, + }, + mutate.IndexAddendum{ + Add: imgArm, + Descriptor: v1.Descriptor{ + Platform: &v1.Platform{OS: "linux", Architecture: "arm64"}, + }, + }, + ) + idxRef, err := name.ParseReference(host+"/app:multi", name.Insecure) + if err != nil { + t.Fatalf("parse index ref: %v", err) + } + if err := remote.WriteIndex(idxRef, idx); err != nil { + t.Fatalf("push index: %v", err) + } + + return &resolveTestEnv{ + imageRef: host + "/app:single", + indexRef: host + "/app:multi", + } +} + +func newOpts() *registry.Options { + return registry.New().WithInsecure() +} + +func mapKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func TestResolve_SingleImage(t *testing.T) { + env := setupResolveEnv(t) + out, err := image.Resolve(context.Background(), []string{env.imageRef}, false, "", newOpts()) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if _, ok := out.Images[env.imageRef]; !ok { + t.Errorf("expected image in Images map, got Images=%v Indices=%v", mapKeys(out.Images), mapKeys(out.Indices)) + } + if len(out.Indices) != 0 { + t.Errorf("expected no Indices, got %v", mapKeys(out.Indices)) + } +} + +func TestResolve_IndexKeptWhenNoPlatform(t *testing.T) { + env := setupResolveEnv(t) + out, err := image.Resolve(context.Background(), []string{env.indexRef}, true, "", newOpts()) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if _, ok := out.Indices[env.indexRef]; !ok { + t.Errorf("expected index in Indices map (keepMultiArchIndex=true, no platform), got Images=%v Indices=%v", + mapKeys(out.Images), mapKeys(out.Indices)) + } +} + +func TestResolve_IndexFlattenedWhenPlatformPinned(t *testing.T) { + env := setupResolveEnv(t) + opts := newOpts().WithPlatform(&v1.Platform{OS: "linux", Architecture: "amd64"}) + out, err := image.Resolve(context.Background(), []string{env.indexRef}, true, "", opts) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if _, ok := out.Images[env.indexRef]; !ok { + t.Errorf("expected platform-pinned index to resolve to single Image, got Images=%v Indices=%v", + mapKeys(out.Images), mapKeys(out.Indices)) + } + if len(out.Indices) != 0 { + t.Errorf("expected no Indices when platform pinned, got %v", mapKeys(out.Indices)) + } +} + +func TestResolve_IndexFlattenedWhenKeepFalse(t *testing.T) { + env := setupResolveEnv(t) + out, err := image.Resolve(context.Background(), []string{env.indexRef}, false, "", newOpts()) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if _, ok := out.Images[env.indexRef]; !ok { + t.Errorf("expected index to flatten to Image when keepMultiArchIndex=false, got Images=%v Indices=%v", + mapKeys(out.Images), mapKeys(out.Indices)) + } +} + +func TestResolve_CachePathWrapsImage(t *testing.T) { + env := setupResolveEnv(t) + cacheDir := t.TempDir() + out, err := image.Resolve(context.Background(), []string{env.imageRef}, false, cacheDir, newOpts()) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + img, ok := out.Images[env.imageRef] + if !ok { + t.Fatalf("missing image in Images map") + } + // Materializing layers populates the cache directory. + layers, err := img.Layers() + if err != nil { + t.Fatalf("Layers: %v", err) + } + for _, l := range layers { + rc, err := l.Compressed() + if err != nil { + t.Fatalf("Compressed: %v", err) + } + buf := make([]byte, 4096) + for { + if _, err := rc.Read(buf); err != nil { + break + } + } + _ = rc.Close() + } + entries, err := os.ReadDir(cacheDir) + if err != nil { + t.Fatalf("ReadDir cache: %v", err) + } + if len(entries) == 0 { + t.Errorf("expected cache directory to be populated, got 0 entries in %s", cacheDir) + } +} + +// Pre-fix: --cache-path was silently dropped on the index path, so +// `pull --format oci` of a multi-arch image (the common shape) re-downloaded +// every layer on every run. Resolve must wrap the index with cache.ImageIndex +// symmetrically with the single-image branch. +func TestResolve_CachePathWrapsIndex(t *testing.T) { + env := setupResolveEnv(t) + cacheDir := t.TempDir() + out, err := image.Resolve(context.Background(), []string{env.indexRef}, true, cacheDir, newOpts()) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + idx, ok := out.Indices[env.indexRef] + if !ok { + t.Fatalf("missing index in Indices map") + } + manifest, err := idx.IndexManifest() + if err != nil { + t.Fatalf("IndexManifest: %v", err) + } + for _, desc := range manifest.Manifests { + child, err := idx.Image(desc.Digest) + if err != nil { + t.Fatalf("Image(%s): %v", desc.Digest, err) + } + layers, err := child.Layers() + if err != nil { + t.Fatalf("child Layers: %v", err) + } + for _, l := range layers { + rc, err := l.Compressed() + if err != nil { + t.Fatalf("Compressed: %v", err) + } + buf := make([]byte, 4096) + for { + if _, err := rc.Read(buf); err != nil { + break + } + } + _ = rc.Close() + } + } + entries, err := os.ReadDir(cacheDir) + if err != nil { + t.Fatalf("ReadDir cache: %v", err) + } + if len(entries) == 0 { + t.Errorf("expected cache directory to be populated by index pull, got 0 entries in %s", cacheDir) + } +} + +func TestResolve_MultipleSources(t *testing.T) { + env := setupResolveEnv(t) + out, err := image.Resolve(context.Background(), + []string{env.imageRef, env.indexRef}, + true, "", newOpts(), + ) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if _, ok := out.Images[env.imageRef]; !ok { + t.Errorf("missing single image in Images") + } + if _, ok := out.Indices[env.indexRef]; !ok { + t.Errorf("missing index in Indices") + } +} + +// Duplicate refs would silently collapse into a single map slot, dropping +// copies that the caller asked for - `cr pull foo:1 foo:1 dst.tar` would +// produce a single-image tarball. Make that an explicit error instead. +func TestResolve_DuplicateRefsAreRejected(t *testing.T) { + env := setupResolveEnv(t) + _, err := image.Resolve(context.Background(), + []string{env.imageRef, env.imageRef}, + false, "", newOpts(), + ) + if err == nil { + t.Fatalf("expected duplicate-ref error, got nil") + } + if !strings.Contains(err.Error(), "duplicate source reference") { + t.Fatalf("expected duplicate-ref error, got: %v", err) + } +} + +// nil opts is a programmer error - explicit early failure beats a panic +// inside FetchDescriptor's name.ParseReference. +func TestResolve_NilOptsReturnsError(t *testing.T) { + _, err := image.Resolve(context.Background(), []string{"alpine:3.19"}, false, "", nil) + if err == nil { + t.Fatalf("expected error on nil opts, got nil") + } +} diff --git a/internal/cr/internal/imagefs/doc.go b/internal/cr/internal/imagefs/doc.go new file mode 100644 index 00000000..7d2cd91a --- /dev/null +++ b/internal/cr/internal/imagefs/doc.go @@ -0,0 +1,26 @@ +/* +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 imagefs reads the filesystem of an OCI/Docker image as the merged +// view that a running container would see, and extracts it to disk with +// path-traversal and absolute-symlink-rewrite protection. +// +// Whiteouts (.wh.* markers and .wh..wh..opq opaque markers) are honored: +// files explicitly deleted in an upper layer are invisible in the result. +// +// NOT a Go fs.FS implementation - the API surface is purpose-built for the +// `d8 cr fs` subcommands (MergedFS / ReadFile / ExtractMerged). +package imagefs diff --git a/internal/cr/internal/imagefs/errors.go b/internal/cr/internal/imagefs/errors.go new file mode 100644 index 00000000..5fce9355 --- /dev/null +++ b/internal/cr/internal/imagefs/errors.go @@ -0,0 +1,32 @@ +/* +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 imagefs + +import "errors" + +// ErrNotFound signals an unknown layer reference or a missing file. +var ErrNotFound = errors.New("not found") + +// ErrNotRegularFile signals that a path exists but is not a regular file. +var ErrNotRegularFile = errors.New("not a regular file") + +// ErrStopWalk stops a WalkTar iteration without surfacing as an error. +var ErrStopWalk = errors.New("stop walk") + +// ErrFileTooLarge signals that a file's size exceeds the in-memory cap +// imposed by ReadFile to bound peak RSS when serving `fs cat`. +var ErrFileTooLarge = errors.New("file too large") diff --git a/internal/cr/internal/imagefs/extractor.go b/internal/cr/internal/imagefs/extractor.go new file mode 100644 index 00000000..cbf4daf1 --- /dev/null +++ b/internal/cr/internal/imagefs/extractor.go @@ -0,0 +1,344 @@ +/* +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 imagefs + +import ( + "archive/tar" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// ExtractStats summarizes how many entries were materialized. +type ExtractStats struct { + Files int + Dirs int + Symlinks int + Hardlinks int + TotalSize int64 +} + +// ExtractMerged writes the merged filesystem of img into destDir, honoring +// whiteouts top-down. Paths attempting to escape destDir are rejected. +// +// Cancellation is checked between layers and at every tar entry, so a +// Ctrl-C from the cobra command interrupts a multi-GB extraction within +// the next entry boundary instead of running to completion. +func ExtractMerged(ctx context.Context, img v1.Image, destDir string) (ExtractStats, error) { + layers, err := img.Layers() + if err != nil { + return ExtractStats{}, fmt.Errorf("get layers: %w", err) + } + absDest, err := filepath.Abs(destDir) + if err != nil { + return ExtractStats{}, fmt.Errorf("resolve dest: %w", err) + } + if err := os.MkdirAll(absDest, 0o755); err != nil { + return ExtractStats{}, fmt.Errorf("create dest: %w", err) + } + + var stats ExtractStats + for i, layer := range layers { + if err := ctx.Err(); err != nil { + return stats, err + } + rc, err := layer.Uncompressed() + if err != nil { + return stats, fmt.Errorf("layer %d: uncompress: %w", i+1, err) + } + err = extractTarTo(ctx, rc, absDest, &stats) + _ = rc.Close() + if err != nil { + return stats, fmt.Errorf("layer %d: %w", i+1, err) + } + } + return stats, nil +} + +func extractTarTo(ctx context.Context, rc io.Reader, destAbs string, stats *ExtractStats) error { + return WalkTar(rc, func(hdr *tar.Header, r io.Reader) error { + if err := ctx.Err(); err != nil { + return err + } + name := normalizePath(hdr.Name) + if name == "." { + return nil + } + + if target, opaque := Whiteout(name); target != "" || opaque { + return applyWhiteout(destAbs, target, opaque) + } + + absPath, err := safeJoin(destAbs, name) + if err != nil { + return err + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := ensureNoSymlinkComponents(destAbs, absPath, true); err != nil { + return err + } + if err := os.MkdirAll(absPath, fs.FileMode(hdr.Mode&0o777)|0o700); err != nil { + return fmt.Errorf("mkdir %s: %w", absPath, err) + } + stats.Dirs++ + + case tar.TypeReg: + if err := ensureNoSymlinkComponents(destAbs, absPath, true); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("mkdir parent %s: %w", absPath, err) + } + // If a lower layer left a directory at absPath, OpenFile would + // return EISDIR. Symlink replacements are already rejected by + // ensureNoSymlinkComponents above; clearStalePath finishes the + // "upper layer replaces an entry of a different kind" matrix. + if err := clearStalePath(absPath); err != nil { + return err + } + f, err := os.OpenFile(absPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fs.FileMode(hdr.Mode&0o777)) + if err != nil { + return fmt.Errorf("create %s: %w", absPath, err) + } + n, err := io.Copy(f, r) + closeErr := f.Close() + if err != nil { + return fmt.Errorf("write %s: %w", absPath, err) + } + if closeErr != nil { + return fmt.Errorf("close %s: %w", absPath, closeErr) + } + stats.Files++ + stats.TotalSize += n + + case tar.TypeSymlink: + if err := ensureNoSymlinkComponents(destAbs, absPath, true); err != nil { + return err + } + linkname, err := resolveSymlinkTarget(destAbs, absPath, hdr.Linkname) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("mkdir parent %s: %w", absPath, err) + } + if err := clearStalePath(absPath); err != nil { + return err + } + if err := os.Symlink(linkname, absPath); err != nil { + return fmt.Errorf("symlink %s -> %s: %w", absPath, linkname, err) + } + stats.Symlinks++ + + case tar.TypeLink: + if err := ensureNoSymlinkComponents(destAbs, absPath, true); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("mkdir parent %s: %w", absPath, err) + } + linkTarget, err := safeJoin(destAbs, normalizePath(hdr.Linkname)) + if err != nil { + return err + } + if err := ensureNoSymlinkComponents(destAbs, linkTarget, false); err != nil { + return err + } + if err := clearStalePath(absPath); err != nil { + return err + } + if err := os.Link(linkTarget, absPath); err != nil { + return fmt.Errorf("hardlink %s -> %s: %w", absPath, linkTarget, err) + } + stats.Hardlinks++ + } + return nil + }) +} + +func applyWhiteout(destAbs, target string, opaque bool) error { + t := normalizePath(target) + if opaque && t == "." { + return clearDirContents(destAbs) + } + absPath, err := safeJoin(destAbs, t) + if err != nil { + return err + } + if err := ensureNoSymlinkComponents(destAbs, absPath, false); err != nil { + return err + } + if opaque { + return clearDirContents(absPath) + } + _ = os.RemoveAll(absPath) + return nil +} + +// clearStalePath removes path if it already exists, picking RemoveAll for +// directories and Remove for everything else (regular files, symlinks, +// devices). Without this, `cr fs extract` on a layered image where an upper +// layer replaces a lower-layer directory with a symlink/hardlink fails: +// os.Remove on a non-empty directory returns ENOTEMPTY, the error is then +// dropped, and the subsequent os.Symlink/os.Link surfaces a confusing +// EEXIST. Returning the error from the cleanup itself keeps the failure +// site honest. +func clearStalePath(path string) error { + fi, err := os.Lstat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("lstat %s: %w", path, err) + } + // Lstat on a symlink does NOT set IsDir even when the target is a + // directory, so this branch picks RemoveAll only for real directories. + if fi.IsDir() { + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove stale dir %s: %w", path, err) + } + return nil + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove stale entry %s: %w", path, err) + } + return nil +} + +// clearDirContents removes every direct entry of dir. A missing dir is fine +// (nothing to clear); any other ReadDir error is propagated so we never +// silently leave stale data on disk. Per-entry RemoveAll failures are +// aggregated and returned together - dropping them would defeat the +// whole-dir-cleared invariant promised by an opaque whiteout marker. +func clearDirContents(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("read whiteout dir %s: %w", dir, err) + } + var errs []error + for _, e := range entries { + entryPath := filepath.Join(dir, e.Name()) + if rerr := os.RemoveAll(entryPath); rerr != nil { + errs = append(errs, fmt.Errorf("remove %s: %w", entryPath, rerr)) + } + } + return errors.Join(errs...) +} + +func safeJoin(base, rel string) (string, error) { + if slices.Contains(strings.Split(filepath.ToSlash(rel), "/"), "..") { + return "", fmt.Errorf("unsafe path (contains ..): %q", rel) + } + joined := filepath.Clean(filepath.Join(base, filepath.FromSlash(rel))) + if !withinBase(base, joined) { + return "", fmt.Errorf("unsafe path (escapes destination): %q", rel) + } + return joined, nil +} + +// withinBase reports whether path is at or below base (both expected to be +// absolute). The last guard against path-traversal: filepath.Rel produces a +// "../"-prefixed result iff path escapes base. +func withinBase(base, path string) bool { + rel, err := filepath.Rel(base, path) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") +} + +func ensureNoSymlinkComponents(base, absPath string, allowFinalMissing bool) error { + if !withinBase(base, absPath) { + return fmt.Errorf("unsafe path (escapes destination): %q", absPath) + } + rel, err := filepath.Rel(base, absPath) + if err != nil { + return fmt.Errorf("resolve relative path: %w", err) + } + if rel == "." { + return nil + } + cur := base + parts := strings.Split(rel, string(filepath.Separator)) + for i, part := range parts { + cur = filepath.Join(cur, part) + info, err := os.Lstat(cur) + if err != nil { + if os.IsNotExist(err) { + if allowFinalMissing && i == len(parts)-1 { + return nil + } + continue + } + return fmt.Errorf("lstat %s: %w", cur, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("unsafe path (symlink component): %q", cur) + } + } + return nil +} + +// resolveSymlinkTarget validates the symlink target against destAbs and +// returns the linkname to actually create on disk. +// +// Absolute targets (typical in Alpine/busybox: /bin/sh -> /bin/busybox) are +// re-rooted at destAbs and rewritten to a relative path so the extracted tree +// stays self-contained and never points outside destAbs at follow time. +// Relative targets that would escape destAbs are still rejected. +func resolveSymlinkTarget(destAbs, linkPathAbs, target string) (string, error) { + if target == "" { + return "", fmt.Errorf("unsafe symlink target (empty)") + } + if err := ensureNoSymlinkComponents(destAbs, filepath.Dir(linkPathAbs), false); err != nil { + return "", err + } + + if filepath.IsAbs(target) { + // Re-root absolute target inside destAbs and rewrite to a relative + // path from the symlink's directory. filepath.Clean(filepath.Join(base, "/x")) + // collapses to base+"/x" - documented Join semantics for absolute rhs. + rooted := filepath.Clean(filepath.Join(destAbs, target)) + if !withinBase(destAbs, rooted) { + return "", fmt.Errorf("unsafe symlink target (escapes destination): %q", target) + } + rel, err := filepath.Rel(filepath.Dir(linkPathAbs), rooted) + if err != nil { + return "", fmt.Errorf("resolve symlink target %q: %w", target, err) + } + return rel, nil + } + + resolved := filepath.Clean(filepath.Join(filepath.Dir(linkPathAbs), target)) + if !withinBase(destAbs, resolved) { + return "", fmt.Errorf("unsafe symlink target (escapes destination): %q", target) + } + return target, nil +} diff --git a/internal/cr/internal/imagefs/imagefs_test.go b/internal/cr/internal/imagefs/imagefs_test.go new file mode 100644 index 00000000..960f45e0 --- /dev/null +++ b/internal/cr/internal/imagefs/imagefs_test.go @@ -0,0 +1,709 @@ +/* +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 imagefs + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +type tarFile struct { + name string + typeflag byte + content []byte + linkname string + mode int64 +} + +func buildTar(t *testing.T, files []tarFile) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, f := range files { + hdr := &tar.Header{ + Name: f.name, + Typeflag: f.typeflag, + Size: int64(len(f.content)), + Linkname: f.linkname, + Mode: f.mode, + } + if hdr.Mode == 0 { + if f.typeflag == tar.TypeDir { + hdr.Mode = 0o755 + } else { + hdr.Mode = 0o644 + } + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write header %s: %v", f.name, err) + } + if len(f.content) > 0 { + if _, err := tw.Write(f.content); err != nil { + t.Fatalf("write body %s: %v", f.name, err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + return buf.Bytes() +} + +// makeImage constructs a v1.Image out of a list of layer file sets (bottom first). +func makeImage(t *testing.T, layers [][]tarFile) v1.Image { + t.Helper() + img := empty.Image + for _, files := range layers { + data := buildTar(t, files) + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }) + if err != nil { + t.Fatalf("LayerFromOpener: %v", err) + } + img, err = mutate.AppendLayers(img, layer) + if err != nil { + t.Fatalf("AppendLayers: %v", err) + } + } + return img +} + +// ---- tests ---- + +func TestMergedFS_WhiteoutDeletesFile(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "bin/", typeflag: tar.TypeDir}, + {name: "bin/sh", typeflag: tar.TypeReg, content: []byte("sh-content")}, + {name: "bin/cat", typeflag: tar.TypeReg, content: []byte("cat-content")}, + }, + { + {name: "bin/.wh.sh", typeflag: tar.TypeReg}, + }, + }) + entries, err := MergedFS(img) + if err != nil { + t.Fatalf("MergedFS: %v", err) + } + paths := pathsOf(entries) + if contains(paths, "bin/sh") { + t.Errorf("bin/sh should have been whited out, got paths: %v", paths) + } + if !contains(paths, "bin/cat") { + t.Errorf("bin/cat should remain, got paths: %v", paths) + } +} + +func TestMergedFS_OpaqueMarkerWipesDir(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "var/", typeflag: tar.TypeDir}, + {name: "var/log/", typeflag: tar.TypeDir}, + {name: "var/log/old.log", typeflag: tar.TypeReg, content: []byte("old")}, + {name: "var/log/keep.log", typeflag: tar.TypeReg, content: []byte("keep")}, + }, + { + {name: "var/log/.wh..wh..opq", typeflag: tar.TypeReg}, + {name: "var/log/new.log", typeflag: tar.TypeReg, content: []byte("new")}, + }, + }) + entries, err := MergedFS(img) + if err != nil { + t.Fatalf("MergedFS: %v", err) + } + paths := pathsOf(entries) + if contains(paths, "var/log/old.log") || contains(paths, "var/log/keep.log") { + t.Errorf("opaque marker should have wiped var/log/ from lower layers: %v", paths) + } + if !contains(paths, "var/log/new.log") { + t.Errorf("new entry in opaque-layer should remain: %v", paths) + } +} + +func TestMergedFS_UpperLayerWins(t *testing.T) { + img := makeImage(t, [][]tarFile{ + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v1")}}, + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v2")}}, + }) + entries, err := MergedFS(img) + if err != nil { + t.Fatalf("MergedFS: %v", err) + } + for _, e := range entries { + if e.Path == "etc/passwd" && e.Size != 2 { + t.Errorf("expected size 2 ('v2'), got %d", e.Size) + } + } +} + +func TestReadFile_FollowsTopLayer(t *testing.T) { + img := makeImage(t, [][]tarFile{ + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v1")}}, + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v2")}}, + }) + got, err := ReadFile(img, "etc/passwd") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "v2" { + t.Errorf("want v2, got %q", got) + } +} + +func TestReadFile_WhiteoutReturnsNotFound(t *testing.T) { + img := makeImage(t, [][]tarFile{ + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v1")}}, + {{name: "etc/.wh.passwd", typeflag: tar.TypeReg}}, + }) + _, err := ReadFile(img, "etc/passwd") + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestReadFile_WhiteoutThenReAddInSameLayer(t *testing.T) { + img := makeImage(t, [][]tarFile{ + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v1")}}, + { + {name: "etc/.wh.passwd", typeflag: tar.TypeReg}, + {name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v2")}, + }, + }) + got, err := ReadFile(img, "etc/passwd") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "v2" { + t.Fatalf("want v2, got %q", string(got)) + } +} + +// Tar-order-independence guarantee: a same-layer re-add of wantPath wins +// even when the whiteout entry comes AFTER it in tar order. OCI says +// whiteouts apply to lower layers only - never to entries from their own +// layer, regardless of position. Pre-fix: readFromLayer's "latest match +// wins" walk returned readDeleted for this layout, diverging from +// mergeLayer (which the rest of the fs/ subcommands use). +func TestReadFile_ReAddThenWhiteoutInSameLayer(t *testing.T) { + img := makeImage(t, [][]tarFile{ + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v1")}}, + { + {name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v2")}, + {name: "etc/.wh.passwd", typeflag: tar.TypeReg}, + }, + }) + got, err := ReadFile(img, "etc/passwd") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "v2" { + t.Fatalf("want v2, got %q", string(got)) + } +} + +// A regular whiteout on a directory ("etc/.wh.subdir" or "/.wh.etc") wipes +// every descendant from lower layers, not just exact-target matches. Pre-fix: +// readFromLayer applied this rule to opaque markers only, so cat would +// happily return a file already removed from the merged FS view exposed by +// fs ls / fs tree. +func TestReadFile_RegularWhiteoutOnAncestorDeletesDescendants(t *testing.T) { + img := makeImage(t, [][]tarFile{ + {{name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("secret")}}, + {{name: ".wh.etc", typeflag: tar.TypeReg}}, + }) + _, err := ReadFile(img, "etc/passwd") + if err == nil { + t.Fatalf("expected ErrNotFound, got nil") + } + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got: %v", err) + } +} + +func TestReadFile_NotRegularFileReturnsError(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "etc/", typeflag: tar.TypeDir}, + {name: "etc/passwd", typeflag: tar.TypeReg, content: []byte("v1")}, + {name: "etc/link", typeflag: tar.TypeSymlink, linkname: "passwd"}, + }, + }) + + if _, err := ReadFile(img, "etc"); err == nil || !strings.Contains(err.Error(), ErrNotRegularFile.Error()) { + t.Fatalf("expected not-regular-file error for directory, got: %v", err) + } + if _, err := ReadFile(img, "etc/link"); err == nil || !strings.Contains(err.Error(), ErrNotRegularFile.Error()) { + t.Fatalf("expected not-regular-file error for symlink, got: %v", err) + } +} + +// ReadFile must refuse to load a regular file whose size exceeds +// maxReadFileSize, so that `fs cat` on a multi-GB log entry cannot OOM +// the CLI. The check is placed at the producer of `content` (after +// LimitReader), so the in-memory buffer never exceeds the limit + 1 byte. +func TestReadFile_RejectsFileLargerThanLimit(t *testing.T) { + prev := maxReadFileSize + maxReadFileSize = 8 + t.Cleanup(func() { maxReadFileSize = prev }) + + img := makeImage(t, [][]tarFile{ + {{name: "big.log", typeflag: tar.TypeReg, content: bytes.Repeat([]byte("X"), 16)}}, + }) + _, err := ReadFile(img, "big.log") + if err == nil { + t.Fatalf("expected ErrFileTooLarge, got nil") + } + if !errors.Is(err, ErrFileTooLarge) { + t.Fatalf("expected ErrFileTooLarge, got: %v", err) + } +} + +// A file exactly at the limit must be readable. Guards against an off-by-one +// in the LimitReader+check pair. +func TestReadFile_AcceptsFileAtLimit(t *testing.T) { + prev := maxReadFileSize + maxReadFileSize = 8 + t.Cleanup(func() { maxReadFileSize = prev }) + + want := bytes.Repeat([]byte("Y"), 8) + img := makeImage(t, [][]tarFile{ + {{name: "small.log", typeflag: tar.TypeReg, content: want}}, + }) + got, err := ReadFile(img, "small.log") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("content=%q, want %q", got, want) + } +} + +func TestSafeJoin_PathEscapeRejected(t *testing.T) { + cases := []struct { + base, rel string + wantErr bool + }{ + {"/tmp/x", "foo/bar", false}, + {"/tmp/x", "a/b/c", false}, + {"/tmp/x", "../etc/passwd", true}, + {"/tmp/x", "/absolute/evil", false}, // leading / is stripped by safeJoin semantics + {"/tmp/x", "sub/../../escape", true}, + } + for _, c := range cases { + _, err := safeJoin(c.base, c.rel) + if (err != nil) != c.wantErr { + t.Errorf("safeJoin(%q, %q): err=%v, wantErr=%v", c.base, c.rel, err, c.wantErr) + } + } +} + +func TestExtractMerged_RejectsEscapingSymlinkTarget(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "root/", typeflag: tar.TypeDir}, + {name: "root/link", typeflag: tar.TypeSymlink, linkname: "../../outside"}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + _, err := ExtractMerged(context.Background(), img, dest) + if err == nil { + t.Fatalf("expected symlink target validation error") + } + if !strings.Contains(err.Error(), "unsafe symlink target") { + t.Fatalf("expected unsafe symlink target error, got: %v", err) + } +} + +func TestExtractMerged_RejectsWriteThroughSymlinkComponent(t *testing.T) { + tmp := t.TempDir() + dest := filepath.Join(tmp, "dest") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + if err := os.Symlink("../outside", filepath.Join(dest, "leak")); err != nil { + t.Fatalf("create symlink: %v", err) + } + + img := makeImage(t, [][]tarFile{ + { + {name: "leak/pwned.txt", typeflag: tar.TypeReg, content: []byte("owned")}, + }, + }) + + _, err := ExtractMerged(context.Background(), img, dest) + if err == nil { + t.Fatalf("expected symlink-component rejection") + } + if !strings.Contains(err.Error(), "symlink component") { + t.Fatalf("expected symlink-component error, got: %v", err) + } +} + +func TestExtractMerged_RewritesAbsoluteSymlinkTarget(t *testing.T) { + // Alpine/busybox-style layout: /bin/busybox is the real binary, + // /bin/sh and /usr/bin/awk are absolute symlinks into it. + img := makeImage(t, [][]tarFile{ + { + {name: "bin/", typeflag: tar.TypeDir}, + {name: "usr/", typeflag: tar.TypeDir}, + {name: "usr/bin/", typeflag: tar.TypeDir}, + {name: "bin/busybox", typeflag: tar.TypeReg, content: []byte("#!busybox")}, + {name: "bin/sh", typeflag: tar.TypeSymlink, linkname: "/bin/busybox"}, + {name: "usr/bin/awk", typeflag: tar.TypeSymlink, linkname: "/bin/busybox"}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + if _, err := ExtractMerged(context.Background(), img, dest); err != nil { + t.Fatalf("extract failed: %v", err) + } + + cases := map[string]string{ + filepath.Join(dest, "bin/sh"): "busybox", + filepath.Join(dest, "usr/bin/awk"): "../../bin/busybox", + } + for link, wantTarget := range cases { + gotTarget, err := os.Readlink(link) + if err != nil { + t.Fatalf("readlink %s: %v", link, err) + } + if gotTarget != wantTarget { + t.Errorf("symlink %s: target=%q, want %q", link, gotTarget, wantTarget) + } + // Resolved symlink must stay inside dest. EvalSymlinks both sides + // to neutralize per-OS path canonicalization (e.g. macOS /var → /private/var). + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatalf("eval symlinks %s: %v", link, err) + } + want, err := filepath.EvalSymlinks(filepath.Join(dest, "bin/busybox")) + if err != nil { + t.Fatalf("eval want: %v", err) + } + if resolved != want { + t.Errorf("symlink %s resolved to %q, want %q", link, resolved, want) + } + } +} + +// Hardlinks must be rejected when their linkname is itself a symlink +// materialized earlier in the same extraction. The guarantee is conservative +// (we refuse the hardlink even when the symlink resolves to a path inside +// dest), because os.Link follows symlinks at the syscall layer - removing +// the guard would let a malicious tar plant a symlink and then a hardlink +// referencing it as a stepping stone to host paths. +// +// The scenario uses an in-dest target so that, without the guard, os.Link +// definitely succeeds and `pwn` appears on disk - making the regression +// catch deterministic across platforms (macOS / Linux link(2) both follow +// symlinks for the source path). +func TestExtractMerged_RejectsHardlinkThroughSymlinkTarget(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "data.txt", typeflag: tar.TypeReg, content: []byte("secret")}, + {name: "evil", typeflag: tar.TypeSymlink, linkname: "data.txt"}, + {name: "pwn", typeflag: tar.TypeLink, linkname: "evil"}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + _, err := ExtractMerged(context.Background(), img, dest) + if err == nil { + t.Fatalf("expected ExtractMerged to reject hardlink targeting a symlink, got nil") + } + if _, statErr := os.Lstat(filepath.Join(dest, "pwn")); !os.IsNotExist(statErr) { + t.Fatalf("expected pwn to be absent on disk, got Lstat err=%v", statErr) + } +} + +// Root-level opaque marker (".wh..wh..opq" in "/") is a valid OCI directive +// meaning "lower layers contribute nothing here", so on disk it must clear +// every artifact materialized by previous layers. This test pins the OCI- +// correct behavior; treating root-opaque as a no-op would silently break +// image layouts whose upper layer truncates the rootfs. +func TestExtractMerged_RootOpaqueClearsLowerLayers(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "old.txt", typeflag: tar.TypeReg, content: []byte("from-lower")}, + {name: "lib/", typeflag: tar.TypeDir}, + {name: "lib/old.so", typeflag: tar.TypeReg, content: []byte("lib-lower")}, + }, + { + {name: ".wh..wh..opq", typeflag: tar.TypeReg}, + {name: "new.txt", typeflag: tar.TypeReg, content: []byte("from-upper")}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + if _, err := ExtractMerged(context.Background(), img, dest); err != nil { + t.Fatalf("extract failed: %v", err) + } + for _, gone := range []string{"old.txt", "lib/old.so", "lib"} { + if _, err := os.Lstat(filepath.Join(dest, gone)); !os.IsNotExist(err) { + t.Errorf("%s should have been cleared by root opaque marker, Lstat err=%v", gone, err) + } + } + got, err := os.ReadFile(filepath.Join(dest, "new.txt")) + if err != nil { + t.Fatalf("read new.txt: %v", err) + } + if string(got) != "from-upper" { + t.Errorf("new.txt content=%q, want %q", got, "from-upper") + } +} + +// Subdirectory opaque marker must clear only that subdirectory's lower-layer +// content, leaving sibling directories alone. Counterpart to +// TestMergedFS_OpaqueMarkerWipesDir, but on the disk-extract path. +func TestExtractMerged_SubdirOpaqueClearsOnlyThatDir(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "var/", typeflag: tar.TypeDir}, + {name: "var/log/", typeflag: tar.TypeDir}, + {name: "var/log/old.log", typeflag: tar.TypeReg, content: []byte("old")}, + {name: "etc/", typeflag: tar.TypeDir}, + {name: "etc/keep.conf", typeflag: tar.TypeReg, content: []byte("keep")}, + }, + { + {name: "var/log/.wh..wh..opq", typeflag: tar.TypeReg}, + {name: "var/log/new.log", typeflag: tar.TypeReg, content: []byte("new")}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + if _, err := ExtractMerged(context.Background(), img, dest); err != nil { + t.Fatalf("extract failed: %v", err) + } + if _, err := os.Lstat(filepath.Join(dest, "var/log/old.log")); !os.IsNotExist(err) { + t.Errorf("var/log/old.log should be wiped by subdir opaque marker, Lstat err=%v", err) + } + if _, err := os.Lstat(filepath.Join(dest, "etc/keep.conf")); err != nil { + t.Errorf("etc/keep.conf must survive sibling opaque, got Lstat err=%v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "var/log/new.log")) + if err != nil { + t.Fatalf("read var/log/new.log: %v", err) + } + if string(got) != "new" { + t.Errorf("var/log/new.log content=%q, want %q", got, "new") + } +} + +// A whiteout entry whose name parses to an empty or "." target ("foo/.wh." +// or ".wh..") must not be treated as a directive to RemoveAll the parent +// directory. Pre-fix behavior: applyWhiteout(destAbs, ".") wiped dest +// before the rest of the layer was extracted. +func TestExtractMerged_DotWhiteoutDoesNotEraseDest(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "preserved.txt", typeflag: tar.TypeReg, content: []byte("survive")}, + }, + { + {name: ".wh..", typeflag: tar.TypeReg}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + if _, err := ExtractMerged(context.Background(), img, dest); err != nil { + t.Fatalf("extract failed: %v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "preserved.txt")) + if err != nil { + t.Fatalf("read preserved.txt: %v", err) + } + if string(got) != "survive" { + t.Errorf("preserved.txt content=%q, want %q", got, "survive") + } +} + +// Upper layer replaces a lower-layer directory with a regular file of the +// same name. Pre-fix: os.OpenFile(O_CREATE|O_TRUNC) on a directory returned +// EISDIR and extract aborted. +func TestExtractMerged_UpperLayerFileReplacesDir(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "etc/", typeflag: tar.TypeDir}, + {name: "etc/old.conf", typeflag: tar.TypeReg, content: []byte("legacy")}, + }, + { + {name: "etc", typeflag: tar.TypeReg, content: []byte("now-a-file")}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + if _, err := ExtractMerged(context.Background(), img, dest); err != nil { + t.Fatalf("extract failed: %v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "etc")) + if err != nil { + t.Fatalf("read /etc as file: %v", err) + } + if string(got) != "now-a-file" { + t.Errorf("etc content=%q, want %q", got, "now-a-file") + } + fi, err := os.Lstat(filepath.Join(dest, "etc")) + if err != nil { + t.Fatalf("lstat etc: %v", err) + } + if !fi.Mode().IsRegular() { + t.Errorf("etc must be a regular file after replacement, mode=%v", fi.Mode()) + } +} + +// An upper layer is allowed to replace a lower-layer directory entry with +// a symlink (or hardlink) of the same name - this happens in real OCI +// images, e.g. when /tmp graduates from a directory to a tmpfs symlink in +// a sidecar layer. Pre-fix behavior: os.Remove on a non-empty directory +// returned ENOTEMPTY, the error was dropped, and os.Symlink then surfaced +// EEXIST. Verify the swap actually lands on disk. +func TestExtractMerged_UpperLayerSymlinkReplacesDir(t *testing.T) { + img := makeImage(t, [][]tarFile{ + { + {name: "etc/", typeflag: tar.TypeDir}, + {name: "etc/old.conf", typeflag: tar.TypeReg, content: []byte("legacy")}, + {name: "real/", typeflag: tar.TypeDir}, + {name: "real/passwd", typeflag: tar.TypeReg, content: []byte("root:x:0:0")}, + }, + { + {name: "etc", typeflag: tar.TypeSymlink, linkname: "real"}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + if _, err := ExtractMerged(context.Background(), img, dest); err != nil { + t.Fatalf("extract failed: %v", err) + } + + target, err := os.Readlink(filepath.Join(dest, "etc")) + if err != nil { + t.Fatalf("etc must be a symlink, got Readlink err=%v", err) + } + if target != "real" { + t.Errorf("etc -> %q, want %q", target, "real") + } + // Resolved through the symlink, /etc/passwd must serve the new content. + got, err := os.ReadFile(filepath.Join(dest, "etc/passwd")) + if err != nil { + t.Fatalf("read /etc/passwd via symlink: %v", err) + } + if string(got) != "root:x:0:0" { + t.Errorf("etc/passwd content via symlink = %q, want %q", got, "root:x:0:0") + } +} + +// A pre-cancelled context must abort ExtractMerged before any layer is +// materialized to disk, so a Ctrl-C from cobra propagates through to the +// extractor instead of running to completion on multi-GB images. +func TestExtractMerged_RespectsCancelledContext(t *testing.T) { + img := makeImage(t, [][]tarFile{ + {{name: "data.txt", typeflag: tar.TypeReg, content: []byte("payload")}}, + }) + dest := filepath.Join(t.TempDir(), "dest") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := ExtractMerged(ctx, img, dest) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } + if _, statErr := os.Lstat(filepath.Join(dest, "data.txt")); !os.IsNotExist(statErr) { + t.Fatalf("data.txt must not be materialized after cancel, Lstat err=%v", statErr) + } +} + +func TestExtractMerged_HardlinkHappyPath(t *testing.T) { + want := []byte("payload") + img := makeImage(t, [][]tarFile{ + { + {name: "data.txt", typeflag: tar.TypeReg, content: want}, + {name: "alias.txt", typeflag: tar.TypeLink, linkname: "data.txt"}, + }, + }) + dest := filepath.Join(t.TempDir(), "dest") + stats, err := ExtractMerged(context.Background(), img, dest) + if err != nil { + t.Fatalf("extract failed: %v", err) + } + if stats.Hardlinks != 1 { + t.Errorf("stats.Hardlinks=%d, want 1", stats.Hardlinks) + } + got, err := os.ReadFile(filepath.Join(dest, "alias.txt")) + if err != nil { + t.Fatalf("read alias: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("alias content=%q, want %q", got, want) + } +} + +// ---- helpers ---- + +func pathsOf(entries []Entry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Path) + } + return out +} + +func contains(ss []string, s string) bool { + return slices.Contains(ss, s) +} + +func TestApplyWhiteout_PropagatesUnreadableDir(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("chmod read restrictions don't apply to root") + } + base := t.TempDir() + sub := filepath.Join(base, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(sub, "f"), []byte("x"), 0o644); err != nil { + t.Fatalf("write child: %v", err) + } + if err := os.Chmod(sub, 0); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(sub, 0o755) }) + + err := applyWhiteout(base, "sub", true) + if err == nil { + t.Fatal("expected error from unreadable dir, got nil") + } + if !strings.Contains(err.Error(), "read whiteout dir") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestApplyWhiteout_MissingDirIsFine(t *testing.T) { + base := t.TempDir() + if err := applyWhiteout(base, "does-not-exist", true); err != nil { + t.Fatalf("expected nil for missing dir, got %v", err) + } + if err := applyWhiteout(base, "does-not-exist", false); err != nil { + t.Fatalf("expected nil for missing single-file whiteout, got %v", err) + } +} diff --git a/internal/cr/internal/imagefs/reader.go b/internal/cr/internal/imagefs/reader.go new file mode 100644 index 00000000..7cf0a32c --- /dev/null +++ b/internal/cr/internal/imagefs/reader.go @@ -0,0 +1,269 @@ +/* +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 imagefs + +import ( + "archive/tar" + "fmt" + "io" + "io/fs" + "maps" + "sort" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// MergedFS returns the effective filesystem contents of img: layers applied +// bottom-up, whiteouts (single-file and opaque) honored. Entries are sorted +// by Path. +func MergedFS(img v1.Image) ([]Entry, error) { + layers, err := img.Layers() + if err != nil { + return nil, fmt.Errorf("get layers: %w", err) + } + + fileMap := make(map[string]Entry) + for i, layer := range layers { + rc, err := layer.Uncompressed() + if err != nil { + return nil, fmt.Errorf("uncompress layer %d: %w", i+1, err) + } + err = mergeLayer(rc, fileMap) + _ = rc.Close() + if err != nil { + return nil, fmt.Errorf("layer %d: %w", i+1, err) + } + } + return sortedEntries(fileMap), nil +} + +// ReadFile returns the content of filePath in the merged FS. Layers are +// scanned top-down; a whiteout found before a matching entry causes +// ErrNotFound. +func ReadFile(img v1.Image, filePath string) ([]byte, error) { + layers, err := img.Layers() + if err != nil { + return nil, fmt.Errorf("get layers: %w", err) + } + want := normalizePath(filePath) + + for i := len(layers) - 1; i >= 0; i-- { + content, status, err := readFromLayer(layers[i], want) + if err != nil { + return nil, fmt.Errorf("layer %d: %w", i+1, err) + } + switch status { + case readFound: + return content, nil + case readNonRegular: + return nil, fmt.Errorf("%w: %s", ErrNotRegularFile, filePath) + case readDeleted: + return nil, fmt.Errorf("%w: %s", ErrNotFound, filePath) + } + } + return nil, fmt.Errorf("%w: %s", ErrNotFound, filePath) +} + +// ---- internal helpers ---- + +// readStatus reflects the outcome of reading one file from one layer during +// merged-FS traversal. +type readStatus int + +const ( + readMissing readStatus = iota + readFound + readNonRegular + readDeleted +) + +// maxReadFileSize caps the in-memory buffer used by readFromLayer so a +// `fs cat` on a multi-GB log file (or a tar bomb whose tar.Header.Size +// claims an absurd length) cannot OOM the CLI. 256 MiB is well above any +// realistic config / script / manifest, while staying within a typical +// CI runner's memory budget. Declared as var so tests can lower it. +var maxReadFileSize int64 = 256 << 20 + +// readFromLayer searches one layer for wantPath and decides its fate by +// OCI whiteout rules: a same-layer real entry for wantPath always wins, +// regardless of tar order versus a same-layer whiteout - whiteouts apply +// to LOWER layers, never to the layer they belong to. A whiteout on an +// ancestor of wantPath (regular or opaque) deletes wantPath as well. +// +// readStatus distinguishes: +// - readMissing: path does not appear in this layer at all +// - readFound: regular file located; content holds its bytes +// - readNonRegular: path exists but is dir/symlink/hardlink (cat-incompatible) +// - readDeleted: a whiteout marks the path (or an ancestor) as removed +func readFromLayer(layer v1.Layer, wantPath string) ([]byte, readStatus, error) { + rc, err := layer.Uncompressed() + if err != nil { + return nil, readMissing, fmt.Errorf("uncompress: %w", err) + } + defer rc.Close() + + var ( + content []byte + realStatus readStatus + hasReal bool + deleted bool + ) + err = WalkTar(rc, func(hdr *tar.Header, r io.Reader) error { + name := normalizePath(hdr.Name) + if target, opaque := Whiteout(name); target != "" || opaque { + t := normalizePath(target) + if t == wantPath || isAncestor(t, wantPath) { + deleted = true + } + return nil + } + if name != wantPath { + return nil + } + hasReal = true + if !isRegularTarFile(hdr.Typeflag) { + realStatus = readNonRegular + content = nil + return nil + } + b, rerr := io.ReadAll(io.LimitReader(r, maxReadFileSize+1)) + if rerr != nil { + return rerr + } + if int64(len(b)) > maxReadFileSize { + return fmt.Errorf("%w: %s (limit %d bytes)", ErrFileTooLarge, wantPath, maxReadFileSize) + } + content = b + realStatus = readFound + return nil + }) + if err != nil { + return nil, readMissing, err + } + if hasReal { + return content, realStatus, nil + } + if deleted { + return nil, readDeleted, nil + } + return nil, readMissing, nil +} + +func isRegularTarFile(typeflag byte) bool { + // archive/tar.Reader normalizes the legacy '\x00' (TypeRegA) typeflag + // to TypeReg before our callback ever sees the header (see Go stdlib + // archive/tar/reader.go: "Legacy archives use trailing slash for + // directories"), so checking only TypeReg is sufficient. + return typeflag == tar.TypeReg +} + +// mergeLayer applies one layer to fileMap in two phases to respect whiteout +// semantics correctly: +// 1. Collect all real entries / whiteouts / opaque markers of this layer. +// 2. Whiteouts delete matching paths from prior layers (in fileMap) first. +// 3. Opaque markers clear descendants of the marked directory. +// 4. This layer's real entries are copied into fileMap last, so opaques in +// this layer do not wipe its own additions. +func mergeLayer(rc io.Reader, fileMap map[string]Entry) error { + thisLayer := make(map[string]Entry) + whiteouts := make(map[string]struct{}) + opaques := make(map[string]struct{}) + + if err := WalkTar(rc, func(hdr *tar.Header, _ io.Reader) error { + name := normalizePath(hdr.Name) + target, opaque := Whiteout(name) + if opaque { + opaques[normalizePath(target)] = struct{}{} + return nil + } + if target != "" { + whiteouts[normalizePath(target)] = struct{}{} + return nil + } + thisLayer[name] = headerToEntry(hdr) + return nil + }); err != nil { + return err + } + + for wt := range whiteouts { + delete(fileMap, wt) + for k := range fileMap { + if isAncestor(wt, k) { + delete(fileMap, k) + } + } + } + for dir := range opaques { + if dir == "." { + for k := range fileMap { + delete(fileMap, k) + } + continue + } + for k := range fileMap { + if isAncestor(dir, k) { + delete(fileMap, k) + } + } + } + maps.Copy(fileMap, thisLayer) + return nil +} + +func sortedEntries(m map[string]Entry) []Entry { + out := make([]Entry, 0, len(m)) + for _, e := range m { + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +func headerToEntry(hdr *tar.Header) Entry { + mode := fs.FileMode(hdr.Mode & 0o777) + entryType := mapType(hdr.Typeflag) + switch entryType { + case TypeDir: + mode |= fs.ModeDir + case TypeSymlink: + mode |= fs.ModeSymlink + } + return Entry{ + Path: normalizePath(hdr.Name), + Type: entryType, + Size: hdr.Size, + Mode: mode, + ModeStr: mode.String(), + Linkname: hdr.Linkname, + } +} + +func mapType(t byte) EntryType { + switch t { + case tar.TypeReg: + return TypeFile + case tar.TypeDir: + return TypeDir + case tar.TypeSymlink: + return TypeSymlink + case tar.TypeLink: + return TypeHardlink + default: + return TypeOther + } +} diff --git a/internal/cr/internal/imagefs/scope.go b/internal/cr/internal/imagefs/scope.go new file mode 100644 index 00000000..471923d9 --- /dev/null +++ b/internal/cr/internal/imagefs/scope.go @@ -0,0 +1,54 @@ +/* +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 imagefs + +import ( + "path" + "strings" +) + +// FilterBySubpath returns entries whose tar-relative path is at or under +// subpath. An empty or "."-equivalent subpath returns the entries unchanged. +func FilterBySubpath(entries []Entry, subpath string) []Entry { + if subpath == "" { + return entries + } + sub := NormalizeScopePath(subpath) + if sub == "." { + return entries + } + prefix := sub + "/" + out := make([]Entry, 0, len(entries)) + for _, e := range entries { + if e.Path == sub || strings.HasPrefix(e.Path, prefix) { + out = append(out, e) + } + } + return out +} + +// NormalizeScopePath canonicalizes a user-supplied PATH argument used to +// scope `fs ls`/`fs tree` output. Leading "./" or "/" is stripped, the +// path is Clean'd, trailing whitespace is trimmed, and an empty result +// is normalized to ".". +func NormalizeScopePath(raw string) string { + v := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(raw), "./"), "/") + if v == "" { + return "." + } + return strings.TrimPrefix(path.Clean(v), "./") +} diff --git a/internal/cr/internal/imagefs/scope_test.go b/internal/cr/internal/imagefs/scope_test.go new file mode 100644 index 00000000..1eced8f4 --- /dev/null +++ b/internal/cr/internal/imagefs/scope_test.go @@ -0,0 +1,67 @@ +/* +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 imagefs + +import ( + "slices" + "testing" +) + +func TestFilterBySubpath_StrictScope(t *testing.T) { + entries := []Entry{ + {Path: "etc", Type: TypeDir}, + {Path: "etc/passwd", Type: TypeFile}, + {Path: "etc/ssh/sshd_config", Type: TypeFile}, + {Path: "usr/bin/passwd", Type: TypeFile}, + {Path: "passwd", Type: TypeFile}, + } + + got := FilterBySubpath(entries, "passwd") + paths := make([]string, 0, len(got)) + for _, e := range got { + paths = append(paths, e.Path) + } + if !slices.Equal(paths, []string{"passwd"}) { + t.Fatalf("strict scope expected only root passwd, got: %v", paths) + } + + got = FilterBySubpath(entries, "etc") + paths = paths[:0] + for _, e := range got { + paths = append(paths, e.Path) + } + if !slices.Equal(paths, []string{"etc", "etc/passwd", "etc/ssh/sshd_config"}) { + t.Fatalf("strict scope for etc mismatch, got: %v", paths) + } +} + +func TestNormalizeScopePath(t *testing.T) { + cases := map[string]string{ + "": ".", + "/": ".", + "./": ".", + "etc/": "etc", + "/etc/passwd": "etc/passwd", + "./etc/../etc": "etc", + " /var/log/../ ": "var", + } + for in, want := range cases { + if got := NormalizeScopePath(in); got != want { + t.Fatalf("NormalizeScopePath(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/cr/internal/imagefs/types.go b/internal/cr/internal/imagefs/types.go new file mode 100644 index 00000000..97be5934 --- /dev/null +++ b/internal/cr/internal/imagefs/types.go @@ -0,0 +1,41 @@ +/* +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 imagefs + +import "io/fs" + +type EntryType string + +const ( + TypeFile EntryType = "file" + TypeDir EntryType = "dir" + TypeSymlink EntryType = "symlink" + TypeHardlink EntryType = "hardlink" + TypeWhiteout EntryType = "whiteout" + TypeOther EntryType = "other" +) + +type Entry struct { + Path string `json:"path"` + Type EntryType `json:"type"` + Size int64 `json:"size"` + Mode fs.FileMode `json:"-"` + ModeStr string `json:"mode"` + Linkname string `json:"linkname,omitempty"` +} + +func (e Entry) IsDir() bool { return e.Type == TypeDir } diff --git a/internal/cr/internal/imagefs/walker.go b/internal/cr/internal/imagefs/walker.go new file mode 100644 index 00000000..8ce02563 --- /dev/null +++ b/internal/cr/internal/imagefs/walker.go @@ -0,0 +1,71 @@ +/* +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 imagefs + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "path" + "strings" +) + +// WalkTar reads a tar stream and invokes fn for each entry. Returning +// ErrStopWalk stops iteration without propagating as an error. +// +// The function is the shared tar-traversal primitive used by all readers +// and the extractor. Callers pass the io.Reader obtained from +// v1.Layer.Uncompressed(). +func WalkTar(rc io.Reader, fn func(*tar.Header, io.Reader) error) error { + tr := tar.NewReader(rc) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("tar next: %w", err) + } + if err := fn(hdr, tr); err != nil { + if errors.Is(err, ErrStopWalk) { + return nil + } + return err + } + } +} + +// normalizePath strips leading "./" and "/", trailing "/", and cleans the +// result. Empty paths collapse to ".". +func normalizePath(p string) string { + p = strings.TrimPrefix(p, "./") + p = strings.TrimPrefix(p, "/") + p = strings.TrimSuffix(p, "/") + if p == "" { + return "." + } + return path.Clean(p) +} + +// isAncestor reports whether descendant lives under ancestor (not equal). +func isAncestor(ancestor, descendant string) bool { + if ancestor == "." { + return descendant != "." + } + return strings.HasPrefix(descendant, ancestor+"/") +} diff --git a/internal/cr/internal/imagefs/whiteout.go b/internal/cr/internal/imagefs/whiteout.go new file mode 100644 index 00000000..1cae2656 --- /dev/null +++ b/internal/cr/internal/imagefs/whiteout.go @@ -0,0 +1,57 @@ +/* +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 imagefs + +import ( + "path" + "strings" +) + +const ( + whiteoutPrefix = ".wh." + whiteoutOpaqueMarker = ".wh..wh..opq" +) + +// Whiteout classifies a tar entry name as: +// - opaque marker (whole directory deleted from lower layers): returns (dir, true) +// - regular whiteout (single entry deleted): returns (targetPath, false) +// - not a whiteout: returns ("", false) +func Whiteout(name string) (string, bool) { + base := path.Base(name) + dir := path.Dir(name) + + if base == whiteoutOpaqueMarker { + if dir == "." || dir == "/" { + return ".", true + } + return strings.TrimPrefix(dir, "./"), true + } + if target, ok := strings.CutPrefix(base, whiteoutPrefix); ok { + // Reject malformed markers whose suffix is empty (".wh.") or just + // a dot (".wh..") - both would resolve to the directory itself + // and, without this guard, instruct applyWhiteout to RemoveAll + // the whiteout's parent directory. + if target == "" || target == "." { + return "", false + } + if dir == "." || dir == "/" { + return target, false + } + return path.Join(strings.TrimPrefix(dir, "./"), target), false + } + return "", false +} diff --git a/internal/cr/internal/imagefs/whiteout_test.go b/internal/cr/internal/imagefs/whiteout_test.go new file mode 100644 index 00000000..b87f2fbc --- /dev/null +++ b/internal/cr/internal/imagefs/whiteout_test.go @@ -0,0 +1,50 @@ +/* +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 imagefs + +import "testing" + +func TestWhiteout(t *testing.T) { + cases := []struct { + name string + input string + wantTarget string + wantOpaque bool + }{ + {"plain file is not whiteout", "etc/passwd", "", false}, + {"root whiteout", ".wh.foo", "foo", false}, + {"nested whiteout", "bin/.wh.sh", "bin/sh", false}, + {"deep whiteout", "usr/local/bin/.wh.npm", "usr/local/bin/npm", false}, + {"opaque at root", ".wh..wh..opq", ".", true}, + {"opaque in dir", "var/log/.wh..wh..opq", "var/log", true}, + {"regular file with .wh in name", "foo.wh.bar", "", false}, + {"empty string", "", "", false}, + {"malformed marker with empty target", ".wh.", "", false}, + {"malformed marker with dot target", ".wh..", "", false}, + {"malformed marker with empty target in subdir", "subdir/.wh.", "", false}, + {"malformed marker with dot target in subdir", "subdir/.wh..", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + target, opaque := Whiteout(c.input) + if target != c.wantTarget || opaque != c.wantOpaque { + t.Errorf("Whiteout(%q) = (%q, %v), want (%q, %v)", + c.input, target, opaque, c.wantTarget, c.wantOpaque) + } + }) + } +} diff --git a/internal/cr/internal/imageio/doc.go b/internal/cr/internal/imageio/doc.go new file mode 100644 index 00000000..a3c0fe0f --- /dev/null +++ b/internal/cr/internal/imageio/doc.go @@ -0,0 +1,19 @@ +/* +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 imageio bridges v1.Image / v1.ImageIndex and the local filesystem: +// loading and saving tarballs (docker format) and OCI image layouts. +package imageio diff --git a/internal/cr/internal/imageio/formats.go b/internal/cr/internal/imageio/formats.go new file mode 100644 index 00000000..ccd74a54 --- /dev/null +++ b/internal/cr/internal/imageio/formats.go @@ -0,0 +1,33 @@ +/* +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 imageio + +// Pull output formats. Single source of truth - the cobra command (basic/pull.go) +// and the shell-completion enum (cmd/completion) both read from here. +const ( + PullFormatTarball = "tarball" + PullFormatLegacy = "legacy" + PullFormatOCI = "oci" +) + +var pullFormats = []string{PullFormatTarball, PullFormatLegacy, PullFormatOCI} + +// PullFormats returns a defensive copy of the format enum suitable for +// cobra completion or help-text generation. +func PullFormats() []string { + return append([]string(nil), pullFormats...) +} diff --git a/internal/cr/internal/imageio/layout.go b/internal/cr/internal/imageio/layout.go new file mode 100644 index 00000000..f21fb3b9 --- /dev/null +++ b/internal/cr/internal/imageio/layout.go @@ -0,0 +1,132 @@ +/* +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 imageio + +import ( + "fmt" + "os" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/partial" +) + +// SaveOCI appends images and indices to an OCI image-layout directory at +// path, creating it if missing. +func SaveOCI(path string, imgs map[string]v1.Image, idxs map[string]v1.ImageIndex) error { + p, err := openOrCreateLayout(path) + if err != nil { + return err + } + for refStr, img := range imgs { + if err := p.AppendImage(img); err != nil { + return fmt.Errorf("append image %s: %w", refStr, err) + } + } + for refStr, idx := range idxs { + if err := p.AppendIndex(idx); err != nil { + return fmt.Errorf("append index %s: %w", refStr, err) + } + } + return nil +} + +// LoadLocal inspects path and returns a v1.Image or v1.ImageIndex. +// +// path is a file -> docker tarball, returns v1.Image +// path is an OCI layout -> contents determine the type: +// - exactly one image manifest -> v1.Image +// - exactly one nested index -> v1.ImageIndex (unwrapped, --index optional) +// - several entries with asIndex = true -> v1.ImageIndex (the layout's top-level index) +// - several entries without asIndex -> error (ambiguous) +func LoadLocal(path string, asIndex bool) (partial.WithRawManifest, error) { + stat, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + if !stat.IsDir() { + return LoadTarball(path, "") + } + + idx, err := layout.ImageIndexFromPath(path) + if err != nil { + return nil, fmt.Errorf("read OCI layout %s: %w", path, err) + } + + manifest, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("read index manifest: %w", err) + } + + // Single-entry layout: unwrap regardless of asIndex. The layout's + // top-level index.json is a "directory of contents" pointer, not the + // thing the user intends to publish. Pushing it as-is would store a + // redundant 1-entry wrapper in the registry (an index whose only + // manifest is the real index/image), and subsequent pulls would + // preserve that extra layer. asIndex stays meaningful only for layouts + // that actually need a fresh index built from multiple entries. + if len(manifest.Manifests) == 1 { + desc := manifest.Manifests[0] + switch { + case desc.MediaType.IsImage(): + return idx.Image(desc.Digest) + case desc.MediaType.IsIndex(): + return idx.ImageIndex(desc.Digest) + default: + return nil, fmt.Errorf("layout %s contains non-image entry (mediaType %q)", path, desc.MediaType) + } + } + + if !asIndex { + return nil, fmt.Errorf("layout %s contains %d entries; pass --index to push as an index", path, len(manifest.Manifests)) + } + return idx, nil +} + +func openOrCreateLayout(path string) (layout.Path, error) { + stat, err := os.Stat(path) + switch { + case err != nil && !os.IsNotExist(err): + return "", fmt.Errorf("stat %s: %w", path, err) + case err == nil && !stat.IsDir(): + return "", fmt.Errorf("--format oci requires a directory, got file %s", path) + } + + if err == nil { + // Existing directory: take it only if it is already a valid OCI + // layout, or if it is empty. A non-empty directory that is not a + // layout (the user mistyped a destination, e.g. ~/Documents) must + // not be silently overwritten with layout.Write. + if p, lerr := layout.FromPath(path); lerr == nil { + return p, nil + } + entries, rerr := os.ReadDir(path) + if rerr != nil { + return "", fmt.Errorf("read %s: %w", path, rerr) + } + if len(entries) > 0 { + return "", fmt.Errorf("%s exists, is not an OCI layout, and is not empty; refusing to overwrite", path) + } + } + + p, err := layout.Write(path, empty.Index) + if err != nil { + return "", fmt.Errorf("create OCI layout %s: %w", path, err) + } + return p, nil +} diff --git a/internal/cr/internal/imageio/layout_test.go b/internal/cr/internal/imageio/layout_test.go new file mode 100644 index 00000000..192f50ef --- /dev/null +++ b/internal/cr/internal/imageio/layout_test.go @@ -0,0 +1,263 @@ +/* +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 imageio + +import ( + "os" + "path/filepath" + "strings" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/random" +) + +func TestSaveOCI_WritesImage(t *testing.T) { + dir := filepath.Join(t.TempDir(), "layout") + img := randomImage(t) + + err := SaveOCI(dir, map[string]v1.Image{"example.com/app:v1": img}, nil) + if err != nil { + t.Fatalf("SaveOCI: %v", err) + } + + // Verify we wrote a layout with one manifest. + idx, err := layout.ImageIndexFromPath(dir) + if err != nil { + t.Fatalf("layout.ImageIndexFromPath: %v", err) + } + manifest, err := idx.IndexManifest() + if err != nil { + t.Fatalf("IndexManifest: %v", err) + } + if len(manifest.Manifests) != 1 { + t.Fatalf("expected 1 manifest in layout, got %d", len(manifest.Manifests)) + } +} + +func TestSaveOCI_RejectsRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.tar") + if err := os.WriteFile(path, []byte("not a directory"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err := SaveOCI(path, map[string]v1.Image{"example.com/app:v1": randomImage(t)}, nil) + if err == nil { + t.Fatalf("expected error for regular-file destination") + } + if !strings.Contains(err.Error(), "requires a directory") { + t.Fatalf("unexpected error message: %v", err) + } +} + +// SaveOCI must refuse to clobber a non-empty directory that does not look +// like an OCI layout. Pre-fix behavior: layout.FromPath errored, layout.Write +// then planted oci-layout / index.json into the user's existing directory +// alongside their files. The classic footgun is a typo on the destination +// path (e.g. `cr pull --format oci alpine ~/Documents`). +func TestSaveOCI_RejectsNonEmptyNonLayoutDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "userdata") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + stranger := filepath.Join(dir, "important.txt") + if err := os.WriteFile(stranger, []byte("user data"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + + err := SaveOCI(dir, map[string]v1.Image{"example.com/app:v1": randomImage(t)}, nil) + if err == nil { + t.Fatalf("expected refusal, got nil") + } + if !strings.Contains(err.Error(), "not an OCI layout") { + t.Fatalf("unexpected error: %v", err) + } + + // Pre-existing user file must still be intact, no layout files added. + got, err := os.ReadFile(stranger) + if err != nil || string(got) != "user data" { + t.Fatalf("user file corrupted: content=%q err=%v", got, err) + } + if _, err := os.Stat(filepath.Join(dir, "oci-layout")); !os.IsNotExist(err) { + t.Errorf("oci-layout should not have been created, Stat err=%v", err) + } +} + +// An existing empty directory is a legitimate destination - the user might +// have created it ahead of time. Must not error. +func TestSaveOCI_EmptyExistingDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "fresh") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := SaveOCI(dir, map[string]v1.Image{"example.com/app:v1": randomImage(t)}, nil); err != nil { + t.Fatalf("SaveOCI on empty dir: %v", err) + } +} + +// Re-running SaveOCI against an already-valid layout must keep appending +// without surfacing the "non-empty" guard erroneously. +func TestSaveOCI_AppendsToExistingLayout(t *testing.T) { + dir := filepath.Join(t.TempDir(), "layout") + if err := SaveOCI(dir, map[string]v1.Image{"example.com/app:v1": randomImage(t)}, nil); err != nil { + t.Fatalf("first SaveOCI: %v", err) + } + if err := SaveOCI(dir, map[string]v1.Image{"example.com/app:v2": randomImage(t)}, nil); err != nil { + t.Fatalf("second SaveOCI: %v", err) + } + idx, err := layout.ImageIndexFromPath(dir) + if err != nil { + t.Fatalf("read layout: %v", err) + } + manifest, err := idx.IndexManifest() + if err != nil { + t.Fatalf("IndexManifest: %v", err) + } + if len(manifest.Manifests) != 2 { + t.Errorf("expected 2 manifests after append, got %d", len(manifest.Manifests)) + } +} + +func makeLayoutWithImages(t *testing.T, n int) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "layout") + p, err := layout.Write(dir, empty.Index) + if err != nil { + t.Fatalf("layout.Write: %v", err) + } + for range n { + img, err := random.Image(64, 1) + if err != nil { + t.Fatalf("random.Image: %v", err) + } + if err := p.AppendImage(img); err != nil { + t.Fatalf("AppendImage: %v", err) + } + } + return dir +} + +func TestLoadLocal_SingleImageLayout(t *testing.T) { + dir := makeLayoutWithImages(t, 1) + obj, err := LoadLocal(dir, false) + if err != nil { + t.Fatalf("LoadLocal: %v", err) + } + if _, ok := obj.(v1.Image); !ok { + t.Errorf("single-image layout should yield v1.Image, got %T", obj) + } +} + +func TestLoadLocal_NestedIndexLayout(t *testing.T) { + dir := filepath.Join(t.TempDir(), "layout") + p, err := layout.Write(dir, empty.Index) + if err != nil { + t.Fatalf("layout.Write: %v", err) + } + inner, err := random.Index(32, 1, 2) + if err != nil { + t.Fatalf("random.Index: %v", err) + } + if err := p.AppendIndex(inner); err != nil { + t.Fatalf("AppendIndex: %v", err) + } + + obj, err := LoadLocal(dir, false) + if err != nil { + t.Fatalf("LoadLocal: %v", err) + } + if _, ok := obj.(v1.ImageIndex); !ok { + t.Errorf("layout with single inner index should yield v1.ImageIndex, got %T", obj) + } +} + +// A layout produced by `cr pull --format oci` of a multi-arch image holds a +// single nested index. Pushing it with --index must unwrap to that inner +// index instead of returning the layout's top-level wrapper, otherwise the +// registry stores a redundant 1-entry index around the real one and every +// subsequent pull preserves the extra layer. +func TestLoadLocal_NestedIndexLayout_AsIndexUnwraps(t *testing.T) { + dir := filepath.Join(t.TempDir(), "layout") + p, err := layout.Write(dir, empty.Index) + if err != nil { + t.Fatalf("layout.Write: %v", err) + } + inner, err := random.Index(32, 1, 2) + if err != nil { + t.Fatalf("random.Index: %v", err) + } + if err := p.AppendIndex(inner); err != nil { + t.Fatalf("AppendIndex: %v", err) + } + innerDigest, err := inner.Digest() + if err != nil { + t.Fatalf("inner.Digest: %v", err) + } + + obj, err := LoadLocal(dir, true) + if err != nil { + t.Fatalf("LoadLocal asIndex=true: %v", err) + } + got, ok := obj.(v1.ImageIndex) + if !ok { + t.Fatalf("expected v1.ImageIndex, got %T", obj) + } + gotDigest, err := got.Digest() + if err != nil { + t.Fatalf("got.Digest: %v", err) + } + if gotDigest != innerDigest { + t.Errorf("expected inner index digest %s, got %s (wrapper not unwrapped)", innerDigest, gotDigest) + } +} + +func TestLoadLocal_MultipleManifestsRequiresIndex(t *testing.T) { + dir := makeLayoutWithImages(t, 3) + if _, err := LoadLocal(dir, false); err == nil { + t.Fatalf("expected error for multi-manifest layout without --index") + } + obj, err := LoadLocal(dir, true) + if err != nil { + t.Fatalf("LoadLocal with asIndex=true: %v", err) + } + if _, ok := obj.(v1.ImageIndex); !ok { + t.Errorf("asIndex=true should yield v1.ImageIndex, got %T", obj) + } +} + +func TestLoadLocal_TarballFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.tar") + src := randomImage(t) + if err := SaveTarball(path, map[string]v1.Image{"example.com/app:v1": src}); err != nil { + t.Fatalf("SaveTarball: %v", err) + } + obj, err := LoadLocal(path, false) + if err != nil { + t.Fatalf("LoadLocal tarball: %v", err) + } + if _, ok := obj.(v1.Image); !ok { + t.Errorf("tarball should yield v1.Image, got %T", obj) + } +} + +func TestLoadLocal_Missing(t *testing.T) { + if _, err := LoadLocal(filepath.Join(t.TempDir(), "does-not-exist"), false); err == nil { + t.Fatalf("expected error for missing path") + } +} diff --git a/internal/cr/internal/imageio/tarball.go b/internal/cr/internal/imageio/tarball.go new file mode 100644 index 00000000..1dd90b99 --- /dev/null +++ b/internal/cr/internal/imageio/tarball.go @@ -0,0 +1,91 @@ +/* +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 imageio + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// SaveTarball writes imgs to path as a modern docker-compatible tarball +// (references may be digests or tagged names). An empty map is rejected: +// the resulting file would have no manifest.json and LoadTarball could +// not read it back, so callers see a clean error instead of a malformed +// artifact. +func SaveTarball(path string, imgs map[string]v1.Image) error { + if len(imgs) == 0 { + return fmt.Errorf("save tarball %s: at least one image is required", path) + } + refToImage := make(map[name.Reference]v1.Image, len(imgs)) + for refStr, img := range imgs { + ref, err := name.ParseReference(refStr) + if err != nil { + return fmt.Errorf("parse reference %q: %w", refStr, err) + } + refToImage[ref] = img + } + if err := tarball.MultiRefWriteToFile(path, refToImage); err != nil { + return fmt.Errorf("write tarball %s: %w", path, err) + } + return nil +} + +// SaveLegacy writes imgs to path as a legacy docker-format tarball, which +// understands only tagged references (no digests) but is readable by old +// `docker load` versions. Empty maps are rejected for the same reason as +// SaveTarball. +func SaveLegacy(path string, imgs map[string]v1.Image) error { + if len(imgs) == 0 { + return fmt.Errorf("save legacy tarball %s: at least one image is required", path) + } + tagToImage := make(map[name.Tag]v1.Image, len(imgs)) + for refStr, img := range imgs { + tag, err := name.NewTag(refStr) + if err != nil { + return fmt.Errorf("parse tag %q (legacy tarballs require tagged refs): %w", refStr, err) + } + tagToImage[tag] = img + } + if err := tarball.MultiWriteToFile(path, tagToImage); err != nil { + return fmt.Errorf("write legacy tarball %s: %w", path, err) + } + return nil +} + +// LoadTarball reads a docker-format tarball from path. When tag is non-empty +// it picks the matching manifest entry; otherwise the first/only entry. +func LoadTarball(path, tag string) (v1.Image, error) { + if tag == "" { + img, err := tarball.ImageFromPath(path, nil) + if err != nil { + return nil, fmt.Errorf("load tarball %s: %w", path, err) + } + return img, nil + } + ref, err := name.NewTag(tag) + if err != nil { + return nil, fmt.Errorf("parse tag %q: %w", tag, err) + } + img, err := tarball.ImageFromPath(path, &ref) + if err != nil { + return nil, fmt.Errorf("load tarball %s with tag %s: %w", path, tag, err) + } + return img, nil +} diff --git a/internal/cr/internal/imageio/tarball_test.go b/internal/cr/internal/imageio/tarball_test.go new file mode 100644 index 00000000..682c0e99 --- /dev/null +++ b/internal/cr/internal/imageio/tarball_test.go @@ -0,0 +1,114 @@ +/* +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 imageio + +import ( + "path/filepath" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/random" +) + +func randomImage(t *testing.T) v1.Image { + t.Helper() + img, err := random.Image(128, 1) + if err != nil { + t.Fatalf("random.Image: %v", err) + } + return img +} + +func TestSaveTarball_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.tar") + src := randomImage(t) + + err := SaveTarball(path, map[string]v1.Image{"example.com/test:v1": src}) + if err != nil { + t.Fatalf("SaveTarball: %v", err) + } + + loaded, err := LoadTarball(path, "") + if err != nil { + t.Fatalf("LoadTarball: %v", err) + } + + gotDigest, err := loaded.Digest() + if err != nil { + t.Fatalf("digest: %v", err) + } + wantDigest, err := src.Digest() + if err != nil { + t.Fatalf("src digest: %v", err) + } + if gotDigest != wantDigest { + t.Errorf("digest mismatch: got %s, want %s", gotDigest, wantDigest) + } +} + +func TestSaveLegacy_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.tar") + src := randomImage(t) + + // Legacy tarballs require tagged references; digest refs would be rejected. + err := SaveLegacy(path, map[string]v1.Image{"example.com/test:legacy": src}) + if err != nil { + t.Fatalf("SaveLegacy: %v", err) + } + + loaded, err := LoadTarball(path, "example.com/test:legacy") + if err != nil { + t.Fatalf("LoadTarball: %v", err) + } + if _, err := loaded.Digest(); err != nil { + t.Errorf("digest of reloaded legacy image: %v", err) + } +} + +func TestSaveLegacy_RejectsDigestRef(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.tar") + src := randomImage(t) + err := SaveLegacy(path, map[string]v1.Image{ + "example.com/test@sha256:0000000000000000000000000000000000000000000000000000000000000000": src, + }) + if err == nil { + t.Fatalf("SaveLegacy should reject digest references") + } +} + +// SaveTarball used to silently emit a tar with no manifest.json on an empty +// map, which LoadTarball then could not read back. Reject up front so callers +// see a clean failure. +func TestSaveTarball_RejectsEmptyMap(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.tar") + if err := SaveTarball(path, map[string]v1.Image{}); err == nil { + t.Fatalf("SaveTarball should reject empty input") + } +} + +func TestSaveLegacy_RejectsEmptyMap(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty-legacy.tar") + if err := SaveLegacy(path, map[string]v1.Image{}); err == nil { + t.Fatalf("SaveLegacy should reject empty input") + } +} + +func TestLoadTarball_Missing(t *testing.T) { + if _, err := LoadTarball(filepath.Join(t.TempDir(), "none.tar"), ""); err == nil { + t.Fatalf("LoadTarball should error on missing file") + } +} diff --git a/internal/cr/internal/output/doc.go b/internal/cr/internal/output/doc.go new file mode 100644 index 00000000..a7891c59 --- /dev/null +++ b/internal/cr/internal/output/doc.go @@ -0,0 +1,20 @@ +/* +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 output renders imagefs data for `d8 cr fs` subcommands as +// human-readable text. JSON output (`--format json`) is planned for a +// later iteration; imagefs.Entry already carries `json` tags. +package output diff --git a/internal/cr/internal/output/text.go b/internal/cr/internal/output/text.go new file mode 100644 index 00000000..76115c77 --- /dev/null +++ b/internal/cr/internal/output/text.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 output + +import ( + "fmt" + "io" + + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imagefs" +) + +// WriteEntriesText writes entries in simple or long (-l) format. +// Directory entries are emitted with a trailing "/" so the type is visible +// at a glance (matching the convention used by `ls -p`, `tree`, etc.). +func WriteEntriesText(w io.Writer, entries []imagefs.Entry, long bool) error { + for _, e := range entries { + path := e.Path + if e.IsDir() { + path += "/" + } + if long { + if _, err := fmt.Fprintf(w, "%-11s %8s %s\n", e.ModeStr, HumanSize(e.Size), path); err != nil { + return err + } + continue + } + if _, err := fmt.Fprintln(w, path); err != nil { + return err + } + } + return nil +} + +// HumanSize returns a compact human-readable size (e.g. "789 B", "12.3 KB"). +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 %cB", float64(n)/float64(div), units[exp]) +} diff --git a/internal/cr/internal/output/text_test.go b/internal/cr/internal/output/text_test.go new file mode 100644 index 00000000..611ee88a --- /dev/null +++ b/internal/cr/internal/output/text_test.go @@ -0,0 +1,102 @@ +/* +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 output + +import ( + "bytes" + "io/fs" + "math" + "strings" + "testing" + + "github.com/deckhouse/deckhouse-cli/internal/cr/internal/imagefs" +) + +func TestHumanSize(t *testing.T) { + cases := []struct { + in int64 + want string + }{ + {0, "0 B"}, + {1, "1 B"}, + {1023, "1023 B"}, + {1024, "1.0 KB"}, + {1024 * 1024, "1.0 MB"}, + {1024 * 1024 * 1024, "1.0 GB"}, + // EB scale: 1<<60 == 1024^6 - the largest unit reachable for int64. + {1 << 60, "1.0 EB"}, + // Boundary: int64 max must not panic, must stay within "EB". + // (loop cannot reach exp=6 because 1024^7 > MaxInt64). + {math.MaxInt64, "8.0 EB"}, + } + for _, tc := range cases { + got := HumanSize(tc.in) + if got != tc.want { + t.Errorf("HumanSize(%d) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestWriteEntriesText_ShortFormat(t *testing.T) { + entries := []imagefs.Entry{ + {Path: "etc", Type: imagefs.TypeDir, Mode: fs.ModeDir | 0o755}, + {Path: "etc/passwd", Type: imagefs.TypeFile, Size: 42, Mode: 0o644}, + } + var buf bytes.Buffer + if err := WriteEntriesText(&buf, entries, false); err != nil { + t.Fatalf("WriteEntriesText: %v", err) + } + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), buf.String()) + } + // Directory rendered with trailing slash. + if lines[0] != "etc/" { + t.Errorf("dir line = %q, want %q", lines[0], "etc/") + } + if lines[1] != "etc/passwd" { + t.Errorf("file line = %q, want %q", lines[1], "etc/passwd") + } +} + +func TestWriteEntriesText_LongFormat(t *testing.T) { + entries := []imagefs.Entry{ + {Path: "etc/passwd", Type: imagefs.TypeFile, Size: 1024, Mode: 0o644, ModeStr: "-rw-r--r--"}, + } + var buf bytes.Buffer + if err := WriteEntriesText(&buf, entries, true); err != nil { + t.Fatalf("WriteEntriesText: %v", err) + } + out := buf.String() + // Long format must include mode, size and path - exact column widths are + // not part of the contract, only that all three fields are present. + for _, want := range []string{"-rw-r--r--", "1.0 KB", "etc/passwd"} { + if !strings.Contains(out, want) { + t.Errorf("long output missing %q: %q", want, out) + } + } +} + +func TestWriteEntriesText_Empty(t *testing.T) { + var buf bytes.Buffer + if err := WriteEntriesText(&buf, nil, false); err != nil { + t.Fatalf("WriteEntriesText: %v", err) + } + if buf.Len() != 0 { + t.Errorf("expected no output for empty input, got %q", buf.String()) + } +} diff --git a/internal/cr/internal/registry/catalog.go b/internal/cr/internal/registry/catalog.go new file mode 100644 index 00000000..6737300a --- /dev/null +++ b/internal/cr/internal/registry/catalog.go @@ -0,0 +1,59 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "context" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// ListCatalog invokes visit for every repository page on the given registry. +// Not every registry implements /v2/_catalog - the underlying call will +// surface a 404 through the error chain. +func ListCatalog(ctx context.Context, regRef string, opts *Options, visit func(repos []string) error) error { + reg, err := name.NewRegistry(regRef, opts.Name...) + if err != nil { + return fmt.Errorf("parse registry %q: %w", regRef, err) + } + + puller, err := remote.NewPuller(opts.remoteWithContext(ctx)...) + if err != nil { + return fmt.Errorf("create puller: %w", err) + } + + catalogger, err := puller.Catalogger(ctx, reg) + if err != nil { + return fmt.Errorf("read catalog for %s: %w", reg, err) + } + + for catalogger.HasNext() { + if err := ctx.Err(); err != nil { + return err + } + page, err := catalogger.Next(ctx) + if err != nil { + return fmt.Errorf("read next catalog page: %w", err) + } + if err := visit(page.Repos); err != nil { + return err + } + } + return nil +} diff --git a/internal/cr/internal/registry/doc.go b/internal/cr/internal/registry/doc.go new file mode 100644 index 00000000..ad77a6c0 --- /dev/null +++ b/internal/cr/internal/registry/doc.go @@ -0,0 +1,20 @@ +/* +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 registry is the thin domain layer of `d8 cr` on top of +// go-containerregistry/pkg/v1/*. It replaces the upstream pkg/crane facade - +// we own every entry point, error message and option default. +package registry diff --git a/internal/cr/internal/registry/fetch.go b/internal/cr/internal/registry/fetch.go new file mode 100644 index 00000000..648232f2 --- /dev/null +++ b/internal/cr/internal/registry/fetch.go @@ -0,0 +1,79 @@ +/* +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 registry + +import ( + "context" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// Fetch resolves ref and returns a v1.Image. For multi-arch indices +// remote.Image picks the current runtime platform unless opts.Platform pins +// another one. +func Fetch(ctx context.Context, ref string, opts *Options) (v1.Image, error) { + parsed, err := name.ParseReference(ref, opts.Name...) + if err != nil { + return nil, fmt.Errorf("parse reference %q: %w", ref, err) + } + img, err := remote.Image(parsed, opts.remoteWithContext(ctx)...) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", ref, err) + } + return img, nil +} + +// FetchDescriptor returns the raw remote descriptor, leaving media-type +// dispatch to the caller (pull uses it to tell an index from an image). +func FetchDescriptor(ctx context.Context, ref string, opts *Options) (*remote.Descriptor, error) { + parsed, err := name.ParseReference(ref, opts.Name...) + if err != nil { + return nil, fmt.Errorf("parse reference %q: %w", ref, err) + } + desc, err := remote.Get(parsed, opts.remoteWithContext(ctx)...) + if err != nil { + return nil, fmt.Errorf("fetch descriptor %s: %w", ref, err) + } + return desc, nil +} + +// remoteWithContext is the single point where Options is converted to a +// []remote.Option. Keychain / platform / context are finalized here so that +// repeated builder calls (e.g. WithPlatform twice) cannot stack duplicate +// upstream options on o.Remote and rely on go-containerregistry's +// last-write-wins semantics. o.Remote stays untouched, so the same Options +// can be used to dispatch calls with different per-call contexts. +func (o *Options) remoteWithContext(ctx context.Context) []remote.Option { + if ctx == nil { + ctx = o.Context + } + out := make([]remote.Option, 0, len(o.Remote)+3) + out = append(out, o.Remote...) + if o.Keychain != nil { + out = append(out, remote.WithAuthFromKeychain(o.Keychain)) + } + if o.Platform != nil { + out = append(out, remote.WithPlatform(*o.Platform)) + } + if ctx != nil { + out = append(out, remote.WithContext(ctx)) + } + return out +} diff --git a/internal/cr/internal/registry/inspect.go b/internal/cr/internal/registry/inspect.go new file mode 100644 index 00000000..5cc521bb --- /dev/null +++ b/internal/cr/internal/registry/inspect.go @@ -0,0 +1,55 @@ +/* +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 registry + +import ( + "context" + "fmt" +) + +// FetchManifest returns the raw manifest bytes as the registry served them. +// This preserves signatures and byte-for-byte JSON the user may want to pipe. +func FetchManifest(ctx context.Context, ref string, opts *Options) ([]byte, error) { + desc, err := FetchDescriptor(ctx, ref, opts) + if err != nil { + return nil, err + } + return desc.Manifest, nil +} + +// FetchConfig returns the config JSON for ref. Multi-arch indices are +// resolved via the caller's platform (set on Options). +func FetchConfig(ctx context.Context, ref string, opts *Options) ([]byte, error) { + img, err := Fetch(ctx, ref, opts) + if err != nil { + return nil, err + } + cfg, err := img.RawConfigFile() + if err != nil { + return nil, fmt.Errorf("read config %s: %w", ref, err) + } + return cfg, nil +} + +// FetchDigest returns "sha256:" for ref's manifest as served. +func FetchDigest(ctx context.Context, ref string, opts *Options) (string, error) { + desc, err := FetchDescriptor(ctx, ref, opts) + if err != nil { + return "", err + } + return desc.Digest.String(), nil +} diff --git a/internal/cr/internal/registry/options.go b/internal/cr/internal/registry/options.go new file mode 100644 index 00000000..229a91f3 --- /dev/null +++ b/internal/cr/internal/registry/options.go @@ -0,0 +1,98 @@ +/* +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 registry + +import ( + "context" + "net/http" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// Options accumulates everything the domain layer needs to talk to a +// registry: auth, transport, platform hint, name-parsing flags. Each builder +// mutates the receiver and returns it so calls chain. +// +// The two slices (Remote, Name) are what actually gets passed to +// go-containerregistry: Remote to remote.*, Name to name.ParseReference / +// name.NewRepository / name.NewRegistry / name.NewTag. +type Options struct { + Remote []remote.Option + Name []name.Option + Platform *v1.Platform + Keychain authn.Keychain + Context context.Context +} + +// New returns Options seeded with the default Docker keychain and a +// background context. Keychain / platform / context are NOT baked into +// o.Remote here - they are finalized lazily by remoteWithContext at fetch +// time so repeated builder calls (e.g. WithPlatform twice with different +// values) cannot stack duplicate options on the slice. +func New() *Options { + return &Options{ + Keychain: authn.DefaultKeychain, + Context: context.Background(), + } +} + +// WithContext replaces the ambient context. +func (o *Options) WithContext(ctx context.Context) *Options { + o.Context = ctx + return o +} + +// WithKeychain replaces the keychain that authenticates registry calls. +// Last call wins. +func (o *Options) WithKeychain(kc authn.Keychain) *Options { + o.Keychain = kc + return o +} + +// WithPlatform pins a target platform for multi-arch indices. Nil is a no-op +// (so a flag-driven caller can pass the parsed result directly without +// branching). Last non-nil call wins. +func (o *Options) WithPlatform(p *v1.Platform) *Options { + if p == nil { + return o + } + o.Platform = p + return o +} + +// WithInsecure tolerates non-TLS references during name parsing. The HTTP +// transport itself is configured separately via WithTransport. +func (o *Options) WithInsecure() *Options { + o.Name = append(o.Name, name.Insecure) + return o +} + +// WithNondistributable allows pushing foreign (non-distributable) layers. +func (o *Options) WithNondistributable() *Options { + o.Remote = append(o.Remote, remote.WithNondistributable) + return o +} + +// WithTransport installs a custom HTTP transport (typically a clone of +// remote.DefaultTransport with TLS skip-verify toggled). +func (o *Options) WithTransport(t http.RoundTripper) *Options { + o.Remote = append(o.Remote, remote.WithTransport(t)) + return o +} diff --git a/internal/cr/internal/registry/options_test.go b/internal/cr/internal/registry/options_test.go new file mode 100644 index 00000000..f7fdd1b3 --- /dev/null +++ b/internal/cr/internal/registry/options_test.go @@ -0,0 +1,182 @@ +/* +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 registry + +import ( + "context" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +func TestNew_HasDefaults(t *testing.T) { + o := New() + if o.Keychain == nil { + t.Errorf("New() should seed a default keychain") + } + if o.Context == nil { + t.Errorf("New() should seed a background context") + } + if o.Platform != nil { + t.Errorf("New() should leave Platform nil; got %+v", o.Platform) + } + // Keychain / platform / context are finalized lazily by remoteWithContext; + // New() must not pre-bake them into o.Remote, otherwise repeat builder + // calls would silently stack duplicate upstream options. + if len(o.Remote) != 0 { + t.Errorf("New() must leave Remote empty; got %d entries", len(o.Remote)) + } +} + +func TestWithContext_ReplacesCtx(t *testing.T) { + o := New() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + o.WithContext(ctx) + if o.Context != ctx { + t.Errorf("WithContext did not replace Context") + } +} + +func TestWithContext_DoesNotMutateRemote(t *testing.T) { + // remote.WithContext is produced exclusively by remoteWithContext at fetch + // time. Pre-baking it into o.Remote would stack a second WithContext when + // callers pass a derived ctx, relying on go-containerregistry's last-wins + // semantics - fragile. Guard the contract here. + o := New() + before := len(o.Remote) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + o.WithContext(ctx) + if len(o.Remote) != before { + t.Errorf("WithContext should not append to o.Remote; len before=%d after=%d", before, len(o.Remote)) + } +} + +func TestRemoteWithContext_FinalizesLazily(t *testing.T) { + // Default Options + ctx => keychain + ctx are appended at finalize time; + // o.Remote stays empty (it gets stuff only via WithTransport / WithNondistributable). + o := New() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + got := o.remoteWithContext(ctx) + if len(got) != 2 { + t.Errorf("expected 2 finalized options (keychain + ctx); got %d", len(got)) + } + if len(o.Remote) != 0 { + t.Errorf("remoteWithContext mutated o.Remote: got len=%d", len(o.Remote)) + } + // Calling again must not stack more options - finalize is pure of o.Remote. + got2 := o.remoteWithContext(ctx) + if len(got2) != len(got) { + t.Errorf("second call to remoteWithContext returned different length: %d vs %d", len(got2), len(got)) + } +} + +func TestWithPlatform_NilIsNoop(t *testing.T) { + o := New() + before := len(o.Remote) + o.WithPlatform(nil) + if o.Platform != nil { + t.Errorf("nil platform should not be stored; got %+v", o.Platform) + } + if len(o.Remote) != before { + t.Errorf("nil platform should not append remote options") + } +} + +func TestWithPlatform_Stores(t *testing.T) { + o := New() + p, err := v1.ParsePlatform("linux/arm64") + if err != nil { + t.Fatalf("ParsePlatform: %v", err) + } + o.WithPlatform(p) + if o.Platform == nil || o.Platform.OS != "linux" || o.Platform.Architecture != "arm64" { + t.Errorf("Platform not stored: %+v", o.Platform) + } +} + +func TestChainableBuilders(t *testing.T) { + ctx := context.Background() + o := New().WithContext(ctx).WithInsecure().WithNondistributable() + + if o.Context != ctx { + t.Errorf("WithContext did not propagate") + } + if len(o.Name) == 0 { + t.Errorf("WithInsecure should have appended a name.Option") + } +} + +// stubKeychain is a sentinel implementation - we only need pointer identity +// for the anti-duplication tests below. +type stubKeychain struct{ tag string } + +func (stubKeychain) Resolve(_ authn.Resource) (authn.Authenticator, error) { + return authn.Anonymous, nil +} + +func TestWithKeychain_LastWriteReplaces(t *testing.T) { + custom := stubKeychain{tag: "custom"} + o := New().WithKeychain(custom) + if _, ok := o.Keychain.(stubKeychain); !ok { + t.Errorf("Keychain not replaced; got %T", o.Keychain) + } + // Pre-fix behaviour appended a second WithAuthFromKeychain to o.Remote; + // finalize-on-read makes that impossible by construction. + if len(o.Remote) != 0 { + t.Errorf("WithKeychain must not stack options on o.Remote; got %d", len(o.Remote)) + } + // Finalized output must still carry exactly one keychain option (no dupes + // across repeated finalize calls) plus a ctx. + got := o.remoteWithContext(context.Background()) + if len(got) != 2 { + t.Errorf("expected 2 finalized options (keychain + ctx), got %d", len(got)) + } +} + +func TestWithPlatform_RepeatedCallsDoNotStack(t *testing.T) { + p1, _ := v1.ParsePlatform("linux/amd64") + p2, _ := v1.ParsePlatform("linux/arm64") + o := New().WithPlatform(p1).WithPlatform(p2) + + if o.Platform == nil || o.Platform.Architecture != "arm64" { + t.Errorf("last WithPlatform must win; got %+v", o.Platform) + } + if len(o.Remote) != 0 { + t.Errorf("WithPlatform must not stack options on o.Remote; got %d", len(o.Remote)) + } + got := o.remoteWithContext(context.Background()) + // keychain + platform + ctx + if len(got) != 3 { + t.Errorf("expected 3 finalized options (keychain + platform + ctx), got %d", len(got)) + } +} + +func TestInsecureTransport_Cloned(t *testing.T) { + t1 := InsecureTransport() + t2 := InsecureTransport() + if t1 == nil || t2 == nil { + t.Fatalf("InsecureTransport returned nil") + } + if t1 == t2 { + t.Errorf("InsecureTransport should return distinct clones, got the same instance") + } +} diff --git a/internal/cr/internal/registry/push.go b/internal/cr/internal/registry/push.go new file mode 100644 index 00000000..5cee5337 --- /dev/null +++ b/internal/cr/internal/registry/push.go @@ -0,0 +1,58 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "context" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// Push writes obj (v1.Image or v1.ImageIndex) under ref and returns the +// resulting digest. Anything else is a programmer error. +func Push(ctx context.Context, ref string, obj partial.WithRawManifest, opts *Options) (v1.Hash, error) { + // A literal-nil and a typed-nil v1.Image/v1.ImageIndex both land here + // as a nil interface (since v1.Image and v1.ImageIndex are themselves + // interfaces). Catching it up front keeps the type switch from doing + // remote.Write on a nil object and panicking inside go-containerregistry. + if obj == nil { + return v1.Hash{}, fmt.Errorf("push %s: object is nil", ref) + } + parsed, err := name.ParseReference(ref, opts.Name...) + if err != nil { + return v1.Hash{}, fmt.Errorf("parse reference %q: %w", ref, err) + } + remoteOpts := opts.remoteWithContext(ctx) + switch t := obj.(type) { + case v1.Image: + if err := remote.Write(parsed, t, remoteOpts...); err != nil { + return v1.Hash{}, fmt.Errorf("push image %s: %w", ref, err) + } + return t.Digest() + case v1.ImageIndex: + if err := remote.WriteIndex(parsed, t, remoteOpts...); err != nil { + return v1.Hash{}, fmt.Errorf("push index %s: %w", ref, err) + } + return t.Digest() + default: + return v1.Hash{}, fmt.Errorf("push %s: unsupported type %T", ref, obj) + } +} diff --git a/internal/cr/internal/registry/push_test.go b/internal/cr/internal/registry/push_test.go new file mode 100644 index 00000000..18e3fde2 --- /dev/null +++ b/internal/cr/internal/registry/push_test.go @@ -0,0 +1,71 @@ +/* +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 registry + +import ( + "context" + "strings" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// Push must reject nil inputs explicitly instead of letting them reach +// remote.Write / t.Digest() inside go-containerregistry, which would +// panic on a nil receiver. +func TestPush_RejectsNilObject(t *testing.T) { + opts := New() + cases := []struct { + name string + fn func() error + }{ + { + name: "literal nil", + fn: func() error { + _, err := Push(context.Background(), "example.com/repo:v1", nil, opts) + return err + }, + }, + { + name: "uninitialized v1.Image", + fn: func() error { + var nilImage v1.Image + _, err := Push(context.Background(), "example.com/repo:v1", nilImage, opts) + return err + }, + }, + { + name: "uninitialized v1.ImageIndex", + fn: func() error { + var nilIndex v1.ImageIndex + _, err := Push(context.Background(), "example.com/repo:v1", nilIndex, opts) + return err + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := c.fn() + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "object is nil") { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/internal/cr/internal/registry/tags.go b/internal/cr/internal/registry/tags.go new file mode 100644 index 00000000..3dd43083 --- /dev/null +++ b/internal/cr/internal/registry/tags.go @@ -0,0 +1,58 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "context" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// ListTags invokes visit for every tag page of repo. Stopping early: return +// a non-nil error (context.Canceled is reasonable for user abort). +func ListTags(ctx context.Context, repoRef string, opts *Options, visit func(tags []string) error) error { + repo, err := name.NewRepository(repoRef, opts.Name...) + if err != nil { + return fmt.Errorf("parse repository %q: %w", repoRef, err) + } + + puller, err := remote.NewPuller(opts.remoteWithContext(ctx)...) + if err != nil { + return fmt.Errorf("create puller: %w", err) + } + + lister, err := puller.Lister(ctx, repo) + if err != nil { + return fmt.Errorf("read tags for %s: %w", repo, err) + } + + for lister.HasNext() { + if err := ctx.Err(); err != nil { + return err + } + page, err := lister.Next(ctx) + if err != nil { + return fmt.Errorf("read next tag page: %w", err) + } + if err := visit(page.Tags); err != nil { + return err + } + } + return nil +} diff --git a/internal/cr/internal/registry/transport.go b/internal/cr/internal/registry/transport.go new file mode 100644 index 00000000..9acbada1 --- /dev/null +++ b/internal/cr/internal/registry/transport.go @@ -0,0 +1,33 @@ +/* +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 registry + +import ( + "crypto/tls" + "net/http" + + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// InsecureTransport returns a fresh http.Transport cloned from remote's +// default with TLS verification disabled. Use only when the user explicitly +// opts in via --insecure. +func InsecureTransport() http.RoundTripper { + t := remote.DefaultTransport.(*http.Transport).Clone() + t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-opted via --insecure + return t +}