diff --git a/cmd/pulse-sensor-proxy/config_cmd.go b/cmd/pulse-sensor-proxy/config_cmd.go index 27bd1cc..59a7a22 100644 --- a/cmd/pulse-sensor-proxy/config_cmd.go +++ b/cmd/pulse-sensor-proxy/config_cmd.go @@ -104,10 +104,46 @@ Examples: }, } +var migrateToFileCmd = &cobra.Command{ + Use: "migrate-to-file", + Short: "Migrate inline allowed_nodes to file-based configuration", + Long: `Atomically migrates inline allowed_nodes block from config.yaml to allowed_nodes.yaml. + +This command: +- Extracts nodes from inline allowed_nodes block in config.yaml +- Removes the inline block from config.yaml +- Ensures allowed_nodes_file is set in config.yaml +- Writes nodes to allowed_nodes.yaml +- All operations are atomic with file locking + +Safe to run multiple times (idempotent). +`, + RunE: func(cmd *cobra.Command, args []string) error { + cfgPath := configPathFlag + if cfgPath == "" { + cfgPath = defaultConfigPath + } + + allowedNodesPath := allowedNodesPathFlag + if allowedNodesPath == "" { + allowedNodesPath = filepath.Join(filepath.Dir(cfgPath), "allowed_nodes.yaml") + } + + if err := migrateInlineToFile(cfgPath, allowedNodesPath); err != nil { + fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err) + return err + } + + fmt.Println("Migration complete: inline allowed_nodes moved to file") + return nil + }, +} + func init() { // Add subcommands to config command configCmd.AddCommand(validateCmd) configCmd.AddCommand(setAllowedNodesCmd) + configCmd.AddCommand(migrateToFileCmd) // Validate command flags validateCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml (default: /etc/pulse-sensor-proxy/config.yaml)") @@ -118,6 +154,10 @@ func init() { setAllowedNodesCmd.Flags().StringSliceVar(&mergeNodesFlag, "merge", []string{}, "Node to merge (can be specified multiple times)") setAllowedNodesCmd.Flags().BoolVar(&replaceMode, "replace", false, "Replace entire list instead of merging") + // Migrate-to-file command flags + migrateToFileCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml (default: /etc/pulse-sensor-proxy/config.yaml)") + migrateToFileCmd.Flags().StringVar(&allowedNodesPathFlag, "allowed-nodes", "", "Path to allowed_nodes.yaml (default: same dir as config)") + // Add config command to root rootCmd.AddCommand(configCmd) } @@ -351,3 +391,80 @@ func extractNodesFromYAML(data []byte) []string { return nodes } + +// migrateInlineToFile atomically migrates inline allowed_nodes from config.yaml to allowed_nodes.yaml +func migrateInlineToFile(configPath, allowedNodesPath string) error { + configLockPath := configPath + ".lock" + allowedNodesLockPath := allowedNodesPath + ".lock" + + // Lock both files in consistent order to prevent deadlocks + // Always lock config.yaml before allowed_nodes.yaml + return withLockedFile(configLockPath, func(configLock *os.File) error { + return withLockedFile(allowedNodesLockPath, func(allowedNodesLock *os.File) error { + // Read current config + configData, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config: %w", err) + } + + // Parse config to extract inline nodes + var config map[string]interface{} + if err := yaml.Unmarshal(configData, &config); err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + // Extract inline nodes (if any) + var inlineNodes []string + if allowedNodes, ok := config["allowed_nodes"]; ok { + if nodeList, ok := allowedNodes.([]interface{}); ok { + for _, node := range nodeList { + if s, ok := node.(string); ok && s != "" { + inlineNodes = append(inlineNodes, s) + } + } + } + } + + // Remove inline allowed_nodes block from config + delete(config, "allowed_nodes") + + // Ensure allowed_nodes_file is set + if _, exists := config["allowed_nodes_file"]; !exists { + config["allowed_nodes_file"] = allowedNodesPath + } + + // Write updated config atomically + newConfigData, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := atomicWriteFile(configPath, newConfigData, 0644); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + // Merge inline nodes with existing file nodes (if any) + var existingNodes []string + if data, err := os.ReadFile(allowedNodesPath); err == nil { + existingNodes = extractNodesFromYAML(data) + } + + // Combine and deduplicate + allNodes := normalizeNodes(append(existingNodes, inlineNodes...)) + + // Write allowed_nodes.yaml atomically + output := map[string]interface{}{ + "allowed_nodes": allNodes, + } + allowedNodesData, err := yaml.Marshal(output) + if err != nil { + return fmt.Errorf("failed to marshal allowed_nodes: %w", err) + } + + header := "# Managed by pulse-sensor-proxy config CLI\n# Do not edit manually while service is running\n" + finalData := []byte(header + string(allowedNodesData)) + + return atomicWriteFile(allowedNodesPath, finalData, 0644) + }) + }) +} diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index 49675eb..1917c5a 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -312,57 +312,19 @@ PY } migrate_inline_allowed_nodes_to_file() { - # Extract any inline allowed_nodes from config.yaml and migrate them to allowed_nodes.yaml - # This is called during install/upgrade to ensure we never have inline blocks - + # Phase 2: Use config CLI for migration - no Python manipulation if [[ ! -f "$CONFIG_FILE" ]]; then return fi - if ! command -v python3 >/dev/null 2>&1; then - print_warn "python3 not available; skipping inline allowed_nodes migration" + if [[ ! -x "$BINARY_PATH" ]]; then + print_warn "Binary not available yet; skipping migration" return fi - # Extract inline nodes if they exist - local inline_nodes - inline_nodes=$(python3 - "$CONFIG_FILE" <<'PY' -import sys -from pathlib import Path -import yaml - -path = Path(sys.argv[1]) -if not path.exists(): - sys.exit(0) - -try: - data = yaml.safe_load(path.read_text()) - if isinstance(data, dict) and 'allowed_nodes' in data: - nodes = data.get('allowed_nodes', []) - if isinstance(nodes, list): - for node in nodes: - print(node) -except yaml.YAMLError: - pass -PY - ) - - if [[ -n "$inline_nodes" ]]; then - # We found inline nodes - migrate them to the file - local -a nodes_array - mapfile -t nodes_array <<<"$inline_nodes" - - if [[ ${#nodes_array[@]} -gt 0 ]]; then - print_info "Migrating ${#nodes_array[@]} inline allowed_nodes entries to ${ALLOWED_NODES_FILE}" - - # Add them to the file (update_allowed_nodes will merge with existing) - update_allowed_nodes "Migrated from inline config" "${nodes_array[@]}" - - # Now remove the inline block from config.yaml - normalize_allowed_nodes_section - - print_success "Migration complete: inline allowed_nodes moved to file" - fi + # Use CLI to atomically migrate inline nodes to file + if "$BINARY_PATH" config migrate-to-file --config "$CONFIG_FILE" --allowed-nodes "$ALLOWED_NODES_FILE"; then + print_success "Migration complete: inline allowed_nodes moved to file" fi } @@ -649,10 +611,11 @@ update_allowed_nodes() { shift local nodes=("$@") - # File mode is now required - inline mode has been removed - # Phase 2: Use config CLI instead of shell/Python manipulation - ensure_allowed_nodes_file_reference - remove_allowed_nodes_block + # Phase 2: Use config CLI exclusively - no shell/Python manipulation + # First, migrate any inline allowed_nodes to file mode + if "$BINARY_PATH" config migrate-to-file --config "$CONFIG_FILE" --allowed-nodes "$ALLOWED_NODES_FILE" 2>/dev/null; then + print_info "Migrated inline allowed_nodes to file mode" + fi # Build --merge flags for the CLI local merge_args=()