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