diff --git a/aws/keycloak/README.md b/aws/keycloak/README.md index f8a7159..9e8fdb7 100644 --- a/aws/keycloak/README.md +++ b/aws/keycloak/README.md @@ -1,55 +1,61 @@ # aws/keycloak -OpenTofu stack for the first AWS-hosted Keycloak instance in the `lab` -account. +OpenTofu stack for the AWS-hosted Keycloak instance in the `lab` account. This stack creates: -- a dedicated `t4g.small` Amazon Linux 2023 EC2 instance +- a dedicated `t4g.small` Flatcar Container Linux EC2 instance +- a dedicated encrypted gp3 data volume mounted at `/var/lib/keycloak` - an IAM role and instance profile for SSM management - a security group that exposes HTTPS only to lab CIDRs - a private Route 53 `A` record for `id.glab.lol` - scoped Route 53 permissions for ACME DNS-01 validation in `acme.glab.lol` -- a GitHub token broker Lambda, sourced from - `meigma/github-token-broker`, for short-lived `GilmanLab/secrets` access +- a GitHub token broker Lambda, sourced from `meigma/github-token-broker`, for short-lived `GilmanLab/secrets` access - Keycloak instance-role permission to invoke the token broker -- an SSM-driven Docker Compose deployment for Postgres, Keycloak, and Traefik +- Keycloak instance-role permission to decrypt only SOPS secrets with `Repo=GilmanLab/secrets` and `Scope=keycloak` +- Ignition-managed systemd units for Postgres, Keycloak, Traefik, and bootstrap secret fetches -This stack intentionally stops at starting Keycloak. It does **not** configure -realms, GitHub OIDC, clients, backups, Synology sync, Kubernetes OIDC, Argo CD, -Grafana, IAM Identity Center federation, or `keycloak-config-cli`. +This stack intentionally stops at starting Keycloak. It does **not** configure realms, GitHub OIDC, clients, backups, Synology sync, Kubernetes OIDC, Argo CD, Grafana, IAM Identity Center federation, or `keycloak-config-cli`. -Traefik obtains the `id.glab.lol` certificate from Let's Encrypt through -DNS-01. Cloudflare delegates `_acme-challenge.id.glab.lol` to the public -Route 53 `acme.glab.lol` zone, and the Keycloak instance role may mutate only -the delegated TXT record for this hostname. +Traefik obtains the `id.glab.lol` certificate from Let's Encrypt through DNS-01. Cloudflare delegates `_acme-challenge.id.glab.lol` to the public Route 53 `acme.glab.lol` zone, and the Keycloak instance role may mutate only the delegated TXT record for this hostname. + +## Bootstrap model + +Flatcar receives raw Ignition JSON through EC2 user data. Ignition writes non-secret runtime config and helper scripts under `/etc/glab/keycloak`, then enables the systemd units. + +`glab-keycloak-bootstrap.service` runs the pinned `labctl` container: + +```sh +secrets get services/keycloak/bootstrap.sops.yaml \ + --source github \ + --field /stack_env \ + --output /run/glab/keycloak/stack.env \ + --aws-region us-west-2 \ + --broker-function glab-github-token-broker +``` + +Plaintext bootstrap material is written only under `/run/glab/keycloak`. The persistent data volume stores Postgres state in `/var/lib/keycloak/postgres` and ACME state in `/var/lib/keycloak/acme`; the Postgres directory is owned by the container's Postgres UID. ## Prerequisites - OpenTofu `>= 1.10` - `just` - `AWS_PROFILE` set to the `lab` account admin profile -- `GLAB_AWS_STATE_BUCKET` set to the pre-created S3 backend bucket in the - `lab` account +- `GLAB_AWS_STATE_BUCKET` set to the pre-created S3 backend bucket in the `lab` account - the `aws/lab-foundation` and `aws/subnet-router` stacks already applied - the GitHub App SSM parameters created outside Terraform: `/glab/bootstrap/github-app/client-id`, `/glab/bootstrap/github-app/installation-id`, and `/glab/bootstrap/github-app/private-key-pem` -- `gh` and `sha256sum` on the apply host so the broker module can download and - verify the pinned release asset -- Cloudflare delegates `acme.glab.lol` to the `aws/lab-foundation` - `acme_zone_name_servers` output and sets - `_acme-challenge.id.glab.lol` as a CNAME to - `_acme-challenge.id.acme.glab.lol` +- `gh` and `sha256sum` on the apply host so the broker module can download and verify the pinned release asset +- Cloudflare delegates `acme.glab.lol` to the `aws/lab-foundation` `acme_zone_name_servers` output and sets `_acme-challenge.id.glab.lol` as a CNAME to `_acme-challenge.id.acme.glab.lol` +- `secrets/services/keycloak/bootstrap.sops.yaml` exists in `GilmanLab/secrets` with SOPS KMS context `Repo=GilmanLab/secrets` and `Scope=keycloak` -The expected local operator flow is to export both values via `direnv`. +The expected local operator flow is to export both AWS values via `direnv`. ## Legacy token broker cleanup -The old `aws/github-token-broker` stack used the same default Lambda name, -`glab-github-token-broker`, and was originally published from -`GilmanLab/platform`. Do not apply both stacks at the same time. +The old `aws/github-token-broker` stack used the same default Lambda name, `glab-github-token-broker`, and was originally published from `GilmanLab/platform`. Do not apply both stacks at the same time. If the old Lambda is still active, retire it before applying this stack: @@ -69,9 +75,7 @@ tofu plan -destroy \ tofu apply destroy-broker-only.tfplan ``` -Then apply `aws/keycloak`. This removes the legacy Lambda resources while -leaving the GitHub Actions OIDC provider alone; only run a full destroy after -confirming that provider has no shared use. +Then apply `aws/keycloak`. This removes the legacy Lambda resources while leaving the GitHub Actions OIDC provider alone; only run a full destroy after confirming that provider has no shared use. ## Usage @@ -82,15 +86,19 @@ just plan just apply ``` -`just init` uses `GLAB_AWS_STATE_BUCKET` to finish the otherwise-partial S3 -backend configuration. The backend bucket itself is part of the manual AWS -bootstrap and is intentionally not managed by this stack. +`just init` uses `GLAB_AWS_STATE_BUCKET` to finish the otherwise-partial S3 backend configuration. The backend bucket itself is part of the manual AWS bootstrap and is intentionally not managed by this stack. -After apply, use SSM to inspect the host: +After apply, use SSM to inspect the host without printing secret values: ```sh aws ssm send-command \ --document-name AWS-RunShellScript \ --targets Key=instanceids,Values="$(tofu output -raw instance_id)" \ - --parameters commands='["docker compose --env-file /opt/keycloak/stack.env -f /opt/keycloak/compose.yml ps","curl -fsS http://127.0.0.1:9000/health/ready"]' + --parameters commands='[ + "systemctl is-active glab-keycloak-bootstrap.service glab-keycloak-postgres.service glab-keycloak.service glab-keycloak-traefik.service", + "findmnt /var/lib/keycloak", + "stat -c %a\\ %s\\ %n /run/glab/keycloak/stack.env", + "cut -d= -f1 /run/glab/keycloak/stack.env | sort", + "curl -fsS http://127.0.0.1:9000/health/ready" + ]' ``` diff --git a/aws/keycloak/compute.tf b/aws/keycloak/compute.tf index e55072b..1add7f4 100644 --- a/aws/keycloak/compute.tf +++ b/aws/keycloak/compute.tf @@ -1,9 +1,10 @@ resource "aws_instance" "keycloak" { - ami = data.aws_ssm_parameter.al2023_arm64.value + ami = var.flatcar_ami_id associate_public_ip_address = true iam_instance_profile = aws_iam_instance_profile.keycloak.name instance_type = var.instance_type subnet_id = data.aws_subnet.public.id + user_data = local.ignition_config user_data_replace_on_change = true vpc_security_group_ids = [aws_security_group.keycloak.id] @@ -24,3 +25,20 @@ resource "aws_instance" "keycloak" { Name = var.instance_name }) } + +resource "aws_ebs_volume" "keycloak_data" { + availability_zone = data.aws_subnet.public.availability_zone + encrypted = true + size = var.data_volume_size + type = "gp3" + + tags = merge(local.common_tags, { + Name = "${var.instance_name}-data" + }) +} + +resource "aws_volume_attachment" "keycloak_data" { + device_name = var.data_volume_device_name + instance_id = aws_instance.keycloak.id + volume_id = aws_ebs_volume.keycloak_data.id +} diff --git a/aws/keycloak/data.tf b/aws/keycloak/data.tf index 618f778..97a8355 100644 --- a/aws/keycloak/data.tf +++ b/aws/keycloak/data.tf @@ -1,5 +1,3 @@ -data "aws_caller_identity" "current" {} - data "aws_iam_policy_document" "keycloak_assume_role" { statement { actions = ["sts:AssumeRole"] @@ -11,16 +9,31 @@ data "aws_iam_policy_document" "keycloak_assume_role" { } } -data "aws_iam_policy_document" "keycloak_bootstrap_parameters" { +data "aws_iam_policy_document" "keycloak_sops_decrypt" { statement { - sid = "AllowReadWriteKeycloakBootstrapParameters" + sid = "AllowDecryptKeycloakSopsSecrets" actions = [ - "ssm:GetParameter", - "ssm:PutParameter", + "kms:Decrypt", ] resources = [ - local.ssm_parameter_path_arn, + var.sops_kms_key_arn, ] + + condition { + test = "StringEquals" + variable = "kms:EncryptionContext:Repo" + values = [ + var.sops_kms_context_repo, + ] + } + + condition { + test = "StringEquals" + variable = "kms:EncryptionContext:Scope" + values = [ + var.sops_kms_context_scope, + ] + } } } @@ -102,10 +115,6 @@ data "aws_route_table" "public" { } } -data "aws_ssm_parameter" "al2023_arm64" { - name = var.ami_ssm_parameter_name -} - data "aws_instance" "subnet_router" { filter { name = "tag:Name" diff --git a/aws/keycloak/deployment.tf b/aws/keycloak/deployment.tf deleted file mode 100644 index 17555d7..0000000 --- a/aws/keycloak/deployment.tf +++ /dev/null @@ -1,22 +0,0 @@ -resource "aws_ssm_association" "keycloak" { - name = "AWS-RunShellScript" - - parameters = { - commands = local.bootstrap_script - } - - targets { - key = "InstanceIds" - values = [ - aws_instance.keycloak.id, - ] - } - - wait_for_success_timeout_seconds = 1200 - - depends_on = [ - aws_iam_role_policy.keycloak_acme_route53, - aws_iam_role_policy.keycloak_bootstrap_parameters, - aws_iam_role_policy_attachment.keycloak_ssm_managed_instance_core, - ] -} diff --git a/aws/keycloak/iam.tf b/aws/keycloak/iam.tf index 3b5fdd1..db88540 100644 --- a/aws/keycloak/iam.tf +++ b/aws/keycloak/iam.tf @@ -7,18 +7,18 @@ resource "aws_iam_role" "keycloak" { }) } -resource "aws_iam_role_policy" "keycloak_bootstrap_parameters" { - name = "${var.iam_role_name}-bootstrap-parameters" - policy = data.aws_iam_policy_document.keycloak_bootstrap_parameters.json - role = aws_iam_role.keycloak.id -} - resource "aws_iam_role_policy" "keycloak_acme_route53" { name = "${var.iam_role_name}-acme-route53" policy = data.aws_iam_policy_document.keycloak_acme_route53.json role = aws_iam_role.keycloak.id } +resource "aws_iam_role_policy" "keycloak_sops_decrypt" { + name = "${var.iam_role_name}-sops-keycloak-decrypt" + policy = data.aws_iam_policy_document.keycloak_sops_decrypt.json + role = aws_iam_role.keycloak.id +} + resource "aws_iam_role_policy_attachment" "keycloak_ssm_managed_instance_core" { policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" role = aws_iam_role.keycloak.name diff --git a/aws/keycloak/locals.tf b/aws/keycloak/locals.tf index 2c73c1a..820401d 100644 --- a/aws/keycloak/locals.tf +++ b/aws/keycloak/locals.tf @@ -5,62 +5,185 @@ locals { "glab:purpose" = "keycloak" }) - ssm_parameter_names = { - keycloak_admin_password = "${var.ssm_parameter_prefix}/keycloak-admin-password" - postgres_password = "${var.ssm_parameter_prefix}/postgres-password" - } - private_hostname_relative = trimsuffix(var.private_hostname, ".${var.private_zone_name}") acme_challenge_record_name = "_acme-challenge.${local.private_hostname_relative}.${var.acme_zone_name}" traefik_certificate_resolver = "letsencrypt" traefik_acme_storage_file = "/etc/traefik/acme/acme.json" - traefik_acme_storage_host_dir = "${var.runtime_dir}/acme" + traefik_acme_storage_host_dir = "${var.data_dir}/acme" + postgres_state_dir = "${var.data_dir}/postgres" + keycloak_env_path = "${var.bootstrap_runtime_dir}/keycloak.env" + helper_script_dir = "${var.runtime_dir}/bin" + data_volume_device_path = "/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${replace(aws_ebs_volume.keycloak_data.id, "-", "")}" - bootstrap_script = templatefile("${path.module}/templates/bootstrap.sh.tftpl", { - acme_storage_host_dir = local.traefik_acme_storage_host_dir - aws_region = var.aws_region - compose_version = var.compose_version - database_name = var.database_name - database_username = var.database_username - keycloak_admin_password_parameter = local.ssm_parameter_names.keycloak_admin_password - keycloak_admin_username = var.keycloak_admin_username - keycloak_heap_max = var.keycloak_heap_max - keycloak_heap_min = var.keycloak_heap_min - keycloak_runtime_dir = var.runtime_dir - postgres_password_parameter = local.ssm_parameter_names.postgres_password - postgres_state_dir = var.postgres_state_dir - service_unit = local.service_unit - stack_env = local.stack_env - traefik_dynamic_config = local.traefik_dynamic_config - compose = local.compose - }) - compose = templatefile("${path.module}/templates/compose.yml.tftpl", { - acme_ca_server = var.acme_ca_server - acme_email = var.acme_email - acme_zone_id = data.aws_route53_zone.acme.zone_id - aws_region = var.aws_region - keycloak_image = var.keycloak_image - postgres_image = var.postgres_image - traefik_acme_storage_file = local.traefik_acme_storage_file - traefik_certificate_resolver = local.traefik_certificate_resolver - traefik_dns_challenge_resolvers = var.traefik_dns_challenge_resolvers - traefik_image = var.traefik_image - }) - service_unit = templatefile("${path.module}/templates/keycloak.service.tftpl", { - keycloak_runtime_dir = var.runtime_dir - }) - stack_env = templatefile("${path.module}/templates/stack.env.tftpl", { - database_name = var.database_name - database_username = var.database_username - keycloak_admin_username = var.keycloak_admin_username - keycloak_heap_max = var.keycloak_heap_max - keycloak_heap_min = var.keycloak_heap_min - private_hostname = var.private_hostname - postgres_state_dir = var.postgres_state_dir - }) traefik_dynamic_config = templatefile("${path.module}/templates/traefik_dynamic.yml.tftpl", { private_hostname = var.private_hostname traefik_certificate_resolver = local.traefik_certificate_resolver }) - ssm_parameter_path_arn = "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_parameter_prefix}/*" + + bootstrap_unit_name = "glab-keycloak-bootstrap.service" + data_unit_name = "glab-keycloak-data.service" + network_unit_name = "glab-keycloak-network.service" + postgres_unit_name = "glab-keycloak-postgres.service" + keycloak_unit_name = "glab-keycloak.service" + traefik_unit_name = "glab-keycloak-traefik.service" + + runtime_template_vars = { + acme_ca_server = var.acme_ca_server + acme_email = var.acme_email + aws_region = var.aws_region + bootstrap_field = var.bootstrap_field + bootstrap_output_path = var.bootstrap_output_path + bootstrap_runtime_dir = var.bootstrap_runtime_dir + bootstrap_secret_path = var.bootstrap_secret_path + bootstrap_unit_name = local.bootstrap_unit_name + data_dir = var.data_dir + data_unit_name = local.data_unit_name + data_volume_device_path = local.data_volume_device_path + data_volume_label = var.data_volume_label + github_token_broker_function_name = var.github_token_broker_function_name + helper_script_dir = local.helper_script_dir + keycloak_env_path = local.keycloak_env_path + keycloak_image = var.keycloak_image + keycloak_unit_name = local.keycloak_unit_name + labctl_image = var.labctl_image + network_unit_name = local.network_unit_name + postgres_image = var.postgres_image + postgres_state_dir = local.postgres_state_dir + postgres_unit_name = local.postgres_unit_name + runtime_dir = var.runtime_dir + traefik_acme_storage_file = local.traefik_acme_storage_file + traefik_acme_storage_host_dir = local.traefik_acme_storage_host_dir + traefik_certificate_resolver = local.traefik_certificate_resolver + traefik_dns_challenge_resolvers = var.traefik_dns_challenge_resolvers + traefik_image = var.traefik_image + traefik_unit_name = local.traefik_unit_name + traefik_zone_id = data.aws_route53_zone.acme.zone_id + } + + prepare_data_script = templatefile("${path.module}/templates/scripts/prepare-data.sh.tftpl", local.runtime_template_vars) + create_network_script = templatefile("${path.module}/templates/scripts/create-network.sh.tftpl", local.runtime_template_vars) + run_postgres_script = templatefile("${path.module}/templates/scripts/run-postgres.sh.tftpl", local.runtime_template_vars) + wait_postgres_script = templatefile("${path.module}/templates/scripts/wait-postgres.sh.tftpl", local.runtime_template_vars) + run_keycloak_script = templatefile("${path.module}/templates/scripts/run-keycloak.sh.tftpl", local.runtime_template_vars) + run_traefik_script = templatefile("${path.module}/templates/scripts/run-traefik.sh.tftpl", local.runtime_template_vars) + + data_unit = templatefile("${path.module}/templates/systemd/${local.data_unit_name}.tftpl", local.runtime_template_vars) + network_unit = templatefile("${path.module}/templates/systemd/${local.network_unit_name}.tftpl", local.runtime_template_vars) + bootstrap_unit = templatefile("${path.module}/templates/systemd/${local.bootstrap_unit_name}.tftpl", local.runtime_template_vars) + postgres_unit = templatefile("${path.module}/templates/systemd/${local.postgres_unit_name}.tftpl", local.runtime_template_vars) + keycloak_unit = templatefile("${path.module}/templates/systemd/${local.keycloak_unit_name}.tftpl", local.runtime_template_vars) + traefik_unit = templatefile("${path.module}/templates/systemd/${local.traefik_unit_name}.tftpl", local.runtime_template_vars) + + ignition_files = [ + { + path = "${var.runtime_dir}/traefik_dynamic.yml" + mode = 420 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.traefik_dynamic_config)}" + } + }, + { + path = "${local.helper_script_dir}/prepare-data.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.prepare_data_script)}" + } + }, + { + path = "${local.helper_script_dir}/create-network.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.create_network_script)}" + } + }, + { + path = "${local.helper_script_dir}/run-postgres.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.run_postgres_script)}" + } + }, + { + path = "${local.helper_script_dir}/wait-postgres.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.wait_postgres_script)}" + } + }, + { + path = "${local.helper_script_dir}/run-keycloak.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.run_keycloak_script)}" + } + }, + { + path = "${local.helper_script_dir}/run-traefik.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.run_traefik_script)}" + } + }, + ] + + ignition_config = jsonencode({ + ignition = { + version = "3.3.0" + } + storage = { + directories = [ + { + path = "/etc/glab" + mode = 493 + }, + { + path = var.runtime_dir + mode = 493 + }, + { + path = local.helper_script_dir + mode = 493 + }, + ] + files = local.ignition_files + } + systemd = { + units = [ + { + enabled = true + name = "amazon-ssm-agent.service" + }, + { + contents = local.data_unit + enabled = true + name = local.data_unit_name + }, + { + contents = local.network_unit + enabled = true + name = local.network_unit_name + }, + { + contents = local.bootstrap_unit + enabled = true + name = local.bootstrap_unit_name + }, + { + contents = local.postgres_unit + enabled = true + name = local.postgres_unit_name + }, + { + contents = local.keycloak_unit + enabled = true + name = local.keycloak_unit_name + }, + { + contents = local.traefik_unit + enabled = true + name = local.traefik_unit_name + }, + ] + } + }) } diff --git a/aws/keycloak/outputs.tf b/aws/keycloak/outputs.tf index 7ac2580..da6098c 100644 --- a/aws/keycloak/outputs.tf +++ b/aws/keycloak/outputs.tf @@ -48,6 +48,27 @@ output "security_group_id" { value = aws_security_group.keycloak.id } +output "data_volume_id" { + description = "Encrypted EBS volume ID mounted at /var/lib/keycloak." + value = aws_ebs_volume.keycloak_data.id +} + +output "bootstrap_unit_name" { + description = "Systemd unit that fetches Keycloak bootstrap secrets." + value = local.bootstrap_unit_name +} + +output "service_unit_names" { + description = "Systemd units that run the Flatcar Keycloak stack." + value = { + data = local.data_unit_name + network = local.network_unit_name + postgres = local.postgres_unit_name + keycloak = local.keycloak_unit_name + traefik = local.traefik_unit_name + } +} + output "acme_challenge_record_name" { description = "Route 53 TXT record name Traefik may mutate for Keycloak ACME DNS-01 validation." value = local.acme_challenge_record_name @@ -57,8 +78,3 @@ output "acme_zone_id" { description = "Public Route 53 hosted zone ID used for Keycloak ACME DNS-01 validation." value = data.aws_route53_zone.acme.zone_id } - -output "ssm_parameter_names" { - description = "SSM Parameter Store names where the host stores generated bootstrap credentials." - value = local.ssm_parameter_names -} diff --git a/aws/keycloak/templates/bootstrap.sh.tftpl b/aws/keycloak/templates/bootstrap.sh.tftpl deleted file mode 100644 index 1f68078..0000000 --- a/aws/keycloak/templates/bootstrap.sh.tftpl +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -set -euo pipefail - -AWS_REGION='${aws_region}' -ACME_STORAGE_HOST_DIR='${acme_storage_host_dir}' -KEYCLOAK_RUNTIME_DIR='${keycloak_runtime_dir}' -POSTGRES_STATE_DIR='${postgres_state_dir}' -POSTGRES_PASSWORD_PARAMETER='${postgres_password_parameter}' -KEYCLOAK_ADMIN_PASSWORD_PARAMETER='${keycloak_admin_password_parameter}' - -dnf install -y docker openssl -if ! command -v aws >/dev/null 2>&1; then - dnf install -y awscli || dnf install -y awscli2 -fi -systemctl enable --now docker - -compose_arch="$(uname -m)" -case "$${compose_arch}" in - x86_64) compose_arch="x86_64" ;; - aarch64) compose_arch="aarch64" ;; - *) - echo "unsupported architecture: $${compose_arch}" >&2 - exit 1 - ;; -esac - -install -d -m 0755 /usr/local/lib/docker/cli-plugins -curl -fsSL "https://github.com/docker/compose/releases/download/${compose_version}/docker-compose-linux-$${compose_arch}" \ - -o /usr/local/lib/docker/cli-plugins/docker-compose -chmod 0755 /usr/local/lib/docker/cli-plugins/docker-compose - -docker compose version >/dev/null 2>&1 - -ensure_secure_parameter() { - local name="$1" - local value - - if value="$(aws ssm get-parameter \ - --region "$${AWS_REGION}" \ - --name "$${name}" \ - --with-decryption \ - --query 'Parameter.Value' \ - --output text 2>/dev/null)"; then - printf '%s' "$${value}" - return 0 - fi - - value="$(openssl rand -base64 48 | tr -d '\n')" - if ! aws ssm put-parameter \ - --region "$${AWS_REGION}" \ - --name "$${name}" \ - --type SecureString \ - --value "$${value}" >/dev/null; then - value="$(aws ssm get-parameter \ - --region "$${AWS_REGION}" \ - --name "$${name}" \ - --with-decryption \ - --query 'Parameter.Value' \ - --output text)" - fi - - printf '%s' "$${value}" -} - -postgres_password="$(ensure_secure_parameter "$${POSTGRES_PASSWORD_PARAMETER}")" -keycloak_admin_password="$(ensure_secure_parameter "$${KEYCLOAK_ADMIN_PASSWORD_PARAMETER}")" - -install -d -m 0755 "$${KEYCLOAK_RUNTIME_DIR}" -install -d -m 0700 "$${ACME_STORAGE_HOST_DIR}" -install -d -m 0755 "$${POSTGRES_STATE_DIR}" -touch "$${ACME_STORAGE_HOST_DIR}/acme.json" -chmod 0600 "$${ACME_STORAGE_HOST_DIR}/acme.json" - -cat >"$${KEYCLOAK_RUNTIME_DIR}/compose.yml" <<'EOF_COMPOSE' -${compose} -EOF_COMPOSE -chmod 0644 "$${KEYCLOAK_RUNTIME_DIR}/compose.yml" - -cat >"$${KEYCLOAK_RUNTIME_DIR}/traefik_dynamic.yml" <<'EOF_TRAEFIK' -${traefik_dynamic_config} -EOF_TRAEFIK -chmod 0644 "$${KEYCLOAK_RUNTIME_DIR}/traefik_dynamic.yml" - -cat >"$${KEYCLOAK_RUNTIME_DIR}/stack.env" </etc/systemd/system/keycloak.service <<'EOF_SERVICE' -${service_unit} -EOF_SERVICE - -systemctl daemon-reload -systemctl enable keycloak.service -systemctl restart keycloak.service diff --git a/aws/keycloak/templates/compose.yml.tftpl b/aws/keycloak/templates/compose.yml.tftpl deleted file mode 100644 index 38e8a92..0000000 --- a/aws/keycloak/templates/compose.yml.tftpl +++ /dev/null @@ -1,68 +0,0 @@ -services: - postgres: - image: ${postgres_image} - restart: unless-stopped - env_file: - - stack.env - environment: - POSTGRES_DB: $${POSTGRES_DB} - POSTGRES_USER: $${POSTGRES_USER} - POSTGRES_PASSWORD: $${POSTGRES_PASSWORD} - PGDATA: /var/lib/postgresql/data - volumes: - - $${POSTGRES_STATE_DIR}:/var/lib/postgresql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 12 - - keycloak: - image: ${keycloak_image} - restart: unless-stopped - command: - - start - env_file: - - stack.env - environment: - KC_DB: postgres - KC_DB_URL: jdbc:postgresql://postgres:5432/$${POSTGRES_DB} - KC_DB_USERNAME: $${POSTGRES_USER} - KC_DB_PASSWORD: $${POSTGRES_PASSWORD} - KC_BOOTSTRAP_ADMIN_USERNAME: $${KC_BOOTSTRAP_ADMIN_USERNAME} - KC_BOOTSTRAP_ADMIN_PASSWORD: $${KC_BOOTSTRAP_ADMIN_PASSWORD} - KC_HEALTH_ENABLED: "true" - KC_HOSTNAME: https://$${KC_HOSTNAME} - KC_HTTP_ENABLED: "true" - KC_PROXY_HEADERS: xforwarded - JAVA_OPTS_KC_HEAP: -Xms$${KC_HEAP_MIN} -Xmx$${KC_HEAP_MAX} - ports: - - 127.0.0.1:9000:9000 - depends_on: - postgres: - condition: service_healthy - - traefik: - image: ${traefik_image} - restart: unless-stopped - command: - - --entrypoints.websecure.address=:443 - - --certificatesresolvers.${traefik_certificate_resolver}.acme.email=${acme_email} - - --certificatesresolvers.${traefik_certificate_resolver}.acme.storage=${traefik_acme_storage_file} - - --certificatesresolvers.${traefik_certificate_resolver}.acme.caserver=${acme_ca_server} - - --certificatesresolvers.${traefik_certificate_resolver}.acme.dnschallenge.provider=route53 - - --certificatesresolvers.${traefik_certificate_resolver}.acme.dnschallenge.resolvers=${traefik_dns_challenge_resolvers} - - --providers.file.filename=/etc/traefik/dynamic.yml - - --providers.file.watch=true - - --api.dashboard=false - - --log.level=INFO - environment: - AWS_REGION: ${aws_region} - AWS_HOSTED_ZONE_ID: ${acme_zone_id} - ports: - - 443:443 - volumes: - - ./acme:/etc/traefik/acme - - ./traefik_dynamic.yml:/etc/traefik/dynamic.yml:ro - depends_on: - - keycloak diff --git a/aws/keycloak/templates/keycloak.service.tftpl b/aws/keycloak/templates/keycloak.service.tftpl deleted file mode 100644 index f6a73af..0000000 --- a/aws/keycloak/templates/keycloak.service.tftpl +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Keycloak compose stack -Requires=docker.service -After=docker.service network-online.target - -[Service] -Type=oneshot -RemainAfterExit=yes -WorkingDirectory=${keycloak_runtime_dir} -ExecStart=/usr/bin/docker compose --env-file ${keycloak_runtime_dir}/stack.env -f ${keycloak_runtime_dir}/compose.yml up -d --pull always -ExecStop=/usr/bin/docker compose --env-file ${keycloak_runtime_dir}/stack.env -f ${keycloak_runtime_dir}/compose.yml down -TimeoutStartSec=0 - -[Install] -WantedBy=multi-user.target diff --git a/aws/keycloak/templates/scripts/create-network.sh.tftpl b/aws/keycloak/templates/scripts/create-network.sh.tftpl new file mode 100644 index 0000000..0cfdb0c --- /dev/null +++ b/aws/keycloak/templates/scripts/create-network.sh.tftpl @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +if ! /usr/bin/docker network inspect keycloak >/dev/null 2>&1; then + /usr/bin/docker network create keycloak >/dev/null +fi diff --git a/aws/keycloak/templates/scripts/prepare-data.sh.tftpl b/aws/keycloak/templates/scripts/prepare-data.sh.tftpl new file mode 100644 index 0000000..3a6e21c --- /dev/null +++ b/aws/keycloak/templates/scripts/prepare-data.sh.tftpl @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +dev='${data_volume_device_path}' +i=0 +while [ ! -e "$dev" ] && [ "$i" -lt 90 ]; do + i=$((i + 1)) + sleep 2 +done + +if [ ! -e "$dev" ]; then + echo "data volume $dev did not appear" >&2 + exit 1 +fi + +if ! blkid "$dev" >/dev/null 2>&1; then + mkfs.ext4 -F -L '${data_volume_label}' "$dev" +fi + +mkdir -p '${data_dir}' +if ! findmnt -rn '${data_dir}' >/dev/null 2>&1; then + mount "$dev" '${data_dir}' +fi + +mkdir -p '${postgres_state_dir}' '${traefik_acme_storage_host_dir}' +chown 999:999 '${postgres_state_dir}' +chmod 0750 '${data_dir}' +chmod 0700 '${postgres_state_dir}' +chmod 0700 '${traefik_acme_storage_host_dir}' +touch '${traefik_acme_storage_host_dir}/acme.json' +chmod 0600 '${traefik_acme_storage_host_dir}/acme.json' diff --git a/aws/keycloak/templates/scripts/run-keycloak.sh.tftpl b/aws/keycloak/templates/scripts/run-keycloak.sh.tftpl new file mode 100644 index 0000000..8fd7357 --- /dev/null +++ b/aws/keycloak/templates/scripts/run-keycloak.sh.tftpl @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +. '${bootstrap_output_path}' +umask 077 +{ + printf 'POSTGRES_DB=%s\n' "$POSTGRES_DB" + printf 'POSTGRES_USER=%s\n' "$POSTGRES_USER" + printf 'POSTGRES_PASSWORD=%s\n' "$POSTGRES_PASSWORD" + printf 'KC_BOOTSTRAP_ADMIN_USERNAME=%s\n' "$KC_BOOTSTRAP_ADMIN_USERNAME" + printf 'KC_BOOTSTRAP_ADMIN_PASSWORD=%s\n' "$KC_BOOTSTRAP_ADMIN_PASSWORD" + printf 'KC_HEAP_MIN=%s\n' "$KC_HEAP_MIN" + printf 'KC_HEAP_MAX=%s\n' "$KC_HEAP_MAX" + printf 'KC_DB=postgres\n' + printf 'KC_DB_URL=jdbc:postgresql://postgres:5432/%s\n' "$POSTGRES_DB" + printf 'KC_DB_USERNAME=%s\n' "$POSTGRES_USER" + printf 'KC_DB_PASSWORD=%s\n' "$POSTGRES_PASSWORD" + printf 'KC_HEALTH_ENABLED=true\n' + printf 'KC_HOSTNAME=https://%s\n' "$KC_HOSTNAME" + printf 'KC_HTTP_ENABLED=true\n' + printf 'KC_PROXY_HEADERS=xforwarded\n' + printf 'JAVA_OPTS_KC_HEAP=-Xms%s -Xmx%s\n' "$KC_HEAP_MIN" "$KC_HEAP_MAX" +} >'${keycloak_env_path}' +chmod 0600 '${keycloak_env_path}' + +/usr/bin/docker rm -f keycloak >/dev/null 2>&1 || true +exec /usr/bin/docker run \ + --name keycloak \ + --network keycloak \ + --network-alias keycloak \ + --env-file '${keycloak_env_path}' \ + --publish 127.0.0.1:9000:9000 \ + --pull always \ + '${keycloak_image}' \ + start diff --git a/aws/keycloak/templates/scripts/run-postgres.sh.tftpl b/aws/keycloak/templates/scripts/run-postgres.sh.tftpl new file mode 100644 index 0000000..6836eff --- /dev/null +++ b/aws/keycloak/templates/scripts/run-postgres.sh.tftpl @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +/usr/bin/docker rm -f keycloak-postgres >/dev/null 2>&1 || true +exec /usr/bin/docker run \ + --name keycloak-postgres \ + --network keycloak \ + --network-alias postgres \ + --env-file '${bootstrap_output_path}' \ + --env PGDATA=/var/lib/postgresql/data \ + --volume '${postgres_state_dir}:/var/lib/postgresql' \ + --health-cmd 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + --health-interval 10s \ + --health-timeout 5s \ + --health-retries 12 \ + --pull always \ + '${postgres_image}' diff --git a/aws/keycloak/templates/scripts/run-traefik.sh.tftpl b/aws/keycloak/templates/scripts/run-traefik.sh.tftpl new file mode 100644 index 0000000..19cb04c --- /dev/null +++ b/aws/keycloak/templates/scripts/run-traefik.sh.tftpl @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +/usr/bin/docker rm -f keycloak-traefik >/dev/null 2>&1 || true +exec /usr/bin/docker run \ + --name keycloak-traefik \ + --network keycloak \ + --env AWS_REGION='${aws_region}' \ + --env AWS_HOSTED_ZONE_ID='${traefik_zone_id}' \ + --publish 443:443 \ + --volume '${traefik_acme_storage_host_dir}:/etc/traefik/acme' \ + --volume '${runtime_dir}/traefik_dynamic.yml:/etc/traefik/dynamic.yml:ro' \ + --pull always \ + '${traefik_image}' \ + --entrypoints.websecure.address=:443 \ + --certificatesresolvers.${traefik_certificate_resolver}.acme.email='${acme_email}' \ + --certificatesresolvers.${traefik_certificate_resolver}.acme.storage='${traefik_acme_storage_file}' \ + --certificatesresolvers.${traefik_certificate_resolver}.acme.caserver='${acme_ca_server}' \ + --certificatesresolvers.${traefik_certificate_resolver}.acme.dnschallenge.provider=route53 \ + --certificatesresolvers.${traefik_certificate_resolver}.acme.dnschallenge.resolvers='${traefik_dns_challenge_resolvers}' \ + --providers.file.filename=/etc/traefik/dynamic.yml \ + --providers.file.watch=true \ + --api.dashboard=false \ + --log.level=INFO diff --git a/aws/keycloak/templates/scripts/wait-postgres.sh.tftpl b/aws/keycloak/templates/scripts/wait-postgres.sh.tftpl new file mode 100644 index 0000000..dbbb6e9 --- /dev/null +++ b/aws/keycloak/templates/scripts/wait-postgres.sh.tftpl @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +i=0 +while [ "$i" -lt 90 ]; do + if /usr/bin/docker exec keycloak-postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then + exit 0 + fi + i=$((i + 1)) + sleep 2 +done + +echo "postgres did not become ready" >&2 +exit 1 diff --git a/aws/keycloak/templates/stack.env.tftpl b/aws/keycloak/templates/stack.env.tftpl deleted file mode 100644 index 95031bb..0000000 --- a/aws/keycloak/templates/stack.env.tftpl +++ /dev/null @@ -1,7 +0,0 @@ -POSTGRES_DB=${database_name} -POSTGRES_USER=${database_username} -POSTGRES_STATE_DIR=${postgres_state_dir} -KC_BOOTSTRAP_ADMIN_USERNAME=${keycloak_admin_username} -KC_HEAP_MIN=${keycloak_heap_min} -KC_HEAP_MAX=${keycloak_heap_max} -KC_HOSTNAME=${private_hostname} diff --git a/aws/keycloak/templates/systemd/glab-keycloak-bootstrap.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-bootstrap.service.tftpl new file mode 100644 index 0000000..ceb1d3c --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak-bootstrap.service.tftpl @@ -0,0 +1,17 @@ +[Unit] +Description=Fetch Keycloak bootstrap secrets with labctl +Wants=network-online.target +Requires=docker.service ${data_unit_name} +After=network-online.target docker.service ${data_unit_name} +Before=${postgres_unit_name} ${keycloak_unit_name} ${traefik_unit_name} + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStartPre=/usr/bin/mkdir -p ${bootstrap_runtime_dir} +ExecStartPre=/usr/bin/chmod 0700 ${bootstrap_runtime_dir} +ExecStartPre=/usr/bin/rm -f ${bootstrap_output_path} ${keycloak_env_path} +ExecStart=/usr/bin/docker run --rm --network host --user 0:0 -v ${bootstrap_runtime_dir}:${bootstrap_runtime_dir} ${labctl_image} secrets get ${bootstrap_secret_path} --source github --field ${bootstrap_field} --output ${bootstrap_output_path} --aws-region ${aws_region} --broker-function ${github_token_broker_function_name} + +[Install] +WantedBy=multi-user.target diff --git a/aws/keycloak/templates/systemd/glab-keycloak-data.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-data.service.tftpl new file mode 100644 index 0000000..4c2eca2 --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak-data.service.tftpl @@ -0,0 +1,11 @@ +[Unit] +Description=Prepare Keycloak data volume +Before=${bootstrap_unit_name} ${postgres_unit_name} ${traefik_unit_name} + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${helper_script_dir}/prepare-data.sh + +[Install] +WantedBy=multi-user.target diff --git a/aws/keycloak/templates/systemd/glab-keycloak-network.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-network.service.tftpl new file mode 100644 index 0000000..4444076 --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak-network.service.tftpl @@ -0,0 +1,13 @@ +[Unit] +Description=Create Keycloak Docker network +Requires=docker.service +After=docker.service +Before=${postgres_unit_name} ${keycloak_unit_name} ${traefik_unit_name} + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${helper_script_dir}/create-network.sh + +[Install] +WantedBy=multi-user.target diff --git a/aws/keycloak/templates/systemd/glab-keycloak-postgres.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-postgres.service.tftpl new file mode 100644 index 0000000..08f9e92 --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak-postgres.service.tftpl @@ -0,0 +1,16 @@ +[Unit] +Description=Keycloak Postgres container +Requires=docker.service ${data_unit_name} ${network_unit_name} ${bootstrap_unit_name} +After=docker.service ${data_unit_name} ${network_unit_name} ${bootstrap_unit_name} + +[Service] +Restart=always +RestartSec=10 +EnvironmentFile=${bootstrap_output_path} +ExecStart=${helper_script_dir}/run-postgres.sh +ExecStop=-/usr/bin/docker stop keycloak-postgres +ExecStopPost=-/usr/bin/docker rm -f keycloak-postgres +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/aws/keycloak/templates/systemd/glab-keycloak-traefik.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-traefik.service.tftpl new file mode 100644 index 0000000..e0c38b4 --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak-traefik.service.tftpl @@ -0,0 +1,15 @@ +[Unit] +Description=Keycloak Traefik container +Requires=docker.service ${network_unit_name} ${data_unit_name} ${keycloak_unit_name} +After=docker.service ${network_unit_name} ${data_unit_name} ${keycloak_unit_name} + +[Service] +Restart=always +RestartSec=10 +ExecStart=${helper_script_dir}/run-traefik.sh +ExecStop=-/usr/bin/docker stop keycloak-traefik +ExecStopPost=-/usr/bin/docker rm -f keycloak-traefik +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/aws/keycloak/templates/systemd/glab-keycloak.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak.service.tftpl new file mode 100644 index 0000000..483be55 --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak.service.tftpl @@ -0,0 +1,17 @@ +[Unit] +Description=Keycloak container +Requires=docker.service ${network_unit_name} ${bootstrap_unit_name} ${postgres_unit_name} +After=docker.service ${network_unit_name} ${bootstrap_unit_name} ${postgres_unit_name} + +[Service] +Restart=always +RestartSec=10 +EnvironmentFile=${bootstrap_output_path} +ExecStartPre=${helper_script_dir}/wait-postgres.sh +ExecStart=${helper_script_dir}/run-keycloak.sh +ExecStop=-/usr/bin/docker stop keycloak +ExecStopPost=-/usr/bin/docker rm -f keycloak +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/aws/keycloak/tests/main.tftest.hcl b/aws/keycloak/tests/main.tftest.hcl index 19a9597..030d2b2 100644 --- a/aws/keycloak/tests/main.tftest.hcl +++ b/aws/keycloak/tests/main.tftest.hcl @@ -1,27 +1,15 @@ mock_provider "aws" { alias = "mock" - mock_data "aws_caller_identity" { - defaults = { - account_id = "123456789012" - } - } - - mock_data "aws_partition" { - defaults = { - partition = "aws" - } - } - - mock_data "aws_region" { + mock_data "aws_iam_policy_document" { defaults = { - name = "us-west-2" + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" } } - mock_data "aws_iam_policy_document" { + mock_resource "aws_ebs_volume" { defaults = { - json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + id = "vol-0123456789abcdef0" } } @@ -34,6 +22,7 @@ mock_provider "aws" { mock_data "aws_route53_zone" { defaults = { + arn = "arn:aws:route53:::hostedzone/Z00000000000000000" zone_id = "Z00000000000000000" } } @@ -44,12 +33,6 @@ mock_provider "aws" { } } - mock_data "aws_ssm_parameter" { - defaults = { - value = "ami-0000000000000000" - } - } - mock_data "aws_instance" { defaults = { id = "i-0000000000000000" @@ -64,7 +47,8 @@ mock_provider "aws" { mock_data "aws_subnet" { defaults = { - id = "subnet-00000000" + availability_zone = "us-west-2a" + id = "subnet-00000000" } } @@ -82,6 +66,11 @@ run "plan_defaults" { aws = aws.mock } + assert { + condition = aws_instance.keycloak.ami == "ami-0ce605082061bbb10" + error_message = "The Keycloak host should default to the current Flatcar stable arm64 AMI for us-west-2." + } + assert { condition = aws_instance.keycloak.instance_type == "t4g.small" error_message = "The default instance type should match the design doc." @@ -98,27 +87,72 @@ run "plan_defaults" { } assert { - condition = aws_instance.keycloak.root_block_device[0].encrypted == true - error_message = "The Keycloak root volume should be encrypted." + condition = aws_instance.keycloak.metadata_options[0].http_put_response_hop_limit == 2 + error_message = "The Keycloak host should allow containerized labctl to reach IMDSv2." } assert { - condition = aws_route53_record.private.name == "id.glab.lol" - error_message = "The private DNS record should default to id.glab.lol." + condition = aws_instance.keycloak.root_block_device[0].encrypted == true && aws_instance.keycloak.root_block_device[0].volume_type == "gp3" && aws_instance.keycloak.root_block_device[0].volume_size == 16 + error_message = "The Keycloak root volume should be an encrypted 16 GiB gp3 volume." } assert { - condition = local.acme_challenge_record_name == "_acme-challenge.id.acme.glab.lol" - error_message = "The default ACME challenge record should target the delegated Route 53 zone." + condition = aws_ebs_volume.keycloak_data.encrypted == true && aws_ebs_volume.keycloak_data.type == "gp3" && aws_ebs_volume.keycloak_data.size == 8 + error_message = "The Keycloak data volume should default to encrypted 8 GiB gp3." } assert { - condition = strcontains(local.compose, "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=route53") + condition = aws_volume_attachment.keycloak_data.device_name == "/dev/xvdf" && aws_volume_attachment.keycloak_data.volume_id == aws_ebs_volume.keycloak_data.id + error_message = "The Keycloak data volume should attach to the Flatcar host." + } + + assert { + condition = strcontains(local.ignition_config, "\"version\":\"3.3.0\"") + error_message = "The Keycloak host should pass raw Ignition v3 JSON as EC2 user data." + } + + assert { + condition = strcontains(local.ignition_config, "amazon-ssm-agent.service") + error_message = "The Keycloak host should enable the Flatcar AWS SSM agent." + } + + assert { + condition = strcontains(local.bootstrap_unit, "ghcr.io/gilmanlab/platform/labctl@sha256:4638b36a168df88d4206d5ff23aed62a6d8459ba7a2481c0b7c65c696445c1ec") + error_message = "The bootstrap unit should use the pinned labctl 0.2.0 image digest." + } + + assert { + condition = strcontains(local.bootstrap_unit, "--network host --user 0:0") + error_message = "The bootstrap container should run with host networking and root inside the container." + } + + assert { + condition = strcontains(local.bootstrap_unit, "secrets get services/keycloak/bootstrap.sops.yaml --source github --field /stack_env --output /run/glab/keycloak/stack.env") + error_message = "The bootstrap unit should fetch only the Keycloak stack_env field into /run." + } + + assert { + condition = strcontains(local.prepare_data_script, "/var/lib/keycloak/postgres") && strcontains(local.prepare_data_script, "/var/lib/keycloak/acme") && strcontains(local.prepare_data_script, "chown 999:999") + error_message = "The data preparation script should place Postgres and ACME state on the data volume." + } + + assert { + condition = strcontains(local.ignition_config, "/etc/glab/keycloak/bin/run-keycloak.sh") && !strcontains(local.ignition_config, "/usr/local/lib/glab-keycloak") + error_message = "Ignition should write helper scripts under writable root-backed config paths, not Flatcar's read-only /usr tree." + } + + assert { + condition = strcontains(local.run_keycloak_script, "/run/glab/keycloak/keycloak.env") && strcontains(local.run_keycloak_script, "KC_DB_PASSWORD") + error_message = "The Keycloak runner should derive container-only env from the /run bootstrap secret." + } + + assert { + condition = strcontains(local.run_traefik_script, "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=route53") error_message = "Traefik should use the Route 53 DNS-01 provider." } assert { - condition = strcontains(local.compose, "AWS_HOSTED_ZONE_ID: Z00000000000000000") + condition = strcontains(local.run_traefik_script, "AWS_HOSTED_ZONE_ID='Z00000000000000000'") error_message = "Traefik should receive the delegated Route 53 zone ID." } @@ -127,6 +161,16 @@ run "plan_defaults" { error_message = "The Keycloak router should request certificates through the Let's Encrypt resolver." } + assert { + condition = aws_route53_record.private.name == "id.glab.lol" + error_message = "The private DNS record should default to id.glab.lol." + } + + assert { + condition = local.acme_challenge_record_name == "_acme-challenge.id.acme.glab.lol" + error_message = "The default ACME challenge record should target the delegated Route 53 zone." + } + assert { condition = length(aws_vpc_security_group_ingress_rule.keycloak_https) == 1 error_message = "The default lab CIDRs should produce exactly one HTTPS ingress rule." @@ -156,6 +200,11 @@ run "plan_defaults" { condition = aws_iam_role_policy.keycloak_github_token_broker_invoke.role == aws_iam_role.keycloak.id error_message = "The Keycloak instance role should be allowed to invoke the token broker." } + + assert { + condition = aws_iam_role_policy.keycloak_sops_decrypt.role == aws_iam_role.keycloak.id + error_message = "The Keycloak instance role should receive the SOPS KMS decrypt policy." + } } run "plan_overrides" { @@ -166,9 +215,11 @@ run "plan_overrides" { } variables { + data_volume_size = 32 + flatcar_ami_id = "ami-00000000000000000" + github_token_broker_function_name = "glab-keycloak-staging-github-token-broker" instance_name = "glab-aws-keycloak-staging" instance_type = "t4g.medium" - github_token_broker_function_name = "glab-keycloak-staging-github-token-broker" lab_cidrs = ["10.10.0.0/16", "10.20.0.0/16"] operator_tailscale_cidrs = { laptop = "100.64.1.1/32" @@ -177,11 +228,21 @@ run "plan_overrides" { private_hostname = "id.staging.glab.lol" } + assert { + condition = aws_instance.keycloak.ami == "ami-00000000000000000" + error_message = "The Flatcar AMI override should propagate." + } + assert { condition = aws_instance.keycloak.instance_type == "t4g.medium" error_message = "The instance type override should propagate." } + assert { + condition = aws_ebs_volume.keycloak_data.size == 32 + error_message = "The data volume size override should propagate." + } + assert { condition = length(aws_vpc_security_group_ingress_rule.keycloak_https) == 2 error_message = "Two lab CIDRs should produce two HTTPS ingress rules." @@ -260,7 +321,7 @@ run "reject_invalid_root_volume_size" { } variables { - root_volume_size = 4 + root_volume_size = 12 } expect_failures = [ @@ -268,7 +329,39 @@ run "reject_invalid_root_volume_size" { ] } -run "reject_invalid_ssm_parameter_prefix" { +run "reject_invalid_data_volume_size" { + command = plan + + providers = { + aws = aws.mock + } + + variables { + data_volume_size = 4 + } + + expect_failures = [ + var.data_volume_size, + ] +} + +run "reject_unpinned_labctl_image" { + command = plan + + providers = { + aws = aws.mock + } + + variables { + labctl_image = "ghcr.io/gilmanlab/platform/labctl:0.2.0" + } + + expect_failures = [ + var.labctl_image, + ] +} + +run "reject_non_keycloak_secret_path" { command = plan providers = { @@ -276,10 +369,10 @@ run "reject_invalid_ssm_parameter_prefix" { } variables { - ssm_parameter_prefix = "glab/keycloak/" + bootstrap_secret_path = "network/vyos/bootstrap.sops.yaml" } expect_failures = [ - var.ssm_parameter_prefix, + var.bootstrap_secret_path, ] } diff --git a/aws/keycloak/variables.tf b/aws/keycloak/variables.tf index 77a4957..7f0217a 100644 --- a/aws/keycloak/variables.tf +++ b/aws/keycloak/variables.tf @@ -1,9 +1,3 @@ -variable "ami_ssm_parameter_name" { - description = "SSM public parameter that resolves to the latest Amazon Linux 2023 arm64 AMI." - type = string - default = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64" -} - variable "aws_region" { description = "AWS region in which the Keycloak instance is created." type = string @@ -43,36 +37,91 @@ variable "acme_zone_name" { } } -variable "compose_version" { - description = "Docker Compose CLI plugin version installed on the Keycloak host." +variable "bootstrap_field" { + description = "RFC 6901 field path extracted from the SOPS file by labctl." type = string - default = "v2.38.2" + default = "/stack_env" validation { - condition = startswith(var.compose_version, "v") - error_message = "compose_version must include the leading 'v' used by Docker Compose release tags." + condition = startswith(var.bootstrap_field, "/") + error_message = "bootstrap_field must be an RFC 6901 pointer beginning with '/'." } } -variable "database_name" { - description = "Postgres database name used by Keycloak." +variable "bootstrap_output_path" { + description = "Path where labctl writes the decrypted dotenv payload on the Flatcar host." type = string - default = "keycloak" + default = "/run/glab/keycloak/stack.env" validation { - condition = can(regex("^[a-zA-Z_][a-zA-Z0-9_]*$", var.database_name)) - error_message = "database_name must be a valid Postgres identifier." + condition = startswith(var.bootstrap_output_path, "/run/") + error_message = "bootstrap_output_path must stay under /run." } } -variable "database_username" { - description = "Postgres user name used by Keycloak." +variable "bootstrap_runtime_dir" { + description = "Ephemeral host directory mounted into the labctl container." type = string - default = "keycloak" + default = "/run/glab/keycloak" + + validation { + condition = startswith(var.bootstrap_runtime_dir, "/run/") && !endswith(var.bootstrap_runtime_dir, "/") + error_message = "bootstrap_runtime_dir must stay under /run and must not end with '/'." + } +} + +variable "bootstrap_secret_path" { + description = "Path to the SOPS-encrypted Keycloak bootstrap secret in GilmanLab/secrets." + type = string + default = "services/keycloak/bootstrap.sops.yaml" + + validation { + condition = can(regex("^services/keycloak/[^[:space:]]+\\.sops\\.yaml$", var.bootstrap_secret_path)) + error_message = "bootstrap_secret_path must point at a services/keycloak/*.sops.yaml file." + } +} + +variable "data_dir" { + description = "Mount point for the encrypted Keycloak data volume." + type = string + default = "/var/lib/keycloak" + + validation { + condition = startswith(var.data_dir, "/") && !endswith(var.data_dir, "/") + error_message = "data_dir must be an absolute path and must not end with '/'." + } +} + +variable "data_volume_label" { + description = "Filesystem label assigned to the Keycloak data volume when first formatted." + type = string + default = "keycloak-data" + + validation { + condition = can(regex("^[A-Za-z0-9._-]{1,16}$", var.data_volume_label)) + error_message = "data_volume_label must be 1-16 filesystem-label-safe characters." + } +} + +variable "data_volume_size" { + description = "Size, in GiB, of the encrypted gp3 data volume mounted at data_dir." + type = number + default = 8 + + validation { + condition = var.data_volume_size >= 8 && var.data_volume_size <= 1024 + error_message = "data_volume_size must be between 8 and 1024 GiB." + } +} + +variable "data_volume_device_name" { + description = "Requested EC2 device name for the Keycloak data volume attachment." + type = string + default = "/dev/xvdf" validation { - condition = can(regex("^[a-zA-Z_][a-zA-Z0-9_]*$", var.database_username)) - error_message = "database_username must be a valid Postgres identifier." + condition = startswith(var.data_volume_device_name, "/dev/") + error_message = "data_volume_device_name must be an absolute /dev path." } } @@ -87,22 +136,15 @@ variable "dns_record_ttl" { } } -variable "iam_role_name" { - description = "IAM role name used by the Keycloak instance." +variable "flatcar_ami_id" { + description = "Flatcar stable arm64 AMI ID for us-west-2. Recheck the official Flatcar AWS EC2 table before live apply." type = string - default = "glab-aws-keycloak" -} - -variable "instance_name" { - description = "Name tag for the Keycloak EC2 instance." - type = string - default = "glab-aws-keycloak" -} + default = "ami-0ce605082061bbb10" -variable "instance_type" { - description = "EC2 instance type for the Keycloak host." - type = string - default = "t4g.small" + validation { + condition = can(regex("^ami-[0-9a-f]{17}$", var.flatcar_ami_id)) + error_message = "flatcar_ami_id must be a literal EC2 AMI ID." + } } variable "github_token_broker_function_name" { @@ -202,37 +244,22 @@ variable "github_token_broker_ssm_parameter_paths" { } } -variable "keycloak_admin_username" { - description = "Temporary bootstrap admin username passed to Keycloak on first startup." +variable "iam_role_name" { + description = "IAM role name used by the Keycloak instance." type = string - default = "admin" - - validation { - condition = length(trimspace(var.keycloak_admin_username)) > 0 - error_message = "keycloak_admin_username must not be empty." - } + default = "glab-aws-keycloak" } -variable "keycloak_heap_max" { - description = "Maximum JVM heap assigned to Keycloak." +variable "instance_name" { + description = "Name tag for the Keycloak EC2 instance." type = string - default = "768m" - - validation { - condition = can(regex("^[0-9]+[mMgG]$", var.keycloak_heap_max)) - error_message = "keycloak_heap_max must use a JVM memory suffix such as 768m or 1g." - } + default = "glab-aws-keycloak" } -variable "keycloak_heap_min" { - description = "Initial JVM heap assigned to Keycloak." +variable "instance_type" { + description = "EC2 instance type for the Keycloak host." type = string - default = "256m" - - validation { - condition = can(regex("^[0-9]+[mMgG]$", var.keycloak_heap_min)) - error_message = "keycloak_heap_min must use a JVM memory suffix such as 256m or 1g." - } + default = "t4g.small" } variable "keycloak_image" { @@ -252,6 +279,17 @@ variable "lab_cidrs" { } } +variable "labctl_image" { + description = "Pinned labctl container image used for the bootstrap secret fetch." + type = string + default = "ghcr.io/gilmanlab/platform/labctl@sha256:4638b36a168df88d4206d5ff23aed62a6d8459ba7a2481c0b7c65c696445c1ec" + + validation { + condition = startswith(var.labctl_image, "ghcr.io/gilmanlab/platform/labctl@sha256:") + error_message = "labctl_image must be pinned by digest." + } +} + variable "operator_tailscale_cidrs" { description = "Named operator Tailscale IPv4 CIDRs that may reach Keycloak directly over HTTPS." type = map(string) @@ -269,17 +307,6 @@ variable "postgres_image" { default = "postgres:18.3-trixie" } -variable "postgres_state_dir" { - description = "Host path mounted into the Postgres container for durable database state." - type = string - default = "/var/lib/keycloak/postgres" - - validation { - condition = startswith(var.postgres_state_dir, "/") - error_message = "postgres_state_dir must be an absolute path." - } -} - variable "private_hostname" { description = "Private DNS hostname for the Keycloak service." type = string @@ -320,19 +347,19 @@ variable "root_volume_size" { default = 16 validation { - condition = var.root_volume_size >= 8 && var.root_volume_size <= 100 - error_message = "root_volume_size must be between 8 and 100 GiB." + condition = var.root_volume_size >= 13 && var.root_volume_size <= 100 + error_message = "root_volume_size must be between 13 and 100 GiB for the current Flatcar AMI snapshot." } } variable "runtime_dir" { - description = "Host path that stores the Keycloak Compose stack and generated TLS material." + description = "Root-backed host path that stores non-secret Keycloak and Traefik runtime config." type = string - default = "/opt/keycloak" + default = "/etc/glab/keycloak" validation { - condition = startswith(var.runtime_dir, "/") - error_message = "runtime_dir must be an absolute path." + condition = startswith(var.runtime_dir, "/") && !endswith(var.runtime_dir, "/") + error_message = "runtime_dir must be an absolute path and must not end with '/'." } } @@ -342,14 +369,26 @@ variable "security_group_name" { default = "glab-aws-keycloak" } -variable "ssm_parameter_prefix" { - description = "SSM Parameter Store path prefix where the host stores generated bootstrap credentials." +variable "sops_kms_context_repo" { + description = "KMS encryption context Repo value allowed for Keycloak SOPS decrypts." + type = string + default = "GilmanLab/secrets" +} + +variable "sops_kms_context_scope" { + description = "KMS encryption context Scope value allowed for Keycloak SOPS decrypts." + type = string + default = "keycloak" +} + +variable "sops_kms_key_arn" { + description = "Customer-managed KMS key used by the secrets repository SOPS rules." type = string - default = "/glab/keycloak" + default = "arn:aws:kms:us-west-2:186067932323:key/2aba1d94-6eaf-4d80-8d26-2077f32fd7c5" validation { - condition = startswith(var.ssm_parameter_prefix, "/") && !endswith(var.ssm_parameter_prefix, "/") - error_message = "ssm_parameter_prefix must start with '/' and must not end with '/'." + condition = can(regex("^arn:aws[a-zA-Z-]*:kms:[a-z0-9-]+:[0-9]{12}:key/[A-Za-z0-9-]+$", var.sops_kms_key_arn)) + error_message = "sops_kms_key_arn must be a literal KMS key ARN." } }