Skip to content

Commit 569d5de

Browse files
authored
feat: add support for copying certs to dockerd's registry directory (#101)
1 parent 7f3cd37 commit 569d5de

File tree

10 files changed

+412
-39
lines changed

10 files changed

+412
-39
lines changed

cli/clitest/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func New(t *testing.T, cmd string, args ...string) (context.Context, *cobra.Comm
4949

5050
var (
5151
execer = NewFakeExecer()
52-
fs = NewMemFS()
52+
fs = xunixfake.NewMemFS()
5353
mnt = &mount.FakeMounter{}
5454
client = NewFakeDockerClient()
5555
iface = GetNetLink(t)

cli/clitest/fake.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,13 @@ import (
88
"strings"
99

1010
dockertypes "github.com/docker/docker/api/types"
11-
"github.com/spf13/afero"
1211
testingexec "k8s.io/utils/exec/testing"
1312

1413
"github.com/coder/envbox/dockerutil"
1514
"github.com/coder/envbox/dockerutil/dockerfake"
1615
"github.com/coder/envbox/xunix/xunixfake"
1716
)
1817

19-
func NewMemFS() *xunixfake.MemFS {
20-
return &xunixfake.MemFS{
21-
MemMapFs: &afero.MemMapFs{},
22-
Owner: map[string]xunixfake.FileOwner{},
23-
}
24-
}
25-
2618
func NewFakeExecer() *xunixfake.FakeExec {
2719
return &xunixfake.FakeExec{
2820
Commands: map[string]*xunixfake.FakeCmd{},

cli/docker.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,20 +157,20 @@ func dockerCmd() *cobra.Command {
157157
RunE: func(cmd *cobra.Command, args []string) (err error) {
158158
var (
159159
ctx = cmd.Context()
160-
log = slog.Make(slogjson.Sink(io.Discard))
161-
blog buildlog.Logger = buildlog.NopLogger{}
160+
log = slog.Make(slogjson.Sink(cmd.ErrOrStderr()), slogkubeterminate.Make()).Leveled(slog.LevelDebug)
161+
blog buildlog.Logger = buildlog.JSONLogger{Encoder: json.NewEncoder(os.Stderr)}
162162
)
163163

164+
if flags.noStartupLogs {
165+
log = slog.Make(slogjson.Sink(io.Discard))
166+
blog = buildlog.NopLogger{}
167+
}
168+
164169
httpClient, err := xhttp.Client(log, flags.extraCertsPath)
165170
if err != nil {
166171
return xerrors.Errorf("http client: %w", err)
167172
}
168173

169-
if !flags.noStartupLogs {
170-
log = slog.Make(slogjson.Sink(cmd.ErrOrStderr()), slogkubeterminate.Make()).Leveled(slog.LevelDebug)
171-
blog = buildlog.JSONLogger{Encoder: json.NewEncoder(os.Stderr)}
172-
}
173-
174174
if !flags.noStartupLogs && flags.agentToken != "" && flags.coderURL != "" {
175175
coderURL, err := url.Parse(flags.coderURL)
176176
if err != nil {
@@ -197,7 +197,6 @@ func dockerCmd() *cobra.Command {
197197
}
198198
}(&err)
199199

200-
blog.Info("Waiting for dockerd to startup...")
201200
sysboxArgs := []string{}
202201
if flags.disableIDMappedMount {
203202
sysboxArgs = append(sysboxArgs, "--disable-idmapped-mount")
@@ -231,6 +230,7 @@ func dockerCmd() *cobra.Command {
231230

232231
log.Debug(ctx, "starting dockerd", slog.F("args", args))
233232

233+
blog.Info("Waiting for sysbox processes to startup...")
234234
dockerd := background.New(ctx, log, "dockerd", dargs...)
235235
err = dockerd.Start()
236236
if err != nil {
@@ -289,11 +289,32 @@ func dockerCmd() *cobra.Command {
289289
// We wait for the daemon after spawning the goroutine in case
290290
// startup causes the daemon to encounter encounter a 'no space left
291291
// on device' error.
292+
blog.Info("Waiting for dockerd to startup...")
292293
err = dockerutil.WaitForDaemon(ctx, client)
293294
if err != nil {
294295
return xerrors.Errorf("wait for dockerd: %w", err)
295296
}
296297

298+
if flags.extraCertsPath != "" {
299+
// Parse the registry from the inner image
300+
registry, err := name.ParseReference(flags.innerImage)
301+
if err != nil {
302+
return xerrors.Errorf("invalid image: %w", err)
303+
}
304+
registryName := registry.Context().RegistryStr()
305+
306+
// Write certificates for the registry
307+
err = dockerutil.WriteCertsForRegistry(ctx, registryName, flags.extraCertsPath)
308+
if err != nil {
309+
return xerrors.Errorf("write certs for registry: %w", err)
310+
}
311+
312+
blog.Infof("Successfully copied certificates from %q to %q", flags.extraCertsPath, filepath.Join("/etc/docker/certs.d", registryName))
313+
log.Debug(ctx, "wrote certificates for registry", slog.F("registry", registryName),
314+
slog.F("extra_certs_path", flags.extraCertsPath),
315+
)
316+
}
317+
297318
err = runDockerCVM(ctx, log, client, blog, flags)
298319
if err != nil {
299320
// It's possible we failed because we ran out of disk while

dockerutil/image.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"strings"
910
"time"
1011

1112
dockertypes "github.com/docker/docker/api/types"
@@ -65,6 +66,14 @@ func PullImage(ctx context.Context, config *PullImageConfig) error {
6566
}
6667

6768
err = pullImageFn()
69+
// We should bail early if we're going to fail due to a
70+
// certificate error. We can't xerrors.As here since this is
71+
// returned from the daemon so the client is reporting
72+
// essentially an unwrapped error.
73+
if isTLSVerificationErr(err) {
74+
return err
75+
}
76+
6877
if err == nil {
6978
return nil
7079
}
@@ -253,3 +262,7 @@ func DefaultLogImagePullFn(log buildlog.Logger) func(ImagePullEvent) error {
253262
return nil
254263
}
255264
}
265+
266+
func isTLSVerificationErr(err error) bool {
267+
return err != nil && strings.Contains(err.Error(), "tls: failed to verify certificate: x509: certificate signed by unknown authority")
268+
}

dockerutil/registry.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package dockerutil
2+
3+
import (
4+
"context"
5+
"io"
6+
"path/filepath"
7+
8+
"github.com/spf13/afero"
9+
"golang.org/x/xerrors"
10+
11+
"github.com/coder/envbox/xunix"
12+
)
13+
14+
// WriteCertsForRegistry writes the certificates found in the provided directory
15+
// to the correct subdirectory that the Docker daemon uses when pulling images
16+
// from the specified private registry.
17+
func WriteCertsForRegistry(ctx context.Context, registryName, certsDir string) error {
18+
fs := xunix.GetFS(ctx)
19+
20+
// Docker certs directory.
21+
registryCertsDir := filepath.Join("/etc/docker/certs.d", registryName)
22+
23+
// If the directory already exists it means someone
24+
// has either wrapped the image or has mounted in certs
25+
// manually. We should assume the user knows what they're
26+
// doing and avoid mucking with their solution.
27+
if _, err := fs.Stat(registryCertsDir); err == nil {
28+
return nil
29+
}
30+
31+
// Ensure the registry certs directory exists.
32+
err := fs.MkdirAll(registryCertsDir, 0o755)
33+
if err != nil {
34+
return xerrors.Errorf("create registry certs directory: %w", err)
35+
}
36+
37+
// Check if certsDir is a file.
38+
fileInfo, err := fs.Stat(certsDir)
39+
if err != nil {
40+
return xerrors.Errorf("stat certs directory/file: %w", err)
41+
}
42+
43+
if !fileInfo.IsDir() {
44+
// If it's a file, copy it directly
45+
err = copyCertFile(fs, certsDir, filepath.Join(registryCertsDir, "ca.crt"))
46+
if err != nil {
47+
return xerrors.Errorf("copy cert file: %w", err)
48+
}
49+
return nil
50+
}
51+
52+
// If it's a directory, copy all cert files in the root of the directory
53+
entries, err := afero.ReadDir(fs, certsDir)
54+
if err != nil {
55+
return xerrors.Errorf("read certs directory: %w", err)
56+
}
57+
58+
for _, entry := range entries {
59+
if entry.IsDir() {
60+
continue
61+
}
62+
srcPath := filepath.Join(certsDir, entry.Name())
63+
dstPath := filepath.Join(registryCertsDir, entry.Name())
64+
err = copyCertFile(fs, srcPath, dstPath)
65+
if err != nil {
66+
return xerrors.Errorf("copy cert file %s: %w", entry.Name(), err)
67+
}
68+
}
69+
70+
return nil
71+
}
72+
73+
func copyCertFile(fs xunix.FS, src, dst string) error {
74+
srcFile, err := fs.Open(src)
75+
if err != nil {
76+
return xerrors.Errorf("open source file: %w", err)
77+
}
78+
defer srcFile.Close()
79+
80+
dstFile, err := fs.Create(dst)
81+
if err != nil {
82+
return xerrors.Errorf("create destination file: %w", err)
83+
}
84+
defer dstFile.Close()
85+
86+
_, err = io.Copy(dstFile, srcFile)
87+
if err != nil {
88+
return xerrors.Errorf("copy file contents: %w", err)
89+
}
90+
91+
return nil
92+
}

dockerutil/registry_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package dockerutil_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/spf13/afero"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/coder/envbox/dockerutil"
14+
"github.com/coder/envbox/xunix"
15+
"github.com/coder/envbox/xunix/xunixfake"
16+
)
17+
18+
func TestWriteCertsForRegistry(t *testing.T) {
19+
t.Parallel()
20+
21+
t.Run("SingleCertFile", func(t *testing.T) {
22+
t.Parallel()
23+
// Test setup
24+
fs := xunixfake.NewMemFS()
25+
ctx := xunix.WithFS(context.Background(), fs)
26+
27+
// Create a test certificate file
28+
certContent := []byte("test certificate content")
29+
err := afero.WriteFile(fs, "/certs/ca.crt", certContent, 0o644)
30+
require.NoError(t, err)
31+
32+
// Run the function
33+
err = dockerutil.WriteCertsForRegistry(ctx, "test.registry.com", "/certs/ca.crt")
34+
require.NoError(t, err)
35+
36+
// Check the result
37+
copiedContent, err := afero.ReadFile(fs, "/etc/docker/certs.d/test.registry.com/ca.crt")
38+
require.NoError(t, err)
39+
assert.Equal(t, certContent, copiedContent)
40+
})
41+
42+
t.Run("MultipleCertFiles", func(t *testing.T) {
43+
t.Parallel()
44+
// Test setup
45+
fs := xunixfake.NewMemFS()
46+
ctx := xunix.WithFS(context.Background(), fs)
47+
48+
// Create test certificate files
49+
certFiles := []string{"ca.crt", "client.cert", "client.key"}
50+
for _, file := range certFiles {
51+
err := afero.WriteFile(fs, filepath.Join("/certs", file), []byte("content of "+file), 0o644)
52+
require.NoError(t, err)
53+
}
54+
55+
// Run the function
56+
err := dockerutil.WriteCertsForRegistry(ctx, "test.registry.com", "/certs")
57+
require.NoError(t, err)
58+
59+
// Check the results
60+
for _, file := range certFiles {
61+
copiedContent, err := afero.ReadFile(fs, filepath.Join("/etc/docker/certs.d/test.registry.com", file))
62+
require.NoError(t, err)
63+
assert.Equal(t, []byte("content of "+file), copiedContent)
64+
}
65+
})
66+
t.Run("ExistingRegistryCertsDir", func(t *testing.T) {
67+
t.Parallel()
68+
// Test setup
69+
fs := xunixfake.NewMemFS()
70+
ctx := xunix.WithFS(context.Background(), fs)
71+
72+
// Create an existing registry certs directory
73+
registryCertsDir := "/etc/docker/certs.d/test.registry.com"
74+
err := fs.MkdirAll(registryCertsDir, 0o755)
75+
require.NoError(t, err)
76+
77+
// Create a file in the existing directory
78+
existingContent := []byte("existing certificate content")
79+
err = afero.WriteFile(fs, filepath.Join(registryCertsDir, "existing.crt"), existingContent, 0o644)
80+
require.NoError(t, err)
81+
82+
// Create a test certificate file in the source directory
83+
certContent := []byte("new certificate content")
84+
err = afero.WriteFile(fs, "/certs/ca.crt", certContent, 0o644)
85+
require.NoError(t, err)
86+
87+
// Run the function
88+
err = dockerutil.WriteCertsForRegistry(ctx, "test.registry.com", "/certs")
89+
require.NoError(t, err)
90+
91+
// Check that the existing file was not modified
92+
existingFileContent, err := afero.ReadFile(fs, filepath.Join(registryCertsDir, "existing.crt"))
93+
require.NoError(t, err)
94+
assert.Equal(t, existingContent, existingFileContent)
95+
96+
// Check that the new file was not copied
97+
_, err = fs.Stat(filepath.Join(registryCertsDir, "ca.crt"))
98+
assert.True(t, os.IsNotExist(err), "New certificate file should not have been copied")
99+
})
100+
}

0 commit comments

Comments
 (0)