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:
parent
3250de8f6d
commit
c1185ebc9d
2 changed files with 128 additions and 48 deletions
|
|
@ -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() {
|
func init() {
|
||||||
// Add subcommands to config command
|
// Add subcommands to config command
|
||||||
configCmd.AddCommand(validateCmd)
|
configCmd.AddCommand(validateCmd)
|
||||||
configCmd.AddCommand(setAllowedNodesCmd)
|
configCmd.AddCommand(setAllowedNodesCmd)
|
||||||
|
configCmd.AddCommand(migrateToFileCmd)
|
||||||
|
|
||||||
// Validate command flags
|
// Validate command flags
|
||||||
validateCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml (default: /etc/pulse-sensor-proxy/config.yaml)")
|
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().StringSliceVar(&mergeNodesFlag, "merge", []string{}, "Node to merge (can be specified multiple times)")
|
||||||
setAllowedNodesCmd.Flags().BoolVar(&replaceMode, "replace", false, "Replace entire list instead of merging")
|
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
|
// Add config command to root
|
||||||
rootCmd.AddCommand(configCmd)
|
rootCmd.AddCommand(configCmd)
|
||||||
}
|
}
|
||||||
|
|
@ -351,3 +391,80 @@ func extractNodesFromYAML(data []byte) []string {
|
||||||
|
|
||||||
return nodes
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -312,57 +312,19 @@ PY
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate_inline_allowed_nodes_to_file() {
|
migrate_inline_allowed_nodes_to_file() {
|
||||||
# Extract any inline allowed_nodes from config.yaml and migrate them to allowed_nodes.yaml
|
# Phase 2: Use config CLI for migration - no Python manipulation
|
||||||
# This is called during install/upgrade to ensure we never have inline blocks
|
|
||||||
|
|
||||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
if [[ ! -x "$BINARY_PATH" ]]; then
|
||||||
print_warn "python3 not available; skipping inline allowed_nodes migration"
|
print_warn "Binary not available yet; skipping migration"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Extract inline nodes if they exist
|
# Use CLI to atomically migrate inline nodes to file
|
||||||
local inline_nodes
|
if "$BINARY_PATH" config migrate-to-file --config "$CONFIG_FILE" --allowed-nodes "$ALLOWED_NODES_FILE"; then
|
||||||
inline_nodes=$(python3 - "$CONFIG_FILE" <<'PY'
|
print_success "Migration complete: inline allowed_nodes moved to file"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -649,10 +611,11 @@ update_allowed_nodes() {
|
||||||
shift
|
shift
|
||||||
local nodes=("$@")
|
local nodes=("$@")
|
||||||
|
|
||||||
# File mode is now required - inline mode has been removed
|
# Phase 2: Use config CLI exclusively - no shell/Python manipulation
|
||||||
# Phase 2: Use config CLI instead of shell/Python manipulation
|
# First, migrate any inline allowed_nodes to file mode
|
||||||
ensure_allowed_nodes_file_reference
|
if "$BINARY_PATH" config migrate-to-file --config "$CONFIG_FILE" --allowed-nodes "$ALLOWED_NODES_FILE" 2>/dev/null; then
|
||||||
remove_allowed_nodes_block
|
print_info "Migrated inline allowed_nodes to file mode"
|
||||||
|
fi
|
||||||
|
|
||||||
# Build --merge flags for the CLI
|
# Build --merge flags for the CLI
|
||||||
local merge_args=()
|
local merge_args=()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue