From bcdc305251e7f814e6f404670ce387d76ae1a522 Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Mon, 19 Jan 2026 13:49:19 -0800 Subject: [PATCH] Remove drizzle and serverless infra. Add terraform deployment. --- .gitignore | 34 +- hasura/config.yaml | 7 + hasura/metadata/actions.graphql | 0 hasura/metadata/actions.yaml | 6 + hasura/metadata/allow_list.yaml | 1 + hasura/metadata/api_limits.yaml | 1 + hasura/metadata/backend_configs.yaml | 1 + hasura/metadata/cron_triggers.yaml | 1 + hasura/metadata/databases/databases.yaml | 14 + .../default/tables/public_shops.yaml | 3 + .../databases/default/tables/tables.yaml | 1 + .../graphql_schema_introspection.yaml | 1 + hasura/metadata/inherited_roles.yaml | 1 + hasura/metadata/metrics_config.yaml | 1 + hasura/metadata/network.yaml | 1 + hasura/metadata/opentelemetry.yaml | 1 + hasura/metadata/query_collections.yaml | 1 + hasura/metadata/remote_schemas.yaml | 1 + hasura/metadata/rest_endpoints.yaml | 1 + hasura/metadata/version.yaml | 1 + .../down.sql | 1 + .../up.sql | 18 + serverless/drizzle.config.ts | 10 - serverless/drizzle/0000_fearless_vector.sql | 42 -- serverless/drizzle/meta/0000_snapshot.json | 195 ------- serverless/drizzle/meta/_journal.json | 13 - serverless/serverless.yml | 68 --- serverless/src/lib/hasura.ts | 55 ++ terraform/.terraform.lock.hcl | 45 ++ terraform/hasura.tf | 517 ++++++++++++++++++ 30 files changed, 713 insertions(+), 329 deletions(-) create mode 100644 hasura/config.yaml create mode 100644 hasura/metadata/actions.graphql create mode 100644 hasura/metadata/actions.yaml create mode 100644 hasura/metadata/allow_list.yaml create mode 100644 hasura/metadata/api_limits.yaml create mode 100644 hasura/metadata/backend_configs.yaml create mode 100644 hasura/metadata/cron_triggers.yaml create mode 100644 hasura/metadata/databases/databases.yaml create mode 100644 hasura/metadata/databases/default/tables/public_shops.yaml create mode 100644 hasura/metadata/databases/default/tables/tables.yaml create mode 100644 hasura/metadata/graphql_schema_introspection.yaml create mode 100644 hasura/metadata/inherited_roles.yaml create mode 100644 hasura/metadata/metrics_config.yaml create mode 100644 hasura/metadata/network.yaml create mode 100644 hasura/metadata/opentelemetry.yaml create mode 100644 hasura/metadata/query_collections.yaml create mode 100644 hasura/metadata/remote_schemas.yaml create mode 100644 hasura/metadata/rest_endpoints.yaml create mode 100644 hasura/metadata/version.yaml create mode 100644 hasura/migrations/default/1768859340572_create_table_public_shops/down.sql create mode 100644 hasura/migrations/default/1768859340572_create_table_public_shops/up.sql delete mode 100644 serverless/drizzle.config.ts delete mode 100644 serverless/drizzle/0000_fearless_vector.sql delete mode 100644 serverless/drizzle/meta/0000_snapshot.json delete mode 100644 serverless/drizzle/meta/_journal.json create mode 100644 serverless/src/lib/hasura.ts create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/hasura.tf diff --git a/.gitignore b/.gitignore index f80e3c3..4750699 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,36 @@ deploy.ps1 # Sentry Config File .env.sentry-build-plugin -.serverless/ \ No newline at end of file +.serverless/ + +# Local .terraform directories +.terraform/ + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore transient lock info files created by terraform apply +.terraform.tfstate.lock.info + + +.terraformrc +terraform.rc diff --git a/hasura/config.yaml b/hasura/config.yaml new file mode 100644 index 0000000..c445cfa --- /dev/null +++ b/hasura/config.yaml @@ -0,0 +1,7 @@ +version: 3 +endpoint: https://db.es.imex.online +admin_secret: UXWqeUlNMc2dd2SD7DTOKgjEQlVkZkaW +metadata_directory: metadata +actions: + kind: synchronous + handler_webhook_baseurl: http://localhost:3000 diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql new file mode 100644 index 0000000..e69de29 diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml new file mode 100644 index 0000000..1edb4c2 --- /dev/null +++ b/hasura/metadata/actions.yaml @@ -0,0 +1,6 @@ +actions: [] +custom_types: + enums: [] + input_objects: [] + objects: [] + scalars: [] diff --git a/hasura/metadata/allow_list.yaml b/hasura/metadata/allow_list.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/allow_list.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/api_limits.yaml b/hasura/metadata/api_limits.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/api_limits.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/backend_configs.yaml b/hasura/metadata/backend_configs.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/backend_configs.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/cron_triggers.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/databases/databases.yaml b/hasura/metadata/databases/databases.yaml new file mode 100644 index 0000000..65a11b2 --- /dev/null +++ b/hasura/metadata/databases/databases.yaml @@ -0,0 +1,14 @@ +- name: default + kind: postgres + configuration: + connection_info: + database_url: + from_env: HASURA_GRAPHQL_DATABASE_URL + isolation_level: read-committed + pool_settings: + connection_lifetime: 600 + idle_timeout: 180 + max_connections: 50 + retries: 1 + use_prepared_statements: true + tables: "!include default/tables/tables.yaml" diff --git a/hasura/metadata/databases/default/tables/public_shops.yaml b/hasura/metadata/databases/default/tables/public_shops.yaml new file mode 100644 index 0000000..10d6138 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_shops.yaml @@ -0,0 +1,3 @@ +table: + name: shops + schema: public diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml new file mode 100644 index 0000000..b7e787b --- /dev/null +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -0,0 +1 @@ +- "!include public_shops.yaml" diff --git a/hasura/metadata/graphql_schema_introspection.yaml b/hasura/metadata/graphql_schema_introspection.yaml new file mode 100644 index 0000000..61a4dca --- /dev/null +++ b/hasura/metadata/graphql_schema_introspection.yaml @@ -0,0 +1 @@ +disabled_for_roles: [] diff --git a/hasura/metadata/inherited_roles.yaml b/hasura/metadata/inherited_roles.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/inherited_roles.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/metrics_config.yaml b/hasura/metadata/metrics_config.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/metrics_config.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/network.yaml b/hasura/metadata/network.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/network.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/opentelemetry.yaml b/hasura/metadata/opentelemetry.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/opentelemetry.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/query_collections.yaml b/hasura/metadata/query_collections.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/query_collections.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/remote_schemas.yaml b/hasura/metadata/remote_schemas.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/remote_schemas.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/rest_endpoints.yaml b/hasura/metadata/rest_endpoints.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/rest_endpoints.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/version.yaml b/hasura/metadata/version.yaml new file mode 100644 index 0000000..0a70aff --- /dev/null +++ b/hasura/metadata/version.yaml @@ -0,0 +1 @@ +version: 3 diff --git a/hasura/migrations/default/1768859340572_create_table_public_shops/down.sql b/hasura/migrations/default/1768859340572_create_table_public_shops/down.sql new file mode 100644 index 0000000..de2f7a1 --- /dev/null +++ b/hasura/migrations/default/1768859340572_create_table_public_shops/down.sql @@ -0,0 +1 @@ +DROP TABLE "public"."shops"; diff --git a/hasura/migrations/default/1768859340572_create_table_public_shops/up.sql b/hasura/migrations/default/1768859340572_create_table_public_shops/up.sql new file mode 100644 index 0000000..39b533a --- /dev/null +++ b/hasura/migrations/default/1768859340572_create_table_public_shops/up.sql @@ -0,0 +1,18 @@ +CREATE TABLE "public"."shops" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "es_api_key" text NOT NULL, "active_until" timestamptz NOT NULL, PRIMARY KEY ("id") , UNIQUE ("es_api_key")); +CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() +RETURNS TRIGGER AS $$ +DECLARE + _new record; +BEGIN + _new := NEW; + _new."updated_at" = NOW(); + RETURN _new; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER "set_public_shops_updated_at" +BEFORE UPDATE ON "public"."shops" +FOR EACH ROW +EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); +COMMENT ON TRIGGER "set_public_shops_updated_at" ON "public"."shops" +IS 'trigger to set value of column "updated_at" to current timestamp on row update'; +CREATE EXTENSION IF NOT EXISTS pgcrypto; diff --git a/serverless/drizzle.config.ts b/serverless/drizzle.config.ts deleted file mode 100644 index 317fae5..0000000 --- a/serverless/drizzle.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - schema: './src/db/schema/**/*.ts', - out: './drizzle', - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL ?? '', - }, -}); diff --git a/serverless/drizzle/0000_fearless_vector.sql b/serverless/drizzle/0000_fearless_vector.sql deleted file mode 100644 index 222e849..0000000 --- a/serverless/drizzle/0000_fearless_vector.sql +++ /dev/null @@ -1,42 +0,0 @@ -CREATE TABLE - "joblines" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid () NOT NULL, - "created_at" timestamp - with - time zone DEFAULT now () NOT NULL, - "jobId" uuid NOT NULL, - "line_desc" text - ); - ---> statement-breakpoint -CREATE TABLE - "jobs" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid () NOT NULL, - "shopId" uuid NOT NULL, - "created_at" timestamp - with - time zone DEFAULT now () NOT NULL, - "clm_no" text, - "ciecaid" text - ); - ---> statement-breakpoint -CREATE TABLE - "shops" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid () NOT NULL, - "created_at" timestamp - with - time zone DEFAULT now () NOT NULL, - "es_api_key" text NOT NULL, - "active" boolean DEFAULT true NOT NULL, - CONSTRAINT "shops_es_api_key_unique" UNIQUE ("es_api_key") - ); - ---> statement-breakpoint -ALTER TABLE "joblines" ADD CONSTRAINT "joblines_jobId_jobs_id_fk" FOREIGN KEY ("jobId") REFERENCES "public"."jobs" ("id") ON DELETE no action ON UPDATE no action; - ---> statement-breakpoint -ALTER TABLE "jobs" ADD CONSTRAINT "jobs_shopId_shops_id_fk" FOREIGN KEY ("shopId") REFERENCES "public"."shops" ("id") ON DELETE no action ON UPDATE no action; - ---> statement-breakpoint -CREATE INDEX "clm_no_idx" ON "jobs" USING btree ("clm_no"); \ No newline at end of file diff --git a/serverless/drizzle/meta/0000_snapshot.json b/serverless/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 066efe7..0000000 --- a/serverless/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "id": "e789ff38-2370-43ff-825a-fde4c3efca40", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.joblines": { - "name": "joblines", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "jobId": { - "name": "jobId", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "line_desc": { - "name": "line_desc", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "joblines_jobId_jobs_id_fk": { - "name": "joblines_jobId_jobs_id_fk", - "tableFrom": "joblines", - "tableTo": "jobs", - "columnsFrom": [ - "jobId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.jobs": { - "name": "jobs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "shopId": { - "name": "shopId", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "clm_no": { - "name": "clm_no", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ciecaid": { - "name": "ciecaid", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "clm_no_idx": { - "name": "clm_no_idx", - "columns": [ - { - "expression": "clm_no", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "jobs_shopId_shops_id_fk": { - "name": "jobs_shopId_shops_id_fk", - "tableFrom": "jobs", - "tableTo": "shops", - "columnsFrom": [ - "shopId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.shops": { - "name": "shops", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "es_api_key": { - "name": "es_api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "active": { - "name": "active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "shops_es_api_key_unique": { - "name": "shops_es_api_key_unique", - "nullsNotDistinct": false, - "columns": [ - "es_api_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/serverless/drizzle/meta/_journal.json b/serverless/drizzle/meta/_journal.json deleted file mode 100644 index 7a2c104..0000000 --- a/serverless/drizzle/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1768419700617, - "tag": "0000_fearless_vector", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/serverless/serverless.yml b/serverless/serverless.yml index edc0e23..96ff850 100644 --- a/serverless/serverless.yml +++ b/serverless/serverless.yml @@ -2,10 +2,6 @@ service: esdp-api app: esdp-api-app frameworkVersion: '4' -package: - patterns: - - drizzle/** - stages: prod: # Enables observability in the prod stage @@ -17,8 +13,6 @@ stages: domain: es.imex.online es_user: Imex2 es_password: Patrick - infra_service: esdp-infra - infra_stage: shared beta: # Enables observability in the prod stage observability: false @@ -29,8 +23,6 @@ stages: domain: beta.es.imex.online es_user: Imex2 es_password: Patrick - infra_service: esdp-infra - infra_stage: shared alpha: # Enables observability in the prod stage observability: false @@ -40,8 +32,6 @@ stages: domain: alpha.es.imex.online es_user: Imex2 es_password: Patrick - infra_service: esdp-infra - infra_stage: shared dev: # Enables observability in the prod stage observability: false @@ -51,17 +41,6 @@ stages: domain: dev.es.imex.online es_user: Imex2 es_password: Patrick - infra_service: esdp-infra - infra_stage: shared - -custom: - infra_stack: ${param:infra_service}-${param:infra_stage} - - db: - host: ${cf:${self:custom.infra_stack}.DbProxyEndpoint} - port: ${cf:${self:custom.infra_stack}.DbPort} - name: ${cf:${self:custom.infra_stack}.DbName} - secretArn: ${cf:${self:custom.infra_stack}.DbSecretArn} # params: # dev: @@ -81,31 +60,6 @@ provider: httpApi: # This creates a cheaper, faster "HTTP API" Gateway cors: true # Automatically configures CORS - # Ensure all Lambdas can reach the shared RDS Proxy in the infra VPC - vpc: - securityGroupIds: - - ${cf:${self:custom.infra_stack}.LambdaSecurityGroupId} - subnetIds: - - ${cf:${self:custom.infra_stack}.PrivateSubnetAId} - - ${cf:${self:custom.infra_stack}.PrivateSubnetBId} - - # Default DB connection settings for all Lambdas (used by src/lib/db.ts) - environment: - DB_HOST: ${self:custom.db.host} - DB_PORT: ${self:custom.db.port} - DB_NAME: ${self:custom.db.name} - DB_SECRET_ARN: ${self:custom.db.secretArn} - - # Allow Lambdas to fetch the DB credentials from Secrets Manager - iam: - role: - statements: - - Effect: Allow - Action: - - secretsmanager:GetSecretValue - Resource: - - ${self:custom.db.secretArn} - build: esbuild: bundle: true @@ -151,28 +105,6 @@ functions: path: /emsupload method: post - dbMigrate: - handler: src/handlers/dbMigrate.handler - timeout: 30 - memorySize: 512 - iamRoleStatements: - - Effect: Allow - Action: - - secretsmanager:GetSecretValue - Resource: - - ${cf:${self:custom.infra_stack}.DbSecretArn} - - dbPing: - handler: src/handlers/dbPing.handler - timeout: 15 - memorySize: 256 - iamRoleStatements: - - Effect: Allow - Action: - - secretsmanager:GetSecretValue - Resource: - - ${cf:${self:custom.infra_stack}.DbSecretArn} - resources: Resources: UploadBucket: diff --git a/serverless/src/lib/hasura.ts b/serverless/src/lib/hasura.ts new file mode 100644 index 0000000..4b3672d --- /dev/null +++ b/serverless/src/lib/hasura.ts @@ -0,0 +1,55 @@ +import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; + +const client = new SecretsManagerClient({ region: 'ca-central-1' }); + +interface HasuraCredentials { + database_url: string; + admin_secret: string; +} + +let cachedCredentials: HasuraCredentials | null = null; + +/** + * Fetches Hasura credentials from AWS Secrets Manager + * Caches the result for subsequent invocations within the same Lambda container + */ +export async function getHasuraCredentials(): Promise { + if (cachedCredentials) { + return cachedCredentials; + } + + const secretArn = process.env.HASURA_SECRET_ARN; + if (!secretArn) { + throw new Error('HASURA_SECRET_ARN environment variable not set'); + } + + try { + const response = await client.send(new GetSecretValueCommand({ SecretId: secretArn })); + + if (!response.SecretString) { + throw new Error('Secret value is empty'); + } + + cachedCredentials = JSON.parse(response.SecretString); + return cachedCredentials!; + } catch (error) { + console.error('Failed to fetch Hasura credentials:', error); + throw error; + } +} + +/** + * Example: Get just the admin secret + */ +export async function getHasuraAdminSecret(): Promise { + const creds = await getHasuraCredentials(); + return creds.admin_secret; +} + +/** + * Example: Get just the database URL + */ +export async function getHasuraDatabaseUrl(): Promise { + const creds = await getHasuraCredentials(); + return creds.database_url; +} diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..8d9d6a1 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,45 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.28.0" + constraints = "~> 6.0" + hashes = [ + "h1:RwoFuX1yGMVaKJaUmXDKklEaQ/yUCEdt5k2kz+/g08c=", + "zh:0ba0d5eb6e0c6a933eb2befe3cdbf22b58fbc0337bf138f95bf0e8bb6e6df93e", + "zh:23eacdd4e6db32cf0ff2ce189461bdbb62e46513978d33c5de4decc4670870ec", + "zh:307b06a15fc00a8e6fd243abde2cbe5112e9d40371542665b91bec1018dd6e3c", + "zh:37a02d5b45a9d050b9642c9e2e268297254192280df72f6e46641daca52e40ec", + "zh:3da866639f07d92e734557d673092719c33ede80f4276c835bf7f231a669aa33", + "zh:480060b0ba310d0f6b6a14d60b276698cb103c48fd2f7e2802ae47c963995ec6", + "zh:57796453455c20db80d9168edbf125bf6180e1aae869de1546a2be58e4e405ec", + "zh:69139cba772d4df8de87598d8d8a2b1b4b254866db046c061dccc79edb14e6b9", + "zh:7312763259b859ff911c5452ca8bdf7d0be6231c5ea0de2df8f09d51770900ac", + "zh:8d2d6f4015d3c155d7eb53e36f019a729aefb46ebfe13f3a637327d3a1402ecc", + "zh:94ce589275c77308e6253f607de96919b840c2dd36c44aa798f693c9dd81af42", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:adaceec6a1bf4f5df1e12bd72cf52b72087c72efed078aef636f8988325b1a8b", + "zh:d37be1ce187d94fd9df7b13a717c219964cd835c946243f096c6b230cdfd7e92", + "zh:fe6205b5ca2ff36e68395cb8d3ae10a3728f405cdbcd46b206a515e1ebcf17a1", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.8.0" + constraints = "~> 3.0" + hashes = [ + "h1:BYpqK2+ZHqNF9sauVugKJSeFWMCx11I/z/1lMplwUC0=", + "zh:0e71891d8f25564e8d0b61654ed2ca52101862b9a2a07d736395193ae07b134b", + "zh:1c56852d094161997df5fd8a6cbca7c6c979b3f8c3c00fbcc374a59305d117b1", + "zh:20698fb8a2eaa7e23c4f8e3d22250368862f578cf618be0281d5b61496cbef13", + "zh:3afbdd5e955f6d0105fed4f6b7fef7ba165cd780569483e688002108cf06586c", + "zh:4ce22b96e625dc203ea653d53551d46156dd63ad79e45bcbe0224b2e6357f243", + "zh:4ff84b568ad468d140f8f6201a372c6c4bea17d64527b72e341ae8fafea65b8e", + "zh:54b071cb509203c43e420cc589523709bbc6e65d80c1cd9384f5bd88fd1ff1a2", + "zh:63fc5f9f341a573cd5c8bcfc994a58fa52a5ad88d2cbbd80f5a9f143c5006e75", + "zh:73cb8b39887589914686d14a99b4de6e85e48603f7235d87da5594e3fbb7d8a7", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7ee20f28aa6a25539a5b9fc249e751dec5a5b130dcd73c5d05efdf4d5e320454", + "zh:994a83fddab1d44a8f546920ed34e45ea6caefe4f08735bada6c28dc9010e5e4", + ] +} diff --git a/terraform/hasura.tf b/terraform/hasura.tf new file mode 100644 index 0000000..2ccd31e --- /dev/null +++ b/terraform/hasura.tf @@ -0,0 +1,517 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "aws" { + region = "ca-central-1" +} + +locals { + # CONFIGURATION + my_ip = "70.36.57.88/32" # REPLACE WITH YOUR IP + domain_name = "db.es.imex.online" + hosted_zone_name = "imex.online" # The root zone you manage in Route53 + region = "ca-central-1" + app_name = "esdp-hasura" +} + +# ----------------------------------------------------------------------------- +# 1. SECRETS & CREDENTIALS (Auto-generated) +# ----------------------------------------------------------------------------- + +# Generate a random username +resource "random_string" "db_username" { + length = 8 + special = false + numeric = false # PG usernames shouldn't start with numbers usually + upper = false +} + +# Generate a secure random password +resource "random_password" "db_password" { + length = 24 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" # Safe chars for Postgres +} + +# Generate a random secret for Hasura Admin +resource "random_password" "hasura_admin_secret" { + length = 32 + special = false +} + +# Create the Secret Container in AWS Secrets Manager +resource "aws_secretsmanager_secret" "hasura_credentials" { + name = "${local.app_name}-credentials-${random_string.suffix.result}" +} + +resource "random_string" "suffix" { + length = 6 + special = false + upper = false +} + +# Store the connection string and admin secret +# We wait for the DB to be created so we can grab the endpoint +resource "aws_secretsmanager_secret_version" "creds" { + secret_id = aws_secretsmanager_secret.hasura_credentials.id + secret_string = jsonencode({ + # Hasura needs the full connection URL + database_url = "postgres://${random_string.db_username.result}:${random_password.db_password.result}@${aws_db_instance.default.endpoint}/esdp" + admin_secret = random_password.hasura_admin_secret.result + }) +} + +# ----------------------------------------------------------------------------- +# 2. NETWORKING +# ----------------------------------------------------------------------------- + +resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true + tags = { Name = "${local.app_name}-vpc" } +} + +resource "aws_internet_gateway" "gw" { + vpc_id = aws_vpc.main.id +} + +# Get available AZs in the configured region +data "aws_availability_zones" "available" { + state = "available" +} + +# Public Subnets (ALB + Public DB + Fargate) +resource "aws_subnet" "public_a" { + vpc_id = aws_vpc.main.id + cidr_block = "10.0.1.0/24" + availability_zone = data.aws_availability_zones.available.names[0] + map_public_ip_on_launch = true +} + +resource "aws_subnet" "public_b" { + vpc_id = aws_vpc.main.id + cidr_block = "10.0.2.0/24" + availability_zone = data.aws_availability_zones.available.names[1] + map_public_ip_on_launch = true +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.gw.id + } +} + +resource "aws_route_table_association" "a" { + subnet_id = aws_subnet.public_a.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "b" { + subnet_id = aws_subnet.public_b.id + route_table_id = aws_route_table.public.id +} + +# ----------------------------------------------------------------------------- +# 3. SECURITY GROUPS +# ----------------------------------------------------------------------------- + +resource "aws_security_group" "alb_sg" { + name = "${local.app_name}-alb-sg" + vpc_id = aws_vpc.main.id + + # Allow HTTPS from anywhere + ingress { + protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_blocks = ["0.0.0.0/0"] + } + + # Allow HTTP for redirection + ingress { + protocol = "tcp" + from_port = 80 + to_port = 80 + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_security_group" "ecs_sg" { + name = "${local.app_name}-ecs-sg" + vpc_id = aws_vpc.main.id + + # Only accept traffic from ALB + ingress { + protocol = "tcp" + from_port = 8080 + to_port = 8080 + security_groups = [aws_security_group.alb_sg.id] + } + + egress { + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_security_group" "db_sg" { + name = "${local.app_name}-db-sg" + vpc_id = aws_vpc.main.id + + # 1. Allow ECS Tasks + ingress { + protocol = "tcp" + from_port = 5432 + to_port = 5432 + security_groups = [aws_security_group.ecs_sg.id] + } + + # 2. Allow YOUR IP (Strict Access) + ingress { + protocol = "tcp" + from_port = 5432 + to_port = 5432 + cidr_blocks = [local.my_ip] + } + + egress { + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] + } +} + +# ----------------------------------------------------------------------------- +# 4. DATABASE (RDS Postgres t4g.micro) +# ----------------------------------------------------------------------------- + +resource "aws_db_subnet_group" "default" { + name = "${local.app_name}-db-subnets" + subnet_ids = [aws_subnet.public_a.id, aws_subnet.public_b.id] +} + +resource "aws_db_instance" "default" { + identifier = "${local.app_name}-db" + engine = "postgres" + engine_version = "17.6" + instance_class = "db.t4g.micro" + allocated_storage = 20 + storage_type = "gp3" + + db_name = "esdp" + username = random_string.db_username.result + password = random_password.db_password.result + + publicly_accessible = true + vpc_security_group_ids = [aws_security_group.db_sg.id] + db_subnet_group_name = aws_db_subnet_group.default.name + skip_final_snapshot = true + apply_immediately = true + deletion_protection = true + +} + +# ----------------------------------------------------------------------------- +# 5. DOMAIN & SSL (ACM + Route53) +# ----------------------------------------------------------------------------- + +data "aws_route53_zone" "main" { + name = local.hosted_zone_name + private_zone = false +} + +resource "aws_acm_certificate" "cert" { + domain_name = local.domain_name + validation_method = "DNS" + lifecycle { + create_before_destroy = true + } +} + +# Create DNS Record for Validation +resource "aws_route53_record" "cert_validation" { + for_each = { + for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => dvo + } + allow_overwrite = true + name = each.value.resource_record_name + records = [each.value.resource_record_value] + ttl = 60 + type = each.value.resource_record_type + zone_id = data.aws_route53_zone.main.zone_id +} + +# Wait for Validation to complete +resource "aws_acm_certificate_validation" "cert" { + certificate_arn = aws_acm_certificate.cert.arn + validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn] +} + +# Point Domain to ALB +resource "aws_route53_record" "www" { + zone_id = data.aws_route53_zone.main.zone_id + name = local.domain_name + type = "A" + + alias { + name = aws_lb.main.dns_name + zone_id = aws_lb.main.zone_id + evaluate_target_health = true + } +} + +# ----------------------------------------------------------------------------- +# 6. LOAD BALANCER (ALB) +# ----------------------------------------------------------------------------- + +resource "aws_lb" "main" { + name = "${local.app_name}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb_sg.id] + subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id] +} + +resource "aws_lb_target_group" "hasura" { + name = "${local.app_name}-tg" + port = 8080 + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" + + health_check { + path = "/healthz" + matcher = "200" + interval = 60 + } +} + +# HTTP Listener (Redirect to HTTPS) +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.main.arn + port = "80" + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +# HTTPS Listener +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.main.arn + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = aws_acm_certificate_validation.cert.certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.hasura.arn + } +} + +# ----------------------------------------------------------------------------- +# 7. ECS FARGATE (Compute) +# ----------------------------------------------------------------------------- + +# IAM Role for Task Execution (Needs permission to pull images + read secrets) +resource "aws_iam_role" "ecs_execution_role" { + name = "${local.app_name}-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) +} + +# Attach basic ECS policy +resource "aws_iam_role_policy_attachment" "ecs_execution_basic" { + role = aws_iam_role.ecs_execution_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# Attach Secrets Manager Read Policy +resource "aws_iam_policy" "secrets_policy" { + name = "${local.app_name}-secrets-policy" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [aws_secretsmanager_secret.hasura_credentials.arn] + }] + }) +} + +resource "aws_iam_role_policy_attachment" "ecs_secrets_attach" { + role = aws_iam_role.ecs_execution_role.name + policy_arn = aws_iam_policy.secrets_policy.arn +} + +# CloudWatch Logs Policy for log group creation +resource "aws_iam_policy" "cloudwatch_logs_policy" { + name = "${local.app_name}-cloudwatch-logs-policy" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:${local.region}:*:log-group:/ecs/${local.app_name}*" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "ecs_logs_attach" { + role = aws_iam_role.ecs_execution_role.name + policy_arn = aws_iam_policy.cloudwatch_logs_policy.arn +} + +resource "aws_ecs_cluster" "main" { + name = "${local.app_name}-cluster" +} + +resource "aws_ecs_task_definition" "hasura" { + family = "${local.app_name}-task" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = 256 # 0.25 vCPU + memory = 512 # 0.5 GB + execution_role_arn = aws_iam_role.ecs_execution_role.arn + + container_definitions = jsonencode([ + { + name = "hasura" + image = "hasura/graphql-engine:v2.48.10" + essential = true + portMappings = [{ containerPort = 8080 }] + + # INJECT SECRETS HERE + secrets = [ + { + name = "HASURA_GRAPHQL_DATABASE_URL" + valueFrom = "${aws_secretsmanager_secret.hasura_credentials.arn}:database_url::" + }, + { + name = "HASURA_GRAPHQL_ADMIN_SECRET" + valueFrom = "${aws_secretsmanager_secret.hasura_credentials.arn}:admin_secret::" + } + ] + + environment = [ + { name = "HASURA_GRAPHQL_ENABLE_CONSOLE", value = "true" }, # Set false for strict prod, + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = "/ecs/${local.app_name}" + "awslogs-region" = local.region + "awslogs-stream-prefix" = "ecs" + "awslogs-create-group" = "true" + } + } + } + ]) +} + +resource "aws_ecs_service" "hasura" { + name = "${local.app_name}-service" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.hasura.arn + desired_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id] + security_groups = [aws_security_group.ecs_sg.id] + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.hasura.arn + container_name = "hasura" + container_port = 8080 + } +} + +# ----------------------------------------------------------------------------- +# 8. AUTO SCALING +# ----------------------------------------------------------------------------- + +resource "aws_appautoscaling_target" "ecs_target" { + max_capacity = 3 + min_capacity = 1 + resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.hasura.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "ecs_policy_cpu" { + name = "scale-cpu" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs_target.resource_id + scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs_target.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = 70.0 + scale_in_cooldown = 60 + scale_out_cooldown = 60 + } +} + +# ----------------------------------------------------------------------------- +# OUTPUTS +# ----------------------------------------------------------------------------- + +output "hasura_console_url" { + value = "https://${local.domain_name}/console" +} + +output "db_public_endpoint" { + value = aws_db_instance.default.endpoint +} + +output "secrets_arn" { + value = aws_secretsmanager_secret.hasura_credentials.arn +}