tfc_toolset/
workspace.rs

1use crate::{
2    build_request,
3    error::{surf_to_tool_error, ToolError},
4    filter, set_page_number,
5    settings::{Core, Operators, Query, Tag},
6    tag, variable, variable_set, Meta, BASE_URL,
7};
8use log::{error, info};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::{fmt::Display, vec};
12use surf::{http::Method, Client};
13use time::OffsetDateTime;
14use url::Url;
15
16#[derive(Clone, Debug, Deserialize, Serialize)]
17pub struct FilteredResultInner {
18    pub workspaces: Vec<WorkspaceVariables>,
19}
20
21#[derive(Clone, Debug, Deserialize, Serialize)]
22pub struct FilteredResultOuter {
23    pub query: Query,
24    pub result: FilteredResultInner,
25}
26
27#[derive(Clone, Debug, Deserialize, Serialize)]
28pub struct WorkspaceVariables {
29    pub workspace: Workspace,
30    pub variables: Vec<variable::Variable>,
31}
32
33#[derive(Clone, Debug, Deserialize, Serialize)]
34pub struct WorkspaceTags {
35    pub workspace: Workspace,
36    pub tags: Vec<tag::Tags>,
37}
38
39#[derive(Clone, Debug, Deserialize, Serialize)]
40pub struct WorkspaceVariableSets {
41    pub workspace: Workspace,
42    pub variables: Vec<variable_set::VarSets>,
43}
44#[derive(Clone, Debug, Deserialize, Serialize)]
45pub struct Workspace {
46    pub id: String,
47    pub attributes: Attributes,
48}
49
50#[derive(Clone, Debug, Deserialize)]
51struct Workspaces {
52    pub data: Vec<Workspace>,
53    pub meta: Option<Meta>,
54}
55
56#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
57#[serde(rename_all = "kebab-case")]
58pub struct VcsRepo {
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub branch: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub identifier: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub ingress_submodules: Option<bool>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub oauth_token_id: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub tags_regex: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub repository_http_url: Option<String>,
71}
72
73impl VcsRepo {
74    pub fn new(
75        identifier: String,
76        oauth_token_id: Option<String>,
77        branch: Option<String>,
78        ingress_submodules: Option<bool>,
79        tags_regex: Option<String>,
80    ) -> Self {
81        Self {
82            identifier: Some(identifier),
83            oauth_token_id,
84            branch,
85            ingress_submodules,
86            tags_regex,
87            repository_http_url: None,
88        }
89    }
90}
91
92#[derive(Clone, Debug, Deserialize, Serialize)]
93pub struct Project {
94    pub id: String,
95}
96
97#[derive(Clone, Debug, Deserialize, Serialize)]
98pub struct ProjectOuter {
99    pub data: Project,
100}
101
102#[derive(Clone, Debug, Deserialize, Serialize)]
103pub struct Relationships {
104    pub project: ProjectOuter,
105}
106
107impl Relationships {
108    pub fn new(project_id: String) -> Self {
109        Self { project: ProjectOuter { data: Project { id: project_id } } }
110    }
111}
112
113#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum ExecutionMode {
116    Remote,
117    Local,
118    Agent,
119    #[default]
120    Unknown,
121}
122
123impl Display for ExecutionMode {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            ExecutionMode::Remote => write!(f, "remote"),
127            ExecutionMode::Local => write!(f, "local"),
128            ExecutionMode::Agent => write!(f, "agent"),
129            ExecutionMode::Unknown => write!(f, "unknown"),
130        }
131    }
132}
133
134impl From<String> for ExecutionMode {
135    fn from(item: String) -> Self {
136        match item.as_str() {
137            "remote" => ExecutionMode::Remote,
138            "local" => ExecutionMode::Local,
139            "agent" => ExecutionMode::Agent,
140            "unknown" => ExecutionMode::Unknown,
141            _ => ExecutionMode::Unknown,
142        }
143    }
144}
145
146#[derive(Clone, Debug, Serialize, Deserialize)]
147#[serde(rename_all = "kebab-case")]
148pub struct Attributes {
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub name: Option<String>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub agent_pool_id: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub allow_destroy_plan: Option<bool>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub assessments_enabled: Option<bool>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub auto_apply: Option<bool>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    // This seems to make the field required in the JSON
161    // Would be nice to have a way to make it optional
162    // commented it out for now
163    //#[serde(with = "time::serde::rfc3339::option")]
164    pub auto_destroy_at: Option<OffsetDateTime>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub description: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub execution_mode: Option<ExecutionMode>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub file_triggers_enabled: Option<bool>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub global_remote_state: Option<bool>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub queue_all_runs: Option<bool>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub source_name: Option<String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub source_url: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub speculative_enabled: Option<bool>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub terraform_version: Option<String>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub trigger_patterns: Option<Vec<String>>,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub trigger_prefixes: Option<Vec<String>>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub vcs_repo: Option<VcsRepo>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub working_directory: Option<String>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub relationships: Option<Relationships>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub tag_names: Option<Vec<String>>,
195}
196
197impl Default for Attributes {
198    fn default() -> Self {
199        Self {
200            name: None,
201            agent_pool_id: None,
202            allow_destroy_plan: None,
203            assessments_enabled: None,
204            auto_apply: None,
205            auto_destroy_at: None,
206            description: None,
207            execution_mode: None,
208            file_triggers_enabled: None,
209            global_remote_state: None,
210            queue_all_runs: None,
211            source_name: Some("tfc-toolset".to_string()),
212            source_url: None,
213            speculative_enabled: None,
214            terraform_version: None,
215            trigger_patterns: None,
216            trigger_prefixes: None,
217            vcs_repo: None,
218            working_directory: None,
219            relationships: None,
220            tag_names: None,
221        }
222    }
223}
224
225#[derive(Clone, Debug, Serialize, Deserialize)]
226pub struct WorkspaceRequest {
227    #[serde(rename = "type")]
228    pub relationship_type: String,
229    pub attributes: Attributes,
230}
231
232#[derive(Clone, Debug, Serialize, Deserialize)]
233struct WorkspaceOuter {
234    pub data: WorkspaceRequest,
235}
236
237impl WorkspaceOuter {
238    pub fn new(options: Attributes) -> Self {
239        Self {
240            data: WorkspaceRequest {
241                relationship_type: "workspaces".to_string(),
242                attributes: options,
243            },
244        }
245    }
246}
247
248#[derive(Clone, Debug, Serialize, Deserialize)]
249struct WorkspaceResponseOuter {
250    pub data: Workspace,
251}
252
253async fn send_show_req(
254    url: Url,
255    config: &Core,
256    client: Client,
257) -> Result<Workspace, ToolError> {
258    let req = build_request(Method::Get, url, config, None);
259    match client.send(req).await {
260        Ok(mut r) => {
261            if r.status().is_success() {
262                info!("Successfully retrieved workspace!");
263                let res = r
264                    .body_json::<WorkspaceResponseOuter>()
265                    .await
266                    .map_err(surf_to_tool_error)?;
267                Ok(res.data)
268            } else {
269                error!("Failed to retrieve workspace :(");
270                let error =
271                    r.body_string().await.map_err(surf_to_tool_error)?;
272                Err(ToolError::General(anyhow::anyhow!(error)))
273            }
274        }
275        Err(e) => Err(ToolError::General(e.into_inner())),
276    }
277}
278
279pub async fn show(
280    workspace_id: &str,
281    config: &Core,
282    client: Client,
283) -> Result<Workspace, ToolError> {
284    info!("Retrieving workspace {}.", workspace_id);
285    let url = Url::parse(&format!("{}/workspaces/{}", BASE_URL, workspace_id))?;
286    send_show_req(url, config, client).await
287}
288
289pub async fn show_by_name(
290    workspace_name: &str,
291    config: &Core,
292    client: Client,
293) -> Result<Workspace, ToolError> {
294    info!("Retrieving workspace {}.", workspace_name);
295    let url = Url::parse(&format!(
296        "{}/organizations/{}/workspaces/{}",
297        BASE_URL, config.org, workspace_name
298    ))?;
299    send_show_req(url, config, client).await
300}
301
302pub async fn create(
303    options: Attributes,
304    config: &Core,
305    client: Client,
306) -> Result<Workspace, ToolError> {
307    let name = options.name.clone().expect("Workspace name is required.");
308    info!("Creating workspace {}.", name);
309    let url = Url::parse(&format!(
310        "{}/organizations/{}/workspaces/",
311        BASE_URL, config.org
312    ))?;
313    let req = build_request(
314        Method::Post,
315        url,
316        config,
317        Some(json!(WorkspaceOuter::new(options))),
318    );
319    match client.send(req).await {
320        Ok(mut r) => {
321            if r.status().is_success() {
322                info!("Successfully created workspace!");
323                let res = r
324                    .body_json::<WorkspaceResponseOuter>()
325                    .await
326                    .map_err(surf_to_tool_error)?;
327                Ok(res.data)
328            } else {
329                error!("Failed to create workspace :(");
330                let error =
331                    r.body_string().await.map_err(surf_to_tool_error)?;
332                Err(ToolError::General(anyhow::anyhow!(error)))
333            }
334        }
335        Err(e) => Err(ToolError::General(e.into_inner())),
336    }
337}
338
339pub async fn update(
340    workspace_id: &str,
341    options: Attributes,
342    config: &Core,
343    client: Client,
344) -> Result<Workspace, ToolError> {
345    info!("Updating workspace {}.", workspace_id);
346    let url = Url::parse(&format!("{}/workspaces/{}", BASE_URL, workspace_id))?;
347    let req = build_request(
348        Method::Patch,
349        url,
350        config,
351        Some(json!(WorkspaceOuter::new(options))),
352    );
353    match client.send(req).await {
354        Ok(mut r) => {
355            if r.status().is_success() {
356                info!("Successfully updated workspace!");
357                let res = r
358                    .body_json::<WorkspaceResponseOuter>()
359                    .await
360                    .map_err(surf_to_tool_error)?;
361                Ok(res.data)
362            } else {
363                error!("Failed to update workspace :(");
364                let error =
365                    r.body_string().await.map_err(surf_to_tool_error)?;
366                Err(ToolError::General(anyhow::anyhow!(error)))
367            }
368        }
369        Err(e) => Err(ToolError::General(e.into_inner())),
370    }
371}
372
373pub async fn update_by_name(
374    workspace_name: &str,
375    options: Attributes,
376    config: &Core,
377    client: Client,
378) -> Result<Workspace, ToolError> {
379    info!("Updating workspace {}.", workspace_name);
380    let url = Url::parse(&format!(
381        "{}/organizations/{}/workspaces/{}",
382        BASE_URL, config.org, workspace_name
383    ))?;
384    let req = build_request(
385        Method::Patch,
386        url,
387        config,
388        Some(json!(WorkspaceOuter::new(options))),
389    );
390    match client.send(req).await {
391        Ok(mut r) => {
392            if r.status().is_success() {
393                info!("Successfully updated workspace!");
394                let res = r
395                    .body_json::<WorkspaceResponseOuter>()
396                    .await
397                    .map_err(surf_to_tool_error)?;
398                Ok(res.data)
399            } else {
400                error!("Failed to update workspace :(");
401                let error =
402                    r.body_string().await.map_err(surf_to_tool_error)?;
403                Err(ToolError::General(anyhow::anyhow!(error)))
404            }
405        }
406        Err(e) => Err(ToolError::General(e.into_inner())),
407    }
408}
409
410pub async fn delete(
411    workspace_id: &str,
412    safe_delete: bool,
413    config: &Core,
414    client: Client,
415) -> Result<(), ToolError> {
416    info!("Deleting workspace {}.", workspace_id);
417    let mut url =
418        Url::parse(&format!("{}/workspaces/{}", BASE_URL, workspace_id))?;
419    let mut method = Method::Delete;
420    if safe_delete {
421        url = Url::parse(&format!("{}/actions/safe-delete", url))?;
422        method = Method::Post;
423    }
424    let req = build_request(method, url, config, None);
425    match client.send(req).await {
426        Ok(mut r) => {
427            if r.status().is_success() {
428                info!("Successfully deleted workspace!");
429                Ok(())
430            } else {
431                error!("Failed to delete workspace :(");
432                let error =
433                    r.body_string().await.map_err(surf_to_tool_error)?;
434                Err(ToolError::General(anyhow::anyhow!(error)))
435            }
436        }
437        Err(e) => Err(ToolError::General(e.into_inner())),
438    }
439}
440
441pub async fn delete_by_name(
442    workspace_name: &str,
443    safe_delete: bool,
444    config: &Core,
445    client: Client,
446) -> Result<(), ToolError> {
447    info!("Deleting workspace {}.", workspace_name);
448    let mut url = Url::parse(&format!(
449        "{}/organizations/{}/workspaces/{}",
450        BASE_URL, config.org, workspace_name
451    ))?;
452    let mut method = Method::Delete;
453    if safe_delete {
454        url = Url::parse(&format!("{}/actions/safe-delete", url))?;
455        method = Method::Post;
456    }
457    let req = build_request(method, url, config, None);
458    match client.send(req).await {
459        Ok(mut r) => {
460            if r.status().is_success() {
461                info!("Successfully deleted workspace!");
462                Ok(())
463            } else {
464                error!("Failed to delete workspace :(");
465                let error =
466                    r.body_string().await.map_err(surf_to_tool_error)?;
467                Err(ToolError::General(anyhow::anyhow!(error)))
468            }
469        }
470        Err(e) => Err(ToolError::General(e.into_inner())),
471    }
472}
473
474pub async fn list(
475    filter: bool,
476    config: &Core,
477    client: Client,
478) -> Result<Vec<Workspace>, ToolError> {
479    info!("Retrieving the initial list of workspaces.");
480    let mut params = vec![
481        ("page[size]", config.pagination.page_size.clone()),
482        ("page[number]", config.pagination.start_page.clone()),
483    ];
484    if let Some(project) = config.project.clone() {
485        params.push(("filter[project][id]", project))
486    }
487    let mut url = Url::parse_with_params(
488        &format!("{}/organizations/{}/workspaces/", BASE_URL, config.org),
489        &params,
490    )?;
491    if filter {
492        if let Some(query) = config.workspaces.query.clone() {
493            if let Some(name) = query.name {
494                url = Url::parse_with_params(
495                    url.as_str(),
496                    &[("search[name]", name)],
497                )?
498            }
499            if let Some(wildcard_name) = query.wildcard_name {
500                url = Url::parse_with_params(
501                    url.as_str(),
502                    &[("search[wildcard-name]", wildcard_name)],
503                )?
504            }
505            if let Some(tags) = query.tags {
506                let mut include_tags: Vec<Tag> = Vec::new();
507                let mut exclude_tags: Vec<Tag> = Vec::new();
508                for tag in tags {
509                    match tag.operator {
510                        Operators::Equals => {
511                            include_tags.push(tag);
512                        }
513                        Operators::NotEquals => {
514                            exclude_tags.push(tag);
515                        }
516                        _ => {}
517                    }
518                }
519                if !include_tags.is_empty() {
520                    url = Url::parse_with_params(
521                        url.as_str(),
522                        &[(
523                            "search[tags]",
524                            include_tags
525                                .iter()
526                                .map(|t| t.name.clone())
527                                .collect::<Vec<String>>()
528                                .join(","),
529                        )],
530                    )?
531                }
532                if !exclude_tags.is_empty() {
533                    url = Url::parse_with_params(
534                        url.as_str(),
535                        &[(
536                            "search[exclude-tags]",
537                            exclude_tags
538                                .iter()
539                                .map(|t| t.name.clone())
540                                .collect::<Vec<String>>()
541                                .join(","),
542                        )],
543                    )?
544                }
545            }
546        }
547    }
548    let req = build_request(Method::Get, url.clone(), config, None);
549    let mut workspaces: Vec<Workspace> = vec![];
550    let mut workspace_list: Workspaces = match client.send(req).await {
551        Ok(mut r) => {
552            if r.status().is_success() {
553                info!("Successfully retrieved workspaces!");
554                match r.body_json().await {
555                    Ok(r) => r,
556                    Err(e) => {
557                        error!("{:#?}", e);
558                        return Err(ToolError::General(anyhow::anyhow!(e)));
559                    }
560                }
561            } else {
562                error!("Failed to retrieve workspaces :(");
563                let error =
564                    r.body_string().await.map_err(surf_to_tool_error)?;
565                return Err(ToolError::General(anyhow::anyhow!(error)));
566            }
567        }
568        Err(e) => {
569            return Err(ToolError::General(anyhow::anyhow!(e)));
570        }
571    };
572    workspaces.append(&mut workspace_list.data);
573    // Need to check pagination
574    if let Some(meta) = workspace_list.meta {
575        let max_depth = config.pagination.max_depth.parse::<u32>()?;
576        if max_depth > 1 || max_depth == 0 {
577            let current_depth: u32 = 1;
578            if let Some(next_page) = meta.pagination.next_page {
579                if max_depth == 0 || current_depth < max_depth {
580                    let num_pages: u32 = if max_depth
581                        >= meta.pagination.total_pages
582                        || max_depth == 0
583                    {
584                        meta.pagination.total_pages
585                    } else {
586                        max_depth
587                    };
588
589                    // Get the next page and merge the result
590                    for n in next_page..=num_pages {
591                        let u = url.clone();
592                        info!("Retrieving workspaces page {}.", &n);
593                        let u = match set_page_number(n, u) {
594                            Some(u) => u,
595                            None => {
596                                error!("Failed to set page number.");
597                                return Err(ToolError::Pagination(
598                                    "Failed to set page number.".to_string(),
599                                ));
600                            }
601                        };
602                        let req =
603                            build_request(Method::Get, u.clone(), config, None);
604                        let mut response = client
605                            .send(req)
606                            .await
607                            .map_err(surf_to_tool_error)?;
608                        if response.status().is_success() {
609                            info!(
610                                "Successfully retrieved workspaces page {}!",
611                                &n
612                            );
613                            let mut ws = response
614                                .body_json::<Workspaces>()
615                                .await
616                                .map_err(surf_to_tool_error)?;
617                            workspaces.append(&mut ws.data);
618                        } else {
619                            let e = response
620                                .body_string()
621                                .await
622                                .map_err(surf_to_tool_error)?;
623                            error!("{:#?}", e);
624                        }
625                    }
626                }
627            }
628        }
629    }
630    info!("Finished retrieving workspaces.");
631    if filter {
632        if let Some(query) = config.workspaces.query.clone() {
633            // Filter the workspaces if query tags have been provided
634            if query.tags.is_some() {
635                info!("Filtering workspaces with tags query.");
636                filter::workspace::by_tag(&mut workspaces, config)?;
637            }
638
639            if query.variables.is_some() {
640                // Get the variables for each workspace
641                let mut workspaces_variables =
642                    variable::list_batch(config, client, workspaces.clone())
643                        .await?;
644                // Filter the workspaces if query variables have been provided
645                if query.variables.is_some() {
646                    info!("Filtering workspaces with variable query.");
647                    filter::workspace::by_variable(
648                        &mut workspaces_variables,
649                        config,
650                    )?;
651                }
652
653                workspaces.clear();
654                for ws in &workspaces_variables {
655                    workspaces.push(ws.workspace.clone());
656                }
657            }
658        }
659    }
660    Ok(workspaces)
661}