417 lines
12 KiB
Go
417 lines
12 KiB
Go
package edgecmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/cobra"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"iop/packages/go/config"
|
|
"iop/packages/go/version"
|
|
)
|
|
|
|
var (
|
|
regAlias string
|
|
regToken string
|
|
regAdapter string
|
|
regOllamaBaseURL string
|
|
regOllamaContextSize int
|
|
regRuntimeConcurrency int
|
|
regTarget string
|
|
regArtifactBaseURL string
|
|
)
|
|
|
|
var tokenGenerator = func() string {
|
|
return uuid.NewString()
|
|
}
|
|
|
|
func refreshBootstrapForExistingArtifact(cfg *config.EdgeConfig, target, artifactURL string) error {
|
|
outputDir := ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
|
|
adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost)
|
|
bootstrapDir := filepath.Join(outputDir, "bootstrap")
|
|
baseOpts := nodeBootstrapPackOptions{
|
|
OutputDir: outputDir,
|
|
Version: version.Version,
|
|
ArtifactBaseURL: artifactURL,
|
|
EdgeAddr: addressFor(adv.Host, cfg.Server.Listen),
|
|
}
|
|
if err := writeUniversalBootstrapScript(bootstrapDir, baseOpts); err != nil {
|
|
return err
|
|
}
|
|
if strings.HasPrefix(target, "windows-") {
|
|
targetOpts := baseOpts
|
|
targetOpts.Target = target
|
|
psScriptPath := filepath.Join(bootstrapDir, "node-"+target+".ps1")
|
|
if err := os.WriteFile(psScriptPath, []byte(nodeBootstrapPowerShellScript(targetOpts)), 0o644); err != nil {
|
|
return fmt.Errorf("write powershell bootstrap script: %w", err)
|
|
}
|
|
}
|
|
|
|
nodeBinary := filepath.Join(outputDir, target, "iop-node")
|
|
if _, err := os.Stat(nodeBinary); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("stat node artifact: %w", err)
|
|
}
|
|
targetOpts := baseOpts
|
|
targetOpts.NodeBinary = nodeBinary
|
|
targetOpts.Target = target
|
|
return packNodeBootstrap(targetOpts)
|
|
}
|
|
|
|
func nodeCmd() *cobra.Command {
|
|
c := &cobra.Command{
|
|
Use: "node",
|
|
Short: "Manage edge nodes",
|
|
Long: `Manage node records stored in edge.yaml.
|
|
|
|
Use node register to create or update the Node definition and print the
|
|
one-line bootstrap command for the target host. After registration, run
|
|
iop-edge config check and then start iop-edge serve.`,
|
|
Example: ` iop-edge node register node-silicon-ollama --adapter ollama
|
|
iop-edge config check
|
|
iop-edge serve`,
|
|
}
|
|
c.AddCommand(nodeRegisterCmd())
|
|
return c
|
|
}
|
|
|
|
func nodeRegisterCmd() *cobra.Command {
|
|
c := &cobra.Command{
|
|
Use: "register [node-id]",
|
|
Short: "Register or update a node record",
|
|
Long: `Register or update a node record in edge.yaml and print a one-line bootstrap command.
|
|
|
|
The command writes the node id, alias, token, adapter, and runtime settings into
|
|
edge.yaml. The printed bootstrap command is the user-facing command to run on
|
|
the Node host; the user should not create node.yaml or run iop-node serve
|
|
directly in the default flow.`,
|
|
Example: ` iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434
|
|
iop-edge node register node-linux-cli --adapter cli
|
|
iop-edge config check`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runNodeRegister,
|
|
}
|
|
|
|
c.Flags().StringVar(®Alias, "alias", "", "node alias (defaults to node-id)")
|
|
c.Flags().StringVar(®Token, "token", "", "node token (defaults to auto-generated uuid)")
|
|
c.Flags().StringVar(®Adapter, "adapter", "cli", "node adapter: ollama or cli")
|
|
c.Flags().StringVar(®OllamaBaseURL, "ollama-base-url", "", "ollama base URL")
|
|
c.Flags().IntVar(®OllamaContextSize, "ollama-context-size", 2048, "ollama context size")
|
|
c.Flags().IntVar(®RuntimeConcurrency, "runtime-concurrency", 1, "legacy runtime concurrency metadata")
|
|
c.Flags().StringVar(®Target, "target", "", "bootstrap target platform (e.g. darwin-arm64, linux-amd64)")
|
|
c.Flags().StringVar(®ArtifactBaseURL, "artifact-base-url", "", "artifact base URL for bootstrap download")
|
|
|
|
return c
|
|
}
|
|
|
|
// runNodeRegister loads edge.yaml, applies the requested node definition, resolves
|
|
// the bootstrap target/artifact URL, persists the config, and prints the one-line
|
|
// bootstrap command. Each step is delegated to a focused helper below.
|
|
func runNodeRegister(cmd *cobra.Command, args []string) error {
|
|
nodeID := args[0]
|
|
|
|
path, err := ResolveConfigPath(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfgPtr, err := config.LoadEdge(path)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
cfg := *cfgPtr
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read config file: %w", err)
|
|
}
|
|
|
|
if err := validateNodeRegisterFlags(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
nodeDef := applyNodeDefinition(cmd, &cfg, nodeID)
|
|
|
|
target := resolveRegisterTarget(cmd)
|
|
artifactURL := resolveRegisterArtifactURL(cmd, &cfg)
|
|
|
|
if err := validateEdgeConfig(&cfg); err != nil {
|
|
return fmt.Errorf("validate node register: %w", err)
|
|
}
|
|
if err := refreshBootstrapForExistingArtifact(&cfg, target, artifactURL); err != nil {
|
|
return fmt.Errorf("refresh bootstrap artifacts: %w", err)
|
|
}
|
|
|
|
if err := writeEdgeConfigFile(path, data, &cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintln(cmd.OutOrStdout(), bootstrapCommand(artifactURL, target, nodeDef.Token))
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateNodeRegisterFlags rejects invalid --adapter and --runtime-concurrency values.
|
|
func validateNodeRegisterFlags(cmd *cobra.Command) error {
|
|
if cmd.Flags().Changed("adapter") {
|
|
if regAdapter != "ollama" && regAdapter != "cli" {
|
|
return fmt.Errorf("invalid adapter %q: must be ollama or cli", regAdapter)
|
|
}
|
|
}
|
|
if cmd.Flags().Changed("runtime-concurrency") && regRuntimeConcurrency < 0 {
|
|
return fmt.Errorf("runtime concurrency must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyNodeDefinition updates the existing node record for nodeID in place, or appends
|
|
// a new one, and returns a pointer to the affected definition inside cfg.Nodes.
|
|
func applyNodeDefinition(cmd *cobra.Command, cfg *config.EdgeConfig, nodeID string) *config.NodeDefinition {
|
|
for i := range cfg.Nodes {
|
|
if cfg.Nodes[i].ID == nodeID {
|
|
nodeDef := &cfg.Nodes[i]
|
|
updateExistingNodeDefinition(cmd, nodeDef)
|
|
return nodeDef
|
|
}
|
|
}
|
|
|
|
cfg.Nodes = append(cfg.Nodes, buildNewNodeDefinition(cmd, nodeID))
|
|
return &cfg.Nodes[len(cfg.Nodes)-1]
|
|
}
|
|
|
|
// updateExistingNodeDefinition applies only the flags the caller changed onto an
|
|
// existing node record, backfilling a token when the record has none.
|
|
func updateExistingNodeDefinition(cmd *cobra.Command, nodeDef *config.NodeDefinition) {
|
|
if cmd.Flags().Changed("alias") {
|
|
nodeDef.Alias = regAlias
|
|
}
|
|
if cmd.Flags().Changed("token") {
|
|
nodeDef.Token = regToken
|
|
}
|
|
|
|
if cmd.Flags().Changed("adapter") {
|
|
if regAdapter == "ollama" {
|
|
nodeDef.Adapters.Ollama.Enabled = true
|
|
nodeDef.Adapters.CLI.Enabled = false
|
|
if nodeDef.Adapters.Ollama.BaseURL == "" && regOllamaBaseURL == "" {
|
|
nodeDef.Adapters.Ollama.BaseURL = "http://127.0.0.1:11434"
|
|
}
|
|
} else if regAdapter == "cli" {
|
|
nodeDef.Adapters.CLI.Enabled = true
|
|
nodeDef.Adapters.Ollama.Enabled = false
|
|
}
|
|
}
|
|
|
|
if cmd.Flags().Changed("ollama-base-url") {
|
|
nodeDef.Adapters.Ollama.BaseURL = regOllamaBaseURL
|
|
}
|
|
if cmd.Flags().Changed("ollama-context-size") {
|
|
nodeDef.Adapters.Ollama.ContextSize = regOllamaContextSize
|
|
}
|
|
|
|
if cmd.Flags().Changed("runtime-concurrency") {
|
|
nodeDef.Runtime.Concurrency = regRuntimeConcurrency
|
|
}
|
|
|
|
if nodeDef.Token == "" {
|
|
nodeDef.Token = tokenGenerator()
|
|
}
|
|
}
|
|
|
|
// buildNewNodeDefinition constructs a fresh node record from defaults and changed flags.
|
|
func buildNewNodeDefinition(cmd *cobra.Command, nodeID string) config.NodeDefinition {
|
|
alias := nodeID
|
|
if cmd.Flags().Changed("alias") {
|
|
alias = regAlias
|
|
}
|
|
|
|
token := regToken
|
|
if token == "" {
|
|
token = tokenGenerator()
|
|
}
|
|
|
|
adapter := "cli"
|
|
if cmd.Flags().Changed("adapter") {
|
|
adapter = regAdapter
|
|
}
|
|
|
|
var ollama config.OllamaConf
|
|
var cli config.CLIConf
|
|
|
|
if adapter == "ollama" {
|
|
ollama.Enabled = true
|
|
ollama.BaseURL = "http://127.0.0.1:11434"
|
|
if cmd.Flags().Changed("ollama-base-url") {
|
|
ollama.BaseURL = regOllamaBaseURL
|
|
}
|
|
ollama.ContextSize = 2048
|
|
if cmd.Flags().Changed("ollama-context-size") {
|
|
ollama.ContextSize = regOllamaContextSize
|
|
}
|
|
} else {
|
|
cli.Enabled = true
|
|
}
|
|
|
|
concurrency := 1
|
|
if cmd.Flags().Changed("runtime-concurrency") {
|
|
concurrency = regRuntimeConcurrency
|
|
}
|
|
|
|
return config.NodeDefinition{
|
|
ID: nodeID,
|
|
Alias: alias,
|
|
Token: token,
|
|
Adapters: config.AdaptersConf{
|
|
Ollama: ollama,
|
|
CLI: cli,
|
|
},
|
|
Runtime: config.RuntimeConf{
|
|
Concurrency: concurrency,
|
|
},
|
|
}
|
|
}
|
|
|
|
// resolveRegisterTarget returns the bootstrap target platform, defaulting to the host.
|
|
func resolveRegisterTarget(cmd *cobra.Command) string {
|
|
target := runtime.GOOS + "-" + runtime.GOARCH
|
|
if cmd.Flags().Changed("target") {
|
|
target = regTarget
|
|
}
|
|
return target
|
|
}
|
|
|
|
// resolveRegisterArtifactURL resolves the artifact base URL, persisting an explicit
|
|
// --artifact-base-url override into cfg and falling back to the advertised default.
|
|
func resolveRegisterArtifactURL(cmd *cobra.Command, cfg *config.EdgeConfig) string {
|
|
artifactURL := cfg.Bootstrap.ArtifactBaseURL
|
|
if cmd.Flags().Changed("artifact-base-url") {
|
|
artifactURL = regArtifactBaseURL
|
|
cfg.Bootstrap.ArtifactBaseURL = regArtifactBaseURL
|
|
}
|
|
if artifactURL == "" {
|
|
artifactURL = defaultArtifactBaseURL(resolveAdvertiseHost(cfg.Server.AdvertiseHost).Host, cfg.Bootstrap.Listen)
|
|
}
|
|
return artifactURL
|
|
}
|
|
|
|
// writeEdgeConfigFile renders the updated config back onto the original YAML document
|
|
// with preserving-patch semantics and writes it with 0600 permissions.
|
|
func writeEdgeConfigFile(path string, data []byte, cfg *config.EdgeConfig) error {
|
|
outData, err := patchYAML(data, cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("patch config: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, outData, 0o600); err != nil {
|
|
return fmt.Errorf("write config file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func patchYAML(data []byte, cfg *config.EdgeConfig) ([]byte, error) {
|
|
var doc yaml.Node
|
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
|
return nil, fmt.Errorf("unmarshal yaml node: %w", err)
|
|
}
|
|
|
|
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
|
|
return nil, errors.New("invalid yaml structure: root is not a map")
|
|
}
|
|
|
|
rootMap := doc.Content[0]
|
|
|
|
nodesBytes, err := yaml.Marshal(cfg.Nodes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal nodes: %w", err)
|
|
}
|
|
var tempDoc yaml.Node
|
|
if err := yaml.Unmarshal(nodesBytes, &tempDoc); err != nil {
|
|
return nil, fmt.Errorf("unmarshal nodes temp doc: %w", err)
|
|
}
|
|
var nodesValNode *yaml.Node
|
|
if len(tempDoc.Content) > 0 {
|
|
nodesValNode = tempDoc.Content[0]
|
|
} else {
|
|
nodesValNode = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
}
|
|
|
|
foundNodes := false
|
|
for i := 0; i < len(rootMap.Content); i += 2 {
|
|
if rootMap.Content[i].Value == "nodes" {
|
|
rootMap.Content[i+1] = nodesValNode
|
|
foundNodes = true
|
|
break
|
|
}
|
|
}
|
|
if !foundNodes {
|
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "nodes"}
|
|
rootMap.Content = append(rootMap.Content, keyNode, nodesValNode)
|
|
}
|
|
|
|
fieldsToPatch := make(map[string]string)
|
|
if cfg.Bootstrap.ArtifactBaseURL != "" {
|
|
fieldsToPatch["artifact_base_url"] = cfg.Bootstrap.ArtifactBaseURL
|
|
}
|
|
patchBootstrapStringFields(rootMap, fieldsToPatch)
|
|
|
|
out, err := yaml.Marshal(&doc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal patched doc: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func patchBootstrapStringFields(rootMap *yaml.Node, fields map[string]string) {
|
|
if len(fields) == 0 {
|
|
return
|
|
}
|
|
|
|
var bootstrapVal *yaml.Node
|
|
foundBootstrap := false
|
|
for i := 0; i < len(rootMap.Content); i += 2 {
|
|
if rootMap.Content[i].Value == "bootstrap" {
|
|
bootstrapVal = rootMap.Content[i+1]
|
|
foundBootstrap = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !foundBootstrap {
|
|
bootstrapKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bootstrap"}
|
|
bootstrapVal = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
rootMap.Content = append(rootMap.Content, bootstrapKey, bootstrapVal)
|
|
}
|
|
|
|
if bootstrapVal.Kind != yaml.MappingNode {
|
|
return
|
|
}
|
|
|
|
keys := []string{"artifact_base_url"}
|
|
for _, k := range keys {
|
|
v, ok := fields[k]
|
|
if !ok || v == "" {
|
|
continue
|
|
}
|
|
foundField := false
|
|
for j := 0; j < len(bootstrapVal.Content); j += 2 {
|
|
if bootstrapVal.Content[j].Value == k {
|
|
bootstrapVal.Content[j+1].Value = v
|
|
bootstrapVal.Content[j+1].Tag = "!!str"
|
|
foundField = true
|
|
break
|
|
}
|
|
}
|
|
if !foundField {
|
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}
|
|
valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v}
|
|
bootstrapVal.Content = append(bootstrapVal.Content, keyNode, valNode)
|
|
}
|
|
}
|
|
}
|