def test_get_api_headers(mocker: MockerFixture) -> None: token = "test" mocker.patch("terraform_manager.terraform.find_token", return_value=token) headers = get_api_headers(TEST_TERRAFORM_DOMAIN, token=None) assert headers == { "Authorization": f"Bearer {token}", "Content-Type": HTTP_CONTENT_TYPE } token = "test123" headers = get_api_headers(TEST_TERRAFORM_DOMAIN, token=token) assert headers == { "Authorization": f"Bearer {token}", "Content-Type": HTTP_CONTENT_TYPE }
def _internal_batch_operation( terraform_domain: str, workspaces: List[Workspace], *, json: Dict[str, Any], on_success: SuccessHandler[Workspace], on_failure: ErrorHandler[Workspace], no_tls: bool = False, token: Optional[str] = None, write_output: bool = False ) -> bool: headers = get_api_headers(terraform_domain, token=token, write_error_messages=write_output) all_successful = True base_url = f"{get_protocol(no_tls)}://{terraform_domain}/api/v2" for workspace in workspaces: url = f"{base_url}/workspaces/{workspace.workspace_id}" response = safe_http_request( lambda: throttle(lambda: requests.patch(url, json=json, headers=headers)) ) if response.status_code == 200: on_success(workspace) else: all_successful = False on_failure(workspace, response) return all_successful
def exhaust_pages(endpoint: str, *, json_mapper: Callable[[Any], A], token: Optional[str] = None, write_error_messages: bool = False) -> List[A]: """ Iterates through every page that will be returned by a given Terraform API endpoint. :param endpoint: The full URL of a GET-able Terraform API endpoint (either Terraform Cloud or Enterprise). :param json_mapper: A mapping function that takes the value of the "data" field as input and returns a new value (which will be aggregated for all pages). :param token: A token suitable for authenticating against the Terraform API. If not specified, a token will be searched for in the documented locations. :param write_error_messages: Whether to write error messages to STDERR. :return: A list of outputs from the json_mapper function. """ current_page = 1 aggregate = [] headers = get_api_headers(parse_domain(endpoint), token=token, write_error_messages=write_error_messages) while current_page is not None: parameters = { # See: https://www.terraform.io/docs/cloud/api/index.html#pagination "page[number]": current_page, "page[size]": 100 } response = safe_http_request(lambda: throttle(lambda: requests.get( endpoint, headers=headers, params=parameters))) if response.status_code == 200: json = response.json() if "data" in json: aggregate.append(json_mapper(json["data"])) current_page = _get_next_page(json) else: if write_error_messages: # yapf: disable print(( f"Error: the Terraform API returned an error response from {endpoint} with " f"parameters {parameters} - response from the API was {response.json()}" ), file=sys.stderr) # yapf: enable current_page = None return aggregate
def _get_active_runs_for_workspace( terraform_domain: str, workspace: Workspace, *, no_tls: bool = False, token: Optional[str] = None ) -> List[Run]: required_attributes = ["created-at", "status", "status-timestamps", "has-changes"] active_runs = [] headers = get_api_headers(terraform_domain, token=token, write_error_messages=False) base_url = f"{get_protocol(no_tls)}://{terraform_domain}/api/v2" endpoint = f"{base_url}/workspaces/{workspace.workspace_id}/runs" parameters = { # See: https://www.terraform.io/docs/cloud/api/index.html#pagination # Note that this method only checks the most recent 100 runs for the workspace (this will be # sufficient in practice) "page[number]": 1, "page[size]": 100 } response = safe_http_request( lambda: throttle(lambda: requests.get(endpoint, headers=headers, params=parameters)) ) if response.status_code == 200: json = response.json() if "data" in json and len(json["data"]) > 0: for run_json in json["data"]: if "id" in run_json and "attributes" in run_json: attributes = run_json["attributes"] if all([x in attributes for x in required_attributes]): run = Run( run_id=run_json["id"], workspace=workspace, created_at=attributes["created-at"], status=attributes["status"], all_status_timestamps=attributes["status-timestamps"], has_changes=attributes["has-changes"] ) if run.is_active and run.has_changes: active_runs.append(run) return active_runs
def test_get_api_headers_missing_token(mocker: MockerFixture) -> None: mocker.patch("terraform_manager.terraform.find_token", return_value=None) headers = get_api_headers(TEST_TERRAFORM_DOMAIN, token=None) assert headers == {"Content-Type": HTTP_CONTENT_TYPE}
def lock_or_unlock_workspaces( terraform_domain: str, organization: str, workspaces: List[Workspace], *, set_lock: bool, no_tls: bool = False, token: Optional[str] = None, write_output: bool = False ) -> bool: """ Locks or unlocks each of the given workspaces. :param terraform_domain: The domain corresponding to the targeted Terraform installation (either Terraform Cloud or Enterprise). :param organization: The organization containing the workspaces to lock/unlock. :param workspaces: The workspaces to lock or unlock. :param set_lock: The desired state of the workspaces' locks. If True, workspaces will be locked. If False, workspaces will be unlocked. :param no_tls: Whether to use SSL/TLS encryption when communicating with the Terraform API. :param token: A token suitable for authenticating against the Terraform API. If not specified, a token will be searched for in the documented locations. :param write_output: Whether to print a tabulated result of the patch operations to STDOUT. :return: Whether all lock/unlock operations were successful. If even a single one failed, returns False. """ headers = get_api_headers(terraform_domain, token=token, write_error_messages=write_output) operation = "lock" if set_lock else "unlock" base_url = f"{get_protocol(no_tls)}://{terraform_domain}/api/v2" report = [] all_successful = True for workspace in workspaces: url = f"{base_url}/workspaces/{workspace.workspace_id}/actions/{operation}" response = safe_http_request(lambda: throttle(lambda: requests.post(url, headers=headers))) if response.status_code == 200: report.append([workspace.name, workspace.is_locked, set_lock, "success", "none"]) elif response.status_code == 409: report.append([ workspace.name, workspace.is_locked, set_lock, "success", f"workspace was already {operation}ed" ]) else: all_successful = False report.append([ workspace.name, workspace.is_locked, workspace.is_locked, "error", wrap_text(str(response.json()), MESSAGE_COLUMN_CHARACTER_COUNT) ]) if write_output: print(( f'Terraform workspace {operation} results for organization "{organization}" at ' f'"{terraform_domain}":' )) print() print( tabulate( sorted(report, key=lambda x: (x[3], x[0])), headers=["Workspace", "Lock State Before", "Lock State After", "Status", "Message"] ) ) print() return all_successful
def configure_variables(terraform_domain: str, organization: str, workspaces: List[Workspace], *, variables: List[Variable], no_tls: bool = False, token: Optional[str] = None, write_output: bool = False) -> bool: """ Creates or updates (in-place) one or more variables for the workspaces. If variables already exist with same keys, they will instead be updated so that all their fields equal the ones given in the variables passed to this method. This behavior allows this method to be idempotent. If any of the specified variables are invalid, no operations will be performed by this method. :param terraform_domain: The domain corresponding to the targeted Terraform installation (either Terraform Cloud or Enterprise). :param organization: The organization containing the workspaces to patch. :param workspaces: The workspaces to patch. :param variables: The variables to either create or update (or a mix thereof). :param no_tls: Whether to use SSL/TLS encryption when communicating with the Terraform API. :param token: A token suitable for authenticating against the Terraform API. If not specified, a token will be searched for in the documented locations. :param write_output: Whether to print a tabulated result of the patch operations to STDOUT. :return: Whether all HTTP operations were successful. If even a single one failed OR any of the variables are invalid, returns False. """ if len(variables) == 0: if write_output: print( "No variables to configure - returning successful immediately." ) return True elif not all([variable.is_valid for variable in variables]): if write_output: print( "At least one variable is invalid, so no variables will be configured.", file=sys.stderr) return False report = [] def on_success(w: Workspace, create: bool) -> SuccessHandler[Variable]: operation = "create" if create else "update" def callback(v: Variable) -> None: report.append([w.name, v.key, operation, "success", "none"]) return callback def on_failure(w: Workspace, create: bool) -> ErrorHandler[Variable]: operation = "create" if create else "update" def callback(v: Variable, response: Union[Response, ErrorResponse]) -> None: report.append([ w.name, v.key, operation, "error", wrap_text(str(response.json()), MESSAGE_COLUMN_CHARACTER_COUNT) ]) return callback headers = get_api_headers(terraform_domain, token=token, write_error_messages=write_output) base_url = f"{get_protocol(no_tls)}://{terraform_domain}/api/v2" all_successful = True for workspace in workspaces: updates_needed = {} creations_needed = [] existing_variables = _get_existing_variables(base_url, headers, workspace, write_output=write_output) if existing_variables is None: # Reminder: it will be none if something went wrong all_successful = False continue for new_variable in variables: needs_update = False for variable_id, old_variable in existing_variables.items(): if old_variable.key == new_variable.key: needs_update = True updates_needed[variable_id] = new_variable break if not needs_update: creations_needed.append(new_variable) create_result = _create_variables( base_url, headers, workspace=workspace, creations=creations_needed, on_success=on_success(workspace, True), on_failure=on_failure(workspace, True)) if all_successful: all_successful = create_result update_result = _update_variables( base_url, headers, workspace=workspace, updates=updates_needed, on_success=on_success(workspace, False), on_failure=on_failure(workspace, False)) if all_successful: all_successful = update_result if write_output: print(( f'Terraform workspace variable configuration results for organization "{organization}" ' f'at "{terraform_domain}":')) print() print( tabulate(sorted(report, key=lambda x: (x[3], x[2], x[0], x[1])), headers=[ "Workspace", "Variable", "Operation", "Status", "Message" ])) print() return all_successful
def delete_variables(terraform_domain: str, organization: str, workspaces: List[Workspace], *, variables: List[str], no_tls: bool = False, token: Optional[str] = None, write_output: bool = False) -> bool: """ Deletes one or more variables for the workspaces. If a variable does not exist in a particular workspace, no operation is performed relative to that variable (this is a safe operation). This behavior allows this method to be idempotent. :param terraform_domain: The domain corresponding to the targeted Terraform installation (either Terraform Cloud or Enterprise). :param organization: The organization containing the workspaces to patch. :param workspaces: The workspaces to patch. :param variables: The keys of the variables to delete. :param no_tls: Whether to use SSL/TLS encryption when communicating with the Terraform API. :param token: A token suitable for authenticating against the Terraform API. If not specified, a token will be searched for in the documented locations. :param write_output: Whether to print a tabulated result of the patch operations to STDOUT. :return: Whether all HTTP operations were successful. If even a single one failed, returns False. """ if len(variables) == 0: if write_output: print("No variables to delete - returning successful immediately.") return True report = [] headers = get_api_headers(terraform_domain, token=token, write_error_messages=write_output) base_url = f"{get_protocol(no_tls)}://{terraform_domain}/api/v2" all_successful = True for workspace in workspaces: existing_variables = _get_existing_variables(base_url, headers, workspace, write_output=write_output) if existing_variables is None: # Reminder: it will be none if something went wrong all_successful = False continue for variable_id, variable in existing_variables.items(): if variable.key in variables: response = safe_http_request( lambda: throttle(lambda: requests.delete( f"{base_url}/workspaces/{workspace.workspace_id}/vars/{variable_id}", headers=headers))) if response.status_code == 204: report.append([ workspace.name, variable.key, "delete", "success", "none" ]) else: all_successful = False report.append([ workspace.name, variable.key, "delete", "error", wrap_text(str(response.json()), MESSAGE_COLUMN_CHARACTER_COUNT) ]) if write_output: print(( f'Terraform workspace variable deletion results for organization "{organization}" at ' f'"{terraform_domain}":')) print() print( tabulate(sorted(report, key=lambda x: (x[3], x[2], x[0], x[1])), headers=[ "Workspace", "Variable", "Operation", "Status", "Message" ])) print() return all_successful