@hiiretail/gcp-infra-generators 1.9.0 → 1.11.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.
@@ -0,0 +1,70 @@
1
+ # Firebase Generator
2
+
3
+ This generator scaffolds Firebase infrastructure configuration for use with Terragrunt and the [terraform-google-firebase](https://github.com/GoogleCloudPlatform/terraform-google-firebase) module (v0.2.3).
4
+
5
+ ## Generated Files
6
+
7
+ Located at `infra/{env}/firebase/`:
8
+
9
+ 1. **firebase.yaml** - Platform configuration
10
+ 2. **terragrunt.hcl** - Firebase multi-platform app registration
11
+
12
+ ## File Architecture
13
+
14
+ ```text
15
+ infra/
16
+ prod/firebase/
17
+ firebase.yaml
18
+ terragrunt.hcl
19
+ staging/firebase/
20
+ firebase.yaml
21
+ terragrunt.hcl
22
+ ```
23
+
24
+ ## firebase.yaml Format
25
+
26
+ ```yaml
27
+ apps:
28
+ android:
29
+ package_name: "com.example.app"
30
+ ios:
31
+ bundle_id: "com.example.app"
32
+ ```
33
+
34
+ - **android.package_name**: Android application ID in reverse-domain format
35
+ - **ios.bundle_id**: iOS bundle identifier in reverse-domain format
36
+
37
+ Either platform section can be removed to stop managing that platform. At least one must remain.
38
+
39
+ ## Apps Module Outputs (FCM Config Files)
40
+
41
+ After `terragrunt apply`, the platform SDK config files are available as Terraform outputs:
42
+
43
+ | Output | Platform | File |
44
+ | ---------------- | -------- | ------------------------------------------- |
45
+ | `android_config` | Android | `google-services.json` (base64-encoded) |
46
+ | `apple_config` | iOS | `GoogleService-Info.plist` (base64-encoded) |
47
+
48
+ Decode and save the files from `infra/{env}/firebase/`:
49
+
50
+ ```bash
51
+ # Android
52
+ terragrunt output -raw android_config | base64 --decode > google-services.json
53
+
54
+ # iOS
55
+ terragrunt output -raw apple_config | base64 --decode > GoogleService-Info.plist
56
+ ```
57
+
58
+ ## Terragrunt Configuration
59
+
60
+ The `terragrunt.hcl` reads from the `firebase.yaml` in the same directory:
61
+
62
+ ```hcl
63
+ locals {
64
+ config = yamldecode(file("${get_terragrunt_dir()}/firebase.yaml"))
65
+ }
66
+ ```
67
+
68
+ ```bash
69
+ terragrunt apply --terragrunt-working-dir infra/prod/firebase
70
+ ```
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "Firebase",
3
+ "description": "Configure Firebase apps, authentication, and Firestore rules"
4
+ }
@@ -0,0 +1,102 @@
1
+ const path = require('path');
2
+ const chalk = require('chalk');
3
+ const BaseGenerator = require('../../../src/BaseGenerator');
4
+ const { required } = require('../../../src/validators');
5
+
6
+ module.exports = class extends BaseGenerator {
7
+ prompting() {
8
+ const prompts = [
9
+ {
10
+ type: 'input',
11
+ name: 'appDisplayName',
12
+ message: 'App display name (shared across all platforms):',
13
+ validate: required,
14
+ },
15
+ {
16
+ type: 'checkbox',
17
+ name: 'platforms',
18
+ message: 'Which platforms do you want to register Firebase apps for?',
19
+ choices: [
20
+ { name: 'Android', value: 'android', checked: true },
21
+ { name: 'iOS', value: 'ios', checked: true },
22
+ ],
23
+ validate: (selected) =>
24
+ selected.length > 0 || 'Select at least one platform.',
25
+ },
26
+ {
27
+ when: (response) => response.platforms.includes('android'),
28
+ type: 'input',
29
+ name: 'androidPackageName',
30
+ message:
31
+ 'Android package name - letters, digits, underscores, and dots only, no hyphens (e.g. com.example_app.checkout):',
32
+ validate: required,
33
+ },
34
+ {
35
+ when: (response) => response.platforms.includes('ios'),
36
+ type: 'input',
37
+ name: 'iosBundleId',
38
+ message:
39
+ 'iOS bundle ID - letters, digits, hyphens, and dots only, no underscores (e.g. com.example-app.checkout):',
40
+ validate: required,
41
+ },
42
+ ];
43
+
44
+ return this.prompt(prompts).then((props) => {
45
+ this.answers = props;
46
+ });
47
+ }
48
+
49
+ writing() {
50
+ const {
51
+ appDisplayName,
52
+ platforms = [],
53
+ androidPackageName,
54
+ iosBundleId,
55
+ } = this.answers;
56
+
57
+ const yamlContext = {
58
+ appDisplayName,
59
+ platforms,
60
+ androidPackageName,
61
+ iosBundleId,
62
+ };
63
+
64
+ ['prod', 'staging'].forEach((env) => {
65
+ const firebaseDest = path.join('infra', env, 'firebase');
66
+
67
+ this.fs.copyTpl(
68
+ this.templatePath('firebase', 'firebase.yaml'),
69
+ this.destinationPath(firebaseDest, 'firebase.yaml'),
70
+ yamlContext,
71
+ );
72
+
73
+ this.fs.copy(
74
+ this.templatePath('firebase', 'terragrunt.hcl'),
75
+ this.destinationPath(firebaseDest, 'terragrunt.hcl'),
76
+ );
77
+ });
78
+ }
79
+
80
+ end() {
81
+ const header = chalk.green(
82
+ 'Your Firebase configuration has been created. Review the generated files:',
83
+ );
84
+ let message = header;
85
+ let i = 1;
86
+
87
+ message += `\n${chalk.green(`${i++}.`)} ${chalk.cyan(path.join('firebase', 'firebase.yaml'))} - Shared Firebase configuration`;
88
+ message += `\n${chalk.green(`${i++}.`)} ${chalk.cyan(path.join('firebase', 'terragrunt.hcl'))} - Firebase apps module`;
89
+
90
+ message += chalk.yellow(
91
+ '\n\nAfter applying, retrieve the FCM config files from infra/{env}/firebase/:',
92
+ );
93
+ message += chalk.cyan(
94
+ '\n terragrunt output -raw android_config | base64 --decode > google-services.json',
95
+ );
96
+ message += chalk.cyan(
97
+ '\n terragrunt output -raw apple_config | base64 --decode > GoogleService-Info.plist',
98
+ );
99
+
100
+ this.log(`\n${message}\n`);
101
+ }
102
+ };
@@ -0,0 +1,38 @@
1
+ # Terragrunt configuration for Firebase multi-platform application
2
+ # https://github.com/GoogleCloudPlatform/terraform-google-firebase
3
+
4
+ terraform {
5
+ source = "git::https://github.com/extenda/terraform-google-firebase//modules/firebase_multi_platform_application/apps?ref=feat/Add-wrapper-apps-around-multi-application-module"
6
+ }
7
+
8
+ include {
9
+ path = find_in_parent_folders("terragrunt_root.hcl")
10
+ }
11
+
12
+ generate "firebase_providers" {
13
+ path = "firebase_providers.tf"
14
+ if_exists = "overwrite_terragrunt"
15
+ contents = <<EOF
16
+ provider "google" {
17
+ project = var.project_id
18
+ billing_project = var.project_id
19
+ user_project_override = true
20
+ }
21
+
22
+ provider "google-beta" {
23
+ project = var.project_id
24
+ billing_project = var.project_id
25
+ user_project_override = true
26
+ }
27
+ EOF
28
+ }
29
+
30
+ locals {
31
+ project_vars = read_terragrunt_config(find_in_parent_folders("project.hcl"))
32
+ config = yamldecode(file("${get_terragrunt_dir()}/../firebase.yaml"))
33
+ }
34
+
35
+ inputs = {
36
+ project_id = local.project_vars.locals.project_id
37
+ apps = local.config.apps
38
+ }
@@ -0,0 +1,17 @@
1
+ # Firebase configuration
2
+ #
3
+ # Naming conventions per platform:
4
+ # android.package_name — Java package notation: letters, digits, underscores, and dots only.
5
+ # No hyphens. Example: com.example_app.checkout
6
+ # ios.bundle_id — Reverse DNS notation: letters, digits, hyphens, and dots only.
7
+ # No underscores. Example: com.example-app.checkout
8
+ apps:
9
+ - display_name: "<%- appDisplayName %>"
10
+ <% if (platforms.includes('android')) { -%>
11
+ android:
12
+ package_name: <%- androidPackageName %>
13
+ <% } -%>
14
+ <% if (platforms.includes('ios')) { -%>
15
+ ios:
16
+ bundle_id: <%- iosBundleId %>
17
+ <% } -%>
@@ -0,0 +1,38 @@
1
+ # Terragrunt configuration for Firebase multi-platform application
2
+ # https://github.com/GoogleCloudPlatform/terraform-google-firebase
3
+
4
+ terraform {
5
+ source = "git::https://github.com/extenda/terraform-google-firebase//modules/firebase_multi_platform_application/apps?ref=v1.0.0"
6
+ }
7
+
8
+ include {
9
+ path = find_in_parent_folders("terragrunt_root.hcl")
10
+ }
11
+
12
+ generate "firebase_providers" {
13
+ path = "firebase_providers.tf"
14
+ if_exists = "overwrite_terragrunt"
15
+ contents = <<EOF
16
+ provider "google" {
17
+ project = var.project_id
18
+ billing_project = var.project_id
19
+ user_project_override = true
20
+ }
21
+
22
+ provider "google-beta" {
23
+ project = var.project_id
24
+ billing_project = var.project_id
25
+ user_project_override = true
26
+ }
27
+ EOF
28
+ }
29
+
30
+ locals {
31
+ project_vars = read_terragrunt_config(find_in_parent_folders("project.hcl"))
32
+ config = yamldecode(file("${get_terragrunt_dir()}/firebase.yaml"))
33
+ }
34
+
35
+ inputs = {
36
+ project_id = local.project_vars.locals.project_id
37
+ apps = local.config.apps
38
+ }
@@ -0,0 +1,246 @@
1
+ # Firestore Generator
2
+
3
+ This generator scaffolds a Firestore database configuration for use with Terragrunt and the [terraform-google-firestore](https://github.com/extenda/terraform-google-firestore) module (via wrapper supporting multi-database deployments).
4
+
5
+ ## Generated Files
6
+
7
+ The generator creates files at the firestore level per environment (prod/staging), supporting **multiple databases** deployed in a single `terragrunt plan`:
8
+
9
+ Located at `infra/{env}/firestore/`:
10
+ 1. **databases.yaml** - Central configuration for all databases with YAML anchors for safe defaults
11
+ 2. **schema/*.yaml** - Individual collection schemas (fields and indexes)
12
+ 3. **terragrunt.hcl** - Multi-database orchestration that calls the wrapper module via `for_each`
13
+
14
+ ## File Architecture
15
+
16
+ ```
17
+ infra/
18
+ prod/firestore/
19
+ databases.yaml ← All databases + backup schedules with YAML anchors
20
+ terragrunt.hcl ← Multi-database orchestration (calls wrapper)
21
+ schema/
22
+ users.yaml ← Fields and indexes for users collection
23
+ posts.yaml ← Fields and indexes for posts collection
24
+ staging/firestore/
25
+ databases.yaml
26
+ terragrunt.hcl
27
+ schema/
28
+ users.yaml
29
+ posts.yaml
30
+ ```
31
+
32
+ ## Databases YAML Format
33
+
34
+ The `databases.yaml` file defines all Firestore databases and backup schedules using YAML anchors for reusable defaults.
35
+
36
+ ### Basic Structure
37
+
38
+ ```yaml
39
+ # YAML anchors define reusable backup schedules
40
+ backup_schedule_configuration: &default_backup_schedule
41
+ weekly_recurrence:
42
+ day: "MONDAY"
43
+ retention: "8w"
44
+
45
+ # All databases in one place
46
+ databases:
47
+ - name: "database1"
48
+ backup_schedule_configuration: *default_backup_schedule
49
+ collections:
50
+ - schema: schema/users.yaml
51
+ - schema: schema/posts.yaml
52
+
53
+ - name: "database2"
54
+ backup_schedule_configuration: *default_backup_schedule
55
+ collections:
56
+ - schema: schema/users.yaml
57
+ - schema: schema/posts.yaml
58
+ # Optional: override delete protection for this database only
59
+ delete_protection_state: "DELETE_PROTECTION_ENABLED"
60
+ ```
61
+
62
+ ### Databases Properties
63
+
64
+ - **name**: Unique database identifier (required)
65
+ - **backup_schedule_configuration**: Backup schedule using YAML anchors (required)
66
+ - **daily_recurrence**: For daily backups with retention (e.g., "30d")
67
+ - **weekly_recurrence**: For weekly backups with day and retention (e.g., "8w")
68
+ - **collections**: List of schema references (required)
69
+ - **schema**: Path to schema file relative to firestore folder (e.g., `schema/users.yaml`)
70
+ - **delete_protection_state**: Optional override (`DELETE_PROTECTION_ENABLED` or `DELETE_PROTECTION_DISABLED`)
71
+
72
+ ### Example: Multiple Databases
73
+
74
+ ```yaml
75
+ backup_schedule_configuration: &daily
76
+ daily_recurrence:
77
+ retention: "30d"
78
+
79
+ backup_schedule_configuration: &weekly
80
+ weekly_recurrence:
81
+ day: "FRIDAY"
82
+ retention: "12w"
83
+
84
+ databases:
85
+ - name: "production-db"
86
+ backup_schedule_configuration: *weekly
87
+ collections:
88
+ - schema: schema/users.yaml
89
+ - schema: schema/posts.yaml
90
+ - schema: schema/comments.yaml
91
+
92
+ - name: "analytics-db"
93
+ backup_schedule_configuration: *daily
94
+ collections:
95
+ - schema: schema/events.yaml
96
+ - schema: schema/metrics.yaml
97
+ delete_protection_state: "DELETE_PROTECTION_DISABLED"
98
+ ```
99
+
100
+ ## Schema YAML Format
101
+
102
+ Each schema file in `schema/` defines the TTL field and composite indexes for a collection.
103
+
104
+ ### Basic Structure
105
+
106
+ ```yaml
107
+ name: users
108
+ description: "User profiles with indexes"
109
+
110
+ ttl_field: expires_at
111
+
112
+ indexes:
113
+ - name: email_created_index
114
+ fields:
115
+ - field_path: "email"
116
+ order: "ASCENDING"
117
+ array_config: null
118
+ - field_path: "created_at"
119
+ order: "ASCENDING"
120
+ array_config: null
121
+ - field_path: "__name__"
122
+ order: "ASCENDING"
123
+ array_config: null
124
+ ```
125
+
126
+ ### TTL Field
127
+
128
+ - **ttl_field**: The name of the field that has Time To Live enabled (optional scalar, omit if no TTL). Only one field per collection can have TTL.
129
+
130
+ ### Index Properties
131
+
132
+ - **name**: Index name (required, must be unique within the database)
133
+ - **fields**: List of fields in the composite index
134
+ - **field_path**: Field name (required, quoted string)
135
+ - **order**: `"ASCENDING"` or `"DESCENDING"` for ordered fields; `null` for array fields
136
+ - **array_config**: `"CONTAINS"` for array-contains queries; `null` for ordered fields
137
+
138
+ ### Example: Complete Schema
139
+
140
+ ```yaml
141
+ name: posts
142
+ description: "Blog posts with advanced indexing"
143
+
144
+ ttl_field: expires_at
145
+
146
+ indexes:
147
+ - name: posts_by_author_created
148
+ fields:
149
+ - field_path: "author_id"
150
+ order: "ASCENDING"
151
+ array_config: null
152
+ - field_path: "created_at"
153
+ order: "ASCENDING"
154
+ array_config: null
155
+ - field_path: "__name__"
156
+ order: "ASCENDING"
157
+ array_config: null
158
+
159
+ - name: posts_by_tags
160
+ fields:
161
+ - field_path: "tags"
162
+ array_config: "CONTAINS"
163
+ order: null
164
+ - field_path: "created_at"
165
+ order: "ASCENDING"
166
+ array_config: null
167
+ - field_path: "__name__"
168
+ order: "ASCENDING"
169
+ array_config: null
170
+ ```
171
+
172
+ ## Terragrunt Configuration
173
+
174
+ The `terragrunt.hcl` file automatically handles multi-database orchestration with safe defaults.
175
+
176
+ ### Safe Defaults
177
+
178
+ The terragrunt.hcl applies safe defaults to all databases:
179
+
180
+ ```hcl
181
+ default_database_config = {
182
+ location = "europe-west1"
183
+ database_type = "FIRESTORE_NATIVE"
184
+ concurrency_mode = "OPTIMISTIC"
185
+ delete_protection_state = "DELETE_PROTECTION_ENABLED" # ← Prevents accidental deletion
186
+ deletion_policy = "DELETE"
187
+ point_in_time_recovery_enablement = "POINT_IN_TIME_RECOVERY_DISABLED"
188
+ kms_key_name = null
189
+ }
190
+ ```
191
+
192
+ These defaults are merged with database-specific overrides in `databases.yaml`. Each database inherits all defaults unless explicitly overridden.
193
+
194
+ ### Multi-Database Deployment
195
+
196
+ When you run `terragrunt plan`, it:
197
+ 1. Loads all databases from `databases.yaml`
198
+ 2. Loads all schemas from `schema/*.yaml`
199
+ 3. Merges each database config with safe defaults
200
+ 4. Calls the wrapper module via `for_each` for each database
201
+ 5. Shows all resources for all databases in one plan
202
+
203
+ ## Editing Configurations
204
+
205
+ After generation, you can:
206
+
207
+ 1. **Modify databases.yaml** to:
208
+ - Add/remove databases
209
+ - Adjust backup schedules using YAML anchors
210
+ - Override delete_protection_state per database
211
+ - Add/remove collections per database
212
+ - Reference existing or new schema files
213
+
214
+ 2. **Modify schema/*.yaml** to:
215
+ - Add/remove fields
216
+ - Add/remove composite indexes
217
+ - Adjust field ordering and indexing
218
+
219
+ 3. **Leave terragrunt.hcl as-is** - it orchestrates the wrapper module automatically with safe defaults
220
+
221
+ ## Using Different Configurations Per Environment
222
+
223
+ The generator creates identical files for prod and staging. You can customize each environment by editing:
224
+ - `infra/prod/firestore/databases.yaml`
225
+ - `infra/staging/firestore/databases.yaml`
226
+
227
+ To use existing schemas across multiple databases, reference the same schema files in multiple database entries in `databases.yaml`:
228
+
229
+ ```yaml
230
+ databases:
231
+ - name: "db1"
232
+ collections:
233
+ - schema: schema/users.yaml # Reuse schema
234
+ - name: "db2"
235
+ collections:
236
+ - schema: schema/users.yaml # Same schema in different database
237
+ ```
238
+
239
+ ## Firestore Constraints
240
+
241
+ - **TTL Limit**: Only ONE field per database can have TTL enabled (mark with `ttl_enabled: true`)
242
+ - **Single-Field Indexes**: Use `order` for non-array fields to enable efficient sorting/filtering
243
+ - **Array Indexes**: Use `array_config: CONTAINS` for array fields to enable array-contains queries
244
+ - **Composite Indexes**: Automatically include `__name__` field at the end for document ID ordering
245
+ - **Collections**: Implicitly created when documents are added (no Terraform resource for collection itself)
246
+ - **Schema Reuse**: Multiple databases can reference the same schema file
@@ -3,6 +3,10 @@ const chalk = require('chalk');
3
3
  const BaseGenerator = require('../../../src/BaseGenerator');
4
4
  const { required } = require('../../../src/validators');
5
5
  const getTribeAndClanName = require('../../init/clan-infra/tribe-clan-repo');
6
+ const {
7
+ validateTtlField,
8
+ validateCompositeIndexName,
9
+ } = require('./validators');
6
10
 
7
11
  module.exports = class extends BaseGenerator {
8
12
  prompting() {
@@ -24,32 +28,31 @@ module.exports = class extends BaseGenerator {
24
28
  },
25
29
  {
26
30
  type: 'list',
27
- name: 'databaseConcurrencyMode',
31
+ name: 'concurrencyMode',
28
32
  message: 'Select the database concurrency mode.',
29
- default: 'OPTIMISTIC',
33
+ default: 'PESSIMISTIC',
30
34
  choices: ['OPTIMISTIC', 'PESSIMISTIC'],
31
35
  },
32
36
  {
33
- type: 'list',
34
- name: 'databaseBackupEnabled',
35
- message: 'Enable database backup?',
36
- default: 'true',
37
- choices: ['true', 'false'],
37
+ type: 'input',
38
+ name: 'location',
39
+ default: 'europe-west1',
40
+ message:
41
+ 'Please provide the database location (e.g., europe-west1, us-central1).',
42
+ validate: required,
38
43
  },
39
44
  {
40
- when: (response) => response.databaseBackupEnabled === 'true',
41
45
  type: 'list',
42
- name: 'databaseBackupSchedule',
43
- message: 'Select the backup schedule.',
46
+ name: 'backupSchedule',
47
+ message: 'Enable database backups?',
44
48
  default: 'daily-backup',
45
- choices: ['daily-backup', 'weekly-backup'],
49
+ choices: ['daily-backup', 'weekly-backup', 'none'],
46
50
  },
47
51
  {
48
- when: (response) => response.databaseBackupSchedule === 'weekly-backup',
52
+ when: (response) => response.backupSchedule === 'weekly-backup',
49
53
  type: 'list',
50
- name: 'databaseBackupWeeklyRecurrence',
51
- message:
52
- 'Please provide the day of the week the backup must be created.',
54
+ name: 'backupWeeklyDay',
55
+ message: 'Select the day for weekly backups.',
53
56
  default: 'SUNDAY',
54
57
  choices: [
55
58
  'MONDAY',
@@ -62,93 +65,238 @@ module.exports = class extends BaseGenerator {
62
65
  ],
63
66
  },
64
67
  {
65
- when: (response) => response.databaseBackupEnabled === 'true',
68
+ when: (response) => response.backupSchedule !== 'none',
66
69
  type: 'input',
67
- name: 'databaseBackupRetention',
68
- default: '259200s',
69
- message:
70
- 'Please provide how long to keep the backups in seconds. ex. "259200s" is 3 days',
70
+ name: 'backupRetention',
71
+ default: (response) =>
72
+ response.backupSchedule === 'weekly-backup' ? '1209600s' : '259200s',
73
+ message: (response) => {
74
+ const days =
75
+ response.backupSchedule === 'weekly-backup' ? '14 days' : '3 days';
76
+ const seconds =
77
+ response.backupSchedule === 'weekly-backup'
78
+ ? '1209600s'
79
+ : '259200s';
80
+ return `Please provide backup retention in seconds (e.g., "${seconds}" is ${days}).`;
81
+ },
71
82
  validate: required,
72
83
  },
73
84
  {
74
85
  type: 'input',
75
- name: 'databaseCollectionName',
76
- message: 'Please provide the name of the collection being indexed.',
86
+ name: 'collectionName',
87
+ message: 'Please provide the name of the collection.',
77
88
  validate: required,
78
89
  },
79
90
  {
80
91
  type: 'input',
81
- name: 'databaseCollectionIndexName',
82
- message: 'Please provide the name of the index.',
83
- validate: required,
92
+ name: 'collectionDescription',
93
+ message: 'Please provide a description for this collection.',
94
+ default: '',
84
95
  },
85
96
  {
86
97
  type: 'input',
87
- name: 'databaseCollectionFieldPath',
88
- message: 'Please provide the name of the field.',
89
- validate: required,
98
+ name: 'ttlField',
99
+ message: 'Which field should have TTL enabled? (leave blank for none)',
100
+ default: '',
101
+ validate: validateTtlField,
90
102
  },
91
103
  {
92
- type: 'list',
93
- name: 'databaseCollectionFieldorder',
94
- message: 'Select the specified order for the field.',
95
- default: 'ASCENDING',
96
- choices: ['ASCENDING', 'DESCENDING'],
104
+ type: 'input',
105
+ name: 'compositeIndexFields',
106
+ message:
107
+ 'Which fields should be in composite indexes? (comma-separated, leave blank for none)',
108
+ default: '',
109
+ },
110
+ {
111
+ when: (response) => response.compositeIndexFields.trim().length > 0,
112
+ type: 'input',
113
+ name: 'compositeIndexName',
114
+ message:
115
+ 'Please provide a name for the composite index.\n (the name must be unique within the database, e.g., "<collection>-index-1")',
116
+ validate: validateCompositeIndexName,
117
+ },
118
+ {
119
+ when: (response) => response.compositeIndexFields.trim().length > 0,
120
+ type: 'input',
121
+ name: 'arrayFieldsInComposite',
122
+ message:
123
+ 'Which fields in the composite index are array fields? (comma-separated, leave blank for none)',
124
+ default: '',
97
125
  },
98
126
  ];
99
127
 
100
128
  return this.prompt(prompts).then((props) => {
101
129
  this.answers = props;
130
+
131
+ // Check for existing files (databases.yaml and schema files)
132
+ const existingFiles = [];
133
+ ['prod', 'staging'].forEach((env) => {
134
+ const databasesPath = this.destinationPath(
135
+ path.join('infra', env, 'firestore', 'databases.yaml'),
136
+ );
137
+ const schemaDir = this.destinationPath(
138
+ path.join('infra', env, 'firestore', 'schema'),
139
+ );
140
+
141
+ if (this.fs.exists(databasesPath)) {
142
+ existingFiles.push(`infra/${env}/firestore/databases.yaml`);
143
+ }
144
+ if (this.fs.exists(schemaDir)) {
145
+ existingFiles.push(`infra/${env}/firestore/schema/`);
146
+ }
147
+ });
148
+
149
+ if (existingFiles.length > 0) {
150
+ return this.prompt([
151
+ {
152
+ type: 'list',
153
+ name: 'existingFilesAction',
154
+ message: `Found existing firestore files: ${existingFiles.join(', ')}. What do you want to do?`,
155
+ choices: [
156
+ { name: 'Use existing schema', value: 'use' },
157
+ { name: 'Overwrite with new configuration', value: 'overwrite' },
158
+ ],
159
+ },
160
+ ]).then((action) => {
161
+ this.answers.existingFilesAction = action.existingFilesAction;
162
+ });
163
+ }
102
164
  });
103
165
  }
104
166
 
105
167
  writing() {
106
- const { databaseName, databaseCollectionName, ...rest } = this.answers;
168
+ const {
169
+ databaseName,
170
+ collectionName,
171
+ collectionDescription,
172
+ ttlField,
173
+ compositeIndexFields,
174
+ compositeIndexName,
175
+ arrayFieldsInComposite,
176
+ ...rest
177
+ } = this.answers;
178
+
179
+ const ttlFields = ttlField ? [ttlField.trim()] : [];
180
+ const compositeFields = compositeIndexFields
181
+ ? compositeIndexFields
182
+ .split(',')
183
+ .map((f) => f.trim())
184
+ .filter(Boolean)
185
+ : [];
186
+ const compositeArrayFields = arrayFieldsInComposite
187
+ ? arrayFieldsInComposite
188
+ .split(',')
189
+ .map((f) => f.trim())
190
+ .filter(Boolean)
191
+ : [];
192
+
193
+ // Build schema file content
194
+ let schemaYaml =
195
+ '# The last field entry is always for the field path __name__.\n' +
196
+ '# If, on creation, __name__ was not specified as the last field,\n' +
197
+ '# it will be added automatically with the same direction as that of the last field defined.\n' +
198
+ '# If the final field in a composite index is not directional, the __name__ will be ordered "ASCENDING".\n' +
199
+ '# Index names must be unique within the database, e.g., "<collection name>-index-1"\n\n';
200
+ schemaYaml += `name: ${collectionName}\n`;
201
+ schemaYaml += `description: "${collectionDescription || collectionName}"\n\n`;
202
+
203
+ if (ttlFields.length > 0) {
204
+ schemaYaml += `ttl_field: ${ttlFields[0]}\n`;
205
+ }
206
+
207
+ if (compositeFields.length > 0) {
208
+ schemaYaml += 'indexes:\n';
209
+ schemaYaml += ` - name: ${compositeIndexName}\n`;
210
+ schemaYaml += ' fields:\n';
211
+
212
+ compositeFields.forEach((field) => {
213
+ schemaYaml += ` - field_path: ${field}\n`;
214
+ if (compositeArrayFields.includes(field)) {
215
+ schemaYaml += ` array_config: "CONTAINS"\n`;
216
+ schemaYaml += ` order: null\n`;
217
+ } else {
218
+ schemaYaml += ` order: "ASCENDING"\n`;
219
+ schemaYaml += ` array_config: null\n`;
220
+ }
221
+ });
222
+
223
+ // Add __name__ field at the end
224
+ schemaYaml += ` - field_path: "__name__"\n`;
225
+ schemaYaml += ` order: "ASCENDING"\n`;
226
+ schemaYaml += ` array_config: null\n`;
227
+ }
107
228
 
108
229
  ['prod', 'staging'].forEach((env) => {
109
230
  const context = {
110
231
  ...rest,
111
232
  databaseName,
112
- databaseCollectionName,
233
+ databaseType: this.answers.databaseType,
234
+ collectionName,
113
235
  env,
114
236
  };
115
- const baseDest = path.join('infra', env, 'firestore', databaseName);
116
- const collectionDest = path.join(
117
- baseDest,
118
- 'indexes',
119
- databaseCollectionName,
120
- );
237
+ const firestoreDest = path.join('infra', env, 'firestore');
121
238
 
122
- // Files to copy at database root
123
- ['spec.hcl', 'terragrunt.hcl'].forEach((file) => {
124
- this.fs.copyTpl(
125
- this.templatePath('firestore', file),
126
- this.destinationPath(baseDest, file),
127
- context,
239
+ // Only write databases.yaml and schema if not using existing
240
+ if (this.answers.existingFilesAction !== 'use') {
241
+ const databasesPath = this.destinationPath(
242
+ firestoreDest,
243
+ 'databases.yaml',
128
244
  );
129
- });
245
+ const schemaDir = this.destinationPath(firestoreDest, 'schema');
246
+ const schemaFileName = `${collectionName.toLowerCase()}.yaml`;
247
+ const schemaPath = this.destinationPath(schemaDir, schemaFileName);
248
+
249
+ // Delete existing files first to avoid Yeoman conflict prompts
250
+ if (this.fs.exists(databasesPath)) {
251
+ this.fs.delete(databasesPath);
252
+ }
253
+ if (this.fs.exists(schemaPath)) {
254
+ this.fs.delete(schemaPath);
255
+ }
130
256
 
131
- // Files to copy inside indexes/<databaseCollectionName>/
132
- ['indexes.yaml', 'terragrunt.hcl'].forEach((file) => {
257
+ // Copy databases.yaml to firestore folder
133
258
  this.fs.copyTpl(
134
- this.templatePath('firestore/indexes', file),
135
- this.destinationPath(collectionDest, file),
259
+ this.templatePath('firestore', 'databases.yaml'),
260
+ databasesPath,
136
261
  context,
137
262
  );
138
- });
263
+
264
+ // Write schema file to schema/ subdirectory
265
+ this.fs.write(schemaPath, schemaYaml);
266
+ }
267
+
268
+ // Copy terragrunt.hcl to firestore folder (multi-database wrapper)
269
+ this.fs.copyTpl(
270
+ this.templatePath('firestore', 'terragrunt.hcl'),
271
+ this.destinationPath(firestoreDest, 'terragrunt.hcl'),
272
+ context,
273
+ );
139
274
  });
140
275
  }
141
276
 
142
277
  end() {
143
- const { databaseName } = this.answers;
144
-
145
- const firestoreDir = path.join(databaseName);
146
- this.log(`
147
- ${chalk.green(`Your Firestore database has now been created. To finalize your configuration, please continue
148
- with manual editing of the generated files.`)}
149
- ${chalk.green('1.')} Review and add more indexes as the firestore module requires at least two.
150
- \u2192 ${chalk.cyan(path.join(firestoreDir, 'indexes.yaml'))}
151
- ${chalk.green('2.')} Push this change in a feature branch and open a pull request.
152
- `);
278
+ const { existingFilesAction } = this.answers;
279
+
280
+ const header = chalk.green(
281
+ 'Your Firestore database has now been created. Review the generated configuration:',
282
+ );
283
+ let message = header;
284
+
285
+ if (existingFilesAction === 'use') {
286
+ const databasesPreserved = `${chalk.green('1.')} ${chalk.cyan(path.join('firestore', 'databases.yaml'))} - ${chalk.yellow('(preserved existing file)')}`;
287
+ const schemaPreserved = `${chalk.green('2.')} ${chalk.cyan(path.join('firestore', 'schema', '*'))} - ${chalk.yellow('(preserved existing files)')}`;
288
+ message += `\n${databasesPreserved}`;
289
+ message += `\n${schemaPreserved}`;
290
+ } else {
291
+ const databasesNew = `${chalk.green('1.')} ${chalk.cyan(path.join('firestore', 'databases.yaml'))} - Database configuration with defaults`;
292
+ const schemaNew = `${chalk.green('2.')} ${chalk.cyan(path.join('firestore', 'schema', '*'))} - Collection schema files`;
293
+ message += `\n${databasesNew}`;
294
+ message += `\n${schemaNew}`;
295
+ }
296
+
297
+ const terragruntFile = `${chalk.green('3.')} ${chalk.cyan(path.join('firestore', 'terragrunt.hcl'))} - Multi-database orchestration`;
298
+ message += `\n${terragruntFile}`;
299
+
300
+ this.log(`\n${message}\n`);
153
301
  }
154
302
  };
@@ -0,0 +1,26 @@
1
+ # Firestore Databases Configuration
2
+ #
3
+ # Defaults are configured in terragrunt.hcl
4
+ # Each database only needs:
5
+ # - name: unique identifier
6
+ # - collections: list of schema references or inline definitions
7
+ # - backup_schedule_configuration: backup schedule
8
+ # - (optional) any field overrides
9
+
10
+ backup_schedule_configuration: &default_backup_schedule
11
+ <% if (backupSchedule === 'daily-backup') { %>
12
+ daily_recurrence:
13
+ retention: "<%- backupRetention %>"
14
+ <% } else if (backupSchedule === 'weekly-backup') { %>
15
+ weekly_recurrence:
16
+ day: "<%- backupWeeklyDay %>"
17
+ retention: "<%- backupRetention %>"
18
+ <% } else { %>
19
+ null
20
+ <% } %>
21
+
22
+ databases:
23
+ - name: "<%- databaseName %>"
24
+ backup_schedule_configuration: *default_backup_schedule
25
+ collections:
26
+ - schema: schema/<%= collectionName.toLowerCase() %>.yaml
@@ -1,7 +1,8 @@
1
- # Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the
2
- # working directory, into a temporary folder, and execute your Terraform commands in that folder.
1
+ # Terragrunt configuration for Firestore databases using the multi-database wrapper
2
+ # https://github.com/extenda/terraform-google-firestore
3
+
3
4
  terraform {
4
- source = "git::https://github.com/extenda/tf-module-gcp-firestore//modules/database?ref=v1.0.2"
5
+ source = "git::https://github.com/extenda/terraform-google-firestore//databases?ref=v1.0.0"
5
6
  }
6
7
 
7
8
  # Include all settings from the root terragrunt.hcl file
@@ -10,16 +11,103 @@ include {
10
11
  }
11
12
 
12
13
  locals {
13
- project_vars = read_terragrunt_config(find_in_parent_folders("project.hcl"))
14
- database_name = basename(get_terragrunt_dir())
15
- database_spec_vars = read_terragrunt_config("${get_terragrunt_dir()}/spec.hcl")
14
+ # Load project variables
15
+ project_vars = read_terragrunt_config(find_in_parent_folders("project.hcl"))
16
+
17
+ # Default configuration for all databases
18
+ default_database_config = {
19
+ location = "<%- location %>"
20
+ database_type = "<%- databaseType %>"
21
+ concurrency_mode = "<%- concurrencyMode %>"
22
+ delete_protection_state = "DELETE_PROTECTION_ENABLED"
23
+ deletion_policy = "DELETE"
24
+ point_in_time_recovery_enablement = "POINT_IN_TIME_RECOVERY_DISABLED"
25
+ kms_key_name = null
26
+ }
27
+
28
+ # Load database configuration from databases.yaml
29
+ database_config = yamldecode(file("${get_terragrunt_dir()}/databases.yaml"))
30
+
31
+ # Get all databases from databases.yaml and merge with defaults
32
+ all_databases = [
33
+ for db in try(local.database_config.databases, []) :
34
+ merge(local.default_database_config, db)
35
+ ]
36
+
37
+ # Build field and index configurations for each database
38
+ databases_collections = {
39
+ for db in local.all_databases : db.name => try(db.collections, [])
40
+ }
41
+
42
+ # Load collection schemas from referenced files OR inline definitions
43
+ collection_schemas = {
44
+ for db_name, collections in local.databases_collections : db_name => {
45
+ for collection in collections :
46
+ (try(collection.schema, null) != null ? yamldecode(file("${get_terragrunt_dir()}/${collection.schema}")).name : collection.name) => merge(
47
+ {
48
+ name = try(collection.schema, null) != null ? yamldecode(file("${get_terragrunt_dir()}/${collection.schema}")).name : collection.name
49
+ description = try(collection.schema, null) != null ? yamldecode(file("${get_terragrunt_dir()}/${collection.schema}")).description : try(collection.description, "")
50
+ ttl_field = try(collection.schema, null) != null ? try(yamldecode(file("${get_terragrunt_dir()}/${collection.schema}")).ttl_field, null) : try(collection.ttl_field, null)
51
+ indexes = try(collection.schema, null) != null ? try(yamldecode(file("${get_terragrunt_dir()}/${collection.schema}")).indexes, []) : try(collection.indexes, [])
52
+ }
53
+ )
54
+ }
55
+ }
56
+
57
+ # Build field configuration for each database (TTL fields only)
58
+ databases_field_configuration = {
59
+ for db_name, schemas in local.collection_schemas : db_name => flatten([
60
+ for schema_name, schema in schemas :
61
+ try(schema.ttl_field, null) != null ? [
62
+ {
63
+ collection = schema_name
64
+ field = schema.ttl_field
65
+ ttl_enabled = true
66
+ }
67
+ ] : []
68
+ ])
69
+ }
70
+
71
+ # Build composite index configuration for each database
72
+ databases_composite_index_configuration = {
73
+ for db_name, schemas in local.collection_schemas : db_name => flatten([
74
+ for schema_name, schema in schemas : [
75
+ for idx, index in try(schema.indexes, []) :
76
+ {
77
+ index_id = index.name
78
+ collection = schema_name
79
+ query_scope = try(index.query_scope, "COLLECTION")
80
+ api_scope = try(index.api_scope, "ANY_API")
81
+ fields = [
82
+ for field_config in try(index.fields, []) : {
83
+ field_path = field_config.field_path
84
+ order = try(field_config.order, "ASCENDING")
85
+ array_config = try(field_config.array_config, null)
86
+ vector_config = try(field_config.vector_config, null)
87
+ }
88
+ ]
89
+ }
90
+ ]
91
+ ])
92
+ }
16
93
  }
17
94
 
18
- # These are the variables we have to pass in to use the module specified in the terragrunt configuration above
19
- inputs = merge(
20
- local.database_spec_vars.locals,
21
- {
22
- project_id = local.project_vars.locals.project_id,
23
- database_name = local.database_name
95
+ # Inputs to pass to the multi-database wrapper
96
+ inputs = {
97
+ project_id = local.project_vars.locals.project_id
98
+ databases = {
99
+ for db in local.all_databases : db.name => {
100
+ database_id = db.name
101
+ location = db.location
102
+ database_type = db.database_type
103
+ concurrency_mode = db.concurrency_mode
104
+ delete_protection_state = db.delete_protection_state
105
+ backup_schedule_configuration = db.backup_schedule_configuration
106
+ point_in_time_recovery_enablement = db.point_in_time_recovery_enablement
107
+ deletion_policy = db.deletion_policy
108
+ kms_key_name = db.kms_key_name
109
+ field_configuration = local.databases_field_configuration[db.name]
110
+ composite_index_configuration = local.databases_composite_index_configuration[db.name]
24
111
  }
25
- )
112
+ }
113
+ }
@@ -0,0 +1,22 @@
1
+ module.exports = {
2
+ validateTtlField: (input) => {
3
+ if (input.trim() === '') {
4
+ return true;
5
+ }
6
+ if (input.includes(',')) {
7
+ return 'Please enter only one field for TTL. Multiple fields are not supported.';
8
+ }
9
+ return true;
10
+ },
11
+
12
+ validateCompositeIndexName: (input) => {
13
+ const trimmedInput = input.trim();
14
+ if (!trimmedInput) {
15
+ return 'Composite index name is required';
16
+ }
17
+ if (trimmedInput.includes(',')) {
18
+ return 'Please enter only one name for the composite index';
19
+ }
20
+ return true;
21
+ },
22
+ };
@@ -11528,7 +11528,7 @@
11528
11528
  },
11529
11529
  "packages/generators": {
11530
11530
  "name": "@hiiretail/gcp-infra-generators",
11531
- "version": "1.9.0",
11531
+ "version": "1.11.0",
11532
11532
  "license": "MIT",
11533
11533
  "dependencies": {
11534
11534
  "@google-cloud/storage": "^7.18.0",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiiretail/gcp-infra-generators",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Infrastructure as code generator for GCP.",
5
5
  "scripts": {
6
6
  "build": "node esbuild.js && npm run build:deps",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiiretail/gcp-infra-generators",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Infrastructure as code generator for GCP.",
5
5
  "scripts": {
6
6
  "build": "node esbuild.js && npm run build:deps",
@@ -1,12 +0,0 @@
1
- # The last field entry is always for the field path __name__.
2
- # If, on creation, __name__ was not specified as the last field,
3
- # it will be added automatically with the same direction as that of the last field defined.
4
- # If the final field in a composite index is not directional, the __name__ will be ordered "ASCENDING".
5
- indexes:
6
- <%-databaseCollectionIndexName%>:
7
- - field_path: "<%-databaseCollectionFieldPath%>"
8
- array_config: "CONTAINS"
9
- order: "<%-databaseCollectionFieldorder%>"
10
- - field_path: "__name__"
11
- order: "<%-databaseCollectionFieldorder%>"
12
- array_config: null
@@ -1,27 +0,0 @@
1
- # Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the
2
- # working directory, into a temporary folder, and execute your Terraform commands in that folder.
3
- terraform {
4
- source = "git::https://github.com/extenda/tf-module-gcp-firestore//modules/index?ref=v1.0.0"
5
- }
6
-
7
- # Include all settings from the root terragrunt.hcl file
8
- include {
9
- path = find_in_parent_folders("terragrunt_root.hcl")
10
- }
11
-
12
- locals {
13
- project_vars = read_terragrunt_config(find_in_parent_folders("project.hcl"))
14
- collection = basename(get_terragrunt_dir())
15
- database_name = basename(dirname(dirname(get_terragrunt_dir())))
16
- }
17
-
18
- # These are the variables we have to pass in to use the module specified in the terragrunt configuration above
19
- inputs = merge(
20
- yamldecode(
21
- file("${get_terragrunt_dir()}/indexes.yaml")),
22
- {
23
- project_id = local.project_vars.locals.project_id
24
- database_name = local.database_name
25
- collection = local.collection
26
- }
27
- )
@@ -1,31 +0,0 @@
1
- locals {
2
- ###################
3
- # REQUIRED INPUTS #
4
- ###################
5
-
6
- database_location_id = "europe-west1"
7
- database_type = "<%- databaseType %>"
8
- database_concurrency_mode = "<%- databaseConcurrencyMode %>"
9
-
10
- ###################
11
- # OPTIONAL INPUTS #
12
- ###################
13
-
14
- # Description: Enables protection of a Firestore database from accidental deletion across all surfaces (API, gcloud, Cloud Console, and Terraform).
15
- #Type: String
16
- #Default: "DELETE_PROTECTION_DISABLED"
17
- database_delete_protection_state = "DELETE_PROTECTION_ENABLED"
18
-
19
- <% if (databaseBackupEnabled === 'true') { %>
20
- #Type: Bool
21
- #Default: true
22
- # Description: Firestore backup settings
23
- database_backup_enabled = true
24
- database_backup_schedule = "<%- databaseBackupSchedule %>"
25
- <% if (databaseBackupSchedule === 'weekly-backup') { %>
26
- database_backup_weekly_recurrence = "<%- databaseBackupWeeklyRecurrence %>"
27
- <% } %>
28
- database_point_in_time_recovery_enablement = "POINT_IN_TIME_RECOVERY_DISABLED"
29
- database_backup_retention = "<%- databaseBackupRetention %>"
30
- <% } %>
31
- }