feat: add registry:auth-status to read back login state
There was no way to ask whether Dokku was already logged in to a registry with a given credential, so tooling converging a declared state onto a server had to re-run `registry:login` on every pass purely to discover that nothing needed to change. The new `registry:auth-status` command compares a supplied credential against the stored one and reports the result through its exit code alone, distinguishing a server with no credential from one whose credential differs, and from one whose secret a docker credential helper or an identity token puts out of reach. An `auth-servers` triad on `registry:report` exposes which servers have credentials without emitting any part of one.
This commit is contained in:
@@ -4,10 +4,11 @@
|
||||
> New as of 0.25.0
|
||||
|
||||
```
|
||||
registry:login [--global|--password-stdin] [<app>] <server> <username> [<password>] # Login to a docker registry
|
||||
registry:logout [--global] [<app>] <server> # Logout from a docker registry
|
||||
registry:report [<app>] [<flag>] # Displays a registry report for one or more apps
|
||||
registry:set <app>|--global <key> (<value>) # Set or clear a registry property for an app
|
||||
registry:auth-status [--password-stdin] <app>|--global <server> [<username> [<password>]] # Reports whether the stored registry credential matches the requested state
|
||||
registry:login [--global|--password-stdin] [<app>] <server> <username> [<password>] # Login to a docker registry
|
||||
registry:logout [--global] [<app>] <server> # Logout from a docker registry
|
||||
registry:report [<app>] [<flag>] # Displays a registry report for one or more apps
|
||||
registry:set <app>|--global <key> (<value>) # Set or clear a registry property for an app
|
||||
```
|
||||
|
||||
The registry plugin enables interacting with remote registries, which is useful when either deploying images via `git:from-image` or when interacting with custom schedulers to deploy built image artifacts.
|
||||
@@ -85,6 +86,52 @@ dokku registry:logout node-js-app docker.io
|
||||
|
||||
When an app is destroyed, any per-app registry credentials are automatically removed.
|
||||
|
||||
### Checking the configured auth state
|
||||
|
||||
> [!IMPORTANT]
|
||||
> New as of 0.38.29
|
||||
|
||||
The `registry:auth-status` command reports whether a stored registry credential matches a desired state without exposing it. This allows external tooling such as configuration management systems to converge idempotently, rather than re-running `registry:login` on every pass to discover that nothing needed to change.
|
||||
|
||||
```shell
|
||||
# check whether an app is configured with the expected credentials
|
||||
dokku registry:auth-status node-js-app ghcr.io $USERNAME $REGISTRY_PAT_TOKEN
|
||||
|
||||
# check whether the global credentials match
|
||||
dokku registry:auth-status --global docker.io $USERNAME $PASSWORD
|
||||
|
||||
# check whether any credential is configured for a server
|
||||
dokku registry:auth-status node-js-app ghcr.io
|
||||
```
|
||||
|
||||
As with `registry:login`, the password may be provided via `STDIN`. This is the preferred form, as it keeps the password out of the process list:
|
||||
|
||||
```shell
|
||||
echo "$PASSWORD" | dokku registry:auth-status --password-stdin node-js-app ghcr.io $USERNAME
|
||||
```
|
||||
|
||||
The result is reported via the exit code, and nothing is printed when the state could be determined:
|
||||
|
||||
| Exit code | Description |
|
||||
| --------- | ----------- |
|
||||
| `0` | The stored credential matches the requested state. |
|
||||
| `1` | There is no stored credential for the specified server. |
|
||||
| `2` | There is a stored credential for the specified server, but it does not match the requested state. |
|
||||
| `3` | The command was called with invalid arguments and the state could not be checked. |
|
||||
| `4` | A credential is stored for the specified server, but it cannot be compared. |
|
||||
| `20` | The specified app does not exist. |
|
||||
|
||||
The distinction between `1` and `2` allows a caller to tell a server that needs a credential created apart from a server whose existing credential would be replaced, without issuing a second call.
|
||||
|
||||
When called without a username, the requested state is that no credential exists for the server. An existing credential is therefore reported as `2`, and `1` is never returned. This is the check to perform before deciding whether a `registry:logout` is necessary.
|
||||
|
||||
Exit code `4` is returned when a credential exists but Dokku cannot read the secret it holds - either because a docker credential helper is configured for that server and stores the secret outside of `config.json`, or because the registry issued an identity token in place of a password. In both cases a username that differs from the one supplied is still reported as `2`, so `4` means only that an otherwise matching credential could not be confirmed. Callers should treat `4` as "unknown" rather than retrying the login indefinitely.
|
||||
|
||||
Unlike `registry:login`, the `--global` flag is required in order to check the global credentials - the deprecated implicit-global form is not accepted. A per-app check reads only the credentials created by `registry:login <app>` and does not fall back to the global credentials, so an app configured with a credential identical to the global one is still reported as needing one of its own. To see which credentials would actually be used for an app, read the `computed-auth-servers` field of `registry:report`.
|
||||
|
||||
> [!WARNING]
|
||||
> A `0` exit code means the stored credential matches the one supplied, not that it still authenticates against the registry. A password that has been revoked upstream compares equal here and fails at push or pull time.
|
||||
|
||||
### Setting a remote server
|
||||
|
||||
To specify a remote server registry for pushes, set the `server` property via the `registry:set` command. The default value for this property is empty string. Setting the value to `docker.io` or `hub.docker.com` will result in the computed value being empty string (as that is the default, implicit registry), while any non-zero length value will have a `/` appended to it if there is not one already.
|
||||
@@ -241,3 +288,20 @@ dokku registry:set --global push-extra-tags
|
||||
| `push-extra-tags` | app + global | none | `--registry-push-extra-tags`, `--registry-global-push-extra-tags`, `--registry-computed-push-extra-tags` | Comma-separated list of additional tags pushed alongside the deploy tag |
|
||||
| `push-on-release` | app + global | `false` | `--registry-push-on-release`, `--registry-global-push-on-release`, `--registry-computed-push-on-release` | When `true`, pushes the image to the registry on every successful build |
|
||||
| `server` | app + global | none | `--registry-server`, `--registry-global-server`, `--registry-computed-server` | Registry server host (e.g. `ghcr.io`) used when pushing images |
|
||||
|
||||
### Read-only report fields
|
||||
|
||||
> [!IMPORTANT]
|
||||
> New as of 0.38.29
|
||||
|
||||
The following `registry:report` fields describe credentials created by `registry:login` and are not settable via `registry:set`.
|
||||
|
||||
| Report flag | Scope | Description |
|
||||
|---|---|---|
|
||||
| `--registry-auth-servers` | app only | Comma-separated list of servers the app has its own credentials for |
|
||||
| `--registry-global-auth-servers` | app + global | Comma-separated list of servers the global credentials cover |
|
||||
| `--registry-computed-auth-servers` | app + global | Comma-separated list of servers whose credentials would be used for the app |
|
||||
|
||||
Servers are listed under the name they would be passed to `registry:login`, so a Docker Hub credential is reported as `docker.io` rather than the index server address docker stores it under. Only the server names are reported; no part of a credential is ever emitted.
|
||||
|
||||
Docker is pointed at one config directory or the other rather than a merge of the two, so an app with any credential of its own shadows the global credentials entirely. The computed value is therefore the app's own list when it has one and the global list otherwise, rather than a per-server merge of the two.
|
||||
|
||||
@@ -16,6 +16,30 @@ type ErrWithExitCode interface {
|
||||
ExitCode() int
|
||||
}
|
||||
|
||||
// ExitCodeError carries an explicit exit code for a command failure. A blank
|
||||
// Message exits with the code without printing anything, which suits commands
|
||||
// that report their result through the exit code alone.
|
||||
type ExitCodeError struct {
|
||||
// Code is the exit code to terminate with
|
||||
Code int
|
||||
|
||||
// Message is the error text printed to stderr, if any
|
||||
Message string
|
||||
}
|
||||
|
||||
var _ ErrWithExitCode = (*ExitCodeError)(nil)
|
||||
|
||||
// Error returns the error text to print to stderr
|
||||
func (e *ExitCodeError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ExitCode returns an exit code to use in case this error bubbles
|
||||
// up into an os.Exit() call
|
||||
func (e *ExitCodeError) ExitCode() int {
|
||||
return e.Code
|
||||
}
|
||||
|
||||
type writer struct {
|
||||
mu *sync.Mutex
|
||||
source string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
SUBCOMMANDS = subcommands/login subcommands/logout subcommands/report subcommands/set
|
||||
SUBCOMMANDS = subcommands/auth-status subcommands/login subcommands/logout subcommands/report subcommands/set
|
||||
TRIGGERS = triggers/deployed-app-image-repo triggers/deployed-app-image-tag triggers/deployed-app-repository triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-create triggers/post-delete triggers/post-release-builder triggers/report
|
||||
BUILD = commands subcommands triggers
|
||||
PLUGIN_NAME = registry
|
||||
|
||||
219
plugins/registry/auth_status.go
Normal file
219
plugins/registry/auth_status.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// exit codes reported by registry:auth-status. Codes 0 through 3 match the
|
||||
// codes git:auth-status reports for the equivalent netrc states.
|
||||
const (
|
||||
// authStatusMatch means the stored credential matches the requested state
|
||||
authStatusMatch = 0
|
||||
|
||||
// authStatusMissing means no credential is stored for the server
|
||||
authStatusMissing = 1
|
||||
|
||||
// authStatusDiffers means a credential is stored but does not match
|
||||
authStatusDiffers = 2
|
||||
|
||||
// authStatusInvalidArguments means the state could not be checked
|
||||
authStatusInvalidArguments = 3
|
||||
|
||||
// authStatusUnknown means a credential is stored but its secret is unreadable
|
||||
authStatusUnknown = 4
|
||||
)
|
||||
|
||||
// dockerAuthEntry is the subset of a docker config.json auths entry dokku reads
|
||||
type dockerAuthEntry struct {
|
||||
Auth string `json:"auth"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IdentityToken string `json:"identitytoken"`
|
||||
}
|
||||
|
||||
// dockerConfigFile is the subset of a docker config.json dokku reads
|
||||
type dockerConfigFile struct {
|
||||
Auths map[string]dockerAuthEntry `json:"auths"`
|
||||
CredsStore string `json:"credsStore"`
|
||||
CredHelpers map[string]string `json:"credHelpers"`
|
||||
}
|
||||
|
||||
// authStatusInput describes the credential state a caller is asking about
|
||||
type authStatusInput struct {
|
||||
// ConfigBytes is the contents of the docker config.json, nil when absent
|
||||
ConfigBytes []byte
|
||||
|
||||
// Server is the registry server being asked about
|
||||
Server string
|
||||
|
||||
// Username is the expected username, empty to assert no credential exists
|
||||
Username string
|
||||
|
||||
// Password is the expected password
|
||||
Password string
|
||||
}
|
||||
|
||||
// parseDockerConfig unmarshals the subset of a docker config.json dokku reads
|
||||
func parseDockerConfig(b []byte) (dockerConfigFile, error) {
|
||||
var config dockerConfigFile
|
||||
if len(b) == 0 {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(b, &config); err != nil {
|
||||
return config, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// lookupAuthEntry returns the auths entry docker would use for a server. The
|
||||
// exact key is tried first, then every stored key is compared in hostname form,
|
||||
// which is how docker matches credentials written under a legacy key.
|
||||
func lookupAuthEntry(config dockerConfigFile, server string) (dockerAuthEntry, bool) {
|
||||
key := dockerAuthKey(server)
|
||||
if entry, ok := config.Auths[key]; ok {
|
||||
return entry, true
|
||||
}
|
||||
|
||||
// map iteration order is random, so the keys are sorted to keep the answer
|
||||
// stable when more than one of them resolves to the same server
|
||||
storedKeys := make([]string, 0, len(config.Auths))
|
||||
for storedKey := range config.Auths {
|
||||
storedKeys = append(storedKeys, storedKey)
|
||||
}
|
||||
sort.Strings(storedKeys)
|
||||
|
||||
for _, storedKey := range storedKeys {
|
||||
if convertToHostname(storedKey) == key {
|
||||
return config.Auths[storedKey], true
|
||||
}
|
||||
}
|
||||
|
||||
return dockerAuthEntry{}, false
|
||||
}
|
||||
|
||||
// credentialHelperFor returns the credential helper docker would consult for a
|
||||
// server, preferring a server-specific helper over the global store
|
||||
func credentialHelperFor(config dockerConfigFile, server string) string {
|
||||
key := dockerAuthKey(server)
|
||||
if helper, ok := config.CredHelpers[key]; ok && helper != "" {
|
||||
return helper
|
||||
}
|
||||
|
||||
hostname := convertToHostname(normalizeRegistryServer(server))
|
||||
if helper, ok := config.CredHelpers[hostname]; ok && helper != "" {
|
||||
return helper
|
||||
}
|
||||
|
||||
return config.CredsStore
|
||||
}
|
||||
|
||||
// decodeAuthEntry returns the username and password stored in an auths entry.
|
||||
// The secret is unreadable when a helper holds it or the registry issued an
|
||||
// identity token, in which case a username may still be available.
|
||||
func decodeAuthEntry(entry dockerAuthEntry) (string, string, bool) {
|
||||
if entry.Auth == "" {
|
||||
if entry.Username != "" && entry.Password != "" {
|
||||
return entry.Username, entry.Password, true
|
||||
}
|
||||
|
||||
return entry.Username, "", false
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(entry.Auth)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
username, password, found := strings.Cut(string(decoded), ":")
|
||||
if !found {
|
||||
// without a separator there is no username to read either
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return username, "", false
|
||||
}
|
||||
|
||||
return username, password, true
|
||||
}
|
||||
|
||||
// checkAuthStatus reports how a stored docker credential compares to the
|
||||
// requested state. It returns the exit code to terminate with and, when the
|
||||
// credential cannot be compared, the reason to print to stderr.
|
||||
func checkAuthStatus(input authStatusInput) (int, string) {
|
||||
config, err := parseDockerConfig(input.ConfigBytes)
|
||||
if err != nil {
|
||||
return authStatusUnknown, fmt.Sprintf("Unable to parse the docker config for %s: %s", input.Server, err.Error())
|
||||
}
|
||||
|
||||
entry, ok := lookupAuthEntry(config, input.Server)
|
||||
if !ok {
|
||||
if input.Username == "" {
|
||||
return authStatusMatch, ""
|
||||
}
|
||||
|
||||
return authStatusMissing, ""
|
||||
}
|
||||
|
||||
// that a credential exists is the whole answer an empty username asks for,
|
||||
// and needs no access to the secret it holds
|
||||
if input.Username == "" {
|
||||
return authStatusDiffers, ""
|
||||
}
|
||||
|
||||
// a helper keeps the secret outside of the file, leaving nothing to compare
|
||||
// against in the entry docker blanked when it stored the credential
|
||||
if helper := credentialHelperFor(config, input.Server); helper != "" {
|
||||
return authStatusUnknown, fmt.Sprintf("Unable to compare the credential for %s: the %s credential helper holds it", input.Server, helper)
|
||||
}
|
||||
|
||||
username, password, readable := decodeAuthEntry(entry)
|
||||
if readable {
|
||||
if username != input.Username || password != input.Password {
|
||||
return authStatusDiffers, ""
|
||||
}
|
||||
|
||||
return authStatusMatch, ""
|
||||
}
|
||||
|
||||
// a username that differs settles the comparison without the secret, so
|
||||
// only an otherwise matching credential is left indeterminate
|
||||
if username != "" && username != input.Username {
|
||||
return authStatusDiffers, ""
|
||||
}
|
||||
|
||||
if entry.IdentityToken != "" {
|
||||
return authStatusUnknown, fmt.Sprintf("Unable to compare the credential for %s: it is stored as an identity token", input.Server)
|
||||
}
|
||||
|
||||
return authStatusUnknown, fmt.Sprintf("Unable to compare the credential for %s: no password is stored for it", input.Server)
|
||||
}
|
||||
|
||||
// authServersFromConfig returns the sorted registry servers a docker config
|
||||
// holds credentials for, named as they would be passed to registry:login
|
||||
func authServersFromConfig(config dockerConfigFile) []string {
|
||||
servers := []string{}
|
||||
seen := map[string]bool{}
|
||||
for key := range config.Auths {
|
||||
server := convertToHostname(key)
|
||||
if dockerAuthKey(key) == dockerIndexServer {
|
||||
server = "docker.io"
|
||||
}
|
||||
|
||||
if server == "" || seen[server] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[server] = true
|
||||
servers = append(servers, server)
|
||||
}
|
||||
|
||||
sort.Strings(servers)
|
||||
return servers
|
||||
}
|
||||
138
plugins/registry/auth_status_test.go
Normal file
138
plugins/registry/auth_status_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func encodeAuth(value string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
|
||||
func hubConfig() string {
|
||||
return fmt.Sprintf(`{"auths":{"https://index.docker.io/v1/":{"auth":%q}}}`, encodeAuth("user:pass"))
|
||||
}
|
||||
|
||||
func TestCheckAuthStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config string
|
||||
server string
|
||||
username string
|
||||
password string
|
||||
want int
|
||||
}{
|
||||
{"no config with credential", "", "docker.io", "user", "pass", authStatusMissing},
|
||||
{"no config without credential", "", "docker.io", "", "", authStatusMatch},
|
||||
{"empty auths after logout", `{"auths":{}}`, "docker.io", "user", "pass", authStatusMissing},
|
||||
{"empty auths without credential", `{"auths":{}}`, "docker.io", "", "", authStatusMatch},
|
||||
|
||||
{"hub via docker.io", hubConfig(), "docker.io", "user", "pass", authStatusMatch},
|
||||
{"hub via hub.docker.com", hubConfig(), "hub.docker.com", "user", "pass", authStatusMatch},
|
||||
{"hub via docker.com", hubConfig(), "docker.com", "user", "pass", authStatusMatch},
|
||||
{"hub via index.docker.io", hubConfig(), "index.docker.io", "user", "pass", authStatusMatch},
|
||||
{"hub via index server address", hubConfig(), dockerIndexServer, "user", "pass", authStatusMatch},
|
||||
// docker stores registry-1.docker.io under its own key, so a hub
|
||||
// credential must not be reported as covering it
|
||||
{"registry-1.docker.io is not hub", hubConfig(), "registry-1.docker.io", "user", "pass", authStatusMissing},
|
||||
{"wrong password", hubConfig(), "docker.io", "user", "wrong", authStatusDiffers},
|
||||
{"wrong username", hubConfig(), "docker.io", "other", "pass", authStatusDiffers},
|
||||
{"no username with credential stored", hubConfig(), "docker.io", "", "", authStatusDiffers},
|
||||
{"unrelated server", hubConfig(), "ghcr.io", "user", "pass", authStatusMissing},
|
||||
|
||||
{"other registry exact key", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusMatch},
|
||||
{"other registry legacy key", fmt.Sprintf(`{"auths":{"https://ghcr.io/v1/":{"auth":%q}}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusMatch},
|
||||
{"registry with port", fmt.Sprintf(`{"auths":{"localhost:5000":{"auth":%q}}}`, encodeAuth("user:pass")), "localhost:5000", "user", "pass", authStatusMatch},
|
||||
{"password containing a colon", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}}}`, encodeAuth("user:pa:ss")), "ghcr.io", "user", "pa:ss", authStatusMatch},
|
||||
{"plaintext username and password fields", `{"auths":{"ghcr.io":{"username":"user","password":"pass"}}}`, "ghcr.io", "user", "pass", authStatusMatch},
|
||||
|
||||
{"identity token with matching username", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q,"identitytoken":"token"}}}`, encodeAuth("user:")), "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
{"identity token with differing username", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q,"identitytoken":"token"}}}`, encodeAuth("user:")), "ghcr.io", "other", "pass", authStatusDiffers},
|
||||
{"identity token without credential", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q,"identitytoken":"token"}}}`, encodeAuth("user:")), "ghcr.io", "", "", authStatusDiffers},
|
||||
|
||||
{"credsStore holds the secret", `{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}`, "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
{"credsStore without credential", `{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}`, "ghcr.io", "", "", authStatusDiffers},
|
||||
{"credHelpers for this server", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}},"credHelpers":{"ghcr.io":"ecr-login"}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
{"credHelpers for another server", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}},"credHelpers":{"other.io":"ecr-login"}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusMatch},
|
||||
{"credHelpers keyed by hub index server", `{"auths":{"https://index.docker.io/v1/":{}},"credHelpers":{"https://index.docker.io/v1/":"desktop"}}`, "docker.io", "user", "pass", authStatusUnknown},
|
||||
|
||||
{"empty entry", `{"auths":{"ghcr.io":{}}}`, "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
{"undecodable auth", `{"auths":{"ghcr.io":{"auth":"!!!not base64!!!"}}}`, "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
{"auth without a separator", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}}}`, encodeAuth("nocolon")), "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
{"invalid json", `{`, "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
{"auths is not an object", `{"auths":"nope"}`, "ghcr.io", "user", "pass", authStatusUnknown},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var contents []byte
|
||||
if tc.config != "" {
|
||||
contents = []byte(tc.config)
|
||||
}
|
||||
|
||||
got, message := checkAuthStatus(authStatusInput{
|
||||
ConfigBytes: contents,
|
||||
Server: tc.server,
|
||||
Username: tc.username,
|
||||
Password: tc.password,
|
||||
})
|
||||
if got != tc.want {
|
||||
t.Errorf("checkAuthStatus() = %d, want %d", got, tc.want)
|
||||
}
|
||||
if got == authStatusUnknown && message == "" {
|
||||
t.Error("checkAuthStatus() returned no reason for an unreadable credential")
|
||||
}
|
||||
if got != authStatusUnknown && message != "" {
|
||||
t.Errorf("checkAuthStatus() returned an unexpected message: %s", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAuthStatusDoesNotLeakCredentials(t *testing.T) {
|
||||
_, message := checkAuthStatus(authStatusInput{
|
||||
ConfigBytes: []byte(`{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}`),
|
||||
Server: "ghcr.io",
|
||||
Username: "user",
|
||||
Password: "supersecret",
|
||||
})
|
||||
if message == "" {
|
||||
t.Fatal("expected a reason to be reported")
|
||||
}
|
||||
for _, secret := range []string{"supersecret", "user"} {
|
||||
if strings.Contains(message, secret) {
|
||||
t.Errorf("message %q leaks %q", message, secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServersFromConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config string
|
||||
want []string
|
||||
}{
|
||||
{"no config", "", []string{}},
|
||||
{"no credentials", `{"auths":{}}`, []string{}},
|
||||
{"hub rendered as docker.io", hubConfig(), []string{"docker.io"}},
|
||||
{"sorted across registries", `{"auths":{"quay.io":{},"ghcr.io":{},"https://index.docker.io/v1/":{}}}`, []string{"docker.io", "ghcr.io", "quay.io"}},
|
||||
{"legacy keys reduced to hostnames", `{"auths":{"https://ghcr.io/v1/":{}}}`, []string{"ghcr.io"}},
|
||||
{"duplicate hub keys collapsed", `{"auths":{"https://index.docker.io/v1/":{},"docker.io":{}}}`, []string{"docker.io"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
config, err := parseDockerConfig([]byte(tc.config))
|
||||
if err != nil {
|
||||
t.Fatalf("parseDockerConfig() errored: %s", err)
|
||||
}
|
||||
|
||||
if got := authServersFromConfig(config); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("authServersFromConfig() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -58,13 +57,12 @@ func HasAppRegistryAuth(appName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(content, &config); err != nil {
|
||||
config, err := parseDockerConfig(content)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
auths, ok := config["auths"].(map[string]interface{})
|
||||
return ok && len(auths) > 0
|
||||
return len(config.Auths) > 0
|
||||
}
|
||||
|
||||
// GetDockerConfigArgs returns docker --config arguments if per-app config exists
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dokku/dokku/plugins/common"
|
||||
@@ -17,6 +19,8 @@ func ReportSingleApp(appName string, format string, infoFlag string) error {
|
||||
var flags map[string]common.ReportFunc
|
||||
if appName == "--global" {
|
||||
flags = map[string]common.ReportFunc{
|
||||
"--registry-computed-auth-servers": reportComputedAuthServers,
|
||||
"--registry-global-auth-servers": reportGlobalAuthServers,
|
||||
"--registry-computed-push-on-release": reportComputedPushOnRelease,
|
||||
"--registry-global-push-on-release": reportGlobalPushOnRelease,
|
||||
"--registry-computed-server": reportComputedServer,
|
||||
@@ -28,6 +32,9 @@ func ReportSingleApp(appName string, format string, infoFlag string) error {
|
||||
}
|
||||
} else {
|
||||
flags = map[string]common.ReportFunc{
|
||||
"--registry-auth-servers": reportAuthServers,
|
||||
"--registry-computed-auth-servers": reportComputedAuthServers,
|
||||
"--registry-global-auth-servers": reportGlobalAuthServers,
|
||||
"--registry-computed-image-repo": reportComputedImageRepo,
|
||||
"--registry-image-repo": reportImageRepo,
|
||||
"--registry-computed-push-on-release": reportComputedPushOnRelease,
|
||||
@@ -156,3 +163,36 @@ func reportComputedPushExtraTags(appName string) string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// reportAuthServers lists the servers the app has its own credentials for
|
||||
func reportAuthServers(appName string) string {
|
||||
return authServersFromConfigPath(GetAppRegistryConfigPath(appName))
|
||||
}
|
||||
|
||||
// reportGlobalAuthServers lists the servers the global config has credentials for
|
||||
func reportGlobalAuthServers(appName string) string {
|
||||
return authServersFromConfigPath(GetGlobalRegistryConfigPath())
|
||||
}
|
||||
|
||||
// reportComputedAuthServers lists the servers whose credentials dokku would use
|
||||
// for the app. Docker is pointed at one config directory or the other rather
|
||||
// than a merge of both, so an app with any credential of its own shadows the
|
||||
// global config entirely.
|
||||
func reportComputedAuthServers(appName string) string {
|
||||
return authServersFromConfigPath(filepath.Join(GetComputedAppRegistryConfigDir(appName), "config.json"))
|
||||
}
|
||||
|
||||
// authServersFromConfigPath lists the servers a docker config holds credentials for
|
||||
func authServersFromConfigPath(configPath string) string {
|
||||
contents, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
config, err := parseDockerConfig(contents)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Join(authServersFromConfig(config), ",")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ Manage registry settings for an app
|
||||
Additional commands:`
|
||||
|
||||
helpContent = `
|
||||
registry:auth-status [--password-stdin] <app>|--global <server> [<username> [<password>]], Reports whether the stored registry credential matches the requested state (exit 0 if matches)
|
||||
registry:login [--global|--password-stdin] [<app>] <server> <username> [<password>], Login to a docker registry
|
||||
registry:logout [--global] [<app>] <server>, Logout from a docker registry
|
||||
registry:report [<app>] [<flag>], Displays a registry report for one or more apps
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -18,6 +20,40 @@ func main() {
|
||||
|
||||
var err error
|
||||
switch subcommand {
|
||||
case "auth-status":
|
||||
// the result of this command is its exit code, so parse errors must not
|
||||
// be left to pflag: ExitOnError exits 2 on an unknown flag, which this
|
||||
// command defines as a differing credential, and 0 on --help
|
||||
args := flag.NewFlagSet("registry:auth-status", flag.ContinueOnError)
|
||||
args.SetOutput(io.Discard)
|
||||
passwordStdin := args.Bool("password-stdin", false, "--password-stdin: read password from stdin")
|
||||
global := args.Bool("global", false, "--global: check the global registry credentials")
|
||||
if parseErr := args.Parse(os.Args[2:]); parseErr != nil {
|
||||
message := parseErr.Error()
|
||||
if errors.Is(parseErr, flag.ErrHelp) {
|
||||
message = "Usage: dokku registry:auth-status [--password-stdin] <app>|--global <server> [<username> [<password>]]"
|
||||
}
|
||||
|
||||
common.LogFailWithError(registry.InvalidAuthStatusArguments(message))
|
||||
}
|
||||
|
||||
var appName, server, username, password string
|
||||
offset := 0
|
||||
if !*global {
|
||||
appName = args.Arg(0)
|
||||
offset = 1
|
||||
if appName == "" {
|
||||
common.LogFailWithError(registry.InvalidAuthStatusArguments("Please specify an app or the --global flag"))
|
||||
}
|
||||
}
|
||||
|
||||
server = args.Arg(offset)
|
||||
username = args.Arg(offset + 1)
|
||||
if !*passwordStdin {
|
||||
password = args.Arg(offset + 2)
|
||||
}
|
||||
|
||||
err = registry.CommandAuthStatus(appName, server, username, password, *passwordStdin)
|
||||
case "login":
|
||||
args := flag.NewFlagSet("registry:login", flag.ExitOnError)
|
||||
passwordStdin := args.Bool("password-stdin", false, "--password-stdin: read password from stdin")
|
||||
|
||||
@@ -11,15 +11,92 @@ import (
|
||||
"github.com/dokku/dokku/plugins/common"
|
||||
)
|
||||
|
||||
// readPasswordFromStdin reads a registry password from stdin
|
||||
func readPasswordFromStdin() (string, error) {
|
||||
stdin, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(stdin)), nil
|
||||
}
|
||||
|
||||
// CommandAuthStatus reports whether the stored registry credential matches the
|
||||
// requested state, communicating the result via the exit code alone
|
||||
func CommandAuthStatus(appName string, server string, username string, password string, passwordStdin bool) error {
|
||||
if passwordStdin {
|
||||
if username == "" {
|
||||
return InvalidAuthStatusArguments("Missing username argument")
|
||||
}
|
||||
|
||||
stdin, err := readPasswordFromStdin()
|
||||
if err != nil {
|
||||
return InvalidAuthStatusArguments(err.Error())
|
||||
}
|
||||
|
||||
password = stdin
|
||||
}
|
||||
|
||||
if server == "" {
|
||||
return InvalidAuthStatusArguments("Missing server argument")
|
||||
}
|
||||
if username != "" && password == "" {
|
||||
return InvalidAuthStatusArguments("Missing password argument")
|
||||
}
|
||||
if username == "" && password != "" {
|
||||
return InvalidAuthStatusArguments("Missing username argument")
|
||||
}
|
||||
|
||||
configPath := GetGlobalRegistryConfigPath()
|
||||
if appName != "" {
|
||||
if err := common.VerifyAppName(appName); err != nil {
|
||||
// a missing app carries its own exit code, while an invalid name is
|
||||
// a malformed call and must not be reported as a missing credential
|
||||
if _, ok := err.(common.ErrWithExitCode); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return InvalidAuthStatusArguments(err.Error())
|
||||
}
|
||||
|
||||
configPath = GetAppRegistryConfigPath(appName)
|
||||
}
|
||||
|
||||
contents, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return &common.ExitCodeError{
|
||||
Code: authStatusUnknown,
|
||||
Message: fmt.Sprintf("Unable to read the docker config: %s", err.Error()),
|
||||
}
|
||||
}
|
||||
|
||||
code, message := checkAuthStatus(authStatusInput{
|
||||
ConfigBytes: contents,
|
||||
Server: server,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
if code == authStatusMatch {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &common.ExitCodeError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// InvalidAuthStatusArguments reports a registry:auth-status call that could not be checked
|
||||
func InvalidAuthStatusArguments(message string) error {
|
||||
return &common.ExitCodeError{Code: authStatusInvalidArguments, Message: message}
|
||||
}
|
||||
|
||||
// CommandLogin logs a user into the specified server
|
||||
func CommandLogin(appName string, server string, username string, password string, passwordStdin bool) error {
|
||||
if passwordStdin {
|
||||
stdin, err := io.ReadAll(os.Stdin)
|
||||
stdin, err := readPasswordFromStdin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password = strings.TrimSpace(string(stdin))
|
||||
password = stdin
|
||||
}
|
||||
|
||||
if server == "" {
|
||||
|
||||
@@ -120,6 +120,49 @@ teardown() {
|
||||
assert_output_not_contains "index.docker.io"
|
||||
}
|
||||
|
||||
@test "(registry:auth-status) matches a credential written by registry:login" {
|
||||
if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then
|
||||
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
|
||||
fi
|
||||
|
||||
run /bin/bash -c "dokku registry:login --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 0
|
||||
assert_output ""
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status --global docker.io $DOCKERHUB_USERNAME wrong-password"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 2
|
||||
|
||||
run /bin/bash -c "dokku registry:report --global --format json | jq -r '.\"global-auth-servers\"'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "docker.io"
|
||||
|
||||
run /bin/bash -c "dokku registry:logout --global docker.io"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 1
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status --global docker.io"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 0
|
||||
}
|
||||
|
||||
@test "(registry) per-app credentials deleted on app destroy" {
|
||||
if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then
|
||||
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
|
||||
@@ -579,6 +622,13 @@ teardown() {
|
||||
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
|
||||
fi
|
||||
|
||||
# this test pushes to the registry, so it establishes its own credentials
|
||||
# rather than relying on a login left behind by an earlier test
|
||||
run /bin/bash -c "dokku registry:login --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "dokku registry:set $TEST_APP push-on-release true"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
@@ -708,6 +758,13 @@ teardown() {
|
||||
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
|
||||
fi
|
||||
|
||||
# this test pushes to the registry, so it establishes its own credentials
|
||||
# rather than relying on a login left behind by an earlier test
|
||||
run /bin/bash -c "dokku registry:login --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "dokku registry:set $TEST_APP push-on-release true"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
@@ -755,3 +812,214 @@ teardown() {
|
||||
assert_success
|
||||
assert_output "true"
|
||||
}
|
||||
|
||||
@test "(registry:auth-status) validates arguments" {
|
||||
run /bin/bash -c "dokku registry:auth-status"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 3
|
||||
assert_output_contains "Please specify an app or the --global flag"
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status --global"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 3
|
||||
assert_output_contains "Missing server argument"
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 3
|
||||
assert_output_contains "Missing server argument"
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io username"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 3
|
||||
assert_output_contains "Missing password argument"
|
||||
|
||||
# an unknown flag must not be reported as a differing credential, which is
|
||||
# what pflag would exit with if it handled the error itself
|
||||
run /bin/bash -c "dokku registry:auth-status --nonsense --global docker.io"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 3
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status --help"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 3
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status nonexistent-app-name docker.io username password"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 20
|
||||
|
||||
# a malformed app name must not be reported as a missing credential
|
||||
run /bin/bash -c "dokku registry:auth-status 'BAD NAME' docker.io username password"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 3
|
||||
}
|
||||
|
||||
@test "(registry:auth-status) reports per-app credential state" {
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io fakeuser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 1
|
||||
assert_output ""
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 0
|
||||
assert_output ""
|
||||
|
||||
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"}}}"
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io fakeuser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 0
|
||||
assert_output ""
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io fakeuser wrongpass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 2
|
||||
assert_output ""
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io otheruser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 2
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 2
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io fakeuser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 1
|
||||
|
||||
run /bin/bash -c "printf 'fakepass' | dokku registry:auth-status --password-stdin $TEST_APP docker.io fakeuser"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 0
|
||||
|
||||
run /bin/bash -c "printf 'wrongpass' | dokku registry:auth-status --password-stdin $TEST_APP docker.io fakeuser"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 2
|
||||
|
||||
run /bin/bash -c "dokku --app $TEST_APP registry:auth-status docker.io fakeuser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 0
|
||||
}
|
||||
|
||||
@test "(registry:auth-status) normalizes server names and reports unreadable credentials" {
|
||||
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"}}}"
|
||||
|
||||
for server in docker.io hub.docker.com docker.com index.docker.io; do
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP $server fakeuser fakepass"
|
||||
echo "server: $server"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 0
|
||||
done
|
||||
|
||||
# docker stores registry-1.docker.io under a key of its own rather than the
|
||||
# index server, so a docker hub credential does not cover it
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP registry-1.docker.io fakeuser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 1
|
||||
|
||||
write_registry_auth_config "$TEST_APP" '{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}'
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io fakeuser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 4
|
||||
assert_output_contains "credential helper"
|
||||
assert_output_not_contains "fakepass"
|
||||
|
||||
# knowing a credential exists needs no access to the secret
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 2
|
||||
|
||||
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"ghcr.io\":{\"auth\":\"$(fake_registry_auth fakeuser '')\",\"identitytoken\":\"token\"}}}"
|
||||
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io fakeuser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 4
|
||||
assert_output_contains "identity token"
|
||||
|
||||
# a username that differs settles the comparison without reading the secret
|
||||
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io otheruser fakepass"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_exit_status 2
|
||||
}
|
||||
|
||||
@test "(registry:report) auth-servers raw vs computed vs global" {
|
||||
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"auth-servers\"'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output ""
|
||||
|
||||
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"computed-auth-servers\" == .\"global-auth-servers\"'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "true"
|
||||
|
||||
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"},\"ghcr.io\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"}}}"
|
||||
|
||||
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"auth-servers\"'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "docker.io,ghcr.io"
|
||||
|
||||
# a per-app config shadows the global one entirely rather than merging
|
||||
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"computed-auth-servers\"'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "docker.io,ghcr.io"
|
||||
|
||||
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r 'has(\"auth-servers\") and has(\"registry-auth-servers\")'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "true"
|
||||
|
||||
run /bin/bash -c "dokku registry:report --global --format json | jq -r 'has(\"global-auth-servers\") and has(\"computed-auth-servers\")'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "true"
|
||||
}
|
||||
|
||||
fake_registry_auth() {
|
||||
declare desc="base64 encodes a username and password as docker stores them"
|
||||
declare USERNAME="$1" PASSWORD="$2"
|
||||
printf '%s:%s' "$USERNAME" "$PASSWORD" | base64 -w0
|
||||
}
|
||||
|
||||
write_registry_auth_config() {
|
||||
declare desc="writes a docker config.json for an app without contacting a registry"
|
||||
declare APP="$1" CONTENTS="$2"
|
||||
mkdir -p "/var/lib/dokku/config/registry/$APP"
|
||||
echo "$CONTENTS" >"/var/lib/dokku/config/registry/$APP/config.json"
|
||||
chown -R dokku:dokku "/var/lib/dokku/config/registry/$APP"
|
||||
chmod 600 "/var/lib/dokku/config/registry/$APP/config.json"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user