1use std::{collections::BTreeSet, io::Cursor};
2
3use rustferry_remote::{
4 ArtifactDownloadRequest, ArtifactDownloadResult, ArtifactListRequest, ArtifactManifest,
5 BuildProvider, CancellationToken, EventPage, EventRequest, HandshakeRequest, HandshakeResponse,
6 IosArtifactType, IosDeviceBuildRequest, JobHandle, ProviderCapabilities, ProviderCheck,
7 ProviderCheckStatus, ProviderDoctorReport, ProviderDoctorRequest, ProviderFeature,
8 ProviderFuture, RemoteBuildError, RemoteBuildResult, SigningMode, SourceMode,
9 WorkerStdioCodecError, WorkerStdioRequest, WorkerStdioRequestEnvelope, WorkerStdioResponse,
10 WorkerStdioResponseEnvelope, decode_worker_stdio_response, encode_worker_stdio_request,
11};
12
13use crate::{
14 config::{SshConfigError, SshEndpointConfig},
15 transport::{
16 MAX_SSH_RESPONSE_BYTES, ProcessSshRunner, SshRunner, SshTransportError,
17 build_ssh_invocation,
18 },
19};
20
21pub const SSH_PROVIDER_ID: &str = "ssh-macos";
23
24#[derive(Debug)]
26pub struct SshBuildProvider<R = ProcessSshRunner> {
27 config: SshEndpointConfig,
28 runner: R,
29 generic_capabilities: ProviderCapabilities,
30}
31
32impl SshBuildProvider<ProcessSshRunner> {
33 #[must_use]
35 pub fn with_process_runner(config: SshEndpointConfig) -> Self {
36 Self::new(config, ProcessSshRunner)
37 }
38}
39
40impl<R> SshBuildProvider<R> {
41 #[must_use]
43 pub fn new(config: SshEndpointConfig, runner: R) -> Self {
44 Self {
45 config,
46 runner,
47 generic_capabilities: ProviderCapabilities::default(),
48 }
49 }
50
51 pub fn config(&self) -> &SshEndpointConfig {
53 &self.config
54 }
55}
56
57impl<R> SshBuildProvider<R>
58where
59 R: SshRunner,
60{
61 fn exchange(
62 &self,
63 request: WorkerStdioRequest,
64 cancellation: &CancellationToken,
65 ) -> RemoteBuildResult<WorkerStdioResponseEnvelope> {
66 cancellation.check()?;
67 let invocation = build_ssh_invocation(&self.config).map_err(map_config_error)?;
68 let mut encoded = Vec::new();
69 encode_worker_stdio_request(&mut encoded, &WorkerStdioRequestEnvelope::new(request))
70 .map_err(map_request_codec_error)?;
71 let response = self
72 .runner
73 .exchange(&invocation, &encoded, cancellation)
74 .map_err(map_transport_error)?;
75 cancellation.check()?;
76 decode_worker_stdio_response(&mut Cursor::new(response)).map_err(map_response_codec_error)
77 }
78}
79
80impl<R> BuildProvider for SshBuildProvider<R>
81where
82 R: SshRunner,
83{
84 fn id(&self) -> &str {
85 SSH_PROVIDER_ID
86 }
87
88 fn capabilities(&self) -> &ProviderCapabilities {
89 &self.generic_capabilities
90 }
91
92 fn handshake(
93 &self,
94 request: HandshakeRequest,
95 cancellation: CancellationToken,
96 ) -> ProviderFuture<'_, HandshakeResponse> {
97 let result = (|| {
98 cancellation.check()?;
99 request.validate()?;
100 let snapshot_capabilities = snapshot_session_capabilities();
101 for feature in &request.required_features {
102 snapshot_capabilities.require(SSH_PROVIDER_ID, feature.clone())?;
103 }
104 let envelope = self.exchange(
105 WorkerStdioRequest::Handshake(request.clone()),
106 &cancellation,
107 )?;
108 match envelope.response {
109 WorkerStdioResponse::Handshake(mut response) => {
110 if response.provider != SSH_PROVIDER_ID {
111 return Err(provider_failure(
112 "worker_identity_mismatch",
113 "SSH worker returned an unexpected provider identity",
114 false,
115 ));
116 }
117 response.capabilities = intersect_snapshot_capabilities(&response.capabilities);
118 response.validate_for(&request)?;
119 Ok(response)
120 }
121 WorkerStdioResponse::Error(error) => Err(RemoteBuildError::ProviderFailure {
122 provider: SSH_PROVIDER_ID.to_owned(),
123 code: error.code,
124 message: error.message,
125 retryable: error.retryable,
126 }),
127 WorkerStdioResponse::ProviderDoctor(_) => Err(provider_failure(
128 "unexpected_worker_response",
129 "SSH worker returned a response for another operation",
130 false,
131 )),
132 }
133 })();
134 Box::pin(async move { result })
135 }
136
137 fn doctor(
138 &self,
139 request: ProviderDoctorRequest,
140 cancellation: CancellationToken,
141 ) -> ProviderFuture<'_, ProviderDoctorReport> {
142 let result = (|| {
143 cancellation.check()?;
144 let envelope = self.exchange(
145 WorkerStdioRequest::ProviderDoctor(request.clone()),
146 &cancellation,
147 )?;
148 match envelope.response {
149 WorkerStdioResponse::ProviderDoctor(mut report) => {
150 if report.provider != SSH_PROVIDER_ID {
151 return Err(provider_failure(
152 "worker_identity_mismatch",
153 "SSH worker returned an unexpected provider identity",
154 false,
155 ));
156 }
157 report.capabilities = intersect_snapshot_capabilities(&report.capabilities);
158 let complete = snapshot_required_features()
159 .iter()
160 .all(|feature| report.capabilities.supports(feature))
161 && report.capabilities.retention_seconds == Some(0);
162 if !complete {
163 report.checks.push(ProviderCheck {
164 code: "ssh.snapshot.unsupported".to_owned(),
165 status: ProviderCheckStatus::Error,
166 message: "SSH worker does not implement the complete unsigned snapshot session"
167 .to_owned(),
168 help: Some(
169 "Upgrade cargo-ferry and ferry-worker-macos together, then rerun doctor"
170 .to_owned(),
171 ),
172 });
173 }
174 report.ready = report.ready
175 && complete
176 && report
177 .checks
178 .iter()
179 .all(|check| check.status != ProviderCheckStatus::Error);
180 Ok(report)
181 }
182 WorkerStdioResponse::Error(error) => Err(RemoteBuildError::ProviderFailure {
183 provider: SSH_PROVIDER_ID.to_owned(),
184 code: error.code,
185 message: error.message,
186 retryable: error.retryable,
187 }),
188 WorkerStdioResponse::Handshake(_) => Err(provider_failure(
189 "unexpected_worker_response",
190 "SSH worker returned a response for another operation",
191 false,
192 )),
193 }
194 })();
195 Box::pin(async move { result })
196 }
197
198 fn submit(
199 &self,
200 _request: IosDeviceBuildRequest,
201 cancellation: CancellationToken,
202 ) -> ProviderFuture<'_, JobHandle> {
203 unsupported(
204 &cancellation,
205 ProviderFeature::IosDeviceBuild,
206 SSH_PROVIDER_ID,
207 )
208 }
209
210 fn events(
211 &self,
212 _request: EventRequest,
213 cancellation: CancellationToken,
214 ) -> ProviderFuture<'_, EventPage> {
215 unsupported(&cancellation, ProviderFeature::LiveEvents, SSH_PROVIDER_ID)
216 }
217
218 fn list_artifacts(
219 &self,
220 _request: ArtifactListRequest,
221 cancellation: CancellationToken,
222 ) -> ProviderFuture<'_, Vec<ArtifactManifest>> {
223 unsupported(
224 &cancellation,
225 ProviderFeature::ArtifactListing,
226 SSH_PROVIDER_ID,
227 )
228 }
229
230 fn download_artifact(
231 &self,
232 _request: ArtifactDownloadRequest,
233 cancellation: CancellationToken,
234 ) -> ProviderFuture<'_, ArtifactDownloadResult> {
235 unsupported(
236 &cancellation,
237 ProviderFeature::ArtifactDownload,
238 SSH_PROVIDER_ID,
239 )
240 }
241}
242
243#[must_use]
245pub fn snapshot_required_features() -> Vec<ProviderFeature> {
246 vec![
247 ProviderFeature::SourceMode(SourceMode::Snapshot),
248 ProviderFeature::IosDeviceBuild,
249 ProviderFeature::SigningMode(SigningMode::UnsignedCompileOnly),
250 ProviderFeature::LiveEvents,
251 ProviderFeature::Cancellation,
252 ProviderFeature::ArtifactType(IosArtifactType::Xcarchive),
253 ProviderFeature::ArtifactDownload,
254 ProviderFeature::Cleanup,
255 ]
256}
257
258fn snapshot_session_capabilities() -> ProviderCapabilities {
259 ProviderCapabilities {
260 source_modes: BTreeSet::from([SourceMode::Snapshot]),
261 ios_device_build: true,
262 ios_simulator_build: false,
263 signing_modes: BTreeSet::from([SigningMode::UnsignedCompileOnly]),
264 personal_team: false,
265 live_events: true,
266 live_logs: false,
267 cancellation: true,
268 artifact_types: BTreeSet::from([IosArtifactType::Xcarchive]),
269 cache: false,
270 max_source_bytes: Some(640 * 1024 * 1024),
271 retention_seconds: Some(0),
272 artifact_listing: false,
273 artifact_download: true,
274 cleanup: true,
275 physical_device_access: false,
276 }
277}
278
279fn intersect_snapshot_capabilities(worker: &ProviderCapabilities) -> ProviderCapabilities {
280 let local = snapshot_session_capabilities();
281 ProviderCapabilities {
282 source_modes: local
283 .source_modes
284 .intersection(&worker.source_modes)
285 .copied()
286 .collect(),
287 ios_device_build: local.ios_device_build && worker.ios_device_build,
288 ios_simulator_build: false,
289 signing_modes: local
290 .signing_modes
291 .intersection(&worker.signing_modes)
292 .copied()
293 .collect(),
294 personal_team: false,
295 live_events: local.live_events && worker.live_events,
296 live_logs: false,
297 cancellation: local.cancellation && worker.cancellation,
298 artifact_types: local
299 .artifact_types
300 .intersection(&worker.artifact_types)
301 .copied()
302 .collect(),
303 cache: false,
304 max_source_bytes: Some(
305 worker
306 .max_source_bytes
307 .unwrap_or(u64::MAX)
308 .min(local.max_source_bytes.expect("local source limit")),
309 ),
310 retention_seconds: (worker.retention_seconds == Some(0)).then_some(0),
311 artifact_listing: false,
312 artifact_download: local.artifact_download && worker.artifact_download,
313 cleanup: local.cleanup && worker.cleanup,
314 physical_device_access: false,
315 }
316}
317
318fn unsupported<T>(
319 cancellation: &CancellationToken,
320 feature: ProviderFeature,
321 provider: &'static str,
322) -> ProviderFuture<'static, T>
323where
324 T: Send + 'static,
325{
326 let result = cancellation.check().and_then(|()| {
327 Err(RemoteBuildError::UnsupportedCapability {
328 provider: provider.to_owned(),
329 feature,
330 })
331 });
332 Box::pin(async move { result })
333}
334
335fn map_config_error(_error: SshConfigError) -> RemoteBuildError {
336 provider_failure(
337 "ssh_configuration_invalid",
338 "SSH endpoint trust or identity configuration is no longer valid",
339 false,
340 )
341}
342
343fn map_transport_error(error: SshTransportError) -> RemoteBuildError {
344 match error {
345 SshTransportError::Cancelled => RemoteBuildError::Cancelled,
346 SshTransportError::ResponseTooLarge { maximum } => RemoteBuildError::EventTooLarge {
347 bytes: maximum.saturating_add(1),
348 maximum,
349 },
350 SshTransportError::RequestTooLarge { .. } => RemoteBuildError::Serialization {
351 message: "worker request exceeds the SSH transport limit".to_owned(),
352 },
353 SshTransportError::SpawnFailed => provider_failure(
354 "ssh_client_unavailable",
355 "OpenSSH client could not be started",
356 false,
357 ),
358 SshTransportError::IoFailed => provider_failure(
359 "ssh_transport_io_failed",
360 "OpenSSH protocol pipe failed",
361 true,
362 ),
363 SshTransportError::TimedOut => provider_failure(
364 "ssh_transport_timeout",
365 "SSH worker operation timed out",
366 true,
367 ),
368 SshTransportError::IdentityFileChanged | SshTransportError::TrustSnapshotChanged => {
369 provider_failure(
370 "ssh_configuration_invalid",
371 "SSH endpoint trust or identity configuration is no longer valid",
372 false,
373 )
374 }
375 SshTransportError::ProcessFailed { .. } => provider_failure(
376 "ssh_process_failed",
377 "OpenSSH client exited unsuccessfully",
378 true,
379 ),
380 }
381}
382
383fn map_request_codec_error(error: WorkerStdioCodecError) -> RemoteBuildError {
384 RemoteBuildError::Serialization {
385 message: error.public_message().to_owned(),
386 }
387}
388
389fn map_response_codec_error(error: WorkerStdioCodecError) -> RemoteBuildError {
390 match error {
391 WorkerStdioCodecError::EmptyInput | WorkerStdioCodecError::TruncatedJson => {
392 RemoteBuildError::TruncatedEvent
393 }
394 WorkerStdioCodecError::ResponseTooLarge => RemoteBuildError::EventTooLarge {
395 bytes: MAX_SSH_RESPONSE_BYTES.saturating_add(1),
396 maximum: MAX_SSH_RESPONSE_BYTES,
397 },
398 WorkerStdioCodecError::IncompatibleProtocolVersion {
399 supported,
400 received,
401 } => RemoteBuildError::IncompatibleProtocolVersion {
402 supported,
403 received,
404 },
405 WorkerStdioCodecError::Io
406 | WorkerStdioCodecError::MalformedJson
407 | WorkerStdioCodecError::RequestTooLarge
408 | WorkerStdioCodecError::UnsupportedSchemaVersion { .. }
409 | WorkerStdioCodecError::InvalidRequest
410 | WorkerStdioCodecError::InvalidResponse => RemoteBuildError::MalformedEvent {
411 message: error.public_message().to_owned(),
412 },
413 }
414}
415
416fn provider_failure(
417 code: &'static str,
418 message: &'static str,
419 retryable: bool,
420) -> RemoteBuildError {
421 RemoteBuildError::ProviderFailure {
422 provider: SSH_PROVIDER_ID.to_owned(),
423 code: code.to_owned(),
424 message: message.to_owned(),
425 retryable,
426 }
427}