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