From d43b7f16119378a335edf7e1c3029f8e666f526e Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Mon, 24 Feb 2025 17:35:48 +0800
Subject: [PATCH] feat(builds): add builds:fetch cmd

---
 cmd/builds.go         | 106 ++++++++++++++++++++++++++++++++
 cmd/builds_test.go    | 140 ++++++++++++++++++++++++++++++++++++++++++
 cmd/cmd.go            |   1 +
 parser/builds.go      |  55 +++++++++++++++--
 parser/builds_test.go |   8 +++
 parser/config.go      |   6 +-
 parser/pts.go         |   2 +-
 parser/releases.go    |   2 +-
 8 files changed, 309 insertions(+), 11 deletions(-)

diff --git a/cmd/builds.go b/cmd/builds.go
index 86b1d383..defa0858 100644
--- a/cmd/builds.go
+++ b/cmd/builds.go
@@ -1,8 +1,10 @@
 package cmd
 
 import (
+	"bytes"
 	"fmt"
 	"os"
+	"path/filepath"
 
 	yaml "gopkg.in/yaml.v3"
 
@@ -100,6 +102,93 @@ func (d *DryccCmd) BuildsCreate(appID, image, stack, procfile, dryccpath, confir
 	return nil
 }
 
+func (d *DryccCmd) BuildsFetch(appID string, version int, procfile, dryccpath, confirm string) error {
+	s, appID, err := load(d.ConfigFile, appID)
+	if err != nil {
+		return err
+	}
+
+	build, err := builds.Get(s.Client, appID, version)
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+	// Confirm again
+	err = buildFetchConfirmAction(confirm, procfile, dryccpath)
+	if err != nil {
+		return err
+	}
+	os.Remove(procfile)
+	os.ReadDir(dryccpath)
+	if len(build.Procfile) != 0 {
+		err := os.WriteFile(procfile, []byte(d.toYamlString(build.Procfile, 2)), 0755)
+		if err != nil {
+			return fmt.Errorf("failed to write Procfile: %w", err)
+		}
+	}
+
+	if len(build.Dryccfile) != 0 {
+		err := writeDryccfileToPath(dryccpath, build.Dryccfile)
+		if err != nil {
+			return fmt.Errorf("failed to write Dryccfile: %w", err)
+		}
+	}
+	d.Println("done")
+
+	return nil
+
+}
+
+func writeDryccfileToPath(dryccpath string, dryccfile map[string]interface{}) error {
+	// Create the directory if it doesn't exist
+	err := os.MkdirAll(dryccpath, os.ModePerm)
+	if err != nil {
+		return fmt.Errorf("failed to create directory: %w", err)
+	}
+
+	// Write config section
+	if config, ok := dryccfile["config"].(map[string]interface{}); ok {
+		configDir := filepath.Join(dryccpath, "config")
+		err := os.MkdirAll(configDir, os.ModePerm)
+		if err != nil {
+			return fmt.Errorf("failed to create config directory: %w", err)
+		}
+
+		for key, values := range config {
+			envFilePath := filepath.Join(configDir, key)
+			var content string
+			for k, v := range values.(map[string]interface{}) {
+				// Append each key-value pair with newline
+				content += fmt.Sprintf("%s=%v\n", k, v)
+			}
+			// Write accumulated content once
+			err := os.WriteFile(envFilePath, []byte(content), 0755)
+			if err != nil {
+				return fmt.Errorf("failed to write env file: %w", err)
+			}
+		}
+	}
+
+	// Write pipeline section
+	if pipeline, ok := dryccfile["pipeline"].(map[string]interface{}); ok {
+		for fileName, pipelineConfig := range pipeline {
+			filePath := filepath.Join(dryccpath, fileName)
+			var buf bytes.Buffer
+			encoder := yaml.NewEncoder(&buf)
+			encoder.SetIndent(2)
+			if err := encoder.Encode(pipelineConfig); err != nil {
+				return fmt.Errorf("failed to marshal pipeline config: %w", err)
+			}
+			yamlContent := buf.Bytes()
+			err = os.WriteFile(filePath, yamlContent, 0755)
+			if err != nil {
+				return fmt.Errorf("failed to write pipeline file: %w", err)
+			}
+		}
+	}
+
+	return nil
+}
+
 func parseProcfile(procfile []byte) (map[string]string, error) {
 	procfileMap := make(map[string]string)
 	return procfileMap, yaml.Unmarshal(procfile, &procfileMap)
@@ -125,3 +214,20 @@ func buildConfirmAction(c *drycc.Client, appID string, procfileMap map[string]st
 	}
 	return nil
 }
+
+func buildFetchConfirmAction(confirm, procfile, dryccpath string) error {
+
+	if confirm == "" || confirm != "yes" {
+		// hint
+		msg := fmt.Sprintf(" !    WARNING: Potentially Build Fetch Action\n"+
+			" !    This operation will first rm the current \x1b[1m%s\x1b[0m and \x1b[1m%s\x1b[0m locally\n"+
+			" !    To proceed, type \"yes\" !\n\n> ", procfile, dryccpath)
+
+		fmt.Print(msg)
+		fmt.Scanln(&confirm)
+		if confirm != "yes" {
+			return fmt.Errorf("cancel the build create fetch")
+		}
+	}
+	return nil
+}
diff --git a/cmd/builds_test.go b/cmd/builds_test.go
index b8920df1..7336dbec 100644
--- a/cmd/builds_test.go
+++ b/cmd/builds_test.go
@@ -8,9 +8,12 @@ import (
 	"path/filepath"
 	"testing"
 
+	"reflect"
+
 	"github.com/drycc/controller-sdk-go/api"
 	"github.com/drycc/workflow-cli/pkg/testutil"
 	"github.com/stretchr/testify/assert"
+	"gopkg.in/yaml.v3"
 )
 
 func TestParseProcfile(t *testing.T) {
@@ -196,3 +199,140 @@ deploy:
 	assert.Equal(t, testutil.StripProgress(b.String()), "Creating build... done\n", "output")
 
 }
+
+func TestBuildsFetch(t *testing.T) {
+	t.Parallel()
+
+	// Mock server setup
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+
+	cmdr := DryccCmd{WOut: new(bytes.Buffer), ConfigFile: cf}
+
+	// Mock build data
+	server.Mux.HandleFunc("/v2/apps/testapp/build/", func(w http.ResponseWriter, _ *http.Request) {
+		testutil.SetHeaders(w)
+		fmt.Fprintf(w, `{
+			"app": "testapp",
+			"procfile": {"web": "node server.js"},
+			"dryccfile": {
+				"pipeline": {
+					"web.yaml": {
+						"kind": "pipeline",
+						"ptype": "web",
+						"deploy": {
+							"command": ["bash", "-c"],
+							"args": ["echo hello"]
+						}
+					}
+				},
+				"config": {
+					"env1.env": {"KEY1": "VALUE1"},
+					"env2.env": {"KEY2": "VALUE2"}
+				}
+			}
+		}`)
+	})
+
+	// Helper function to compare YAML content ignoring field order
+	isEqualYAML := func(actual, expected string) bool {
+		var actualMap, expectedMap map[string]interface{}
+		err := yaml.Unmarshal([]byte(actual), &actualMap)
+		if err != nil {
+			return false
+		}
+		err = yaml.Unmarshal([]byte(expected), &expectedMap)
+		if err != nil {
+			return false
+		}
+		return reflect.DeepEqual(actualMap, expectedMap)
+	}
+
+	// Test case 1: Successful fetch with valid confirm
+	t.Run("Successful Fetch", func(t *testing.T) {
+		tmpDir := t.TempDir() // Create a unique temporary directory for this test
+		procfilePath := filepath.Join(tmpDir, "Procfile")
+		dryccpath := filepath.Join(tmpDir, ".drycc")
+
+		// Mock user input for confirmation
+		restoreStdin := os.Stdin
+		r, w, _ := os.Pipe()
+		os.Stdin = r
+		go func() {
+			defer w.Close() // Ensure the pipe is closed after writing
+			w.Write([]byte("yes\n"))
+		}()
+
+		err := cmdr.BuildsFetch("testapp", 0, procfilePath, dryccpath, "")
+		if err != nil {
+			t.Fatalf("expected no error, got %v", err)
+		}
+
+		// Verify Procfile content
+		content, err := os.ReadFile(procfilePath)
+		if err != nil {
+			t.Fatalf("failed to read Procfile: %v", err)
+		}
+		expectedProcfile := "web: node server.js\n"
+		if string(content) != expectedProcfile {
+			t.Errorf("expected Procfile content %q, got %q", expectedProcfile, string(content))
+		}
+
+		// Verify .drycc/config/env1.env content
+		env1Content, err := os.ReadFile(filepath.Join(dryccpath, "config", "env1.env"))
+		if err != nil {
+			t.Fatalf("failed to read env1.env: %v", err)
+		}
+		expectedEnv1 := "KEY1=VALUE1\n"
+		if string(env1Content) != expectedEnv1 {
+			t.Errorf("expected env1.env content %q, got %q", expectedEnv1, string(env1Content))
+		}
+
+		// Verify .drycc/web.yaml content
+		webYamlContent, err := os.ReadFile(filepath.Join(dryccpath, "web.yaml"))
+		if err != nil {
+			t.Fatalf("failed to read web.yaml: %v", err)
+		}
+		expectedWebYaml := "kind: pipeline\nptype: web\ndeploy:\n  command:\n  - bash\n  - -c\n  args:\n  - echo hello\n"
+		if !isEqualYAML(string(webYamlContent), expectedWebYaml) {
+			t.Errorf("expected web.yaml content %q, got %q", expectedWebYaml, string(webYamlContent))
+		}
+
+		os.Stdin = restoreStdin
+	})
+
+	// Test case 2: User cancels the operation
+	t.Run("User Cancels Operation", func(t *testing.T) {
+		tmpDir := t.TempDir() // Create a unique temporary directory for this test
+		procfilePath := filepath.Join(tmpDir, "Procfile")
+		dryccpath := filepath.Join(tmpDir, ".drycc")
+
+		// Mock user input for cancellation
+		restoreStdin := os.Stdin
+		r, w, _ := os.Pipe()
+		os.Stdin = r
+		go func() {
+			defer w.Close() // Ensure the pipe is closed after writing
+			w.Write([]byte("no\n"))
+		}()
+
+		err := cmdr.BuildsFetch("testapp", 0, procfilePath, dryccpath, "")
+		if err == nil || err.Error() != "cancel the build create fetch" {
+			t.Fatalf("expected cancellation error, got %v", err)
+		}
+
+		// Verify files were not created
+		if _, err := os.Stat(procfilePath); !os.IsNotExist(err) {
+			t.Errorf("expected Procfile to not exist, but it does")
+		}
+
+		if _, err := os.Stat(dryccpath); !os.IsNotExist(err) {
+			t.Errorf("expected .drycc directory to not exist, but it does")
+		}
+
+		os.Stdin = restoreStdin
+	})
+}
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 322ba6f4..689421f6 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -36,6 +36,7 @@ type Commander interface {
 	TokensRemove(string, string) error
 	BuildsInfo(string, int) error
 	BuildsCreate(string, string, string, string, string, string) error
+	BuildsFetch(string, int, string, string, string) error
 	CertsList(string, int) error
 	CertAdd(string, string, string, string) error
 	CertRemove(string, string) error
diff --git a/parser/builds.go b/parser/builds.go
index c94102c4..fe3d9d3c 100644
--- a/parser/builds.go
+++ b/parser/builds.go
@@ -10,8 +10,9 @@ func Builds(argv []string, cmdr cmd.Commander) error {
 	usage := `
 Valid commands for builds:
 
-builds:info        Print information about a specific build
+builds:info        print information about a specific build
 builds:create      imports an image and deploys as a new release
+builds:fetch       fetch the Procfile and dryccfile to the local
 
 Use 'drycc help [command]' to learn more.
 `
@@ -21,6 +22,8 @@ Use 'drycc help [command]' to learn more.
 		return buildsInfo(argv, cmdr)
 	case "builds:create":
 		return buildsCreate(argv, cmdr)
+	case "builds:fetch":
+		return buildsFetch(argv, cmdr)
 	default:
 		if printHelp(argv, usage) {
 			return nil
@@ -81,15 +84,15 @@ Arguments:
 
 Options:
   -a --app=<app>
-    The uniquely identifiable name for the application.
+    the uniquely identifiable name for the application.
   -s --stack=<stack>
-    The stack name for the application, defaults to container.
+    the stack name for the application, defaults to container.
   -p --procfile=<procfile>
-    A YAML file used to supply a Procfile to the application.
+    a YAML file used to supply a Procfile to the application.
   -d --dryccpath=<dryccpath>
-    Drycc config path to the application, default is '.drycc'.
+    drycc config path to the application, default is '.drycc'.
   --confirm=yes
-    To proceed, type "yes".
+    to proceed, type "yes".
 `
 
 	args, err := docopt.ParseArgs(usage, argv, "")
@@ -108,3 +111,43 @@ Options:
 
 	return cmdr.BuildsCreate(app, image, stack, procfile, dryccpath, confirm)
 }
+
+func buildsFetch(argv []string, cmdr cmd.Commander) error {
+	usage := `
+Print information about a specific build.
+
+Usage: drycc builds:fetch [options]
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+  -v --version=<version>
+    the version for which the build info needs to be fetched.
+  -p --procfile=<procfile>
+    a YAML file used to supply a Procfile to the application.
+  -d --dryccpath=<dryccpath>
+    drycc config path to the application, default is '.drycc'.
+  --confirm=yes
+    to proceed, type "yes".
+`
+
+	args, err := docopt.ParseArgs(usage, argv, "")
+
+	if err != nil {
+		return err
+	}
+
+	app := safeGetString(args, "--app")
+	var version int
+	if safeGetString(args, "--version") != "" {
+		if version, err = versionFromString(safeGetString(args, "--version")); err != nil {
+			return err
+		}
+	}
+
+	procfile := safeGetValue(args, "--procfile", "Procfile")
+	dryccpath := safeGetValue(args, "--dryccpath", ".drycc")
+	confirm := safeGetString(args, "--confirm")
+
+	return cmdr.BuildsFetch(app, version, procfile, dryccpath, confirm)
+}
diff --git a/parser/builds_test.go b/parser/builds_test.go
index c3ee1571..b28cf268 100644
--- a/parser/builds_test.go
+++ b/parser/builds_test.go
@@ -17,6 +17,10 @@ func (d FakeDryccCmd) BuildsCreate(string, string, string, string, string, strin
 	return errors.New("builds:create")
 }
 
+func (d FakeDryccCmd) BuildsFetch(string, int, string, string, string) error {
+	return errors.New("builds:fetch")
+}
+
 func TestBuilds(t *testing.T) {
 	t.Parallel()
 
@@ -42,6 +46,10 @@ func TestBuilds(t *testing.T) {
 			args:     []string{"builds:create", "drycc/example-go:latest"},
 			expected: "",
 		},
+		{
+			args:     []string{"builds:fetch"},
+			expected: "",
+		},
 		{
 			args:     []string{"builds"},
 			expected: "builds:info",
diff --git a/parser/config.go b/parser/config.go
index ac45fd30..5e6d77e8 100644
--- a/parser/config.go
+++ b/parser/config.go
@@ -106,7 +106,7 @@ Options:
   -g --group=<group>
     the group for which the config needs to be set.
   --confirm=yes
-    To proceed, type "yes".
+    to proceed, type "yes".
 `
 
 	args, err := docopt.ParseArgs(usage, argv, "")
@@ -140,7 +140,7 @@ Options:
   -g --group=<group>
     the group for which the config needs to be unset.
   --confirm=yes
-    To proceed, type "yes".
+    to proceed, type "yes".
 `
 
 	args, err := docopt.ParseArgs(usage, argv, "")
@@ -216,7 +216,7 @@ Options:
   --path=<path>
     a path leading to an environment file [default: .env]
   --confirm=yes
-    To proceed, type "yes".
+    to proceed, type "yes".
 `
 
 	args, err := docopt.ParseArgs(usage, argv, "")
diff --git a/parser/pts.go b/parser/pts.go
index 27b6811e..94ee275f 100644
--- a/parser/pts.go
+++ b/parser/pts.go
@@ -104,7 +104,7 @@ Options:
   -a --app=<app>
     the uniquely identifiable name for the application.
   --confirm=yes
-    To proceed, type "yes".
+    to proceed, type "yes".
 `
 
 	args, err := docopt.ParseArgs(usage, argv, "")
diff --git a/parser/releases.go b/parser/releases.go
index 91b346d8..7fbc9c30 100644
--- a/parser/releases.go
+++ b/parser/releases.go
@@ -123,7 +123,7 @@ Options:
   -f --force
     force deploy.
   --confirm=yes
-    To proceed, type "yes".
+    to proceed, type "yes".
 `
 
 	args, err := docopt.ParseArgs(usage, argv, "")
