Line data Source code
1 : use crate::{background_process, local_env::LocalEnv};
2 : use camino::{Utf8Path, Utf8PathBuf};
3 : use hyper::Method;
4 : use pageserver_api::{
5 : controller_api::{
6 : NodeConfigureRequest, NodeRegisterRequest, TenantCreateResponse, TenantLocateResponse,
7 : TenantShardMigrateRequest, TenantShardMigrateResponse,
8 : },
9 : models::{
10 : TenantCreateRequest, TenantShardSplitRequest, TenantShardSplitResponse,
11 : TimelineCreateRequest, TimelineInfo,
12 : },
13 : shard::{ShardStripeSize, TenantShardId},
14 : };
15 : use pageserver_client::mgmt_api::ResponseErrorMessageExt;
16 : use postgres_backend::AuthType;
17 : use serde::{de::DeserializeOwned, Deserialize, Serialize};
18 : use std::{fs, str::FromStr};
19 : use tokio::process::Command;
20 : use tracing::instrument;
21 : use url::Url;
22 : use utils::{
23 : auth::{encode_from_key_file, Claims, Scope},
24 : id::{NodeId, TenantId},
25 : };
26 :
27 : pub struct StorageController {
28 : env: LocalEnv,
29 : listen: String,
30 : path: Utf8PathBuf,
31 : private_key: Option<Vec<u8>>,
32 : public_key: Option<String>,
33 : postgres_port: u16,
34 : client: reqwest::Client,
35 : }
36 :
37 : const COMMAND: &str = "storage_controller";
38 :
39 : const STORAGE_CONTROLLER_POSTGRES_VERSION: u32 = 16;
40 :
41 : // Use a shorter pageserver unavailability interval than the default to speed up tests.
42 : const NEON_LOCAL_MAX_UNAVAILABLE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
43 :
44 0 : #[derive(Serialize, Deserialize)]
45 : pub struct AttachHookRequest {
46 : pub tenant_shard_id: TenantShardId,
47 : pub node_id: Option<NodeId>,
48 : }
49 :
50 0 : #[derive(Serialize, Deserialize)]
51 : pub struct AttachHookResponse {
52 : pub gen: Option<u32>,
53 : }
54 :
55 0 : #[derive(Serialize, Deserialize)]
56 : pub struct InspectRequest {
57 : pub tenant_shard_id: TenantShardId,
58 : }
59 :
60 0 : #[derive(Serialize, Deserialize)]
61 : pub struct InspectResponse {
62 : pub attachment: Option<(u32, NodeId)>,
63 : }
64 :
65 : impl StorageController {
66 0 : pub fn from_env(env: &LocalEnv) -> Self {
67 0 : let path = Utf8PathBuf::from_path_buf(env.base_data_dir.clone())
68 0 : .unwrap()
69 0 : .join("attachments.json");
70 0 :
71 0 : // Makes no sense to construct this if pageservers aren't going to use it: assume
72 0 : // pageservers have control plane API set
73 0 : let listen_url = env.control_plane_api.clone().unwrap();
74 0 :
75 0 : let listen = format!(
76 0 : "{}:{}",
77 0 : listen_url.host_str().unwrap(),
78 0 : listen_url.port().unwrap()
79 0 : );
80 0 :
81 0 : // Convention: NeonEnv in python tests reserves the next port after the control_plane_api
82 0 : // port, for use by our captive postgres.
83 0 : let postgres_port = listen_url
84 0 : .port()
85 0 : .expect("Control plane API setting should always have a port")
86 0 : + 1;
87 0 :
88 0 : // Assume all pageservers have symmetric auth configuration: this service
89 0 : // expects to use one JWT token to talk to all of them.
90 0 : let ps_conf = env
91 0 : .pageservers
92 0 : .first()
93 0 : .expect("Config is validated to contain at least one pageserver");
94 0 : let (private_key, public_key) = match ps_conf.http_auth_type {
95 0 : AuthType::Trust => (None, None),
96 : AuthType::NeonJWT => {
97 0 : let private_key_path = env.get_private_key_path();
98 0 : let private_key = fs::read(private_key_path).expect("failed to read private key");
99 0 :
100 0 : // If pageserver auth is enabled, this implicitly enables auth for this service,
101 0 : // using the same credentials.
102 0 : let public_key_path =
103 0 : camino::Utf8PathBuf::try_from(env.base_data_dir.join("auth_public_key.pem"))
104 0 : .unwrap();
105 :
106 : // This service takes keys as a string rather than as a path to a file/dir: read the key into memory.
107 0 : let public_key = if std::fs::metadata(&public_key_path)
108 0 : .expect("Can't stat public key")
109 0 : .is_dir()
110 : {
111 : // Our config may specify a directory: this is for the pageserver's ability to handle multiple
112 : // keys. We only use one key at a time, so, arbitrarily load the first one in the directory.
113 0 : let mut dir =
114 0 : std::fs::read_dir(&public_key_path).expect("Can't readdir public key path");
115 0 : let dent = dir
116 0 : .next()
117 0 : .expect("Empty key dir")
118 0 : .expect("Error reading key dir");
119 0 :
120 0 : std::fs::read_to_string(dent.path()).expect("Can't read public key")
121 : } else {
122 0 : std::fs::read_to_string(&public_key_path).expect("Can't read public key")
123 : };
124 0 : (Some(private_key), Some(public_key))
125 : }
126 : };
127 :
128 0 : Self {
129 0 : env: env.clone(),
130 0 : path,
131 0 : listen,
132 0 : private_key,
133 0 : public_key,
134 0 : postgres_port,
135 0 : client: reqwest::ClientBuilder::new()
136 0 : .build()
137 0 : .expect("Failed to construct http client"),
138 0 : }
139 0 : }
140 :
141 0 : fn pid_file(&self) -> Utf8PathBuf {
142 0 : Utf8PathBuf::from_path_buf(self.env.base_data_dir.join("storage_controller.pid"))
143 0 : .expect("non-Unicode path")
144 0 : }
145 :
146 : /// PIDFile for the postgres instance used to store storage controller state
147 0 : fn postgres_pid_file(&self) -> Utf8PathBuf {
148 0 : Utf8PathBuf::from_path_buf(
149 0 : self.env
150 0 : .base_data_dir
151 0 : .join("storage_controller_postgres.pid"),
152 0 : )
153 0 : .expect("non-Unicode path")
154 0 : }
155 :
156 : /// Find the directory containing postgres binaries, such as `initdb` and `pg_ctl`
157 : ///
158 : /// This usually uses STORAGE_CONTROLLER_POSTGRES_VERSION of postgres, but will fall back
159 : /// to other versions if that one isn't found. Some automated tests create circumstances
160 : /// where only one version is available in pg_distrib_dir, such as `test_remote_extensions`.
161 0 : pub async fn get_pg_bin_dir(&self) -> anyhow::Result<Utf8PathBuf> {
162 0 : let prefer_versions = [STORAGE_CONTROLLER_POSTGRES_VERSION, 15, 14];
163 :
164 0 : for v in prefer_versions {
165 0 : let path = Utf8PathBuf::from_path_buf(self.env.pg_bin_dir(v)?).unwrap();
166 0 : if tokio::fs::try_exists(&path).await? {
167 0 : return Ok(path);
168 0 : }
169 : }
170 :
171 : // Fall through
172 0 : anyhow::bail!(
173 0 : "Postgres binaries not found in {}",
174 0 : self.env.pg_distrib_dir.display()
175 0 : );
176 0 : }
177 :
178 : /// Readiness check for our postgres process
179 0 : async fn pg_isready(&self, pg_bin_dir: &Utf8Path) -> anyhow::Result<bool> {
180 0 : let bin_path = pg_bin_dir.join("pg_isready");
181 0 : let args = ["-h", "localhost", "-p", &format!("{}", self.postgres_port)];
182 0 : let exitcode = Command::new(bin_path).args(args).spawn()?.wait().await?;
183 :
184 0 : Ok(exitcode.success())
185 0 : }
186 :
187 : /// Create our database if it doesn't exist, and run migrations.
188 : ///
189 : /// This function is equivalent to the `diesel setup` command in the diesel CLI. We implement
190 : /// the same steps by hand to avoid imposing a dependency on installing diesel-cli for developers
191 : /// who just want to run `cargo neon_local` without knowing about diesel.
192 : ///
193 : /// Returns the database url
194 0 : pub async fn setup_database(&self) -> anyhow::Result<String> {
195 0 : const DB_NAME: &str = "storage_controller";
196 0 : let database_url = format!("postgresql://localhost:{}/{DB_NAME}", self.postgres_port);
197 :
198 0 : let pg_bin_dir = self.get_pg_bin_dir().await?;
199 0 : let createdb_path = pg_bin_dir.join("createdb");
200 0 : let output = Command::new(&createdb_path)
201 0 : .args([
202 0 : "-h",
203 0 : "localhost",
204 0 : "-p",
205 0 : &format!("{}", self.postgres_port),
206 0 : DB_NAME,
207 0 : ])
208 0 : .output()
209 0 : .await
210 0 : .expect("Failed to spawn createdb");
211 0 :
212 0 : if !output.status.success() {
213 0 : let stderr = String::from_utf8(output.stderr).expect("Non-UTF8 output from createdb");
214 0 : if stderr.contains("already exists") {
215 0 : tracing::info!("Database {DB_NAME} already exists");
216 : } else {
217 0 : anyhow::bail!("createdb failed with status {}: {stderr}", output.status);
218 : }
219 0 : }
220 :
221 0 : Ok(database_url)
222 0 : }
223 :
224 0 : pub async fn start(&self) -> anyhow::Result<()> {
225 0 : // Start a vanilla Postgres process used by the storage controller for persistence.
226 0 : let pg_data_path = Utf8PathBuf::from_path_buf(self.env.base_data_dir.clone())
227 0 : .unwrap()
228 0 : .join("storage_controller_db");
229 0 : let pg_bin_dir = self.get_pg_bin_dir().await?;
230 0 : let pg_log_path = pg_data_path.join("postgres.log");
231 0 :
232 0 : if !tokio::fs::try_exists(&pg_data_path).await? {
233 : // Initialize empty database
234 0 : let initdb_path = pg_bin_dir.join("initdb");
235 0 : let mut child = Command::new(&initdb_path)
236 0 : .args(["-D", pg_data_path.as_ref()])
237 0 : .spawn()
238 0 : .expect("Failed to spawn initdb");
239 0 : let status = child.wait().await?;
240 0 : if !status.success() {
241 0 : anyhow::bail!("initdb failed with status {status}");
242 0 : }
243 0 :
244 0 : tokio::fs::write(
245 0 : &pg_data_path.join("postgresql.conf"),
246 0 : format!("port = {}", self.postgres_port),
247 0 : )
248 0 : .await?;
249 0 : };
250 :
251 0 : println!("Starting storage controller database...");
252 0 : let db_start_args = [
253 0 : "-w",
254 0 : "-D",
255 0 : pg_data_path.as_ref(),
256 0 : "-l",
257 0 : pg_log_path.as_ref(),
258 0 : "start",
259 0 : ];
260 0 :
261 0 : background_process::start_process(
262 0 : "storage_controller_db",
263 0 : &self.env.base_data_dir,
264 0 : pg_bin_dir.join("pg_ctl").as_std_path(),
265 0 : db_start_args,
266 0 : [],
267 0 : background_process::InitialPidFile::Create(self.postgres_pid_file()),
268 0 : || self.pg_isready(&pg_bin_dir),
269 0 : )
270 0 : .await?;
271 :
272 : // Run migrations on every startup, in case something changed.
273 0 : let database_url = self.setup_database().await?;
274 :
275 0 : let max_unavailable: humantime::Duration = NEON_LOCAL_MAX_UNAVAILABLE_INTERVAL.into();
276 0 :
277 0 : let mut args = vec![
278 0 : "-l",
279 0 : &self.listen,
280 0 : "-p",
281 0 : self.path.as_ref(),
282 0 : "--dev",
283 0 : "--database-url",
284 0 : &database_url,
285 0 : "--max-unavailable-interval",
286 0 : &max_unavailable.to_string(),
287 0 : ]
288 0 : .into_iter()
289 0 : .map(|s| s.to_string())
290 0 : .collect::<Vec<_>>();
291 0 : if let Some(private_key) = &self.private_key {
292 0 : let claims = Claims::new(None, Scope::PageServerApi);
293 0 : let jwt_token =
294 0 : encode_from_key_file(&claims, private_key).expect("failed to generate jwt token");
295 0 : args.push(format!("--jwt-token={jwt_token}"));
296 0 : }
297 :
298 0 : if let Some(public_key) = &self.public_key {
299 0 : args.push(format!("--public-key=\"{public_key}\""));
300 0 : }
301 :
302 0 : if let Some(control_plane_compute_hook_api) = &self.env.control_plane_compute_hook_api {
303 0 : args.push(format!(
304 0 : "--compute-hook-url={control_plane_compute_hook_api}"
305 0 : ));
306 0 : }
307 :
308 0 : background_process::start_process(
309 0 : COMMAND,
310 0 : &self.env.base_data_dir,
311 0 : &self.env.storage_controller_bin(),
312 0 : args,
313 0 : [(
314 0 : "NEON_REPO_DIR".to_string(),
315 0 : self.env.base_data_dir.to_string_lossy().to_string(),
316 0 : )],
317 0 : background_process::InitialPidFile::Create(self.pid_file()),
318 0 : || async {
319 0 : match self.ready().await {
320 0 : Ok(_) => Ok(true),
321 0 : Err(_) => Ok(false),
322 : }
323 0 : },
324 0 : )
325 0 : .await?;
326 :
327 0 : Ok(())
328 0 : }
329 :
330 0 : pub async fn stop(&self, immediate: bool) -> anyhow::Result<()> {
331 0 : background_process::stop_process(immediate, COMMAND, &self.pid_file())?;
332 :
333 0 : let pg_data_path = self.env.base_data_dir.join("storage_controller_db");
334 0 : let pg_bin_dir = self.get_pg_bin_dir().await?;
335 :
336 0 : println!("Stopping storage controller database...");
337 0 : let pg_stop_args = ["-D", &pg_data_path.to_string_lossy(), "stop"];
338 0 : let stop_status = Command::new(pg_bin_dir.join("pg_ctl"))
339 0 : .args(pg_stop_args)
340 0 : .spawn()?
341 0 : .wait()
342 0 : .await?;
343 0 : if !stop_status.success() {
344 0 : let pg_status_args = ["-D", &pg_data_path.to_string_lossy(), "status"];
345 0 : let status_exitcode = Command::new(pg_bin_dir.join("pg_ctl"))
346 0 : .args(pg_status_args)
347 0 : .spawn()?
348 0 : .wait()
349 0 : .await?;
350 :
351 : // pg_ctl status returns this exit code if postgres is not running: in this case it is
352 : // fine that stop failed. Otherwise it is an error that stop failed.
353 : const PG_STATUS_NOT_RUNNING: i32 = 3;
354 0 : if Some(PG_STATUS_NOT_RUNNING) == status_exitcode.code() {
355 0 : println!("Storage controller database is already stopped");
356 0 : return Ok(());
357 : } else {
358 0 : anyhow::bail!("Failed to stop storage controller database: {stop_status}")
359 : }
360 0 : }
361 0 :
362 0 : Ok(())
363 0 : }
364 :
365 0 : fn get_claims_for_path(path: &str) -> anyhow::Result<Option<Claims>> {
366 0 : let category = match path.find('/') {
367 0 : Some(idx) => &path[..idx],
368 0 : None => path,
369 : };
370 :
371 0 : match category {
372 0 : "status" | "ready" => Ok(None),
373 0 : "control" | "debug" => Ok(Some(Claims::new(None, Scope::Admin))),
374 0 : "v1" => Ok(Some(Claims::new(None, Scope::PageServerApi))),
375 0 : _ => Err(anyhow::anyhow!("Failed to determine claims for {}", path)),
376 : }
377 0 : }
378 :
379 : /// Simple HTTP request wrapper for calling into storage controller
380 0 : async fn dispatch<RQ, RS>(
381 0 : &self,
382 0 : method: hyper::Method,
383 0 : path: String,
384 0 : body: Option<RQ>,
385 0 : ) -> anyhow::Result<RS>
386 0 : where
387 0 : RQ: Serialize + Sized,
388 0 : RS: DeserializeOwned + Sized,
389 0 : {
390 0 : // The configured URL has the /upcall path prefix for pageservers to use: we will strip that out
391 0 : // for general purpose API access.
392 0 : let listen_url = self.env.control_plane_api.clone().unwrap();
393 0 : let url = Url::from_str(&format!(
394 0 : "http://{}:{}/{path}",
395 0 : listen_url.host_str().unwrap(),
396 0 : listen_url.port().unwrap()
397 0 : ))
398 0 : .unwrap();
399 0 :
400 0 : let mut builder = self.client.request(method, url);
401 0 : if let Some(body) = body {
402 0 : builder = builder.json(&body)
403 0 : }
404 0 : if let Some(private_key) = &self.private_key {
405 0 : println!("Getting claims for path {}", path);
406 0 : if let Some(required_claims) = Self::get_claims_for_path(&path)? {
407 0 : println!("Got claims {:?} for path {}", required_claims, path);
408 0 : let jwt_token = encode_from_key_file(&required_claims, private_key)?;
409 0 : builder = builder.header(
410 0 : reqwest::header::AUTHORIZATION,
411 0 : format!("Bearer {jwt_token}"),
412 0 : );
413 0 : }
414 0 : }
415 :
416 0 : let response = builder.send().await?;
417 0 : let response = response.error_from_body().await?;
418 :
419 0 : Ok(response
420 0 : .json()
421 0 : .await
422 0 : .map_err(pageserver_client::mgmt_api::Error::ReceiveBody)?)
423 0 : }
424 :
425 : /// Call into the attach_hook API, for use before handing out attachments to pageservers
426 0 : #[instrument(skip(self))]
427 : pub async fn attach_hook(
428 : &self,
429 : tenant_shard_id: TenantShardId,
430 : pageserver_id: NodeId,
431 : ) -> anyhow::Result<Option<u32>> {
432 : let request = AttachHookRequest {
433 : tenant_shard_id,
434 : node_id: Some(pageserver_id),
435 : };
436 :
437 : let response = self
438 : .dispatch::<_, AttachHookResponse>(
439 : Method::POST,
440 : "debug/v1/attach-hook".to_string(),
441 : Some(request),
442 : )
443 : .await?;
444 :
445 : Ok(response.gen)
446 : }
447 :
448 0 : #[instrument(skip(self))]
449 : pub async fn inspect(
450 : &self,
451 : tenant_shard_id: TenantShardId,
452 : ) -> anyhow::Result<Option<(u32, NodeId)>> {
453 : let request = InspectRequest { tenant_shard_id };
454 :
455 : let response = self
456 : .dispatch::<_, InspectResponse>(
457 : Method::POST,
458 : "debug/v1/inspect".to_string(),
459 : Some(request),
460 : )
461 : .await?;
462 :
463 : Ok(response.attachment)
464 : }
465 :
466 0 : #[instrument(skip(self))]
467 : pub async fn tenant_create(
468 : &self,
469 : req: TenantCreateRequest,
470 : ) -> anyhow::Result<TenantCreateResponse> {
471 : self.dispatch(Method::POST, "v1/tenant".to_string(), Some(req))
472 : .await
473 : }
474 :
475 0 : #[instrument(skip(self))]
476 : pub async fn tenant_locate(&self, tenant_id: TenantId) -> anyhow::Result<TenantLocateResponse> {
477 : self.dispatch::<(), _>(
478 : Method::GET,
479 : format!("debug/v1/tenant/{tenant_id}/locate"),
480 : None,
481 : )
482 : .await
483 : }
484 :
485 0 : #[instrument(skip(self))]
486 : pub async fn tenant_migrate(
487 : &self,
488 : tenant_shard_id: TenantShardId,
489 : node_id: NodeId,
490 : ) -> anyhow::Result<TenantShardMigrateResponse> {
491 : self.dispatch(
492 : Method::PUT,
493 : format!("control/v1/tenant/{tenant_shard_id}/migrate"),
494 : Some(TenantShardMigrateRequest {
495 : tenant_shard_id,
496 : node_id,
497 : }),
498 : )
499 : .await
500 : }
501 :
502 0 : #[instrument(skip(self), fields(%tenant_id, %new_shard_count))]
503 : pub async fn tenant_split(
504 : &self,
505 : tenant_id: TenantId,
506 : new_shard_count: u8,
507 : new_stripe_size: Option<ShardStripeSize>,
508 : ) -> anyhow::Result<TenantShardSplitResponse> {
509 : self.dispatch(
510 : Method::PUT,
511 : format!("control/v1/tenant/{tenant_id}/shard_split"),
512 : Some(TenantShardSplitRequest {
513 : new_shard_count,
514 : new_stripe_size,
515 : }),
516 : )
517 : .await
518 : }
519 :
520 0 : #[instrument(skip_all, fields(node_id=%req.node_id))]
521 : pub async fn node_register(&self, req: NodeRegisterRequest) -> anyhow::Result<()> {
522 : self.dispatch::<_, ()>(Method::POST, "control/v1/node".to_string(), Some(req))
523 : .await
524 : }
525 :
526 0 : #[instrument(skip_all, fields(node_id=%req.node_id))]
527 : pub async fn node_configure(&self, req: NodeConfigureRequest) -> anyhow::Result<()> {
528 : self.dispatch::<_, ()>(
529 : Method::PUT,
530 : format!("control/v1/node/{}/config", req.node_id),
531 : Some(req),
532 : )
533 : .await
534 : }
535 :
536 0 : #[instrument(skip(self))]
537 : pub async fn ready(&self) -> anyhow::Result<()> {
538 : self.dispatch::<(), ()>(Method::GET, "ready".to_string(), None)
539 : .await
540 : }
541 :
542 0 : #[instrument(skip_all, fields(%tenant_id, timeline_id=%req.new_timeline_id))]
543 : pub async fn tenant_timeline_create(
544 : &self,
545 : tenant_id: TenantId,
546 : req: TimelineCreateRequest,
547 : ) -> anyhow::Result<TimelineInfo> {
548 : self.dispatch(
549 : Method::POST,
550 : format!("v1/tenant/{tenant_id}/timeline"),
551 : Some(req),
552 : )
553 : .await
554 : }
555 : }
|