286 lines
8.3 KiB
Markdown
286 lines
8.3 KiB
Markdown
|
|
# CTRL-C Handling Implementation Notes
|
|||
|
|
|
|||
|
|
## Overview
|
|||
|
|
|
|||
|
|
Implemented graceful CTRL-C handling for sudo password prompts during server creation/generation operations.
|
|||
|
|
|
|||
|
|
## Problem Statement
|
|||
|
|
|
|||
|
|
When `fix_local_hosts: true` is set, the provisioning tool requires sudo access to modify `/etc/hosts` and SSH config. When a user cancels the sudo password prompt (no password, wrong password, timeout), the system would:
|
|||
|
|
|
|||
|
|
1. Exit with code 1 (sudo failed)
|
|||
|
|
2. Propagate null values up the call stack
|
|||
|
|
3. Show cryptic Nushell errors about pipeline failures
|
|||
|
|
4. Leave the operation in an inconsistent state
|
|||
|
|
|
|||
|
|
**Important Unix Limitation**: Pressing CTRL-C at the sudo password prompt sends SIGINT to the entire process group, interrupting Nushell before exit code handling can occur. This **cannot be caught** and is expected Unix behavior.
|
|||
|
|
|
|||
|
|
## Solution Architecture
|
|||
|
|
|
|||
|
|
### Key Principle: Return Values, Not Exit Codes
|
|||
|
|
|
|||
|
|
Instead of using `exit 130` which kills the entire process, we use **return values** to signal cancellation and let each layer of the call stack handle it gracefully.
|
|||
|
|
|
|||
|
|
### Three-Layer Approach
|
|||
|
|
|
|||
|
|
1. **Detection Layer** (ssh.nu helper functions)
|
|||
|
|
- Detects sudo cancellation via exit code + stderr
|
|||
|
|
- Returns `false` instead of calling `exit`
|
|||
|
|
|
|||
|
|
2. **Propagation Layer** (ssh.nu core functions)
|
|||
|
|
- `on_server_ssh()`: Returns `false` on cancellation
|
|||
|
|
- `server_ssh()`: Uses `reduce` to propagate failures
|
|||
|
|
|
|||
|
|
3. **Handling Layer** (create.nu, generate.nu)
|
|||
|
|
- Checks return values
|
|||
|
|
- Displays user-friendly messages
|
|||
|
|
- Returns `false` to caller
|
|||
|
|
|
|||
|
|
## Implementation Details
|
|||
|
|
|
|||
|
|
### 1. Helper Functions (ssh.nu:11-32)
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
def check_sudo_cached []: nothing -> bool {
|
|||
|
|
let result = (do --ignore-errors { ^sudo -n true } | complete)
|
|||
|
|
$result.exit_code == 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def run_sudo_with_interrupt_check [
|
|||
|
|
command: closure
|
|||
|
|
operation_name: string
|
|||
|
|
]: nothing -> bool {
|
|||
|
|
let result = (do --ignore-errors { do $command } | complete)
|
|||
|
|
if $result.exit_code == 1 and ($result.stderr | str contains "password is required") {
|
|||
|
|
print "\n⚠ Operation cancelled - sudo password required but not provided"
|
|||
|
|
print "ℹ Run 'sudo -v' first to cache credentials, or run without --fix-local-hosts"
|
|||
|
|
return false # Signal cancellation
|
|||
|
|
} else if $result.exit_code != 0 and $result.exit_code != 1 {
|
|||
|
|
error make {msg: $"($operation_name) failed: ($result.stderr)"}
|
|||
|
|
}
|
|||
|
|
true
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Design Decision**: Return `bool` instead of throwing error or calling `exit`. This allows the caller to decide how to handle cancellation.
|
|||
|
|
|
|||
|
|
### 2. Pre-emptive Warning (ssh.nu:155-160)
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
if $server.fix_local_hosts and not (check_sudo_cached) {
|
|||
|
|
print "\n⚠ Sudo access required for --fix-local-hosts"
|
|||
|
|
print "ℹ You will be prompted for your password, or press CTRL-C to cancel"
|
|||
|
|
print " Tip: Run 'sudo -v' beforehand to cache credentials\n"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Design Decision**: Warn users upfront so they're not surprised by the password prompt.
|
|||
|
|
|
|||
|
|
### 3. CTRL-C Detection (ssh.nu:171-199)
|
|||
|
|
|
|||
|
|
All sudo commands wrapped with detection:
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
let result = (do --ignore-errors { ^sudo <command> } | complete)
|
|||
|
|
if $result.exit_code == 1 and ($result.stderr | str contains "password is required") {
|
|||
|
|
print "\n⚠ Operation cancelled"
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Design Decision**: Use `do --ignore-errors` + `complete` to capture both exit code and stderr without throwing exceptions.
|
|||
|
|
|
|||
|
|
### 4. State Accumulation Pattern (ssh.nu:122-129)
|
|||
|
|
|
|||
|
|
Using Nushell's `reduce` instead of mutable variables:
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
let all_succeeded = ($settings.data.servers | reduce -f true { |server, acc|
|
|||
|
|
if $text_match == null or $server.hostname == $text_match {
|
|||
|
|
let result = (on_server_ssh $settings $server $ip_type $request_from $run)
|
|||
|
|
$acc and $result
|
|||
|
|
} else {
|
|||
|
|
$acc
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Design Decision**: Nushell doesn't allow mutable variable capture in closures. Use `reduce` for accumulating boolean state across iterations.
|
|||
|
|
|
|||
|
|
### 5. Caller Handling (create.nu:262-266, generate.nu:269-273)
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
let ssh_result = (on_server_ssh $settings $server "pub" "create" false)
|
|||
|
|
if not $ssh_result {
|
|||
|
|
_print "\n✗ Server creation cancelled"
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Design Decision**: Check return value and provide context-specific message before returning.
|
|||
|
|
|
|||
|
|
## Error Flow Diagram
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
User presses CTRL-C during password prompt
|
|||
|
|
↓
|
|||
|
|
sudo exits with code 1, stderr: "password is required"
|
|||
|
|
↓
|
|||
|
|
do --ignore-errors captures exit code & stderr
|
|||
|
|
↓
|
|||
|
|
Detection logic identifies cancellation
|
|||
|
|
↓
|
|||
|
|
Print user-friendly message
|
|||
|
|
↓
|
|||
|
|
Return false (not exit!)
|
|||
|
|
↓
|
|||
|
|
on_server_ssh returns false
|
|||
|
|
↓
|
|||
|
|
Caller (create.nu/generate.nu) checks return value
|
|||
|
|
↓
|
|||
|
|
Print "✗ Server creation cancelled"
|
|||
|
|
↓
|
|||
|
|
Return false to settings.nu
|
|||
|
|
↓
|
|||
|
|
settings.nu handles false gracefully (no append)
|
|||
|
|
↓
|
|||
|
|
Clean exit, no cryptic errors
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Nushell Idioms Used
|
|||
|
|
|
|||
|
|
### 1. `do --ignore-errors` + `complete`
|
|||
|
|
|
|||
|
|
Captures both stdout, stderr, and exit code without throwing:
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
let result = (do --ignore-errors { ^sudo command } | complete)
|
|||
|
|
# result = { stdout: "...", stderr: "...", exit_code: 1 }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. `reduce` for Accumulation
|
|||
|
|
|
|||
|
|
Instead of mutable variables in loops:
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
# ❌ BAD - mutable capture in closure
|
|||
|
|
mut all_succeeded = true
|
|||
|
|
$servers | each { |s|
|
|||
|
|
$all_succeeded = false # Error: capture of mutable variable
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ✅ GOOD - reduce with accumulator
|
|||
|
|
let all_succeeded = ($servers | reduce -f true { |s, acc|
|
|||
|
|
$acc and (check_server $s)
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. Early Returns for Error Handling
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
if not $condition {
|
|||
|
|
print "Error message"
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
# Continue with happy path
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Testing Scenarios
|
|||
|
|
|
|||
|
|
### Scenario 1: CTRL-C During First Sudo Command
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
provisioning -c server create
|
|||
|
|
# Password: [CTRL-C]
|
|||
|
|
|
|||
|
|
# Expected Output:
|
|||
|
|
# ⚠ Operation cancelled - sudo password required but not provided
|
|||
|
|
# ℹ Run 'sudo -v' first to cache credentials
|
|||
|
|
# ✗ Server creation cancelled
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Scenario 2: Pre-cached Credentials
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
sudo -v
|
|||
|
|
provisioning -c server create
|
|||
|
|
|
|||
|
|
# Expected: No password prompt, smooth operation
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Scenario 3: Wrong Password 3 Times
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
provisioning -c server create
|
|||
|
|
# Password: [wrong]
|
|||
|
|
# Password: [wrong]
|
|||
|
|
# Password: [wrong]
|
|||
|
|
|
|||
|
|
# Expected: Same as CTRL-C (treated as cancellation)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Scenario 4: Multiple Servers, Cancel on Second
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# If creating multiple servers and CTRL-C on second:
|
|||
|
|
# - First server completes successfully
|
|||
|
|
# - Second server shows cancellation message
|
|||
|
|
# - Operation stops, doesn't proceed to third
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Maintenance Notes
|
|||
|
|
|
|||
|
|
### Adding New Sudo Commands
|
|||
|
|
|
|||
|
|
When adding new sudo commands to the codebase:
|
|||
|
|
|
|||
|
|
1. Wrap with `do --ignore-errors` + `complete`
|
|||
|
|
2. Check for exit code 1 + "password is required"
|
|||
|
|
3. Return `false` on cancellation
|
|||
|
|
4. Let caller handle the `false` return value
|
|||
|
|
|
|||
|
|
Example template:
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
let result = (do --ignore-errors { ^sudo new-command } | complete)
|
|||
|
|
if $result.exit_code == 1 and ($result.stderr | str contains "password is required") {
|
|||
|
|
print "\n⚠ Operation cancelled - sudo password required"
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Common Pitfalls
|
|||
|
|
|
|||
|
|
1. **Don't use `exit`**: It kills the entire process
|
|||
|
|
2. **Don't use mutable variables in closures**: Use `reduce` instead
|
|||
|
|
3. **Don't ignore return values**: Always check and propagate
|
|||
|
|
4. **Don't forget the pre-check warning**: Users should know sudo is needed
|
|||
|
|
|
|||
|
|
## Future Improvements
|
|||
|
|
|
|||
|
|
1. **Sudo Credential Manager**: Optionally use a credential manager (keychain, etc.)
|
|||
|
|
2. **Sudo-less Mode**: Alternative implementation that doesn't require root
|
|||
|
|
3. **Timeout Handling**: Detect when sudo times out waiting for password
|
|||
|
|
4. **Multiple Password Attempts**: Distinguish between CTRL-C and wrong password
|
|||
|
|
|
|||
|
|
## References
|
|||
|
|
|
|||
|
|
- Nushell `complete` command: https://www.nushell.sh/commands/docs/complete.html
|
|||
|
|
- Nushell `reduce` command: https://www.nushell.sh/commands/docs/reduce.html
|
|||
|
|
- Sudo exit codes: man sudo (exit code 1 = authentication failure)
|
|||
|
|
- POSIX signal conventions: SIGINT (CTRL-C) = 130
|
|||
|
|
|
|||
|
|
## Related Files
|
|||
|
|
|
|||
|
|
- `provisioning/core/nulib/servers/ssh.nu` - Core implementation
|
|||
|
|
- `provisioning/core/nulib/servers/create.nu` - Calls on_server_ssh
|
|||
|
|
- `provisioning/core/nulib/servers/generate.nu` - Calls on_server_ssh
|
|||
|
|
- `docs/troubleshooting/CTRL-C_SUDO_HANDLING.md` - User-facing docs
|
|||
|
|
- `docs/quick-reference/SUDO_PASSWORD_HANDLING.md` - Quick reference
|
|||
|
|
|
|||
|
|
## Changelog
|
|||
|
|
|
|||
|
|
- **2025-01-XX**: Initial implementation with return values (v2)
|
|||
|
|
- **2025-01-XX**: Fixed mutable variable capture with `reduce` pattern
|
|||
|
|
- **2025-01-XX**: First attempt with `exit 130` (reverted, caused process termination)
|