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
6 changes: 6 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ jobs:
install-kind: false
requires-secret: false

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

# Up tests

- label: up-workspaces
Expand Down
179 changes: 179 additions & 0 deletions cmd/readconfiguration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/devsy-org/devsy/cmd/flags"
config2 "github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/spf13/cobra"
)

// ReadConfigurationCmd holds the read-configuration cmd flags.
type ReadConfigurationCmd struct {
*flags.GlobalFlags

WorkspaceFolder string
Config string
IncludeFeaturesConfiguration bool
IncludeMergedConfiguration bool
}

// NewReadConfigurationCmd creates a new read-configuration command.
func NewReadConfigurationCmd(f *flags.GlobalFlags) *cobra.Command {
cmd := &ReadConfigurationCmd{GlobalFlags: f}
readConfigCmd := &cobra.Command{
Use: "read-configuration",
Short: "Reads and outputs the merged devcontainer configuration as JSON",
RunE: cmd.Run,
}

readConfigCmd.Flags().
StringVar(
&cmd.WorkspaceFolder,
"workspace-folder",
"",
"Path to the workspace folder",
)
_ = readConfigCmd.MarkFlagRequired("workspace-folder")
readConfigCmd.Flags().
StringVar(
&cmd.Config,
"config",
"",
"Path to a specific devcontainer.json",
)
readConfigCmd.Flags().
BoolVar(
&cmd.IncludeFeaturesConfiguration,
"include-features-configuration",
false,
"Include features in the output",
)
readConfigCmd.Flags().
BoolVar(
&cmd.IncludeMergedConfiguration,
"include-merged-configuration",
false,
"Include the merged configuration in the output",
)

return readConfigCmd
}

type readConfigurationOutput struct {
Configuration *config2.DevContainerConfig `json:"configuration"`
Workspace readConfigurationWorkspace `json:"workspace"`
Features map[string]any `json:"features,omitempty"`
Merged *config2.MergedDevContainerConfig `json:"mergedConfiguration,omitempty"`
}

type readConfigurationWorkspace struct {
Folder string `json:"workspaceFolder"`
}

// Run executes the read-configuration command.
func (cmd *ReadConfigurationCmd) Run(
_ *cobra.Command,
_ []string,
) error {
parsedConfig, workspaceFolder, err := cmd.resolveConfig()
if err != nil {
return err
}

output := readConfigurationOutput{
Configuration: parsedConfig,
Workspace: readConfigurationWorkspace{
Folder: workspaceFolder,
},
}

if cmd.IncludeFeaturesConfiguration {
output.Features = parsedConfig.Features
}

if cmd.IncludeMergedConfiguration {
merged, mergeErr := buildMergedConfig(parsedConfig)
if mergeErr != nil {
return mergeErr
}
output.Merged = merged
}

out, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("marshal output: %w", err)
}

_, _ = os.Stdout.Write(out)
_, _ = os.Stdout.WriteString("\n")

return nil
}

func (cmd *ReadConfigurationCmd) resolveConfig() (
*config2.DevContainerConfig,
string,
error,
) {
workspaceFolder, err := filepath.Abs(cmd.WorkspaceFolder)
if err != nil {
return nil, "", fmt.Errorf("resolve workspace folder: %w", err)
}

info, err := os.Stat(workspaceFolder)
if err != nil {
return nil, "", fmt.Errorf(
"workspace folder %s: %w",
workspaceFolder,
err,
)
}
if !info.IsDir() {
return nil, "", fmt.Errorf(
"workspace folder %s is not a directory",
workspaceFolder,
)
}

var parsedConfig *config2.DevContainerConfig
if cmd.Config != "" {
parsedConfig, err = config2.ParseDevContainerJSONFile(cmd.Config)
} else {
parsedConfig, err = config2.ParseDevContainerJSON(
workspaceFolder,
"",
)
}
if err != nil {
return nil, "", fmt.Errorf("parse devcontainer config: %w", err)
}
if parsedConfig == nil {
return nil, "", fmt.Errorf(
"no devcontainer configuration found in %s",
workspaceFolder,
)
}

return parsedConfig, workspaceFolder, nil
}

func buildMergedConfig(
parsedConfig *config2.DevContainerConfig,
) (*config2.MergedDevContainerConfig, error) {
imageMetadataConfig := &config2.ImageMetadataConfig{}
config2.AddConfigToImageMetadata(parsedConfig, imageMetadataConfig)

mergedConfig, err := config2.MergeConfiguration(
parsedConfig,
imageMetadataConfig.Config,
)
if err != nil {
return nil, fmt.Errorf("merge configuration: %w", err)
}

return mergedConfig, nil
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func BuildRoot() *cobra.Command {
rootCmd.AddCommand(NewUpgradeCmd())
rootCmd.AddCommand(NewTroubleshootCmd(globalFlags))
rootCmd.AddCommand(NewPingCmd(globalFlags))
rootCmd.AddCommand(NewReadConfigurationCmd(globalFlags))

inheritCommandFlagsFromEnvironment(rootCmd)

Expand Down
1 change: 1 addition & 0 deletions e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
_ "github.com/devsy-org/devsy/e2e/tests/machine"
_ "github.com/devsy-org/devsy/e2e/tests/machineprovider"
_ "github.com/devsy-org/devsy/e2e/tests/provider"
_ "github.com/devsy-org/devsy/e2e/tests/readconfiguration"
_ "github.com/devsy-org/devsy/e2e/tests/ssh"
_ "github.com/devsy-org/devsy/e2e/tests/tunnel"
_ "github.com/devsy-org/devsy/e2e/tests/up"
Expand Down
140 changes: 140 additions & 0 deletions e2e/tests/readconfiguration/readconfiguration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package readconfiguration

import (
"context"
"encoding/json"
"os"

"github.com/devsy-org/devsy/e2e/framework"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)

var _ = ginkgo.Describe("read-configuration command", ginkgo.Label("read-configuration"), func() {
var initialDir string

ginkgo.BeforeEach(func() {
var err error
initialDir, err = os.Getwd()
framework.ExpectNoError(err)
})

ginkgo.It("outputs valid JSON with expected fields", func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")
tempDir, err := framework.CopyToTempDirWithoutChdir(
"tests/readconfiguration/testdata",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) })

stdout, _, err := f.ExecCommandCapture(ctx, []string{
"read-configuration",
"--workspace-folder", tempDir,
})
framework.ExpectNoError(err)

var result map[string]any
err = json.Unmarshal([]byte(stdout), &result)
framework.ExpectNoError(err, "output should be valid JSON")

gomega.Expect(result).To(gomega.HaveKey("configuration"))
gomega.Expect(result).To(gomega.HaveKey("workspace"))

config, ok := result["configuration"].(map[string]any)
gomega.Expect(ok).To(gomega.BeTrue(), "configuration should be an object")
gomega.Expect(config).To(gomega.HaveKeyWithValue("name", "Test Read Configuration"))
gomega.Expect(config).To(
gomega.HaveKeyWithValue("image", "mcr.microsoft.com/devcontainers/base:ubuntu"),
)
gomega.Expect(config).To(gomega.HaveKey("features"))
gomega.Expect(config).To(
gomega.HaveKeyWithValue("remoteUser", "vscode"),
)

ws, ok := result["workspace"].(map[string]any)
gomega.Expect(ok).To(gomega.BeTrue(), "workspace should be an object")
gomega.Expect(ws).To(gomega.HaveKey("workspaceFolder"))

gomega.Expect(result).NotTo(gomega.HaveKey("features"),
"features should not appear without --include-features-configuration")
gomega.Expect(result).NotTo(gomega.HaveKey("mergedConfiguration"),
"merged config should not appear without --include-merged-configuration")
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("includes features with --include-features-configuration", func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")
tempDir, err := framework.CopyToTempDirWithoutChdir(
"tests/readconfiguration/testdata",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) })

stdout, _, err := f.ExecCommandCapture(ctx, []string{
"read-configuration",
"--workspace-folder", tempDir,
"--include-features-configuration",
})
framework.ExpectNoError(err)

var result map[string]any
err = json.Unmarshal([]byte(stdout), &result)
framework.ExpectNoError(err)

gomega.Expect(result).To(gomega.HaveKey("features"))
features, ok := result["features"].(map[string]any)
gomega.Expect(ok).To(gomega.BeTrue(), "features should be an object")
gomega.Expect(features).To(
gomega.HaveKey("ghcr.io/devcontainers/features/node:1"),
)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("includes merged configuration with --include-merged-configuration",
func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")
tempDir, err := framework.CopyToTempDirWithoutChdir(
"tests/readconfiguration/testdata",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) })

stdout, _, err := f.ExecCommandCapture(ctx, []string{
"read-configuration",
"--workspace-folder", tempDir,
"--include-merged-configuration",
})
framework.ExpectNoError(err)

var result map[string]any
err = json.Unmarshal([]byte(stdout), &result)
framework.ExpectNoError(err)

gomega.Expect(result).To(gomega.HaveKey("mergedConfiguration"))
merged, ok := result["mergedConfiguration"].(map[string]any)
gomega.Expect(ok).To(gomega.BeTrue(), "mergedConfiguration should be an object")
gomega.Expect(merged).To(
gomega.HaveKeyWithValue("remoteUser", "vscode"),
)
gomega.Expect(merged).To(
gomega.HaveKeyWithValue("image", "mcr.microsoft.com/devcontainers/base:ubuntu"),
)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("fails with missing workspace folder", func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")

_, _, err := f.ExecCommandCapture(ctx, []string{
"read-configuration",
"--workspace-folder", "/nonexistent/path/that/does/not/exist",
})
framework.ExpectError(err)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("fails without --workspace-folder flag", func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")

_, _, err := f.ExecCommandCapture(ctx, []string{
"read-configuration",
})
framework.ExpectError(err)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "Test Read Configuration",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "18"
}
},
"remoteUser": "vscode",
"forwardPorts": [3000],
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint"]
}
}
}
Loading