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