From cd959e8d15f2212b07853aeff1f5697249d251f5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 05:38:37 -0500 Subject: [PATCH] feat(envelope): surface hostRequirements warnings in CLI result JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire ValidateHostRequirements return value through resolvedContainer → setupContainerParams → Result.HostWarnings → WriteResultJSON so that unmet hostRequirements (cpus, memory, storage) appear in the JSON envelope emitted by `devsy up`. - Add Warnings []string field to ResultEnvelope (omitempty) - Add HostWarnings []string to Result struct - Propagate warnings from resolveNewContainer through the setup chain - Update WriteResultJSON signature; pass nil from build/exec callers - Add E2E test with devcontainer requiring 128 cpus --- cmd/build.go | 2 +- cmd/exec.go | 2 +- cmd/up.go | 10 ++- e2e/tests/up/host_requirements.go | 67 +++++++++++++++++++ .../.devcontainer.json | 7 ++ pkg/devcontainer/config/envelope.go | 13 ++-- pkg/devcontainer/config/envelope_test.go | 38 ++++++++++- pkg/devcontainer/config/result.go | 1 + pkg/devcontainer/setup.go | 2 + pkg/devcontainer/single.go | 5 +- 10 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 e2e/tests/up/host_requirements.go create mode 100644 e2e/tests/up/testdata/docker-host-requirements/.devcontainer.json diff --git a/cmd/build.go b/cmd/build.go index 2d61e84f2..b8fa3071c 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -232,6 +232,6 @@ func (cmd *BuildCmd) build( } } user := devcconfig.GetRemoteUser(result) - _ = devcconfig.WriteResultJSON(os.Stdout, containerID, user, workdir) + _ = devcconfig.WriteResultJSON(os.Stdout, containerID, user, workdir, nil) return nil } diff --git a/cmd/exec.go b/cmd/exec.go index 0211382b0..b2482d940 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -133,7 +133,7 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { return err } - _ = devcconfig.WriteResultJSON(os.Stderr, containerDetails.ID, user, workdir) + _ = devcconfig.WriteResultJSON(os.Stderr, containerDetails.ID, user, workdir, nil) return nil } diff --git a/cmd/up.go b/cmd/up.go index 7118c4255..bd3ce5f78 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -107,10 +107,14 @@ func (cmd *UpCmd) Run( } containerID := "" - if wctx.result != nil && wctx.result.ContainerDetails != nil { - containerID = wctx.result.ContainerDetails.ID + var warnings []string + if wctx.result != nil { + if wctx.result.ContainerDetails != nil { + containerID = wctx.result.ContainerDetails.ID + } + warnings = wctx.result.HostWarnings } - _ = config2.WriteResultJSON(os.Stdout, containerID, wctx.user, wctx.workdir) + _ = config2.WriteResultJSON(os.Stdout, containerID, wctx.user, wctx.workdir, warnings) return nil } diff --git a/e2e/tests/up/host_requirements.go b/e2e/tests/up/host_requirements.go new file mode 100644 index 000000000..2dad621b4 --- /dev/null +++ b/e2e/tests/up/host_requirements.go @@ -0,0 +1,67 @@ +package up + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command host requirements warnings", + ginkgo.Label("up-host-requirements"), + func() { + var dtc *dockerTestContext + + ginkgo.BeforeEach(func(ctx context.Context) { + var err error + dtc = &dockerTestContext{} + dtc.initialDir, err = os.Getwd() + framework.ExpectNoError(err) + + dtc.f, err = setupDockerProvider( + filepath.Join(dtc.initialDir, "bin"), "docker", + ) + framework.ExpectNoError(err) + }) + + ginkgo.It("surfaces hostRequirements warnings in JSON envelope", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-host-requirements", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + stdout, _, err := dtc.f.DevsyUpStreams(ctx, tempDir) + framework.ExpectNoError(err) + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + gomega.Expect(lines).NotTo(gomega.BeEmpty()) + + lastLine := lines[len(lines)-1] + var envelope config.ResultEnvelope + err = json.Unmarshal([]byte(lastLine), &envelope) + framework.ExpectNoError(err) + + gomega.Expect(envelope.Outcome).To(gomega.Equal("success")) + gomega.Expect(envelope.Warnings).NotTo(gomega.BeEmpty()) + + found := false + for _, w := range envelope.Warnings { + if strings.Contains(w, "cpus:") { + found = true + break + } + } + gomega.Expect(found).To(gomega.BeTrue(), + "expected a cpus warning in %v", envelope.Warnings) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }, +) diff --git a/e2e/tests/up/testdata/docker-host-requirements/.devcontainer.json b/e2e/tests/up/testdata/docker-host-requirements/.devcontainer.json new file mode 100644 index 000000000..eda533fce --- /dev/null +++ b/e2e/tests/up/testdata/docker-host-requirements/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "HostRequirements", + "image": "ghcr.io/devsy-org/test-images/go:1", + "hostRequirements": { + "cpus": 128 + } +} diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index f6f2d5e1d..bf1048ef1 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -7,10 +7,11 @@ import ( ) type ResultEnvelope struct { - Outcome string `json:"outcome"` - ContainerID string `json:"containerId"` - RemoteUser string `json:"remoteUser"` - RemoteWorkspaceFolder string `json:"remoteWorkspaceFolder"` + Outcome string `json:"outcome"` + ContainerID string `json:"containerId"` + RemoteUser string `json:"remoteUser"` + RemoteWorkspaceFolder string `json:"remoteWorkspaceFolder"` + Warnings []string `json:"warnings,omitempty"` } type ErrorEnvelope struct { @@ -18,12 +19,14 @@ type ErrorEnvelope struct { Message string `json:"message"` } -func WriteResultJSON(w io.Writer, containerID, user, workdir string) error { +//nolint:revive +func WriteResultJSON(w io.Writer, containerID, user, workdir string, warnings []string) error { env := ResultEnvelope{ Outcome: "success", ContainerID: containerID, RemoteUser: user, RemoteWorkspaceFolder: workdir, + Warnings: warnings, } data, err := json.Marshal(env) if err != nil { diff --git a/pkg/devcontainer/config/envelope_test.go b/pkg/devcontainer/config/envelope_test.go index 517eb89cb..1a50424de 100644 --- a/pkg/devcontainer/config/envelope_test.go +++ b/pkg/devcontainer/config/envelope_test.go @@ -13,37 +13,52 @@ func TestWriteResultJSON(t *testing.T) { containerID string user string workdir string + warnings []string }{ { name: "typical values", containerID: "abc123def456", user: "vscode", workdir: "/workspaces/project", + warnings: nil, }, { name: "root user", containerID: "sha256:abcdef1234567890", user: "root", workdir: "/workspaces/my-app", + warnings: nil, }, { name: "empty strings", containerID: "", user: "", workdir: "", + warnings: nil, }, { name: "special characters in container ID", containerID: "container:with/special-chars_123", user: "dev-user", workdir: "/workspaces/my project", + warnings: nil, + }, + { + name: "with warnings", + containerID: "abc123", + user: "vscode", + workdir: "/workspaces/project", + warnings: []string{ + "cpus: required 128, available 4", + "memory: required 256gb (274877906944 bytes), available 17179869184 bytes", + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var buf bytes.Buffer - err := WriteResultJSON(&buf, tt.containerID, tt.user, tt.workdir) + err := WriteResultJSON(&buf, tt.containerID, tt.user, tt.workdir, tt.warnings) if err != nil { t.Fatalf("WriteResultJSON returned error: %v", err) } @@ -71,6 +86,27 @@ func TestWriteResultJSON(t *testing.T) { t.Errorf("remoteWorkspaceFolder = %q, want %q", envelope.RemoteWorkspaceFolder, tt.workdir) } + if len(tt.warnings) == 0 { + if envelope.Warnings != nil { + t.Errorf("warnings = %v, want nil (omitempty)", envelope.Warnings) + } + if bytes.Contains(output, []byte(`"warnings"`)) { + t.Error("warnings field must be omitted when empty") + } + } + if len(tt.warnings) > 0 { + if len(envelope.Warnings) != len(tt.warnings) { + t.Fatalf( + "warnings length = %d, want %d", + len(envelope.Warnings), len(tt.warnings), + ) + } + for i, w := range tt.warnings { + if envelope.Warnings[i] != w { + t.Errorf("warnings[%d] = %q, want %q", i, envelope.Warnings[i], w) + } + } + } if bytes.Contains(output[:len(output)-1], []byte("\n")) { t.Error("JSON must be single-line (no embedded newlines)") diff --git a/pkg/devcontainer/config/result.go b/pkg/devcontainer/config/result.go index ec3b02845..652b184b4 100644 --- a/pkg/devcontainer/config/result.go +++ b/pkg/devcontainer/config/result.go @@ -14,6 +14,7 @@ type Result struct { MergedConfig *MergedDevContainerConfig `json:"MergedConfig"` SubstitutionContext *SubstitutionContext `json:"SubstitutionContext"` ContainerDetails *ContainerDetails `json:"ContainerDetails"` + HostWarnings []string `json:"HostWarnings,omitempty"` } type DevContainerConfigWithPath struct { diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 8fd3d2ede..121440492 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -36,6 +36,7 @@ type setupContainerParams struct { mergedConfig *config.MergedDevContainerConfig substitutionContext *config.SubstitutionContext timeout time.Duration + hostWarnings []string } type setupInfo struct { @@ -119,6 +120,7 @@ func (r *runner) buildResult(params *setupContainerParams) *config.Result { MergedConfig: params.mergedConfig, SubstitutionContext: params.substitutionContext, ContainerDetails: params.containerDetails, + HostWarnings: params.hostWarnings, } if r.WorkspaceConfig.CLIOptions.DefaultUserEnvProbe != "" { diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index f1725b9c4..5828eaa47 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -39,6 +39,7 @@ exec /usr/local/bin/devsy agent container daemon type resolvedContainer struct { details *config.ContainerDetails mergedConfig *config.MergedDevContainerConfig + hostWarnings []string } // resolveParams bundles the arguments shared by the container resolution methods. @@ -101,6 +102,7 @@ func (r *runner) runSingleContainer( mergedConfig: resolved.mergedConfig, substitutionContext: substitutionContext, timeout: timeout, + hostWarnings: resolved.hostWarnings, }) } @@ -220,7 +222,7 @@ func (r *runner) resolveNewContainer( ctx context.Context, p *resolveParams, ) (*resolvedContainer, error) { - config.ValidateHostRequirements( + hostWarnings := config.ValidateHostRequirements( p.parsedConfig.Config.HostRequirements, config.SystemHostInfo{}, p.substitutionContext.LocalWorkspaceFolder, @@ -276,6 +278,7 @@ func (r *runner) resolveNewContainer( return &resolvedContainer{ details: containerDetails, mergedConfig: mergedConfig, + hostWarnings: hostWarnings, }, nil }