def 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."""

    args = init_args(inputs)
    backend_tf = dump_backend_hcl(module)

    candidate_versions = list(versions)

    while candidate_versions:
        result = try_init(earliest_non_prerelease_version(candidate_versions),
                          args, inputs.get('INPUT_WORKSPACE',
                                           'default'), backend_tf)
        if isinstance(result, Version):
            return result
        elif isinstance(result, Constraint):
            candidate_versions = list(
                apply_constraints(candidate_versions, [result]))
        elif result is None:
            return None
        else:
            candidate_versions = list(
                apply_constraints(
                    candidate_versions,
                    [Constraint(f'!={earliest_version(candidate_versions)}')]))

    return None
def get_backend_constraints(
        module: TerraformModule,
        backend_config_vars: dict[str, str]) -> list[Constraint]:
    """
    Get any version constraints we can glean from the backend configuration variables

    This should be enough to get a version of terraform that can init the backend and pull the state
    """

    backend_type, config = backend_config(module)
    backend_constraints = json.loads(
        importlib.resources.read_binary('terraform_version',
                                        'backend_constraints.json'))

    if backend_type == 'azurerm':
        backend_type = 'azure'

    if backend_type not in backend_constraints:
        return []

    constraints = [
        Constraint(constraint)
        for constraint in backend_constraints[backend_type]['terraform']
    ]

    for config_var in config | backend_config_vars:
        if config_var not in backend_constraints[backend_type][
                'config_variables']:
            continue

        for constraint in backend_constraints[backend_type][
                'config_variables'][config_var]:
            constraints.append(Constraint(constraint))

    for env_var in os.environ:
        if env_var not in backend_constraints[backend_type][
                'environment_variables']:
            continue

        for constraint in backend_constraints[backend_type][
                'environment_variables'][env_var]:
            constraints.append(Constraint(constraint))

    return constraints
def test_backend_constraints():

    module = hcl2.loads('''
        terraform {
            backend "oss" {
                access_key = "sausage"
                mystery = true
                assume_role {
                    role_arn = "asdasd"
                    session_name = "hello"
                }
            }
        }
    ''')

    assert get_backend_constraints(module, {}) == [
        Constraint('>=0.12.2'),
        Constraint('>=0.12.2'),
        Constraint('>=0.12.6')
    ]

    module = hcl2.loads('''
        terraform {
            backend "gcs" {
                bucket = "sausage"
                impersonate_service_account = true
                region = "europe-west2"
                unknown = "??"
                path = "hello"
            }
        }
    ''')

    assert get_backend_constraints(module, {}) == [
        Constraint('>=0.9.0'),
        Constraint('>=0.9.0'),
        Constraint('>=0.14.0'),
        Constraint('>=0.11.0'),
        Constraint('<=0.15.3'),
        Constraint('>=0.9.0'),
        Constraint('<=0.14.11')
    ]
Esempio n. 4
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
Esempio n. 5
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
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
Esempio n. 7
0
def test_constraint():
    constraint = Constraint('0.12.4-hello')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == 'hello' and constraint.operator == '='
    assert str(constraint) == '=0.12.4-hello'
    assert constraint.is_allowed(Version('0.12.4-hello'))
    assert not constraint.is_allowed(Version('0.12.4'))

    constraint = Constraint('0.12.4')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '='
    assert str(constraint) == '=0.12.4'

    constraint = Constraint(' =  0  .1 2. 4-hello')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == 'hello' and constraint.operator == '='
    assert str(constraint) == '=0.12.4-hello'

    constraint = Constraint('  =   0  .1 2. 4')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '='
    assert str(constraint) == '=0.12.4'

    constraint = Constraint('  >=  0  .1 2. 4')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '>='
    assert str(constraint) == '>=0.12.4'
    assert constraint.is_allowed(Version('0.12.4'))
    assert constraint.is_allowed(Version('0.12.8'))
    assert constraint.is_allowed(Version('0.13.0'))
    assert constraint.is_allowed(Version('1.1.1'))
    assert not constraint.is_allowed(Version('0.12.3'))
    assert not constraint.is_allowed(Version('0.10.0'))

    constraint = Constraint('  >  0  .1 2. 4')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '>'
    assert str(constraint) == '>0.12.4'
    assert not constraint.is_allowed(Version('0.12.4'))
    assert constraint.is_allowed(Version('0.12.8'))
    assert constraint.is_allowed(Version('0.13.0'))
    assert constraint.is_allowed(Version('1.1.1'))
    assert not constraint.is_allowed(Version('0.12.3'))
    assert not constraint.is_allowed(Version('0.10.0'))

    constraint = Constraint('  <  0  .1 2. 4')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '<'
    assert str(constraint) == '<0.12.4'
    assert not constraint.is_allowed(Version('0.12.4'))
    assert not constraint.is_allowed(Version('0.12.8'))
    assert not constraint.is_allowed(Version('0.13.0'))
    assert not constraint.is_allowed(Version('1.1.1'))
    assert constraint.is_allowed(Version('0.12.3'))
    assert constraint.is_allowed(Version('0.10.0'))

    constraint = Constraint(' <= 0  .1 2. 4')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '<='
    assert str(constraint) == '<=0.12.4'
    assert constraint.is_allowed(Version('0.12.4'))
    assert not constraint.is_allowed(Version('0.12.8'))
    assert not constraint.is_allowed(Version('0.13.0'))
    assert not constraint.is_allowed(Version('1.1.1'))
    assert constraint.is_allowed(Version('0.12.3'))
    assert constraint.is_allowed(Version('0.10.0'))

    constraint = Constraint('  !=  0  .1 2. 4')
    assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '!='
    assert str(constraint) == '!=0.12.4'
    assert not constraint.is_allowed(Version('0.12.4'))
    assert constraint.is_allowed(Version('0.12.8'))
    assert constraint.is_allowed(Version('0.13.0'))
    assert constraint.is_allowed(Version('1.1.1'))
    assert constraint.is_allowed(Version('0.12.3'))
    assert constraint.is_allowed(Version('0.10.0'))

    constraint = Constraint('1')
    assert (constraint.major == 1 and constraint.minor is None
            and constraint.patch is None and constraint.pre_release == ''
            and constraint.operator == '=')

    assert str(constraint) == '=1'
    assert constraint.is_allowed(Version('1.0.0'))
    assert not constraint.is_allowed(Version('1.0.1'))
    assert not constraint.is_allowed(Version('0.0.9'))
    assert not constraint.is_allowed(Version('1.0.0-wooo'))

    constraint = Constraint('1.2')
    assert (constraint.major == 1 and constraint.minor == 2
            and constraint.patch is None and constraint.pre_release == ''
            and constraint.operator == '=')

    assert str(constraint) == '=1.2'

    constraint = Constraint('~>1.2.3')
    assert constraint.major == 1 and constraint.minor == 2 and constraint.patch == 3 and constraint.pre_release == '' and constraint.operator == '~>'
    assert str(constraint) == '~>1.2.3'

    constraint = Constraint('~>1.2')
    assert (constraint.major == 1 and constraint.minor == 2
            and constraint.patch is None and constraint.pre_release == ''
            and constraint.operator == '~>')

    assert str(constraint) == '~>1.2'

    assert Constraint('0.12.0') < Constraint('0.12.1')
    assert Constraint('0.12.0') == Constraint('0.12.0')
    assert Constraint('0.12.0') != Constraint('0.15.0')

    test_ordering = [
        Constraint('0.11.0'),
        Constraint('<0.12.0'),
        Constraint('<=0.12.0'),
        Constraint('0.12.0'),
        Constraint('~>0.12.0'),
        Constraint('>=0.12.0'),
        Constraint('>0.12.0'),
        Constraint('0.12.5'),
        Constraint('0.13.0'),
    ]
    assert test_ordering == sorted(test_ordering)
    result = subprocess.run([terraform_path, 'state', 'pull'],
                            env=os.environ | {
                                'TF_INPUT': 'false',
                                'TF_WORKSPACE': workspace
                            },
                            capture_output=True,
                            cwd=module_dir)
    debug(f'{result.args=}')
    debug(f'{result.returncode=}')
    debug(f'{result.stdout.decode()=}')
    debug(f'{result.stderr.decode()=}')

    if result.returncode != 0:
        if b'does not support state version 4' in result.stderr:
            return Constraint('>=0.12.0')
        raise Exception(result.stderr)

    try:
        state = json.loads(result.stdout.decode())
        if state['version'] == 4 and state['serial'] == 0 and not state.get(
                'outputs', {}):
            return None  # This workspace has no state

        if b'no state' in result.stderr:
            return None

        if terraform < Version('0.12.0'):
            # terraform_version is reported correctly in state output
            return Version(state['terraform_version'])
    if version := try_read_asdf(inputs, github_env.get('GITHUB_WORKSPACE',
                                                       '/'), versions):
        sys.stdout.write(
            'Using terraform version specified in .tool-versions file\n')
        return version

    if version := try_read_env(actions_env, versions):
        sys.stdout.write(
            'Using latest terraform version that matches the TERRAFORM_VERSION constraints\n'
        )
        return version

    if inputs.get('INPUT_BACKEND_CONFIG', '').strip():
        # key=value form of backend config was introduced in 0.9.1
        versions = list(apply_constraints(versions, [Constraint('>=0.9.1')]))

    try:
        backend_config = read_backend_config_vars(inputs)
        versions = list(
            apply_constraints(versions,
                              get_backend_constraints(module, backend_config)))
        backend_type = get_backend_type(module)
    except Exception as e:
        debug('Failed to get backend config')
        debug(str(e))
        return latest_non_prerelease_version(versions)

    if backend_type not in ['remote', 'local']:
        if version := try_guess_state_version(inputs, module, versions):
            sys.stdout.write(