Skip to content

Commit 43df895

Browse files
authored
Merge pull request #50 from labd/feature/api-gateway-improvements
WIP: Feature/api gateway improvements
2 parents ec2188b + f147684 commit 43df895

File tree

15 files changed

+342
-128
lines changed

15 files changed

+342
-128
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
- Add support for multiple API endpoints:
88
- `base_url` replaced with `endpoints`
99
- `has_public_api` replaced with `endpoint`
10-
- Add new required `frontdoor_id` Terraform variable for components with `endpoint` defined
1110
- Improved dependencies between components and MACH-managed commercetools configurations
1211
- Improved git log parsing
1312
- Add `mach bootstrap` commands:
1413
- `mach bootstrap config` for creating a new MACH configuration
1514
- `mach bootstrap component` for creating a new MACH component
1615
- Updated Terraform commercetools provider to `0.24.1`
16+
- AWS: Set `auto-deploy` on API gateway stage
17+
- Azure: Add new required `frontdoor_id` Terraform variable for components with `endpoint` defined
1718

1819

1920
**Breaking changes**

src/mach/commands.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,12 @@ def terraform_command(f):
2323
default=None,
2424
help="YAML file to parse. If not set parse all *.yml files.",
2525
)
26-
@click.option(
27-
"--with-sp-login",
28-
is_flag=True,
29-
default=False,
30-
help="If az login with service principal environment variables "
31-
"(ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID) should be done.",
32-
)
33-
@click.option(
34-
"--auto-approve",
35-
is_flag=True,
36-
default=False,
37-
help="",
38-
)
3926
@click.option(
4027
"--output-path",
4128
default="deployments",
4229
help="Output path, defaults to `cwd`/deployments.",
4330
)
44-
def new_func(file, with_sp_login: bool, auto_approve: bool, output_path: str):
31+
def new_func(file, output_path: str, **kwargs):
4532
files = get_input_files(file)
4633

4734
try:
@@ -52,9 +39,8 @@ def new_func(file, with_sp_login: bool, auto_approve: bool, output_path: str):
5239
try:
5340
result = f(
5441
file=file,
55-
with_sp_login=with_sp_login,
56-
auto_approve=auto_approve,
5742
configs=configs,
43+
**kwargs,
5844
)
5945
except subprocess.CalledProcessError as e:
6046
click.echo("Failed to run")
@@ -84,13 +70,26 @@ def plan(file, configs, *args, **kwargs):
8470

8571

8672
@mach.command()
73+
@click.option(
74+
"--with-sp-login",
75+
is_flag=True,
76+
default=False,
77+
help="If az login with service principal environment variables "
78+
"(ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID) should be done.",
79+
)
80+
@click.option(
81+
"--auto-approve",
82+
is_flag=True,
83+
default=False,
84+
help="",
85+
)
8786
@terraform_command
8887
def apply(file, configs, with_sp_login, auto_approve, *args, **kwargs):
8988
"""Apply the configuration."""
9089
for config in configs:
9190
generate_terraform(config)
9291
apply_terraform(
93-
config.deployment_path,
92+
config,
9493
with_sp_login=with_sp_login,
9594
auto_approve=auto_approve,
9695
)

src/mach/parse.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections import defaultdict
12
from os.path import basename, splitext
23
from pathlib import Path
34
from typing import Dict, List
@@ -10,6 +11,7 @@
1011
ComponentConfig,
1112
MachConfig,
1213
SentryDsn,
14+
Site,
1315
SiteAzureSettings,
1416
)
1517
from mach.validate import validate_config
@@ -114,9 +116,25 @@ def resolve_site_configs(config: MachConfig) -> MachConfig:
114116

115117
config = resolve_site_components(config)
116118

119+
for site in config.sites:
120+
resolve_endpoint_components(site)
121+
117122
return config
118123

119124

125+
def resolve_endpoint_components(site: Site):
126+
endpoint_components = defaultdict(list)
127+
128+
for c in site.components:
129+
if not site.endpoints:
130+
continue
131+
132+
endpoint_components[c.endpoint].append(c)
133+
134+
for endpoint in site.endpoints:
135+
endpoint.components = endpoint_components.get(endpoint.key, [])
136+
137+
120138
def resolve_site_components(config: MachConfig) -> MachConfig:
121139
"""If no component info is specified, use global component settings."""
122140
component_info: Dict[str, ComponentConfig] = {

src/mach/templates/partials/aws.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ provider "aws" {
2626
{% if site.used_endpoints %}
2727
{% include 'partials/endpoints/aws_domains.tf' %}
2828

29-
{% for endpoint_name, endpoint_url in site.used_endpoints.items() %}
29+
{% for endpoint in site.used_endpoints %}
3030
{% include 'partials/endpoints/aws_api_gateway.tf' %}
3131

3232
{% endfor %}
Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,75 @@
1-
resource "aws_acm_certificate" "{{ endpoint_name|slugify }}" {
2-
domain_name = "{{ endpoint_url }}"
1+
resource "aws_acm_certificate" "{{ endpoint.key|slugify }}" {
2+
domain_name = "{{ endpoint.url }}"
33
validation_method = "DNS"
44
}
55

6-
resource "aws_route53_record" "{{ endpoint_name|slugify }}_acm_validation" {
6+
resource "aws_route53_record" "{{ endpoint.key|slugify }}_acm_validation" {
77
zone_id = data.aws_route53_zone.main.zone_id
8-
name = tolist(aws_acm_certificate.{{ endpoint_name|slugify }}.domain_validation_options)[0].resource_record_name
9-
type = tolist(aws_acm_certificate.{{ endpoint_name|slugify }}.domain_validation_options)[0].resource_record_type
8+
name = tolist(aws_acm_certificate.{{ endpoint.key|slugify }}.domain_validation_options)[0].resource_record_name
9+
type = tolist(aws_acm_certificate.{{ endpoint.key|slugify }}.domain_validation_options)[0].resource_record_type
1010
ttl = 60
11-
records = [tolist(aws_acm_certificate.{{ endpoint_name|slugify }}.domain_validation_options)[0].resource_record_value]
11+
records = [tolist(aws_acm_certificate.{{ endpoint.key|slugify }}.domain_validation_options)[0].resource_record_value]
1212
}
1313

1414
# API Gateway
15-
resource "aws_apigatewayv2_api" "{{ endpoint_name|slugify }}_gateway" {
16-
name = "{{ site.identifier }}-api"
15+
resource "aws_apigatewayv2_api" "{{ endpoint.key|slugify }}_gateway" {
16+
name = "{{ site.identifier }}-{{ endpoint.key|slugify }}-api"
1717
protocol_type = "HTTP"
1818
}
1919

20-
resource "aws_apigatewayv2_route" "{{ endpoint_name|slugify }}_application" {
21-
api_id = aws_apigatewayv2_api.{{ endpoint_name|slugify }}_gateway.id
20+
resource "aws_apigatewayv2_route" "{{ endpoint.key|slugify }}_application" {
21+
api_id = aws_apigatewayv2_api.{{ endpoint.key|slugify }}_gateway.id
2222
route_key = "$default"
2323
}
2424

25-
resource "aws_apigatewayv2_deployment" "{{ endpoint_name|slugify }}_default" {
26-
api_id = aws_apigatewayv2_api.{{ endpoint_name|slugify }}_gateway.id
25+
resource "aws_apigatewayv2_deployment" "{{ endpoint.key|slugify }}_default" {
26+
api_id = aws_apigatewayv2_api.{{ endpoint.key|slugify }}_gateway.id
2727
description = "Stage for default release"
2828

29-
triggers = {
30-
redeployment = sha1(join(",", list(
31-
{% for component in site.public_api_components %}
32-
module.{{ component.name }}.component_version,
33-
{% endfor %}
34-
)))
35-
}
36-
3729
lifecycle {
3830
create_before_destroy = true
3931
}
4032

4133
depends_on = [
42-
{% for component in site.public_api_components %}
34+
{% for component in endpoint.components %}
4335
module.{{ component.name }},
4436
{% endfor %}
4537
]
4638
}
4739

48-
resource "aws_apigatewayv2_stage" "{{ endpoint_name|slugify }}_default" {
40+
resource "aws_apigatewayv2_stage" "{{ endpoint.key|slugify }}_default" {
4941
name = "$default"
50-
api_id = aws_apigatewayv2_api.{{ endpoint_name|slugify }}_gateway.id
51-
deployment_id = aws_apigatewayv2_deployment.{{ endpoint_name|slugify }}_default.id
52-
53-
depends_on = [aws_apigatewayv2_deployment.{{ endpoint_name|slugify }}_default]
42+
description = "Stage for default release"
43+
api_id = aws_apigatewayv2_api.{{ endpoint.key|slugify }}_gateway.id
44+
deployment_id = aws_apigatewayv2_deployment.{{ endpoint.key|slugify }}_default.id
45+
auto_deploy = true
5446
}
5547

5648
# Route53 mappings
57-
resource "aws_apigatewayv2_domain_name" "{{ endpoint_name|slugify }}" {
58-
domain_name = "{{ endpoint_url }}"
49+
resource "aws_apigatewayv2_domain_name" "{{ endpoint.key|slugify }}" {
50+
domain_name = "{{ endpoint.url }}"
5951

6052
domain_name_configuration {
61-
certificate_arn = aws_acm_certificate.{{ endpoint_name|slugify }}.arn
53+
certificate_arn = aws_acm_certificate.{{ endpoint.key|slugify }}.arn
6254
endpoint_type = "REGIONAL"
6355
security_policy = "TLS_1_2"
6456
}
6557
}
6658

67-
resource "aws_route53_record" "{{ endpoint_name|slugify }}" {
68-
name = aws_apigatewayv2_domain_name.{{ endpoint_name|slugify }}.domain_name
59+
resource "aws_route53_record" "{{ endpoint.key|slugify }}" {
60+
name = aws_apigatewayv2_domain_name.{{ endpoint.key|slugify }}.domain_name
6961
type = "A"
7062
zone_id = data.aws_route53_zone.main.id
7163

7264
alias {
73-
name = aws_apigatewayv2_domain_name.{{ endpoint_name|slugify }}.domain_name_configuration[0].target_domain_name
74-
zone_id = aws_apigatewayv2_domain_name.{{ endpoint_name|slugify }}.domain_name_configuration[0].hosted_zone_id
65+
name = aws_apigatewayv2_domain_name.{{ endpoint.key|slugify }}.domain_name_configuration[0].target_domain_name
66+
zone_id = aws_apigatewayv2_domain_name.{{ endpoint.key|slugify }}.domain_name_configuration[0].hosted_zone_id
7567
evaluate_target_health = false
7668
}
7769
}
7870

79-
resource "aws_apigatewayv2_api_mapping" "{{ endpoint_name|slugify }}" {
80-
api_id = aws_apigatewayv2_api.{{ endpoint_name|slugify }}_gateway.id
81-
stage = aws_apigatewayv2_stage.{{ endpoint_name|slugify }}_default.id
82-
domain_name = "{{ endpoint_url }}"
71+
resource "aws_apigatewayv2_api_mapping" "{{ endpoint.key|slugify }}" {
72+
api_id = aws_apigatewayv2_api.{{ endpoint.key|slugify }}_gateway.id
73+
stage = aws_apigatewayv2_stage.{{ endpoint.key|slugify }}_default.id
74+
domain_name = "{{ endpoint.url }}"
8375
}

src/mach/terraform.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,28 @@ def plan_terraform(output_dir: Path):
4949

5050

5151
def apply_terraform(
52-
output_dir: Path, *, with_sp_login: bool = False, auto_approve: bool = False
52+
config: MachConfig,
53+
*,
54+
with_sp_login: bool = False,
55+
auto_approve: bool = False,
5356
):
5457
"""Terraform apply for all generated sites."""
55-
for site_dir in output_dir.iterdir():
56-
if site_dir.is_dir():
57-
click.echo(f"Applying Terraform for {site_dir.name}")
58-
run_terraform("init", site_dir)
59-
if with_sp_login:
60-
azure_sp_login()
61-
cmd = ["apply"]
62-
if auto_approve:
63-
cmd += ["-auto-approve"]
64-
run_terraform(cmd, site_dir)
58+
for site in config.sites:
59+
site_dir = config.deployment_path / Path(site.identifier)
60+
if not site_dir.is_dir():
61+
click.echo(f"Could not find site directory {site_dir}")
62+
continue
63+
64+
click.echo(f"Applying Terraform for {site.identifier}")
65+
run_terraform("init", site_dir)
66+
67+
if with_sp_login:
68+
azure_sp_login()
69+
70+
cmd = ["apply"]
71+
if auto_approve:
72+
cmd += ["-auto-approve"]
73+
run_terraform(cmd, site_dir)
6574

6675

6776
def azure_sp_login():

src/mach/types.py

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses_json import config, dataclass_json
77
from dataclasses_jsonschema import JsonSchemaMixin
88
from mach import utils
9+
from marshmallow import ValidationError, fields
910

1011
TerraformVariables = Dict[str, Any]
1112
StoreVariables = Dict[str, TerraformVariables]
@@ -398,13 +399,67 @@ def merge(self, config: AzureConfig):
398399
self.service_object_ids = self.service_object_ids or config.service_object_ids
399400

400401

402+
@dataclass_json
403+
@dataclass
404+
class Endpoint:
405+
url: str
406+
key: str = field(metadata=config(exclude=lambda x: True))
407+
components: Optional[List[Component]] = _list()
408+
409+
@property
410+
def contains_defaults(self):
411+
"""Indicate if this endpoint contains just default values.
412+
413+
Other then the `url` attribute.
414+
If only defaults, we can serialize the endpoints by just
415+
rendering the url, not the entire object.
416+
417+
At this moment, we don't have any additional options, so it's always default.
418+
This can be extended in the future.
419+
"""
420+
return True
421+
422+
def __post_init__(self):
423+
"""Ensure endpoints have protocol stripped."""
424+
self.url = utils.strip_protocol(self.url)
425+
426+
427+
class EndpointsField(fields.Dict):
428+
def _serialize(self, value, attr, obj, **kwargs):
429+
result = {}
430+
for endpoint in value:
431+
if endpoint.contains_defaults:
432+
result[endpoint.key] = endpoint.url
433+
else:
434+
result[endpoint.key] = endpoint.to_dict()
435+
436+
return super()._deserialize(result, attr, obj, **kwargs)
437+
438+
def _deserialize(self, value, attr, data, **kwargs):
439+
value = super()._deserialize(value, attr, data, **kwargs)
440+
result = []
441+
for k, v in value.items():
442+
if isinstance(v, str):
443+
result.append(Endpoint(key=k, url=v))
444+
elif isinstance(v, dict):
445+
v["key"] = k
446+
result.append(Endpoint.schema(infer_missing=True).load(v))
447+
else:
448+
raise ValidationError(f"Unexpected value found for endpoint {k}")
449+
450+
return result
451+
452+
401453
@dataclass_json
402454
@dataclass
403455
class Site(JsonSchemaMixin):
404456
"""Site definition."""
405457

406458
identifier: str
407-
endpoints: Dict[str, str] = _default({})
459+
endpoints: Optional[List[Endpoint]] = field(
460+
default_factory=list,
461+
metadata=config(mm_field=EndpointsField(), exclude=lambda x: not x),
462+
)
408463
commercetools: Optional[CommercetoolsSettings] = _none()
409464
contentful: Optional[ContentfulSettings] = _none()
410465
amplience: Optional[AmplienceSettings] = _none()
@@ -417,19 +472,10 @@ class Site(JsonSchemaMixin):
417472
def public_api_components(self) -> List[Component]:
418473
return [c for c in self.components if c.endpoint]
419474

420-
def __post_init__(self):
421-
"""Ensure endpoints have protocol stripped."""
422-
if self.endpoints:
423-
self.endpoints = {
424-
k: utils.strip_protocol(v) for k, v in self.endpoints.items()
425-
}
426-
427475
@property
428-
def used_endpoints(self):
476+
def used_endpoints(self) -> List[Endpoint]:
429477
"""Return only the endpoints that are actually used by the components."""
430-
used_endpoints = {c.endpoint for c in self.components if c.endpoint}
431-
432-
return {k: v for k, v in self.endpoints.items() if k in used_endpoints}
478+
return [ep for ep in self.endpoints if ep.components]
433479

434480

435481
@dataclass_json

0 commit comments

Comments
 (0)