# Infrastructure as code

## AWS

### Deploying from Terraform, CDK or Pulumi

Use this when every change to your AWS account goes through an infrastructure repository.  What the CLI
does is small, and you can do it yourself: it mints a collector identity, writes `collector.toml`,
base64-encodes it, and creates one CloudFormation stack from the published template with eight
parameters.  Your Terraform, CDK or Pulumi does the same with a stack resource, and the result is
identical to what the CLI deploys.

#### What you need first

1. Create a collector identity.  In the DBGorilla app, open **Settings, Collectors, New Collector**
   and choose **Create credentials** under **Deploy with your infrastructure as code**.  You get an
   agent ID, a tenant ID and a secret.  The secret is shown once.  The agent and tenant IDs go in
   the config; the secret goes in a stack parameter and never in the repository.
2. Create a database user that logs in with IAM.  On each database:

   ```sql
   CREATE USER dbg_collector;
   GRANT rds_iam TO dbg_collector;
   GRANT pg_monitor TO dbg_collector;
   ```

   plus the per-schema `USAGE` and `SELECT` grants from
   [Database grants](/docs/connect/prepare-postgresql/#database-grants).
   The stack creates a task role allowed to call `rds-db:connect`; the `RdsConnectResources`
   parameter narrows that to your instance and user.
3. Commit `collector.toml` to the repository, with no secrets in it.  Both secrets are references
   the stack fills from Secrets Manager.

   The encoded config must fit in 4096 characters, so keep comments out of the file you commit.

   The file, with the Aurora variant, is in
   [AWS RDS and Aurora PostgreSQL](/docs/connect/aws-rds-aurora/#aws-rds-and-aurora-postgresql).

   The sample enables `explain`, which fetches query plans and never runs the query, and
   `collect_statistics`, which copies optimizer statistics and never table rows.  To turn either
   off, remove it from `allowed` or set `enabled = false`, and update the stack.  See
   [Database commands](/docs/reference/collector-commands/#database-commands).

   `dbgorilla collector install` gives each new database the same two.  An interactive run asks
   per database with both selected.  `--enable-commands` allows all three, `--commands=explain`
   allows the ones you list, and `--enable-commands=false` or `--commands=""` allows none,
   `explain` included.  Re-running `install` against a collector you already have keeps each
   database's current commands unless you pass one of those flags.  A database added on that run
   gets the two defaults.

#### The stack parameters

| Parameter | Value |
|---|---|
| `CollectorConfig` | `collector.toml`, base64 on one line |
| `ServerSecret` | the secret from the app |
| `DbPassword` | empty with IAM auth |
| `CollectorImage` | `dbgorillapublic.azurecr.io/dbg-collector:0.14.0`, or a digest |
| `RdsConnectResources` | `arn:aws:rds-db:<region>:<account>:dbuser:<DbiResourceId>/dbg_collector` |
| `Subnets` | subnets that can reach the database |
| `SecurityGroupId` | a group allowing egress to the database and to DBGorilla on 443 |
| `AssignPublicIp` | `ENABLED` unless the subnets have a NAT route |

Pin the template by version in place of `latest`, so a review shows the change when the contract
moves: `https://dbgorilla-cfn-us-east-1.s3.us-east-1.amazonaws.com/collector/fargate/v1.4.yaml`.
The template creates two IAM roles, neither with a fixed name, so `CAPABILITY_IAM` is enough.

#### Terraform

```hcl
variable "dbg_server_secret" {
  type      = string
  sensitive = true
}

resource "aws_cloudformation_stack" "dbg_collector" {
  name         = "dbgorilla-collector"
  template_url = "https://dbgorilla-cfn-us-east-1.s3.us-east-1.amazonaws.com/collector/fargate/v1.4.yaml"
  capabilities = ["CAPABILITY_IAM"]

  parameters = {
    CollectorConfig      = base64encode(file("${path.module}/collector.toml"))
    ServerSecret         = var.dbg_server_secret
    DbPassword           = ""
    CollectorImage       = "dbgorillapublic.azurecr.io/dbg-collector:0.14.0"
    RdsConnectResources  = "arn:aws:rds-db:us-east-1:123456789012:dbuser:${aws_db_instance.prod.resource_id}/dbg_collector"
    Subnets              = join(",", aws_db_subnet_group.prod.subnet_ids)
    SecurityGroupId      = aws_security_group.dbg_collector.id
    AssignPublicIp       = "ENABLED"
  }
}
```

`base64encode` produces a single line, which is what the parameter requires.  Supply the secret
from your secret store at plan time.  It lands in Secrets Manager inside the stack, and if you mark
the variable sensitive it is never written to state in clear text.

#### AWS CDK

```ts
import { CfnStack } from "aws-cdk-lib/aws-cloudformation";
import { SecretValue } from "aws-cdk-lib";
import { readFileSync } from "node:fs";

new CfnStack(this, "DbgCollector", {
  templateUrl: "https://dbgorilla-cfn-us-east-1.s3.us-east-1.amazonaws.com/collector/fargate/v1.4.yaml",
  parameters: {
    CollectorConfig: readFileSync("collector.toml").toString("base64"),
    ServerSecret: SecretValue.secretsManager("dbgorilla/collector/server-secret").unsafeUnwrap(),
    DbPassword: "",
    CollectorImage: "dbgorillapublic.azurecr.io/dbg-collector:0.14.0",
    RdsConnectResources: `arn:aws:rds-db:${this.region}:${this.account}:dbuser:${db.instanceResourceId}/dbg_collector`,
    Subnets: vpc.privateSubnets.map((s) => s.subnetId).join(","),
    SecurityGroupId: collectorSg.securityGroupId,
    AssignPublicIp: "DISABLED",
  },
});
```

`CfnStack` is a nested stack, so the collector's ECS service, log group and roles appear under your
CDK stack in the console.  Add `CAPABILITY_IAM` to your deploy if your pipeline does not already
grant it.

#### Pulumi

```ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
import { readFileSync } from "node:fs";

const cfg = new pulumi.Config();

new aws.cloudformation.Stack("dbg-collector", {
  templateUrl: "https://dbgorilla-cfn-us-east-1.s3.us-east-1.amazonaws.com/collector/fargate/v1.4.yaml",
  capabilities: ["CAPABILITY_IAM"],
  parameters: {
    CollectorConfig: readFileSync("collector.toml").toString("base64"),
    ServerSecret: cfg.requireSecret("dbgServerSecret"),
    DbPassword: "",
    CollectorImage: "dbgorillapublic.azurecr.io/dbg-collector:0.14.0",
    RdsConnectResources: pulumi.interpolate`arn:aws:rds-db:us-east-1:123456789012:dbuser:${db.resourceId}/dbg_collector`,
    Subnets: pulumi.output(subnetGroup.subnetIds).apply((ids) => ids.join(",")),
    SecurityGroupId: collectorSg.id,
    AssignPublicIp: "ENABLED",
  },
});
```

Store the secret with `pulumi config set --secret dbgServerSecret <secret>`.  Pulumi encrypts it in
state and shows it as `[secret]` in every preview.

#### Upgrading and removing

Change `CollectorImage` and apply.  The stack replaces the task definition and the service rolls to
the new image.

Destroying the stack removes the task, the log group and the two secrets.  The collector's identity
in DBGorilla stays, so delete the collector in the app as well.  A new deploy with a new identity is
a new collector; the old one does not resume.

## Kubernetes

### Deploying from Terraform

If the cluster is managed from an infrastructure repository, the `helm install` on
[Kubernetes](/docs/connect/kubernetes/#install) becomes a `helm_release`.  The chart, the values and
the config file are the same; only the tool applying them changes.

```hcl
terraform {
  required_providers {
    helm       = { source = "hashicorp/helm", version = "~> 2.13" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.30" }
  }
}

variable "dbg_server_secret" {
  type      = string
  sensitive = true
}

variable "app_pg_password" {
  type      = string
  sensitive = true
}

resource "kubernetes_secret" "dbg_collector" {
  metadata {
    name      = "dbg-collector"
    namespace = "databases"
  }
  data = {
    DBG_SERVER_SECRET = var.dbg_server_secret
    APP_PG_PASSWORD   = var.app_pg_password
  }
}

resource "helm_release" "dbg_collector" {
  name       = "dbg-collector"
  namespace  = "databases"
  repository = "oci://dbgorillapublic.azurecr.io/charts"
  chart      = "dbg-collector"
  version    = "0.10.1"

  values = [yamlencode({
    secrets = {
      existingSecret = kubernetes_secret.dbg_collector.metadata[0].name
    }
    extraEnv = [{
      name = "APP_PG_PASSWORD"
      valueFrom = {
        secretKeyRef = {
          name = kubernetes_secret.dbg_collector.metadata[0].name
          key  = "APP_PG_PASSWORD"
        }
      }
    }]
    config = {
      inline = file("${path.module}/collector.toml")
    }
  })]
}
```

Three things differ from the CLI path, and none of them reports an error when you get it wrong:

**`existingSecret` injects `DBG_SERVER_SECRET` and nothing else.**  The chart works from
`values.yaml` alone, so you must declare the password variables your TOML references.  Every one
of them needs an `extraEnv` entry, as above.  Miss one and the collector starts, connects to
DBGorilla, and cannot log in to that database.

Pin `version`.  Without it Helm resolves the newest chart in the registry at apply time, so two
applies from the same commit can install different versions.

The Secret holds live credentials, and Terraform writes them to state.  Mark the variables
sensitive, keep the state encrypted, or drop `kubernetes_secret` and point `existingSecret` at a
Secret you manage another way, with an ExternalSecret for instance.  The chart only needs the name.

A GitOps controller is the same shape: a Flux `HelmRelease` or an Argo CD Application with the
same chart, version and values.

## CloudNativePG

### Deploying from Terraform

The chart is the same one the [Kubernetes page](#deploying-from-terraform) deploys, so the
`helm_release` there is the whole pattern.  Two values differ here.

```hcl
resource "helm_release" "dbg_collector" {
  name       = "dbg-collector"
  namespace  = "prod-db"
  repository = "oci://dbgorillapublic.azurecr.io/charts"
  chart      = "dbg-collector"
  version    = "0.10.1"

  values = [yamlencode({
    rbac = {
      create     = true
      scope      = "namespaced"
      namespaces = ["prod-db"]
    }
    secrets = {
      existingSecret = kubernetes_secret.dbg_collector.metadata[0].name
    }
    extraEnv = [{
      name = "APP_DB_PASSWORD"
      valueFrom = {
        secretKeyRef = {
          name = kubernetes_secret.dbg_collector.metadata[0].name
          key  = "APP_DB_PASSWORD"
        }
      }
    }]
    config = {
      inline = file("${path.module}/collector.toml")
    }
  })]
}
```

`rbac.create` is `false` by default, so a Terraform run that leaves it out installs a collector
that cannot read the Cluster it is pointed at.  `rbac.namespaces` is the list the RoleBindings are
written into; it is not inferred from `namespace`.

The password rule from the note above applies unchanged: with `existingSecret` the chart injects
`DBG_SERVER_SECRET` only, and every database password needs its own `extraEnv` entry naming the
key.

## Google Cloud

### Deploying from Terraform

Use this when every change to the project goes through an infrastructure repository.  The CLI does
three things you can do yourself: it mints a collector identity, writes three secrets to Secret
Manager, and applies the published Terraform module.  The module is the same one either way, so the
result is identical to a CLI install.

#### What the CLI does that you have to do too

1. Create a collector identity, as in [What you need first](#what-you-need-first).
2. Create three Secret Manager secrets, named after your deployment: `<name>-server-secret`,
   `<name>-db-password` and `<name>-instaclustr-api-key`.  You create them yourself.  The module
   grants the collector's service account read access to those exact names, and the instance
   fetches all three at boot.
3. Commit `collector.toml` to the repository, base64 on one line, with no secrets in it.  The config
   block under [Without the CLI](/docs/connect/google-cloud/#without-the-cli) is the same file.

All three secrets must exist, including the Instaclustr one you are not using.  The boot script
reads them in order and stops on the first failure, so a missing secret leaves an instance that
runs, logs nothing useful, and never connects.  Give the two you do not need a single space as
their value.

#### The module inputs

The module lives at `gs://dbgorilla-collector-templates/collector/gce/v1.5/` and Terraform reads
it directly.  Pin the version, so a review shows the change when the contract moves.

| Input | Value |
|---|---|
| `collector_config` | `collector.toml`, base64 on one line |
| `collector_image` | `dbgorillapublic.azurecr.io/dbg-collector:0.14.0`, or a digest |
| `network` | `projects/<project>/global/networks/<name>` |
| `subnetwork` | required on a custom-mode VPC, empty on an auto-mode one |
| `region` | the databases' region |
| `runtime_service_account` | the service account the module creates.  Its local part names every resource, including the three secrets above. |
| `cloud_sql_roles` | `true` for a Cloud SQL target: the Cloud SQL viewer, client and IAM-login roles. `false` for anything else. |
| `alloydb_roles` | `true` for an AlloyDB target: the AlloyDB viewer, client and IAM-login roles. `false` for anything else. |
| `login_instances` | Cloud SQL instance ids, comma-separated, that the IAM login is restricted to: the instance and its read replicas.  Empty grants login on every Cloud SQL instance in the project. |
| `stable_egress` | `true` puts the instance behind a Cloud NAT with a reserved address, for a database gated by IP allowlist |
| `nat_subnet_cidr` | an unused CIDR, required when `stable_egress` is true |

#### Terraform

```hcl
variable "dbg_server_secret" {
  type      = string
  sensitive = true
}

locals {
  name    = "dbgorilla-collector"
  project = "my-project"
  region  = "us-central1"
}

resource "google_secret_manager_secret" "collector" {
  for_each = toset([
    "${local.name}-server-secret",
    "${local.name}-db-password",
    "${local.name}-instaclustr-api-key",
  ])
  secret_id = each.value
  replication {
    auto {}
  }
}

resource "google_secret_manager_secret_version" "server_secret" {
  secret      = google_secret_manager_secret.collector["${local.name}-server-secret"].id
  secret_data = var.dbg_server_secret
}

# Present but unused: IAM authentication carries no password, and the boot
# script reads both of these regardless.
resource "google_secret_manager_secret_version" "db_password" {
  secret      = google_secret_manager_secret.collector["${local.name}-db-password"].id
  secret_data = " "
}

resource "google_secret_manager_secret_version" "instaclustr" {
  secret      = google_secret_manager_secret.collector["${local.name}-instaclustr-api-key"].id
  secret_data = " "
}

module "dbg_collector" {
  source = "gcs::https://www.googleapis.com/storage/v1/dbgorilla-collector-templates/collector/gce/v1.5"

  collector_config        = base64encode(file("${path.module}/collector.toml"))
  collector_image         = "dbgorillapublic.azurecr.io/dbg-collector:0.14.0"
  network                 = "projects/${local.project}/global/networks/prod"
  subnetwork              = "projects/${local.project}/regions/${local.region}/subnetworks/prod-${local.region}"
  region                  = local.region
  runtime_service_account = "${local.name}@${local.project}.iam.gserviceaccount.com"
}
```

**The module declares its own `google` provider, which makes it a legacy module: `depends_on`,
`count` and `for_each` on the module block are refused by `terraform validate`.**  So you cannot
order the secrets before it that way.  Create them first, either in their own root module or with
one `terraform apply -target=google_secret_manager_secret.collector` before the full apply.
Without that, the module's IAM bindings can be attempted before the secrets exist, and the apply
fails.  Re-running it succeeds: the install is not broken.

The instance has no public IP.  Image pulls and the connection to DBGorilla both need egress from
the VPC, through a Cloud NAT or an equivalent route, whether or not you set `stable_egress`.

#### Infrastructure Manager instead

The CLI applies this module through Infrastructure Manager rather than running Terraform itself.
To do the same, point a `google_infra_manager_deployment` at the same `gs://` directory; it takes
the same inputs.  In a repository that already runs Terraform, it adds nothing.

#### Upgrading and removing

Change `collector_image` and apply.  The instance group rolls to the new image.

Destroying removes the instance group, the service account and the networking the module owns.  The
three secrets stay, because the module never owned them, and so does the collector's identity in
DBGorilla.  Delete the collector in the app as well.  A new deploy with a new identity is a new
collector; the old one does not resume.