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