Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/d8/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
59 changes: 59 additions & 0 deletions internal/cr/cmd/basic/catalog.go
Original file line number Diff line number Diff line change
@@ -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
})
}
43 changes: 43 additions & 0 deletions internal/cr/cmd/basic/config.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
98 changes: 98 additions & 0 deletions internal/cr/cmd/basic/digest.go
Original file line number Diff line number Diff line change
@@ -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
}
144 changes: 144 additions & 0 deletions internal/cr/cmd/basic/export.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading