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 _get_existing_variables( base_url: str, headers: Dict[str, str], workspace: Workspace, *, write_output: bool = False) -> Optional[Dict[str, Variable]]: """ Fetches the variables for a given workspaces. This method will eagerly exit and return None if anything unexpected is encountered. This is done prophylactically as the ensuing update/create operations hinge on the successful completion of this method. :param base_url: A URL fragment onto which a path will be appended to construct the Terraform API endpoint. :param headers: The headers to provide in the API HTTP request to fetch the variables. :param workspace: The workspace for which variables will be fetched. :param write_output: Whether to print a tabulated result of the patch operations to STDOUT. :return: A dictionary mapping variable IDs to variables, or None if an error occurred. """ def write_parse_error(json_object: Any) -> None: if write_output: print( f"Warning: a variable was not successfully parsed from {json.dumps(json_object)}", file=sys.stderr) # yapf: disable response = safe_http_request( lambda: throttle(lambda: requests.get( f"{base_url}/workspaces/{workspace.workspace_id}/vars", headers=headers )) ) # yapf: enable variables = {} if response.status_code == 200: body = response.json() if "data" in body and isinstance(body["data"], list): for obj in body["data"]: if isinstance(obj, dict) and "id" in obj and "attributes" in obj: variable_id = obj["id"] variable = Variable.from_json(obj["attributes"]) if variable is not None: variables[variable_id] = variable else: write_parse_error(obj) return None else: write_parse_error(obj) return None else: write_parse_error(response.json()) return None else: if write_output: print( f'Error: failed to get the existing variables for workspace "{workspace.name}".', file=sys.stderr) return None return variables
def _create_variables( base_url: str, headers: Dict[str, str], *, workspace: Workspace, creations: List[Variable], on_success: Callable[[Variable], None], on_failure: Callable[[Variable, Union[Response, ErrorResponse]], None] ) -> bool: """ Fetches the variables for a given workspaces. :param base_url: A URL fragment onto which a path will be appended to construct the Terraform API endpoint. :param headers: The headers to provide in the API HTTP request to fetch the variables. :param workspace: The workspace for which variables will be fetched. :param creations: A list of variables to be created. :param on_success: A function which will be passed a Variable object when that variable has been successfully patched. :param on_failure: A function which will be passed a Variable object when that variable has NOT been successfully patched. :return: Whether all HTTP operations were successful. If even a single one failed, returns False. """ all_successful = True for variable in creations: data = {"data": {"type": "vars", "attributes": variable.to_json()}} response = safe_http_request(lambda: throttle(lambda: requests.post( f"{base_url}/workspaces/{workspace.workspace_id}/vars", headers=headers, json=data))) if response.status_code == 201: on_success(variable) else: on_failure(variable, response) all_successful = False 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 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 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