diff options
author | Martin Polden <mpolden@mpolden.no> | 2021-10-06 10:42:50 +0200 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2021-10-07 09:33:53 +0200 |
commit | a3530f08a4611542f5a318563d96fec63dbf0bb9 (patch) | |
tree | 97d75ce7b111571d70c6f39f575974b4d1eb6699 /client | |
parent | 88fcb5fcb1cb5f040653f67d4e0b35abab089166 (diff) |
Implement vespa prod init
Diffstat (limited to 'client')
-rw-r--r-- | client/go/cmd/command_tester.go | 7 | ||||
-rw-r--r-- | client/go/cmd/prod.go | 277 | ||||
-rw-r--r-- | client/go/cmd/prod_test.go | 118 | ||||
-rw-r--r-- | client/go/cmd/root.go | 2 | ||||
-rw-r--r-- | client/go/vespa/xml/config.go | 5 |
5 files changed, 408 insertions, 1 deletions
diff --git a/client/go/cmd/command_tester.go b/client/go/cmd/command_tester.go index 8eaf6be2c22..201adb8cab7 100644 --- a/client/go/cmd/command_tester.go +++ b/client/go/cmd/command_tester.go @@ -7,6 +7,7 @@ package cmd import ( "bytes" "crypto/tls" + "io" "io/ioutil" "net/http" "os" @@ -23,6 +24,7 @@ import ( type command struct { homeDir string cacheDir string + stdin io.ReadWriter args []string moreArgs []string } @@ -67,6 +69,11 @@ func execute(cmd command, t *testing.T, client *mockHttpClient) (string, string) var capturedErr bytes.Buffer stdout = &capturedOut stderr = &capturedErr + if cmd.stdin != nil { + stdin = cmd.stdin + } else { + stdin = os.Stdin + } // Execute command and return output rootCmd.SetArgs(append(cmd.args, cmd.moreArgs...)) diff --git a/client/go/cmd/prod.go b/client/go/cmd/prod.go new file mode 100644 index 00000000000..15d9edc18a7 --- /dev/null +++ b/client/go/cmd/prod.go @@ -0,0 +1,277 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/vespa-engine/vespa/client/go/util" + "github.com/vespa-engine/vespa/client/go/vespa" + "github.com/vespa-engine/vespa/client/go/vespa/xml" +) + +func init() { + rootCmd.AddCommand(prodCmd) + prodCmd.AddCommand(prodInitCmd) +} + +var prodCmd = &cobra.Command{ + Use: "prod", + Short: "Deploy an application package to production in Vespa Cloud", + Long: `Deploy an application package to production in Vespa Cloud. + +Configure and deploy your application package to production in Vespa Cloud.`, + Example: `$ vespa prod init +$ vespa prod submit`, + DisableAutoGenTag: true, + Run: func(cmd *cobra.Command, args []string) { + // Root command does nothing + cmd.Help() + exitFunc(1) + }, +} + +var prodInitCmd = &cobra.Command{ + Use: "init", + Short: "Modify service.xml and deployment.xml for production deployment", + Long: `Modify service.xml and deployment.xml for production deployment. + +Only basic deployment configuration is available through this command. For +advanced configuration see the relevant Vespa Cloud documentation and make +changes to deployment.xml and services.xml directly. + +Reference: +https://cloud.vespa.ai/en/reference/services +https://cloud.vespa.ai/en/reference/deployment`, + DisableAutoGenTag: true, + Run: func(cmd *cobra.Command, args []string) { + appSource := applicationSource(args) + pkg, err := vespa.FindApplicationPackage(appSource, false) + if err != nil { + fatalErr(err) + return + } + if pkg.IsZip() { + fatalErrHint(fmt.Errorf("Cannot modify compressed application package %s", pkg.Path), + "Try running 'mvn clean' and re-running this command") + return + } + + deploymentXML, err := readDeploymentXML(pkg) + if err != nil { + fatalErr(err, "Could not read deployment.xml") + return + } + servicesXML, err := readServicesXML(pkg) + if err != nil { + fatalErr(err, "A services.xml declaring your cluster(s) must exist") + return + } + + fmt.Fprint(stdout, "This will modify any existing ", color.Yellow("deployment.xml"), " and ", color.Yellow("services.xml"), + "!\nBefore modification a backup of the original file will be created.\n\n") + fmt.Fprint(stdout, "A default value is suggested (shown inside brackets) based on\nthe files' existing contents. Press enter to use it.\n\n") + fmt.Fprint(stdout, "Abort the configuration at any time by pressing Ctrl-C. The\nfiles will remain untouched.\n\n") + r := bufio.NewReader(stdin) + deploymentXML = updateRegions(r, deploymentXML) + servicesXML = updateNodes(r, servicesXML) + + fmt.Fprintln(stdout) + if err := writeWithBackup(pkg, "deployment.xml", deploymentXML.String()); err != nil { + fatalErr(err) + return + } + if err := writeWithBackup(pkg, "services.xml", servicesXML.String()); err != nil { + fatalErr(err) + return + } + }, +} + +func writeWithBackup(pkg vespa.ApplicationPackage, filename, contents string) error { + dst := filepath.Join(pkg.Path, filename) + if util.PathExists(dst) { + data, err := ioutil.ReadFile(dst) + if err != nil { + return err + } + if bytes.Equal(data, []byte(contents)) { + fmt.Fprintf(stdout, "Not writing %s: File is unchanged\n", color.Yellow(filename)) + return nil + } + renamed := false + for i := 1; i <= 1000; i++ { + bak := fmt.Sprintf("%s.%d.bak", dst, i) + if !util.PathExists(bak) { + fmt.Fprintf(stdout, "Backing up existing %s to %s\n", color.Yellow(filename), color.Yellow(bak)) + if err := os.Rename(dst, bak); err != nil { + return err + } + renamed = true + break + } + } + if !renamed { + return fmt.Errorf("could not find an unused backup name for %s", dst) + } + } + fmt.Fprintf(stdout, "Writing %s\n", color.Green(dst)) + return ioutil.WriteFile(dst, []byte(contents), 0644) +} + +func updateRegions(r *bufio.Reader, deploymentXML xml.Deployment) xml.Deployment { + regions := promptRegions(r, deploymentXML) + parts := strings.Split(regions, ",") + regionElements := xml.Regions(parts...) + if err := deploymentXML.Replace("prod", "region", regionElements); err != nil { + fatalErr(err, "Could not update region elements in deployment.xml") + } + return deploymentXML +} + +func promptRegions(r *bufio.Reader, deploymentXML xml.Deployment) string { + fmt.Fprintln(stdout, color.Cyan("> Deployment regions")) + fmt.Fprintf(stdout, "Documentation: %s\n", color.Green("https://cloud.vespa.ai/en/reference/zones")) + fmt.Fprintf(stdout, "Example: %s\n\n", color.Yellow("aws-us-east-1c,aws-us-west-2a")) + var currentRegions []string + for _, r := range deploymentXML.Prod.Regions { + currentRegions = append(currentRegions, r.Name) + } + if len(deploymentXML.Instance) > 0 { + for _, r := range deploymentXML.Instance[0].Prod.Regions { + currentRegions = append(currentRegions, r.Name) + } + } + validator := func(input string) error { + regions := strings.Split(input, ",") + for _, r := range regions { + if !xml.ValidProdRegion(r) { + return fmt.Errorf("invalid region %s", r) + } + } + return nil + } + return prompt(r, "Which regions do you wish to deploy in?", strings.Join(currentRegions, ","), validator) +} + +func updateNodes(r *bufio.Reader, servicesXML xml.Services) xml.Services { + for _, c := range servicesXML.Container { + nodes := promptNodes(r, c.ID, c.Nodes) + if err := servicesXML.Replace("container#"+c.ID, "nodes", nodes); err != nil { + fatalErr(err) + return xml.Services{} + } + } + for _, c := range servicesXML.Content { + nodes := promptNodes(r, c.ID, c.Nodes) + if err := servicesXML.Replace("content#"+c.ID, "nodes", nodes); err != nil { + fatalErr(err) + return xml.Services{} + } + } + return servicesXML +} + +func promptNodes(r *bufio.Reader, clusterID string, defaultValue xml.Nodes) xml.Nodes { + count := promptNodeCount(r, clusterID, defaultValue.Count) + const autoSpec = "auto" + defaultSpec := autoSpec + resources := defaultValue.Resources + if resources != nil { + defaultSpec = defaultValue.Resources.String() + } + spec := promptResources(r, clusterID, defaultSpec) + if spec == autoSpec { + resources = nil + } else { + r, err := xml.ParseResources(spec) + if err != nil { + fatalErr(err) // Should not happen as resources have already been validated + return xml.Nodes{} + } + resources = &r + } + return xml.Nodes{Count: count, Resources: resources} +} + +func promptNodeCount(r *bufio.Reader, clusterID string, nodeCount string) string { + fmt.Fprintln(stdout, color.Cyan("\n> Node count: "+clusterID+" cluster")) + fmt.Fprintf(stdout, "Documentation: %s\n", color.Green("https://cloud.vespa.ai/en/reference/services")) + fmt.Fprintf(stdout, "Example: %s\nExample: %s\n\n", color.Yellow("4"), color.Yellow("[2,8]")) + validator := func(input string) error { + _, _, err := xml.ParseNodeCount(input) + return err + } + return prompt(r, fmt.Sprintf("How many nodes should the %s cluster have?", color.Cyan(clusterID)), nodeCount, validator) +} + +func promptResources(r *bufio.Reader, clusterID string, resources string) string { + fmt.Fprintln(stdout, color.Cyan("\n> Node resources: "+clusterID+" cluster")) + fmt.Fprintf(stdout, "Documentation: %s\n", color.Green("https://cloud.vespa.ai/en/reference/services")) + fmt.Fprintf(stdout, "Example: %s\nExample: %s\n\n", color.Yellow("auto"), color.Yellow("vcpu=4,memory=8Gb,disk=100Gb")) + validator := func(input string) error { + if input == "auto" { + return nil + } + _, err := xml.ParseResources(input) + return err + } + return prompt(r, fmt.Sprintf("Which resources should each node in the %s cluster have?", color.Cyan(clusterID)), resources, validator) +} + +func readDeploymentXML(pkg vespa.ApplicationPackage) (xml.Deployment, error) { + f, err := os.Open(filepath.Join(pkg.Path, "deployment.xml")) + if errors.Is(err, os.ErrNotExist) { + // Return a default value if there is no current deployment.xml + return xml.DefaultDeployment, nil + } else if err != nil { + return xml.Deployment{}, err + } + defer f.Close() + return xml.ReadDeployment(f) +} + +func readServicesXML(pkg vespa.ApplicationPackage) (xml.Services, error) { + f, err := os.Open(filepath.Join(pkg.Path, "services.xml")) + if err != nil { + return xml.Services{}, err + } + defer f.Close() + return xml.ReadServices(f) +} + +func prompt(r *bufio.Reader, question, defaultAnswer string, validator func(input string) error) string { + var input string + for input == "" { + fmt.Fprint(stdout, question) + if defaultAnswer != "" { + fmt.Fprint(stdout, " [", color.Yellow(defaultAnswer), "]") + } + fmt.Fprint(stdout, " ") + + var err error + input, err = r.ReadString('\n') + if err != nil { + fatalErr(err) + return "" + } + input = strings.TrimSpace(input) + if input == "" { + input = defaultAnswer + } + + if err := validator(input); err != nil { + printErr(err) + fmt.Fprintln(stderr) + input = "" + } + } + return input +} diff --git a/client/go/cmd/prod_test.go b/client/go/cmd/prod_test.go new file mode 100644 index 00000000000..5abb6d24577 --- /dev/null +++ b/client/go/cmd/prod_test.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/vespa-engine/vespa/client/go/util" +) + +func TestProdInit(t *testing.T) { + homeDir := filepath.Join(t.TempDir(), ".vespa") + pkgDir := filepath.Join(t.TempDir(), "app") + createApplication(t, pkgDir) + + answers := []string{ + // Regions + "invalid input", + "aws-us-west-2a,aws-eu-west-1a", + + // Node count: qrs + "invalid input", + "4", + + // Node resources: qrs + "invalid input", + "auto", + + // Node count: music + "invalid input", + "6", + + // Node resources: music + "invalid input", + "vcpu=16,memory=64Gb,disk=100Gb", + } + var buf bytes.Buffer + buf.WriteString(strings.Join(answers, "\n") + "\n") + execute(command{stdin: &buf, homeDir: homeDir, args: []string{"prod", "init", pkgDir}}, t, nil) + + // Verify contents + deploymentPath := filepath.Join(pkgDir, "src", "main", "application", "deployment.xml") + deploymentXML := readFileString(t, deploymentPath) + assert.Contains(t, deploymentXML, `<region active="true">aws-us-west-2a</region>`) + assert.Contains(t, deploymentXML, `<region active="true">aws-eu-west-1a</region>`) + + servicesPath := filepath.Join(pkgDir, "src", "main", "application", "services.xml") + servicesXML := readFileString(t, servicesPath) + containerFragment := `<container id="qrs" version="1.0"> + <document-api></document-api> + <search></search> + <nodes count="4"></nodes> + </container>` + assert.Contains(t, servicesXML, containerFragment) + contentFragment := `<content id="music" version="1.0"> + <redundancy>2</redundancy> + <documents> + <document type="music" mode="index"></document> + </documents> + <nodes count="6"> + <resources vcpu="16" memory="64Gb" disk="100Gb"></resources> + </nodes> + </content>` + assert.Contains(t, servicesXML, contentFragment) + + // Backups are created + assert.True(t, util.PathExists(deploymentPath+".1.bak")) + assert.True(t, util.PathExists(servicesPath+".1.bak")) +} + +func readFileString(t *testing.T, filename string) string { + content, err := ioutil.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +func createApplication(t *testing.T, pkgDir string) { + appDir := filepath.Join(pkgDir, "src", "main", "application") + if err := os.MkdirAll(appDir, 0755); err != nil { + t.Fatal(err) + } + + deploymentXML := `<deployment version="1.0"> + <prod> + <region active="true">aws-us-east-1c</region> + </prod> +</deployment>` + if err := ioutil.WriteFile(filepath.Join(appDir, "deployment.xml"), []byte(deploymentXML), 0644); err != nil { + t.Fatal(err) + } + + servicesXML := `<services version="1.0" xmlns:deploy="vespa" xmlns:preprocess="properties"> + <container id="qrs" version="1.0"> + <document-api/> + <search/> + <nodes count="2"> + <resources vcpu="4" memory="8Gb" disk="100Gb"/> + </nodes> + </container> + <content id="music" version="1.0"> + <redundancy>2</redundancy> + <documents> + <document type="music" mode="index"></document> + </documents> + <nodes count="4"></nodes> + </content> +</services>` + + if err := ioutil.WriteFile(filepath.Join(appDir, "services.xml"), []byte(servicesXML), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/client/go/cmd/root.go b/client/go/cmd/root.go index ab6c19a665b..5aae55ab6e4 100644 --- a/client/go/cmd/root.go +++ b/client/go/cmd/root.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" + "io" "io/ioutil" "log" "os" @@ -37,6 +38,7 @@ Vespa documentation: https://docs.vespa.ai`, waitSecsArg int colorArg string quietArg bool + stdin io.ReadWriter = os.Stdin color = aurora.NewAurora(false) stdout = colorable.NewColorableStdout() diff --git a/client/go/vespa/xml/config.go b/client/go/vespa/xml/config.go index c9b65822e7f..a30db238afb 100644 --- a/client/go/vespa/xml/config.go +++ b/client/go/vespa/xml/config.go @@ -194,7 +194,10 @@ func ParseNodeCount(s string) (int, int, error) { } // ValidProdRegion returns whether string s is a valid production region. -func ValidProdRegion(s string) bool { +func ValidProdRegion(s string, system string) bool { + if system == "publiccd" { + return s == "aws-us-east-1c" + } switch s { case "aws-us-east-1c", "aws-us-west-2a", "aws-eu-west-1a", "aws-ap-northeast-1a": |