diff --git a/internal/lifecycle/lifecycle.go b/internal/lifecycle/lifecycle.go index c3137ba..9aa8801 100644 --- a/internal/lifecycle/lifecycle.go +++ b/internal/lifecycle/lifecycle.go @@ -18,11 +18,11 @@ import ( "errors" "fmt" "github.com/GoogleCloudPlatform/serverless-sample-tester/internal/util" + "github.com/spf13/viper" "log" "os" "os/exec" "path/filepath" - "github.com/spf13/viper" ) // Lifecycle is a list of ordered exec.Cmd that should be run to execute a certain process. @@ -58,8 +58,6 @@ func NewLifecycle(sampleDir, serviceName, gcrURL string) (Lifecycle, error) { readmePath = filepath.Join(sampleDir, "README.md") } - - if _, err := os.Stat(readmePath); err == nil { lifecycle, err := parseREADME(readmePath, serviceName, gcrURL) // Show README location @@ -69,11 +67,11 @@ func NewLifecycle(sampleDir, serviceName, gcrURL string) (Lifecycle, error) { return lifecycle, nil } - if !errors.Is(err, errNoREADMECodeBlocksFound) { + if !errors.Is(err, errNoReadmeCodeBlocksFound) { return nil, fmt.Errorf("lifecycle.parseREADME: %s: %w", readmePath, err) } - log.Println("No code blocks immediately preceded by %s found in README.md\n", codeTag) + log.Printf("No code blocks immediately preceded by %s found in README.md\n", codeTag) } else { log.Println("No README.md found") } diff --git a/internal/lifecycle/readme.go b/internal/lifecycle/readme.go index 81a7b57..703420f 100644 --- a/internal/lifecycle/readme.go +++ b/internal/lifecycle/readme.go @@ -42,7 +42,11 @@ var ( mdCodeFenceStartRegexp = regexp.MustCompile("^\\w*`{3,}[^`]*$") - errNoREADMECodeBlocksFound = fmt.Errorf("lifecycle.extractCodeBlocks: no code blocks immediately preceded by %s found", codeTag) + errNoReadmeCodeBlocksFound = fmt.Errorf("lifecycle.extractCodeBlocks: no code blocks immediately preceded by %s found", codeTag) + errCodeBlockNotClosed = fmt.Errorf("unexpected EOF: code block not closed") + errCodeBlockStartNotFound = fmt.Errorf("expecting start of code block immediately after code tag") + errEOFAfterCodeTag = fmt.Errorf("unexpected EOF: file ended immediately after code tag") + errCodeBlockEndAfterLineCont = "end of code block: expecting command line continuation" ) // codeBlock is a slice of strings containing terminal commands. codeBlocks, for example, could be used to hold the @@ -68,7 +72,7 @@ func (cb codeBlock) toCommands(serviceName, gcrURL string) ([]*exec.Cmd, error) i++ if i >= len(cb) { - return nil, fmt.Errorf("unexpected end of code block: expecting command line continuation; code block dump:\n%s", strings.Join(cb, "\n")) + return nil, fmt.Errorf("%s; code block dump:\n%s", errCodeBlockEndAfterLineCont, strings.Join(cb, "\n")) } l := cb[i] @@ -98,9 +102,10 @@ func (cb codeBlock) toCommands(serviceName, gcrURL string) ([]*exec.Cmd, error) return cmds, nil } -// parseREADME parses a README file with the given name. It reads terminal commands surrounded by one of the codeTags -// listed above and loads them into a Lifecycle. In the process, it replaces the Cloud Run service name and Container -// Registry tag with the provided inputs. +// parseREADME parses a README file with the given name. It parses terminal commands in code blocks annotated by the +// codeTag and loads them into a Lifecycle. In the process, it replaces the Cloud Run service name and Container +// Registry tag with the provided inputs. It also expands environment variables and supports bash-style line +// continuations. func parseREADME(filename, serviceName, gcrURL string) (Lifecycle, error) { file, err := os.Open(filename) if err != nil { @@ -109,20 +114,29 @@ func parseREADME(filename, serviceName, gcrURL string) (Lifecycle, error) { defer file.Close() scanner := bufio.NewScanner(file) + + return extractLifecycle(scanner, serviceName, gcrURL) +} + +// extractLifecycle is a helper function for parseREADME. It takes a scanner that reads from a Markdown file and parses +// terminal commands in code blocks annotated by the codeTag and loads them into a Lifecycle. In the process, it +// replaces the Cloud Run service name and Container Registry tag with the provided inputs. It also expands environment +// variables and supports bash-style line continuations. +func extractLifecycle(scanner *bufio.Scanner, serviceName, gcrURL string) (Lifecycle, error) { codeBlocks, err := extractCodeBlocks(scanner) if err != nil { - return nil, fmt.Errorf("lifecycle.extractCodeBlocks: %s: %w", filename, err) + return nil, fmt.Errorf("lifecycle.extractCodeBlocks: %w", err) } if len(codeBlocks) == 0 { - return nil, errNoREADMECodeBlocksFound + return nil, errNoReadmeCodeBlocksFound } var l Lifecycle for _, b := range codeBlocks { cmds, err := b.toCommands(serviceName, gcrURL) if err != nil { - return l, fmt.Errorf("codeBlock.toCommands: code blocks in %s: %w", filename, err) + return l, fmt.Errorf("codeBlock.toCommands: %w", err) } l = append(l, cmds...) @@ -147,14 +161,14 @@ func extractCodeBlocks(scanner *bufio.Scanner) ([]codeBlock, error) { if err := scanner.Err(); err != nil { return nil, fmt.Errorf("line %d: bufio.Scanner.Scan: %w", lineNum, err) } - return nil, fmt.Errorf("unexpected EOF: file ended immediately after code tag") + return nil, errEOFAfterCodeTag } lineNum++ startCodeBlockLine := scanner.Text() m := mdCodeFenceStartRegexp.MatchString(startCodeBlockLine) if !m { - return nil, fmt.Errorf("line %d: expecting start of code block immediately after code tag", lineNum) + return nil, fmt.Errorf("line %d: %w", lineNum, errCodeBlockStartNotFound) } c := strings.Count(startCodeBlockLine, "`") @@ -178,7 +192,7 @@ func extractCodeBlocks(scanner *bufio.Scanner) ([]codeBlock, error) { } if !blockClosed { - return nil, fmt.Errorf("unexpected EOF: code block not closed") + return nil, errCodeBlockNotClosed } blocks = append(blocks, block) @@ -204,7 +218,7 @@ func replaceServiceName(command, serviceName string) string { sp := strings.Split(command, " ") // Detects if the user specified the Cloud Run service name in an environment variable - for i := 0; i < len(sp); i++ { + for i := 0; i < len(sp); i++ { if sp[i] == os.ExpandEnv("$CLOUD_RUN_SERVICE_NAME") { sp[i] = serviceName return strings.Join(sp, " ") @@ -212,7 +226,7 @@ func replaceServiceName(command, serviceName string) string { } // Searches for specific gcloud keywords and takes service name from them - for i := 0; i < len(sp) - 1; i++ { + for i := 0; i < len(sp)-1; i++ { if sp[i] == "deploy" || sp[i] == "update" { sp[i+1] = serviceName return strings.Join(sp, " ") diff --git a/internal/lifecycle/readme_test.go b/internal/lifecycle/readme_test.go new file mode 100644 index 0000000..2869bf6 --- /dev/null +++ b/internal/lifecycle/readme_test.go @@ -0,0 +1,458 @@ +package lifecycle + +import ( + "bufio" + "errors" + "os" + "os/exec" + "reflect" + "strings" + "testing" +) + +// setEnv takes a map of environment variables to their values and sets the program's environment accordingly. +func setEnv(e map[string]string) error { + for k, v := range e { + if err := os.Setenv(k, v); err != nil { + return err + } + } + return nil +} + +// unsetEnv takes a map of environment variables to their values and unsets the environment variables in the program's +// environment. +func unsetEnv(e map[string]string) error { + for k := range e { + if err := os.Unsetenv(k); err != nil { + return err + } + } + return nil +} + +// uniqueServiceName is the Cloud Run Service name that will replace the existing service names in each codeBlock test. +const uniqueServiceName = "unique_service_name" + +// uniqueGCRURL is the Container Registry URL tag that will replace the existing Container Registry URL tag in each codeBlock test. +const uniqueGCRURL = "gcr.io/unique/tag" + +type toCommandsTest struct { + codeBlock codeBlock // input code block + cmds []*exec.Cmd // expected result of codeBlock.toCommands + err string // expected string contained in return error of codeBlock.toCommands + env map[string]string // map of environment variables to values for this test +} + +var toCommandsTests = []toCommandsTest{ + // single one-line command + { + codeBlock: codeBlock{ + "echo hello world", + }, + cmds: []*exec.Cmd{ + exec.Command("echo", "hello", "world"), + }, + }, + + // two one-line commands + { + codeBlock: codeBlock{ + "echo line one", + "echo line two", + }, + cmds: []*exec.Cmd{ + exec.Command("echo", "line", "one"), + exec.Command("echo", "line", "two"), + }, + }, + + // single multiline command + { + codeBlock: codeBlock{ + "echo multi \\", + "line command", + }, + cmds: []*exec.Cmd{ + exec.Command("echo", "multi", "line", "command"), + }, + }, + + // line cont char but code block closes at next line + { + codeBlock: codeBlock{ + "echo multi \\", + }, + cmds: nil, + err: errCodeBlockEndAfterLineCont, + }, + + // expand environment variable test + { + codeBlock: codeBlock{ + "echo ${TEST_ENV}", + }, + cmds: []*exec.Cmd{ + exec.Command("echo", "hello", "world"), + }, + env: map[string]string{ + "TEST_ENV": "hello world", + }, + }, + + // replace Cloud Run service name with provided name test + { + codeBlock: codeBlock{ + "gcloud run services deploy hello_world", + }, + cmds: []*exec.Cmd{ + exec.Command("gcloud", "--quiet", "run", "services", "deploy", uniqueServiceName), + }, + }, + + // replace Container Registry URL with provided URL test + { + codeBlock: codeBlock{ + "gcloud builds submit --tag=gcr.io/hello/world", + }, + cmds: []*exec.Cmd{ + exec.Command("gcloud", "--quiet", "builds", "submit", "--tag="+uniqueGCRURL), + }, + }, + + // replace multiline GCR URL with provided URL test + { + codeBlock: codeBlock{ + "gcloud builds submit --tag=gcr.io/hello/\\", + "world", + }, + cmds: []*exec.Cmd{ + exec.Command("gcloud", "--quiet", "builds", "submit", "--tag="+uniqueGCRURL), + }, + }, + + // replace Cloud Run service name and GCR URL with provided inputs test + { + codeBlock: codeBlock{ + "gcloud run services deploy hello_world --image=gcr.io/hello/world", + }, + cmds: []*exec.Cmd{ + exec.Command("gcloud", "--quiet", "run", "services", "deploy", uniqueServiceName, "--image="+uniqueGCRURL), + }, + }, + + // replace Cloud Run service name and GCR URL with `--image url` syntax test + // this test breaks right now (issue #3) + //{ + // codeBlock: codeBlock{ + // "gcloud run services deploy hello_world --image gcr.io/hello/world", + // }, + // cmds: []*exec.Cmd{ + // exec.Command("gcloud", "--quiet", "run", "services", "deploy", uniqueServiceName, "--image", uniqueGCRURL), + // }, + // serviceName: "unique_service_name", + // gcrURL: "gcr.io/unique/tag", + //}, + { + codeBlock: codeBlock{ + "gcloud run services deploy hello_world --image=gcr.io/hello/world --add-cloudsql-instances=${TEST_CLOUD_SQL_CONNECTION}", + }, + cmds: []*exec.Cmd{ + exec.Command("gcloud", "--quiet", "run", "services", "deploy", uniqueServiceName, "--image="+uniqueGCRURL, "--add-cloudsql-instances=project:region:instance"), + }, + env: map[string]string{ + "TEST_CLOUD_SQL_CONNECTION": "project:region:instance", + }, + }, + + // replace Cloud Run service name provided name in command with multiline arguments test + { + codeBlock: codeBlock{ + "gcloud run services update hello_world --add-cloudsql-instances=\\", + "project:region:instance", + }, + cmds: []*exec.Cmd{ + exec.Command("gcloud", "--quiet", "run", "services", "update", uniqueServiceName, "--add-cloudsql-instances=project:region:instance"), + }, + }, + + // replace Cloud Run service name provided name and expand environment variables in command with multiline arguments test + { + codeBlock: codeBlock{ + "gcloud run services update hello_world --add-cloudsql-instances=\\", + "${TEST_CLOUD_SQL_CONNECTION}", + }, + cmds: []*exec.Cmd{ + exec.Command("gcloud", "--quiet", "run", "services", "update", uniqueServiceName, "--add-cloudsql-instances=project:region:instance"), + }, + env: map[string]string{ + "TEST_CLOUD_SQL_CONNECTION": "project:region:instance", + }, + }, +} + +func TestToCommands(t *testing.T) { + for i, tc := range toCommandsTests { + if len(tc.codeBlock) == 0 { + continue + } + + if err := setEnv(tc.env); err != nil { + t.Errorf("#%d: setEnv: %v", i, err) + + if err = unsetEnv(tc.env); err != nil { + t.Errorf("#%d: unsetEnv: %v", i, err) + } + + continue + } + + cmds, err := tc.codeBlock.toCommands(uniqueServiceName, uniqueGCRURL) + + var errorMatch bool + if err == nil { + errorMatch = tc.err == "" + } else { + errorMatch = strings.Contains(err.Error(), tc.err) + } + + if !errorMatch { + t.Errorf("#%d: error mismatch\nwant: %s\ngot: %v", i, tc.err, err) + } + + if (errorMatch && err == nil) && !reflect.DeepEqual(cmds, tc.cmds) { + t.Errorf("#%d: result mismatch\nwant: %#+v\ngot: %#+v", i, tc.cmds, cmds) + } + + if err := unsetEnv(tc.env); err != nil { + t.Errorf("#%d: unsetEnv: %v", i, err) + } + } +} + +type parseREADMETest struct { + inFileName string // input Markdown file + lifecycle Lifecycle // expected result of parseREADME + err error // expected parseREADME return error +} + +var parseREADMETests = []parseREADMETest{ + // three code blocks, only two with comment code tags. one with one command, the other with two commands + { + inFileName: "readme_test.md", + lifecycle: Lifecycle{ + exec.Command("echo", "hello", "world"), + exec.Command("echo", "line", "one"), + exec.Command("echo", "line", "two"), + }, + }, +} + +func TestParseREADME(t *testing.T) { + for i, tc := range parseREADMETests { + if tc.inFileName == "" { + continue + } + + // Cloud Run Service name and Container Registry URL tag replacement will be tested in TestToCommands + lifecycle, err := parseREADME(tc.inFileName, "", "") + + if !errors.Is(err, tc.err) { + t.Errorf("#%d: error mismatch\nwant: %v\ngot: %v", i, tc.err, err) + continue + } + + if err == nil && !reflect.DeepEqual(lifecycle, tc.lifecycle) { + t.Errorf("#%d: result mismatch\nwant: %#+v\ngot: %#+v", i, tc.lifecycle, lifecycle) + continue + } + } +} + +type extractLifecycleTest struct { + in string // input Markdown string + lifecycle Lifecycle // expected results of extractLifecycle on in + err error // expected error +} + +var extractLifecycleTests = []extractLifecycleTest{ + // single code block + { + in: "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo hello world\n" + + "```\n", + lifecycle: Lifecycle{ + exec.Command("echo", "hello", "world"), + }, + }, + + // two code blocks with markdown text in the middle + { + in: "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo build command\n" + + "```\n" + + "markdown instructions\n" + + "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo deploy command\n" + + "```\n", + lifecycle: Lifecycle{ + exec.Command("echo", "build", "command"), + exec.Command("echo", "deploy", "command"), + }, + }, +} + +func TestExtractLifecycle(t *testing.T) { + for i, tc := range extractLifecycleTests { + if tc.in == "" { + continue + } + + s := bufio.NewScanner(strings.NewReader(tc.in)) + + // Cloud Run Service name and Container Registry URL tag replacement will be tested in TestToCommands + lifecycle, err := extractLifecycle(s, "", "") + + if !errors.Is(err, tc.err) { + t.Errorf("#%d: error mismatch\nwant: %v\ngot: %v", i, tc.err, err) + continue + } + + if err == nil && !reflect.DeepEqual(lifecycle, tc.lifecycle) { + t.Errorf("#%d: result mismatch\nwant: %#+v\ngot: %#+v", i, tc.lifecycle, lifecycle) + } + } +} + +type extractCodeBlocksTest struct { + in string // input Markdown string + codeBlocks []codeBlock // expected result of extractCodeBlocks + err error // expected return error of extractCodeBlocks +} + +var extractCodeBlocksTests = []extractCodeBlocksTest{ + // single code block + { + in: "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo hello world\n" + + "```\n", + codeBlocks: []codeBlock{ + []string{ + "echo hello world", + }, + }, + }, + + // code block not closed + { + in: "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo hello world\n", + codeBlocks: nil, + err: errCodeBlockNotClosed, + }, + + // code block doesn't start immediately after code tag + { + in: "[//]: # ({sst-run-unix})\n" + + "not start of code block\n" + + "```\n" + + "echo hello world\n" + + "```\n", + codeBlocks: nil, + err: errCodeBlockStartNotFound, + }, + + // EOF immediately after code tag + { + in: "instuctions\n" + + "[//]: # ({sst-run-unix})\n", + codeBlocks: nil, + err: errEOFAfterCodeTag, + }, + + // single code block, two lines + { + in: "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo line one\n" + + "echo line two\n" + + "```\n", + codeBlocks: []codeBlock{ + []string{ + "echo line one", + "echo line two", + }, + }, + }, + + // two code blocks with markdown instructions in the middle + { + in: "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo build command\n" + + "```\n" + + "markdown instructions\n" + + "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo deploy command\n" + + "```\n", + codeBlocks: []codeBlock{ + []string{ + "echo build command", + }, + []string{ + "echo deploy command", + }, + }, + }, + + // two code blocks, but only one is annotated with code tag + { + in: "[//]: # ({sst-run-unix})\n" + + "```\n" + + "echo build and deploy command\n" + + "```\n" + + "markdown instructions\n" + + "```\n" + + "echo irrelevant command\n" + + "```\n", + codeBlocks: []codeBlock{ + []string{ + "echo build and deploy command", + }, + }, + }, + + // one code block, but not annotated with code tag + { + in: "```\n" + + "echo hello world\n" + + "```\n", + codeBlocks: nil, + }, +} + +func TestExtractCodeBlocks(t *testing.T) { + for i, tc := range extractCodeBlocksTests { + if tc.in == "" { + continue + } + + s := bufio.NewScanner(strings.NewReader(tc.in)) + codeBlocks, err := extractCodeBlocks(s) + + if !errors.Is(err, tc.err) { + t.Errorf("#%d: error mismatch\nwant: %v\ngot: %v", i, tc.err, err) + continue + } + + if err == nil && !reflect.DeepEqual(codeBlocks, tc.codeBlocks) { + t.Errorf("#%d: result mismatch\nwant: %#+v\ngot: %#+v", i, tc.codeBlocks, codeBlocks) + } + } +} diff --git a/internal/lifecycle/readme_test.md b/internal/lifecycle/readme_test.md new file mode 100644 index 0000000..0f0b9af --- /dev/null +++ b/internal/lifecycle/readme_test.md @@ -0,0 +1,17 @@ +This code block with one command should be picked up: +[//]: # ({sst-run-unix}) +```bash +echo hello world +``` + +This code block with two commands with indents should be picked up: +[//]: # ({sst-run-unix}) +```bash +echo line one + echo line two +``` + +This code block without a comment code tag should not be picked up: +```bash +echo hello world +```