django-lambda-tasks 0.1.5__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. django_lambda_tasks-0.2.0/.kiro/specs/ssm-environment-loader/.config.kiro +1 -0
  2. django_lambda_tasks-0.2.0/.kiro/specs/ssm-environment-loader/design.md +225 -0
  3. django_lambda_tasks-0.2.0/.kiro/specs/ssm-environment-loader/requirements.md +56 -0
  4. django_lambda_tasks-0.2.0/.kiro/specs/ssm-environment-loader/tasks.md +124 -0
  5. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/steering/product.md +17 -2
  6. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/steering/structure.md +4 -2
  7. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/PKG-INFO +47 -1
  8. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/README.md +46 -0
  9. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/handler.py +9 -6
  10. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/secret_loader.py +13 -1
  11. django_lambda_tasks-0.2.0/lambda_tasks/ssm_environment_loader.py +93 -0
  12. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/pyproject.toml +1 -1
  13. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_handler.py +25 -0
  14. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_secret_loader.py +16 -5
  15. django_lambda_tasks-0.2.0/tests/test_ssm_environment_loader.py +654 -0
  16. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.github/workflows/ci.yml +0 -0
  17. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.github/workflows/release.yml +0 -0
  18. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.gitignore +0 -0
  19. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  20. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  21. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  22. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  23. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  24. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  25. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  26. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  27. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  28. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  29. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  30. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  31. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  32. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  33. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  34. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  35. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
  36. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/retry-delay/design.md +0 -0
  37. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/retry-delay/requirements.md +0 -0
  38. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/retry-delay/tasks.md +0 -0
  39. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  40. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
  41. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  42. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  43. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  44. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  45. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  46. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  47. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
  48. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/singleton-task/design.md +0 -0
  49. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/singleton-task/requirements.md +0 -0
  50. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/singleton-task/tasks.md +0 -0
  51. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/task-retry/.config.kiro +0 -0
  52. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/task-retry/design.md +0 -0
  53. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/task-retry/requirements.md +0 -0
  54. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/specs/task-retry/tasks.md +0 -0
  55. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.kiro/steering/tech.md +0 -0
  56. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.pre-commit-config.yaml +0 -0
  57. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/.vscode/settings.json +0 -0
  58. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/README.md +0 -0
  59. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_app/__init__.py +0 -0
  60. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_app/apps.py +0 -0
  61. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_app/tasks.py +0 -0
  62. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_app/urls.py +0 -0
  63. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_app/views.py +0 -0
  64. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_project/__init__.py +0 -0
  65. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_project/settings.py +0 -0
  66. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_project/urls.py +0 -0
  67. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/example_project/wsgi.py +0 -0
  68. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/example/manage.py +0 -0
  69. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/__init__.py +0 -0
  70. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/admin.py +0 -0
  71. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/apps.py +0 -0
  72. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/decorators.py +0 -0
  73. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/logging.py +0 -0
  74. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/migrations/0001_initial.py +0 -0
  75. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/migrations/__init__.py +0 -0
  76. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/models.py +0 -0
  77. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/settings.py +0 -0
  78. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/tasks.py +0 -0
  79. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/lambda_tasks/timeouts.py +0 -0
  80. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/conftest.py +0 -0
  81. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/settings.py +0 -0
  82. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_admin.py +0 -0
  83. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_decorator.py +0 -0
  84. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_decorators.py +0 -0
  85. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_deferred_enqueue.py +0 -0
  86. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_kwargs_only.py +0 -0
  87. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_logging.py +0 -0
  88. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_models.py +0 -0
  89. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_serializer.py +0 -0
  90. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_settings.py +0 -0
  91. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_tasks.py +0 -0
  92. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_timeout_validation.py +0 -0
  93. {django_lambda_tasks-0.1.5 → django_lambda_tasks-0.2.0}/tests/test_timeouts.py +0 -0
@@ -0,0 +1 @@
1
+ {"specId": "293fc556-56fb-43f9-971a-68228fd351a8", "workflowType": "requirements-first", "specType": "feature"}
@@ -0,0 +1,225 @@
1
+ # Design Document: SSM Environment Loader
2
+
3
+ ## Overview
4
+
5
+ This feature adds a new module `ssm_environment_loader.py` to the `lambda_tasks` package that reads an AWS SSM Parameter Store parameter at Lambda cold start and sets its JSON content as environment variables. It follows the same pattern as the existing `secret_loader.py` — a module-level cached function called before `django.setup()` in `handler.py`.
6
+
7
+ The module exposes a single public function `resolve_ssm_environment()` that:
8
+ 1. Checks for the `LAMBDA_TASKS_SSM_ENVIRONMENT` env var
9
+ 2. If present, fetches the named SSM parameter via boto3
10
+ 3. Parses the parameter value as a flat JSON object (`dict[str, str]`)
11
+ 4. Sets each key-value pair in `os.environ`
12
+ 5. Caches the result so subsequent calls are no-ops
13
+
14
+ This runs **before** `resolve_secrets_into_env()` in the cold-start sequence, allowing SSM-loaded env vars to be referenced by the secret loader.
15
+
16
+ ## Architecture
17
+
18
+ ```mermaid
19
+ sequenceDiagram
20
+ participant Lambda as Lambda Container
21
+ participant SSM as ssm_environment_loader
22
+ participant Secret as secret_loader
23
+ participant Django as django.setup()
24
+
25
+ Lambda->>SSM: resolve_ssm_environment()
26
+ alt LAMBDA_TASKS_SSM_ENVIRONMENT is set
27
+ SSM->>SSM: Check module-level cache
28
+ alt Not cached
29
+ SSM->>AWS: ssm.get_parameter(Name=param_name, WithDecryption=True)
30
+ AWS-->>SSM: Parameter value (JSON string)
31
+ SSM->>SSM: Validate JSON (flat str→str object)
32
+ SSM->>SSM: Set os.environ for each key-value pair
33
+ SSM->>SSM: Store in module-level cache
34
+ end
35
+ end
36
+ Lambda->>Secret: resolve_secrets_into_env()
37
+ Lambda->>Lambda: Check DJANGO_SETTINGS_MODULE
38
+ alt DJANGO_SETTINGS_MODULE is set and apps not ready
39
+ Lambda->>Django: django.setup()
40
+ end
41
+ ```
42
+
43
+ Both loaders run **unconditionally** before the `DJANGO_SETTINGS_MODULE` check — they are both idempotent and cached. The SSM parameter may provide `DJANGO_SETTINGS_MODULE` itself, and secrets may reference SSM-loaded vars.
44
+
45
+ ## Components and Interfaces
46
+
47
+ ### Module: `lambda_tasks/ssm_environment_loader.py`
48
+
49
+ **Public API:**
50
+
51
+ ```python
52
+ def resolve_ssm_environment() -> None:
53
+ """Load SSM parameter content into os.environ.
54
+
55
+ Reads the parameter named by LAMBDA_TASKS_SSM_ENVIRONMENT,
56
+ parses it as a flat JSON object, and sets each key-value pair
57
+ as an environment variable. Idempotent — cached after first call.
58
+
59
+ Raises:
60
+ ValueError: If the parameter content is not valid JSON,
61
+ not a flat string→string mapping, or contains
62
+ an empty string key.
63
+ """
64
+ ```
65
+
66
+ **Internal helpers (keyword-only args, fully typed):**
67
+
68
+ ```python
69
+ def _fetch_parameter(*, parameter_name: str) -> str:
70
+ """Fetch a single SSM parameter value using boto3."""
71
+
72
+ def _validate_and_parse(*, raw_value: str, parameter_name: str) -> dict[str, str]:
73
+ """Parse JSON and validate it is a flat str→str mapping.
74
+
75
+ Raises ValueError with descriptive messages on failure.
76
+ """
77
+ ```
78
+
79
+ **Module-level state:**
80
+
81
+ ```python
82
+ _cache: dict[str, str] | None = None # None = not yet loaded; dict = loaded content
83
+ _loaded: bool = False # Sentinel to distinguish "loaded empty" from "not loaded"
84
+ ```
85
+
86
+ ### Integration point: `lambda_tasks/handler.py`
87
+
88
+ The cold-start block changes from:
89
+
90
+ ```python
91
+ if os.environ.get("DJANGO_SETTINGS_MODULE") and not apps.ready:
92
+ resolve_secrets_into_env()
93
+ django.setup()
94
+ ```
95
+
96
+ To:
97
+
98
+ ```python
99
+ from lambda_tasks.ssm_environment_loader import resolve_ssm_environment
100
+
101
+ # Both loaders are idempotent and run unconditionally before the
102
+ # DJANGO_SETTINGS_MODULE check — SSM may provide that var, and
103
+ # secrets may depend on SSM-loaded vars.
104
+ resolve_ssm_environment()
105
+ resolve_secrets_into_env()
106
+
107
+ if os.environ.get("DJANGO_SETTINGS_MODULE") and not apps.ready:
108
+ django.setup()
109
+ ```
110
+
111
+ ## Data Models
112
+
113
+ ### SSM Parameter Content Format
114
+
115
+ The SSM parameter value must be a JSON object where all keys and values are strings:
116
+
117
+ ```json
118
+ {
119
+ "DATABASE_URL": "postgres://user:pass@host:5432/db",
120
+ "REDIS_URL": "redis://host:6379/0",
121
+ "DJANGO_SECRET_KEY": "some-secret-key"
122
+ }
123
+ ```
124
+
125
+ **Validation rules:**
126
+ 1. Must be valid JSON (parseable by `json.loads`)
127
+ 2. Must be a JSON object (top-level `dict`)
128
+ 3. All values must be strings (no nested objects, arrays, numbers, booleans, or null)
129
+ 4. No empty string keys
130
+
131
+ ### Module-level Cache
132
+
133
+ ```python
134
+ _loaded: bool = False
135
+ ```
136
+
137
+ A simple boolean sentinel. Once `resolve_ssm_environment()` completes successfully, `_loaded` is set to `True`. Subsequent calls check this flag and return immediately. This is simpler than caching the parsed dict since we only need to know "did we already run?"
138
+
139
+ ## Correctness Properties
140
+
141
+ *A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
142
+
143
+ ### Property 1: Parameter content round-trip into environment
144
+
145
+ *For any* valid flat JSON object (where all keys are non-empty strings and all values are strings), when the SSM parameter returns that JSON, calling `resolve_ssm_environment()` SHALL result in every key-value pair from the JSON being present in `os.environ` with the correct value.
146
+
147
+ **Validates: Requirements 1.3**
148
+
149
+ ### Property 2: Invalid JSON rejection
150
+
151
+ *For any* string that is not valid JSON, when the SSM parameter returns that string, calling `resolve_ssm_environment()` SHALL raise a `ValueError` whose message contains the parameter name.
152
+
153
+ **Validates: Requirements 2.1**
154
+
155
+ ### Property 3: Non-flat JSON rejection with key identification
156
+
157
+ *For any* valid JSON object containing at least one non-string value (int, float, list, dict, bool, or null), calling `resolve_ssm_environment()` SHALL raise a `ValueError` whose message contains the names of all offending keys.
158
+
159
+ **Validates: Requirements 2.2**
160
+
161
+ ### Property 4: Idempotent execution
162
+
163
+ *For any* valid SSM parameter content, calling `resolve_ssm_environment()` N times (where N ≥ 1) SHALL result in exactly one SSM API call, with all subsequent calls returning immediately without contacting AWS.
164
+
165
+ **Validates: Requirements 3.1, 3.2**
166
+
167
+ ## Error Handling
168
+
169
+ | Condition | Behaviour | Rationale |
170
+ |---|---|---|
171
+ | `LAMBDA_TASKS_SSM_ENVIRONMENT` not set | Return immediately, no API call | Opt-in behaviour; no-op when not configured |
172
+ | SSM parameter not found (boto3 `ParameterNotFound`) | Let exception propagate | Fail fast at cold start; misconfiguration should crash the container |
173
+ | SSM API error (network, permissions) | Let exception propagate | Fail fast; boto3 exceptions propagate per project convention |
174
+ | Parameter value is not valid JSON | Raise `ValueError` with parameter name | Fail fast with actionable error message |
175
+ | JSON is not a flat object | Raise `ValueError` listing offending keys | Fail fast; identify exactly what's wrong |
176
+ | JSON contains empty string key | Raise `ValueError` | Empty env var names are invalid on all platforms |
177
+
178
+ **Design decision:** SSM keys override existing env vars without conflict detection. This differs from `secret_loader.py` which raises on conflicts. The rationale is that SSM parameters represent the canonical environment configuration — they are expected to override deployment-time defaults. This keeps the mental model simple: "SSM wins."
179
+
180
+ ## Testing Strategy
181
+
182
+ ### Property-Based Tests (Hypothesis)
183
+
184
+ The project already uses Hypothesis (`.hypothesis/` directory exists, `hypothesis` in dev dependencies). Each correctness property maps to a Hypothesis test with minimum 100 iterations.
185
+
186
+ | Property | Generator Strategy | Assertion |
187
+ |---|---|---|
188
+ | 1: Content round-trip | `st.dictionaries(keys=st.text(min_size=1, ...), values=st.text())` | All pairs present in `os.environ` |
189
+ | 2: Invalid JSON rejection | `st.text().filter(lambda s: not is_valid_json(s))` | `ValueError` raised, parameter name in message |
190
+ | 3: Non-flat JSON rejection | `st.dictionaries(...)` with at least one non-string value | `ValueError` raised, offending keys in message |
191
+ | 4: Idempotent execution | Valid content + `st.integers(min_value=2, max_value=10)` for call count | API called exactly once |
192
+
193
+ **Property test configuration:**
194
+ - Library: `hypothesis` (already installed)
195
+ - Minimum iterations: 100 (Hypothesis default is higher, which is fine)
196
+ - Tag format: `# Feature: ssm-environment-loader, Property {N}: {title}`
197
+
198
+ ### Unit Tests (Example-Based)
199
+
200
+ | Scenario | Type |
201
+ |---|---|
202
+ | Env var not set → no-op, no boto3 client created | Example (Req 1.2) |
203
+ | Empty string key in JSON → ValueError | Edge case (Req 2.3) |
204
+ | Handler cold-start ordering (SSM → secrets → Django) | Integration (Req 1.4, 1.5) |
205
+ | Module importable from `lambda_tasks.ssm_environment_loader` | Smoke (Req 4.2) |
206
+ | `resolve_ssm_environment` callable with no args | Smoke (Req 4.1) |
207
+ | SSM parameter override of existing env var | Example (confirms override behaviour) |
208
+
209
+ ### Test File
210
+
211
+ Tests live in `tests/test_ssm_environment_loader.py` following the project convention of one test file per source module.
212
+
213
+ ### Mocking Strategy
214
+
215
+ - boto3 SSM client is mocked at the module level (same pattern as `test_secret_loader.py`)
216
+ - `os.environ` manipulation via `monkeypatch` (pytest fixture)
217
+ - Module-level cache (`_loaded`) reset via autouse fixture between tests
218
+ - No real AWS calls in any test
219
+
220
+ ### TDD Approach
221
+
222
+ Following the project's TDD convention:
223
+ 1. Write a failing test for each property/example
224
+ 2. Implement the minimum code to make it pass
225
+ 3. Refactor while keeping tests green
@@ -0,0 +1,56 @@
1
+ # Requirements Document
2
+
3
+ ## Introduction
4
+
5
+ This feature adds SSM Parameter Store environment loading to the Lambda cold-start sequence. When the environment variable `LAMBDA_TASKS_SSM_ENVIRONMENT` is set, the Lambda handler reads the named SSM parameter, parses its JSON content as a flat key-value mapping, and sets the resulting pairs as environment variables. This happens before `django.setup()` and only on cold start, following the same pattern as the existing `resolve_secrets_into_env()` in `secret_loader.py`.
6
+
7
+ ## Glossary
8
+
9
+ - **SSM_Environment_Loader**: The module responsible for reading an SSM Parameter Store parameter and setting its JSON content as environment variables
10
+ - **SSM_Parameter**: An AWS Systems Manager Parameter Store parameter containing a JSON object whose keys and values are strings
11
+ - **Cold_Start**: The first invocation of a Lambda container, before `django.setup()` has been called
12
+ - **Handler**: The Lambda entry point in `handler.py` that orchestrates cold-start setup and SQS record processing
13
+
14
+ ## Requirements
15
+
16
+ ### Requirement 1: Load SSM parameter on cold start
17
+
18
+ **User Story:** As a developer deploying Lambda tasks, I want environment variables loaded from an SSM parameter at cold start, so that I can manage environment configuration centrally in Parameter Store without baking values into the Lambda deployment package.
19
+
20
+ #### Acceptance Criteria
21
+
22
+ 1. WHEN the environment variable `LAMBDA_TASKS_SSM_ENVIRONMENT` is set and the Lambda container is performing cold start, THE SSM_Environment_Loader SHALL retrieve the SSM parameter whose name matches the value of `LAMBDA_TASKS_SSM_ENVIRONMENT`
23
+ 2. WHEN the environment variable `LAMBDA_TASKS_SSM_ENVIRONMENT` is not set, THE SSM_Environment_Loader SHALL take no action and make no AWS API calls
24
+ 3. WHEN the SSM parameter is retrieved successfully, THE SSM_Environment_Loader SHALL parse the parameter value as a JSON object and set each key-value pair as an environment variable in `os.environ`
25
+ 4. THE SSM_Environment_Loader SHALL execute before the `os.environ.get("DJANGO_SETTINGS_MODULE")` check in the handler cold-start block
26
+ 5. THE SSM_Environment_Loader SHALL execute before `resolve_secrets_into_env()` is called during cold start
27
+ 6. THE SSM_Environment_Loader SHALL execute before `django.setup()` is called during cold start
28
+
29
+ ### Requirement 2: Validate SSM parameter content
30
+
31
+ **User Story:** As a developer, I want the loader to fail fast on invalid parameter content, so that misconfiguration is caught immediately at cold start rather than causing subtle runtime errors.
32
+
33
+ #### Acceptance Criteria
34
+
35
+ 1. IF the SSM parameter value is not valid JSON, THEN THE SSM_Environment_Loader SHALL raise a `ValueError` with a descriptive message including the parameter name
36
+ 2. IF the SSM parameter JSON is not a flat object (i.e. contains non-string values), THEN THE SSM_Environment_Loader SHALL raise a `ValueError` with a descriptive message identifying the offending keys
37
+ 3. IF the SSM parameter JSON contains an empty string as a key, THEN THE SSM_Environment_Loader SHALL raise a `ValueError` with a descriptive message
38
+
39
+ ### Requirement 3: Idempotent execution
40
+
41
+ **User Story:** As a developer, I want the loader to be safe to call multiple times, so that warm invocations pay no extra cost and the function can be called defensively.
42
+
43
+ #### Acceptance Criteria
44
+
45
+ 1. WHEN the SSM_Environment_Loader has already successfully loaded the parameter, THE SSM_Environment_Loader SHALL skip the AWS API call on subsequent invocations and return immediately
46
+ 2. THE SSM_Environment_Loader SHALL use a module-level cache to store the fetched parameter value for the lifetime of the Lambda container
47
+
48
+ ### Requirement 4: Function signature conventions
49
+
50
+ **User Story:** As a maintainer, I want the loader to follow the project's coding conventions, so that the codebase remains consistent.
51
+
52
+ #### Acceptance Criteria
53
+
54
+ 1. THE SSM_Environment_Loader SHALL expose a single public function `resolve_ssm_environment()` that takes no arguments
55
+ 2. THE SSM_Environment_Loader SHALL reside in a module named `ssm_environment_loader.py` within the `lambda_tasks` package
56
+ 3. THE SSM_Environment_Loader SHALL use keyword-only arguments for all internal helper functions with full type annotations
@@ -0,0 +1,124 @@
1
+ # Implementation Plan: SSM Environment Loader
2
+
3
+ ## Overview
4
+
5
+ Implement a new module `lambda_tasks/ssm_environment_loader.py` that reads an AWS SSM Parameter Store parameter at Lambda cold start and sets its JSON content as environment variables. Follow TDD: write failing tests first, then implement the minimum code to pass. Integrate into `handler.py` so SSM loading runs before secrets and Django setup.
6
+
7
+ ## Tasks
8
+
9
+ - [x] 1. Create module skeleton and test infrastructure
10
+ - [x] 1.1 Create `lambda_tasks/ssm_environment_loader.py` with module docstring, imports, module-level `_loaded` sentinel, and stub `resolve_ssm_environment()` that does nothing
11
+ - Define `_loaded: bool = False`
12
+ - Import `os`, `json`, `logging`, `boto3`
13
+ - Stub `resolve_ssm_environment() -> None` with `pass` body
14
+ - Stub `_fetch_parameter(*, parameter_name: str) -> str` with `pass` body
15
+ - Stub `_validate_and_parse(*, raw_value: str, parameter_name: str) -> dict[str, str]` with `pass` body
16
+ - All functions use keyword-only args and full type annotations
17
+ - _Requirements: 4.1, 4.2, 4.3_
18
+
19
+ - [x] 1.2 Create `tests/test_ssm_environment_loader.py` with test scaffolding
20
+ - Add autouse fixture to reset `_loaded` sentinel between tests
21
+ - Add fixture to patch boto3 SSM client at module level (same pattern as `test_secret_loader.py`)
22
+ - Add monkeypatch-based env var helpers
23
+ - Verify module is importable and `resolve_ssm_environment` is callable with no args
24
+ - _Requirements: 4.1, 4.2_
25
+
26
+ - [x] 2. Implement validation logic (`_validate_and_parse`)
27
+ - [x] 2.1 Write unit tests for `_validate_and_parse` covering invalid JSON, non-flat objects, and empty keys
28
+ - Test: invalid JSON string raises `ValueError` with parameter name in message
29
+ - Test: JSON with non-string values raises `ValueError` listing offending keys
30
+ - Test: JSON with empty string key raises `ValueError`
31
+ - Test: valid flat JSON returns `dict[str, str]`
32
+ - _Requirements: 2.1, 2.2, 2.3_
33
+
34
+ - [x] 2.2 Implement `_validate_and_parse` to pass all validation tests
35
+ - Parse with `json.loads`; catch `json.JSONDecodeError` and raise `ValueError` with parameter name
36
+ - Check top-level is a `dict`; raise if not
37
+ - Check all values are `str`; collect offending keys and raise `ValueError` listing them
38
+ - Check no empty string keys; raise `ValueError` if found
39
+ - Return validated `dict[str, str]`
40
+ - _Requirements: 2.1, 2.2, 2.3_
41
+
42
+ - [x] 2.3 Write property test for invalid JSON rejection
43
+ - **Property 2: Invalid JSON rejection**
44
+ - Generate arbitrary strings that are not valid JSON
45
+ - Assert `_validate_and_parse` raises `ValueError` with parameter name in message
46
+ - **Validates: Requirements 2.1**
47
+
48
+ - [x] 2.4 Write property test for non-flat JSON rejection with key identification
49
+ - **Property 3: Non-flat JSON rejection with key identification**
50
+ - Generate JSON objects with at least one non-string value (int, float, list, dict, bool, None)
51
+ - Assert `_validate_and_parse` raises `ValueError` whose message contains all offending key names
52
+ - **Validates: Requirements 2.2**
53
+
54
+ - [x] 3. Implement core loading logic (`resolve_ssm_environment`)
55
+ - [x] 3.1 Write unit tests for `resolve_ssm_environment` no-op behaviour
56
+ - Test: when `LAMBDA_TASKS_SSM_ENVIRONMENT` is not set, no boto3 client is created and no API call is made
57
+ - Test: when `LAMBDA_TASKS_SSM_ENVIRONMENT` is not set, `os.environ` is unchanged
58
+ - _Requirements: 1.2_
59
+
60
+ - [x] 3.2 Write unit tests for `resolve_ssm_environment` happy path
61
+ - Test: when SSM parameter contains valid flat JSON, all key-value pairs are set in `os.environ`
62
+ - Test: SSM keys override existing env vars (no conflict detection)
63
+ - Test: `_fetch_parameter` calls `ssm.get_parameter(Name=param_name, WithDecryption=True)`
64
+ - _Requirements: 1.1, 1.3_
65
+
66
+ - [x] 3.3 Implement `_fetch_parameter` and `resolve_ssm_environment`
67
+ - `_fetch_parameter`: create boto3 SSM client, call `get_parameter(Name=parameter_name, WithDecryption=True)`, return `Parameter.Value`
68
+ - `resolve_ssm_environment`: check `LAMBDA_TASKS_SSM_ENVIRONMENT` env var; if not set, return immediately; if `_loaded` is True, return immediately; call `_fetch_parameter`, call `_validate_and_parse`, set each key in `os.environ`, set `_loaded = True`
69
+ - _Requirements: 1.1, 1.2, 1.3_
70
+
71
+ - [x] 3.4 Write property test for parameter content round-trip into environment
72
+ - **Property 1: Parameter content round-trip into environment**
73
+ - Generate arbitrary flat `dict[str, str]` (non-empty keys, string values)
74
+ - Mock SSM to return `json.dumps(generated_dict)`
75
+ - Assert every key-value pair from the dict is present in `os.environ` after calling `resolve_ssm_environment()`
76
+ - **Validates: Requirements 1.3**
77
+
78
+ - [x] 4. Implement idempotency
79
+ - [x] 4.1 Write unit tests for idempotent execution
80
+ - Test: calling `resolve_ssm_environment()` twice results in only one boto3 API call
81
+ - Test: no boto3 client created on second call when `_loaded` is True
82
+ - _Requirements: 3.1, 3.2_
83
+
84
+ - [x] 4.2 Verify idempotency implementation passes (already implemented via `_loaded` flag in 3.3)
85
+ - Confirm `_loaded` sentinel prevents re-execution
86
+ - _Requirements: 3.1, 3.2_
87
+
88
+ - [x] 4.3 Write property test for idempotent execution
89
+ - **Property 4: Idempotent execution**
90
+ - Generate valid SSM content and a call count N (2 to 10)
91
+ - Call `resolve_ssm_environment()` N times
92
+ - Assert boto3 `get_parameter` was called exactly once
93
+ - **Validates: Requirements 3.1, 3.2**
94
+
95
+ - [x] 5. Checkpoint - Ensure all tests pass
96
+ - Ensure all tests pass, ask the user if questions arise.
97
+
98
+ - [x] 6. Integrate into handler and verify ordering
99
+ - [x] 6.1 Write integration test for handler cold-start ordering
100
+ - Test: `resolve_ssm_environment()` is called before `resolve_secrets_into_env()`
101
+ - Test: both loaders run unconditionally before the `DJANGO_SETTINGS_MODULE` check
102
+ - Test: `django.setup()` is only called when `DJANGO_SETTINGS_MODULE` is set and `apps.ready` is False
103
+ - _Requirements: 1.4, 1.5, 1.6_
104
+
105
+ - [x] 6.2 Modify `lambda_tasks/handler.py` to integrate SSM loader
106
+ - Add `from lambda_tasks.ssm_environment_loader import resolve_ssm_environment`
107
+ - Move `resolve_secrets_into_env()` outside the `if` block
108
+ - Add `resolve_ssm_environment()` call before `resolve_secrets_into_env()`
109
+ - Keep `django.setup()` inside the `if os.environ.get("DJANGO_SETTINGS_MODULE") and not apps.ready:` conditional
110
+ - _Requirements: 1.4, 1.5, 1.6_
111
+
112
+ - [x] 7. Final checkpoint - Ensure all tests pass
113
+ - Ensure all tests pass, ask the user if questions arise.
114
+
115
+ ## Notes
116
+
117
+ - Tasks marked with `*` are optional and can be skipped for faster MVP
118
+ - Each task references specific requirements for traceability
119
+ - Checkpoints ensure incremental validation
120
+ - Property tests validate universal correctness properties from the design document
121
+ - Unit tests validate specific examples and edge cases
122
+ - Follow TDD: write failing tests first (tasks X.1, X.2), then implement (tasks X.3)
123
+ - All functions use keyword-only arguments and full type annotations per project conventions
124
+ - boto3 is mocked in all tests — no real AWS calls
@@ -135,11 +135,26 @@ class SQSLambdaTaskMessage(BaseModel):
135
135
  - Returns `{"batchItemFailures": [...]}` for partial-batch failure reporting
136
136
  - Only pre-execution failures (malformed message, import error, misconfiguration) are reported as `batchItemFailures` — task logic failures are caught and recorded as `FAILED` TaskRecords without raising
137
137
  - Recommended SQS queue settings: `maxReceiveCount=1` with a DLQ configured; automatic retries are not useful since task failures are not re-driven by design
138
- - Calls `resolve_secrets_into_env()` before `django.setup()` at cold start to populate env vars from AWS Secrets Manager
138
+ - Cold-start sequence: `resolve_ssm_environment()` → `resolve_secrets_into_env()` conditional `django.setup()`
139
+ - Both loaders run unconditionally (outside the `DJANGO_SETTINGS_MODULE` check) — SSM may provide that var, and secrets may depend on SSM-loaded vars
140
+
141
+ ## SSM Environment Loader
142
+
143
+ `resolve_ssm_environment()` in `ssm_environment_loader.py` runs once at Lambda cold start, before `resolve_secrets_into_env()` and `django.setup()`.
144
+
145
+ When the environment variable `LAMBDA_TASKS_SSM_ENVIRONMENT` is set, the loader fetches the named SSM parameter, parses its JSON content as a flat key-value mapping, and sets the resulting pairs as environment variables.
146
+
147
+ Behaviour:
148
+ - If `LAMBDA_TASKS_SSM_ENVIRONMENT` is not set, does nothing (no AWS API calls)
149
+ - Fetches the parameter with `WithDecryption=True`
150
+ - Validates the parameter value is a flat JSON object (all values must be strings, no empty keys)
151
+ - Sets each key-value pair in `os.environ` — existing env vars are overridden (no conflict detection)
152
+ - Idempotent via a module-level `_loaded` sentinel — subsequent calls are free no-ops
153
+ - Invalid JSON, non-flat objects, or empty keys raise `ValueError` at cold start
139
154
 
140
155
  ## Secret Loader
141
156
 
142
- `resolve_secrets_into_env()` in `secret_loader.py` runs once at Lambda cold start, before `django.setup()`.
157
+ `resolve_secrets_into_env()` in `secret_loader.py` runs once at Lambda cold start, after `resolve_ssm_environment()` and before `django.setup()`.
143
158
 
144
159
  Any env var prefixed `LAMBDA_TASKS_SECRET_` is treated as a Secrets Manager reference. The unprefixed name is the target env var.
145
160
 
@@ -18,6 +18,7 @@ django-lambda-tasks/
18
18
  │ ├── models.py # TaskRecord, SQSLambdaTaskMessage, SQSLambdaTask
19
19
  │ ├── settings.py # LambdaTasksSettings (lazy Django settings reader)
20
20
  │ ├── secret_loader.py # Resolves LAMBDA_TASKS_SECRET_* env vars at cold start
21
+ │ ├── ssm_environment_loader.py # Loads env vars from SSM Parameter Store at cold start
21
22
  │ ├── tasks.py # Built-in maintenance tasks (cleanup_task_records)
22
23
  │ ├── timeouts.py # TimeoutContext implementation
23
24
  │ └── migrations/ # Django migrations for TaskRecord
@@ -39,8 +40,9 @@ django-lambda-tasks/
39
40
 
40
41
  - `decorators.py` — defines `@lambda_task`; enforces kwargs-only at decoration time
41
42
  - `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (Pydantic, SQS schema + execution logic), `SQSLambdaTask` (Pydantic, holds message + routing; `_execute()` publishes to SQS or executes eagerly; `execute_on_commit()` registers `_execute` with `transaction.on_commit`)
42
- - `handler.py` — Lambda entry point; calls `resolve_secrets_into_env()` then `django.setup()` at cold start; processes SQS records independently; returns `batchItemFailures`
43
- - `secret_loader.py` — resolves `LAMBDA_TASKS_SECRET_*` env vars from Secrets Manager before Django starts; validates format, detects conflicts, batches API calls, caches results in-process
43
+ - `handler.py` — Lambda entry point; calls `resolve_ssm_environment()` then `resolve_secrets_into_env()` then conditionally `django.setup()` at cold start; processes SQS records independently; returns `batchItemFailures`
44
+ - `ssm_environment_loader.py` — loads env vars from an SSM Parameter Store parameter at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
45
+ - `secret_loader.py` — resolves `LAMBDA_TASKS_SECRET_*` env vars from Secrets Manager before Django starts; validates format, detects conflicts, batches API calls; idempotent via `_loaded` sentinel
44
46
  - `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
45
47
  - `settings.py` — `LambdaTasksSettings` instantiated fresh per use (reads live Django settings)
46
48
  - `admin.py` — Django admin registration for `TaskRecord`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-lambda-tasks
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: Run async tasks in a lambda function
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: awslambdaric
@@ -437,6 +437,12 @@ lambda_tasks.handler.handler
437
437
 
438
438
  Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
439
439
 
440
+ | Environment Variable | Required | Description |
441
+ |---|---|---|
442
+ | `DJANGO_SETTINGS_MODULE` | Yes | Django settings module path (e.g. `myapp.settings.production`). |
443
+ | `LAMBDA_TASKS_SSM_ENVIRONMENT` | No | SSM Parameter Store parameter name to load as environment variables at cold start. |
444
+ | `LAMBDA_TASKS_SECRET_*` | No | Secrets Manager references resolved into env vars at cold start (see below). |
445
+
440
446
  ### Resolving Django settings from AWS Secrets Manager
441
447
 
442
448
  The Lambda handler supports loading secret values from AWS Secrets Manager into the environment before Django starts. This lets your Django settings file read from `os.environ` as normal while keeping secrets out of plaintext environment variables.
@@ -487,6 +493,46 @@ The following all raise `ValueError` at cold start, preventing the Lambda contai
487
493
  - The named JSON key does not exist in the fetched secret
488
494
  - The secret value is not valid JSON
489
495
 
496
+ ### Loading environment variables from SSM Parameter Store
497
+
498
+ The Lambda handler supports loading environment variables from an AWS SSM Parameter Store parameter at cold start. This lets you manage environment configuration centrally in Parameter Store without baking values into the Lambda deployment package.
499
+
500
+ Set the `LAMBDA_TASKS_SSM_ENVIRONMENT` environment variable to the name of an SSM parameter:
501
+
502
+ ```
503
+ LAMBDA_TASKS_SSM_ENVIRONMENT=/myapp/prod/environment
504
+ ```
505
+
506
+ The parameter value must be a flat JSON object where all keys and values are strings:
507
+
508
+ ```json
509
+ {
510
+ "DATABASE_URL": "postgres://user:pass@host:5432/db",
511
+ "REDIS_URL": "redis://host:6379/0",
512
+ "DJANGO_SETTINGS_MODULE": "myapp.settings.production"
513
+ }
514
+ ```
515
+
516
+ At cold start, before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_ssm_environment()` which:
517
+
518
+ 1. Checks for the `LAMBDA_TASKS_SSM_ENVIRONMENT` env var — if not set, does nothing
519
+ 2. Fetches the named parameter via `ssm.get_parameter(Name=..., WithDecryption=True)`
520
+ 3. Validates the parameter value is a flat JSON object (all values must be strings, no empty keys)
521
+ 4. Sets each key-value pair in `os.environ` — existing env vars are overridden
522
+ 5. Caches the result via a module-level sentinel — subsequent calls are free no-ops
523
+
524
+ Because SSM loading runs first, the parameter can provide `DJANGO_SETTINGS_MODULE` itself, and secrets loaded by `resolve_secrets_into_env()` can reference SSM-loaded values.
525
+
526
+ #### Validation errors
527
+
528
+ The following raise `ValueError` at cold start, preventing the Lambda container from starting:
529
+
530
+ - Parameter value is not valid JSON
531
+ - JSON is not a flat object (contains non-string values) — error message lists the offending keys
532
+ - JSON contains an empty string key
533
+
534
+ AWS errors (parameter not found, permission denied) propagate as boto3 exceptions and crash the container at cold start.
535
+
490
536
  ---
491
537
 
492
538
  ## Built-in tasks
@@ -425,6 +425,12 @@ lambda_tasks.handler.handler
425
425
 
426
426
  Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and that all task modules are importable (i.e. your application code is on the Python path).
427
427
 
428
+ | Environment Variable | Required | Description |
429
+ |---|---|---|
430
+ | `DJANGO_SETTINGS_MODULE` | Yes | Django settings module path (e.g. `myapp.settings.production`). |
431
+ | `LAMBDA_TASKS_SSM_ENVIRONMENT` | No | SSM Parameter Store parameter name to load as environment variables at cold start. |
432
+ | `LAMBDA_TASKS_SECRET_*` | No | Secrets Manager references resolved into env vars at cold start (see below). |
433
+
428
434
  ### Resolving Django settings from AWS Secrets Manager
429
435
 
430
436
  The Lambda handler supports loading secret values from AWS Secrets Manager into the environment before Django starts. This lets your Django settings file read from `os.environ` as normal while keeping secrets out of plaintext environment variables.
@@ -475,6 +481,46 @@ The following all raise `ValueError` at cold start, preventing the Lambda contai
475
481
  - The named JSON key does not exist in the fetched secret
476
482
  - The secret value is not valid JSON
477
483
 
484
+ ### Loading environment variables from SSM Parameter Store
485
+
486
+ The Lambda handler supports loading environment variables from an AWS SSM Parameter Store parameter at cold start. This lets you manage environment configuration centrally in Parameter Store without baking values into the Lambda deployment package.
487
+
488
+ Set the `LAMBDA_TASKS_SSM_ENVIRONMENT` environment variable to the name of an SSM parameter:
489
+
490
+ ```
491
+ LAMBDA_TASKS_SSM_ENVIRONMENT=/myapp/prod/environment
492
+ ```
493
+
494
+ The parameter value must be a flat JSON object where all keys and values are strings:
495
+
496
+ ```json
497
+ {
498
+ "DATABASE_URL": "postgres://user:pass@host:5432/db",
499
+ "REDIS_URL": "redis://host:6379/0",
500
+ "DJANGO_SETTINGS_MODULE": "myapp.settings.production"
501
+ }
502
+ ```
503
+
504
+ At cold start, before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_ssm_environment()` which:
505
+
506
+ 1. Checks for the `LAMBDA_TASKS_SSM_ENVIRONMENT` env var — if not set, does nothing
507
+ 2. Fetches the named parameter via `ssm.get_parameter(Name=..., WithDecryption=True)`
508
+ 3. Validates the parameter value is a flat JSON object (all values must be strings, no empty keys)
509
+ 4. Sets each key-value pair in `os.environ` — existing env vars are overridden
510
+ 5. Caches the result via a module-level sentinel — subsequent calls are free no-ops
511
+
512
+ Because SSM loading runs first, the parameter can provide `DJANGO_SETTINGS_MODULE` itself, and secrets loaded by `resolve_secrets_into_env()` can reference SSM-loaded values.
513
+
514
+ #### Validation errors
515
+
516
+ The following raise `ValueError` at cold start, preventing the Lambda container from starting:
517
+
518
+ - Parameter value is not valid JSON
519
+ - JSON is not a flat object (contains non-string values) — error message lists the offending keys
520
+ - JSON contains an empty string key
521
+
522
+ AWS errors (parameter not found, permission denied) propagate as boto3 exceptions and crash the container at cold start.
523
+
478
524
  ---
479
525
 
480
526
  ## Built-in tasks
@@ -13,23 +13,26 @@ import django
13
13
  from django.apps import apps
14
14
 
15
15
  from lambda_tasks.secret_loader import resolve_secrets_into_env
16
+ from lambda_tasks.ssm_environment_loader import resolve_ssm_environment
17
+
18
+ # Both loaders are idempotent and run unconditionally before the
19
+ # DJANGO_SETTINGS_MODULE check — SSM may provide that var, and
20
+ # secrets may depend on SSM-loaded vars.
21
+ resolve_ssm_environment()
22
+ resolve_secrets_into_env()
16
23
 
17
- # Cold-start Django setup — runs once per Lambda container.
18
- # Secrets are resolved first so Django settings can reference the populated
19
- # env vars. resolve_secrets_into_env() is idempotent and caches fetched
20
- # secrets in-process, so subsequent invocations pay no extra cost.
21
24
  if os.environ.get("DJANGO_SETTINGS_MODULE") and not apps.ready:
22
- resolve_secrets_into_env()
23
25
  django.setup()
24
26
 
25
27
 
26
28
  logger = logging.getLogger(__name__)
27
29
 
28
30
 
29
- def handler(*, event: dict, context: object) -> dict:
31
+ def handler(event: dict, context: object) -> dict:
30
32
  """AWS Lambda entry point. Processes a batch of SQS records.
31
33
 
32
34
  Returns a partial-batch failure report so AWS only re-drives failed records.
35
+ Signature is fixed by AWS and uses two args only.
33
36
  """
34
37
  # Local import due to AppRegistryNotReady
35
38
  from lambda_tasks.models import SQSLambdaTaskMessage
@@ -37,6 +37,10 @@ logger = logging.getLogger(__name__)
37
37
 
38
38
  _PREFIX = "LAMBDA_TASKS_SECRET_"
39
39
 
40
+ # Module-level sentinel: once resolve_secrets_into_env() completes successfully,
41
+ # subsequent calls return immediately without re-scanning or re-resolving.
42
+ _loaded: bool = False
43
+
40
44
  # Module-level cache: (arn, version_stage, version_id) → raw secret string.
41
45
  # Populated on first call; reused for the lifetime of the Lambda container.
42
46
  _secret_cache: dict[tuple[str, str, str], dict[str, str]] = {}
@@ -139,8 +143,14 @@ def resolve_secrets_into_env() -> None:
139
143
  ``FOO``, not both
140
144
 
141
145
  This function is idempotent — calling it multiple times is safe and cheap
142
- because resolved secrets are cached after the first fetch.
146
+ because a module-level sentinel prevents re-execution after the first
147
+ successful call.
143
148
  """
149
+ global _loaded
150
+
151
+ if _loaded:
152
+ return
153
+
144
154
  references: dict[str, _SecretReference] = {} # target_name → _SecretReference
145
155
 
146
156
  for key, value in os.environ.items():
@@ -181,3 +191,5 @@ def resolve_secrets_into_env() -> None:
181
191
  os.environ[target] = secret_value
182
192
 
183
193
  logger.info(f"Resolved {target} from secret {ref}")
194
+
195
+ _loaded = True