Skip to content
Draft
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
470 changes: 364 additions & 106 deletions crates/daemon/src/gateway.rs

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions crates/daemon/src/gateway_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ use state::fs;
use crate::DaemonError;

static CANDIDATE_CONFIG_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) const GATEWAY_HEALTH_HOSTNAME: &str = "pv-gateway.localhost";
pub(crate) const GATEWAY_HEALTH_PATH: &str = "/__pv/health";
pub(crate) const GATEWAY_HEALTH_RESPONSE: &str = "pv-gateway-health-v1";

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GatewayConfigInput {
Expand Down Expand Up @@ -73,6 +76,12 @@ pub fn render_gateway_config(input: &GatewayConfigInput) -> Result<String, Daemo
output.push_str(" }\n");
output.push_str(" }\n");
output.push_str("}\n");
output.push_str(&format!(
"\nhttp://{GATEWAY_HEALTH_HOSTNAME} {{\n bind 127.0.0.1 ::1\n respond {GATEWAY_HEALTH_PATH} \"{GATEWAY_HEALTH_RESPONSE}\" 200\n}}\n"
));
output.push_str(&format!(
"\nhttps://{GATEWAY_HEALTH_HOSTNAME} {{\n bind 127.0.0.1 ::1\n tls {{\n issuer internal {{\n ca local\n }}\n }}\n respond {GATEWAY_HEALTH_PATH} \"{GATEWAY_HEALTH_RESPONSE}\" 200\n}}\n"
));

if input.import_project_configs {
output.push('\n');
Expand Down
82 changes: 81 additions & 1 deletion crates/daemon/src/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use rustls::pki_types::ServerName;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use state::{PvPaths, StateError, fs};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::process::Child;
use tokio::time::{Instant, sleep, timeout};
Expand Down Expand Up @@ -131,6 +131,16 @@ pub enum ReadinessCheck {
server_name: String,
ca_certificate_path: Utf8PathBuf,
},
GatewayIdentity {
http_host: String,
http_port: u16,
https_host: String,
https_port: u16,
server_name: String,
path: String,
expected_body: String,
ca_certificate_path: Utf8PathBuf,
},
RedisPing {
host: String,
port: u16,
Expand Down Expand Up @@ -504,6 +514,19 @@ impl ReadinessCheck {
"gateway:https:{server_name}:{https_host}:{https_port};tcp:{http_host}:{http_port}"
)
}
Self::GatewayIdentity {
http_host,
http_port,
https_host,
https_port,
server_name,
path,
..
} => {
format!(
"gateway-identity:https:{server_name}:{https_host}:{https_port}{path};http:{http_host}:{http_port}{path}"
)
}
Self::RedisPing { host, port } => format!("redis-ping:{host}:{port}"),
Self::Http { host, port, path } => format!("http:{host}:{port}{path}"),
}
Expand All @@ -524,6 +547,32 @@ async fn check_once(check: &ReadinessCheck) -> Result<(), DaemonError> {
check_tcp_once(http_host, *http_port).await?;
check_https_once(https_host, *https_port, server_name, ca_certificate_path).await
}
ReadinessCheck::GatewayIdentity {
http_host,
http_port,
https_host,
https_port,
server_name,
path,
expected_body,
ca_certificate_path,
} => {
let http_stream = TcpStream::connect((http_host.as_str(), *http_port)).await?;
check_gateway_identity_response(http_stream, server_name, path, expected_body).await?;
let tcp_stream = TcpStream::connect((https_host.as_str(), *https_port)).await?;
let connector = TlsConnector::from(tls_client_config(ca_certificate_path)?);
let server_name_text = server_name.to_owned();
let tls_server_name =
ServerName::try_from(server_name_text.clone()).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid TLS server name `{server_name_text}`: {error}"),
)
})?;
let tls_stream = connector.connect(tls_server_name, tcp_stream).await?;

check_gateway_identity_response(tls_stream, server_name, path, expected_body).await
}
ReadinessCheck::RedisPing { host, port } => {
let url = format!("redis://{host}:{port}/");
let client = redis::Client::open(url)?;
Expand Down Expand Up @@ -554,6 +603,37 @@ async fn check_once(check: &ReadinessCheck) -> Result<(), DaemonError> {
}
}

async fn check_gateway_identity_response<Stream>(
mut stream: Stream,
server_name: &str,
path: &str,
expected_body: &str,
) -> Result<(), DaemonError>
where
Stream: AsyncRead + AsyncWrite + Unpin,
{
let request =
format!("GET {path} HTTP/1.1\r\nHost: {server_name}\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).await?;

let mut response = Vec::new();
stream.take(4096).read_to_end(&mut response).await?;
if http_response_has_success_body(&response, expected_body.as_bytes()) {
return Ok(());
}

Err(io::Error::other("Gateway identity readiness returned an unexpected response").into())
}

fn http_response_has_success_body(response: &[u8], expected_body: &[u8]) -> bool {
let Some(headers_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else {
return false;
};
let body = &response[headers_end + 4..];

http_status_is_success(response, response.len()) && body == expected_body
}

async fn check_tcp_once(host: &str, port: u16) -> Result<(), DaemonError> {
let _stream = TcpStream::connect((host, port)).await?;

Expand Down
11 changes: 11 additions & 0 deletions crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args):
pass

def do_GET(self):
if self.path == "/__pv/health":
body = b"pv-gateway-health-v1"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return

super().do_GET()


class Server(http.server.ThreadingHTTPServer):
def server_bind(self):
Expand Down
138 changes: 134 additions & 4 deletions crates/daemon/tests/gateway_reconciliation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ use camino::{Utf8Path, Utf8PathBuf};
use camino_tempfile::tempdir;
use daemon::DaemonError;
use daemon::gateway::{
FrankenphpCommand, build_runtime_plan, gateway_process_spec, promote_validated_config_for_test,
reconcile_gateway_runtimes, reconcile_gateway_runtimes_with_readiness_timeout, validate_config,
worker_process_spec,
FrankenphpCommand, GatewayPfRoutingState, build_runtime_plan, gateway_process_spec,
promote_validated_config_for_test, reconcile_gateway_runtimes_with_pf_state_for_test,
validate_config, worker_process_spec,
};
use insta::{Settings, assert_debug_snapshot};
use rcgen::generate_simple_self_signed;
Expand All @@ -19,7 +19,7 @@ use std::collections::BTreeMap;
use std::ffi::OsString;
use std::net::TcpListener;
use std::process::Output;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::time::{sleep, timeout};

const GATEWAY_RECONCILIATION_SUMMARY: &str = "Gateway runtime reconciled";
Expand All @@ -41,6 +41,27 @@ const FAKE_FRANKENPHP_HANGS_ON_PORT_SERVER_SCRIPT: &str = include_str!(concat!(
));
const FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL: &str = "__PV_BLOCKED_PORT__";

async fn reconcile_gateway_runtimes(paths: &PvPaths) -> Result<String, DaemonError> {
reconcile_gateway_runtimes_with_pf_state_for_test(
paths,
Duration::from_secs(60),
GatewayPfRoutingState::Inactive,
)
.await
}

async fn reconcile_gateway_runtimes_with_readiness_timeout(
paths: &PvPaths,
readiness_timeout: Duration,
) -> Result<String, DaemonError> {
reconcile_gateway_runtimes_with_pf_state_for_test(
paths,
readiness_timeout,
GatewayPfRoutingState::Inactive,
)
.await
}

#[expect(
clippy::disallowed_types,
reason = "regression tests spawn a nested test process to control inherited env without unsafe mutation"
Expand Down Expand Up @@ -147,6 +168,114 @@ async fn gateway_reconciliation_starts_gateway_without_linked_projects() -> Resu
Ok(())
}

#[tokio::test]
async fn prepared_pf_files_do_not_force_public_readiness_when_rules_are_inactive() -> Result<()> {
let tempdir = tempdir()?;
let paths = PvPaths::for_home(tempdir.path().join("home"));
let release_path = tempdir.path().join("fake-frankenphp-release");
let fake_frankenphp = release_path.join("bin/frankenphp");

write_fake_frankenphp(&fake_frankenphp)?;

let mut database = Database::open(&paths)?;
database.record_managed_resource_track_installed(
"frankenphp",
"8.4",
"fake-frankenphp-pv1",
&release_path,
)?;
let ports = available_loopback_ports(2)?;
seed_runtime_ports(&paths, &mut database, ports[0], ports[1], &[])?;
drop(database);

let redirects = platform::PfRedirectConfig::new(ports[0], ports[1]);
fs::write_sensitive_file(&paths.pf_anchor_config(), &redirects.render_anchor())?;
fs::write_sensitive_file(
&paths.pf_conf_reference_config(),
&platform::PfConfReference.render(),
)?;

let started_at = Instant::now();
let summary = reconcile_gateway_runtimes_with_pf_state_for_test(
&paths,
Duration::from_millis(100),
GatewayPfRoutingState::Inactive,
)
.await?;

assert_eq!(summary, GATEWAY_RECONCILIATION_SUMMARY);
assert!(started_at.elapsed() < Duration::from_secs(2));
assert_runtime_states_snapshot(
"prepared_pf_files_do_not_force_public_readiness_when_rules_are_inactive",
Database::open(&paths)?.runtime_observed_states()?,
)?;

stop_runtime_from_pid_file(&paths.gateway_pid()).await?;

Ok(())
}

#[tokio::test]
async fn unknown_pf_state_keeps_owned_gateway_running_after_bounded_public_probe() -> Result<()> {
assert_uncertain_pf_state_preserves_gateway(
GatewayPfRoutingState::Unknown,
"unknown_pf_state_keeps_owned_gateway_running_after_bounded_public_probe",
)
.await
}

#[tokio::test]
async fn drifted_pf_state_keeps_owned_gateway_running_after_bounded_public_probe() -> Result<()> {
assert_uncertain_pf_state_preserves_gateway(
GatewayPfRoutingState::Drifted,
"drifted_pf_state_keeps_owned_gateway_running_after_bounded_public_probe",
)
.await
}

async fn assert_uncertain_pf_state_preserves_gateway(
pf_routing_state: GatewayPfRoutingState,
snapshot_name: &str,
) -> Result<()> {
let tempdir = tempdir()?;
let paths = PvPaths::for_home(tempdir.path().join("home"));
let release_path = tempdir.path().join("fake-frankenphp-release");
let fake_frankenphp = release_path.join("bin/frankenphp");

write_fake_frankenphp(&fake_frankenphp)?;

let mut database = Database::open(&paths)?;
database.record_managed_resource_track_installed(
"frankenphp",
"8.4",
"fake-frankenphp-pv1",
&release_path,
)?;
let ports = available_loopback_ports(2)?;
seed_runtime_ports(&paths, &mut database, ports[0], ports[1], &[])?;
drop(database);

let started_at = Instant::now();
let summary = reconcile_gateway_runtimes_with_pf_state_for_test(
&paths,
Duration::from_millis(100),
pf_routing_state,
)
.await?;

assert_eq!(summary, GATEWAY_RECONCILIATION_SUMMARY);
assert!(started_at.elapsed() < Duration::from_secs(2));
assert!(paths.gateway_pid().exists());
assert_runtime_states_snapshot(
snapshot_name,
Database::open(&paths)?.runtime_observed_states()?,
)?;

stop_runtime_from_pid_file(&paths.gateway_pid()).await?;

Ok(())
}

#[tokio::test]
async fn gateway_reconciliation_preserves_running_runtimes_on_second_reconcile() -> Result<()> {
let tempdir = tempdir()?;
Expand Down Expand Up @@ -2090,6 +2219,7 @@ fn seed_gateway_test_tls(paths: &PvPaths) -> Result<()> {
"broken.test".to_owned(),
"changed.acme.test".to_owned(),
"other.test".to_owned(),
"pv-gateway.localhost".to_owned(),
])?;
fs::write_sensitive_file(&paths.ca_certificate(), &certified_key.cert.pem())?;
fs::write_sensitive_file(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ Gateway:
}
}

http://pv-gateway.localhost {
bind 127.0.0.1 ::1
respond /__pv/health "pv-gateway-health-v1" 200
}

https://pv-gateway.localhost {
bind 127.0.0.1 ::1
tls {
issuer internal {
ca local
}
}
respond /__pv/health "pv-gateway-health-v1" 200
}

import "/Users/Alice Smith/.pv/config/gateway/projects/*.Caddyfile"

Worker:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,19 @@ expression: render_gateway_config(&input)?
}
}

http://pv-gateway.localhost {
bind 127.0.0.1 ::1
respond /__pv/health "pv-gateway-health-v1" 200
}

https://pv-gateway.localhost {
bind 127.0.0.1 ::1
tls {
issuer internal {
ca local
}
}
respond /__pv/health "pv-gateway-health-v1" 200
}

import "/Users/alice/.pv/config/gateway/projects/*.Caddyfile"
Loading
Loading