| 205 |
205 |
|
Ok(PathBuf::from(release_root).join("releases").join(version))
|
| 206 |
206 |
|
}
|
| 207 |
207 |
|
|
| 208 |
|
- |
/// Pre-swap config-drift check: source the node's env file, then run the
|
| 209 |
|
- |
/// freshly-deployed binary in `MNW_CHECK_CONFIG=1` mode (loads config, exits
|
| 210 |
|
- |
/// 0/1, no DB/migrations/bind). A non-zero exit — a required var missing — is
|
| 211 |
|
- |
/// surfaced by `run_checked` as an error, failing the promote before the swap.
|
|
208 |
+ |
/// Pre-swap config-drift check: load the node's env file the way systemd loads
|
|
209 |
+ |
/// it, then run the freshly-deployed binary in `MNW_CHECK_CONFIG=1` mode (loads
|
|
210 |
+ |
/// config, exits 0/1, no DB/migrations/bind). A non-zero exit — a required var
|
|
211 |
+ |
/// missing — is surfaced by `run_checked` as an error, failing the promote
|
|
212 |
+ |
/// before the swap.
|
| 212 |
213 |
|
///
|
| 213 |
214 |
|
/// Bounded by a timeout as a backstop: a binary predating `MNW_CHECK_CONFIG`
|
| 214 |
215 |
|
/// would ignore the var and try to start normally, which must not hang the
|
| 220 |
221 |
|
deployed_bin: &str,
|
| 221 |
222 |
|
env_file: &str,
|
| 222 |
223 |
|
) -> Result<()> {
|
| 223 |
|
- |
// `set -a` exports every var the env file assigns so Config::from_env sees
|
| 224 |
|
- |
// them; the binary then validates and exits. Quoted paths; the env file is
|
| 225 |
|
- |
// operator-controlled config, not user input.
|
| 226 |
|
- |
let script = format!(
|
| 227 |
|
- |
"set -eu; set -a; . {env}; set +a; MNW_CHECK_CONFIG=1 {bin}",
|
| 228 |
|
- |
env = sh_quote(env_file),
|
| 229 |
|
- |
bin = sh_quote(deployed_bin),
|
| 230 |
|
- |
);
|
|
224 |
+ |
let script = config_check_script(env_file, deployed_bin);
|
| 231 |
225 |
|
let fut = run_checked(executor, &script, "pre-swap config check");
|
| 232 |
226 |
|
match tokio::time::timeout(std::time::Duration::from_secs(20), fut).await {
|
| 233 |
227 |
|
Ok(result) => result.map(|_| ()),
|
| 238 |
232 |
|
}
|
| 239 |
233 |
|
}
|
| 240 |
234 |
|
|
|
235 |
+ |
/// Shell that loads `env_file` with systemd `EnvironmentFile=` semantics, then
|
|
236 |
+ |
/// runs `bin` under `MNW_CHECK_CONFIG=1`.
|
|
237 |
+ |
///
|
|
238 |
+ |
/// Load the file line by line and `export` each `KEY=VALUE` verbatim rather than
|
|
239 |
+ |
/// `. env_file`. Dot-sourcing runs the file as a script, so any shell
|
|
240 |
+ |
/// metacharacter in a value (`$`, backticks, `;`, `&`, a glob, whitespace) is
|
|
241 |
+ |
/// expanded or word-split — a DB URL carrying a password silently dropped
|
|
242 |
+ |
/// `DATABASE_URL` to empty on our nodes, which would fail the check (and thus
|
|
243 |
+ |
/// every deploy) even though systemd starts the service fine. `export "$line"`
|
|
244 |
+ |
/// assigns the already-expanded word literally, matching systemd's "no variable
|
|
245 |
+ |
/// expansion" rule. Comments and blank lines are skipped; the `|| [ -n "$line" ]`
|
|
246 |
+ |
/// guard processes a final line with no trailing newline. (Quoted values —
|
|
247 |
+ |
/// `KEY="v"` — aren't unquoted here the way systemd would, but our env files use
|
|
248 |
+ |
/// bare `KEY=VALUE`, and a stray quote can only make the check stricter, never
|
|
249 |
+ |
/// wave a bad config through.)
|
|
250 |
+ |
fn config_check_script(env_file: &str, bin: &str) -> String {
|
|
251 |
+ |
format!(
|
|
252 |
+ |
"set -eu\n\
|
|
253 |
+ |
while IFS= read -r __sando_l || [ -n \"$__sando_l\" ]; do\n\
|
|
254 |
+ |
\tcase \"$__sando_l\" in ''|'#'*) continue ;; esac\n\
|
|
255 |
+ |
\texport \"$__sando_l\"\n\
|
|
256 |
+ |
done < {env}\n\
|
|
257 |
+ |
MNW_CHECK_CONFIG=1 {bin}\n",
|
|
258 |
+ |
env = sh_quote(env_file),
|
|
259 |
+ |
bin = sh_quote(bin),
|
|
260 |
+ |
)
|
|
261 |
+ |
}
|
|
262 |
+ |
|
| 241 |
263 |
|
/// Build the swap-and-restart shell script for a remote node.
|
| 242 |
264 |
|
///
|
| 243 |
265 |
|
/// The symlink swap is atomic via `mv -T` of a freshly-created symlink over the
|
| 626 |
648 |
|
assert_eq!(target.to_string_lossy(), "releases/new", "no prev existed to roll back to");
|
| 627 |
649 |
|
}
|
| 628 |
650 |
|
|
|
651 |
+ |
// ---- config_check_script: systemd-faithful env loading ----
|
|
652 |
+ |
|
|
653 |
+ |
#[tokio::test]
|
|
654 |
+ |
async fn config_check_script_loads_values_with_shell_metachars() {
|
|
655 |
+ |
// The bug: `. env_file` expands/word-splits values, so a URL or a
|
|
656 |
+ |
// password containing a shell metacharacter is mangled — it dropped
|
|
657 |
+ |
// DATABASE_URL to empty on a real node, which would fail every deploy.
|
|
658 |
+ |
// The export-loop must load such a value intact. The "binary" is a
|
|
659 |
+ |
// checker script (a real path, like a deployed binary) that exits 0 only
|
|
660 |
+ |
// if the var arrived byte-for-byte — it compares against the expected
|
|
661 |
+ |
// value read from a file, so nothing re-interprets the metacharacters.
|
|
662 |
+ |
let tricky = "postgres://u:p$ss;w&rd@h/db `x` $(y)";
|
|
663 |
+ |
// Plain files in a tempdir: no lingering write fd, so the checker can be
|
|
664 |
+ |
// exec'd (a NamedTempFile stays open and would ETXTBSY).
|
|
665 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
666 |
+ |
let expected_path = dir.path().join("expected");
|
|
667 |
+ |
std::fs::write(&expected_path, tricky).unwrap(); // no trailing newline
|
|
668 |
+ |
|
|
669 |
+ |
let env_path = dir.path().join("node.env");
|
|
670 |
+ |
std::fs::write(
|
|
671 |
+ |
&env_path,
|
|
672 |
+ |
format!(
|
|
673 |
+ |
"# a comment\n\nDATABASE_URL={tricky}\nOTHER=plain\nEXPECTED_FILE={ef}\n",
|
|
674 |
+ |
ef = expected_path.display(),
|
|
675 |
+ |
),
|
|
676 |
+ |
)
|
|
677 |
+ |
.unwrap();
|
|
678 |
+ |
|
|
679 |
+ |
let checker_path = dir.path().join("checker.sh");
|
|
680 |
+ |
std::fs::write(
|
|
681 |
+ |
&checker_path,
|
|
682 |
+ |
"#!/bin/sh\nwant=$(cat \"$EXPECTED_FILE\")\n\
|
|
683 |
+ |
[ \"$DATABASE_URL\" = \"$want\" ] || { echo \"DB [$DATABASE_URL] != [$want]\" >&2; exit 1; }\n\
|
|
684 |
+ |
[ \"$OTHER\" = plain ] || { echo \"OTHER [$OTHER]\" >&2; exit 1; }\n",
|
|
685 |
+ |
)
|
|
686 |
+ |
.unwrap();
|
|
687 |
+ |
std::fs::set_permissions(&checker_path, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
|
|
688 |
+ |
|
|
689 |
+ |
let script = config_check_script(
|
|
690 |
+ |
&env_path.to_string_lossy(),
|
|
691 |
+ |
&checker_path.to_string_lossy(),
|
|
692 |
+ |
);
|
|
693 |
+ |
let out = run_script(&script).await;
|
|
694 |
+ |
assert!(
|
|
695 |
+ |
out.status.success(),
|
|
696 |
+ |
"value with shell metachars must load intact; stderr: {}",
|
|
697 |
+ |
String::from_utf8_lossy(&out.stderr),
|
|
698 |
+ |
);
|
|
699 |
+ |
}
|
|
700 |
+ |
|
|
701 |
+ |
#[tokio::test]
|
|
702 |
+ |
async fn config_check_script_propagates_binary_failure() {
|
|
703 |
+ |
// A required var missing (the binary exits non-zero) must fail the check.
|
|
704 |
+ |
let env = tempfile::NamedTempFile::new().unwrap();
|
|
705 |
+ |
std::fs::write(env.path(), "FOO=bar\n").unwrap();
|
|
706 |
+ |
let script = config_check_script(&env.path().to_string_lossy(), "false");
|
|
707 |
+ |
let out = run_script(&script).await;
|
|
708 |
+ |
assert!(!out.status.success(), "a non-zero MNW_CHECK_CONFIG exit must fail the check");
|
|
709 |
+ |
}
|
|
710 |
+ |
|
| 629 |
711 |
|
#[tokio::test]
|
| 630 |
712 |
|
async fn deploy_node_denied_when_executor_lacks_deploy_grant() {
|
| 631 |
713 |
|
// Defense in depth: an executor without the deploy grant refuses the
|