Skip to content

Commit 5e86155

Browse files
authored
feat(builds): add builds:fetch cmd (#72)
1 parent 3b1325e commit 5e86155

8 files changed

Lines changed: 309 additions & 11 deletions

File tree

cmd/builds.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"fmt"
56
"os"
7+
"path/filepath"
68

79
yaml "gopkg.in/yaml.v3"
810

@@ -100,6 +102,93 @@ func (d *DryccCmd) BuildsCreate(appID, image, stack, procfile, dryccpath, confir
100102
return nil
101103
}
102104

105+
func (d *DryccCmd) BuildsFetch(appID string, version int, procfile, dryccpath, confirm string) error {
106+
s, appID, err := load(d.ConfigFile, appID)
107+
if err != nil {
108+
return err
109+
}
110+
111+
build, err := builds.Get(s.Client, appID, version)
112+
if d.checkAPICompatibility(s.Client, err) != nil {
113+
return err
114+
}
115+
// Confirm again
116+
err = buildFetchConfirmAction(confirm, procfile, dryccpath)
117+
if err != nil {
118+
return err
119+
}
120+
os.Remove(procfile)
121+
os.ReadDir(dryccpath)
122+
if len(build.Procfile) != 0 {
123+
err := os.WriteFile(procfile, []byte(d.toYamlString(build.Procfile, 2)), 0755)
124+
if err != nil {
125+
return fmt.Errorf("failed to write Procfile: %w", err)
126+
}
127+
}
128+
129+
if len(build.Dryccfile) != 0 {
130+
err := writeDryccfileToPath(dryccpath, build.Dryccfile)
131+
if err != nil {
132+
return fmt.Errorf("failed to write Dryccfile: %w", err)
133+
}
134+
}
135+
d.Println("done")
136+
137+
return nil
138+
139+
}
140+
141+
func writeDryccfileToPath(dryccpath string, dryccfile map[string]interface{}) error {
142+
// Create the directory if it doesn't exist
143+
err := os.MkdirAll(dryccpath, os.ModePerm)
144+
if err != nil {
145+
return fmt.Errorf("failed to create directory: %w", err)
146+
}
147+
148+
// Write config section
149+
if config, ok := dryccfile["config"].(map[string]interface{}); ok {
150+
configDir := filepath.Join(dryccpath, "config")
151+
err := os.MkdirAll(configDir, os.ModePerm)
152+
if err != nil {
153+
return fmt.Errorf("failed to create config directory: %w", err)
154+
}
155+
156+
for key, values := range config {
157+
envFilePath := filepath.Join(configDir, key)
158+
var content string
159+
for k, v := range values.(map[string]interface{}) {
160+
// Append each key-value pair with newline
161+
content += fmt.Sprintf("%s=%v\n", k, v)
162+
}
163+
// Write accumulated content once
164+
err := os.WriteFile(envFilePath, []byte(content), 0755)
165+
if err != nil {
166+
return fmt.Errorf("failed to write env file: %w", err)
167+
}
168+
}
169+
}
170+
171+
// Write pipeline section
172+
if pipeline, ok := dryccfile["pipeline"].(map[string]interface{}); ok {
173+
for fileName, pipelineConfig := range pipeline {
174+
filePath := filepath.Join(dryccpath, fileName)
175+
var buf bytes.Buffer
176+
encoder := yaml.NewEncoder(&buf)
177+
encoder.SetIndent(2)
178+
if err := encoder.Encode(pipelineConfig); err != nil {
179+
return fmt.Errorf("failed to marshal pipeline config: %w", err)
180+
}
181+
yamlContent := buf.Bytes()
182+
err = os.WriteFile(filePath, yamlContent, 0755)
183+
if err != nil {
184+
return fmt.Errorf("failed to write pipeline file: %w", err)
185+
}
186+
}
187+
}
188+
189+
return nil
190+
}
191+
103192
func parseProcfile(procfile []byte) (map[string]string, error) {
104193
procfileMap := make(map[string]string)
105194
return procfileMap, yaml.Unmarshal(procfile, &procfileMap)
@@ -125,3 +214,20 @@ func buildConfirmAction(c *drycc.Client, appID string, procfileMap map[string]st
125214
}
126215
return nil
127216
}
217+
218+
func buildFetchConfirmAction(confirm, procfile, dryccpath string) error {
219+
220+
if confirm == "" || confirm != "yes" {
221+
// hint
222+
msg := fmt.Sprintf(" ! WARNING: Potentially Build Fetch Action\n"+
223+
" ! This operation will first rm the current \x1b[1m%s\x1b[0m and \x1b[1m%s\x1b[0m locally\n"+
224+
" ! To proceed, type \"yes\" !\n\n> ", procfile, dryccpath)
225+
226+
fmt.Print(msg)
227+
fmt.Scanln(&confirm)
228+
if confirm != "yes" {
229+
return fmt.Errorf("cancel the build create fetch")
230+
}
231+
}
232+
return nil
233+
}

cmd/builds_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import (
88
"path/filepath"
99
"testing"
1010

11+
"reflect"
12+
1113
"github.com/drycc/controller-sdk-go/api"
1214
"github.com/drycc/workflow-cli/pkg/testutil"
1315
"github.com/stretchr/testify/assert"
16+
"gopkg.in/yaml.v3"
1417
)
1518

1619
func TestParseProcfile(t *testing.T) {
@@ -196,3 +199,140 @@ deploy:
196199
assert.Equal(t, testutil.StripProgress(b.String()), "Creating build... done\n", "output")
197200

198201
}
202+
203+
func TestBuildsFetch(t *testing.T) {
204+
t.Parallel()
205+
206+
// Mock server setup
207+
cf, server, err := testutil.NewTestServerAndClient()
208+
if err != nil {
209+
t.Fatal(err)
210+
}
211+
defer server.Close()
212+
213+
cmdr := DryccCmd{WOut: new(bytes.Buffer), ConfigFile: cf}
214+
215+
// Mock build data
216+
server.Mux.HandleFunc("/v2/apps/testapp/build/", func(w http.ResponseWriter, _ *http.Request) {
217+
testutil.SetHeaders(w)
218+
fmt.Fprintf(w, `{
219+
"app": "testapp",
220+
"procfile": {"web": "node server.js"},
221+
"dryccfile": {
222+
"pipeline": {
223+
"web.yaml": {
224+
"kind": "pipeline",
225+
"ptype": "web",
226+
"deploy": {
227+
"command": ["bash", "-c"],
228+
"args": ["echo hello"]
229+
}
230+
}
231+
},
232+
"config": {
233+
"env1.env": {"KEY1": "VALUE1"},
234+
"env2.env": {"KEY2": "VALUE2"}
235+
}
236+
}
237+
}`)
238+
})
239+
240+
// Helper function to compare YAML content ignoring field order
241+
isEqualYAML := func(actual, expected string) bool {
242+
var actualMap, expectedMap map[string]interface{}
243+
err := yaml.Unmarshal([]byte(actual), &actualMap)
244+
if err != nil {
245+
return false
246+
}
247+
err = yaml.Unmarshal([]byte(expected), &expectedMap)
248+
if err != nil {
249+
return false
250+
}
251+
return reflect.DeepEqual(actualMap, expectedMap)
252+
}
253+
254+
// Test case 1: Successful fetch with valid confirm
255+
t.Run("Successful Fetch", func(t *testing.T) {
256+
tmpDir := t.TempDir() // Create a unique temporary directory for this test
257+
procfilePath := filepath.Join(tmpDir, "Procfile")
258+
dryccpath := filepath.Join(tmpDir, ".drycc")
259+
260+
// Mock user input for confirmation
261+
restoreStdin := os.Stdin
262+
r, w, _ := os.Pipe()
263+
os.Stdin = r
264+
go func() {
265+
defer w.Close() // Ensure the pipe is closed after writing
266+
w.Write([]byte("yes\n"))
267+
}()
268+
269+
err := cmdr.BuildsFetch("testapp", 0, procfilePath, dryccpath, "")
270+
if err != nil {
271+
t.Fatalf("expected no error, got %v", err)
272+
}
273+
274+
// Verify Procfile content
275+
content, err := os.ReadFile(procfilePath)
276+
if err != nil {
277+
t.Fatalf("failed to read Procfile: %v", err)
278+
}
279+
expectedProcfile := "web: node server.js\n"
280+
if string(content) != expectedProcfile {
281+
t.Errorf("expected Procfile content %q, got %q", expectedProcfile, string(content))
282+
}
283+
284+
// Verify .drycc/config/env1.env content
285+
env1Content, err := os.ReadFile(filepath.Join(dryccpath, "config", "env1.env"))
286+
if err != nil {
287+
t.Fatalf("failed to read env1.env: %v", err)
288+
}
289+
expectedEnv1 := "KEY1=VALUE1\n"
290+
if string(env1Content) != expectedEnv1 {
291+
t.Errorf("expected env1.env content %q, got %q", expectedEnv1, string(env1Content))
292+
}
293+
294+
// Verify .drycc/web.yaml content
295+
webYamlContent, err := os.ReadFile(filepath.Join(dryccpath, "web.yaml"))
296+
if err != nil {
297+
t.Fatalf("failed to read web.yaml: %v", err)
298+
}
299+
expectedWebYaml := "kind: pipeline\nptype: web\ndeploy:\n command:\n - bash\n - -c\n args:\n - echo hello\n"
300+
if !isEqualYAML(string(webYamlContent), expectedWebYaml) {
301+
t.Errorf("expected web.yaml content %q, got %q", expectedWebYaml, string(webYamlContent))
302+
}
303+
304+
os.Stdin = restoreStdin
305+
})
306+
307+
// Test case 2: User cancels the operation
308+
t.Run("User Cancels Operation", func(t *testing.T) {
309+
tmpDir := t.TempDir() // Create a unique temporary directory for this test
310+
procfilePath := filepath.Join(tmpDir, "Procfile")
311+
dryccpath := filepath.Join(tmpDir, ".drycc")
312+
313+
// Mock user input for cancellation
314+
restoreStdin := os.Stdin
315+
r, w, _ := os.Pipe()
316+
os.Stdin = r
317+
go func() {
318+
defer w.Close() // Ensure the pipe is closed after writing
319+
w.Write([]byte("no\n"))
320+
}()
321+
322+
err := cmdr.BuildsFetch("testapp", 0, procfilePath, dryccpath, "")
323+
if err == nil || err.Error() != "cancel the build create fetch" {
324+
t.Fatalf("expected cancellation error, got %v", err)
325+
}
326+
327+
// Verify files were not created
328+
if _, err := os.Stat(procfilePath); !os.IsNotExist(err) {
329+
t.Errorf("expected Procfile to not exist, but it does")
330+
}
331+
332+
if _, err := os.Stat(dryccpath); !os.IsNotExist(err) {
333+
t.Errorf("expected .drycc directory to not exist, but it does")
334+
}
335+
336+
os.Stdin = restoreStdin
337+
})
338+
}

cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Commander interface {
3636
TokensRemove(string, string) error
3737
BuildsInfo(string, int) error
3838
BuildsCreate(string, string, string, string, string, string) error
39+
BuildsFetch(string, int, string, string, string) error
3940
CertsList(string, int) error
4041
CertAdd(string, string, string, string) error
4142
CertRemove(string, string) error

parser/builds.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ func Builds(argv []string, cmdr cmd.Commander) error {
1010
usage := `
1111
Valid commands for builds:
1212
13-
builds:info Print information about a specific build
13+
builds:info print information about a specific build
1414
builds:create imports an image and deploys as a new release
15+
builds:fetch fetch the Procfile and dryccfile to the local
1516
1617
Use 'drycc help [command]' to learn more.
1718
`
@@ -21,6 +22,8 @@ Use 'drycc help [command]' to learn more.
2122
return buildsInfo(argv, cmdr)
2223
case "builds:create":
2324
return buildsCreate(argv, cmdr)
25+
case "builds:fetch":
26+
return buildsFetch(argv, cmdr)
2427
default:
2528
if printHelp(argv, usage) {
2629
return nil
@@ -81,15 +84,15 @@ Arguments:
8184
8285
Options:
8386
-a --app=<app>
84-
The uniquely identifiable name for the application.
87+
the uniquely identifiable name for the application.
8588
-s --stack=<stack>
86-
The stack name for the application, defaults to container.
89+
the stack name for the application, defaults to container.
8790
-p --procfile=<procfile>
88-
A YAML file used to supply a Procfile to the application.
91+
a YAML file used to supply a Procfile to the application.
8992
-d --dryccpath=<dryccpath>
90-
Drycc config path to the application, default is '.drycc'.
93+
drycc config path to the application, default is '.drycc'.
9194
--confirm=yes
92-
To proceed, type "yes".
95+
to proceed, type "yes".
9396
`
9497

9598
args, err := docopt.ParseArgs(usage, argv, "")
@@ -108,3 +111,43 @@ Options:
108111

109112
return cmdr.BuildsCreate(app, image, stack, procfile, dryccpath, confirm)
110113
}
114+
115+
func buildsFetch(argv []string, cmdr cmd.Commander) error {
116+
usage := `
117+
Print information about a specific build.
118+
119+
Usage: drycc builds:fetch [options]
120+
121+
Options:
122+
-a --app=<app>
123+
the uniquely identifiable name for the application.
124+
-v --version=<version>
125+
the version for which the build info needs to be fetched.
126+
-p --procfile=<procfile>
127+
a YAML file used to supply a Procfile to the application.
128+
-d --dryccpath=<dryccpath>
129+
drycc config path to the application, default is '.drycc'.
130+
--confirm=yes
131+
to proceed, type "yes".
132+
`
133+
134+
args, err := docopt.ParseArgs(usage, argv, "")
135+
136+
if err != nil {
137+
return err
138+
}
139+
140+
app := safeGetString(args, "--app")
141+
var version int
142+
if safeGetString(args, "--version") != "" {
143+
if version, err = versionFromString(safeGetString(args, "--version")); err != nil {
144+
return err
145+
}
146+
}
147+
148+
procfile := safeGetValue(args, "--procfile", "Procfile")
149+
dryccpath := safeGetValue(args, "--dryccpath", ".drycc")
150+
confirm := safeGetString(args, "--confirm")
151+
152+
return cmdr.BuildsFetch(app, version, procfile, dryccpath, confirm)
153+
}

parser/builds_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ func (d FakeDryccCmd) BuildsCreate(string, string, string, string, string, strin
1717
return errors.New("builds:create")
1818
}
1919

20+
func (d FakeDryccCmd) BuildsFetch(string, int, string, string, string) error {
21+
return errors.New("builds:fetch")
22+
}
23+
2024
func TestBuilds(t *testing.T) {
2125
t.Parallel()
2226

@@ -42,6 +46,10 @@ func TestBuilds(t *testing.T) {
4246
args: []string{"builds:create", "drycc/example-go:latest"},
4347
expected: "",
4448
},
49+
{
50+
args: []string{"builds:fetch"},
51+
expected: "",
52+
},
4553
{
4654
args: []string{"builds"},
4755
expected: "builds:info",

0 commit comments

Comments
 (0)