Skip to content

Commit 429f5f5

Browse files
authored
feat: add service exec and resolve IDs from service (#173)
## Summary - Add `zeabur service exec` command to run commands inside service containers via the `executeCommand` GraphQL mutation - Remove context-based ID resolution from all commands — when a service ID is provided, environment and project IDs are now derived from the service itself instead of relying on project context - Add `ResolveEnvironmentIDByServiceID` helper to `internal/util/env.go` ## Motivation The old design resolved environment IDs from the project context, which could point to a completely different project than the service belongs to, causing `NOT_RUNNING_SERVICE` errors even when the service is active. Now: service ID → fetch service → get project ID → resolve environment. No more wrong env IDs. ## Changed commands (32 files) - **New**: `service exec` — execute commands in service containers - **Service**: restart, redeploy, suspend, delete, expose, get, metric, instruction, network, update/tag, list, deploy - **Domain**: create, list, delete - **Variable**: create, list, update, delete, env - **Deployment**: get, list, log - **Other**: deploy, upload ## Test plan - [ ] `go build ./...` and `go test ./...` pass - [ ] `zeabur service exec --id <svc> -- ls` resolves env from service's project - [ ] `zeabur service restart --id <svc>` no longer requires project context - [ ] Interactive mode still prompts correctly when no flags provided 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added `service exec` command to execute commands inside service containers with results and exit codes. * Added `--region` flag to template deployment for automatic project creation in specified regions. * **Refactor** * Simplified command parameter resolution and validation for improved reliability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents a6adc3f + 55aea0c commit 429f5f5

32 files changed

Lines changed: 552 additions & 562 deletions

File tree

internal/cmd/deploy/deploy.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ func NewCmdDeploy(f *cmdutil.Factory) *cobra.Command {
3333
cmd := &cobra.Command{
3434
Use: "deploy",
3535
Short: "Deploy local project to Zeabur with one command",
36-
PreRunE: util.NeedProjectContextWhenNonInteractive(f),
3736
RunE: func(cmd *cobra.Command, args []string) error {
3837
return runDeploy(f, opts)
3938
},

internal/cmd/deployment/get/get.go

Lines changed: 29 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,17 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
2424
opts := &Options{}
2525

2626
cmd := &cobra.Command{
27-
Use: "get",
28-
Short: "Get deployment, if deployment-id is not specified, use serviceID/serviceName and environmentID to get the deployment",
29-
PreRunE: util.NeedProjectContextWhenNonInteractive(f),
27+
Use: "get",
28+
Short: "Get deployment, if deployment-id is not specified, use serviceID/serviceName and environmentID to get the deployment",
3029
RunE: func(cmd *cobra.Command, args []string) error {
3130
return runGet(f, opts)
3231
},
3332
}
3433

35-
zctx := f.Config.GetContext()
36-
3734
cmd.Flags().StringVar(&opts.deploymentID, "deployment-id", "", "Deployment ID")
38-
cmd.Flags().StringVar(&opts.serviceID, "service-id", zctx.GetService().GetID(), "Service ID")
39-
cmd.Flags().StringVar(&opts.serviceName, "service-name", zctx.GetService().GetName(), "Service Name")
40-
cmd.Flags().StringVar(&opts.environmentID, "env-id", zctx.GetEnvironment().GetID(), "Environment ID")
35+
cmd.Flags().StringVar(&opts.serviceID, "service-id", "", "Service ID")
36+
cmd.Flags().StringVar(&opts.serviceName, "service-name", "", "Service Name")
37+
cmd.Flags().StringVar(&opts.environmentID, "env-id", "", "Environment ID")
4138

4239
return cmd
4340
}
@@ -69,39 +66,39 @@ func runGetInteractive(f *cmdutil.Factory, opts *Options) error {
6966
}
7067

7168
func runGetNonInteractive(f *cmdutil.Factory, opts *Options) (err error) {
72-
if opts.deploymentID == "" && opts.environmentID == "" {
73-
projectID := f.Config.GetContext().GetProject().GetID()
74-
envID, resolveErr := util.ResolveEnvironmentID(f.ApiClient, projectID)
75-
if resolveErr != nil {
76-
return resolveErr
69+
// If deployment ID is provided, just use it directly
70+
if opts.deploymentID != "" {
71+
deployment, err := getDeploymentByID(f, opts.deploymentID)
72+
if err != nil {
73+
return err
7774
}
78-
opts.environmentID = envID
75+
f.Printer.Table(deployment.Header(), deployment.Rows())
76+
return nil
7977
}
8078

81-
if err = paramCheck(opts); err != nil {
82-
return err
79+
// Resolve service ID from name
80+
if opts.serviceID == "" && opts.serviceName != "" {
81+
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.serviceName)
82+
if err != nil {
83+
return fmt.Errorf("failed to get service: %w", err)
84+
}
85+
opts.serviceID = service.ID
8386
}
8487

85-
var deployment *model.Deployment
88+
if opts.serviceID == "" {
89+
return errors.New("--deployment-id or --service-id/--service-name is required")
90+
}
8691

87-
// If deployment id is provided, get deployment by deployment id
88-
if opts.deploymentID != "" {
89-
deployment, err = getDeploymentByID(f, opts.deploymentID)
90-
} else {
91-
// or, get deployment by service id and environment id
92-
93-
// If service id is not provided, get service id by service name
94-
if opts.serviceID == "" {
95-
var service *model.Service
96-
if service, err = util.GetServiceByName(f.Config, f.ApiClient, opts.serviceName); err != nil {
97-
return fmt.Errorf("failed to get service: %w", err)
98-
} else {
99-
opts.serviceID = service.ID
100-
}
92+
// Resolve environment from service's project
93+
if opts.environmentID == "" {
94+
envID, err := util.ResolveEnvironmentIDByServiceID(f.ApiClient, opts.serviceID)
95+
if err != nil {
96+
return err
10197
}
102-
deployment, err = getDeploymentByServiceAndEnvironment(f, opts.serviceID, opts.environmentID)
98+
opts.environmentID = envID
10399
}
104100

101+
deployment, err := getDeploymentByServiceAndEnvironment(f, opts.serviceID, opts.environmentID)
105102
if err != nil {
106103
return err
107104
}
@@ -132,19 +129,3 @@ func getDeploymentByServiceAndEnvironment(f *cmdutil.Factory, serviceID, environ
132129

133130
return deployment, nil
134131
}
135-
136-
func paramCheck(opts *Options) error {
137-
if opts.deploymentID != "" {
138-
return nil
139-
}
140-
141-
if opts.serviceID == "" && opts.serviceName == "" {
142-
return errors.New("when deployment-id is not specified, service-id or service-name is required")
143-
}
144-
145-
if opts.environmentID == "" {
146-
return errors.New("when deployment-id is not specified, env-id is required")
147-
}
148-
149-
return nil
150-
}

internal/cmd/deployment/list/list.go

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
)
1313

1414
type Options struct {
15-
// todo: support service name
1615
serviceID string
1716
serviceName string
1817
environmentID string
@@ -25,17 +24,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
2524
Use: "list",
2625
Short: "List deployments",
2726
Aliases: []string{"ls"},
28-
PreRunE: util.NeedProjectContextWhenNonInteractive(f),
2927
RunE: func(cmd *cobra.Command, args []string) error {
3028
return runList(f, opts)
3129
},
3230
}
3331

34-
zctx := f.Config.GetContext()
35-
36-
cmd.Flags().StringVar(&opts.serviceID, "service-id", zctx.GetService().GetID(), "Service ID")
37-
cmd.Flags().StringVar(&opts.serviceName, "service-name", zctx.GetService().GetName(), "Service Name")
38-
cmd.Flags().StringVar(&opts.environmentID, "env-id", zctx.GetEnvironment().GetID(), "Environment ID")
32+
cmd.Flags().StringVar(&opts.serviceID, "service-id", "", "Service ID")
33+
cmd.Flags().StringVar(&opts.serviceName, "service-name", "", "Service Name")
34+
cmd.Flags().StringVar(&opts.environmentID, "env-id", "", "Environment ID")
3935

4036
return cmd
4137
}
@@ -65,26 +61,26 @@ func runListInteractive(f *cmdutil.Factory, opts *Options) error {
6561
}
6662

6763
func runListNonInteractive(f *cmdutil.Factory, opts *Options) error {
68-
if opts.environmentID == "" {
69-
projectID := f.Config.GetContext().GetProject().GetID()
70-
envID, err := util.ResolveEnvironmentID(f.ApiClient, projectID)
64+
// Resolve service ID from name
65+
if opts.serviceID == "" && opts.serviceName != "" {
66+
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.serviceName)
7167
if err != nil {
72-
return err
68+
return fmt.Errorf("failed to get service: %w", err)
7369
}
74-
opts.environmentID = envID
70+
opts.serviceID = service.ID
7571
}
7672

77-
if err := paramCheck(opts); err != nil {
78-
return err
73+
if opts.serviceID == "" {
74+
return errors.New("--service-id or --service-name is required")
7975
}
8076

81-
// If service id is not provided, get service id by service name
82-
if opts.serviceID == "" {
83-
if service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.serviceName); err != nil {
84-
return fmt.Errorf("failed to get service: %w", err)
85-
} else {
86-
opts.serviceID = service.ID
77+
// Resolve environment from service's project
78+
if opts.environmentID == "" {
79+
envID, err := util.ResolveEnvironmentIDByServiceID(f.ApiClient, opts.serviceID)
80+
if err != nil {
81+
return err
8782
}
83+
opts.environmentID = envID
8884
}
8985

9086
deployments, err := f.ApiClient.ListAllDeployments(context.Background(), opts.serviceID, opts.environmentID)
@@ -101,15 +97,3 @@ func runListNonInteractive(f *cmdutil.Factory, opts *Options) error {
10197

10298
return nil
10399
}
104-
105-
func paramCheck(opts *Options) error {
106-
if opts.serviceID == "" && opts.serviceName == "" {
107-
return errors.New("service-id or service-name is required")
108-
}
109-
110-
if opts.environmentID == "" {
111-
return errors.New("environment is required")
112-
}
113-
114-
return nil
115-
}

internal/cmd/deployment/log/log.go

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,18 @@ func NewCmdLog(f *cmdutil.Factory) *cobra.Command {
3333
opts := &Options{}
3434

3535
cmd := &cobra.Command{
36-
Use: "log",
37-
Short: "Get deployment logs, if deployment-id is not specified, use serviceID/serviceName and environmentID to get the deployment",
38-
PreRunE: util.NeedProjectContextWhenNonInteractive(f),
36+
Use: "log",
37+
Short: "Get deployment logs, if deployment-id is not specified, use serviceID/serviceName and environmentID to get the deployment",
3938
RunE: func(cmd *cobra.Command, args []string) error {
4039
return runLog(f, opts)
4140
},
4241
}
4342

44-
zctx := f.Config.GetContext()
45-
46-
cmd.Flags().StringVar(&opts.projectID, "project-id", zctx.GetProject().GetID(), "Project ID")
43+
cmd.Flags().StringVar(&opts.projectID, "project-id", "", "Project ID")
4744
cmd.Flags().StringVar(&opts.deploymentID, "deployment-id", "", "Deployment ID")
48-
cmd.Flags().StringVar(&opts.serviceID, "service-id", zctx.GetService().GetID(), "Service ID")
49-
cmd.Flags().StringVar(&opts.serviceName, "service-name", zctx.GetService().GetName(), "Service Name")
50-
cmd.Flags().StringVar(&opts.environmentID, "env-id", zctx.GetEnvironment().GetID(), "Environment ID")
45+
cmd.Flags().StringVar(&opts.serviceID, "service-id", "", "Service ID")
46+
cmd.Flags().StringVar(&opts.serviceName, "service-name", "", "Service Name")
47+
cmd.Flags().StringVar(&opts.environmentID, "env-id", "", "Environment ID")
5148
cmd.Flags().StringVarP(&opts.logType, "type", "t", logTypeRuntime, "Log type, runtime or build")
5249
cmd.Flags().BoolVarP(&opts.watch, "watch", "w", false, "Watch logs")
5350

@@ -63,13 +60,8 @@ func runLog(f *cmdutil.Factory, opts *Options) error {
6360
}
6461

6562
func runLogInteractive(f *cmdutil.Factory, opts *Options) error {
66-
zctx := f.Config.GetContext()
67-
68-
if opts.projectID == "" {
69-
opts.projectID = zctx.GetProject().GetID()
70-
}
71-
7263
if opts.deploymentID == "" {
64+
zctx := f.Config.GetContext()
7365
_, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
7466
ProjectCtx: zctx,
7567
ServiceID: &opts.serviceID,
@@ -95,8 +87,7 @@ func runLogNonInteractive(f *cmdutil.Factory, opts *Options) (err error) {
9587
opts.serviceID = service.ID
9688
}
9789

98-
// When serviceID is available, always resolve projectID and environmentID from the service
99-
// instead of relying on context (which may point to a different project).
90+
// When serviceID is available, resolve projectID and environmentID from the service
10091
if opts.serviceID != "" {
10192
service, err := f.ApiClient.GetService(context.Background(), opts.serviceID, "", "", "")
10293
if err != nil {
@@ -105,24 +96,13 @@ func runLogNonInteractive(f *cmdutil.Factory, opts *Options) (err error) {
10596
if service.Project != nil {
10697
opts.projectID = service.Project.ID
10798
}
108-
envID, resolveErr := util.ResolveEnvironmentID(f.ApiClient, opts.projectID)
109-
if resolveErr != nil {
110-
return resolveErr
111-
}
112-
opts.environmentID = envID
113-
}
114-
115-
// Fallback: resolve environmentID from context project if still empty
116-
if opts.deploymentID == "" && opts.environmentID == "" {
117-
projectID := opts.projectID
118-
if projectID == "" {
119-
projectID = f.Config.GetContext().GetProject().GetID()
120-
}
121-
envID, resolveErr := util.ResolveEnvironmentID(f.ApiClient, projectID)
122-
if resolveErr != nil {
123-
return resolveErr
99+
if opts.environmentID == "" {
100+
envID, resolveErr := util.ResolveEnvironmentID(f.ApiClient, opts.projectID)
101+
if resolveErr != nil {
102+
return resolveErr
103+
}
104+
opts.environmentID = envID
124105
}
125-
opts.environmentID = envID
126106
}
127107

128108
if err = paramCheck(opts); err != nil {

internal/cmd/domain/create/create.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,11 @@ type Options struct {
2323

2424
func NewCmdCreateDomain(f *cmdutil.Factory) *cobra.Command {
2525
opts := &Options{}
26-
zctx := f.Config.GetContext()
2726

2827
cmd := &cobra.Command{
2928
Use: "create",
3029
Short: "create a domain",
3130
Long: `Create a domain for a service`,
32-
PreRunE: util.RunEChain(
33-
util.NeedProjectContextWhenNonInteractive(f),
34-
util.DefaultIDNameByContext(zctx.GetService(), &opts.id, &opts.name),
35-
util.DefaultIDByContext(zctx.GetEnvironment(), &opts.environmentID),
36-
),
3731
RunE: func(cmd *cobra.Command, args []string) error {
3832
return runCreateDomain(f, opts)
3933
},
@@ -93,7 +87,17 @@ func runCreateDomainInteractive(f *cmdutil.Factory, opts *Options) error {
9387
opts.domainName = domainInput
9488
}
9589

96-
project, err := f.ApiClient.GetProject(context.Background(), zctx.GetProject().GetID(), "", "")
90+
// Get project from the service to check domain availability
91+
service, err := f.ApiClient.GetService(context.Background(), opts.id, "", "", "")
92+
if err != nil {
93+
return fmt.Errorf("get service failed: %w", err)
94+
}
95+
projectID := ""
96+
if service.Project != nil {
97+
projectID = service.Project.ID
98+
}
99+
100+
project, err := f.ApiClient.GetProject(context.Background(), projectID, "", "")
97101
if err != nil {
98102
return err
99103
}
@@ -146,16 +150,24 @@ func runCreateDomainInteractive(f *cmdutil.Factory, opts *Options) error {
146150
}
147151

148152
func runCreateDomainNonInteractive(f *cmdutil.Factory, opts *Options) error {
149-
zctx := f.Config.GetContext()
153+
if opts.id == "" && opts.name != "" {
154+
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
155+
if err != nil {
156+
return err
157+
}
158+
opts.id = service.ID
159+
}
150160

151-
if _, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
152-
ProjectCtx: zctx,
153-
ServiceID: &opts.id,
154-
ServiceName: &opts.name,
155-
EnvironmentID: &opts.environmentID,
156-
CreateNew: false,
157-
}); err != nil {
158-
return err
161+
if opts.id == "" {
162+
return fmt.Errorf("--id or --name is required")
163+
}
164+
165+
if opts.environmentID == "" {
166+
envID, err := util.ResolveEnvironmentIDByServiceID(f.ApiClient, opts.id)
167+
if err != nil {
168+
return err
169+
}
170+
opts.environmentID = envID
159171
}
160172

161173
s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,

0 commit comments

Comments
 (0)