Skip to content

Commit dff558f

Browse files
authored
[PRDGRO-1889] Blueprint Validate Command (#247)
* Generated types from OpenAPI Schema * Add Blueprint validate command * Add blueprints command path * We don't want to swallow errors * Use generated types * We can just return err
1 parent e9227cc commit dff558f

5 files changed

Lines changed: 408 additions & 0 deletions

File tree

cmd/blueprints.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
var blueprintsCmd = &cobra.Command{
8+
Use: "blueprints",
9+
Short: "Manage blueprints",
10+
Long: `Manage blueprint files (render.yaml) including validation.`,
11+
GroupID: GroupManagement.ID,
12+
}
13+
14+
func init() {
15+
rootCmd.AddCommand(blueprintsCmd)
16+
}

cmd/blueprintvalidate.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"mime/multipart"
9+
"os"
10+
"path/filepath"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/render-oss/cli/pkg/client"
15+
bptypes "github.com/render-oss/cli/pkg/client/blueprints"
16+
"github.com/render-oss/cli/pkg/command"
17+
"github.com/render-oss/cli/pkg/config"
18+
)
19+
20+
var blueprintValidateCmd = &cobra.Command{
21+
Use: "validate [file]",
22+
Short: "Validate a render.yaml file",
23+
Long: `Validate a render.yaml blueprint file for errors before committing.
24+
25+
Validates:
26+
- YAML syntax
27+
- Schema validation (required fields, types)
28+
- Semantic validation (valid plans, regions, etc.)
29+
- Conflict checking against existing resources
30+
31+
Examples:
32+
render blueprints validate # Validate ./render.yaml
33+
render blueprints validate ./my-blueprint.yaml # Validate specific file
34+
render blueprints validate -o json # Output as JSON`,
35+
Args: cobra.MaximumNArgs(1),
36+
SilenceUsage: true,
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
ctx := cmd.Context()
39+
return runBlueprintValidate(ctx, cmd, args)
40+
},
41+
}
42+
43+
func init() {
44+
blueprintsCmd.AddCommand(blueprintValidateCmd)
45+
blueprintValidateCmd.Flags().StringP("workspace", "w", "", "Workspace ID to validate against (defaults to current workspace)")
46+
}
47+
48+
func runBlueprintValidate(ctx context.Context, cmd *cobra.Command, args []string) error {
49+
output := command.GetFormatFromContext(ctx)
50+
51+
filePath := "render.yaml"
52+
if len(args) > 0 {
53+
filePath = args[0]
54+
}
55+
56+
absPath, err := filepath.Abs(filePath)
57+
if err != nil {
58+
return fmt.Errorf("failed to resolve file path: %w", err)
59+
}
60+
61+
content, err := os.ReadFile(absPath)
62+
if err != nil {
63+
if os.IsNotExist(err) {
64+
return fmt.Errorf("file not found: %s", absPath)
65+
}
66+
return fmt.Errorf("failed to read file: %w", err)
67+
}
68+
69+
workspaceID, err := cmd.Flags().GetString("workspace")
70+
if err != nil {
71+
return fmt.Errorf("failed to get workspace flag: %w", err)
72+
}
73+
if workspaceID == "" {
74+
workspaceID, err = config.WorkspaceID()
75+
if err != nil {
76+
return fmt.Errorf("no workspace specified and no default workspace set. Use --workspace or run 'render workspace set'")
77+
}
78+
}
79+
80+
var body bytes.Buffer
81+
writer := multipart.NewWriter(&body)
82+
83+
if err := writer.WriteField("ownerId", workspaceID); err != nil {
84+
return fmt.Errorf("failed to write ownerId field: %w", err)
85+
}
86+
87+
part, err := writer.CreateFormFile("file", filepath.Base(absPath))
88+
if err != nil {
89+
return fmt.Errorf("failed to create form file: %w", err)
90+
}
91+
if _, err := part.Write(content); err != nil {
92+
return fmt.Errorf("failed to write file content: %w", err)
93+
}
94+
95+
if err := writer.Close(); err != nil {
96+
return fmt.Errorf("failed to close multipart writer: %w", err)
97+
}
98+
99+
c, err := client.NewDefaultClient()
100+
if err != nil {
101+
return fmt.Errorf("failed to create client: %w", err)
102+
}
103+
104+
resp, err := c.ValidateBlueprintWithBodyWithResponse(
105+
ctx,
106+
writer.FormDataContentType(),
107+
&body,
108+
)
109+
if err != nil {
110+
return fmt.Errorf("failed to validate blueprint: %w", err)
111+
}
112+
113+
if resp.StatusCode() != 200 {
114+
return fmt.Errorf("validation request failed with status %d: %s", resp.StatusCode(), string(resp.Body))
115+
}
116+
117+
result := resp.JSON200
118+
119+
if output.Interactive() {
120+
return printValidationResultInteractive(absPath, result)
121+
}
122+
123+
_, err = command.PrintData(cmd, result, func(r *bptypes.ValidateBlueprintResponse) string {
124+
jsonBytes, err := json.MarshalIndent(r, "", " ")
125+
if err != nil {
126+
return fmt.Sprintf("{\"error\": \"failed to format result: %v\"}\n", err)
127+
}
128+
return string(jsonBytes) + "\n"
129+
})
130+
return err
131+
}
132+
133+
func formatValidationError(e bptypes.ValidationError) string {
134+
var location string
135+
if e.Line != nil && *e.Line > 0 {
136+
location = fmt.Sprintf("line %d", *e.Line)
137+
if e.Column != nil && *e.Column > 0 {
138+
location += fmt.Sprintf(", column %d", *e.Column)
139+
}
140+
}
141+
142+
path := ""
143+
if e.Path != nil {
144+
path = *e.Path
145+
}
146+
147+
switch {
148+
case location != "" && path != "":
149+
return fmt.Sprintf(" %s (%s): %s", path, location, e.Error)
150+
case path != "":
151+
return fmt.Sprintf(" %s: %s", path, e.Error)
152+
case location != "":
153+
return fmt.Sprintf(" %s: %s", location, e.Error)
154+
default:
155+
return fmt.Sprintf(" %s", e.Error)
156+
}
157+
}
158+
159+
func printValidationResultInteractive(filePath string, result *bptypes.ValidateBlueprintResponse) error {
160+
if result.Valid {
161+
fmt.Printf("%s is valid\n", filePath)
162+
163+
if result.Plan != nil {
164+
fmt.Println("\nPlan summary:")
165+
if result.Plan.Services != nil && len(*result.Plan.Services) > 0 {
166+
fmt.Printf(" %-14s %d\n", "Services:", len(*result.Plan.Services))
167+
}
168+
if result.Plan.Databases != nil && len(*result.Plan.Databases) > 0 {
169+
fmt.Printf(" %-14s %d\n", "Databases:", len(*result.Plan.Databases))
170+
}
171+
if result.Plan.Redis != nil && len(*result.Plan.Redis) > 0 {
172+
fmt.Printf(" %-14s %d\n", "Redis:", len(*result.Plan.Redis))
173+
}
174+
if result.Plan.EnvGroups != nil && len(*result.Plan.EnvGroups) > 0 {
175+
fmt.Printf(" %-14s %d\n", "Env Groups:", len(*result.Plan.EnvGroups))
176+
}
177+
if result.Plan.TotalActions != nil {
178+
fmt.Printf(" %-14s %d\n", "Total Actions:", *result.Plan.TotalActions)
179+
}
180+
}
181+
return nil
182+
}
183+
184+
if result.Errors != nil {
185+
for _, e := range *result.Errors {
186+
fmt.Println(formatValidationError(e))
187+
}
188+
}
189+
190+
return fmt.Errorf("%s has validation errors", filePath)
191+
}

pkg/client/blueprints/blueprints_gen.go

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)