Skip to content

Commit da87baa

Browse files
committed
Add initial "multi-stage" support in bashbrew
This allows bashbrew to properly handle cross-repository and cross-tag dependencies even in the face of multiple `FROM` instructions or `COPY --from=`. This also provides the scaffolding necessary to implement this in scripts using `bashbrew cat`. As fallback behavior, the `*DockerFrom` functions should return the `FROM` of the last stage in the `Dockerfile` (which is essentially the `FROM` of the final image). Also, the output of `bashbrew from` is now a space-separated list.
1 parent 28a21ad commit da87baa

File tree

5 files changed

+172
-72
lines changed

5 files changed

+172
-72
lines changed

go/src/bashbrew/cmd-build.go

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,29 @@ func cmdBuild(c *cli.Context) error {
4444
continue
4545
}
4646

47-
from, err := r.DockerFrom(entry)
47+
meta, err := r.DockerfileMetadata(entry)
4848
if err != nil {
4949
return cli.NewMultiError(fmt.Errorf(`failed fetching/scraping FROM for %q (tags %q)`, r.RepoName, entry.TagsString()), err)
5050
}
5151

52-
if from != "scratch" && pull != "never" {
53-
doPull := false
54-
switch pull {
55-
case "always":
56-
doPull = true
57-
case "missing":
58-
_, err := dockerInspect("{{.Id}}", from)
59-
doPull = (err != nil)
60-
default:
61-
return fmt.Errorf(`unexpected value for --pull: %s`, pull)
62-
}
63-
if doPull {
64-
// TODO detect if "from" is something we've built (ie, "python:3-onbuild" is "FROM python:3" but we don't want to pull "python:3" if we "bashbrew build python")
65-
fmt.Printf("Pulling %s (%s)\n", from, r.EntryIdentifier(entry))
66-
if !dryRun {
67-
dockerPull(from)
52+
for _, from := range meta.Froms {
53+
if from != "scratch" && pull != "never" {
54+
doPull := false
55+
switch pull {
56+
case "always":
57+
doPull = true
58+
case "missing":
59+
_, err := dockerInspect("{{.Id}}", from)
60+
doPull = (err != nil)
61+
default:
62+
return fmt.Errorf(`unexpected value for --pull: %s`, pull)
63+
}
64+
if doPull {
65+
// TODO detect if "from" is something we've built (ie, "python:3-onbuild" is "FROM python:3" but we don't want to pull "python:3" if we "bashbrew build python")
66+
fmt.Printf("Pulling %s (%s)\n", from, r.EntryIdentifier(entry))
67+
if !dryRun {
68+
dockerPull(from)
69+
}
6870
}
6971
}
7072
}
@@ -90,6 +92,8 @@ func cmdBuild(c *cli.Context) error {
9092
}
9193
defer archive.Close()
9294

95+
// TODO use "meta.StageNames" to do "docker build --target" so we can tag intermediate stages too for cache (streaming "git archive" directly to "docker build" makes that a little hard to accomplish without re-streaming)
96+
9397
err = dockerBuild(cacheTag, entry.ArchFile(arch), archive)
9498
if err != nil {
9599
return cli.NewMultiError(fmt.Errorf(`failed building %q (tags %q)`, r.RepoName, entry.TagsString()), err)

go/src/bashbrew/cmd-deps.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,14 @@ func cmdFamily(parents bool, c *cli.Context) error {
6767
continue
6868
}
6969

70-
from, err := r.DockerFrom(entry)
70+
meta, err := r.DockerfileMetadata(entry)
7171
if err != nil {
7272
return cli.NewMultiError(fmt.Errorf(`failed fetching/scraping FROM for %q (tags %q)`, r.RepoName, entry.TagsString()), err)
7373
}
74-
for _, tag := range r.Tags("", false, entry) {
75-
network.AddEdge(from, tag)
74+
for _, from := range meta.Froms {
75+
for _, tag := range r.Tags("", false, entry) {
76+
network.AddEdge(from, tag)
77+
}
7678
}
7779
}
7880
}

go/src/bashbrew/cmd-from.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/codegangsta/cli"
78
)
@@ -27,13 +28,14 @@ func cmdFrom(c *cli.Context) error {
2728
continue
2829
}
2930

30-
from, err := r.DockerFrom(entry)
31+
meta, err := r.DockerfileMetadata(entry)
3132
if err != nil {
3233
return cli.NewMultiError(fmt.Errorf(`failed fetching/scraping FROM for %q (tags %q)`, r.RepoName, entry.TagsString()), err)
3334
}
3435

36+
froms := strings.Join(meta.Froms, " ")
3537
for _, tag := range r.Tags(namespace, uniq, entry) {
36-
fmt.Printf("%s: %s\n", tag, from)
38+
fmt.Printf("%s: %s\n", tag, froms)
3739
}
3840
}
3941
}

go/src/bashbrew/docker.go

Lines changed: 125 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,81 +10,169 @@ import (
1010
"os"
1111
"os/exec"
1212
"path"
13+
"strconv"
1314
"strings"
1415

1516
"github.com/codegangsta/cli"
1617
"github.com/docker-library/go-dockerlibrary/manifest"
1718
)
1819

19-
var dockerFromCache = map[string]string{}
20+
type DockerfileMetadata struct {
21+
StageFroms []string // every image "FROM" instruction value (or the parent stage's FROM value in the case of a named stage)
22+
StageNames []string // the name of any named stage (in order)
23+
StageNameFroms map[string]string // map of stage names to FROM values (or the parent stage's FROM value in the case of a named stage), useful for resolving stage names to FROM values
24+
25+
Froms []string // every "FROM" or "COPY --from=xxx" value (minus named and/or numbered stages in the case of "--from=")
26+
}
2027

2128
func (r Repo) DockerFrom(entry *manifest.Manifest2822Entry) (string, error) {
2229
return r.ArchDockerFrom(arch, entry)
2330
}
2431

2532
func (r Repo) ArchDockerFrom(arch string, entry *manifest.Manifest2822Entry) (string, error) {
26-
commit, err := r.fetchGitRepo(arch, entry)
33+
dockerfileMeta, err := r.ArchDockerfileMetadata(arch, entry)
2734
if err != nil {
2835
return "", err
2936
}
3037

38+
return dockerfileMeta.StageFroms[len(dockerfileMeta.StageFroms)-1], nil
39+
}
40+
41+
func (r Repo) DockerfileMetadata(entry *manifest.Manifest2822Entry) (*DockerfileMetadata, error) {
42+
return r.ArchDockerfileMetadata(arch, entry)
43+
}
44+
45+
var dockerfileMetadataCache = map[string]*DockerfileMetadata{}
46+
47+
func (r Repo) ArchDockerfileMetadata(arch string, entry *manifest.Manifest2822Entry) (*DockerfileMetadata, error) {
48+
commit, err := r.fetchGitRepo(arch, entry)
49+
if err != nil {
50+
return nil, err
51+
}
52+
3153
dockerfileFile := path.Join(entry.ArchDirectory(arch), entry.ArchFile(arch))
3254

3355
cacheKey := strings.Join([]string{
3456
commit,
3557
dockerfileFile,
3658
}, "\n")
37-
if from, ok := dockerFromCache[cacheKey]; ok {
38-
return from, nil
59+
if meta, ok := dockerfileMetadataCache[cacheKey]; ok {
60+
return meta, nil
3961
}
4062

4163
dockerfile, err := gitShow(commit, dockerfileFile)
4264
if err != nil {
43-
return "", err
65+
return nil, err
4466
}
4567
defer dockerfile.Close()
4668

47-
from, err := dockerfileFrom(dockerfile)
69+
meta, err := parseDockerfileMetadata(dockerfile)
4870
if err != nil {
49-
return "", err
71+
return nil, err
5072
}
5173

5274
if err := dockerfile.Close(); err != nil {
53-
return "", err
75+
return nil, err
5476
}
5577

56-
// make sure to add ":latest" if it's implied
57-
from = latestizeRepoTag(from)
58-
59-
dockerFromCache[cacheKey] = from
60-
return from, nil
78+
dockerfileMetadataCache[cacheKey] = meta
79+
return meta, nil
6180
}
6281

63-
// TODO determine multi-stage-builds impact here (once official images are willing/able to support them; post-17.06 at the earliest)
64-
func dockerfileFrom(dockerfile io.Reader) (string, error) {
82+
func parseDockerfileMetadata(dockerfile io.Reader) (*DockerfileMetadata, error) {
83+
meta := &DockerfileMetadata{
84+
// panic: assignment to entry in nil map
85+
StageNameFroms: map[string]string{},
86+
// (nil slices work fine)
87+
}
88+
6589
scanner := bufio.NewScanner(dockerfile)
6690
for scanner.Scan() {
6791
line := strings.TrimSpace(scanner.Text())
92+
6893
if line == "" {
6994
// ignore blank lines
7095
continue
7196
}
97+
7298
if line[0] == '#' {
99+
// TODO handle "escape" parser directive
100+
// TODO handle "syntax" parser directive -- explode appropriately (since custom syntax invalidates our Dockerfile parsing)
73101
// ignore comments
74102
continue
75103
}
104+
105+
// handle line continuations
106+
// (TODO see note above regarding "escape" parser directive)
107+
for line[len(line)-1] == '\\' && scanner.Scan() {
108+
nextLine := strings.TrimSpace(scanner.Text())
109+
if nextLine == "" || nextLine[0] == '#' {
110+
// ignore blank lines and comments
111+
continue
112+
}
113+
line = line[0:len(line)-1]+nextLine
114+
}
115+
76116
fields := strings.Fields(line)
77117
if len(fields) < 1 {
118+
// must be a much more complex empty line??
78119
continue
79120
}
80-
if strings.ToUpper(fields[0]) == "FROM" {
81-
return fields[1], nil
121+
instruction := strings.ToUpper(fields[0])
122+
123+
// TODO balk at ARG / $ in from values
124+
125+
switch instruction {
126+
case "FROM":
127+
from := fields[1]
128+
129+
if stageFrom, ok := meta.StageNameFroms[from]; ok {
130+
// if this is a valid stage name, we should resolve it back to the original FROM value of that previous stage (we don't care about inter-stage dependencies for the purposes of either tag dependency calculation or tag building -- just how many there are and what external things they require)
131+
from = stageFrom
132+
}
133+
134+
// make sure to add ":latest" if it's implied
135+
from = latestizeRepoTag(from)
136+
137+
meta.StageFroms = append(meta.StageFroms, from)
138+
meta.Froms = append(meta.Froms, from)
139+
140+
if len(fields) == 4 && strings.ToUpper(fields[2]) == "AS" {
141+
stageName := fields[3]
142+
meta.StageNames = append(meta.StageNames, stageName)
143+
meta.StageNameFroms[stageName] = from
144+
}
145+
case "ADD", "COPY":
146+
for _, arg := range fields[1:] {
147+
if !strings.HasPrefix(arg, "--") {
148+
// doesn't appear to be a "flag"; time to bail!
149+
break
150+
}
151+
if !strings.HasPrefix(arg, "--from=") {
152+
// ignore any flags we're not interested in
153+
continue
154+
}
155+
from := arg[len("--from="):]
156+
157+
if stageFrom, ok := meta.StageNameFroms[from]; ok {
158+
// see note above regarding stage names in FROM
159+
from = stageFrom
160+
} else if stageNumber, err := strconv.Atoi(from); err == nil && stageNumber < len(meta.StageFroms) {
161+
// must be a stage number, we should resolve it too
162+
from = meta.StageFroms[stageNumber]
163+
}
164+
165+
// make sure to add ":latest" if it's implied
166+
from = latestizeRepoTag(from)
167+
168+
meta.Froms = append(meta.Froms, from)
169+
}
82170
}
83171
}
84172
if err := scanner.Err(); err != nil {
85-
return "", err
173+
return nil, err
86174
}
87-
return "", nil
175+
return meta, nil
88176
}
89177

90178
func (r Repo) DockerCacheName(entry *manifest.Manifest2822Entry) (string, error) {
@@ -121,26 +209,29 @@ var dockerFromIdCache = map[string]string{
121209
}
122210

123211
func (r Repo) dockerBuildUniqueBits(entry *manifest.Manifest2822Entry) ([]string, error) {
124-
from, err := r.DockerFrom(entry)
212+
uniqueBits := []string{
213+
entry.ArchGitRepo(arch),
214+
entry.ArchGitCommit(arch),
215+
entry.ArchDirectory(arch),
216+
entry.ArchFile(arch),
217+
}
218+
meta, err := r.DockerfileMetadata(entry)
125219
if err != nil {
126220
return nil, err
127221
}
128-
fromId, ok := dockerFromIdCache[from]
129-
if !ok {
130-
var err error
131-
fromId, err = dockerInspect("{{.Id}}", from)
132-
if err != nil {
133-
return nil, err
222+
for _, from := range meta.Froms {
223+
fromId, ok := dockerFromIdCache[from]
224+
if !ok {
225+
var err error
226+
fromId, err = dockerInspect("{{.Id}}", from)
227+
if err != nil {
228+
return nil, err
229+
}
230+
dockerFromIdCache[from] = fromId
134231
}
135-
dockerFromIdCache[from] = fromId
232+
uniqueBits = append(uniqueBits, fromId)
136233
}
137-
return []string{
138-
entry.ArchGitRepo(arch),
139-
entry.ArchGitCommit(arch),
140-
entry.ArchDirectory(arch),
141-
entry.ArchFile(arch),
142-
fromId,
143-
}, nil
234+
return uniqueBits, nil
144235
}
145236

146237
func dockerBuild(tag string, file string, context io.Reader) error {

go/src/bashbrew/sort.go

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -101,27 +101,28 @@ func sortRepoObjects(rs []*Repo, applyConstraints bool) ([]*Repo, error) {
101101
continue
102102
}
103103

104-
from, err := r.DockerFrom(entry)
104+
meta, err := r.DockerfileMetadata(entry)
105105
if err != nil {
106106
return nil, err
107107
}
108-
from = latestizeRepoTag(from)
109108

110-
fromNode, ok := canonicalNodes[from]
111-
if !ok {
112-
// if our FROM isn't in the list of things we're sorting, it isn't relevant in this context
113-
continue
114-
}
109+
for _, from := range meta.Froms {
110+
fromNode, ok := canonicalNodes[from]
111+
if !ok {
112+
// if our FROM isn't in the list of things we're sorting, it isn't relevant in this context
113+
continue
114+
}
115115

116-
// TODO somehow reconcile/avoid "a:a -> b:b, b:b -> a:c" (which will exhibit here as cyclic)
117-
for _, tag := range r.Tags("", false, entry) {
118-
if tagNode, ok := canonicalNodes[tag]; ok {
119-
if tagNode == fromNode {
120-
// don't be cyclic
121-
continue
122-
}
123-
if err := network.AddEdge(fromNode, tagNode); err != nil {
124-
return nil, err
116+
// TODO somehow reconcile/avoid "a:a -> b:b, b:b -> a:c" (which will exhibit here as cyclic)
117+
for _, tag := range r.Tags("", false, entry) {
118+
if tagNode, ok := canonicalNodes[tag]; ok {
119+
if tagNode == fromNode {
120+
// don't be cyclic
121+
continue
122+
}
123+
if err := network.AddEdge(fromNode, tagNode); err != nil {
124+
return nil, err
125+
}
125126
}
126127
}
127128
}

0 commit comments

Comments
 (0)