fundaia 0.10.0

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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
//! "There is a newer one" — and then there is not, because it installed it.
//!
//! Four properties are what make this worth having at all, and every one of
//! them is a constraint on when it may act rather than on what it does:
//!
//! - **It never delays the command.** The look at crates.io is started before
//!   the command runs and collected after it, so it overlaps the work instead
//!   of being added to it. Whatever has not answered by then is dropped.
//! - **It goes to stderr.** `deploy` is meant to be chained and its output is
//!   read by scripts; a notice on stdout is a notice that ends up in a variable.
//! - **A failure is silent.** No network, a captive portal, crates.io down, a
//!   read-only home directory: none of that is the person's problem right now,
//!   and a CLI that explains why it could not check its own version is worse
//!   than one that never checks.
//! - **It replaces itself whenever it is used, except inside CI.** It used to
//!   hold back unless stderr was a terminal, which kept every agent and script
//!   on whatever version it was installed with for ever. The swap is a `rename`
//!   and the running command keeps its old inode, so upgrading under a pipe is
//!   as safe as upgrading under a person — the next invocation is the new
//!   version either way. The one place a binary must not move is a CI job,
//!   where a pipeline that changes tools between steps is a bug nobody will
//!   find; `CI` in the environment — which every hosted runner sets — keeps the
//!   old behaviour there: the sentence, and nothing else.
//!
//! The answer is cached for a day in the directory that already holds the
//! profiles. The stamp records the *attempt*, not the success — otherwise a
//! machine with no route to crates.io asks again on every single invocation,
//! and an update that cannot be installed is retried by every command all day.

mod install;
mod registry;
mod version;

use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{bail, Result};
use owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle;

use crate::config::config_path;
use crate::render::theme;

use install::{Progress, SelfInstaller};

/// Where each of the two callers wants the running commentary.
///
/// The automatic check writes to stderr, because nobody asked for it and
/// `deploy` is meant to be chained; `upgrade` writes to stdout, because there
/// it is the answer to what was typed.
struct OnStderr;
struct OnStdout;

impl Progress for OnStderr {
    fn say(&self, message: &str) {
        eprintln!("  {}", message.style(theme::muted()));
    }
}

impl Progress for OnStdout {
    fn say(&self, message: &str) {
        println!("  {}", message.style(theme::muted()));
    }
}

/// For machine-readable invocations, where stderr carries structured errors
/// and a stray sentence about versions would land in whatever parses them.
struct Silently;

impl Progress for Silently {
    fn say(&self, _message: &str) {}
}

/// How long one look at the registry is good for.
const CACHE_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);

/// The whole network budget of the automatic check. Deliberately below any
/// human patience threshold: this is a courtesy, and a courtesy that hangs is a
/// bug.
const FETCH_TIMEOUT: Duration = Duration::from_secs(2);

/// What `upgrade` waits, which is longer because somebody asked for that one
/// and is watching it.
const ASKED_TIMEOUT: Duration = Duration::from_secs(15);

/// What the process is willing to wait for the answer *after* the command is
/// done. Nearly always zero — every command talks to a server first, and the
/// check finishes during that — so this is a backstop, not a budget.
const TAIL_WAIT: Duration = Duration::from_millis(400);

const OPT_OUT: &str = "FUNDAIA_NO_UPDATE_CHECK";

/// Keeps the notice and drops the installing, for somebody who would rather
/// their package manager owned this binary.
const NO_SELF_UPDATE: &str = "FUNDAIA_NO_SELF_UPDATE";

/// A check in flight, or nothing at all.
///
/// Owned by `main` and collected there, rather than a background task that
/// prints whenever it happens to finish: a line that lands in the middle of a
/// table is worse than no line.
pub struct UpdateCheck {
    current: &'static str,
    /// Says nothing on stderr: on for JSON invocations, whose stderr is read
    /// by a program. The work is the same either way.
    quiet: bool,
    /// Set when the cache already knew, so the fetch does not say it twice.
    announced: bool,
    /// Absent when the check was switched off, or answered from disk.
    pending: Option<Pending>,
}

struct Pending {
    /// What the stale cache said, kept so a failed look does not forget it.
    known: String,
    fetch: JoinHandle<Option<String>>,
}

impl UpdateCheck {
    /// Reads what is already known and, if it is stale, starts asking.
    ///
    /// Anything on disk is used immediately: the common case is a person who
    /// ran the tool yesterday, and there is no reason to make them wait for a
    /// round trip to be told what a file already says.
    pub fn start(current: &'static str, quiet: bool) -> Self {
        let mut check = Self {
            current,
            quiet,
            announced: false,
            pending: None,
        };

        if switched_off(OPT_OUT) {
            return check;
        }

        let Some(path) = cache_path() else {
            return check;
        };

        let cached = Cache::read(&path);

        if let Some(newer) = version::newer(current, &cached.latest) {
            if !quiet {
                announce(current, newer);
            }
            check.announced = true;
        }

        if is_fresh(cached.checked_at, now_seconds()) {
            return check;
        }

        check.pending = Some(Pending {
            known: cached.latest,
            fetch: tokio::spawn(registry::latest_version(FETCH_TIMEOUT)),
        });

        check
    }

    /// Collects the answer if it is there, and acts on it.
    ///
    /// The stamp is written either way, and that is the point of writing it
    /// here rather than where the answer is. It records *that the tool asked*,
    /// so a machine with no route to crates.io asks once a day like everybody
    /// else — and an update that failed to install is not attempted again by
    /// the very next command.
    pub async fn finish(self) {
        let Some(pending) = self.pending else {
            return;
        };

        let found = match tokio::time::timeout(TAIL_WAIT, pending.fetch).await {
            Ok(Ok(Some(latest))) => Some(latest),
            // A refusal, a timeout, no network, a panic: all the same answer.
            _ => None,
        };

        let latest = found.unwrap_or(pending.known);
        remember(&latest);

        let Some(newer) = version::newer(self.current, &latest) else {
            return;
        };

        // Anything the cache already knew was said before the command ran.
        if !self.announced && !self.quiet {
            announce(self.current, newer);
        }

        replace_this_binary(newer, self.quiet).await;
    }
}

/// `fundaia upgrade`, which is the same work asked for on purpose.
///
/// Three things differ from the automatic path, all of them because somebody
/// typed this. The cache is ignored, since the point of asking is to get
/// today's answer rather than yesterday's. It speaks on stdout, because here
/// the version *is* what was asked. And it fails loudly instead of shrugging: a
/// person who asked to be updated and was not is owed the reason.
pub async fn upgrade(current: &'static str, check_only: bool) -> Result<()> {
    let Some(latest) = registry::latest_version(ASKED_TIMEOUT).await else {
        bail!("no se ha podido consultar crates.io");
    };

    remember(&latest);

    let Some(newer) = version::newer(current, &latest) else {
        println!(
            "  {} fundaia {current} ya es la versión más reciente.",
            theme::CHECK.style(theme::success()),
        );
        return Ok(());
    };

    println!(
        "  Hay una versión nueva: {current} {} {}",
        theme::ARROW,
        newer.style(theme::strong()),
    );

    if check_only {
        return Ok(());
    }

    let installer = SelfInstaller::prepare(newer)?;
    installer.install(&OnStdout).await?;

    println!(
        "  {} Instalada en {}",
        theme::CHECK.style(theme::success()),
        installer.destination().display().style(theme::muted()),
    );

    Ok(())
}

/// Updates in place, or explains once why it left the binary alone.
///
/// Every failure here is reported and swallowed. This runs after a command that
/// already did what it was asked, and a deploy that worked must not exit
/// non-zero because the tool could not upgrade itself afterwards.
async fn replace_this_binary(version: &str, quiet: bool) {
    if switched_off(NO_SELF_UPDATE) || running_in_ci() {
        if !quiet {
            eprintln!("  {}", manual_route().style(theme::muted()));
        }
        return;
    }

    let installed = match SelfInstaller::prepare(version) {
        Ok(installer) if quiet => installer.install(&Silently).await,
        Ok(installer) => installer.install(&OnStderr).await,
        Err(error) => Err(error),
    };

    if quiet {
        return;
    }

    match installed {
        Ok(()) => eprintln!(
            "  {} {}",
            theme::CHECK.style(theme::success()),
            format!("Actualizado. La siguiente orden ya usa {version}.").style(theme::muted()),
        ),
        Err(error) => {
            eprintln!(
                "  {}",
                format!("no se ha podido actualizar: {error}").style(theme::muted())
            );
            eprintln!("  {}", manual_route().style(theme::muted()));
        }
    }
}

/// Every hosted runner sets `CI`, and a binary that changes between two steps
/// of somebody's pipeline is a bug they will never find. Everywhere else the
/// swap is safe: the running command keeps its inode.
fn running_in_ci() -> bool {
    truthy_env("CI")
}

/// What to do by hand when this cannot do it. `cargo install` rather than
/// `install.sh`, because a person who has the tool at all has one of the two
/// and only one of them is a single line worth printing.
fn manual_route() -> &'static str {
    "Actualiza con `cargo install fundaia`."
}

/// The headline, on stderr, in the muted style the rest of the tool uses for
/// anything that is not the answer to what was asked.
fn announce(current: &str, latest: &str) {
    eprintln!(
        "  {} Hay una versión nueva de fundaia: {current} {} {latest}",
        "!".style(theme::warning()),
        theme::ARROW,
    );
}

/// Records that the registry was asked, and what it said.
fn remember(latest: &str) {
    let Some(path) = cache_path() else {
        return;
    };

    let _ = Cache::write(
        &path,
        &Cache {
            checked_at: now_seconds(),
            latest: latest.to_owned(),
        },
    );
}

/// The last answer, and when it was last asked for.
#[derive(Debug, Default, Serialize, Deserialize)]
struct Cache {
    /// Unix seconds of the last attempt, whether or not it worked.
    #[serde(default)]
    checked_at: u64,
    /// Empty until one succeeds.
    #[serde(default)]
    latest: String,
}

impl Cache {
    /// Anything unreadable is treated as absent: this file is a convenience
    /// the tool wrote itself, and refusing to run over it would be absurd.
    fn read(path: &Path) -> Self {
        std::fs::read_to_string(path)
            .ok()
            .and_then(|text| toml::from_str(&text).ok())
            .unwrap_or_default()
    }

    fn write(path: &Path, cache: &Self) -> Option<()> {
        let directory = path.parent()?;
        std::fs::create_dir_all(directory).ok()?;
        std::fs::write(path, toml::to_string(cache).ok()?).ok()
    }
}

/// Beside the profiles, so `FUNDAIA_CONFIG` moves both together.
fn cache_path() -> Option<PathBuf> {
    Some(beside(&config_path().ok()?))
}

fn beside(config: &Path) -> PathBuf {
    config.with_file_name("update-check.toml")
}

fn now_seconds() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|since| since.as_secs())
        .unwrap_or(0)
}

/// A clock that moved backwards leaves the stamp in the future, and
/// `saturating_sub` reads that as fresh — which is the safe direction. The
/// alternative is a machine whose clock is wrong asking on every invocation.
fn is_fresh(checked_at: u64, now: u64) -> bool {
    checked_at != 0 && now.saturating_sub(checked_at) < CACHE_LIFETIME.as_secs()
}

fn switched_off(variable: &str) -> bool {
    truthy_env(variable)
}

fn truthy_env(variable: &str) -> bool {
    std::env::var(variable).is_ok_and(|value| silences(&value))
}

/// `=1` switches it off; `=0` and an empty value do not.
///
/// Unset and set-to-nothing are the same intent, and a shell that exports an
/// empty variable did not mean "yes".
fn silences(value: &str) -> bool {
    let value = value.trim();
    !value.is_empty() && value != "0"
}

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

    fn scratch(name: &str) -> PathBuf {
        std::env::temp_dir().join(format!("fundaia-update-{name}.toml"))
    }

    #[test]
    fn it_should_treat_a_stamp_from_a_minute_ago_as_fresh() {
        assert!(is_fresh(1_000_000, 1_000_060));
    }

    #[test]
    fn it_should_treat_a_stamp_from_two_days_ago_as_stale() {
        assert!(!is_fresh(1_000_000, 1_000_000 + 2 * 24 * 60 * 60));
    }

    #[test]
    fn it_should_treat_a_missing_stamp_as_stale() {
        assert!(!is_fresh(0, 1_000_000));
    }

    #[test]
    fn it_should_treat_a_stamp_from_the_future_as_fresh() {
        assert!(is_fresh(2_000_000, 1_000_000));
    }

    #[test]
    fn it_should_remember_the_version_it_last_found() {
        let path = scratch("remembers");
        Cache::write(
            &path,
            &Cache {
                checked_at: 42,
                latest: "9.9.9".to_owned(),
            },
        )
        .expect("the temporary directory is writable");

        let read = Cache::read(&path);
        let _ = std::fs::remove_file(&path);

        assert_eq!(read.latest, "9.9.9");
    }

    #[test]
    fn it_should_remember_when_it_last_asked() {
        let path = scratch("stamps");
        Cache::write(
            &path,
            &Cache {
                checked_at: 42,
                latest: String::new(),
            },
        )
        .expect("the temporary directory is writable");

        let read = Cache::read(&path);
        let _ = std::fs::remove_file(&path);

        assert_eq!(read.checked_at, 42);
    }

    #[test]
    fn it_should_treat_a_cache_that_is_not_there_as_empty() {
        assert_eq!(Cache::read(&scratch("never-written")).checked_at, 0);
    }

    #[test]
    fn it_should_treat_a_corrupt_cache_as_empty() {
        let path = scratch("corrupt");
        std::fs::write(&path, "this is not toml {{").expect("the temporary directory is writable");

        let read = Cache::read(&path);
        let _ = std::fs::remove_file(&path);

        assert_eq!(read.checked_at, 0);
    }

    #[test]
    fn it_should_be_silenced_by_the_environment_variable() {
        assert!(silences("1"));
    }

    /// GitHub Actions and its siblings set `CI=true`, and that spelling is the
    /// one that has to keep binaries still.
    #[test]
    fn it_should_read_a_true_as_on() {
        assert!(silences("true"));
    }

    #[test]
    fn it_should_not_be_silenced_by_an_empty_value() {
        assert!(!silences(""));
    }

    #[test]
    fn it_should_not_be_silenced_by_a_zero() {
        assert!(!silences("0"));
    }

    #[test]
    fn it_should_keep_the_cache_beside_the_profiles() {
        assert_eq!(
            beside(Path::new("/somewhere/fundaia/config.toml")),
            PathBuf::from("/somewhere/fundaia/update-check.toml"),
        );
    }
}