From 333b6c36668704aa04dc6166bc108e86413d2c73 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Thu, 2 May 2024 14:53:41 +0200 Subject: [PATCH] sg: cloud ephemeral add status and delete commands (#62265) * minor refactor & impl GetInstance standardise all method names on CRUD prefix + Instance suffix * add `sg cloud status` and `sg cloud delete` commands * bazel * small refactor and add status formats * fixup --- dev/sg/internal/cloud/BUILD.bazel | 2 + dev/sg/internal/cloud/client.go | 33 ++++++++++- dev/sg/internal/cloud/delete_command.go | 68 +++++++++++++++++++++ dev/sg/internal/cloud/deploy_command.go | 17 +++++- dev/sg/internal/cloud/instance.go | 2 - dev/sg/internal/cloud/list_command.go | 20 +------ dev/sg/internal/cloud/printers.go | 40 ++++++++++++- dev/sg/internal/cloud/status_command.go | 79 +++++++++++++++++++++++++ dev/sg/sg_cloud.go | 2 + 9 files changed, 237 insertions(+), 26 deletions(-) create mode 100644 dev/sg/internal/cloud/delete_command.go create mode 100644 dev/sg/internal/cloud/status_command.go diff --git a/dev/sg/internal/cloud/BUILD.bazel b/dev/sg/internal/cloud/BUILD.bazel index 1ca13e8895a..6e0b222ba8e 100644 --- a/dev/sg/internal/cloud/BUILD.bazel +++ b/dev/sg/internal/cloud/BUILD.bazel @@ -4,11 +4,13 @@ go_library( name = "cloud", srcs = [ "client.go", + "delete_command.go", "deploy_command.go", "instance.go", "list_command.go", "list_versions_command.go", "printers.go", + "status_command.go", ], importpath = "github.com/sourcegraph/sourcegraph/dev/sg/internal/cloud", visibility = ["//dev/sg:__subpackages__"], diff --git a/dev/sg/internal/cloud/client.go b/dev/sg/internal/cloud/client.go index aac0d65c6dd..508cb171c4d 100644 --- a/dev/sg/internal/cloud/client.go +++ b/dev/sg/internal/cloud/client.go @@ -28,6 +28,15 @@ const DevEnvironment = "dev" // It is set to internal because in cloud, internal instance types does not have metrics or security enabled. const EphemeralInstanceType = "internal" +var _ EphemeralClient = &Client{} + +type EphemeralClient interface { + CreateInstance(context.Context, *DeploymentSpec) (*Instance, error) + GetInstance(context.Context, string) (*Instance, error) + ListInstances(context.Context) ([]*Instance, error) + DeleteInstance(context.Context, string) error +} + type Client struct { client cloudapiv1connect.InstanceServiceClient token string @@ -42,7 +51,7 @@ type DeploymentSpec struct { func NewDeploymentSpec(name, version string) *DeploymentSpec { return &DeploymentSpec{ - Name: name, + Name: sanitizeInstanceName(name), Version: version, InstanceFeatures: map[string]string{ "ephemeral": "true", // need to have this to make the instance ephemeral @@ -88,6 +97,21 @@ func newRequestWithToken[T any](token string, message *T) *connect.Request[T] { return req } +func (c *Client) GetInstance(ctx context.Context, name string) (*Instance, error) { + req := newRequestWithToken(c.token, &cloudapiv1.GetInstanceRequest{ + Name: name, + Environment: DevEnvironment, + }, + ) + + resp, err := c.client.GetInstance(ctx, req) + if err != nil { + return nil, errors.Wrapf(err, "failed to get instance %q", name) + } + + return newInstance(resp.Msg.GetInstance()), nil +} + func (c *Client) ListInstances(ctx context.Context) ([]*Instance, error) { req := newRequestWithToken(c.token, &cloudapiv1.ListInstancesRequest{ InstanceFilter: &cloudapiv1.InstanceFilter{ @@ -105,7 +129,7 @@ func (c *Client) ListInstances(ctx context.Context) ([]*Instance, error) { return toInstances(resp.Msg.GetInstances()...), nil } -func (c *Client) DeployVersion(ctx context.Context, spec *DeploymentSpec) (*Instance, error) { +func (c *Client) CreateInstance(ctx context.Context, spec *DeploymentSpec) (*Instance, error) { // TODO(burmudar): Better method to get LicenseKeys licenseKey := os.Getenv("EPHEMERAL_LICENSE_KEY") if licenseKey == "" { @@ -134,3 +158,8 @@ func (c *Client) DeployVersion(ctx context.Context, spec *DeploymentSpec) (*Inst func (c *Client) DeleteInstance(ctx context.Context, name string) error { return nil } + +func sanitizeInstanceName(name string) string { + name = strings.ToLower(name) + return strings.ReplaceAll(name, "/", "-") +} diff --git a/dev/sg/internal/cloud/delete_command.go b/dev/sg/internal/cloud/delete_command.go new file mode 100644 index 00000000000..f81b4d98981 --- /dev/null +++ b/dev/sg/internal/cloud/delete_command.go @@ -0,0 +1,68 @@ +package cloud + +import ( + "fmt" + + "github.com/urfave/cli/v2" + + "github.com/sourcegraph/sourcegraph/dev/sg/internal/repo" + "github.com/sourcegraph/sourcegraph/dev/sg/internal/std" + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/sourcegraph/sourcegraph/lib/output" +) + +var DeleteEphemeralCommand = cli.Command{ + Name: "delete", + Usage: "sg could delete ", + Description: "delete ephemeral cloud instance identified either by the current branch or provided as a cli arg", + Action: wipAction(deleteCloudEphemeral), + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "name or slug of the cloud ephemeral instance to delete", + }, + }, +} + +func deleteCloudEphemeral(ctx *cli.Context) error { + email, err := GetGCloudAccount(ctx.Context) + if err != nil { + return err + } + + cloudClient, err := NewClient(ctx.Context, email, APIEndpoint) + if err != nil { + return err + } + + name := ctx.String("name") + if name == "" { + currentBranch, err := repo.GetCurrentBranch(ctx.Context) + if err != nil { + return errors.Wrap(err, "failed to determine current branch") + } + name = currentBranch + } + name = sanitizeInstanceName(name) + + var answ string + _, err = std.PromptAndScan(std.Out, fmt.Sprintf("Are you sure you want to delete ephemeral instance %q? (yes/no)", name), &answ) + if err != nil { + return err + } + + if oneOfEquals(answ, "no", "n") { + return ErrUserCancelled + } + + cloudEmoji := "☁️" + pending := std.Out.Pending(output.Linef(cloudEmoji, output.StylePending, "Deleting ephemeral instance %q", name)) + err = cloudClient.DeleteInstance(ctx.Context, name) + if err != nil { + pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "deleting of %q failed", name)) + return err + } + + pending.Complete(output.Linef(output.EmojiSuccess, output.StyleBold, "Ephemeral instance %q deleted", name)) + return nil +} diff --git a/dev/sg/internal/cloud/deploy_command.go b/dev/sg/internal/cloud/deploy_command.go index 39b59ee8964..63194635741 100644 --- a/dev/sg/internal/cloud/deploy_command.go +++ b/dev/sg/internal/cloud/deploy_command.go @@ -27,7 +27,7 @@ var DeployEphemeralCommand = cli.Command{ Name: "deploy", Usage: "sg could deploy --branch --tag ", Description: "Deploy the specified branch or tag to an ephemeral Sourcegraph Cloud environment", - Action: deployCloudEphemeral, + Action: wipAction(deployCloudEphemeral), Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", @@ -108,6 +108,19 @@ func triggerEphemeralBuild(ctx context.Context, currRepo *repo.GitRepo) (*buildk return build, nil } +func wipAction(actionFn cli.ActionFunc) cli.ActionFunc { + if actionFn == nil { + return nil + } + return func(ctx *cli.Context) error { + if err := printWIPNotice(ctx); err != nil { + return err + } + + return actionFn(ctx) + } +} + func printWIPNotice(ctx *cli.Context) error { if ctx.Bool("skip-wip-notice") { return nil @@ -146,7 +159,7 @@ func createDeploymentForVersion(ctx context.Context, name, version string) error name, version, ) - inst, err := cloudClient.DeployVersion(ctx, spec) + inst, err := cloudClient.CreateInstance(ctx, spec) if err != nil { pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "deployment failed: %v", err)) return errors.Wrapf(err, "failed to deploy version %v", version) diff --git a/dev/sg/internal/cloud/instance.go b/dev/sg/internal/cloud/instance.go index dbfb642ee12..6c05883f9bc 100644 --- a/dev/sg/internal/cloud/instance.go +++ b/dev/sg/internal/cloud/instance.go @@ -35,12 +35,10 @@ Environment : %s Version : %s Hostname : %s AdminEmail : %s - CreatedAt : %s Project : %s Region : %s DeletetAt : %s - Status : %s `, i.ID, i.Name, i.InstanceType, i.Environment, i.Version, i.Hostname, i.AdminEmail, i.CreatedAt, i.Project, i.Region, i.DeletedAt, diff --git a/dev/sg/internal/cloud/list_command.go b/dev/sg/internal/cloud/list_command.go index 49cd77ca80d..fea36a8b7e5 100644 --- a/dev/sg/internal/cloud/list_command.go +++ b/dev/sg/internal/cloud/list_command.go @@ -12,7 +12,7 @@ var ListEphemeralCommand = cli.Command{ Name: "list", Usage: "sg could list", Description: "list ephemeral cloud instances attached to your GCP account", - Action: listCloudEphemeral, + Action: wipAction(listCloudEphemeral), Flags: []cli.Flag{ &cli.BoolFlag{ Name: "json", @@ -44,22 +44,8 @@ func listCloudEphemeral(ctx *cli.Context) error { if ctx.Bool("json") { printer = &jsonInstancePrinter{w: os.Stdout} } else { - valueFunc := func(inst *Instance) []any { - name := inst.Name - if len(name) > 20 { - name = name[:20] - } - - status := inst.Status - createdAt := inst.CreatedAt.String() - - return []any{ - name, status, createdAt, - } - - } - printer = newTerminalInstancePrinter(valueFunc, "%-20s %-11s %s", "Name", "Status", "Created At") + printer = newDefaultTerminalInstancePrinter() } - return printer.Print(instances) + return printer.Print(instances...) } diff --git a/dev/sg/internal/cloud/printers.go b/dev/sg/internal/cloud/printers.go index 0c0d129fc2e..731271f50de 100644 --- a/dev/sg/internal/cloud/printers.go +++ b/dev/sg/internal/cloud/printers.go @@ -10,7 +10,11 @@ import ( ) type Printer interface { - Print([]*Instance) error + Print(...*Instance) error +} + +type rawInstancePrinter struct { + w io.Writer } type terminalInstancePrinter struct { @@ -23,6 +27,24 @@ type jsonInstancePrinter struct { w io.Writer } +func newDefaultTerminalInstancePrinter() *terminalInstancePrinter { + valueFunc := func(inst *Instance) []any { + name := inst.Name + if len(name) > 20 { + name = name[:20] + } + + status := inst.Status + createdAt := inst.CreatedAt.String() + + return []any{ + name, status, createdAt, + } + + } + return newTerminalInstancePrinter(valueFunc, "%-20s %-11s %s", "Name", "Status", "Created At") +} + func newTerminalInstancePrinter(valueFunc func(i *Instance) []any, headingFmt string, headings ...string) *terminalInstancePrinter { anyHeadings := make([]any, 0, len(headings)) for _, h := range headings { @@ -36,7 +58,7 @@ func newTerminalInstancePrinter(valueFunc func(i *Instance) []any, headingFmt st } } -func (p *terminalInstancePrinter) Print(items []*Instance) error { +func (p *terminalInstancePrinter) Print(items ...*Instance) error { heading := fmt.Sprintf(p.headingFmt, p.headings...) std.Out.WriteLine(output.Line("", output.StyleBold, heading)) for _, inst := range items { @@ -51,6 +73,18 @@ func newJSONInstancePrinter(w io.Writer) *jsonInstancePrinter { return &jsonInstancePrinter{w: w} } -func (p *jsonInstancePrinter) Print(items []*Instance) error { +func (p *jsonInstancePrinter) Print(items ...*Instance) error { return json.NewEncoder(p.w).Encode(items) } + +func newRawInstancePrinter(w io.Writer) *rawInstancePrinter { + return &rawInstancePrinter{w: w} +} + +func (p *rawInstancePrinter) Print(items ...*Instance) error { + for _, inst := range items { + fmt.Fprintln(p.w, inst.String()) + } + + return nil +} diff --git a/dev/sg/internal/cloud/status_command.go b/dev/sg/internal/cloud/status_command.go new file mode 100644 index 00000000000..e1448d9f35e --- /dev/null +++ b/dev/sg/internal/cloud/status_command.go @@ -0,0 +1,79 @@ +package cloud + +import ( + "os" + + "github.com/urfave/cli/v2" + + "github.com/sourcegraph/sourcegraph/dev/sg/internal/repo" + "github.com/sourcegraph/sourcegraph/dev/sg/internal/std" + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/sourcegraph/sourcegraph/lib/output" +) + +var StatusEphemeralCommand = cli.Command{ + Name: "status", + Usage: "sg could status", + Description: "get the status of the ephemeral cloud instance for this branch or instance with the provided slug", + Action: wipAction(statusCloudEphemeral), + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "name of the instance to get the status of", + }, + &cli.BoolFlag{ + Name: "json", + Usage: "print the instance details in JSON", + }, + &cli.BoolFlag{ + Name: "raw", + Usage: "print all of the instance details", + }, + }, +} + +func statusCloudEphemeral(ctx *cli.Context) error { + email, err := GetGCloudAccount(ctx.Context) + if err != nil { + return err + } + + cloudClient, err := NewClient(ctx.Context, email, APIEndpoint) + if err != nil { + return err + } + + name := ctx.String("name") + if name == "" { + currentBranch, err := repo.GetCurrentBranch(ctx.Context) + if err != nil { + return errors.Wrap(err, "failed to determine current branch") + } + name = currentBranch + } + name = sanitizeInstanceName(name) + + cloudEmoji := "☁️" + pending := std.Out.Pending(output.Linef(cloudEmoji, output.StylePending, "Getting status of ephemeral instance %q", name)) + inst, err := cloudClient.GetInstance(ctx.Context, name) + if err != nil { + pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "getting status of %q failed", name)) + return err + } + pending.Complete(output.Linef(output.EmojiSuccess, output.StyleBold, "Ephemeral instance %q status retrieved", name)) + + var printer Printer + + switch { + case ctx.Bool("json"): + printer = newJSONInstancePrinter(os.Stdout) + case ctx.Bool("raw"): + printer = newRawInstancePrinter(os.Stdout) + default: + printer = newDefaultTerminalInstancePrinter() + } + + std.Out.Write("Ephemeral instance details:") + printer.Print(inst) + return nil +} diff --git a/dev/sg/sg_cloud.go b/dev/sg/sg_cloud.go index f02e76d0727..9dcf47ae32f 100644 --- a/dev/sg/sg_cloud.go +++ b/dev/sg/sg_cloud.go @@ -48,6 +48,8 @@ var cloudCommand = &cli.Command{ &cloud.DeployEphemeralCommand, &cloud.ListEphemeralCommand, &cloud.ListVersionsEphemeralCommand, + &cloud.StatusEphemeralCommand, + &cloud.DeleteEphemeralCommand, }, }