Repository: regclient/regclient (go)

Issue:
[Feature] Support for creating new images that contain nothing
### Current Behavior

AFAICT regctl has no command that can create an empty image, eg: `FROM scratch`

### Expected Behavior

None

### Example Solution

I am proposing adding a new subcommand named `regctl image create` that will create an empty image and push it a registry/ocidir.

Example: `regctl image create <REF> --os linux --arch arm64 --variant v8`

### Version

```text
VCSTag:     v0.6.0
VCSRef:     9de7397da9f1c00dad5213519366002376b8d5ed
VCSCommit:  9de7397da9f1c00dad5213519366002376b8d5ed
VCSState:   clean
VCSDate:    2024-03-24T18:09:00Z
Platform:   darwin/arm64
GoVer:      go1.22.1
GoCompiler: gc
```

### Environment

<!--
Please include:
- How you are running the tool: downloaded binary or from within a container
- Your platform: Linux, Windows, or Mac, x86 or ARM.
- Your registry: Docker Hub, ECR, GCR, ACR, Harbor, registry:2, etc.
-->

- Running as binary or container:
- Host platform:
- Registry description:

### Anything else

<!--
Links? References? Anything that will give us more context about the issue that you are encountering!
-->


Pull request description:
feat: Add regctl image create command
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Fixes issue

Fixes #722.
<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

This adds a `regctl image create` command that outputs a scratch manifest and image config.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

```shell
regctl image create localhost:5000/library/scratch
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed

<!-- markdownlint-disable-file MD041 -->


Patch:
diff --git a/cmd/regctl/image.go b/cmd/regctl/image.go
index 4d38084..e19d7e4 100644
--- a/cmd/regctl/image.go
+++ b/cmd/regctl/image.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"archive/tar"
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -26,8 +27,11 @@ import (
 	"github.com/regclient/regclient/pkg/template"
 	"github.com/regclient/regclient/types"
 	"github.com/regclient/regclient/types/blob"
+	"github.com/regclient/regclient/types/descriptor"
+	"github.com/regclient/regclient/types/docker/schema2"
 	"github.com/regclient/regclient/types/errs"
 	"github.com/regclient/regclient/types/manifest"
+	"github.com/regclient/regclient/types/mediatype"
 	v1 "github.com/regclient/regclient/types/oci/v1"
 	"github.com/regclient/regclient/types/platform"
 	"github.com/regclient/regclient/types/ref"
@@ -35,19 +39,25 @@ import (
 
 type imageCmd struct {
 	rootOpts        *rootCmd
+	annotations     []string
+	byDigest        bool
 	checkBaseRef    string
 	checkBaseDigest string
 	checkSkipConfig bool
 	create          string
+	created         string
+	digestTags      bool
 	exportCompress  bool
 	exportRef       string
 	fastCheck       bool
 	forceRecursive  bool
 	format          string
+	formatCreate    string
 	formatFile      string
 	importName      string
 	includeExternal bool
-	digestTags      bool
+	labels          []string
+	mediaType       string
 	modOpts         []mod.Opts
 	platform        string
 	platforms       []string
@@ -55,6 +65,11 @@ type imageCmd struct {
 	replace         bool
 }
 
+var imageKnownTypes = []string{
+	mediatype.OCI1Manifest,
+	mediatype.Docker2Manifest,
+}
+
 func NewImageCmd(rootOpts *rootCmd) *cobra.Command {
 	imageOpts := imageCmd{
 		rootOpts: rootOpts,
@@ -120,6 +135,19 @@ regctl image copy --platform windows/amd64,osver=10.0.17763.4974 --include-exter
 		ValidArgsFunction: rootOpts.completeArgTag,
 		RunE:              imageOpts.runImageCopy,
 	}
+	var imageCreateCmd = &cobra.Command{
+		Use:     "create <image_ref>",
+		Aliases: []string{"init", "new"},
+		Short:   "create a new image manifest",
+		Long:    `Create a new image manifest from an initially empty (scratch) state.`,
+		Example: `
+# create a scratch image
+regctl image create ocidir://new-image:scratch
+`,
+		Args:              cobra.ExactArgs(1),
+		ValidArgsFunction: rootOpts.completeArgTag,
+		RunE:              imageOpts.runImageCreate,
+	}
 	var imageDeleteCmd = &cobra.Command{
 		Use:     "delete <image_ref>",
 		Aliases: []string{"del", "rm", "remove"},
@@ -284,6 +312,17 @@ regctl image ratelimit alpine --format '{{.Remain}}'`,
 	imageCopyCmd.Flags().BoolVarP(&imageOpts.digestTags, "digest-tags", "", false, "Include digest tags (\"sha256-<digest>.*\") when copying manifests")
 	imageCopyCmd.Flags().BoolVarP(&imageOpts.referrers, "referrers", "", false, "Include referrers")
 
+	imageCreateCmd.Flags().StringArrayVar(&imageOpts.annotations, "annotation", []string{}, "Annotation to set on manifest")
+	imageCreateCmd.Flags().BoolVar(&imageOpts.byDigest, "by-digest", false, "Push manifest by digest instead of tag")
+	imageCreateCmd.Flags().StringVar(&imageOpts.created, "created", "", "Created timestamp to set (use \"now\" or RFC3339 syntax)")
+	imageCreateCmd.Flags().StringVar(&imageOpts.formatCreate, "format", "", "Format output with go template syntax")
+	imageCreateCmd.Flags().StringArrayVar(&imageOpts.labels, "label", []string{}, "Labels to set in the image config")
+	imageCreateCmd.Flags().StringVar(&imageOpts.mediaType, "media-type", mediatype.OCI1Manifest, "Media-type for manifest")
+	imageCreateCmd.Flags().StringVar(&imageOpts.platform, "platform", "", "Platform to set on the image")
+	_ = imageCreateCmd.RegisterFlagCompletionFunc("media-type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+		return imageKnownTypes, cobra.ShellCompDirectiveNoFileComp
+	})
+
 	imageDeleteCmd.Flags().BoolVarP(&manifestOpts.forceTagDeref, "force-tag-dereference", "", false, "Dereference the a tag to a digest, this is unsafe")
 
 	imageDigestCmd.Flags().BoolVarP(&manifestOpts.list, "list", "", true, "Do not resolve platform from manifest list (enabled by default)")
@@ -824,6 +863,7 @@ regctl image ratelimit alpine --format '{{.Remain}}'`,
 
 	imageTopCmd.AddCommand(imageCheckBaseCmd)
 	imageTopCmd.AddCommand(imageCopyCmd)
+	imageTopCmd.AddCommand(imageCreateCmd)
 	imageTopCmd.AddCommand(imageDeleteCmd)
 	imageTopCmd.AddCommand(imageDigestCmd)
 	imageTopCmd.AddCommand(imageExportCmd)
@@ -1141,6 +1181,141 @@ func (ip *imageProgress) display(final bool) {
 	}
 }
 
+func (imageOpts *imageCmd) runImageCreate(cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+
+	// validate media type
+	if imageOpts.mediaType != mediatype.OCI1Manifest && imageOpts.mediaType != mediatype.Docker2Manifest {
+		return fmt.Errorf("unsupported manifest media type: %s%.0w", imageOpts.mediaType, errs.ErrUnsupportedMediaType)
+	}
+
+	// parse ref
+	r, err := ref.New(args[0])
+	if err != nil {
+		return err
+	}
+
+	// setup regclient
+	rc := imageOpts.rootOpts.newRegClient()
+	defer rc.Close(ctx, r)
+
+	// define the image config
+	conf := v1.Image{
+		Config: v1.ImageConfig{},
+		RootFS: v1.RootFS{
+			Type:    "layers",
+			DiffIDs: []digest.Digest{},
+		},
+		History: []v1.History{},
+	}
+
+	if imageOpts.created == "now" {
+		now := time.Now().UTC()
+		conf.Created = &now
+	} else if imageOpts.created != "" {
+		t, err := time.Parse(time.RFC3339, imageOpts.created)
+		if err != nil {
+			return fmt.Errorf("failed to parse created time %s: %w", imageOpts.created, err)
+		}
+		conf.Created = &t
+	}
+
+	labels := map[string]string{}
+	for _, l := range imageOpts.labels {
+		lSplit := strings.SplitN(l, "=", 2)
+		if len(lSplit) == 1 {
+			labels[lSplit[0]] = ""
+		} else {
+			labels[lSplit[0]] = lSplit[1]
+		}
+	}
+	if len(labels) > 0 {
+		conf.Config.Labels = labels
+	}
+
+	if imageOpts.platform != "" {
+		p, err := platform.Parse(imageOpts.platform)
+		if err != nil {
+			return fmt.Errorf("failed to parse platform: %w", err)
+		}
+		conf.Platform = p
+	}
+
+	// TODO: add layers
+
+	// push the config
+	cJSON, err := json.Marshal(conf)
+	if err != nil {
+		return fmt.Errorf("failed to marshal config: %w", err)
+	}
+	cd, err := rc.BlobPut(ctx, r, descriptor.Descriptor{}, bytes.NewReader(cJSON))
+	if err != nil {
+		return fmt.Errorf("failed to push config: %w", err)
+	}
+
+	// parse annotations
+	annotations := map[string]string{}
+	for _, a := range imageOpts.annotations {
+		aSplit := strings.SplitN(a, "=", 2)
+		if len(aSplit) == 1 {
+			annotations[aSplit[0]] = ""
+		} else {
+			annotations[aSplit[0]] = aSplit[1]
+		}
+	}
+
+	// build the manifest
+	mOpts := []manifest.Opts{}
+	switch imageOpts.mediaType {
+	case mediatype.OCI1Manifest:
+		cd.MediaType = mediatype.OCI1ImageConfig
+		m := v1.Manifest{
+			Versioned: v1.ManifestSchemaVersion,
+			MediaType: mediatype.OCI1Manifest,
+			Config:    cd,
+		}
+		if len(annotations) > 0 {
+			m.Annotations = annotations
+		}
+		mOpts = append(mOpts, manifest.WithOrig(m))
+	case mediatype.Docker2Manifest:
+		cd.MediaType = mediatype.Docker2ImageConfig
+		m := schema2.Manifest{
+			Versioned: schema2.ManifestSchemaVersion,
+			Config:    cd,
+		}
+		if len(annotations) > 0 {
+			m.Annotations = annotations
+		}
+		mOpts = append(mOpts, manifest.WithOrig(m))
+	}
+	mm, err := manifest.New(mOpts...)
+	if err != nil {
+		return err
+	}
+
+	// push the image
+	if imageOpts.byDigest {
+		r.Tag = ""
+		r.Digest = mm.GetDescriptor().Digest.String()
+	}
+	err = rc.ManifestPut(ctx, r, mm)
+	if err != nil {
+		return err
+	}
+
+	// format output
+	result := struct {
+		Manifest manifest.Manifest
+	}{
+		Manifest: mm,
+	}
+	if imageOpts.byDigest && imageOpts.formatCreate == "" {
+		imageOpts.formatCreate = "{{ printf \"%s\\n\" .Manifest.GetDescriptor.Digest }}"
+	}
+	return template.Writer(cmd.OutOrStdout(), imageOpts.formatCreate, result)
+}
+
 func (imageOpts *imageCmd) runImageExport(cmd *cobra.Command, args []string) error {
 	ctx := cmd.Context()
 	r, err := ref.New(args[0])
diff --git a/docs/regctl.md b/docs/regctl.md
index 50edf13..8a0cc92 100644
--- a/docs/regctl.md
+++ b/docs/regctl.md
@@ -138,6 +138,7 @@ Usage:
 Available Commands:
   check-base  check if the base image has changed
   copy        copy or retag image
+  create      create a new image manifest
   delete      delete image
   digest      show digest for pinning
   export      export image
@@ -156,6 +157,8 @@ The OCI annotations used to automatically detect the base image are `org.opencon
 
 The `copy` command allows images to be copied between registries, between repositories on the same registry, or retag an image within the same repository, and only pulls the layers when needed (typically not needed with the same registry server).
 
+The `create` command creates a new image manifest and config, starting from scratch.
+
 The `delete` command removes the image manifest from the server.
 This will impact all tags pointing to the same manifest and requires a digest to be included in the image reference to be deleted (e.g. `myimage@sha256:abcd...`).
 Using `--force-tag-dereference` will automatically lookup the digest for a specific tag, and will delete the underlying image which will delete any other tags pointing to the same image.


Test patch:
diff --git a/cmd/regctl/image_test.go b/cmd/regctl/image_test.go
index 5bd9039..3f93759 100644
--- a/cmd/regctl/image_test.go
+++ b/cmd/regctl/image_test.go
@@ -6,6 +6,19 @@ import (
 	"testing"
 )
 
+func TestImageCreate(t *testing.T) {
+	tmpDir := t.TempDir()
+	imageRef := fmt.Sprintf("ocidir://%s/repo:scratch", tmpDir)
+
+	out, err := cobraTest(t, nil, "image", "create", imageRef)
+	if err != nil {
+		t.Fatalf("failed to run image create: %v", err)
+	}
+	if out != "" {
+		t.Errorf("unexpected output: %v", out)
+	}
+}
+
 func TestImageExportImport(t *testing.T) {
 	tmpDir := t.TempDir()
 	srcRef := "ocidir://../../testdata/testrepo:v2"