Skip to main content

greentic_ext_runtime/
runtime_deploy.rs

1//! `deployment` interface dispatch for deploy extensions (Mode B).
2//!
3//! Mirrors the export-walking pattern of the `targets` callers in
4//! [`crate::runtime`] but lives in its own module to keep `runtime.rs`
5//! under the workspace 500-line cap. First consumer: the designer's
6//! wizard deploy step driving `greentic.deploy-github`.
7
8use crate::error::RuntimeError;
9use crate::loaded::ExtensionId;
10use crate::runtime::ExtensionRuntime;
11use crate::types::{DeployExtensionError, DeployJob, DeployRequest, DeployStatus};
12
13use crate::host_bindings::deploy::exports::greentic::extension_deploy0_1_0::deployment::{
14    DeployJob as WitDeployJob, DeployRequest as WitDeployRequest, DeployStatus as WitDeployStatus,
15};
16use crate::host_bindings::deploy::greentic::extension_base0_1_0::types::ExtensionError as WitExtensionError;
17use crate::host_bindings::deploy_v02::exports::greentic::extension_deploy0_2_0::deployment::{
18    DeployJob as WitDeployJobV2, DeployRequest as WitDeployRequestV2,
19    DeployStatus as WitDeployStatusV2,
20};
21use crate::host_bindings::deploy_v02::greentic::extension_base0_2_0::types::ExtensionError as WitExtensionErrorV2;
22
23const BASE_IFACE: &str = "greentic:extension-deploy/deployment";
24
25impl ExtensionRuntime {
26    /// Start a deployment inside the extension. Returns the initial job.
27    ///
28    /// Calls `greentic:extension-deploy/deployment::deploy`, resolving the
29    /// interface newest-first across `@0.2.0`/`@0.1.0`. The matched version
30    /// selects the `extension-error` ABI (6-variant base at `@0.2.0`,
31    /// 4-variant base at `@0.1.0`). The call is synchronous and may take up
32    /// to ~2 minutes for network-bound extensions (artifact upload); run it
33    /// on a blocking thread.
34    pub fn deploy(&self, ext_id: &str, req: DeployRequest) -> Result<DeployJob, RuntimeError> {
35        let (mut store, instance) = self.deploy_instance(ext_id)?;
36        let (iface_idx, version) = resolve_deployment_iface(&mut store, &instance)?;
37        let func_idx = resolve_func(&mut store, &instance, &iface_idx, "deploy")?;
38        if version == "0.2.0" {
39            let func = instance
40                .get_typed_func::<(WitDeployRequestV2,), (Result<WitDeployJobV2, WitExtensionErrorV2>,)>(
41                    &mut store, &func_idx,
42                )
43                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
44            let wit_req = WitDeployRequestV2 {
45                target_id: req.target_id,
46                artifact_bytes: req.artifact_bytes,
47                credentials_json: req.credentials_json,
48                config_json: req.config_json,
49                deployment_name: req.deployment_name,
50            };
51            let (result,) = func
52                .call(&mut store, (wit_req,))
53                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
54            result
55                .map(job_to_host_v2)
56                .map_err(|e| RuntimeError::Deploy(err_to_host_v2(e)))
57        } else {
58            let func = instance
59                .get_typed_func::<(WitDeployRequest,), (Result<WitDeployJob, WitExtensionError>,)>(
60                    &mut store, &func_idx,
61                )
62                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
63            let wit_req = WitDeployRequest {
64                target_id: req.target_id,
65                artifact_bytes: req.artifact_bytes,
66                credentials_json: req.credentials_json,
67                config_json: req.config_json,
68                deployment_name: req.deployment_name,
69            };
70            let (result,) = func
71                .call(&mut store, (wit_req,))
72                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
73            result
74                .map(job_to_host)
75                .map_err(|e| RuntimeError::Deploy(err_to_host(e)))
76        }
77    }
78
79    /// Poll a previously started deployment job.
80    pub fn deploy_poll(&self, ext_id: &str, job_id: &str) -> Result<DeployJob, RuntimeError> {
81        let (mut store, instance) = self.deploy_instance(ext_id)?;
82        let (iface_idx, version) = resolve_deployment_iface(&mut store, &instance)?;
83        let func_idx = resolve_func(&mut store, &instance, &iface_idx, "poll")?;
84        if version == "0.2.0" {
85            let func = instance
86                .get_typed_func::<(String,), (Result<WitDeployJobV2, WitExtensionErrorV2>,)>(
87                    &mut store, &func_idx,
88                )
89                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
90            let (result,) = func
91                .call(&mut store, (job_id.to_string(),))
92                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
93            result
94                .map(job_to_host_v2)
95                .map_err(|e| RuntimeError::Deploy(err_to_host_v2(e)))
96        } else {
97            let func = instance
98                .get_typed_func::<(String,), (Result<WitDeployJob, WitExtensionError>,)>(
99                    &mut store, &func_idx,
100                )
101                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
102            let (result,) = func
103                .call(&mut store, (job_id.to_string(),))
104                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
105            result
106                .map(job_to_host)
107                .map_err(|e| RuntimeError::Deploy(err_to_host(e)))
108        }
109    }
110
111    /// Roll back a previously started deployment job.
112    pub fn deploy_rollback(&self, ext_id: &str, job_id: &str) -> Result<(), RuntimeError> {
113        let (mut store, instance) = self.deploy_instance(ext_id)?;
114        let (iface_idx, version) = resolve_deployment_iface(&mut store, &instance)?;
115        let func_idx = resolve_func(&mut store, &instance, &iface_idx, "rollback")?;
116        if version == "0.2.0" {
117            let func = instance
118                .get_typed_func::<(String,), (Result<(), WitExtensionErrorV2>,)>(
119                    &mut store, &func_idx,
120                )
121                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
122            let (result,) = func
123                .call(&mut store, (job_id.to_string(),))
124                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
125            result.map_err(|e| RuntimeError::Deploy(err_to_host_v2(e)))
126        } else {
127            let func = instance
128                .get_typed_func::<(String,), (Result<(), WitExtensionError>,)>(
129                    &mut store, &func_idx,
130                )
131                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
132            let (result,) = func
133                .call(&mut store, (job_id.to_string(),))
134                .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
135            result.map_err(|e| RuntimeError::Deploy(err_to_host(e)))
136        }
137    }
138
139    /// Resolve a loaded extension into a fresh store + instance.
140    ///
141    /// Deliberate departure from the sibling dispatch modules (which inline
142    /// these lines per method): three callers here made the duplication
143    /// worth factoring, at the cost of naming wasmtime types in a private
144    /// signature.
145    fn deploy_instance(
146        &self,
147        ext_id: &str,
148    ) -> Result<
149        (
150            wasmtime::Store<crate::host_state::HostState>,
151            wasmtime::component::Instance,
152        ),
153        RuntimeError,
154    > {
155        let loaded = self
156            .loaded()
157            .get(&ExtensionId(ext_id.to_string()))
158            .cloned()
159            .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
160        loaded
161            .build_store_and_instance(
162                self.engine(),
163                self.host_overrides().clone(),
164                &crate::host_ports::HostCallContext::default(),
165            )
166            .map_err(RuntimeError::Wasmtime)
167    }
168}
169
170/// Resolve the `deployment` interface newest-first across the deploy version
171/// table, returning the export index plus the matched bare version string so
172/// callers can pick the correct typed signature.
173fn resolve_deployment_iface(
174    store: &mut wasmtime::Store<crate::host_state::HostState>,
175    instance: &wasmtime::component::Instance,
176) -> Result<(wasmtime::component::ComponentExportIndex, &'static str), RuntimeError> {
177    let (iface_idx, _name, version) = crate::runtime::resolve_iface_versions(
178        store,
179        instance,
180        BASE_IFACE,
181        crate::runtime::DEPLOY_VERSIONS,
182    )?;
183    Ok((iface_idx, version))
184}
185
186fn resolve_func(
187    store: &mut wasmtime::Store<crate::host_state::HostState>,
188    instance: &wasmtime::component::Instance,
189    iface_idx: &wasmtime::component::ComponentExportIndex,
190    name: &str,
191) -> Result<wasmtime::component::ComponentExportIndex, RuntimeError> {
192    instance
193        .get_export_index(&mut *store, Some(iface_idx), name)
194        .ok_or_else(|| {
195            RuntimeError::Wasmtime(anyhow::anyhow!(
196                "interface '{BASE_IFACE}' does not export '{name}'"
197            ))
198        })
199}
200
201fn job_to_host(j: WitDeployJob) -> DeployJob {
202    DeployJob {
203        id: j.id,
204        status: match j.status {
205            WitDeployStatus::Pending => DeployStatus::Pending,
206            WitDeployStatus::Provisioning => DeployStatus::Provisioning,
207            WitDeployStatus::Configuring => DeployStatus::Configuring,
208            WitDeployStatus::Starting => DeployStatus::Starting,
209            WitDeployStatus::Running => DeployStatus::Running,
210            WitDeployStatus::Failed => DeployStatus::Failed,
211            WitDeployStatus::RolledBack => DeployStatus::RolledBack,
212        },
213        message: j.message,
214        endpoints: j.endpoints,
215    }
216}
217
218fn err_to_host(e: WitExtensionError) -> DeployExtensionError {
219    match e {
220        WitExtensionError::InvalidInput(m) => DeployExtensionError::InvalidInput(m),
221        WitExtensionError::MissingCapability(m) => DeployExtensionError::MissingCapability(m),
222        WitExtensionError::PermissionDenied(m) => DeployExtensionError::PermissionDenied(m),
223        WitExtensionError::Internal(m) => DeployExtensionError::Internal(m),
224    }
225}
226
227fn job_to_host_v2(j: WitDeployJobV2) -> DeployJob {
228    DeployJob {
229        id: j.id,
230        status: match j.status {
231            WitDeployStatusV2::Pending => DeployStatus::Pending,
232            WitDeployStatusV2::Provisioning => DeployStatus::Provisioning,
233            WitDeployStatusV2::Configuring => DeployStatus::Configuring,
234            WitDeployStatusV2::Starting => DeployStatus::Starting,
235            WitDeployStatusV2::Running => DeployStatus::Running,
236            WitDeployStatusV2::Failed => DeployStatus::Failed,
237            WitDeployStatusV2::RolledBack => DeployStatus::RolledBack,
238        },
239        message: j.message,
240        endpoints: j.endpoints,
241    }
242}
243
244fn err_to_host_v2(e: WitExtensionErrorV2) -> DeployExtensionError {
245    match e {
246        WitExtensionErrorV2::InvalidInput(m) => DeployExtensionError::InvalidInput(m),
247        WitExtensionErrorV2::MissingCapability(m) => DeployExtensionError::MissingCapability(m),
248        WitExtensionErrorV2::PermissionDenied(m) => DeployExtensionError::PermissionDenied(m),
249        WitExtensionErrorV2::NotFound(m) => DeployExtensionError::NotFound(m),
250        WitExtensionErrorV2::SchemaInvalid(m) => DeployExtensionError::SchemaInvalid(m),
251        WitExtensionErrorV2::Internal(m) => DeployExtensionError::Internal(m),
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn deploy_returns_not_found_for_unknown_extension() {
261        let rt = ExtensionRuntime::for_test();
262        let req = DeployRequest {
263            target_id: "github-repo".into(),
264            artifact_bytes: vec![1, 2, 3],
265            credentials_json: "{}".into(),
266            config_json: "{}".into(),
267            deployment_name: "demo".into(),
268        };
269        match rt.deploy("greentic.deploy-github", req) {
270            Err(RuntimeError::NotFound(id)) => assert_eq!(id, "greentic.deploy-github"),
271            other => panic!("expected NotFound, got {other:?}"),
272        }
273    }
274
275    #[test]
276    fn deploy_poll_returns_not_found_for_unknown_extension() {
277        let rt = ExtensionRuntime::for_test();
278        match rt.deploy_poll("greentic.deploy-github", "job-1") {
279            Err(RuntimeError::NotFound(id)) => assert_eq!(id, "greentic.deploy-github"),
280            other => panic!("expected NotFound, got {other:?}"),
281        }
282    }
283
284    #[test]
285    fn deploy_rollback_returns_not_found_for_unknown_extension() {
286        let rt = ExtensionRuntime::for_test();
287        match rt.deploy_rollback("greentic.deploy-github", "job-1") {
288            Err(RuntimeError::NotFound(id)) => assert_eq!(id, "greentic.deploy-github"),
289            other => panic!("expected NotFound, got {other:?}"),
290        }
291    }
292}