diff --git a/src/host/mod.rs b/src/host/mod.rs index c13952c..53879b4 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -23,8 +23,8 @@ use async_trait::async_trait; use crate::Result; use crate::host::types::{ - AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, Domain, - EnvVar, EnvVarRecord, Site, SiteSpec, + AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, + DeploymentLog, Domain, EnvVar, EnvVarRecord, Site, SiteSpec, }; use crate::providers::ProviderKind; @@ -134,6 +134,14 @@ pub trait Host: Send + Sync + std::fmt::Debug { /// Returns a provider error. async fn list_deployments(&self, site: &str, limit: u32) -> Result>; + /// Lists the build and runtime events a deployment recorded, oldest first. + /// + /// # Errors + /// + /// Returns a provider error, including [`Error::NotFound`](crate::Error::NotFound) + /// for an unknown deployment identifier. + async fn deployment_logs(&self, id: &str) -> Result>; + /// Points the site's production traffic at an existing deployment. /// /// This is both the promote and the rollback: a rollback is a promote of an diff --git a/src/host/test.rs b/src/host/test.rs index e697380..2e470ac 100644 --- a/src/host/test.rs +++ b/src/host/test.rs @@ -6,7 +6,7 @@ use crate::Error; use crate::bundle::Bundle; use crate::host::types::{ AnalyticsDimension, AnalyticsQuery, DatabaseKind, DatabaseSpec, DeployRequest, Deployment, - DeploymentStatus, DeploymentTarget, EnvVar, Framework, SiteSpec, + DeploymentLog, DeploymentStatus, DeploymentTarget, EnvVar, Framework, SiteSpec, }; fn bundle() -> Bundle { @@ -247,6 +247,18 @@ fn a_deployment_round_trips_through_json() { ); } +#[test] +fn a_deployment_log_round_trips_through_json() { + let log = DeploymentLog { + created_at_ms: Some(1), + kind: "stderr".to_owned(), + message: "missing module".to_owned(), + }; + + let json = serde_json::to_string(&log).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), log); +} + #[test] fn a_status_this_crate_does_not_model_survives_a_round_trip() { let status = DeploymentStatus::Other("BLOCKED".to_owned()); diff --git a/src/host/types.rs b/src/host/types.rs index 9b8ce6a..a0dffbc 100644 --- a/src/host/types.rs +++ b/src/host/types.rs @@ -250,6 +250,23 @@ pub struct Deployment { pub error_message: Option, } +/// One build or runtime event a provider recorded for a deployment. +/// +/// Providers use different event names, so [`kind`](Self::kind) is preserved +/// rather than forced into a small enum. The message is the provider's +/// human-readable payload; it is not a request credential or environment +/// variable value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeploymentLog { + /// When the provider recorded the event, in milliseconds since the Unix epoch. + #[serde(default)] + pub created_at_ms: Option, + /// The provider's event kind, such as `stdout`, `stderr`, or `error`. + pub kind: String, + /// The event's human-readable message. + pub message: String, +} + /// An environment variable to set on a site. /// /// The value is write-only across this API: it goes out in a request and is diff --git a/src/lib.rs b/src/lib.rs index 847d877..5c0a1ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,8 +66,8 @@ pub use error::{Error, Result}; pub use host::Host; pub use host::types::{ AnalyticsBucket, AnalyticsDimension, AnalyticsQuery, AnalyticsSummary, Database, DatabaseKind, - DatabaseSpec, DeployRequest, Deployment, DeploymentStatus, DeploymentTarget, Domain, EnvVar, - EnvVarRecord, Framework, Site, SiteSpec, + DatabaseSpec, DeployRequest, Deployment, DeploymentLog, DeploymentStatus, DeploymentTarget, + Domain, EnvVar, EnvVarRecord, Framework, Site, SiteSpec, }; pub use launch::launch; pub use launch::types::{Launch, LaunchPlan}; diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs index 8a6f1de..81f1bbe 100644 --- a/src/providers/vercel/mod.rs +++ b/src/providers/vercel/mod.rs @@ -31,7 +31,8 @@ use serde_json::Value; use crate::host::Host; use crate::host::types::{ AnalyticsBucket, AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, - Deployment, DeploymentTarget, Domain, EnvVar, EnvVarRecord, Framework, Site, SiteSpec, + Deployment, DeploymentLog, DeploymentTarget, Domain, EnvVar, EnvVarRecord, Framework, Site, + SiteSpec, }; use crate::providers::ProviderKind; use crate::{Credentials, Error, Result}; @@ -39,8 +40,9 @@ use crate::{Credentials, Error, Result}; use self::http::{DEFAULT_BASE_URL, Http}; use self::wire::{ AnalyticsEnvelope, Configuration, ConnectResource, CreateDeployment, CreateDomain, - CreateEnvVar, CreateProject, CreateStore, DeploymentBody, Deployments, DomainBody, Domains, - Envs, Products, Project, ProjectSettings, Projects, StoreEnvelope, UploadedFile, + CreateEnvVar, CreateProject, CreateStore, DeploymentBody, DeploymentEvents, Deployments, + DomainBody, Domains, Envs, Products, Project, ProjectSettings, Projects, StoreEnvelope, + UploadedFile, }; mod http; @@ -428,6 +430,23 @@ impl Host for Vercel { .collect()) } + async fn deployment_logs(&self, id: &str) -> Result> { + let events: DeploymentEvents = self + .http + .get_json( + &format!("/v3/deployments/{}/events", encode_segment(id)), + &[], + "deployment events", + ) + .await?; + + Ok(events + .events + .into_iter() + .map(self::wire::DeploymentEvent::into_log) + .collect()) + } + async fn promote(&self, site: &str, deployment: &str) -> Result<()> { let project = self.project_id(site).await?; let builder = self.http.request( diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs index 5ad0651..a56461e 100644 --- a/src/providers/vercel/test.rs +++ b/src/providers/vercel/test.rs @@ -492,6 +492,33 @@ async fn an_empty_deployment_list_decodes() { ); } +#[tokio::test] +async fn deployment_events_preserve_their_kind_message_and_timestamp() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v3/deployments/dpl_1/events", + 200, + json!({ + "events": [ + {"created": 2_u64, "type": "stdout", "payload": "Building route /"}, + {"created": 3_u64, "type": "error", "payload": {"code": "BUILD_FAILED"}} + ] + }), + ) + .await; + + let logs = host(&server).deployment_logs("dpl_1").await.unwrap(); + + assert_eq!(logs.len(), 2); + assert_eq!(logs[0].created_at_ms, Some(2)); + assert_eq!(logs[0].kind, "stdout"); + assert_eq!(logs[0].message, "Building route /"); + assert_eq!(logs[1].kind, "error"); + assert_eq!(logs[1].message, r#"{"code":"BUILD_FAILED"}"#); +} + #[tokio::test] async fn promoting_resolves_the_project_first() { let server = MockServer::start().await; diff --git a/src/providers/vercel/wire.rs b/src/providers/vercel/wire.rs index e37fc1b..c72b491 100644 --- a/src/providers/vercel/wire.rs +++ b/src/providers/vercel/wire.rs @@ -12,10 +12,11 @@ //! disagree about their names — `uid` against `id`, `state` against `readyState`. use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::host::types::{ - Database, DatabaseKind, Deployment, DeploymentStatus, DeploymentTarget, Domain, EnvVarRecord, - Framework, Site, + Database, DatabaseKind, Deployment, DeploymentLog, DeploymentStatus, DeploymentTarget, Domain, + EnvVarRecord, Framework, Site, }; /// The body of `POST /v11/projects`. @@ -140,6 +141,41 @@ pub(super) struct Deployments { pub(super) deployments: Vec, } +/// The envelope returned by `GET /v3/deployments/{id}/events`. +#[derive(Deserialize)] +pub(super) struct DeploymentEvents { + #[serde(default)] + pub(super) events: Vec, +} + +/// One Vercel deployment event. +#[derive(Deserialize)] +pub(super) struct DeploymentEvent { + #[serde(default)] + pub(super) created: Option, + #[serde(rename = "type")] + pub(super) kind: String, + #[serde(default)] + pub(super) payload: Option, +} + +impl DeploymentEvent { + /// Preserves a non-string payload as JSON rather than silently losing it. + pub(super) fn into_log(self) -> DeploymentLog { + let message = match self.payload { + Some(Value::String(message)) => message, + Some(payload) => payload.to_string(), + None => String::new(), + }; + + DeploymentLog { + created_at_ms: self.created, + kind: self.kind, + message, + } + } +} + /// One entry of the `POST /v10/projects/{id}/env` array body. #[derive(Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index e7b868c..063022a 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -16,8 +16,8 @@ use serde::{Deserialize, Serialize}; use crate::host::types::{ - AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, Domain, - EnvVar, EnvVarRecord, Site, SiteSpec, + AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, + DeploymentLog, Domain, EnvVar, EnvVarRecord, Site, SiteSpec, }; use crate::launch::types::{Launch, LaunchPlan}; use crate::providers::{ProviderKind, connect_to}; @@ -119,6 +119,11 @@ pub enum Operation { #[serde(default = "default_limit")] limit: u32, }, + /// List a deployment's build and runtime events, oldest first. + DeploymentLogs { + /// The deployment's identifier. + id: String, + }, /// Point production traffic at an existing deployment. Promote { /// The site's name or identifier. @@ -173,6 +178,8 @@ pub enum Outcome { Deployment(Deployment), /// Several deployments. Deployments(Vec), + /// A deployment's build and runtime events. + DeploymentLogs(Vec), /// A site's environment variables, without their values. Env(Vec), /// One database. @@ -230,6 +237,9 @@ pub async fn execute(request: Request) -> Result { .list_deployments(&site, limit) .await .map(Outcome::Deployments), + Operation::DeploymentLogs { id } => { + host.deployment_logs(&id).await.map(Outcome::DeploymentLogs) + } Operation::Promote { site, deployment } => host .promote(&site, &deployment) .await