Skip to content

Commit 174d796

Browse files
committed
feat(dvb): add apply command for YAML-based devnet management
1 parent 8e9d844 commit 174d796

3 files changed

Lines changed: 285 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ tests/e2e/TEST_RESULTS.md
4848
docs/plans/
4949

5050
# Compiled binaries
51-
devnetd
52-
dvb
51+
/devnetd
52+
/dvb
5353
*.test
5454
.serena/
5555

cmd/dvb/apply.go

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// cmd/dvb/apply.go
2+
package main
3+
4+
import (
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
v1 "github.com/altuslabsxyz/devnet-builder/api/proto/gen/v1"
12+
"github.com/altuslabsxyz/devnet-builder/internal/config"
13+
"github.com/fatih/color"
14+
"github.com/spf13/cobra"
15+
"gopkg.in/yaml.v3"
16+
)
17+
18+
func newApplyCmd() *cobra.Command {
19+
var (
20+
filePath string
21+
dryRun bool
22+
force bool
23+
output string
24+
)
25+
26+
cmd := &cobra.Command{
27+
Use: "apply",
28+
Short: "Apply a devnet configuration from YAML",
29+
Long: `Apply a devnet configuration from a YAML file.
30+
31+
The apply command compares the desired state (from YAML) with the current state
32+
and makes minimal changes to reconcile them. It's idempotent - running it
33+
multiple times produces the same result.
34+
35+
Examples:
36+
# Apply a devnet configuration
37+
dvb apply -f devnet.yaml
38+
39+
# Preview changes without applying
40+
dvb apply -f devnet.yaml --dry-run
41+
42+
# Apply all YAML files in a directory
43+
dvb apply -f ./devnets/
44+
45+
# Force recreation (destroy + create)
46+
dvb apply -f devnet.yaml --force`,
47+
RunE: func(cmd *cobra.Command, args []string) error {
48+
if daemonClient == nil {
49+
return fmt.Errorf("daemon not running - start with: devnetd")
50+
}
51+
52+
if filePath == "" {
53+
return fmt.Errorf("--file/-f is required")
54+
}
55+
56+
// Load YAML files
57+
devnets, err := loadYAMLFiles(filePath)
58+
if err != nil {
59+
return fmt.Errorf("failed to load YAML: %w", err)
60+
}
61+
62+
if len(devnets) == 0 {
63+
return fmt.Errorf("no valid devnet configurations found in %s", filePath)
64+
}
65+
66+
// Process each devnet
67+
for _, yamlDevnet := range devnets {
68+
if err := applyDevnet(cmd, yamlDevnet, dryRun, force, output); err != nil {
69+
return err
70+
}
71+
}
72+
73+
return nil
74+
},
75+
}
76+
77+
cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to YAML file or directory (required)")
78+
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without applying")
79+
cmd.Flags().BoolVar(&force, "force", false, "Force recreation (destroy existing + create new)")
80+
cmd.Flags().StringVarP(&output, "output", "o", "text", "Output format: text, json")
81+
82+
return cmd
83+
}
84+
85+
// loadYAMLFiles loads devnet configurations from a file or directory
86+
func loadYAMLFiles(path string) ([]*config.YAMLDevnet, error) {
87+
info, err := os.Stat(path)
88+
if err != nil {
89+
return nil, fmt.Errorf("cannot access %s: %w", path, err)
90+
}
91+
92+
var files []string
93+
if info.IsDir() {
94+
// Find all YAML files in directory
95+
entries, err := os.ReadDir(path)
96+
if err != nil {
97+
return nil, err
98+
}
99+
for _, entry := range entries {
100+
if entry.IsDir() {
101+
continue
102+
}
103+
name := entry.Name()
104+
if strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") {
105+
files = append(files, filepath.Join(path, name))
106+
}
107+
}
108+
} else {
109+
files = []string{path}
110+
}
111+
112+
var devnets []*config.YAMLDevnet
113+
for _, file := range files {
114+
parsed, err := parseYAMLFile(file)
115+
if err != nil {
116+
return nil, fmt.Errorf("failed to parse %s: %w", file, err)
117+
}
118+
devnets = append(devnets, parsed...)
119+
}
120+
121+
return devnets, nil
122+
}
123+
124+
// parseYAMLFile parses a YAML file that may contain multiple documents
125+
func parseYAMLFile(path string) ([]*config.YAMLDevnet, error) {
126+
f, err := os.Open(path)
127+
if err != nil {
128+
return nil, err
129+
}
130+
defer f.Close()
131+
132+
var devnets []*config.YAMLDevnet
133+
decoder := yaml.NewDecoder(f)
134+
135+
for docIndex := 0; ; docIndex++ {
136+
var doc config.YAMLDevnet
137+
err := decoder.Decode(&doc)
138+
if err == io.EOF {
139+
break
140+
}
141+
if err != nil {
142+
return nil, fmt.Errorf("document %d: %w", docIndex, err)
143+
}
144+
145+
// Validate the document
146+
if err := doc.Validate(); err != nil {
147+
return nil, fmt.Errorf("validation failed for document %d in %s: %w", docIndex, path, err)
148+
}
149+
150+
devnets = append(devnets, &doc)
151+
}
152+
153+
return devnets, nil
154+
}
155+
156+
// applyDevnet applies a single devnet configuration
157+
func applyDevnet(cmd *cobra.Command, yamlDevnet *config.YAMLDevnet, dryRun, force bool, output string) error {
158+
ctx := cmd.Context()
159+
name := yamlDevnet.Metadata.Name
160+
161+
// Check if devnet exists
162+
existing, err := daemonClient.GetDevnet(ctx, name)
163+
exists := err == nil && existing != nil
164+
165+
// Convert YAML to proto spec
166+
spec := yamlToProtoSpec(&yamlDevnet.Spec)
167+
168+
if dryRun {
169+
return printDryRun(yamlDevnet, exists, force, output)
170+
}
171+
172+
// Handle force recreation
173+
if exists && force {
174+
color.Yellow("⚠ Destroying existing devnet %q for recreation", name)
175+
if err := daemonClient.DeleteDevnet(ctx, name); err != nil {
176+
return fmt.Errorf("failed to destroy existing devnet: %w", err)
177+
}
178+
exists = false
179+
}
180+
181+
// Create or update
182+
if exists {
183+
// For now, just report that devnet already exists
184+
// TODO: Implement update logic when daemon supports it
185+
color.Yellow("! Devnet %q already exists (update not yet supported)", name)
186+
fmt.Printf(" Current phase: %s\n", existing.Status.Phase)
187+
fmt.Printf(" Use --force to recreate\n")
188+
return nil
189+
}
190+
191+
// Create new devnet
192+
devnet, err := daemonClient.CreateDevnet(ctx, name, spec, yamlDevnet.Metadata.Labels)
193+
if err != nil {
194+
return fmt.Errorf("failed to create devnet: %w", err)
195+
}
196+
197+
color.Green("✓ Devnet %q created", devnet.Metadata.Name)
198+
fmt.Printf(" Phase: %s\n", devnet.Status.Phase)
199+
fmt.Printf(" Plugin: %s\n", devnet.Spec.Plugin)
200+
fmt.Printf(" Validators: %d\n", devnet.Spec.Validators)
201+
if devnet.Spec.FullNodes > 0 {
202+
fmt.Printf(" Full Nodes: %d\n", devnet.Spec.FullNodes)
203+
}
204+
205+
return nil
206+
}
207+
208+
// yamlToProtoSpec converts YAML spec to proto DevnetSpec
209+
func yamlToProtoSpec(yamlSpec *config.YAMLDevnetSpec) *v1.DevnetSpec {
210+
spec := &v1.DevnetSpec{
211+
Plugin: yamlSpec.Network, // YAML uses "network", proto uses "plugin"
212+
NetworkType: yamlSpec.NetworkType,
213+
Validators: int32(yamlSpec.Validators),
214+
FullNodes: int32(yamlSpec.FullNodes),
215+
Mode: yamlSpec.Mode,
216+
SdkVersion: yamlSpec.NetworkVersion,
217+
}
218+
219+
// Set defaults
220+
if spec.Validators == 0 {
221+
spec.Validators = 4
222+
}
223+
if spec.Mode == "" {
224+
spec.Mode = "docker"
225+
}
226+
227+
return spec
228+
}
229+
230+
// printDryRun prints what would happen without actually applying
231+
func printDryRun(yamlDevnet *config.YAMLDevnet, exists, force bool, output string) error {
232+
name := yamlDevnet.Metadata.Name
233+
234+
fmt.Printf("Devnet: %s (dry-run)\n\n", name)
235+
236+
if exists && !force {
237+
color.Yellow("! Devnet already exists (no changes)")
238+
fmt.Printf(" Use --force to recreate\n")
239+
return nil
240+
}
241+
242+
action := "create"
243+
if exists && force {
244+
action = "recreate"
245+
}
246+
247+
fmt.Printf("Plan: 1 to %s\n\n", action)
248+
249+
// Show what would be created
250+
if action == "recreate" {
251+
color.Yellow("~ devnet/%s (recreate)", name)
252+
} else {
253+
color.Green("+ devnet/%s", name)
254+
}
255+
256+
fmt.Printf(" network: %s\n", yamlDevnet.Spec.Network)
257+
if yamlDevnet.Spec.NetworkVersion != "" {
258+
fmt.Printf(" version: %s\n", yamlDevnet.Spec.NetworkVersion)
259+
}
260+
if yamlDevnet.Spec.Mode != "" {
261+
fmt.Printf(" mode: %s\n", yamlDevnet.Spec.Mode)
262+
}
263+
validators := yamlDevnet.Spec.Validators
264+
if validators == 0 {
265+
validators = 4
266+
}
267+
fmt.Printf(" validators: %d\n", validators)
268+
if yamlDevnet.Spec.FullNodes > 0 {
269+
fmt.Printf(" fullNodes: %d\n", yamlDevnet.Spec.FullNodes)
270+
}
271+
272+
// Show nodes that would be created
273+
for i := 0; i < validators; i++ {
274+
color.Green(" + node/%s-%d (validator)", name, i)
275+
}
276+
for i := 0; i < yamlDevnet.Spec.FullNodes; i++ {
277+
color.Green(" + node/%s-full-%d (full)", name, i)
278+
}
279+
280+
fmt.Printf("\nRun without --dry-run to apply.\n")
281+
return nil
282+
}

cmd/dvb/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func main() {
5858
rootCmd.AddCommand(
5959
newVersionCmd(),
6060
newDaemonCmd(),
61+
newApplyCmd(),
6162
newDeployCmd(),
6263
newListCmd(),
6364
newStatusCmd(),

0 commit comments

Comments
 (0)