Skip to content

Commit

Permalink
Merge pull request #87 from ephur/add_invalid_config_handling
Browse files Browse the repository at this point in the history
Add invalid config handling (and improve error handling throughout)
  • Loading branch information
ephur authored May 28, 2024
2 parents 4ff4416 + 12dd750 commit 67be231
Show file tree
Hide file tree
Showing 21 changed files with 904 additions and 388 deletions.
1 change: 1 addition & 0 deletions .isort.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[settings]
known_third_party = atlassian,boto3,botocore,click,deepdiff,google,hcl2,jinja2,mergedeep,moto,pkg_resources,pytest,pytest_lazyfixture,tenacity,yaml
profile = black
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ lint: init

format: init
poetry run black tfworker tests
@poetry run seed-isort-config || echo "known_third_party setting changed. Please commit pyproject.toml"
poetry run seed-isort-config || echo "known_third_party setting changed. Please commit pyproject.toml"
poetry run isort tfworker tests

test: init
poetry run pytest -p no:warnings
poetry run pytest -p no:warnings --disable-socket
poetry run coverage report --fail-under=60 -m --skip-empty

dep-test: init
poetry run pytest
poetry run pytest --disable-socket
poetry run coverage report --fail-under=60 -m --skip-empty

clean:
Expand Down
508 changes: 299 additions & 209 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ seed-isort-config = "^2.2.0"
flake8 = "^6.0.0"
wheel = "^0.40"
pytest-depends = "^1.0.1"
pytest-socket = "^0.7.0"
pytest-lazy-fixture = "^0.6.3"
coverage = "^7.2"
pytest-cov = "^4.0.0"
moto = {extras = ["sts"], version = "^4.1.4"}
moto = {extras = ["sts","dynamodb", "s3"], version = "^5.0.8"}
deepdiff = "^6.2.0"
Sphinx = "5.1.1"

Expand Down
11 changes: 5 additions & 6 deletions tests/authenticators/test_aws_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@

import pytest
from botocore.credentials import Credentials
from moto import mock_sts
from moto import mock_aws

from tfworker.authenticators.aws import (AWSAuthenticator,
MissingArgumentException)
from tfworker.authenticators.aws import AWSAuthenticator, MissingArgumentException
from tfworker.commands.root import RootCommand
from tfworker.constants import DEFAULT_BACKEND_PREFIX

Expand Down Expand Up @@ -69,7 +68,7 @@ def test_with_no_backend_bucket(self):
AWSAuthenticator(state_args={}, deployment="deployfu")
assert "backend_bucket" in str(e.value)

@mock_sts
@mock_aws
def test_with_access_key_pair_creds(
self, sts_client, state_args, aws_access_key_id, aws_secret_access_key
):
Expand All @@ -78,7 +77,7 @@ def test_with_access_key_pair_creds(
assert auth.secret_access_key == aws_secret_access_key
assert auth.session_token is None

@mock_sts
@mock_aws
def test_with_access_key_pair_creds_and_role_arn(
self, sts_client, state_args_with_role_arn, aws_secret_access_key
):
Expand Down Expand Up @@ -110,7 +109,7 @@ def test_with_profile(
assert auth.secret_access_key == aws_credentials_instance.secret_key
assert auth.session_token is None

@mock_sts
@mock_aws
def test_with_prefix(self, state_args):
auth = AWSAuthenticator(state_args, deployment="deployfu")
assert auth.prefix == DEFAULT_BACKEND_PREFIX.format(deployment="deployfu")
Expand Down
193 changes: 193 additions & 0 deletions tests/backends/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import random
import string
from unittest.mock import MagicMock, patch

import pytest
from botocore.exceptions import ClientError
from moto import mock_aws

from tests.conftest import MockAWSAuth
from tfworker.backends import S3Backend
from tfworker.backends.base import BackendError
from tfworker.handlers import HandlerError

STATE_BUCKET = "test_bucket"
STATE_PREFIX = "terraform"
Expand All @@ -25,6 +32,7 @@
EMPTY_STATE = f"{STATE_PREFIX}/{STATE_DEPLOYMENT}/empty/terraform.tfstate"
OCCUPIED_STATE = f"{STATE_PREFIX}/{STATE_DEPLOYMENT}/occupied/terraform.tfstate"
LOCK_DIGEST = "1234123412341234"
NO_SUCH_BUCKET = "no_such_bucket"


@pytest.fixture(scope="class")
Expand Down Expand Up @@ -159,6 +167,191 @@ def test_clean_locking_state(self, basec, state_setup, dynamodb_client):
)


class TestS3BackendInit:
def setup_method(self, method):
self.authenticators = {"aws": MockAWSAuth()}
self.definitions = {}

def test_no_session(self):
self.authenticators["aws"]._session = None
with pytest.raises(BackendError):
result = S3Backend(self.authenticators, self.definitions)

def test_no_backend_session(self):
self.authenticators["aws"]._backend_session = None
with pytest.raises(BackendError):
result = S3Backend(self.authenticators, self.definitions)

@patch("tfworker.backends.S3Backend._ensure_locking_table", return_value=None)
@patch("tfworker.backends.S3Backend._ensure_backend_bucket", return_value=None)
@patch("tfworker.backends.S3Backend._get_bucket_files", return_value={})
def test_deployment_undefined(
self,
mock_get_bucket_files,
mock_ensure_backend_bucket,
mock_ensure_locking_table,
):
# arrange
result = S3Backend(self.authenticators, self.definitions)
assert result._deployment == "undefined"
assert mock_get_bucket_files.called
assert mock_ensure_backend_bucket.called
assert mock_ensure_locking_table.called

@patch("tfworker.backends.S3Backend._ensure_locking_table", return_value=None)
@patch("tfworker.backends.S3Backend._ensure_backend_bucket", return_value=None)
@patch("tfworker.backends.S3Backend._get_bucket_files", return_value={})
@patch("tfworker.backends.s3.S3Handler", side_effect=HandlerError("message"))
def test_handler_error(
self,
mock_get_bucket_files,
mock_ensure_backend_bucket,
mock_ensure_locking_table,
mock_handler,
):
with pytest.raises(SystemExit):
result = S3Backend(self.authenticators, self.definitions)


class TestS3BackendEnsureBackendBucket:
from botocore.exceptions import ClientError

@pytest.fixture(autouse=True)
def setup_class(self, state_setup):
pass

@patch("tfworker.backends.S3Backend._ensure_locking_table", return_value=None)
@patch("tfworker.backends.S3Backend._ensure_backend_bucket", return_value=None)
@patch("tfworker.backends.S3Backend._get_bucket_files", return_value={})
def setup_method(
self,
method,
mock_get_bucket_files,
mock_ensure_backend_bucket,
mock_ensure_locking_table,
):
with mock_aws():
self.authenticators = {"aws": MockAWSAuth()}
self.definitions = {}
self.backend = S3Backend(self.authenticators, self.definitions)
self.backend._authenticator.bucket = STATE_BUCKET
self.backend._authenticator.backend_region = STATE_REGION

def teardown_method(self, method):
with mock_aws():
try:
self.backend._s3_client.delete_bucket(Bucket=NO_SUCH_BUCKET)
except Exception:
pass

@mock_aws
def test_check_bucket_does_not_exist(self):
result = self.backend._check_bucket_exists(NO_SUCH_BUCKET)
assert result is False

@mock_aws
def test_check_bucket_exists(self):
result = self.backend._check_bucket_exists(STATE_BUCKET)
assert result is True

@mock_aws
def test_check_bucket_exists_error(self):
self.backend._s3_client = MagicMock()
self.backend._s3_client.head_bucket.side_effect = ClientError(
{"Error": {"Code": "403", "Message": "Unauthorized"}}, "head_bucket"
)

with pytest.raises(ClientError):
result = self.backend._check_bucket_exists(STATE_BUCKET)
assert self.backend._s3_client.head_bucket.called

@mock_aws
def test_bucket_not_exist_no_create(self, capfd):
self.backend._authenticator.create_backend_bucket = False
self.backend._authenticator.bucket = NO_SUCH_BUCKET
with pytest.raises(BackendError):
result = self.backend._ensure_backend_bucket()
assert (
"Backend bucket not found and --no-create-backend-bucket specified."
in capfd.readouterr().out
)

@mock_aws
def test_create_bucket(self):
self.backend._authenticator.create_backend_bucket = True
self.backend._authenticator.bucket = NO_SUCH_BUCKET
assert NO_SUCH_BUCKET not in [
x["Name"] for x in self.backend._s3_client.list_buckets()["Buckets"]
]
result = self.backend._ensure_backend_bucket()
assert result is None
assert NO_SUCH_BUCKET in [
x["Name"] for x in self.backend._s3_client.list_buckets()["Buckets"]
]

@mock_aws
def test_create_bucket_invalid_location_constraint(self, capsys):
self.backend._authenticator.create_backend_bucket = True
self.backend._authenticator.bucket = NO_SUCH_BUCKET
self.backend._authenticator.backend_region = "us-west-1"
# moto doesn't properly raise a location constraint when the session doesn't match the region
# so we'll just do it manually
assert self.backend._authenticator.backend_session.region_name != "us-west-1"
assert self.backend._authenticator.backend_region == "us-west-1"
assert NO_SUCH_BUCKET not in [
x["Name"] for x in self.backend._s3_client.list_buckets()["Buckets"]
]
self.backend._s3_client = MagicMock()
self.backend._s3_client.create_bucket.side_effect = ClientError(
{
"Error": {
"Code": "InvalidLocationConstraint",
"Message": "InvalidLocationConstraint",
}
},
"create_bucket",
)

with pytest.raises(SystemExit):
result = self.backend._create_bucket(NO_SUCH_BUCKET)
assert "InvalidLocationConstraint" in capsys.readouterr().out

assert NO_SUCH_BUCKET not in [
x["Name"] for x in self.backend._s3_client.list_buckets()["Buckets"]
]

# This test can not be enabled until several other tests are refactored to not create the bucket needlessly
# as the method itself skips this check when being run through a test, the same also applies to "BucketAlreadyOwnedByYou"
# @mock_aws
# def test_create_bucket_already_exists(self, capsys):
# self.backend._authenticator.create_backend_bucket = True
# self.backend._authenticator.bucket = STATE_BUCKET
# assert STATE_BUCKET in [ x['Name'] for x in self.backend._s3_client.list_buckets()['Buckets'] ]

# with pytest.raises(SystemExit):
# result = self.backend._create_bucket(STATE_BUCKET)
# assert f"Bucket {STATE_BUCKET} already exists" in capsys.readouterr().out

def test_create_bucket_error(self):
self.backend._authenticator.create_backend_bucket = True
self.backend._authenticator.bucket = NO_SUCH_BUCKET
self.backend._s3_client = MagicMock()
self.backend._s3_client.create_bucket.side_effect = ClientError(
{"Error": {"Code": "403", "Message": "Unauthorized"}}, "create_bucket"
)

with pytest.raises(ClientError):
result = self.backend._create_bucket(NO_SUCH_BUCKET)
assert self.backend._s3_client.create_bucket.called


def test_backend_remotes(basec, state_setup):
remotes = basec.backend.remotes()
assert len(remotes) == 2
assert "empty" in remotes
assert "occupied" in remotes


def test_backend_clean_all(basec, request, state_setup, dynamodb_client, s3_client):
# this function should trigger an exit
with pytest.raises(SystemExit):
Expand Down
49 changes: 48 additions & 1 deletion tests/commands/test_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def test_config_loader(self, rootc, capfd):
invalidrc.load_config()
assert e.value.code == 1
out, err = capfd.readouterr()
assert "can not read" in out
assert "configuration file does not exist" in out

# a j2 template with invalid substitutions should raise an error
invalidrc = tfworker.commands.root.RootCommand(
Expand Down Expand Up @@ -245,3 +245,50 @@ def test_get_platform(
assert machine == actual_machine
mock1.assert_called_once()
mock2.assert_called_once()


import io

import pytest

from tfworker.commands.root import ordered_config_load


class TestOrderedConfigLoad:
def test_ordered_config_load(self):
config = """
key1: value1
key2: value2
key3:
- item1
- item2
"""
result = ordered_config_load(config)
assert result == {
"key1": "value1",
"key2": "value2",
"key3": ["item1", "item2"],
}

def test_ordered_config_load_invalid(self, capfd):
config = """
key1: value1
key2: value2
key3:
- item1
- item2
key4:
subkey1: value1
subkey1a: value2
- ohnoimalistnow
"""
expected_error_out = ""
for i, line in enumerate(config.split("\n")):
expected_error_out += f"{i+1}: {line}\n"
with pytest.raises(SystemExit) as e:
ordered_config_load(config)
out, err = capfd.readouterr()
assert e.value.code == 1
assert "error loading yaml/json" in out
assert "the configuration that caused the error was" in out
assert expected_error_out in out
14 changes: 4 additions & 10 deletions tests/commands/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,8 @@ def mock_get_distribution(package: str):

class TestVersionCommand:
def test_exec(self, capsys):
VersionCommand().exec()
vc = VersionCommand()
vc._version = "1.2.3"
vc.exec()
text = capsys.readouterr()
assert text.out.startswith("terraform-worker version")

with mock.patch(
"tfworker.commands.version.get_distribution",
side_effect=mock_get_distribution,
):
VersionCommand().exec()
text = capsys.readouterr()
assert text.out == "terraform-worker version unknown\n"
assert text.out == "terraform-worker version 1.2.3\n"
Loading

0 comments on commit 67be231

Please sign in to comment.