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() {
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=()
|
||||
|
|
|
|||
Loading…
Reference in a new issue