Skip to content

Commit

Permalink
feat(blueprints): Add support for blueprints (#260)
Browse files Browse the repository at this point in the history
  • Loading branch information
0michalsokolowski0 authored Oct 16, 2024
1 parent 48da190 commit 33fabf4
Show file tree
Hide file tree
Showing 13 changed files with 753 additions and 31 deletions.
87 changes: 87 additions & 0 deletions internal/cmd/blueprint/blueprint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package blueprint

import (
"fmt"
"math"

"github.com/spacelift-io/spacectl/internal/cmd"
"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
"github.com/urfave/cli/v2"
)

// Command encapsulates the blueprintNode command subtree.
func Command() *cli.Command {
return &cli.Command{
Name: "blueprint",
Usage: "Manage a Spacelift blueprints",
Subcommands: []*cli.Command{
{
Name: "list",
Usage: "List the blueprints you have access to",
Flags: []cli.Flag{
cmd.FlagShowLabels,
cmd.FlagOutputFormat,
cmd.FlagNoColor,
cmd.FlagLimit,
cmd.FlagSearch,
},
Action: listBlueprints(),
Before: cmd.PerformAllBefore(
cmd.HandleNoColor,
authenticated.Ensure,
validateLimit,
validateSearch,
),
ArgsUsage: cmd.EmptyArgsUsage,
},
{
Name: "show",
Usage: "Shows detailed information about a specific blueprint",
Flags: []cli.Flag{
flagRequiredBlueprintID,
cmd.FlagOutputFormat,
cmd.FlagNoColor,
},
Action: (&showCommand{}).show,
Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure),
ArgsUsage: cmd.EmptyArgsUsage,
},
{
Name: "deploy",
Usage: "Deploy a stack from the blueprint",
Flags: []cli.Flag{
flagRequiredBlueprintID,
cmd.FlagNoColor,
},
Action: (&deployCommand{}).deploy,
Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure),
ArgsUsage: cmd.EmptyArgsUsage,
},
},
}
}

func validateLimit(cliCtx *cli.Context) error {
if cliCtx.IsSet(cmd.FlagLimit.Name) {
if cliCtx.Uint(cmd.FlagLimit.Name) == 0 {
return fmt.Errorf("limit must be greater than 0")
}

if cliCtx.Uint(cmd.FlagLimit.Name) >= math.MaxInt32 {
return fmt.Errorf("limit must be less than %d", math.MaxInt32)
}
}

return nil
}

func validateSearch(cliCtx *cli.Context) error {
if cliCtx.IsSet(cmd.FlagSearch.Name) {
if cliCtx.String(cmd.FlagSearch.Name) == "" {
return fmt.Errorf("search must be non-empty")
}

}

return nil
}
192 changes: 192 additions & 0 deletions internal/cmd/blueprint/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package blueprint

import (
"fmt"
"slices"
"strconv"
"strings"

"github.com/manifoldco/promptui"
"github.com/pkg/errors"
"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
"github.com/urfave/cli/v2"
)

type deployCommand struct{}

func (c *deployCommand) deploy(cliCtx *cli.Context) error {
blueprintID := cliCtx.String(flagRequiredBlueprintID.Name)

b, found, err := getBlueprintByID(cliCtx.Context, blueprintID)
if err != nil {
return errors.Wrapf(err, "failed to query for blueprint ID %q", blueprintID)
}

if !found {
return fmt.Errorf("blueprint with ID %q not found", blueprintID)
}

templateInputs := make([]BlueprintStackCreateInputPair, 0, len(b.Inputs))

for _, input := range b.Inputs {
var value string
switch strings.ToLower(input.Type) {
case "", "short_text", "long_text":
value, err = promptForTextInput(input)
if err != nil {
return err
}
case "secret":
value, err = promptForSecretInput(input)
if err != nil {
return err
}
case "number":
value, err = promptForIntegerInput(input)
if err != nil {
return err
}
case "float":
value, err = promptForFloatInput(input)
if err != nil {
return err
}
case "boolean":
value, err = promptForSelectInput(input, []string{"true", "false"})
if err != nil {
return err
}
case "select":
value, err = promptForSelectInput(input, input.Options)
if err != nil {
return err
}
}

templateInputs = append(templateInputs, BlueprintStackCreateInputPair{
ID: input.ID,
Value: value,
})
}

var mutation struct {
BlueprintCreateStack struct {
StackID string `graphql:"stackID"`
} `graphql:"blueprintCreateStack(id: $id, input: $input)"`
}

err = authenticated.Client.Mutate(
cliCtx.Context,
&mutation,
map[string]any{
"id": blueprintID,
"input": BlueprintStackCreateInput{
TemplateInputs: templateInputs,
},
},
)
if err != nil {
return fmt.Errorf("failed to deploy stack from the blueprint: %w", err)
}

url := authenticated.Client.URL("/stack/%s", mutation.BlueprintCreateStack.StackID)
fmt.Printf("\nCreated stack: %q", url)

return nil
}

func formatLabel(input blueprintInput) string {
if input.Description != "" {
return fmt.Sprintf("%s (%s) - %s", input.Name, input.ID, input.Description)
}
return fmt.Sprintf("%s (%s)", input.Name, input.ID)
}

func promptForTextInput(input blueprintInput) (string, error) {
prompt := promptui.Prompt{
Label: formatLabel(input),
Default: input.Default,
}
result, err := prompt.Run()
if err != nil {
return "", fmt.Errorf("failed to read text input for %q: %w", input.Name, err)
}

return result, nil
}

func promptForSecretInput(input blueprintInput) (string, error) {
prompt := promptui.Prompt{
Label: formatLabel(input),
Default: input.Default,
Mask: '*',
}
result, err := prompt.Run()
if err != nil {
return "", fmt.Errorf("failed to read secret input for %q: %w", input.Name, err)
}

return result, nil
}

func promptForIntegerInput(input blueprintInput) (string, error) {
prompt := promptui.Prompt{
Label: formatLabel(input),
Default: input.Default,
Validate: func(s string) error {
_, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("input must be an integer")
}

return nil
},
}
result, err := prompt.Run()
if err != nil {
return "", fmt.Errorf("failed to read integer input for %q: %w", input.Name, err)
}

return result, nil
}

func promptForFloatInput(input blueprintInput) (string, error) {
prompt := promptui.Prompt{
Label: formatLabel(input),
Default: input.Default,
Validate: func(s string) error {
_, err := strconv.ParseFloat(s, 64)
if err != nil {
return fmt.Errorf("input must be a float")
}

return nil
},
}
result, err := prompt.Run()
if err != nil {
return "", fmt.Errorf("failed to read float input for %q: %w", input.Name, err)
}

return result, nil
}

func promptForSelectInput(input blueprintInput, options []string) (string, error) {
cursorPosition := 0
if input.Default != "" {
cursorPosition = slices.Index(options, input.Default)
}

sel := promptui.Select{
Label: formatLabel(input),
Items: options,
CursorPos: cursorPosition,
}

_, result, err := sel.Run()
if err != nil {
return "", fmt.Errorf("failed to read selected input for %q: %w", input.Name, err)
}

return result, nil
}
10 changes: 10 additions & 0 deletions internal/cmd/blueprint/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package blueprint

import "github.com/urfave/cli/v2"

var flagRequiredBlueprintID = &cli.StringFlag{
Name: "blueprint-id",
Aliases: []string{"b-id"},
Usage: "[Required] `ID` of the blueprint",
Required: true,
}
12 changes: 12 additions & 0 deletions internal/cmd/blueprint/inputs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package blueprint

// BlueprintStackCreateInputPair represents a key-value pair for a blueprint input.
type BlueprintStackCreateInputPair struct {
ID string `json:"id"`
Value string `json:"value"`
}

// BlueprintStackCreateInput represents the input for creating a new stack from a blueprint.
type BlueprintStackCreateInput struct {
TemplateInputs []BlueprintStackCreateInputPair `json:"templateInputs"`
}
Loading

0 comments on commit 33fabf4

Please sign in to comment.