Skip to content

Commit 6969a42

Browse files
[PRDGRO-2070] Services Create Non-Interactive Command (#311)
* create types for runtime, envvars, region, secretfile and servicetype to help with validation of service create params * Add a service type to validate and normalize cli input * Create a registry helper to get registry creds by name or id * Helper to get a service by it's name or id * Secret file helper to read file from disk * Use the service type to build a service create request * Populate a service from the api so we can 'clone' an existing service * Add support for string arrays, we need them for envvars * Wire it all up in a command * Alias service to services * RegistryCredential can also be set for the docker runtime * Rename to ParseOption to hopfully be a little clearer to future readers * Better seperation of concerns in clone.go extract values from the api serivce then apply them to empty input fields GitOrigin-RevId: 6e2cd0501061f0e8840c0041b1110e23bbbda642
1 parent 7f654c1 commit 6969a42

31 files changed

Lines changed: 3068 additions & 2 deletions

cmd/service.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ import (
2626
)
2727

2828
var servicesCmd = &cobra.Command{
29-
Use: "services",
30-
Short: "Manage services and datastores",
29+
Use: "services",
30+
Aliases: []string{"service"},
31+
Short: "Manage services and datastores",
3132
Long: `Manage services and datastores for the active workspace.
3233
In interactive mode you can view logs, restart, deploy, SSH, and open PSQL sessions.`,
3334
GroupID: GroupCore.ID,

cmd/servicecreate.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/render-oss/cli/pkg/client"
10+
"github.com/render-oss/cli/pkg/command"
11+
"github.com/render-oss/cli/pkg/config"
12+
"github.com/render-oss/cli/pkg/dependencies"
13+
"github.com/render-oss/cli/pkg/service"
14+
"github.com/render-oss/cli/pkg/text"
15+
"github.com/render-oss/cli/pkg/types"
16+
servicetypes "github.com/render-oss/cli/pkg/types/service"
17+
)
18+
19+
var ServiceCreateCmd = &cobra.Command{
20+
Use: "create",
21+
Args: cobra.NoArgs,
22+
Short: "Create a new service",
23+
Long: `Create a new service on Render.
24+
25+
This command currently runs in non-interactive mode only.
26+
Provide all config with flags.
27+
28+
Examples:
29+
render services create --name my-api --type web_service --repo https://github.com/org/repo --output json
30+
render services create --from srv-abc123 --name my-api-clone --output json
31+
`,
32+
}
33+
34+
func init() {
35+
servicesCmd.AddCommand(ServiceCreateCmd)
36+
37+
ServiceCreateCmd.RunE = func(cmd *cobra.Command, args []string) error {
38+
var cliInput servicetypes.Service
39+
if err := command.ParseCommand(cmd, args, &cliInput); err != nil {
40+
return fmt.Errorf("failed to parse command: %w", err)
41+
}
42+
43+
// Interactive mode is not implemented yet; force non-interactive behavior for now.
44+
command.DefaultFormatNonInteractive(cmd)
45+
46+
ctx := cmd.Context()
47+
48+
nonInteractive, err := command.NonInteractive(cmd, func() (*client.Service, error) {
49+
return createServiceNonInteractive(ctx, cliInput)
50+
}, func(svc *client.Service) string {
51+
return text.FormatStringF("Created service %s (%s)", svc.Name, svc.Id)
52+
})
53+
if err != nil {
54+
return err
55+
}
56+
if nonInteractive {
57+
return nil
58+
}
59+
60+
// TODO: Implement interactive TUI mode in a later phase
61+
// This will use views.NewServiceCreateView for guided configuration
62+
return fmt.Errorf("interactive mode not yet implemented")
63+
}
64+
65+
ServiceCreateCmd.Flags().String("name", "", "Service name")
66+
serviceTypeFlag := command.NewEnumInput(servicetypes.ServiceTypeValues(), false)
67+
ServiceCreateCmd.Flags().Var(serviceTypeFlag, "type", "Service type")
68+
ServiceCreateCmd.Flags().String("from", "", "Clone configuration from existing service (ID or name). Other flags override cloned values.")
69+
ServiceCreateCmd.Flags().String("repo", "", "Git repository URL")
70+
ServiceCreateCmd.Flags().String("branch", "", "Git branch")
71+
ServiceCreateCmd.Flags().String("image", "", "Docker image URL")
72+
regionFlag := command.NewEnumInput(types.RegionValues(), false)
73+
ServiceCreateCmd.Flags().Var(regionFlag, "region", "Deployment region")
74+
ServiceCreateCmd.Flags().String("plan", "", "Service plan")
75+
runtimeFlag := command.NewEnumInput(servicetypes.ServiceRuntimeValues(), false)
76+
ServiceCreateCmd.Flags().Var(runtimeFlag, "runtime", "Runtime environment")
77+
ServiceCreateCmd.Flags().String("root-directory", "", "Root directory")
78+
ServiceCreateCmd.Flags().String("build-command", "", "Build command")
79+
ServiceCreateCmd.Flags().String("start-command", "", "Start command")
80+
ServiceCreateCmd.Flags().String("health-check-path", "", "Health check path")
81+
ServiceCreateCmd.Flags().String("publish-directory", "", "Publish directory")
82+
ServiceCreateCmd.Flags().String("cron-command", "", "Cron command")
83+
ServiceCreateCmd.Flags().String("cron-schedule", "", "Cron schedule")
84+
ServiceCreateCmd.Flags().String("environment-id", "", "Environment ID")
85+
ServiceCreateCmd.Flags().StringArray("env-var", nil, "Environment variable in KEY=VALUE format (can be specified multiple times)")
86+
ServiceCreateCmd.Flags().StringArray("secret-file", nil, "Secret file in NAME:LOCAL_PATH format (can be specified multiple times)")
87+
ServiceCreateCmd.Flags().String("registry-credential", "", "Registry credential")
88+
ServiceCreateCmd.Flags().Bool("auto-deploy", true, "Enable auto-deploy")
89+
ServiceCreateCmd.Flags().String("pre-deploy-command", "", "Pre-deploy command")
90+
}
91+
92+
func createServiceNonInteractive(ctx context.Context, cliInput servicetypes.Service) (*client.Service, error) {
93+
deps := dependencies.GetFromContext(ctx)
94+
serviceRepo := deps.ServiceRepo()
95+
registryService := deps.RegistryService()
96+
97+
ownerID, err := config.WorkspaceID()
98+
if err != nil {
99+
return nil, err
100+
}
101+
102+
cliInput = servicetypes.NormalizeServiceCreateCLIInput(cliInput)
103+
104+
if cliInput.From != nil {
105+
if err := getConfigFromService(ctx, serviceRepo, &cliInput); err != nil {
106+
return nil, fmt.Errorf("failed to clone configuration from source service %q: %w", *cliInput.From, err)
107+
}
108+
}
109+
110+
cliInput, err = servicetypes.NormalizeAndValidateCreateInput(cliInput, false)
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
if cliInput.RegistryCredential != nil && cliInput.SupportsRegistryCredentials() {
116+
registryCredentialID, err := registryService.FindOneRegistryCredentialByIDFromNameOrID(ctx, ownerID, *cliInput.RegistryCredential)
117+
if err != nil {
118+
return nil, err
119+
}
120+
cliInput.RegistryCredential = &registryCredentialID
121+
}
122+
123+
body, err := service.BuildCreateRequest(cliInput, ownerID)
124+
if err != nil {
125+
return nil, err
126+
}
127+
return serviceRepo.CreateService(ctx, body)
128+
}
129+
130+
func getConfigFromService(ctx context.Context, repo *service.Repo, input *servicetypes.Service) error {
131+
serviceID, err := repo.ResolveServiceIDFromNameOrID(ctx, *input.From)
132+
if err != nil {
133+
return fmt.Errorf("failed to resolve source service %s: %w", *input.From, err)
134+
}
135+
input.From = &serviceID
136+
137+
sourceService, err := repo.GetService(ctx, serviceID)
138+
if err != nil {
139+
return fmt.Errorf("failed to load source service %s: %w", serviceID, err)
140+
}
141+
service.ServiceFromAPI(input, sourceService)
142+
return nil
143+
}

cmd/servicecreate_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/spf13/cobra"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestServiceCreateCmdRejectsPositionalArgs(t *testing.T) {
11+
require.Error(t, ServiceCreateCmd.Args(ServiceCreateCmd, []string{"unexpected"}))
12+
require.NoError(t, ServiceCreateCmd.Args(ServiceCreateCmd, []string{}))
13+
}
14+
15+
func TestServiceAliasResolvesToCreateCommand(t *testing.T) {
16+
plural, _, err := rootCmd.Find([]string{"services", "create"})
17+
require.NoError(t, err)
18+
require.Same(t, ServiceCreateCmd, plural)
19+
20+
alias, _, err := rootCmd.Find([]string{"service", "create"})
21+
require.NoError(t, err)
22+
require.Same(t, ServiceCreateCmd, alias)
23+
}
24+
25+
func TestServiceCreateNoArgsValidationPreventsExecution(t *testing.T) {
26+
called := false
27+
cmd := &cobra.Command{
28+
Use: "create",
29+
Args: ServiceCreateCmd.Args,
30+
RunE: func(_ *cobra.Command, _ []string) error {
31+
called = true
32+
return nil
33+
},
34+
}
35+
cmd.SetArgs([]string{"unexpected"})
36+
37+
err := cmd.Execute()
38+
require.Error(t, err)
39+
require.False(t, called)
40+
}

pkg/command/inputs.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ func getStringSliceValue(flags *pflag.FlagSet, tag string) ([]string, error) {
8383
val := cobraEnum.SelectedValues()
8484
return val, nil
8585
}
86+
87+
// StringArray flags support multiple invocations: --flag a --flag b
88+
// Unlike StringSlice which expects comma-separated: --flag=a,b
89+
if flag.Value.Type() == "stringArray" {
90+
val, err := flags.GetStringArray(tag)
91+
if err != nil {
92+
return nil, err
93+
}
94+
return val, nil
95+
}
8696
}
8797

8898
val, err := flags.GetStringSlice(tag)

pkg/command/inputs_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ func TestParseCommand(t *testing.T) {
110110
require.Equal(t, []string{"bar", "baz"}, v.Foo)
111111
})
112112

113+
t.Run("parse string array", func(t *testing.T) {
114+
type testStruct struct {
115+
Foo []string `cli:"foo"`
116+
}
117+
var v testStruct
118+
cmd := &cobra.Command{}
119+
cmd.Flags().StringArray("foo", []string{}, "")
120+
require.NoError(t, cmd.ParseFlags([]string{"--foo", "bar", "--foo", "baz"}))
121+
122+
err := command.ParseCommand(cmd, []string{}, &v)
123+
require.NoError(t, err)
124+
125+
require.Equal(t, []string{"bar", "baz"}, v.Foo)
126+
})
127+
113128
t.Run("arg parsing", func(t *testing.T) {
114129
t.Run("simple arg", func(t *testing.T) {
115130
type testStruct struct {

pkg/dependencies/dependencies.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/render-oss/cli/pkg/owner"
1414
"github.com/render-oss/cli/pkg/postgres"
1515
"github.com/render-oss/cli/pkg/project"
16+
"github.com/render-oss/cli/pkg/registry"
1617
"github.com/render-oss/cli/pkg/resource"
1718
"github.com/render-oss/cli/pkg/service"
1819
"github.com/render-oss/cli/pkg/tasks"
@@ -45,6 +46,8 @@ type cachedDependencies struct {
4546
projectRepo cache[*project.Repo]
4647
environmentRepo cache[*environment.Repo]
4748
serviceRepo cache[*service.Repo]
49+
registryRepo cache[*registry.Repo]
50+
registryService cache[*registry.Service]
4851
postgresRepo cache[*postgres.Repo]
4952
keyValueRepo cache[*keyvalue.Repo]
5053
userRepo cache[*user.Repo]
@@ -125,6 +128,12 @@ func (d *Dependencies) ServiceRepo() *service.Repo {
125128
})
126129
}
127130

131+
func (d *Dependencies) RegistryRepo() *registry.Repo {
132+
return d.cache.registryRepo.Get(func() *registry.Repo {
133+
return registry.NewRepo(d.client)
134+
})
135+
}
136+
128137
func (d *Dependencies) PostgresRepo() *postgres.Repo {
129138
return d.cache.postgresRepo.Get(func() *postgres.Repo {
130139
return postgres.NewRepo(d.client)
@@ -173,6 +182,12 @@ func (d *Dependencies) KeyValueService() *keyvalue.Service {
173182
})
174183
}
175184

185+
func (d *Dependencies) RegistryService() *registry.Service {
186+
return d.cache.registryService.Get(func() *registry.Service {
187+
return registry.NewService(d.RegistryRepo())
188+
})
189+
}
190+
176191
func (d *Dependencies) ResourceService() *resource.Service {
177192
return d.cache.resourceService.Get(func() *resource.Service {
178193
return resource.NewResourceService(

pkg/registry/repo.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package registry
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/render-oss/cli/pkg/client"
9+
)
10+
11+
type Repo struct {
12+
client *client.ClientWithResponses
13+
}
14+
15+
func NewRepo(c *client.ClientWithResponses) *Repo {
16+
return &Repo{
17+
client: c,
18+
}
19+
}
20+
21+
func (s *Repo) ListRegistryCredentials(ctx context.Context, params *client.ListRegistryCredentialsParams) (*[]client.RegistryCredential, error) {
22+
resp, err := s.client.ListRegistryCredentialsWithResponse(ctx, params)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
if err := client.ErrorFromResponse(resp); err != nil {
28+
return nil, err
29+
}
30+
31+
return resp.JSON200, nil
32+
}
33+
34+
func (s *Repo) GetRegistryCredential(ctx context.Context, id string) (*client.RegistryCredential, error) {
35+
resp, err := s.client.RetrieveRegistryCredentialWithResponse(ctx, id)
36+
if err != nil {
37+
return nil, err
38+
}
39+
if resp.StatusCode() == http.StatusNotFound {
40+
return nil, nil
41+
}
42+
43+
if err := client.ErrorFromResponse(resp); err != nil {
44+
return nil, err
45+
}
46+
47+
if resp.JSON200 == nil {
48+
return nil, fmt.Errorf("registry credential lookup failed for %q: empty response", id)
49+
}
50+
51+
return resp.JSON200, nil
52+
}

0 commit comments

Comments
 (0)