diff --git a/args.go b/args.go index 3b5e45c21..4b9947bf4 100644 --- a/args.go +++ b/args.go @@ -34,6 +34,8 @@ const ( ArgActionStatus = "status" // ArgActionType is an action type argument. ArgActionType = "action-type" + // ArgApp is the app ID. + ArgApp = "app" // ArgAppSpec is a path to an app spec. ArgAppSpec = "spec" // ArgAppLogType the type of log. @@ -140,6 +142,8 @@ const ( ArgRecordTag = "record-tag" // ArgRegionSlug is a region slug argument. ArgRegionSlug = "region" + // ArgSchemaOnly is a schema only argument. + ArgSchemaOnly = "schema-only" // ArgSizeSlug is a size slug argument. ArgSizeSlug = "size" // ArgsSSHKeyPath is a ssh argument. diff --git a/commands/apps.go b/commands/apps.go index cf1ad86bc..2c24381c6 100644 --- a/commands/apps.go +++ b/commands/apps.go @@ -40,7 +40,7 @@ func Apps() *Command { Use: "apps", Aliases: []string{"app", "a"}, Short: "Display commands for working with apps", - Long: "The subcommands of `doctl app` manage your App Platform apps.", + Long: "The subcommands of `doctl app` manage your App Platform apps. For documentation on app specs used by multiple commands, see https://www.digitalocean.com/docs/app-platform/concepts/app-spec.", }, } @@ -54,7 +54,7 @@ func Apps() *Command { aliasOpt("c"), displayerType(&displayers.Apps{}), ) - AddStringFlag(create, doctl.ArgAppSpec, "", "", "Path to an app spec in JSON or YAML format. For more information about app specs, see https://www.digitalocean.com/docs/app-platform/concepts/app-spec", requiredOpt()) + AddStringFlag(create, doctl.ArgAppSpec, "", "", `Path to an app spec in JSON or YAML format. Set to "-" to read from stdin.`, requiredOpt()) CmdBuilder( cmd, @@ -92,7 +92,7 @@ Only basic information is included with the text output format. For complete app aliasOpt("u"), displayerType(&displayers.Apps{}), ) - AddStringFlag(update, doctl.ArgAppSpec, "", "", "Path to an app spec in JSON or YAML format.", requiredOpt()) + AddStringFlag(update, doctl.ArgAppSpec, "", "", `Path to an app spec in JSON or YAML format. Set to "-" to read from stdin.`, requiredOpt()) deleteApp := CmdBuilder( cmd, @@ -177,6 +177,21 @@ Three types of logs are supported and can be configured with --`+doctl.ArgAppLog displayerType(&displayers.AppRegions{}), ) + propose := CmdBuilder( + cmd, + RunAppsPropose, + "propose", + "Propose an app spec", + `Reviews and validates an app specification for a new or existing app. The request returns some information about the proposed app, including app cost and upgrade cost. If an existing app ID is specified, the app spec is treated as a proposed update to the existing app. + +Only basic information is included with the text output format. For complete app details including an updated app spec, use the JSON format.`, + Writer, + aliasOpt("c"), + displayerType(&displayers.Apps{}), + ) + AddStringFlag(propose, doctl.ArgAppSpec, "", "", "Path to an app spec in JSON or YAML format. For more information about app specs, see https://www.digitalocean.com/docs/app-platform/concepts/app-spec", requiredOpt()) + AddStringFlag(propose, doctl.ArgApp, "", "", "An optional existing app ID. If specified, the app spec will be treated as a proposed update to the existing app.") + cmd.AddCommand(appsSpec()) cmd.AddCommand(appsTier()) @@ -190,21 +205,7 @@ func RunAppsCreate(c *CmdConfig) error { return err } - specFile, err := os.Open(specPath) // guardrails-disable-line - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("Failed to open app spec: %s does not exist", specPath) - } - return fmt.Errorf("Failed to open app spec: %w", err) - } - defer specFile.Close() - - specBytes, err := ioutil.ReadAll(specFile) - if err != nil { - return fmt.Errorf("Failed to read app spec: %w", err) - } - - appSpec, err := parseAppSpec(specBytes) + appSpec, err := readAppSpec(os.Stdin, specPath) if err != nil { return err } @@ -255,21 +256,7 @@ func RunAppsUpdate(c *CmdConfig) error { return err } - specFile, err := os.Open(specPath) // guardrails-disable-line - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("Failed to open app spec: %s does not exist", specPath) - } - return fmt.Errorf("Failed to open app spec: %w", err) - } - defer specFile.Close() - - specBytes, err := ioutil.ReadAll(specFile) - if err != nil { - return fmt.Errorf("Failed to read app spec: %w", err) - } - - appSpec, err := parseAppSpec(specBytes) + appSpec, err := readAppSpec(os.Stdin, specPath) if err != nil { return err } @@ -531,6 +518,65 @@ func RunAppsGetLogs(c *CmdConfig) error { return nil } +// RunAppsPropose proposes an app spec +func RunAppsPropose(c *CmdConfig) error { + appID, err := c.Doit.GetString(c.NS, doctl.ArgApp) + if err != nil { + return err + } + + specPath, err := c.Doit.GetString(c.NS, doctl.ArgAppSpec) + if err != nil { + return err + } + + appSpec, err := readAppSpec(os.Stdin, specPath) + if err != nil { + return err + } + + res, err := c.Apps().Propose(&godo.AppProposeRequest{ + Spec: appSpec, + AppID: appID, + }) + + if err != nil { + // most likely an invalid app spec. The error message would start with "error validating app spec" + return err + } + + return c.Display(displayers.AppProposeResponse{Res: res}) +} + +func readAppSpec(stdin io.Reader, path string) (*godo.AppSpec, error) { + var spec io.Reader + if path == "-" { + spec = stdin + } else { + specFile, err := os.Open(path) // guardrails-disable-line + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("opening app spec: %s does not exist", path) + } + return nil, fmt.Errorf("opening app spec: %w", err) + } + defer specFile.Close() + spec = specFile + } + + byt, err := ioutil.ReadAll(spec) + if err != nil { + return nil, fmt.Errorf("reading app spec: %w", err) + } + + s, err := parseAppSpec(byt) + if err != nil { + return nil, fmt.Errorf("parsing app spec: %w", err) + } + + return s, nil +} + func parseAppSpec(spec []byte) (*godo.AppSpec, error) { jsonSpec, err := yaml.YAMLToJSON(spec) if err != nil { @@ -542,7 +588,7 @@ func parseAppSpec(spec []byte) (*godo.AppSpec, error) { var appSpec godo.AppSpec if err := dec.Decode(&appSpec); err != nil { - return nil, fmt.Errorf("Failed to parse app spec: %v", err) + return nil, err } return &appSpec, nil @@ -561,11 +607,12 @@ func appsSpec() *Command { Optionally, pass a deployment ID to get the spec of that specific deployment.`, Writer) AddStringFlag(getCmd, doctl.ArgAppDeployment, "", "", "optional: a deployment ID") - AddStringFlag(getCmd, doctl.ArgFormat, "", "yaml", `the format to output the spec as; either "yaml" or "json"`) + AddStringFlag(getCmd, doctl.ArgFormat, "", "yaml", `the format to output the spec in; either "yaml" or "json"`) - CmdBuilder(cmd, RunAppsSpecValidate(os.Stdin), "validate ", "Validate an application spec", `Use this command to check whether a given app spec (YAML or JSON) is valid. + validateCmd := CmdBuilder(cmd, RunAppsSpecValidate, "validate ", "Validate an application spec", `Use this command to check whether a given app spec (YAML or JSON) is valid. You may pass - as the filename to read from stdin.`, Writer) + AddBoolFlag(validateCmd, doctl.ArgSchemaOnly, "", false, "Only validate the spec schema and not the correctness of the spec.") return cmd } @@ -620,41 +667,45 @@ func RunAppsSpecGet(c *CmdConfig) error { } // RunAppsSpecValidate validates an app spec file -func RunAppsSpecValidate(stdin io.Reader) func(c *CmdConfig) error { - return func(c *CmdConfig) error { - if len(c.Args) < 1 { - return doctl.NewMissingArgsErr(c.NS) - } +func RunAppsSpecValidate(c *CmdConfig) error { + if len(c.Args) < 1 { + return doctl.NewMissingArgsErr(c.NS) + } - specPath := c.Args[0] - var spec io.Reader - if specPath == "-" { - spec = stdin - } else { - specFile, err := os.Open(specPath) // guardrails-disable-line - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("Failed to open app spec: %s does not exist", specPath) - } - return fmt.Errorf("Failed to open app spec: %w", err) - } - defer specFile.Close() - spec = specFile - } + specPath := c.Args[0] + appSpec, err := readAppSpec(os.Stdin, specPath) + if err != nil { + return err + } - specBytes, err := ioutil.ReadAll(spec) - if err != nil { - return fmt.Errorf("Failed to read app spec: %w", err) - } + schemaOnly, err := c.Doit.GetBool(c.NS, doctl.ArgSchemaOnly) + if err != nil { + return err + } - _, err = parseAppSpec(specBytes) + if schemaOnly { + ymlSpec, err := yaml.Marshal(appSpec) if err != nil { - return err + return fmt.Errorf("marshaling the spec as yaml: %v", err) } + _, err = c.Out.Write(ymlSpec) + return err + } - c.Out.Write([]byte("The spec is valid.\n")) - return nil + res, err := c.Apps().Propose(&godo.AppProposeRequest{ + Spec: appSpec, + }) + if err != nil { + // most likely an invalid app spec. The error message would start with "error validating app spec" + return err + } + + ymlSpec, err := yaml.Marshal(res.Spec) + if err != nil { + return fmt.Errorf("marshaling the spec as yaml: %v", err) } + _, err = c.Out.Write(ymlSpec) + return err } // RunAppsListRegions lists all app platform regions. diff --git a/commands/apps_test.go b/commands/apps_test.go index b5564aa4c..259b08d38 100644 --- a/commands/apps_test.go +++ b/commands/apps_test.go @@ -32,6 +32,7 @@ func TestAppsCommand(t *testing.T) { "list-deployments", "list-regions", "logs", + "propose", "spec", "tier", ) @@ -426,18 +427,17 @@ const ( } ] }` - validYAMLSpec = ` -name: test + validYAMLSpec = `name: test services: -- name: web - github: - repo: digitalocean/sample-golang +- github: branch: main + repo: digitalocean/sample-golang + name: web static_sites: -- name: static - git: - repo_clone_url: git@github.com:digitalocean/sample-gatsby.git +- git: branch: main + repo_clone_url: git@github.com:digitalocean/sample-gatsby.git + name: static routes: - path: /static ` @@ -459,31 +459,33 @@ static_sites: ` ) -func Test_parseAppSpec(t *testing.T) { - expectedSpec := &godo.AppSpec{ - Name: "test", - Services: []*godo.AppServiceSpec{ - { - Name: "web", - GitHub: &godo.GitHubSourceSpec{ - Repo: "digitalocean/sample-golang", - Branch: "main", - }, +var validAppSpec = &godo.AppSpec{ + Name: "test", + Services: []*godo.AppServiceSpec{ + { + Name: "web", + GitHub: &godo.GitHubSourceSpec{ + Repo: "digitalocean/sample-golang", + Branch: "main", }, }, - StaticSites: []*godo.AppStaticSiteSpec{ - { - Name: "static", - Git: &godo.GitSourceSpec{ - RepoCloneURL: "git@github.com:digitalocean/sample-gatsby.git", - Branch: "main", - }, - Routes: []*godo.AppRouteSpec{ - {Path: "/static"}, - }, + }, + StaticSites: []*godo.AppStaticSiteSpec{ + { + Name: "static", + Git: &godo.GitSourceSpec{ + RepoCloneURL: "git@github.com:digitalocean/sample-gatsby.git", + Branch: "main", + }, + Routes: []*godo.AppRouteSpec{ + {Path: "/static"}, }, }, - } + }, +} + +func Test_parseAppSpec(t *testing.T) { + expectedSpec := validAppSpec t.Run("json", func(t *testing.T) { spec, err := parseAppSpec([]byte(validJSONSpec)) @@ -505,62 +507,118 @@ func Test_parseAppSpec(t *testing.T) { }) } -func TestRunAppSpecValidate(t *testing.T) { - validYAMLSpec := `` - +func Test_readAppSpec(t *testing.T) { tcs := []struct { - name string - testFn testFn + name string + setup func(t *testing.T) (path string, stdin io.Reader) + + wantSpec *godo.AppSpec + wantErr error }{ { - name: "stdin yaml", - testFn: func(config *CmdConfig, tm *tcMocks) { - config.Args = append(config.Args, "-") - - err := RunAppsSpecValidate(bytes.NewBufferString(validYAMLSpec))(config) - require.NoError(t, err) + name: "stdin", + setup: func(t *testing.T) (string, io.Reader) { + return "-", bytes.NewBufferString(validYAMLSpec) }, + wantSpec: validAppSpec, }, { - name: "stdin json", - testFn: func(config *CmdConfig, tm *tcMocks) { - config.Args = append(config.Args, "-") - - err := RunAppsSpecValidate(bytes.NewBufferString(validJSONSpec))(config) - require.NoError(t, err) + name: "file yaml", + setup: func(t *testing.T) (string, io.Reader) { + return testTempFile(t, []byte(validJSONSpec)), nil }, + wantSpec: validAppSpec, }, - { - name: "file yaml", - testFn: func(config *CmdConfig, tm *tcMocks) { - file, err := ioutil.TempFile("", "doctl-test") - require.NoError(t, err) - defer func() { - _ = os.Remove(file.Name()) - }() - config.Args = append(config.Args, file.Name()) + } - _, err = file.WriteString(validYAMLSpec) + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + path, stdin := tc.setup(t) + spec, err := readAppSpec(stdin, path) + if tc.wantErr != nil { + require.Equal(t, tc.wantErr, err) + } else { require.NoError(t, err) + } - err = RunAppsSpecValidate(nil)(config) - require.NoError(t, err) - }, + assert.Equal(t, tc.wantSpec, spec) + }) + } +} + +func testTempFile(t *testing.T, data []byte) string { + t.Helper() + file := t.TempDir() + "/file" + err := ioutil.WriteFile(file, data, 0644) + require.NoError(t, err, "writing temp file") + return file +} + +func TestRunAppSpecValidate(t *testing.T) { + tcs := []struct { + name string + spec string + schemaOnly bool + mock func(tm *tcMocks) + + wantError string + wantOut string + }{ + { + name: "valid yaml", + spec: validYAMLSpec, + schemaOnly: true, + wantOut: validYAMLSpec, }, { - name: "stdin invalid", - testFn: func(config *CmdConfig, tm *tcMocks) { - config.Args = append(config.Args, "-") - - err := RunAppsSpecValidate(bytes.NewBufferString("hello"))(config) - require.Error(t, err) + name: "valid json", + spec: validJSONSpec, + schemaOnly: true, + wantOut: validYAMLSpec, + }, + { + name: "valid json with ProposeApp req", + spec: validJSONSpec, + mock: func(tm *tcMocks) { + tm.apps.EXPECT().Propose(&godo.AppProposeRequest{ + Spec: validAppSpec, + }).Return(&godo.AppProposeResponse{ + Spec: &godo.AppSpec{ + Name: "validated-spec", + }, + }, nil) }, + wantOut: "name: validated-spec\n", + }, + { + name: "invalid", + spec: "hello", + schemaOnly: true, + wantError: "parsing app spec: json: cannot unmarshal string into Go value of type godo.AppSpec", }, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - withTestClient(t, tc.testFn) + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + config.Args = append(config.Args, testTempFile(t, []byte(tc.spec))) + config.Doit.Set(config.NS, doctl.ArgSchemaOnly, tc.schemaOnly) + var buf bytes.Buffer + config.Out = &buf + + if tc.mock != nil { + tc.mock(tm) + } + + err := RunAppsSpecValidate(config) + if tc.wantError != "" { + require.Equal(t, tc.wantError, err.Error()) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantOut, buf.String()) + }) }) } } diff --git a/commands/displayers/apps.go b/commands/displayers/apps.go index 93c576af6..a9854af14 100644 --- a/commands/displayers/apps.go +++ b/commands/displayers/apps.go @@ -284,3 +284,88 @@ func (is AppInstanceSizes) JSON(w io.Writer) error { e.SetIndent("", " ") return e.Encode(is) } + +type AppProposeResponse struct { + Res *godo.AppProposeResponse +} + +var _ Displayable = (*AppProposeResponse)(nil) + +func (r AppProposeResponse) Cols() []string { + cols := []string{ + "AppNameAvailable", + } + + if r.Res.AppNameSuggestion != "" { + cols = append(cols, "AppNameSuggestion") + } + + cols = append(cols, []string{ + "AppIsStatic", + "StaticApps", + "AppCost", + "AppTierUpgradeCost", + "AppTierDowngradeCost", + }...) + + return cols +} + +func (r AppProposeResponse) ColMap() map[string]string { + return map[string]string{ + "AppNameAvailable": "App Name Available?", + "AppNameSuggestion": "Suggested App Name", + "AppIsStatic": "Is Static?", + "StaticApps": "Static App Usage", + "AppCost": "$/month", + "AppTierUpgradeCost": "$/month on higher tier", + "AppTierDowngradeCost": "$/month on lower tier", + } +} + +func (r AppProposeResponse) KV() []map[string]interface{} { + existingStatic, _ := strconv.ParseInt(r.Res.ExistingStaticApps, 10, 64) + maxFreeStatic, _ := strconv.ParseInt(r.Res.MaxFreeStaticApps, 10, 64) + var paidStatic int64 + freeStatic := existingStatic + if existingStatic > maxFreeStatic { + paidStatic = existingStatic - maxFreeStatic + freeStatic = maxFreeStatic + } + + staticApps := fmt.Sprintf("%d of %d free", freeStatic, maxFreeStatic) + if paidStatic > 0 { + staticApps = fmt.Sprintf("%s, %d paid", staticApps, paidStatic) + } + + downgradeCost := "n/a" + upgradeCost := "n/a" + + if r.Res.AppTierDowngradeCost > 0 { + downgradeCost = fmt.Sprintf("%0.2f", r.Res.AppTierDowngradeCost) + } + if r.Res.AppTierUpgradeCost > 0 { + upgradeCost = fmt.Sprintf("%0.2f", r.Res.AppTierUpgradeCost) + } + + out := map[string]interface{}{ + "AppNameAvailable": boolToYesNo(r.Res.AppNameAvailable), + "AppIsStatic": boolToYesNo(r.Res.AppIsStatic), + "StaticApps": staticApps, + "AppCost": fmt.Sprintf("%0.2f", r.Res.AppCost), + "AppTierUpgradeCost": upgradeCost, + "AppTierDowngradeCost": downgradeCost, + } + + if r.Res.AppNameSuggestion != "" { + out["AppNameSuggestion"] = r.Res.AppNameSuggestion + } + + return []map[string]interface{}{out} +} + +func (r AppProposeResponse) JSON(w io.Writer) error { + e := json.NewEncoder(w) + e.SetIndent("", " ") + return e.Encode(r.Res) +} diff --git a/commands/displayers/util.go b/commands/displayers/util.go index 3dfe6d1bd..e4966300e 100644 --- a/commands/displayers/util.go +++ b/commands/displayers/util.go @@ -28,3 +28,11 @@ func bytesToHumanReadibleUnit(bytes uint64, baseUnit uint64, units []string) str } return fmt.Sprintf("%.2f %sB", float64(bytes)/float64(div), units[exp]) } + +func boolToYesNo(b bool) string { + if b { + return "yes" + } + + return "no" +} diff --git a/do/apps.go b/do/apps.go index dd70abc70..e44ceb8d4 100644 --- a/do/apps.go +++ b/do/apps.go @@ -26,6 +26,7 @@ type AppsService interface { List() ([]*godo.App, error) Update(appID string, req *godo.AppUpdateRequest) (*godo.App, error) Delete(appID string) error + Propose(req *godo.AppProposeRequest) (*godo.AppProposeResponse, error) CreateDeployment(appID string, forceRebuild bool) (*godo.Deployment, error) GetDeployment(appID, deploymentID string) (*godo.Deployment, error) @@ -115,6 +116,14 @@ func (s *appsService) Delete(appID string) error { return err } +func (s *appsService) Propose(req *godo.AppProposeRequest) (*godo.AppProposeResponse, error) { + res, _, err := s.client.Apps.Propose(s.ctx, req) + if err != nil { + return nil, err + } + return res, nil +} + func (s *appsService) CreateDeployment(appID string, forceRebuild bool) (*godo.Deployment, error) { deployment, _, err := s.client.Apps.CreateDeployment(s.ctx, appID, &godo.DeploymentCreateRequest{ ForceBuild: forceRebuild, diff --git a/do/mocks/AppsService.go b/do/mocks/AppsService.go index c3ba64305..c65298434 100644 --- a/do/mocks/AppsService.go +++ b/do/mocks/AppsService.go @@ -108,6 +108,21 @@ func (mr *MockAppsServiceMockRecorder) Delete(appID interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockAppsService)(nil).Delete), appID) } +// Propose mocks base method. +func (m *MockAppsService) Propose(req *godo.AppProposeRequest) (*godo.AppProposeResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Propose", req) + ret0, _ := ret[0].(*godo.AppProposeResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Propose indicates an expected call of Propose. +func (mr *MockAppsServiceMockRecorder) Propose(req interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Propose", reflect.TypeOf((*MockAppsService)(nil).Propose), req) +} + // CreateDeployment mocks base method. func (m *MockAppsService) CreateDeployment(appID string, forceRebuild bool) (*godo.Deployment, error) { m.ctrl.T.Helper() diff --git a/integration/apps_spec_test.go b/integration/apps_spec_test.go index 4fdc4c588..2b74fe156 100644 --- a/integration/apps_spec_test.go +++ b/integration/apps_spec_test.go @@ -13,6 +13,7 @@ import ( "github.com/digitalocean/godo" "github.com/mitchellh/copystructure" "github.com/sclevine/spec" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -127,12 +128,53 @@ var _ = suite("apps/spec/validate", func(t *testing.T, when spec.G, it spec.S) { server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Add("content-type", "application/json") - dump, err := httputil.DumpRequest(req, true) - if err != nil { - t.Fatal("failed to dump request") - } + switch req.URL.Path { + case "/v2/apps/propose": + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } - t.Fatalf("received unknown request: %s", dump) + if req.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + var r godo.AppProposeRequest + err := json.NewDecoder(req.Body).Decode(&r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + assert.Empty(t, r.AppID) + assert.Equal(t, &testAppSpec, r.Spec) + + json.NewEncoder(w).Encode(&godo.AppProposeResponse{ + Spec: &godo.AppSpec{ + Name: "test", + Services: []*godo.AppServiceSpec{ + { + Name: "service", + GitHub: &godo.GitHubSourceSpec{ + Repo: "digitalocean/doctl", + Branch: "main", + }, + Routes: []*godo.AppRouteSpec{{ + Path: "/", + }}, + }, + }, + }, + }) + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } })) }) @@ -140,7 +182,27 @@ var _ = suite("apps/spec/validate", func(t *testing.T, when spec.G, it spec.S) { cmd := exec.Command(builtBinaryPath, "-t", "some-magic-token", "-u", server.URL, - "apps", "spec", "validate", "-", + "apps", "spec", "validate", + "--schema-only", "-", + ) + byt, err := json.Marshal(testAppSpec) + expect.NoError(err) + + cmd.Stdin = bytes.NewReader(byt) + + output, err := cmd.CombinedOutput() + expect.NoError(err) + + expectedOutput := "name: test\nservices:\n- github:\n branch: main\n repo: digitalocean/doctl\n name: service" + expect.Equal(expectedOutput, strings.TrimSpace(string(output))) + }) + + it("calls proposeapp", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "apps", "spec", "validate", + "-", ) byt, err := json.Marshal(testAppSpec) expect.NoError(err) @@ -150,7 +212,7 @@ var _ = suite("apps/spec/validate", func(t *testing.T, when spec.G, it spec.S) { output, err := cmd.CombinedOutput() expect.NoError(err) - expectedOutput := "The spec is valid." + expectedOutput := "name: test\nservices:\n- github:\n branch: main\n repo: digitalocean/doctl\n name: service\n routes:\n - path: /" expect.Equal(expectedOutput, strings.TrimSpace(string(output))) }) @@ -158,7 +220,8 @@ var _ = suite("apps/spec/validate", func(t *testing.T, when spec.G, it spec.S) { cmd := exec.Command(builtBinaryPath, "-t", "some-magic-token", "-u", server.URL, - "apps", "spec", "validate", "-", + "apps", "spec", "validate", + "--schema-only", "-", ) testSpec := `name: test services: @@ -171,7 +234,7 @@ services: output, err := cmd.CombinedOutput() expect.Equal("exit status 1", err.Error()) - expectedOutput := "Error: Failed to parse app spec: json: cannot unmarshal object into Go struct field AppSpec.services of type []*godo.AppServiceSpec" + expectedOutput := "Error: parsing app spec: json: cannot unmarshal object into Go struct field AppSpec.services of type []*godo.AppServiceSpec" expect.Equal(expectedOutput, strings.TrimSpace(string(output))) }) }) diff --git a/integration/apps_test.go b/integration/apps_test.go index c91662468..80cec7e47 100644 --- a/integration/apps_test.go +++ b/integration/apps_test.go @@ -820,3 +820,137 @@ var _ = suite("apps/list-regions", func(t *testing.T, when spec.G, it spec.S) { expect.Equal(expectedOutput, strings.TrimSpace(string(output))) }) }) + +var _ = suite("apps/propose", func(t *testing.T, when spec.G, it spec.S) { + var ( + expect *require.Assertions + server *httptest.Server + ) + + testAppUUID2 := "93a37175-f520-0000-0000-26e63491dbf4" + + it.Before(func() { + expect = require.New(t) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Add("content-type", "application/json") + + switch req.URL.Path { + case "/v2/apps/propose": + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + var r godo.AppProposeRequest + err := json.NewDecoder(req.Body).Decode(&r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + assert.Equal(t, &testAppSpec, r.Spec) + + switch r.AppID { + case testAppUUID: + json.NewEncoder(w).Encode(&godo.AppProposeResponse{ + AppIsStatic: true, + AppNameAvailable: false, + AppNameSuggestion: "new-name", + AppCost: 5, + AppTierUpgradeCost: 10, + MaxFreeStaticApps: "3", + }) + case testAppUUID2: + json.NewEncoder(w).Encode(&godo.AppProposeResponse{ + AppIsStatic: true, + AppNameAvailable: true, + AppCost: 20, + AppTierDowngradeCost: 15, + ExistingStaticApps: "5", + MaxFreeStaticApps: "3", + }) + default: + t.Errorf("unexpected app uuid %s", r.AppID) + } + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } + })) + }) + + it("prints info about the proposed app", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "apps", "propose", + "--spec", "-", + "--app", testAppUUID, + ) + byt, err := json.Marshal(testAppSpec) + expect.NoError(err) + + cmd.Stdin = bytes.NewReader(byt) + + output, err := cmd.CombinedOutput() + expect.NoError(err) + + expectedOutput := `App Name Available? Suggested App Name Is Static? Static App Usage $/month $/month on higher tier $/month on lower tier +no new-name yes 0 of 3 free 5.00 10.00 n/a` + expect.Equal(expectedOutput, strings.TrimSpace(string(output))) + }) + + it("prints info about the proposed app with paid static apps", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "apps", "propose", + "--spec", "-", + "--app", testAppUUID2, + ) + byt, err := json.Marshal(testAppSpec) + expect.NoError(err) + + cmd.Stdin = bytes.NewReader(byt) + + output, err := cmd.CombinedOutput() + expect.NoError(err) + + expectedOutput := `App Name Available? Is Static? Static App Usage $/month $/month on higher tier $/month on lower tier +yes yes 3 of 3 free, 2 paid 20.00 n/a 15.00` + expect.Equal(expectedOutput, strings.TrimSpace(string(output))) + }) + + it("fails on invalid specs", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "apps", "propose", + "--spec", "-", + "--app", "wrong-id", // this shouldn't reach the HTTP server + ) + testSpec := `name: test +services: + name: service + github: + repo: digitalocean/doctl +` + cmd.Stdin = strings.NewReader(testSpec) + + output, err := cmd.CombinedOutput() + expect.Equal("exit status 1", err.Error()) + + expectedOutput := "Error: parsing app spec: json: cannot unmarshal object into Go struct field AppSpec.services of type []*godo.AppServiceSpec" + expect.Equal(expectedOutput, strings.TrimSpace(string(output))) + }) +})