Skip to main content

kasl/commands/
server.rs

1//! `kasl server`: connect this machine to a kasl-server, and see where it
2//! stands.
3//!
4//! The server is the team's, not this machine's: an administrator issues an
5//! agent token and hands it over, and connecting is pasting it here (ADR 0004
6//! in kasl-server - there is no open enrolment to abuse). What this command
7//! owns is everything around that: checking the server is really a
8//! kasl-server before storing anything, putting the token in the OS keyring
9//! rather than in a readable config file, and saying whose token it is out
10//! loud, while a person is watching.
11//!
12//! That last check is the one worth spelling out. A token is an opaque string;
13//! one pasted from the wrong chat window works perfectly and files this
14//! machine's days under a colleague's name. The server is asked who it thinks
15//! is connecting, and the answer is printed.
16
17use crate::api::kasl_server::{AGENT_TOKEN_PROMPT, AGENT_TOKEN_SECRET, KaslServer, UploadError, normalize_url};
18use crate::db::server_outbox::ServerOutbox;
19use crate::db::workdays::Workdays;
20use crate::libs::config::{Config, KaslServerConfig};
21use crate::libs::day_delivery::{Delivered, deliver, record_single};
22use crate::libs::day_upload::build_day_upload;
23use crate::libs::messages::Message;
24use crate::libs::secret::Secret;
25use crate::{msg_error_anyhow, msg_info, msg_print, msg_success, msg_warning};
26use anyhow::{Context, Result};
27use chrono::{Duration, Local, NaiveDate};
28use clap::{Args, Subcommand};
29use dialoguer::{Input, Password, theme::ColorfulTheme};
30use reqwest::StatusCode;
31
32/// Command-line arguments for the server command.
33#[derive(Debug, Args)]
34pub struct ServerArgs {
35    #[command(subcommand)]
36    command: ServerCommand,
37}
38
39/// Available server operations.
40#[derive(Debug, Subcommand)]
41enum ServerCommand {
42    /// Connect this machine to a kasl-server
43    #[command(about = "Connect this machine to a kasl-server")]
44    Connect(ConnectArgs),
45
46    /// Show the current connection
47    #[command(about = "Show the current connection to a kasl-server")]
48    Status,
49
50    /// Send a day to the server
51    #[command(about = "Send a day's work to the connected kasl-server")]
52    Push(PushArgs),
53
54    /// Send everything that is still owed
55    #[command(about = "Send every day still waiting to reach the server")]
56    Flush,
57
58    /// Show what is still waiting to be sent
59    #[command(about = "Show the days still waiting to reach the server")]
60    Queue,
61
62    /// Queue a stretch of past days
63    #[command(about = "Queue every recorded day in a date range and send them")]
64    Backfill(BackfillArgs),
65
66    /// Forget the connection and the stored token
67    #[command(about = "Forget the connection and the stored agent token")]
68    Disconnect,
69}
70
71/// Arguments accepted by `kasl server backfill`.
72#[derive(Debug, Args)]
73pub struct BackfillArgs {
74    /// First date of the range, YYYY-MM-DD
75    #[arg(long, value_name = "YYYY-MM-DD")]
76    from: NaiveDate,
77
78    /// Last date of the range, YYYY-MM-DD; defaults to today
79    #[arg(long, value_name = "YYYY-MM-DD")]
80    to: Option<NaiveDate>,
81}
82
83/// Arguments accepted by `kasl server push`.
84#[derive(Debug, Args)]
85pub struct PushArgs {
86    /// Send yesterday instead of today
87    #[arg(long, short, help = "Send the last day instead of today")]
88    last: bool,
89
90    /// Send a specific date, YYYY-MM-DD
91    #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "last")]
92    date: Option<NaiveDate>,
93}
94
95/// Arguments accepted by `kasl server connect`.
96#[derive(Debug, Args)]
97pub struct ConnectArgs {
98    /// Server URL, e.g. https://kasl.example.com; prompted for when omitted
99    #[arg(long, value_name = "URL")]
100    url: Option<String>,
101
102    /// PEM file with the CA that signed the server's certificate
103    #[arg(long, value_name = "PATH")]
104    ca_certificate: Option<String>,
105}
106
107/// Routes a server subcommand.
108pub async fn cmd(args: ServerArgs) -> Result<()> {
109    match args.command {
110        ServerCommand::Connect(args) => connect(args).await,
111        ServerCommand::Status => status().await,
112        ServerCommand::Push(args) => push(args).await,
113        ServerCommand::Flush => flush().await,
114        ServerCommand::Queue => queue(),
115        ServerCommand::Backfill(args) => backfill(args).await,
116        ServerCommand::Disconnect => disconnect(),
117    }
118}
119
120/// Walks through connecting: URL, token, and two checks against the server.
121///
122/// Nothing is written until both checks pass. A half-written connection - a
123/// URL saved with a token the server never accepted - would leave the agent
124/// looking configured while every upload failed.
125async fn connect(args: ConnectArgs) -> Result<()> {
126    // The token is always typed at a prompt - never taken from an argument,
127    // where it would land in shell history - so this command cannot finish
128    // without a terminal whatever else it was given. Checked before anything
129    // else so a run that cannot succeed fails immediately, rather than after
130    // reaching the network and reporting a server it is about to walk away
131    // from.
132    crate::libs::prompt::ensure_interactive("`kasl server connect` needs a terminal to ask for the agent token")?;
133
134    let mut config = Config::read().unwrap_or_default();
135
136    let url = match args.url {
137        Some(url) => normalize_url(&url),
138        None => {
139            let entered: String = Input::with_theme(&ColorfulTheme::default())
140                .with_prompt(Message::PromptKaslServerUrl.to_string())
141                .with_initial_text(config.kasl_server.as_ref().map(|s| s.url.clone()).unwrap_or_default())
142                .interact_text()?;
143            normalize_url(&entered)
144        }
145    };
146
147    // A URL without a scheme reaches nothing and the failure reads like the
148    // server is down, so it is caught here where the cause is still visible.
149    if !url.starts_with("http://") && !url.starts_with("https://") {
150        return Err(msg_error_anyhow!(Message::KaslServerUrlNeedsScheme(url)));
151    }
152
153    let candidate = KaslServerConfig {
154        url: url.clone(),
155        // A certificate named now wins; otherwise an existing one is kept, so
156        // reconnecting to the same server does not silently drop it.
157        ca_certificate: args
158            .ca_certificate
159            .or_else(|| config.kasl_server.as_ref().and_then(|s| s.ca_certificate.clone())),
160    };
161
162    let client = KaslServer::new(&candidate)?;
163
164    // First check: is this a kasl-server at all? Asked before the token, so a
165    // mistyped URL is not reported as a rejected token.
166    let health = client.health().await?;
167    msg_info!(Message::KaslServerReached {
168        url: url.clone(),
169        version: health.version.clone(),
170    });
171    if health.database != "ok" {
172        // Serviceable enough to answer, not enough to accept a day. Worth
173        // saying now rather than at the first upload.
174        msg_warning!(Message::KaslServerDatabaseUnhealthy(health.database.clone()));
175    }
176
177    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
178    let token: String = Password::with_theme(&ColorfulTheme::default())
179        .with_prompt(Message::PromptKaslServerToken.to_string())
180        .interact()?;
181    let token = token.trim().to_string();
182    if token.is_empty() {
183        return Err(msg_error_anyhow!(Message::KaslServerTokenEmpty));
184    }
185
186    // Second check: the server accepts this token, and says whose it is.
187    let identity = client.identify(&token).await?;
188
189    // Both checks passed - only now is anything persisted.
190    secret
191        .store(&token)
192        .context("the token was accepted but could not be stored in the OS keyring")?;
193    config.kasl_server = Some(candidate);
194    config.save()?;
195
196    msg_success!(Message::KaslServerConnected {
197        user_name: identity.user_name,
198        agent_name: identity.agent_name,
199    });
200    Ok(())
201}
202
203/// Reports the stored connection, and whether it still works.
204///
205/// Reaches the server rather than reading the config back: a connection that
206/// was valid when it was made and is not any more - a revoked token, a server
207/// that moved - is exactly what someone runs this to find out.
208async fn status() -> Result<()> {
209    let config = Config::read().unwrap_or_default();
210    let Some(server_config) = config.kasl_server else {
211        msg_print!(Message::KaslServerNotConnected);
212        return Ok(());
213    };
214
215    msg_info!(Message::KaslServerConfigured(server_config.url.clone()));
216
217    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
218    let Some(token) = secret.try_get_cached() else {
219        // The config says connected and the keyring disagrees: reconnecting is
220        // the fix, and saying so beats a 401 at the next upload.
221        msg_warning!(Message::KaslServerTokenMissing);
222        return Ok(());
223    };
224
225    let client = KaslServer::new(&server_config)?;
226
227    match client.health().await {
228        Ok(health) => msg_info!(Message::KaslServerReached {
229            url: server_config.url.clone(),
230            version: health.version,
231        }),
232        Err(error) => {
233            msg_warning!(Message::KaslServerUnreachable(error.to_string()));
234            return Ok(());
235        }
236    }
237
238    match client.identify(&token).await {
239        Ok(identity) => msg_success!(Message::KaslServerConnected {
240            user_name: identity.user_name,
241            agent_name: identity.agent_name,
242        }),
243        Err(error) => msg_warning!(Message::KaslServerTokenRejected(error.to_string())),
244    }
245
246    Ok(())
247}
248
249/// Sends one day's work to the connected server.
250///
251/// The whole day goes every time - workday bounds, pauses, tasks - because
252/// the server stores a day as a unit and the last upload wins (ADR 0004 in
253/// kasl-server). Sending the same day twice therefore changes nothing, and a
254/// day corrected here corrects itself there on the next push.
255///
256/// The day is assembled before the token is fetched: a day that cannot be
257/// built - a timestamp with no valid offset, a task without an id - is a local
258/// problem, and reporting it without first touching the keyring or the network
259/// keeps the cause visible.
260///
261/// A day that cannot be delivered is queued rather than lost, and a day that
262/// is delivered takes the rest of the backlog with it - a laptop coming back
263/// from a week offline pays the whole debt on the first push, without the user
264/// having to know a queue exists.
265async fn push(args: PushArgs) -> Result<()> {
266    let date = match args.date {
267        Some(date) => date,
268        None if args.last => (Local::now() - Duration::days(1)).date_naive(),
269        None => Local::now().date_naive(),
270    };
271
272    let config = Config::read().unwrap_or_default();
273    let Some(server_config) = config.kasl_server else {
274        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
275    };
276
277    let Some(day) = build_day_upload(date)? else {
278        msg_print!(Message::KaslServerNoDayToPush(date.to_string()));
279        return Ok(());
280    };
281
282    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
283    let Some(token) = secret.try_get_cached() else {
284        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
285    };
286
287    let client = KaslServer::new(&server_config)?;
288
289    match client.upload_day(&token, &day).await {
290        Ok(accepted) => {
291            msg_success!(Message::KaslServerDayPushed {
292                date: accepted.date.to_string(),
293                pauses: accepted.pauses,
294                tasks: accepted.tasks,
295            });
296            // Worth saying out loud rather than hiding in a debug log: this is
297            // the visible consequence of declaring the task set authoritative,
298            // and the only sign that a deletion here reached the server.
299            if accepted.deleted_tasks > 0 {
300                msg_info!(Message::KaslServerTasksDeleted(accepted.deleted_tasks));
301            }
302
303            // A day that arrives cancels its own debt. Without this a date
304            // queued by an earlier failure would be sent again by the next
305            // flush, forever.
306            ServerOutbox::new()?.remove(date)?;
307
308            // Today went, so the backlog is worth a try on the same
309            // connection: a machine that comes back online typically owes
310            // several days, and making the user run a second command to
311            // discover that would be a queue that hides itself.
312            flush_with(&client, &token).await?;
313            Ok(())
314        }
315        // Three failures, three different things to do about them: a
316        // credential to renew, a payload to fix, or a server to wait for.
317        // Telling someone whose token was revoked to fix the day and push
318        // again would send them looking at data that is not the problem.
319        // All three are errors - the day did not arrive in any of them.
320        Err(error) => {
321            // Queued before it is reported, and only if a retry could ever
322            // work: a day the server will never accept as sent would
323            // otherwise sit in the queue retrying until someone noticed.
324            let outcome = record_single(&mut ServerOutbox::new()?, date, &error)?;
325            if matches!(outcome, Delivered::Deferred { .. }) {
326                msg_info!(Message::KaslServerDayQueued(date.to_string()));
327            }
328
329            match error {
330                error @ UploadError::Rejected {
331                    status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
332                    ..
333                } => Err(msg_error_anyhow!(Message::KaslServerPushTokenRejected(error.to_string()))),
334                error @ UploadError::Rejected { .. } => Err(msg_error_anyhow!(Message::KaslServerPushRejected(error.to_string()))),
335                error => Err(msg_error_anyhow!(Message::KaslServerPushRetryable(error.to_string()))),
336            }
337        }
338    }
339}
340
341/// Sends everything the outbox still owes.
342async fn flush() -> Result<()> {
343    // What is owed is checked before the connection is, because an empty queue
344    // is nothing to do whatever the connection looks like. The other order
345    // makes an hourly `kasl server flush` on an unconnected machine fail every
346    // hour over work that does not exist - and a cron job that cries wolf is
347    // one nobody reads by the time it matters.
348    if ServerOutbox::new()?.count()? == 0 {
349        msg_print!(Message::KaslServerQueueEmpty);
350        return Ok(());
351    }
352
353    let (client, token) = connected_client()?;
354    flush_with(&client, &token).await
355}
356
357/// Drains the outbox against an already-built client.
358///
359/// Shared with `push` so a successful upload carries the backlog with it, on
360/// the connection that was just proven to work.
361async fn flush_with(client: &KaslServer, token: &str) -> Result<()> {
362    let mut outbox = ServerOutbox::new()?;
363    let dates: Vec<NaiveDate> = outbox.pending()?.into_iter().map(|owed| owed.date).collect();
364    if dates.is_empty() {
365        return Ok(());
366    }
367
368    msg_info!(Message::KaslServerQueueSending(dates.len()));
369
370    let outcomes = deliver(client, token, &mut outbox, &dates).await?;
371
372    let (mut accepted, mut refused, mut deferred) = (0, 0, 0);
373    for outcome in &outcomes {
374        match outcome {
375            Delivered::Accepted {
376                date,
377                pauses,
378                tasks,
379                deleted_tasks,
380            } => {
381                accepted += 1;
382                msg_success!(Message::KaslServerDayPushed {
383                    date: date.to_string(),
384                    pauses: *pauses,
385                    tasks: *tasks,
386                });
387                if *deleted_tasks > 0 {
388                    msg_info!(Message::KaslServerTasksDeleted(*deleted_tasks));
389                }
390            }
391            // Named rather than counted: a day dropped because the server
392            // will never take it is data that is not going to arrive, and
393            // burying that in a total would be the queue losing a day
394            // quietly.
395            Delivered::Refused { date, reason } => {
396                refused += 1;
397                msg_warning!(Message::KaslServerDayRefused {
398                    date: date.to_string(),
399                    reason: reason.clone(),
400                });
401            }
402            Delivered::Deferred { date, reason } => {
403                deferred += 1;
404                msg_warning!(Message::KaslServerDayDeferred {
405                    date: date.to_string(),
406                    reason: reason.clone(),
407                });
408            }
409        }
410    }
411
412    msg_print!(Message::KaslServerFlushSummary { accepted, refused, deferred });
413    Ok(())
414}
415
416/// Lists what is still owed, without touching the network.
417///
418/// Deliberately offline: this is the command someone runs to find out whether
419/// their work is safe, and it has to answer on a train.
420fn queue() -> Result<()> {
421    let outbox = ServerOutbox::new()?;
422    let owed = outbox.pending()?;
423
424    if owed.is_empty() {
425        msg_print!(Message::KaslServerQueueEmpty);
426        return Ok(());
427    }
428
429    msg_info!(Message::KaslServerQueueOwed(owed.len() as i64));
430    for day in &owed {
431        msg_print!(Message::KaslServerQueueEntry {
432            date: day.date.to_string(),
433            attempts: day.attempts,
434            last_error: day.last_error.clone(),
435        });
436    }
437
438    Ok(())
439}
440
441/// Queues every recorded day in a range and sends them.
442///
443/// The range is walked against the database rather than the calendar: only
444/// dates that actually have a workday are queued, so a month containing
445/// weekends and leave does not fill the outbox with days that were never
446/// worked and can never be sent.
447async fn backfill(args: BackfillArgs) -> Result<()> {
448    let to = args.to.unwrap_or_else(|| Local::now().date_naive());
449    if args.from > to {
450        return Err(msg_error_anyhow!(Message::KaslServerBackfillOrderReversed));
451    }
452
453    let (client, token) = connected_client()?;
454
455    let mut workdays = Workdays::new()?;
456    let mut dates = Vec::new();
457    let mut date = args.from;
458    while date <= to {
459        if workdays.fetch(date)?.is_some() {
460            dates.push(date);
461        }
462        date += Duration::days(1);
463    }
464
465    if dates.is_empty() {
466        msg_print!(Message::KaslServerBackfillNoDays {
467            from: args.from.to_string(),
468            to: to.to_string(),
469        });
470        return Ok(());
471    }
472
473    msg_info!(Message::KaslServerBackfillRange {
474        from: args.from.to_string(),
475        to: to.to_string(),
476        days: dates.len(),
477    });
478
479    // Queued before they are sent, so an interrupted backfill is not lost: a
480    // run cut off halfway leaves the rest owed rather than forgotten.
481    let mut outbox = ServerOutbox::new()?;
482    for date in &dates {
483        outbox.enqueue(*date, "queued by backfill")?;
484    }
485
486    flush_with(&client, &token).await
487}
488
489/// The client and token for the configured server, or a message saying why
490/// there is none.
491///
492/// Both failures are the same shape - nothing can be sent - and both have a
493/// single fix, `kasl server connect`.
494fn connected_client() -> Result<(KaslServer, String)> {
495    let config = Config::read().unwrap_or_default();
496    let Some(server_config) = config.kasl_server else {
497        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
498    };
499
500    let Some(token) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).try_get_cached() else {
501        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
502    };
503
504    Ok((KaslServer::new(&server_config)?, token))
505}
506
507/// Forgets the connection: the token first, then the config.
508///
509/// In that order deliberately. If removing the config succeeded and the token
510/// removal then failed, a working credential would be left behind with nothing
511/// pointing at it - the one outcome this command exists to prevent.
512fn disconnect() -> Result<()> {
513    let mut config = Config::read().unwrap_or_default();
514
515    // An unreachable keyring must not stop the address being forgotten. On a
516    // headless machine - a container, a build agent, a server with no session
517    // keyring - there is no store to hold a token and nothing to remove, and
518    // refusing to disconnect there leaves the config pointing at a server for
519    // good. The failure is still reported, because on a machine that does have
520    // a keyring it means a credential survived.
521    if let Err(error) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).delete() {
522        msg_warning!(Message::KaslServerTokenNotRemoved(error.to_string()));
523    }
524
525    if config.kasl_server.take().is_some() {
526        config.save()?;
527        msg_success!(Message::KaslServerDisconnected);
528    } else {
529        // The token is gone either way, which is what was asked for.
530        msg_print!(Message::KaslServerNotConnected);
531    }
532
533    Ok(())
534}