1mod 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
43pub const OWNER_ANNOTATION: &str = "trestle.owner";
45pub const OWNER: &str = "trestle";
47pub const INSTANCE_ANNOTATION: &str = "trestle.instance";
49
50#[derive(Debug, thiserror::Error)]
52pub enum NeonError {
53 #[error("the Neon CLI (`neon` or `neonctl`) is not installed")]
55 NotInstalled,
56 #[error("the Neon CLI is not signed in: {0}")]
58 NotAuthenticated(String),
59 #[error("`neon {command}` failed: {stderr}")]
61 Failed {
62 command: String,
64 stderr: String,
66 },
67 #[error("parsing the output of `neon {command}`: {reason}")]
69 Parse {
70 command: String,
72 reason: String,
74 },
75 #[error("running `neon {command}`: {source}")]
77 Io {
78 command: String,
80 #[source]
82 source: std::io::Error,
83 },
84 #[error("no branch `{0}` in the project")]
86 NoSuchBranch(String),
87 #[error("branch `{0}` was not created by trestle; refusing to reset or delete it")]
89 NotOwned(String),
90 #[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 branch: String,
99 slugs: Vec<String>,
101 },
102 #[error("branch `{0}` has no object storage enabled")]
104 NoStorage(String),
105}
106
107#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Branch {
110 pub id: String,
112 pub name: String,
114 #[serde(default)]
116 pub parent_id: Option<String>,
117 #[serde(default)]
119 pub expires_at: Option<String>,
120 #[serde(default)]
122 pub default: bool,
123 #[serde(default)]
125 pub annotations: BTreeMap<String, String>,
126}
127
128impl Branch {
129 pub fn is_owned(&self) -> bool {
131 self.annotations.get(OWNER_ANNOTATION).map(String::as_str) == Some(OWNER)
132 }
133
134 pub fn is(&self, name_or_id: &str) -> bool {
136 self.id == name_or_id || self.name == name_or_id
137 }
138}
139
140#[derive(Clone, Debug, Default, PartialEq, Eq)]
142pub struct CreateBranch {
143 pub name: String,
145 pub parent: Option<String>,
147 pub expires_at: Option<String>,
149 pub annotations: BTreeMap<String, String>,
151}
152
153#[derive(Clone, PartialEq, Eq)]
155pub struct Connection {
156 pub url: SecretValue,
158 pub host: String,
160 pub role: String,
162 pub password: SecretValue,
164 pub database: String,
166 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
185pub struct Storage {
186 pub enabled: bool,
188 #[serde(default)]
190 pub s3_endpoint: String,
191 #[serde(default)]
193 pub region: String,
194 #[serde(default)]
196 pub force_path_style: bool,
197}
198
199#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Function {
202 pub slug: String,
204 pub invocation_url: String,
206}
207
208#[derive(Clone, PartialEq, Eq)]
210pub struct StorageCredential {
211 pub token_id: String,
213 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#[async_trait]
229pub trait NeonControl: Send + Sync {
230 async fn auth_status(&self) -> Result<String, NeonError>;
232
233 async fn branches(&self, project: &str) -> Result<Vec<Branch>, NeonError>;
235
236 async fn create_branch(&self, project: &str, spec: &CreateBranch) -> Result<Branch, NeonError>;
238
239 async fn delete_branch(&self, project: &str, id: &str) -> Result<(), NeonError>;
241
242 async fn reset_branch(&self, project: &str, id: &str) -> Result<(), NeonError>;
244
245 async fn connection(
248 &self,
249 project: &str,
250 branch: &str,
251 database: Option<&str>,
252 pooled: bool,
253 ) -> Result<Connection, NeonError>;
254
255 async fn databases(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError>;
257
258 async fn create_database(
260 &self,
261 project: &str,
262 branch: &str,
263 name: &str,
264 ) -> Result<(), NeonError>;
265
266 async fn functions(&self, project: &str, branch: &str) -> Result<Vec<Function>, NeonError>;
268
269 async fn storage(&self, project: &str, branch: &str) -> Result<Storage, NeonError>;
271
272 async fn buckets(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError>;
274
275 async fn create_bucket(&self, project: &str, branch: &str, name: &str)
277 -> Result<(), NeonError>;
278
279 async fn create_credential(
281 &self,
282 project: &str,
283 branch: &str,
284 name: &str,
285 ) -> Result<StorageCredential, NeonError>;
286
287 async fn revoke_credential(
289 &self,
290 project: &str,
291 branch: &str,
292 token_id: &str,
293 ) -> Result<(), NeonError>;
294}
295
296pub 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
309pub 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
335pub 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
346pub 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
370pub 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
376pub 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
447pub 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}