diff --git a/cmd/tag.go b/cmd/tag.go index e278e93..0cbe679 100644 --- a/cmd/tag.go +++ b/cmd/tag.go @@ -1,7 +1,16 @@ package cmd import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/meigma/blob" "github.com/spf13/cobra" + "github.com/spf13/viper" + + internalcfg "github.com/meigma/blob-cli/internal/config" ) var tagCmd = &cobra.Command{ @@ -15,7 +24,92 @@ new reference to the existing content.`, Example: ` blob tag ghcr.io/acme/configs:v1.0.0 ghcr.io/acme/configs:latest blob tag ghcr.io/acme/configs@sha256:abc... ghcr.io/acme/configs:stable`, Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: runTag, +} + +// tagResult contains the result of a tag operation. +type tagResult struct { + SrcRef string `json:"src_ref"` + ResolvedSrcRef string `json:"resolved_src_ref,omitempty"` + DstRef string `json:"dst_ref"` + ResolvedDstRef string `json:"resolved_dst_ref,omitempty"` + Digest string `json:"digest"` + Status string `json:"status"` +} + +func runTag(cmd *cobra.Command, args []string) error { + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + srcRef := args[0] + dstRef := args[1] + + resolvedSrcRef := cfg.ResolveAlias(srcRef) + resolvedDstRef := cfg.ResolveAlias(dstRef) + + client, err := blob.NewClient(blob.WithDockerConfig()) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + ctx := cmd.Context() + + manifest, err := client.Fetch(ctx, resolvedSrcRef) + if err != nil { + return fmt.Errorf("fetching source manifest: %w", err) + } + + digest := manifest.Digest() + + if err := client.Tag(ctx, resolvedDstRef, digest); err != nil { + return fmt.Errorf("tagging manifest: %w", err) + } + + result := tagResult{ + SrcRef: srcRef, + DstRef: dstRef, + Digest: digest, + Status: "success", + } + + if srcRef != resolvedSrcRef { + result.ResolvedSrcRef = resolvedSrcRef + } + if dstRef != resolvedDstRef { + result.ResolvedDstRef = resolvedDstRef + } + + return outputTagResult(cfg, &result) +} + +// outputTagResult formats and outputs the tag result. +func outputTagResult(cfg *internalcfg.Config, result *tagResult) error { + if cfg.Quiet { return nil - }, + } + if viper.GetString("output") == internalcfg.OutputJSON { + return tagJSON(result) + } + return tagText(result) +} + +func tagJSON(result *tagResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func tagText(result *tagResult) error { + fmt.Printf("Tagged %s\n", result.DstRef) + if result.ResolvedDstRef != "" { + fmt.Printf(" Resolved: %s\n", result.ResolvedDstRef) + } + fmt.Printf("Source: %s\n", result.SrcRef) + if result.ResolvedSrcRef != "" { + fmt.Printf(" Resolved: %s\n", result.ResolvedSrcRef) + } + fmt.Printf("Digest: %s\n", result.Digest) + return nil } diff --git a/cmd/tag_test.go b/cmd/tag_test.go new file mode 100644 index 0000000..318f3ec --- /dev/null +++ b/cmd/tag_test.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTagCmd_NilConfig(t *testing.T) { + // Reset viper and restore after test to avoid affecting other tests. + viper.Reset() + t.Cleanup(viper.Reset) + + ctx := context.Background() + + tagCmd.SetContext(ctx) + err := tagCmd.RunE(tagCmd, []string{"ghcr.io/test:v1", "ghcr.io/test:latest"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "configuration not loaded") +} + +func TestTagText_Basic(t *testing.T) { + result := &tagResult{ + SrcRef: "ghcr.io/acme/configs:v1.0.0", + DstRef: "ghcr.io/acme/configs:latest", + Digest: "sha256:abc123def456", + Status: "success", + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := tagText(result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + got := buf.String() + assert.Contains(t, got, "Tagged ghcr.io/acme/configs:latest") + assert.Contains(t, got, "Source: ghcr.io/acme/configs:v1.0.0") + assert.Contains(t, got, "Digest: sha256:abc123def456") + assert.NotContains(t, got, "Resolved:") +} + +func TestTagText_WithResolvedRefs(t *testing.T) { + result := &tagResult{ + SrcRef: "src-alias", + ResolvedSrcRef: "ghcr.io/acme/configs:v1.0.0", + DstRef: "dst-alias", + ResolvedDstRef: "ghcr.io/acme/configs:latest", + Digest: "sha256:abc123", + Status: "success", + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := tagText(result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + got := buf.String() + assert.Contains(t, got, "Tagged dst-alias") + assert.Contains(t, got, "Resolved: ghcr.io/acme/configs:latest") + assert.Contains(t, got, "Source: src-alias") + assert.Contains(t, got, "Resolved: ghcr.io/acme/configs:v1.0.0") +} + +func TestTagJSON(t *testing.T) { + result := &tagResult{ + SrcRef: "src-alias", + ResolvedSrcRef: "ghcr.io/acme/configs:v1.0.0", + DstRef: "dst-alias", + ResolvedDstRef: "ghcr.io/acme/configs:latest", + Digest: "sha256:abc123def456", + Status: "success", + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := tagJSON(result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + var got tagResult + err = json.Unmarshal(buf.Bytes(), &got) + require.NoError(t, err) + + assert.Equal(t, "src-alias", got.SrcRef) + assert.Equal(t, "ghcr.io/acme/configs:v1.0.0", got.ResolvedSrcRef) + assert.Equal(t, "dst-alias", got.DstRef) + assert.Equal(t, "ghcr.io/acme/configs:latest", got.ResolvedDstRef) + assert.Equal(t, "sha256:abc123def456", got.Digest) + assert.Equal(t, "success", got.Status) +} + +func TestTagJSON_OmitsEmpty(t *testing.T) { + result := &tagResult{ + SrcRef: "ghcr.io/acme/configs:v1.0.0", + DstRef: "ghcr.io/acme/configs:latest", + Digest: "sha256:abc123", + Status: "success", + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := tagJSON(result) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + require.NoError(t, err) + + jsonStr := buf.String() + assert.NotContains(t, jsonStr, "resolved_src_ref") + assert.NotContains(t, jsonStr, "resolved_dst_ref") +}