Skip to main content

keygen_rs/
machine.rs

1//! Machine activation and management.
2//!
3//! This module provides functionality for activating machines against licenses,
4//! managing machine heartbeats, and checking out machine files for offline use.
5
6use crate::certificate::CertificateFileResponse;
7use crate::client::{Client, ClientOptions, Response};
8use crate::config::get_config;
9use crate::config::KeygenConfig;
10use crate::errors::Error;
11#[cfg(feature = "token")]
12use crate::insert_optional;
13use crate::machine_file::MachineFile;
14use crate::KeygenResponseData;
15use chrono::{DateTime, Utc};
16#[cfg(not(target_arch = "wasm32"))]
17use futures::future::{BoxFuture, FutureExt};
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20use std::collections::HashMap;
21use std::sync::Arc;
22#[cfg(not(target_arch = "wasm32"))]
23use std::time::Duration;
24#[cfg(not(target_arch = "wasm32"))]
25use tokio::sync::mpsc;
26
27/// Heartbeat status as returned by the Keygen API
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
30pub enum HeartbeatStatus {
31    Alive,
32    Dead,
33    NotStarted,
34    Resurrected,
35}
36
37impl HeartbeatStatus {
38    /// Parses a HeartbeatStatus from a string, returning None for unknown values
39    pub fn parse(s: &str) -> Option<Self> {
40        match s.to_uppercase().as_str() {
41            "ALIVE" => Some(Self::Alive),
42            "DEAD" => Some(Self::Dead),
43            "NOT_STARTED" => Some(Self::NotStarted),
44            "RESURRECTED" => Some(Self::Resurrected),
45            _ => None,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub(crate) struct MachineAttributes {
52    pub fingerprint: String,
53    pub name: Option<String>,
54    pub platform: Option<String>,
55    pub hostname: Option<String>,
56    pub ip: Option<String>,
57    pub cores: Option<i32>,
58    pub metadata: Option<HashMap<String, Value>>,
59    #[serde(rename = "requireHeartbeat")]
60    pub require_heartbeat: bool,
61    #[serde(rename = "heartbeatStatus")]
62    pub heartbeat_status: String,
63    #[serde(rename = "heartbeatDuration")]
64    pub heartbeat_duration: Option<i32>,
65    pub created: DateTime<Utc>,
66    pub updated: DateTime<Utc>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub(crate) struct MachineResponse {
71    pub data: KeygenResponseData<MachineAttributes>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub(crate) struct MachinesResponse {
76    pub data: Vec<KeygenResponseData<MachineAttributes>>,
77    pub meta: Option<Value>,
78    pub links: Option<Value>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Machine {
83    pub id: String,
84    pub fingerprint: String,
85    pub name: Option<String>,
86    pub platform: Option<String>,
87    pub hostname: Option<String>,
88    pub ip: Option<String>,
89    pub cores: Option<i32>,
90    pub metadata: Option<HashMap<String, Value>>,
91    #[serde(rename = "requireHeartbeat")]
92    pub require_heartbeat: bool,
93    #[serde(rename = "heartbeatStatus")]
94    pub heartbeat_status: String,
95    #[serde(rename = "heartbeatDuration")]
96    pub heartbeat_duration: Option<i32>,
97    pub created: DateTime<Utc>,
98    pub updated: DateTime<Utc>,
99    pub account_id: Option<String>,
100    pub environment_id: Option<String>,
101    pub product_id: Option<String>,
102    pub license_id: Option<String>,
103    pub owner_id: Option<String>,
104    pub group_id: Option<String>,
105    #[serde(skip)]
106    pub config: Option<Arc<KeygenConfig>>,
107}
108
109#[derive(Debug, Clone, Default)]
110pub struct MachineCheckoutOpts {
111    pub ttl: Option<i64>,
112    pub include: Option<Vec<String>>,
113}
114
115impl MachineCheckoutOpts {
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    pub fn with_ttl(ttl: i64) -> Self {
121        Self {
122            ttl: Some(ttl),
123            ..Self::default()
124        }
125    }
126
127    pub fn with_include(include: Vec<String>) -> Self {
128        Self {
129            include: Some(include),
130            ..Self::default()
131        }
132    }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct MachineListFilters {
137    pub license: Option<String>,
138    pub user: Option<String>,
139    pub platform: Option<String>,
140    pub name: Option<String>,
141    pub fingerprint: Option<String>,
142    pub ip: Option<String>,
143    pub hostname: Option<String>,
144    pub product: Option<String>,
145    pub owner: Option<String>,
146    pub group: Option<String>,
147    pub policy: Option<String>,
148    pub key: Option<String>,
149    pub metadata: Option<HashMap<String, Value>>,
150    pub page_number: Option<i32>,
151    pub page_size: Option<i32>,
152    pub limit: Option<i32>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct MachineCreateRequest {
157    pub fingerprint: String,
158    pub name: Option<String>,
159    pub platform: Option<String>,
160    pub hostname: Option<String>,
161    pub ip: Option<String>,
162    pub cores: Option<i32>,
163    pub metadata: Option<HashMap<String, Value>>,
164    pub license_id: String,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct MachineUpdateRequest {
169    pub name: Option<String>,
170    pub platform: Option<String>,
171    pub hostname: Option<String>,
172    pub ip: Option<String>,
173    pub cores: Option<i32>,
174    pub metadata: Option<HashMap<String, Value>>,
175}
176
177impl Machine {
178    pub(crate) fn from(data: KeygenResponseData<MachineAttributes>) -> Machine {
179        Machine {
180            id: data.id,
181            fingerprint: data.attributes.fingerprint,
182            name: data.attributes.name,
183            platform: data.attributes.platform,
184            hostname: data.attributes.hostname,
185            ip: data.attributes.ip,
186            cores: data.attributes.cores,
187            metadata: data.attributes.metadata,
188            require_heartbeat: data.attributes.require_heartbeat,
189            heartbeat_status: data.attributes.heartbeat_status,
190            heartbeat_duration: data.attributes.heartbeat_duration,
191            created: data.attributes.created,
192            updated: data.attributes.updated,
193            account_id: data.relationships.account_id(),
194            environment_id: data.relationships.environment_id(),
195            product_id: data.relationships.product_id(),
196            license_id: data.relationships.license_id(),
197            owner_id: data.relationships.owner_id(),
198            group_id: data.relationships.group_id(),
199            config: None,
200        }
201    }
202
203    /// Associates a configuration with this Machine
204    pub fn with_config(mut self, config: KeygenConfig) -> Self {
205        self.config = Some(Arc::new(config));
206        self
207    }
208
209    /// Gets a client for this machine, using the associated config or global config
210    fn get_client(&self) -> Result<Client, Error> {
211        let config = if let Some(ref cfg) = self.config {
212            cfg.as_ref().clone()
213        } else {
214            get_config()?
215        };
216        Client::new(ClientOptions::from(config))
217    }
218
219    pub async fn deactivate(&self) -> Result<(), Error> {
220        let client = self.get_client()?;
221        let _response = client
222            .delete::<(), serde_json::Value>(&format!("machines/{}", self.id), None::<&()>)
223            .await?;
224        Ok(())
225    }
226
227    pub async fn checkout(&self, options: &MachineCheckoutOpts) -> Result<MachineFile, Error> {
228        let mut query = json!({
229            "encrypt": 1
230        });
231
232        if let Some(ttl) = options.ttl {
233            query["ttl"] = ttl.into();
234        }
235
236        if let Some(ref include) = options.include {
237            query["include"] = json!(include.join(","));
238        } else {
239            query["include"] = "license.entitlements".into();
240        }
241
242        let client = self.get_client()?;
243        let response = client
244            .post(
245                &format!("machines/{}/actions/check-out", self.id),
246                None::<&()>,
247                Some(&query),
248            )
249            .await?;
250
251        let machine_file_response: CertificateFileResponse = serde_json::from_value(response.body)?;
252        let machine_file = MachineFile::from(machine_file_response.data);
253        Ok(machine_file)
254    }
255
256    pub async fn ping(&self) -> Result<Machine, Error> {
257        let client = self.get_client()?;
258        let response: Response<MachineResponse> = client
259            .post(
260                &format!("machines/{}/actions/ping", self.id),
261                None::<&()>,
262                None::<&()>,
263            )
264            .await?;
265        let machine = Machine::from(response.body.data).with_config(
266            self.config
267                .as_ref()
268                .ok_or(Error::MissingConfiguration)?
269                .as_ref()
270                .clone(),
271        );
272        Ok(machine)
273    }
274
275    #[cfg(not(target_arch = "wasm32"))]
276    pub fn monitor(
277        self: Arc<Self>,
278        heartbeat_interval: Duration,
279        tx: Option<mpsc::Sender<Result<Machine, Error>>>,
280        mut cancel_rx: Option<mpsc::Receiver<()>>,
281    ) -> BoxFuture<'static, ()> {
282        async move {
283            async fn send(
284                tx: &Option<mpsc::Sender<Result<Machine, Error>>>,
285                result: Result<Machine, Error>,
286            ) {
287                if let Some(tx) = tx {
288                    let _ = tx.send(result).await;
289                }
290            }
291
292            let mut interval = tokio::time::interval(heartbeat_interval);
293            interval.tick().await;
294
295            send(&tx, self.ping().await).await;
296
297            loop {
298                tokio::select! {
299                    _ = interval.tick() => {
300                        send(&tx, self.ping().await).await;
301                    }
302                    _ = async {
303                        if let Some(ref mut rx) = cancel_rx {
304                            rx.recv().await
305                        } else {
306                            std::future::pending::<Option<()>>().await
307                        }
308                    } => {
309                        break;
310                    }
311                }
312            }
313        }
314        .boxed()
315    }
316
317    /// Create a new machine
318    #[cfg(feature = "token")]
319    pub async fn create(request: MachineCreateRequest) -> Result<Machine, Error> {
320        let config = get_config()?;
321        let client = Client::new(ClientOptions::from(config))?;
322        let mut attributes = serde_json::Map::new();
323        attributes.insert("fingerprint".to_string(), json!(request.fingerprint));
324
325        insert_optional(&mut attributes, "name", request.name)?;
326        insert_optional(&mut attributes, "platform", request.platform)?;
327        insert_optional(&mut attributes, "hostname", request.hostname)?;
328        insert_optional(&mut attributes, "ip", request.ip)?;
329        insert_optional(&mut attributes, "cores", request.cores)?;
330        insert_optional(&mut attributes, "metadata", request.metadata)?;
331
332        let body = json!({
333            "data": {
334                "type": "machines",
335                "attributes": attributes,
336                "relationships": {
337                    "license": {
338                        "data": {
339                            "type": "licenses",
340                            "id": request.license_id
341                        }
342                    }
343                }
344            }
345        });
346
347        let response = client.post("machines", Some(&body), None::<&()>).await?;
348        let machine_response: MachineResponse = serde_json::from_value(response.body)?;
349        Ok(Machine::from(machine_response.data))
350    }
351
352    /// List machines with optional filters
353    #[cfg(feature = "token")]
354    pub async fn list(filters: Option<MachineListFilters>) -> Result<Vec<Machine>, Error> {
355        let config = get_config()?;
356        let client = Client::new(ClientOptions::from(config))?;
357        let mut query_params = Vec::new();
358        if let Some(filters) = filters {
359            if let Some(license) = filters.license {
360                query_params.push(("license".to_string(), license));
361            }
362            if let Some(user) = filters.user {
363                query_params.push(("user".to_string(), user));
364            }
365            if let Some(platform) = filters.platform {
366                query_params.push(("platform".to_string(), platform));
367            }
368            if let Some(name) = filters.name {
369                query_params.push(("name".to_string(), name));
370            }
371            if let Some(fingerprint) = filters.fingerprint {
372                query_params.push(("fingerprint".to_string(), fingerprint));
373            }
374            if let Some(ip) = filters.ip {
375                query_params.push(("ip".to_string(), ip));
376            }
377            if let Some(hostname) = filters.hostname {
378                query_params.push(("hostname".to_string(), hostname));
379            }
380            if let Some(product) = filters.product {
381                query_params.push(("product".to_string(), product));
382            }
383            if let Some(owner) = filters.owner {
384                query_params.push(("owner".to_string(), owner));
385            }
386            if let Some(group) = filters.group {
387                query_params.push(("group".to_string(), group));
388            }
389            if let Some(policy) = filters.policy {
390                query_params.push(("policy".to_string(), policy));
391            }
392            if let Some(key) = filters.key {
393                query_params.push(("key".to_string(), key));
394            }
395            if let Some(metadata) = filters.metadata {
396                for (key, value) in metadata {
397                    query_params.push((format!("metadata[{key}]"), value.to_string()));
398                }
399            }
400            // Add pagination parameters
401            if let Some(page_number) = filters.page_number {
402                query_params.push(("page[number]".to_string(), page_number.to_string()));
403            }
404            if let Some(page_size) = filters.page_size {
405                query_params.push(("page[size]".to_string(), page_size.to_string()));
406            }
407            if let Some(limit) = filters.limit {
408                query_params.push(("limit".to_string(), limit.to_string()));
409            }
410        }
411
412        let query = if query_params.is_empty() {
413            None
414        } else {
415            Some(
416                query_params
417                    .into_iter()
418                    .collect::<HashMap<String, String>>(),
419            )
420        };
421
422        let response = client.get("machines", query.as_ref()).await?;
423        let machines_response: MachinesResponse = serde_json::from_value(response.body)?;
424        Ok(machines_response
425            .data
426            .into_iter()
427            .map(Machine::from)
428            .collect())
429    }
430
431    /// Get a machine by ID
432    #[cfg(feature = "token")]
433    pub async fn get(id: &str) -> Result<Machine, Error> {
434        let config = get_config()?;
435        let client = Client::new(ClientOptions::from(config))?;
436        let endpoint = format!("machines/{id}");
437        let response = client.get(&endpoint, None::<&()>).await?;
438        let machine_response: MachineResponse = serde_json::from_value(response.body)?;
439        Ok(Machine::from(machine_response.data))
440    }
441
442    /// Update a machine
443    #[cfg(feature = "token")]
444    pub async fn update(&self, request: MachineUpdateRequest) -> Result<Machine, Error> {
445        let client = self.get_client()?;
446        let endpoint = format!("machines/{}", self.id);
447
448        let mut attributes = serde_json::Map::new();
449        insert_optional(&mut attributes, "name", request.name)?;
450        insert_optional(&mut attributes, "platform", request.platform)?;
451        insert_optional(&mut attributes, "hostname", request.hostname)?;
452        insert_optional(&mut attributes, "ip", request.ip)?;
453        insert_optional(&mut attributes, "cores", request.cores)?;
454        insert_optional(&mut attributes, "metadata", request.metadata)?;
455
456        let body = json!({
457            "data": {
458                "type": "machines",
459                "attributes": attributes
460            }
461        });
462
463        let response = client.patch(&endpoint, Some(&body), None::<&()>).await?;
464        let machine_response: MachineResponse = serde_json::from_value(response.body)?;
465        Ok(Machine::from(machine_response.data))
466    }
467
468    /// Reset machine heartbeat
469    #[cfg(feature = "token")]
470    pub async fn reset(&self) -> Result<Machine, Error> {
471        let client = self.get_client()?;
472        let endpoint = format!("machines/{}/actions/reset", self.id);
473        let response = client.post(&endpoint, None::<&()>, None::<&()>).await?;
474        let machine_response: MachineResponse = serde_json::from_value(response.body)?;
475        Ok(Machine::from(machine_response.data))
476    }
477
478    /// Change the machine owner.
479    #[cfg(feature = "token")]
480    pub async fn change_owner(&self, owner_id: &str) -> Result<Machine, Error> {
481        let client = self.get_client()?;
482        let endpoint = format!("machines/{}/owner", self.id);
483        let body = json!({
484            "data": {
485                "type": "users",
486                "id": owner_id
487            }
488        });
489        let response = client.put(&endpoint, Some(&body), None::<&()>).await?;
490        let machine_response: MachineResponse = serde_json::from_value(response.body)?;
491        Ok(Machine::from(machine_response.data))
492    }
493
494    /// Change the machine group.
495    #[cfg(feature = "token")]
496    pub async fn change_group(&self, group_id: &str) -> Result<Machine, Error> {
497        let client = self.get_client()?;
498        let endpoint = format!("machines/{}/group", self.id);
499        let body = json!({
500            "data": {
501                "type": "groups",
502                "id": group_id
503            }
504        });
505        let response = client.put(&endpoint, Some(&body), None::<&()>).await?;
506        let machine_response: MachineResponse = serde_json::from_value(response.body)?;
507        Ok(Machine::from(machine_response.data))
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::{
515        KeygenRelationship, KeygenRelationshipData, KeygenRelationships, KeygenResponseData,
516    };
517    use chrono::Utc;
518
519    #[test]
520    fn test_machine_relationships() {
521        // Test that all relationship IDs are properly extracted
522        let machine_data = KeygenResponseData {
523            id: "test-machine-id".to_string(),
524            r#type: "machines".to_string(),
525            attributes: MachineAttributes {
526                fingerprint: "test-fingerprint".to_string(),
527                name: Some("Test Machine".to_string()),
528                platform: Some("linux".to_string()),
529                hostname: Some("test-host".to_string()),
530                ip: Some("192.168.1.1".to_string()),
531                cores: Some(8),
532                metadata: Some(HashMap::new()),
533                require_heartbeat: true,
534                heartbeat_status: "ALIVE".to_string(),
535                heartbeat_duration: Some(3600),
536                created: Utc::now(),
537                updated: Utc::now(),
538            },
539            relationships: KeygenRelationships {
540                policy: None,
541                account: Some(KeygenRelationship {
542                    data: Some(KeygenRelationshipData {
543                        r#type: "accounts".to_string(),
544                        id: "test-account-id".to_string(),
545                    }),
546                    links: None,
547                }),
548                product: Some(KeygenRelationship {
549                    data: Some(KeygenRelationshipData {
550                        r#type: "products".to_string(),
551                        id: "test-product-id".to_string(),
552                    }),
553                    links: None,
554                }),
555                group: Some(KeygenRelationship {
556                    data: Some(KeygenRelationshipData {
557                        r#type: "groups".to_string(),
558                        id: "test-group-id".to_string(),
559                    }),
560                    links: None,
561                }),
562                owner: Some(KeygenRelationship {
563                    data: Some(KeygenRelationshipData {
564                        r#type: "users".to_string(),
565                        id: "test-owner-id".to_string(),
566                    }),
567                    links: None,
568                }),
569                users: None,
570                machines: None,
571                environment: Some(KeygenRelationship {
572                    data: Some(KeygenRelationshipData {
573                        r#type: "environments".to_string(),
574                        id: "test-environment-id".to_string(),
575                    }),
576                    links: None,
577                }),
578                license: Some(KeygenRelationship {
579                    data: Some(KeygenRelationshipData {
580                        r#type: "licenses".to_string(),
581                        id: "test-license-id".to_string(),
582                    }),
583                    links: None,
584                }),
585                release: None,
586                other: HashMap::new(),
587            },
588        };
589
590        let machine = Machine::from(machine_data);
591
592        assert_eq!(machine.account_id, Some("test-account-id".to_string()));
593        assert_eq!(
594            machine.environment_id,
595            Some("test-environment-id".to_string())
596        );
597        assert_eq!(machine.product_id, Some("test-product-id".to_string()));
598        assert_eq!(machine.license_id, Some("test-license-id".to_string()));
599        assert_eq!(machine.owner_id, Some("test-owner-id".to_string()));
600        assert_eq!(machine.group_id, Some("test-group-id".to_string()));
601        assert_eq!(machine.id, "test-machine-id");
602        assert_eq!(machine.fingerprint, "test-fingerprint");
603    }
604
605    #[test]
606    fn test_machine_without_relationships() {
607        // Test that all relationship IDs are None when no relationships exist
608        let machine_data = KeygenResponseData {
609            id: "test-machine-id".to_string(),
610            r#type: "machines".to_string(),
611            attributes: MachineAttributes {
612                fingerprint: "test-fingerprint".to_string(),
613                name: Some("Test Machine".to_string()),
614                platform: Some("linux".to_string()),
615                hostname: Some("test-host".to_string()),
616                ip: Some("192.168.1.1".to_string()),
617                cores: Some(8),
618                metadata: Some(HashMap::new()),
619                require_heartbeat: true,
620                heartbeat_status: "ALIVE".to_string(),
621                heartbeat_duration: Some(3600),
622                created: Utc::now(),
623                updated: Utc::now(),
624            },
625            relationships: KeygenRelationships {
626                policy: None,
627                account: None,
628                product: None,
629                group: None,
630                owner: None,
631                users: None,
632                machines: None,
633                environment: None,
634                license: None,
635                release: None,
636                other: HashMap::new(),
637            },
638        };
639
640        let machine = Machine::from(machine_data);
641
642        assert_eq!(machine.account_id, None);
643        assert_eq!(machine.environment_id, None);
644        assert_eq!(machine.product_id, None);
645        assert_eq!(machine.license_id, None);
646        assert_eq!(machine.owner_id, None);
647        assert_eq!(machine.group_id, None);
648    }
649
650    #[test]
651    fn test_heartbeat_status_parse() {
652        assert_eq!(
653            HeartbeatStatus::parse("ALIVE"),
654            Some(HeartbeatStatus::Alive)
655        );
656        assert_eq!(HeartbeatStatus::parse("DEAD"), Some(HeartbeatStatus::Dead));
657        assert_eq!(
658            HeartbeatStatus::parse("NOT_STARTED"),
659            Some(HeartbeatStatus::NotStarted)
660        );
661        assert_eq!(
662            HeartbeatStatus::parse("RESURRECTED"),
663            Some(HeartbeatStatus::Resurrected)
664        );
665        assert_eq!(HeartbeatStatus::parse("UNKNOWN"), None);
666    }
667
668    #[test]
669    fn test_heartbeat_status_parse_case_insensitive() {
670        assert_eq!(
671            HeartbeatStatus::parse("alive"),
672            Some(HeartbeatStatus::Alive)
673        );
674        assert_eq!(HeartbeatStatus::parse("dead"), Some(HeartbeatStatus::Dead));
675        assert_eq!(
676            HeartbeatStatus::parse("not_started"),
677            Some(HeartbeatStatus::NotStarted)
678        );
679        assert_eq!(
680            HeartbeatStatus::parse("resurrected"),
681            Some(HeartbeatStatus::Resurrected)
682        );
683    }
684
685    #[test]
686    fn test_heartbeat_status_serialize() {
687        assert_eq!(
688            serde_json::to_string(&HeartbeatStatus::Alive).unwrap(),
689            "\"ALIVE\""
690        );
691        assert_eq!(
692            serde_json::to_string(&HeartbeatStatus::Dead).unwrap(),
693            "\"DEAD\""
694        );
695        assert_eq!(
696            serde_json::to_string(&HeartbeatStatus::NotStarted).unwrap(),
697            "\"NOT_STARTED\""
698        );
699        assert_eq!(
700            serde_json::to_string(&HeartbeatStatus::Resurrected).unwrap(),
701            "\"RESURRECTED\""
702        );
703    }
704
705    #[test]
706    fn test_heartbeat_status_deserialize() {
707        assert_eq!(
708            serde_json::from_str::<HeartbeatStatus>("\"ALIVE\"").unwrap(),
709            HeartbeatStatus::Alive
710        );
711        assert_eq!(
712            serde_json::from_str::<HeartbeatStatus>("\"DEAD\"").unwrap(),
713            HeartbeatStatus::Dead
714        );
715        assert_eq!(
716            serde_json::from_str::<HeartbeatStatus>("\"NOT_STARTED\"").unwrap(),
717            HeartbeatStatus::NotStarted
718        );
719        assert_eq!(
720            serde_json::from_str::<HeartbeatStatus>("\"RESURRECTED\"").unwrap(),
721            HeartbeatStatus::Resurrected
722        );
723    }
724}