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') ]
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 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
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(