Skip to content
This repository was archived by the owner on Jul 18, 2025. It is now read-only.

Commit 5bf177d

Browse files
Add bundle command to create a CNAB from a Docker Application Package
Signed-off-by: Silvin Lubecki <silvin.lubecki@docker.com>
1 parent f009c0e commit 5bf177d

13 files changed

Lines changed: 353 additions & 2 deletions

File tree

cmd/docker-app/bundle.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io/ioutil"
9+
10+
"github.com/deis/duffle/pkg/bundle"
11+
"github.com/docker/app/internal/packager"
12+
"github.com/docker/app/types"
13+
"github.com/docker/app/types/metadata"
14+
"github.com/docker/cli/cli"
15+
"github.com/docker/cli/cli/command"
16+
"github.com/docker/distribution/reference"
17+
dockertypes "github.com/docker/docker/api/types"
18+
"github.com/docker/docker/pkg/jsonmessage"
19+
"github.com/pkg/errors"
20+
"github.com/spf13/cobra"
21+
)
22+
23+
type bundleOptions struct {
24+
invocationImageName string
25+
namespace string
26+
out string
27+
}
28+
29+
func bundleCmd(dockerCli command.Cli) *cobra.Command {
30+
var opts bundleOptions
31+
cmd := &cobra.Command{
32+
Use: "bundle [<app-name>]",
33+
Short: "Create a CNAB invocation image and bundle.json for the application.",
34+
Args: cli.RequiresMaxArgs(1),
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
return runBundle(dockerCli, firstOrEmpty(args), opts)
37+
},
38+
}
39+
40+
cmd.Flags().StringVarP(&opts.invocationImageName, "invocation-image", "i", "", "specify the name of invocation image to build")
41+
cmd.Flags().StringVar(&opts.namespace, "namespace", "", "namespace to use (default: namespace in metadata)")
42+
cmd.Flags().StringVarP(&opts.out, "out", "o", "bundle.json", "path to the output bundle.json (- for stdout)")
43+
return cmd
44+
}
45+
46+
func runBundle(dockerCli command.Cli, appName string, opts bundleOptions) error {
47+
bundle, err := makeBundle(dockerCli, appName, opts.namespace, opts.invocationImageName)
48+
if err != nil {
49+
return err
50+
}
51+
if bundle == nil || len(bundle.InvocationImages) == 0 {
52+
return fmt.Errorf("failed to create bundle %q", appName)
53+
}
54+
fmt.Fprintf(dockerCli.Out(), "Invocation image %q successfully built\n", bundle.InvocationImages[0].Image)
55+
bundleBytes, err := json.MarshalIndent(bundle, "", "\t")
56+
if err != nil {
57+
return err
58+
}
59+
if opts.out == "-" {
60+
_, err = dockerCli.Out().Write(bundleBytes)
61+
return err
62+
}
63+
return ioutil.WriteFile(opts.out, bundleBytes, 0644)
64+
}
65+
66+
func makeBundle(dockerCli command.Cli, appName, namespace, invocationImageName string) (*bundle.Bundle, error) {
67+
app, err := packager.Extract(appName)
68+
if err != nil {
69+
return nil, err
70+
}
71+
defer app.Cleanup()
72+
return makeBundleFromApp(dockerCli, app, namespace, invocationImageName)
73+
}
74+
75+
func makeBundleFromApp(dockerCli command.Cli, app *types.App, namespace, invocationImageName string) (*bundle.Bundle, error) {
76+
meta := app.Metadata()
77+
invocationImageName, err := makeImageName(meta, namespace, invocationImageName, "-invoc")
78+
if err != nil {
79+
return nil, err
80+
}
81+
if _, err := makeImageName(app.Metadata(), namespace, "", ""); err != nil {
82+
return nil, err
83+
}
84+
85+
buildContext := bytes.NewBuffer(nil)
86+
if err := packager.PackInvocationImageContext(app, buildContext); err != nil {
87+
return nil, err
88+
}
89+
90+
buildResp, err := dockerCli.Client().ImageBuild(context.TODO(), buildContext, dockertypes.ImageBuildOptions{
91+
Dockerfile: "Dockerfile",
92+
Tags: []string{invocationImageName},
93+
})
94+
if err != nil {
95+
return nil, err
96+
}
97+
defer buildResp.Body.Close()
98+
99+
if err := jsonmessage.DisplayJSONMessagesStream(buildResp.Body, ioutil.Discard, 0, false, func(jsonmessage.JSONMessage) {}); err != nil {
100+
return nil, err
101+
}
102+
return packager.ToCNAB(app, invocationImageName), nil
103+
}
104+
105+
func makeImageName(meta metadata.AppMetadata, namespace, name, suffix string) (string, error) {
106+
if name == "" {
107+
name = fmt.Sprintf("%s:%s%s", meta.Name, meta.Version, suffix)
108+
}
109+
if namespace == "" {
110+
namespace = meta.Namespace
111+
}
112+
if namespace != "" {
113+
name = fmt.Sprintf("%s/%s", namespace, name)
114+
}
115+
if _, err := reference.ParseNormalizedNamed(name); err != nil {
116+
return "", errors.Wrapf(err, "image name %q is invalid, please check namespace, name and version fields", name)
117+
}
118+
return name, nil
119+
}

cmd/docker-app/bundle_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/docker/app/types/metadata"
7+
"gotest.tools/assert"
8+
)
9+
10+
func TestMakeInvocationImage(t *testing.T) {
11+
testcases := []struct {
12+
name string
13+
imageName string
14+
namespace string
15+
meta metadata.AppMetadata
16+
expected string
17+
err string
18+
}{
19+
{
20+
name: "specify-image-name",
21+
imageName: "my-invocation-image",
22+
expected: "my-invocation-image",
23+
},
24+
{
25+
name: "specify-image-name-and-namespace",
26+
imageName: "my-invocation-image",
27+
namespace: "my-namespace",
28+
expected: "my-namespace/my-invocation-image",
29+
},
30+
{
31+
name: "simple-metadata",
32+
meta: metadata.AppMetadata{Name: "name", Version: "version"},
33+
expected: "name:version-invoc",
34+
},
35+
{
36+
name: "simple-metadata-with-overridden-namespace",
37+
namespace: "my-namespace",
38+
meta: metadata.AppMetadata{Name: "name", Version: "version"},
39+
expected: "my-namespace/name:version-invoc",
40+
},
41+
{
42+
name: "metadata-with-namespace",
43+
meta: metadata.AppMetadata{Name: "name", Version: "version", Namespace: "namespace"},
44+
expected: "namespace/name:version-invoc",
45+
},
46+
{
47+
name: "metadata-with-namespace-and-overridden-namespace",
48+
namespace: "my-namespace",
49+
meta: metadata.AppMetadata{Name: "name", Version: "version", Namespace: "namespace"},
50+
expected: "my-namespace/name:version-invoc",
51+
},
52+
{
53+
name: "simple-metadata",
54+
meta: metadata.AppMetadata{Name: "WrongName&%*", Version: "version"},
55+
err: "invalid",
56+
},
57+
}
58+
for _, c := range testcases {
59+
t.Run(c.name, func(t *testing.T) {
60+
actual, err := makeImageName(c.meta, c.namespace, c.imageName, "-invoc")
61+
if c.err != "" {
62+
assert.ErrorContains(t, err, c.err)
63+
assert.Equal(t, actual, "")
64+
} else {
65+
assert.NilError(t, err)
66+
assert.Equal(t, actual, c.expected)
67+
}
68+
})
69+
}
70+
}

cmd/docker-app/deploy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func runDeploy(dockerCli command.Cli, flags *pflag.FlagSet, appname string, opts
6464
return err
6565
}
6666
defer app.Cleanup()
67-
deployOrchestrator, err := command.GetStackOrchestrator(opts.deployOrchestrator, dockerCli.ConfigFile().StackOrchestrator, dockerCli.Err())
67+
deployOrchestrator, err := command.GetStackOrchestrator(opts.deployOrchestrator, "", dockerCli.ConfigFile().StackOrchestrator, dockerCli.Err())
6868
if err != nil {
6969
return err
7070
}

cmd/docker-app/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func addCommands(cmd *cobra.Command, dockerCli command.Cli) {
5353
validateCmd(),
5454
versionCmd(dockerCli),
5555
completionCmd(dockerCli, cmd),
56+
bundleCmd(dockerCli),
5657
)
5758
if internal.Experimental == "on" {
5859
cmd.AddCommand(

internal/packager/packing.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,80 @@ package packager
22

33
import (
44
"archive/tar"
5+
"errors"
56
"fmt"
67
"io"
78
"io/ioutil"
89
"os"
910
"path/filepath"
1011

1112
"github.com/docker/app/internal"
13+
"github.com/docker/app/types"
1214
"github.com/docker/docker/pkg/archive"
1315
)
1416

17+
var dockerFile = `FROM docker/cnab-app-base:` + internal.Version + `
18+
COPY . .`
19+
20+
const dockerIgnore = "Dockerfile"
21+
1522
func tarAdd(tarout *tar.Writer, path, file string) error {
1623
payload, err := ioutil.ReadFile(file)
1724
if err != nil {
1825
return err
1926
}
27+
return tarAddBytes(tarout, path, payload)
28+
}
29+
30+
func tarAddBytes(tarout *tar.Writer, path string, payload []byte) error {
2031
h := &tar.Header{
2132
Name: path,
2233
Size: int64(len(payload)),
2334
Mode: 0644,
2435
Typeflag: tar.TypeReg,
2536
}
26-
err = tarout.WriteHeader(h)
37+
err := tarout.WriteHeader(h)
2738
if err != nil {
2839
return err
2940
}
3041
_, err = tarout.Write(payload)
3142
return err
3243
}
3344

45+
// PackInvocationImageContext creates a Docker build context for building a CNAB invocation image
46+
func PackInvocationImageContext(app *types.App, target io.Writer) error {
47+
tarout := tar.NewWriter(target)
48+
defer tarout.Close()
49+
prefix := fmt.Sprintf("%s%s/", app.Metadata().Name, internal.AppExtension)
50+
if len(app.Composes()) != 1 {
51+
return errors.New("app should have one and only one compose file")
52+
}
53+
if len(app.ParametersRaw()) != 1 {
54+
return errors.New("app should have one and only parameters file")
55+
}
56+
if err := tarAddBytes(tarout, "Dockerfile", []byte(dockerFile)); err != nil {
57+
return err
58+
}
59+
if err := tarAddBytes(tarout, ".dockerignore", []byte(dockerIgnore)); err != nil {
60+
return err
61+
}
62+
if err := tarAddBytes(tarout, prefix+internal.MetadataFileName, app.MetadataRaw()); err != nil {
63+
return err
64+
}
65+
if err := tarAddBytes(tarout, prefix+internal.ComposeFileName, app.Composes()[0]); err != nil {
66+
return err
67+
}
68+
if err := tarAddBytes(tarout, prefix+internal.ParametersFileName, app.ParametersRaw()[0]); err != nil {
69+
return err
70+
}
71+
for _, attachment := range app.Attachments() {
72+
if err := tarAdd(tarout, prefix+attachment.Path(), filepath.Join(app.Path, attachment.Path())); err != nil {
73+
return err
74+
}
75+
}
76+
return nil
77+
}
78+
3479
// Pack packs the app as a single file
3580
func Pack(appname string, target io.Writer) error {
3681
tarout := tar.NewWriter(target)

internal/packager/packing_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package packager
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"fmt"
7+
"io"
8+
"strings"
9+
"testing"
10+
11+
"github.com/docker/app/types"
12+
"gotest.tools/assert"
13+
)
14+
15+
func TestPackInvocationImageContext(t *testing.T) {
16+
app, err := types.NewAppFromDefaultFiles("testdata/packages/packing.dockerapp")
17+
assert.NilError(t, err)
18+
buf := bytes.NewBuffer(nil)
19+
assert.NilError(t, PackInvocationImageContext(app, buf))
20+
assert.NilError(t, hasExpectedFiles(buf, map[string]bool{
21+
"Dockerfile": true,
22+
".dockerignore": true,
23+
"packing.dockerapp/metadata.yml": true,
24+
"packing.dockerapp/docker-compose.yml": true,
25+
"packing.dockerapp/parameters.yml": true,
26+
"packing.dockerapp/config.cfg": true,
27+
"packing.dockerapp/nesteddir/config2.cfg": true,
28+
"packing.dockerapp/nesteddir/nested2/nested3/config3.cfg": true,
29+
}))
30+
}
31+
32+
func hasExpectedFiles(r io.Reader, expectedFiles map[string]bool) error {
33+
tr := tar.NewReader(r)
34+
var errors []string
35+
originalExpectedFilesCount := len(expectedFiles)
36+
for {
37+
hdr, err := tr.Next()
38+
if err == io.EOF {
39+
break // End of archive
40+
}
41+
if err != nil {
42+
return err
43+
}
44+
if hdr.Size == 0 {
45+
errors = append(errors, fmt.Sprintf("content of '%s' is empty", hdr.Name))
46+
}
47+
if _, ok := expectedFiles[hdr.Name]; !ok {
48+
errors = append(errors, fmt.Sprintf("couldn't find file '%s' in the tar archive", hdr.Name))
49+
continue
50+
}
51+
delete(expectedFiles, hdr.Name)
52+
}
53+
if len(expectedFiles) != 0 {
54+
errors = append(errors, fmt.Sprintf("number of expected files is in archive is '%d', but just '%d' were found",
55+
originalExpectedFilesCount, originalExpectedFilesCount-len(expectedFiles)))
56+
for k := range expectedFiles {
57+
errors = append(errors, fmt.Sprintf("expected file '%s' not found", k))
58+
}
59+
}
60+
if len(errors) != 0 {
61+
return fmt.Errorf("%s", strings.Join(errors, "\n"))
62+
}
63+
return nil
64+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Contents of config.cfg
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
version: "3.7"
2+
services:
3+
front:
4+
image: nginx:${myapp.nginx_version}
5+
deploy:
6+
replicas: ${myapp.nginx_replicas}
7+
debug:
8+
image: busybox:latest
9+
ports:
10+
- $aport
11+
- $sport:$dport
12+
privileged: ${privileged}
13+
read_only: $read_only
14+
tty: $tty
15+
stdin_open: $stdin_open
16+
deploy:
17+
resources:
18+
limits:
19+
memory: $memory
20+
healthcheck:
21+
test: ["/ping", "debug"]
22+
interval: 2m
23+
timeout: $timeout
24+
monitor:
25+
image: busybox:latest
26+
command: monitor --source ${app.name}-${app.version} $$dollar
27+
x-enabled: "! ${myapp.debug}"
28+
app-watcher:
29+
image: $watcher.image
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
version: 0.1.0
2+
name: packing
3+
namespace: my-namespace
4+
description: "hello"
5+
maintainers:
6+
- name: bearclaw
7+
email: bearclaw@bearclaw.com
8+
- name: bob
9+
email: bob@bob.com

0 commit comments

Comments
 (0)