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