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
185 changes: 185 additions & 0 deletions cmd/features/publish.go
Original file line number Diff line number Diff line change
@@ -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
}
184 changes: 184 additions & 0 deletions cmd/features/publish_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions cmd/features/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading