Skip to content
Merged
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
39 changes: 36 additions & 3 deletions internal/cr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ listing tags, inspecting manifests and configs, and exploring image contents
file-by-file.

Authentication piggybacks on the standard Docker config
(`~/.docker/config.json`), so `docker login` / `d8 login` work transparently.
(`~/.docker/config.json`), so `d8 cr login` / `docker login` work
transparently. You can also pass `--username`/`--password` to any command for
one-off credentials without touching the config.

---

Expand Down Expand Up @@ -83,6 +85,18 @@ manifest, config, digest, all `fs *`, export). With `--format oci`, `pull`
keeps the entire index by default - omit `--platform` and you get every
platform on disk; pass it and you narrow down to one.

### Authentication

- **`login`** - verify credentials against a registry and persist them to the
Docker config (`~/.docker/config.json`, honouring `$DOCKER_CONFIG` and any
configured credential helper / store), exactly like `docker login`. Once
logged in, every other `d8 cr` command authenticates transparently.
Username and password come from `--username`/`--password`; omit either to be
prompted interactively (the password is read without echo).
- **Inline credentials** - the persistent `--username`/`--password` flags work
on every command, letting CI pass one-off credentials (and override whatever
the Docker config holds) without a prior `login`.

### Security and connectivity

- **TLS by default**, with `--insecure` to opt into plain HTTP and skip
Expand Down Expand Up @@ -110,6 +124,7 @@ platform on disk; pass it and you narrow down to one.

```
d8 cr
├── login [REGISTRY] Log in to a registry and save credentials
├── pull IMAGE... PATH Pull image(s) to a tarball or OCI layout
├── push PATH IMAGE Push a tarball or OCI layout to a registry
├── export IMAGE [TARBALL] Stream the merged filesystem as a tar
Expand All @@ -135,6 +150,8 @@ These persistent flags apply to every `d8 cr` subcommand:
| `--insecure` | Allow plain HTTP and skip TLS verification |
| `--allow-nondistributable-artifacts` | Include non-distributable (foreign) layers when pushing |
| `--platform os/arch[/variant][:osversion]` | Resolve multi-arch indices to a specific platform (image-level commands only) |
| `-u`, `--username` | Registry username (overrides the Docker config; use with `--password`) |
| `-p`, `--password` | Registry password or token (overrides the Docker config; use with `--username`) |

---

Expand Down Expand Up @@ -276,6 +293,22 @@ diff \

A copy-pasteable cheat-sheet of the most common invocations.

### Log in

```bash
# Prompt for username and password
d8 cr login registry.internal

# Non-interactive (e.g. CI)
d8 cr login registry.internal --username robot --password "$TOKEN"

# Log in to Docker Hub (default registry when REGISTRY is omitted)
d8 cr login --username alice --password "$TOKEN"

# Skip login entirely: pass credentials to any command directly
d8 cr pull --username robot --password "$TOKEN" registry.internal/app:v1 ./app.tar
```

### Pull

```bash
Expand Down Expand Up @@ -409,11 +442,11 @@ single-responsibility packages:
| Package | Responsibility |
|---|---|
| `internal/cr/cmd` | Root cobra command, persistent flags, integration tests |
| `internal/cr/cmd/basic` | Top-level subcommands: `pull`, `push`, `export`, `ls`, `catalog`, `manifest`, `config`, `digest` |
| `internal/cr/cmd/basic` | Top-level subcommands: `login`, `pull`, `push`, `export`, `ls`, `catalog`, `manifest`, `config`, `digest` |
| `internal/cr/cmd/fs` | `fs` subtree: `ls`, `cat`, `tree`, `extract` |
| `internal/cr/cmd/completion` | Shell-completion helpers (bounded, fail-silent) |
| `internal/cr/cmd/rootflagnames` | Single source of truth for persistent flag names |
| `internal/cr/internal/registry` | Thin domain layer over `go-containerregistry` (fetch / push / list / options) |
| `internal/cr/internal/registry` | Thin domain layer over `go-containerregistry` (fetch / push / list / login / options) |
| `internal/cr/internal/image` | Pure operations over `v1.Image` / `v1.ImageIndex` (multi-arch resolve, ...) |
| `internal/cr/internal/imageio` | Disk I/O: tarball + OCI image-layout load/save |
| `internal/cr/internal/imagefs` | Merged-filesystem reader with whiteout handling and safe extraction |
Expand Down
138 changes: 138 additions & 0 deletions internal/cr/cmd/basic/login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
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 (
"bufio"
"fmt"
"io"
"os"
"strings"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/deckhouse/deckhouse-cli/internal/cr/cmd/completion"
"github.com/deckhouse/deckhouse-cli/internal/cr/cmd/rootflagnames"
"github.com/deckhouse/deckhouse-cli/internal/cr/internal/registry"
)

const loginLong = `Log in to a container registry.

Verifies the credentials against the registry and, on success, writes them to
the Docker config (~/.docker/config.json, honouring $DOCKER_CONFIG). Every
other "d8 cr" command then authenticates from that config automatically, the
same way "docker login" works.

Credentials come from the --username/--password flags; either may be omitted
to be prompted interactively (the password is read without echo). With no
REGISTRY argument the default Docker Hub registry is used.`

const loginExample = ` # Prompt for username and password
d8 cr login registry.example.com

# Non-interactive (e.g. CI)
d8 cr login registry.example.com --username robot --password "$TOKEN"

# Log in to Docker Hub
d8 cr login --username alice --password "$TOKEN"`

func NewLoginCmd(opts *registry.Options) *cobra.Command {
cmd := &cobra.Command{
Use: "login [REGISTRY]",
Short: "Log in to a container registry",
Long: loginLong,
Example: loginExample,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: completion.RegistryHost(),
RunE: func(cmd *cobra.Command, args []string) error {
var host string
if len(args) == 1 {
host = args[0]
}

username, _ := cmd.Flags().GetString(rootflagnames.Username)
password, _ := cmd.Flags().GetString(rootflagnames.Password)

username, password, err := resolveCredentials(cmd.InOrStdin(), cmd.OutOrStdout(), username, password)
if err != nil {
return err
}

res, err := registry.Login(cmd.Context(), host, username, password, opts)
if err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), "Login succeeded for %s\nCredentials saved in %s\n", res.ServerAddress, res.ConfigFile)

return nil
},
}

return cmd
}

// resolveCredentials fills in any missing username/password by prompting on
// the terminal. The password prompt suppresses echo when stdin is a TTY;
// otherwise (piped input) it reads a plain line so scripts still work.
func resolveCredentials(in io.Reader, out io.Writer, username, password string) (string, string, error) {
reader := bufio.NewReader(in)

if username == "" {
fmt.Fprint(out, "Username: ")

line, err := reader.ReadString('\n')
if err != nil && line == "" {
return "", "", fmt.Errorf("read username: %w", err)
}

username = strings.TrimSpace(line)
if username == "" {
return "", "", fmt.Errorf("username is required")
}
}

if password == "" {
fmt.Fprint(out, "Password: ")

if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
b, err := term.ReadPassword(int(f.Fd()))

fmt.Fprintln(out)

if err != nil {
return "", "", fmt.Errorf("read password: %w", err)
}

password = string(b)
} else {
line, err := reader.ReadString('\n')
if err != nil && line == "" {
return "", "", fmt.Errorf("read password: %w", err)
}

password = strings.TrimRight(line, "\r\n")
}

if password == "" {
return "", "", fmt.Errorf("password is required")
}
}

return username, password, nil
}
4 changes: 3 additions & 1 deletion internal/cr/cmd/cr.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ const (
transfer (pull/push), and browse contents.

Authentication uses the Docker config (~/.docker/config.json) - run
"d8 login" first if the registry requires credentials.
"d8 cr login" (or "docker login") first if the registry requires
credentials, or pass --username/--password to any command directly.
`
)

Expand All @@ -52,6 +53,7 @@ func NewCommand() *cobra.Command {

setupRootFlags(cr, opts)
cr.AddCommand(
basic.NewLoginCmd(opts),
basic.NewPullCmd(opts),
basic.NewPushCmd(opts),
basic.NewExportCmd(opts),
Expand Down
2 changes: 2 additions & 0 deletions internal/cr/cmd/rootflagnames/names.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ const (
Insecure = "insecure"
AllowNondistributable = "allow-nondistributable-artifacts"
Platform = "platform"
Username = "username"
Password = "password"
)
20 changes: 20 additions & 0 deletions internal/cr/cmd/rootflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,17 @@ func setupRootFlags(cmd *cobra.Command, opts *registry.Options) {
insecure bool
ndLayers bool
platform string
username string
password 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)")
flags.StringVarP(&username, rootflagnames.Username, "u", "", "Registry username (overrides the Docker config; use with --password)")
flags.StringVarP(&password, rootflagnames.Password, "p", "", "Registry password or token (overrides the Docker config; use with --username)")
// 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
Expand Down Expand Up @@ -81,6 +85,22 @@ func setupRootFlags(cmd *cobra.Command, opts *registry.Options) {
opts.WithPlatform(p)
}

// Inline credentials win over whatever the Docker keychain would
// resolve, so a one-off `--username/--password` works without a prior
// `cr login`. Both halves are required together here: a lone
// `--password` (or `--username`) would otherwise be silently dropped
// and the command would fall back to the Docker config, masking the
// mistake with a confusing 401. `login` is exempt - it reads the flags
// directly and prompts for whichever half is missing.
if c.Name() != "login" {
switch {
case username != "" && password != "":
opts.WithKeychain(registry.NewStaticKeychain(username, password))
case username != "" || password != "":
return fmt.Errorf("--%s and --%s must be used together", rootflagnames.Username, rootflagnames.Password)
}
}

return nil
}
}
Expand Down
Loading
Loading