Skip to content

Commit

Permalink
add --with-env flag to publish command
Browse files Browse the repository at this point in the history
this flag allow publishing env variables in the Compose OCI artifact

Signed-off-by: Guillaume Lours <[email protected]>
  • Loading branch information
glours committed Jan 29, 2025
1 parent 4b70ff0 commit 8402888
Show file tree
Hide file tree
Showing 11 changed files with 193 additions and 8 deletions.
6 changes: 5 additions & 1 deletion cmd/compose/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type publishOptions struct {
*ProjectOptions
resolveImageDigests bool
ociVersion string
withEnvironment bool
}

func publishCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command {
Expand All @@ -45,7 +46,9 @@ func publishCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Servic
}
flags := cmd.Flags()
flags.BoolVar(&opts.resolveImageDigests, "resolve-image-digests", false, "Pin image tags to digests")
flags.StringVar(&opts.ociVersion, "oci-version", "", "OCI Image/Artifact specification version (automatically determined by default)")
flags.StringVar(&opts.ociVersion, "oci-version", "", "OCI image/artifact specification version (automatically determined by default)")
flags.BoolVar(&opts.withEnvironment, "with-env", false, "Include environment variables in the published OCI artifact")

return cmd
}

Expand All @@ -58,5 +61,6 @@ func runPublish(ctx context.Context, dockerCli command.Cli, backend api.Service,
return backend.Publish(ctx, project, repository, api.PublishOptions{
ResolveImageDigests: opts.resolveImageDigests,
OCIVersion: api.OCIVersion(opts.ociVersion),
WithEnvironment: opts.withEnvironment,
})
}
3 changes: 2 additions & 1 deletion docs/reference/compose_alpha_publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ Publish compose application
| Name | Type | Default | Description |
|:--------------------------|:---------|:--------|:-------------------------------------------------------------------------------|
| `--dry-run` | `bool` | | Execute command in dry run mode |
| `--oci-version` | `string` | | OCI Image/Artifact specification version (automatically determined by default) |
| `--oci-version` | `string` | | OCI image/artifact specification version (automatically determined by default) |
| `--resolve-image-digests` | `bool` | | Pin image tags to digests |
| `--with-env` | `bool` | | Include environment variables in the published OCI artifact |


<!---MARKER_GEN_END-->
Expand Down
12 changes: 11 additions & 1 deletion docs/reference/docker_compose_alpha_publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ options:
- option: oci-version
value_type: string
description: |
OCI Image/Artifact specification version (automatically determined by default)
OCI image/artifact specification version (automatically determined by default)
deprecated: false
hidden: false
experimental: false
Expand All @@ -25,6 +25,16 @@ options:
experimentalcli: false
kubernetes: false
swarm: false
- option: with-env
value_type: bool
default_value: "false"
description: Include environment variables in the published OCI artifact
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
inherited_options:
- option: dry-run
value_type: bool
Expand Down
14 changes: 14 additions & 0 deletions internal/ocipush/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ const (
// > an artifactType field, and tooling to work with artifacts should
// > fallback to the config.mediaType value.
ComposeEmptyConfigMediaType = "application/vnd.docker.compose.config.empty.v1+json"
// ComposeEnvFileMediaType is the media type for each Env File layer in the image manifest.
ComposeEnvFileMediaType = "application/vnd.docker.compose.envfile"
)

// clientAuthStatusCodes are client (4xx) errors that are authentication
Expand Down Expand Up @@ -81,6 +83,18 @@ func DescriptorForComposeFile(path string, content []byte) v1.Descriptor {
}
}

func DescriptorForEnvFile(path string, content []byte) v1.Descriptor {
return v1.Descriptor{
MediaType: ComposeEnvFileMediaType,
Digest: digest.FromString(string(content)),
Size: int64(len(content)),
Annotations: map[string]string{
"com.docker.compose.version": api.ComposeVersion,
"com.docker.compose.envfile": filepath.Base(path),
},
}
}

func PushManifest(
ctx context.Context,
resolver *imagetools.Resolver,
Expand Down
1 change: 1 addition & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ const (
// PublishOptions group options of the Publish API
type PublishOptions struct {
ResolveImageDigests bool
WithEnvironment bool

OCIVersion OCIVersion
}
Expand Down
57 changes: 56 additions & 1 deletion pkg/compose/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package compose

import (
"context"
"fmt"
"os"

"github.com/compose-spec/compose-go/v2/types"
Expand All @@ -35,7 +36,11 @@ func (s *composeService) Publish(ctx context.Context, project *types.Project, re
}

func (s *composeService) publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
err := s.Push(ctx, project, api.PushOptions{IgnoreFailures: true, ImageMandatory: true})
err := preChecks(project, options)
if err != nil {
return err
}
err = s.Push(ctx, project, api.PushOptions{IgnoreFailures: true, ImageMandatory: true})
if err != nil {
return err
}
Expand Down Expand Up @@ -63,6 +68,10 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
})
}

if options.WithEnvironment {
layers = append(layers, envFileLayers(project)...)
}

if options.ResolveImageDigests {
yaml, err := s.generateImageDigestsOverride(ctx, project)
if err != nil {
Expand Down Expand Up @@ -120,3 +129,49 @@ func (s *composeService) generateImageDigestsOverride(ctx context.Context, proje
}
return override.MarshalYAML()
}

func preChecks(project *types.Project, options api.PublishOptions) error {
if !options.WithEnvironment {
for _, service := range project.Services {
if len(service.EnvFiles) > 0 {
return fmt.Errorf("service %q has env_file declared. To avoid leaking sensitive data, "+
"you must either explicitly allow the sending of environment variables by using the --with-env flag,"+
" or remove sensitive data from your Compose configuration", service.Name)
}
if len(service.Environment) > 0 {
return fmt.Errorf("service %q has environment variable(s) declared. To avoid leaking sensitive data, "+
"you must either explicitly allow the sending of environment variables by using the --with-env flag,"+
" or remove sensitive data from your Compose configuration", service.Name)
}
}

for _, config := range project.Configs {
if config.Environment != "" {
return fmt.Errorf("config %q is declare as an environment variable. To avoid leaking sensitive data, "+
"you must either explicitly allow the sending of environment variables by using the --with-env flag,"+
" or remove sensitive data from your Compose configuration", config.Name)
}
}
}

return nil
}

func envFileLayers(project *types.Project) []ocipush.Pushable {
var layers []ocipush.Pushable
for _, service := range project.Services {
for _, envFile := range service.EnvFiles {
f, err := os.ReadFile(envFile.Path)
if err != nil {
// if we can't read the file, skip to the next one
continue
}
layerDescriptor := ocipush.DescriptorForEnvFile(envFile.Path, f)
layers = append(layers, ocipush.Pushable{
Descriptor: layerDescriptor,
Data: f,
})
}
}
return layers
}
7 changes: 7 additions & 0 deletions pkg/e2e/fixtures/publish/compose-env-file.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
serviceA:
image: "alpine:3.12"
env_file:
- publish.env
serviceB:
image: "alpine:3.12"
7 changes: 7 additions & 0 deletions pkg/e2e/fixtures/publish/compose-environment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
serviceA:
image: "alpine:3.12"
environment:
- "FOO=bar"
serviceB:
image: "alpine:3.12"
1 change: 1 addition & 0 deletions pkg/e2e/fixtures/publish/publish.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FOO=bar
56 changes: 56 additions & 0 deletions pkg/e2e/publish_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package e2e

import (
"strings"
"testing"

"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
)

func TestPublishChecks(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "compose-e2e-explicit-profiles"

t.Run("publish error environment", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/publish/compose-environment.yml",
"-p", projectName, "alpha", "publish", "test/test")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: `service "serviceA" has environment variable(s) declared. To avoid leaking sensitive data,`})
})

t.Run("publish error env_file", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/publish/compose-env-file.yml",
"-p", projectName, "alpha", "publish", "test/test")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: `service "serviceA" has env_file declared. To avoid leaking sensitive data,`})
})

t.Run("publish success environment", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/publish/compose-environment.yml",
"-p", projectName, "alpha", "publish", "test/test", "--with-env", "--dry-run")
assert.Assert(t, strings.Contains(res.Combined(), "test/test publishing"), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), "test/test published"), res.Combined())
})

t.Run("publish success env_file", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/publish/compose-env-file.yml",
"-p", projectName, "alpha", "publish", "test/test", "--with-env", "--dry-run")
assert.Assert(t, strings.Contains(res.Combined(), "test/test publishing"), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), "test/test published"), res.Combined())
})
}
37 changes: 33 additions & 4 deletions pkg/remote/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,46 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, com
if err != nil {
return err
}
if i > 0 {
_, err = f.Write([]byte("\n---\n"))
if err != nil {

switch layer.MediaType {
case ocipush.ComposeYAMLMediaType:
if err := writeComposeFile(layer, i, f, content); err != nil {
return err
}
case ocipush.ComposeEnvFileMediaType:
if err := writeEnvFile(layer, local, content); err != nil {
return err
}
case ocipush.ComposeEmptyConfigMediaType:
}
_, err = f.Write(content)
}
return nil
}

func writeComposeFile(layer v1.Descriptor, i int, f *os.File, content []byte) error {
if _, ok := layer.Annotations["com.docker.compose.file"]; i > 0 && ok {
_, err := f.Write([]byte("\n---\n"))
if err != nil {
return err
}
}
_, err := f.Write(content)
return err
}

func writeEnvFile(layer v1.Descriptor, local string, content []byte) error {
envfilePath, ok := layer.Annotations["com.docker.compose.envfile"]
if !ok {
return fmt.Errorf("missing annotation com.docker.compose.envfile in layer %q", layer.Digest)
}
otherFile, err := os.Create(filepath.Join(local, envfilePath))
if err != nil {
return err
}
_, err = otherFile.Write(content)
if err != nil {
return err
}
return nil
}

Expand Down

0 comments on commit 8402888

Please sign in to comment.