-
Notifications
You must be signed in to change notification settings - Fork 1
feat: promote apps to top-level command and update for refactored deploy API #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| package apps | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
|
|
||
| "github.com/pterm/pterm" | ||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" | ||
|
|
||
| "github.com/formancehq/fctl/v3/cmd/apps/printer" | ||
| "github.com/formancehq/fctl/v3/cmd/apps/shared" | ||
| fctl "github.com/formancehq/fctl/v3/pkg" | ||
| ) | ||
|
|
||
| type Deploy struct { | ||
| *components.Run | ||
| logs []components.Log | ||
| } | ||
|
|
||
| type DeployCtrl struct { | ||
| store *Deploy | ||
| } | ||
|
|
||
| var _ fctl.Controller[*Deploy] = (*DeployCtrl)(nil) | ||
|
|
||
| func newDeployStore() *Deploy { | ||
| return &Deploy{} | ||
| } | ||
|
|
||
| func NewDeployCtrl() *DeployCtrl { | ||
| return &DeployCtrl{ | ||
| store: newDeployStore(), | ||
| } | ||
| } | ||
|
|
||
| func NewDeploy() *cobra.Command { | ||
| return fctl.NewCommand("deploy", | ||
| fctl.WithShortDescription("Push manifest and deploy to a deployment"), | ||
| fctl.WithStringFlag("name", "", "App name (required)"), | ||
| fctl.WithStringFlag("path", "", "Path to the manifest file (required)"), | ||
| fctl.WithStringFlag("deployment", "default", "Deployment name"), | ||
| fctl.WithIntFlag("version", 0, "Manifest version to deploy (0 = latest)"), | ||
| fctl.WithBoolFlag("wait", true, "Wait for the deployment to complete"), | ||
| fctl.WithController(NewDeployCtrl()), | ||
| ) | ||
| } | ||
|
|
||
| func (c *DeployCtrl) GetStore() *Deploy { | ||
| return c.store | ||
| } | ||
|
|
||
| func (c *DeployCtrl) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { | ||
| _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| _, apiClient, err := fctl.NewAppDeployClientFromFlags( | ||
| cmd, | ||
| relyingParty, | ||
| fctl.NewPTermDialog(), | ||
| profileName, | ||
| *profile, | ||
| ) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| appName := fctl.GetString(cmd, "name") | ||
| if appName == "" { | ||
| return nil, fmt.Errorf("--name is required") | ||
| } | ||
| path := fctl.GetString(cmd, "path") | ||
| if path == "" { | ||
| return nil, fmt.Errorf("--path is required") | ||
| } | ||
| deploymentName := fctl.GetString(cmd, "deployment") | ||
|
|
||
| cmd.SilenceUsage = true | ||
|
|
||
| // 1. Look up app by name | ||
| appsRes, err := apiClient.ListApps(cmd.Context(), nil, nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var appID string | ||
| for _, app := range appsRes.ListAppsResponse.Data.Items { | ||
| if app.Name == appName { | ||
| appID = app.ID | ||
| break | ||
| } | ||
| } | ||
| if appID == "" { | ||
| return nil, fmt.Errorf("app %q not found", appName) | ||
| } | ||
|
|
||
| // 2. Read and push manifest | ||
| data, err := os.ReadFile(filepath.Clean(path)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| _, err = apiClient.PushManifest(cmd.Context(), appID, data) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to push manifest: %w", err) | ||
| } | ||
|
|
||
| // 3. Deploy to deployment | ||
| var versionPtr *int64 | ||
| if v := fctl.GetInt(cmd, "version"); v > 0 { | ||
| v64 := int64(v) | ||
| versionPtr = &v64 | ||
| } | ||
| deployRes, err := apiClient.DeployToDeployment(cmd.Context(), appID, deploymentName, versionPtr) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to deploy: %w", err) | ||
| } | ||
| c.store.Run = &deployRes.RunResponse.Data | ||
|
|
||
| // 4. Wait if requested | ||
| if fctl.GetBool(cmd, "wait") { | ||
| run, logs, err := shared.WaitRunCompletion(cmd, apiClient, c.store.Run.ID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| c.store.Run = run | ||
| c.store.logs = logs | ||
| } | ||
|
|
||
| return c, nil | ||
| } | ||
|
|
||
| func (c *DeployCtrl) Render(cmd *cobra.Command, args []string) error { | ||
| if c.store.Run.Status == "errored" { | ||
| if len(c.store.logs) > 0 { | ||
| if err := printer.RenderLogs(cmd.ErrOrStderr(), c.store.logs); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return fmt.Errorf("deployment failed: %s", c.store.Run.ID) | ||
| } | ||
|
|
||
| pterm.Success.Printfln("Deployment %s: %s", c.store.Run.Status, c.store.Run.ID) | ||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package deployments | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/pterm/pterm" | ||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" | ||
|
|
||
| fctl "github.com/formancehq/fctl/v3/pkg" | ||
| ) | ||
|
|
||
| type CreateStore struct { | ||
| components.Deployment | ||
| } | ||
|
|
||
| type CreateCtrl struct { | ||
| store *CreateStore | ||
| } | ||
|
|
||
| var _ fctl.Controller[*CreateStore] = (*CreateCtrl)(nil) | ||
|
|
||
| func newCreateStore() *CreateStore { | ||
| return &CreateStore{} | ||
| } | ||
|
|
||
| func NewCreateCtrl() *CreateCtrl { | ||
| return &CreateCtrl{ | ||
| store: newCreateStore(), | ||
| } | ||
| } | ||
|
|
||
| func NewCreate() *cobra.Command { | ||
| return fctl.NewCommand("create", | ||
| fctl.WithShortDescription("Create a deployment for an app"), | ||
| fctl.WithStringFlag("id", "", "App ID (required)"), | ||
| fctl.WithStringFlag("name", "", "Deployment name (required)"), | ||
| fctl.WithStringFlag("stack-id", "", "Stack ID (required)"), | ||
| fctl.WithController(NewCreateCtrl()), | ||
| ) | ||
| } | ||
|
|
||
| func (c *CreateCtrl) GetStore() *CreateStore { | ||
| return c.store | ||
| } | ||
|
|
||
| func (c *CreateCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { | ||
| _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| _, apiClient, err := fctl.NewAppDeployClientFromFlags( | ||
| cmd, | ||
| relyingParty, | ||
| fctl.NewPTermDialog(), | ||
| profileName, | ||
| *profile, | ||
| ) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| id := fctl.GetString(cmd, "id") | ||
| if id == "" { | ||
| return nil, fmt.Errorf("--id is required") | ||
| } | ||
| name := fctl.GetString(cmd, "name") | ||
| if name == "" { | ||
| return nil, fmt.Errorf("--name is required") | ||
| } | ||
| stackID := fctl.GetString(cmd, "stack-id") | ||
| if stackID == "" { | ||
| return nil, fmt.Errorf("--stack-id is required") | ||
| } | ||
|
|
||
| cmd.SilenceUsage = true | ||
|
|
||
| res, err := apiClient.CreateDeployment(cmd.Context(), id, components.CreateDeploymentRequest{ | ||
| Name: name, | ||
| StackID: stackID, | ||
| }) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| c.store.Deployment = res.DeploymentResponse.Data | ||
|
|
||
| return c, nil | ||
| } | ||
|
|
||
| func (c *CreateCtrl) Render(cmd *cobra.Command, args []string) error { | ||
| pterm.Success.Printfln("Deployment created successfully") | ||
| if err := pterm. | ||
| DefaultTable. | ||
| WithHasHeader(). | ||
| WithData([][]string{ | ||
| {"Name", "App ID", "Stack ID", "Workspace ID"}, | ||
| {c.store.Deployment.Name, c.store.Deployment.AppID, c.store.Deployment.StackID, c.store.Deployment.WorkspaceID}, | ||
| }). | ||
| WithWriter(cmd.OutOrStdout()). | ||
| Render(); err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,86 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| package deployments | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| "github.com/pterm/pterm" | ||||||||||||||||||||||||||||||||||||||||||||||
| "github.com/spf13/cobra" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| fctl "github.com/formancehq/fctl/v3/pkg" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| type DeleteStore struct { | ||||||||||||||||||||||||||||||||||||||||||||||
| Name string | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| type DeleteCtrl struct { | ||||||||||||||||||||||||||||||||||||||||||||||
| store *DeleteStore | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| var _ fctl.Controller[*DeleteStore] = (*DeleteCtrl)(nil) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func newDeleteStore() *DeleteStore { | ||||||||||||||||||||||||||||||||||||||||||||||
| return &DeleteStore{} | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func NewDeleteCtrl() *DeleteCtrl { | ||||||||||||||||||||||||||||||||||||||||||||||
| return &DeleteCtrl{ | ||||||||||||||||||||||||||||||||||||||||||||||
| store: newDeleteStore(), | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func NewDelete() *cobra.Command { | ||||||||||||||||||||||||||||||||||||||||||||||
| return fctl.NewCommand("delete", | ||||||||||||||||||||||||||||||||||||||||||||||
| fctl.WithShortDescription("Delete a deployment"), | ||||||||||||||||||||||||||||||||||||||||||||||
| fctl.WithStringFlag("id", "", "App ID (required)"), | ||||||||||||||||||||||||||||||||||||||||||||||
| fctl.WithStringFlag("name", "", "Deployment name (required)"), | ||||||||||||||||||||||||||||||||||||||||||||||
| fctl.WithController(NewDeleteCtrl()), | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func (c *DeleteCtrl) GetStore() *DeleteStore { | ||||||||||||||||||||||||||||||||||||||||||||||
| return c.store | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func (c *DeleteCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { | ||||||||||||||||||||||||||||||||||||||||||||||
| _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| _, apiClient, err := fctl.NewAppDeployClientFromFlags( | ||||||||||||||||||||||||||||||||||||||||||||||
| cmd, | ||||||||||||||||||||||||||||||||||||||||||||||
| relyingParty, | ||||||||||||||||||||||||||||||||||||||||||||||
| fctl.NewPTermDialog(), | ||||||||||||||||||||||||||||||||||||||||||||||
| profileName, | ||||||||||||||||||||||||||||||||||||||||||||||
| *profile, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| id := fctl.GetString(cmd, "id") | ||||||||||||||||||||||||||||||||||||||||||||||
| if id == "" { | ||||||||||||||||||||||||||||||||||||||||||||||
| return nil, fmt.Errorf("--id is required") | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| name := fctl.GetString(cmd, "name") | ||||||||||||||||||||||||||||||||||||||||||||||
| if name == "" { | ||||||||||||||||||||||||||||||||||||||||||||||
| return nil, fmt.Errorf("--name is required") | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| cmd.SilenceUsage = true | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| _, err = apiClient.DeleteDeployment(cmd.Context(), id, name) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| c.store.Name = name | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| return c, nil | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+73
to
+80
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate delete API result before reporting success. The response object is ignored, so API-level delete failures can be reported as successful deletions. Suggested fix- _, err = apiClient.DeleteDeployment(cmd.Context(), id, name)
+ res, err := apiClient.DeleteDeployment(cmd.Context(), id, name)
if err != nil {
return nil, err
}
+ if res == nil {
+ return nil, fmt.Errorf("empty response from deployment API")
+ }
+ if res.Error != nil {
+ return nil, fmt.Errorf("delete deployment request failed")
+ }
c.store.Name = name📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func (c *DeleteCtrl) Render(cmd *cobra.Command, args []string) error { | ||||||||||||||||||||||||||||||||||||||||||||||
| pterm.Success.Println("Deployment deleted", c.store.Name) | ||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pin the deploy to the manifest version you just pushed.
With
--version == 0, this code pushes a new manifest and then deployslatestby passingnil. That is a TOCTOU race: another push for the same app can land between those two calls and a different manifest gets deployed. The same block also still requires--pathand creates a fresh manifest when the user explicitly asked for--version > 0, which leaves an unused extra version behind.PushManifestalready returns the created version incmd/apps/manifest/push.go:83-88, so this path should deploy that exact version, skip the push for explicit versions, and reject negative values instead of treating them aslatest.Suggested direction
Also applies to: 100-116
🤖 Prompt for AI Agents