Skip to content

Commit 765c348

Browse files
committed
[supervisor, ws-manager] Write docker credentials into client config file if passed into workspace
Tool: gitpod/catfood.gitpod.cloud
1 parent 79f70a5 commit 765c348

File tree

4 files changed

+308
-1
lines changed

4 files changed

+308
-1
lines changed

Diff for: components/supervisor/pkg/supervisor/config.go

+3
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ type WorkspaceConfig struct {
350350
ConfigcatEnabled bool `env:"GITPOD_CONFIGCAT_ENABLED"`
351351

352352
SSHGatewayCAPublicKey string `env:"GITPOD_SSH_CA_PUBLIC_KEY"`
353+
354+
// Comma-separated list of host:<base64ed user:password> pairs to authenticate against docker registries
355+
GitpodImageAuth string `env:"GITPOD_IMAGE_AUTH"`
353356
}
354357

355358
// WorkspaceGitpodToken is a list of tokens that should be added to supervisor's token service.

Diff for: components/supervisor/pkg/supervisor/docker.go

+128
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ package supervisor
66

77
import (
88
"context"
9+
"encoding/json"
910
"errors"
1011
"fmt"
1112
"io"
1213
"net"
1314
"os"
1415
"os/exec"
16+
"path/filepath"
1517
"strings"
1618
"sync"
1719
"syscall"
@@ -38,6 +40,9 @@ const (
3840

3941
logsDir = "/workspace/.gitpod/logs"
4042
dockerUpLogFilePath = logsDir + "/docker-up.log"
43+
44+
gitpodUserId = 33333
45+
gitpodGroupId = 33333
4146
)
4247

4348
var (
@@ -57,6 +62,17 @@ func socketActivationForDocker(parentCtx context.Context, wg *sync.WaitGroup, te
5762
return
5863
}
5964

65+
// insert credentials into docker config
66+
credentialsWritten, err := insertCredentialsIntoConfig(cfg.GitpodImageAuth)
67+
if err != nil {
68+
log.WithError(err).Warn("authentication: cannot write credentials to config")
69+
}
70+
if credentialsWritten > 0 {
71+
log.Info("authentication: successfully wrote credentials")
72+
} else {
73+
log.Info("authentication: no credentials provided")
74+
}
75+
6076
logFile, err := openDockerUpLogFile()
6177
if err != nil {
6278
log.WithError(err).Error("docker-up: cannot open log file")
@@ -285,3 +301,115 @@ func openDockerUpLogFile() (*os.File, error) {
285301
}
286302
return logFile, nil
287303
}
304+
305+
func insertCredentialsIntoConfig(imageAuth string) (int, error) {
306+
imageAuth = strings.TrimSpace(imageAuth)
307+
if imageAuth == "" {
308+
return 0, nil
309+
}
310+
311+
authConfig := DockerConfig{
312+
Auths: make(map[string]RegistryAuth),
313+
}
314+
authenticationPerHost := strings.Split(imageAuth, ",")
315+
for _, hostCredentials := range authenticationPerHost {
316+
parts := strings.SplitN(hostCredentials, ":", 2)
317+
if len(parts) < 2 {
318+
continue
319+
}
320+
host := parts[0]
321+
if strings.Contains(host, "docker.io") {
322+
host = "https://index.docker.io/v1/"
323+
}
324+
325+
authConfig.Auths[host] = RegistryAuth{
326+
Auth: parts[1],
327+
}
328+
}
329+
if len(authConfig.Auths) == 0 {
330+
return 0, nil
331+
}
332+
333+
err := insertDockerRegistryAuthentication(authConfig, gitpodUserId, gitpodGroupId)
334+
if err != nil {
335+
return 0, xerrors.Errorf("cannot append registry auth: %w", err)
336+
}
337+
338+
return len(authConfig.Auths), nil
339+
}
340+
341+
type RegistryAuth struct {
342+
Auth string `json:"auth"`
343+
}
344+
345+
type DockerConfig struct {
346+
Auths map[string]RegistryAuth `json:"auths"`
347+
}
348+
349+
// insertDockerRegistryAuthentication inserts the provided registry credentials to the existing Docker config file
350+
func insertDockerRegistryAuthentication(newConfig DockerConfig, uid, pid int) error {
351+
userHome, err := os.UserHomeDir()
352+
if err != nil {
353+
return fmt.Errorf("failed to determine user home directory: %w", err)
354+
}
355+
356+
dockerDir := filepath.Join(userHome, ".docker")
357+
if err := os.MkdirAll(dockerDir, 0744); err != nil {
358+
return fmt.Errorf("failed to create docker config directory: %w", err)
359+
}
360+
if err := os.Chown(dockerDir, uid, pid); err != nil {
361+
return fmt.Errorf("failed to change ownership of docker config directory: %w", err)
362+
}
363+
configPath := filepath.Join(dockerDir, "config.json")
364+
365+
// Read existing config if it exists
366+
var rawConfig map[string]interface{}
367+
existingBytes, err := os.ReadFile(configPath)
368+
if err != nil && !os.IsNotExist(err) {
369+
return fmt.Errorf("failed to read existing docker config: %w", err)
370+
}
371+
372+
var configCreated bool
373+
if len(existingBytes) > 0 {
374+
if err := json.Unmarshal(existingBytes, &rawConfig); err != nil {
375+
return fmt.Errorf("failed to parse existing docker config: %w", err)
376+
}
377+
} else {
378+
configCreated = true
379+
rawConfig = make(map[string]interface{})
380+
}
381+
382+
// Get existing auths or create new
383+
existingAuths := make(map[string]interface{})
384+
if authsRaw, ok := rawConfig["auths"]; ok {
385+
if authsMap, ok := authsRaw.(map[string]interface{}); ok {
386+
existingAuths = authsMap
387+
}
388+
}
389+
390+
// Merge new auth entries
391+
for registry, auth := range newConfig.Auths {
392+
// We overwrite existing registry entries
393+
existingAuths[registry] = auth
394+
}
395+
396+
// Update auths in raw config while preserving other fields
397+
rawConfig["auths"] = existingAuths
398+
399+
// Write merged config back to file
400+
bytes, err := json.MarshalIndent(rawConfig, "", " ")
401+
if err != nil {
402+
return fmt.Errorf("failed to marshal docker config: %w", err)
403+
}
404+
405+
if err := os.WriteFile(configPath, bytes, 0644); err != nil {
406+
return fmt.Errorf("failed to write docker config file: %w", err)
407+
}
408+
if configCreated {
409+
if err := os.Chown(configPath, uid, pid); err != nil {
410+
return fmt.Errorf("failed to change ownership of docker config file: %w", err)
411+
}
412+
}
413+
414+
return nil
415+
}

Diff for: components/supervisor/pkg/supervisor/docker_test.go

+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2025 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License.AGPL.txt in the project root for license information.
4+
5+
package supervisor
6+
7+
import (
8+
"encoding/json"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/google/go-cmp/cmp"
14+
)
15+
16+
func TestInsertRegistryAuth(t *testing.T) {
17+
type expectation struct {
18+
json string
19+
err string
20+
}
21+
tests := []struct {
22+
name string
23+
existingConfig string // JSON string of existing config.json
24+
inputConfig DockerConfig
25+
expect expectation
26+
}{
27+
{
28+
name: "append to empty config",
29+
existingConfig: "",
30+
inputConfig: DockerConfig{
31+
Auths: map[string]RegistryAuth{
32+
"reg1.example.com": {Auth: "dXNlcjE6cGFzczE="}, // user1:pass1
33+
},
34+
},
35+
expect: expectation{
36+
json: `{
37+
"auths": {
38+
"reg1.example.com": {"auth": "dXNlcjE6cGFzczE="}
39+
}
40+
}`,
41+
},
42+
},
43+
{
44+
name: "merge with existing config preserving other fields",
45+
existingConfig: `{
46+
"auths": {
47+
"reg1.example.com": {"auth": "existing=="}
48+
},
49+
"credsStore": "desktop",
50+
"experimental": "enabled",
51+
"stackOrchestrator": "swarm"
52+
}`,
53+
inputConfig: DockerConfig{
54+
Auths: map[string]RegistryAuth{
55+
"reg2.example.com": {Auth: "bmV3QXV0aA=="}, // newAuth
56+
},
57+
},
58+
expect: expectation{
59+
json: `{
60+
"auths": {
61+
"reg1.example.com": {"auth": "existing=="},
62+
"reg2.example.com": {"auth": "bmV3QXV0aA=="}
63+
},
64+
"credsStore": "desktop",
65+
"experimental": "enabled",
66+
"stackOrchestrator": "swarm"
67+
}`,
68+
},
69+
},
70+
{
71+
name: "override existing registry auth preserving structure",
72+
existingConfig: `{
73+
"auths": {
74+
"reg1.example.com": {"auth": "old=="}
75+
},
76+
"credHelpers": {
77+
"registry.example.com": "ecr-login"
78+
}
79+
}`,
80+
inputConfig: DockerConfig{
81+
Auths: map[string]RegistryAuth{
82+
"reg1.example.com": {Auth: "updated=="},
83+
},
84+
},
85+
expect: expectation{
86+
json: `{
87+
"auths": {
88+
"reg1.example.com": {"auth": "updated=="}
89+
},
90+
"credHelpers": {
91+
"registry.example.com": "ecr-login"
92+
}
93+
}`,
94+
},
95+
},
96+
{
97+
name: "invalid existing config json",
98+
existingConfig: `{invalid json`,
99+
inputConfig: DockerConfig{
100+
Auths: map[string]RegistryAuth{
101+
"reg1.example.com": {Auth: "dXNlcjE6cGFzczE="},
102+
},
103+
},
104+
expect: expectation{
105+
err: "failed to parse existing docker config: invalid character 'i' looking for beginning of object key string",
106+
},
107+
},
108+
}
109+
110+
for _, tc := range tests {
111+
t.Run(tc.name, func(t *testing.T) {
112+
// Create temp dir for test
113+
tmpDir, err := os.MkdirTemp("", "docker-test-*")
114+
if err != nil {
115+
t.Fatal(err)
116+
}
117+
defer os.RemoveAll(tmpDir)
118+
119+
// Set up mock home dir
120+
oldHome := os.Getenv("HOME")
121+
defer os.Setenv("HOME", oldHome)
122+
os.Setenv("HOME", tmpDir)
123+
124+
// Write existing config if any
125+
if tc.existingConfig != "" {
126+
configDir := filepath.Join(tmpDir, ".docker")
127+
if err := os.MkdirAll(configDir, 0700); err != nil {
128+
t.Fatal(err)
129+
}
130+
if err := os.WriteFile(
131+
filepath.Join(configDir, "config.json"),
132+
[]byte(tc.existingConfig),
133+
0600,
134+
); err != nil {
135+
t.Fatal(err)
136+
}
137+
}
138+
139+
// Run test
140+
err = insertDockerRegistryAuthentication(tc.inputConfig, 33333, 33333)
141+
if tc.expect.err != "" {
142+
if err == nil {
143+
t.Error("expected error but got none")
144+
}
145+
146+
if diff := cmp.Diff(tc.expect.err, err.Error()); diff != "" {
147+
t.Errorf("unexpected error (-want +got):\n%s", diff)
148+
}
149+
return
150+
}
151+
if err != nil {
152+
t.Fatalf("unexpected error: %v", err)
153+
}
154+
155+
// Read resulting config
156+
configBytes, err := os.ReadFile(filepath.Join(tmpDir, ".docker", "config.json"))
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
161+
// Compare JSON structural equality
162+
var got, want interface{}
163+
if err := json.Unmarshal(configBytes, &got); err != nil {
164+
t.Fatal(err)
165+
}
166+
if err := json.Unmarshal([]byte(tc.expect.json), &want); err != nil {
167+
t.Fatal(err)
168+
}
169+
170+
if diff := cmp.Diff(want, got); diff != "" {
171+
t.Errorf("unexpected config (-want +got):\n%s", diff)
172+
}
173+
})
174+
}
175+
}

Diff for: components/ws-manager-mk2/controllers/create.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,8 @@ func createWorkspaceEnvironment(sctx *startWorkspaceContext) ([]corev1.EnvVar, e
596596
"GITPOD_EXTERNAL_EXTENSIONS",
597597
"GITPOD_WORKSPACE_CLASS_INFO",
598598
"GITPOD_IDE_ALIAS",
599-
"GITPOD_RLIMIT_CORE":
599+
"GITPOD_RLIMIT_CORE",
600+
"GITPOD_IMAGE_AUTH":
600601
// these variables are allowed - don't skip them
601602
default:
602603
if strings.HasPrefix(e.Name, "GITPOD_") {

0 commit comments

Comments
 (0)