diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0886d81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +ARG BUILDER_IMAGE +ARG AWS_CLI_VERSION +FROM ${BUILDER_IMAGE} AS builder + +WORKDIR /usr/src/app + +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" + +RUN pip install --no-cache-dir --upgrade pip +#RUN apk add cargo + +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +FROM ${BUILDER_IMAGE} AS service + +# Install the aws cli v2, which is now default in alpine +ARG AWS_CLI_VERSION +RUN apk update && apk add aws-cli==${AWS_CLI_VERSION} bash zip curl + +#ENV PATH=/usr/bin:/venv/bin:/root/app/site-packages/bin:$PATH +ENV PATH=/usr/bin:/venv/bin:$PATH +WORKDIR /app/site-packages +COPY --from=builder /venv /venv + +# Add awslocal to site-packages/bin, with priority to awsv2 bin +# As awscli is added for the bindings, v1 will be also added +COPY bin/awslocal-docker /venv/bin/awslocal + +USER root + +VOLUME /app/data +#SHELL ["/bin/bash"] + +ENTRYPOINT ["/venv/bin/python3", "/root/app/site-packages/bin/awslocal"] + diff --git a/bin/awslocal-docker b/bin/awslocal-docker new file mode 100755 index 0000000..c72d2e5 --- /dev/null +++ b/bin/awslocal-docker @@ -0,0 +1,269 @@ +#!/venv/bin/python3 + + +""" +Thin wrapper around the "aws" command line interface (CLI) for use +with LocalStack. + +The "awslocal" CLI allows you to easily interact with your local services +without having to specify "--endpoint-url=http://..." for every single command. + +Example: +Instead of the following command ... +aws --endpoint-url=https://localhost:4568 --no-verify-ssl kinesis list-streams +... you can simply use this: +awslocal kinesis list-streams + +Options: + Run "aws help" for more details on the aws CLI subcommands. +""" + +import os +import sys +import subprocess +import re +from threading import Thread + +from boto3.session import Session + +PARENT_FOLDER = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) +S3_VIRTUAL_ENDPOINT_HOSTNAME = 's3.localhost.localstack.cloud' +if os.path.isdir(os.path.join(PARENT_FOLDER, '.venv')): + sys.path.insert(0, PARENT_FOLDER) + +# names of additional environment variables to pass to subprocess +ENV_VARS_TO_PASS = ['PATH', 'PYTHONPATH', 'SYSTEMROOT', 'HOME', 'TERM', 'PAGER'] + +# service names without endpoints +NO_ENDPOINT_SERVICES = ('help', 'configure') + +from localstack_client import config # noqa: E402 + + +def get_service(): + for param in sys.argv[1:]: + if not param.startswith('-'): + return param + + +def get_service_endpoint(localstack_host=None): + service = get_service() + if service == 's3api': + service = 's3' + endpoints = config.get_service_endpoints(localstack_host=localstack_host) + # defaulting to use the endpoint for STS (could also be one of the other services in the existing list) + # otherwise newly-added services in LocalStack would always need to be added to the _service_ports dict in localstack_client + return endpoints.get(service) or endpoints.get("sts") + + +def usage(): + print(__doc__.strip()) + + +def run(cmd, env=None): + """ + Replaces this process with the AWS CLI process, with the given command and environment + """ + if not env: + env = {} + os.execvpe(cmd[0], cmd, env) + + +def awscli_is_v1(): + try: + from awscli import __version__ as awscli_version + if re.match(r'^1.\d+.\d+$', awscli_version): + return True + return False + except Exception: + version = subprocess.check_output(['aws', '--version']) + version = version.decode('UTF-8') if isinstance(version, bytes) else version + return 'aws-cli/1' in version + + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == '-h': + return usage() + try: + import awscli.clidriver # noqa: F401 + except Exception: + return run_as_separate_process() + patch_awscli_libs() + run_in_process() + + +def prepare_environment(): + + # prepare env vars + env_dict = os.environ.copy() + env_dict['PYTHONWARNINGS'] = os.environ.get( + 'PYTHONWARNINGS', 'ignore:Unverified HTTPS request') + + env_dict.pop('AWS_DATA_PATH', None) + + session = Session() + credentials = session.get_credentials() + + if not credentials: + env_dict['AWS_ACCESS_KEY_ID'] = 'test' + env_dict['AWS_SECRET_ACCESS_KEY'] = 'test' + + if not session.region_name: + env_dict['AWS_DEFAULT_REGION'] = 'us-east-1' + + # update environment variables in the current process + os.environ.update(env_dict) + + return env_dict + + +def prepare_cmd_args(): + # get service and endpoint + localstack_host = os.environ.get('LOCALSTACK_HOST') + endpoint = get_service_endpoint(localstack_host=localstack_host) + service = get_service() + + if not endpoint and service and service not in NO_ENDPOINT_SERVICES: + msg = 'Unable to find LocalStack endpoint for service "%s"' % service + print('ERROR: %s' % msg) + return sys.exit(1) + + # prepare cmd args + cmd_args = sys.argv + if endpoint: + cmd_args.insert(1, '--endpoint-url=%s' % endpoint) + if 'https' in endpoint: + cmd_args.insert(2, '--no-verify-ssl') + # TODO: check the logic below and make it more resilient + if 'cloudformation' in cmd_args and any(cmd in cmd_args for cmd in ['deploy', 'package']): + if awscli_is_v1(): + cmd_args.insert(2, '--s3-endpoint-url=%s' % endpoint) + else: + print('!NOTE! awslocal does not currently work with the cloudformation package/deploy commands supplied by ' + 'the AWS CLI v2. Please run "pip install awscli" to install version 1 of the AWS CLI') + + return list(cmd_args) + + +def run_as_separate_process(): + """ + Constructs a command line string and calls "aws" as an external process. + """ + env_dict = prepare_environment() + env_dict = {k: v for k, v in env_dict.items() if k.startswith('AWS_') or k in ENV_VARS_TO_PASS} + + cmd_args = prepare_cmd_args() + cmd_args[0] = 'aws' + + # run the command + run(cmd_args, env_dict) + + +def run_in_process(): + """ + Modifies the command line args in sys.argv and calls the AWS cli + method directly in this process. + """ + profile_name = ( + sys.argv[sys.argv.index('--profile') + 1] if '--profile' in sys.argv else 'default') + + endpoint_url = ( + sys.argv[sys.argv.index('--endpoint-url') + 1] if '--endpoint-url' in sys.argv else '') + + if not endpoint_url: + endpoint_url = ( + sys.argv[sys.argv.index('--endpoint') + 1] if '--endpoint' in sys.argv else '') + + import botocore.session + + session = botocore.session.get_session() + + if S3_VIRTUAL_ENDPOINT_HOSTNAME not in endpoint_url: + try: + profiles = session.full_config.get('profiles') + if profiles: + current_profile = profiles.get(profile_name) or {} + addressing_style = current_profile.get('s3', {}).get('addressing_style') + if addressing_style in ['virtual', 'auto']: + msg = ("Addressing style is set to 'virtual' or 'auto' in the aws config. " + "Please change it to 'path'.") + print('WARNING: %s' % msg) + except KeyError: + pass + + import awscli.clidriver + if os.environ.get('LC_CTYPE', '') == 'UTF-8': + os.environ['LC_CTYPE'] = 'en_US.UTF-8' + prepare_environment() + prepare_cmd_args() + sys.exit(awscli.clidriver.main()) + + +def patch_awscli_libs(): + # TODO: Temporary fix until this PR is merged: https://github.com/aws/aws-cli/pull/3309 + + import inspect + from awscli import paramfile + from awscli.customizations.cloudformation import deploy, package + from botocore.serialize import Serializer + + # add parameter definitions + if awscli_is_v1(): + paramfile.PARAMFILE_DISABLED.add('custom.package.s3-endpoint-url') + paramfile.PARAMFILE_DISABLED.add('custom.deploy.s3-endpoint-url') + + s3_endpoint_arg = { + 'name': 's3-endpoint-url', + 'help_text': ( + 'URL of storage service where packaged templates and artifacts' + ' will be uploaded. Useful for testing and local development' + ' or when uploading to a non-AWS storage service that is' + ' nonetheless S3-compatible.' + ) + } + + # add argument definition for S3 endpoint to use for CF package/deploy + for arg_table in [deploy.DeployCommand.ARG_TABLE, package.PackageCommand.ARG_TABLE]: + existing = [a for a in arg_table if a.get('name') == 's3-endpoint-url'] + if not existing: + arg_table.append(s3_endpoint_arg) + + def wrap_create_client(_init_orig): + """ Returns a new constructor that wraps the S3 client creation to use the custom endpoint for CF. """ + + def new_init(self, session, *args, **kwargs): + def create_client(*args, **kwargs): + if args and args[0] == 's3': + # get stack frame of caller + curframe = inspect.currentframe() + calframe = inspect.getouterframes(curframe, 2) + fname = calframe[1].filename + + # check if we are executing within the target method + is_target = (os.path.join('cloudformation', 'deploy.py') in fname + or os.path.join('cloudformation', 'package.py') in fname) + if is_target: + if 'endpoint_url' not in kwargs: + args_passed = inspect.getargvalues(calframe[1].frame).locals + kwargs['endpoint_url'] = args_passed['parsed_args'].s3_endpoint_url + return create_client_orig(*args, **kwargs) + + if not hasattr(session, '_s3_endpoint_patch_applied'): + create_client_orig = session.create_client + session.create_client = create_client + session._s3_endpoint_patch_applied = True + _init_orig(self, session, *args, **kwargs) + + return new_init + + deploy.DeployCommand.__init__ = wrap_create_client(deploy.DeployCommand.__init__) + package.PackageCommand.__init__ = wrap_create_client(package.PackageCommand.__init__) + + # Apply a patch to botocore, to skip adding `data-` host prefixes to endpoint URLs, e.g. for: + # awslocal servicediscovery discover-instances --service-name s1 --namespace-name ns1 + if hasattr(Serializer, "_expand_host_prefix"): + config.patch_expand_host_prefix() + + +if __name__ == '__main__': + main() diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4b48716 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + + awslocal: + image: marcellodesales/awscli-local:0.22.7 + platform: linux/amd64 + build: + context: . + target: service + args: + BUILDER_IMAGE: python:3.13.2-alpine3.21 + AWS_CLI_VERSION: 2.22.10-r0 diff --git a/requirements.txt b/requirements.txt index e51a5f6..cd807db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ localstack-client +awscli diff --git a/tflocal/Dockerfile b/tflocal/Dockerfile new file mode 100644 index 0000000..88faf50 --- /dev/null +++ b/tflocal/Dockerfile @@ -0,0 +1,10 @@ +ARG BUILDER_IMAGE +FROM ${BUILDER_IMAGE} AS builder + +RUN apk add opentofu && ln -s /usr/bin/tofu /usr/bin/terraform + +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +ENTRYPOINT ["/venv/bin/tflocal"] + diff --git a/tflocal/README.md b/tflocal/README.md new file mode 100644 index 0000000..2e1a6a7 --- /dev/null +++ b/tflocal/README.md @@ -0,0 +1,49 @@ +# Terraform Local - tflocal + +* Based on https://github.com/localstack/terraform-local +* Parent image is awslocal +* Depends on OpenTofu as the terraform shim + +```console + +$ tflocal +Usage: tofu [global options] [args] + +The available commands for execution are listed below. +The primary workflow commands are given first, followed by +less common or more advanced commands. + +Main commands: + init Prepare your working directory for other commands + validate Check whether the configuration is valid + plan Show changes required by the current configuration + apply Create or update infrastructure + destroy Destroy previously-created infrastructure + +All other commands: + console Try OpenTofu expressions at an interactive command prompt + fmt Reformat your configuration in the standard style + force-unlock Release a stuck lock on the current workspace + get Install or upgrade remote OpenTofu modules + graph Generate a Graphviz graph of the steps in an operation + import Associate existing infrastructure with a OpenTofu resource + login Obtain and save credentials for a remote host + logout Remove locally-stored credentials for a remote host + metadata Metadata related commands + output Show output values from your root module + providers Show the providers required for this configuration + refresh Update the state to match remote systems + show Show the current state or a saved plan + state Advanced state management + taint Mark a resource instance as not fully functional + test Execute integration tests for OpenTofu modules + untaint Remove the 'tainted' state from a resource instance + version Show the current OpenTofu version + workspace Workspace management + +Global options (use these before the subcommand, if any): + -chdir=DIR Switch to a different working directory before executing the + given subcommand. + -help Show this help output, or the help for a specified subcommand. + -version An alias for the "version" subcommand. +``` diff --git a/tflocal/docker-compose.yaml b/tflocal/docker-compose.yaml new file mode 100644 index 0000000..ac8d640 --- /dev/null +++ b/tflocal/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + + awslocal: + image: marcellodesales/terraform-local:0.21.0 + platform: linux/amd64 + build: + context: . + args: + BUILDER_IMAGE: marcellodesales/awscli-local:0.22.0.7 + diff --git a/tflocal/requirements.txt b/tflocal/requirements.txt new file mode 100644 index 0000000..49079cf --- /dev/null +++ b/tflocal/requirements.txt @@ -0,0 +1 @@ +terraform-local==v0.21.0