diff --git a/api/src/main/java/org/terrakube/api/plugin/storage/controller/TerraformStateController.java b/api/src/main/java/org/terrakube/api/plugin/storage/controller/TerraformStateController.java index e208c67fa..005e20af9 100644 --- a/api/src/main/java/org/terrakube/api/plugin/storage/controller/TerraformStateController.java +++ b/api/src/main/java/org/terrakube/api/plugin/storage/controller/TerraformStateController.java @@ -1,13 +1,16 @@ package org.terrakube.api.plugin.storage.controller; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.springframework.http.ResponseEntity; import org.terrakube.api.plugin.storage.StorageTypeService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.terrakube.api.repository.ArchiveRepository; +import org.terrakube.api.repository.HistoryRepository; +import org.terrakube.api.repository.WorkspaceRepository; +import org.terrakube.api.rs.workspace.history.History; import org.terrakube.api.rs.workspace.history.archive.Archive; import jakarta.servlet.http.HttpServletRequest; @@ -16,57 +19,62 @@ import java.util.Optional; import java.util.UUID; -@AllArgsConstructor @RestController @Slf4j @RequestMapping("/tfstate/v1") public class TerraformStateController { - private StorageTypeService storageTypeService; + private final StorageTypeService storageTypeService; + private final ArchiveRepository archiveRepository; + private final WorkspaceRepository workspaceRepository; + private final HistoryRepository historyRepository; + private final String hostname; - private ArchiveRepository archiveRepository; - - @GetMapping( - value = "/organization/{organizationId}/workspace/{workspaceId}/jobId/{jobId}/step/{stepId}/terraform.tfstate", - produces = MediaType.APPLICATION_OCTET_STREAM_VALUE - ) - public @ResponseBody byte[] getTerraformPlanBinary(@PathVariable("organizationId") String organizationId, @PathVariable("workspaceId") String workspaceId, @PathVariable("jobId") String jobId, @PathVariable("stepId") String stepId) { + public TerraformStateController(StorageTypeService storageTypeService, + ArchiveRepository archiveRepository, + WorkspaceRepository workspaceRepository, + HistoryRepository historyRepository, + @Value("${org.terrakube.hostname}") String hostname) { + this.storageTypeService = storageTypeService; + this.archiveRepository = archiveRepository; + this.workspaceRepository = workspaceRepository; + this.historyRepository = historyRepository; + this.hostname = hostname; + } + @GetMapping(value = "/organization/{organizationId}/workspace/{workspaceId}/jobId/{jobId}/step/{stepId}/terraform.tfstate", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public @ResponseBody byte[] getTerraformPlanBinary(@PathVariable("organizationId") String organizationId, + @PathVariable("workspaceId") String workspaceId, @PathVariable("jobId") String jobId, + @PathVariable("stepId") String stepId) { return storageTypeService.getTerraformPlan(organizationId, workspaceId, jobId, stepId); } - @GetMapping( - value = "/organization/{organizationId}/workspace/{workspaceId}/state/{stateFilename}.json", - produces = MediaType.APPLICATION_JSON_VALUE - ) - public @ResponseBody byte[] getTerraformStateJson(@PathVariable("organizationId") String organizationId, @PathVariable("workspaceId") String workspaceId, @PathVariable("stateFilename") String stateFilename) { + @GetMapping(value = "/organization/{organizationId}/workspace/{workspaceId}/state/{stateFilename}.json", produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody byte[] getTerraformStateJson(@PathVariable("organizationId") String organizationId, + @PathVariable("workspaceId") String workspaceId, @PathVariable("stateFilename") String stateFilename) { return storageTypeService.getTerraformStateJson(organizationId, workspaceId, stateFilename); } - @GetMapping( - value = "/organization/{organizationId}/workspace/{workspaceId}/state/terraform.tfstate", - produces = MediaType.APPLICATION_JSON_VALUE - ) - public @ResponseBody byte[] getCurrentTerraformState(@PathVariable("organizationId") String organizationId, @PathVariable("workspaceId") String workspaceId) { + @GetMapping(value = "/organization/{organizationId}/workspace/{workspaceId}/state/terraform.tfstate", produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody byte[] getCurrentTerraformState(@PathVariable("organizationId") String organizationId, + @PathVariable("workspaceId") String workspaceId) { return storageTypeService.getCurrentTerraformState(organizationId, workspaceId); } - @PutMapping( - value = "/archive/{archiveId}/terraform.tfstate", - produces = MediaType.APPLICATION_JSON_VALUE - ) - public ResponseEntity uploadHostedState(HttpServletRequest httpServletRequest, @PathVariable("archiveId") String archiveId) throws IOException { + @PutMapping(value = "/archive/{archiveId}/terraform.tfstate", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity uploadHostedState(HttpServletRequest httpServletRequest, + @PathVariable("archiveId") String archiveId) throws IOException { log.info("uploadHostedState for: {}", archiveId); Optional archive = archiveRepository.findById(UUID.fromString(archiveId)); if (archive.isPresent()) { Archive archiveData = archive.get(); - String terraformState = IOUtils.toString(httpServletRequest.getInputStream(), StandardCharsets.UTF_8.name()); + String terraformState = IOUtils.toString(httpServletRequest.getInputStream(), + StandardCharsets.UTF_8.name()); log.debug(terraformState); storageTypeService.uploadState( archiveData.getHistory().getWorkspace().getOrganization().getId().toString(), archiveData.getHistory().getWorkspace().getId().toString(), terraformState, - archiveData.getHistory().getId().toString() - ); + archiveData.getHistory().getId().toString()); archiveRepository.deleteById(archiveData.getId()); return ResponseEntity.status(201).body(""); } else { @@ -74,27 +82,97 @@ public ResponseEntity uploadHostedState(HttpServletRequest httpServletRe } } - @PutMapping( - value = "/archive/{archiveId}/terraform.json.tfstate", - produces = MediaType.APPLICATION_JSON_VALUE - ) - public ResponseEntity uploadJsonHostedState(HttpServletRequest httpServletRequest, @PathVariable("archiveId") String archiveId) throws IOException { + @PutMapping(value = "/archive/{archiveId}/terraform.json.tfstate", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity uploadJsonHostedState(HttpServletRequest httpServletRequest, + @PathVariable("archiveId") String archiveId) throws IOException { log.info("uploadJsonHostedState for: {}", archiveId); Optional archive = archiveRepository.findById(UUID.fromString(archiveId)); if (archive.isPresent()) { Archive archiveData = archive.get(); - String terraformJsonState = IOUtils.toString(httpServletRequest.getInputStream(), StandardCharsets.UTF_8.name()); + String terraformJsonState = IOUtils.toString(httpServletRequest.getInputStream(), + StandardCharsets.UTF_8.name()); log.debug(terraformJsonState); storageTypeService.uploadTerraformStateJson( archiveData.getHistory().getWorkspace().getOrganization().getId().toString(), archiveData.getHistory().getWorkspace().getId().toString(), terraformJsonState, - archiveData.getHistory().getId().toString() - ); + archiveData.getHistory().getId().toString()); archiveRepository.deleteById(archiveData.getId()); return ResponseEntity.status(201).body(""); } else { return ResponseEntity.status(403).body(""); } } + + @PutMapping(value = "/organization/{organizationId}/workspace/{workspaceId}/rollback/{stateFilename}.json", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity rollbackToState( + @PathVariable("organizationId") String organizationId, + @PathVariable("workspaceId") String workspaceId, + @PathVariable("stateFilename") String stateFilename) { + + log.info("Rolling back workspace {} in organization {} to state {}", workspaceId, organizationId, + stateFilename); + + try { + // Retrieve the previous JSON state + byte[] previousJsonState = storageTypeService.getTerraformStateJson(organizationId, workspaceId, + stateFilename); + if (previousJsonState == null || previousJsonState.length == 0) { + log.error("Failed to retrieve the JSON state: {}", stateFilename); + return ResponseEntity.status(404).body("JSON state not found"); + } + + // Retrieve the previous raw Terraform state by replacing ".json" with + // ".raw.json" + String rawStateFilename = stateFilename + ".raw"; + byte[] previousRawState = storageTypeService.getTerraformStateJson(organizationId, workspaceId, + rawStateFilename); + if (previousRawState == null || previousRawState.length == 0) { + log.error("Failed to retrieve the raw Terraform state: {}", rawStateFilename); + return ResponseEntity.status(404).body("Raw Terraform state not found"); + } + + // Create a new history entry for the rollback + History newHistory = new History(); + newHistory.setWorkspace(workspaceRepository.findById(UUID.fromString(workspaceId)).orElse(null)); + newHistory.setSerial(1); + newHistory.setMd5("0"); + newHistory.setLineage("0"); + newHistory.setOutput(""); // Output will be updated with the new state URL + newHistory.setJobReference(stateFilename.replace(".json", "")); // Use the previous history id as the job reference + historyRepository.save(newHistory); + + // Upload the previous JSON state as the current state + String jsonStateContent = new String(previousJsonState, StandardCharsets.UTF_8); + storageTypeService.uploadTerraformStateJson( + organizationId, + workspaceId, + jsonStateContent, + newHistory.getId().toString()); + + // Upload the previous raw Terraform state as the current state + String rawStateContent = new String(previousRawState, StandardCharsets.UTF_8); + storageTypeService.uploadState( + organizationId, + workspaceId, + rawStateContent, + newHistory.getId().toString()); + + // Update history output with new state URL + newHistory.setOutput(String.format("https://%s/tfstate/v1/organization/%s/workspace/%s/state/%s.json", + hostname, + organizationId, + workspaceId, + newHistory.getId().toString())); + historyRepository.save(newHistory); + + log.info("State rollback successful for workspace: {}", workspaceId); + return ResponseEntity.status(201).body("Rollback successful"); + + } catch (Exception e) { + log.error("Error during rollback: {}", e.getMessage()); + return ResponseEntity.status(500).body("Rollback failed"); + } + } + } diff --git a/api/src/main/resources/db/changelog/changelog.xml b/api/src/main/resources/db/changelog/changelog.xml index ceaf82f1a..c86386921 100644 --- a/api/src/main/resources/db/changelog/changelog.xml +++ b/api/src/main/resources/db/changelog/changelog.xml @@ -62,4 +62,5 @@ + diff --git a/api/src/main/resources/db/changelog/local/changelog-2.23.0-job-reference-size.xml b/api/src/main/resources/db/changelog/local/changelog-2.23.0-job-reference-size.xml new file mode 100644 index 000000000..84dd7f09d --- /dev/null +++ b/api/src/main/resources/db/changelog/local/changelog-2.23.0-job-reference-size.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/ui/src/domain/Workspaces/Details.jsx b/ui/src/domain/Workspaces/Details.jsx index ea4082f91..a1515c099 100644 --- a/ui/src/domain/Workspaces/Details.jsx +++ b/ui/src/domain/Workspaces/Details.jsx @@ -1195,6 +1195,7 @@ export const WorkspaceDetails = ({ setOrganizationName, selectedTab }) => { workspace={workspace.data} organizationId={organizationId} organizationName={organizationNameLocal} + onRollback={loadWorkspace} /> diff --git a/ui/src/domain/Workspaces/States.jsx b/ui/src/domain/Workspaces/States.jsx index 366ae7f42..7bf7141d8 100644 --- a/ui/src/domain/Workspaces/States.jsx +++ b/ui/src/domain/Workspaces/States.jsx @@ -1,5 +1,16 @@ import { React, useState, useRef, useCallback, useMemo } from "react"; -import { List, Space, Card, Row, Col, Avatar, Tooltip } from "antd"; +import { + List, + Space, + Card, + Row, + Col, + Avatar, + Tooltip, + Button, + Popconfirm, + message, +} from "antd"; import Editor from "@monaco-editor/react"; import axiosInstance, { axiosClient } from "../../config/axiosConfig"; import ReactFlow, { @@ -10,7 +21,11 @@ import ReactFlow, { } from "reactflow"; import NodeResource from "./NodeResource"; import { DownloadState } from "./DownloadState"; -import { InfoCircleOutlined, UserOutlined } from "@ant-design/icons"; +import { + InfoCircleOutlined, + UserOutlined, + RollbackOutlined, +} from "@ant-design/icons"; import "reactflow/dist/style.css"; import { ResourceDrawer } from "../Workspaces/ResourceDrawer"; @@ -20,7 +35,8 @@ export const States = ({ stateDetailsVisible, workspace, organizationId, - organizationName + organizationName, + onRollback, }) => { const [currentState, setCurrentState] = useState({}); const [stateContent, setStateContent] = useState(""); @@ -244,15 +260,44 @@ export const States = ({ [] ); + const handleRollback = () => { + const outputUrl = currentState.output; + const rollbackUrl = outputUrl.replace("/state/", "/rollback/"); // Replace /state/ with /rollback/ + + axiosInstance + .put(rollbackUrl) + .then((response) => { + // Show success message on successful rollback + message.success( + "The state was successfully rolled back. Please verify that the workspace version is compatible with this state." + ); + + console.log("Rollback successful:", response.data); + onRollback(false, false); + setStateDetailsVisible(false); + }) + .catch((error) => { + // Extract error message from the API response + const errorMessage = + error.response?.data || "An unexpected error occurred."; + + // Show the error message + message.error(`Failed to roll back the state: ${errorMessage}`); + + // Log the error for debugging purposes + console.error("Error during rollback:", error); + }); + }; + return (
{!stateDetailsVisible ? ( a.jobReference - b.jobReference) - .reverse()} + dataSource={history.sort( + (a, b) => new Date(b.createdDate) - new Date(a.createdDate) + )} renderItem={(item) => ( - job #{item.jobReference} + + {/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + item.jobReference + ) + ? `rollback to #${item.jobReference}` + : `job #${item.jobReference}`} + @@ -280,22 +331,51 @@ export const States = ({ } /> - +

{currentState.title}

#{currentState.id} - {currentState.createdBy} triggered from Terraform + {currentState.createdBy} triggered from Terraform{" "} + + {currentState.relativeDate} + - job #{currentState.jobReference} + + {/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + currentState.jobReference + ) + ? `rollback to #${currentState.jobReference}` + : `job #${currentState.jobReference}`} + - - -
- {currentState.relativeDate} + + + + Restoring this workspace to its previous state may lead to + loss of data.
Any resources that have been added or + modified since this state was saved
will no longer + be tracked by Terrakube. + + } + onConfirm={handleRollback} + okText="Yes" + cancelText="No" + > + + + +
+ +