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 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 ¶ms,
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 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 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 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 let mut workspaces_variables =
642 variable::list_batch(config, client, workspaces.clone())
643 .await?;
644 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}