def dump_backend_hcl(module: TerraformModule) -> str:
    """Return a string representation of the backend config for the given module."""
    def hcl_value(value: str | bool | int | float) -> str:
        """The value as represented in hcl."""
        if isinstance(value, str):
            return f'"{value}"'
        elif value is True:
            return 'true'
        elif value is False:
            return 'false'
        else:
            return str(value)

    backend_type, config = backend_config(module)
    debug(f'{backend_type=}')
    if backend_type == 'local':
        return ''

    tf = 'terraform {\n    backend "' + backend_type + '" {\n'

    for k, v in config.items():
        if isinstance(v, list):
            tf += f'        {k} {{\n'
            for block in v:
                for k, v in block.items():
                    tf += f'            {k} = {hcl_value(v)}\n'
            tf += '        }\n'
        else:
            tf += f'        {k} = {hcl_value(v)}\n'

    tf += '    }\n'
    tf += '}\n'

    return tf
Example #2
0
    def __getitem__(self, key):
        if os.path.isfile(os.path.join(self._cache_dir, key)):
            with open(os.path.join(self._cache_dir, key)) as f:
                debug(f'Read {key} from {self._label}')
                return f.read()

        raise IndexError(key)
Example #3
0
def try_read_local_state(module_dir: Path) -> Optional[Version]:
    try:
        return read_local_state(module_dir)
    except Exception as e:
        debug(str(e))

    return None
def download_version(version: Version, target_dir: Path) -> Path:
    """
    Download the executable for the given version of terraform.

    The return value is the path to the executable
    """

    terraform_path = Path(target_dir, 'terraform')

    if os.path.exists(terraform_path):
        return terraform_path

    debug(f'Downloading terraform {version}')

    local_filename, headers = urlretrieve(
        f'https://releases.hashicorp.com/terraform/{version}/terraform_{version}_{get_platform()}_{get_arch()}.zip',
        f'/tmp/terraform_{version}_linux_amd64.zip'
    )

    with ZipFile(local_filename) as f:
        f.extract('terraform', target_dir)

    os.chmod(terraform_path, 755)

    return Path(os.path.abspath(terraform_path))
Example #5
0
def fingerprint(backend_type: BackendType, backend_config: BackendConfig,
                env) -> bytes:
    backends = {
        'remote': fingerprint_remote,
        'artifactory': fingerprint_artifactory,
        'azurerm': fingerprint_azurerm,
        'consul': fingerprint_consul,
        'cloud': fingerprint_cloud,
        'cos': fingerprint_cos,
        'etcd': fingerprint_etcd,
        'etcd3': fingerprint_etcd3,
        'gcs': fingerprint_gcs,
        'http': fingerprint_http,
        'kubernetes': fingerprint_kubernetes,
        'manta': fingerprint_manta,
        'oss': fingerprint_oss,
        'pg': fingerprint_pg,
        's3': fingerprint_s3,
        'swift': fingerprint_swift,
        'local': fingerprint_local,
    }

    fingerprint_inputs = backends.get(backend_type,
                                      lambda c, e: c)(backend_config, env)

    debug(f'Backend fingerprint includes {fingerprint_inputs.keys()}')

    return canonicaljson.encode_canonical_json(fingerprint_inputs)
def try_get_required_version(module: TerraformModule, versions: Iterable[Version]) -> Optional[Version]:
    try:
        return get_required_version(module, versions)
    except Exception as e:
        debug('Failed to get terraform version from required_version constraint')

    return None
Example #7
0
    def api_request(self, method: str, *args, **kwargs) -> requests.Response:
        response = self._session.request(method, *args, **kwargs)

        if 400 <= response.status_code < 500:
            debug(str(response.headers))

            try:
                message = response.json()['message']

                if response.headers['X-RateLimit-Remaining'] == '0':
                    limit_reset = datetime.datetime.fromtimestamp(
                        int(response.headers['X-RateLimit-Reset']))
                    sys.stdout.write(message)
                    sys.stdout.write(
                        f' Try again when the rate limit resets at {limit_reset} UTC.\n'
                    )
                    sys.exit(1)

                if message != 'Resource not accessible by integration':
                    sys.stdout.write(message)
                    sys.stdout.write('\n')
                    debug(response.content.decode())

            except Exception:
                sys.stdout.write(response.content.decode())
                sys.stdout.write('\n')
                raise

        return response
def find_pr(github: GithubApi, actions_env: GithubEnv) -> PrUrl:
    """
    Find the pull request this event is related to

    >>> find_pr()
    'https://api.github.com/repos/dflook/terraform-github-actions/pulls/8'

    """

    event: Optional[dict[str, Any]]

    if os.path.isfile(actions_env['GITHUB_EVENT_PATH']):
        with open(actions_env['GITHUB_EVENT_PATH']) as f:
            event = json.load(f)
    else:
        debug('Event payload is not available')
        event = None

    event_type = actions_env['GITHUB_EVENT_NAME']

    if event_type in [
            'pull_request', 'pull_request_review_comment',
            'pull_request_target', 'pull_request_review', 'issue_comment'
    ]:

        if event is not None:
            # Pull pr url from event payload

            if event_type in [
                    'pull_request', 'pull_request_review_comment',
                    'pull_request_target', 'pull_request_review'
            ]:
                return cast(PrUrl, event['pull_request']['url'])

            if event_type == 'issue_comment':

                if 'pull_request' in event['issue']:
                    return cast(PrUrl, event['issue']['pull_request']['url'])
                else:
                    raise WorkflowException(
                        'This comment is not for a PR. Add a filter of `if: github.event.issue.pull_request`'
                    )

        else:
            # Event payload is not available

            if actions_env.get('GITHUB_REF_TYPE') == 'branch':
                if match := re.match(r'refs/pull/(\d+)/',
                                     actions_env.get('GITHUB_REF', '')):
                    return cast(
                        PrUrl,
                        f'{actions_env["GITHUB_API_URL"]}/repos/{actions_env["GITHUB_REPOSITORY"]}/pulls/{match.group(1)}'
                    )

            raise WorkflowException(
                f'Event payload is not available at the GITHUB_EVENT_PATH {actions_env["GITHUB_EVENT_PATH"]!r}. '
                +
                f'This is required when run by {event_type} events. The environment has not been setup properly by the actions runner. '
                + 'This can happen when the runner is running in a container')
def try_guess_state_version(inputs: InitInputs, module: TerraformModule,
                            versions: Iterable[Version]) -> Optional[Version]:
    """Try and guess the terraform version that wrote the remote state file of the specified module."""

    try:
        return guess_state_version(inputs, module, versions)
    except Exception as e:
        debug('Failed to find the terraform version from existing state')
        debug(str(e))

    return None
Example #10
0
def loads(hcl: str) -> dict:
    tmp_path = Path('/tmp/load_test.hcl')

    with open(tmp_path, 'w') as f:
        f.write(hcl)

    if is_loadable(tmp_path):
        return hcl2.loads(hcl)

    debug(f'Unable to load hcl')
    raise ValueError(f'Unable to load hcl')
def try_get_remote_workspace_version(
        inputs: InitInputs, module: TerraformModule, cli_config_path: Path,
        versions: Iterable[Version]) -> Optional[Version]:
    try:
        return get_remote_workspace_version(inputs, module, cli_config_path,
                                            versions)
    except Exception as exception:
        debug('Failed to get terraform version from remote workspace')
        debug(str(exception))

    return None
Example #12
0
    def __setitem__(self, key, value):
        if value is None:
            debug(f'Cache value for {key} should not be set to {value}')
            return

        path = os.path.join(self._cache_dir, key)

        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(os.path.join(self._cache_dir, key), 'w') as f:
            f.write(value)
            debug(f'Wrote {key} to {self._label}')
Example #13
0
def get_cli_credentials(path: Path, hostname: str) -> Optional[str]:
    """Get the terraform cloud token for a hostname from a cli credentials file."""

    try:
        with open(os.path.expanduser(path)) as f:
            config = f.read()
    except Exception:
        debug('Failed to parse CLI Config file')
        return None

    credentials = read_cli_config(config)
    return credentials.get(hostname)
Example #14
0
def is_loadable(path: Path) -> bool:
    try:
        subprocess.run([sys.executable, '-m', 'terraform.hcl', path],
                       timeout=10)
    except subprocess.TimeoutExpired:
        debug('TimeoutExpired')
        # We found a file that won't parse :(
        return False
    except:
        # If we get an exception, we can still try and load it.
        return True

    return True
Example #15
0
def plan_hash(plan_text: str, salt: str) -> str:
    """
    Compute a hash of the plan

    This currently uses the plan text output.
    TODO: Change to use the plan json output
    """

    debug(f'Hashing with salt {salt}')

    plan = remove_warnings(remove_unchanged_attributes(plan_text))

    return comment_hash(plan.encode(), salt)
def find_comment(github: GithubApi, issue_url: IssueUrl, username: str,
                 headers: dict[str, str],
                 legacy_description: str) -> TerraformComment:
    """
    Find a github comment that matches the given headers

    If no comment is found with the specified headers, tries to find a comment that matches the specified description instead.
    This is in case the comment was made with an earlier version, where comments were matched by description only.

    If not existing comment is found a new TerraformComment object is returned which represents a PR comment yet to be created.

    :param github: The github api object to make requests with
    :param issue_url: The issue to find the comment in
    :param username: The user who made the comment
    :param headers: The headers that must be present on the comment
    :param legacy_description: The description that must be present on the comment, if not headers are found.
    """

    debug(f"Searching for comment with {headers=}")

    backup_comment = None

    for comment_payload in github.paged_get(issue_url + '/comments'):
        if comment_payload['user']['login'] != username:
            continue

        if comment := _from_api_payload(comment_payload):

            if comment.headers:
                # Match by headers only

                if matching_headers(comment, headers):
                    debug(
                        f'Found comment that matches headers {comment.headers=} '
                    )
                    return comment

                debug(f"Didn't match comment with {comment.headers=}")

            else:
                # Match by description only

                if comment.description == legacy_description and backup_comment is None:
                    debug(
                        f'Found backup comment that matches legacy description {comment.description=}'
                    )
                    backup_comment = comment
                else:
                    debug(f"Didn't match comment with {comment.description=}")
Example #17
0
def read_local_state(module_dir: Path) -> Optional[Version]:
    """Return the terraform version that wrote a local terraform.tfstate file."""

    state_path = os.path.join(module_dir, 'terraform.tfstate')

    if not os.path.isfile(state_path):
        return None

    try:
        with open(state_path) as f:
            state = json.load(f)
            if state.get('serial') > 0:
                return Version(state.get('terraform_version'))
    except Exception as e:
        debug(str(e))

    return None
Example #18
0
def get_version_constraints(
        module: TerraformModule) -> Optional[list[Constraint]]:
    """Get the Terraform version constraint from the given module."""

    for block in module.get('terraform', []):
        if 'required_version' not in block:
            continue

        try:
            return [
                Constraint(c)
                for c in str(block['required_version']).split(',')
            ]
        except Exception:
            debug('required_version constraint is malformed')

    return None
def read_backend_config_vars(init_inputs: InitInputs) -> dict[str, str]:
    """Read any backend config from input variables."""

    config: dict[str, str] = {}

    for path in init_inputs.get('INPUT_BACKEND_CONFIG_FILE',
                                '').replace(',', '\n').splitlines():
        try:
            config |= load_backend_config_file(Path(path))  # type: ignore
        except Exception as e:
            debug(f'Failed to load backend config file {path}')
            debug(str(e))

    for backend_var in init_inputs.get('INPUT_BACKEND_CONFIG',
                                       '').replace(',', '\n').splitlines():
        if match := re.match(r'(.*)\s*=\s*(.*)', backend_var):
            config[match.group(1)] = match.group(2)
Example #20
0
def try_read_env(actions_env: ActionsEnv,
                 versions: Iterable[Version]) -> Optional[Version]:
    if 'TERRAFORM_VERSION' not in actions_env:
        return None

    constraint = actions_env['TERRAFORM_VERSION']

    try:
        valid_versions = list(
            apply_constraints(versions,
                              [Constraint(c) for c in constraint.split(',')]))
        if not valid_versions:
            return None
        return latest_non_prerelease_version(valid_versions)

    except Exception as exception:
        debug(str(exception))

    return None
Example #21
0
def try_read_asdf(inputs: InitInputs, workspace_path: str,
                  versions: Iterable[Version]) -> Optional[Version]:
    """Return the version from an asdf .tool-versions file if possible."""

    module_path = os.path.abspath(inputs.get('INPUT_PATH', '.'))

    while module_path not in ['/', workspace_path]:
        asdf_path = os.path.join(module_path, '.tool-versions')

        if os.path.isfile(asdf_path):
            try:
                with open(asdf_path) as f:
                    return parse_asdf(f.read(), versions)
            except Exception as e:
                debug(str(e))

        module_path = os.path.dirname(module_path)

    return None
def try_init(terraform: Version, init_args: list[str], workspace: str,
             backend_tf: str) -> Optional[Union[Version, Constraint]]:
    """
    Try and initialize the specified backend using the specified terraform version.

    Returns the information discovered from doing the init. This could be:
    - Version: the version of terraform used to write the state
    - Constraint: a constraint to apply to the available versions, that further narrows down to the version used to write the state
    - None: There is no remote state
    """

    terraform_path = get_executable(terraform)
    module_dir = tempfile.mkdtemp()

    with open(os.path.join(module_dir, 'terraform.tf'), 'w') as f:
        f.write(backend_tf)

    # Here we go
    result = subprocess.run([str(terraform_path), 'init'] + init_args,
                            env=os.environ | {
                                'TF_INPUT': 'false',
                                'TF_WORKSPACE': workspace
                            },
                            capture_output=True,
                            cwd=module_dir)
    debug(f'{result.args[:2]=}')
    debug(f'{result.returncode=}')
    debug(result.stdout.decode())
    debug(result.stderr.decode())

    if result.returncode != 0:
        if match := re.search(
                rb'state snapshot was created by Terraform v(.*),',
                result.stderr):
            return Version(match.group(1).decode())
        elif b'does not support state version 4' in result.stderr:
            return Constraint('>=0.12.0')
        elif b'Failed to select workspace' in result.stderr:
            return None
        else:
            debug(str(result.stderr))
            return None
def try_read_tfswitch(inputs: InitInputs) -> Optional[Version]:
    """
    Return the terraform version specified by any .tfswitchrc file.

    :param inputs: The action inputs
    :returns: The terraform version specified by the file, which may be None.
    """

    tfswitch_path = os.path.join(inputs.get('INPUT_PATH', '.'), '.tfswitchrc')

    if not os.path.exists(tfswitch_path):
        return None

    try:
        with open(tfswitch_path) as f:
            return parse_tfswitch(f.read())
    except Exception as e:
        debug(str(e))

    return None
def current_user(actions_env: GithubEnv) -> str:
    token_hash = hashlib.sha256(actions_env['GITHUB_TOKEN'].encode()).hexdigest()
    cache_key = f'token-cache/{token_hash}'

    if cache_key in job_cache:
        username = job_cache[cache_key]
    else:
        response = github.get(f'{actions_env["GITHUB_API_URL"]}/user')
        if response.status_code != 403:
            user = response.json()
            debug(json.dumps(user))

            username = user['login']
        else:
            # Assume this is the github actions app token
            username = '******'

        job_cache[cache_key] = username

    return username
Example #25
0
def try_read_tfenv(inputs: InitInputs,
                   versions: Iterable[Version]) -> Optional[Version]:
    """
    Return the terraform version specified by any .terraform-version file.

    :param inputs: The action inputs
    :param versions: The available terraform versions
    :returns: The terraform version specified by any .terraform-version file, which may be None.
    """

    tfenv_path = os.path.join(inputs.get('INPUT_PATH', '.'),
                              '.terraform-version')

    if not os.path.exists(tfenv_path):
        return None

    try:
        with open(tfenv_path) as f:
            return parse_tfenv(f.read(), versions)
    except Exception as e:
        debug(str(e))

    return None
Example #26
0
def load_module(path: Path) -> TerraformModule:
    """
    Load the terraform module.

    Every .tf file in the given directory is read and merged into one terraform module.
    If any .tf file fails to parse, it is ignored.
    """

    module = cast(TerraformModule, {})

    for file in os.listdir(path):
        if not file.endswith('.tf'):
            continue

        try:
            tf_file = cast(TerraformModule,
                           terraform.hcl.load(os.path.join(path, file)))
            module = merge(module, tf_file)
        except Exception as e:
            # ignore tf files that don't parse
            debug(f'Failed to parse {file}')
            debug(str(e))

    return module
Example #27
0
def get_remote_backend_config(
        module: TerraformModule, backend_config_files: str,
        backend_config_vars: str,
        cli_config_path: Path) -> Optional[BackendConfig]:
    """
    A complete backend config

    :param module: The terraform module to get the backend config from. At least a partial backend config must be present.
    :param backend_config_files: Files containing additional backend config.
    :param backend_config_vars: Additional backend config variables.
    :param cli_config_path: A Terraform CLI config file to use.
    """

    found = False
    backend_config = cast(BackendConfig, {
        'hostname': 'app.terraform.io',
        'workspaces': {}
    })

    for terraform in module.get('terraform', []):
        for backend in terraform.get('backend', []):
            if 'remote' not in backend:
                return None

            found = True
            if 'hostname' in backend['remote']:
                backend_config['hostname'] = str(backend['remote']['hostname'])

            backend_config['organization'] = backend['remote'].get(
                'organization')
            backend_config['token'] = backend['remote'].get('token')

            if backend['remote'].get('workspaces', []):
                backend_config['workspaces'] = backend['remote']['workspaces'][
                    0]

    if not found:
        return None

    def read_backend_files() -> None:
        """Read backend config files specified in env var"""
        for file in backend_config_files.replace(',', '\n').splitlines():
            for key, value in load_backend_config_file(Path(file)).items():
                backend_config[key] = value[0] if isinstance(
                    value, list) else value  # type: ignore

    def read_backend_vars() -> None:
        """Read backend config values specified in env var"""
        for line in backend_config_vars.replace(',', '\n').splitlines():
            key, value = line.split('=', maxsplit=1)
            backend_config[key] = value  # type: ignore

    read_backend_files()
    read_backend_vars()

    if backend_config.get('token') is None and cli_config_path:
        if token := get_cli_credentials(cli_config_path,
                                        str(backend_config['hostname'])):
            backend_config['token'] = token
        else:
            debug(f'No token found for {backend_config["hostname"]}')
            return backend_config
def main() -> int:
    if len(sys.argv) < 2:
        sys.stderr.write(f'''Usage:
    STATUS="<status>" {sys.argv[0]} plan <plan.txt
    STATUS="<status>" {sys.argv[0]} status
    {sys.argv[0]} get plan.txt
    {sys.argv[0]} approved plan.txt
''')
        return 1

    debug(repr(sys.argv))

    action_inputs = cast(PlanPrInputs, os.environ)

    module = load_module(Path(action_inputs.get('INPUT_PATH', '.')))

    backend_type, backend_config = complete_config(action_inputs, module)

    backend_fingerprint = fingerprint(backend_type, backend_config, os.environ)

    comment = get_comment(action_inputs, backend_fingerprint)

    status = cast(Status, os.environ.get('STATUS', ''))

    if sys.argv[1] == 'plan':
        body = cast(Plan, sys.stdin.read().strip())
        description = format_classic_description(action_inputs)

        only_if_exists = False
        if action_inputs['INPUT_ADD_GITHUB_COMMENT'] == 'changes-only' and os.environ.get('TF_CHANGES', 'true') == 'false':
            only_if_exists = True

        if comment.comment_url is None and only_if_exists:
            debug('Comment doesn\'t already exist - not creating it')
            return 0

        headers = comment.headers.copy()
        headers['plan_job_ref'] = job_workflow_ref()
        headers['plan_hash'] = plan_hash(body, comment.issue_url)
        headers['plan_text_format'], plan_text = format_plan_text(body)

        comment = update_comment(
            github,
            comment,
            description=description,
            summary=create_summary(body),
            headers=headers,
            body=plan_text,
            status=status
        )

    elif sys.argv[1] == 'status':
        if comment.comment_url is None:
            debug("Can't set status of comment that doesn't exist")
            return 1
        else:
            comment = update_comment(github, comment, status=status)

    elif sys.argv[1] == 'get':
        if comment.comment_url is None:
            debug("Can't get the plan from comment that doesn't exist")
            return 1

        with open(sys.argv[2], 'w') as f:
            f.write(comment.body)

    elif sys.argv[1] == 'approved':

        proposed_plan = remove_warnings(remove_unchanged_attributes(Path(sys.argv[2]).read_text().strip()))
        if comment.comment_url is None:
            sys.stdout.write("Plan not found on PR\n")
            sys.stdout.write("Generate the plan first using the dflook/terraform-plan action. Alternatively set the auto_approve input to 'true'\n")
            sys.stdout.write("If dflook/terraform-plan was used with add_github_comment set to changes-only, this may mean the plan has since changed to include changes\n")
            output('failure-reason', 'plan-changed')
            sys.exit(1)

        if not is_approved(proposed_plan, comment):

            sys.stdout.write("Not applying the plan - it has changed from the plan on the PR\n")
            sys.stdout.write("The plan on the PR must be up to date. Alternatively, set the auto_approve input to 'true' to apply outdated plans\n")
            comment = update_comment(github, comment, status=f':x: Plan not applied in {job_markdown_ref()} (Plan has changed)')

            approved_plan_path = os.path.join(os.environ['STEP_TMP_DIR'], 'approved-plan.txt')
            with open(approved_plan_path, 'w') as f:
                f.write(comment.body.strip())
            proposed_plan_path = os.path.join(os.environ['STEP_TMP_DIR'], 'proposed-plan.txt')
            with open(proposed_plan_path, 'w') as f:
                _, formatted_proposed_plan = format_plan_text(proposed_plan.strip())
                f.write(formatted_proposed_plan.strip())

            debug(f'diff {proposed_plan_path} {approved_plan_path}')
            diff_complete = subprocess.run(['diff', proposed_plan_path, approved_plan_path], check=False, capture_output=True, encoding='utf-8')
            sys.stdout.write(diff_complete.stdout)
            sys.stderr.write(diff_complete.stderr)

            if diff_complete.returncode != 0:
                sys.stdout.write("""Performing diff between the pull request plan and the plan generated at execution time.
> are lines from the plan in the pull request
< are lines from the plan generated at execution
Plan differences:
""")

            if comment.headers.get('plan_text_format', 'text') == 'trunc':
                sys.stdout.write('\nThe plan text was too large for a PR comment, not all differences may be shown here.')

            if plan_ref := comment.headers.get('plan_job_ref'):
                sys.stdout.write(f'\nCompare with the plan generated by the dflook/terraform-plan action in {plan_ref}\n')

            output('failure-reason', 'plan-changed')

            step_cache['comment'] = serialize(comment)
            return 1
    }

    if backend_type := os.environ.get('TERRAFORM_BACKEND_TYPE'):
        headers['backend_type'] = backend_type

    headers['label'] = os.environ.get('INPUT_LABEL') or None

    plan_modifier = {}
    if target := os.environ.get('INPUT_TARGET'):
        plan_modifier['target'] = sorted(t.strip() for t in target.replace(',', '\n', ).split('\n') if t.strip())

    if replace := os.environ.get('INPUT_REPLACE'):
        plan_modifier['replace'] = sorted(t.strip() for t in replace.replace(',', '\n', ).split('\n') if t.strip())

    if plan_modifier:
        debug(f'Plan modifier: {plan_modifier}')
        headers['plan_modifier'] = hashlib.sha256(canonicaljson.encode_canonical_json(plan_modifier)).hexdigest()

    return find_comment(github, issue_url, username, headers, legacy_description)

def is_approved(proposed_plan: str, comment: TerraformComment) -> bool:

    if approved_plan_hash := comment.headers.get('plan_hash'):
        debug('Approving plan based on plan hash')
        return plan_hash(proposed_plan, comment.issue_url) == approved_plan_hash
    else:
        debug('Approving plan based on plan text')
        return plan_cmp(proposed_plan, comment.body)

def format_plan_text(plan_text: str) -> Tuple[str, str]:
    """
def is_approved(proposed_plan: str, comment: TerraformComment) -> bool:

    if approved_plan_hash := comment.headers.get('plan_hash'):
        debug('Approving plan based on plan hash')
        return plan_hash(proposed_plan, comment.issue_url) == approved_plan_hash