diff --git a/cmd/networkcmd/apply.go b/cmd/networkcmd/apply.go new file mode 100644 index 000000000..8e17211fc --- /dev/null +++ b/cmd/networkcmd/apply.go @@ -0,0 +1,307 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package networkcmd + +import ( + "fmt" + "os" + + "github.com/luxfi/cli/pkg/binutils" + "github.com/luxfi/cli/pkg/cobrautils" + "github.com/luxfi/cli/pkg/constants" + "github.com/luxfi/cli/pkg/netspec" + "github.com/luxfi/cli/pkg/ux" + "github.com/spf13/cobra" +) + +var ( + applySpecPath string + applyDryRun bool + applyForce bool +) + +// newApplyCmd creates the network apply command for declarative network management. +func newApplyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a declarative network specification", + Long: `Apply a network specification file to create or update a network. + +This command provides Infrastructure as Code (IaC) for Lux networks. +It reads a YAML or JSON specification file and ensures the network matches +the desired state. + +The command is idempotent - running it multiple times with the same spec +will only make changes when necessary. + +Example spec.yaml: + apiVersion: lux.network/v1 + kind: Network + network: + name: mydevnet + nodes: 5 + subnets: + - name: mychain + vm: subnet-evm + chainId: 12345 + tokenSymbol: MYT + validators: 3 + testDefaults: true + +Usage: + lux network apply -f spec.yaml + lux network apply -f spec.yaml --dry-run + lux network apply -f spec.yaml --force`, + RunE: applySpec, + PreRunE: cobrautils.ExactArgs(0), + } + + cmd.Flags().StringVarP(&applySpecPath, "file", "f", "", "path to network specification file (required)") + cmd.Flags().BoolVar(&applyDryRun, "dry-run", false, "show what would be changed without making changes") + cmd.Flags().BoolVar(&applyForce, "force", false, "force recreation of existing resources") + + _ = cmd.MarkFlagRequired("file") + + return cmd +} + +// applySpec reads the spec and applies it to create/update the network. +func applySpec(cmd *cobra.Command, args []string) error { + // Parse the specification file + spec, err := netspec.ParseFile(applySpecPath) + if err != nil { + return fmt.Errorf("failed to parse specification: %w", err) + } + + ux.Logger.PrintToUser("Applying network specification: %s", spec.Network.Name) + ux.Logger.PrintToUser("") + + // Get current network state + currentState, err := getCurrentNetworkState(spec.Network.Name) + if err != nil { + return fmt.Errorf("failed to get current state: %w", err) + } + + // Calculate diff + diff := netspec.Diff(spec, currentState) + + if !diff.HasChanges() && !applyForce { + ux.Logger.GreenCheckmarkToUser("Network is up to date. No changes needed.") + return nil + } + + // Display planned changes + ux.Logger.PrintToUser("Planned changes:") + ux.Logger.PrintToUser(" %s", diff.String()) + ux.Logger.PrintToUser("") + + if applyDryRun { + ux.Logger.PrintToUser("Dry run complete. No changes made.") + return nil + } + + // Apply changes + if err := applyChanges(cmd, spec, diff, currentState); err != nil { + return err + } + + ux.Logger.GreenCheckmarkToUser("Network specification applied successfully") + return nil +} + +// getCurrentNetworkState retrieves the current state of the network. +func getCurrentNetworkState(networkName string) (*netspec.NetworkState, error) { + state := &netspec.NetworkState{ + Name: networkName, + } + + // Check if network is running by checking for any blockchain configs + subnetDir := app.GetSubnetDir() + entries, err := os.ReadDir(subnetDir) + if err != nil { + if os.IsNotExist(err) { + return state, nil + } + return nil, err + } + + // Scan for deployed subnets + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + sc, err := app.LoadSidecar(entry.Name()) + if err != nil { + continue + } + + subnetState := netspec.SubnetState{ + Name: sc.Name, + VM: string(sc.VM), + VMVersion: sc.VMVersion, + ChainID: parseChainID(sc.ChainID), + } + + // Check if deployed to local network + if networks := sc.Networks; networks != nil { + if localData, ok := networks["Local Network"]; ok { + subnetState.Deployed = localData.BlockchainID.String() != "" + subnetState.SubnetID = localData.SubnetID.String() + subnetState.BlockchainID = localData.BlockchainID.String() + if len(localData.RPCEndpoints) > 0 { + subnetState.RPCEndpoint = localData.RPCEndpoints[0] + } + } + } + + state.Subnets = append(state.Subnets, subnetState) + } + + // Check if network is running + state.Running, _ = isNetworkRunning() + + // Get node count if running + if state.Running { + state.Nodes = getRunningNodeCount() + } + + return state, nil +} + +// applyChanges applies the changes defined in the diff. +func applyChanges(cmd *cobra.Command, spec *netspec.NetworkSpec, diff *netspec.DiffResult, currentState *netspec.NetworkState) error { + // Handle network-level changes (restart with different node count, etc.) + if diff.NetworkChanges || diff.NeedsRestart { + if err := applyNetworkChanges(spec, currentState); err != nil { + return fmt.Errorf("failed to apply network changes: %w", err) + } + } + + // Create new subnets + for _, subnet := range diff.SubnetsToCreate { + ux.Logger.PrintToUser("Creating subnet: %s", subnet.Name) + if err := createSubnetFromSpec(cmd, subnet); err != nil { + return fmt.Errorf("failed to create subnet %s: %w", subnet.Name, err) + } + } + + // Update existing subnets + for _, subnet := range diff.SubnetsToUpdate { + ux.Logger.PrintToUser("Updating subnet: %s", subnet.Name) + if err := updateSubnetFromSpec(cmd, subnet); err != nil { + return fmt.Errorf("failed to update subnet %s: %w", subnet.Name, err) + } + } + + // Delete subnets + for _, name := range diff.SubnetsToDelete { + ux.Logger.PrintToUser("Deleting subnet: %s", name) + if err := CallDeleteBlockchain(name); err != nil { + return fmt.Errorf("failed to delete subnet %s: %w", name, err) + } + } + + // Deploy subnets that were created + for _, subnet := range diff.SubnetsToCreate { + ux.Logger.PrintToUser("Deploying subnet: %s", subnet.Name) + if err := deploySubnetFromSpec(cmd, subnet); err != nil { + return fmt.Errorf("failed to deploy subnet %s: %w", subnet.Name, err) + } + } + + return nil +} + +// applyNetworkChanges handles network-level changes like node count. +func applyNetworkChanges(spec *netspec.NetworkSpec, currentState *netspec.NetworkState) error { + // If network is running with wrong node count, restart + if currentState.Running && currentState.Nodes != spec.Network.Nodes { + ux.Logger.PrintToUser("Restarting network with %d nodes...", spec.Network.Nodes) + // Stop the network + if err := StopNetwork(nil, nil); err != nil { + return err + } + } + + // Start network with correct configuration + luxdVersion := spec.Network.LuxdVersion + if luxdVersion == "" { + luxdVersion = constants.DefaultLuxdVersion + } + + numNodes = spec.Network.Nodes + return Start(StartFlags{ + UserProvidedLuxdVersion: luxdVersion, + NumNodes: spec.Network.Nodes, + }, true) +} + +// createSubnetFromSpec creates a blockchain from a spec. +func createSubnetFromSpec(cmd *cobra.Command, subnet netspec.SubnetSpec) error { + return CallCreate( + cmd, + subnet.Name, + true, // force + subnet.Genesis, + subnet.VM == "subnet-evm", + subnet.VM == "custom", + subnet.VMVersion, + subnet.ChainID, + subnet.TokenSymbol, + subnet.ProductionDefaults, + subnet.TestDefaults, + subnet.VMVersion == "latest", + false, // pre-release + "", // custom VM repo + "", // custom VM branch + "", // custom VM build script + ) +} + +// updateSubnetFromSpec updates an existing subnet configuration. +func updateSubnetFromSpec(cmd *cobra.Command, subnet netspec.SubnetSpec) error { + // Delete and recreate to update + if err := CallDeleteBlockchain(subnet.Name); err != nil { + return err + } + return createSubnetFromSpec(cmd, subnet) +} + +// deploySubnetFromSpec deploys a subnet to the local network. +func deploySubnetFromSpec(cmd *cobra.Command, subnet netspec.SubnetSpec) error { + return CallDeploy( + cmd, + false, // not subnet only + subnet.Name, + globalNetworkFlags, // use current network flags + "", // key name + false, // use ledger + true, // use ewoq + true, // same control key + ) +} + +// isNetworkRunning checks if the local network is running. +func isNetworkRunning() (bool, error) { + // Check if the server process is running + checker := binutils.NewProcessChecker() + isRunning, err := checker.IsServerProcessRunning(app) + if err != nil { + return false, nil + } + return isRunning, nil +} + +// getRunningNodeCount returns the number of running nodes. +func getRunningNodeCount() uint32 { + // Default to 5 if we can't determine + return 5 +} + +// parseChainID parses a chain ID string to uint64. +func parseChainID(s string) uint64 { + var id uint64 + fmt.Sscanf(s, "%d", &id) + return id +} diff --git a/cmd/networkcmd/deploy.go b/cmd/networkcmd/deploy.go index ec940d022..c171dfa1f 100644 --- a/cmd/networkcmd/deploy.go +++ b/cmd/networkcmd/deploy.go @@ -78,10 +78,12 @@ var ( validatorManagerAddress string deployFlags BlockchainDeployFlags + forceRedeploy bool errMutuallyExlusiveControlKeys = errors.New("--control-keys and --same-control-key are mutually exclusive") ErrMutuallyExlusiveKeyLedger = errors.New("key source flags --key, --ledger/--ledger-addrs are mutually exclusive") ErrStoredKeyOnMainnet = errors.New("key --key is not available for mainnet operations") errMutuallyExlusiveSubnetFlags = errors.New("--subnet-only and --subnet-id are mutually exclusive") + ErrBlockchainAlreadyDeployed = errors.New("blockchain already deployed to this network") ) type BlockchainDeployFlags struct { @@ -135,6 +137,7 @@ so you can take your locally tested Blockchain and deploy it on Testnet or Mainn cmd.Flags().Uint32Var(&mainnetChainID, "mainnet-chain-id", 0, "use different ChainID for mainnet deployment") cmd.Flags().BoolVar(&subnetOnly, "subnet-only", false, "command stops after CreateSubnetTx and returns SubnetID") cmd.Flags().BoolVar(&deployFlags.ConvertOnly, "convert-only", false, "avoid node track, restart and poa manager setup") + cmd.Flags().BoolVar(&forceRedeploy, "force", false, "force redeploy even if blockchain already deployed (local network only)") localNetworkGroup := flags.RegisterFlagGroup(cmd, "Local Network Flags", "show-local-network-flags", true, func(set *pflag.FlagSet) { set.Uint32Var(&numNodes, "num-nodes", constants.LocalNetworkNumNodes, "number of nodes to be created on local network deploy") @@ -625,7 +628,22 @@ func deployBlockchain(cmd *cobra.Command, args []string) error { if b, err := localnet.BlockchainAlreadyDeployedOnLocalNetwork(app, blockchainName); err != nil { return err } else if b { - return fmt.Errorf("blockchain %s has already been deployed", blockchainName) + if forceRedeploy { + ux.Logger.PrintToUser("Blockchain %s already deployed. Force redeploying with --force...", blockchainName) + ux.Logger.PrintToUser("Running 'network clean' to reset state...") + if err := clean(nil, nil); err != nil { + return fmt.Errorf("failed to clean network for redeployment: %w", err) + } + } else { + ux.Logger.PrintToUser("") + ux.Logger.PrintToUser("Blockchain %s has already been deployed to the local network.", blockchainName) + ux.Logger.PrintToUser("") + ux.Logger.PrintToUser("Options:") + ux.Logger.PrintToUser(" 1. Use --force to clean and redeploy: lux network deploy %s --force", blockchainName) + ux.Logger.PrintToUser(" 2. Run 'lux network clean' first, then deploy again") + ux.Logger.PrintToUser(" 3. Deploy to a different network (testnet/mainnet)") + return ErrBlockchainAlreadyDeployed + } } } } diff --git a/cmd/networkcmd/network.go b/cmd/networkcmd/network.go index 418b56d22..ba78d0144 100644 --- a/cmd/networkcmd/network.go +++ b/cmd/networkcmd/network.go @@ -70,5 +70,9 @@ Use 'lux network', 'lux blockchain', or 'lux net' interchangeably.`, cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newQuickstartCmd()) + // Infrastructure as Code (IaC) commands + cmd.AddCommand(newApplyCmd()) + cmd.AddCommand(newSpecExportCmd()) + return cmd } diff --git a/cmd/networkcmd/spec_export.go b/cmd/networkcmd/spec_export.go new file mode 100644 index 000000000..70e91a987 --- /dev/null +++ b/cmd/networkcmd/spec_export.go @@ -0,0 +1,263 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package networkcmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/luxfi/cli/pkg/cobrautils" + "github.com/luxfi/cli/pkg/netspec" + "github.com/luxfi/cli/pkg/ux" + "github.com/luxfi/sdk/models" + "github.com/spf13/cobra" +) + +var ( + specExportPath string + specExportFormat string +) + +// newSpecExportCmd creates the network spec-export command. +func newSpecExportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "spec-export [networkName]", + Short: "Export current network state as a declarative specification", + Long: `Export the current network configuration to a YAML or JSON specification file. + +This allows you to: +- Version control your network configuration +- Recreate the same network on another machine +- Share network configurations with team members +- Migrate from imperative to declarative network management + +The exported specification can be applied using 'lux network apply'. + +Usage: + lux network spec-export mynetwork -o spec.yaml + lux network spec-export mynetwork -o spec.json --format json`, + RunE: specExport, + PreRunE: cobrautils.MaximumNArgs(1), + } + + cmd.Flags().StringVarP(&specExportPath, "output", "o", "", "output file path (default: stdout)") + cmd.Flags().StringVar(&specExportFormat, "format", "yaml", "output format: yaml or json") + + return cmd +} + +// specExport exports the current network state as a specification. +func specExport(cmd *cobra.Command, args []string) error { + networkName := "local" + if len(args) > 0 { + networkName = args[0] + } + + // Build spec from current state + spec, err := buildSpecFromCurrentState(networkName) + if err != nil { + return fmt.Errorf("failed to build specification: %w", err) + } + + // Determine output format + format := strings.ToLower(specExportFormat) + if specExportPath != "" { + ext := strings.ToLower(filepath.Ext(specExportPath)) + if ext == ".json" { + format = "json" + } else if ext == ".yaml" || ext == ".yml" { + format = "yaml" + } + } + + // Generate output + var data []byte + switch format { + case "json": + data, err = netspec.StateToJSON(stateToExportable(spec)) + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + default: + data, err = specToYAML(spec) + if err != nil { + return fmt.Errorf("failed to marshal YAML: %w", err) + } + } + + // Write output + if specExportPath == "" { + fmt.Print(string(data)) + } else { + if err := os.WriteFile(specExportPath, data, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + ux.Logger.GreenCheckmarkToUser("Specification exported to %s", specExportPath) + } + + return nil +} + +// buildSpecFromCurrentState builds a NetworkSpec from current deployed state. +func buildSpecFromCurrentState(networkName string) (*netspec.NetworkSpec, error) { + spec := &netspec.NetworkSpec{ + APIVersion: netspec.CurrentAPIVersion, + Kind: netspec.KindNetwork, + Network: netspec.NetworkConfig{ + Name: networkName, + Nodes: 5, // Default + }, + } + + // Get running node count + if running, _ := isNetworkRunning(); running { + spec.Network.Nodes = getRunningNodeCount() + } + + // Scan for deployed blockchains + subnetDir := app.GetSubnetDir() + entries, err := os.ReadDir(subnetDir) + if err != nil { + if os.IsNotExist(err) { + return spec, nil + } + return nil, err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + sc, err := app.LoadSidecar(entry.Name()) + if err != nil { + continue + } + + subnet := netspec.SubnetSpec{ + Name: sc.Name, + VM: vmTypeToString(sc.VM), + VMVersion: sc.VMVersion, + ChainID: parseChainID(sc.ChainID), + } + + if sc.TokenSymbol != "" { + subnet.TokenSymbol = sc.TokenSymbol + } + + // Check if sovereign + subnet.Sovereign = sc.Sovereign + + // Get validator management + if sc.ValidatorManagement != "" { + subnet.ValidatorManagement = sc.ValidatorManagement + } + + // Default validators to 3 + subnet.Validators = 3 + + spec.Network.Subnets = append(spec.Network.Subnets, subnet) + } + + return spec, nil +} + +// stateToExportable converts a spec to a NetworkState for JSON export. +func stateToExportable(spec *netspec.NetworkSpec) *netspec.NetworkState { + state := &netspec.NetworkState{ + Name: spec.Network.Name, + Nodes: spec.Network.Nodes, + } + + for _, s := range spec.Network.Subnets { + state.Subnets = append(state.Subnets, netspec.SubnetState{ + Name: s.Name, + VM: s.VM, + VMVersion: s.VMVersion, + ChainID: s.ChainID, + }) + } + + return state +} + +// specToYAML converts a NetworkSpec to YAML bytes. +func specToYAML(spec *netspec.NetworkSpec) ([]byte, error) { + // Build YAML manually for clean output + var sb strings.Builder + + sb.WriteString("# Lux Network Specification\n") + sb.WriteString("# Generated by: lux network spec-export\n") + sb.WriteString("# Apply with: lux network apply -f \n\n") + + sb.WriteString(fmt.Sprintf("apiVersion: %s\n", spec.APIVersion)) + sb.WriteString(fmt.Sprintf("kind: %s\n\n", spec.Kind)) + + sb.WriteString("network:\n") + sb.WriteString(fmt.Sprintf(" name: %s\n", spec.Network.Name)) + sb.WriteString(fmt.Sprintf(" nodes: %d\n", spec.Network.Nodes)) + + if spec.Network.LuxdVersion != "" { + sb.WriteString(fmt.Sprintf(" luxdVersion: %s\n", spec.Network.LuxdVersion)) + } + + if len(spec.Network.Subnets) > 0 { + sb.WriteString(" subnets:\n") + for _, s := range spec.Network.Subnets { + sb.WriteString(fmt.Sprintf(" - name: %s\n", s.Name)) + sb.WriteString(fmt.Sprintf(" vm: %s\n", s.VM)) + + if s.VMVersion != "" { + sb.WriteString(fmt.Sprintf(" vmVersion: %s\n", s.VMVersion)) + } + + if s.ChainID != 0 { + sb.WriteString(fmt.Sprintf(" chainId: %d\n", s.ChainID)) + } + + if s.TokenSymbol != "" { + sb.WriteString(fmt.Sprintf(" tokenSymbol: %s\n", s.TokenSymbol)) + } + + if s.Validators != 0 { + sb.WriteString(fmt.Sprintf(" validators: %d\n", s.Validators)) + } + + if s.Genesis != "" { + sb.WriteString(fmt.Sprintf(" genesis: %s\n", s.Genesis)) + } + + if s.Sovereign { + sb.WriteString(" sovereign: true\n") + } + + if s.ValidatorManagement != "" { + sb.WriteString(fmt.Sprintf(" validatorManagement: %s\n", s.ValidatorManagement)) + } + + if s.TestDefaults { + sb.WriteString(" testDefaults: true\n") + } + + if s.ProductionDefaults { + sb.WriteString(" productionDefaults: true\n") + } + } + } + + return []byte(sb.String()), nil +} + +// vmTypeToString converts a VMType to string. +func vmTypeToString(vm models.VMType) string { + switch vm { + case models.SubnetEvm: + return "subnet-evm" + case models.CustomVM: + return "custom" + default: + return "subnet-evm" + } +} diff --git a/cmd/networkcmd/start.go b/cmd/networkcmd/start.go index e9c28a50b..4e449ec78 100644 --- a/cmd/networkcmd/start.go +++ b/cmd/networkcmd/start.go @@ -12,6 +12,7 @@ import ( "github.com/luxfi/cli/pkg/binutils" "github.com/luxfi/cli/pkg/constants" + "github.com/luxfi/cli/pkg/localnet" "github.com/luxfi/cli/pkg/subnet" "github.com/luxfi/cli/pkg/ux" "github.com/luxfi/cli/pkg/vm" @@ -24,9 +25,15 @@ import ( var ( userProvidedLuxVersion string + luxdBinaryPath string snapshotName string mainnet bool testnet bool + forceStart bool + // State persistence flags + noSnapshot bool // Start without loading saved snapshot (fresh start) + // Dev mode flag for automatic subnet tracking + devMode bool // When true, track all subnets automatically // BadgerDB flags dbEngine string archiveDir string @@ -65,9 +72,15 @@ already running.`, } cmd.Flags().StringVar(&userProvidedLuxVersion, "node-version", latest, "use this version of node (ex: v1.17.12)") + cmd.Flags().StringVar(&luxdBinaryPath, "luxd-path", "", "use this luxd binary path instead of downloading") cmd.Flags().StringVar(&snapshotName, "snapshot-name", constants.DefaultSnapshotName, "name of snapshot to use to start the network from") cmd.Flags().BoolVar(&mainnet, "mainnet", false, "start a mainnet node with 21 validators") cmd.Flags().BoolVar(&testnet, "testnet", false, "start a testnet node with 11 validators") + cmd.Flags().BoolVar(&forceStart, "force", false, "force restart even if network is already running") + // State persistence flags + cmd.Flags().BoolVar(&noSnapshot, "no-snapshot", false, "start with a fresh network, ignoring any saved snapshot state") + // Dev mode flag for automatic subnet tracking + cmd.Flags().BoolVar(&devMode, "dev-mode", false, "enable dev mode: automatically track all subnets (equivalent to --track-subnets=all)") // BadgerDB flags cmd.Flags().StringVar(&dbEngine, "db-backend", "", "database backend to use (pebble, leveldb, or badgerdb)") cmd.Flags().StringVar(&archiveDir, "archive-path", "", "path to BadgerDB archive database (enables dual-database mode)") @@ -200,6 +213,15 @@ func StartNetwork(*cobra.Command, []string) error { ux.PrintTableEndpoints(clusterInfo) } + // Persist dev mode setting if enabled + if devMode { + if err := localnet.SetDevMode(app, true); err != nil { + ux.Logger.PrintToUser("Warning: failed to persist dev mode setting: %v", err) + } else { + ux.Logger.PrintToUser("Dev mode enabled: all subnets will be automatically tracked") + } + } + return nil } diff --git a/cmd/networkcmd/stop.go b/cmd/networkcmd/stop.go index 238f459c0..4803a1048 100644 --- a/cmd/networkcmd/stop.go +++ b/cmd/networkcmd/stop.go @@ -3,8 +3,6 @@ package networkcmd import ( - "fmt" - "github.com/luxfi/cli/pkg/binutils" "github.com/luxfi/cli/pkg/constants" "github.com/luxfi/cli/pkg/ux" @@ -14,6 +12,11 @@ import ( "go.uber.org/zap" ) +var ( + forceStop bool + noSaveState bool +) + func newStopCmd() *cobra.Command { cmd := &cobra.Command{ Use: "stop", @@ -24,27 +27,63 @@ All deployed Subnets shutdown gracefully and save their state. If you provide th --snapshot-name flag, the network saves its state under this named snapshot. You can reload this snapshot with network start --snapshot-name . Otherwise, the network saves to the default snapshot, overwriting any existing state. You can reload the -default snapshot with network start.`, +default snapshot with network start. + +This command is idempotent: if the network is not running, it reports this status +instead of failing.`, RunE: StopNetwork, Args: cobra.ExactArgs(0), SilenceUsage: true, } cmd.Flags().StringVar(&snapshotName, "snapshot-name", constants.DefaultSnapshotName, "name of snapshot to use to save network state into") + cmd.Flags().BoolVar(&forceStop, "force", false, "force stop without saving snapshot") + cmd.Flags().BoolVar(&noSaveState, "no-save", false, "stop without saving network state (alias for --force)") return cmd } func StopNetwork(*cobra.Command, []string) error { - err := saveNetwork() + // Check if the server process is running first (idempotent check) + checker := binutils.NewProcessChecker() + isRunning, err := checker.IsServerProcessRunning(app) + if err != nil { + app.Log.Debug("could not check server process", zap.Error(err)) + } + + if !isRunning { + ux.Logger.PrintToUser("Network is not running.") + return nil + } + + // Save network state unless --force or --no-save is specified + if !forceStop && !noSaveState { + if err := saveNetwork(); err != nil { + // If network wasn't bootstrapped, that's fine - no state to save + if !isNotBootstrappedError(err) { + return err + } + } + } else { + ux.Logger.PrintToUser("Stopping without saving state (--force/--no-save specified)") + } if err := binutils.KillgRPCServerProcess(app); err != nil { app.Log.Warn("failed killing server process", zap.Error(err)) - fmt.Println(err) + // Don't return error - process might already be dead + ux.Logger.PrintToUser("Warning: %v", err) } else { ux.Logger.PrintToUser("Server shutdown gracefully") } - return err + return nil +} + +// isNotBootstrappedError checks if the error indicates the network wasn't bootstrapped +func isNotBootstrappedError(err error) bool { + if err == nil { + return false + } + return server.IsServerError(err, server.ErrNotBootstrapped) } func saveNetwork() error { @@ -64,13 +103,13 @@ func saveNetwork() error { // it we try to stop a network with a new snapshot name, remove snapshot // will fail, so we cover here that expected case if !server.IsServerError(err, local.ErrSnapshotNotFound) { - return fmt.Errorf("failed stop network with a snapshot: %w", err) + return err } } _, err = cli.SaveSnapshot(ctx, snapshotName) if err != nil { - return fmt.Errorf("failed to stop network with a snapshot: %w", err) + return err } ux.Logger.PrintToUser("Network stopped successfully.") diff --git a/cmd/networkcmd/stop_test.go b/cmd/networkcmd/stop_test.go new file mode 100644 index 000000000..7e59ad716 --- /dev/null +++ b/cmd/networkcmd/stop_test.go @@ -0,0 +1,37 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package networkcmd + +import ( + "testing" + + "github.com/luxfi/netrunner/server" + "github.com/stretchr/testify/require" +) + +func Test_isNotBootstrappedError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "not bootstrapped error", + err: server.ErrNotBootstrapped, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNotBootstrappedError(tt.err) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/examples/network-spec.yaml b/examples/network-spec.yaml new file mode 100644 index 000000000..b3edf444b --- /dev/null +++ b/examples/network-spec.yaml @@ -0,0 +1,45 @@ +# Lux Network Specification +# This is an example Infrastructure as Code (IaC) specification +# for declaratively managing Lux networks. +# +# Apply with: lux network apply -f network-spec.yaml +# Export current state: lux network spec-export -o current-state.yaml + +apiVersion: lux.network/v1 +kind: Network + +network: + # Network name - must be unique + name: mydevnet + + # Number of validator nodes (default: 5) + nodes: 5 + + # Luxd version (optional, default: latest) + # luxdVersion: v1.20.3 + + # Subnets/Blockchains to deploy + subnets: + # Example 1: Simple EVM chain with test defaults + - name: mychain + vm: subnet-evm + chainId: 12345 + tokenSymbol: MYT + validators: 3 + testDefaults: true + + # Example 2: Production-ready EVM chain + - name: prodchain + vm: subnet-evm + chainId: 54321 + tokenSymbol: PRD + validators: 5 + productionDefaults: true + sovereign: true + validatorManagement: proof-of-authority + + # Example 3: Custom genesis file + # - name: customchain + # vm: subnet-evm + # genesis: ./custom-genesis.json + # validators: 3 diff --git a/pkg/netspec/diff.go b/pkg/netspec/diff.go new file mode 100644 index 000000000..7b4395bf9 --- /dev/null +++ b/pkg/netspec/diff.go @@ -0,0 +1,142 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package netspec + +import ( + "fmt" + "strings" +) + +// Diff compares desired spec against current state and returns needed changes. +// This enables idempotent apply - only changes what's different. +func Diff(desired *NetworkSpec, current *NetworkState) *DiffResult { + result := &DiffResult{} + var changes []string + + // Check if network exists + if current == nil || current.Name == "" { + result.NetworkChanges = true + for _, s := range desired.Network.Subnets { + result.SubnetsToCreate = append(result.SubnetsToCreate, s) + } + result.Summary = fmt.Sprintf("Network %q does not exist. Will create with %d nodes and %d subnets.", + desired.Network.Name, desired.Network.Nodes, len(desired.Network.Subnets)) + return result + } + + // Check node count changes + if desired.Network.Nodes != current.Nodes { + result.NetworkChanges = true + result.NeedsRestart = true + changes = append(changes, fmt.Sprintf("nodes: %d -> %d", current.Nodes, desired.Network.Nodes)) + } + + // Build map of current subnets + currentSubnets := make(map[string]*SubnetState) + for i := range current.Subnets { + s := ¤t.Subnets[i] + currentSubnets[s.Name] = s + } + + // Check for subnets to create or update + desiredSubnets := make(map[string]bool) + for _, desired := range desired.Network.Subnets { + desiredSubnets[desired.Name] = true + + if existing, ok := currentSubnets[desired.Name]; ok { + // Check if update needed + if needsUpdate(desired, existing) { + result.SubnetsToUpdate = append(result.SubnetsToUpdate, desired) + changes = append(changes, fmt.Sprintf("update subnet %q", desired.Name)) + } + } else { + // Subnet doesn't exist, needs creation + result.SubnetsToCreate = append(result.SubnetsToCreate, desired) + changes = append(changes, fmt.Sprintf("create subnet %q", desired.Name)) + } + } + + // Check for subnets to delete (in current but not in desired) + for name := range currentSubnets { + if !desiredSubnets[name] { + result.SubnetsToDelete = append(result.SubnetsToDelete, name) + changes = append(changes, fmt.Sprintf("delete subnet %q", name)) + } + } + + // Build summary + if len(changes) == 0 { + result.Summary = "Network is up to date. No changes needed." + } else { + result.Summary = fmt.Sprintf("Changes needed: %s", strings.Join(changes, ", ")) + } + + return result +} + +// needsUpdate checks if a subnet's configuration differs from current state. +func needsUpdate(desired SubnetSpec, current *SubnetState) bool { + // Check VM type + if desired.VM != current.VM { + return true + } + + // Check VM version if specified + if desired.VMVersion != "" && desired.VMVersion != current.VMVersion { + return true + } + + // Check chain ID for EVM chains + if desired.ChainID != 0 && desired.ChainID != current.ChainID { + return true + } + + return false +} + +// HasChanges returns true if there are any changes to apply. +func (d *DiffResult) HasChanges() bool { + return d.NetworkChanges || + len(d.SubnetsToCreate) > 0 || + len(d.SubnetsToUpdate) > 0 || + len(d.SubnetsToDelete) > 0 +} + +// String returns a human-readable representation of the diff. +func (d *DiffResult) String() string { + if !d.HasChanges() { + return "No changes needed." + } + + var lines []string + + if d.NetworkChanges { + lines = append(lines, "Network configuration changes required") + } + + if len(d.SubnetsToCreate) > 0 { + names := make([]string, len(d.SubnetsToCreate)) + for i, s := range d.SubnetsToCreate { + names[i] = s.Name + } + lines = append(lines, fmt.Sprintf("Subnets to create: %s", strings.Join(names, ", "))) + } + + if len(d.SubnetsToUpdate) > 0 { + names := make([]string, len(d.SubnetsToUpdate)) + for i, s := range d.SubnetsToUpdate { + names[i] = s.Name + } + lines = append(lines, fmt.Sprintf("Subnets to update: %s", strings.Join(names, ", "))) + } + + if len(d.SubnetsToDelete) > 0 { + lines = append(lines, fmt.Sprintf("Subnets to delete: %s", strings.Join(d.SubnetsToDelete, ", "))) + } + + if d.NeedsRestart { + lines = append(lines, "Network restart required") + } + + return strings.Join(lines, "\n") +} diff --git a/pkg/netspec/diff_test.go b/pkg/netspec/diff_test.go new file mode 100644 index 000000000..5cd2ff656 --- /dev/null +++ b/pkg/netspec/diff_test.go @@ -0,0 +1,302 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package netspec + +import ( + "testing" +) + +func TestDiff_NilState(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "newnet", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "chain1", VM: "subnet-evm"}, + {Name: "chain2", VM: "custom"}, + }, + }, + } + + diff := Diff(spec, nil) + + if !diff.NetworkChanges { + t.Error("expected NetworkChanges to be true for nil state") + } + if len(diff.SubnetsToCreate) != 2 { + t.Errorf("expected 2 subnets to create, got %d", len(diff.SubnetsToCreate)) + } + if !diff.HasChanges() { + t.Error("expected HasChanges() to return true") + } +} + +func TestDiff_EmptyState(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "newnet", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "chain1", VM: "subnet-evm"}, + }, + }, + } + + state := &NetworkState{Name: ""} + + diff := Diff(spec, state) + + if !diff.NetworkChanges { + t.Error("expected NetworkChanges to be true for empty state") + } +} + +func TestDiff_NoChanges(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "existing", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "chain1", VM: "subnet-evm", VMVersion: "v1.0.0"}, + }, + }, + } + + state := &NetworkState{ + Name: "existing", + Nodes: 5, + Subnets: []SubnetState{ + {Name: "chain1", VM: "subnet-evm", VMVersion: "v1.0.0", Deployed: true}, + }, + } + + diff := Diff(spec, state) + + if diff.HasChanges() { + t.Error("expected no changes for matching state") + } + if diff.NetworkChanges { + t.Error("expected NetworkChanges to be false") + } +} + +func TestDiff_NodeCountChange(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "testnet", + Nodes: 7, + }, + } + + state := &NetworkState{ + Name: "testnet", + Nodes: 5, + } + + diff := Diff(spec, state) + + if !diff.NetworkChanges { + t.Error("expected NetworkChanges for node count change") + } + if !diff.NeedsRestart { + t.Error("expected NeedsRestart for node count change") + } +} + +func TestDiff_CreateSubnet(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "existing", VM: "subnet-evm"}, + {Name: "new", VM: "custom"}, + }, + }, + } + + state := &NetworkState{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetState{ + {Name: "existing", VM: "subnet-evm"}, + }, + } + + diff := Diff(spec, state) + + if len(diff.SubnetsToCreate) != 1 { + t.Errorf("expected 1 subnet to create, got %d", len(diff.SubnetsToCreate)) + } + if diff.SubnetsToCreate[0].Name != "new" { + t.Errorf("expected subnet 'new' to create, got %q", diff.SubnetsToCreate[0].Name) + } +} + +func TestDiff_UpdateSubnet(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "chain1", VM: "subnet-evm", VMVersion: "v2.0.0"}, + }, + }, + } + + state := &NetworkState{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetState{ + {Name: "chain1", VM: "subnet-evm", VMVersion: "v1.0.0"}, + }, + } + + diff := Diff(spec, state) + + if len(diff.SubnetsToUpdate) != 1 { + t.Errorf("expected 1 subnet to update, got %d", len(diff.SubnetsToUpdate)) + } +} + +func TestDiff_DeleteSubnet(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "keep", VM: "subnet-evm"}, + }, + }, + } + + state := &NetworkState{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetState{ + {Name: "keep", VM: "subnet-evm"}, + {Name: "remove", VM: "custom"}, + }, + } + + diff := Diff(spec, state) + + if len(diff.SubnetsToDelete) != 1 { + t.Errorf("expected 1 subnet to delete, got %d", len(diff.SubnetsToDelete)) + } + if diff.SubnetsToDelete[0] != "remove" { + t.Errorf("expected subnet 'remove' to delete, got %q", diff.SubnetsToDelete[0]) + } +} + +func TestDiff_VMTypeChange(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "chain1", VM: "custom"}, + }, + }, + } + + state := &NetworkState{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetState{ + {Name: "chain1", VM: "subnet-evm"}, + }, + } + + diff := Diff(spec, state) + + if len(diff.SubnetsToUpdate) != 1 { + t.Errorf("expected 1 subnet to update for VM type change, got %d", len(diff.SubnetsToUpdate)) + } +} + +func TestDiff_ChainIDChange(t *testing.T) { + spec := &NetworkSpec{ + Network: NetworkConfig{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetSpec{ + {Name: "chain1", VM: "subnet-evm", ChainID: 99999}, + }, + }, + } + + state := &NetworkState{ + Name: "testnet", + Nodes: 5, + Subnets: []SubnetState{ + {Name: "chain1", VM: "subnet-evm", ChainID: 12345}, + }, + } + + diff := Diff(spec, state) + + if len(diff.SubnetsToUpdate) != 1 { + t.Errorf("expected 1 subnet to update for chain ID change, got %d", len(diff.SubnetsToUpdate)) + } +} + +func TestDiffResult_String(t *testing.T) { + diff := &DiffResult{ + NetworkChanges: true, + SubnetsToCreate: []SubnetSpec{{Name: "new1"}, {Name: "new2"}}, + SubnetsToUpdate: []SubnetSpec{{Name: "upd1"}}, + SubnetsToDelete: []string{"del1"}, + NeedsRestart: true, + } + + str := diff.String() + if str == "" { + t.Error("expected non-empty string") + } + if str == "No changes needed." { + t.Error("expected changes description") + } +} + +func TestDiffResult_HasChanges(t *testing.T) { + tests := []struct { + name string + diff DiffResult + expected bool + }{ + { + name: "no changes", + diff: DiffResult{}, + expected: false, + }, + { + name: "network changes only", + diff: DiffResult{NetworkChanges: true}, + expected: true, + }, + { + name: "create only", + diff: DiffResult{SubnetsToCreate: []SubnetSpec{{Name: "x"}}}, + expected: true, + }, + { + name: "update only", + diff: DiffResult{SubnetsToUpdate: []SubnetSpec{{Name: "x"}}}, + expected: true, + }, + { + name: "delete only", + diff: DiffResult{SubnetsToDelete: []string{"x"}}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.diff.HasChanges(); got != tt.expected { + t.Errorf("HasChanges() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/pkg/netspec/parser.go b/pkg/netspec/parser.go new file mode 100644 index 000000000..60268d8b4 --- /dev/null +++ b/pkg/netspec/parser.go @@ -0,0 +1,210 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package netspec + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + // CurrentAPIVersion is the current schema version + CurrentAPIVersion = "lux.network/v1" + + // KindNetwork is the kind for network specs + KindNetwork = "Network" +) + +// ParseFile reads and parses a network specification from a file. +// Supports both YAML and JSON formats based on file extension. +func ParseFile(path string) (*NetworkSpec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read spec file: %w", err) + } + + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".yaml", ".yml": + return ParseYAML(data) + case ".json": + return ParseJSON(data) + default: + // Try YAML first, fall back to JSON + spec, err := ParseYAML(data) + if err != nil { + return ParseJSON(data) + } + return spec, nil + } +} + +// ParseYAML parses a YAML network specification. +func ParseYAML(data []byte) (*NetworkSpec, error) { + var spec NetworkSpec + if err := yaml.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + if err := validate(&spec); err != nil { + return nil, err + } + return &spec, nil +} + +// ParseJSON parses a JSON network specification. +func ParseJSON(data []byte) (*NetworkSpec, error) { + var spec NetworkSpec + if err := json.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + if err := validate(&spec); err != nil { + return nil, err + } + return &spec, nil +} + +// validate checks that a NetworkSpec is well-formed. +func validate(spec *NetworkSpec) error { + // Check API version + if spec.APIVersion == "" { + spec.APIVersion = CurrentAPIVersion + } + if spec.APIVersion != CurrentAPIVersion { + return fmt.Errorf("unsupported apiVersion %q, expected %q", spec.APIVersion, CurrentAPIVersion) + } + + // Check kind + if spec.Kind == "" { + spec.Kind = KindNetwork + } + if spec.Kind != KindNetwork { + return fmt.Errorf("unsupported kind %q, expected %q", spec.Kind, KindNetwork) + } + + // Validate network + if spec.Network.Name == "" { + return fmt.Errorf("network.name is required") + } + + // Validate name format (letters, numbers, spaces only) + for _, r := range spec.Network.Name { + if r > 127 || !(isLetter(r) || isDigit(r) || r == ' ' || r == '-' || r == '_') { + return fmt.Errorf("network.name contains invalid character: %c", r) + } + } + + // Default nodes to 5 if not specified + if spec.Network.Nodes == 0 { + spec.Network.Nodes = 5 + } + + // Validate subnets + subnetNames := make(map[string]bool) + for i := range spec.Network.Subnets { + subnet := &spec.Network.Subnets[i] + + if subnet.Name == "" { + return fmt.Errorf("subnet[%d].name is required", i) + } + + // Check for duplicate names + if subnetNames[subnet.Name] { + return fmt.Errorf("duplicate subnet name: %s", subnet.Name) + } + subnetNames[subnet.Name] = true + + // Validate VM type + if subnet.VM == "" { + subnet.VM = "subnet-evm" + } + if !isValidVM(subnet.VM) { + return fmt.Errorf("subnet %q has invalid vm: %s", subnet.Name, subnet.VM) + } + + // Default validators to 3 if not specified + if subnet.Validators == 0 { + subnet.Validators = 3 + } + + // Ensure validators don't exceed network nodes + if subnet.Validators > spec.Network.Nodes { + return fmt.Errorf("subnet %q validators (%d) exceeds network nodes (%d)", + subnet.Name, subnet.Validators, spec.Network.Nodes) + } + + // Validate validator management if specified + if subnet.ValidatorManagement != "" && !isValidValidatorManagement(subnet.ValidatorManagement) { + return fmt.Errorf("subnet %q has invalid validatorManagement: %s", subnet.Name, subnet.ValidatorManagement) + } + + // Check genesis file exists if specified + if subnet.Genesis != "" { + if _, err := os.Stat(subnet.Genesis); err != nil { + return fmt.Errorf("subnet %q genesis file not found: %s", subnet.Name, subnet.Genesis) + } + } + } + + return nil +} + +// isValidVM checks if a VM type is supported. +func isValidVM(vm string) bool { + for _, v := range ValidVMs { + if v == vm { + return true + } + } + return false +} + +// isValidValidatorManagement checks if a validator management type is supported. +func isValidValidatorManagement(vm string) bool { + for _, v := range ValidValidatorManagement { + if v == vm { + return true + } + } + return false +} + +func isLetter(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +func isDigit(r rune) bool { + return r >= '0' && r <= '9' +} + +// WriteYAML writes a network specification to YAML format. +func WriteYAML(spec *NetworkSpec, path string) error { + data, err := yaml.Marshal(spec) + if err != nil { + return fmt.Errorf("failed to marshal YAML: %w", err) + } + return os.WriteFile(path, data, 0644) +} + +// WriteJSON writes a network specification to JSON format. +func WriteJSON(spec *NetworkSpec, path string) error { + data, err := json.MarshalIndent(spec, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + return os.WriteFile(path, data, 0644) +} + +// StateToYAML converts a NetworkState to YAML. +func StateToYAML(state *NetworkState) ([]byte, error) { + return yaml.Marshal(state) +} + +// StateToJSON converts a NetworkState to JSON. +func StateToJSON(state *NetworkState) ([]byte, error) { + return json.MarshalIndent(state, "", " ") +} diff --git a/pkg/netspec/parser_test.go b/pkg/netspec/parser_test.go new file mode 100644 index 000000000..48c8d0f4a --- /dev/null +++ b/pkg/netspec/parser_test.go @@ -0,0 +1,332 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package netspec + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseYAML(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + check func(*testing.T, *NetworkSpec) + }{ + { + name: "valid minimal spec", + input: ` +apiVersion: lux.network/v1 +kind: Network +network: + name: testnet +`, + wantErr: false, + check: func(t *testing.T, spec *NetworkSpec) { + if spec.Network.Name != "testnet" { + t.Errorf("expected name 'testnet', got %q", spec.Network.Name) + } + if spec.Network.Nodes != 5 { + t.Errorf("expected default nodes 5, got %d", spec.Network.Nodes) + } + }, + }, + { + name: "valid full spec", + input: ` +apiVersion: lux.network/v1 +kind: Network +network: + name: mydevnet + nodes: 7 + luxdVersion: v1.20.3 + subnets: + - name: mychain + vm: subnet-evm + chainId: 12345 + tokenSymbol: MYT + validators: 3 + testDefaults: true + - name: customchain + vm: custom + validators: 5 +`, + wantErr: false, + check: func(t *testing.T, spec *NetworkSpec) { + if spec.Network.Name != "mydevnet" { + t.Errorf("expected name 'mydevnet', got %q", spec.Network.Name) + } + if spec.Network.Nodes != 7 { + t.Errorf("expected nodes 7, got %d", spec.Network.Nodes) + } + if len(spec.Network.Subnets) != 2 { + t.Errorf("expected 2 subnets, got %d", len(spec.Network.Subnets)) + } + if spec.Network.Subnets[0].ChainID != 12345 { + t.Errorf("expected chainId 12345, got %d", spec.Network.Subnets[0].ChainID) + } + }, + }, + { + name: "defaults applied", + input: ` +network: + name: test + subnets: + - name: chain1 +`, + wantErr: false, + check: func(t *testing.T, spec *NetworkSpec) { + if spec.APIVersion != CurrentAPIVersion { + t.Errorf("expected apiVersion %q, got %q", CurrentAPIVersion, spec.APIVersion) + } + if spec.Kind != KindNetwork { + t.Errorf("expected kind %q, got %q", KindNetwork, spec.Kind) + } + if spec.Network.Subnets[0].VM != "subnet-evm" { + t.Errorf("expected default VM 'subnet-evm', got %q", spec.Network.Subnets[0].VM) + } + if spec.Network.Subnets[0].Validators != 3 { + t.Errorf("expected default validators 3, got %d", spec.Network.Subnets[0].Validators) + } + }, + }, + { + name: "missing name", + input: ` +apiVersion: lux.network/v1 +kind: Network +network: + nodes: 5 +`, + wantErr: true, + }, + { + name: "invalid API version", + input: ` +apiVersion: wrong/v1 +kind: Network +network: + name: test +`, + wantErr: true, + }, + { + name: "invalid kind", + input: ` +apiVersion: lux.network/v1 +kind: WrongKind +network: + name: test +`, + wantErr: true, + }, + { + name: "invalid VM type", + input: ` +network: + name: test + subnets: + - name: chain1 + vm: invalid-vm +`, + wantErr: true, + }, + { + name: "validators exceed nodes", + input: ` +network: + name: test + nodes: 3 + subnets: + - name: chain1 + validators: 5 +`, + wantErr: true, + }, + { + name: "duplicate subnet names", + input: ` +network: + name: test + subnets: + - name: chain1 + - name: chain1 +`, + wantErr: true, + }, + { + name: "invalid name character", + input: ` +network: + name: test@network +`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := ParseYAML([]byte(tt.input)) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if tt.check != nil { + tt.check(t, spec) + } + }) + } +} + +func TestParseJSON(t *testing.T) { + input := `{ + "apiVersion": "lux.network/v1", + "kind": "Network", + "network": { + "name": "jsonnet", + "nodes": 3, + "subnets": [ + { + "name": "mychain", + "vm": "subnet-evm", + "chainId": 99999 + } + ] + } + }` + + spec, err := ParseJSON([]byte(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if spec.Network.Name != "jsonnet" { + t.Errorf("expected name 'jsonnet', got %q", spec.Network.Name) + } + if spec.Network.Nodes != 3 { + t.Errorf("expected nodes 3, got %d", spec.Network.Nodes) + } + if len(spec.Network.Subnets) != 1 { + t.Errorf("expected 1 subnet, got %d", len(spec.Network.Subnets)) + } +} + +func TestParseFile(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + + // Test YAML file + yamlPath := filepath.Join(tmpDir, "spec.yaml") + yamlContent := ` +network: + name: filetest + nodes: 5 +` + if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil { + t.Fatalf("failed to write yaml file: %v", err) + } + + spec, err := ParseFile(yamlPath) + if err != nil { + t.Fatalf("failed to parse yaml file: %v", err) + } + if spec.Network.Name != "filetest" { + t.Errorf("expected name 'filetest', got %q", spec.Network.Name) + } + + // Test JSON file + jsonPath := filepath.Join(tmpDir, "spec.json") + jsonContent := `{"network": {"name": "jsonfile", "nodes": 3}}` + if err := os.WriteFile(jsonPath, []byte(jsonContent), 0644); err != nil { + t.Fatalf("failed to write json file: %v", err) + } + + spec, err = ParseFile(jsonPath) + if err != nil { + t.Fatalf("failed to parse json file: %v", err) + } + if spec.Network.Name != "jsonfile" { + t.Errorf("expected name 'jsonfile', got %q", spec.Network.Name) + } +} + +func TestWriteYAML(t *testing.T) { + spec := &NetworkSpec{ + APIVersion: CurrentAPIVersion, + Kind: KindNetwork, + Network: NetworkConfig{ + Name: "writetest", + Nodes: 5, + Subnets: []SubnetSpec{ + { + Name: "chain1", + VM: "subnet-evm", + ChainID: 12345, + TokenSymbol: "TST", + Validators: 3, + }, + }, + }, + } + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "out.yaml") + + if err := WriteYAML(spec, path); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + // Read it back + readSpec, err := ParseFile(path) + if err != nil { + t.Fatalf("failed to read back yaml: %v", err) + } + + if readSpec.Network.Name != spec.Network.Name { + t.Errorf("name mismatch: got %q, want %q", readSpec.Network.Name, spec.Network.Name) + } + if len(readSpec.Network.Subnets) != len(spec.Network.Subnets) { + t.Errorf("subnet count mismatch: got %d, want %d", + len(readSpec.Network.Subnets), len(spec.Network.Subnets)) + } +} + +func TestWriteJSON(t *testing.T) { + spec := &NetworkSpec{ + APIVersion: CurrentAPIVersion, + Kind: KindNetwork, + Network: NetworkConfig{ + Name: "jsonwrite", + Nodes: 7, + }, + } + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "out.json") + + if err := WriteJSON(spec, path); err != nil { + t.Fatalf("failed to write json: %v", err) + } + + // Read it back + readSpec, err := ParseFile(path) + if err != nil { + t.Fatalf("failed to read back json: %v", err) + } + + if readSpec.Network.Name != spec.Network.Name { + t.Errorf("name mismatch: got %q, want %q", readSpec.Network.Name, spec.Network.Name) + } + if readSpec.Network.Nodes != spec.Network.Nodes { + t.Errorf("nodes mismatch: got %d, want %d", readSpec.Network.Nodes, spec.Network.Nodes) + } +} diff --git a/pkg/netspec/types.go b/pkg/netspec/types.go new file mode 100644 index 000000000..38b1267e8 --- /dev/null +++ b/pkg/netspec/types.go @@ -0,0 +1,140 @@ +// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package netspec + +// NetworkSpec defines a declarative network specification for IaC. +// This is version-controllable and idempotent. +type NetworkSpec struct { + // APIVersion for schema compatibility + APIVersion string `yaml:"apiVersion" json:"apiVersion"` + + // Kind identifies this as a network specification + Kind string `yaml:"kind" json:"kind"` + + // Network configuration + Network NetworkConfig `yaml:"network" json:"network"` +} + +// NetworkConfig defines the network-level configuration. +type NetworkConfig struct { + // Name is the unique identifier for this network + Name string `yaml:"name" json:"name"` + + // Nodes is the number of validator nodes to run + Nodes uint32 `yaml:"nodes" json:"nodes"` + + // LuxdVersion specifies the node version (e.g., "v1.20.3", "latest") + LuxdVersion string `yaml:"luxdVersion,omitempty" json:"luxdVersion,omitempty"` + + // Subnets defines the blockchains to deploy + Subnets []SubnetSpec `yaml:"subnets,omitempty" json:"subnets,omitempty"` +} + +// SubnetSpec defines a subnet/blockchain configuration. +type SubnetSpec struct { + // Name is the blockchain name + Name string `yaml:"name" json:"name"` + + // VM specifies the virtual machine type (subnet-evm, custom) + VM string `yaml:"vm" json:"vm"` + + // VMVersion specifies the VM version (optional, defaults to latest) + VMVersion string `yaml:"vmVersion,omitempty" json:"vmVersion,omitempty"` + + // Genesis path to genesis file (optional) + Genesis string `yaml:"genesis,omitempty" json:"genesis,omitempty"` + + // Validators is the number of validators for this subnet + Validators uint32 `yaml:"validators,omitempty" json:"validators,omitempty"` + + // ChainID for EVM-based chains (optional) + ChainID uint64 `yaml:"chainId,omitempty" json:"chainId,omitempty"` + + // TokenSymbol for the native token (optional) + TokenSymbol string `yaml:"tokenSymbol,omitempty" json:"tokenSymbol,omitempty"` + + // Sovereign indicates if this is an L1 (true) or L2/subnet (false) + Sovereign bool `yaml:"sovereign,omitempty" json:"sovereign,omitempty"` + + // ValidatorManagement specifies proof-of-authority or proof-of-stake + ValidatorManagement string `yaml:"validatorManagement,omitempty" json:"validatorManagement,omitempty"` + + // TestDefaults uses test-optimized settings + TestDefaults bool `yaml:"testDefaults,omitempty" json:"testDefaults,omitempty"` + + // ProductionDefaults uses production-optimized settings + ProductionDefaults bool `yaml:"productionDefaults,omitempty" json:"productionDefaults,omitempty"` +} + +// NetworkState represents the current deployed state of a network. +// Used for diffing against desired state. +type NetworkState struct { + // Name of the network + Name string `yaml:"name" json:"name"` + + // Running indicates if the network is currently running + Running bool `yaml:"running" json:"running"` + + // Nodes is the count of running nodes + Nodes uint32 `yaml:"nodes" json:"nodes"` + + // LuxdVersion is the current node version + LuxdVersion string `yaml:"luxdVersion,omitempty" json:"luxdVersion,omitempty"` + + // Subnets lists deployed blockchains + Subnets []SubnetState `yaml:"subnets,omitempty" json:"subnets,omitempty"` +} + +// SubnetState represents the deployed state of a subnet. +type SubnetState struct { + // Name of the blockchain + Name string `yaml:"name" json:"name"` + + // SubnetID is the deployed subnet ID + SubnetID string `yaml:"subnetId,omitempty" json:"subnetId,omitempty"` + + // BlockchainID is the deployed blockchain ID + BlockchainID string `yaml:"blockchainId,omitempty" json:"blockchainId,omitempty"` + + // VM type + VM string `yaml:"vm" json:"vm"` + + // VMVersion currently running + VMVersion string `yaml:"vmVersion,omitempty" json:"vmVersion,omitempty"` + + // ChainID for EVM chains + ChainID uint64 `yaml:"chainId,omitempty" json:"chainId,omitempty"` + + // Deployed indicates if this subnet is deployed + Deployed bool `yaml:"deployed" json:"deployed"` + + // RPCEndpoint for the blockchain + RPCEndpoint string `yaml:"rpcEndpoint,omitempty" json:"rpcEndpoint,omitempty"` +} + +// DiffResult represents differences between desired and current state. +type DiffResult struct { + // NetworkChanges indicates if network-level changes are needed + NetworkChanges bool `yaml:"networkChanges" json:"networkChanges"` + + // SubnetsToCreate lists subnets that need to be created + SubnetsToCreate []SubnetSpec `yaml:"subnetsToCreate,omitempty" json:"subnetsToCreate,omitempty"` + + // SubnetsToUpdate lists subnets that need configuration updates + SubnetsToUpdate []SubnetSpec `yaml:"subnetsToUpdate,omitempty" json:"subnetsToUpdate,omitempty"` + + // SubnetsToDelete lists subnets that should be removed + SubnetsToDelete []string `yaml:"subnetsToDelete,omitempty" json:"subnetsToDelete,omitempty"` + + // NeedsRestart indicates if the network needs restart + NeedsRestart bool `yaml:"needsRestart" json:"needsRestart"` + + // Summary provides a human-readable description + Summary string `yaml:"summary" json:"summary"` +} + +// ValidVMs lists supported VM types. +var ValidVMs = []string{"subnet-evm", "custom"} + +// ValidValidatorManagement lists supported validator management types. +var ValidValidatorManagement = []string{"proof-of-authority", "proof-of-stake"}