diff --git a/cmd/features/publish.go b/cmd/features/publish.go new file mode 100644 index 000000000..be8d32dc7 --- /dev/null +++ b/cmd/features/publish.go @@ -0,0 +1,185 @@ +package features + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "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 features to OCI registry", + Long: `Publish packaged dev container features to an OCI registry. + +Takes the path to a packaged feature directory (output of 'features package' +or a directory containing devcontainer-feature.json) and pushes it as an +OCI artifact.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runPublish(publishFlags) + }, + } + + publishCmd.Flags().StringVar( + &publishFlags.Target, "target", "", + "Path to packaged feature directory", + ) + publishCmd.Flags().StringVar( + &publishFlags.Registry, "registry", "ghcr.io", + "Target OCI registry", + ) + publishCmd.Flags().StringVar( + &publishFlags.Namespace, "namespace", "", + "Registry namespace (e.g., devcontainers/features)", + ) + _ = 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) + } + + featureCfg, err := validatePublishTarget(target) + if err != nil { + return err + } + + version := featureCfg.Version + if version == "" { + version = "latest" + } + + ref, err := parsePublishRef(f.Registry, f.Namespace, featureCfg.ID, version) + if err != nil { + return err + } + + log.Infof("Publishing feature %q to %s", featureCfg.ID, ref.String()) + + img, err := buildFeatureImage(target) + if err != nil { + return err + } + + if err := remote.Write( + ref, img, remote.WithAuthFromKeychain(authn.DefaultKeychain), + ); err != nil { + return fmt.Errorf("push feature to registry: %w", err) + } + + log.Infof("Feature published successfully: %s", ref.String()) + + metadata := publishedFeatureMetadata{ + ID: featureCfg.ID, + Version: version, + Ref: 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 +} + +type publishedFeatureMetadata struct { + ID string `json:"id"` + Version string `json:"version"` + Ref string `json:"ref"` +} + +func validatePublishTarget(target string) (*config.FeatureConfig, 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) + } + + featureCfg, err := config.ParseDevContainerFeature(target) + if err != nil { + return nil, fmt.Errorf("parse feature metadata: %w", err) + } + + if featureCfg.ID == "" { + return nil, fmt.Errorf("feature metadata missing required 'id' field") + } + + return featureCfg, 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 buildFeatureImage(sourceDir string) (v1.Image, error) { + var buf bytes.Buffer + if err := extract.WriteTar(&buf, sourceDir, true); err != nil { + return nil, fmt.Errorf("create feature 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 feature image: %w", err) + } + + return img, nil +} diff --git a/cmd/features/publish_test.go b/cmd/features/publish_test.go new file mode 100644 index 000000000..2a72df7e4 --- /dev/null +++ b/cmd/features/publish_test.go @@ -0,0 +1,184 @@ +package features + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testRegistry = "ghcr.io" + testVersion = "1.0.0" + testFeatureNode = "node" +) + +func TestPublishCmd_FlagDefaults(t *testing.T) { + cmd := NewPublishCmd(nil) + + targetFlag := cmd.Flags().Lookup("target") + require.NotNil(t, targetFlag) + assert.Equal(t, "", targetFlag.DefValue) + + registryFlag := cmd.Flags().Lookup("registry") + require.NotNil(t, registryFlag) + assert.Equal(t, testRegistry, registryFlag.DefValue) + + namespaceFlag := cmd.Flags().Lookup("namespace") + require.NotNil(t, namespaceFlag) + assert.Equal(t, "", namespaceFlag.DefValue) +} + +func TestPublishCmd_AllFlagsRegistered(t *testing.T) { + cmd := NewPublishCmd(nil) + expected := []string{"target", "registry", "namespace"} + for _, name := range expected { + assert.NotNil(t, cmd.Flags().Lookup(name), "flag %q should be registered", name) + } +} + +func TestPublishCmd_TargetRequired(t *testing.T) { + cmd := NewPublishCmd(nil) + flag := cmd.Flags().Lookup("target") + require.NotNil(t, flag) + + annotations := flag.Annotations + require.Contains(t, annotations, "cobra_annotation_bash_completion_one_required_flag") +} + +func TestBuildPublishReference(t *testing.T) { + tests := []struct { + name string + registry string + namespace string + id string + version string + want string + }{ + { + name: "with namespace", + registry: testRegistry, + namespace: "devcontainers/features", + id: "go", + version: testVersion, + want: testRegistry + "/devcontainers/features/go:" + testVersion, + }, + { + name: "without namespace", + registry: testRegistry, + id: "go", + version: testVersion, + want: testRegistry + "/go:" + testVersion, + }, + { + name: "custom registry", + registry: "registry.example.com", + namespace: "my-org/features", + id: testFeatureNode, + version: "2.0.0", + want: "registry.example.com/my-org/features/" + testFeatureNode + ":2.0.0", + }, + { + name: "latest version", + registry: testRegistry, + namespace: "test", + id: "python", + version: "latest", + want: testRegistry + "/test/python:latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildPublishReference(tt.registry, tt.namespace, tt.id, tt.version) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestValidatePublishTarget_NotADirectory(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "not-a-dir.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("hello"), 0o600)) + + _, err := validatePublishTarget(tmpFile) + require.Error(t, err) + assert.Contains(t, err.Error(), "target must be a directory") +} + +func TestValidatePublishTarget_MissingMetadata(t *testing.T) { + tmpDir := t.TempDir() + + _, err := validatePublishTarget(tmpDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse feature metadata") +} + +func TestValidatePublishTarget_MissingID(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, "devcontainer-feature.json"), + []byte(`{"name": "My Feature", "version": "1.0.0"}`), + 0o600, + )) + + _, err := validatePublishTarget(tmpDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required 'id' field") +} + +func TestValidatePublishTarget_Valid(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, "devcontainer-feature.json"), + []byte(`{"id": "go", "version": "1.0.0", "name": "Go"}`), + 0o600, + )) + + cfg, err := validatePublishTarget(tmpDir) + require.NoError(t, err) + assert.Equal(t, "go", cfg.ID) + assert.Equal(t, testVersion, cfg.Version) +} + +func TestValidatePublishTarget_NonexistentPath(t *testing.T) { + _, err := validatePublishTarget("/nonexistent/path/that/does/not/exist") + require.Error(t, err) + assert.Contains(t, err.Error(), "stat target") +} + +func TestBuildFeatureImage(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, "devcontainer-feature.json"), + []byte(`{"id": "go", "version": "1.0.0"}`), + 0o600, + )) + // #nosec G306 -- test install script must be executable + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, "install.sh"), + []byte("#!/bin/bash\necho hello\n"), + 0o750, + )) + + img, err := buildFeatureImage(tmpDir) + require.NoError(t, err) + require.NotNil(t, img) + + layers, err := img.Layers() + require.NoError(t, err) + assert.Len(t, layers, 1) +} + +func TestParsePublishRef_Valid(t *testing.T) { + ref, err := parsePublishRef(testRegistry, "devcontainers/features", "go", testVersion) + require.NoError(t, err) + assert.Contains(t, ref.String(), testRegistry+"/devcontainers/features/go:"+testVersion) +} + +func TestParsePublishRef_Invalid(t *testing.T) { + _, err := parsePublishRef("", "", "INVALID REF!!!", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "parse publish reference") +} diff --git a/cmd/features/root.go b/cmd/features/root.go index 932fa51de..9dd0bf94f 100644 --- a/cmd/features/root.go +++ b/cmd/features/root.go @@ -19,6 +19,7 @@ func NewFeaturesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { featuresCmd.AddCommand(NewGenerateDocsCmd(globalFlags)) featuresCmd.AddCommand(NewTestCmd(globalFlags)) featuresCmd.AddCommand(NewPackageCmd(globalFlags)) + featuresCmd.AddCommand(NewPublishCmd(globalFlags)) return featuresCmd } diff --git a/e2e/tests/features/features_publish.go b/e2e/tests/features/features_publish.go new file mode 100644 index 000000000..56f18e753 --- /dev/null +++ b/e2e/tests/features/features_publish.go @@ -0,0 +1,158 @@ +package features + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + cmdPublish = "publish" + flagRegistry = "--registry" + flagNamespace = "--namespace" + fileFeatureJSON = "devcontainer-feature.json" + fileInstallShell = "install.sh" +) + +var _ = ginkgo.Describe("features publish", ginkgo.Label("features"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("publishes a feature to an OCI registry", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + + featureDir := createFeatureDir("go", "1.0.0", "Go") + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPublish, + flagTarget, featureDir, + flagRegistry, regHost, + flagNamespace, "test/features", + }) + framework.ExpectNoError(err) + + var result map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &result)).To(gomega.Succeed()) + gomega.Expect(result["id"]).To(gomega.Equal("go")) + gomega.Expect(result["version"]).To(gomega.Equal("1.0.0")) + gomega.Expect(result["ref"]). + To(gomega.ContainSubstring(regHost + "/test/features/go:1.0.0")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("publishes without namespace", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + + featureDir := createFeatureDir("node", "2.0.0", "Node.js") + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPublish, + flagTarget, featureDir, + flagRegistry, regHost, + }) + framework.ExpectNoError(err) + + var result map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &result)).To(gomega.Succeed()) + gomega.Expect(result["id"]).To(gomega.Equal("node")) + gomega.Expect(result["version"]).To(gomega.Equal("2.0.0")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("uses latest when version is empty", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + + featureDir, err := os.MkdirTemp("", "e2e-publish-noversion-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(featureDir) }) + + framework.ExpectNoError(os.WriteFile( + filepath.Join(featureDir, fileFeatureJSON), + []byte(`{"id": "python", "name": "Python"}`), + 0o600, + )) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPublish, + flagTarget, featureDir, + flagRegistry, regHost, + flagNamespace, "test/features", + }) + framework.ExpectNoError(err) + + var result map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &result)).To(gomega.Succeed()) + gomega.Expect(result["version"]).To(gomega.Equal("latest")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("fails when target directory does not exist", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + _, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPublish, + flagTarget, "/nonexistent/path", + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("fails when target has no feature metadata", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + emptyDir, err := os.MkdirTemp("", "e2e-publish-empty-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(emptyDir) }) + + _, _, err = f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPublish, + flagTarget, emptyDir, + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) +}) + +func createFeatureDir(id, version, displayName string) string { + dir, err := os.MkdirTemp("", "e2e-publish-feature-*") + framework.ExpectNoError(err) + + featureJSON := `{"id": "` + id + `", "version": "` + version + `", "name": "` + displayName + `"}` + framework.ExpectNoError(os.WriteFile( + filepath.Join(dir, fileFeatureJSON), + []byte(featureJSON), + 0o600, + )) + + // #nosec G306 -- test install script must be executable + framework.ExpectNoError(os.WriteFile( + filepath.Join(dir, fileInstallShell), + []byte("#!/bin/bash\necho installed\n"), + 0o750, + )) + + return dir +}