Skip to content

Commit c7511c6

Browse files
adhikjoshiclaude
andcommitted
feat(credentials): manage Docker registry credentials from the CLI
Add a `gpulab credentials` command group (add/list/rm) backed by the new V1 /credentials API, so private-image logins can be stored without the web UI. Credentials are referenced by ID via the existing --credentials flag. Also add inline --registry / --registry-username / --registry-password flags to `templates create|edit`: when a username and password are given, the API creates and links a credential in one call. Passwords are write-only and never returned by `credentials list`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4d2b88 commit c7511c6

5 files changed

Lines changed: 316 additions & 32 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,29 @@ gpulab templates delete my-template --force
196196
The target for `info`/`edit`/`delete` may be a full UUID, a UUID prefix, or the
197197
template name.
198198

199+
### Private images
200+
201+
For images that need a registry login, attach Docker credentials. Set them inline
202+
when creating the template (the credential is created and linked in one call):
203+
204+
```bash
205+
gpulab templates create --name private-trainer --image adhik/private:v1 \
206+
--registry-username adhik --registry-password dckr_pat_xxx
207+
# --registry defaults to docker.io; pass it for ghcr.io, quay.io, etc.
208+
```
209+
210+
Or manage credentials as reusable, named resources and reference them by ID:
211+
212+
```bash
213+
# Store once (use --password-stdin to keep the token out of shell history)
214+
echo "$DOCKER_TOKEN" | gpulab credentials add --username adhik --password-stdin
215+
gpulab credentials # list — shows the ID
216+
gpulab templates create --name app --image adhik/private:v1 --credentials 42
217+
gpulab credentials rm 42 # delete when no longer needed
218+
```
219+
220+
Passwords/tokens are write-only — they are never returned by `credentials list`.
221+
199222
## Commands
200223

201224
| Command | Description |
@@ -220,6 +243,9 @@ template name.
220243
| `gpulab templates create --name <n> --image <img>` | Create a template |
221244
| `gpulab templates edit <uuid\|name> [flags]` | Edit a template |
222245
| `gpulab templates delete <uuid\|name>` | Delete a template |
246+
| `gpulab credentials` | List Docker registry credentials |
247+
| `gpulab credentials add --username <u> --password <p>` | Store a registry credential |
248+
| `gpulab credentials rm <id>` | Delete a registry credential |
223249
| `gpulab gpus types` | List GPU types |
224250
| `gpulab volumes` | List volumes |
225251
| `gpulab serverless` | Manage serverless GPU endpoints |

‎internal/api/credentials.go‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
// Credential is a stored Docker registry credential. The password is write-only
9+
// and never returned by the API; templates reference a credential by its ID.
10+
type Credential struct {
11+
ID int `json:"id"`
12+
Registry string `json:"registry"`
13+
Username string `json:"username"`
14+
CreatedAt string `json:"createdAt"`
15+
UpdatedAt string `json:"updatedAt"`
16+
}
17+
18+
// CreateCredentialRequest is the payload for storing a new registry credential.
19+
// Registry defaults to docker.io server-side when left empty.
20+
type CreateCredentialRequest struct {
21+
Registry string `json:"registry,omitempty"`
22+
Username string `json:"username"`
23+
Password string `json:"password"`
24+
}
25+
26+
func (c *Client) ListCredentials() ([]Credential, error) {
27+
data, err := c.Get("/v1/credentials")
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
var resp struct {
33+
Data []Credential `json:"data"`
34+
}
35+
if err := json.Unmarshal(data, &resp); err != nil {
36+
return nil, fmt.Errorf("failed to parse credentials: %w", err)
37+
}
38+
return resp.Data, nil
39+
}
40+
41+
func (c *Client) CreateCredential(req *CreateCredentialRequest) (*Credential, error) {
42+
data, err := c.Post("/v1/credentials", req)
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
var resp struct {
48+
Data Credential `json:"data"`
49+
}
50+
if err := json.Unmarshal(data, &resp); err != nil {
51+
return nil, fmt.Errorf("failed to parse created credential: %w", err)
52+
}
53+
return &resp.Data, nil
54+
}
55+
56+
func (c *Client) DeleteCredential(id int) error {
57+
_, err := c.Delete(fmt.Sprintf("/v1/credentials/%d", id))
58+
return err
59+
}

‎internal/api/templates.go‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,22 @@ type TemplateCategory struct {
113113
// TemplateRequest is the create/update payload. Pointer and omitempty fields let
114114
// the edit command send only the flags the user actually changed (partial PUT).
115115
type TemplateRequest struct {
116-
Name string `json:"name,omitempty"`
117-
DockerImage string `json:"docker_image,omitempty"`
118-
Description *string `json:"description,omitempty"`
119-
AuthorURL *string `json:"author_url,omitempty"`
120-
AuthorName *string `json:"author_name,omitempty"`
121-
Thumbnail *string `json:"thumbnail,omitempty"`
122-
Visibility string `json:"visibility,omitempty"`
123-
ContainerType string `json:"container_type,omitempty"`
124-
CategoryID *int `json:"category_id,omitempty"`
125-
CredentialsID *int `json:"credentials_id,omitempty"`
116+
Name string `json:"name,omitempty"`
117+
DockerImage string `json:"docker_image,omitempty"`
118+
Description *string `json:"description,omitempty"`
119+
AuthorURL *string `json:"author_url,omitempty"`
120+
AuthorName *string `json:"author_name,omitempty"`
121+
Thumbnail *string `json:"thumbnail,omitempty"`
122+
Visibility string `json:"visibility,omitempty"`
123+
ContainerType string `json:"container_type,omitempty"`
124+
CategoryID *int `json:"category_id,omitempty"`
125+
CredentialsID *int `json:"credentials_id,omitempty"`
126+
// Inline registry credentials. When username + password are set, the API
127+
// creates a credential and links it, so a private image needs no separate
128+
// 'credentials add' step. Registry defaults to docker.io server-side.
129+
Registry *string `json:"registry,omitempty"`
130+
RegistryUsername *string `json:"registry_username,omitempty"`
131+
RegistryPassword *string `json:"registry_password,omitempty"`
126132
VolumeMountPath *string `json:"volume_mount_path,omitempty"`
127133
ExposedPorts *string `json:"exposed_ports,omitempty"`
128134
EnvironmentVariables map[string]string `json:"environment_variables,omitempty"`

‎internal/commands/credentials.go‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package commands
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"os"
8+
"strconv"
9+
"strings"
10+
11+
"github.com/GPULab-AI/gpulab-cli/internal/api"
12+
"github.com/GPULab-AI/gpulab-cli/internal/output"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var (
17+
credRegistry string
18+
credUsername string
19+
credPassword string
20+
credPasswordStdin bool
21+
credDeleteForce bool
22+
)
23+
24+
func init() {
25+
rootCmd.AddCommand(credentialsCmd)
26+
credentialsCmd.AddCommand(credentialsListCmd)
27+
credentialsCmd.AddCommand(credentialsAddCmd)
28+
credentialsCmd.AddCommand(credentialsDeleteCmd)
29+
30+
credentialsAddCmd.Flags().StringVar(&credRegistry, "registry", "docker.io", "Registry host (e.g. docker.io, quay.io, ghcr.io)")
31+
credentialsAddCmd.Flags().StringVar(&credUsername, "username", "", "Registry username")
32+
credentialsAddCmd.Flags().StringVar(&credPassword, "password", "", "Registry password or access token")
33+
credentialsAddCmd.Flags().BoolVar(&credPasswordStdin, "password-stdin", false, "Read the password from stdin (keeps it out of shell history)")
34+
credentialsAddCmd.MarkFlagRequired("username")
35+
36+
credentialsDeleteCmd.Flags().BoolVar(&credDeleteForce, "force", false, "Skip confirmation")
37+
}
38+
39+
var credentialsCmd = &cobra.Command{
40+
Use: "credentials",
41+
Aliases: []string{"creds"},
42+
Short: "Manage Docker registry credentials (for private images)",
43+
Long: `Store, list, and delete Docker registry credentials used to pull private images.
44+
45+
Reference a credential from a template with 'gpulab templates create --credentials <id>',
46+
or set one inline with --registry-username / --registry-password on create/edit.
47+
48+
Running 'gpulab credentials' with no subcommand lists every credential.`,
49+
Example: ` gpulab credentials add --registry docker.io --username adhik --password ***
50+
gpulab credentials add --username adhik --password-stdin < token.txt
51+
gpulab credentials # list credentials
52+
gpulab credentials rm 42`,
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
return credentialsListCmd.RunE(cmd, args)
55+
},
56+
}
57+
58+
var credentialsListCmd = &cobra.Command{
59+
Use: "list",
60+
Aliases: []string{"ls"},
61+
Short: "List stored registry credentials",
62+
RunE: func(cmd *cobra.Command, args []string) error {
63+
client := requireAuth()
64+
creds, err := client.ListCredentials()
65+
if err != nil {
66+
return err
67+
}
68+
69+
switch getOutputFormat() {
70+
case output.FormatJSON:
71+
output.PrintJSON(creds)
72+
case output.FormatQuiet:
73+
ids := make([]string, len(creds))
74+
for i, c := range creds {
75+
ids[i] = strconv.Itoa(c.ID)
76+
}
77+
output.PrintQuiet(ids)
78+
default:
79+
if len(creds) == 0 {
80+
fmt.Println("No credentials found. Add one with 'gpulab credentials add'.")
81+
return nil
82+
}
83+
headers := []string{"ID", "REGISTRY", "USERNAME", "CREATED"}
84+
rows := make([][]string, len(creds))
85+
for i, c := range creds {
86+
rows[i] = []string{strconv.Itoa(c.ID), c.Registry, c.Username, c.CreatedAt}
87+
}
88+
output.PrintTable(headers, rows)
89+
}
90+
return nil
91+
},
92+
}
93+
94+
var credentialsAddCmd = &cobra.Command{
95+
Use: "add",
96+
Short: "Add a Docker registry credential",
97+
Long: `Store a Docker registry credential for pulling private images.
98+
99+
The password may be passed with --password or piped in via --password-stdin
100+
(recommended, so it stays out of your shell history).`,
101+
Example: ` gpulab credentials add --username adhik --password dckr_pat_xxx
102+
echo "$DOCKER_TOKEN" | gpulab credentials add --username adhik --password-stdin
103+
gpulab credentials add --registry ghcr.io --username adhik --password ghp_xxx`,
104+
RunE: func(cmd *cobra.Command, args []string) error {
105+
client := requireAuth()
106+
107+
password := credPassword
108+
if credPasswordStdin {
109+
data, err := io.ReadAll(bufio.NewReader(os.Stdin))
110+
if err != nil {
111+
return fmt.Errorf("failed to read password from stdin: %w", err)
112+
}
113+
password = strings.TrimRight(string(data), "\r\n")
114+
}
115+
if password == "" {
116+
return fmt.Errorf("a password is required; pass --password or --password-stdin")
117+
}
118+
119+
cred, err := client.CreateCredential(&api.CreateCredentialRequest{
120+
Registry: credRegistry,
121+
Username: credUsername,
122+
Password: password,
123+
})
124+
if err != nil {
125+
return err
126+
}
127+
128+
if flagJSON {
129+
output.PrintJSON(cred)
130+
return nil
131+
}
132+
output.PrintSuccess(fmt.Sprintf("Credential created: %d", cred.ID))
133+
fmt.Printf("Registry: %s\n", cred.Registry)
134+
fmt.Printf("Username: %s\n", cred.Username)
135+
fmt.Printf("\nReference it on a template with --credentials %d\n", cred.ID)
136+
return nil
137+
},
138+
}
139+
140+
var credentialsDeleteCmd = &cobra.Command{
141+
Use: "delete [ID]",
142+
Aliases: []string{"rm", "remove"},
143+
Short: "Delete a registry credential",
144+
Args: cobra.ExactArgs(1),
145+
RunE: func(cmd *cobra.Command, args []string) error {
146+
client := requireAuth()
147+
148+
id, err := strconv.Atoi(args[0])
149+
if err != nil {
150+
return fmt.Errorf("credential id must be a number, got %q", args[0])
151+
}
152+
153+
if !credDeleteForce && !flagJSON && !flagQuiet {
154+
fmt.Printf("Delete credential %d? [y/N] ", id)
155+
var confirm string
156+
fmt.Scanln(&confirm)
157+
if strings.ToLower(confirm) != "y" {
158+
fmt.Println("Cancelled.")
159+
return nil
160+
}
161+
}
162+
163+
if err := client.DeleteCredential(id); err != nil {
164+
return err
165+
}
166+
167+
if flagJSON {
168+
output.PrintJSON(map[string]interface{}{"status": "success", "action": "deleted", "id": id})
169+
return nil
170+
}
171+
output.PrintSuccess(fmt.Sprintf("Credential deleted: %d", id))
172+
return nil
173+
},
174+
}

‎internal/commands/templates.go‎

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,30 @@ import (
1010
)
1111

1212
var (
13-
tplName string
14-
tplImage string
15-
tplDescription string
16-
tplVisibility string
17-
tplType string
18-
tplCategory int
19-
tplCredentials int
20-
tplMountPath string
21-
tplPorts string
22-
tplEnv []string
23-
tplEnvFile string
24-
tplCommand string
25-
tplDisk int
26-
tplVolumeDisk int
27-
tplMemory int
28-
tplPullPolicy string
29-
tplAuthorName string
30-
tplAuthorURL string
31-
tplThumbnail string
32-
tplNotes string
33-
tplDeleteForce bool
13+
tplName string
14+
tplImage string
15+
tplDescription string
16+
tplVisibility string
17+
tplType string
18+
tplCategory int
19+
tplCredentials int
20+
tplRegistry string
21+
tplRegistryUsername string
22+
tplRegistryPassword string
23+
tplMountPath string
24+
tplPorts string
25+
tplEnv []string
26+
tplEnvFile string
27+
tplCommand string
28+
tplDisk int
29+
tplVolumeDisk int
30+
tplMemory int
31+
tplPullPolicy string
32+
tplAuthorName string
33+
tplAuthorURL string
34+
tplThumbnail string
35+
tplNotes string
36+
tplDeleteForce bool
3437
)
3538

3639
func init() {
@@ -61,7 +64,10 @@ func addTemplateWriteFlags(cmd *cobra.Command) {
6164
f.StringVar(&tplVisibility, "visibility", "", "Visibility: public|private (default private)")
6265
f.StringVar(&tplType, "type", "", "Container type: gpu|cpu (default gpu)")
6366
f.IntVar(&tplCategory, "category", 0, "Category ID (see 'gpulab templates categories')")
64-
f.IntVar(&tplCredentials, "credentials", 0, "Docker credentials ID (for private images)")
67+
f.IntVar(&tplCredentials, "credentials", 0, "Docker credentials ID (for private images; see 'gpulab credentials')")
68+
f.StringVar(&tplRegistry, "registry", "", "Registry host for inline credentials (default docker.io)")
69+
f.StringVar(&tplRegistryUsername, "registry-username", "", "Registry username; creates+links a credential inline")
70+
f.StringVar(&tplRegistryPassword, "registry-password", "", "Registry password/token; creates+links a credential inline")
6571
f.StringVar(&tplMountPath, "mount-path", "", "Volume mount path inside the container")
6672
f.StringVar(&tplPorts, "ports", "", "Exposed ports, comma-separated (e.g. 80,443,8080-8085)")
6773
f.StringArrayVarP(&tplEnv, "env", "e", nil, "Environment variable KEY=VALUE (repeatable; bare KEY inherits from host)")
@@ -79,6 +85,7 @@ func addTemplateWriteFlags(cmd *cobra.Command) {
7985

8086
var templateWriteFlagNames = []string{
8187
"name", "image", "description", "visibility", "type", "category", "credentials",
88+
"registry", "registry-username", "registry-password",
8289
"mount-path", "ports", "env", "env-file", "command", "disk", "volume-disk",
8390
"memory", "pull-policy", "author-name", "author-url", "thumbnail", "notes",
8491
}
@@ -144,6 +151,18 @@ func buildTemplateRequest(cmd *cobra.Command) (*api.TemplateRequest, error) {
144151
v := tplCredentials
145152
req.CredentialsID = &v
146153
}
154+
if f.Changed("registry") {
155+
v := tplRegistry
156+
req.Registry = &v
157+
}
158+
if f.Changed("registry-username") {
159+
v := tplRegistryUsername
160+
req.RegistryUsername = &v
161+
}
162+
if f.Changed("registry-password") {
163+
v := tplRegistryPassword
164+
req.RegistryPassword = &v
165+
}
147166
if f.Changed("disk") {
148167
v := tplDisk
149168
req.ContainerDiskSize = &v

0 commit comments

Comments
 (0)