fundaia 0.30.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
use crate::api::client::ApiError;

/// Why the process stopped, as a number a shell can read.
///
/// **The one thing an agent gets for free.** Everything else this tool answers
/// costs a parse: `--json` on stdout has to be read, the human block on stderr
/// has to be read *and* understood. An exit code is already in `$?` before
/// anything is read at all, and the question it answers — do I fix the call,
/// wait and repeat it, or stop and ask somebody — is the only question a caller
/// has between two commands.
///
/// Every failure used to leave a `1`, so telling "there is no project called
/// api" from "the instance is unreachable" meant parsing a sentence written for
/// a person. These are the platform's own error codes, one number each, and the
/// number travels in the `--json` object too so the two contracts cannot
/// disagree about what just happened.
///
/// **The numbers never change.** A script that branches on `4` is a script that
/// would break silently, which is the worst way for a contract to move; new
/// classes take the next free number rather than a renumbering.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Exit {
    /// It worked.
    Ok = 0,
    /// A bug here, or a platform that broke rather than refused.
    Internal = 1,
    // 2 is clap's: the arguments did not parse. Never produced here, and
    // reserved so nothing else ever means "you typed it wrong".
    /// Nothing goes by that name.
    NotFound = 3,
    /// Understood and refused. The identical call is refused identically.
    Invalid = 4,
    /// No token, or one the platform no longer knows.
    Unauthorized = 5,
    /// A real token without reach into that project.
    Forbidden = 6,
    /// Something already has that name, or a deployment is already running.
    Conflict = 7,
    /// Too many calls in a row. Nothing about the call was wrong.
    RateLimited = 8,
    /// GitHub, a registry or the machine misbehaved — not this platform.
    Upstream = 9,
    /// The instance was never reached: no DNS, no route, no answer.
    Unreachable = 10,
    /// The command worked and the deployment it started did not.
    DeploymentFailed = 11,
}

impl Exit {
    /// Whether the identical invocation can be expected to work later.
    ///
    /// The field agents actually branch on, and the one they get wrong in both
    /// directions when it is missing: abandoning a rate limit that would have
    /// cleared in a minute, or repeating a validation error until something
    /// stops them. A deployment failure is *not* retryable — the build will
    /// fail the same way until the code or the variables change — which is the
    /// distinction a plain non-zero exit cannot make.
    pub fn retryable(self) -> bool {
        matches!(
            self,
            Exit::RateLimited | Exit::Upstream | Exit::Unreachable | Exit::Internal
        )
    }

    pub fn code(self) -> i32 {
        self as i32
    }

    /// The word for this class: the platform's own error code where there is
    /// one, so the number, the name and `error.code` in `--json` all agree.
    pub fn name(self) -> &'static str {
        match self {
            Exit::Ok => "ok",
            Exit::Internal => "internal_error",
            Exit::NotFound => "not_found",
            Exit::Invalid => "validation_failed",
            Exit::Unauthorized => "unauthorized",
            Exit::Forbidden => "forbidden",
            Exit::Conflict => "conflict",
            Exit::RateLimited => "rate_limited",
            Exit::Upstream => "external_service_failed",
            Exit::Unreachable => "unreachable",
            Exit::DeploymentFailed => "deployment_failed",
        }
    }

    /// What the number means, in one sentence a model can act on.
    ///
    /// The same sentences `GET /api/infra/agent` tabulates. In English because
    /// this is the half read by whatever is driving the binary, while `hint` is
    /// the half printed for the person watching it.
    pub fn meaning(self) -> &'static str {
        match self {
            Exit::Ok => "It worked.",
            Exit::Internal => {
                "A bug here, or a platform that broke rather than refused. Read the deployment or the activity log before assuming nothing happened: a call can fail after it has written."
            }
            Exit::NotFound => "Nothing goes by that name — or nothing this token can reach.",
            Exit::Invalid => "Understood and refused. The identical call is refused identically.",
            Exit::Unauthorized => "No token, or one the platform no longer knows.",
            Exit::Forbidden => "A real token without reach into that project.",
            Exit::Conflict => {
                "Something already has that name, or a deployment is already running on that service."
            }
            Exit::RateLimited => "Too many calls in a row. Nothing about the call was wrong.",
            Exit::Upstream => {
                "Something the platform does not control misbehaved: GitHub, a registry, the machine."
            }
            Exit::Unreachable => "The instance was never reached: no DNS, no route, no answer.",
            Exit::DeploymentFailed => {
                "The command worked and the deployment it started did not. The pipeline says which stage stopped."
            }
        }
    }

    /// Every class, for `fundaia schema` to publish.
    ///
    /// A contract nobody can read is a contract nobody relies on: an agent that
    /// has to learn the numbers by collecting failures will collect three of
    /// them and assume the rest are `1`. The roster is also what keeps `name`
    /// and `hint` exhaustive — a variant added without a line in either is a
    /// test failure rather than a `None` somebody finds later.
    pub fn all() -> [Exit; 11] {
        [
            Exit::Ok,
            Exit::Internal,
            Exit::NotFound,
            Exit::Invalid,
            Exit::Unauthorized,
            Exit::Forbidden,
            Exit::Conflict,
            Exit::RateLimited,
            Exit::Upstream,
            Exit::Unreachable,
            Exit::DeploymentFailed,
        ]
    }
}

/// A refusal this binary makes itself, before any instance is asked.
///
/// Not every failure comes from the platform. "No profile configured" and
/// "`--url` points somewhere the saved token was not saved for" are refusals
/// made here, and they used to land on the same unclassified exit as a bug —
/// which told a caller to stop and report it when the answer was `fundaia
/// login` or one more argument. Carrying the class means a local refusal reads
/// exactly like the equivalent one from the server: an agent branches on
/// `unauthorized` without caring which side decided it.
#[derive(Debug)]
pub struct Refused {
    message: String,
    exit: Exit,
}

impl Refused {
    /// This machine has no credential for the instance being asked.
    pub fn not_signed_in(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            exit: Exit::Unauthorized,
        }
    }

    /// The arguments were understood and are not a combination this tool takes.
    pub fn invalid(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            exit: Exit::Invalid,
        }
    }
}

impl std::fmt::Display for Refused {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for Refused {}

/// A deployment that ran and failed, as distinct from a command that failed.
///
/// `fundaia deploy` has always exited non-zero when the pipeline failed, so
/// that `fundaia deploy … && something` behaves. What it could not say was
/// *which* kind of non-zero: a failing build and an unreachable server left the
/// same `1`, and the difference is the difference between reading the log and
/// checking the network.
#[derive(Debug)]
pub struct DeploymentFailed(pub String);

impl std::fmt::Display for DeploymentFailed {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl std::error::Error for DeploymentFailed {}

/// What the process should exit with, given what went wrong.
///
/// Read off the platform's error code rather than the HTTP status, because the
/// code is the word every surface of this platform agrees on — the same one
/// `fundaia --json` prints, an MCP tool error carries and `/api/infra/agent`
/// tabulates. Statuses collide (403 is both a forbidden project and GitHub
/// refusing a private repository) and codes do not.
pub fn classify(error: &anyhow::Error) -> Exit {
    // The two this binary raises itself, before anything with a code answered.
    if let Some(refused) = error.downcast_ref::<Refused>() {
        return refused.exit;
    }

    if error.downcast_ref::<DeploymentFailed>().is_some() {
        return Exit::DeploymentFailed;
    }

    if let Some(api) = error.downcast_ref::<ApiError>() {
        return match api.code.as_str() {
            "not_found" => Exit::NotFound,
            "validation_failed" => Exit::Invalid,
            "unauthorized" => Exit::Unauthorized,
            "forbidden" => Exit::Forbidden,
            "conflict" => Exit::Conflict,
            "rate_limited" => Exit::RateLimited,
            "external_service_failed" => Exit::Upstream,
            _ => Exit::Internal,
        };
    }

    // Below the API: the request never became an answer. Looked for anywhere in
    // the chain because `reqwest`'s error arrives under whatever `context` the
    // call site added — "could not reach the instance" is the useful sentence
    // and it is not the one carrying the kind.
    if unreachable(error) {
        return Exit::Unreachable;
    }

    Exit::Internal
}

fn unreachable(error: &anyhow::Error) -> bool {
    error.chain().any(|cause| {
        cause
            .downcast_ref::<reqwest::Error>()
            .is_some_and(|transport| {
                transport.is_connect() || transport.is_timeout() || transport.is_request()
            })
    })
}

/// What to try next, for the failures where there is an obvious answer.
///
/// A table keyed on the class rather than on the message, because the message
/// is written for a person and is allowed to be reworded. Every class the
/// server can answer with has a line: a code with no advice is a code an agent
/// has to go and look up, and this is the table it would have looked it up in.
///
/// The same advice `GET /api/infra/agent` tabulates, in Spanish because this
/// half is user-facing copy and the other half is read by a model.
pub fn hint(exit: Exit) -> Option<&'static str> {
    match exit {
        Exit::NotFound => Some(
            "Ese nombre no existe aquí: `fundaia status` dice qué hay. Puede ser de otra cuenta.",
        ),
        Exit::Invalid => Some("Cambia la llamada: repetirla igual se rechaza igual."),
        Exit::Unauthorized => Some("Ejecuta `fundaia login` para volver a entrar."),
        Exit::Forbidden => {
            Some("El token es válido pero no alcanza ese proyecto: pídelo a quien lo comparte.")
        }
        Exit::Conflict => Some("Ya hay algo con ese nombre, o hay un despliegue en curso."),
        Exit::RateLimited => Some("Demasiadas peticiones seguidas: espera un minuto y repite."),
        Exit::Upstream => {
            Some("Ha fallado algo de fuera (GitHub, un registro, la máquina): repite una vez.")
        }
        Exit::Unreachable => {
            Some("No se ha llegado a la instancia: comprueba `fundaia whoami` y la red.")
        }
        Exit::DeploymentFailed => {
            Some("El despliegue ha fallado: `fundaia logs --step` en el paso que paró.")
        }
        Exit::Internal | Exit::Ok => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use reqwest::StatusCode;

    fn api(code: &str, status: StatusCode) -> anyhow::Error {
        ApiError {
            status,
            code: code.to_owned(),
            message: "whatever the server said".to_owned(),
        }
        .into()
    }

    #[test]
    fn it_should_reserve_two_for_the_arguments_clap_refuses() {
        assert!(!Exit::all().iter().any(|exit| exit.code() == 2));
    }

    #[test]
    fn it_should_give_every_class_a_number_of_its_own() {
        let codes: Vec<i32> = Exit::all().iter().map(|exit| exit.code()).collect();
        assert_eq!(
            codes.iter().collect::<std::collections::HashSet<_>>().len(),
            codes.len()
        );
    }

    #[test]
    fn it_should_name_every_class_it_publishes() {
        let unnamed: Vec<Exit> = Exit::all()
            .into_iter()
            .filter(|exit| exit.name().is_empty())
            .collect();
        assert_eq!(unnamed, Vec::<Exit>::new());
    }

    #[test]
    fn it_should_exit_three_when_there_is_nothing_by_that_name() {
        assert_eq!(classify(&api("not_found", StatusCode::NOT_FOUND)).code(), 3);
    }

    #[test]
    fn it_should_exit_five_when_the_token_is_not_known() {
        assert_eq!(
            classify(&api("unauthorized", StatusCode::UNAUTHORIZED)).code(),
            5
        );
    }

    #[test]
    fn it_should_exit_six_when_the_token_does_not_reach_that_project() {
        assert_eq!(classify(&api("forbidden", StatusCode::FORBIDDEN)).code(), 6);
    }

    #[test]
    fn it_should_exit_seven_when_something_already_has_the_name() {
        assert_eq!(classify(&api("conflict", StatusCode::CONFLICT)).code(), 7);
    }

    /// A code this binary has never heard of is a platform newer than it is.
    /// `Internal` rather than a panic or a new number: the answer is the same
    /// as for a bug here, which is stop and say so.
    #[test]
    fn it_should_fall_back_to_one_for_a_code_it_does_not_know() {
        assert_eq!(
            classify(&api("something_invented_later", StatusCode::IM_A_TEAPOT)).code(),
            1
        );
    }

    /// The fix is `fundaia login`, and that is what exit 5 means — whichever
    /// side worked out there was no credential.
    #[test]
    fn it_should_exit_five_when_this_machine_has_no_credential() {
        let refused: anyhow::Error = Refused::not_signed_in("run `fundaia login`").into();
        assert_eq!(classify(&refused).code(), 5);
    }

    #[test]
    fn it_should_exit_four_when_the_arguments_are_a_combination_it_refuses() {
        let refused: anyhow::Error = Refused::invalid("pass --token as well").into();
        assert_eq!(classify(&refused).code(), 4);
    }

    #[test]
    fn it_should_exit_eleven_when_the_deployment_itself_failed() {
        let failed: anyhow::Error = DeploymentFailed("exit 137".to_owned()).into();
        assert_eq!(classify(&failed).code(), 11);
    }

    /// The distinction a plain `1` could not make: a failing build fails again.
    #[test]
    fn it_should_not_invite_a_retry_of_a_failed_deployment() {
        assert!(!Exit::DeploymentFailed.retryable());
    }

    #[test]
    fn it_should_invite_a_retry_after_a_rate_limit() {
        assert!(Exit::RateLimited.retryable());
    }

    #[test]
    fn it_should_not_invite_a_retry_of_a_refusal() {
        assert!(!Exit::Invalid.retryable());
    }

    /// Every class but the two with nothing to advise: a success, and a bug
    /// here — where the answer is "stop and say so", not a next call.
    #[test]
    fn it_should_advise_on_every_failure_it_can_name() {
        let unadvised: Vec<Exit> = Exit::all()
            .into_iter()
            .filter(|exit| !matches!(exit, Exit::Ok | Exit::Internal))
            .filter(|exit| hint(*exit).is_none())
            .collect();

        assert_eq!(unadvised, Vec::<Exit>::new());
    }

    /// `fundaia login` is the whole of the answer, so the hint says it and
    /// nothing else: an agent reading this one acts on it without a lookup.
    #[test]
    fn it_should_name_the_command_that_fixes_a_missing_token() {
        assert!(hint(Exit::Unauthorized).is_some_and(|advice| advice.contains("fundaia login")));
    }

    /// A hint that names a flag the parser does not accept is worse than no
    /// hint: it sends whoever followed it into a usage error. This one said
    /// `--stage`, and the flag is `--step`.
    #[test]
    fn it_should_name_the_flag_that_narrows_a_log_as_the_parser_spells_it() {
        assert!(hint(Exit::DeploymentFailed).is_some_and(|advice| advice.contains("--step")));
    }
}