Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/bin/bake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
//! `DATABASE_URL` (the stacker Postgres) persists the BakeRecord; without it
//! the bake still snapshots and prints the record, but it is not registered.

use stacker::connectors::hetzner::{HetznerCloudClient, HetznerSnapshotTarget};
use stacker::connectors::hetzner::{
HetznerCloudClient, HetznerCloudConnector, HetznerSnapshotTarget,
};
use stacker::helpers::bake::run_bake;

#[tokio::main]
Expand Down Expand Up @@ -161,6 +163,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
stacker::helpers::bake_finalize::check_contract_usable(&protected_keys, allow_unsanitized)
.map_err(|e| e.to_string())?;

// Confirm the provider knows this box *before* sanitizing it. Sanitizing
// removes the operator's own SSH key — that is the point, since a key left
// in the image would grant its holder root on every clone — so a target the
// provider cannot match leaves a cleaned box that can no longer be reached
// and never got snapshotted. Happens with one wrong flag: `--server-id`
// takes the *provider's* id, and passing Stacker's own server id instead
// matches nothing.
let connector = HetznerCloudClient::from_env().map_err(|e| e.to_string())?;
let resolved_server_id = connector
.resolve_snapshot_target(&token, &target)
.await
.map_err(|e| {
format!(
"refusing to touch the build box: the Hetzner server could not be \
identified ({e}). --server-id must be the Hetzner server id, not \
Stacker's; --ip must be the box's current public address."
)
})?;
eprintln!("==> Build box resolved to Hetzner server {resolved_server_id}.");

// Sanitize the build box before the snapshot is taken.
let finalize_outcome = match (&ssh_key, allow_unsanitized) {
(Some(key_path), _) => {
Expand Down Expand Up @@ -211,7 +233,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
};

let connector = HetznerCloudClient::from_env().map_err(|e| e.to_string())?;
let record = run_bake(
&connector, &token, target, &stack, &version, healthy, &detail,
)
Expand Down
19 changes: 19 additions & 0 deletions src/connectors/hetzner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ pub trait HetznerCloudConnector: Send + Sync {
rules: Vec<HetznerFirewallRule>,
server_id: i64,
) -> Result<HetznerFirewallResult, ConnectorError>;

/// Resolve a snapshot target to the provider's server id without acting on
/// it. Exposed so a caller that is about to modify the machine can confirm
/// the provider actually knows it first — a wrong id discovered after the
/// build box has been sanitized costs the whole box, since sanitizing
/// removes the operator's own SSH access.
async fn resolve_snapshot_target(
&self,
token: &str,
target: &HetznerSnapshotTarget,
) -> Result<i64, ConnectorError>;
}

#[derive(Clone)]
Expand Down Expand Up @@ -186,6 +197,14 @@ impl HetznerCloudClient {

#[async_trait]
impl HetznerCloudConnector for HetznerCloudClient {
async fn resolve_snapshot_target(
&self,
token: &str,
target: &HetznerSnapshotTarget,
) -> Result<i64, ConnectorError> {
self.resolve_server_id(token, target).await
}

async fn create_server_snapshot(
&self,
token: &str,
Expand Down
90 changes: 90 additions & 0 deletions src/helpers/bake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ mod tests {
struct MockOk;
#[async_trait::async_trait]
impl HetznerCloudConnector for MockOk {
async fn resolve_snapshot_target(
&self,
_t: &str,
_target: &HetznerSnapshotTarget,
) -> Result<i64, crate::connectors::ConnectorError> {
Ok(123)
}
async fn create_server_snapshot(
&self,
_t: &str,
Expand Down Expand Up @@ -180,6 +187,89 @@ mod tests {
}
}

/// A connector that cannot match the target — the shape of a wrong
/// `--server-id`, which Hetzner answers with "server not found".
struct MockUnknownTarget;
#[async_trait::async_trait]
impl HetznerCloudConnector for MockUnknownTarget {
async fn resolve_snapshot_target(
&self,
_t: &str,
_target: &HetznerSnapshotTarget,
) -> Result<i64, crate::connectors::ConnectorError> {
Err(crate::connectors::ConnectorError::NotFound(
"server not found".to_string(),
))
}
async fn create_server_snapshot(
&self,
_t: &str,
_target: HetznerSnapshotTarget,
_d: &str,
) -> Result<crate::connectors::hetzner::HetznerSnapshot, crate::connectors::ConnectorError>
{
unreachable!("must not be reached when the target does not resolve")
}
async fn create_server_from_image(
&self,
_t: &str,
_r: crate::connectors::hetzner::HetznerCreateServerRequest,
) -> Result<
crate::connectors::hetzner::HetznerProvisionedServer,
crate::connectors::ConnectorError,
> {
unreachable!()
}
async fn list_server_types(
&self,
_t: &str,
) -> Result<Vec<String>, crate::connectors::ConnectorError> {
Ok(vec![])
}
async fn add_ssh_key(
&self,
_t: &str,
_n: &str,
_k: &str,
) -> Result<crate::connectors::hetzner::HetznerSshKey, crate::connectors::ConnectorError>
{
unreachable!()
}
async fn create_firewall(
&self,
_t: &str,
_n: &str,
_r: Vec<crate::connectors::hetzner::HetznerFirewallRule>,
_s: i64,
) -> Result<
crate::connectors::hetzner::HetznerFirewallResult,
crate::connectors::ConnectorError,
> {
unreachable!()
}
}

/// The bake binary resolves the target before it sanitizes the box, because
/// sanitizing removes the operator's own SSH key. Getting this order wrong
/// leaves a cleaned, unreachable box and no snapshot — which is what a
/// Stacker server id passed to `--server-id` produced in practice.
#[tokio::test]
async fn an_unmatched_target_is_refused_before_anything_is_snapshotted() {
let target = HetznerSnapshotTarget {
provider_server_id: Some(702),
server_name: None,
public_ip: Some("203.0.113.10".to_string()),
};
let err = MockUnknownTarget
.resolve_snapshot_target("tok", &target)
.await
.unwrap_err();
assert!(
err.to_string().contains("server not found"),
"the provider's reason should survive to the caller: {err}"
);
}

#[tokio::test]
async fn run_bake_snapshots_only_when_healthy() {
let target = HetznerSnapshotTarget {
Expand Down
Loading