Skip to content

Commit 2e50cfb

Browse files
committed
image build: match pre-pulling corner case to buildah
When a containerfile contains stages with duplicate names, buildah's behavior is weird: containers/buildah#6731 Implement the same behavior for now. If buildah adjusts the behavior, our integration tests will catch it and we can adjust accordingly. Signed-off-by: Adam Cmiel <acmiel@redhat.com>
1 parent 5006ec5 commit 2e50cfb

File tree

3 files changed

+119
-45
lines changed

3 files changed

+119
-45
lines changed

integration_tests/build_test.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,7 +1556,15 @@ RUN echo "this instruction also creates an intermediate layer" > /tmp/bar.txt
15561556
contextDir := setupTestContext(t)
15571557

15581558
writeContainerfile(contextDir, fmt.Sprintf(`
1559-
# Real base image used for an earlier stage
1559+
# This stage is unused, because the second stage1 overrides it
1560+
FROM scratch AS stage1
1561+
1562+
LABEL stage1.label=this-stage-is-unused
1563+
LABEL common.build.label=this-stage-is-unused
1564+
LABEL common.label=this-stage-is-unused
1565+
1566+
1567+
# Real base image
15601568
FROM %s AS stage1
15611569
15621570
LABEL stage1.label=value-gets-overriden
@@ -2054,6 +2062,14 @@ FROM scratch AS stage1
20542062
LABEL stage1.label=label-from-stage1
20552063
LABEL common.label=common-stage1
20562064
2065+
2066+
# --target matches the *first* stage with a matching name, so this is skipped
2067+
FROM base.image.does.not/exist:latest AS stage1
2068+
2069+
LABEL stage1.label=unused-stage
2070+
2071+
2072+
# (this is skipped too, the stage is unnamed)
20572073
FROM stage1
20582074
20592075
LABEL stage2.label=label-from-stage2
@@ -2256,16 +2272,25 @@ RUN echo > /dev/udp/127.0.0.1/9
22562272
baseImage2 := createBaseImage("base2", 2048, "scratch")
22572273
baseImage3 := createBaseImage("base3", 4096, "scratch")
22582274
realBaseImage := createBaseImage("realbase", 8192, baseImage)
2275+
unusedBaseImage := createBaseImage("unused", 16384, baseImage)
22592276

22602277
r := strings.NewReplacer(
22612278
"{baseImage1}", baseImage1,
22622279
"{baseImage2}", baseImage2,
22632280
"{baseImage3}", baseImage3,
22642281
"{realBaseImage}", realBaseImage,
2282+
"{unusedBaseImage}", unusedBaseImage,
22652283
)
22662284

22672285
contextDir := setupTestContext(t)
22682286
writeContainerfile(contextDir, r.Replace(`
2287+
# This base image is "unused" because the second base1 overrides this stage.
2288+
# However, buildah will build *both* of the base1 stages, so we have to pre-pull both of the images.
2289+
FROM {unusedBaseImage} AS base1
2290+
2291+
RUN echo "the unused stage WAS built"
2292+
2293+
22692294
FROM {baseImage1} AS base1
22702295
22712296
# COPY directly from an image
@@ -2312,14 +2337,17 @@ RUN cp /random-data.bin /data/realBaseImage.bin
23122337

23132338
container := setupBuildContainerWithCleanup(t, buildParams, imageRegistry)
23142339

2315-
_, _, err := runBuildWithOutput(container, buildParams)
2340+
stdout, _, err := runBuildWithOutput(container, buildParams)
23162341
// Main check: no error (would fail without pre-pulling)
23172342
Expect(err).ToNot(HaveOccurred())
23182343

2319-
// Secondary check: verify that the correct base was pulled for each FROM/from
2344+
// Verify that buildah really did build the unused stage (otherwise we wasted a pull)
2345+
Expect(stdout).To(ContainSubstring("the unused stage WAS built"))
2346+
2347+
// Verify that the correct base was pulled for each FROM/from
23202348
// by checking the sizes of the random-data files
2321-
stdout := runWithMountedOutputImage(container, outputRef, "cd $CONTAINER_ROOT; du -b data/*")
2322-
Expect(stdout).To(SatisfyAll(
2349+
stdout2 := runWithMountedOutputImage(container, outputRef, "cd $CONTAINER_ROOT; du -b data/*")
2350+
Expect(stdout2).To(SatisfyAll(
23232351
ContainSubstring("1024\tdata/baseImage1.bin"),
23242352
ContainSubstring("2048\tdata/baseImage2.bin"),
23252353
ContainSubstring("4096\tdata/baseImage3.bin"),

pkg/commands/build.go

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,8 +1168,9 @@ func (c *Build) prePullBaseImages(df *dockerfile.Dockerfile) error {
11681168

11691169
var targetStage int
11701170
if c.Params.Target != "" {
1171-
if idx, ok := resolveStageRef(df.Stages, c.Params.Target); ok {
1172-
targetStage = idx
1171+
if stages, ok := findMatchingStages(df.Stages, c.Params.Target); ok {
1172+
// buildah's --target matches the first stage with a matching name
1173+
targetStage = stages[0]
11731174
} else {
11741175
return fmt.Errorf("target stage %q not found", c.Params.Target)
11751176
}
@@ -1203,10 +1204,12 @@ func (c *Build) collectBaseImages(df *dockerfile.Dockerfile, targetStage int) []
12031204
stagesToProcess := []int{}
12041205
stagesSeen := make(map[int]struct{})
12051206

1206-
enqueue := func(stageIdx int) {
1207-
if _, seen := stagesSeen[stageIdx]; !seen {
1208-
stagesSeen[stageIdx] = struct{}{}
1209-
stagesToProcess = append(stagesToProcess, stageIdx)
1207+
enqueue := func(stageIndexes ...int) {
1208+
for _, stageIdx := range stageIndexes {
1209+
if _, seen := stagesSeen[stageIdx]; !seen {
1210+
stagesSeen[stageIdx] = struct{}{}
1211+
stagesToProcess = append(stagesToProcess, stageIdx)
1212+
}
12101213
}
12111214
}
12121215
enqueue(targetStage)
@@ -1228,9 +1231,10 @@ func (c *Build) collectBaseImages(df *dockerfile.Dockerfile, targetStage int) []
12281231
precedingStages := df.Stages[:stageIdx]
12291232

12301233
for _, ref := range getFromRefsInCommands(stage) {
1231-
if idx, ok := resolveStageRef(precedingStages, ref); ok {
1232-
// ref is a stage
1233-
enqueue(idx)
1234+
if stages, ok := findMatchingStages(precedingStages, ref); ok {
1235+
// ref matches one or more stages
1236+
// buildah builds all matching stages, we have to pre-pull all the images
1237+
enqueue(stages...)
12341238
} else {
12351239
// ref is an image
12361240
// (the third option is that ref is a --build-context,
@@ -1243,16 +1247,28 @@ func (c *Build) collectBaseImages(df *dockerfile.Dockerfile, targetStage int) []
12431247
return slices.Sorted(maps.Keys(baseImageSet))
12441248
}
12451249

1246-
func resolveStageRef(stages []*dockerfile.Stage, ref string) (int, bool) {
1250+
// Given a list of containerfile stages and a string ref, determine if the ref matches any stage(s).
1251+
// If yes, return ({indexes of matching stages}, true).
1252+
//
1253+
// First, look for matching named stages ('FROM ... AS name').
1254+
// If no such stage exists, the ref may be an integer index of a stage - parse it and verify bounds.
1255+
// If ref doesn't match any named stage and isn't a valid integer index, return (nil, false).
1256+
func findMatchingStages(stages []*dockerfile.Stage, ref string) ([]int, bool) {
1257+
var matchingStages []int
12471258
for i, stage := range stages {
12481259
if stage.Name != nil && *stage.Name == ref {
1249-
return i, true
1260+
matchingStages = append(matchingStages, i)
12501261
}
12511262
}
1263+
if len(matchingStages) > 0 {
1264+
return matchingStages, true
1265+
}
1266+
12521267
if i, err := strconv.Atoi(ref); err == nil && 0 <= i && i < len(stages) {
1253-
return i, true
1268+
return []int{i}, true
12541269
}
1255-
return -1, false
1270+
1271+
return nil, false
12561272
}
12571273

12581274
// Returns all 'from' references from a stage's commands (COPY --from and RUN --mount=from).

pkg/commands/build_test.go

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,68 +1473,83 @@ func Test_Build_injectBuildinfo(t *testing.T) {
14731473
g.Expect(c.buildinfoBuildContext.Location).To(Equal(filepath.Join(c.tempWorkdir, "buildinfo")))
14741474
}
14751475

1476-
func Test_resolveStageRef(t *testing.T) {
1476+
func Test_findMatchingStages(t *testing.T) {
14771477
g := NewWithT(t)
14781478

14791479
tests := []struct {
1480-
name string
1481-
dockerfile string
1482-
ref string
1483-
expectIndex int
1484-
expectOk bool
1480+
name string
1481+
dockerfile string
1482+
ref string
1483+
expectIndexes []int
1484+
expectOk bool
14851485
}{
14861486
{
14871487
name: "match stage by name",
14881488
dockerfile: strings.Join([]string{
14891489
"FROM golang:1.21 AS builder",
14901490
"FROM scratch",
14911491
}, "\n"),
1492-
ref: "builder",
1493-
expectIndex: 0,
1494-
expectOk: true,
1492+
ref: "builder",
1493+
expectIndexes: []int{0},
1494+
expectOk: true,
14951495
},
14961496
{
14971497
name: "match stage by numeric index",
14981498
dockerfile: strings.Join([]string{
14991499
"FROM golang:1.21 AS builder",
15001500
"FROM scratch",
15011501
}, "\n"),
1502-
ref: "1",
1503-
expectIndex: 1,
1504-
expectOk: true,
1502+
ref: "1",
1503+
expectIndexes: []int{1},
1504+
expectOk: true,
15051505
},
15061506
{
1507-
name: "no match for unknown name",
1508-
dockerfile: "FROM golang:1.21 AS builder\n",
1509-
ref: "nonexistent",
1510-
expectIndex: -1,
1511-
expectOk: false,
1507+
name: "no match for unknown name",
1508+
dockerfile: "FROM golang:1.21 AS builder\n",
1509+
ref: "nonexistent",
1510+
expectIndexes: nil,
1511+
expectOk: false,
15121512
},
15131513
{
1514-
name: "no match for negative index",
1515-
dockerfile: "FROM golang:1.21\n",
1516-
ref: "-1",
1517-
expectIndex: -1,
1518-
expectOk: false,
1514+
name: "no match for negative index",
1515+
dockerfile: "FROM golang:1.21\n",
1516+
ref: "-1",
1517+
expectIndexes: nil,
1518+
expectOk: false,
15191519
},
15201520
{
15211521
name: "no match for index out of range",
15221522
dockerfile: strings.Join([]string{
15231523
"FROM golang:1.21 AS builder",
15241524
"FROM scratch",
15251525
}, "\n"),
1526-
ref: "2",
1527-
expectIndex: -1,
1528-
expectOk: false,
1526+
ref: "2",
1527+
expectIndexes: nil,
1528+
expectOk: false,
1529+
},
1530+
{
1531+
name: "duplicate stage names return multiple indexes",
1532+
dockerfile: strings.Join([]string{
1533+
"FROM golang:1.21 AS builder",
1534+
"RUN echo first",
1535+
"",
1536+
"FROM alpine:3.18 AS builder",
1537+
"RUN echo second",
1538+
"",
1539+
"FROM scratch",
1540+
}, "\n"),
1541+
ref: "builder",
1542+
expectIndexes: []int{0, 1},
1543+
expectOk: true,
15291544
},
15301545
}
15311546

15321547
for _, tt := range tests {
15331548
t.Run(tt.name, func(t *testing.T) {
15341549
df := parseDockerfile(t, g, tt.dockerfile)
1535-
idx, ok := resolveStageRef(df.Stages, tt.ref)
1550+
indexes, ok := findMatchingStages(df.Stages, tt.ref)
15361551
g.Expect(ok).To(Equal(tt.expectOk), "expected ok=%v", tt.expectOk)
1537-
g.Expect(idx).To(Equal(tt.expectIndex), "expected index=%d", tt.expectIndex)
1552+
g.Expect(indexes).To(Equal(tt.expectIndexes), "expected indexes=%v", tt.expectIndexes)
15381553
})
15391554
}
15401555
}
@@ -1715,6 +1730,21 @@ func Test_Build_collectBaseImages(t *testing.T) {
17151730
"golang:1.21",
17161731
},
17171732
},
1733+
{
1734+
name: "duplicate stage names: all matching stages are included",
1735+
dockerfile: strings.Join([]string{
1736+
"FROM imageA AS builder",
1737+
"RUN echo first",
1738+
"",
1739+
"FROM imageB AS builder",
1740+
"RUN echo second",
1741+
"",
1742+
"FROM scratch",
1743+
"COPY --from=builder /app /app",
1744+
}, "\n"),
1745+
targetStage: 2,
1746+
expected: []string{"imageA", "imageB"},
1747+
},
17181748
{
17191749
name: "unused stage not reachable from target is excluded",
17201750
dockerfile: strings.Join([]string{

0 commit comments

Comments
 (0)