Skip to main content

rustferry_remote/
provider.rs

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
20/// Boxed, runtime-neutral future returned by provider trait methods.
21pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = RemoteBuildResult<T>> + Send + 'a>>;
22
23/// One typed feature that a caller may require from a provider.
24#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
25#[serde(rename_all = "snake_case", tag = "feature", content = "value")]
26pub enum ProviderFeature {
27    /// Exact source transport.
28    SourceMode(SourceMode),
29    /// Physical-iPhone compilation and packaging.
30    IosDeviceBuild,
31    /// iOS Simulator compilation.
32    IosSimulatorBuild,
33    /// Exact signing strategy.
34    SigningMode(SigningMode),
35    /// Apple Personal Team provisioning.
36    PersonalTeam,
37    /// Incremental job-event retrieval.
38    LiveEvents,
39    /// Live application or build-log events.
40    LiveLogs,
41    /// Remote job cancellation.
42    Cancellation,
43    /// Exact artifact format.
44    ArtifactType(IosArtifactType),
45    /// Provider-side build cache.
46    Cache,
47    /// Artifact manifest enumeration.
48    ArtifactListing,
49    /// Verified artifact download.
50    ArtifactDownload,
51    /// Explicit remote-workspace cleanup.
52    Cleanup,
53    /// Access to a physical iPhone attached to the worker.
54    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/// Deterministic capability inventory returned during handshake and doctor.
79#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
80#[allow(clippy::struct_excessive_bools)]
81pub struct ProviderCapabilities {
82    /// Supported exact source modes.
83    pub source_modes: BTreeSet<SourceMode>,
84    /// Whether physical-iPhone builds are implemented.
85    pub ios_device_build: bool,
86    /// Whether iOS Simulator builds are implemented.
87    pub ios_simulator_build: bool,
88    /// Supported signing strategies.
89    pub signing_modes: BTreeSet<SigningMode>,
90    /// Whether Apple Personal Team provisioning is supported.
91    pub personal_team: bool,
92    /// Whether callers can retrieve incremental events.
93    pub live_events: bool,
94    /// Whether the provider exposes live log events.
95    pub live_logs: bool,
96    /// Whether the provider can cancel an active job.
97    pub cancellation: bool,
98    /// Artifact types the provider can return.
99    pub artifact_types: BTreeSet<IosArtifactType>,
100    /// Whether provider-side compilation caches are enabled.
101    pub cache: bool,
102    /// Maximum accepted source bytes, or no provider-advertised limit.
103    pub max_source_bytes: Option<u64>,
104    /// Artifact retention window in seconds, or provider-defined retention.
105    pub retention_seconds: Option<u64>,
106    /// Whether artifact manifests can be enumerated after a job.
107    pub artifact_listing: bool,
108    /// Whether verified artifact download is implemented.
109    pub artifact_download: bool,
110    /// Whether explicit job cleanup is implemented.
111    pub cleanup: bool,
112    /// Whether a worker may access an attached physical device.
113    pub physical_device_access: bool,
114}
115
116impl ProviderCapabilities {
117    /// Whether this inventory satisfies one exact feature.
118    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    /// Return a typed error when a provider lacks one required feature.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`RemoteBuildError::UnsupportedCapability`] when the feature is absent.
142    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/// Versioned client handshake request.
159#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
160pub struct HandshakeRequest {
161    /// Highest protocol version supported by the client.
162    pub protocol_version: ProtocolVersion,
163    /// `RustFerry` client semantic version.
164    pub client_version: Version,
165    /// Features required before the client may submit a job.
166    pub required_features: Vec<ProviderFeature>,
167}
168
169impl HandshakeRequest {
170    /// Validate protocol compatibility with this client library.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`RemoteBuildError::IncompatibleProtocolVersion`] for another major version.
175    pub fn validate(&self) -> RemoteBuildResult<()> {
176        CURRENT_PROTOCOL_VERSION
177            .negotiate(self.protocol_version)
178            .map(|_| ())
179    }
180}
181
182/// Versioned worker handshake response.
183#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
184pub struct HandshakeResponse {
185    /// Negotiated protocol version.
186    pub protocol_version: ProtocolVersion,
187    /// `RustFerry` worker semantic version.
188    pub worker_version: Version,
189    /// Stable provider identifier.
190    pub provider: String,
191    /// Stable, non-secret worker identifier.
192    pub worker_id: String,
193    /// Capabilities active for this worker and provider configuration.
194    pub capabilities: ProviderCapabilities,
195}
196
197impl HandshakeResponse {
198    /// Build a response using the highest compatible protocol version.
199    ///
200    /// # Errors
201    ///
202    /// Returns a typed error for incompatible versions, unsafe IDs, or missing capabilities.
203    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    /// Validate identity, version, and required capabilities against a request.
223    ///
224    /// # Errors
225    ///
226    /// Returns a typed error for incompatible versions, unsafe IDs, or missing capabilities.
227    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/// Versioned provider readiness request.
244#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
245pub struct ProviderDoctorRequest {
246    /// Negotiated protocol version.
247    pub protocol_version: ProtocolVersion,
248    /// Caller-owned operation identifier.
249    pub operation_id: String,
250    /// Whether signing prerequisites must be checked.
251    pub require_signing: bool,
252}
253
254/// Result status of one provider doctor check.
255#[derive(
256    Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
257)]
258#[serde(rename_all = "snake_case")]
259pub enum ProviderCheckStatus {
260    /// Check passed.
261    Ready,
262    /// Optional feature is degraded.
263    Warning,
264    /// Required feature is unavailable.
265    Error,
266}
267
268/// One safe provider doctor check.
269#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
270pub struct ProviderCheck {
271    /// Stable check identifier.
272    pub code: String,
273    /// Check result.
274    pub status: ProviderCheckStatus,
275    /// Sanitized human summary.
276    pub message: String,
277    /// Optional concrete remediation.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub help: Option<String>,
280}
281
282/// Provider readiness and capability report.
283#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
284pub struct ProviderDoctorReport {
285    /// Negotiated protocol version.
286    pub protocol_version: ProtocolVersion,
287    /// Stable provider identifier.
288    pub provider: String,
289    /// Whether every required check passed.
290    pub ready: bool,
291    /// Deterministically ordered checks.
292    pub checks: Vec<ProviderCheck>,
293    /// Capabilities available under the checked configuration.
294    pub capabilities: ProviderCapabilities,
295}
296
297/// Provider response after accepting a build request.
298#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
299pub struct JobHandle {
300    /// Stable provider job identifier.
301    pub job_id: String,
302    /// Initial provider state.
303    pub state: JobState,
304    /// Milliseconds since Unix epoch in UTC.
305    pub created_at_ms: u64,
306}
307
308/// Request for an ordered page of job events.
309#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
310pub struct EventRequest {
311    /// Stable provider job identifier.
312    pub job_id: String,
313    /// Last sequence already consumed, or none for the first page.
314    pub after_sequence: Option<u64>,
315    /// Maximum events to return; must be between 1 and 1000.
316    pub limit: u32,
317}
318
319impl EventRequest {
320    /// Validate identifier and page bound.
321    ///
322    /// # Errors
323    ///
324    /// Returns a typed validation error for an invalid job ID or page limit.
325    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/// Ordered provider event page.
338#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
339pub struct EventPage {
340    /// Stable provider job identifier.
341    pub job_id: String,
342    /// State observed with this page.
343    pub state: JobState,
344    /// Strictly ordered events.
345    pub events: Vec<RemoteBuildEvent>,
346    /// Sequence to use for the next page, when more data may arrive.
347    pub next_sequence: Option<u64>,
348    /// Whether the provider has no further events for this job.
349    pub complete: bool,
350}
351
352/// Request to cancel an active provider job.
353#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
354pub struct CancellationRequest {
355    /// Stable provider job identifier.
356    pub job_id: String,
357    /// Safe, stable cancellation reason.
358    pub reason: String,
359}
360
361/// Provider acknowledgement of a cancellation request.
362#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
363pub struct CancellationAck {
364    /// Stable provider job identifier.
365    pub job_id: String,
366    /// Whether this request initiated cancellation.
367    pub accepted: bool,
368    /// State observed after handling the request.
369    pub state: JobState,
370}
371
372/// Request to enumerate artifact manifests for one job.
373#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
374pub struct ArtifactListRequest {
375    /// Stable provider job identifier.
376    pub job_id: String,
377}
378
379/// Request to download one artifact to an explicit client path.
380#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
381pub struct ArtifactDownloadRequest {
382    /// Stable provider job identifier.
383    pub job_id: String,
384    /// Stable artifact identifier from its manifest.
385    pub artifact_id: String,
386    /// Absolute client destination. Providers must not reinterpret it as a worker path.
387    pub destination: ProtocolPath,
388}
389
390impl ArtifactDownloadRequest {
391    /// Validate identifiers and destination semantics.
392    ///
393    /// # Errors
394    ///
395    /// Returns a typed validation error for invalid IDs or a non-client destination.
396    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/// Verified result of one provider artifact download.
411#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
412pub struct ArtifactDownloadResult {
413    /// Verified manifest supplied by the worker.
414    pub manifest: ArtifactManifest,
415    /// Absolute client path containing verified bytes.
416    pub local_path: ProtocolPath,
417    /// Canonical, secret-free identity captured from the retained published file object.
418    pub local_file_identity: String,
419}
420
421/// Request to remove provider-owned material for one job.
422#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
423pub struct CleanupRequest {
424    /// Stable provider job identifier.
425    pub job_id: String,
426    /// Whether retained artifact bytes should also be removed.
427    pub remove_artifacts: bool,
428}
429
430/// Object-safe asynchronous provider interface without a required executor or runtime.
431///
432/// Implementations return boxed futures and must not report success until the corresponding
433/// provider operation is confirmed. Optional methods return `unsupported_capability` by default.
434pub trait BuildProvider: Send + Sync {
435    /// Stable provider identifier used in events and errors.
436    fn id(&self) -> &str;
437
438    /// Capabilities active for this provider configuration.
439    fn capabilities(&self) -> &ProviderCapabilities;
440
441    /// Negotiate protocol and worker compatibility.
442    fn handshake(
443        &self,
444        request: HandshakeRequest,
445        cancellation: CancellationToken,
446    ) -> ProviderFuture<'_, HandshakeResponse>;
447
448    /// Inspect provider, worker, and optional signing readiness.
449    fn doctor(
450        &self,
451        request: ProviderDoctorRequest,
452        cancellation: CancellationToken,
453    ) -> ProviderFuture<'_, ProviderDoctorReport>;
454
455    /// Submit one declarative physical-iPhone build.
456    fn submit(
457        &self,
458        request: IosDeviceBuildRequest,
459        cancellation: CancellationToken,
460    ) -> ProviderFuture<'_, JobHandle>;
461
462    /// Retrieve a bounded ordered page of events.
463    fn events(
464        &self,
465        request: EventRequest,
466        cancellation: CancellationToken,
467    ) -> ProviderFuture<'_, EventPage>;
468
469    /// Request cancellation of an active job.
470    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    /// List verified artifact manifests for a job.
489    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    /// Download and independently verify one artifact.
504    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    /// Delete provider-owned workspace, signing material, and optional retained artifacts.
519    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}