fundaia 0.32.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! Deployments: starting them, following them, and what they shipped or broke.

use super::*;

impl ApiClient {
    pub async fn deployments(&self, service_environment_id: &str) -> Result<Vec<Deployment>> {
        Ok(self
            .get::<DeploymentsPage>(&format!(
                "/api/infra/services/{service_environment_id}/deployments"
            ))
            .await?
            .deployments)
    }

    /// Only the deployments that shipped one commit: the build, a retry, a
    /// rollback back to it. The same route as `deployments`, filtered on the
    /// server, so an old commit is found however long the history has grown.
    pub async fn deployments_of_commit(
        &self,
        service_environment_id: &str,
        commit: &str,
    ) -> Result<Vec<Deployment>> {
        Ok(self
            .get::<DeploymentsPage>(&format!(
                "/api/infra/services/{service_environment_id}/deployments?commit={commit}"
            ))
            .await?
            .deployments)
    }

    /// The diff of one commit, from the repository the service is built from.
    pub async fn commit_change(
        &self,
        service_environment_id: &str,
        sha: &str,
    ) -> Result<CommitChangeResponse> {
        self.get(&format!(
            "/api/infra/services/{service_environment_id}/commits/{sha}"
        ))
        .await
    }

    pub async fn deployment(&self, deployment_id: &str) -> Result<DeploymentDetail> {
        self.get(&format!("/api/infra/deployments/{deployment_id}"))
            .await
    }

    pub async fn incidents(
        &self,
        service_environment_id: &str,
        decided: bool,
    ) -> Result<Vec<Incident>> {
        Ok(self
            .get::<IncidentsPage>(&format!(
                "/api/infra/services/{service_environment_id}/incidents?decided={decided}"
            ))
            .await?
            .incidents)
    }

    /// Records a decision and answers with the incident as it now stands.
    ///
    /// `send` rather than `patch`, which throws the body away: this endpoint
    /// answers with the updated row and `--json` is supposed to print it.
    pub async fn decide_incident(&self, incident_id: &str, status: &str) -> Result<Incident> {
        self.send(
            Method::PATCH,
            &format!("/api/infra/incidents/{incident_id}"),
            Some(&serde_json::json!({ "status": status })),
        )
        .await
    }

    /// What is waiting in a whole project, which is the question worth asking:
    /// a held commit is somebody else's push, so nobody knows which service.
    pub async fn project_approvals(&self, project_id: &str) -> Result<Vec<Approval>> {
        Ok(self
            .get::<ApprovalsPage>(&format!("/api/infra/projects/{project_id}/approvals"))
            .await?
            .approvals)
    }

    /// One service's requests, answered ones included.
    pub async fn service_approvals(&self, service_environment_id: &str) -> Result<Vec<Approval>> {
        Ok(self
            .get::<ApprovalsPage>(&format!(
                "/api/infra/services/{service_environment_id}/approvals"
            ))
            .await?
            .approvals)
    }

    pub async fn approve_deployment(&self, approval_id: &str) -> Result<Deployment> {
        Ok(self
            .post_empty::<DeploymentAccepted>(&format!(
                "/api/infra/approvals/{approval_id}/approve"
            ))
            .await?
            .deployment)
    }

    pub async fn reject_deployment(&self, approval_id: &str) -> Result<serde_json::Value> {
        self.post_empty(&format!("/api/infra/approvals/{approval_id}/reject"))
            .await
    }

    pub async fn autofix_runs(&self, service_environment_id: &str) -> Result<Vec<AutofixRun>> {
        Ok(self
            .get::<AutofixPage>(&format!(
                "/api/infra/services/{service_environment_id}/autofix"
            ))
            .await?
            .runs)
    }

    /// Writes whatever the caller named and answers with the settings as they
    /// now stand — so `--json` prints the result rather than the request.
    pub async fn set_autofix(
        &self,
        service_environment_id: &str,
        changes: &AutofixPatch,
    ) -> Result<serde_json::Value> {
        self.put(
            &format!("/api/infra/services/{service_environment_id}/autofix"),
            changes,
        )
        .await
    }

    /// Starts a run and answers with it. The work happens after this returns —
    /// the platform does not hold the request open for four minutes.
    pub async fn fix_incident(&self, incident_id: &str) -> Result<AutofixRun> {
        self.post_empty(&format!("/api/infra/incidents/{incident_id}/fix"))
            .await
    }

    pub async fn deploy(&self, service_environment_id: &str) -> Result<Deployment> {
        Ok(self
            .post_empty::<DeploymentAccepted>(&format!(
                "/api/infra/services/{service_environment_id}/deploy"
            ))
            .await?
            .deployment)
    }

    /// Sends an archive and, unless told otherwise, deploys it.
    ///
    /// The bytes are the whole body rather than a multipart form: there is one
    /// part, and a boundary would mean the server reading a hundred megabytes
    /// to find where it ends. **Unbounded** like a stream, because an ordinary
    /// API call's budget is a minute and an upload over a domestic connection
    /// is minutes.
    pub async fn upload(
        &self,
        service_environment_id: &str,
        archive: Vec<u8>,
        file_name: &str,
        deploy: bool,
    ) -> Result<UploadAccepted> {
        let path = format!(
            "/api/infra/services/{service_environment_id}/uploads{}",
            if deploy { "" } else { "?deploy=false" }
        );

        let response = self
            .unbounded_request(Method::POST, &path, None::<&()>)
            .header("content-type", "application/gzip")
            .header("x-fundaia-upload-name", file_name)
            .body(archive)
            .send()
            .await?;

        let text = Self::check(response).await?;
        serde_json::from_str(&text)
            .with_context(|| format!("could not read the answer from {path}"))
    }

    pub async fn uploads(&self, service_environment_id: &str) -> Result<UploadsPage> {
        self.get(&format!(
            "/api/infra/services/{service_environment_id}/uploads"
        ))
        .await
    }

    pub async fn restart(&self, service_environment_id: &str) -> Result<Deployment> {
        Ok(self
            .post_empty::<DeploymentAccepted>(&format!(
                "/api/infra/services/{service_environment_id}/restart"
            ))
            .await?
            .deployment)
    }

    pub async fn stop(&self, service_environment_id: &str) -> Result<()> {
        let response = self
            .request(
                Method::POST,
                &format!("/api/infra/services/{service_environment_id}/stop"),
                Some(&serde_json::json!({})),
            )
            .send()
            .await?;
        Self::check(response).await?;
        Ok(())
    }

    pub async fn redeploy(&self, deployment_id: &str) -> Result<Deployment> {
        Ok(self
            .post_empty::<DeploymentAccepted>(&format!(
                "/api/infra/deployments/{deployment_id}/redeploy"
            ))
            .await?
            .deployment)
    }

    pub async fn rollback(&self, deployment_id: &str) -> Result<Deployment> {
        Ok(self
            .post_empty::<DeploymentAccepted>(&format!(
                "/api/infra/deployments/{deployment_id}/rollback"
            ))
            .await?
            .deployment)
    }

    /// Stops a deployment that has not finished.
    ///
    /// Answers with the deployment *and its pipeline*, the way the detail route
    /// does: there is nothing left to watch, so the whole answer arrives at once.
    pub async fn cancel(&self, deployment_id: &str) -> Result<DeploymentDetail> {
        self.post_empty(&format!("/api/infra/deployments/{deployment_id}/cancel"))
            .await
    }

    /// Replays and then follows a deployment's log.
    pub async fn stream_log<F>(
        &self,
        deployment_id: &str,
        after: u64,
        stage: Option<&str>,
        mut on_line: F,
    ) -> Result<()>
    where
        F: FnMut(LogLine),
    {
        let path = format!(
            "/api/infra/deployments/{deployment_id}/log/stream?after={after}{}",
            stage_query(stage)
        );

        self.read_events(&path, |event| {
            if let Some(line) = parse_log_event(event) {
                on_line(line);
            }
            if event.contains("event: end") {
                ControlFlow::Break(())
            } else {
                ControlFlow::Continue(())
            }
        })
        .await
    }

    /// One page of log history, for the non-following case.
    pub async fn read_log(
        &self,
        deployment_id: &str,
        after: u64,
        stage: Option<&str>,
        trace: Option<&str>,
    ) -> Result<Vec<LogLine>> {
        Ok(self
            .get::<LogPage>(&format!(
                "/api/infra/deployments/{deployment_id}/log?after={after}{}{}",
                stage_query(stage),
                trace_query(trace)
            ))
            .await?
            .lines)
    }
}