Skip to main content

cratefield_cli/
import.rs

1//! `fz import supabase inspect` (issue #658, ADR 0026): connects read-only
2//! to a Supabase project and writes the migration report.
3//!
4//! The engine is `cratefield-import-supabase`, which reads Postgres through
5//! sqlx and so is native-only. It is behind the `import-supabase` feature
6//! for the reason `postgres` and `push-send` are: a venture links this
7//! crate beside a wasm build, and that graph must never see sqlx or tokio.
8//! Without the feature the command exists and says what to install — loud
9//! absence, not a silently smaller `fz`.
10//!
11//! **Credentials.** The database URL comes from `--db-url` or
12//! `SUPABASE_DB_URL`, the Management API token from `--management-token`
13//! or `SUPABASE_ACCESS_TOKEN`, and `--classify` reads `TYPESAFE_API_KEY`
14//! through the `TypeSafe` adapter's own `from_env`. `fz` never prompts (ADR
15//! 0026, Decision 6), so a missing URL is a refusal naming the variable.
16//! The values are held cleared-on-drop, never printed, and the report names
17//! the database by host and database alone.
18
19use std::path::{Path, PathBuf};
20
21use crate::EnvVars;
22use cratefield_core::Config;
23
24/// The environment variable the database URL is read from.
25pub const DB_URL_VAR: &str = "SUPABASE_DB_URL";
26/// The environment variable the Management API token is read from.
27pub const ACCESS_TOKEN_VAR: &str = "SUPABASE_ACCESS_TOKEN";
28
29/// The report format.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Format {
32    /// Markdown, for a person.
33    Markdown,
34    /// JSON, for the later steps and the dashboard.
35    Json,
36}
37
38/// Everything `fz import supabase inspect` was given.
39#[derive(Debug, Clone)]
40pub struct InspectArgs {
41    /// The project ref.
42    pub project: String,
43    /// `--db-url`, if given.
44    pub db_url: Option<String>,
45    /// `--management-token`, if given.
46    pub management_token: Option<String>,
47    /// The format.
48    pub format: Format,
49    /// `--out`, if given.
50    pub out: Option<PathBuf>,
51    /// `--transfer-mbps`.
52    pub transfer_mbps: u32,
53    /// `--classify`.
54    pub classify: bool,
55    /// `--classify-threshold`.
56    pub classify_threshold: f32,
57}
58
59/// Where each credential came from — the flag or the environment — with
60/// the value itself never leaving this struct.
61#[derive(Debug)]
62pub struct Credentials {
63    /// The database URL.
64    pub db_url: Secret,
65    /// The Management API token, if any.
66    pub management_token: Option<Secret>,
67    /// Notes for stderr: a credential given as a flag is in the shell
68    /// history and the process list.
69    pub notes: Vec<String>,
70}
71
72/// A string that prints as `[redacted]`. The engine has its own; this one
73/// exists so the argument handling compiles without the feature.
74#[derive(Clone)]
75pub struct Secret(zeroize::Zeroizing<String>);
76
77impl Secret {
78    /// The value. Named `expose` so a reader has to notice.
79    #[must_use]
80    pub fn expose(&self) -> &str {
81        &self.0
82    }
83}
84
85impl std::fmt::Debug for Secret {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str("[redacted]")
88    }
89}
90
91/// Resolves the credentials from the flags, then `config` (the
92/// environment). Never prompts.
93///
94/// # Errors
95///
96/// A refusal naming `SUPABASE_DB_URL` when no URL was given either way.
97pub fn credentials(args: &InspectArgs, config: &dyn Config) -> Result<Credentials, String> {
98    let mut notes = Vec::new();
99    let db_url = match &args.db_url {
100        Some(url) => {
101            notes.push(format!(
102                "note: --db-url puts the password in your shell history and the process list; \
103                 prefer {DB_URL_VAR}"
104            ));
105            url.clone()
106        }
107        None => config.get(DB_URL_VAR).ok_or_else(|| {
108            format!(
109                "no database URL: set {DB_URL_VAR} to the read-only role's connection string \
110                 (docs/import/supabase.md), or pass --db-url. fz never prompts"
111            )
112        })?,
113    };
114    let management_token = match &args.management_token {
115        Some(token) => {
116            notes.push(format!(
117                "note: --management-token puts the token in your shell history and the process \
118                 list; prefer {ACCESS_TOKEN_VAR}"
119            ));
120            Some(token.clone())
121        }
122        None => config.get(ACCESS_TOKEN_VAR),
123    };
124    Ok(Credentials {
125        db_url: Secret(zeroize::Zeroizing::new(db_url)),
126        management_token: management_token.map(|token| Secret(zeroize::Zeroizing::new(token))),
127        notes,
128    })
129}
130
131/// Runs the command: inspects, then writes the report to `--out` or
132/// stdout.
133///
134/// # Errors
135///
136/// A refusal or an access error (connection, permission, a refused token):
137/// the exit code is non-zero for those alone. Blockers are in the report
138/// and the exit code is zero.
139pub fn inspect(args: &InspectArgs) -> Result<(), String> {
140    let credentials = credentials(args, &EnvVars)?;
141    for note in &credentials.notes {
142        eprintln!("{note}");
143    }
144    let rendered = run(args, &credentials)?;
145    if let Some(path) = &args.out {
146        write_report(path, &rendered.text)?;
147        eprintln!("wrote {}: {}", path.display(), rendered.summary);
148    } else {
149        print!("{}", rendered.text);
150    }
151    Ok(())
152}
153
154fn write_report(path: &Path, text: &str) -> Result<(), String> {
155    std::fs::write(path, text).map_err(|err| format!("cannot write {}: {err}", path.display()))
156}
157
158/// The rendered report and its one-line summary.
159pub struct Rendered {
160    /// The report in the requested format.
161    pub text: String,
162    /// `N automatic, N needs work, N blockers`.
163    pub summary: String,
164}
165
166#[cfg(feature = "import-supabase")]
167fn run(args: &InspectArgs, credentials: &Credentials) -> Result<Rendered, String> {
168    use cratefield_import_supabase as engine;
169    use std::sync::Arc;
170
171    let clock: Arc<dyn cratefield_core::Clock> = Arc::new(cratefield_runtime_native::TokioClock);
172    let http: Arc<dyn cratefield_core::HttpClient> =
173        Arc::new(cratefield_core::BoundedHttpClient::new(
174            Arc::new(cratefield_runtime_native::ReqwestClient::new()),
175            Arc::clone(&clock),
176        ));
177    let mut options = engine::InspectOptions::new(
178        args.project.clone(),
179        engine::Secret::new(credentials.db_url.expose()),
180    );
181    options.transfer_mbps = args.transfer_mbps;
182    options.classify_threshold = args.classify_threshold;
183    options.management = credentials.management_token.as_ref().map(|token| {
184        engine::ManagementApi::new(
185            Arc::clone(&http),
186            engine::DEFAULT_API_BASE,
187            engine::Secret::new(token.expose()),
188        )
189    });
190    if args.classify {
191        // TypeSafe (its judge, Jev) through the adapter's own reader, so
192        // the key's name is the adapter's, not ours.
193        let classifier = cratefield_adapter_typesafe::TypeSafe::from_env(
194            Arc::clone(&http),
195            Arc::clone(&clock),
196        )
197        .ok_or_else(|| {
198            "--classify needs TYPESAFE_API_KEY (the TypeSafe adapter reads it); without it, \
199             drop --classify and unplaced policies stay needs_review"
200                .to_owned()
201        })?;
202        options.classifier = Some(Arc::new(classifier));
203    }
204    let runtime = tokio::runtime::Builder::new_current_thread()
205        .enable_all()
206        .build()
207        .map_err(|err| format!("cannot start the async runtime inspect needs: {err}"))?;
208    let report = runtime
209        .block_on(engine::inspect(&options))
210        .map_err(|error| error.to_string())?;
211    let s = &report.summary;
212    let summary = format!(
213        "{} automatic, {} needs work, {} blocker(s){}",
214        s.automatic,
215        s.needs_work,
216        s.blockers,
217        if s.ready { "" } else { " — not ready" }
218    );
219    Ok(Rendered {
220        text: match args.format {
221            Format::Json => report.to_json(),
222            Format::Markdown => report.to_markdown(),
223        },
224        summary,
225    })
226}
227
228#[cfg(not(feature = "import-supabase"))]
229fn run(_args: &InspectArgs, _credentials: &Credentials) -> Result<Rendered, String> {
230    Err(
231        // As with `push-send`: an installed binary, never a feature on the
232        // venture's dependency, whose wasm build must not see sqlx.
233        "this `fz` was built without the `import-supabase` feature, so it cannot reach Postgres. \
234         Install one that has it — `cargo install cratefield-cli --features import-supabase` — \
235         and run that binary (it needs no compiled-in harness)."
236            .to_owned(),
237    )
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::collections::BTreeMap;
244
245    struct Vars(BTreeMap<&'static str, &'static str>);
246
247    impl Config for Vars {
248        fn get(&self, key: &str) -> Option<String> {
249            self.0.get(key).map(|value| (*value).to_owned())
250        }
251    }
252
253    fn args() -> InspectArgs {
254        InspectArgs {
255            project: "abcdefghijklmnopqrst".to_owned(),
256            db_url: None,
257            management_token: None,
258            format: Format::Markdown,
259            out: None,
260            transfer_mbps: 100,
261            classify: false,
262            classify_threshold: 0.8,
263        }
264    }
265
266    #[test]
267    fn a_missing_url_is_a_refusal_not_a_prompt() {
268        let error = credentials(&args(), &Vars(BTreeMap::new())).unwrap_err();
269        assert!(error.contains(DB_URL_VAR), "{error}");
270        assert!(error.contains("never prompts"), "{error}");
271    }
272
273    #[test]
274    fn the_environment_is_read_and_never_printed() {
275        let vars = Vars(BTreeMap::from([
276            (DB_URL_VAR, "postgres://ro:hunter2@db.example.test/postgres"),
277            (ACCESS_TOKEN_VAR, "sbp_secret_token"),
278        ]));
279        let credentials = credentials(&args(), &vars).expect("resolved");
280        assert!(credentials.notes.is_empty());
281        assert!(credentials.management_token.is_some());
282        let debug = format!("{credentials:?}");
283        assert!(
284            !debug.contains("hunter2") && !debug.contains("sbp_"),
285            "{debug}"
286        );
287    }
288
289    #[test]
290    fn a_flag_wins_and_earns_a_note() {
291        let mut given = args();
292        given.db_url = Some("postgres://ro:hunter2@db.example.test/postgres".to_owned());
293        let vars = Vars(BTreeMap::from([(DB_URL_VAR, "postgres://other")]));
294        let credentials = credentials(&given, &vars).expect("resolved");
295        assert_eq!(
296            credentials.db_url.expose(),
297            "postgres://ro:hunter2@db.example.test/postgres"
298        );
299        assert_eq!(credentials.notes.len(), 1);
300        assert!(!credentials.notes[0].contains("hunter2"));
301    }
302
303    #[test]
304    #[cfg(not(feature = "import-supabase"))]
305    fn without_the_feature_the_refusal_names_the_install() {
306        let mut given = args();
307        given.db_url = Some("postgres://ro:hunter2@db.example.test/postgres".to_owned());
308        let error = inspect(&given).unwrap_err();
309        assert!(
310            error.contains("cargo install cratefield-cli --features import-supabase"),
311            "{error}"
312        );
313        assert!(!error.contains("hunter2"));
314    }
315
316    #[test]
317    #[cfg(feature = "import-supabase")]
318    fn an_unreachable_database_is_an_error_that_quotes_no_secret() {
319        let mut given = args();
320        given.db_url = Some("postgres://ro:hunter2@127.0.0.1:1/postgres".to_owned());
321        let error = inspect(&given).unwrap_err();
322        assert!(error.contains("could not connect"), "{error}");
323        assert!(!error.contains("hunter2"), "{error}");
324    }
325}