Skip to content

Commit 33fabf4

Browse files
feat(blueprints): Add support for blueprints (#260)
1 parent 48da190 commit 33fabf4

File tree

13 files changed

+753
-31
lines changed

13 files changed

+753
-31
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package blueprint
2+
3+
import (
4+
"fmt"
5+
"math"
6+
7+
"github.com/spacelift-io/spacectl/internal/cmd"
8+
"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
9+
"github.com/urfave/cli/v2"
10+
)
11+
12+
// Command encapsulates the blueprintNode command subtree.
13+
func Command() *cli.Command {
14+
return &cli.Command{
15+
Name: "blueprint",
16+
Usage: "Manage a Spacelift blueprints",
17+
Subcommands: []*cli.Command{
18+
{
19+
Name: "list",
20+
Usage: "List the blueprints you have access to",
21+
Flags: []cli.Flag{
22+
cmd.FlagShowLabels,
23+
cmd.FlagOutputFormat,
24+
cmd.FlagNoColor,
25+
cmd.FlagLimit,
26+
cmd.FlagSearch,
27+
},
28+
Action: listBlueprints(),
29+
Before: cmd.PerformAllBefore(
30+
cmd.HandleNoColor,
31+
authenticated.Ensure,
32+
validateLimit,
33+
validateSearch,
34+
),
35+
ArgsUsage: cmd.EmptyArgsUsage,
36+
},
37+
{
38+
Name: "show",
39+
Usage: "Shows detailed information about a specific blueprint",
40+
Flags: []cli.Flag{
41+
flagRequiredBlueprintID,
42+
cmd.FlagOutputFormat,
43+
cmd.FlagNoColor,
44+
},
45+
Action: (&showCommand{}).show,
46+
Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure),
47+
ArgsUsage: cmd.EmptyArgsUsage,
48+
},
49+
{
50+
Name: "deploy",
51+
Usage: "Deploy a stack from the blueprint",
52+
Flags: []cli.Flag{
53+
flagRequiredBlueprintID,
54+
cmd.FlagNoColor,
55+
},
56+
Action: (&deployCommand{}).deploy,
57+
Before: cmd.PerformAllBefore(cmd.HandleNoColor, authenticated.Ensure),
58+
ArgsUsage: cmd.EmptyArgsUsage,
59+
},
60+
},
61+
}
62+
}
63+
64+
func validateLimit(cliCtx *cli.Context) error {
65+
if cliCtx.IsSet(cmd.FlagLimit.Name) {
66+
if cliCtx.Uint(cmd.FlagLimit.Name) == 0 {
67+
return fmt.Errorf("limit must be greater than 0")
68+
}
69+
70+
if cliCtx.Uint(cmd.FlagLimit.Name) >= math.MaxInt32 {
71+
return fmt.Errorf("limit must be less than %d", math.MaxInt32)
72+
}
73+
}
74+
75+
return nil
76+
}
77+
78+
func validateSearch(cliCtx *cli.Context) error {
79+
if cliCtx.IsSet(cmd.FlagSearch.Name) {
80+
if cliCtx.String(cmd.FlagSearch.Name) == "" {
81+
return fmt.Errorf("search must be non-empty")
82+
}
83+
84+
}
85+
86+
return nil
87+
}

internal/cmd/blueprint/deploy.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package blueprint
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
"strconv"
7+
"strings"
8+
9+
"github.com/manifoldco/promptui"
10+
"github.com/pkg/errors"
11+
"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
12+
"github.com/urfave/cli/v2"
13+
)
14+
15+
type deployCommand struct{}
16+
17+
func (c *deployCommand) deploy(cliCtx *cli.Context) error {
18+
blueprintID := cliCtx.String(flagRequiredBlueprintID.Name)
19+
20+
b, found, err := getBlueprintByID(cliCtx.Context, blueprintID)
21+
if err != nil {
22+
return errors.Wrapf(err, "failed to query for blueprint ID %q", blueprintID)
23+
}
24+
25+
if !found {
26+
return fmt.Errorf("blueprint with ID %q not found", blueprintID)
27+
}
28+
29+
templateInputs := make([]BlueprintStackCreateInputPair, 0, len(b.Inputs))
30+
31+
for _, input := range b.Inputs {
32+
var value string
33+
switch strings.ToLower(input.Type) {
34+
case "", "short_text", "long_text":
35+
value, err = promptForTextInput(input)
36+
if err != nil {
37+
return err
38+
}
39+
case "secret":
40+
value, err = promptForSecretInput(input)
41+
if err != nil {
42+
return err
43+
}
44+
case "number":
45+
value, err = promptForIntegerInput(input)
46+
if err != nil {
47+
return err
48+
}
49+
case "float":
50+
value, err = promptForFloatInput(input)
51+
if err != nil {
52+
return err
53+
}
54+
case "boolean":
55+
value, err = promptForSelectInput(input, []string{"true", "false"})
56+
if err != nil {
57+
return err
58+
}
59+
case "select":
60+
value, err = promptForSelectInput(input, input.Options)
61+
if err != nil {
62+
return err
63+
}
64+
}
65+
66+
templateInputs = append(templateInputs, BlueprintStackCreateInputPair{
67+
ID: input.ID,
68+
Value: value,
69+
})
70+
}
71+
72+
var mutation struct {
73+
BlueprintCreateStack struct {
74+
StackID string `graphql:"stackID"`
75+
} `graphql:"blueprintCreateStack(id: $id, input: $input)"`
76+
}
77+
78+
err = authenticated.Client.Mutate(
79+
cliCtx.Context,
80+
&mutation,
81+
map[string]any{
82+
"id": blueprintID,
83+
"input": BlueprintStackCreateInput{
84+
TemplateInputs: templateInputs,
85+
},
86+
},
87+
)
88+
if err != nil {
89+
return fmt.Errorf("failed to deploy stack from the blueprint: %w", err)
90+
}
91+
92+
url := authenticated.Client.URL("/stack/%s", mutation.BlueprintCreateStack.StackID)
93+
fmt.Printf("\nCreated stack: %q", url)
94+
95+
return nil
96+
}
97+
98+
func formatLabel(input blueprintInput) string {
99+
if input.Description != "" {
100+
return fmt.Sprintf("%s (%s) - %s", input.Name, input.ID, input.Description)
101+
}
102+
return fmt.Sprintf("%s (%s)", input.Name, input.ID)
103+
}
104+
105+
func promptForTextInput(input blueprintInput) (string, error) {
106+
prompt := promptui.Prompt{
107+
Label: formatLabel(input),
108+
Default: input.Default,
109+
}
110+
result, err := prompt.Run()
111+
if err != nil {
112+
return "", fmt.Errorf("failed to read text input for %q: %w", input.Name, err)
113+
}
114+
115+
return result, nil
116+
}
117+
118+
func promptForSecretInput(input blueprintInput) (string, error) {
119+
prompt := promptui.Prompt{
120+
Label: formatLabel(input),
121+
Default: input.Default,
122+
Mask: '*',
123+
}
124+
result, err := prompt.Run()
125+
if err != nil {
126+
return "", fmt.Errorf("failed to read secret input for %q: %w", input.Name, err)
127+
}
128+
129+
return result, nil
130+
}
131+
132+
func promptForIntegerInput(input blueprintInput) (string, error) {
133+
prompt := promptui.Prompt{
134+
Label: formatLabel(input),
135+
Default: input.Default,
136+
Validate: func(s string) error {
137+
_, err := strconv.Atoi(s)
138+
if err != nil {
139+
return fmt.Errorf("input must be an integer")
140+
}
141+
142+
return nil
143+
},
144+
}
145+
result, err := prompt.Run()
146+
if err != nil {
147+
return "", fmt.Errorf("failed to read integer input for %q: %w", input.Name, err)
148+
}
149+
150+
return result, nil
151+
}
152+
153+
func promptForFloatInput(input blueprintInput) (string, error) {
154+
prompt := promptui.Prompt{
155+
Label: formatLabel(input),
156+
Default: input.Default,
157+
Validate: func(s string) error {
158+
_, err := strconv.ParseFloat(s, 64)
159+
if err != nil {
160+
return fmt.Errorf("input must be a float")
161+
}
162+
163+
return nil
164+
},
165+
}
166+
result, err := prompt.Run()
167+
if err != nil {
168+
return "", fmt.Errorf("failed to read float input for %q: %w", input.Name, err)
169+
}
170+
171+
return result, nil
172+
}
173+
174+
func promptForSelectInput(input blueprintInput, options []string) (string, error) {
175+
cursorPosition := 0
176+
if input.Default != "" {
177+
cursorPosition = slices.Index(options, input.Default)
178+
}
179+
180+
sel := promptui.Select{
181+
Label: formatLabel(input),
182+
Items: options,
183+
CursorPos: cursorPosition,
184+
}
185+
186+
_, result, err := sel.Run()
187+
if err != nil {
188+
return "", fmt.Errorf("failed to read selected input for %q: %w", input.Name, err)
189+
}
190+
191+
return result, nil
192+
}

internal/cmd/blueprint/flags.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package blueprint
2+
3+
import "github.com/urfave/cli/v2"
4+
5+
var flagRequiredBlueprintID = &cli.StringFlag{
6+
Name: "blueprint-id",
7+
Aliases: []string{"b-id"},
8+
Usage: "[Required] `ID` of the blueprint",
9+
Required: true,
10+
}

internal/cmd/blueprint/inputs.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package blueprint
2+
3+
// BlueprintStackCreateInputPair represents a key-value pair for a blueprint input.
4+
type BlueprintStackCreateInputPair struct {
5+
ID string `json:"id"`
6+
Value string `json:"value"`
7+
}
8+
9+
// BlueprintStackCreateInput represents the input for creating a new stack from a blueprint.
10+
type BlueprintStackCreateInput struct {
11+
TemplateInputs []BlueprintStackCreateInputPair `json:"templateInputs"`
12+
}

0 commit comments

Comments
 (0)