Skip to main content

olai_stack_runtime/neon/
mod.rs

1//! Neon branches as an environment source (feature `neon`).
2//!
3//! An instance can be bound to a branch of a Neon project. The branch then supplies what the
4//! plan's Neon-hosted placements need (see
5//! [`Plan::neon_requirements`](olai_stack_topology::Plan::neon_requirements)): its Postgres,
6//! its object storage and the URLs of the functions deployed on it. Everything goes through the
7//! Neon CLI behind [`NeonControl`]: [`NeonCli`] runs `neon`, and `FakeNeon` (feature
8//! `testing`) stands in for it in tests. A pure compose environment never touches it.
9//!
10//! - [`bind`] checks the branch against the plan's requirements (a missing function is an
11//!   error then, not at start), creates the databases and buckets it lacks, and mints a storage
12//!   credential on the branch for the instance. A child branch inherits its parent's
13//!   credentials, so reusing one would leak access into every later branch; each binding mints
14//!   its own, and [`unbind`] revokes it.
15//! - [`NeonBranchSource`] is the [`Source`](crate::Source) an instance takes the branch's
16//!   overlay from: `DATABASE_URL`, `DATABASE_URL_UNPOOLED`, `PG*`, the `NEON_S3_*` storage
17//!   coordinates and each function's `<SLUG>_FUNCTION_URL`.
18//! - Branches trestle creates ([`create_owned_branch`]) carry an ownership annotation and an
19//!   expiry. Only owned branches are reset or deleted ([`reset_owned_branch`],
20//!   [`delete_owned_branch`]).
21//!
22//! See `docs/neon.md` for what the CLI prints.
23
24mod cli;
25#[cfg(feature = "testing")]
26mod fake;
27mod source;
28
29use std::collections::BTreeMap;
30use std::fmt;
31
32use async_trait::async_trait;
33use olai_stack_topology::NeonRequirements;
34use serde::{Deserialize, Serialize};
35
36use crate::secrets::SecretValue;
37
38pub use cli::{NeonCli, locate_neon};
39#[cfg(feature = "testing")]
40pub use fake::{FakeNeon, NeonCall};
41pub use source::{NEON_SOURCE, NeonBinding, NeonBranchSource, StorageCredentialRef};
42
43/// The annotation key that marks a branch as created by trestle.
44pub const OWNER_ANNOTATION: &str = "trestle.owner";
45/// The value of [`OWNER_ANNOTATION`] on a trestle-created branch.
46pub const OWNER: &str = "trestle";
47/// The annotation key naming the instance a trestle-created branch was made for.
48pub const INSTANCE_ANNOTATION: &str = "trestle.instance";
49
50/// What can go wrong talking to Neon.
51#[derive(Debug, thiserror::Error)]
52pub enum NeonError {
53    /// Neither `neon` nor `neonctl` was found.
54    #[error("the Neon CLI (`neon` or `neonctl`) is not installed")]
55    NotInstalled,
56    /// The CLI isn't signed in.
57    #[error("the Neon CLI is not signed in: {0}")]
58    NotAuthenticated(String),
59    /// A command ran and failed.
60    #[error("`neon {command}` failed: {stderr}")]
61    Failed {
62        /// The arguments.
63        command: String,
64        /// What it printed on stderr.
65        stderr: String,
66    },
67    /// A command's output didn't parse.
68    #[error("parsing the output of `neon {command}`: {reason}")]
69    Parse {
70        /// The arguments.
71        command: String,
72        /// What was wrong.
73        reason: String,
74    },
75    /// Spawning the CLI failed.
76    #[error("running `neon {command}`: {source}")]
77    Io {
78        /// The arguments.
79        command: String,
80        /// The I/O error.
81        #[source]
82        source: std::io::Error,
83    },
84    /// No branch of that name or id in the project.
85    #[error("no branch `{0}` in the project")]
86    NoSuchBranch(String),
87    /// The branch wasn't created by trestle, so it is left alone.
88    #[error("branch `{0}` was not created by trestle; refusing to reset or delete it")]
89    NotOwned(String),
90    /// Functions the plan runs on the branch aren't deployed there.
91    #[error(
92        "branch `{branch}` has no function {}; deploy it on the branch (a branch only copies \
93         its parent's functions when it is created)",
94        slugs.iter().map(|s| format!("`{s}`")).collect::<Vec<_>>().join(", ")
95    )]
96    MissingFunctions {
97        /// The branch.
98        branch: String,
99        /// The missing slugs.
100        slugs: Vec<String>,
101    },
102    /// The branch has no object storage, but the plan uses it.
103    #[error("branch `{0}` has no object storage enabled")]
104    NoStorage(String),
105}
106
107/// A branch of a Neon project.
108#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Branch {
110    /// Its id (`br-…`).
111    pub id: String,
112    /// Its name.
113    pub name: String,
114    /// Its parent's id.
115    #[serde(default)]
116    pub parent_id: Option<String>,
117    /// When it expires (RFC 3339), if it does.
118    #[serde(default)]
119    pub expires_at: Option<String>,
120    /// Whether it is the project's default branch.
121    #[serde(default)]
122    pub default: bool,
123    /// Its annotations (only the API lists them).
124    #[serde(default)]
125    pub annotations: BTreeMap<String, String>,
126}
127
128impl Branch {
129    /// Whether trestle created it.
130    pub fn is_owned(&self) -> bool {
131        self.annotations.get(OWNER_ANNOTATION).map(String::as_str) == Some(OWNER)
132    }
133
134    /// Whether `name_or_id` names it.
135    pub fn is(&self, name_or_id: &str) -> bool {
136        self.id == name_or_id || self.name == name_or_id
137    }
138}
139
140/// How to create a branch.
141#[derive(Clone, Debug, Default, PartialEq, Eq)]
142pub struct CreateBranch {
143    /// Its name.
144    pub name: String,
145    /// The parent branch (name or id); the project's default when `None`.
146    pub parent: Option<String>,
147    /// When it expires (RFC 3339).
148    pub expires_at: Option<String>,
149    /// Annotations to set.
150    pub annotations: BTreeMap<String, String>,
151}
152
153/// A branch's Postgres connection, as `neon connection-string --extended` prints it.
154#[derive(Clone, PartialEq, Eq)]
155pub struct Connection {
156    /// The full URL (with the password).
157    pub url: SecretValue,
158    /// The host.
159    pub host: String,
160    /// The role.
161    pub role: String,
162    /// The role's password.
163    pub password: SecretValue,
164    /// The database.
165    pub database: String,
166    /// The query options (`sslmode=require&channel_binding=require`).
167    pub options: String,
168}
169
170impl fmt::Debug for Connection {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.debug_struct("Connection")
173            .field("url", &"<redacted>")
174            .field("host", &self.host)
175            .field("role", &self.role)
176            .field("password", &"<redacted>")
177            .field("database", &self.database)
178            .field("options", &self.options)
179            .finish()
180    }
181}
182
183/// A branch's object storage, as `neon api /projects/<p>/branches/<b>/storage` returns it.
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
185pub struct Storage {
186    /// Whether the branch has it.
187    pub enabled: bool,
188    /// The S3 endpoint.
189    #[serde(default)]
190    pub s3_endpoint: String,
191    /// The region.
192    #[serde(default)]
193    pub region: String,
194    /// Whether clients must address buckets path-style (Neon's always does).
195    #[serde(default)]
196    pub force_path_style: bool,
197}
198
199/// A function deployed on a branch.
200#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Function {
202    /// The slug it was deployed under.
203    pub slug: String,
204    /// Where it is invoked.
205    pub invocation_url: String,
206}
207
208/// A storage credential minted on a branch: the S3 access key id is its `token_id`.
209#[derive(Clone, PartialEq, Eq)]
210pub struct StorageCredential {
211    /// Its id, which is also the S3 access key id.
212    pub token_id: String,
213    /// The S3 secret access key.
214    pub secret_access_key: SecretValue,
215}
216
217impl fmt::Debug for StorageCredential {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        f.debug_struct("StorageCredential")
220            .field("token_id", &self.token_id)
221            .field("secret_access_key", &"<redacted>")
222            .finish()
223    }
224}
225
226/// What trestle asks of Neon, per project. [`NeonCli`] runs the Neon CLI; `FakeNeon` (feature
227/// `testing`) is an in-memory stand-in.
228#[async_trait]
229pub trait NeonControl: Send + Sync {
230    /// Who the CLI is signed in as (an error when it isn't).
231    async fn auth_status(&self) -> Result<String, NeonError>;
232
233    /// The project's branches, with their annotations.
234    async fn branches(&self, project: &str) -> Result<Vec<Branch>, NeonError>;
235
236    /// Create a branch (never printing its credentials).
237    async fn create_branch(&self, project: &str, spec: &CreateBranch) -> Result<Branch, NeonError>;
238
239    /// Delete the branch `id`.
240    async fn delete_branch(&self, project: &str, id: &str) -> Result<(), NeonError>;
241
242    /// Reset the branch `id` to its parent's current state.
243    async fn reset_branch(&self, project: &str, id: &str) -> Result<(), NeonError>;
244
245    /// The branch's Postgres connection to `database` (the default one when `None`), through
246    /// the connection pooler when `pooled`.
247    async fn connection(
248        &self,
249        project: &str,
250        branch: &str,
251        database: Option<&str>,
252        pooled: bool,
253    ) -> Result<Connection, NeonError>;
254
255    /// The branch's databases.
256    async fn databases(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError>;
257
258    /// Create the database `name` on the branch.
259    async fn create_database(
260        &self,
261        project: &str,
262        branch: &str,
263        name: &str,
264    ) -> Result<(), NeonError>;
265
266    /// The functions deployed on the branch.
267    async fn functions(&self, project: &str, branch: &str) -> Result<Vec<Function>, NeonError>;
268
269    /// The branch's object storage.
270    async fn storage(&self, project: &str, branch: &str) -> Result<Storage, NeonError>;
271
272    /// The branch's buckets.
273    async fn buckets(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError>;
274
275    /// Create the bucket `name` on the branch.
276    async fn create_bucket(&self, project: &str, branch: &str, name: &str)
277    -> Result<(), NeonError>;
278
279    /// Mint a storage credential (read and write) called `name` on the branch.
280    async fn create_credential(
281        &self,
282        project: &str,
283        branch: &str,
284        name: &str,
285    ) -> Result<StorageCredential, NeonError>;
286
287    /// Revoke the credential `token_id` on the branch.
288    async fn revoke_credential(
289        &self,
290        project: &str,
291        branch: &str,
292        token_id: &str,
293    ) -> Result<(), NeonError>;
294}
295
296/// The branch `name_or_id` of the project.
297pub async fn find_branch(
298    neon: &dyn NeonControl,
299    project: &str,
300    name_or_id: &str,
301) -> Result<Branch, NeonError> {
302    neon.branches(project)
303        .await?
304        .into_iter()
305        .find(|b| b.is(name_or_id))
306        .ok_or_else(|| NeonError::NoSuchBranch(name_or_id.to_owned()))
307}
308
309/// Create a branch owned by trestle: annotated with [`OWNER_ANNOTATION`] (and the
310/// `instance`, when it is made for one) and expiring at `expires_at`.
311pub async fn create_owned_branch(
312    neon: &dyn NeonControl,
313    project: &str,
314    name: &str,
315    parent: Option<&str>,
316    expires_at: Option<String>,
317    instance: Option<&str>,
318) -> Result<Branch, NeonError> {
319    let mut annotations = BTreeMap::from([(OWNER_ANNOTATION.to_owned(), OWNER.to_owned())]);
320    if let Some(instance) = instance {
321        annotations.insert(INSTANCE_ANNOTATION.to_owned(), instance.to_owned());
322    }
323    neon.create_branch(
324        project,
325        &CreateBranch {
326            name: name.to_owned(),
327            parent: parent.map(str::to_owned),
328            expires_at,
329            annotations,
330        },
331    )
332    .await
333}
334
335/// Delete the branch `name_or_id`, if trestle created it.
336pub async fn delete_owned_branch(
337    neon: &dyn NeonControl,
338    project: &str,
339    name_or_id: &str,
340) -> Result<Branch, NeonError> {
341    let branch = owned(neon, project, name_or_id).await?;
342    neon.delete_branch(project, &branch.id).await?;
343    Ok(branch)
344}
345
346/// Reset the branch `name_or_id` to its parent, if trestle created it.
347pub async fn reset_owned_branch(
348    neon: &dyn NeonControl,
349    project: &str,
350    name_or_id: &str,
351) -> Result<Branch, NeonError> {
352    let branch = owned(neon, project, name_or_id).await?;
353    neon.reset_branch(project, &branch.id).await?;
354    Ok(branch)
355}
356
357async fn owned(
358    neon: &dyn NeonControl,
359    project: &str,
360    name_or_id: &str,
361) -> Result<Branch, NeonError> {
362    let branch = find_branch(neon, project, name_or_id).await?;
363    if branch.is_owned() {
364        Ok(branch)
365    } else {
366        Err(NeonError::NotOwned(branch.name))
367    }
368}
369
370/// An RFC 3339 timestamp `ttl` from now, for [`CreateBranch::expires_at`].
371pub fn expires_in(ttl: std::time::Duration) -> String {
372    let at = chrono::Utc::now() + chrono::Duration::from_std(ttl).unwrap_or_default();
373    at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
374}
375
376/// Bind an instance to the branch `name_or_id` of `project`, for a plan that needs `req`:
377///
378/// - every function the plan runs must be deployed on the branch ([`NeonError::MissingFunctions`]
379///   lists the ones that aren't);
380/// - the databases and buckets it uses are created where missing;
381/// - when it uses object storage, a credential called `credential_name` is minted on the
382///   branch (returned with the binding: the caller stores the secret, the binding only its id).
383///
384/// The returned [`NeonBinding`] is the instance's source config.
385pub async fn bind(
386    neon: &dyn NeonControl,
387    project: &str,
388    name_or_id: &str,
389    req: &NeonRequirements,
390    credential_name: &str,
391) -> Result<(NeonBinding, Option<StorageCredential>), NeonError> {
392    let branch = find_branch(neon, project, name_or_id).await?;
393    if !req.functions.is_empty() {
394        let deployed = neon.functions(project, &branch.id).await?;
395        let missing: Vec<String> = req
396            .functions
397            .iter()
398            .filter(|f| !deployed.iter().any(|d| d.slug == f.slug))
399            .map(|f| f.slug.clone())
400            .collect();
401        if !missing.is_empty() {
402            return Err(NeonError::MissingFunctions {
403                branch: branch.name,
404                slugs: missing,
405            });
406        }
407    }
408    if !req.databases.is_empty() {
409        let existing = neon.databases(project, &branch.id).await?;
410        for db in req.databases.iter().filter(|d| !existing.contains(d)) {
411            neon.create_database(project, &branch.id, db).await?;
412        }
413    }
414    let uses_storage = !req.buckets.is_empty()
415        || req
416            .vars
417            .iter()
418            .any(|v| v.starts_with(source::S3_VAR_PREFIX));
419    let mut credential = None;
420    if uses_storage {
421        if !neon.storage(project, &branch.id).await?.enabled {
422            return Err(NeonError::NoStorage(branch.name));
423        }
424        let existing = neon.buckets(project, &branch.id).await?;
425        for bucket in req.buckets.iter().filter(|b| !existing.contains(b)) {
426            neon.create_bucket(project, &branch.id, bucket).await?;
427        }
428        credential = Some(
429            neon.create_credential(project, &branch.id, credential_name)
430                .await?,
431        );
432    }
433    let binding = NeonBinding {
434        project_id: project.to_owned(),
435        branch: branch.id,
436        branch_name: branch.name,
437        functions: req
438            .functions
439            .iter()
440            .filter_map(|f| Some((f.slug.clone(), f.url_var.clone()?)))
441            .collect(),
442        storage_credential: None,
443    };
444    Ok((binding, credential))
445}
446
447/// Undo [`bind`]: revoke the binding's storage credential, if it has one.
448pub async fn unbind(neon: &dyn NeonControl, binding: &NeonBinding) -> Result<(), NeonError> {
449    if let Some(credential) = &binding.storage_credential {
450        neon.revoke_credential(&binding.project_id, &binding.branch, &credential.token_id)
451            .await?;
452    }
453    Ok(())
454}