@aws/nx-plugin 0.109.0 → 0.110.0

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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/preset/__snapshots__/generator.spec.ts.snap +11 -1
  3. package/src/preset/generator.js +12 -3
  4. package/src/preset/generator.js.map +1 -1
  5. package/src/py/fast-api/__snapshots__/generator.spec.ts.snap +184 -106
  6. package/src/py/lambda-function/__snapshots__/generator.spec.ts.snap +74 -18
  7. package/src/py/mcp-server/__snapshots__/generator.spec.ts.snap +11 -9
  8. package/src/py/strands-agent/__snapshots__/generator.spec.ts.snap +11 -9
  9. package/src/smithy/ts/api/__snapshots__/generator.spec.ts.snap +114 -62
  10. package/src/terraform/project/files/application/scripts/aws-config.ts.template +41 -0
  11. package/src/terraform/project/files/application/scripts/bootstrap.ts.template +91 -0
  12. package/src/terraform/project/files/application/scripts/init.ts.template +44 -0
  13. package/src/terraform/project/generator.js +16 -11
  14. package/src/terraform/project/generator.js.map +1 -1
  15. package/src/trpc/backend/__snapshots__/generator.spec.ts.snap +175 -97
  16. package/src/ts/lambda-function/__snapshots__/generator.spec.ts.snap +74 -18
  17. package/src/ts/mcp-server/__snapshots__/generator.spec.ts.snap +11 -9
  18. package/src/ts/react-website/agui/generator.js +7 -0
  19. package/src/ts/react-website/agui/generator.js.map +1 -1
  20. package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +7 -0
  21. package/src/ts/strands-agent/__snapshots__/generator.spec.ts.snap +11 -9
  22. package/src/utils/agent-core-constructs/files/terraform/app/agent-core/__nameKebabCase__/__nameKebabCase__.tf.template +14 -12
  23. package/src/utils/api-constructs/files/terraform/app/apis/http/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +30 -17
  24. package/src/utils/api-constructs/files/terraform/app/apis/rest/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +32 -19
  25. package/src/utils/api-constructs/files/terraform/core/api/http/http-api/http-api.tf.template +2 -2
  26. package/src/utils/files/terraform/scripts/reset-runtime-config.ts.template +25 -0
  27. package/src/utils/files/terraform/src/core/asset-bucket/asset-bucket.tf.template +203 -0
  28. package/src/utils/files/terraform/src/core/runtime-config/appconfig/appconfig.tf.template +42 -39
  29. package/src/utils/files/terraform/src/core/runtime-config/appconfig-deployment/appconfig-deployment.tf.template +138 -0
  30. package/src/utils/files/terraform/src/core/runtime-config/entry/entry.tf.template +26 -90
  31. package/src/utils/files/terraform/src/core/runtime-config/read/read.tf.template +61 -5
  32. package/src/utils/function-constructs/files/terraform/app/lambda-functions/__functionNameKebabCase__/__functionNameKebabCase__.tf.template +36 -8
  33. package/src/utils/identity-constructs/files/terraform/core/user-identity/identity/identity.tf.template +6 -6
  34. package/src/utils/pnpm-workspace.d.ts +22 -0
  35. package/src/utils/pnpm-workspace.js +58 -0
  36. package/src/utils/pnpm-workspace.js.map +1 -0
  37. package/src/utils/shared-constructs.js +4 -2
  38. package/src/utils/shared-constructs.js.map +1 -1
  39. package/src/utils/versions.d.ts +4 -1
  40. package/src/utils/versions.js +3 -0
  41. package/src/utils/versions.js.map +1 -1
  42. package/src/utils/website-constructs/files/terraform/core/static-website/static-website.tf.template +7 -0
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Clears the runtime-config dist dir before every build so that removed
3
+ * modules don't leave behind stale entries in `connection.json` /
4
+ * `agentcore.json` / per-entry leaf files.
5
+ *
6
+ * Invoked by the `reset-runtime-config` nx target, which the `build`
7
+ * target depends on.
8
+ */
9
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+
12
+ const distDir = process.env.DIST_DIR;
13
+ if (!distDir) {
14
+ throw new Error('DIST_DIR environment variable is required');
15
+ }
16
+
17
+ // `runtime-config.json` starts as `{}` so the reader Terraform module can
18
+ // assume the file exists.
19
+ mkdirSync(distDir, { recursive: true });
20
+ writeFileSync(join(distDir, 'runtime-config.json'), '{}');
21
+
22
+ // Clear and recreate the per-entry leaf-file tree.
23
+ const runtimeConfigDir = join(distDir, 'runtime-config');
24
+ rmSync(runtimeConfigDir, { recursive: true, force: true });
25
+ mkdirSync(join(runtimeConfigDir, 'entries'), { recursive: true });
@@ -0,0 +1,203 @@
1
+ terraform {
2
+ required_version = ">= 1.0"
3
+
4
+ required_providers {
5
+ aws = {
6
+ source = "hashicorp/aws"
7
+ version = "~> 6.0"
8
+ }
9
+ random = {
10
+ source = "hashicorp/random"
11
+ version = "~> 3.0"
12
+ }
13
+ }
14
+ }
15
+
16
+ variable "bucket_name_prefix" {
17
+ description = "Optional prefix to apply to the generated bucket name. Useful when you want a stable, human-recognisable identifier in addition to the account/region/random suffix."
18
+ type = string
19
+ default = "assets"
20
+ }
21
+
22
+ variable "tags" {
23
+ description = "Tags to apply to all resources"
24
+ type = map(string)
25
+ default = {}
26
+ }
27
+
28
+ data "aws_caller_identity" "current" {}
29
+ data "aws_region" "current" {}
30
+
31
+ resource "random_string" "suffix" {
32
+ length = 8
33
+ special = false
34
+ upper = false
35
+ }
36
+
37
+ # Access logs bucket for the asset bucket.
38
+ resource "aws_s3_bucket" "access_logs" {
39
+ #checkov:skip=CKV2_AWS_61:Lifecycle configuration not required for access log bucket
40
+ #checkov:skip=CKV_AWS_144:Cross-region replication not required for access log bucket
41
+ #checkov:skip=CKV2_AWS_62:Event notifications not required for access log bucket
42
+ #checkov:skip=CKV_AWS_18:Access logging the access log bucket would create a cycle
43
+ #checkov:skip=CKV_AWS_145:AES256 (S3-managed) encryption is sufficient for access logs
44
+ bucket = "${var.bucket_name_prefix}-logs-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.region}-${random_string.suffix.result}"
45
+ force_destroy = true
46
+
47
+ tags = var.tags
48
+ }
49
+
50
+ resource "aws_s3_bucket_versioning" "access_logs" {
51
+ bucket = aws_s3_bucket.access_logs.id
52
+ versioning_configuration {
53
+ status = "Enabled"
54
+ }
55
+ }
56
+
57
+ resource "aws_s3_bucket_server_side_encryption_configuration" "access_logs" {
58
+ bucket = aws_s3_bucket.access_logs.id
59
+
60
+ rule {
61
+ apply_server_side_encryption_by_default {
62
+ sse_algorithm = "AES256"
63
+ }
64
+ }
65
+ }
66
+
67
+ resource "aws_s3_bucket_public_access_block" "access_logs" {
68
+ bucket = aws_s3_bucket.access_logs.id
69
+
70
+ block_public_acls = true
71
+ block_public_policy = true
72
+ ignore_public_acls = true
73
+ restrict_public_buckets = true
74
+ }
75
+
76
+ resource "aws_s3_bucket_policy" "access_logs" {
77
+ bucket = aws_s3_bucket.access_logs.id
78
+
79
+ policy = jsonencode({
80
+ Version = "2012-10-17"
81
+ Statement = [
82
+ {
83
+ Sid = "DenyInsecureConnections"
84
+ Effect = "Deny"
85
+ Principal = "*"
86
+ Action = "s3:*"
87
+ Resource = [
88
+ aws_s3_bucket.access_logs.arn,
89
+ "${aws_s3_bucket.access_logs.arn}/*"
90
+ ]
91
+ Condition = {
92
+ Bool = {
93
+ "aws:SecureTransport" = "false"
94
+ }
95
+ }
96
+ },
97
+ {
98
+ Sid = "AllowS3LogDelivery"
99
+ Effect = "Allow"
100
+ Principal = {
101
+ Service = "logging.s3.amazonaws.com"
102
+ }
103
+ Action = "s3:PutObject"
104
+ Resource = "${aws_s3_bucket.access_logs.arn}/*"
105
+ Condition = {
106
+ StringEquals = {
107
+ "aws:SourceAccount" = data.aws_caller_identity.current.account_id
108
+ }
109
+ }
110
+ }
111
+ ]
112
+ })
113
+ }
114
+
115
+ # Shared asset bucket
116
+ resource "aws_s3_bucket" "assets" {
117
+ #checkov:skip=CKV2_AWS_61:Lifecycle configuration not required for transient build artefacts
118
+ #checkov:skip=CKV_AWS_144:Cross-region replication not required for asset bucket
119
+ #checkov:skip=CKV2_AWS_62:Event notifications not required for asset bucket
120
+ #checkov:skip=CKV_AWS_145:AES256 (S3-managed) encryption is sufficient for build artefacts
121
+ bucket = "${var.bucket_name_prefix}-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.region}-${random_string.suffix.result}"
122
+ force_destroy = true
123
+
124
+ tags = var.tags
125
+ }
126
+
127
+ resource "aws_s3_bucket_versioning" "assets" {
128
+ bucket = aws_s3_bucket.assets.id
129
+ versioning_configuration {
130
+ status = "Enabled"
131
+ }
132
+ }
133
+
134
+ resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
135
+ bucket = aws_s3_bucket.assets.id
136
+
137
+ rule {
138
+ apply_server_side_encryption_by_default {
139
+ sse_algorithm = "AES256"
140
+ }
141
+ }
142
+ }
143
+
144
+ resource "aws_s3_bucket_public_access_block" "assets" {
145
+ bucket = aws_s3_bucket.assets.id
146
+
147
+ block_public_acls = true
148
+ block_public_policy = true
149
+ ignore_public_acls = true
150
+ restrict_public_buckets = true
151
+ }
152
+
153
+ resource "aws_s3_bucket_logging" "assets" {
154
+ bucket = aws_s3_bucket.assets.id
155
+
156
+ target_bucket = aws_s3_bucket.access_logs.id
157
+ target_prefix = "s3-access-logs/"
158
+ }
159
+
160
+ resource "aws_s3_bucket_policy" "assets" {
161
+ bucket = aws_s3_bucket.assets.id
162
+
163
+ policy = jsonencode({
164
+ Version = "2012-10-17"
165
+ Statement = [
166
+ {
167
+ Sid = "DenyInsecureConnections"
168
+ Effect = "Deny"
169
+ Principal = "*"
170
+ Action = "s3:*"
171
+ Resource = [
172
+ aws_s3_bucket.assets.arn,
173
+ "${aws_s3_bucket.assets.arn}/*"
174
+ ]
175
+ Condition = {
176
+ Bool = {
177
+ "aws:SecureTransport" = "false"
178
+ }
179
+ }
180
+ }
181
+ ]
182
+ })
183
+ }
184
+
185
+ output "bucket_name" {
186
+ description = "Name of the shared asset bucket — pass to app modules' `asset_bucket_name` input."
187
+ value = aws_s3_bucket.assets.id
188
+ }
189
+
190
+ output "bucket_arn" {
191
+ description = "ARN of the shared asset bucket"
192
+ value = aws_s3_bucket.assets.arn
193
+ }
194
+
195
+ output "access_logs_bucket_name" {
196
+ description = "Name of the access-logs bucket that receives S3 access logs for the asset bucket."
197
+ value = aws_s3_bucket.access_logs.id
198
+ }
199
+
200
+ output "access_logs_bucket_arn" {
201
+ description = "ARN of the access-logs bucket"
202
+ value = aws_s3_bucket.access_logs.arn
203
+ }
@@ -6,36 +6,48 @@ terraform {
6
6
  source = "hashicorp/aws"
7
7
  version = ">= 5.0"
8
8
  }
9
- local = {
10
- source = "hashicorp/local"
11
- version = "~> 2.0"
12
- }
13
9
  }
14
10
  }
15
11
 
12
+ # Shared AppConfig application — call this module once per deployment and
13
+ # thread its outputs into every API / agent / MCP / lambda module that
14
+ # contributes a runtime-config entry. Aggregation and deployment of the
15
+ # collected entries happens in the sibling `appconfig-deployment` module,
16
+ # which you call once at the end of the root module with `depends_on`
17
+ # covering every entry-contributing module.
18
+
16
19
  variable "application_name" {
17
20
  description = "Name of the AppConfig application"
18
21
  type = string
19
22
  }
20
23
 
21
- locals {
22
- config_dir = "${path.module}/../../../../../../../dist/packages/common/terraform/runtime-config"
24
+ variable "namespaces" {
25
+ description = "List of runtime-config namespaces this AppConfig application should expose (one Configuration Profile is created per namespace)."
26
+ type = list(string)
27
+ default = ["connection", "agentcore"]
28
+ }
29
+
30
+ variable "deployment_strategy_id" {
31
+ description = "Optional AppConfig Deployment Strategy ID to use. When null (the default), this module provisions a zero-wait strategy so deployed config is visible to consumers as soon as terraform apply completes. Pass an existing strategy ID (e.g. `AppConfig.AllAtOnce` or a custom one) to reuse it across multiple AppConfig applications."
32
+ type = string
33
+ default = null
23
34
  }
24
35
 
25
- # AppConfig Application
26
36
  resource "aws_appconfig_application" "runtime_config" {
27
37
  name = var.application_name
28
38
  description = "Runtime configuration for ${var.application_name}"
29
39
  }
30
40
 
31
- # AppConfig Environment
32
41
  resource "aws_appconfig_environment" "default" {
33
42
  name = "default"
34
43
  application_id = aws_appconfig_application.runtime_config.id
35
44
  }
36
45
 
37
- # Instant deployment strategy (zero wait)
46
+ # Instant (zero-wait) deployment strategy only provisioned when the
47
+ # caller doesn't pass a shared `deployment_strategy_id`.
38
48
  resource "aws_appconfig_deployment_strategy" "instant" {
49
+ count = var.deployment_strategy_id == null ? 1 : 0
50
+
39
51
  name = "${var.application_name}-instant"
40
52
  deployment_duration_in_minutes = 0
41
53
  growth_factor = 100
@@ -43,18 +55,9 @@ resource "aws_appconfig_deployment_strategy" "instant" {
43
55
  final_bake_time_in_minutes = 0
44
56
  }
45
57
 
46
- # Read all namespace JSON files from the config directory
47
- locals {
48
- namespace_files = fileset(local.config_dir, "*.json")
49
- namespaces = {
50
- for f in local.namespace_files :
51
- trimsuffix(f, ".json") => jsondecode(file("${local.config_dir}/${f}"))
52
- }
53
- }
54
-
55
58
  # Configuration Profile per namespace
56
59
  resource "aws_appconfig_configuration_profile" "namespace" {
57
- for_each = local.namespaces
60
+ for_each = toset(var.namespaces)
58
61
 
59
62
  application_id = aws_appconfig_application.runtime_config.id
60
63
  name = each.key
@@ -62,28 +65,13 @@ resource "aws_appconfig_configuration_profile" "namespace" {
62
65
  type = "AWS.Freeform"
63
66
  }
64
67
 
65
- # Hosted Configuration Version per namespace
66
- resource "aws_appconfig_hosted_configuration_version" "namespace" {
67
- for_each = local.namespaces
68
-
69
- application_id = aws_appconfig_application.runtime_config.id
70
- configuration_profile_id = aws_appconfig_configuration_profile.namespace[each.key].configuration_profile_id
71
- content_type = "application/json"
72
- content = jsonencode(each.value)
73
- }
74
-
75
- # Deployment per namespace
76
- resource "aws_appconfig_deployment" "namespace" {
77
- for_each = local.namespaces
78
-
79
- application_id = aws_appconfig_application.runtime_config.id
80
- environment_id = aws_appconfig_environment.default.environment_id
81
- configuration_profile_id = aws_appconfig_configuration_profile.namespace[each.key].configuration_profile_id
82
- configuration_version = aws_appconfig_hosted_configuration_version.namespace[each.key].version_number
83
- deployment_strategy_id = aws_appconfig_deployment_strategy.instant.id
68
+ locals {
69
+ resolved_deployment_strategy_id = coalesce(
70
+ var.deployment_strategy_id,
71
+ try(aws_appconfig_deployment_strategy.instant[0].id, null),
72
+ )
84
73
  }
85
74
 
86
- # Outputs
87
75
  output "application_id" {
88
76
  description = "AppConfig Application ID"
89
77
  value = aws_appconfig_application.runtime_config.id
@@ -98,3 +86,18 @@ output "environment_id" {
98
86
  description = "AppConfig Environment ID"
99
87
  value = aws_appconfig_environment.default.environment_id
100
88
  }
89
+
90
+ output "deployment_strategy_id" {
91
+ description = "AppConfig Deployment Strategy ID — either the one supplied via `var.deployment_strategy_id` or the zero-wait strategy provisioned by this module."
92
+ value = local.resolved_deployment_strategy_id
93
+ }
94
+
95
+ output "configuration_profile_ids" {
96
+ description = "Map of namespace to Configuration Profile ID."
97
+ value = { for k, p in aws_appconfig_configuration_profile.namespace : k => p.configuration_profile_id }
98
+ }
99
+
100
+ output "namespaces" {
101
+ description = "Passthrough of the namespaces configured on this application."
102
+ value = var.namespaces
103
+ }
@@ -0,0 +1,138 @@
1
+ terraform {
2
+ required_version = ">= 1.0"
3
+
4
+ required_providers {
5
+ aws = {
6
+ source = "hashicorp/aws"
7
+ version = ">= 5.0"
8
+ }
9
+ local = {
10
+ source = "hashicorp/local"
11
+ version = "~> 2.0"
12
+ }
13
+ null = {
14
+ source = "hashicorp/null"
15
+ version = ">= 3.0"
16
+ }
17
+ }
18
+ }
19
+
20
+ variable "application_id" {
21
+ description = "AppConfig application ID (from `core/runtime-config/appconfig.application_id`)."
22
+ type = string
23
+ }
24
+
25
+ variable "environment_id" {
26
+ description = "AppConfig environment ID (from `core/runtime-config/appconfig.environment_id`)."
27
+ type = string
28
+ }
29
+
30
+ variable "deployment_strategy_id" {
31
+ description = "AppConfig deployment strategy ID (from `core/runtime-config/appconfig.deployment_strategy_id`)."
32
+ type = string
33
+ }
34
+
35
+ variable "configuration_profile_ids" {
36
+ description = "Map of namespace to Configuration Profile ID (from `core/runtime-config/appconfig.configuration_profile_ids`)."
37
+ type = map(string)
38
+ }
39
+
40
+ variable "namespaces" {
41
+ description = "List of namespaces to aggregate + deploy. Must match the keys of `configuration_profile_ids`."
42
+ type = list(string)
43
+ default = ["connection", "agentcore"]
44
+ }
45
+
46
+ locals {
47
+ config_dir = "${path.module}/../../../../../../../dist/packages/common/terraform/runtime-config"
48
+ entries_dir = "${local.config_dir}/entries"
49
+ }
50
+
51
+ # Aggregate the per-entry leaf JSON files into a single JSON file per
52
+ # namespace. Runs at apply time so every contributor's entry file is
53
+ # guaranteed to be on disk when aggregation happens.
54
+ resource "null_resource" "aggregate_namespace" {
55
+ for_each = toset(var.namespaces)
56
+
57
+ triggers = {
58
+ namespace = each.key
59
+ # Re-run the aggregation every apply so newly-written entries are
60
+ # always picked up. The local-exec is idempotent.
61
+ always = timestamp()
62
+ }
63
+
64
+ provisioner "local-exec" {
65
+ command = <<-EOT
66
+ uv run python -c "
67
+ import json
68
+ import pathlib
69
+ import sys
70
+
71
+ namespace = '${each.key}'
72
+ entries_dir = pathlib.Path('${local.entries_dir}') / namespace
73
+ output_file = pathlib.Path('${local.config_dir}') / f'{namespace}.json'
74
+
75
+ def deep_merge(target: dict, source: dict) -> dict:
76
+ for k, v in source.items():
77
+ if k in target and isinstance(target[k], dict) and isinstance(v, dict):
78
+ deep_merge(target[k], v)
79
+ else:
80
+ target[k] = v
81
+ return target
82
+
83
+ # Each entry file is named '<key>-<sha>.json' — callers that share the
84
+ # same (namespace, key) pair hash their value into a distinct sha suffix,
85
+ # and their contributions are deep-merged back together under 'key' here.
86
+ merged: dict = {}
87
+ if entries_dir.is_dir():
88
+ for entry_path in sorted(entries_dir.glob('*.json')):
89
+ section = entry_path.stem.rsplit('-', 1)[0]
90
+ try:
91
+ contribution = json.loads(entry_path.read_text())
92
+ except json.JSONDecodeError as e:
93
+ print(f'Skipping malformed entry {entry_path}: {e}', file=sys.stderr)
94
+ continue
95
+ existing = merged.get(section)
96
+ if isinstance(existing, dict) and isinstance(contribution, dict):
97
+ deep_merge(existing, contribution)
98
+ else:
99
+ merged[section] = contribution
100
+
101
+ output_file.parent.mkdir(parents=True, exist_ok=True)
102
+ output_file.write_text(json.dumps(merged, indent=2))
103
+ print(f'Aggregated {namespace}.json with {len(merged)} section(s)')
104
+ "
105
+ EOT
106
+ }
107
+ }
108
+
109
+ data "local_file" "namespace_content" {
110
+ for_each = toset(var.namespaces)
111
+
112
+ filename = "${local.config_dir}/${each.key}.json"
113
+ depends_on = [null_resource.aggregate_namespace]
114
+ }
115
+
116
+ resource "aws_appconfig_hosted_configuration_version" "namespace" {
117
+ for_each = toset(var.namespaces)
118
+
119
+ application_id = var.application_id
120
+ configuration_profile_id = var.configuration_profile_ids[each.key]
121
+ content_type = "application/json"
122
+ content = data.local_file.namespace_content[each.key].content
123
+ }
124
+
125
+ resource "aws_appconfig_deployment" "namespace" {
126
+ for_each = toset(var.namespaces)
127
+
128
+ application_id = var.application_id
129
+ environment_id = var.environment_id
130
+ configuration_profile_id = var.configuration_profile_ids[each.key]
131
+ configuration_version = aws_appconfig_hosted_configuration_version.namespace[each.key].version_number
132
+ deployment_strategy_id = var.deployment_strategy_id
133
+ }
134
+
135
+ output "hosted_configuration_version_numbers" {
136
+ description = "Map of namespace to hosted configuration version number."
137
+ value = { for k, v in aws_appconfig_hosted_configuration_version.namespace : k => v.version_number }
138
+ }
@@ -15,107 +15,43 @@ variable "namespace" {
15
15
  }
16
16
 
17
17
  variable "key" {
18
- description = "Key name within the namespace (e.g., 'apis', 'cognitoProps')"
18
+ description = "Section name within the namespace (e.g., 'apis', 'cognitoProps'). Multiple `entry` modules may share the same key — the `appconfig` / `read` aggregation deep-merges their contributions under that key."
19
19
  type = string
20
20
  }
21
21
 
22
22
  variable "value" {
23
- description = "Value to set at the key"
23
+ description = "Value to set at the key. When the value is an object, its fields are deep-merged with any other `entry` module that targets the same (namespace, key) pair."
24
24
  type = any
25
25
  }
26
26
 
27
27
  locals {
28
- config_dir = "${path.module}/../../../../../../../dist/packages/common/terraform/runtime-config"
29
- config_file_path = "${local.config_dir}/${var.namespace}.json"
28
+ entries_dir = "${path.module}/../../../../../../../dist/packages/common/terraform/runtime-config/entries"
29
+ # A per-entry SHA256 so two modules writing to the same (namespace, key)
30
+ # produce distinct leaf files — the `appconfig-deployment` / `read`
31
+ # modules deep-merge them back together at apply time.
32
+ entry_id = sha256(jsonencode({ key = var.key, value = var.value }))
33
+ entry_file_path = "${local.entries_dir}/${var.namespace}/${var.key}-${local.entry_id}.json"
30
34
  }
31
35
 
32
- # This module writes an entry to a namespaced runtime config JSON file.
33
- # Each namespace maps to a separate file (e.g., connection.json, tables.json)
34
- # which is then used by the AppConfig module and static website module.
35
-
36
- data "external" "updated_config" {
37
- program = ["uv", "run", "python", "-c", <<-EOT
38
- import json
39
- import sys
40
- import os
41
- import time
42
- import fcntl
43
- from pathlib import Path
44
-
45
- # Read input from Terraform
46
- input_data = json.load(sys.stdin)
47
- config_file = input_data['config_file']
48
- key = input_data['key']
49
- value = json.loads(input_data['value'])
50
-
51
- # Create lock file path
52
- lock_file = config_file + '.lock'
53
-
54
- # Ensure directory exists
55
- Path(config_file).parent.mkdir(parents=True, exist_ok=True)
56
-
57
- # Acquire exclusive lock with retry mechanism
58
- max_retries = 30
59
- retry_delay = 1.0
60
-
61
- for attempt in range(max_retries):
62
- try:
63
- # Open lock file for exclusive access
64
- with open(lock_file, 'w') as lock_fd:
65
- # Try to acquire exclusive lock (non-blocking)
66
- fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
67
-
68
- # Critical section - file operations under lock
69
- try:
70
- # Create empty base config if file doesn't exist
71
- if not os.path.exists(config_file):
72
- base_config = {}
73
- with open(config_file, 'w') as f:
74
- json.dump(base_config, f, indent=2)
75
-
76
- # Read current config
77
- with open(config_file, 'r') as f:
78
- config = json.load(f)
79
-
80
- # Set the key directly in the namespace file
81
- config[key] = value
82
-
83
- # Write updated config back to file atomically
84
- temp_file = config_file + '.tmp'
85
- with open(temp_file, 'w') as f:
86
- json.dump(config, f, indent=2)
87
- os.rename(temp_file, config_file)
88
-
89
- # Output the updated config for Terraform
90
- print(json.dumps({"updated_json": json.dumps(config)}))
91
-
92
- # Success - break out of retry loop
93
- break
94
-
95
- finally:
96
- # Lock is automatically released when file is closed
97
- pass
36
+ # Write this entry's contribution as a standalone JSON file.
37
+ resource "local_file" "entry" {
38
+ filename = local.entry_file_path
39
+ content = jsonencode(var.value)
40
+ file_permission = "0644"
41
+ }
98
42
 
99
- except (IOError, OSError) as e:
100
- if attempt < max_retries - 1:
101
- # Wait before retrying
102
- time.sleep(retry_delay)
103
- continue
104
- else:
105
- # Final attempt failed
106
- raise Exception(f"Failed to acquire lock after {max_retries} attempts: {e}")
43
+ # Outputs
44
+ output "entry_file_path" {
45
+ description = "Absolute path to the JSON file that stores this entry's value"
46
+ value = local_file.entry.filename
47
+ }
107
48
 
108
- # Clean up lock file if it exists
109
- try:
110
- os.remove(lock_file)
111
- except OSError:
112
- pass
113
- EOT
114
- ]
49
+ output "namespace" {
50
+ description = "Namespace this entry was written under"
51
+ value = var.namespace
52
+ }
115
53
 
116
- query = {
117
- config_file = local.config_file_path
118
- key = var.key
119
- value = jsonencode(var.value)
120
- }
54
+ output "key" {
55
+ description = "Key this entry was written as"
56
+ value = var.key
121
57
  }