detached 65d845e

Merge branch 'main' into fix/managed-container-restart-policy

commit
65d845e0953acb5c0cc8be2c27acb3185c55b538
author
brice · · 2026-05-05 07:05 UTC
merge of
ecdfc27 + 3a157b3 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +145 -17
M internal/adapters/out/docker/runtime.go +80 -12
@@ -11,6 +11,7 @@ import (
1111 "fmt"
1212 "io"
1313 "math"
14+ "os"
1415 "path/filepath"
1516 "sort"
1617 "strconv"
@@ -49,6 +50,11 @@ type pipeReadCloser struct {
4950 once sync.Once
5051 }
5152
53+type removeOnCloseFile struct {
54+ *os.File
55+ path string
56+}
57+
5258 func (p *pipeReadCloser) Read(b []byte) (int, error) {
5359 return p.pr.Read(b)
5460 }
@@ -71,6 +77,15 @@ func (p *pipeReadCloser) Close() error {
7177 return closeErr
7278 }
7379
80+func (f *removeOnCloseFile) Close() error {
81+ closeErr := f.File.Close()
82+ removeErr := os.Remove(f.path)
83+ if closeErr != nil {
84+ return closeErr
85+ }
86+ return removeErr
87+}
88+
7489 // NewRuntime creates a new Docker runtime instance.
7590 func NewRuntime() (*Runtime, error) {
7691 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
@@ -1508,6 +1523,9 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
15081523 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
15091524 tr := tar.NewReader(reader)
15101525 targetName := normalizedTarPath(targetPath)
1526+ targetBase := filepath.Base(targetName)
1527+ var fallback *removeOnCloseFile
1528+ fallbackMatches := 0
15111529
15121530 for {
15131531 header, err := tr.Next()
@@ -1515,29 +1533,79 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15151533 break
15161534 }
15171535 if err != nil {
1536+ cleanupFallback(fallback)
1537+ _ = reader.Close()
15181538 return nil, err
15191539 }
1540+ if header.Typeflag != tar.TypeReg {
1541+ continue
1542+ }
15201543
1521- if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {
1522- size := header.Size
1523- pr, pw := io.Pipe()
1524- go func() {
1525- defer reader.Close()
1526- _, copyErr := io.CopyN(pw, tr, size)
1527- if copyErr != nil {
1528- _ = pw.CloseWithError(copyErr)
1529- return
1544+ headerName := normalizedTarPath(header.Name)
1545+ if headerName == targetName {
1546+ cleanupFallback(fallback)
1547+ return streamCurrentTarFile(reader, tr, header.Size), nil
1548+ }
1549+ if headerName == targetBase {
1550+ fallbackMatches++
1551+ if fallbackMatches == 1 {
1552+ fallback, err = bufferCurrentTarFile(tr, header.Size)
1553+ if err != nil {
1554+ _ = reader.Close()
1555+ return nil, err
15301556 }
1531- _ = pw.Close()
1532- }()
1533- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1557+ }
15341558 }
15351559 }
15361560
15371561 _ = reader.Close()
1562+ if fallbackMatches == 1 {
1563+ if _, err := fallback.Seek(0, io.SeekStart); err != nil {
1564+ cleanupFallback(fallback)
1565+ return nil, err
1566+ }
1567+ return fallback, nil
1568+ }
1569+ cleanupFallback(fallback)
1570+ if fallbackMatches > 1 {
1571+ return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath)
1572+ }
15381573 return nil, fmt.Errorf("file not found in container: %s", targetPath)
15391574 }
15401575
1576+func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
1577+ pr, pw := io.Pipe()
1578+ go func() {
1579+ defer reader.Close()
1580+ _, copyErr := io.CopyN(pw, tr, size)
1581+ if copyErr != nil {
1582+ _ = pw.CloseWithError(copyErr)
1583+ return
1584+ }
1585+ _ = pw.Close()
1586+ }()
1587+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}
1588+}
1589+
1590+func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) {
1591+ file, err := os.CreateTemp("", "gordon-copy-from-container-*")
1592+ if err != nil {
1593+ return nil, err
1594+ }
1595+ fallback := &removeOnCloseFile{File: file, path: file.Name()}
1596+ if _, err := io.CopyN(fallback, tr, size); err != nil {
1597+ cleanupFallback(fallback)
1598+ return nil, err
1599+ }
1600+ return fallback, nil
1601+}
1602+
1603+func cleanupFallback(fallback *removeOnCloseFile) {
1604+ if fallback != nil {
1605+ _ = fallback.Close()
1606+ }
1607+}
1608+
15411609 func normalizedTarPath(path string) string {
15421610 path = filepath.ToSlash(filepath.Clean(path))
15431611 return strings.TrimPrefix(path, "/")
@@ -11,6 +11,7 @@ import (
11 "fmt"11 "fmt"
12 "io"12 "io"
13 "math"13 "math"
14+ "os"
14 "path/filepath"15 "path/filepath"
15 "sort"16 "sort"
16 "strconv"17 "strconv"
@@ -49,6 +50,11 @@ type pipeReadCloser struct {
49 once sync.Once50 once sync.Once
50 }51 }
51 52
53+type removeOnCloseFile struct {
54+ *os.File
55+ path string
56+}
57+
52 func (p *pipeReadCloser) Read(b []byte) (int, error) {58 func (p *pipeReadCloser) Read(b []byte) (int, error) {
53 return p.pr.Read(b)59 return p.pr.Read(b)
54 }60 }
@@ -71,6 +77,15 @@ func (p *pipeReadCloser) Close() error {
71 return closeErr77 return closeErr
72 }78 }
73 79
80+func (f *removeOnCloseFile) Close() error {
81+ closeErr := f.File.Close()
82+ removeErr := os.Remove(f.path)
83+ if closeErr != nil {
84+ return closeErr
85+ }
86+ return removeErr
87+}
88+
74 // NewRuntime creates a new Docker runtime instance.89 // NewRuntime creates a new Docker runtime instance.
75 func NewRuntime() (*Runtime, error) {90 func NewRuntime() (*Runtime, error) {
76 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())91 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
@@ -1508,6 +1523,9 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
1508 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {1523 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
1509 tr := tar.NewReader(reader)1524 tr := tar.NewReader(reader)
1510 targetName := normalizedTarPath(targetPath)1525 targetName := normalizedTarPath(targetPath)
1526+ targetBase := filepath.Base(targetName)
1527+ var fallback *removeOnCloseFile
1528+ fallbackMatches := 0
1511 1529
1512 for {1530 for {
1513 header, err := tr.Next()1531 header, err := tr.Next()
@@ -1515,29 +1533,79 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1515 break1533 break
1516 }1534 }
1517 if err != nil {1535 if err != nil {
1536+ cleanupFallback(fallback)
1537+ _ = reader.Close()
1518 return nil, err1538 return nil, err
1519 }1539 }
1540+ if header.Typeflag != tar.TypeReg {
1541+ continue
1542+ }
1520 1543
1521- if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {1544+ headerName := normalizedTarPath(header.Name)
1522- size := header.Size1545+ if headerName == targetName {
1523- pr, pw := io.Pipe()1546+ cleanupFallback(fallback)
1524- go func() {1547+ return streamCurrentTarFile(reader, tr, header.Size), nil
1525- defer reader.Close()1548+ }
1526- _, copyErr := io.CopyN(pw, tr, size)1549+ if headerName == targetBase {
1527- if copyErr != nil {1550+ fallbackMatches++
1528- _ = pw.CloseWithError(copyErr)1551+ if fallbackMatches == 1 {
1529- return1552+ fallback, err = bufferCurrentTarFile(tr, header.Size)
1553+ if err != nil {
1554+ _ = reader.Close()
1555+ return nil, err
1530 }1556 }
1531- _ = pw.Close()1557+ }
1532- }()
1533- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1534 }1558 }
1535 }1559 }
1536 1560
1537 _ = reader.Close()1561 _ = reader.Close()
1562+ if fallbackMatches == 1 {
1563+ if _, err := fallback.Seek(0, io.SeekStart); err != nil {
1564+ cleanupFallback(fallback)
1565+ return nil, err
1566+ }
1567+ return fallback, nil
1568+ }
1569+ cleanupFallback(fallback)
1570+ if fallbackMatches > 1 {
1571+ return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath)
1572+ }
1538 return nil, fmt.Errorf("file not found in container: %s", targetPath)1573 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1539 }1574 }
1540 1575
1576+func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser {
1577+ pr, pw := io.Pipe()
1578+ go func() {
1579+ defer reader.Close()
1580+ _, copyErr := io.CopyN(pw, tr, size)
1581+ if copyErr != nil {
1582+ _ = pw.CloseWithError(copyErr)
1583+ return
1584+ }
1585+ _ = pw.Close()
1586+ }()
1587+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}
1588+}
1589+
1590+func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) {
1591+ file, err := os.CreateTemp("", "gordon-copy-from-container-*")
1592+ if err != nil {
1593+ return nil, err
1594+ }
1595+ fallback := &removeOnCloseFile{File: file, path: file.Name()}
1596+ if _, err := io.CopyN(fallback, tr, size); err != nil {
1597+ cleanupFallback(fallback)
1598+ return nil, err
1599+ }
1600+ return fallback, nil
1601+}
1602+
1603+func cleanupFallback(fallback *removeOnCloseFile) {
1604+ if fallback != nil {
1605+ _ = fallback.Close()
1606+ }
1607+}
1608+
1541 func normalizedTarPath(path string) string {1609 func normalizedTarPath(path string) string {
1542 path = filepath.ToSlash(filepath.Clean(path))1610 path = filepath.ToSlash(filepath.Clean(path))
1543 return strings.TrimPrefix(path, "/")1611 return strings.TrimPrefix(path, "/")
M internal/adapters/out/docker/runtime_exec_test.go +65 -5
@@ -54,15 +54,42 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
5454 func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
5555 var buf bytes.Buffer
5656 tw := tar.NewWriter(&buf)
57- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))}))
58- _, err := tw.Write([]byte("wrong"))
57+ require.NoError(t, writeTarFile(tw, "other/.env", "wrong"))
58+ require.NoError(t, writeTarFile(tw, "app/.env", "right"))
59+ require.NoError(t, tw.Close())
60+
61+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
5962 require.NoError(t, err)
60- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))}))
61- _, err = tw.Write([]byte("right"))
63+ defer rc.Close()
64+
65+ data, err := io.ReadAll(rc)
6266 require.NoError(t, err)
67+ assert.Equal(t, "right", string(data))
68+}
69+
70+func TestExtractFileFromTarMatchesBasenameForCopiedFile(t *testing.T) {
71+ var buf bytes.Buffer
72+ tw := tar.NewWriter(&buf)
73+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "backup data"))
6374 require.NoError(t, tw.Close())
6475
65- rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
76+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
77+ require.NoError(t, err)
78+ defer rc.Close()
79+
80+ data, err := io.ReadAll(rc)
81+ require.NoError(t, err)
82+ assert.Equal(t, "backup data", string(data))
83+}
84+
85+func TestExtractFileFromTarPrefersExactPathOverBasename(t *testing.T) {
86+ var buf bytes.Buffer
87+ tw := tar.NewWriter(&buf)
88+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "wrong"))
89+ require.NoError(t, writeTarFile(tw, "tmp/gordon-backup-test.bak", "right"))
90+ require.NoError(t, tw.Close())
91+
92+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
6693 require.NoError(t, err)
6794 defer rc.Close()
6895
@@ -71,6 +98,39 @@ func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
7198 assert.Equal(t, "right", string(data))
7299 }
73100
101+func TestExtractFileFromTarRejectsAmbiguousBasenameFallback(t *testing.T) {
102+ var buf bytes.Buffer
103+ tw := tar.NewWriter(&buf)
104+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "first"))
105+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "second"))
106+ require.NoError(t, tw.Close())
107+
108+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
109+ require.Error(t, err)
110+ assert.Contains(t, err.Error(), "ambiguous")
111+ assert.Nil(t, rc)
112+}
113+
114+func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) {
115+ var buf bytes.Buffer
116+ tw := tar.NewWriter(&buf)
117+ require.NoError(t, writeTarFile(tw, "other/gordon-backup-test.bak", "wrong"))
118+ require.NoError(t, tw.Close())
119+
120+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "file not found")
123+ assert.Nil(t, rc)
124+}
125+
126+func writeTarFile(tw *tar.Writer, name, content string) error {
127+ if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(content))}); err != nil {
128+ return err
129+ }
130+ _, err := tw.Write([]byte(content))
131+ return err
132+}
133+
74134 func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {
75135 data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))
76136 require.Error(t, err)
@@ -54,15 +54,42 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
54 func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {54 func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
55 var buf bytes.Buffer55 var buf bytes.Buffer
56 tw := tar.NewWriter(&buf)56 tw := tar.NewWriter(&buf)
57- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))}))57+ require.NoError(t, writeTarFile(tw, "other/.env", "wrong"))
58- _, err := tw.Write([]byte("wrong"))58+ require.NoError(t, writeTarFile(tw, "app/.env", "right"))
59+ require.NoError(t, tw.Close())
60+
61+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
59 require.NoError(t, err)62 require.NoError(t, err)
60- require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))}))63+ defer rc.Close()
61- _, err = tw.Write([]byte("right"))64+
65+ data, err := io.ReadAll(rc)
62 require.NoError(t, err)66 require.NoError(t, err)
67+ assert.Equal(t, "right", string(data))
68+}
69+
70+func TestExtractFileFromTarMatchesBasenameForCopiedFile(t *testing.T) {
71+ var buf bytes.Buffer
72+ tw := tar.NewWriter(&buf)
73+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "backup data"))
63 require.NoError(t, tw.Close())74 require.NoError(t, tw.Close())
64 75
65- rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")76+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
77+ require.NoError(t, err)
78+ defer rc.Close()
79+
80+ data, err := io.ReadAll(rc)
81+ require.NoError(t, err)
82+ assert.Equal(t, "backup data", string(data))
83+}
84+
85+func TestExtractFileFromTarPrefersExactPathOverBasename(t *testing.T) {
86+ var buf bytes.Buffer
87+ tw := tar.NewWriter(&buf)
88+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "wrong"))
89+ require.NoError(t, writeTarFile(tw, "tmp/gordon-backup-test.bak", "right"))
90+ require.NoError(t, tw.Close())
91+
92+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
66 require.NoError(t, err)93 require.NoError(t, err)
67 defer rc.Close()94 defer rc.Close()
68 95
@@ -71,6 +98,39 @@ func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
71 assert.Equal(t, "right", string(data))98 assert.Equal(t, "right", string(data))
72 }99 }
73 100
101+func TestExtractFileFromTarRejectsAmbiguousBasenameFallback(t *testing.T) {
102+ var buf bytes.Buffer
103+ tw := tar.NewWriter(&buf)
104+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "first"))
105+ require.NoError(t, writeTarFile(tw, "gordon-backup-test.bak", "second"))
106+ require.NoError(t, tw.Close())
107+
108+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
109+ require.Error(t, err)
110+ assert.Contains(t, err.Error(), "ambiguous")
111+ assert.Nil(t, rc)
112+}
113+
114+func TestExtractFileFromTarDoesNotMatchBasenameInNestedDirectory(t *testing.T) {
115+ var buf bytes.Buffer
116+ tw := tar.NewWriter(&buf)
117+ require.NoError(t, writeTarFile(tw, "other/gordon-backup-test.bak", "wrong"))
118+ require.NoError(t, tw.Close())
119+
120+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/tmp/gordon-backup-test.bak")
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "file not found")
123+ assert.Nil(t, rc)
124+}
125+
126+func writeTarFile(tw *tar.Writer, name, content string) error {
127+ if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(content))}); err != nil {
128+ return err
129+ }
130+ _, err := tw.Write([]byte(content))
131+ return err
132+}
133+
74 func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {134 func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {
75 data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))135 data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))
76 require.Error(t, err)136 require.Error(t, err)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close