diff --git a/cmd/root.go b/cmd/root.go index 6701c2247..490545b4e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/devsy-org/devsy/cmd/machine" "github.com/devsy-org/devsy/cmd/pro" "github.com/devsy-org/devsy/cmd/provider" + "github.com/devsy-org/devsy/cmd/templates" "github.com/devsy-org/devsy/cmd/use" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" @@ -136,6 +137,7 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewRunUserCommandsCmd(globalFlags)) rootCmd.AddCommand(NewRunUserCommandsCmdAlias(globalFlags)) rootCmd.AddCommand(features.NewFeaturesCmd(globalFlags)) + rootCmd.AddCommand(templates.NewTemplatesCmd(globalFlags)) inheritCommandFlagsFromEnvironment(rootCmd) diff --git a/cmd/templates/apply.go b/cmd/templates/apply.go new file mode 100644 index 000000000..c9236b22c --- /dev/null +++ b/cmd/templates/apply.go @@ -0,0 +1,384 @@ +package templates + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/extract" + "github.com/devsy-org/devsy/pkg/image" + "github.com/devsy-org/devsy/pkg/log" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/spf13/cobra" +) + +const templateMetadataFile = "devcontainer-template.json" + +type ApplyFlags struct { + WorkspaceFolder string + TemplateID string + TemplateArgs []string + Features []string + OmitPaths []string +} + +func NewApplyCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + applyFlags := &ApplyFlags{} + applyCmd := &cobra.Command{ + Use: "apply", + Short: "Fetch template from OCI registry and scaffold devcontainer config", + RunE: func(cmd *cobra.Command, args []string) error { + return runApply(applyFlags) + }, + } + + applyCmd.Flags().StringVar( + &applyFlags.WorkspaceFolder, "workspace-folder", ".", + "Target workspace folder to scaffold into", + ) + applyCmd.Flags().StringVar( + &applyFlags.TemplateID, "template-id", "", + "OCI reference of the template", + ) + applyCmd.Flags().StringSliceVar( + &applyFlags.TemplateArgs, "template-args", nil, + "Template variable arguments as key=value pairs", + ) + applyCmd.Flags().StringSliceVar( + &applyFlags.Features, "features", nil, + "Feature IDs to add to the generated devcontainer.json", + ) + applyCmd.Flags().StringSliceVar( + &applyFlags.OmitPaths, "omit-paths", nil, + "Glob patterns for paths to skip during scaffolding", + ) + _ = applyCmd.MarkFlagRequired("template-id") + + return applyCmd +} + +func runApply(f *ApplyFlags) error { + ref, err := name.ParseReference(f.TemplateID) + if err != nil { + return fmt.Errorf("parse template reference: %w", err) + } + + log.Infof("Pulling template: %s", ref.String()) + + img, err := pullTemplateImage(ref) + if err != nil { + return err + } + + tempDir, err := os.MkdirTemp("", "devsy-template-*") + if err != nil { + return fmt.Errorf("create temp directory: %w", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + if err := extractTemplateToDir(img, tempDir); err != nil { + return err + } + + metadata, err := readTemplateMetadata(tempDir) + if err != nil { + return err + } + + templateVars := parseTemplateArgs(f.TemplateArgs, metadata) + + if err := applyTemplateSubstitution(tempDir, templateVars); err != nil { + return err + } + + return applyToWorkspace(f, tempDir) +} + +func applyToWorkspace(f *ApplyFlags, tempDir string) error { + workspaceFolder, err := filepath.Abs(f.WorkspaceFolder) + if err != nil { + return fmt.Errorf("resolve workspace folder: %w", err) + } + + if err := copyTemplateFiles(tempDir, workspaceFolder, f.OmitPaths); err != nil { + return err + } + + if len(f.Features) > 0 { + if err := addFeaturesToDevcontainer(workspaceFolder, f.Features); err != nil { + return err + } + } + + log.Infof("Template applied successfully to %s", workspaceFolder) + + return nil +} + +func pullTemplateImage(ref name.Reference) (v1.Image, error) { + log.Debugf("fetching template OCI image: reference=%s", ref.String()) + + img, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + err = image.SanitizeRegistryError(err) + registry := ref.Context().RegistryStr() + + return nil, fmt.Errorf("pull template from %s: %w", registry, err) + } + + return img, nil +} + +func extractTemplateToDir(img v1.Image, destDir string) error { + manifest, err := img.Manifest() + if err != nil { + return fmt.Errorf("read manifest: %w", err) + } + + if len(manifest.Layers) == 0 { + return fmt.Errorf("template image has no layers") + } + + layer, err := img.LayerByDigest(manifest.Layers[0].Digest) + if err != nil { + return fmt.Errorf("retrieve template layer: %w", err) + } + + data, err := layer.Uncompressed() + if err != nil { + return fmt.Errorf("read template layer: %w", err) + } + defer func() { _ = data.Close() }() + + if err := extract.Extract(data, destDir); err != nil { + return fmt.Errorf("extract template: %w", err) + } + + return nil +} + +type TemplateMetadata struct { + ID string `json:"id"` + Version string `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + Options map[string]TemplateOption `json:"options,omitempty"` +} + +type TemplateOption struct { + Type string `json:"type"` + Description string `json:"description"` + Default any `json:"default,omitempty"` + Enum []any `json:"enum,omitempty"` +} + +func readTemplateMetadata(templateDir string) (*TemplateMetadata, error) { + metadataPath := filepath.Join(templateDir, templateMetadataFile) + + data, err := os.ReadFile(filepath.Clean(metadataPath)) + if err != nil { + if os.IsNotExist(err) { + return &TemplateMetadata{}, nil + } + + return nil, fmt.Errorf("read template metadata: %w", err) + } + + var metadata TemplateMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("parse template metadata: %w", err) + } + + return &metadata, nil +} + +func parseTemplateArgs(args []string, metadata *TemplateMetadata) map[string]string { + vars := make(map[string]string) + + if metadata != nil && metadata.Options != nil { + for key, opt := range metadata.Options { + if opt.Default != nil { + vars[key] = fmt.Sprintf("%v", opt.Default) + } + } + } + + for _, arg := range args { + key, value, found := strings.Cut(arg, "=") + if found { + vars[key] = value + } + } + + return vars +} + +func applyTemplateSubstitution(templateDir string, vars map[string]string) error { + return filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() || info.Name() == templateMetadataFile { + return nil + } + + return substituteFileVars(path, info, vars) + }) +} + +func substituteFileVars(path string, info os.FileInfo, vars map[string]string) error { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return err + } + + content := string(data) + modified := false + + for key, value := range vars { + placeholder := "${templateOption:" + key + "}" + if strings.Contains(content, placeholder) { + content = strings.ReplaceAll(content, placeholder, value) + modified = true + } + } + + if modified { + //nolint:gosec // path is from filepath.Walk within temp dir + return os.WriteFile(path, []byte(content), info.Mode()) + } + + return nil +} + +func copyTemplateFiles(srcDir, destDir string, omitPaths []string) error { + if err := os.MkdirAll(destDir, 0o750); err != nil { + return fmt.Errorf("create workspace folder: %w", err) + } + + copier := &templateCopier{destDir: destDir, omitPaths: omitPaths} + + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + if relPath == "." || info.Name() == templateMetadataFile { + return nil + } + + return copier.copyEntry(path, relPath, info) + }) +} + +type templateCopier struct { + destDir string + omitPaths []string +} + +func (c *templateCopier) copyEntry(path, relPath string, info os.FileInfo) error { + if shouldOmit(relPath, c.omitPaths) { + if info.IsDir() { + return filepath.SkipDir + } + + return nil + } + + destPath := filepath.Join(c.destDir, relPath) + + if info.IsDir() { + return os.MkdirAll(destPath, 0o750) + } + + return copyFile(path, destPath, info.Mode()) +} + +func copyFile(srcPath, destPath string, mode os.FileMode) error { + data, err := os.ReadFile(filepath.Clean(srcPath)) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil { + return err + } + + //nolint:gosec // mode from source template, path constructed from destDir+relPath + return os.WriteFile(destPath, data, mode) +} + +func shouldOmit(relPath string, omitPaths []string) bool { + for _, pattern := range omitPaths { + matched, err := doublestar.Match(pattern, relPath) + if err == nil && matched { + return true + } + } + + return false +} + +func addFeaturesToDevcontainer(workspaceFolder string, features []string) error { + devcontainerPath := findDevcontainerJSON(workspaceFolder) + if devcontainerPath == "" { + log.Warnf("no devcontainer.json found in workspace, --features not applied") + return nil + } + + data, err := os.ReadFile(filepath.Clean(devcontainerPath)) + if err != nil { + return fmt.Errorf("read devcontainer.json: %w", err) + } + + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("parse devcontainer.json: %w", err) + } + + featureMap, ok := config["features"].(map[string]any) + if !ok { + featureMap = make(map[string]any) + } + + for _, featureID := range features { + featureMap[featureID] = map[string]any{} + } + + config["features"] = featureMap + + output, err := json.MarshalIndent(config, "", "\t") + if err != nil { + return fmt.Errorf("marshal devcontainer.json: %w", err) + } + + // #nosec G306 -- devcontainer.json needs to be readable + return os.WriteFile(devcontainerPath, output, 0o644) +} + +func findDevcontainerJSON(workspaceFolder string) string { + path := filepath.Join(workspaceFolder, ".devcontainer", "devcontainer.json") + if _, err := os.Stat(path); err == nil { + return path + } + + path = filepath.Join(workspaceFolder, ".devcontainer.json") + if _, err := os.Stat(path); err == nil { + return path + } + + return "" +} diff --git a/cmd/templates/generate_docs.go b/cmd/templates/generate_docs.go new file mode 100644 index 000000000..043c62cde --- /dev/null +++ b/cmd/templates/generate_docs.go @@ -0,0 +1,183 @@ +package templates + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +type GenerateDocsFlags struct { + ProjectFolder string + GithubOwner string + GithubRepo string +} + +func NewGenerateDocsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + docsFlags := &GenerateDocsFlags{} + docsCmd := &cobra.Command{ + Use: "generate-docs", + Short: "Generate markdown docs from template metadata", + RunE: func(cmd *cobra.Command, args []string) error { + return runGenerateDocs(docsFlags) + }, + } + + docsCmd.Flags().StringVar( + &docsFlags.ProjectFolder, "project-folder", "", + "Path to project folder containing template sources", + ) + docsCmd.Flags().StringVar( + &docsFlags.GithubOwner, "github-owner", "", + "GitHub owner for documentation links", + ) + docsCmd.Flags().StringVar( + &docsFlags.GithubRepo, "github-repo", "", + "GitHub repo for documentation links", + ) + _ = docsCmd.MarkFlagRequired("project-folder") + + return docsCmd +} + +func runGenerateDocs(f *GenerateDocsFlags) error { + projectFolder, err := filepath.Abs(f.ProjectFolder) + if err != nil { + return fmt.Errorf("resolve project folder: %w", err) + } + + templates, err := discoverTemplates(projectFolder) + if err != nil { + return err + } + + if len(templates) == 0 { + log.Infof("No templates found in %s", projectFolder) + return nil + } + + for _, tmpl := range templates { + docPath := filepath.Join(filepath.Dir(tmpl.path), "README.md") + content := generateTemplateDoc(tmpl.metadata, f.GithubOwner, f.GithubRepo) + + // #nosec G306 -- documentation files need to be readable + if err := os.WriteFile(docPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write documentation for %s: %w", tmpl.metadata.ID, err) + } + + log.Infof("Generated docs: %s", docPath) + } + + log.Infof("Generated documentation for %d template(s)", len(templates)) + + return nil +} + +type discoveredTemplate struct { + path string + metadata *TemplateMetadata +} + +func discoverTemplates(projectFolder string) ([]discoveredTemplate, error) { + var templates []discoveredTemplate + + err := filepath.Walk(projectFolder, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.Name() != templateMetadataFile { + return nil + } + + //nolint:gosec // path from filepath.Walk within project folder + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return err + } + + var metadata TemplateMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + log.Debugf("skipping invalid template metadata: %s", path) + return nil + } + + templates = append(templates, discoveredTemplate{ + path: path, + metadata: &metadata, + }) + + return nil + }) + + return templates, err +} + +func generateTemplateDoc( + metadata *TemplateMetadata, + githubOwner, githubRepo string, +) string { + var sb strings.Builder + + title := metadata.Name + if title == "" { + title = metadata.ID + } + + fmt.Fprintf(&sb, "# %s\n\n", title) + + if metadata.Description != "" { + fmt.Fprintf(&sb, "%s\n\n", metadata.Description) + } + + if metadata.Version != "" { + fmt.Fprintf(&sb, "**Version:** %s\n\n", metadata.Version) + } + + if githubOwner != "" && githubRepo != "" { + fmt.Fprintf( + &sb, + "**Source:** [%s/%s](https://github.com/%s/%s)\n\n", + githubOwner, githubRepo, githubOwner, githubRepo, + ) + } + + writeOptionsTable(&sb, metadata) + + return sb.String() +} + +func writeOptionsTable(sb *strings.Builder, metadata *TemplateMetadata) { + if len(metadata.Options) == 0 { + return + } + + sb.WriteString("## Options\n\n") + sb.WriteString("| Option | Type | Default | Description |\n") + sb.WriteString("|--------|------|---------|-------------|\n") + + keys := make([]string, 0, len(metadata.Options)) + for key := range metadata.Options { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + opt := metadata.Options[key] + defaultVal := "" + if opt.Default != nil { + defaultVal = fmt.Sprintf("%v", opt.Default) + } + + fmt.Fprintf(sb, "| %s | %s | %s | %s |\n", key, opt.Type, defaultVal, opt.Description) + } + + sb.WriteString("\n") +} diff --git a/cmd/templates/metadata.go b/cmd/templates/metadata.go new file mode 100644 index 000000000..bbeff2b87 --- /dev/null +++ b/cmd/templates/metadata.go @@ -0,0 +1,74 @@ +package templates + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" +) + +type MetadataFlags struct { + TemplateID string +} + +func NewMetadataCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + metadataFlags := &MetadataFlags{} + metadataCmd := &cobra.Command{ + Use: "metadata", + Short: "Fetch published template metadata from OCI registry", + RunE: func(cmd *cobra.Command, args []string) error { + return runMetadata(metadataFlags) + }, + } + + metadataCmd.Flags().StringVar( + &metadataFlags.TemplateID, "template-id", "", + "OCI reference of the template", + ) + _ = metadataCmd.MarkFlagRequired("template-id") + + return metadataCmd +} + +func runMetadata(f *MetadataFlags) error { + ref, err := name.ParseReference(f.TemplateID) + if err != nil { + return fmt.Errorf("parse template reference: %w", err) + } + + log.Infof("Fetching metadata for: %s", ref.String()) + + img, err := pullTemplateImage(ref) + if err != nil { + return err + } + + tempDir, err := os.MkdirTemp("", "devsy-template-metadata-*") + if err != nil { + return fmt.Errorf("create temp directory: %w", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + if err := extractTemplateToDir(img, tempDir); err != nil { + return err + } + + metadata, err := readTemplateMetadata(tempDir) + if err != nil { + return err + } + + output, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + + _, _ = os.Stdout.Write(output) + _, _ = os.Stdout.WriteString("\n") + + return nil +} diff --git a/cmd/templates/publish.go b/cmd/templates/publish.go new file mode 100644 index 000000000..c0cd550e0 --- /dev/null +++ b/cmd/templates/publish.go @@ -0,0 +1,165 @@ +package templates + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/extract" + "github.com/devsy-org/devsy/pkg/log" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/stream" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/spf13/cobra" +) + +type PublishFlags struct { + Target string + Registry string + Namespace string +} + +func NewPublishCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + publishFlags := &PublishFlags{} + publishCmd := &cobra.Command{ + Use: "publish", + Short: "Package and push templates to OCI registry", + RunE: func(cmd *cobra.Command, args []string) error { + return runPublish(publishFlags) + }, + } + + publishCmd.Flags().StringVar( + &publishFlags.Target, "target", "", + "Path to template source directory", + ) + publishCmd.Flags().StringVar( + &publishFlags.Registry, "registry", "ghcr.io", + "Target OCI registry", + ) + publishCmd.Flags().StringVar( + &publishFlags.Namespace, "namespace", "", + "Registry namespace (e.g., devcontainers/templates)", + ) + _ = publishCmd.MarkFlagRequired("target") + + return publishCmd +} + +func runPublish(f *PublishFlags) error { + target, err := filepath.Abs(f.Target) + if err != nil { + return fmt.Errorf("resolve target path: %w", err) + } + + metadata, err := validatePublishTarget(target) + if err != nil { + return err + } + + version := metadata.Version + if version == "" { + version = "latest" + } + + ref, err := parsePublishRef(f.Registry, f.Namespace, metadata.ID, version) + if err != nil { + return err + } + + log.Infof("Publishing template %q to %s", metadata.ID, ref.String()) + + img, err := buildTemplateImage(target) + if err != nil { + return err + } + + if err := remote.Write( + ref, img, remote.WithAuthFromKeychain(authn.DefaultKeychain), + ); err != nil { + return fmt.Errorf("push template to registry: %w", err) + } + + log.Infof("Template published successfully: %s", ref.String()) + + metadataJSON, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("marshal published metadata: %w", err) + } + + _, _ = os.Stdout.Write(metadataJSON) + _, _ = os.Stdout.WriteString("\n") + + return nil +} + +func validatePublishTarget(target string) (*TemplateMetadata, error) { + stat, err := os.Stat(target) + if err != nil { + return nil, fmt.Errorf("stat target: %w", err) + } + + if !stat.IsDir() { + return nil, fmt.Errorf("target must be a directory: %s", target) + } + + metadata, err := readTemplateMetadata(target) + if err != nil { + return nil, err + } + + if metadata.ID == "" { + return nil, fmt.Errorf("template metadata missing required 'id' field") + } + + return metadata, nil +} + +func parsePublishRef( + registry, namespace, id, version string, +) (name.Reference, error) { + refStr := buildPublishReference(registry, namespace, id, version) + + ref, err := name.ParseReference(refStr) + if err != nil { + return nil, fmt.Errorf("parse publish reference %q: %w", refStr, err) + } + + return ref, nil +} + +func buildPublishReference(registry, namespace, id, version string) string { + if namespace != "" { + return fmt.Sprintf("%s/%s/%s:%s", registry, namespace, id, version) + } + + return fmt.Sprintf("%s/%s:%s", registry, id, version) +} + +func buildTemplateImage(sourceDir string) (v1.Image, error) { + var buf bytes.Buffer + if err := extract.WriteTar(&buf, sourceDir, true); err != nil { + return nil, fmt.Errorf("create template archive: %w", err) + } + + layer := stream.NewLayer( + io.NopCloser(bytes.NewReader(buf.Bytes())), + stream.WithMediaType(types.OCILayer), + ) + + img, err := mutate.AppendLayers(empty.Image, layer) + if err != nil { + return nil, fmt.Errorf("build template image: %w", err) + } + + return img, nil +} diff --git a/cmd/templates/templates.go b/cmd/templates/templates.go new file mode 100644 index 000000000..a006ef596 --- /dev/null +++ b/cmd/templates/templates.go @@ -0,0 +1,23 @@ +package templates + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +func NewTemplatesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + templatesCmd := &cobra.Command{ + Use: "templates", + Short: "Devcontainer template commands", + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.NoArgs, + } + + templatesCmd.AddCommand(NewApplyCmd(globalFlags)) + templatesCmd.AddCommand(NewPublishCmd(globalFlags)) + templatesCmd.AddCommand(NewMetadataCmd(globalFlags)) + templatesCmd.AddCommand(NewGenerateDocsCmd(globalFlags)) + + return templatesCmd +} diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 80d440a6e..ba0a0bd9e 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -26,6 +26,7 @@ import ( _ "github.com/devsy-org/devsy/e2e/tests/readconfiguration" _ "github.com/devsy-org/devsy/e2e/tests/selfupdate" _ "github.com/devsy-org/devsy/e2e/tests/ssh" + _ "github.com/devsy-org/devsy/e2e/tests/templates" _ "github.com/devsy-org/devsy/e2e/tests/tunnel" _ "github.com/devsy-org/devsy/e2e/tests/up" _ "github.com/devsy-org/devsy/e2e/tests/up-features" diff --git a/e2e/tests/templates/templates.go b/e2e/tests/templates/templates.go new file mode 100644 index 000000000..91466a61a --- /dev/null +++ b/e2e/tests/templates/templates.go @@ -0,0 +1,130 @@ +package templates + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + cmdTemplates = "templates" + flagTemplateID = "--template-id" + flagWorkspaceFolder = "--workspace-folder" + subCmdApply = "apply" + testTemplateRef = "ghcr.io/devcontainers/templates/go:latest" +) + +var _ = ginkgo.Describe("templates command", ginkgo.Label("templates"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Describe(subCmdApply, func() { + ginkgo.It("applies a template from OCI registry", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := os.MkdirTemp("", "devsy-e2e-templates-apply-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err = f.ExecCommandCapture(ctx, []string{ + cmdTemplates, subCmdApply, + flagTemplateID, testTemplateRef, + flagWorkspaceFolder, tempDir, + }) + framework.ExpectNoError(err) + + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + _, err = os.Stat(devcontainerPath) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), + "devcontainer.json should exist after apply") + + data, err := os.ReadFile(devcontainerPath) //nolint:gosec // test path from MkdirTemp + framework.ExpectNoError(err) + + var config map[string]any + err = json.Unmarshal(data, &config) + framework.ExpectNoError(err, "devcontainer.json should be valid JSON") + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("applies template with features", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := os.MkdirTemp("", "devsy-e2e-templates-features-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err = f.ExecCommandCapture(ctx, []string{ + cmdTemplates, subCmdApply, + flagTemplateID, testTemplateRef, + flagWorkspaceFolder, tempDir, + "--features", "ghcr.io/devcontainers/features/node:1", + }) + framework.ExpectNoError(err) + + devcontainerPath := filepath.Join(tempDir, ".devcontainer", "devcontainer.json") + data, err := os.ReadFile(devcontainerPath) //nolint:gosec // test path from MkdirTemp + framework.ExpectNoError(err) + + var config map[string]any + err = json.Unmarshal(data, &config) + framework.ExpectNoError(err) + + features, ok := config["features"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "features should be present") + gomega.Expect(features).To( + gomega.HaveKey("ghcr.io/devcontainers/features/node:1"), + ) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("fails with invalid template-id", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := os.MkdirTemp("", "devsy-e2e-templates-invalid-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err = f.ExecCommandCapture(ctx, []string{ + cmdTemplates, subCmdApply, + flagTemplateID, "ghcr.io/nonexistent/template-that-does-not-exist:v999", + flagWorkspaceFolder, tempDir, + }) + framework.ExpectError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + + ginkgo.Describe("metadata", func() { + ginkgo.It("fetches template metadata from OCI registry", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdTemplates, "metadata", + flagTemplateID, testTemplateRef, + }) + framework.ExpectNoError(err) + + var metadata map[string]any + err = json.Unmarshal([]byte(stdout), &metadata) + framework.ExpectNoError(err, "metadata output should be valid JSON") + + gomega.Expect(metadata).To(gomega.HaveKey("id")) + gomega.Expect(metadata["id"]).To(gomega.Equal("go")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("fails with invalid template-id", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + _, _, err := f.ExecCommandCapture(ctx, []string{ + cmdTemplates, "metadata", + flagTemplateID, "ghcr.io/nonexistent/template-that-does-not-exist:v999", + }) + framework.ExpectError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) +})