Skip to main content

olai_stack_runtime/neon/
cli.rs

1//! [`NeonCli`]: the [`NeonControl`] that runs the Neon CLI.
2
3use 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
17/// The environment variable naming the Neon CLI to run, ahead of every other place it is
18/// looked for.
19pub const NEON_CLI_VAR: &str = "NEON_CLI";
20
21/// Where the Neon CLI is, in order: `explicit`, `$NEON_CLI`, `neon` on `PATH`, the Homebrew
22/// prefixes and `~/.npm-global/bin`, a login shell's `command -v neon`, then the same for
23/// `neonctl`. An app started from the Finder has launchd's `PATH`, which holds none of them,
24/// hence the fixed places and the login shell.
25pub 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
54/// `command -v <name>` in the user's login shell, which reads their profile's `PATH`.
55fn 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/// The Neon CLI (`neon`, or `neonctl`), run with `--output json`. Signed in the way the CLI is
68/// (`~/.config/neon`, shared with the terminal) or with `NEON_API_KEY`.
69#[derive(Clone, Debug)]
70pub struct NeonCli {
71    program: PathBuf,
72}
73
74impl NeonCli {
75    /// The CLI [`locate_neon`] finds; [`NeonError::NotInstalled`] when there is none.
76    pub fn new() -> Result<Self, NeonError> {
77        locate_neon(None)
78            .map(Self::with_program)
79            .ok_or(NeonError::NotInstalled)
80    }
81
82    /// The CLI at `program`.
83    pub fn with_program(program: impl Into<PathBuf>) -> Self {
84        NeonCli {
85            program: program.into(),
86        }
87    }
88
89    /// The program it runs.
90    pub fn program(&self) -> &Path {
91        &self.program
92    }
93
94    /// Run `neon <args> --output json` and return its stdout.
95    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    /// Run `args` and parse the first JSON value it prints.
126    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
135/// Whether `stderr` says the CLI isn't signed in.
136fn 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
149/// The first JSON value in `text`, which several verbs surround with lines of text (a
150/// `WARNING:` after `credentials create`, progress lines around `functions deploy`).
151pub(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/// `GET /projects/<p>/branches`: the branches, and their annotations keyed by branch id.
168#[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    /// The branches with their annotations filled in.
183    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/// `connection-string --extended`.
194#[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/// `credentials create`.
219#[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        // The CLI stores annotations but doesn't print them back.
276        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        // Prints a sentence, not JSON.
388        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        // A `{` inside a line of text isn't the start of a value.
461        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}