mod cli;
#[cfg(feature = "testing")]
mod fake;
mod source;
use std::collections::BTreeMap;
use std::fmt;
use async_trait::async_trait;
use olai_stack_topology::NeonRequirements;
use serde::{Deserialize, Serialize};
use crate::secrets::SecretValue;
pub use cli::{NeonCli, locate_neon};
#[cfg(feature = "testing")]
pub use fake::{FakeNeon, NeonCall};
pub use source::{NEON_SOURCE, NeonBinding, NeonBranchSource, StorageCredentialRef};
pub const OWNER_ANNOTATION: &str = "trestle.owner";
pub const OWNER: &str = "trestle";
pub const INSTANCE_ANNOTATION: &str = "trestle.instance";
#[derive(Debug, thiserror::Error)]
pub enum NeonError {
#[error("the Neon CLI (`neon` or `neonctl`) is not installed")]
NotInstalled,
#[error("the Neon CLI is not signed in: {0}")]
NotAuthenticated(String),
#[error("`neon {command}` failed: {stderr}")]
Failed {
command: String,
stderr: String,
},
#[error("parsing the output of `neon {command}`: {reason}")]
Parse {
command: String,
reason: String,
},
#[error("running `neon {command}`: {source}")]
Io {
command: String,
#[source]
source: std::io::Error,
},
#[error("no branch `{0}` in the project")]
NoSuchBranch(String),
#[error("branch `{0}` was not created by trestle; refusing to reset or delete it")]
NotOwned(String),
#[error(
"branch `{branch}` has no function {}; deploy it on the branch (a branch only copies \
its parent's functions when it is created)",
slugs.iter().map(|s| format!("`{s}`")).collect::<Vec<_>>().join(", ")
)]
MissingFunctions {
branch: String,
slugs: Vec<String>,
},
#[error("branch `{0}` has no object storage enabled")]
NoStorage(String),
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Branch {
pub id: String,
pub name: String,
#[serde(default)]
pub parent_id: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub default: bool,
#[serde(default)]
pub annotations: BTreeMap<String, String>,
}
impl Branch {
pub fn is_owned(&self) -> bool {
self.annotations.get(OWNER_ANNOTATION).map(String::as_str) == Some(OWNER)
}
pub fn is(&self, name_or_id: &str) -> bool {
self.id == name_or_id || self.name == name_or_id
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CreateBranch {
pub name: String,
pub parent: Option<String>,
pub expires_at: Option<String>,
pub annotations: BTreeMap<String, String>,
}
#[derive(Clone, PartialEq, Eq)]
pub struct Connection {
pub url: SecretValue,
pub host: String,
pub role: String,
pub password: SecretValue,
pub database: String,
pub options: String,
}
impl fmt::Debug for Connection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Connection")
.field("url", &"<redacted>")
.field("host", &self.host)
.field("role", &self.role)
.field("password", &"<redacted>")
.field("database", &self.database)
.field("options", &self.options)
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Storage {
pub enabled: bool,
#[serde(default)]
pub s3_endpoint: String,
#[serde(default)]
pub region: String,
#[serde(default)]
pub force_path_style: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Function {
pub slug: String,
pub invocation_url: String,
}
#[derive(Clone, PartialEq, Eq)]
pub struct StorageCredential {
pub token_id: String,
pub secret_access_key: SecretValue,
}
impl fmt::Debug for StorageCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StorageCredential")
.field("token_id", &self.token_id)
.field("secret_access_key", &"<redacted>")
.finish()
}
}
#[async_trait]
pub trait NeonControl: Send + Sync {
async fn auth_status(&self) -> Result<String, NeonError>;
async fn branches(&self, project: &str) -> Result<Vec<Branch>, NeonError>;
async fn create_branch(&self, project: &str, spec: &CreateBranch) -> Result<Branch, NeonError>;
async fn delete_branch(&self, project: &str, id: &str) -> Result<(), NeonError>;
async fn reset_branch(&self, project: &str, id: &str) -> Result<(), NeonError>;
async fn connection(
&self,
project: &str,
branch: &str,
database: Option<&str>,
pooled: bool,
) -> Result<Connection, NeonError>;
async fn databases(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError>;
async fn create_database(
&self,
project: &str,
branch: &str,
name: &str,
) -> Result<(), NeonError>;
async fn functions(&self, project: &str, branch: &str) -> Result<Vec<Function>, NeonError>;
async fn storage(&self, project: &str, branch: &str) -> Result<Storage, NeonError>;
async fn buckets(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError>;
async fn create_bucket(&self, project: &str, branch: &str, name: &str)
-> Result<(), NeonError>;
async fn create_credential(
&self,
project: &str,
branch: &str,
name: &str,
) -> Result<StorageCredential, NeonError>;
async fn revoke_credential(
&self,
project: &str,
branch: &str,
token_id: &str,
) -> Result<(), NeonError>;
}
pub async fn find_branch(
neon: &dyn NeonControl,
project: &str,
name_or_id: &str,
) -> Result<Branch, NeonError> {
neon.branches(project)
.await?
.into_iter()
.find(|b| b.is(name_or_id))
.ok_or_else(|| NeonError::NoSuchBranch(name_or_id.to_owned()))
}
pub async fn create_owned_branch(
neon: &dyn NeonControl,
project: &str,
name: &str,
parent: Option<&str>,
expires_at: Option<String>,
instance: Option<&str>,
) -> Result<Branch, NeonError> {
let mut annotations = BTreeMap::from([(OWNER_ANNOTATION.to_owned(), OWNER.to_owned())]);
if let Some(instance) = instance {
annotations.insert(INSTANCE_ANNOTATION.to_owned(), instance.to_owned());
}
neon.create_branch(
project,
&CreateBranch {
name: name.to_owned(),
parent: parent.map(str::to_owned),
expires_at,
annotations,
},
)
.await
}
pub async fn delete_owned_branch(
neon: &dyn NeonControl,
project: &str,
name_or_id: &str,
) -> Result<Branch, NeonError> {
let branch = owned(neon, project, name_or_id).await?;
neon.delete_branch(project, &branch.id).await?;
Ok(branch)
}
pub async fn reset_owned_branch(
neon: &dyn NeonControl,
project: &str,
name_or_id: &str,
) -> Result<Branch, NeonError> {
let branch = owned(neon, project, name_or_id).await?;
neon.reset_branch(project, &branch.id).await?;
Ok(branch)
}
async fn owned(
neon: &dyn NeonControl,
project: &str,
name_or_id: &str,
) -> Result<Branch, NeonError> {
let branch = find_branch(neon, project, name_or_id).await?;
if branch.is_owned() {
Ok(branch)
} else {
Err(NeonError::NotOwned(branch.name))
}
}
pub fn expires_in(ttl: std::time::Duration) -> String {
let at = chrono::Utc::now() + chrono::Duration::from_std(ttl).unwrap_or_default();
at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub async fn bind(
neon: &dyn NeonControl,
project: &str,
name_or_id: &str,
req: &NeonRequirements,
credential_name: &str,
) -> Result<(NeonBinding, Option<StorageCredential>), NeonError> {
let branch = find_branch(neon, project, name_or_id).await?;
if !req.functions.is_empty() {
let deployed = neon.functions(project, &branch.id).await?;
let missing: Vec<String> = req
.functions
.iter()
.filter(|f| !deployed.iter().any(|d| d.slug == f.slug))
.map(|f| f.slug.clone())
.collect();
if !missing.is_empty() {
return Err(NeonError::MissingFunctions {
branch: branch.name,
slugs: missing,
});
}
}
if !req.databases.is_empty() {
let existing = neon.databases(project, &branch.id).await?;
for db in req.databases.iter().filter(|d| !existing.contains(d)) {
neon.create_database(project, &branch.id, db).await?;
}
}
let uses_storage = !req.buckets.is_empty()
|| req
.vars
.iter()
.any(|v| v.starts_with(source::S3_VAR_PREFIX));
let mut credential = None;
if uses_storage {
if !neon.storage(project, &branch.id).await?.enabled {
return Err(NeonError::NoStorage(branch.name));
}
let existing = neon.buckets(project, &branch.id).await?;
for bucket in req.buckets.iter().filter(|b| !existing.contains(b)) {
neon.create_bucket(project, &branch.id, bucket).await?;
}
credential = Some(
neon.create_credential(project, &branch.id, credential_name)
.await?,
);
}
let binding = NeonBinding {
project_id: project.to_owned(),
branch: branch.id,
branch_name: branch.name,
functions: req
.functions
.iter()
.filter_map(|f| Some((f.slug.clone(), f.url_var.clone()?)))
.collect(),
storage_credential: None,
};
Ok((binding, credential))
}
pub async fn unbind(neon: &dyn NeonControl, binding: &NeonBinding) -> Result<(), NeonError> {
if let Some(credential) = &binding.storage_credential {
neon.revoke_credential(&binding.project_id, &binding.branch, &credential.token_id)
.await?;
}
Ok(())
}