provisioning/docs/book/development/CTRL-C_IMPLEMENTATION_NOTES.html

475 lines
24 KiB
HTML
Raw Normal View History

<!DOCTYPE HTML>
<html lang="en" class="ayu sidebar-visible" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>Ctrl-C Implementation Notes - Provisioning Platform Documentation</title>
<!-- Custom HTML head -->
<meta name="description" content="Complete documentation for the Provisioning Platform - Infrastructure automation with Nushell, KCL, and Rust">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="../favicon.svg">
<link rel="shortcut icon" href="../favicon.png">
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/general.css">
<link rel="stylesheet" href="../css/chrome.css">
<link rel="stylesheet" href="../css/print.css" media="print">
<!-- Fonts -->
<link rel="stylesheet" href="../FontAwesome/css/font-awesome.css">
<link rel="stylesheet" href="../fonts/fonts.css">
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" id="highlight-css" href="../highlight.css">
<link rel="stylesheet" id="tomorrow-night-css" href="../tomorrow-night.css">
<link rel="stylesheet" id="ayu-highlight-css" href="../ayu-highlight.css">
<!-- Custom theme stylesheets -->
<!-- Provide site root and default themes to javascript -->
<script>
const path_to_root = "../";
const default_light_theme = "ayu";
const default_dark_theme = "navy";
</script>
<!-- Start loading toc.js asap -->
<script src="../toc.js"></script>
</head>
<body>
<div id="mdbook-help-container">
<div id="mdbook-help-popup">
<h2 class="mdbook-help-title">Keyboard shortcuts</h2>
<div>
<p>Press <kbd></kbd> or <kbd></kbd> to navigate between chapters</p>
<p>Press <kbd>S</kbd> or <kbd>/</kbd> to search in the book</p>
<p>Press <kbd>?</kbd> to show this help</p>
<p>Press <kbd>Esc</kbd> to hide this help</p>
</div>
</div>
</div>
<div id="body-container">
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
let theme = localStorage.getItem('mdbook-theme');
let sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
const default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? default_dark_theme : default_light_theme;
let theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
const html = document.documentElement;
html.classList.remove('ayu')
html.classList.add(theme);
html.classList.add("js");
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
let sidebar = null;
const sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
}
sidebar_toggle.checked = sidebar === 'visible';
html.classList.remove('sidebar-visible');
html.classList.add("sidebar-" + sidebar);
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<!-- populated by js -->
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
<noscript>
<iframe class="sidebar-iframe-outer" src="../toc.html"></iframe>
</noscript>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="default_theme">Auto</button></li>
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
<button id="search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
</div>
<h1 class="menu-title">Provisioning Platform Documentation</h1>
<div class="right-buttons">
<a href="../print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
<a href="https://github.com/provisioning/provisioning-platform" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa fa-github"></i>
</a>
<a href="https://github.com/provisioning/provisioning-platform/edit/main/provisioning/docs/src/development/CTRL-C_IMPLEMENTATION_NOTES.md" title="Suggest an edit" aria-label="Suggest an edit">
<i id="git-edit-button" class="fa fa-edit"></i>
</a>
</div>
</div>
<div id="search-wrapper" class="hidden">
<form id="searchbar-outer" class="searchbar-outer">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
</form>
<div id="searchresults-outer" class="searchresults-outer hidden">
<div id="searchresults-header" class="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
</div>
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="content" class="content">
<main>
<h1 id="ctrl-c-handling-implementation-notes"><a class="header" href="#ctrl-c-handling-implementation-notes">CTRL-C Handling Implementation Notes</a></h1>
<h2 id="overview"><a class="header" href="#overview">Overview</a></h2>
<p>Implemented graceful CTRL-C handling for sudo password prompts during server creation/generation operations.</p>
<h2 id="problem-statement"><a class="header" href="#problem-statement">Problem Statement</a></h2>
<p>When <code>fix_local_hosts: true</code> is set, the provisioning tool requires sudo access to modify <code>/etc/hosts</code> and SSH config. When a user cancels the sudo password prompt (no password, wrong password, timeout), the system would:</p>
<ol>
<li>Exit with code 1 (sudo failed)</li>
<li>Propagate null values up the call stack</li>
<li>Show cryptic Nushell errors about pipeline failures</li>
<li>Leave the operation in an inconsistent state</li>
</ol>
<p><strong>Important Unix Limitation</strong>: Pressing CTRL-C at the sudo password prompt sends SIGINT to the entire process group, interrupting Nushell before exit code handling can occur. This <strong>cannot be caught</strong> and is expected Unix behavior.</p>
<h2 id="solution-architecture"><a class="header" href="#solution-architecture">Solution Architecture</a></h2>
<h3 id="key-principle-return-values-not-exit-codes"><a class="header" href="#key-principle-return-values-not-exit-codes">Key Principle: Return Values, Not Exit Codes</a></h3>
<p>Instead of using <code>exit 130</code> which kills the entire process, we use <strong>return values</strong> to signal cancellation and let each layer of the call stack handle it gracefully.</p>
<h3 id="three-layer-approach"><a class="header" href="#three-layer-approach">Three-Layer Approach</a></h3>
<ol>
<li>
<p><strong>Detection Layer</strong> (ssh.nu helper functions)</p>
<ul>
<li>Detects sudo cancellation via exit code + stderr</li>
<li>Returns <code>false</code> instead of calling <code>exit</code></li>
</ul>
</li>
<li>
<p><strong>Propagation Layer</strong> (ssh.nu core functions)</p>
<ul>
<li><code>on_server_ssh()</code>: Returns <code>false</code> on cancellation</li>
<li><code>server_ssh()</code>: Uses <code>reduce</code> to propagate failures</li>
</ul>
</li>
<li>
<p><strong>Handling Layer</strong> (create.nu, generate.nu)</p>
<ul>
<li>Checks return values</li>
<li>Displays user-friendly messages</li>
<li>Returns <code>false</code> to caller</li>
</ul>
</li>
</ol>
<h2 id="implementation-details"><a class="header" href="#implementation-details">Implementation Details</a></h2>
<h3 id="1-helper-functions-sshnu11-32"><a class="header" href="#1-helper-functions-sshnu11-32">1. Helper Functions (ssh.nu:11-32)</a></h3>
<pre><code class="language-nushell">def check_sudo_cached []: nothing -&gt; 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 -&gt; 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
}
</code></pre>
<p><strong>Design Decision</strong>: Return <code>bool</code> instead of throwing error or calling <code>exit</code>. This allows the caller to decide how to handle cancellation.</p>
<h3 id="2-pre-emptive-warning-sshnu155-160"><a class="header" href="#2-pre-emptive-warning-sshnu155-160">2. Pre-emptive Warning (ssh.nu:155-160)</a></h3>
<pre><code class="language-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"
}
</code></pre>
<p><strong>Design Decision</strong>: Warn users upfront so theyre not surprised by the password prompt.</p>
<h3 id="3-ctrl-c-detection-sshnu171-199"><a class="header" href="#3-ctrl-c-detection-sshnu171-199">3. CTRL-C Detection (ssh.nu:171-199)</a></h3>
<p>All sudo commands wrapped with detection:</p>
<pre><code class="language-nushell">let result = (do --ignore-errors { ^sudo &lt;command&gt; } | complete)
if $result.exit_code == 1 and ($result.stderr | str contains "password is required") {
print "\n⚠ Operation cancelled"
return false
}
</code></pre>
<p><strong>Design Decision</strong>: Use <code>do --ignore-errors</code> + <code>complete</code> to capture both exit code and stderr without throwing exceptions.</p>
<h3 id="4-state-accumulation-pattern-sshnu122-129"><a class="header" href="#4-state-accumulation-pattern-sshnu122-129">4. State Accumulation Pattern (ssh.nu:122-129)</a></h3>
<p>Using Nushells <code>reduce</code> instead of mutable variables:</p>
<pre><code class="language-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
}
})
</code></pre>
<p><strong>Design Decision</strong>: Nushell doesnt allow mutable variable capture in closures. Use <code>reduce</code> for accumulating boolean state across iterations.</p>
<h3 id="5-caller-handling-createnu262-266-generatenu269-273"><a class="header" href="#5-caller-handling-createnu262-266-generatenu269-273">5. Caller Handling (create.nu:262-266, generate.nu:269-273)</a></h3>
<pre><code class="language-nushell">let ssh_result = (on_server_ssh $settings $server "pub" "create" false)
if not $ssh_result {
_print "\n✗ Server creation cancelled"
return false
}
</code></pre>
<p><strong>Design Decision</strong>: Check return value and provide context-specific message before returning.</p>
<h2 id="error-flow-diagram"><a class="header" href="#error-flow-diagram">Error Flow Diagram</a></h2>
<pre><code>User presses CTRL-C during password prompt
sudo exits with code 1, stderr: "password is required"
do --ignore-errors captures exit code &amp; 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
</code></pre>
<h2 id="nushell-idioms-used"><a class="header" href="#nushell-idioms-used">Nushell Idioms Used</a></h2>
<h3 id="1-do---ignore-errors--complete"><a class="header" href="#1-do---ignore-errors--complete">1. <code>do --ignore-errors</code> + <code>complete</code></a></h3>
<p>Captures both stdout, stderr, and exit code without throwing:</p>
<pre><code class="language-nushell">let result = (do --ignore-errors { ^sudo command } | complete)
# result = { stdout: "...", stderr: "...", exit_code: 1 }
</code></pre>
<h3 id="2-reduce-for-accumulation"><a class="header" href="#2-reduce-for-accumulation">2. <code>reduce</code> for Accumulation</a></h3>
<p>Instead of mutable variables in loops:</p>
<pre><code class="language-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)
})
</code></pre>
<h3 id="3-early-returns-for-error-handling"><a class="header" href="#3-early-returns-for-error-handling">3. Early Returns for Error Handling</a></h3>
<pre><code class="language-nushell">if not $condition {
print "Error message"
return false
}
# Continue with happy path
</code></pre>
<h2 id="testing-scenarios"><a class="header" href="#testing-scenarios">Testing Scenarios</a></h2>
<h3 id="scenario-1-ctrl-c-during-first-sudo-command"><a class="header" href="#scenario-1-ctrl-c-during-first-sudo-command">Scenario 1: CTRL-C During First Sudo Command</a></h3>
<pre><code class="language-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
</code></pre>
<h3 id="scenario-2-pre-cached-credentials"><a class="header" href="#scenario-2-pre-cached-credentials">Scenario 2: Pre-cached Credentials</a></h3>
<pre><code class="language-bash">sudo -v
provisioning -c server create
# Expected: No password prompt, smooth operation
</code></pre>
<h3 id="scenario-3-wrong-password-3-times"><a class="header" href="#scenario-3-wrong-password-3-times">Scenario 3: Wrong Password 3 Times</a></h3>
<pre><code class="language-bash">provisioning -c server create
# Password: [wrong]
# Password: [wrong]
# Password: [wrong]
# Expected: Same as CTRL-C (treated as cancellation)
</code></pre>
<h3 id="scenario-4-multiple-servers-cancel-on-second"><a class="header" href="#scenario-4-multiple-servers-cancel-on-second">Scenario 4: Multiple Servers, Cancel on Second</a></h3>
<pre><code class="language-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
</code></pre>
<h2 id="maintenance-notes"><a class="header" href="#maintenance-notes">Maintenance Notes</a></h2>
<h3 id="adding-new-sudo-commands"><a class="header" href="#adding-new-sudo-commands">Adding New Sudo Commands</a></h3>
<p>When adding new sudo commands to the codebase:</p>
<ol>
<li>Wrap with <code>do --ignore-errors</code> + <code>complete</code></li>
<li>Check for exit code 1 + “password is required”</li>
<li>Return <code>false</code> on cancellation</li>
<li>Let caller handle the <code>false</code> return value</li>
</ol>
<p>Example template:</p>
<pre><code class="language-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
}
</code></pre>
<h3 id="common-pitfalls"><a class="header" href="#common-pitfalls">Common Pitfalls</a></h3>
<ol>
<li><strong>Dont use <code>exit</code></strong>: It kills the entire process</li>
<li><strong>Dont use mutable variables in closures</strong>: Use <code>reduce</code> instead</li>
<li><strong>Dont ignore return values</strong>: Always check and propagate</li>
<li><strong>Dont forget the pre-check warning</strong>: Users should know sudo is needed</li>
</ol>
<h2 id="future-improvements"><a class="header" href="#future-improvements">Future Improvements</a></h2>
<ol>
<li><strong>Sudo Credential Manager</strong>: Optionally use a credential manager (keychain, etc.)</li>
<li><strong>Sudo-less Mode</strong>: Alternative implementation that doesnt require root</li>
<li><strong>Timeout Handling</strong>: Detect when sudo times out waiting for password</li>
<li><strong>Multiple Password Attempts</strong>: Distinguish between CTRL-C and wrong password</li>
</ol>
<h2 id="references"><a class="header" href="#references">References</a></h2>
<ul>
<li>Nushell <code>complete</code> command: https://www.nushell.sh/commands/docs/complete.html</li>
<li>Nushell <code>reduce</code> command: https://www.nushell.sh/commands/docs/reduce.html</li>
<li>Sudo exit codes: man sudo (exit code 1 = authentication failure)</li>
<li>POSIX signal conventions: SIGINT (CTRL-C) = 130</li>
</ul>
<h2 id="related-files"><a class="header" href="#related-files">Related Files</a></h2>
<ul>
<li><code>provisioning/core/nulib/servers/ssh.nu</code> - Core implementation</li>
<li><code>provisioning/core/nulib/servers/create.nu</code> - Calls on_server_ssh</li>
<li><code>provisioning/core/nulib/servers/generate.nu</code> - Calls on_server_ssh</li>
<li><code>docs/troubleshooting/CTRL-C_SUDO_HANDLING.md</code> - User-facing docs</li>
<li><code>docs/quick-reference/SUDO_PASSWORD_HANDLING.md</code> - Quick reference</li>
</ul>
<h2 id="changelog"><a class="header" href="#changelog">Changelog</a></h2>
<ul>
<li><strong>2025-01-XX</strong>: Initial implementation with return values (v2)</li>
<li><strong>2025-01-XX</strong>: Fixed mutable variable capture with <code>reduce</code> pattern</li>
<li><strong>2025-01-XX</strong>: First attempt with <code>exit 130</code> (reverted, caused process termination)</li>
</ul>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../development/kcl/VALIDATION_EXECUTIVE_SUMMARY.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<a rel="next prefetch" href="../guides/from-scratch.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
<a rel="prev" href="../development/kcl/VALIDATION_EXECUTIVE_SUMMARY.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<a rel="next prefetch" href="../guides/from-scratch.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
</nav>
</div>
<!-- Livereload script (if served using the cli tool) -->
<script>
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsAddress = wsProtocol + "//" + location.host + "/" + "__livereload";
const socket = new WebSocket(wsAddress);
socket.onmessage = function (event) {
if (event.data === "reload") {
socket.close();
location.reload();
}
};
window.onbeforeunload = function() {
socket.close();
}
</script>
<script>
window.playground_copyable = true;
</script>
<script src="../elasticlunr.min.js"></script>
<script src="../mark.min.js"></script>
<script src="../searcher.js"></script>
<script src="../clipboard.min.js"></script>
<script src="../highlight.js"></script>
<script src="../book.js"></script>
<!-- Custom JS scripts -->
</div>
</body>
</html>