1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6
7use async_trait::async_trait;
8use serde::Deserialize;
9use serde::de::DeserializeOwned;
10use tokio::process::Command;
11
12use super::{
13 Branch, Connection, CreateBranch, Function, NeonControl, NeonError, Storage, StorageCredential,
14};
15use crate::secrets::SecretValue;
16
17pub const NEON_CLI_VAR: &str = "NEON_CLI";
20
21pub fn locate_neon(explicit: Option<&Path>) -> Option<PathBuf> {
26 if let Some(path) = explicit {
27 return path.is_file().then(|| path.to_owned());
28 }
29 if let Some(path) = std::env::var_os(NEON_CLI_VAR).map(PathBuf::from)
30 && path.is_file()
31 {
32 return Some(path);
33 }
34 ["neon", "neonctl"].into_iter().find_map(find_program)
35}
36
37fn find_program(name: &str) -> Option<PathBuf> {
38 let on_path = std::env::var_os("PATH")
39 .into_iter()
40 .flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
41 .map(|dir| dir.join(name));
42 let home = std::env::var_os("HOME").map(PathBuf::from);
43 let fixed = ["/opt/homebrew/bin", "/usr/local/bin"]
44 .into_iter()
45 .map(PathBuf::from)
46 .chain(home.map(|h| h.join(".npm-global/bin")))
47 .map(|dir| dir.join(name));
48 on_path
49 .chain(fixed)
50 .find(|p| p.is_file())
51 .or_else(|| login_shell_lookup(name))
52}
53
54fn login_shell_lookup(name: &str) -> Option<PathBuf> {
56 let shell = std::env::var_os("SHELL")?;
57 let out = std::process::Command::new(shell)
58 .args(["-lc", &format!("command -v {name}")])
59 .stdin(Stdio::null())
60 .stderr(Stdio::null())
61 .output()
62 .ok()?;
63 let path = PathBuf::from(String::from_utf8(out.stdout).ok()?.trim());
64 (out.status.success() && path.is_file()).then_some(path)
65}
66
67#[derive(Clone, Debug)]
70pub struct NeonCli {
71 program: PathBuf,
72}
73
74impl NeonCli {
75 pub fn new() -> Result<Self, NeonError> {
77 locate_neon(None)
78 .map(Self::with_program)
79 .ok_or(NeonError::NotInstalled)
80 }
81
82 pub fn with_program(program: impl Into<PathBuf>) -> Self {
84 NeonCli {
85 program: program.into(),
86 }
87 }
88
89 pub fn program(&self) -> &Path {
91 &self.program
92 }
93
94 async fn run(&self, args: &[&str]) -> Result<String, NeonError> {
96 let command = args.join(" ");
97 let out = Command::new(&self.program)
98 .args(args)
99 .args(["--output", "json"])
100 .stdin(Stdio::null())
101 .kill_on_drop(true)
102 .output()
103 .await
104 .map_err(|source| match source.kind() {
105 std::io::ErrorKind::NotFound => NeonError::NotInstalled,
106 _ => NeonError::Io {
107 command: command.clone(),
108 source,
109 },
110 })?;
111 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
112 if !out.status.success() {
113 return Err(if is_auth_error(&stderr) {
114 NeonError::NotAuthenticated(stderr)
115 } else {
116 NeonError::Failed { command, stderr }
117 });
118 }
119 String::from_utf8(out.stdout).map_err(|e| NeonError::Parse {
120 command,
121 reason: e.to_string(),
122 })
123 }
124
125 async fn json<T: DeserializeOwned>(&self, args: &[&str]) -> Result<T, NeonError> {
127 let stdout = self.run(args).await?;
128 first_json(&stdout).map_err(|reason| NeonError::Parse {
129 command: args.join(" "),
130 reason,
131 })
132 }
133}
134
135fn is_auth_error(stderr: &str) -> bool {
137 let s = stderr.to_ascii_lowercase();
138 [
139 "unauthorized",
140 "not authenticated",
141 "authentication",
142 "401",
143 "neon auth",
144 ]
145 .iter()
146 .any(|needle| s.contains(needle))
147}
148
149pub(super) fn first_json<T: DeserializeOwned>(text: &str) -> Result<T, String> {
152 let mut last = String::from("no JSON in the output");
153 for (at, _) in text
154 .match_indices(['{', '['])
155 .filter(|(at, _)| *at == 0 || text[..*at].ends_with('\n'))
156 {
157 let mut values = serde_json::Deserializer::from_str(&text[at..]).into_iter::<T>();
158 match values.next() {
159 Some(Ok(value)) => return Ok(value),
160 Some(Err(e)) => last = e.to_string(),
161 None => {}
162 }
163 }
164 Err(last)
165}
166
167#[derive(Deserialize)]
169pub(super) struct ApiBranches {
170 pub branches: Vec<Branch>,
171 #[serde(default)]
172 pub annotations: BTreeMap<String, ApiAnnotation>,
173}
174
175#[derive(Deserialize)]
176pub(super) struct ApiAnnotation {
177 #[serde(default)]
178 pub value: BTreeMap<String, String>,
179}
180
181impl ApiBranches {
182 pub fn into_branches(mut self) -> Vec<Branch> {
184 for branch in &mut self.branches {
185 if let Some(a) = self.annotations.remove(&branch.id) {
186 branch.annotations = a.value;
187 }
188 }
189 self.branches
190 }
191}
192
193#[derive(Deserialize)]
195pub(super) struct Extended {
196 pub connection_string: String,
197 pub host: String,
198 pub role: String,
199 pub password: String,
200 pub database: String,
201 #[serde(default)]
202 pub options: String,
203}
204
205impl From<Extended> for Connection {
206 fn from(e: Extended) -> Self {
207 Connection {
208 url: SecretValue::new(e.connection_string),
209 host: e.host,
210 role: e.role,
211 password: SecretValue::new(e.password),
212 database: e.database,
213 options: e.options,
214 }
215 }
216}
217
218#[derive(Deserialize)]
220pub(super) struct CreatedCredential {
221 pub token_id: String,
222 pub s3_secret_access_key: String,
223}
224
225#[derive(Deserialize)]
226struct Named {
227 name: String,
228}
229
230#[async_trait]
231impl NeonControl for NeonCli {
232 async fn auth_status(&self) -> Result<String, NeonError> {
233 #[derive(Deserialize)]
234 struct Me {
235 #[serde(default)]
236 login: Option<String>,
237 #[serde(default)]
238 email: Option<String>,
239 }
240 let me: Me = self.json(&["me"]).await?;
241 Ok(me.login.or(me.email).unwrap_or_default())
242 }
243
244 async fn branches(&self, project: &str) -> Result<Vec<Branch>, NeonError> {
245 let path = format!("/projects/{project}/branches");
246 let api: ApiBranches = self.json(&["api", &path]).await?;
247 Ok(api.into_branches())
248 }
249
250 async fn create_branch(&self, project: &str, spec: &CreateBranch) -> Result<Branch, NeonError> {
251 #[derive(Deserialize)]
252 struct Created {
253 branch: Branch,
254 }
255 let annotations = serde_json::to_string(&spec.annotations).unwrap_or_default();
256 let mut args = vec![
257 "branches",
258 "create",
259 "--project-id",
260 project,
261 "--name",
262 &spec.name,
263 "--no-secrets",
264 ];
265 if let Some(parent) = &spec.parent {
266 args.extend(["--parent", parent]);
267 }
268 if let Some(at) = &spec.expires_at {
269 args.extend(["--expires-at", at]);
270 }
271 if !spec.annotations.is_empty() {
272 args.extend(["--annotation", &annotations]);
273 }
274 let mut created: Created = self.json(&args).await?;
275 created.branch.annotations = spec.annotations.clone();
277 Ok(created.branch)
278 }
279
280 async fn delete_branch(&self, project: &str, id: &str) -> Result<(), NeonError> {
281 self.run(&["branches", "delete", id, "--project-id", project])
282 .await
283 .map(drop)
284 }
285
286 async fn reset_branch(&self, project: &str, id: &str) -> Result<(), NeonError> {
287 self.run(&["branches", "reset", id, "--parent", "--project-id", project])
288 .await
289 .map(drop)
290 }
291
292 async fn connection(
293 &self,
294 project: &str,
295 branch: &str,
296 database: Option<&str>,
297 pooled: bool,
298 ) -> Result<Connection, NeonError> {
299 let mut args = vec![
300 "connection-string",
301 branch,
302 "--project-id",
303 project,
304 "--extended",
305 ];
306 if pooled {
307 args.push("--pooled");
308 }
309 if let Some(db) = database {
310 args.extend(["--database-name", db]);
311 }
312 let extended: Extended = self.json(&args).await?;
313 Ok(extended.into())
314 }
315
316 async fn databases(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError> {
317 let dbs: Vec<Named> = self
318 .json(&[
319 "databases",
320 "list",
321 "--project-id",
322 project,
323 "--branch",
324 branch,
325 ])
326 .await?;
327 Ok(dbs.into_iter().map(|d| d.name).collect())
328 }
329
330 async fn create_database(
331 &self,
332 project: &str,
333 branch: &str,
334 name: &str,
335 ) -> Result<(), NeonError> {
336 self.run(&[
337 "databases",
338 "create",
339 "--name",
340 name,
341 "--project-id",
342 project,
343 "--branch",
344 branch,
345 ])
346 .await
347 .map(drop)
348 }
349
350 async fn functions(&self, project: &str, branch: &str) -> Result<Vec<Function>, NeonError> {
351 self.json(&[
352 "functions",
353 "list",
354 "--project-id",
355 project,
356 "--branch",
357 branch,
358 ])
359 .await
360 }
361
362 async fn storage(&self, project: &str, branch: &str) -> Result<Storage, NeonError> {
363 let path = format!("/projects/{project}/branches/{branch}/storage");
364 self.json(&["api", &path]).await
365 }
366
367 async fn buckets(&self, project: &str, branch: &str) -> Result<Vec<String>, NeonError> {
368 let buckets: Vec<Named> = self
369 .json(&[
370 "buckets",
371 "list",
372 "--project-id",
373 project,
374 "--branch",
375 branch,
376 ])
377 .await?;
378 Ok(buckets.into_iter().map(|b| b.name).collect())
379 }
380
381 async fn create_bucket(
382 &self,
383 project: &str,
384 branch: &str,
385 name: &str,
386 ) -> Result<(), NeonError> {
387 self.run(&[
389 "buckets",
390 "create",
391 name,
392 "--project-id",
393 project,
394 "--branch",
395 branch,
396 ])
397 .await
398 .map(drop)
399 }
400
401 async fn create_credential(
402 &self,
403 project: &str,
404 branch: &str,
405 name: &str,
406 ) -> Result<StorageCredential, NeonError> {
407 let created: CreatedCredential = self
408 .json(&[
409 "credentials",
410 "create",
411 "--project-id",
412 project,
413 "--branch",
414 branch,
415 "--name",
416 name,
417 "--scope",
418 "storage:read",
419 "--scope",
420 "storage:write",
421 ])
422 .await?;
423 Ok(StorageCredential {
424 token_id: created.token_id,
425 secret_access_key: SecretValue::new(created.s3_secret_access_key),
426 })
427 }
428
429 async fn revoke_credential(
430 &self,
431 project: &str,
432 branch: &str,
433 token_id: &str,
434 ) -> Result<(), NeonError> {
435 self.run(&[
436 "credentials",
437 "revoke",
438 token_id,
439 "--project-id",
440 project,
441 "--branch",
442 branch,
443 ])
444 .await
445 .map(drop)
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn the_first_json_value_wins_around_text() {
455 let v: serde_json::Value =
456 first_json("Function deployment triggered.\n{\"id\": 1}\nDone.\n").unwrap();
457 assert_eq!(v["id"], 1);
458 let v: serde_json::Value = first_json("{\"a\": [1]}\nWARNING: store it\n").unwrap();
459 assert_eq!(v["a"][0], 1);
460 let v: serde_json::Value = first_json("note {curly}\n[2]").unwrap();
462 assert_eq!(v[0], 2);
463 assert!(first_json::<serde_json::Value>("Bucket created").is_err());
464 }
465
466 #[test]
467 fn auth_errors_are_told_apart() {
468 assert!(is_auth_error("ERROR: Unauthorized. Run `neon auth`"));
469 assert!(!is_auth_error("ERROR: branch not found"));
470 }
471}