Skip to main content

agent_deploy_contract/
lib.rs

1//! Stable, transport-neutral deploy consumer contract.
2//! This crate intentionally has no HTTP, SQL, async-runtime, or provider dependency.
3
4use serde::{Deserialize, Serialize};
5
6pub mod paths {
7    pub const ROOT: &str = "/internal/v1/deploy";
8    pub const APPS: &str = "/internal/v1/deploy/apps";
9    pub const DEPLOYMENTS: &str = "/internal/v1/deploy/deployments";
10    pub const OPERATIONS: &str = "/internal/v1/deploy/operations";
11    pub const ROUTES: &str = "/internal/v1/deploy/routes";
12    pub const ENVIRONMENTS: &str = "/internal/v1/deploy/environments";
13    pub const ARTIFACTS: &str = "/internal/v1/deploy/artifacts";
14    pub const PROMOTIONS: &str = "/internal/v1/deploy/promotions";
15    pub const APP: &str = "/internal/v1/deploy/apps/{appId}";
16    pub const APP_DEPLOYMENTS: &str = "/internal/v1/deploy/apps/{appId}/deployments";
17    pub const DEPLOYMENT: &str = "/internal/v1/deploy/deployments/{deploymentId}";
18    pub const DEPLOYMENT_CANCEL: &str = "/internal/v1/deploy/deployments/{deploymentId}/cancel";
19    pub const DEPLOYMENT_ROLLBACK: &str = "/internal/v1/deploy/deployments/{deploymentId}/rollback";
20    pub const DEPLOYMENT_RESUME: &str = "/internal/v1/deploy/deployments/{deploymentId}/resume";
21    pub const DEPLOYMENT_LOGS: &str = "/internal/v1/deploy/deployments/{deploymentId}/logs";
22    pub const OPERATION: &str = "/internal/v1/deploy/operations/{operationId}";
23    pub const RELEASES: &str = "/internal/v1/deploy/environments/{environmentId}/releases";
24    pub const APP_ENVIRONMENTS: &str = "/internal/v1/deploy/apps/{appId}/environments";
25    pub const ENVIRONMENT: &str = "/internal/v1/deploy/environments/{environmentId}";
26    pub const ARTIFACT: &str = "/internal/v1/deploy/artifacts/{digest}";
27    pub const RELEASE: &str =
28        "/internal/v1/deploy/environments/{environmentId}/releases/{releaseId}";
29    pub const RELEASE_ACTIVATE: &str =
30        "/internal/v1/deploy/environments/{environmentId}/releases/{releaseId}/activate";
31    pub const ROUTE: &str = "/internal/v1/deploy/routes/{appId}/{environment}";
32    pub const PROMOTION: &str = "/internal/v1/deploy/promotions/{promotionId}";
33    pub const PROMOTION_APPROVE: &str = "/internal/v1/deploy/promotions/{promotionId}/approve";
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub struct Application {
39    pub id: String,
40    pub name: String,
41    pub source_repository: String,
42    pub default_build_spec: BuildSpec,
43    pub runtime_port: Option<u16>,
44    pub version: i64,
45    pub created_at: String,
46    pub updated_at: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "camelCase")]
51pub struct CreateApplicationRequest {
52    pub name: String,
53    pub source_repository: String,
54    pub default_build_spec: BuildSpec,
55    pub runtime_port: Option<u16>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "camelCase")]
60pub struct UpdateApplicationRequest {
61    pub name: String,
62    pub source_repository: String,
63    pub default_build_spec: BuildSpec,
64    pub runtime_port: Option<u16>,
65    pub expected_version: i64,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "camelCase")]
70pub struct DeleteApplicationRequest {
71    pub expected_version: i64,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "camelCase")]
76pub struct DeleteResult {
77    pub deleted: bool,
78}
79
80pub type ApplicationPage = CursorPage<Application>;
81pub type DeploymentPage = CursorPage<Deployment>;
82pub type ReleasePage = CursorPage<Release>;
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "camelCase")]
86pub struct CreateDeploymentRequest {
87    pub source_revision: SourceRevision,
88    pub environment: String,
89    #[serde(default)]
90    pub strategy: DeploymentStrategy,
91    pub build_spec: Option<BuildSpec>,
92    #[serde(default)]
93    pub labels: std::collections::BTreeMap<String, String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "camelCase")]
98pub struct SourceRevision {
99    pub repository: String,
100    pub commit_sha: String,
101    pub archive_digest: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "camelCase")]
106pub struct BuildSpec {
107    pub dockerfile: String,
108    pub context: String,
109    pub platform: Option<String>,
110    pub cpu_millis: u32,
111    pub memory_bytes: u64,
112    pub disk_bytes: u64,
113    pub timeout_seconds: u32,
114}
115
116#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum DeploymentStrategy {
119    #[default]
120    Recreate,
121    Rolling,
122    Canary,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "camelCase")]
127pub struct RollingConfig {
128    pub max_surge: u32,
129    pub max_unavailable: u32,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "camelCase")]
134pub struct CanaryConfig {
135    pub steps_percent: Vec<u8>,
136    pub observation_seconds: u32,
137    pub auto_promote: bool,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "camelCase")]
142pub struct SecretRef {
143    pub provider: String,
144    pub key: String,
145    pub version: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "camelCase")]
150pub struct Environment {
151    pub id: String,
152    pub app_id: String,
153    pub name: String,
154    pub strategy: DeploymentStrategy,
155    pub rolling: Option<RollingConfig>,
156    pub canary: Option<CanaryConfig>,
157    #[serde(default)]
158    pub secret_refs: Vec<SecretRef>,
159    pub approval_required: bool,
160    pub version: i64,
161    pub created_at: String,
162    pub updated_at: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "camelCase")]
167pub struct CreateEnvironmentRequest {
168    pub name: String,
169    pub strategy: DeploymentStrategy,
170    pub rolling: Option<RollingConfig>,
171    pub canary: Option<CanaryConfig>,
172    #[serde(default)]
173    pub secret_refs: Vec<SecretRef>,
174    pub approval_required: bool,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(rename_all = "camelCase")]
179pub struct Artifact {
180    pub digest: String,
181    pub size_bytes: u64,
182    pub storage_key: String,
183    pub sbom_digest: String,
184    pub signature: String,
185    pub policy_state: ArtifactPolicyState,
186    pub created_at: String,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
190#[serde(rename_all = "snake_case")]
191pub enum ArtifactPolicyState {
192    Pending,
193    Accepted,
194    Rejected,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "camelCase")]
199pub struct GetOperationRequest {
200    pub operation_id: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204#[serde(rename_all = "camelCase")]
205pub struct CancelDeploymentRequest {
206    pub reason: Option<String>,
207    pub version: Option<i64>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "camelCase")]
212pub struct ResumeDeploymentRequest {
213    pub expected_version: i64,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217#[serde(rename_all = "camelCase")]
218pub struct RollbackDeploymentRequest {
219    pub target_deployment_id: String,
220    pub environment: String,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224#[serde(rename_all = "camelCase")]
225pub struct ListLogsRequest {
226    pub after: Option<i64>,
227    pub limit: Option<u32>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(rename_all = "camelCase")]
232pub struct LogChunk {
233    pub cursor: i64,
234    pub stream: LogStream,
235    pub timestamp: String,
236    pub payload: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240#[serde(rename_all = "snake_case")]
241pub enum LogStream {
242    Stdout,
243    Stderr,
244    System,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248#[serde(rename_all = "camelCase")]
249pub struct CursorPage<T> {
250    pub items: Vec<T>,
251    pub next_cursor: Option<String>,
252}
253
254pub type LogPage = CursorPage<LogChunk>;
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[serde(rename_all = "camelCase")]
258pub struct RouteBinding {
259    pub app_id: String,
260    pub environment: String,
261    pub release_id: String,
262    pub upstream: String,
263    /// Previous healthy upstream retained while a rolling/canary release is
264    /// below 100% traffic.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub fallback_upstream: Option<String>,
267    /// Percentage of new requests sent to `upstream` (0..=100).
268    #[serde(default = "full_traffic")]
269    pub traffic_percent: u8,
270    pub healthy: bool,
271    pub version: i64,
272    pub updated_at: String,
273}
274
275const fn full_traffic() -> u8 {
276    100
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
280#[serde(rename_all = "camelCase")]
281pub struct Release {
282    pub id: String,
283    pub app_id: String,
284    pub environment: String,
285    pub artifact_digest: String,
286    pub runtime_config_digest: String,
287    /// Immutable provider route captured when the release is created.
288    ///
289    /// The deploy data plane validates this URL against the configured provider
290    /// origin before proxying, so a route switch never has to rediscover the
291    /// "latest" deployment.
292    #[serde(default)]
293    pub upstream: String,
294    pub created_at: String,
295    pub approved_by: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
299#[serde(rename_all = "camelCase")]
300pub struct ActivateReleaseRequest {
301    pub expected_route_version: i64,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
305#[serde(rename_all = "camelCase")]
306pub struct PromoteReleaseRequest {
307    pub source_release_id: String,
308    pub target_environment_id: String,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
312#[serde(rename_all = "camelCase")]
313pub struct ApprovalRequest {
314    pub expected_version: i64,
315    pub comment: Option<String>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "camelCase")]
320pub struct Promotion {
321    pub id: String,
322    pub source_release_id: String,
323    pub target_environment_id: String,
324    pub state: PromotionState,
325    pub requested_by: String,
326    pub approved_by: Option<String>,
327    pub comment: Option<String>,
328    pub version: i64,
329    pub created_at: String,
330    pub updated_at: String,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(rename_all = "snake_case")]
335pub enum PromotionState {
336    PendingApproval,
337    Approved,
338    Rejected,
339    Activated,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
343#[serde(rename_all = "snake_case")]
344pub enum OperationState {
345    Queued,
346    Running,
347    Succeeded,
348    Failed,
349    Canceling,
350    Canceled,
351}
352
353impl OperationState {
354    pub fn parse(value: &str) -> Option<Self> {
355        Some(match value {
356            "queued" => Self::Queued,
357            "running" => Self::Running,
358            "succeeded" => Self::Succeeded,
359            "failed" => Self::Failed,
360            "canceling" => Self::Canceling,
361            "canceled" => Self::Canceled,
362            _ => return None,
363        })
364    }
365
366    pub const fn is_terminal(&self) -> bool {
367        matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
368    }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
372#[serde(rename_all = "snake_case")]
373pub enum DeploymentPhase {
374    Queued,
375    Fetching,
376    Building,
377    Publishing,
378    Starting,
379    Verifying,
380    Activating,
381    AwaitingApproval,
382    Succeeded,
383    Failed,
384    Canceling,
385    Canceled,
386}
387
388impl DeploymentPhase {
389    pub fn parse(value: &str) -> Option<Self> {
390        Some(match value {
391            "queued" => Self::Queued,
392            "fetching" => Self::Fetching,
393            "building" => Self::Building,
394            "publishing" | "uploading" => Self::Publishing,
395            "starting" => Self::Starting,
396            "verifying" => Self::Verifying,
397            "activating" => Self::Activating,
398            "awaiting_approval" => Self::AwaitingApproval,
399            "succeeded" => Self::Succeeded,
400            "failed" => Self::Failed,
401            "canceling" => Self::Canceling,
402            "canceled" => Self::Canceled,
403            _ => return None,
404        })
405    }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409#[serde(rename_all = "snake_case")]
410pub enum DeploymentStatus {
411    Pending,
412    Running,
413    Succeeded,
414    Failed,
415    Canceling,
416    Canceled,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
420#[serde(rename_all = "camelCase")]
421pub struct Operation {
422    pub id: String,
423    pub deployment_id: String,
424    pub state: OperationState,
425    pub phase: DeploymentPhase,
426    pub attempt: i64,
427    pub lease_owner: Option<String>,
428    pub lease_deadline: Option<String>,
429    pub fencing_token: i64,
430    pub provider_external_id: String,
431    pub version: i64,
432    pub created_at: String,
433    pub started_at: Option<String>,
434    pub finished_at: Option<String>,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub next_poll_after_ms: Option<u64>,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub error_code: Option<String>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
442#[serde(rename_all = "camelCase")]
443pub struct Deployment {
444    pub id: String,
445    pub app_id: String,
446    pub operation_id: String,
447    pub status: DeploymentStatus,
448    pub phase: DeploymentPhase,
449    pub provider_ref: Option<String>,
450    pub endpoint: Option<String>,
451    pub created_at: String,
452    pub updated_at: String,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
456#[serde(rename_all = "camelCase")]
457pub struct ErrorEnvelope {
458    pub error: InfraError,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
462#[serde(rename_all = "camelCase")]
463pub struct InfraError {
464    pub code: String,
465    pub message: String,
466    pub category: ErrorCategory,
467    pub retryable: bool,
468    pub request_id: String,
469    #[serde(default)]
470    pub details: serde_json::Value,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
474#[serde(rename_all = "snake_case")]
475pub enum ErrorCategory {
476    Validation,
477    Auth,
478    Forbidden,
479    Conflict,
480    Quota,
481    NotFound,
482    Upstream,
483    Timeout,
484    Internal,
485}
486
487pub const IDEMPOTENCY_CONFLICT: &str = "IDEMPOTENCY_CONFLICT";
488pub const VERSION_CONFLICT: &str = "VERSION_CONFLICT";
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    #[test]
494    fn operation_wire_is_camel_case() {
495        let value = serde_json::to_value(Operation {
496            id: "o".into(),
497            deployment_id: "d".into(),
498            state: OperationState::Queued,
499            phase: DeploymentPhase::Queued,
500            attempt: 0,
501            lease_owner: None,
502            lease_deadline: None,
503            fencing_token: 0,
504            provider_external_id: "deploy:d".into(),
505            version: 1,
506            created_at: "t".into(),
507            started_at: None,
508            finished_at: None,
509            next_poll_after_ms: Some(250),
510            error_code: None,
511        })
512        .unwrap();
513        assert_eq!(value["deploymentId"], "d");
514        assert!(value.get("deployment_id").is_none());
515    }
516
517    #[test]
518    fn progressive_route_and_release_upstream_are_stable_wire_contracts() {
519        let route = RouteBinding {
520            app_id: "app".into(),
521            environment: "prod".into(),
522            release_id: "new".into(),
523            upstream: "https://provider/new".into(),
524            fallback_upstream: Some("https://provider/old".into()),
525            traffic_percent: 25,
526            healthy: true,
527            version: 2,
528            updated_at: "now".into(),
529        };
530        let value = serde_json::to_value(&route).unwrap();
531        assert_eq!(value["fallbackUpstream"], "https://provider/old");
532        assert_eq!(value["trafficPercent"], 25);
533        let decoded: RouteBinding = serde_json::from_value(value).unwrap();
534        assert_eq!(decoded, route);
535
536        let legacy: RouteBinding = serde_json::from_value(serde_json::json!({
537            "appId":"app","environment":"prod","releaseId":"old",
538            "upstream":"https://provider/old","healthy":true,"version":1,"updatedAt":"now"
539        }))
540        .unwrap();
541        assert_eq!(legacy.traffic_percent, 100);
542        assert!(legacy.fallback_upstream.is_none());
543    }
544}