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/go/cmd/prod.go | |
parent | 88fcb5fcb1cb5f040653f67d4e0b35abab089166 (diff) |
Implement vespa prod init
Diffstat (limited to 'client/go/cmd/prod.go')
-rw-r--r-- | client/go/cmd/prod.go | 277 |
1 files changed, 277 insertions, 0 deletions
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 +} |