Skip to content

Infrastructure as code

Last updated

View as Markdown

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.

  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:

    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. 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.

    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.

    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.

ParameterValue
CollectorConfigcollector.toml, base64 on one line
ServerSecretthe secret from the app
DbPasswordempty with IAM auth
CollectorImagedbgorillapublic.azurecr.io/dbg-collector:0.14.0, or a digest
RdsConnectResourcesarn:aws:rds-db:<region>:<account>:dbuser:<DbiResourceId>/dbg_collector
Subnetssubnets that can reach the database
SecurityGroupIda group allowing egress to the database and to DBGorilla on 443
AssignPublicIpENABLED 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.

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.

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.

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.

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.

If the cluster is managed from an infrastructure repository, the helm install on Kubernetes becomes a helm_release. The chart, the values and the config file are the same; only the tool applying them changes.

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.

The chart is the same one the Kubernetes page deploys, so the helm_release there is the whole pattern. Two values differ here.

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.

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.

  1. Create a collector identity, as in 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 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 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.

InputValue
collector_configcollector.toml, base64 on one line
collector_imagedbgorillapublic.azurecr.io/dbg-collector:0.14.0, or a digest
networkprojects/<project>/global/networks/<name>
subnetworkrequired on a custom-mode VPC, empty on an auto-mode one
regionthe databases’ region
runtime_service_accountthe service account the module creates. Its local part names every resource, including the three secrets above.
cloud_sql_rolestrue for a Cloud SQL target: the Cloud SQL viewer, client and IAM-login roles. false for anything else.
alloydb_rolestrue for an AlloyDB target: the AlloyDB viewer, client and IAM-login roles. false for anything else.
login_instancesCloud 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_egresstrue puts the instance behind a Cloud NAT with a reserved address, for a database gated by IP allowlist
nat_subnet_cidran unused CIDR, required when stable_egress is true
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.

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.

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.