Skip to content

Commit 9775952

Browse files
authored
Add support to multi-account environments (#30)
Co-authored-by: Gábor NÉMETH <[email protected]>
1 parent 1aedeef commit 9775952

File tree

5 files changed

+270
-10
lines changed

5 files changed

+270
-10
lines changed

.gitignore

+112
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,115 @@ dist/
44

55
*.pyc
66
__pycache__/
7+
8+
# Created by https://www.toptal.com/developers/gitignore/api/pycharm+all,visualstudiocode
9+
# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all,visualstudiocode
10+
11+
### PyCharm+all ###
12+
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
13+
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
14+
15+
# User-specific stuff
16+
.idea/**/workspace.xml
17+
.idea/**/tasks.xml
18+
.idea/**/usage.statistics.xml
19+
.idea/**/dictionaries
20+
.idea/**/shelf
21+
22+
# AWS User-specific
23+
.idea/**/aws.xml
24+
25+
# Generated files
26+
.idea/**/contentModel.xml
27+
28+
# Sensitive or high-churn files
29+
.idea/**/dataSources/
30+
.idea/**/dataSources.ids
31+
.idea/**/dataSources.local.xml
32+
.idea/**/sqlDataSources.xml
33+
.idea/**/dynamic.xml
34+
.idea/**/uiDesigner.xml
35+
.idea/**/dbnavigator.xml
36+
37+
# Gradle
38+
.idea/**/gradle.xml
39+
.idea/**/libraries
40+
41+
# Gradle and Maven with auto-import
42+
# When using Gradle or Maven with auto-import, you should exclude module files,
43+
# since they will be recreated, and may cause churn. Uncomment if using
44+
# auto-import.
45+
# .idea/artifacts
46+
# .idea/compiler.xml
47+
# .idea/jarRepositories.xml
48+
# .idea/modules.xml
49+
# .idea/*.iml
50+
# .idea/modules
51+
# *.iml
52+
# *.ipr
53+
54+
# CMake
55+
cmake-build-*/
56+
57+
# Mongo Explorer plugin
58+
.idea/**/mongoSettings.xml
59+
60+
# File-based project format
61+
*.iws
62+
63+
# IntelliJ
64+
out/
65+
66+
# mpeltonen/sbt-idea plugin
67+
.idea_modules/
68+
69+
# JIRA plugin
70+
atlassian-ide-plugin.xml
71+
72+
# Cursive Clojure plugin
73+
.idea/replstate.xml
74+
75+
# SonarLint plugin
76+
.idea/sonarlint/
77+
78+
# Crashlytics plugin (for Android Studio and IntelliJ)
79+
com_crashlytics_export_strings.xml
80+
crashlytics.properties
81+
crashlytics-build.properties
82+
fabric.properties
83+
84+
# Editor-based Rest Client
85+
.idea/httpRequests
86+
87+
# Android studio 3.1+ serialized cache file
88+
.idea/caches/build_file_checksums.ser
89+
90+
### PyCharm+all Patch ###
91+
# Ignore everything but code style settings and run configurations
92+
# that are supposed to be shared within teams.
93+
94+
.idea/*
95+
96+
!.idea/codeStyles
97+
!.idea/runConfigurations
98+
99+
### VisualStudioCode ###
100+
.vscode/*
101+
!.vscode/settings.json
102+
!.vscode/tasks.json
103+
!.vscode/launch.json
104+
!.vscode/extensions.json
105+
!.vscode/*.code-snippets
106+
107+
# Local History for Visual Studio Code
108+
.history/
109+
110+
# Built Visual Studio Code Extensions
111+
*.vsix
112+
113+
### VisualStudioCode Patch ###
114+
# Ignore all local history of files
115+
.history
116+
.ionide
117+
118+
# End of https://www.toptal.com/developers/gitignore/api/pycharm+all,visualstudiocode

README.md

+9
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ The following environment variables can be configured:
3131
* `USE_EXEC`: whether to use `os.exec` instead of `subprocess.Popen` (try using this in case of I/O issues)
3232
* `<SERVICE>_ENDPOINT`: setting a custom service endpoint, e.g., `COGNITO_IDP_ENDPOINT=http://example.com`
3333
* `AWS_DEFAULT_REGION`: the AWS region to use (default: `us-east-1`, or determined from local credentials if `boto3` is installed)
34+
* `CUSTOMIZE_ACCESS_KEY`: enables to override the static AWS Access Key ID. The following cases are taking precedence over each other from top to bottom:
35+
* `AWS_ACCESS_KEY_ID` environment variable is set
36+
* `access_key` is set in the Terraform AWS provider
37+
* `AWS_PROFILE` environment variable is set and configured
38+
* `AWS_DEFAULT_PROFILE` environment variable is set and configured
39+
* `default` profile's credentials are configured
40+
* falls back to the default `AWS_ACCESS_KEY_ID` mock value
41+
* `AWS_ACCESS_KEY_ID`: AWS Access Key ID to use for multi account setups (default: `test` -> account ID: `000000000000`)
3442

3543
## Usage
3644

@@ -39,6 +47,7 @@ please refer to the man pages of `terraform --help`.
3947

4048
## Change Log
4149

50+
* v0.14: Add support to multi-account environments
4251
* v0.13: Fix S3 automatic `use_s3_path_style` detection when setting S3_HOSTNAME or LOCALSTACK_HOSTNAME
4352
* v0.12: Fix local endpoint overrides for Terraform AWS provider 5.9.0; fix parsing of alias and region defined as value lists
4453
* v0.11: Minor fix to handle boolean values in S3 backend configs

bin/tflocal

+31-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ from localstack_client import config # noqa: E402
2424
import hcl2 # noqa: E402
2525

2626
DEFAULT_REGION = "us-east-1"
27+
DEFAULT_ACCESS_KEY = "test"
28+
CUSTOMIZE_ACCESS_KEY = str(os.environ.get("CUSTOMIZE_ACCESS_KEY")).strip().lower() in ["1", "true"]
2729
LOCALHOST_HOSTNAME = "localhost.localstack.cloud"
2830
S3_HOSTNAME = os.environ.get("S3_HOSTNAME") or f"s3.{LOCALHOST_HOSTNAME}"
2931
USE_EXEC = str(os.environ.get("USE_EXEC")).strip().lower() in ["1", "true"]
@@ -33,7 +35,7 @@ LOCALSTACK_HOSTNAME = os.environ.get("LOCALSTACK_HOSTNAME") or "localhost"
3335
EDGE_PORT = int(os.environ.get("EDGE_PORT") or 4566)
3436
TF_PROVIDER_CONFIG = """
3537
provider "aws" {
36-
access_key = "test"
38+
access_key = "<access_key>"
3739
secret_key = "test"
3840
skip_credentials_validation = true
3941
skip_metadata_api_check = true
@@ -116,8 +118,12 @@ def create_provider_config_file(provider_aliases=None):
116118
# create provider configs
117119
provider_configs = []
118120
for provider in provider_aliases:
121+
provider_config = TF_PROVIDER_CONFIG.replace(
122+
"<access_key>",
123+
get_access_key(provider) if CUSTOMIZE_ACCESS_KEY else DEFAULT_ACCESS_KEY
124+
)
119125
endpoints = "\n".join([f'{s} = "{get_service_endpoint(s)}"' for s in services])
120-
provider_config = TF_PROVIDER_CONFIG.replace("<endpoints>", endpoints)
126+
provider_config = provider_config.replace("<endpoints>", endpoints)
121127
additional_configs = []
122128
if use_s3_path_style():
123129
additional_configs += [" s3_use_path_style = true"]
@@ -243,6 +249,29 @@ def get_region() -> str:
243249
return region or DEFAULT_REGION
244250

245251

252+
def get_access_key(provider: dict) -> str:
253+
access_key = str(os.environ.get("AWS_ACCESS_KEY_ID") or provider.get("access_key", "")).strip()
254+
if access_key and access_key != DEFAULT_ACCESS_KEY:
255+
# Change live access key to mocked one
256+
return deactivate_access_key(access_key)
257+
try:
258+
# If boto3 is installed, try to get the access_key from local credentials.
259+
# Note that boto3 is currently not included in the dependencies, to
260+
# keep the library lightweight.
261+
import boto3
262+
access_key = boto3.session.Session().get_credentials().access_key
263+
except Exception:
264+
pass
265+
# fall back to default region
266+
return deactivate_access_key(access_key or DEFAULT_ACCESS_KEY)
267+
268+
269+
def deactivate_access_key(access_key: str) -> str:
270+
"""Safe guarding user from accidental live credential usage by deactivating access key IDs.
271+
See more: https://docs.localstack.cloud/references/credentials/"""
272+
return "L" + access_key[1:] if access_key[0] == "A" else access_key
273+
274+
246275
def get_service_endpoint(service: str) -> str:
247276
"""Get the service endpoint URL for the given service name"""
248277
# allow configuring a custom endpoint via the environment

setup.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = terraform-local
3-
version = 0.13
3+
version = 0.14
44
url = https://github.com/localstack/terraform-local
55
author = LocalStack Team
66
author_email = [email protected]

tests/test_apply.py

+117-7
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,98 @@
1-
import tempfile
21
import os
3-
import boto3
4-
import uuid
2+
import random
53
import subprocess
6-
from typing import Dict
4+
import tempfile
5+
import uuid
6+
from typing import Dict, Generator
7+
8+
import boto3
9+
import pytest
710

811
THIS_PATH = os.path.abspath(os.path.dirname(__file__))
912
ROOT_PATH = os.path.join(THIS_PATH, "..")
1013
TFLOCAL_BIN = os.path.join(ROOT_PATH, "bin", "tflocal")
1114
LOCALSTACK_ENDPOINT = "http://localhost:4566"
1215

1316

17+
@pytest.mark.parametrize("customize_access_key", [True, False])
18+
def test_customize_access_key_feature_flag(monkeypatch, customize_access_key: bool):
19+
monkeypatch.setenv("CUSTOMIZE_ACCESS_KEY", str(customize_access_key))
20+
21+
# create buckets in multiple accounts
22+
access_key = mock_access_key()
23+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", access_key)
24+
bucket_name = short_uid()
25+
26+
create_test_bucket(bucket_name)
27+
28+
s3_bucket_names_default_account = get_bucket_names()
29+
s3_bucket_names_specific_account = get_bucket_names(aws_access_key_id=access_key)
30+
31+
if customize_access_key:
32+
# if CUSTOMISE_ACCESS_KEY is enabled, the bucket name is only in the specific account
33+
assert bucket_name not in s3_bucket_names_default_account
34+
assert bucket_name in s3_bucket_names_specific_account
35+
else:
36+
# if CUSTOMISE_ACCESS_KEY is disabled, the bucket name is only in the default account
37+
assert bucket_name in s3_bucket_names_default_account
38+
assert bucket_name not in s3_bucket_names_specific_account
39+
40+
41+
def _profile_names() -> Generator:
42+
yield short_uid()
43+
yield "default"
44+
45+
46+
def _generate_test_name(param: str) -> str:
47+
return "random" if param != "default" else param
48+
49+
50+
@pytest.mark.parametrize("profile_name", _profile_names(), ids=_generate_test_name)
51+
def test_access_key_override_by_profile(monkeypatch, profile_name: str):
52+
monkeypatch.setenv("CUSTOMIZE_ACCESS_KEY", "1")
53+
access_key = mock_access_key()
54+
bucket_name = short_uid()
55+
credentials = """
56+
[%s]
57+
aws_access_key_id = %s
58+
aws_secret_access_key = test
59+
region = eu-west-1
60+
""" % (profile_name, access_key)
61+
with tempfile.TemporaryDirectory() as temp_dir:
62+
credentials_file = os.path.join(temp_dir, "credentials")
63+
with open(credentials_file, "w") as f:
64+
f.write(credentials)
65+
66+
if profile_name != "default":
67+
monkeypatch.setenv("AWS_PROFILE", profile_name)
68+
monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", credentials_file)
69+
70+
create_test_bucket(bucket_name)
71+
72+
extra_param = {"aws_access_key_id": None, "aws_secret_access_key": None} if profile_name == "default" else {}
73+
s3_bucket_names_specific_profile = get_bucket_names(**extra_param)
74+
75+
monkeypatch.delenv("AWS_PROFILE", raising=False)
76+
77+
s3_bucket_names_default_account = get_bucket_names()
78+
79+
assert bucket_name in s3_bucket_names_specific_profile
80+
assert bucket_name not in s3_bucket_names_default_account
81+
82+
83+
def test_access_key_override_by_provider(monkeypatch):
84+
monkeypatch.setenv("CUSTOMIZE_ACCESS_KEY", "1")
85+
access_key = mock_access_key()
86+
bucket_name = short_uid()
87+
create_test_bucket(bucket_name, access_key)
88+
89+
s3_bucket_names_default_account = get_bucket_names()
90+
s3_bucket_names_specific_account = get_bucket_names(aws_access_key_id=access_key)
91+
92+
assert bucket_name not in s3_bucket_names_default_account
93+
assert bucket_name in s3_bucket_names_specific_account
94+
95+
1496
def test_s3_path_addressing():
1597
bucket_name = f"bucket.{short_uid()}"
1698
config = """
@@ -45,7 +127,7 @@ def test_use_s3_path_style(monkeypatch):
45127
assert not use_s3_path_style() # noqa
46128

47129

48-
def test_provider_aliases(monkeypatch):
130+
def test_provider_aliases():
49131
queue_name1 = f"q{short_uid()}"
50132
queue_name2 = f"q{short_uid()}"
51133
config = """
@@ -127,15 +209,43 @@ def deploy_tf_script(script: str, env_vars: Dict[str, str] = None):
127209
return out
128210

129211

212+
def get_bucket_names(**kwargs: dict) -> list:
213+
s3 = client("s3", region_name="eu-west-1", **kwargs)
214+
s3_buckets = s3.list_buckets().get("Buckets")
215+
return [s["Name"] for s in s3_buckets]
216+
217+
218+
def create_test_bucket(bucket_name: str, access_key: str = None) -> None:
219+
access_key_section = f'access_key = "{access_key}"' if access_key else ""
220+
config = """
221+
provider "aws" {
222+
%s
223+
region = "eu-west-1"
224+
}
225+
resource "aws_s3_bucket" "test_bucket" {
226+
bucket = "%s"
227+
}""" % (access_key_section, bucket_name)
228+
deploy_tf_script(config)
229+
230+
130231
def short_uid() -> str:
131232
return str(uuid.uuid4())[0:8]
132233

133234

235+
def mock_access_key() -> str:
236+
return str(random.randrange(999999999999)).zfill(12)
237+
238+
134239
def client(service: str, **kwargs):
240+
# if aws access key is not set AND no profile is in the environment,
241+
# we want to set the accesss key and the secret key to test
242+
if "aws_access_key_id" not in kwargs and "AWS_PROFILE" not in os.environ:
243+
kwargs["aws_access_key_id"] = "test"
244+
if "aws_access_key_id" in kwargs and "aws_secret_access_key" not in kwargs:
245+
kwargs["aws_secret_access_key"] = "test"
246+
boto3.setup_default_session()
135247
return boto3.client(
136248
service,
137-
aws_access_key_id="test",
138-
aws_secret_access_key="test",
139249
endpoint_url=LOCALSTACK_ENDPOINT,
140250
**kwargs,
141251
)

0 commit comments

Comments
 (0)