Skip to content

Commit 77f63b4

Browse files
committed
Remove .chalice/config.json and generate it dynamically
1 parent 2509c54 commit 77f63b4

File tree

8 files changed

+221
-12
lines changed

8 files changed

+221
-12
lines changed

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,14 @@ lint: hooks-lint # alias
5252

5353
mypy: hooks-mypy # alias
5454

55+
# =============================================================================
56+
# Config related commands
57+
generate-chalice-config:
58+
@cd $(PROJECT_DIR)/runtime && poetry run python -m chalicelib.cli generate-chalice-config
59+
5560
# =============================================================================
5661
# AWS CDK related commands
57-
stack-ecr-deploy:
62+
stack-ecr-deploy: generate-chalice-config
5863
@cdk deploy notico-ecr
5964

6065
stack-queue-deploy:
@@ -69,7 +74,7 @@ stack-lambda-deploy:
6974
cleanup-deploy:
7075
@rm -rf $(PROJECT_DIR)/cdk.out
7176
@rm -rf $(PROJECT_DIR)/chalice.out
72-
@rm -rf $(PROJECT_DIR)/runtime/.chalice/deployments
77+
@rm -rf $(PROJECT_DIR)/runtime/.chalice
7378

7479
stack-deploy: docker-build-prod stack-queue-deploy stack-s3-deploy stack-lambda-deploy cleanup-deploy
7580

cdk.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def __init__(
182182
ecr_repo=ecr_repo,
183183
stage_config={
184184
"automatic_layer": True,
185-
"environment_variables": config.env_vars,
185+
"environment_variables": config.env_dict,
186186
},
187187
)
188188
app_default_role = app.get_role("DefaultRole")

runtime/.chalice/config.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

runtime/chalicelib/cli/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pathlib
2+
import typing
3+
4+
import chalicelib.util.import_util as import_util
5+
import typer
6+
7+
typer_app = typer.Typer()
8+
9+
_cli_patterns: list[list[typing.Callable]] = import_util.auto_import_patterns(
10+
pattern="cli_funcs",
11+
file_prefix="",
12+
dir=pathlib.Path(__file__).parent,
13+
)
14+
for _cli_funcs in _cli_patterns:
15+
for _cli_func in _cli_funcs:
16+
typer_app.command()(_cli_func)

runtime/chalicelib/cli/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import chalicelib.cli
2+
3+
if __name__ == "__main__":
4+
chalicelib.cli.typer_app()

runtime/chalicelib/cli/dummy.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def initial() -> None:
2+
pass
3+
4+
5+
cli_funcs = [initial]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import hashlib
2+
import pathlib
3+
4+
import chalicelib.config as config_module
5+
6+
BUF_SIZE = 65536
7+
8+
CLI_PATH = pathlib.Path(__file__).parent
9+
RUNTIME_PATH = CLI_PATH.parent.parent
10+
CHALICE_CONF_DIR = RUNTIME_PATH / ".chalice"
11+
CHALICE_CONF_PATH = CHALICE_CONF_DIR / "config.json"
12+
CHALICE_CONF_BACKUP_PATH = CHALICE_CONF_DIR / "config.json.bak"
13+
14+
15+
def generate_chalice_config() -> None:
16+
if not CHALICE_CONF_DIR.exists():
17+
CHALICE_CONF_DIR.mkdir()
18+
19+
if CHALICE_CONF_PATH.exists():
20+
CHALICE_CONF_PATH.rename(CHALICE_CONF_BACKUP_PATH)
21+
22+
CHALICE_CONF_PATH.write_text(
23+
data=config_module.config.chalice_config.model_dump_json(
24+
indent=2,
25+
exclude_none=True,
26+
by_alias=True,
27+
)
28+
)
29+
30+
if CHALICE_CONF_BACKUP_PATH.exists():
31+
curr_file_md5 = hashlib.md5(string=CHALICE_CONF_PATH.read_bytes(), usedforsecurity=False).hexdigest()
32+
backup_file_md5 = hashlib.md5(string=CHALICE_CONF_BACKUP_PATH.read_bytes(), usedforsecurity=False).hexdigest()
33+
34+
if curr_file_md5 == backup_file_md5:
35+
print("No changes detected in chalice config.")
36+
CHALICE_CONF_BACKUP_PATH.unlink()
37+
else:
38+
print("Chalice config updated.")
39+
else:
40+
print("Chalice config generated.")
41+
42+
43+
cli_funcs = [generate_chalice_config]

runtime/chalicelib/config.py

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import contextlib
2+
import json
23
import logging
34
import typing
45
import urllib.parse
56

7+
import chalice.constants
68
import firebase_admin
79
import firebase_admin.credentials
810
import httpx
@@ -22,6 +24,89 @@ def log_response(resp: httpx.Response) -> None:
2224
logger.info(f"RES [{req.method}]{req.url}<{resp.status_code}> {resp.read().decode(errors='ignore')=}")
2325

2426

27+
class ChaliceAPIGatewayCustomDomainConfig(pydantic.BaseModel):
28+
domain_name: str
29+
certificate_arn: str
30+
tls_version: typing.Literal["TLS_1_0", "TLS_1_2"] = chalice.constants.DEFAULT_TLS_VERSION # TLS_1_2
31+
url_prefix: str | None = None
32+
tags: dict[str, str] | None = None
33+
34+
35+
class ChaliceLambdaConfig(pydantic.BaseModel):
36+
autogen_policy: bool = True
37+
iam_policy_file: pydantic.FilePath | None = None
38+
iam_role_arn: str | None = None
39+
manage_iam_role: bool = True
40+
subnet_ids: list[str] | None = None
41+
security_group_ids: list[str] | None = None
42+
43+
lambda_memory_size: int | None = chalice.constants.DEFAULT_LAMBDA_MEMORY_SIZE
44+
lambda_timeout: int | None = chalice.constants.DEFAULT_LAMBDA_TIMEOUT
45+
layers: list[str] | None = None
46+
log_retention_in_days: int | None = None
47+
reserved_concurrency: int | None = None
48+
49+
environment_variables: dict[str, str] | None = None
50+
tags: dict[str, str] | None = None
51+
52+
@pydantic.field_validator("lambda_memory_size", mode="before")
53+
@classmethod
54+
def validate_lambda_memory_size(cls, lambda_memory_size: int) -> int:
55+
if lambda_memory_size % 64 != 0:
56+
raise ValueError("lambda_memory_size must be a multiple of 64")
57+
58+
return lambda_memory_size
59+
60+
@pydantic.model_validator(mode="after")
61+
def validate_model(self) -> typing.Self:
62+
if not (self.autogen_policy or self.iam_policy_file):
63+
raise ValueError("iam_policy_file must be set when autogen_policy is False")
64+
65+
if not (self.manage_iam_role or self.iam_role_arn):
66+
raise ValueError("iam_role_arn must be set when manage_iam_role is False")
67+
68+
if bool(self.subnet_ids) != bool(self.security_group_ids):
69+
raise ValueError("subnet_ids and security_group_ids must be set together")
70+
71+
return self
72+
73+
74+
class ChaliceStageConfig(ChaliceLambdaConfig, pydantic.BaseModel):
75+
api_gateway_stage: str = chalice.constants.DEFAULT_APIGATEWAY_STAGE_NAME
76+
77+
api_gateway_custom_domain: ChaliceAPIGatewayCustomDomainConfig | None = None
78+
websocket_api_custom_domain: ChaliceAPIGatewayCustomDomainConfig | None = None
79+
80+
automatic_layer: bool = False
81+
minimum_compression_size: int | None = pydantic.Field(
82+
default=None,
83+
ge=chalice.constants.MIN_COMPRESSION_SIZE,
84+
le=chalice.constants.MAX_COMPRESSION_SIZE,
85+
)
86+
xray: bool = False
87+
lambda_functions: dict[str, ChaliceLambdaConfig] | None = None
88+
89+
90+
class ChaliceRootConfig(ChaliceStageConfig, pydantic.BaseModel):
91+
api_gateway_endpoint_type: typing.Literal["EDGE", "REGIONAL", "PRIVATE"] = chalice.constants.DEFAULT_ENDPOINT_TYPE
92+
api_gateway_endpoint_vpce: list[str] | str | None = None
93+
api_gateway_policy_file: pydantic.FilePath | None = None
94+
95+
stages: dict[str, ChaliceStageConfig] | None = None
96+
97+
@pydantic.model_validator(mode="after")
98+
def validate_model(self) -> typing.Self:
99+
if self.api_gateway_endpoint_type == "PRIVATE" and not self.api_gateway_endpoint_vpce:
100+
raise ValueError("api_gateway_endpoint_vpce must be set when api_gateway_endpoint_type is PRIVATE")
101+
102+
return self
103+
104+
105+
class ChaliceConfig(ChaliceRootConfig, pydantic.BaseModel):
106+
version: typing.Literal["2.0"] = "2.0"
107+
app_name: str
108+
109+
25110
class InfraConfig(pydantic_settings.BaseSettings):
26111
ecr_repo_name: str = "notico"
27112
lambda_name: str = "notico-lambda"
@@ -66,6 +151,10 @@ def get_session(self, service: AllowedToastServices) -> httpx.Client: # type: i
66151
event_hooks={"request": [log_request], "response": [log_response]},
67152
)
68153

154+
@pydantic.field_serializer("secret_key", "sender_key", when_used="json-unless-none")
155+
def dump_secret(self, v: pydantic.SecretStr) -> str:
156+
return v.get_secret_value()
157+
69158

70159
class FirebaseConfig(ServiceConfig, pydantic_settings.BaseSettings):
71160
certificate: pydantic.SecretStr | None = None
@@ -81,6 +170,10 @@ def get_session(self) -> firebase_admin.App | None: # type: ignore[override]
81170

82171
return self._app
83172

173+
@pydantic.field_serializer("certificate", when_used="json-unless-none")
174+
def dump_secret(self, v: pydantic.SecretStr) -> str:
175+
return v.get_secret_value()
176+
84177

85178
class TelegramConfig(ServiceConfig, pydantic_settings.BaseSettings):
86179
bot_token: pydantic.SecretStr | None = None
@@ -93,20 +186,70 @@ def get_session(self) -> httpx.Client:
93186
event_hooks={"request": [log_request], "response": [log_response]},
94187
)
95188

189+
@pydantic.field_serializer("bot_token", when_used="json-unless-none")
190+
def dump_secret(self, v: pydantic.SecretStr) -> str:
191+
return v.get_secret_value()
192+
96193

97194
class SlackConfig(ServiceConfig, pydantic_settings.BaseSettings):
98195
channel: str | None = None
99196
token: pydantic.SecretStr | None = None
100197

198+
@pydantic.field_serializer("token", when_used="json-unless-none")
199+
def dump_secret(self, v: pydantic.SecretStr) -> str:
200+
return v.get_secret_value()
201+
101202

102203
class Config(pydantic_settings.BaseSettings):
204+
project_name: str = "notico"
205+
206+
chalice: ChaliceRootConfig = pydantic.Field(default_factory=ChaliceRootConfig)
103207
infra: InfraConfig = pydantic.Field(default_factory=InfraConfig)
104208
toast: ToastConfig = pydantic.Field(default_factory=ToastConfig)
105209
firebase: FirebaseConfig = pydantic.Field(default_factory=FirebaseConfig)
106210
slack: SlackConfig = pydantic.Field(default_factory=SlackConfig)
107211
telegram: TelegramConfig = pydantic.Field(default_factory=TelegramConfig)
108212

109-
env_vars: dict[str, str] = pydantic.Field(default_factory=dict)
213+
model_config = pydantic_settings.SettingsConfigDict(
214+
env_nested_delimiter="__",
215+
case_sensitive=False,
216+
arbitrary_types_allowed=True,
217+
)
218+
219+
@property
220+
def env_dict(self) -> dict[str, str]:
221+
settings_dict: dict[str, typing.Any] = self.model_dump(
222+
mode="json",
223+
by_alias=True,
224+
exclude=["chalice"],
225+
exclude_unset=True,
226+
exclude_none=True,
227+
)
228+
result_dict: dict[str, str] = {}
229+
230+
if not (delimiters := self.model_config.get("env_nested_delimiter")):
231+
raise ValueError("env_nested_delimiter must be set in model_config")
232+
233+
def _flatten_dict(d: dict, prefix: str = "") -> None:
234+
for k, v in d.items():
235+
key = f"{prefix}{delimiters}{k}" if prefix else k
236+
if isinstance(v, dict):
237+
_flatten_dict(v, key)
238+
elif isinstance(v, str):
239+
result_dict[key] = v
240+
else:
241+
result_dict[key] = json.dumps(v)
242+
243+
_flatten_dict(settings_dict)
244+
return result_dict
245+
246+
@property
247+
def chalice_config(self) -> ChaliceConfig:
248+
return ChaliceConfig(
249+
app_name=self.project_name,
250+
environment_variables=self.env_dict,
251+
**self.chalice.model_dump(mode="python", exclude_unset=True, exclude_none=True),
252+
)
110253

111254

112-
config = Config(_env_nested_delimiter="__", _case_sensitive=False)
255+
config = Config()

0 commit comments

Comments
 (0)