diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 7233f059..dddf3830 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -253,6 +253,76 @@ jobs: - name: Integration Tests run: make integration-tests + awscliv2_test: + runs-on: ${{ matrix.os }} + needs: build + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.8","3.9","3.10","3.11","3.12"] + + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build + + # setuptools_scm sometimes will regenerate _version.py which causes + # Makefile to rebuild things. To avoid unnecessary rebuilds we set the + # following environment variable. + - name: Set setuptools_scm environment variable + shell: bash + run: | + PYTHONPATH=src python -c 'from awscli_login._version import __version__; print(f"SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AWSCLI_LOGIN={__version__}")' > "$RUNNER_TEMP/_awscli_version" + cat "$RUNNER_TEMP/_awscli_version" >> "$GITHUB_ENV" + + - name: Touch build artifacts + run: | + touch docs/readme.rst + touch src/awscli_login/_version.py + touch dist/* # Must be newer than docs/readme.rst + touch .twinecheck # Must be newer than build + + - name: Get pip cache dir + id: pip-cache + run: echo "::set-output name=dir::$(python -m pip cache dir)" + + - name: Save pip cache + uses: actions/cache@v3 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-pip-${{ secrets.CACHE_VERSION }} + + - name: Get date + id: date + run: echo "::set-output name=week::$(date '+%U')" + + - name: Upgrade bash (macOS) + if: matrix.os == 'macOS-latest' + run: brew install bash + + - name: Install awscli-login package + shell: bash + run: | + make venv.v2 + + - name: Integration Tests + shell: bash + run: | + make integration-tests-v2 + env: + PYTHONWARNINGS: "ignore" # Disable Python warnings + publish: needs: [unit_tests, integration_tests] runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index f17fbc82..8da9d17f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ cache dist htmlcov venv +venv.v2 docs/src/ .*.docker diff --git a/Makefile b/Makefile index 2d040331..e34d9ffc 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ deps-test: # Python packages needed to run integration_tests tests deps-integration-test: - $(PIP) vcrpy + $(PIP) awscli vcrpy # Python packages needed to publish a production or test release deps-publish: @@ -121,17 +121,37 @@ install-build: .install-build # https://github.com/actions/runner-images/issues/2216 ifeq ($(RUNNER_OS),Windows) idp_integration_deps= -integration-tests: export AWSCLI_LOGIN_SKIP_DOCKER_TESTS=1 -integration-tests: export AWSCLI_LOGIN_WINDOWS_TEST=1 +integration-tests integration-tests-v2: export AWSCLI_LOGIN_SKIP_DOCKER_TESTS=1 +integration-tests integration-tests-v2: export AWSCLI_LOGIN_WINDOWS_TEST=1 # As of 9/13/2024, macos-latest does not support docker. This may # change by the end of the year. See issue #208. else ifeq ($(RUNNER_OS),macOS) idp_integration_deps= -integration-tests: export AWSCLI_LOGIN_SKIP_DOCKER_TESTS=1 +integration-tests integration-tests-v2: export AWSCLI_LOGIN_SKIP_DOCKER_TESTS=1 else idp_integration_deps=.idp.docker endif integration-tests: $(idp_integration_deps) + aws --version 2>/dev/null | grep '^aws-cli/1.' || (echo "Not running awscli V1!"; exit 1) + make -C src/integration_tests/ + +ifeq ($(RUNNER_OS),Windows) + VBIN := Scripts + VPKG := lib/site-packages* +else + VBIN := bin + VPKG := lib/python*/site-packages +endif +venv.v2: $(RELEASE) + rm -rf $@ + python -m venv $@ + $@/$(VBIN)/python -m pip install vcrpy $< + +integration-tests-v2: export AWSCLI_TEST_V2:=1 +integration-tests-v2: export AWSCLI_TEST_PLUGIN_PATH=$(wildcard $(PWD)/venv.v2/$(VPKG)) +integration-tests-v2: export PATH:=$(PWD)/venv.v2/$(VBIN):$(PATH) +integration-tests-v2: $(idp_integration_deps) venv.v2 + aws --version 2>/dev/null | grep '^aws-cli/2.' || (echo "Not running awscli V2!"; exit 1) make -C src/integration_tests/ test_fast: export AWSCLI_LOGIN_FAST_TEST_ONLY=1 diff --git a/docs/readme/body.rst b/docs/readme/body.rst index 4d911e36..c46853f4 100644 --- a/docs/readme/body.rst +++ b/docs/readme/body.rst @@ -1,3 +1,136 @@ +Configuration +============= + +After awscli-login has been installed, run the following command +to enable the plugin:: + + $ aws configure set plugins.login awscli_login + +If you receive a bad interpreter error or other error please see +the `Known Issues`_ section below. If it succeeds the AWS CLI +configuration file ``~/.aws/config`` should be updated with the +following section:: + + [plugins] + login = awscli_login + +AWS CLI V2 Configuration +------------------------ + +If you are configuring `AWS CLI`_ V2, The path to the site +packages directory where ``awscli-login`` resides must be supplied +as well. This can be looked up using the following command:: + + $ pip show awscli-login + Name: awscli-login + Version: 1.0 + Summary: Plugin for the AWS CLI that retrieves and rotates credentials using SAML ECP and STS. + Home-page: + Author: + Author-email: "David D. Riddle" + License: MIT License + Location: /usr/lib/python3.12/site-packages + Requires: botocore, keyring, lxml, requests + Required-by: + +The ``Location`` field has the required path information, and must be passed to ``aws configure``:: + + $ aws configure set plugins.cli_legacy_plugin_path <> + +Note: If your output matched the example above, you would paste in +``/usr/lib/python3.12/site-packages``. + +On POSIX systems such as macOS and Linux the preceding can be set +more easily using the following one-liner:: + + $ aws configure set plugins.cli_legacy_plugin_path $(pip show awscli-login | sed -nr 's/^Location: (.*)/\1/p') + +If it succeeds the AWS CLI configuration file ``~/.aws/config`` +should be updated with the following section:: + + [plugins] + login = awscli_login + cli_legacy_plugin_path = /usr/lib/python3.12/site-packages + +Note that ``cli_legacy_plugin_path`` should point to the same value +as given in the ``Location`` field given by ``pip show awscli-login`` +above. + +System Configuration +-------------------- + +The command or script ``aws-login`` must be on your ``$PATH``. If it +is not on your path you can use the following command to determine +its location:: + + $ pip show awscli-login --files + +On Windows look for the file ``aws-login.exe``, on POSIX systems +look for ``bin/aws-login``. Then add the path to the environment variable +``PATH``. If the path is a relative path, note that it is relative +to the ``Location`` field. You may set your PATH using the following +examples: + +* Linux/Mac:: + + $ export PATH="$PATH:/usr/local/bin" + +* Windows:: + + $ $env:PATH+=';C:\Users\USERNAME\AppData\Roaming\Python\Python312\Scripts' + +Verify that ``aws-login`` appears on your PATH: + +* Linux/Mac:: + + $ which aws-login + /usr/local/bin/aws-login + +* Windows:: + + $ Get-Command aws-login + + CommandType Name Version Source + ----------- ---- ------- ------ + Application aws-login.exe 0.0.0.0 C:\Users\USERNAME\AppData\Roaming\Python\Python312\Scripts\aws-login.exe + +Upgrade +======= + +If you are upgrading from ``awscli-login`` version ``0.2b1`` or +earlier, please follow the `Installation`_ instructions above, then +proceed to the `Getting Started`_ section below to reconfigure your +profiles which is required. + +Reconfiguration is required because in previous versions of +``awscli-login`` credentials were directly stored in AWS CLI's +credentials file ``~/.aws/credentials``. This is no longer the case. +Now each profile contains a reference to the ``aws-login`` script. + +Previously ``~/.aws/credentials`` would have looked looked like this +after a log out:: + + [default] + aws_access_key_id = abc + aws_secret_access_key = def + aws_session_token = ghi + aws_security_token = ghi + +After a reconfiguration, the example ``~/.aws/credentials`` file +above should look like this:: + + [default] + credential_process = aws-login --profile default + +If you attempt to log into a profile that has not been reconfigured +you will receive the following error message:: + + $ aws login + Credential process is not set for current profile "foo". + Reconfigure using: + + aws login configure + Getting Started =============== diff --git a/docs/readme/readme.rst b/docs/readme/readme.rst index c0afaf9f..4b13c32e 100644 --- a/docs/readme/readme.rst +++ b/docs/readme/readme.rst @@ -3,16 +3,21 @@ Installation ============ -The simplest way to install the ``awscli-login`` plugin is to use pip. +If you are upgrading from ``awscli-login`` version ``0.2b1`` or earlier, +please logout then uninstall ``awscli-login`` and ``awscli`` to +ensure a smooth upgrade:: -.. include:: readme/install.{{ ENV }}.rst + $ aws logout + $ pip uninstall awscli-login awscli + +Before installing ``awscli-login`` you need to install `AWS CLI`_ V1 or V2. +Instructions for installing V2 can be found on Amazon's website `here`_: -After ``awscli-login`` has been installed, run the following command -to enable the plugin:: +.. _here: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html +.. _AWS CLI: https://aws.amazon.com/cli/ - $ aws configure set plugins.login awscli_login +AWS CLI V1 can be installed using ``pip install awscli``. -If you receive a bad interpreter error or other error please see -the `Known Issues`_ section below. +.. include:: readme/install.{{ ENV }}.rst .. include:: readme/body.rst diff --git a/pyproject.toml b/pyproject.toml index 259a1616..d90110fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,6 @@ build-backend = "setuptools.build_meta" name = "awscli-login" dynamic = ["version"] dependencies = [ - "awscli", "botocore", "keyring", "lxml", @@ -42,6 +41,7 @@ Changelog = "https://github.com/techservicesillinois/awscli-login/blob/master/CH [project.optional-dependencies] test = [ + "awscli", "tblib", "wurlitzer", "vcrpy", diff --git a/src/awscli_login/__init__.py b/src/awscli_login/__init__.py index bc5b9220..4c4a9c74 100644 --- a/src/awscli_login/__init__.py +++ b/src/awscli_login/__init__.py @@ -1,8 +1,12 @@ # Rudimentary documentation for the aws-cli plugin API can be found # here: https://github.com/aws/aws-cli/issues/1261 +import copy +import json import logging +import subprocess from argparse import Namespace +from tempfile import NamedTemporaryFile, TemporaryDirectory try: from awscli.customizations.commands import BasicCommand @@ -16,8 +20,7 @@ class BasicCommand(): # type: ignore class Session(): # type: ignore pass -from .__main__ import main, logout -from .configure import configure +from .configure import configure, exit_if_credential_process_not_set logger = logging.getLogger(__package__) @@ -43,7 +46,25 @@ def inject_subcommands(command_table, session: Session, **kwargs): command_table['configure'] = Configure(session) -class Login(BasicCommand): +class ExternalCommand(BasicCommand): + """ + Used to run subcommands in the external aws-login script. + """ + + def _run_main(self, args: Namespace, parsed_globals): + with TemporaryDirectory() as tmpdir: + tmp = NamedTemporaryFile(dir=tmpdir, delete=False) + tmp.write(bytes(json.dumps(vars(args)), 'utf-8')) + tmp.close() + + cmd = ["aws-login", f"--{self.NAME}", tmp.name] + if self._session.profile: + cmd += ["--profile", self._session.profile] + + return subprocess.run(cmd).returncode + + +class Login(ExternalCommand): NAME = 'login' DESCRIPTION = ('is a plugin that manages retrieving and rotating' ' Amazon STS keys using the Shibboleth IdP and Duo' @@ -144,10 +165,14 @@ class Login(BasicCommand): UPDATE = False def _run_main(self, args: Namespace, parsed_globals): - return main(args, self._session) + r = exit_if_credential_process_not_set(copy.copy(args), self._session) + if r: + return r + else: + return super()._run_main(args, self._session) -class Logout(BasicCommand): +class Logout(ExternalCommand): NAME = 'logout' DESCRIPTION = (''' Log out of selected profile by clearing the profile's credentials @@ -167,9 +192,6 @@ class Logout(BasicCommand): UPDATE = False - def _run_main(self, args: Namespace, parsed_globals): - return logout(args, self._session) - class Configure(BasicCommand): NAME = 'login' diff --git a/src/awscli_login/__main__.py b/src/awscli_login/__main__.py index 70f4e883..75b62898 100755 --- a/src/awscli_login/__main__.py +++ b/src/awscli_login/__main__.py @@ -21,7 +21,6 @@ class Session(): # type: ignore from ._typing import Role from .util import ( get_selection, - raise_if_credential_process_not_set, ) logger = logging.getLogger(__package__) @@ -53,8 +52,6 @@ def login(profile: Profile, session: Session, interactive: bool = True): # Exit if already logged in if interactive: - raise_if_credential_process_not_set(session, profile.name) - try: profile.raise_if_logged_in() if profile.force_refresh: diff --git a/src/awscli_login/configure.py b/src/awscli_login/configure.py index 4dd26a93..b2cba6b3 100644 --- a/src/awscli_login/configure.py +++ b/src/awscli_login/configure.py @@ -5,7 +5,7 @@ class Session(): # type: ignore pass from .config import Profile, error_handler -from .util import update_credential_file +from .util import update_credential_file, raise_if_credential_process_not_set @error_handler() @@ -15,3 +15,10 @@ def configure(profile: Profile, session: Session, interactive: bool = True): return profile.update() + + +@error_handler() +def exit_if_credential_process_not_set(profile: Profile, session: Session): + """ Interactive login profile configuration. """ + session.set_credentials(None, None) # Disable credential lookup + raise_if_credential_process_not_set(session, profile.name) diff --git a/src/awscli_login/credentials.py b/src/awscli_login/credentials.py index ddc4d916..3b229046 100644 --- a/src/awscli_login/credentials.py +++ b/src/awscli_login/credentials.py @@ -3,11 +3,12 @@ import argparse import json +from argparse import Namespace from datetime import datetime from botocore.session import Session -from .__main__ import login +from .__main__ import main as login, logout from .config import Profile, error_handler @@ -35,6 +36,7 @@ def init_parser(): parser.add_argument( "-p", "--profile", + default=None, type=str, help="AWS profile name") parser.add_argument( @@ -43,6 +45,11 @@ def init_parser(): action="count", default=0, help="Display verbose information") + + hidden = parser.add_mutually_exclusive_group() + for flag in ["--login", "--logout"]: + hidden.add_argument(flag, type=argparse.FileType('r'), + help=argparse.SUPPRESS) return parser @@ -60,7 +67,13 @@ def main(): # https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html args = init_parser().parse_args() session = Session(profile=args.profile) - _main(args, session) + + if args.login: + return login(Namespace(**json.load(args.login)), session) + elif args.logout: + return logout(Namespace(**json.load(args.logout)), session) + else: + return _main(args, session) if __name__ == "__main__": diff --git a/src/integration_tests/tests/base.bash b/src/integration_tests/tests/base.bash index 6d55dfdd..0c6e9fe7 100644 --- a/src/integration_tests/tests/base.bash +++ b/src/integration_tests/tests/base.bash @@ -7,7 +7,14 @@ eval "_clean_$(declare -f setup)" # Rename setup to _clean_setup setup() { _clean_setup aws configure set plugins.login awscli_login - + if [ -v AWSCLI_TEST_V2 ]; then + assert_exists "$AWSCLI_TEST_PLUGIN_PATH" + aws configure set \ + plugins.cli_legacy_plugin_path "$AWSCLI_TEST_PLUGIN_PATH" + echo "Configured for AWSCLI V2." + else + echo "Configured for AWSCLI V1." + fi assert_exists "$AWS_CONFIG_FILE" assert_not_exists "$AWSCLI_LOGIN_ROOT/.aws-login/config" } diff --git a/src/integration_tests/tests/login.bats b/src/integration_tests/tests/login.bats index 50a161f3..5ac9e292 100644 --- a/src/integration_tests/tests/login.bats +++ b/src/integration_tests/tests/login.bats @@ -7,14 +7,17 @@ load 'common' [default]$CR credential_process = aws-login --profile default EOF - + # The expiration used to end in Z now in +00:00. They each + # indicate the same UTC time. When login is run as a plugin you + # get Z, when you run it from the credentials script as a + # standalone you get +00:00. I don't know why. ! read -r -d '' CREDS <<- EOF [default]$CR aws_access_key_id = ABCDEFGHIJKLMNOPQRST$CR aws_secret_access_key = SUPER DUPER SECRET KEY$CR aws_session_token = BOGUS TOKEN$CR aws_security_token = BOGUS TOKEN$CR - expiration = 2222-09-06T22:28:39Z$CR + expiration = 2222-09-06T22:28:39+00:00$CR aws_principal_arn = arn:aws:iam::123456789010:saml-provider/shibboleth.illinois.edu$CR aws_role_arn = arn:aws:iam::123456789010:role/Team$CR username = netid diff --git a/src/integration_tests/tests/login_configure.bats b/src/integration_tests/tests/login_configure.bats index 3a843851..f53ba964 100644 --- a/src/integration_tests/tests/login_configure.bats +++ b/src/integration_tests/tests/login_configure.bats @@ -8,10 +8,10 @@ load 'base' run aws login ! read -r -d '' ERROR_MESG <<- EOF # NOTA BENE: <<- strips tabs - The login profile default could not be found!$CR - Configure the profile with the following command:$CR + Credential process is not set for current profile "default".$CR + Reconfigure using:$CR $CR - aws login configure --profile default + aws login configure EOF assert_output "$ERROR_MESG" } diff --git a/src/tests/test_main.py b/src/tests/test_main.py index 3bc46beb..c4938cb3 100644 --- a/src/tests/test_main.py +++ b/src/tests/test_main.py @@ -89,10 +89,8 @@ def setUp(self): @patch("awscli_login.__main__.get_selection", return_value=["PrincipalArn2", "RoleArn2"]) @patch("awscli_login.__main__.refresh", side_effect=Exception("W00T!")) - @patch("awscli_login.__main__.raise_if_credential_process_not_set") def test_interactive_login( - self, raise_if_cp_not_set, refresh, - get_selection, save_sts_token, authenticate): + self, refresh, get_selection, save_sts_token, authenticate): """ Interactive login wo/refreshable creds should prompt user. """ login(self.profile, self.session, interactive=True) self.session.set_credentials.assert_called_with(None, None) @@ -126,10 +124,8 @@ def test_interactive_login( return_value=["PrincipalArn2", "RoleArn2"]) @patch("awscli_login.__main__.refresh", return_value=("SAML", ["PrincipalArn", "RoleArn"])) - @patch("awscli_login.__main__.raise_if_credential_process_not_set") def test_interactive_refresh_login( - self, raise_if_cp_not_set, refresh, - get_selection, save_sts_token, authenticate): + self, refresh, get_selection, save_sts_token, authenticate): """ Interactive login w/refreshable creds should not prompt user. """ login(self.profile, self.session, interactive=True) self.session.set_credentials.assert_called_with(None, None) @@ -159,10 +155,8 @@ def test_interactive_refresh_login( return_value=["PrincipalArn2", "RoleArn2"]) @patch("awscli_login.__main__.refresh", return_value=("SAML", ["PrincipalArn", "RoleArn"])) - @patch("awscli_login.__main__.raise_if_credential_process_not_set") def test_noninteractive_login( - self, raise_if_cp_not_set, refresh, - get_selection, save_sts_token, authenticate): + self, refresh, get_selection, save_sts_token, authenticate): """ A non interactive login should not prompt the user. """ login(self.profile, self.session, interactive=False) self.session.set_credentials.assert_called_with(None, None)