diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 2fbd42603..ab583ccf8 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -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 diff --git a/cmd/readconfiguration.go b/cmd/readconfiguration.go new file mode 100644 index 000000000..755d6f12d --- /dev/null +++ b/cmd/readconfiguration.go @@ -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 +} diff --git a/cmd/root.go b/cmd/root.go index f332c432f..a848d1bf6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 4460e21ed..8aa6a5ee5 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -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" diff --git a/e2e/tests/readconfiguration/readconfiguration.go b/e2e/tests/readconfiguration/readconfiguration.go new file mode 100644 index 000000000..52ebb0b55 --- /dev/null +++ b/e2e/tests/readconfiguration/readconfiguration.go @@ -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())) +}) diff --git a/e2e/tests/readconfiguration/testdata/.devcontainer/devcontainer.json b/e2e/tests/readconfiguration/testdata/.devcontainer/devcontainer.json new file mode 100644 index 000000000..ee5b9dd33 --- /dev/null +++ b/e2e/tests/readconfiguration/testdata/.devcontainer/devcontainer.json @@ -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"] + } + } +}