feat(sensor-proxy): complete Phase 2 with CLI-based config migration

Add `config migrate-to-file` command and update installer to eliminate
all shell/Python config manipulation, ensuring atomic operations throughout.

Changes:
- Add `config migrate-to-file` command to atomically migrate inline
  allowed_nodes blocks to file-based configuration
- Update installer's update_allowed_nodes() to call CLI exclusively
- Simplify migrate_inline_allowed_nodes_to_file() to use CLI
- Remove dependency on Python/sed for config manipulation
- Implement dual-file locking (config.yaml + allowed_nodes.yaml) to
  prevent race conditions during migration

All config mutations now flow through the Phase 2 CLI with:
- File locking (flock)
- Atomic writes (temp + rename + fsync)
- Proper YAML parsing/generation

This completes Phase 2 architecture and eliminates the root cause of
config corruption issues.

Related to prior commits: 53dec6010, 3dc073a28, 804a638ea, 131666bc1
This commit is contained in:
rcourtman 2025-11-19 10:35:49 +00:00
parent 3250de8f6d
commit c1185ebc9d
2 changed files with 128 additions and 48 deletions

View file

@ -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)
})
})
}

View file

@ -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=()