1use std::{collections::BTreeSet, fmt, future::Future, pin::Pin};
2
3use schemars::JsonSchema;
4use semver::Version;
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 artifact::ArtifactManifest,
9 cancellation::CancellationToken,
10 error::{RemoteBuildError, RemoteBuildResult},
11 protocol::{
12 CURRENT_PROTOCOL_VERSION, CleanupConfirmation, IosArtifactType, IosDeviceBuildRequest,
13 JobState, ProtocolPath, ProtocolPathSemantics, ProtocolVersion, RemoteBuildEvent,
14 validate_identifier,
15 },
16 signing::SigningMode,
17 source::SourceMode,
18};
19
20pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = RemoteBuildResult<T>> + Send + 'a>>;
22
23#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
25#[serde(rename_all = "snake_case", tag = "feature", content = "value")]
26pub enum ProviderFeature {
27 SourceMode(SourceMode),
29 IosDeviceBuild,
31 IosSimulatorBuild,
33 SigningMode(SigningMode),
35 PersonalTeam,
37 LiveEvents,
39 LiveLogs,
41 Cancellation,
43 ArtifactType(IosArtifactType),
45 Cache,
47 ArtifactListing,
49 ArtifactDownload,
51 Cleanup,
53 PhysicalDeviceAccess,
55}
56
57impl fmt::Display for ProviderFeature {
58 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 Self::SourceMode(mode) => write!(formatter, "source mode `{mode:?}`"),
61 Self::IosDeviceBuild => formatter.write_str("physical-iPhone builds"),
62 Self::IosSimulatorBuild => formatter.write_str("iOS Simulator builds"),
63 Self::SigningMode(mode) => write!(formatter, "signing mode `{mode:?}`"),
64 Self::PersonalTeam => formatter.write_str("Apple Personal Team signing"),
65 Self::LiveEvents => formatter.write_str("live build events"),
66 Self::LiveLogs => formatter.write_str("live logs"),
67 Self::Cancellation => formatter.write_str("job cancellation"),
68 Self::ArtifactType(kind) => write!(formatter, "artifact type `{kind}`"),
69 Self::Cache => formatter.write_str("provider build cache"),
70 Self::ArtifactListing => formatter.write_str("artifact listing"),
71 Self::ArtifactDownload => formatter.write_str("artifact download"),
72 Self::Cleanup => formatter.write_str("remote cleanup"),
73 Self::PhysicalDeviceAccess => formatter.write_str("physical-device access"),
74 }
75 }
76}
77
78#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
80#[allow(clippy::struct_excessive_bools)]
81pub struct ProviderCapabilities {
82 pub source_modes: BTreeSet<SourceMode>,
84 pub ios_device_build: bool,
86 pub ios_simulator_build: bool,
88 pub signing_modes: BTreeSet<SigningMode>,
90 pub personal_team: bool,
92 pub live_events: bool,
94 pub live_logs: bool,
96 pub cancellation: bool,
98 pub artifact_types: BTreeSet<IosArtifactType>,
100 pub cache: bool,
102 pub max_source_bytes: Option<u64>,
104 pub retention_seconds: Option<u64>,
106 pub artifact_listing: bool,
108 pub artifact_download: bool,
110 pub cleanup: bool,
112 pub physical_device_access: bool,
114}
115
116impl ProviderCapabilities {
117 pub fn supports(&self, feature: &ProviderFeature) -> bool {
119 match feature {
120 ProviderFeature::SourceMode(mode) => self.source_modes.contains(mode),
121 ProviderFeature::IosDeviceBuild => self.ios_device_build,
122 ProviderFeature::IosSimulatorBuild => self.ios_simulator_build,
123 ProviderFeature::SigningMode(mode) => self.signing_modes.contains(mode),
124 ProviderFeature::PersonalTeam => self.personal_team,
125 ProviderFeature::LiveEvents => self.live_events,
126 ProviderFeature::LiveLogs => self.live_logs,
127 ProviderFeature::Cancellation => self.cancellation,
128 ProviderFeature::ArtifactType(kind) => self.artifact_types.contains(kind),
129 ProviderFeature::Cache => self.cache,
130 ProviderFeature::ArtifactListing => self.artifact_listing,
131 ProviderFeature::ArtifactDownload => self.artifact_download,
132 ProviderFeature::Cleanup => self.cleanup,
133 ProviderFeature::PhysicalDeviceAccess => self.physical_device_access,
134 }
135 }
136
137 pub fn require(
143 &self,
144 provider: impl Into<String>,
145 feature: ProviderFeature,
146 ) -> RemoteBuildResult<()> {
147 if self.supports(&feature) {
148 Ok(())
149 } else {
150 Err(RemoteBuildError::UnsupportedCapability {
151 provider: provider.into(),
152 feature,
153 })
154 }
155 }
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
160pub struct HandshakeRequest {
161 pub protocol_version: ProtocolVersion,
163 pub client_version: Version,
165 pub required_features: Vec<ProviderFeature>,
167}
168
169impl HandshakeRequest {
170 pub fn validate(&self) -> RemoteBuildResult<()> {
176 CURRENT_PROTOCOL_VERSION
177 .negotiate(self.protocol_version)
178 .map(|_| ())
179 }
180}
181
182#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
184pub struct HandshakeResponse {
185 pub protocol_version: ProtocolVersion,
187 pub worker_version: Version,
189 pub provider: String,
191 pub worker_id: String,
193 pub capabilities: ProviderCapabilities,
195}
196
197impl HandshakeResponse {
198 pub fn negotiate(
204 request: &HandshakeRequest,
205 provider: impl Into<String>,
206 worker_id: impl Into<String>,
207 worker_version: Version,
208 capabilities: ProviderCapabilities,
209 ) -> RemoteBuildResult<Self> {
210 let protocol_version = CURRENT_PROTOCOL_VERSION.negotiate(request.protocol_version)?;
211 let response = Self {
212 protocol_version,
213 worker_version,
214 provider: provider.into(),
215 worker_id: worker_id.into(),
216 capabilities,
217 };
218 response.validate_for(request)?;
219 Ok(response)
220 }
221
222 pub fn validate_for(&self, request: &HandshakeRequest) -> RemoteBuildResult<()> {
228 request.validate()?;
229 request
230 .protocol_version
231 .negotiate(self.protocol_version)
232 .map(|_| ())?;
233 validate_identifier("provider", &self.provider)?;
234 validate_identifier("worker_id", &self.worker_id)?;
235 for feature in &request.required_features {
236 self.capabilities
237 .require(self.provider.clone(), feature.clone())?;
238 }
239 Ok(())
240 }
241}
242
243#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
245pub struct ProviderDoctorRequest {
246 pub protocol_version: ProtocolVersion,
248 pub operation_id: String,
250 pub require_signing: bool,
252}
253
254#[derive(
256 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
257)]
258#[serde(rename_all = "snake_case")]
259pub enum ProviderCheckStatus {
260 Ready,
262 Warning,
264 Error,
266}
267
268#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
270pub struct ProviderCheck {
271 pub code: String,
273 pub status: ProviderCheckStatus,
275 pub message: String,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub help: Option<String>,
280}
281
282#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
284pub struct ProviderDoctorReport {
285 pub protocol_version: ProtocolVersion,
287 pub provider: String,
289 pub ready: bool,
291 pub checks: Vec<ProviderCheck>,
293 pub capabilities: ProviderCapabilities,
295}
296
297#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
299pub struct JobHandle {
300 pub job_id: String,
302 pub state: JobState,
304 pub created_at_ms: u64,
306}
307
308#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
310pub struct EventRequest {
311 pub job_id: String,
313 pub after_sequence: Option<u64>,
315 pub limit: u32,
317}
318
319impl EventRequest {
320 pub fn validate(&self) -> RemoteBuildResult<()> {
326 validate_identifier("job_id", &self.job_id)?;
327 if !(1..=1000).contains(&self.limit) {
328 return Err(RemoteBuildError::InvalidEventPayload {
329 event: "event_request",
330 reason: "limit must be between 1 and 1000",
331 });
332 }
333 Ok(())
334 }
335}
336
337#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
339pub struct EventPage {
340 pub job_id: String,
342 pub state: JobState,
344 pub events: Vec<RemoteBuildEvent>,
346 pub next_sequence: Option<u64>,
348 pub complete: bool,
350}
351
352#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
354pub struct CancellationRequest {
355 pub job_id: String,
357 pub reason: String,
359}
360
361#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
363pub struct CancellationAck {
364 pub job_id: String,
366 pub accepted: bool,
368 pub state: JobState,
370}
371
372#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
374pub struct ArtifactListRequest {
375 pub job_id: String,
377}
378
379#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
381pub struct ArtifactDownloadRequest {
382 pub job_id: String,
384 pub artifact_id: String,
386 pub destination: ProtocolPath,
388}
389
390impl ArtifactDownloadRequest {
391 pub fn validate(&self) -> RemoteBuildResult<()> {
397 validate_identifier("job_id", &self.job_id)?;
398 validate_identifier("artifact_id", &self.artifact_id)?;
399 self.destination.validate()?;
400 if self.destination.semantics != ProtocolPathSemantics::ClientAbsolute {
401 return Err(RemoteBuildError::InvalidEventPayload {
402 event: "artifact_download_request",
403 reason: "destination must be client_absolute",
404 });
405 }
406 Ok(())
407 }
408}
409
410#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
412pub struct ArtifactDownloadResult {
413 pub manifest: ArtifactManifest,
415 pub local_path: ProtocolPath,
417 pub local_file_identity: String,
419}
420
421#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
423pub struct CleanupRequest {
424 pub job_id: String,
426 pub remove_artifacts: bool,
428}
429
430pub trait BuildProvider: Send + Sync {
435 fn id(&self) -> &str;
437
438 fn capabilities(&self) -> &ProviderCapabilities;
440
441 fn handshake(
443 &self,
444 request: HandshakeRequest,
445 cancellation: CancellationToken,
446 ) -> ProviderFuture<'_, HandshakeResponse>;
447
448 fn doctor(
450 &self,
451 request: ProviderDoctorRequest,
452 cancellation: CancellationToken,
453 ) -> ProviderFuture<'_, ProviderDoctorReport>;
454
455 fn submit(
457 &self,
458 request: IosDeviceBuildRequest,
459 cancellation: CancellationToken,
460 ) -> ProviderFuture<'_, JobHandle>;
461
462 fn events(
464 &self,
465 request: EventRequest,
466 cancellation: CancellationToken,
467 ) -> ProviderFuture<'_, EventPage>;
468
469 fn cancel(
471 &self,
472 _request: CancellationRequest,
473 cancellation: CancellationToken,
474 ) -> ProviderFuture<'_, CancellationAck> {
475 let result = cancellation.check().and_then(|()| {
476 self.capabilities()
477 .require(self.id().to_owned(), ProviderFeature::Cancellation)
478 .and_then(|()| {
479 Err(RemoteBuildError::UnsupportedCapability {
480 provider: self.id().to_owned(),
481 feature: ProviderFeature::Cancellation,
482 })
483 })
484 });
485 Box::pin(async move { result })
486 }
487
488 fn list_artifacts(
490 &self,
491 _request: ArtifactListRequest,
492 cancellation: CancellationToken,
493 ) -> ProviderFuture<'_, Vec<ArtifactManifest>> {
494 let result = cancellation.check().and_then(|()| {
495 Err(RemoteBuildError::UnsupportedCapability {
496 provider: self.id().to_owned(),
497 feature: ProviderFeature::ArtifactListing,
498 })
499 });
500 Box::pin(async move { result })
501 }
502
503 fn download_artifact(
505 &self,
506 _request: ArtifactDownloadRequest,
507 cancellation: CancellationToken,
508 ) -> ProviderFuture<'_, ArtifactDownloadResult> {
509 let result = cancellation.check().and_then(|()| {
510 Err(RemoteBuildError::UnsupportedCapability {
511 provider: self.id().to_owned(),
512 feature: ProviderFeature::ArtifactDownload,
513 })
514 });
515 Box::pin(async move { result })
516 }
517
518 fn cleanup(
520 &self,
521 _request: CleanupRequest,
522 cancellation: CancellationToken,
523 ) -> ProviderFuture<'_, CleanupConfirmation> {
524 let result = cancellation.check().and_then(|()| {
525 Err(RemoteBuildError::UnsupportedCapability {
526 provider: self.id().to_owned(),
527 feature: ProviderFeature::Cleanup,
528 })
529 });
530 Box::pin(async move { result })
531 }
532}