| 837 |
837 |
|
use super::*;
|
| 838 |
838 |
|
use crate::config::Config;
|
| 839 |
839 |
|
use crate::topology::{BackupConfig, CanaryPolicy, Gate, Node, RepoConfig, Tier, Topology};
|
|
840 |
+ |
use async_trait::async_trait;
|
| 840 |
841 |
|
use axum::body::Body;
|
| 841 |
842 |
|
use axum::http::{Request, StatusCode};
|
| 842 |
843 |
|
use http_body_util::BodyExt;
|
|
844 |
+ |
use ops_exec::{CapabilitySet, Executor, LogSink, RunOutput, Step, SyncOpts};
|
| 843 |
845 |
|
use sqlx::SqlitePool;
|
| 844 |
846 |
|
use sqlx::sqlite::SqlitePoolOptions;
|
|
847 |
+ |
use std::os::unix::process::ExitStatusExt;
|
| 845 |
848 |
|
use std::path::PathBuf;
|
| 846 |
|
- |
use std::sync::Arc;
|
|
849 |
+ |
use std::sync::{Arc, Mutex as StdMutex};
|
| 847 |
850 |
|
use tower::ServiceExt;
|
| 848 |
851 |
|
|
| 849 |
852 |
|
async fn fresh_pool() -> SqlitePool {
|
| 1662 |
1665 |
|
);
|
| 1663 |
1666 |
|
}
|
| 1664 |
1667 |
|
|
|
1668 |
+ |
// ---- FleetFake: a multi-node promote across recorded fake executors ----
|
|
1669 |
+ |
//
|
|
1670 |
+ |
// The route-level promote tests until now ran a single local node against a
|
|
1671 |
+ |
// real LocalExec, so the sequential-canary fan-out, the cross-node deploy
|
|
1672 |
+ |
// ordering, and the mid-canary rollback of already-flipped nodes had no
|
|
1673 |
+ |
// coverage. FleetFake records every deploy op (tagged by node) into one
|
|
1674 |
+ |
// shared log so ordering is visible across the fleet, and can fail any op
|
|
1675 |
+ |
// whose shell script or rsync target contains a marker — used to fail a
|
|
1676 |
+ |
// node's forward deploy of the new version while its rollback to the prior
|
|
1677 |
+ |
// version (a different `releases/<v>` path) still succeeds.
|
|
1678 |
+ |
|
|
1679 |
+ |
struct FleetFake {
|
|
1680 |
+ |
tag: String,
|
|
1681 |
+ |
caps: CapabilitySet,
|
|
1682 |
+ |
log: Arc<StdMutex<Vec<String>>>,
|
|
1683 |
+ |
fail_if_contains: Option<String>,
|
|
1684 |
+ |
}
|
|
1685 |
+ |
|
|
1686 |
+ |
impl FleetFake {
|
|
1687 |
+ |
fn fails(&self, text: &str) -> bool {
|
|
1688 |
+ |
self.fail_if_contains
|
|
1689 |
+ |
.as_deref()
|
|
1690 |
+ |
.is_some_and(|m| text.contains(m))
|
|
1691 |
+ |
}
|
|
1692 |
+ |
}
|
|
1693 |
+ |
|
|
1694 |
+ |
#[async_trait]
|
|
1695 |
+ |
impl Executor for FleetFake {
|
|
1696 |
+ |
async fn run_streaming(
|
|
1697 |
+ |
&self,
|
|
1698 |
+ |
step: &Step,
|
|
1699 |
+ |
_sink: &mut dyn LogSink,
|
|
1700 |
+ |
) -> anyhow::Result<RunOutput> {
|
|
1701 |
+ |
let script = step.argv.last().cloned().unwrap_or_default();
|
|
1702 |
+ |
self.log
|
|
1703 |
+ |
.lock()
|
|
1704 |
+ |
.unwrap()
|
|
1705 |
+ |
.push(format!("{}:run:{script}", self.tag));
|
|
1706 |
+ |
Ok(RunOutput {
|
|
1707 |
+ |
status: std::process::ExitStatus::from_raw(if self.fails(&script) {
|
|
1708 |
+ |
1 << 8
|
|
1709 |
+ |
} else {
|
|
1710 |
+ |
0
|
|
1711 |
+ |
}),
|
|
1712 |
+ |
stdout: Vec::new(),
|
|
1713 |
+ |
stderr: Vec::new(),
|
|
1714 |
+ |
})
|
|
1715 |
+ |
}
|
|
1716 |
+ |
async fn pull_file(
|
|
1717 |
+ |
&self,
|
|
1718 |
+ |
_r: &std::path::Path,
|
|
1719 |
+ |
_l: &std::path::Path,
|
|
1720 |
+ |
_o: &SyncOpts,
|
|
1721 |
+ |
) -> anyhow::Result<()> {
|
|
1722 |
+ |
Ok(())
|
|
1723 |
+ |
}
|
|
1724 |
+ |
async fn pull_dir(
|
|
1725 |
+ |
&self,
|
|
1726 |
+ |
_r: &std::path::Path,
|
|
1727 |
+ |
_l: &std::path::Path,
|
|
1728 |
+ |
_o: &SyncOpts,
|
|
1729 |
+ |
) -> anyhow::Result<()> {
|
|
1730 |
+ |
Ok(())
|
|
1731 |
+ |
}
|
|
1732 |
+ |
async fn pull_glob(
|
|
1733 |
+ |
&self,
|
|
1734 |
+ |
_g: &str,
|
|
1735 |
+ |
_l: &std::path::Path,
|
|
1736 |
+ |
_o: &SyncOpts,
|
|
1737 |
+ |
) -> anyhow::Result<()> {
|
|
1738 |
+ |
Ok(())
|
|
1739 |
+ |
}
|
|
1740 |
+ |
async fn push_dir(
|
|
1741 |
+ |
&self,
|
|
1742 |
+ |
_local: &std::path::Path,
|
|
1743 |
+ |
remote: &std::path::Path,
|
|
1744 |
+ |
_o: &SyncOpts,
|
|
1745 |
+ |
) -> anyhow::Result<()> {
|
|
1746 |
+ |
let dst = remote.display().to_string();
|
|
1747 |
+ |
self.log
|
|
1748 |
+ |
.lock()
|
|
1749 |
+ |
.unwrap()
|
|
1750 |
+ |
.push(format!("{}:push:{dst}", self.tag));
|
|
1751 |
+ |
if self.fails(&dst) {
|
|
1752 |
+ |
anyhow::bail!("fake rsync failure on {}", self.tag);
|
|
1753 |
+ |
}
|
|
1754 |
+ |
Ok(())
|
|
1755 |
+ |
}
|
|
1756 |
+ |
fn capabilities(&self) -> &CapabilitySet {
|
|
1757 |
+ |
&self.caps
|
|
1758 |
+ |
}
|
|
1759 |
+ |
}
|
|
1760 |
+ |
|
|
1761 |
+ |
/// Rebuild tier "a" with `names` as remote fake nodes sharing one op log,
|
|
1762 |
+ |
/// seed the version/tier_state prerequisites for a promote of 3.0.0 up from
|
|
1763 |
+ |
/// `host` (tier "a" starts on 2.0.0 with 1.0.0 behind it), and optionally
|
|
1764 |
+ |
/// make `fail_node` fail any op containing `fail_marker`. Returns the state
|
|
1765 |
+ |
/// and the shared log.
|
|
1766 |
+ |
async fn fleet_fixture(
|
|
1767 |
+ |
names: &[&str],
|
|
1768 |
+ |
fail_node: Option<&str>,
|
|
1769 |
+ |
fail_marker: &str,
|
|
1770 |
+ |
) -> (AppState, Arc<StdMutex<Vec<String>>>) {
|
|
1771 |
+ |
use crate::topology::{default_actuate, default_observe};
|
|
1772 |
+ |
let mut state = test_state().await;
|
|
1773 |
+ |
let log = Arc::new(StdMutex::new(Vec::<String>::new()));
|
|
1774 |
+ |
|
|
1775 |
+ |
let nodes: Vec<Node> = names
|
|
1776 |
+ |
.iter()
|
|
1777 |
+ |
.map(|name| Node {
|
|
1778 |
+ |
name: (*name).into(),
|
|
1779 |
+ |
ssh_target: format!("deploy@{name}"),
|
|
1780 |
+ |
release_root: format!("/tmp/fleet/{name}"),
|
|
1781 |
+ |
service_name: "makenotwork.service".into(),
|
|
1782 |
+ |
health_url: None,
|
|
1783 |
+ |
config_check_env_file: None,
|
|
1784 |
+ |
actuate: default_actuate(),
|
|
1785 |
+ |
observe: default_observe(),
|
|
1786 |
+ |
companions: Vec::new(),
|
|
1787 |
+ |
})
|
|
1788 |
+ |
.collect();
|
|
1789 |
+ |
|
|
1790 |
+ |
let mut topo = (*state.topo).clone();
|
|
1791 |
+ |
topo.tiers[1].nodes = nodes.clone();
|
|
1792 |
+ |
// Drop the tier's post-deploy gate: the deploy fan-out is the subject
|
|
1793 |
+ |
// here, and node_health would need its own probe wiring.
|
|
1794 |
+ |
topo.tiers[1].gates = vec![];
|
|
1795 |
+ |
state.topo = Arc::new(topo);
|
|
1796 |
+ |
|
|
1797 |
+ |
let execs: crate::state::ExecutorMap = nodes
|
|
1798 |
+ |
.iter()
|
|
1799 |
+ |
.map(|n| {
|
|
1800 |
+ |
let nm = n.name.to_string();
|
|
1801 |
+ |
let fail = fail_node == Some(nm.as_str());
|
|
1802 |
+ |
let exec: Arc<dyn Executor> = Arc::new(FleetFake {
|
|
1803 |
+ |
tag: nm,
|
|
1804 |
+ |
caps: CapabilitySet::from_tokens(["deploy", "restart"], ["health"]),
|
|
1805 |
+ |
log: log.clone(),
|
|
1806 |
+ |
fail_if_contains: fail.then(|| fail_marker.to_string()),
|
|
1807 |
+ |
});
|
|
1808 |
+ |
(n.name.clone(), exec)
|
|
1809 |
+ |
})
|
|
1810 |
+ |
.collect();
|
|
1811 |
+ |
state.executors = Arc::new(execs);
|
|
1812 |
+ |
|
|
1813 |
+ |
for n in &nodes {
|
|
1814 |
+ |
// deploys.node FKs into `nodes`.
|
|
1815 |
+ |
sqlx::query(
|
|
1816 |
+ |
"INSERT INTO nodes (name, tier, ssh_target, release_root) VALUES (?, 'a', ?, ?)",
|
|
1817 |
+ |
)
|
|
1818 |
+ |
.bind(&n.name)
|
|
1819 |
+ |
.bind(&n.ssh_target)
|
|
1820 |
+ |
.bind(&n.release_root)
|
|
1821 |
+ |
.execute(&state.pool)
|
|
1822 |
+ |
.await
|
|
1823 |
+ |
.unwrap();
|
|
1824 |
+ |
}
|
|
1825 |
+ |
for v in ["1.0.0", "2.0.0", "3.0.0"] {
|
|
1826 |
+ |
sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES (?, 'sha', datetime('now'), '/tmp/staged/makenotwork')")
|
|
1827 |
+ |
.bind(v).execute(&state.pool).await.unwrap();
|
|
1828 |
+ |
}
|
|
1829 |
+ |
sqlx::query("UPDATE tier_state SET current_version = '3.0.0' WHERE tier = 'host'")
|
|
1830 |
+ |
.execute(&state.pool)
|
|
1831 |
+ |
.await
|
|
1832 |
+ |
.unwrap();
|
|
1833 |
+ |
sqlx::query(
|
|
1834 |
+ |
"UPDATE tier_state SET current_version = '2.0.0', previous_version = '1.0.0' WHERE tier = 'a'",
|
|
1835 |
+ |
)
|
|
1836 |
+ |
.execute(&state.pool)
|
|
1837 |
+ |
.await
|
|
1838 |
+ |
.unwrap();
|
|
1839 |
+ |
(state, log)
|
|
1840 |
+ |
}
|
|
1841 |
+ |
|
|
1842 |
+ |
/// Index of the first logged op belonging to `node` (panics if the node was
|
|
1843 |
+ |
/// never touched — the message names it).
|
|
1844 |
+ |
fn first_touch(log: &[String], node: &str) -> usize {
|
|
1845 |
+ |
let prefix = format!("{node}:");
|
|
1846 |
+ |
log.iter()
|
|
1847 |
+ |
.position(|e| e.starts_with(&prefix))
|
|
1848 |
+ |
.unwrap_or_else(|| panic!("node {node:?} was never deployed to; log: {log:#?}"))
|
|
1849 |
+ |
}
|
|
1850 |
+ |
|
|
1851 |
+ |
#[tokio::test]
|
|
1852 |
+ |
async fn promote_deploys_every_node_in_tier_order_and_advances() {
|
|
1853 |
+ |
let (state, log) = fleet_fixture(&["a1", "a2", "a3"], None, "").await;
|
|
1854 |
+ |
let pool = state.pool.clone();
|
|
1855 |
+ |
|
|
1856 |
+ |
let Json(body) = promote_inner(state, "a".into(), PromoteBody::default())
|
|
1857 |
+ |
.await
|
|
1858 |
+ |
.expect("all nodes deploy, so the promote succeeds");
|
|
1859 |
+ |
assert_eq!(
|
|
1860 |
+ |
body["nodes_deployed"],
|
|
1861 |
+ |
serde_json::json!(["a1", "a2", "a3"]),
|
|
1862 |
+ |
"the response names every node the promote reached",
|
|
1863 |
+ |
);
|
|
1864 |
+ |
|
|
1865 |
+ |
let (cur, prev) = tier_versions(&pool, "a").await;
|
|
1866 |
+ |
assert_eq!(cur.as_deref(), Some("3.0.0"));
|
|
1867 |
+ |
assert_eq!(prev.as_deref(), Some("2.0.0"));
|
|
1868 |
+ |
|
|
1869 |
+ |
// Sequential canary: a1 is fully touched before a2, a2 before a3.
|
|
1870 |
+ |
let log = log.lock().unwrap().clone();
|
|
1871 |
+ |
assert!(
|
|
1872 |
+ |
first_touch(&log, "a1") < first_touch(&log, "a2")
|
|
1873 |
+ |
&& first_touch(&log, "a2") < first_touch(&log, "a3"),
|
|
1874 |
+ |
"nodes must deploy in tier order: {log:#?}",
|
|
1875 |
+ |
);
|
|
1876 |
+ |
|
|
1877 |
+ |
// Every node has a green deploy row for the promoted version.
|
|
1878 |
+ |
let ok: i64 = sqlx::query_scalar(
|
|
1879 |
+ |
"SELECT COUNT(*) FROM deploys WHERE version = '3.0.0' AND outcome = 'ok'",
|
|
1880 |
+ |
)
|
|
1881 |
+ |
.fetch_one(&pool)
|
|
1882 |
+ |
.await
|
|
1883 |
+ |
.unwrap();
|
|
1884 |
+ |
assert_eq!(ok, 3, "one ok deploy row per node");
|
|
1885 |
+ |
}
|
|
1886 |
+ |
|
|
1887 |
+ |
#[tokio::test]
|
|
1888 |
+ |
async fn a_mid_canary_deploy_failure_rolls_touched_nodes_back_and_does_not_advance() {
|
|
1889 |
+ |
// a2 fails its forward deploy of 3.0.0; a1 was already flipped, a3 is
|
|
1890 |
+ |
// never reached. The touched nodes (a1, a2) roll back to 2.0.0 — their
|
|
1891 |
+ |
// rollback ops target `releases/2.0.0`, which the marker does not match —
|
|
1892 |
+ |
// and tier_state must NOT advance.
|
|
1893 |
+ |
let (state, log) = fleet_fixture(&["a1", "a2", "a3"], Some("a2"), "releases/3.0.0").await;
|
|
1894 |
+ |
let pool = state.pool.clone();
|
|
1895 |
+ |
|
|
1896 |
+ |
let err = promote_inner(state, "a".into(), PromoteBody::default())
|
|
1897 |
+ |
.await
|
|
1898 |
+ |
.expect_err("a mid-canary deploy failure must fail the promote");
|
|
1899 |
+ |
assert!(
|
|
1900 |
+ |
matches!(err, crate::error::Error::Other(_)),
|
|
1901 |
+ |
"a deploy failure propagates as Other, got: {err:?}",
|
|
1902 |
+ |
);
|
|
1903 |
+ |
|
|
1904 |
+ |
// tier_state untouched: the failure returns before advance_tier.
|
|
1905 |
+ |
let (cur, prev) = tier_versions(&pool, "a").await;
|
|
1906 |
+ |
assert_eq!(
|
|
1907 |
+ |
cur.as_deref(),
|
|
1908 |
+ |
Some("2.0.0"),
|
|
1909 |
+ |
"a failed rollout must not advance"
|
|
1910 |
+ |
);
|
|
1911 |
+ |
assert_eq!(prev.as_deref(), Some("1.0.0"));
|
|
1912 |
+ |
|
|
1913 |
+ |
let log = log.lock().unwrap().clone();
|
|
1914 |
+ |
// a3 sits after the failed a2 in the sequence and is never touched.
|
|
1915 |
+ |
assert!(
|
|
1916 |
+ |
!log.iter().any(|e| e.starts_with("a3:")),
|
|
1917 |
+ |
"nodes after the failure must not be deployed to: {log:#?}",
|
|
1918 |
+ |
);
|
|
1919 |
+ |
// Both touched nodes were rolled back to the prior version.
|
|
1920 |
+ |
for n in ["a1", "a2"] {
|
|
1921 |
+ |
assert!(
|
|
1922 |
+ |
log.iter()
|
|
1923 |
+ |
.any(|e| e.starts_with(&format!("{n}:")) && e.contains("releases/2.0.0")),
|
|
1924 |
+ |
"touched node {n} must be restored to 2.0.0: {log:#?}",
|
|
1925 |
+ |
);
|
|
1926 |
+ |
}
|
|
1927 |
+ |
|
|
1928 |
+ |
// The forward attempt is on the record: a1 ok, a2 failed.
|
|
1929 |
+ |
let a1: String = sqlx::query_scalar(
|
|
1930 |
+ |
"SELECT outcome FROM deploys WHERE version = '3.0.0' AND node = 'a1'",
|
|
1931 |
+ |
)
|
|
1932 |
+ |
.fetch_one(&pool)
|
|
1933 |
+ |
.await
|
|
1934 |
+ |
.unwrap();
|
|
1935 |
+ |
assert_eq!(a1, "ok");
|
|
1936 |
+ |
let a2: String = sqlx::query_scalar(
|
|
1937 |
+ |
"SELECT outcome FROM deploys WHERE version = '3.0.0' AND node = 'a2'",
|
|
1938 |
+ |
)
|
|
1939 |
+ |
.fetch_one(&pool)
|
|
1940 |
+ |
.await
|
|
1941 |
+ |
.unwrap();
|
|
1942 |
+ |
assert_eq!(a2, "failed");
|
|
1943 |
+ |
|
|
1944 |
+ |
// Both touched nodes restored => the tier is consistent on 2.0.0, so the
|
|
1945 |
+ |
// partial flag is cleared, not set.
|
|
1946 |
+ |
let reason: Option<String> =
|
|
1947 |
+ |
sqlx::query_scalar("SELECT partial_reason FROM tier_state WHERE tier = 'a'")
|
|
1948 |
+ |
.fetch_one(&pool)
|
|
1949 |
+ |
.await
|
|
1950 |
+ |
.unwrap();
|
|
1951 |
+ |
assert_eq!(
|
|
1952 |
+ |
reason, None,
|
|
1953 |
+ |
"a fully-restored canary leaves the tier consistent, not partial",
|
|
1954 |
+ |
);
|
|
1955 |
+ |
}
|
|
1956 |
+ |
|
| 1665 |
1957 |
|
/// Build a one-node tier "a" on a tempdir release root, pre-seeded as if a
|
| 1666 |
1958 |
|
/// promote had flipped it to `current` with `prev` still staged on disk.
|
| 1667 |
1959 |
|
/// Returns the state (topology rewired to the tempdir node) and the tempdir,
|