airs-cli 0.7.0

Native Rust CLI for Prisma AIRS (executable airs-cli-rs until 1.0), built on the scm-sdk crate
Documentation
//! One management write (POST, PUT, DELETE) and its outcome (DESIGN §13.2, D95).
//!
//! The SDK never re-sends a write. The CLI adds exactly one replay: a write answered
//! HTTP 401 (`RemoteOutcome::Rejected`, so nothing was processed) is sent once more,
//! after the SDK invalidated the token, so the replay fetches a fresh one. A 403 is
//! never replayed (the CLI cannot see `x-opa-decision`). A failure whose outcome is not
//! a plain rejection gets one detail line saying what may or did happen.

use crate::clients::SINGLE_REQUEST_BUDGET;
use crate::error::{self, CliError, Operation, Phase, Plane, SdkContext, SecretSet};
use scm::{ErrorKind, RemoteOutcome};

/// What a write does, for its outcome lines (`<thing> <done>`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Change {
    TopicCreated,
    TopicUpdated,
    TopicDeleted,
    ProfileCreated,
    ProfileUpdated,
    ProfileDeleted,
    /// A red team write (DESIGN §15.3 "Writes and outcomes"): `(thing, done)`.
    Words(&'static str, &'static str),
}

impl Change {
    /// `(thing, done)`: `("topic", "created")`.
    pub fn words(self) -> (&'static str, &'static str) {
        match self {
            Self::TopicCreated => ("topic", "created"),
            Self::TopicUpdated => ("topic", "updated"),
            Self::TopicDeleted => ("topic", "deleted"),
            Self::ProfileCreated => ("profile", "created"),
            Self::ProfileUpdated => ("profile", "updated"),
            Self::ProfileDeleted => ("profile", "deleted"),
            Self::Words(thing, done) => (thing, done),
        }
    }

    /// `Outcome unknown: the <thing> may have been <done>.`
    pub fn unknown_line(self) -> String {
        let (thing, done) = self.words();
        format!("Outcome unknown: the {thing} may have been {done}.")
    }

    /// `Outcome: the <thing> was <done>, but the response could not be read.`
    pub fn unreadable_line(self) -> String {
        let (thing, done) = self.words();
        format!("Outcome: the {thing} was {done}, but the response could not be read.")
    }
}

/// Whether a failed write is a plain 401 rejection, which the CLI replays once.
fn replayable(error: &scm::Error) -> bool {
    error.kind() == ErrorKind::Http
        && error.status() == Some(401)
        && error.remote_outcome() == RemoteOutcome::Rejected
}

/// Send one write with `call`, replaying it once after a 401 (D95); the SDK error of
/// the final attempt is returned as is.
pub async fn attempt<T>(mut call: impl AsyncFnMut() -> scm::Result<T>) -> scm::Result<T> {
    match call().await {
        Err(error) if replayable(&error) => call().await,
        other => other,
    }
}

/// [`attempt`], with the error described by [`describe`]: the §6 message plus the
/// outcome detail line.
pub async fn send<T>(
    secrets: &SecretSet,
    change: Change,
    call: impl AsyncFnMut() -> scm::Result<T>,
) -> Result<T, CliError> {
    attempt(call)
        .await
        .map_err(|error| describe(&error, change, secrets))
}

/// [`send`] with one extra detail line after an unknown or unreadable outcome (the
/// red team hints of §15.3, such as `Check it with '…' before accepting again.`).
pub async fn send_hinted<T>(
    secrets: &SecretSet,
    change: Change,
    hint: Option<&str>,
    call: impl AsyncFnMut() -> scm::Result<T>,
) -> Result<T, CliError> {
    attempt(call).await.map_err(|error| {
        let mut failure = describe(&error, change, secrets);
        if let (CliError::Operational(failure), Some(hint)) = (&mut failure, hint)
            && failure
                .details
                .iter()
                .any(|line| *line == change.unknown_line() || *line == change.unreadable_line())
        {
            failure.details.push(hint.to_owned());
        }
        failure
    })
}

/// The failure of a write: the §6 message, then `Outcome unknown: …` when the write may
/// have been applied, or `Outcome: … could not be read.` for a 2xx whose body could not
/// be decoded (the write was applied, G4). An interruption maps to its §13.2 context.
pub fn describe(error: &scm::Error, change: Change, secrets: &SecretSet) -> CliError {
    let mut failure = error::from_sdk(
        error,
        &SdkContext {
            plane: Plane::Management,
            phase: Phase::Api,
            operation: Operation::Write,
            timeout: SINGLE_REQUEST_BUDGET,
            secrets,
        },
    );
    if let CliError::Operational(failure) = &mut failure {
        let applied = error.kind() == ErrorKind::Decode
            && error
                .status()
                .is_some_and(|status| (200..300).contains(&status));
        if applied {
            failure.details.push(change.unreadable_line());
        } else if error.remote_outcome() == RemoteOutcome::Unknown {
            failure.details.push(change.unknown_line());
        }
    }
    failure
}

/// The local schema failure of a request body (D116): `<METHOD> <path>` names the
/// request, the id included.
pub fn invalid_body(method: &str, path: &str) -> CliError {
    CliError::operational(format!(
        "AISEC_USER_REQUEST_PAYLOAD_ERROR:Request body for {method} {path} did not match schema"
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::{Failure, InterruptContext};

    #[test]
    fn outcome_lines() {
        assert_eq!(
            Change::TopicCreated.unknown_line(),
            "Outcome unknown: the topic may have been created."
        );
        assert_eq!(
            Change::ProfileDeleted.unreadable_line(),
            "Outcome: the profile was deleted, but the response could not be read."
        );
        assert_eq!(Change::ProfileUpdated.words(), ("profile", "updated"));
    }

    #[test]
    fn local_and_rejected_failures_add_no_outcome_line() {
        let secrets = SecretSet::default();
        let validation = describe(
            &scm::Error::new(ErrorKind::Validation),
            Change::TopicCreated,
            &secrets,
        );
        assert_eq!(
            validation,
            CliError::Operational(Failure {
                message: "AISEC_USER_REQUEST_PAYLOAD_ERROR:Validation error".into(),
                status: None,
                details: Vec::new(),
            })
        );
        let cancelled = describe(
            &scm::Error::new(ErrorKind::Cancelled),
            Change::TopicDeleted,
            &secrets,
        );
        assert_eq!(
            cancelled,
            CliError::Interrupted(InterruptContext::NothingChanged)
        );
        assert_eq!(
            invalid_body("POST", "/v1/mgmt/profile").message(),
            "AISEC_USER_REQUEST_PAYLOAD_ERROR:Request body for POST /v1/mgmt/profile did not match schema"
        );
    }
}