Repository navigation
Expand file tree
/
Copy pathtag_runtime_image_test.go
More file actions
223 lines (191 loc) · 8.91 KB
/
Copy pathtag_runtime_image_test.go
File metadata and controls
223 lines (191 loc) · 8.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
package main
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/require"
)
const (
taggedImage = "ghcr.io/codefly-dev/service-postgres@sha256:d8847e56"
taggedDigest = "sha256:d8847e56"
staleDigest = "sha256:5731ed01"
taggedTag = "ghcr.io/codefly-dev/service-postgres:postgres-17.10-pgvector-0.8.5-alpine3.24"
releaseTag = "ghcr.io/codefly-dev/service-postgres:v0.0.130"
)
// readBackBudget is the number of reads the script spends before it rules on
// the tag: one per propagation delay, plus the first read.
const readBackBudget = 8
// registry describes how ghcr.io answers reads of a tag just written: some
// reads failing outright, then some serving whatever digest the tag held
// before the push, then digest. The counts apply to each tag separately,
// because propagation is per tag.
type registry struct {
failedReads int
staleReads int
digest string
// unreadableTag never resolves, however many times it is read.
unreadableTag string
}
func TestTagRuntimeImagePublishesTheTagBeforeReadingItBack(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{digest: taggedDigest}, taggedTag)
require.NoErrorf(t, err, "output: %s", output)
calls := readCalls(t, log)
require.Equal(t, "buildx imagetools create --tag "+taggedTag+" "+taggedImage+" ", calls[0])
require.Len(t, calls, 2, "a tag that reads back correctly costs one read")
}
func TestTagRuntimeImageRetriesWhileTheTagCannotBeRead(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{failedReads: 2, digest: taggedDigest}, taggedTag)
require.NoErrorf(t, err, "output: %s", output)
require.Len(t, readCalls(t, log), 4)
}
// A propagation lag can serve the digest the tag held before the push just as
// easily as it can fail the read, so a mismatch is no more final than an
// unreadable tag until the budget is spent.
func TestTagRuntimeImageRetriesWhileTheTagServesAStaleDigest(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{staleReads: 2, digest: taggedDigest}, taggedTag)
require.NoErrorf(t, err, "output: %s", output)
require.Len(t, readCalls(t, log), 4)
}
func TestTagRuntimeImageFailsWhenTheTagNeverBecomesReadable(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{failedReads: readBackBudget + 1}, taggedTag)
require.Error(t, err)
require.Contains(t, output, taggedTag+" was published but did not resolve to a digest")
require.Len(t, readCalls(t, log), readBackBudget+1)
}
// The retry must still terminate: a tag that genuinely holds another digest —
// a concurrent build moved it — has to redden the job, naming what it found.
func TestTagRuntimeImageFailsWhenTheTagKeepsAnotherDigest(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{digest: staleDigest}, taggedTag)
require.Error(t, err)
require.Contains(t, output, taggedTag+" resolves to "+staleDigest+", want "+taggedDigest)
require.Len(t, readCalls(t, log), readBackBudget+1)
}
// Without timeout every read fails, and the retry would report that as a tag
// that never resolved rather than as the missing dependency it is.
func TestTagRuntimeImageFailsLoudlyWithoutTimeout(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
command := tagRuntimeImageCommand(t, log, registry{digest: taggedDigest}, taggedTag)
command.Env = append(command.Env, "PATH="+t.TempDir())
output, err := command.CombinedOutput()
require.Error(t, err)
require.Contains(t, string(output), "timeout is required to bound registry reads")
}
// The release publishes the version tag and the runtime tag together, so one
// create has to carry both — and both have to be read back.
func TestTagRuntimeImagePublishesEveryTagInOneCreate(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{digest: taggedDigest}, releaseTag, taggedTag)
require.NoErrorf(t, err, "output: %s", output)
calls := readCalls(t, log)
require.Equal(t,
"buildx imagetools create --tag "+releaseTag+" --tag "+taggedTag+" "+taggedImage+" ",
calls[0],
)
require.Len(t, calls, 3, "every tag is read back, not just the first")
}
// Every tag is retried, not just the first one.
func TestTagRuntimeImageRetriesEveryTagNotJustTheFirst(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{failedReads: 2, digest: taggedDigest}, releaseTag, taggedTag)
require.NoErrorf(t, err, "output: %s", output)
require.Len(t, readCalls(t, log), 7, "one create, then three reads of each tag")
}
// The delay schedule is spent across the tags, not restarted for each: by the
// time a later tag is first read, the waiting already done for the earlier ones
// has given it exactly that long to propagate. Restarting per tag would grow
// the worst case with the tag count while buying nothing.
func TestTagRuntimeImageSpendsOneScheduleAcrossEveryTag(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log,
registry{failedReads: 3, digest: taggedDigest, unreadableTag: taggedTag}, releaseTag, taggedTag)
require.Error(t, err)
require.Contains(t, output, taggedTag+" was published but did not resolve to a digest")
// Four reads resolve the first tag, spending three delays; the second tag
// inherits the cursor and so gets the four that remain, not a fresh seven.
require.Len(t, readCalls(t, log), 10,
"the second tag must inherit the schedule the first one spent")
}
func TestTagRuntimeImageNamesTheLaterTagThatNeverResolves(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log,
registry{digest: taggedDigest, unreadableTag: taggedTag}, releaseTag, taggedTag)
require.Error(t, err)
require.Contains(t, output, taggedTag+" was published but did not resolve to a digest")
require.NotContains(t, output, releaseTag+" was published")
require.Len(t, readCalls(t, log), readBackBudget+2,
"one create, one read of the tag that resolved, then the full budget on the one that did not")
}
// An empty later tag reached docker as --tag "" because the guard only covered
// the first argument, leaving the check asymmetric with the loop it guards.
func TestTagRuntimeImageRefusesAnEmptyLaterTag(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{digest: taggedDigest}, releaseTag, "")
require.Error(t, err)
require.Contains(t, output, "tag arguments must not be empty")
require.NoFileExists(t, log, "nothing may be published once an argument is rejected")
}
func TestTagRuntimeImageRefusesToPublishNothing(t *testing.T) {
log := filepath.Join(t.TempDir(), "docker.log")
output, err := runTagRuntimeImage(t, log, registry{digest: taggedDigest})
require.Error(t, err)
require.Contains(t, output, "at least one tag is required")
require.NoFileExists(t, log, "a caller without tags must publish nothing")
}
func runTagRuntimeImage(t *testing.T, log string, ghcr registry, tags ...string) (string, error) {
t.Helper()
output, err := tagRuntimeImageCommand(t, log, ghcr, tags...).CombinedOutput()
return string(output), err
}
func tagRuntimeImageCommand(t *testing.T, log string, ghcr registry, tags ...string) *exec.Cmd {
t.Helper()
command := exec.Command("bash", append([]string{".github/scripts/tag-runtime-image.sh"}, tags...)...)
command.Env = append(os.Environ(),
"PATH="+fakeDocker(t)+string(os.PathListSeparator)+os.Getenv("PATH"),
"DOCKER_LOG="+log,
"DOCKER_FAILED_READS="+strconv.Itoa(ghcr.failedReads),
"DOCKER_STALE_READS="+strconv.Itoa(ghcr.staleReads),
"DOCKER_STALE_DIGEST="+staleDigest,
"DOCKER_DIGEST="+ghcr.digest,
"DOCKER_UNREADABLE_TAG="+ghcr.unreadableTag,
"EXPECTED_DIGEST="+taggedDigest,
"RUNTIME_IMAGE="+taggedImage,
)
return command
}
// fakeDocker also shadows sleep, so the propagation budget does not become the
// test's runtime. Reads are counted per tag, the way a registry propagates them.
func fakeDocker(t *testing.T) string {
t.Helper()
bin := t.TempDir()
for name, source := range map[string]string{
"docker": `#!/usr/bin/env bash
set -euo pipefail
printf '%s ' "$@" >>"$DOCKER_LOG"
printf '\n' >>"$DOCKER_LOG"
if [[ "$3" != inspect ]]; then
exit 0
fi
read_number=$(grep -cF "imagetools inspect $4 " "$DOCKER_LOG")
if [[ "$4" == "$DOCKER_UNREADABLE_TAG" ]] || ((read_number <= DOCKER_FAILED_READS)); then
echo "ERROR: unexpected status from HEAD request to $4: 404 Not Found" >&2
exit 1
fi
digest=$DOCKER_DIGEST
if ((read_number <= DOCKER_FAILED_READS + DOCKER_STALE_READS)); then
digest=$DOCKER_STALE_DIGEST
fi
printf 'Name: %s\nDigest: %s\n' "$4" "$digest"
`,
"sleep": "#!/usr/bin/env bash\n",
} {
require.NoError(t, os.WriteFile(filepath.Join(bin, name), []byte(source), 0o755))
}
return bin
}