Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ jobs:

- label: provider
runner: ubuntu-latest
free-disk-space: false
free-disk-space: true
install-kind: true
requires-secret: false

Expand Down
6 changes: 6 additions & 0 deletions cmd/workspace/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func NewBuildCmd(flags *cmdflags.GlobalFlags) *cobra.Command {
}

cmd.registerFlags(buildCmd)
buildCmd.MarkFlagsMutuallyExclusive(names.NoLockfile, names.FrozenLockfile)
return buildCmd
}

Expand Down Expand Up @@ -109,6 +110,11 @@ func (cmd *BuildCmd) registerImageFlags(buildCmd *cobra.Command) {
cliflags.String(&cmd.Output, names.Output, "", "Build output type (docker or oci)"),
cliflags.String(&cmd.ExperimentalLockfile, names.ExperimentalLockfile, "",
"Lockfile path for reproducible builds"),
cliflags.Bool(&cmd.FrozenLockfile, names.FrozenLockfile, false,
"Fail if devcontainer-lock.json is missing or does not match the resolved features "+
"instead of writing it"),
cliflags.Bool(&cmd.NoLockfile, names.NoLockfile, false,
"Disable devcontainer-lock.json generation and verification"),
Comment on lines +113 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document both supported lockfile filenames consistently.

The lockfile is named .devcontainer-lock.json for .devcontainer.json configurations, so naming only devcontainer-lock.json can mislead users preparing frozen builds.

  • cmd/workspace/build.go#L113-L117: update build help to mention both names or say “the applicable devcontainer lockfile.”
  • pkg/provider/workspace.go#L294-L300: update the CLIOptions comments with the same neutral/both-name wording.
  • cmd/workspace/up/up_flags.go#L73-L77: update up-command help with the same wording.
📍 Affects 3 files
  • cmd/workspace/build.go#L113-L117 (this comment)
  • pkg/provider/workspace.go#L294-L300
  • cmd/workspace/up/up_flags.go#L73-L77
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/workspace/build.go` around lines 113 - 117, Update the FrozenLockfile and
NoLockfile help text in cmd/workspace/build.go, the corresponding CLIOptions
comments in pkg/provider/workspace.go, and the up-command help in
cmd/workspace/up/up_flags.go to refer to both supported lockfile names or use
neutral wording such as “the applicable devcontainer lockfile”; keep the
existing option behavior unchanged.

cliflags.String(
&cmd.ImageName,
names.ImageName,
Expand Down
18 changes: 18 additions & 0 deletions cmd/workspace/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ func TestBuildCmd_NoCacheFlag(t *testing.T) {
assert.Equal(t, "false", flag.DefValue)
}

func TestBuildCmd_NoLockfileFlag(t *testing.T) {
buildCmd := NewBuildCmd(&flags.GlobalFlags{})
flag := buildCmd.Flags().Lookup(names.NoLockfile)
require.NotNil(t, flag)
assert.Equal(t, "false", flag.DefValue)
}

func TestBuildCmd_NoLockfileAndFrozenLockfileMutuallyExclusive(t *testing.T) {
buildCmd := NewBuildCmd(&flags.GlobalFlags{})
require.NoError(t, buildCmd.Flags().Parse(
[]string{names.Flag(names.NoLockfile), names.Flag(names.FrozenLockfile)},
))
err := buildCmd.ValidateFlagGroups()
require.Error(t, err)
assert.Contains(t, err.Error(), names.NoLockfile)
assert.Contains(t, err.Error(), names.FrozenLockfile)
}

func TestBuildCmd_NoCacheFlagParsesValue(t *testing.T) {
buildCmd := NewBuildCmd(&flags.GlobalFlags{})
err := buildCmd.ParseFlags([]string{"--no-cache"})
Expand Down
1 change: 1 addition & 0 deletions cmd/workspace/up/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command {
RunE: cmd.execute,
}
cmd.registerFlags(upCmd)
upCmd.MarkFlagsMutuallyExclusive(names.NoLockfile, names.FrozenLockfile)
return upCmd
}

Expand Down
5 changes: 5 additions & 0 deletions cmd/workspace/up/up_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ func (cmd *UpCmd) registerBuildFlags(upCmd *cobra.Command) {
"Default for updateRemoteUserUID when unset in the config (on, off)"),
flags.Bool(cmd.MountWorkspaceGitRoot, names.MountWorkspaceGitRoot, true,
"Mount the workspace git root as the workspace folder"),
flags.Bool(&cmd.FrozenLockfile, names.FrozenLockfile, false,
"Fail if devcontainer-lock.json is missing or does not match the resolved features "+
"instead of writing it"),
flags.Bool(&cmd.NoLockfile, names.NoLockfile, false,
"Disable devcontainer-lock.json generation and verification"),
)
flags.RegisterDevContainerModifierFlags(upCmd.Flags(), flags.DevContainerModifierFlags{
Image: &cmd.DevContainerImage,
Expand Down
11 changes: 11 additions & 0 deletions cmd/workspace/up/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ const (
testBindMountAB = "type=bind,source=/a,target=/b"
)

func TestUpCmd_NoLockfileAndFrozenLockfileMutuallyExclusive(t *testing.T) {
upCmd := NewUpCmd(&flags.GlobalFlags{})
require.NoError(t, upCmd.Flags().Parse(
[]string{names.Flag(names.NoLockfile), names.Flag(names.FrozenLockfile)},
))
err := upCmd.ValidateFlagGroups()
require.Error(t, err)
assert.Contains(t, err.Error(), names.NoLockfile)
assert.Contains(t, err.Error(), names.FrozenLockfile)
}

func TestUpCmd_ValidateDefaultUserEnvProbe(t *testing.T) {
tests := []struct {
name string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/devcontainers/features/git:1": {
"version": "1.0.0",
"resolved": "ghcr.io/devcontainers/features/git@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"integrity": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "Test Lockfile Bad Pin",
"image": "ghcr.io/devsy-org/test-images/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/git:1": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "Test Lockfile",
"image": "ghcr.io/devsy-org/test-images/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/git:1": {}
},
"postStartCommand": "git version"
}
183 changes: 183 additions & 0 deletions e2e/tests/up-features/up_features.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ package up

import (
"context"
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"

"github.com/devsy-org/devsy/e2e/framework"
"github.com/devsy-org/devsy/pkg/devcontainer/feature"
"github.com/devsy-org/devsy/pkg/flags/names"
"github.com/devsy-org/devsy/pkg/hash"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/ghttp"
Expand Down Expand Up @@ -532,6 +537,184 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features"), func(
ginkgo.SpecTimeout(framework.TimeoutLong()),
)

ginkgo.It(
"writes a devcontainer lockfile and reuses it under --frozen-lockfile",
ginkgo.Label("up-features", "lockfile"),
func(ctx context.Context) {
f, err := setupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)

tempDir, err := framework.CopyToTempDir(
"tests/up-features/testdata/docker-features-lockfile",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)

wsName := filepath.Base(tempDir)
ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName)

err = f.DevsyUp(ctx, tempDir)
framework.ExpectNoError(err)

lockContent, err := f.DevsySSH(ctx, wsName, "cat /workspaces/*/.devcontainer-lock.json")
framework.ExpectNoError(err)

var lock feature.Lockfile
framework.ExpectNoError(json.Unmarshal([]byte(strings.TrimSpace(lockContent)), &lock))

const featureKey = "ghcr.io/devcontainers/features/git:1"
gomega.Expect(lock.Features).To(gomega.HaveKey(featureKey))
gomega.Expect(lock.Features).To(gomega.HaveLen(1))

entry := lock.Features[featureKey]
digestRe := regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
gomega.Expect(entry.Integrity).To(gomega.MatchRegexp(digestRe.String()))
gomega.Expect(entry.Resolved).
To(gomega.Equal("ghcr.io/devcontainers/features/git@" + entry.Integrity))
gomega.Expect(entry.Version).To(gomega.MatchRegexp(`^\d+\.\d+\.\d+`))

err = f.DevsyUpRecreate(ctx, tempDir, names.Flag(names.FrozenLockfile))
framework.ExpectNoError(err)
},
ginkgo.SpecTimeout(framework.TimeoutLong()),
)

ginkgo.It(
"fails --frozen-lockfile when no lockfile exists",
ginkgo.Label("up-features", "lockfile"),
func(ctx context.Context) {
f, err := setupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)

tempDir, err := framework.CopyToTempDir(
"tests/up-features/testdata/docker-features-lockfile",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)

wsName := filepath.Base(tempDir)
ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName)

err = f.DevsyUp(ctx, tempDir, names.Flag(names.FrozenLockfile))
framework.ExpectError(err)
},
ginkgo.SpecTimeout(framework.TimeoutLong()),
)

ginkgo.It(
"does not write a lockfile with --no-lockfile",
ginkgo.Label("up-features", "lockfile"),
func(ctx context.Context) {
f, err := setupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)

tempDir, err := framework.CopyToTempDir(
"tests/up-features/testdata/docker-features-lockfile",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)

wsName := filepath.Base(tempDir)
ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName)

err = f.DevsyUp(ctx, tempDir, names.Flag(names.NoLockfile))
framework.ExpectNoError(err)

_, err = f.DevsySSH(ctx, wsName, "cat /workspaces/*/.devcontainer-lock.json")
framework.ExpectError(err)
},
ginkgo.SpecTimeout(framework.TimeoutLong()),
)

ginkgo.It(
"honors a committed lockfile and rejects a bogus pinned digest",
ginkgo.Label("up-features", "lockfile"),
func(ctx context.Context) {
f, err := setupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)

tempDir, err := framework.CopyToTempDir(
"tests/up-features/testdata/docker-features-lockfile-bad",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)

wsName := filepath.Base(tempDir)
ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName)

err = f.DevsyUp(ctx, tempDir)
framework.ExpectError(err)
},
ginkgo.SpecTimeout(framework.TimeoutLong()),
)

ginkgo.It(
"records the exact tarball digest in the lockfile",
ginkgo.Label("up-features", "lockfile"),
func(ctx context.Context) {
server := ghttp.NewServer()
ginkgo.DeferCleanup(server.Close)

tempDir, err := framework.CopyToTempDir(
"tests/up-features/testdata/docker-features-http-headers",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)

featureArchiveFilePath := path.Join(tempDir, "devcontainer-feature-hello.tgz")
featureFiles := []string{
path.Join(tempDir, "devcontainer-feature.json"),
path.Join(tempDir, "install.sh"),
}
framework.ExpectNoError(createTarGzArchive(featureArchiveFilePath, featureFiles))

devContainerFile := filepath.Clean(path.Join(tempDir, ".devcontainer.json"))
devContainerFileBuf, err := os.ReadFile(devContainerFile)
framework.ExpectNoError(err)
output := strings.ReplaceAll(string(devContainerFileBuf), "#{server_url}", server.URL())
// #nosec G306 -- test file, permissive mode is acceptable.
err = os.WriteFile(path.Join(tempDir, ".devcontainer.json"), []byte(output), 0o644)
framework.ExpectNoError(err)

respHeader := http.Header{}
respHeader.Set(
"Content-Disposition",
"attachment; filename=devcontainer-feature-hello.tgz",
)
featureArchiveFileBuf, err := os.ReadFile(filepath.Clean(featureArchiveFilePath))
framework.ExpectNoError(err)
server.AppendHandlers(ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", "/devcontainer-feature-hello.tgz"),
ghttp.RespondWith(http.StatusOK, featureArchiveFileBuf, respHeader),
))

f := framework.NewDefaultFramework(initialDir + "/bin")
_ = f.DevsyProviderDelete(ctx, "docker")
framework.ExpectNoError(f.DevsyProviderAdd(ctx, "docker"))
framework.ExpectNoError(f.DevsyProviderUse(ctx, "docker"))

wsName := filepath.Base(tempDir)
ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName)
framework.ExpectNoError(f.DevsyUp(ctx, tempDir))

lockContent, err := f.DevsySSH(ctx, wsName, "cat /workspaces/*/.devcontainer-lock.json")
framework.ExpectNoError(err)

var lock feature.Lockfile
framework.ExpectNoError(json.Unmarshal([]byte(strings.TrimSpace(lockContent)), &lock))

featureURL := server.URL() + "/devcontainer-feature-hello.tgz"
digestHex, err := hash.File(featureArchiveFilePath)
framework.ExpectNoError(err)

gomega.Expect(lock.Features).To(gomega.HaveKey(featureURL))
entry := lock.Features[featureURL]
gomega.Expect(entry.Resolved).To(gomega.Equal(featureURL))
gomega.Expect(entry.Integrity).To(gomega.Equal("sha256:" + digestHex))
},
ginkgo.SpecTimeout(framework.TimeoutLong()),
)

ginkgo.It("resolves user variable in dockerfile", func(ctx context.Context) {
f, err := setupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)
Expand Down
6 changes: 6 additions & 0 deletions pkg/devcontainer/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ func (r *runner) extendImage(
DevContainerConfig: parsedConfig,
ForceBuild: options.ForceBuild,
SecretOpts: featureSecretOpts(options),
FrozenLockfile: options.FrozenLockfile,
NoLockfile: options.NoLockfile,
})
if err != nil {
return nil, fmt.Errorf("get extended build info: %w", err)
Expand Down Expand Up @@ -184,6 +186,8 @@ func (r *runner) buildAndExtendImage(
DevContainerConfig: parsedConfig,
ForceBuild: options.ForceBuild,
SecretOpts: featureSecretOpts(options),
FrozenLockfile: options.FrozenLockfile,
NoLockfile: options.NoLockfile,
})
if err != nil {
return nil, fmt.Errorf("get extended build info: %w", err)
Expand Down Expand Up @@ -554,6 +558,8 @@ func (r *runner) buildDevImageCompose(
featureSecretsFile: options.FeatureSecretsFile,
pull: options.Pull,
noCache: options.NoCache,
frozenLockfile: options.FrozenLockfile,
noLockfile: options.NoLockfile,
})
if err != nil {
return nil, fmt.Errorf("build and extend docker-compose: %w", err)
Expand Down
12 changes: 6 additions & 6 deletions pkg/devcontainer/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,10 @@ type buildAndExtendParams struct {
composeService *composetypes.ServiceConfig
globalArgs []string
featureSecretsFile string
// pull re-pulls base images during the compose build (--pull), set from
// CLIOptions.Pull.
pull bool
// noCache disables the build cache during the compose build (--no-cache),
// set from CLIOptions.NoCache.
noCache bool
pull bool
noCache bool
frozenLockfile bool
noLockfile bool
}

// composeUpParams groups the inputs shared by extendedDockerComposeUp and
Expand Down Expand Up @@ -757,6 +755,8 @@ func (r *runner) buildComposeOverrideArgs(
featureSecretsFile: start.options.FeatureSecretsFile,
pull: start.options.Pull,
noCache: start.options.NoCache,
frozenLockfile: start.options.FrozenLockfile,
noLockfile: start.options.NoLockfile,
})
if err != nil {
return nil, fmt.Errorf("build and extend docker-compose: %w", err)
Expand Down
2 changes: 2 additions & 0 deletions pkg/devcontainer/compose_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ func (r *runner) prepareExtendedComposeBuild(
SecretsFile: params.featureSecretsFile,
Prompter: &feature.TerminalSecretPrompter{},
},
FrozenLockfile: params.frozenLockfile,
NoLockfile: params.noLockfile,
})
if err != nil {
return preparedComposeBuild{}, err
Expand Down
Loading
Loading