diff --git a/README.md b/README.md index 6150d0f..d1c584b 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,15 @@ Commands can use `server` or `servers` interchangeably. - `coolify server destinations list ` - List a server's destinations - `coolify server destinations create ` - Create a server destination - `coolify server hetzner|digitalocean|vultr` - List provider options and provision a server +- `coolify server registry list ` - List the Docker registries a server is logged in to and which resources use them (requires a token with `read:sensitive`; usernames are hidden in table output unless `-s` is given) +- `coolify server registry login [...]` - Run `docker login` on one or more servers + - `--registry ` - Registry host, optionally with a port, e.g. `ghcr.io`, `registry.example.com:5000`, `docker.io` (required) + - `-u, --username ` - Registry username (required) + - `--password-stdin` - Read the password or access token from stdin. Without it, the password is prompted without echo in a terminal. There is no flag for the password itself + - Example: `echo "$GHCR_TOKEN" | coolify server registry login --registry ghcr.io -u octocat --password-stdin` +- `coolify server registry check ` - Check that the saved login still works +- `coolify server registry logout ` - Run `docker logout` on the server + - `-f, --force` - Skip confirmation prompt ### Projects - `coolify projects list` - List all projects diff --git a/cmd/server/registry.go b/cmd/server/registry.go new file mode 100644 index 0000000..4db8b5a --- /dev/null +++ b/cmd/server/registry.go @@ -0,0 +1,310 @@ +package server + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/coollabsio/coolify-cli/internal/cli" + "github.com/coollabsio/coolify-cli/internal/models" + "github.com/coollabsio/coolify-cli/internal/output" + "github.com/coollabsio/coolify-cli/internal/service" +) + +// maxRegistryPasswordBytes matches the API limit for the password field. +const maxRegistryPasswordBytes = 20000 + +// terminalFD returns the file descriptor of in when it is an interactive terminal. +// It is a variable so tests can simulate a TTY. +var terminalFD = func(in io.Reader) (int, bool) { + file, ok := in.(*os.File) + if !ok { + return 0, false + } + fd := int(file.Fd()) + return fd, term.IsTerminal(fd) +} + +// readHiddenPassword reads a password from a terminal without echo. +// It is a variable so tests can simulate a TTY. +var readHiddenPassword = term.ReadPassword + +func newRegistrySvc(cmd *cobra.Command) (*service.ServerRegistryService, error) { + client, err := cli.GetAPIClient(cmd) + if err != nil { + return nil, fmt.Errorf("failed to get API client: %w", err) + } + return service.NewServerRegistryService(client), nil +} + +// NewRegistryCommand creates the server registry parent command. +func NewRegistryCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "registry", + Aliases: []string{"registries"}, + Short: "Manage Docker registry logins on a server", + Long: `Manage the Docker registry logins of a server. + +Logins are stored in the Docker config of the server that deployments use, +so applications, databases and services on the server can pull private images +and build servers can push them.`, + } + + cmd.AddCommand(newRegistryListCommand()) + cmd.AddCommand(newRegistryLoginCommand()) + cmd.AddCommand(newRegistryCheckCommand()) + cmd.AddCommand(newRegistryLogoutCommand()) + + return cmd +} + +func newRegistryListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List registry logins of a server", + Long: `List the Docker registries a server is logged in to and the registries its resources pull from or push to. + +Credentials are never returned. Usernames are hidden in table output unless -s is given. +Requires an API token with the read:sensitive ability.`, + Args: cli.ExactArgs(1, ""), + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := newRegistrySvc(cmd) + if err != nil { + return err + } + + result, err := svc.List(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("failed to list registry logins: %w", err) + } + + if result.Error != nil && *result.Error != "" { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", *result.Error) + } + + format, _ := cmd.Flags().GetString("format") + showSensitive, _ := cmd.Flags().GetBool("show-sensitive") + + formatter, err := output.NewFormatter(format, output.Options{ + Writer: cmd.OutOrStdout(), + ShowSensitive: showSensitive, + }) + if err != nil { + return err + } + + if format != output.FormatTable { + return formatter.Format(result) + } + + rows := service.ServerRegistryRows(result.Registries, showSensitive, cli.SensitiveInformationOverlay) + if err := formatter.Format(rows); err != nil { + return err + } + + if !showSensitive && hasMaskedUsername(rows) { + fmt.Fprintln(cmd.OutOrStdout(), "\nNote: Use -s to show usernames.") + } + return nil + }, + } +} + +func hasMaskedUsername(rows []models.ServerRegistryRow) bool { + for _, row := range rows { + if row.Username == cli.SensitiveInformationOverlay { + return true + } + } + return false +} + +func newRegistryLoginCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "login [...]", + Short: "Log in to a Docker registry on one or more servers", + Long: `Run docker login on the server, writing to the Docker config that deployments use. +Logging in again to the same registry replaces the saved login. + +The password or access token is never accepted as a flag value. Pipe it with +--password-stdin, or enter it at the hidden prompt when running in a terminal.`, + Example: ` echo "$GHCR_TOKEN" | coolify server registry login --registry ghcr.io --username octocat --password-stdin + coolify server registry login --registry registry.example.com:5000 --username deploy + cat token.txt | coolify server registry login --registry docker.io --username me --password-stdin`, + Args: cli.MinArgs(1, ""), + RunE: func(cmd *cobra.Command, args []string) error { + registry, _ := cmd.Flags().GetString("registry") + username, _ := cmd.Flags().GetString("username") + passwordStdin, _ := cmd.Flags().GetBool("password-stdin") + + registry = service.NormalizeRegistry(registry) + if registry == "" { + return errors.New("--registry is required, for example ghcr.io or registry.example.com:5000") + } + username = strings.TrimSpace(username) + if username == "" { + return errors.New("--username is required") + } + + password, err := readRegistryPassword(cmd, passwordStdin) + if err != nil { + return err + } + + svc, err := newRegistrySvc(cmd) + if err != nil { + return err + } + + req := models.ServerRegistryLoginRequest{Registry: registry, Username: username, Password: password} + + if len(args) == 1 { + resp, err := svc.Login(cmd.Context(), args[0], req) + if err != nil { + return fmt.Errorf("failed to log in to %s: %w", registry, err) + } + fmt.Fprintln(cmd.OutOrStdout(), loginMessage(resp, registry)) + return nil + } + + failed := 0 + for _, serverUUID := range args { + resp, err := svc.Login(cmd.Context(), serverUUID, req) + if err != nil { + failed++ + fmt.Fprintf(cmd.ErrOrStderr(), "%s: failed to log in to %s: %v\n", serverUUID, registry, err) + continue + } + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", serverUUID, loginMessage(resp, registry)) + } + if failed > 0 { + return fmt.Errorf("login to %s failed on %d of %d servers", registry, failed, len(args)) + } + return nil + }, + } + + cmd.Flags().String("registry", "", "Registry host, optionally with a port (e.g. ghcr.io, registry.example.com:5000, docker.io for Docker Hub) (required)") + cmd.Flags().StringP("username", "u", "", "Registry username (required)") + cmd.Flags().Bool("password-stdin", false, "Read the password or access token from stdin") + + return cmd +} + +func loginMessage(resp *models.Response, registry string) string { + if resp != nil && resp.Message != "" { + return resp.Message + } + return fmt.Sprintf("Logged in to %s.", registry) +} + +// readRegistryPassword reads the registry password from stdin when fromStdin is set, +// otherwise from a hidden prompt when stdin is a terminal. +func readRegistryPassword(cmd *cobra.Command, fromStdin bool) (string, error) { + in := cmd.InOrStdin() + + var password string + if fromStdin { + data, err := io.ReadAll(io.LimitReader(in, maxRegistryPasswordBytes+2)) + if err != nil { + return "", fmt.Errorf("failed to read password from stdin: %w", err) + } + // Like docker login --password-stdin: drop one trailing newline only. + password = strings.TrimSuffix(string(data), "\n") + password = strings.TrimSuffix(password, "\r") + } else { + fd, ok := terminalFD(in) + if !ok { + return "", errors.New("no password given: pipe the password or access token with --password-stdin (the password cannot be passed as a flag)") + } + fmt.Fprint(cmd.ErrOrStderr(), "Password: ") + data, err := readHiddenPassword(fd) + fmt.Fprintln(cmd.ErrOrStderr()) + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + password = string(data) + } + + if password == "" { + return "", errors.New("password must not be empty") + } + if len(password) > maxRegistryPasswordBytes { + return "", fmt.Errorf("password must not be longer than %d characters", maxRegistryPasswordBytes) + } + return password, nil +} + +func newRegistryCheckCommand() *cobra.Command { + return &cobra.Command{ + Use: "check ", + Short: "Check that the saved registry login of a server still works", + Args: cli.ExactArgs(2, " "), + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := newRegistrySvc(cmd) + if err != nil { + return err + } + + registry := service.NormalizeRegistry(args[1]) + resp, err := svc.Check(cmd.Context(), args[0], registry) + if err != nil { + return fmt.Errorf("failed to check login for %s: %w", registry, err) + } + + message := resp.Message + if message == "" { + message = fmt.Sprintf("The login for %s works.", registry) + } + fmt.Fprintln(cmd.OutOrStdout(), message) + return nil + }, + } +} + +func newRegistryLogoutCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout ", + Short: "Log out from a Docker registry on a server", + Long: `Run docker logout on the server for a registry. +Resources on the server that pull private images from this registry will fail to deploy until you log in again.`, + Args: cli.ExactArgs(2, " "), + RunE: func(cmd *cobra.Command, args []string) error { + serverUUID := args[0] + registry := service.NormalizeRegistry(args[1]) + + force, _ := cmd.Flags().GetBool("force") + if !force { + var response string + fmt.Fprintf(cmd.OutOrStdout(), "Are you sure you want to log out from %s on server %s? (yes/no): ", registry, serverUUID) + if _, err := fmt.Fscanln(cmd.InOrStdin(), &response); err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if response != "yes" && response != "y" { + fmt.Fprintln(cmd.OutOrStdout(), "Logout cancelled.") + return nil + } + } + + svc, err := newRegistrySvc(cmd) + if err != nil { + return err + } + + if err := svc.Logout(cmd.Context(), serverUUID, registry); err != nil { + return fmt.Errorf("failed to log out from %s: %w", registry, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Logged out from %s on server %s.\n", registry, serverUUID) + return nil + }, + } + + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + return cmd +} diff --git a/cmd/server/registry_test.go b/cmd/server/registry_test.go new file mode 100644 index 0000000..c5c73b1 --- /dev/null +++ b/cmd/server/registry_test.go @@ -0,0 +1,456 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type recordedRequest struct { + Method string + RequestURI string + Body string +} + +type registryTestAPI struct { + mu sync.Mutex + requests []recordedRequest + server *httptest.Server +} + +func (a *registryTestAPI) recorded() []recordedRequest { + a.mu.Lock() + defer a.mu.Unlock() + return append([]recordedRequest(nil), a.requests...) +} + +// newRegistryTestAPI starts a fake Coolify API and points a temporary CLI config at it. +func newRegistryTestAPI(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *registryTestAPI { + t.Helper() + fake := ®istryTestAPI{} + fake.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + fake.mu.Lock() + fake.requests = append(fake.requests, recordedRequest{Method: r.Method, RequestURI: r.RequestURI, Body: string(body)}) + fake.mu.Unlock() + r.Body = io.NopCloser(bytes.NewReader(body)) + handler(w, r) + })) + t.Cleanup(fake.server.Close) + + home := t.TempDir() + t.Setenv("HOME", home) + configDir := filepath.Join(home, ".config", "coolify") + require.NoError(t, os.MkdirAll(configDir, 0o750)) + cfg := map[string]any{ + "instances": []map[string]any{{ + "name": "test", "fqdn": fake.server.URL, "token": "test-token", "default": true, + }}, + } + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.json"), data, 0o600)) + + return fake +} + +// runRegistryCommand runs `coolify server registry ` against the fake API. +func runRegistryCommand(t *testing.T, stdin string, args ...string) (string, string, error) { + t.Helper() + root := &cobra.Command{Use: "coolify", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().String("token", "", "") + root.PersistentFlags().String("context", "", "") + root.PersistentFlags().String("format", "table", "") + root.PersistentFlags().BoolP("show-sensitive", "s", false, "") + root.PersistentFlags().Bool("debug", false, "") + root.AddCommand(NewServerCommand()) + + var stdout, stderr bytes.Buffer + root.SetIn(strings.NewReader(stdin)) + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(append([]string{"server", "registry"}, args...)) + + err := root.ExecuteContext(context.Background()) + return stdout.String(), stderr.String(), err +} + +const registryListFixture = `{ + "registries": [ + {"registry": "ghcr.io", "logged_in": true, "source": "auths", "username": "octocat", "used_by": []}, + {"registry": "gcr.io", "logged_in": true, "source": "credHelpers", "username": null, "used_by": [ + {"type": "Service", "name": "svc", "link": null} + ]}, + {"registry": "registry.example.com:5000", "logged_in": false, "source": null, "username": null, "used_by": [ + {"type": "Application", "name": "app-1", "link": "https://coolify.test/a1"}, + {"type": "Application", "name": "app-2", "link": "https://coolify.test/a2"}, + {"type": "Application", "name": "app-3", "link": "https://coolify.test/a3"}, + {"type": "Database", "name": "db-1", "link": "https://coolify.test/d1"} + ]} + ], + "error": null +}` + +func TestRegistryList_TableOutput(t *testing.T) { + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(registryListFixture)) + }) + + stdout, stderr, err := runRegistryCommand(t, "", "list", "srv-1") + require.NoError(t, err) + assert.Empty(t, stderr) + + requests := fake.recorded() + require.Len(t, requests, 1) + assert.Equal(t, http.MethodGet, requests[0].Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries", requests[0].RequestURI) + + for _, want := range []string{ + "registry", "status", "username", "used_by", + "ghcr.io", "Logged in", + "gcr.io", "Credential helper", "1 service", + "registry.example.com:5000", "Not logged in", "3 applications, 1 database", + "Note: Use -s to show usernames.", + } { + assert.Contains(t, stdout, want) + } + assert.NotContains(t, stdout, "octocat", "usernames are masked without -s") + assert.Contains(t, stdout, "********") + assert.NotContains(t, stdout, "app-1", "table shows only the usage summary") +} + +func TestRegistryList_ShowSensitive(t *testing.T) { + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(registryListFixture)) + }) + + stdout, _, err := runRegistryCommand(t, "", "list", "srv-1", "-s") + require.NoError(t, err) + assert.Contains(t, stdout, "octocat") + assert.NotContains(t, stdout, "********") + assert.NotContains(t, stdout, "Note: Use -s") +} + +func TestRegistryList_JSONOutputIncludesFullUsage(t *testing.T) { + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(registryListFixture)) + }) + + stdout, _, err := runRegistryCommand(t, "", "list", "srv-1", "--format", "json") + require.NoError(t, err) + + var decoded struct { + Registries []struct { + Registry string `json:"registry"` + Username *string `json:"username"` + UsedBy []struct { + Type string `json:"type"` + Name string `json:"name"` + Link string `json:"link"` + } `json:"used_by"` + } `json:"registries"` + Error *string `json:"error"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &decoded)) + require.Len(t, decoded.Registries, 3) + require.Len(t, decoded.Registries[2].UsedBy, 4) + assert.Equal(t, "app-1", decoded.Registries[2].UsedBy[0].Name) + assert.Equal(t, "https://coolify.test/a1", decoded.Registries[2].UsedBy[0].Link) + assert.Nil(t, decoded.Error) +} + +func TestRegistryList_PrintsErrorFieldAsWarning(t *testing.T) { + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registries": [], "error": "The server is not reachable. Validate the server to read its registry logins."}`)) + }) + + stdout, stderr, err := runRegistryCommand(t, "", "list", "srv-1") + require.NoError(t, err) + assert.Equal(t, "Warning: The server is not reachable. Validate the server to read its registry logins.\n", stderr) + assert.Contains(t, stdout, "No data") +} + +func TestRegistryList_SurfacesForbiddenMessage(t *testing.T) { + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Missing required permissions: read:sensitive"}`)) + }) + + _, _, err := runRegistryCommand(t, "", "list", "srv-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to list registry logins") + assert.Contains(t, err.Error(), "Missing required permissions: read:sensitive") +} + +func TestRegistryLogin_PasswordFromStdin(t *testing.T) { + const secret = "ghp_SECRET'token\"with$special" + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"message":"Logged in to ghcr.io."}`)) + }) + + stdout, _, err := runRegistryCommand(t, secret+"\n", + "login", "srv-1", "--registry", "https://GHCR.io/", "--username", "octocat", "--password-stdin") + require.NoError(t, err) + assert.Equal(t, "Logged in to ghcr.io.\n", stdout) + + requests := fake.recorded() + require.Len(t, requests, 1) + assert.Equal(t, http.MethodPost, requests[0].Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries", requests[0].RequestURI) + assert.NotContains(t, requests[0].RequestURI, "SECRET") + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(requests[0].Body), &payload)) + assert.Equal(t, map[string]string{"registry": "ghcr.io", "username": "octocat", "password": secret}, payload) +} + +func TestRegistryLogin_StripsOnlyTrailingNewline(t *testing.T) { + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"message":"ok"}`)) + }) + + _, _, err := runRegistryCommand(t, " pass word \r\n", + "login", "srv-1", "--registry", "ghcr.io", "--username", "octocat", "--password-stdin") + require.NoError(t, err) + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(fake.recorded()[0].Body), &payload)) + assert.Equal(t, " pass word ", payload["password"]) +} + +func TestRegistryLogin_HasNoPasswordFlag(t *testing.T) { + cmd := newRegistryLoginCommand() + assert.Nil(t, cmd.Flags().Lookup("password")) + assert.Nil(t, cmd.Flags().ShorthandLookup("p")) + require.NotNil(t, cmd.Flags().Lookup("password-stdin")) +} + +func TestRegistryLogin_ValidationErrors(t *testing.T) { + tests := []struct { + name string + stdin string + args []string + wantErr string + }{ + {name: "missing registry", stdin: "x", args: []string{"login", "srv-1", "--username", "u", "--password-stdin"}, wantErr: "--registry is required"}, + {name: "missing username", stdin: "x", args: []string{"login", "srv-1", "--registry", "ghcr.io", "--password-stdin"}, wantErr: "--username is required"}, + {name: "empty stdin", stdin: "\n", args: []string{"login", "srv-1", "--registry", "ghcr.io", "--username", "u", "--password-stdin"}, wantErr: "password must not be empty"}, + {name: "too long", stdin: strings.Repeat("a", maxRegistryPasswordBytes+1), args: []string{"login", "srv-1", "--registry", "ghcr.io", "--username", "u", "--password-stdin"}, wantErr: "must not be longer than"}, + {name: "no tty and no --password-stdin", stdin: "secret", args: []string{"login", "srv-1", "--registry", "ghcr.io", "--username", "u"}, wantErr: "--password-stdin"}, + {name: "missing server", stdin: "x", args: []string{"login", "--registry", "ghcr.io", "--username", "u", "--password-stdin"}, wantErr: "missing required arguments"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + _, _, err := runRegistryCommand(t, tt.stdin, tt.args...) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Empty(t, fake.recorded(), "no request is sent when input is invalid") + }) + } +} + +func TestRegistryLogin_PromptsWithoutEchoOnTTY(t *testing.T) { + previousFD, previousRead := terminalFD, readHiddenPassword + t.Cleanup(func() { terminalFD, readHiddenPassword = previousFD, previousRead }) + terminalFD = func(io.Reader) (int, bool) { return 42, true } + var promptedFD int + readHiddenPassword = func(fd int) ([]byte, error) { + promptedFD = fd + return []byte("typed-secret"), nil + } + + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"message":"Logged in to ghcr.io."}`)) + }) + + stdout, stderr, err := runRegistryCommand(t, "", "login", "srv-1", "--registry", "ghcr.io", "-u", "octocat") + require.NoError(t, err) + assert.Equal(t, 42, promptedFD) + assert.Contains(t, stderr, "Password: ") + assert.NotContains(t, stdout+stderr, "typed-secret") + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(fake.recorded()[0].Body), &payload)) + assert.Equal(t, "typed-secret", payload["password"]) +} + +func TestRegistryLogin_PromptReadError(t *testing.T) { + previousFD, previousRead := terminalFD, readHiddenPassword + t.Cleanup(func() { terminalFD, readHiddenPassword = previousFD, previousRead }) + terminalFD = func(io.Reader) (int, bool) { return 3, true } + readHiddenPassword = func(int) ([]byte, error) { return nil, errors.New("interrupted") } + + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + _, _, err := runRegistryCommand(t, "", "login", "srv-1", "--registry", "ghcr.io", "-u", "octocat") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read password: interrupted") +} + +func TestRegistryLogin_SurfacesAPIErrors(t *testing.T) { + tests := []struct { + name string + status int + body string + want string + }{ + {name: "docker failure", status: http.StatusBadRequest, body: `{"message":"Login failed: Error response from daemon: unauthorized: bad token ***"}`, want: "Login failed: Error response from daemon: unauthorized: bad token ***"}, + {name: "validation", status: http.StatusUnprocessableEntity, body: `{"message":"Validation failed.","errors":{"username":["The username must not contain spaces."]}}`, want: "API error 422"}, + {name: "not found", status: http.StatusNotFound, body: `{"message":"Server not found."}`, want: "Server not found."}, + {name: "forbidden", status: http.StatusForbidden, body: `{"message":"Missing required permissions: write"}`, want: "Missing required permissions: write"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + }) + + _, _, err := runRegistryCommand(t, "secret", "login", "srv-1", "--registry", "ghcr.io", "--username", "octocat", "--password-stdin") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to log in to ghcr.io") + assert.Contains(t, err.Error(), tt.want) + assert.NotContains(t, err.Error(), "secret") + }) + } +} + +func TestRegistryLogin_MultipleServers(t *testing.T) { + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/srv-bad/") { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"The server is not reachable. Validate the server first."}`)) + return + } + _, _ = w.Write([]byte(`{"message":"Logged in to ghcr.io."}`)) + }) + + stdout, stderr, err := runRegistryCommand(t, "secret\n", + "login", "srv-1", "srv-bad", "srv-2", "--registry", "ghcr.io", "--username", "octocat", "--password-stdin") + require.Error(t, err) + assert.Equal(t, "login to ghcr.io failed on 1 of 3 servers", err.Error()) + assert.Equal(t, "srv-1: Logged in to ghcr.io.\nsrv-2: Logged in to ghcr.io.\n", stdout) + assert.Contains(t, stderr, "srv-bad: failed to log in to ghcr.io") + assert.Contains(t, stderr, "The server is not reachable. Validate the server first.") + + requests := fake.recorded() + require.Len(t, requests, 3) + for _, req := range requests { + assert.Contains(t, req.Body, `"password":"secret"`, "stdin is read once and reused for every server") + } +} + +func TestRegistryCheck_EscapesRegistryWithPort(t *testing.T) { + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"message":"The login for registry.example.com:5000 works."}`)) + }) + + stdout, _, err := runRegistryCommand(t, "", "check", "srv-1", "registry.example.com:5000") + require.NoError(t, err) + assert.Equal(t, "The login for registry.example.com:5000 works.\n", stdout) + + requests := fake.recorded() + require.Len(t, requests, 1) + assert.Equal(t, http.MethodPost, requests[0].Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries/registry.example.com:5000/check", requests[0].RequestURI) +} + +func TestRegistryCheck_SurfacesFailure(t *testing.T) { + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"Login check failed: unauthorized"}`)) + }) + + _, _, err := runRegistryCommand(t, "", "check", "srv-1", "ghcr.io") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to check login for ghcr.io") + assert.Contains(t, err.Error(), "Login check failed: unauthorized") +} + +func TestRegistryLogout_WithForce(t *testing.T) { + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"message":"Logged out from registry.example.com:5000."}`)) + }) + + stdout, _, err := runRegistryCommand(t, "", "logout", "srv-1", "registry.example.com:5000", "--force") + require.NoError(t, err) + assert.Equal(t, "Logged out from registry.example.com:5000 on server srv-1.\n", stdout) + assert.NotContains(t, stdout, "Are you sure") + + requests := fake.recorded() + require.Len(t, requests, 1) + assert.Equal(t, http.MethodDelete, requests[0].Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries/registry.example.com:5000", requests[0].RequestURI) +} + +func TestRegistryLogout_Confirmation(t *testing.T) { + tests := []struct { + name string + answer string + wantRequest bool + wantOutput string + }{ + {name: "yes", answer: "yes\n", wantRequest: true, wantOutput: "Logged out from ghcr.io on server srv-1."}, + {name: "y", answer: "y\n", wantRequest: true, wantOutput: "Logged out from ghcr.io on server srv-1."}, + {name: "no", answer: "no\n", wantRequest: false, wantOutput: "Logout cancelled."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"message":"Logged out from ghcr.io."}`)) + }) + + stdout, _, err := runRegistryCommand(t, tt.answer, "logout", "srv-1", "ghcr.io") + require.NoError(t, err) + assert.Contains(t, stdout, "Are you sure you want to log out from ghcr.io on server srv-1? (yes/no): ") + assert.Contains(t, stdout, tt.wantOutput) + if tt.wantRequest { + assert.Len(t, fake.recorded(), 1) + } else { + assert.Empty(t, fake.recorded()) + } + }) + } +} + +func TestRegistryLogout_SurfacesFailure(t *testing.T) { + newRegistryTestAPI(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"The server is not reachable. Validate the server first."}`)) + }) + + _, _, err := runRegistryCommand(t, "", "logout", "srv-1", "ghcr.io", "-f") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to log out from ghcr.io") + assert.Contains(t, err.Error(), "The server is not reachable. Validate the server first.") +} + +func TestRegistryCommand_Registered(t *testing.T) { + serverCmd := NewServerCommand() + registryCmd, _, err := serverCmd.Find([]string{"registries", "list"}) + require.NoError(t, err) + assert.Equal(t, "list", registryCmd.Name()) + assert.Equal(t, "registry", registryCmd.Parent().Name()) +} diff --git a/cmd/server/server.go b/cmd/server/server.go index bb201aa..aeb336f 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -10,7 +10,7 @@ func NewServerCommand() *cobra.Command { Use: "server", Aliases: []string{"servers"}, Short: "Server related commands", - Long: `Manage Coolify servers - list, get details, add new servers, validate connections, and remove servers.`, + Long: `Manage Coolify servers - list, get details, add new servers, validate connections, manage Docker registry logins, and remove servers.`, } // Add subcommands @@ -30,6 +30,7 @@ func NewServerCommand() *cobra.Command { cmd.AddCommand(NewSentinelCommand()) cmd.AddCommand(NewCloudflareTunnelCommand()) cmd.AddCommand(NewProxyCommand()) + cmd.AddCommand(NewRegistryCommand()) return cmd } diff --git a/internal/api/client.go b/internal/api/client.go index 3f01d5c..98da617 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -232,7 +232,7 @@ func redactSensitiveFields(value any) { switch typed := value.(type) { case map[string]any: for key, child := range typed { - if key == "token" { + if key == "token" || key == "password" { typed[key] = "********" continue } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 676ae0a..94224e5 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -33,6 +33,25 @@ func TestClient_DebugRedactsSensitiveJSONFields(t *testing.T) { assert.Contains(t, logs.String(), `"token":"********"`) } +func TestClient_DebugRedactsPasswordInRequestBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"message":"Logged in to ghcr.io."}`)) + })) + defer server.Close() + + var logs bytes.Buffer + previousWriter := log.Writer() + log.SetOutput(&logs) + t.Cleanup(func() { log.SetOutput(previousWriter) }) + + client := NewClient(server.URL, "coolify-token", WithDebug(true), WithRetries(0)) + var response map[string]any + err := client.Post(context.Background(), "servers/srv-1/registries", map[string]string{"registry": "ghcr.io", "username": "octocat", "password": "ghp_secret"}, &response) + require.NoError(t, err) + assert.NotContains(t, logs.String(), "ghp_secret") + assert.Contains(t, logs.String(), `"password":"********"`) +} + func TestClient_DebugRedactsSensitiveResponseFields(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"uuid":"token-1","token":"provider-secret"}`)) diff --git a/internal/models/server_registry.go b/internal/models/server_registry.go new file mode 100644 index 0000000..f28f8ae --- /dev/null +++ b/internal/models/server_registry.go @@ -0,0 +1,54 @@ +package models + +// Registry credential sources reported by the server registries API. +const ( + ServerRegistrySourceAuths = "auths" + ServerRegistrySourceCredHelpers = "credHelpers" +) + +// Registry login statuses shown in table output. +const ( + ServerRegistryStatusLoggedIn = "Logged in" + ServerRegistryStatusCredentialHelper = "Credential helper" //nolint:gosec // G101: status label, not a credential + ServerRegistryStatusNotLoggedIn = "Not logged in" +) + +// ServerRegistryUser is a resource on the server that pulls from or pushes to a registry. +type ServerRegistryUser struct { + Type string `json:"type"` + Name string `json:"name"` + Link *string `json:"link"` +} + +// ServerRegistry is a Docker registry a server is logged in to or that its resources use. +// Credentials are never returned by the API, only the username. +type ServerRegistry struct { + Registry string `json:"registry"` + LoggedIn bool `json:"logged_in"` + Source *string `json:"source"` + Username *string `json:"username"` + UsedBy []ServerRegistryUser `json:"used_by"` +} + +// ServerRegistriesResponse is the response of GET /servers/{uuid}/registries. +type ServerRegistriesResponse struct { + Registries []ServerRegistry `json:"registries"` + Error *string `json:"error"` +} + +// ServerRegistryRow is the table view of a ServerRegistry. +// Username is masked by the command unless sensitive output is requested, +// so empty usernames stay empty instead of showing a mask. +type ServerRegistryRow struct { + Registry string `json:"registry"` + Status string `json:"status"` + Username string `json:"username"` + UsedBy string `json:"used_by"` +} + +// ServerRegistryLoginRequest is the body of POST /servers/{uuid}/registries. +type ServerRegistryLoginRequest struct { + Registry string `json:"registry"` + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/internal/service/server_registry.go b/internal/service/server_registry.go new file mode 100644 index 0000000..27e5d4a --- /dev/null +++ b/internal/service/server_registry.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/coollabsio/coolify-cli/internal/api" + "github.com/coollabsio/coolify-cli/internal/models" +) + +// ServerRegistryService manages Docker registry logins on a server. +type ServerRegistryService struct { + client *api.Client +} + +// NewServerRegistryService creates a new server registry service. +func NewServerRegistryService(client *api.Client) *ServerRegistryService { + return &ServerRegistryService{client: client} +} + +var registrySchemePattern = regexp.MustCompile(`^[a-z][a-z0-9+.-]*://`) + +// NormalizeRegistry turns user input such as "https://GHCR.io/" into the registry +// host the API expects ("ghcr.io"). It mirrors the server-side normalization so the +// value can be used as a single URL path segment. +func NormalizeRegistry(registry string) string { + host := strings.ToLower(strings.TrimSpace(registry)) + host = registrySchemePattern.ReplaceAllString(host, "") + host, _, _ = strings.Cut(host, "/") + return host +} + +func serverRegistriesPath(serverUUID string) string { + return "servers/" + url.PathEscape(serverUUID) + "/registries" +} + +func serverRegistryPath(serverUUID, registry string) string { + return serverRegistriesPath(serverUUID) + "/" + url.PathEscape(NormalizeRegistry(registry)) +} + +// List returns the registries the server is logged in to and the registries its resources use. +func (s *ServerRegistryService) List(ctx context.Context, serverUUID string) (*models.ServerRegistriesResponse, error) { + var out models.ServerRegistriesResponse + if err := s.client.Get(ctx, serverRegistriesPath(serverUUID), &out); err != nil { + return nil, err + } + return &out, nil +} + +// Login runs docker login on the server. The password is sent only in the JSON body. +func (s *ServerRegistryService) Login(ctx context.Context, serverUUID string, req models.ServerRegistryLoginRequest) (*models.Response, error) { + req.Registry = NormalizeRegistry(req.Registry) + var out models.Response + if err := s.client.Post(ctx, serverRegistriesPath(serverUUID), req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Check verifies that the saved login of the server for a registry still works. +func (s *ServerRegistryService) Check(ctx context.Context, serverUUID, registry string) (*models.Response, error) { + var out models.Response + if err := s.client.Post(ctx, serverRegistryPath(serverUUID, registry)+"/check", map[string]any{}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Logout runs docker logout on the server for a registry. +func (s *ServerRegistryService) Logout(ctx context.Context, serverUUID, registry string) error { + return s.client.Delete(ctx, serverRegistryPath(serverUUID, registry)) +} + +// ServerRegistryStatus returns the human readable login status of a registry. +func ServerRegistryStatus(registry models.ServerRegistry) string { + switch { + case !registry.LoggedIn: + return models.ServerRegistryStatusNotLoggedIn + case registry.Source != nil && *registry.Source == models.ServerRegistrySourceCredHelpers: + return models.ServerRegistryStatusCredentialHelper + default: + return models.ServerRegistryStatusLoggedIn + } +} + +// ServerRegistryUsageSummary summarizes the resources using a registry, for example +// "3 applications, 1 database". Types are listed in order of first appearance. +func ServerRegistryUsageSummary(users []models.ServerRegistryUser) string { + counts := map[string]int{} + var order []string + for _, user := range users { + kind := strings.ToLower(strings.TrimSpace(user.Type)) + if kind == "" { + kind = "resource" + } + if counts[kind] == 0 { + order = append(order, kind) + } + counts[kind]++ + } + + parts := make([]string, 0, len(order)) + for _, kind := range order { + label := kind + if counts[kind] != 1 { + label += "s" + } + parts = append(parts, fmt.Sprintf("%d %s", counts[kind], label)) + } + return strings.Join(parts, ", ") +} + +// ServerRegistryRows converts registries into table rows. Usernames are replaced +// with mask unless showSensitive is set; empty usernames stay empty. +func ServerRegistryRows(registries []models.ServerRegistry, showSensitive bool, mask string) []models.ServerRegistryRow { + rows := make([]models.ServerRegistryRow, 0, len(registries)) + for _, registry := range registries { + username := "" + if registry.Username != nil && *registry.Username != "" { + username = *registry.Username + if !showSensitive { + username = mask + } + } + rows = append(rows, models.ServerRegistryRow{ + Registry: registry.Registry, + Status: ServerRegistryStatus(registry), + Username: username, + UsedBy: ServerRegistryUsageSummary(registry.UsedBy), + }) + } + return rows +} diff --git a/internal/service/server_registry_test.go b/internal/service/server_registry_test.go new file mode 100644 index 0000000..4dbb286 --- /dev/null +++ b/internal/service/server_registry_test.go @@ -0,0 +1,267 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coollabsio/coolify-cli/internal/api" + "github.com/coollabsio/coolify-cli/internal/models" +) + +func newRegistryTestService(t *testing.T, handler http.HandlerFunc) *ServerRegistryService { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return NewServerRegistryService(api.NewClient(server.URL, "test-token", api.WithRetries(0))) +} + +func TestServerRegistryService_List(t *testing.T) { + svc := newRegistryTestService(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries", r.URL.Path) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{ + "registries": [ + {"registry": "ghcr.io", "logged_in": true, "source": "auths", "username": "octocat", "used_by": []}, + {"registry": "registry.example.com:5000", "logged_in": false, "source": null, "username": null, + "used_by": [{"type": "Application", "name": "private-app", "link": "https://coolify.test/app"}]} + ], + "error": null + }`)) + }) + + result, err := svc.List(context.Background(), "srv-1") + require.NoError(t, err) + require.Len(t, result.Registries, 2) + assert.Nil(t, result.Error) + + ghcr := result.Registries[0] + assert.Equal(t, "ghcr.io", ghcr.Registry) + assert.True(t, ghcr.LoggedIn) + require.NotNil(t, ghcr.Source) + assert.Equal(t, models.ServerRegistrySourceAuths, *ghcr.Source) + require.NotNil(t, ghcr.Username) + assert.Equal(t, "octocat", *ghcr.Username) + assert.Empty(t, ghcr.UsedBy) + + private := result.Registries[1] + assert.False(t, private.LoggedIn) + assert.Nil(t, private.Source) + assert.Nil(t, private.Username) + require.Len(t, private.UsedBy, 1) + assert.Equal(t, "Application", private.UsedBy[0].Type) + assert.Equal(t, "private-app", private.UsedBy[0].Name) + require.NotNil(t, private.UsedBy[0].Link) + assert.Equal(t, "https://coolify.test/app", *private.UsedBy[0].Link) +} + +func TestServerRegistryService_List_ErrorField(t *testing.T) { + svc := newRegistryTestService(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"registries": [], "error": "The server is not reachable. Validate the server to read its registry logins."}`)) + }) + + result, err := svc.List(context.Background(), "srv-1") + require.NoError(t, err) + assert.Empty(t, result.Registries) + require.NotNil(t, result.Error) + assert.Contains(t, *result.Error, "not reachable") +} + +func TestServerRegistryService_Login_SendsPasswordOnlyInBody(t *testing.T) { + const secret = "ghp_SECRET'token\"with$special" + svc := newRegistryTestService(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries", r.URL.Path) + assert.NotContains(t, r.RequestURI, "SECRET") + assert.Empty(t, r.URL.RawQuery) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload map[string]any + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, map[string]any{"registry": "ghcr.io", "username": "octocat", "password": secret}, payload) + + _, _ = w.Write([]byte(`{"message":"Logged in to ghcr.io."}`)) + }) + + resp, err := svc.Login(context.Background(), "srv-1", models.ServerRegistryLoginRequest{ + Registry: "https://GHCR.io/", + Username: "octocat", + Password: secret, + }) + require.NoError(t, err) + assert.Equal(t, "Logged in to ghcr.io.", resp.Message) +} + +func TestServerRegistryService_Check_EscapesRegistryWithPort(t *testing.T) { + svc := newRegistryTestService(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries/registry.example.com:5000/check", r.URL.Path) + assert.Equal(t, "/api/v1/servers/srv-1/registries/registry.example.com:5000/check", r.RequestURI) + _, _ = w.Write([]byte(`{"message":"The login for registry.example.com:5000 works."}`)) + }) + + resp, err := svc.Check(context.Background(), "srv-1", "registry.example.com:5000") + require.NoError(t, err) + assert.Equal(t, "The login for registry.example.com:5000 works.", resp.Message) +} + +func TestServerRegistryService_Logout(t *testing.T) { + svc := newRegistryTestService(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v1/servers/srv-1/registries/ghcr.io", r.RequestURI) + _, _ = w.Write([]byte(`{"message":"Logged out from ghcr.io."}`)) + }) + + require.NoError(t, svc.Logout(context.Background(), "srv-1", "GHCR.io")) +} + +func TestServerRegistryService_PathSegmentsAreEscaped(t *testing.T) { + svc := newRegistryTestService(t, func(w http.ResponseWriter, r *http.Request) { + // Characters that would change the request target stay inside one path segment. + assert.Equal(t, "/api/v1/servers/srv%3F1/registries/bad%20host%3Fx=1%23frag", r.RequestURI) + assert.Empty(t, r.URL.RawQuery) + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation failed.","errors":{"registry":["Enter a registry host."]}}`)) + }) + + err := svc.Logout(context.Background(), "srv?1", "bad host?x=1#frag") + require.Error(t, err) +} + +func TestServerRegistryService_ErrorsSurfaceAPIMessage(t *testing.T) { + tests := []struct { + name string + status int + body string + call func(*ServerRegistryService) error + message string + }{ + { + name: "login docker failure", + status: http.StatusBadRequest, + body: `{"message":"Login failed: Error response from daemon: unauthorized: bad token ***"}`, + call: func(s *ServerRegistryService) error { + _, err := s.Login(context.Background(), "srv-1", models.ServerRegistryLoginRequest{Registry: "ghcr.io", Username: "octocat", Password: "x"}) + return err + }, + message: "Login failed: Error response from daemon: unauthorized: bad token ***", + }, + { + name: "login validation", + status: http.StatusUnprocessableEntity, + body: `{"message":"Validation failed.","errors":{"username":["The username must not contain spaces."]}}`, + call: func(s *ServerRegistryService) error { + _, err := s.Login(context.Background(), "srv-1", models.ServerRegistryLoginRequest{Registry: "ghcr.io", Username: "octo cat", Password: "x"}) + return err + }, + message: "Validation failed.", + }, + { + name: "check failure", + status: http.StatusBadRequest, + body: `{"message":"Login check failed: Error: Cannot perform an interactive login from a non TTY device"}`, + call: func(s *ServerRegistryService) error { + _, err := s.Check(context.Background(), "srv-1", "ghcr.io") + return err + }, + message: "Login check failed: Error: Cannot perform an interactive login from a non TTY device", + }, + { + name: "list forbidden", + status: http.StatusForbidden, + body: `{"message":"Missing required permissions: read:sensitive"}`, + call: func(s *ServerRegistryService) error { + _, err := s.List(context.Background(), "srv-1") + return err + }, + message: "Missing required permissions: read:sensitive", + }, + { + name: "logout server not found", + status: http.StatusNotFound, + body: `{"message":"Server not found."}`, + call: func(s *ServerRegistryService) error { + return s.Logout(context.Background(), "srv-1", "ghcr.io") + }, + message: "Server not found.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := newRegistryTestService(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + }) + + err := tt.call(svc) + require.Error(t, err) + var apiErr *api.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, tt.status, apiErr.StatusCode) + assert.Equal(t, tt.message, apiErr.Message) + }) + } +} + +func TestNormalizeRegistry(t *testing.T) { + tests := map[string]string{ + "ghcr.io": "ghcr.io", + " GHCR.io ": "ghcr.io", + "https://GHCR.io/": "ghcr.io", + "http://registry.example.com:5000/": "registry.example.com:5000", + "registry.example.com:5000/team/app": "registry.example.com:5000", + "docker.io": "docker.io", + "": "", + } + for input, want := range tests { + assert.Equal(t, want, NormalizeRegistry(input), "input %q", input) + } +} + +func TestServerRegistryStatus(t *testing.T) { + assert.Equal(t, models.ServerRegistryStatusLoggedIn, ServerRegistryStatus(models.ServerRegistry{LoggedIn: true, Source: strPtr("auths")})) + assert.Equal(t, models.ServerRegistryStatusLoggedIn, ServerRegistryStatus(models.ServerRegistry{LoggedIn: true})) + assert.Equal(t, models.ServerRegistryStatusCredentialHelper, ServerRegistryStatus(models.ServerRegistry{LoggedIn: true, Source: strPtr("credHelpers")})) + assert.Equal(t, models.ServerRegistryStatusNotLoggedIn, ServerRegistryStatus(models.ServerRegistry{LoggedIn: false})) +} + +func TestServerRegistryUsageSummary(t *testing.T) { + assert.Empty(t, ServerRegistryUsageSummary(nil)) + assert.Equal(t, "3 applications, 1 database", ServerRegistryUsageSummary([]models.ServerRegistryUser{ + {Type: "Application", Name: "a"}, + {Type: "Database", Name: "db"}, + {Type: "Application", Name: "b"}, + {Type: "Application", Name: "c"}, + })) + assert.Equal(t, "1 service, 2 builds", ServerRegistryUsageSummary([]models.ServerRegistryUser{ + {Type: "Service", Name: "s"}, + {Type: "Build", Name: "a"}, + {Type: "Build", Name: "b"}, + })) +} + +func TestServerRegistryRows_MasksUsernames(t *testing.T) { + registries := []models.ServerRegistry{ + {Registry: "ghcr.io", LoggedIn: true, Source: strPtr("auths"), Username: strPtr("octocat")}, + {Registry: "registry.example.com:5000", UsedBy: []models.ServerRegistryUser{{Type: "Application", Name: "app"}}}, + } + + masked := ServerRegistryRows(registries, false, "********") + require.Len(t, masked, 2) + assert.Equal(t, models.ServerRegistryRow{Registry: "ghcr.io", Status: "Logged in", Username: "********"}, masked[0]) + assert.Equal(t, models.ServerRegistryRow{Registry: "registry.example.com:5000", Status: "Not logged in", UsedBy: "1 application"}, masked[1]) + + shown := ServerRegistryRows(registries, true, "********") + assert.Equal(t, "octocat", shown[0].Username) + assert.Empty(t, shown[1].Username) +} diff --git a/llms-full.txt b/llms-full.txt index f1e1b06..74e3487 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -77,6 +77,7 @@ Aliases are derived from the CLI command tree: - `coolify server cloudflare-tunnel` | `coolify server cf-tunnel` - `coolify server digitalocean` | `coolify server digital-ocean` | `coolify server do` - `coolify server domains` | `coolify server domain` +- `coolify server registry` | `coolify server registries` - `coolify server` | `coolify servers` - `coolify service application` | `coolify service applications` | `coolify service app` | `coolify service apps` - `coolify service database` | `coolify service databases` | `coolify service db` | `coolify service dbs` @@ -3852,6 +3853,40 @@ Parameters: description: Redirect URL required: false +Command: coolify server registry check +Description: Check that the saved registry login of a server still works +Parameters: (None) + +Command: coolify server registry list +Description: List registry logins of a server +Parameters: (None) + +Command: coolify server registry login [...] +Description: Log in to a Docker registry on one or more servers +Parameters: + - name: --password-stdin + type: boolean + description: Read the password or access token from stdin + required: false + default: false + - name: --registry + type: string + description: Registry host, optionally with a port (e.g. ghcr.io, registry.example.com:5000, docker.io for Docker Hub) (required) + required: true + - name: --username (-u) + type: string + description: Registry username (required) + required: true + +Command: coolify server registry logout +Description: Log out from a Docker registry on a server +Parameters: + - name: --force (-f) + type: boolean + description: Skip confirmation prompt + required: false + default: false + Command: coolify server remove Description: Remove a server Parameters: (None)