From d0b831b9892b4f3b24540d73d60b3d060496928e Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Tue, 17 Mar 2026 17:40:10 -0300 Subject: [PATCH 01/34] feat(databases): add RDS PostgreSQL db and server service modules Adds rds-postgres-db and rds-postgres-server service modules, each with deployment/permissions Terraform configs, entrypoints, workflow definitions (create/delete/link/unlink/update), scripts, and service specs. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-db/deployment/backend.tf | 3 + rds-postgres-db/deployment/data.tf | 15 ++ rds-postgres-db/deployment/main.tf | 103 +++++++++++++ rds-postgres-db/deployment/outputs.tf | 19 +++ rds-postgres-db/deployment/providers.tf | 16 ++ rds-postgres-db/deployment/variables.tf | 62 ++++++++ rds-postgres-db/entrypoint/entrypoint | 83 +++++++++++ rds-postgres-db/entrypoint/link | 34 +++++ rds-postgres-db/entrypoint/service | 28 ++++ rds-postgres-db/permissions/backend.tf | 3 + rds-postgres-db/permissions/locals.tf | 19 +++ rds-postgres-db/permissions/main.tf | 93 ++++++++++++ rds-postgres-db/permissions/outputs.tf | 30 ++++ rds-postgres-db/permissions/providers.tf | 22 +++ rds-postgres-db/permissions/variables.tf | 52 +++++++ rds-postgres-db/scripts/aws/build_context | 138 ++++++++++++++++++ .../scripts/aws/build_permissions_context | 99 +++++++++++++ .../scripts/aws/delete_tfstate_bucket | 73 +++++++++ rds-postgres-db/scripts/aws/do_tofu | 86 +++++++++++ rds-postgres-db/scripts/aws/reassign_owned | 60 ++++++++ .../scripts/aws/write_link_outputs | 55 +++++++ .../scripts/aws/write_service_outputs | 34 +++++ rds-postgres-db/specs/links/connect.json.tpl | 92 ++++++++++++ rds-postgres-db/specs/service-spec.json.tpl | 24 +++ rds-postgres-db/values.yaml | 18 +++ rds-postgres-db/workflows/aws/create.yaml | 7 + rds-postgres-db/workflows/aws/delete.yaml | 11 ++ rds-postgres-db/workflows/aws/link.yaml | 50 +++++++ rds-postgres-db/workflows/aws/unlink.yaml | 53 +++++++ rds-postgres-db/workflows/aws/update.yaml | 7 + rds-postgres-server/deployment/backend.tf | 3 + rds-postgres-server/deployment/data.tf | 15 ++ rds-postgres-server/deployment/main.tf | 103 +++++++++++++ rds-postgres-server/deployment/outputs.tf | 19 +++ rds-postgres-server/deployment/providers.tf | 16 ++ rds-postgres-server/deployment/variables.tf | 62 ++++++++ rds-postgres-server/entrypoint/entrypoint | 83 +++++++++++ rds-postgres-server/entrypoint/link | 34 +++++ rds-postgres-server/entrypoint/service | 28 ++++ rds-postgres-server/permissions/backend.tf | 3 + rds-postgres-server/permissions/locals.tf | 12 ++ rds-postgres-server/permissions/main.tf | 66 +++++++++ rds-postgres-server/permissions/outputs.tf | 15 ++ rds-postgres-server/permissions/providers.tf | 22 +++ rds-postgres-server/permissions/variables.tf | 47 ++++++ rds-postgres-server/scripts/aws/build_context | 101 +++++++++++++ .../scripts/aws/build_permissions_context | 97 ++++++++++++ .../scripts/aws/delete_tfstate_bucket | 73 +++++++++ rds-postgres-server/scripts/aws/do_tofu | 59 ++++++++ .../scripts/aws/write_link_outputs | 45 ++++++ .../scripts/aws/write_service_outputs | 47 ++++++ .../specs/links/connect.json.tpl | 66 +++++++++ .../specs/service-spec.json.tpl | 85 +++++++++++ rds-postgres-server/values.yaml | 18 +++ rds-postgres-server/workflows/aws/create.yaml | 23 +++ rds-postgres-server/workflows/aws/delete.yaml | 23 +++ rds-postgres-server/workflows/aws/link.yaml | 40 +++++ rds-postgres-server/workflows/aws/unlink.yaml | 36 +++++ rds-postgres-server/workflows/aws/update.yaml | 23 +++ 59 files changed, 2653 insertions(+) create mode 100644 rds-postgres-db/deployment/backend.tf create mode 100644 rds-postgres-db/deployment/data.tf create mode 100644 rds-postgres-db/deployment/main.tf create mode 100644 rds-postgres-db/deployment/outputs.tf create mode 100644 rds-postgres-db/deployment/providers.tf create mode 100644 rds-postgres-db/deployment/variables.tf create mode 100755 rds-postgres-db/entrypoint/entrypoint create mode 100755 rds-postgres-db/entrypoint/link create mode 100755 rds-postgres-db/entrypoint/service create mode 100644 rds-postgres-db/permissions/backend.tf create mode 100644 rds-postgres-db/permissions/locals.tf create mode 100644 rds-postgres-db/permissions/main.tf create mode 100644 rds-postgres-db/permissions/outputs.tf create mode 100644 rds-postgres-db/permissions/providers.tf create mode 100644 rds-postgres-db/permissions/variables.tf create mode 100755 rds-postgres-db/scripts/aws/build_context create mode 100755 rds-postgres-db/scripts/aws/build_permissions_context create mode 100755 rds-postgres-db/scripts/aws/delete_tfstate_bucket create mode 100755 rds-postgres-db/scripts/aws/do_tofu create mode 100755 rds-postgres-db/scripts/aws/reassign_owned create mode 100755 rds-postgres-db/scripts/aws/write_link_outputs create mode 100755 rds-postgres-db/scripts/aws/write_service_outputs create mode 100644 rds-postgres-db/specs/links/connect.json.tpl create mode 100644 rds-postgres-db/specs/service-spec.json.tpl create mode 100644 rds-postgres-db/values.yaml create mode 100644 rds-postgres-db/workflows/aws/create.yaml create mode 100644 rds-postgres-db/workflows/aws/delete.yaml create mode 100644 rds-postgres-db/workflows/aws/link.yaml create mode 100644 rds-postgres-db/workflows/aws/unlink.yaml create mode 100644 rds-postgres-db/workflows/aws/update.yaml create mode 100644 rds-postgres-server/deployment/backend.tf create mode 100644 rds-postgres-server/deployment/data.tf create mode 100644 rds-postgres-server/deployment/main.tf create mode 100644 rds-postgres-server/deployment/outputs.tf create mode 100644 rds-postgres-server/deployment/providers.tf create mode 100644 rds-postgres-server/deployment/variables.tf create mode 100755 rds-postgres-server/entrypoint/entrypoint create mode 100755 rds-postgres-server/entrypoint/link create mode 100755 rds-postgres-server/entrypoint/service create mode 100644 rds-postgres-server/permissions/backend.tf create mode 100644 rds-postgres-server/permissions/locals.tf create mode 100644 rds-postgres-server/permissions/main.tf create mode 100644 rds-postgres-server/permissions/outputs.tf create mode 100644 rds-postgres-server/permissions/providers.tf create mode 100644 rds-postgres-server/permissions/variables.tf create mode 100755 rds-postgres-server/scripts/aws/build_context create mode 100755 rds-postgres-server/scripts/aws/build_permissions_context create mode 100755 rds-postgres-server/scripts/aws/delete_tfstate_bucket create mode 100755 rds-postgres-server/scripts/aws/do_tofu create mode 100755 rds-postgres-server/scripts/aws/write_link_outputs create mode 100755 rds-postgres-server/scripts/aws/write_service_outputs create mode 100644 rds-postgres-server/specs/links/connect.json.tpl create mode 100644 rds-postgres-server/specs/service-spec.json.tpl create mode 100644 rds-postgres-server/values.yaml create mode 100644 rds-postgres-server/workflows/aws/create.yaml create mode 100644 rds-postgres-server/workflows/aws/delete.yaml create mode 100644 rds-postgres-server/workflows/aws/link.yaml create mode 100644 rds-postgres-server/workflows/aws/unlink.yaml create mode 100644 rds-postgres-server/workflows/aws/update.yaml diff --git a/rds-postgres-db/deployment/backend.tf b/rds-postgres-db/deployment/backend.tf new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/rds-postgres-db/deployment/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/rds-postgres-db/deployment/data.tf b/rds-postgres-db/deployment/data.tf new file mode 100644 index 0000000..9854479 --- /dev/null +++ b/rds-postgres-db/deployment/data.tf @@ -0,0 +1,15 @@ +# Discover shared RDS infrastructure (tagged by nullplatform during setup) + +data "aws_vpc" "main" { + id = var.vpc_id +} + +data "aws_subnets" "private" { + filter { + name = "vpc-id" + values = [var.vpc_id] + } + tags = { + "nullplatform/subnet-type" = "private" + } +} diff --git a/rds-postgres-db/deployment/main.tf b/rds-postgres-db/deployment/main.tf new file mode 100644 index 0000000..24f05dc --- /dev/null +++ b/rds-postgres-db/deployment/main.tf @@ -0,0 +1,103 @@ +# --------------------------------------------------------------------------- +# Security group for RDS (allows PostgreSQL traffic from within the VPC) +# --------------------------------------------------------------------------- + +resource "aws_security_group" "rds" { + name = "np-rds-${var.instance_name}" + description = "Allow PostgreSQL access from within the VPC" + vpc_id = var.vpc_id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + cidr_blocks = [data.aws_vpc.main.cidr_block] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + "managed-by" = "nullplatform" + "service-id" = var.service_id + } +} + +# --------------------------------------------------------------------------- +# Master password (stored in Secrets Manager, used by link permissions) +# --------------------------------------------------------------------------- + +resource "random_password" "master" { + length = 32 + special = false +} + +resource "aws_secretsmanager_secret" "master" { + name = "nullplatform/rds/${var.instance_name}/master" + recovery_window_in_days = 0 + + tags = { + "managed-by" = "nullplatform" + "rds-instance" = var.instance_name + "service-id" = var.service_id + } +} + +resource "aws_secretsmanager_secret_version" "master" { + secret_id = aws_secretsmanager_secret.master.id + secret_string = jsonencode({ + username = "master" + password = random_password.master.result + }) +} + +# --------------------------------------------------------------------------- +# RDS instance +# --------------------------------------------------------------------------- + +resource "aws_db_subnet_group" "main" { + name = var.instance_name + subnet_ids = data.aws_subnets.private.ids + + tags = { + "managed-by" = "nullplatform" + "service-id" = var.service_id + } +} + +resource "aws_db_instance" "main" { + identifier = var.instance_name + engine = "postgres" + engine_version = var.postgres_version + instance_class = var.instance_class + allocated_storage = var.allocated_storage + storage_type = "gp3" + storage_encrypted = true + + db_name = "postgres" + username = "master" + password = random_password.master.result + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.rds.id] + + multi_az = var.multi_az + publicly_accessible = false + skip_final_snapshot = true + deletion_protection = false + + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + + tags = { + "managed-by" = "nullplatform" + "service-id" = var.service_id + } + + depends_on = [aws_secretsmanager_secret_version.master] +} diff --git a/rds-postgres-db/deployment/outputs.tf b/rds-postgres-db/deployment/outputs.tf new file mode 100644 index 0000000..31bd6ae --- /dev/null +++ b/rds-postgres-db/deployment/outputs.tf @@ -0,0 +1,19 @@ +output "hostname" { + value = aws_db_instance.main.address + description = "RDS endpoint hostname" +} + +output "port" { + value = aws_db_instance.main.port + description = "RDS port" +} + +output "db_instance_identifier" { + value = aws_db_instance.main.identifier + description = "AWS RDS instance identifier" +} + +output "master_secret_arn" { + value = aws_secretsmanager_secret.master.arn + description = "ARN of the Secrets Manager secret for master credentials" +} diff --git a/rds-postgres-db/deployment/providers.tf b/rds-postgres-db/deployment/providers.tf new file mode 100644 index 0000000..4988945 --- /dev/null +++ b/rds-postgres-db/deployment/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "aws" { + region = var.region +} diff --git a/rds-postgres-db/deployment/variables.tf b/rds-postgres-db/deployment/variables.tf new file mode 100644 index 0000000..673ba42 --- /dev/null +++ b/rds-postgres-db/deployment/variables.tf @@ -0,0 +1,62 @@ +variable "service_id" { + type = string + description = "Nullplatform service ID" +} + +variable "instance_name" { + type = string + description = "Unique instance name for AWS resource naming (format: np-)" +} + +variable "region" { + type = string + default = "us-east-1" + description = "AWS region" +} + +variable "vpc_id" { + type = string + description = "VPC ID where the RDS instance will be deployed" +} + +variable "instance_class" { + type = string + default = "db.t3.micro" + description = "RDS instance class" +} + +variable "allocated_storage" { + type = number + default = 20 + description = "Allocated storage in GB" +} + +variable "postgres_version" { + type = string + default = "16" + description = "PostgreSQL major version" +} + +variable "multi_az" { + type = bool + default = false + description = "Enable Multi-AZ deployment for high availability" +} + +variable "backup_retention_period" { + type = number + default = 7 + description = "Number of days to retain automated backups. 0 disables backups." +} + +variable "backup_window" { + type = string + default = "03:00-04:00" + description = "Daily time range for automated backups (UTC, hh:mm-hh:mm)" +} + +variable "maintenance_window" { + type = string + default = "Mon:04:00-Mon:05:00" + description = "Weekly time range for maintenance operations (UTC, ddd:hh:mm-ddd:hh:mm)" +} diff --git a/rds-postgres-db/entrypoint/entrypoint b/rds-postgres-db/entrypoint/entrypoint new file mode 100755 index 0000000..f8b84ad --- /dev/null +++ b/rds-postgres-db/entrypoint/entrypoint @@ -0,0 +1,83 @@ +#!/bin/bash +set -euo pipefail + +if [ -z "${NP_ACTION_CONTEXT:-}" ]; then + echo "NP_ACTION_CONTEXT is not set. Exiting." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Bridge agent API key to the np CLI. +# np-agent passes NP_API_KEY; the np CLI expects NULLPLATFORM_API_KEY. +# Without this bridge, commands like "np service patch" fail with: +# "please login first, refresh token or organization id not found" +# --------------------------------------------------------------------------- +if [ -n "${NP_API_KEY:-}" ] && [ -z "${NULLPLATFORM_API_KEY:-}" ]; then + export NULLPLATFORM_API_KEY="$NP_API_KEY" +fi + +# Strip surrounding single quotes that np-agent may wrap the context in +CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") +export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" + +export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') +export SERVICE_ACTION=$(echo "$CONTEXT" | jq -r '.slug') +export SERVICE_ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type') +export NOTIFICATION_ACTION=$(echo "$CONTEXT" | jq -r '.action') +export LINK=$(echo "$CONTEXT" | jq '.link') + +ACTION_SOURCE=service +IS_LINK_ACTION=$(echo "$CONTEXT" | jq '.link != null') + +if [ "$IS_LINK_ACTION" = "true" ]; then + ACTION_SOURCE=link +fi + +export WORKING_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SERVICE_PATH="" +OVERRIDES_PATH="" + +for arg in "$@"; do + case $arg in + --service-path=*) + SERVICE_PATH="${arg#*=}" + ;; + --overrides-path=*) + OVERRIDES_PATH="${arg#*=}" + ;; + *) + echo "Unknown argument: $arg" + exit 1 + ;; + esac +done + +SERVICE_PATH="${SERVICE_PATH:-$(dirname "$WORKING_DIRECTORY")}" + +# --------------------------------------------------------------------------- +# Resolve relative SERVICE_PATH to absolute. +# The agent child process inherits the CWD of where np-agent was started, +# which is NOT necessarily ~/.np/ (the basepath). Try the current CWD first, +# then fall back to ~/.np/ as the basepath. +# Without this fallback, "cd " fails silently (exit 1, no output) +# when the CWD doesn't contain the service path. +# --------------------------------------------------------------------------- +if [[ "$SERVICE_PATH" != /* ]]; then + if [ -d "$SERVICE_PATH" ]; then + SERVICE_PATH="$(cd "$SERVICE_PATH" && pwd)" + elif [ -d "$HOME/.np/$SERVICE_PATH" ]; then + SERVICE_PATH="$(cd "$HOME/.np/$SERVICE_PATH" && pwd)" + else + echo "ERROR: Cannot resolve SERVICE_PATH='$SERVICE_PATH' from CWD=$(pwd) or $HOME/.np/" + exit 1 + fi +fi + +OVERRIDES_PATH="${OVERRIDES_PATH:-$SERVICE_PATH/overrides}" + +export SERVICE_PATH +export OVERRIDES_PATH +export ACTION_SOURCE + +np service-action exec --live-output --live-report --script="$WORKING_DIRECTORY/$ACTION_SOURCE" diff --git a/rds-postgres-db/entrypoint/link b/rds-postgres-db/entrypoint/link new file mode 100755 index 0000000..f755e81 --- /dev/null +++ b/rds-postgres-db/entrypoint/link @@ -0,0 +1,34 @@ +#!/bin/bash + +echo "Executing link action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE" + +ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE" + +case "$SERVICE_ACTION_TYPE" in + "custom") + ACTION_TO_EXECUTE="$SERVICE_ACTION" + ;; + "create"|"update") + ACTION_TO_EXECUTE="link" + ;; + "delete") + ACTION_TO_EXECUTE="unlink" + ;; +esac + +WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +VALUES_PATH="$SERVICE_PATH/values.yaml" + +CMD="np service workflow exec --workflow $WORKFLOW_PATH" + +if [[ -f "$VALUES_PATH" ]]; then + CMD="$CMD --values $VALUES_PATH" +fi + +if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then + CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH" +fi + +echo "Executing command: $CMD" +eval "$CMD" diff --git a/rds-postgres-db/entrypoint/service b/rds-postgres-db/entrypoint/service new file mode 100755 index 0000000..e876d23 --- /dev/null +++ b/rds-postgres-db/entrypoint/service @@ -0,0 +1,28 @@ +#!/bin/bash + +echo "Executing service action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE" + +ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE" + +case "$SERVICE_ACTION_TYPE" in + "custom") + ACTION_TO_EXECUTE="$SERVICE_ACTION" + ;; +esac + +WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +VALUES_PATH="$SERVICE_PATH/values.yaml" + +CMD="np service workflow exec --workflow $WORKFLOW_PATH" + +if [[ -f "$VALUES_PATH" ]]; then + CMD="$CMD --values $VALUES_PATH" +fi + +if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then + CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH" +fi + +echo "Executing command: $CMD" +eval "$CMD" diff --git a/rds-postgres-db/permissions/backend.tf b/rds-postgres-db/permissions/backend.tf new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/rds-postgres-db/permissions/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/rds-postgres-db/permissions/locals.tf b/rds-postgres-db/permissions/locals.tf new file mode 100644 index 0000000..edcf72e --- /dev/null +++ b/rds-postgres-db/permissions/locals.tf @@ -0,0 +1,19 @@ +locals { + table_privileges = { + # read: SELECT only + "read" = ["SELECT"] + + # write: INSERT/UPDATE/DELETE without SELECT (write-only) + "write" = ["INSERT", "UPDATE", "DELETE"] + + # read-write: full DML access + "read-write" = ["SELECT", "INSERT", "UPDATE", "DELETE"] + } + + # read-write gets CREATE so the app can run schema migrations + schema_privileges = { + "read" = ["USAGE"] + "write" = ["USAGE"] + "read-write" = ["USAGE", "CREATE"] + } +} diff --git a/rds-postgres-db/permissions/main.tf b/rds-postgres-db/permissions/main.tf new file mode 100644 index 0000000..bd56c0b --- /dev/null +++ b/rds-postgres-db/permissions/main.tf @@ -0,0 +1,93 @@ +# --------------------------------------------------------------------------- +# Database — created on first link, preserved on unlink. +# +# On re-links (database already exists in PostgreSQL but not in tfstate), +# do_tofu runs "tofu import postgresql_database.app " before apply, +# so no data is lost. prevent_destroy ensures tofu destroy never drops the DB. +# --------------------------------------------------------------------------- + +resource "postgresql_database" "app" { + name = var.db_name + owner = var.master_username + + lifecycle { + prevent_destroy = true + } +} + +# --------------------------------------------------------------------------- +# App user (password generated by Terraform, stable across re-applies) +# The keepers block ensures the password is only regenerated if link_id +# changes (which it won't), preserving credentials on update workflows. +# --------------------------------------------------------------------------- + +resource "random_password" "user" { + length = 32 + special = false + keepers = { + link_id = var.link_id + } +} + +resource "postgresql_role" "app_user" { + name = var.db_username + password = random_password.user.result + login = true +} + +# --------------------------------------------------------------------------- +# Permissions +# --------------------------------------------------------------------------- + +resource "postgresql_grant" "connect" { + database = postgresql_database.app.name + role = postgresql_role.app_user.name + object_type = "database" + privileges = ["CONNECT"] +} + +resource "postgresql_grant" "schema_usage" { + database = postgresql_database.app.name + role = postgresql_role.app_user.name + schema = "public" + object_type = "schema" + privileges = local.schema_privileges[var.access_level] +} + +# Grant on existing tables +resource "postgresql_grant" "tables" { + database = postgresql_database.app.name + role = postgresql_role.app_user.name + schema = "public" + object_type = "table" + privileges = local.table_privileges[var.access_level] +} + +# Grant on future tables (default privileges applied by master user) +resource "postgresql_default_privileges" "tables" { + role = postgresql_role.app_user.name + database = postgresql_database.app.name + schema = "public" + owner = var.master_username + object_type = "table" + privileges = local.table_privileges[var.access_level] +} + +# Grant USAGE on existing sequences (needed for INSERT on serial/bigserial columns) +resource "postgresql_grant" "sequences" { + database = postgresql_database.app.name + role = postgresql_role.app_user.name + schema = "public" + object_type = "sequence" + privileges = ["USAGE", "SELECT"] +} + +# Grant USAGE on future sequences +resource "postgresql_default_privileges" "sequences" { + role = postgresql_role.app_user.name + database = postgresql_database.app.name + schema = "public" + owner = var.master_username + object_type = "sequence" + privileges = ["USAGE", "SELECT"] +} diff --git a/rds-postgres-db/permissions/outputs.tf b/rds-postgres-db/permissions/outputs.tf new file mode 100644 index 0000000..20b5ff5 --- /dev/null +++ b/rds-postgres-db/permissions/outputs.tf @@ -0,0 +1,30 @@ +output "hostname" { + value = var.db_host + description = "RDS endpoint hostname" +} + +output "port" { + value = var.db_port + description = "RDS port" +} + +output "master_secret_arn" { + value = var.master_secret_arn + description = "ARN of the Secrets Manager secret for master credentials" +} + +output "db_username" { + value = postgresql_role.app_user.name + description = "Created database username" +} + +output "db_password" { + value = random_password.user.result + sensitive = true + description = "Database user password" +} + +output "database_name" { + value = postgresql_database.app.name + description = "Database name" +} diff --git a/rds-postgres-db/permissions/providers.tf b/rds-postgres-db/permissions/providers.tf new file mode 100644 index 0000000..976a2bf --- /dev/null +++ b/rds-postgres-db/permissions/providers.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + version = "~> 1.21" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "postgresql" { + host = var.db_host + port = var.db_port + database = "postgres" + username = var.master_username + password = var.master_password + sslmode = "require" + superuser = false +} diff --git a/rds-postgres-db/permissions/variables.tf b/rds-postgres-db/permissions/variables.tf new file mode 100644 index 0000000..335ba27 --- /dev/null +++ b/rds-postgres-db/permissions/variables.tf @@ -0,0 +1,52 @@ +variable "link_id" { + type = string + description = "Nullplatform link ID (used as keeper to stabilize password across re-applies)" +} + +variable "db_host" { + type = string + description = "RDS endpoint hostname" +} + +variable "db_port" { + type = number + default = 5432 + description = "RDS port" +} + +variable "db_name" { + type = string + description = "Database name to create inside the RDS instance" +} + +variable "db_username" { + type = string + description = "Username for the new database user (derived from link ID)" +} + +variable "master_username" { + type = string + description = "Master username for connecting to PostgreSQL" +} + +variable "master_secret_arn" { + type = string + description = "ARN of the Secrets Manager secret for master credentials (stored in link attributes)" +} + +variable "master_password" { + type = string + sensitive = true + description = "Master password for connecting to PostgreSQL (passed via auto.tfvars)" +} + +variable "access_level" { + type = string + default = "read-write" + description = "Permission level: read, write, or read-write" + + validation { + condition = contains(["read", "write", "read-write"], var.access_level) + error_message = "access_level must be one of: read, write, read-write" + } +} diff --git a/rds-postgres-db/scripts/aws/build_context b/rds-postgres-db/scripts/aws/build_context new file mode 100755 index 0000000..2c0ee57 --- /dev/null +++ b/rds-postgres-db/scripts/aws/build_context @@ -0,0 +1,138 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# build_context — Prepares the execution environment for rds-postgres-db +# service actions. +# +# Service create/update/delete: no AWS attributes to resolve — just creates +# the per-service S3 bucket used to store link tfstate files. +# +# Link/unlink: reads server_service_id from link parameters, fetches the +# rds-postgres-server's connection attributes via np service read, and +# exports them for build_permissions_context. +# --------------------------------------------------------------------------- + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') + +# --- Read static config from values.yaml ------------------------------------ + +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +REGION=$(yaml_value "region" "us-east-1" "$VALUES") +AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") + +if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then + export AWS_PROFILE="${AWS_PROFILE_VAL}" +fi + +# --- Ensure per-service S3 bucket exists (used for link tfstate) ------------ + +TFSTATE_BUCKET="np-service-${SERVICE_ID}" + +if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then + echo "Creating tfstate bucket: ${TFSTATE_BUCKET}" + if [ "$REGION" = "us-east-1" ]; then + aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" + else + aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" \ + --create-bucket-configuration LocationConstraint="$REGION" + fi + aws s3api put-bucket-versioning --bucket "$TFSTATE_BUCKET" \ + --versioning-configuration Status=Enabled + echo "Bucket ${TFSTATE_BUCKET} created with versioning enabled." +else + echo "Using existing tfstate bucket: ${TFSTATE_BUCKET}" +fi + +export TFSTATE_BUCKET + +export OUTPUT_DIR="/tmp/np-service-${SERVICE_ID}" +mkdir -p "$OUTPUT_DIR" + +# --- Link context (link/unlink workflows only) ------------------------------ + +if [ "${ACTION_SOURCE:-}" = "link" ]; then + export LINK_ID=$(echo "$CONTEXT" | jq -r '.link.id // ""') + export LINK_NAME=$(echo "$CONTEXT" | jq -r '.link.name // ""') + export SCOPE_ID=$(echo "$CONTEXT" | jq -r '.link.scope.id // ""') + export SCOPE_NRN=$(echo "$CONTEXT" | jq -r '.link.scope.nrn // ""') + + LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})') + export LINK_ACCESS_LEVEL=$(echo "$LINK_ATTRS" | jq -r '.access_level // "read-write"') + + ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type // ""') + + if [ "$ACTION_TYPE" = "delete" ]; then + # Unlink: read connection info from stored link attributes (written by write_link_outputs) + export SERVER_HOSTNAME=$(echo "$LINK_ATTRS" | jq -r '.hostname // ""') + export SERVER_PORT=$(echo "$LINK_ATTRS" | jq -r '.port // "5432"') + export SERVER_MASTER_SECRET_ARN=$(echo "$LINK_ATTRS" | jq -r '.master_secret_arn // ""') + + if [ -z "$SERVER_HOSTNAME" ]; then + echo "WARNING: hostname not found in link attributes — link was never created successfully. Nothing to destroy." + export LINK_NEVER_CREATED=true + exit 0 + fi + export LINK_NEVER_CREATED=false + else + # Link create/update: read connection info from the referenced server service + SERVER_SERVICE_ID=$(echo "$LINK_ATTRS" | jq -r '.server_service_id // ""') + + if [ -z "$SERVER_SERVICE_ID" ]; then + NAMESPACE_NRN=$(echo "$CONTEXT" | jq -r '.entity_nrn // ""' | sed 's/:application=[^:]*$//') + LINK_DIMENSIONS=$(echo "$CONTEXT" | jq -c '.link.dimensions // {}') + echo "server_service_id not provided, auto-discovering RDS server in ${NAMESPACE_NRN} (dimensions: ${LINK_DIMENSIONS})..." + + SERVER_SERVICES=$(np service list \ + --nrn "$NAMESPACE_NRN" \ + --type dependency \ + --status active \ + --format json | \ + jq --argjson link_dims "$LINK_DIMENSIONS" \ + '[(.results // .) | .[] | . as $svc | select( + (.attributes.hostname // "") != "" and + (.attributes.master_secret_arn // "") != "" and + ($link_dims | to_entries | all(. as $kv | ($svc.dimensions[$kv.key] // null) == $kv.value)) + )]') + SERVER_COUNT=$(echo "$SERVER_SERVICES" | jq 'length') + + if [ "$SERVER_COUNT" -eq 0 ]; then + echo "ERROR: No active RDS server service found in ${NAMESPACE_NRN} matching dimensions: ${LINK_DIMENSIONS}" >&2 + echo " Create an rds-postgres-server service with matching dimensions, or provide server_service_id explicitly." >&2 + exit 1 + elif [ "$SERVER_COUNT" -gt 1 ]; then + echo "ERROR: Multiple RDS server services found in ${NAMESPACE_NRN} matching dimensions: ${LINK_DIMENSIONS}" >&2 + echo " Specify server_service_id explicitly. Available options:" >&2 + echo "$SERVER_SERVICES" | jq -r '.[] | " - \(.id) \(.name) (\(.attributes.hostname))"' >&2 + exit 1 + fi + + SERVER_SERVICE_ID=$(echo "$SERVER_SERVICES" | jq -r '.[0].id') + echo "Auto-discovered server service: ${SERVER_SERVICE_ID} ($(echo "$SERVER_SERVICES" | jq -r '.[0].name'))" + fi + + echo "Reading rds-postgres-server attributes from service ${SERVER_SERVICE_ID}..." + SERVER_JSON=$(np service read --id "$SERVER_SERVICE_ID" --format json) + + export SERVER_HOSTNAME=$(echo "$SERVER_JSON" | jq -r '.attributes.hostname // ""') + export SERVER_PORT=$(echo "$SERVER_JSON" | jq -r '.attributes.port // "5432"') + export SERVER_MASTER_SECRET_ARN=$(echo "$SERVER_JSON" | jq -r '.attributes.master_secret_arn // ""') + + if [ -z "$SERVER_HOSTNAME" ]; then + echo "ERROR: server service ${SERVER_SERVICE_ID} has no hostname attribute." >&2 + echo " Has the rds-postgres-server been created successfully?" >&2 + exit 1 + fi + + if [ -z "$SERVER_MASTER_SECRET_ARN" ]; then + echo "ERROR: server service ${SERVER_SERVICE_ID} has no master_secret_arn attribute." >&2 + exit 1 + fi + fi +fi diff --git a/rds-postgres-db/scripts/aws/build_permissions_context b/rds-postgres-db/scripts/aws/build_permissions_context new file mode 100755 index 0000000..bf79d76 --- /dev/null +++ b/rds-postgres-db/scripts/aws/build_permissions_context @@ -0,0 +1,99 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# build_permissions_context — Prepares the Terraform execution environment +# for link/unlink actions. Runs AFTER build_context, which has exported: +# $CONTEXT, $VALUES, $SERVICE_PATH, $LINK_ID, $LINK_ACCESS_LEVEL, +# $SCOPE_NRN, $TFSTATE_BUCKET, +# $SERVER_HOSTNAME, $SERVER_PORT, $SERVER_MASTER_SECRET_ARN +# +# The database name and username are derived from the application ID +# extracted from SCOPE_NRN, making them deterministic and idempotent +# for the same application. +# --------------------------------------------------------------------------- + +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +REGION=$(yaml_value "region" "us-east-1" "$VALUES") +AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") + +if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then + export AWS_PROFILE="${AWS_PROFILE_VAL}" +fi + +# --- Use server connection info exported by build_context ------------------ + +if [ "${LINK_NEVER_CREATED:-}" = "true" ]; then + echo "Link was never created successfully — skipping permissions context setup." + exit 0 +fi + +DB_HOST="${SERVER_HOSTNAME:-}" +DB_PORT="${SERVER_PORT:-5432}" +MASTER_SECRET_ARN="${SERVER_MASTER_SECRET_ARN:-}" + +if [ -z "$DB_HOST" ]; then + echo "ERROR: SERVER_HOSTNAME not set. Did build_context run successfully?" >&2 + exit 1 +fi + +if [ -z "$MASTER_SECRET_ARN" ]; then + echo "ERROR: SERVER_MASTER_SECRET_ARN not set. Did build_context run successfully?" >&2 + exit 1 +fi + +# --- Retrieve master credentials from Secrets Manager ---------------------- + +echo "Retrieving master credentials from Secrets Manager..." +MASTER_CREDS=$(aws secretsmanager get-secret-value \ + --secret-id "$MASTER_SECRET_ARN" \ + --query 'SecretString' \ + --output text) + +MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username') +MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password') + +# --- Derive DB name and username from application ID ----------------------- +# Extract application_id from notification tags (always present in the context). +# Both the database name and username are set to app_, +# making them deterministic and idempotent for the same application. + +APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // ""') + +if [ -z "$APPLICATION_ID" ]; then + echo "ERROR: Could not extract application_id from context tags." >&2 + echo " CONTEXT tags: $(echo "$CONTEXT" | jq -r '.tags // {}')" >&2 + exit 1 +fi + +# PostgreSQL identifiers max 63 chars. "app_" is well within that limit. +DB_NAME="app_${APPLICATION_ID}" +DB_USERNAME="app_${APPLICATION_ID}" + +echo "Database name: ${DB_NAME}" +echo "DB username: ${DB_USERNAME}" + +# --- Prepare link-specific working directory -------------------------------- + +export OUTPUT_DIR="/tmp/np-link-${LINK_ID}" +mkdir -p "$OUTPUT_DIR" + +# Write sensitive master password to auto.tfvars to keep it out of the process list. +cat > "$OUTPUT_DIR/sensitive.auto.tfvars" <) +# --------------------------------------------------------------------------- + +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +REGION=$(yaml_value "region" "us-east-1" "$VALUES") + +if [ -z "${TFSTATE_BUCKET:-}" ]; then + echo "TFSTATE_BUCKET is not set, skipping bucket cleanup." + exit 0 +fi + +if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then + echo "Bucket ${TFSTATE_BUCKET} does not exist, nothing to delete." + exit 0 +fi + +delete_objects() { + local query="$1" + local label="$2" + local objects + local rc=0 + + objects=$(aws s3api list-object-versions \ + --bucket "$TFSTATE_BUCKET" \ + --region "$REGION" \ + --query "${query}" \ + --output json) || rc=$? + + if [ "$rc" -ne 0 ]; then + echo "WARNING: list-object-versions failed (exit $rc) for ${label}, skipping." + return 0 + fi + + if [ "$objects" = "null" ] || [ -z "$objects" ] || [ "$objects" = "[]" ]; then + echo "No ${label} to delete." + return 0 + fi + + echo "Deleting ${label}..." + local tmpfile + tmpfile=$(mktemp) + printf '{"Objects": %s, "Quiet": true}' "$objects" > "$tmpfile" + aws s3api delete-objects \ + --bucket "$TFSTATE_BUCKET" \ + --region "$REGION" \ + --delete "file://${tmpfile}" + rm -f "$tmpfile" +} + +delete_objects 'Versions[].{Key:Key,VersionId:VersionId}' "object versions" +delete_objects 'DeleteMarkers[].{Key:Key,VersionId:VersionId}' "delete markers" + +echo "Deleting bucket ${TFSTATE_BUCKET}..." +aws s3api delete-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" +echo "Bucket ${TFSTATE_BUCKET} deleted." diff --git a/rds-postgres-db/scripts/aws/do_tofu b/rds-postgres-db/scripts/aws/do_tofu new file mode 100755 index 0000000..41839b8 --- /dev/null +++ b/rds-postgres-db/scripts/aws/do_tofu @@ -0,0 +1,86 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# do_tofu — Copies the Terraform module to OUTPUT_DIR and executes tofu. +# +# Expected environment variables (set by build_context or build_permissions_context): +# OUTPUT_DIR — working directory for this execution +# TOFU_MODULE_DIR — path to the Terraform module (deployment/ or permissions/) +# TOFU_INIT_VARIABLES — -backend-config= flags for tofu init +# TOFU_VARIABLES — -var= flags for tofu apply/destroy +# +# Set by the workflow YAML configuration block: +# TOFU_ACTION — "apply" or "destroy" +# --------------------------------------------------------------------------- + +TOFU_ACTION="${TOFU_ACTION:-apply}" + +if [ "${LINK_NEVER_CREATED:-}" = "true" ]; then + echo "Link was never created successfully — skipping tofu ${TOFU_ACTION}." + exit 0 +fi + +# --------------------------------------------------------------------------- +# Ensure tofu is available. On agents where tofu is not pre-installed, download +# and cache the binary in /tmp/np-tofu-bin so it is only fetched once per pod +# lifetime (the directory survives across actions but is cleared on pod restart). +# --------------------------------------------------------------------------- +if ! command -v tofu &>/dev/null; then + TOFU_VERSION="1.9.0" + TOFU_BIN_DIR="/tmp/np-tofu-bin" + TOFU_BIN="$TOFU_BIN_DIR/tofu" + + if [ ! -f "$TOFU_BIN" ]; then + echo "tofu not found in PATH, installing v${TOFU_VERSION} to ${TOFU_BIN_DIR}..." + mkdir -p "$TOFU_BIN_DIR" + curl -fsSL \ + "https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_amd64.zip" \ + -o /tmp/tofu.zip + unzip -qo /tmp/tofu.zip tofu -d "$TOFU_BIN_DIR" + chmod +x "$TOFU_BIN" + rm -f /tmp/tofu.zip + echo "tofu installed: $("$TOFU_BIN" version | head -1)" + else + echo "Using cached tofu at ${TOFU_BIN}" + fi + + export PATH="$TOFU_BIN_DIR:$PATH" +fi + +cd "$OUTPUT_DIR" + +# Copy module files into the working directory. +# Note: sensitive.auto.tfvars (if written by build_permissions_context) only +# exists in OUTPUT_DIR, not in TOFU_MODULE_DIR, so cp will not overwrite it. +cp -r "$TOFU_MODULE_DIR"/* . + +echo "Running: tofu init" +# shellcheck disable=SC2086 +tofu init $TOFU_INIT_VARIABLES + +TOFU_TARGET_FLAGS="" +if [ -n "${TOFU_TARGETS:-}" ]; then + IFS=',' read -ra _TARGETS <<< "$TOFU_TARGETS" + for target in "${_TARGETS[@]}"; do + TOFU_TARGET_FLAGS="$TOFU_TARGET_FLAGS -target=$target" + done +fi + +# --------------------------------------------------------------------------- +# Pre-import: if TOFU_IMPORT_DB_NAME is set, try to import the database into +# state before apply. This handles re-links where the DB already exists in +# PostgreSQL but not in the current tfstate. Failures are silently ignored +# (the database simply doesn't exist yet and will be created by apply). +# --------------------------------------------------------------------------- +if [ "${TOFU_ACTION}" = "apply" ] && [ -n "${TOFU_IMPORT_DB_NAME:-}" ]; then + echo "Attempting pre-import of postgresql_database.app (${TOFU_IMPORT_DB_NAME})..." + # shellcheck disable=SC2086 + tofu import $TOFU_VARIABLES "postgresql_database.app" "$TOFU_IMPORT_DB_NAME" 2>&1 \ + && echo "Pre-import succeeded (re-link: database already existed)" \ + || echo "Pre-import skipped (first link: database will be created)" +fi + +echo "Running: tofu $TOFU_ACTION" +# shellcheck disable=SC2086 +tofu "$TOFU_ACTION" -auto-approve $TOFU_VARIABLES $TOFU_TARGET_FLAGS diff --git a/rds-postgres-db/scripts/aws/reassign_owned b/rds-postgres-db/scripts/aws/reassign_owned new file mode 100755 index 0000000..59ac620 --- /dev/null +++ b/rds-postgres-db/scripts/aws/reassign_owned @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# reassign_owned — Before unlink, transfers ownership of all objects owned by +# the app role to the master user in the app database. +# +# PostgreSQL refuses to DROP ROLE when the role still owns objects (tables, +# sequences, etc. created by app migrations). Running REASSIGN OWNED in the +# correct database (app_) transfers ownership to master so the role can +# be dropped cleanly. +# +# Requires: CONTEXT, SERVER_HOSTNAME, SERVER_PORT, SERVER_MASTER_SECRET_ARN +# --------------------------------------------------------------------------- + +if [ "${LINK_NEVER_CREATED:-}" = "true" ]; then + echo "Link was never created successfully — skipping ownership reassignment." + exit 0 +fi + +APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // ""') + +if [ -z "$APPLICATION_ID" ]; then + echo "ERROR: Could not extract application_id from context tags." >&2 + exit 1 +fi + +export DB_NAME="app_${APPLICATION_ID}" +export DB_USERNAME="app_${APPLICATION_ID}" + +echo "Reassigning objects owned by ${DB_USERNAME} in database ${DB_NAME}..." + +MASTER_CREDS=$(aws secretsmanager get-secret-value \ + --secret-id "$SERVER_MASTER_SECRET_ARN" \ + --query 'SecretString' \ + --output text) + +export MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username') +export MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password') +export DB_HOST="$SERVER_HOSTNAME" +export DB_PORT="${SERVER_PORT:-5432}" + +# Ensure psql is available. Agent pod runs Alpine Linux — use apk. +if ! command -v psql &>/dev/null; then + echo "psql not found, installing postgresql-client via apk..." + apk add --no-cache postgresql-client -q +fi + +PGPASSWORD="$MASTER_PASS" psql \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$MASTER_USER" \ + -d "$DB_NAME" \ + -v ON_ERROR_STOP=on \ + -c "GRANT \"${DB_USERNAME}\" TO \"${MASTER_USER}\";" \ + -c "REASSIGN OWNED BY \"${DB_USERNAME}\" TO \"${MASTER_USER}\";" \ + -c "DROP OWNED BY \"${DB_USERNAME}\";" \ + -c "REVOKE \"${DB_USERNAME}\" FROM \"${MASTER_USER}\";" + +echo "Ownership reassignment complete." diff --git a/rds-postgres-db/scripts/aws/write_link_outputs b/rds-postgres-db/scripts/aws/write_link_outputs new file mode 100755 index 0000000..a47ae43 --- /dev/null +++ b/rds-postgres-db/scripts/aws/write_link_outputs @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# write_link_outputs — Reads Terraform outputs from the permissions module +# and writes them to the NP link attributes via the API. +# +# hostname, port, username, database_name (export: true) become plain env vars. +# password (export: {secret: true}) becomes a secret env var. +# --------------------------------------------------------------------------- + +cd "$OUTPUT_DIR" + +LINK_ID_VAL=$(echo "$CONTEXT" | jq -r '.link.id') + +echo "Reading Terraform outputs for link $LINK_ID_VAL..." + +ALL_OUTPUTS=$(tofu output -json 2>/dev/null || echo "{}") +HOSTNAME=$(echo "$ALL_OUTPUTS" | jq -r '.hostname.value // ""') +PORT=$(echo "$ALL_OUTPUTS" | jq -r '.port.value // "" | tostring') +DB_USERNAME=$(echo "$ALL_OUTPUTS" | jq -r '.db_username.value // ""') +DB_PASSWORD=$(echo "$ALL_OUTPUTS" | jq -r '.db_password.value // ""') +DATABASE_NAME=$(echo "$ALL_OUTPUTS" | jq -r '.database_name.value // ""') +MASTER_SECRET_ARN=$(echo "$ALL_OUTPUTS"| jq -r '.master_secret_arn.value // ""') + +if [ -z "$DB_USERNAME" ]; then + echo "WARNING: No db_username output found. Skipping link attribute update." + exit 0 +fi + +ATTRS=$(jq -n \ + --arg hostname "$HOSTNAME" \ + --arg port "$PORT" \ + --arg username "$DB_USERNAME" \ + --arg password "$DB_PASSWORD" \ + --arg database_name "$DATABASE_NAME" \ + --arg master_secret_arn "$MASTER_SECRET_ARN" \ + '{ + hostname: $hostname, + port: ($port | tonumber), + username: $username, + password: $password, + database_name: $database_name, + master_secret_arn: $master_secret_arn + }') + +echo "Updating link $LINK_ID_VAL attributes:" +echo " hostname: $HOSTNAME" +echo " port: $PORT" +echo " username: $DB_USERNAME" +echo " database_name: $DATABASE_NAME" +echo " password: ****" + +np link patch --id "$LINK_ID_VAL" --body "{\"attributes\": $ATTRS}" +echo "Link attributes updated successfully." diff --git a/rds-postgres-db/scripts/aws/write_service_outputs b/rds-postgres-db/scripts/aws/write_service_outputs new file mode 100755 index 0000000..30668d3 --- /dev/null +++ b/rds-postgres-db/scripts/aws/write_service_outputs @@ -0,0 +1,34 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# write_service_outputs — Copies the rds-postgres-server's connection +# attributes into this service's attributes via the NP API. +# +# hostname and port (export: true) become env vars in apps when a link is +# activated. master_secret_arn (export: false) is used internally by +# build_permissions_context during link actions. +# --------------------------------------------------------------------------- + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') + +if [ -z "${SERVER_HOSTNAME:-}" ]; then + echo "WARNING: SERVER_HOSTNAME not set. Skipping attribute update." + exit 0 +fi + +ATTRS=$(jq -n \ + --arg hostname "$SERVER_HOSTNAME" \ + --arg port "${SERVER_PORT:-5432}" \ + --arg master_secret_arn "${SERVER_MASTER_SECRET_ARN:-}" \ + '{ + hostname: $hostname, + port: ($port | tonumber) + } + (if $master_secret_arn != "" then {master_secret_arn: $master_secret_arn} else {} end)') + +echo "Updating service $SERVICE_ID attributes:" +echo " hostname: $SERVER_HOSTNAME" +echo " port: ${SERVER_PORT:-5432}" + +np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}" +echo "Service attributes updated successfully." diff --git a/rds-postgres-db/specs/links/connect.json.tpl b/rds-postgres-db/specs/links/connect.json.tpl new file mode 100644 index 0000000..b4d8c41 --- /dev/null +++ b/rds-postgres-db/specs/links/connect.json.tpl @@ -0,0 +1,92 @@ +{ + "name": "Connect", + "slug": "connect", + "unique": false, + "assignable_to": "any", + "use_default_actions": true, + "selectors": { + "category": "Database", + "imported": false, + "provider": "AWS", + "sub_category": "Relational Database" + }, + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [], + "properties": { + "server_service_id": { + "type": "string", + "title": "RDS Server Service ID", + "description": "ID of the rds-postgres-server service. Auto-discovered from the namespace (override only if multiple exist).", + "editableOn": [], + "visibleOn": [], + "order": 1 + }, + "access_level": { + "enum": ["read", "write", "read-write"], + "type": "string", + "title": "Access Level", + "default": "read-write", + "editableOn": ["create", "update"], + "description": "Permission level: read (SELECT), write (INSERT/UPDATE/DELETE), read-write (both)", + "order": 2 + }, + "hostname": { + "type": "string", + "title": "Hostname", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "RDS endpoint hostname", + "order": 3 + }, + "port": { + "type": "number", + "title": "Port", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "RDS port", + "order": 4 + }, + "username": { + "type": "string", + "title": "DB Username", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "Database username (derived from application ID)", + "order": 5 + }, + "password": { + "type": "string", + "title": "DB Password", + "export": {"type": "environment_variable", "secret": true}, + "visibleOn": ["read"], + "editableOn": [], + "description": "Database password (auto-generated)", + "order": 6 + }, + "database_name": { + "type": "string", + "title": "Database", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "Database name (derived from application ID)", + "order": 7 + }, + "master_secret_arn": { + "type": "string", + "export": false, + "visibleOn": [], + "editableOn": [], + "description": "ARN of the Secrets Manager secret for master credentials (internal use)" + } + } + }, + "values": {} + } +} diff --git a/rds-postgres-db/specs/service-spec.json.tpl b/rds-postgres-db/specs/service-spec.json.tpl new file mode 100644 index 0000000..b465bd4 --- /dev/null +++ b/rds-postgres-db/specs/service-spec.json.tpl @@ -0,0 +1,24 @@ +{ + "name": "RDS PostgreSQL DB", + "slug": "rds-postgres-db", + "type": "dependency", + "unique": false, + "assignable_to": "any", + "use_default_actions": true, + "available_links": ["connect"], + "selectors": { + "category": "Database", + "imported": false, + "provider": "AWS", + "sub_category": "Relational Database" + }, + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [], + "properties": {} + }, + "values": {} + } +} diff --git a/rds-postgres-db/values.yaml b/rds-postgres-db/values.yaml new file mode 100644 index 0000000..63b1d2b --- /dev/null +++ b/rds-postgres-db/values.yaml @@ -0,0 +1,18 @@ +# RDS PostgreSQL Service — Static Configuration +# These values are not exposed in the NP UI. They configure the execution +# environment for the agent running this service. +# +# NOTE: In scripts, $VALUES is a FILE PATH (set by np service workflow exec --values). +# It is NOT JSON content. Read values with yaml_value() from build_context. + +# AWS region where RDS instances are created +region: us-east-1 + +# VPC ID where RDS instances will be deployed +vpc_id: "vpc-0a5dfe8e463dee15d" + +# Named AWS profile for local testing (e.g. SSO profile with RDS access) +# If set and AWS_PROFILE is not already in the environment, build_context +# will export it so Terraform and AWS CLI use the correct credentials. +# Run "aws sso login --profile " before starting np-agent locally. +aws_profile: "" diff --git a/rds-postgres-db/workflows/aws/create.yaml b/rds-postgres-db/workflows/aws/create.yaml new file mode 100644 index 0000000..72642ad --- /dev/null +++ b/rds-postgres-db/workflows/aws/create.yaml @@ -0,0 +1,7 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: TFSTATE_BUCKET + type: environment diff --git a/rds-postgres-db/workflows/aws/delete.yaml b/rds-postgres-db/workflows/aws/delete.yaml new file mode 100644 index 0000000..c17f301 --- /dev/null +++ b/rds-postgres-db/workflows/aws/delete.yaml @@ -0,0 +1,11 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: TFSTATE_BUCKET + type: environment + + - name: cleanup tfstate bucket + type: script + file: $SERVICE_PATH/scripts/aws/delete_tfstate_bucket diff --git a/rds-postgres-db/workflows/aws/link.yaml b/rds-postgres-db/workflows/aws/link.yaml new file mode 100644 index 0000000..25e19d2 --- /dev/null +++ b/rds-postgres-db/workflows/aws/link.yaml @@ -0,0 +1,50 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: OUTPUT_DIR + type: environment + - name: TFSTATE_BUCKET + type: environment + - name: LINK_ID + type: environment + - name: LINK_NAME + type: environment + - name: SCOPE_ID + type: environment + - name: SCOPE_NRN + type: environment + - name: LINK_ACCESS_LEVEL + type: environment + - name: SERVER_HOSTNAME + type: environment + - name: SERVER_PORT + type: environment + - name: SERVER_MASTER_SECRET_ARN + type: environment + + - name: build permissions context + type: script + file: $SERVICE_PATH/scripts/aws/build_permissions_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + - name: TOFU_IMPORT_DB_NAME + type: environment + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: apply + + - name: write link outputs + type: script + file: $SERVICE_PATH/scripts/aws/write_link_outputs diff --git a/rds-postgres-db/workflows/aws/unlink.yaml b/rds-postgres-db/workflows/aws/unlink.yaml new file mode 100644 index 0000000..dbfa35c --- /dev/null +++ b/rds-postgres-db/workflows/aws/unlink.yaml @@ -0,0 +1,53 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: OUTPUT_DIR + type: environment + - name: TFSTATE_BUCKET + type: environment + - name: LINK_ID + type: environment + - name: LINK_NAME + type: environment + - name: SCOPE_ID + type: environment + - name: SCOPE_NRN + type: environment + - name: LINK_ACCESS_LEVEL + type: environment + - name: SERVER_HOSTNAME + type: environment + - name: SERVER_PORT + type: environment + - name: SERVER_MASTER_SECRET_ARN + type: environment + - name: LINK_NEVER_CREATED + type: environment + + - name: build permissions context + type: script + file: $SERVICE_PATH/scripts/aws/build_permissions_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + + - name: reassign owned objects + type: script + file: $SERVICE_PATH/scripts/aws/reassign_owned + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: destroy + # Destroy only access resources (role + grants). The database is preserved + # so data survives unlink and is available if the app re-links later. + TOFU_TARGETS: "postgresql_default_privileges.sequences,postgresql_grant.sequences,postgresql_default_privileges.tables,postgresql_grant.tables,postgresql_grant.schema_usage,postgresql_grant.connect,postgresql_role.app_user,random_password.user" diff --git a/rds-postgres-db/workflows/aws/update.yaml b/rds-postgres-db/workflows/aws/update.yaml new file mode 100644 index 0000000..72642ad --- /dev/null +++ b/rds-postgres-db/workflows/aws/update.yaml @@ -0,0 +1,7 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: TFSTATE_BUCKET + type: environment diff --git a/rds-postgres-server/deployment/backend.tf b/rds-postgres-server/deployment/backend.tf new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/rds-postgres-server/deployment/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/rds-postgres-server/deployment/data.tf b/rds-postgres-server/deployment/data.tf new file mode 100644 index 0000000..9854479 --- /dev/null +++ b/rds-postgres-server/deployment/data.tf @@ -0,0 +1,15 @@ +# Discover shared RDS infrastructure (tagged by nullplatform during setup) + +data "aws_vpc" "main" { + id = var.vpc_id +} + +data "aws_subnets" "private" { + filter { + name = "vpc-id" + values = [var.vpc_id] + } + tags = { + "nullplatform/subnet-type" = "private" + } +} diff --git a/rds-postgres-server/deployment/main.tf b/rds-postgres-server/deployment/main.tf new file mode 100644 index 0000000..24f05dc --- /dev/null +++ b/rds-postgres-server/deployment/main.tf @@ -0,0 +1,103 @@ +# --------------------------------------------------------------------------- +# Security group for RDS (allows PostgreSQL traffic from within the VPC) +# --------------------------------------------------------------------------- + +resource "aws_security_group" "rds" { + name = "np-rds-${var.instance_name}" + description = "Allow PostgreSQL access from within the VPC" + vpc_id = var.vpc_id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + cidr_blocks = [data.aws_vpc.main.cidr_block] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + "managed-by" = "nullplatform" + "service-id" = var.service_id + } +} + +# --------------------------------------------------------------------------- +# Master password (stored in Secrets Manager, used by link permissions) +# --------------------------------------------------------------------------- + +resource "random_password" "master" { + length = 32 + special = false +} + +resource "aws_secretsmanager_secret" "master" { + name = "nullplatform/rds/${var.instance_name}/master" + recovery_window_in_days = 0 + + tags = { + "managed-by" = "nullplatform" + "rds-instance" = var.instance_name + "service-id" = var.service_id + } +} + +resource "aws_secretsmanager_secret_version" "master" { + secret_id = aws_secretsmanager_secret.master.id + secret_string = jsonencode({ + username = "master" + password = random_password.master.result + }) +} + +# --------------------------------------------------------------------------- +# RDS instance +# --------------------------------------------------------------------------- + +resource "aws_db_subnet_group" "main" { + name = var.instance_name + subnet_ids = data.aws_subnets.private.ids + + tags = { + "managed-by" = "nullplatform" + "service-id" = var.service_id + } +} + +resource "aws_db_instance" "main" { + identifier = var.instance_name + engine = "postgres" + engine_version = var.postgres_version + instance_class = var.instance_class + allocated_storage = var.allocated_storage + storage_type = "gp3" + storage_encrypted = true + + db_name = "postgres" + username = "master" + password = random_password.master.result + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.rds.id] + + multi_az = var.multi_az + publicly_accessible = false + skip_final_snapshot = true + deletion_protection = false + + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + + tags = { + "managed-by" = "nullplatform" + "service-id" = var.service_id + } + + depends_on = [aws_secretsmanager_secret_version.master] +} diff --git a/rds-postgres-server/deployment/outputs.tf b/rds-postgres-server/deployment/outputs.tf new file mode 100644 index 0000000..31bd6ae --- /dev/null +++ b/rds-postgres-server/deployment/outputs.tf @@ -0,0 +1,19 @@ +output "hostname" { + value = aws_db_instance.main.address + description = "RDS endpoint hostname" +} + +output "port" { + value = aws_db_instance.main.port + description = "RDS port" +} + +output "db_instance_identifier" { + value = aws_db_instance.main.identifier + description = "AWS RDS instance identifier" +} + +output "master_secret_arn" { + value = aws_secretsmanager_secret.master.arn + description = "ARN of the Secrets Manager secret for master credentials" +} diff --git a/rds-postgres-server/deployment/providers.tf b/rds-postgres-server/deployment/providers.tf new file mode 100644 index 0000000..4988945 --- /dev/null +++ b/rds-postgres-server/deployment/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "aws" { + region = var.region +} diff --git a/rds-postgres-server/deployment/variables.tf b/rds-postgres-server/deployment/variables.tf new file mode 100644 index 0000000..673ba42 --- /dev/null +++ b/rds-postgres-server/deployment/variables.tf @@ -0,0 +1,62 @@ +variable "service_id" { + type = string + description = "Nullplatform service ID" +} + +variable "instance_name" { + type = string + description = "Unique instance name for AWS resource naming (format: np-)" +} + +variable "region" { + type = string + default = "us-east-1" + description = "AWS region" +} + +variable "vpc_id" { + type = string + description = "VPC ID where the RDS instance will be deployed" +} + +variable "instance_class" { + type = string + default = "db.t3.micro" + description = "RDS instance class" +} + +variable "allocated_storage" { + type = number + default = 20 + description = "Allocated storage in GB" +} + +variable "postgres_version" { + type = string + default = "16" + description = "PostgreSQL major version" +} + +variable "multi_az" { + type = bool + default = false + description = "Enable Multi-AZ deployment for high availability" +} + +variable "backup_retention_period" { + type = number + default = 7 + description = "Number of days to retain automated backups. 0 disables backups." +} + +variable "backup_window" { + type = string + default = "03:00-04:00" + description = "Daily time range for automated backups (UTC, hh:mm-hh:mm)" +} + +variable "maintenance_window" { + type = string + default = "Mon:04:00-Mon:05:00" + description = "Weekly time range for maintenance operations (UTC, ddd:hh:mm-ddd:hh:mm)" +} diff --git a/rds-postgres-server/entrypoint/entrypoint b/rds-postgres-server/entrypoint/entrypoint new file mode 100755 index 0000000..f8b84ad --- /dev/null +++ b/rds-postgres-server/entrypoint/entrypoint @@ -0,0 +1,83 @@ +#!/bin/bash +set -euo pipefail + +if [ -z "${NP_ACTION_CONTEXT:-}" ]; then + echo "NP_ACTION_CONTEXT is not set. Exiting." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Bridge agent API key to the np CLI. +# np-agent passes NP_API_KEY; the np CLI expects NULLPLATFORM_API_KEY. +# Without this bridge, commands like "np service patch" fail with: +# "please login first, refresh token or organization id not found" +# --------------------------------------------------------------------------- +if [ -n "${NP_API_KEY:-}" ] && [ -z "${NULLPLATFORM_API_KEY:-}" ]; then + export NULLPLATFORM_API_KEY="$NP_API_KEY" +fi + +# Strip surrounding single quotes that np-agent may wrap the context in +CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") +export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" + +export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') +export SERVICE_ACTION=$(echo "$CONTEXT" | jq -r '.slug') +export SERVICE_ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type') +export NOTIFICATION_ACTION=$(echo "$CONTEXT" | jq -r '.action') +export LINK=$(echo "$CONTEXT" | jq '.link') + +ACTION_SOURCE=service +IS_LINK_ACTION=$(echo "$CONTEXT" | jq '.link != null') + +if [ "$IS_LINK_ACTION" = "true" ]; then + ACTION_SOURCE=link +fi + +export WORKING_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SERVICE_PATH="" +OVERRIDES_PATH="" + +for arg in "$@"; do + case $arg in + --service-path=*) + SERVICE_PATH="${arg#*=}" + ;; + --overrides-path=*) + OVERRIDES_PATH="${arg#*=}" + ;; + *) + echo "Unknown argument: $arg" + exit 1 + ;; + esac +done + +SERVICE_PATH="${SERVICE_PATH:-$(dirname "$WORKING_DIRECTORY")}" + +# --------------------------------------------------------------------------- +# Resolve relative SERVICE_PATH to absolute. +# The agent child process inherits the CWD of where np-agent was started, +# which is NOT necessarily ~/.np/ (the basepath). Try the current CWD first, +# then fall back to ~/.np/ as the basepath. +# Without this fallback, "cd " fails silently (exit 1, no output) +# when the CWD doesn't contain the service path. +# --------------------------------------------------------------------------- +if [[ "$SERVICE_PATH" != /* ]]; then + if [ -d "$SERVICE_PATH" ]; then + SERVICE_PATH="$(cd "$SERVICE_PATH" && pwd)" + elif [ -d "$HOME/.np/$SERVICE_PATH" ]; then + SERVICE_PATH="$(cd "$HOME/.np/$SERVICE_PATH" && pwd)" + else + echo "ERROR: Cannot resolve SERVICE_PATH='$SERVICE_PATH' from CWD=$(pwd) or $HOME/.np/" + exit 1 + fi +fi + +OVERRIDES_PATH="${OVERRIDES_PATH:-$SERVICE_PATH/overrides}" + +export SERVICE_PATH +export OVERRIDES_PATH +export ACTION_SOURCE + +np service-action exec --live-output --live-report --script="$WORKING_DIRECTORY/$ACTION_SOURCE" diff --git a/rds-postgres-server/entrypoint/link b/rds-postgres-server/entrypoint/link new file mode 100755 index 0000000..8014267 --- /dev/null +++ b/rds-postgres-server/entrypoint/link @@ -0,0 +1,34 @@ +#!/bin/bash + +echo "Executing link action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE" + +ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE" + +case "$SERVICE_ACTION_TYPE" in + "custom") + ACTION_TO_EXECUTE="$SERVICE_ACTION" + ;; + "create") + ACTION_TO_EXECUTE="link" + ;; + "delete") + ACTION_TO_EXECUTE="unlink" + ;; +esac + +WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +VALUES_PATH="$SERVICE_PATH/values.yaml" + +CMD="np service workflow exec --workflow $WORKFLOW_PATH" + +if [[ -f "$VALUES_PATH" ]]; then + CMD="$CMD --values $VALUES_PATH" +fi + +if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then + CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH" +fi + +echo "Executing command: $CMD" +eval "$CMD" diff --git a/rds-postgres-server/entrypoint/service b/rds-postgres-server/entrypoint/service new file mode 100755 index 0000000..e876d23 --- /dev/null +++ b/rds-postgres-server/entrypoint/service @@ -0,0 +1,28 @@ +#!/bin/bash + +echo "Executing service action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE" + +ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE" + +case "$SERVICE_ACTION_TYPE" in + "custom") + ACTION_TO_EXECUTE="$SERVICE_ACTION" + ;; +esac + +WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml" +VALUES_PATH="$SERVICE_PATH/values.yaml" + +CMD="np service workflow exec --workflow $WORKFLOW_PATH" + +if [[ -f "$VALUES_PATH" ]]; then + CMD="$CMD --values $VALUES_PATH" +fi + +if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then + CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH" +fi + +echo "Executing command: $CMD" +eval "$CMD" diff --git a/rds-postgres-server/permissions/backend.tf b/rds-postgres-server/permissions/backend.tf new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/rds-postgres-server/permissions/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/rds-postgres-server/permissions/locals.tf b/rds-postgres-server/permissions/locals.tf new file mode 100644 index 0000000..b0aa1e6 --- /dev/null +++ b/rds-postgres-server/permissions/locals.tf @@ -0,0 +1,12 @@ +locals { + table_privileges = { + # read: SELECT only + "read" = ["SELECT"] + + # write: INSERT/UPDATE/DELETE without SELECT (write-only) + "write" = ["INSERT", "UPDATE", "DELETE"] + + # read-write: full DML access + "read-write" = ["SELECT", "INSERT", "UPDATE", "DELETE"] + } +} diff --git a/rds-postgres-server/permissions/main.tf b/rds-postgres-server/permissions/main.tf new file mode 100644 index 0000000..1ade962 --- /dev/null +++ b/rds-postgres-server/permissions/main.tf @@ -0,0 +1,66 @@ +# --------------------------------------------------------------------------- +# Database (idempotent: creates if not exists, owned by master) +# --------------------------------------------------------------------------- + +resource "postgresql_database" "app" { + name = var.db_name + owner = var.master_username +} + +# --------------------------------------------------------------------------- +# App user (password generated by Terraform, stable across re-applies) +# The keepers block ensures the password is only regenerated if link_id +# changes (which it won't), preserving credentials on update workflows. +# --------------------------------------------------------------------------- + +resource "random_password" "user" { + length = 32 + special = false + keepers = { + link_id = var.link_id + } +} + +resource "postgresql_role" "app_user" { + name = var.db_username + password = random_password.user.result + login = true +} + +# --------------------------------------------------------------------------- +# Permissions +# --------------------------------------------------------------------------- + +resource "postgresql_grant" "connect" { + database = postgresql_database.app.name + role = postgresql_role.app_user.name + object_type = "database" + privileges = ["CONNECT"] +} + +resource "postgresql_grant" "schema_usage" { + database = postgresql_database.app.name + role = postgresql_role.app_user.name + schema = "public" + object_type = "schema" + privileges = ["USAGE"] +} + +# Grant on existing tables +resource "postgresql_grant" "tables" { + database = postgresql_database.app.name + role = postgresql_role.app_user.name + schema = "public" + object_type = "table" + privileges = local.table_privileges[var.access_level] +} + +# Grant on future tables (default privileges applied by master user) +resource "postgresql_default_privileges" "tables" { + role = postgresql_role.app_user.name + database = postgresql_database.app.name + schema = "public" + owner = var.master_username + object_type = "table" + privileges = local.table_privileges[var.access_level] +} diff --git a/rds-postgres-server/permissions/outputs.tf b/rds-postgres-server/permissions/outputs.tf new file mode 100644 index 0000000..c0ff22f --- /dev/null +++ b/rds-postgres-server/permissions/outputs.tf @@ -0,0 +1,15 @@ +output "db_username" { + value = postgresql_role.app_user.name + description = "Created database username" +} + +output "db_password" { + value = random_password.user.result + sensitive = true + description = "Database user password" +} + +output "database_name" { + value = postgresql_database.app.name + description = "Database name" +} diff --git a/rds-postgres-server/permissions/providers.tf b/rds-postgres-server/permissions/providers.tf new file mode 100644 index 0000000..976a2bf --- /dev/null +++ b/rds-postgres-server/permissions/providers.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + version = "~> 1.21" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "postgresql" { + host = var.db_host + port = var.db_port + database = "postgres" + username = var.master_username + password = var.master_password + sslmode = "require" + superuser = false +} diff --git a/rds-postgres-server/permissions/variables.tf b/rds-postgres-server/permissions/variables.tf new file mode 100644 index 0000000..b2ee1d2 --- /dev/null +++ b/rds-postgres-server/permissions/variables.tf @@ -0,0 +1,47 @@ +variable "link_id" { + type = string + description = "Nullplatform link ID (used as keeper to stabilize password across re-applies)" +} + +variable "db_host" { + type = string + description = "RDS endpoint hostname" +} + +variable "db_port" { + type = number + default = 5432 + description = "RDS port" +} + +variable "db_name" { + type = string + description = "Database name to create inside the RDS instance" +} + +variable "db_username" { + type = string + description = "Username for the new database user (derived from link ID)" +} + +variable "master_username" { + type = string + description = "Master username for connecting to PostgreSQL" +} + +variable "master_password" { + type = string + sensitive = true + description = "Master password for connecting to PostgreSQL (passed via auto.tfvars)" +} + +variable "access_level" { + type = string + default = "read-write" + description = "Permission level: read, write, or read-write" + + validation { + condition = contains(["read", "write", "read-write"], var.access_level) + error_message = "access_level must be one of: read, write, read-write" + } +} diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context new file mode 100755 index 0000000..99faf06 --- /dev/null +++ b/rds-postgres-server/scripts/aws/build_context @@ -0,0 +1,101 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# build_context — Extracts variables from the NP notification context and +# prepares the Terraform execution environment for service actions. +# +# For link actions, also extracts LINK_* variables consumed by +# build_permissions_context in the next workflow step. +# --------------------------------------------------------------------------- + +# --- Parse service context -------------------------------------------------- + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') +SERVICE_NAME=$(echo "$CONTEXT" | jq -r '.service.name // ""') +SERVICE_NAME="${SERVICE_NAME:-svc-${SERVICE_ID}}" + +# Sanitize name for use in AWS resource identifiers (lowercase, alphanumeric + hyphens, max 55 chars) +INSTANCE_NAME="np-$(echo "$SERVICE_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//' | cut -c1-55)" + +# Merge .service.attributes with action .parameters. +# CRITICAL: On the first "create" action, .service.attributes is empty or incomplete. +# The user-provided values are in .parameters. The jq * operator merges objects +# with the right-hand side taking precedence, so parameters always win. +SERVICE_ATTRS=$(echo "$CONTEXT" | jq -r '(.service.attributes // {}) * (.parameters // {})') + +INSTANCE_CLASS=$(echo "$SERVICE_ATTRS" | jq -r '.instance_class // "db.t3.micro"') +ALLOCATED_STORAGE=$(echo "$SERVICE_ATTRS" | jq -r '.allocated_storage // 20') +POSTGRES_VERSION=$(echo "$SERVICE_ATTRS" | jq -r '.postgres_version // "16"') + +# --- Read static config from values.yaml ------------------------------------ +# CRITICAL: $VALUES is a FILE PATH set by "np service workflow exec --values ", +# NOT JSON content. Use yaml_value() to read individual keys. +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +REGION=$(yaml_value "region" "us-east-1" "$VALUES") +VPC_ID=$(yaml_value "vpc_id" "" "$VALUES") +AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") + +if [ -z "$VPC_ID" ]; then + echo "ERROR: vpc_id is required in values.yaml" >&2 + exit 1 +fi + +if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then + export AWS_PROFILE="${AWS_PROFILE_VAL}" +fi + +# --- Ensure per-instance tfstate bucket exists ------------------------------ +# Each service instance gets its own S3 bucket so state is isolated and the +# bucket name is deterministic (reconstructable from SERVICE_ID alone). +# The agent IAM role must allow s3:CreateBucket + s3:PutBucketVersioning +# on arn:aws:s3:::np-service-* and the usual Get/Put/Delete/List actions. + +TFSTATE_BUCKET="np-service-${SERVICE_ID}" + +if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then + echo "Creating tfstate bucket: ${TFSTATE_BUCKET}" + if [ "$REGION" = "us-east-1" ]; then + aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" + else + aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" \ + --create-bucket-configuration LocationConstraint="$REGION" + fi + aws s3api put-bucket-versioning --bucket "$TFSTATE_BUCKET" \ + --versioning-configuration Status=Enabled + echo "Bucket ${TFSTATE_BUCKET} created with versioning enabled." +else + echo "Using existing tfstate bucket: ${TFSTATE_BUCKET}" +fi + +export TFSTATE_BUCKET + +# --- Set Terraform execution variables -------------------------------------- + +export OUTPUT_DIR="/tmp/np-service-${SERVICE_ID}" +mkdir -p "$OUTPUT_DIR" + +export TOFU_MODULE_DIR="$SERVICE_PATH/deployment" + +export TOFU_INIT_VARIABLES="-backend-config=bucket=${TFSTATE_BUCKET} -backend-config=key=terraform.tfstate -backend-config=region=${REGION}" + +export TOFU_VARIABLES="-var=service_id=${SERVICE_ID} -var=instance_name=${INSTANCE_NAME} -var=region=${REGION} -var=vpc_id=${VPC_ID} -var=instance_class=${INSTANCE_CLASS} -var=allocated_storage=${ALLOCATED_STORAGE} -var=postgres_version=${POSTGRES_VERSION}" + +# --- Extract link context (link workflows only) ----------------------------- +# These are consumed by build_permissions_context in the next workflow step. + +if [ "${ACTION_SOURCE:-}" = "link" ]; then + export LINK_ID=$(echo "$CONTEXT" | jq -r '.link.id // ""') + export LINK_NAME=$(echo "$CONTEXT" | jq -r '.link.name // ""') + export SCOPE_ID=$(echo "$CONTEXT" | jq -r '.link.scope.id // ""') + export SCOPE_NRN=$(echo "$CONTEXT" | jq -r '.link.scope.nrn // ""') + + LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})') + export LINK_ACCESS_LEVEL=$(echo "$LINK_ATTRS" | jq -r '.access_level // "read-write"') +fi diff --git a/rds-postgres-server/scripts/aws/build_permissions_context b/rds-postgres-server/scripts/aws/build_permissions_context new file mode 100755 index 0000000..9cb853b --- /dev/null +++ b/rds-postgres-server/scripts/aws/build_permissions_context @@ -0,0 +1,97 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# build_permissions_context — Prepares the Terraform execution environment +# for link actions (link/unlink). Runs AFTER build_context, which has set: +# $CONTEXT, $VALUES, $SERVICE_PATH, $LINK_ID, $LINK_ACCESS_LEVEL +# +# This script creates a DB user per link. It reads master credentials from +# Secrets Manager and passes them to Terraform via a sensitive.auto.tfvars +# file (avoids exposing the password in the OS process list). +# --------------------------------------------------------------------------- + +# IMPORTANT: This script has its own yaml_value() function. +# Shell functions do NOT carry over between scripts; each script is isolated. +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +REGION=$(yaml_value "region" "us-east-1" "$VALUES") +TFSTATE_BUCKET=$(yaml_value "tfstate_bucket" "" "$VALUES") +AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") + +if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then + export AWS_PROFILE="${AWS_PROFILE_VAL}" +fi + +# --- Read service outputs (set by write_service_outputs after RDS creation) - + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') +SERVICE_ATTRS=$(echo "$CONTEXT" | jq -r '.service.attributes // {}') + +DB_HOST=$(echo "$SERVICE_ATTRS" | jq -r '.hostname // ""') +DB_PORT=$(echo "$SERVICE_ATTRS" | jq -r '.port // "5432"') +MASTER_SECRET_ARN=$(echo "$SERVICE_ATTRS" | jq -r '.master_secret_arn // ""') + +if [ -z "$DB_HOST" ]; then + echo "ERROR: Service hostname not found in service attributes." >&2 + echo " Has the RDS instance been created and write_service_outputs completed?" >&2 + exit 1 +fi + +if [ -z "$MASTER_SECRET_ARN" ]; then + echo "ERROR: master_secret_arn not found in service attributes." >&2 + exit 1 +fi + +# --- Retrieve master credentials from Secrets Manager ---------------------- + +echo "Retrieving master credentials from Secrets Manager..." +MASTER_CREDS=$(aws secretsmanager get-secret-value \ + --secret-id "$MASTER_SECRET_ARN" \ + --query 'SecretString' \ + --output text) + +MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username') +MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password') + +# --- Derive link-specific DB username from link ID ------------------------- +# Deterministic: always produces the same username for the same link. +# Format: np_ (~20 chars, safe for Postgres) + +DB_USERNAME="np_$(echo "${LINK_ID}" | tr -d '-' | cut -c1-16 | tr '[:upper:]' '[:lower:]')" + +# --- Extract DB name from link parameters ---------------------------------- +# Merge .link.attributes with .parameters (parameters take precedence on create) + +LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})') +DB_NAME=$(echo "$LINK_ATTRS" | jq -r '.db_name // ""') + +if [ -z "$DB_NAME" ]; then + echo "ERROR: db_name is required to create a link" >&2 + exit 1 +fi + +# --- Prepare link-specific working directory -------------------------------- +# Override OUTPUT_DIR so each link has its own isolated workspace and tfstate. + +export OUTPUT_DIR="/tmp/np-link-${LINK_ID}" +mkdir -p "$OUTPUT_DIR" + +# Write sensitive master password to auto.tfvars instead of -var= flag. +# This keeps it out of the OS process list while Terraform is running. +cat > "$OUTPUT_DIR/sensitive.auto.tfvars" <) +# --------------------------------------------------------------------------- + +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +REGION=$(yaml_value "region" "us-east-1" "$VALUES") + +if [ -z "${TFSTATE_BUCKET:-}" ]; then + echo "TFSTATE_BUCKET is not set, skipping bucket cleanup." + exit 0 +fi + +if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then + echo "Bucket ${TFSTATE_BUCKET} does not exist, nothing to delete." + exit 0 +fi + +delete_objects() { + local query="$1" + local label="$2" + local objects + local rc=0 + + objects=$(aws s3api list-object-versions \ + --bucket "$TFSTATE_BUCKET" \ + --region "$REGION" \ + --query "${query}" \ + --output json) || rc=$? + + if [ "$rc" -ne 0 ]; then + echo "WARNING: list-object-versions failed (exit $rc) for ${label}, skipping." + return 0 + fi + + if [ "$objects" = "null" ] || [ -z "$objects" ] || [ "$objects" = "[]" ]; then + echo "No ${label} to delete." + return 0 + fi + + echo "Deleting ${label}..." + local tmpfile + tmpfile=$(mktemp) + printf '{"Objects": %s, "Quiet": true}' "$objects" > "$tmpfile" + aws s3api delete-objects \ + --bucket "$TFSTATE_BUCKET" \ + --region "$REGION" \ + --delete "file://${tmpfile}" + rm -f "$tmpfile" +} + +delete_objects 'Versions[].{Key:Key,VersionId:VersionId}' "object versions" +delete_objects 'DeleteMarkers[].{Key:Key,VersionId:VersionId}' "delete markers" + +echo "Deleting bucket ${TFSTATE_BUCKET}..." +aws s3api delete-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" +echo "Bucket ${TFSTATE_BUCKET} deleted." diff --git a/rds-postgres-server/scripts/aws/do_tofu b/rds-postgres-server/scripts/aws/do_tofu new file mode 100755 index 0000000..c25adc4 --- /dev/null +++ b/rds-postgres-server/scripts/aws/do_tofu @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# do_tofu — Copies the Terraform module to OUTPUT_DIR and executes tofu. +# +# Expected environment variables (set by build_context or build_permissions_context): +# OUTPUT_DIR — working directory for this execution +# TOFU_MODULE_DIR — path to the Terraform module (deployment/ or permissions/) +# TOFU_INIT_VARIABLES — -backend-config= flags for tofu init +# TOFU_VARIABLES — -var= flags for tofu apply/destroy +# +# Set by the workflow YAML configuration block: +# TOFU_ACTION — "apply" or "destroy" +# --------------------------------------------------------------------------- + +TOFU_ACTION="${TOFU_ACTION:-apply}" + +# --------------------------------------------------------------------------- +# Ensure tofu is available. On agents where tofu is not pre-installed, download +# and cache the binary in /tmp/np-tofu-bin so it is only fetched once per pod +# lifetime (the directory survives across actions but is cleared on pod restart). +# --------------------------------------------------------------------------- +if ! command -v tofu &>/dev/null; then + TOFU_VERSION="1.9.0" + TOFU_BIN_DIR="/tmp/np-tofu-bin" + TOFU_BIN="$TOFU_BIN_DIR/tofu" + + if [ ! -f "$TOFU_BIN" ]; then + echo "tofu not found in PATH, installing v${TOFU_VERSION} to ${TOFU_BIN_DIR}..." + mkdir -p "$TOFU_BIN_DIR" + curl -fsSL \ + "https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_amd64.zip" \ + -o /tmp/tofu.zip + unzip -qo /tmp/tofu.zip tofu -d "$TOFU_BIN_DIR" + chmod +x "$TOFU_BIN" + rm -f /tmp/tofu.zip + echo "tofu installed: $("$TOFU_BIN" version | head -1)" + else + echo "Using cached tofu at ${TOFU_BIN}" + fi + + export PATH="$TOFU_BIN_DIR:$PATH" +fi + +cd "$OUTPUT_DIR" + +# Copy module files into the working directory. +# Note: sensitive.auto.tfvars (if written by build_permissions_context) only +# exists in OUTPUT_DIR, not in TOFU_MODULE_DIR, so cp will not overwrite it. +cp -r "$TOFU_MODULE_DIR"/* . + +echo "Running: tofu init" +# shellcheck disable=SC2086 +tofu init $TOFU_INIT_VARIABLES + +echo "Running: tofu $TOFU_ACTION" +# shellcheck disable=SC2086 +tofu "$TOFU_ACTION" -auto-approve $TOFU_VARIABLES diff --git a/rds-postgres-server/scripts/aws/write_link_outputs b/rds-postgres-server/scripts/aws/write_link_outputs new file mode 100755 index 0000000..755fb9d --- /dev/null +++ b/rds-postgres-server/scripts/aws/write_link_outputs @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# write_link_outputs — Reads Terraform outputs from the permissions module +# and writes them to the NP link attributes via the API. +# +# Fields with "export: true" (username, database_name) become plain env vars. +# Fields with "export: {secret: true}" (password) become secret env vars +# (value is stored encrypted and hidden in API responses, but injected +# into the app at runtime). +# --------------------------------------------------------------------------- + +cd "$OUTPUT_DIR" + +LINK_ID_VAL=$(echo "$CONTEXT" | jq -r '.link.id') + +echo "Reading Terraform outputs for link $LINK_ID_VAL..." + +DB_USERNAME=$(tofu output -raw db_username 2>/dev/null || echo "") +DB_PASSWORD=$(tofu output -raw db_password 2>/dev/null || echo "") +DATABASE_NAME=$(tofu output -raw database_name 2>/dev/null || echo "") + +if [ -z "$DB_USERNAME" ]; then + echo "WARNING: No db_username output found. Skipping link attribute update." + exit 0 +fi + +ATTRS=$(jq -n \ + --arg username "$DB_USERNAME" \ + --arg password "$DB_PASSWORD" \ + --arg database_name "$DATABASE_NAME" \ + '{ + username: $username, + password: $password, + database_name: $database_name + }') + +echo "Updating link $LINK_ID_VAL attributes:" +echo " username: $DB_USERNAME" +echo " database_name: $DATABASE_NAME" +echo " password: ****" + +np link patch --id "$LINK_ID_VAL" --body "{\"attributes\": $ATTRS}" +echo "Link attributes updated successfully." diff --git a/rds-postgres-server/scripts/aws/write_service_outputs b/rds-postgres-server/scripts/aws/write_service_outputs new file mode 100755 index 0000000..02f5bc6 --- /dev/null +++ b/rds-postgres-server/scripts/aws/write_service_outputs @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# write_service_outputs — Reads Terraform outputs from the deployment module +# and writes them to the NP service attributes via the API. +# +# Fields with "export: true" in the service spec (hostname, port) become +# env vars in apps when a link is activated. +# Fields with "export: false" (db_instance_identifier, master_secret_arn) are +# stored internally and used by build_permissions_context during link actions. +# --------------------------------------------------------------------------- + +cd "$OUTPUT_DIR" + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') + +echo "Reading Terraform outputs for service $SERVICE_ID..." + +HOSTNAME=$(tofu output -raw hostname 2>/dev/null || echo "") +PORT=$(tofu output -raw port 2>/dev/null || echo "") +DB_INSTANCE_IDENTIFIER=$(tofu output -raw db_instance_identifier 2>/dev/null || echo "") +MASTER_SECRET_ARN=$(tofu output -raw master_secret_arn 2>/dev/null || echo "") + +if [ -z "$HOSTNAME" ]; then + echo "WARNING: No hostname output found. Skipping attribute update." + exit 0 +fi + +ATTRS=$(jq -n \ + --arg hostname "$HOSTNAME" \ + --arg port "$PORT" \ + --arg db_instance_id "$DB_INSTANCE_IDENTIFIER" \ + --arg master_secret_arn "${MASTER_SECRET_ARN:-}" \ + '{ + hostname: $hostname, + port: ($port | tonumber), + db_instance_identifier: $db_instance_id + } + (if $master_secret_arn != "" then {master_secret_arn: $master_secret_arn} else {} end)') + +echo "Updating service $SERVICE_ID attributes:" +echo " hostname: $HOSTNAME" +echo " port: $PORT" +echo " db_instance_identifier: $DB_INSTANCE_IDENTIFIER" + +np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}" +echo "Service attributes updated successfully." diff --git a/rds-postgres-server/specs/links/connect.json.tpl b/rds-postgres-server/specs/links/connect.json.tpl new file mode 100644 index 0000000..329dcde --- /dev/null +++ b/rds-postgres-server/specs/links/connect.json.tpl @@ -0,0 +1,66 @@ +{ + "name": "Connect", + "slug": "connect", + "unique": false, + "assignable_to": "any", + "use_default_actions": true, + "selectors": { + "category": "Database", + "imported": false, + "provider": "AWS", + "sub_category": "Relational Database" + }, + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["db_name", "access_level"], + "properties": { + "db_name": { + "type": "string", + "title": "Database Name", + "description": "Name of the database to create inside the RDS instance", + "editableOn": ["create"], + "order": 1 + }, + "access_level": { + "enum": ["read", "write", "read-write"], + "type": "string", + "title": "Access Level", + "default": "read-write", + "editableOn": ["create", "update"], + "description": "Permission level: read (SELECT), write (INSERT/UPDATE/DELETE), read-write (both)", + "order": 2 + }, + "username": { + "type": "string", + "title": "DB Username", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "Database username (auto-populated after link creation)", + "order": 3 + }, + "password": { + "type": "string", + "title": "DB Password", + "export": {"type": "environment_variable", "secret": true}, + "visibleOn": ["read"], + "editableOn": [], + "description": "Database password (auto-populated, delivered as secret env var)", + "order": 4 + }, + "database_name": { + "type": "string", + "title": "Database", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "Database name (auto-populated after link creation)", + "order": 5 + } + } + }, + "values": {} + } +} diff --git a/rds-postgres-server/specs/service-spec.json.tpl b/rds-postgres-server/specs/service-spec.json.tpl new file mode 100644 index 0000000..d60120d --- /dev/null +++ b/rds-postgres-server/specs/service-spec.json.tpl @@ -0,0 +1,85 @@ +{ + "name": "RDS PostgreSQL", + "slug": "rds-postgres", + "type": "dependency", + "unique": false, + "assignable_to": "any", + "use_default_actions": true, + "available_links": ["connect"], + "selectors": { + "category": "Database", + "imported": false, + "provider": "AWS", + "sub_category": "Relational Database" + }, + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["instance_class"], + "properties": { + "instance_class": { + "type": "string", + "title": "Instance Class", + "default": "db.t3.micro", + "enum": ["db.t3.micro", "db.t3.small", "db.t3.medium", "db.m5.large"], + "description": "RDS instance type (affects CPU and RAM)", + "editableOn": ["create", "update"], + "order": 1 + }, + "allocated_storage": { + "type": "number", + "title": "Storage (GB)", + "default": 20, + "minimum": 20, + "maximum": 1000, + "description": "Allocated storage size in GB", + "editableOn": ["create", "update"], + "order": 2 + }, + "postgres_version": { + "type": "string", + "title": "PostgreSQL Version", + "default": "16", + "enum": ["14", "15", "16"], + "description": "PostgreSQL major version (cannot be changed after creation)", + "editableOn": ["create"], + "order": 3 + }, + "hostname": { + "type": "string", + "title": "Hostname", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "RDS endpoint hostname (auto-populated after creation)", + "order": 4 + }, + "port": { + "type": "number", + "title": "Port", + "export": true, + "visibleOn": ["read"], + "editableOn": [], + "description": "RDS port (auto-populated after creation)", + "order": 5 + }, + "db_instance_identifier": { + "type": "string", + "export": false, + "visibleOn": [], + "editableOn": [], + "description": "Internal AWS RDS instance identifier" + }, + "master_secret_arn": { + "type": "string", + "export": false, + "visibleOn": [], + "editableOn": [], + "description": "ARN of the Secrets Manager secret holding master credentials (internal use)" + } + } + }, + "values": {} + } +} diff --git a/rds-postgres-server/values.yaml b/rds-postgres-server/values.yaml new file mode 100644 index 0000000..63b1d2b --- /dev/null +++ b/rds-postgres-server/values.yaml @@ -0,0 +1,18 @@ +# RDS PostgreSQL Service — Static Configuration +# These values are not exposed in the NP UI. They configure the execution +# environment for the agent running this service. +# +# NOTE: In scripts, $VALUES is a FILE PATH (set by np service workflow exec --values). +# It is NOT JSON content. Read values with yaml_value() from build_context. + +# AWS region where RDS instances are created +region: us-east-1 + +# VPC ID where RDS instances will be deployed +vpc_id: "vpc-0a5dfe8e463dee15d" + +# Named AWS profile for local testing (e.g. SSO profile with RDS access) +# If set and AWS_PROFILE is not already in the environment, build_context +# will export it so Terraform and AWS CLI use the correct credentials. +# Run "aws sso login --profile " before starting np-agent locally. +aws_profile: "" diff --git a/rds-postgres-server/workflows/aws/create.yaml b/rds-postgres-server/workflows/aws/create.yaml new file mode 100644 index 0000000..28bc308 --- /dev/null +++ b/rds-postgres-server/workflows/aws/create.yaml @@ -0,0 +1,23 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: apply + + - name: write service outputs + type: script + file: $SERVICE_PATH/scripts/aws/write_service_outputs diff --git a/rds-postgres-server/workflows/aws/delete.yaml b/rds-postgres-server/workflows/aws/delete.yaml new file mode 100644 index 0000000..9faf6ae --- /dev/null +++ b/rds-postgres-server/workflows/aws/delete.yaml @@ -0,0 +1,23 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: destroy + + - name: cleanup tfstate bucket + type: script + file: $SERVICE_PATH/scripts/aws/delete_tfstate_bucket diff --git a/rds-postgres-server/workflows/aws/link.yaml b/rds-postgres-server/workflows/aws/link.yaml new file mode 100644 index 0000000..fe267fa --- /dev/null +++ b/rds-postgres-server/workflows/aws/link.yaml @@ -0,0 +1,40 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: OUTPUT_DIR + type: environment + - name: LINK_ID + type: environment + - name: LINK_NAME + type: environment + - name: SCOPE_ID + type: environment + - name: SCOPE_NRN + type: environment + - name: LINK_ACCESS_LEVEL + type: environment + + - name: build permissions context + type: script + file: $SERVICE_PATH/scripts/aws/build_permissions_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: apply + + - name: write link outputs + type: script + file: $SERVICE_PATH/scripts/aws/write_link_outputs diff --git a/rds-postgres-server/workflows/aws/unlink.yaml b/rds-postgres-server/workflows/aws/unlink.yaml new file mode 100644 index 0000000..061851c --- /dev/null +++ b/rds-postgres-server/workflows/aws/unlink.yaml @@ -0,0 +1,36 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: OUTPUT_DIR + type: environment + - name: LINK_ID + type: environment + - name: LINK_NAME + type: environment + - name: SCOPE_ID + type: environment + - name: SCOPE_NRN + type: environment + - name: LINK_ACCESS_LEVEL + type: environment + + - name: build permissions context + type: script + file: $SERVICE_PATH/scripts/aws/build_permissions_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: destroy diff --git a/rds-postgres-server/workflows/aws/update.yaml b/rds-postgres-server/workflows/aws/update.yaml new file mode 100644 index 0000000..28bc308 --- /dev/null +++ b/rds-postgres-server/workflows/aws/update.yaml @@ -0,0 +1,23 @@ +steps: + - name: build context + type: script + file: $SERVICE_PATH/scripts/aws/build_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: apply + + - name: write service outputs + type: script + file: $SERVICE_PATH/scripts/aws/write_service_outputs From 61ce597efebbd8ed99ffdb97b991860408eb97a6 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 19 Mar 2026 12:21:38 -0300 Subject: [PATCH 02/34] feat(databases-postgress): fix db instance create --- rds-postgres-db/db_setup/backend.tf | 3 + rds-postgres-db/db_setup/main.tf | 36 +++++ rds-postgres-db/db_setup/outputs.tf | 30 ++++ rds-postgres-db/db_setup/providers.tf | 22 +++ rds-postgres-db/db_setup/variables.tf | 41 ++++++ rds-postgres-db/permissions/main.tf | 66 +++------ rds-postgres-db/permissions/outputs.tf | 32 +---- rds-postgres-db/permissions/variables.tf | 14 +- rds-postgres-db/scripts/aws/build_context | 97 ++++--------- .../scripts/aws/build_db_setup_context | 132 ++++++++++++++++++ .../scripts/aws/build_permissions_context | 42 ++---- rds-postgres-db/scripts/aws/do_tofu | 9 +- rds-postgres-db/scripts/aws/reassign_owned | 34 ++--- .../scripts/aws/write_link_outputs | 39 +++--- .../scripts/aws/write_service_outputs | 54 ++++--- rds-postgres-db/specs/links/connect.json.tpl | 26 ++-- rds-postgres-db/specs/service-spec.json.tpl | 50 ++++++- rds-postgres-db/workflows/aws/create.yaml | 39 ++++++ rds-postgres-db/workflows/aws/delete.yaml | 40 ++++++ rds-postgres-db/workflows/aws/link.yaml | 6 +- rds-postgres-db/workflows/aws/unlink.yaml | 14 +- 21 files changed, 547 insertions(+), 279 deletions(-) create mode 100644 rds-postgres-db/db_setup/backend.tf create mode 100644 rds-postgres-db/db_setup/main.tf create mode 100644 rds-postgres-db/db_setup/outputs.tf create mode 100644 rds-postgres-db/db_setup/providers.tf create mode 100644 rds-postgres-db/db_setup/variables.tf create mode 100644 rds-postgres-db/scripts/aws/build_db_setup_context diff --git a/rds-postgres-db/db_setup/backend.tf b/rds-postgres-db/db_setup/backend.tf new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/rds-postgres-db/db_setup/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/rds-postgres-db/db_setup/main.tf b/rds-postgres-db/db_setup/main.tf new file mode 100644 index 0000000..fc4516c --- /dev/null +++ b/rds-postgres-db/db_setup/main.tf @@ -0,0 +1,36 @@ +# --------------------------------------------------------------------------- +# Database — created on service create, preserved on service delete. +# +# On re-creates (database already exists), do_tofu runs +# "tofu import postgresql_database.app " before apply so no data +# is lost. prevent_destroy ensures tofu destroy never drops the DB. +# --------------------------------------------------------------------------- + +resource "postgresql_database" "app" { + name = var.db_name + owner = var.master_username + + lifecycle { + prevent_destroy = true + } +} + +# --------------------------------------------------------------------------- +# App user — password is stable for the lifetime of the service. +# keepers use service_id so the password only regenerates if the service +# itself is recreated with a different ID. +# --------------------------------------------------------------------------- + +resource "random_password" "user" { + length = 32 + special = false + keepers = { + service_id = var.service_id + } +} + +resource "postgresql_role" "app_user" { + name = var.db_username + password = random_password.user.result + login = true +} diff --git a/rds-postgres-db/db_setup/outputs.tf b/rds-postgres-db/db_setup/outputs.tf new file mode 100644 index 0000000..20b5ff5 --- /dev/null +++ b/rds-postgres-db/db_setup/outputs.tf @@ -0,0 +1,30 @@ +output "hostname" { + value = var.db_host + description = "RDS endpoint hostname" +} + +output "port" { + value = var.db_port + description = "RDS port" +} + +output "master_secret_arn" { + value = var.master_secret_arn + description = "ARN of the Secrets Manager secret for master credentials" +} + +output "db_username" { + value = postgresql_role.app_user.name + description = "Created database username" +} + +output "db_password" { + value = random_password.user.result + sensitive = true + description = "Database user password" +} + +output "database_name" { + value = postgresql_database.app.name + description = "Database name" +} diff --git a/rds-postgres-db/db_setup/providers.tf b/rds-postgres-db/db_setup/providers.tf new file mode 100644 index 0000000..976a2bf --- /dev/null +++ b/rds-postgres-db/db_setup/providers.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + version = "~> 1.21" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "postgresql" { + host = var.db_host + port = var.db_port + database = "postgres" + username = var.master_username + password = var.master_password + sslmode = "require" + superuser = false +} diff --git a/rds-postgres-db/db_setup/variables.tf b/rds-postgres-db/db_setup/variables.tf new file mode 100644 index 0000000..06f00c3 --- /dev/null +++ b/rds-postgres-db/db_setup/variables.tf @@ -0,0 +1,41 @@ +variable "service_id" { + type = string + description = "Nullplatform service ID (used as keeper to stabilize password across re-applies)" +} + +variable "db_host" { + type = string + description = "RDS endpoint hostname" +} + +variable "db_port" { + type = number + default = 5432 + description = "RDS port" +} + +variable "db_name" { + type = string + description = "Database name to create" +} + +variable "db_username" { + type = string + description = "Username for the database user" +} + +variable "master_username" { + type = string + description = "Master username for connecting to PostgreSQL" +} + +variable "master_secret_arn" { + type = string + description = "ARN of the Secrets Manager secret for master credentials" +} + +variable "master_password" { + type = string + sensitive = true + description = "Master password for connecting to PostgreSQL (passed via auto.tfvars)" +} diff --git a/rds-postgres-db/permissions/main.tf b/rds-postgres-db/permissions/main.tf index bd56c0b..812cd45 100644 --- a/rds-postgres-db/permissions/main.tf +++ b/rds-postgres-db/permissions/main.tf @@ -1,54 +1,20 @@ # --------------------------------------------------------------------------- -# Database — created on first link, preserved on unlink. -# -# On re-links (database already exists in PostgreSQL but not in tfstate), -# do_tofu runs "tofu import postgresql_database.app " before apply, -# so no data is lost. prevent_destroy ensures tofu destroy never drops the DB. -# --------------------------------------------------------------------------- - -resource "postgresql_database" "app" { - name = var.db_name - owner = var.master_username - - lifecycle { - prevent_destroy = true - } -} - -# --------------------------------------------------------------------------- -# App user (password generated by Terraform, stable across re-applies) -# The keepers block ensures the password is only regenerated if link_id -# changes (which it won't), preserving credentials on update workflows. -# --------------------------------------------------------------------------- - -resource "random_password" "user" { - length = 32 - special = false - keepers = { - link_id = var.link_id - } -} - -resource "postgresql_role" "app_user" { - name = var.db_username - password = random_password.user.result - login = true -} - -# --------------------------------------------------------------------------- -# Permissions +# Permissions only — the database and user are created at service level +# (db_setup module). This module manages grants for a specific link. +# On unlink, only these grant resources are destroyed; the user and DB +# are preserved at service level. # --------------------------------------------------------------------------- resource "postgresql_grant" "connect" { - database = postgresql_database.app.name - role = postgresql_role.app_user.name + database = var.db_name + role = var.db_username object_type = "database" privileges = ["CONNECT"] } resource "postgresql_grant" "schema_usage" { - database = postgresql_database.app.name - role = postgresql_role.app_user.name + database = var.db_name + role = var.db_username schema = "public" object_type = "schema" privileges = local.schema_privileges[var.access_level] @@ -56,8 +22,8 @@ resource "postgresql_grant" "schema_usage" { # Grant on existing tables resource "postgresql_grant" "tables" { - database = postgresql_database.app.name - role = postgresql_role.app_user.name + database = var.db_name + role = var.db_username schema = "public" object_type = "table" privileges = local.table_privileges[var.access_level] @@ -65,8 +31,8 @@ resource "postgresql_grant" "tables" { # Grant on future tables (default privileges applied by master user) resource "postgresql_default_privileges" "tables" { - role = postgresql_role.app_user.name - database = postgresql_database.app.name + role = var.db_username + database = var.db_name schema = "public" owner = var.master_username object_type = "table" @@ -75,8 +41,8 @@ resource "postgresql_default_privileges" "tables" { # Grant USAGE on existing sequences (needed for INSERT on serial/bigserial columns) resource "postgresql_grant" "sequences" { - database = postgresql_database.app.name - role = postgresql_role.app_user.name + database = var.db_name + role = var.db_username schema = "public" object_type = "sequence" privileges = ["USAGE", "SELECT"] @@ -84,8 +50,8 @@ resource "postgresql_grant" "sequences" { # Grant USAGE on future sequences resource "postgresql_default_privileges" "sequences" { - role = postgresql_role.app_user.name - database = postgresql_database.app.name + role = var.db_username + database = var.db_name schema = "public" owner = var.master_username object_type = "sequence" diff --git a/rds-postgres-db/permissions/outputs.tf b/rds-postgres-db/permissions/outputs.tf index 20b5ff5..eebae82 100644 --- a/rds-postgres-db/permissions/outputs.tf +++ b/rds-postgres-db/permissions/outputs.tf @@ -1,30 +1,2 @@ -output "hostname" { - value = var.db_host - description = "RDS endpoint hostname" -} - -output "port" { - value = var.db_port - description = "RDS port" -} - -output "master_secret_arn" { - value = var.master_secret_arn - description = "ARN of the Secrets Manager secret for master credentials" -} - -output "db_username" { - value = postgresql_role.app_user.name - description = "Created database username" -} - -output "db_password" { - value = random_password.user.result - sensitive = true - description = "Database user password" -} - -output "database_name" { - value = postgresql_database.app.name - description = "Database name" -} +# Credentials are stored at service level (written by write_service_outputs). +# This module has no outputs — write_link_outputs reads directly from service attributes. diff --git a/rds-postgres-db/permissions/variables.tf b/rds-postgres-db/permissions/variables.tf index 335ba27..6b5c37e 100644 --- a/rds-postgres-db/permissions/variables.tf +++ b/rds-postgres-db/permissions/variables.tf @@ -1,8 +1,3 @@ -variable "link_id" { - type = string - description = "Nullplatform link ID (used as keeper to stabilize password across re-applies)" -} - variable "db_host" { type = string description = "RDS endpoint hostname" @@ -16,12 +11,12 @@ variable "db_port" { variable "db_name" { type = string - description = "Database name to create inside the RDS instance" + description = "Database name (from service attributes)" } variable "db_username" { type = string - description = "Username for the new database user (derived from link ID)" + description = "Database username (from service attributes)" } variable "master_username" { @@ -29,11 +24,6 @@ variable "master_username" { description = "Master username for connecting to PostgreSQL" } -variable "master_secret_arn" { - type = string - description = "ARN of the Secrets Manager secret for master credentials (stored in link attributes)" -} - variable "master_password" { type = string sensitive = true diff --git a/rds-postgres-db/scripts/aws/build_context b/rds-postgres-db/scripts/aws/build_context index 2c0ee57..96854f1 100755 --- a/rds-postgres-db/scripts/aws/build_context +++ b/rds-postgres-db/scripts/aws/build_context @@ -3,14 +3,15 @@ set -euo pipefail # --------------------------------------------------------------------------- # build_context — Prepares the execution environment for rds-postgres-db -# service actions. +# service and link actions. # -# Service create/update/delete: no AWS attributes to resolve — just creates -# the per-service S3 bucket used to store link tfstate files. +# All actions: creates/verifies the per-service S3 tfstate bucket and reads +# the service's stored attributes (hostname, port, master_secret_arn, +# database_name, username) into environment variables. # -# Link/unlink: reads server_service_id from link parameters, fetches the -# rds-postgres-server's connection attributes via np service read, and -# exports them for build_permissions_context. +# Link/unlink: additionally exports link-specific variables (LINK_ID, +# LINK_ACCESS_LEVEL, etc.). For unlink, checks if the service was ever +# created; if not, sets LINK_NEVER_CREATED=true and exits cleanly. # --------------------------------------------------------------------------- SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') @@ -31,7 +32,7 @@ if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then export AWS_PROFILE="${AWS_PROFILE_VAL}" fi -# --- Ensure per-service S3 bucket exists (used for link tfstate) ------------ +# --- Ensure per-service S3 bucket exists (used for all tfstate files) ------- TFSTATE_BUCKET="np-service-${SERVICE_ID}" @@ -55,6 +56,17 @@ export TFSTATE_BUCKET export OUTPUT_DIR="/tmp/np-service-${SERVICE_ID}" mkdir -p "$OUTPUT_DIR" +# --- Read stored service attributes (available after service create) -------- + +echo "Reading service ${SERVICE_ID} attributes..." +SERVICE_JSON=$(np service read --id "$SERVICE_ID" --format json) + +export SERVER_HOSTNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.hostname // ""') +export SERVER_PORT=$(echo "$SERVICE_JSON" | jq -r '.attributes.port // "5432"') +export SERVER_MASTER_SECRET_ARN=$(echo "$SERVICE_JSON" | jq -r '.attributes.master_secret_arn // ""') +export DB_NAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.database_name // ""') +export DB_USERNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.username // ""') + # --- Link context (link/unlink workflows only) ------------------------------ if [ "${ACTION_SOURCE:-}" = "link" ]; then @@ -68,71 +80,10 @@ if [ "${ACTION_SOURCE:-}" = "link" ]; then ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type // ""') - if [ "$ACTION_TYPE" = "delete" ]; then - # Unlink: read connection info from stored link attributes (written by write_link_outputs) - export SERVER_HOSTNAME=$(echo "$LINK_ATTRS" | jq -r '.hostname // ""') - export SERVER_PORT=$(echo "$LINK_ATTRS" | jq -r '.port // "5432"') - export SERVER_MASTER_SECRET_ARN=$(echo "$LINK_ATTRS" | jq -r '.master_secret_arn // ""') - - if [ -z "$SERVER_HOSTNAME" ]; then - echo "WARNING: hostname not found in link attributes — link was never created successfully. Nothing to destroy." - export LINK_NEVER_CREATED=true - exit 0 - fi - export LINK_NEVER_CREATED=false - else - # Link create/update: read connection info from the referenced server service - SERVER_SERVICE_ID=$(echo "$LINK_ATTRS" | jq -r '.server_service_id // ""') - - if [ -z "$SERVER_SERVICE_ID" ]; then - NAMESPACE_NRN=$(echo "$CONTEXT" | jq -r '.entity_nrn // ""' | sed 's/:application=[^:]*$//') - LINK_DIMENSIONS=$(echo "$CONTEXT" | jq -c '.link.dimensions // {}') - echo "server_service_id not provided, auto-discovering RDS server in ${NAMESPACE_NRN} (dimensions: ${LINK_DIMENSIONS})..." - - SERVER_SERVICES=$(np service list \ - --nrn "$NAMESPACE_NRN" \ - --type dependency \ - --status active \ - --format json | \ - jq --argjson link_dims "$LINK_DIMENSIONS" \ - '[(.results // .) | .[] | . as $svc | select( - (.attributes.hostname // "") != "" and - (.attributes.master_secret_arn // "") != "" and - ($link_dims | to_entries | all(. as $kv | ($svc.dimensions[$kv.key] // null) == $kv.value)) - )]') - SERVER_COUNT=$(echo "$SERVER_SERVICES" | jq 'length') - - if [ "$SERVER_COUNT" -eq 0 ]; then - echo "ERROR: No active RDS server service found in ${NAMESPACE_NRN} matching dimensions: ${LINK_DIMENSIONS}" >&2 - echo " Create an rds-postgres-server service with matching dimensions, or provide server_service_id explicitly." >&2 - exit 1 - elif [ "$SERVER_COUNT" -gt 1 ]; then - echo "ERROR: Multiple RDS server services found in ${NAMESPACE_NRN} matching dimensions: ${LINK_DIMENSIONS}" >&2 - echo " Specify server_service_id explicitly. Available options:" >&2 - echo "$SERVER_SERVICES" | jq -r '.[] | " - \(.id) \(.name) (\(.attributes.hostname))"' >&2 - exit 1 - fi - - SERVER_SERVICE_ID=$(echo "$SERVER_SERVICES" | jq -r '.[0].id') - echo "Auto-discovered server service: ${SERVER_SERVICE_ID} ($(echo "$SERVER_SERVICES" | jq -r '.[0].name'))" - fi - - echo "Reading rds-postgres-server attributes from service ${SERVER_SERVICE_ID}..." - SERVER_JSON=$(np service read --id "$SERVER_SERVICE_ID" --format json) - - export SERVER_HOSTNAME=$(echo "$SERVER_JSON" | jq -r '.attributes.hostname // ""') - export SERVER_PORT=$(echo "$SERVER_JSON" | jq -r '.attributes.port // "5432"') - export SERVER_MASTER_SECRET_ARN=$(echo "$SERVER_JSON" | jq -r '.attributes.master_secret_arn // ""') - - if [ -z "$SERVER_HOSTNAME" ]; then - echo "ERROR: server service ${SERVER_SERVICE_ID} has no hostname attribute." >&2 - echo " Has the rds-postgres-server been created successfully?" >&2 - exit 1 - fi - - if [ -z "$SERVER_MASTER_SECRET_ARN" ]; then - echo "ERROR: server service ${SERVER_SERVICE_ID} has no master_secret_arn attribute." >&2 - exit 1 - fi + if [ "$ACTION_TYPE" = "delete" ] && [ -z "$SERVER_HOSTNAME" ]; then + echo "WARNING: service has no hostname — service was never created successfully. Nothing to destroy." + export LINK_NEVER_CREATED=true + exit 0 fi + export LINK_NEVER_CREATED=false fi diff --git a/rds-postgres-db/scripts/aws/build_db_setup_context b/rds-postgres-db/scripts/aws/build_db_setup_context new file mode 100644 index 0000000..e01f3f6 --- /dev/null +++ b/rds-postgres-db/scripts/aws/build_db_setup_context @@ -0,0 +1,132 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# build_db_setup_context — Prepares Tofu execution context for the db_setup +# module. Used by both service create and service delete. +# +# Service create: auto-discovers the rds-postgres-server with matching +# dimensions, derives DB name/username from application_id, then +# retrieves master credentials from Secrets Manager. +# +# Service delete: SERVER_HOSTNAME is already exported by build_context +# (read from service attributes), so server discovery is skipped. +# If service was never created (SERVER_HOSTNAME empty), exits cleanly. +# --------------------------------------------------------------------------- + +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +REGION=$(yaml_value "region" "us-east-1" "$VALUES") +AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") + +if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then + export AWS_PROFILE="${AWS_PROFILE_VAL}" +fi + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') +ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type // ""') + +# --- Resolve connection info ----------------------------------------------- + +if [ -n "${SERVER_HOSTNAME:-}" ]; then + # Service delete: use stored service attributes already exported by build_context + echo "Using stored service attributes for DB setup context..." + DB_HOST="$SERVER_HOSTNAME" + DB_PORT="${SERVER_PORT:-5432}" + MASTER_SECRET_ARN="$SERVER_MASTER_SECRET_ARN" + DB_NAME_VAL="${DB_NAME:-}" + DB_USERNAME_VAL="${DB_USERNAME:-}" + +else + if [ "$ACTION_TYPE" = "delete" ]; then + echo "Service has no stored hostname — was never created successfully. Skipping DB cleanup." + export SETUP_SKIPPED=true + exit 0 + fi + + # Service create: auto-discover rds-postgres-server with matching dimensions + NAMESPACE_NRN=$(echo "$CONTEXT" | jq -r '.entity_nrn // ""' | sed 's/:application=[^:]*$//') + SERVICE_DIMENSIONS=$(echo "$CONTEXT" | jq -c '.service.dimensions // {}') + echo "Auto-discovering RDS server in ${NAMESPACE_NRN} (dimensions: ${SERVICE_DIMENSIONS})..." + + SERVER_SERVICES=$(np service list \ + --nrn "$NAMESPACE_NRN" \ + --type dependency \ + --status active \ + --format json | \ + jq --argjson dims "$SERVICE_DIMENSIONS" \ + '[(.results // .) | .[] | . as $svc | select( + (.attributes.hostname // "") != "" and + (.attributes.master_secret_arn // "") != "" and + ($dims | to_entries | all(. as $kv | ($svc.dimensions[$kv.key] // null) == $kv.value)) + )]') + SERVER_COUNT=$(echo "$SERVER_SERVICES" | jq 'length') + + if [ "$SERVER_COUNT" -eq 0 ]; then + echo "ERROR: No active RDS server found in ${NAMESPACE_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2 + echo " Create an rds-postgres-server service with matching dimensions first." >&2 + exit 1 + elif [ "$SERVER_COUNT" -gt 1 ]; then + echo "ERROR: Multiple RDS servers found in ${NAMESPACE_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2 + echo " Available options:" >&2 + echo "$SERVER_SERVICES" | jq -r '.[] | " - \(.id) \(.name) (\(.attributes.hostname))"' >&2 + exit 1 + fi + + SERVER_SERVICE_ID=$(echo "$SERVER_SERVICES" | jq -r '.[0].id') + echo "Auto-discovered server: ${SERVER_SERVICE_ID} ($(echo "$SERVER_SERVICES" | jq -r '.[0].name'))" + + SERVER_JSON=$(np service read --id "$SERVER_SERVICE_ID" --format json) + DB_HOST=$(echo "$SERVER_JSON" | jq -r '.attributes.hostname // ""') + DB_PORT=$(echo "$SERVER_JSON" | jq -r '.attributes.port // "5432"') + MASTER_SECRET_ARN=$(echo "$SERVER_JSON" | jq -r '.attributes.master_secret_arn // ""') + + if [ -z "$DB_HOST" ]; then + echo "ERROR: Server ${SERVER_SERVICE_ID} has no hostname attribute." >&2 + echo " Has the rds-postgres-server been created successfully?" >&2 + exit 1 + fi + + # Derive DB name and username from application_id + APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // ""') + if [ -z "$APPLICATION_ID" ]; then + echo "ERROR: Could not extract application_id from context tags." >&2 + exit 1 + fi + + DB_NAME_VAL="app_${APPLICATION_ID}" + DB_USERNAME_VAL="app_${APPLICATION_ID}" + echo "Database: ${DB_NAME_VAL}, Username: ${DB_USERNAME_VAL}" +fi + +# --- Retrieve master credentials from Secrets Manager ---------------------- + +echo "Retrieving master credentials from Secrets Manager..." +MASTER_CREDS=$(aws secretsmanager get-secret-value \ + --secret-id "$MASTER_SECRET_ARN" \ + --query 'SecretString' \ + --output text) + +MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username') +MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password') + +# --- Prepare working directory and sensitive vars -------------------------- + +mkdir -p "$OUTPUT_DIR" +cat > "$OUTPUT_DIR/sensitive.auto.tfvars" <&2 + exit 1 +fi + +echo "Database: ${DB_NAME}, Username: ${DB_USERNAME}" + # --- Retrieve master credentials from Secrets Manager ---------------------- echo "Retrieving master credentials from Secrets Manager..." @@ -59,26 +64,6 @@ MASTER_CREDS=$(aws secretsmanager get-secret-value \ MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username') MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password') -# --- Derive DB name and username from application ID ----------------------- -# Extract application_id from notification tags (always present in the context). -# Both the database name and username are set to app_, -# making them deterministic and idempotent for the same application. - -APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // ""') - -if [ -z "$APPLICATION_ID" ]; then - echo "ERROR: Could not extract application_id from context tags." >&2 - echo " CONTEXT tags: $(echo "$CONTEXT" | jq -r '.tags // {}')" >&2 - exit 1 -fi - -# PostgreSQL identifiers max 63 chars. "app_" is well within that limit. -DB_NAME="app_${APPLICATION_ID}" -DB_USERNAME="app_${APPLICATION_ID}" - -echo "Database name: ${DB_NAME}" -echo "DB username: ${DB_USERNAME}" - # --- Prepare link-specific working directory -------------------------------- export OUTPUT_DIR="/tmp/np-link-${LINK_ID}" @@ -92,8 +77,7 @@ EOF # --- Set Terraform execution variables ------------------------------------- export TOFU_MODULE_DIR="$SERVICE_PATH/permissions" -export TOFU_IMPORT_DB_NAME="$DB_NAME" export TOFU_INIT_VARIABLES="-backend-config=bucket=${TFSTATE_BUCKET} -backend-config=key=links/${LINK_ID}.tfstate -backend-config=region=${REGION}" -export TOFU_VARIABLES="-var=link_id=${LINK_ID} -var=db_host=${DB_HOST} -var=db_port=${DB_PORT} -var=db_name=${DB_NAME} -var=db_username=${DB_USERNAME} -var=master_username=${MASTER_USER} -var=master_secret_arn=${MASTER_SECRET_ARN} -var=access_level=${LINK_ACCESS_LEVEL:-read-write}" +export TOFU_VARIABLES="-var=db_host=${DB_HOST} -var=db_port=${DB_PORT} -var=db_name=${DB_NAME} -var=db_username=${DB_USERNAME} -var=master_username=${MASTER_USER} -var=access_level=${LINK_ACCESS_LEVEL:-read-write}" diff --git a/rds-postgres-db/scripts/aws/do_tofu b/rds-postgres-db/scripts/aws/do_tofu index 41839b8..b2d47a1 100755 --- a/rds-postgres-db/scripts/aws/do_tofu +++ b/rds-postgres-db/scripts/aws/do_tofu @@ -16,8 +16,13 @@ set -euo pipefail TOFU_ACTION="${TOFU_ACTION:-apply}" -if [ "${LINK_NEVER_CREATED:-}" = "true" ]; then - echo "Link was never created successfully — skipping tofu ${TOFU_ACTION}." +if [ "${LINK_NEVER_CREATED:-}" = "true" ] || [ "${SETUP_SKIPPED:-}" = "true" ]; then + echo "Skipping tofu ${TOFU_ACTION} — setup was not completed." + exit 0 +fi + +if [ -z "${TOFU_MODULE_DIR:-}" ]; then + echo "TOFU_MODULE_DIR not set — skipping tofu ${TOFU_ACTION}." exit 0 fi diff --git a/rds-postgres-db/scripts/aws/reassign_owned b/rds-postgres-db/scripts/aws/reassign_owned index 59ac620..fff4fd0 100755 --- a/rds-postgres-db/scripts/aws/reassign_owned +++ b/rds-postgres-db/scripts/aws/reassign_owned @@ -2,32 +2,22 @@ set -euo pipefail # --------------------------------------------------------------------------- -# reassign_owned — Before unlink, transfers ownership of all objects owned by -# the app role to the master user in the app database. +# reassign_owned — Before dropping the app role (service delete), transfers +# ownership of all objects owned by the app user to the master user. # # PostgreSQL refuses to DROP ROLE when the role still owns objects (tables, -# sequences, etc. created by app migrations). Running REASSIGN OWNED in the -# correct database (app_) transfers ownership to master so the role can -# be dropped cleanly. +# sequences, etc. created by app migrations). REASSIGN OWNED transfers +# ownership to master so the role can be dropped cleanly. # -# Requires: CONTEXT, SERVER_HOSTNAME, SERVER_PORT, SERVER_MASTER_SECRET_ARN +# Requires: SERVER_HOSTNAME, SERVER_PORT, SERVER_MASTER_SECRET_ARN, +# DB_NAME, DB_USERNAME (all exported by build_context) # --------------------------------------------------------------------------- -if [ "${LINK_NEVER_CREATED:-}" = "true" ]; then - echo "Link was never created successfully — skipping ownership reassignment." +if [ -z "${DB_NAME:-}" ] || [ -z "${DB_USERNAME:-}" ]; then + echo "DB_NAME or DB_USERNAME not set — skipping ownership reassignment." exit 0 fi -APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // ""') - -if [ -z "$APPLICATION_ID" ]; then - echo "ERROR: Could not extract application_id from context tags." >&2 - exit 1 -fi - -export DB_NAME="app_${APPLICATION_ID}" -export DB_USERNAME="app_${APPLICATION_ID}" - echo "Reassigning objects owned by ${DB_USERNAME} in database ${DB_NAME}..." MASTER_CREDS=$(aws secretsmanager get-secret-value \ @@ -35,10 +25,10 @@ MASTER_CREDS=$(aws secretsmanager get-secret-value \ --query 'SecretString' \ --output text) -export MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username') -export MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password') -export DB_HOST="$SERVER_HOSTNAME" -export DB_PORT="${SERVER_PORT:-5432}" +MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username') +MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password') +DB_HOST="$SERVER_HOSTNAME" +DB_PORT="${SERVER_PORT:-5432}" # Ensure psql is available. Agent pod runs Alpine Linux — use apk. if ! command -v psql &>/dev/null; then diff --git a/rds-postgres-db/scripts/aws/write_link_outputs b/rds-postgres-db/scripts/aws/write_link_outputs index a47ae43..4495670 100755 --- a/rds-postgres-db/scripts/aws/write_link_outputs +++ b/rds-postgres-db/scripts/aws/write_link_outputs @@ -2,37 +2,40 @@ set -euo pipefail # --------------------------------------------------------------------------- -# write_link_outputs — Reads Terraform outputs from the permissions module -# and writes them to the NP link attributes via the API. +# write_link_outputs — Reads credentials from the service attributes and +# copies them to the NP link attributes via the API. +# +# Credentials (hostname, port, username, password, database_name) live at +# service level. This script mirrors them to the link so that Nullplatform +# can inject them as environment variables into the application. # # hostname, port, username, database_name (export: true) become plain env vars. # password (export: {secret: true}) becomes a secret env var. # --------------------------------------------------------------------------- -cd "$OUTPUT_DIR" - LINK_ID_VAL=$(echo "$CONTEXT" | jq -r '.link.id') +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') -echo "Reading Terraform outputs for link $LINK_ID_VAL..." +echo "Reading service ${SERVICE_ID} credentials for link ${LINK_ID_VAL}..." +SERVICE_JSON=$(np service read --id "$SERVICE_ID" --format json) -ALL_OUTPUTS=$(tofu output -json 2>/dev/null || echo "{}") -HOSTNAME=$(echo "$ALL_OUTPUTS" | jq -r '.hostname.value // ""') -PORT=$(echo "$ALL_OUTPUTS" | jq -r '.port.value // "" | tostring') -DB_USERNAME=$(echo "$ALL_OUTPUTS" | jq -r '.db_username.value // ""') -DB_PASSWORD=$(echo "$ALL_OUTPUTS" | jq -r '.db_password.value // ""') -DATABASE_NAME=$(echo "$ALL_OUTPUTS" | jq -r '.database_name.value // ""') -MASTER_SECRET_ARN=$(echo "$ALL_OUTPUTS"| jq -r '.master_secret_arn.value // ""') +HOSTNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.hostname // ""') +PORT=$(echo "$SERVICE_JSON" | jq -r '.attributes.port // "5432" | tostring') +USERNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.username // ""') +PASSWORD=$(echo "$SERVICE_JSON" | jq -r '.attributes.password // ""') +DATABASE_NAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.database_name // ""') +MASTER_SECRET_ARN=$(echo "$SERVICE_JSON"| jq -r '.attributes.master_secret_arn // ""') -if [ -z "$DB_USERNAME" ]; then - echo "WARNING: No db_username output found. Skipping link attribute update." +if [ -z "$USERNAME" ]; then + echo "WARNING: No username found in service attributes. Skipping link attribute update." exit 0 fi ATTRS=$(jq -n \ --arg hostname "$HOSTNAME" \ --arg port "$PORT" \ - --arg username "$DB_USERNAME" \ - --arg password "$DB_PASSWORD" \ + --arg username "$USERNAME" \ + --arg password "$PASSWORD" \ --arg database_name "$DATABASE_NAME" \ --arg master_secret_arn "$MASTER_SECRET_ARN" \ '{ @@ -44,10 +47,10 @@ ATTRS=$(jq -n \ master_secret_arn: $master_secret_arn }') -echo "Updating link $LINK_ID_VAL attributes:" +echo "Updating link ${LINK_ID_VAL} attributes:" echo " hostname: $HOSTNAME" echo " port: $PORT" -echo " username: $DB_USERNAME" +echo " username: $USERNAME" echo " database_name: $DATABASE_NAME" echo " password: ****" diff --git a/rds-postgres-db/scripts/aws/write_service_outputs b/rds-postgres-db/scripts/aws/write_service_outputs index 30668d3..c7814c2 100755 --- a/rds-postgres-db/scripts/aws/write_service_outputs +++ b/rds-postgres-db/scripts/aws/write_service_outputs @@ -2,33 +2,55 @@ set -euo pipefail # --------------------------------------------------------------------------- -# write_service_outputs — Copies the rds-postgres-server's connection -# attributes into this service's attributes via the NP API. +# write_service_outputs — Reads Terraform outputs from the db_setup module +# and writes them to the NP service attributes via the API. # -# hostname and port (export: true) become env vars in apps when a link is -# activated. master_secret_arn (export: false) is used internally by -# build_permissions_context during link actions. +# hostname, port, username, database_name, master_secret_arn are stored on +# the service. password is stored as a plain attribute so that link/unlink +# workflows can copy it to link attributes for app env var injection. # --------------------------------------------------------------------------- SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') -if [ -z "${SERVER_HOSTNAME:-}" ]; then - echo "WARNING: SERVER_HOSTNAME not set. Skipping attribute update." +cd "$OUTPUT_DIR" + +echo "Reading Terraform outputs for service ${SERVICE_ID}..." + +ALL_OUTPUTS=$(tofu output -json 2>/dev/null || echo "{}") +HOSTNAME=$(echo "$ALL_OUTPUTS" | jq -r '.hostname.value // ""') +PORT=$(echo "$ALL_OUTPUTS" | jq -r '.port.value // "" | tostring') +USERNAME=$(echo "$ALL_OUTPUTS" | jq -r '.db_username.value // ""') +PASSWORD=$(echo "$ALL_OUTPUTS" | jq -r '.db_password.value // ""') +DATABASE_NAME=$(echo "$ALL_OUTPUTS" | jq -r '.database_name.value // ""') +MASTER_SECRET_ARN=$(echo "$ALL_OUTPUTS"| jq -r '.master_secret_arn.value // ""') + +if [ -z "$HOSTNAME" ]; then + echo "WARNING: No hostname output found. Skipping service attribute update." exit 0 fi ATTRS=$(jq -n \ - --arg hostname "$SERVER_HOSTNAME" \ - --arg port "${SERVER_PORT:-5432}" \ - --arg master_secret_arn "${SERVER_MASTER_SECRET_ARN:-}" \ + --arg hostname "$HOSTNAME" \ + --arg port "$PORT" \ + --arg username "$USERNAME" \ + --arg password "$PASSWORD" \ + --arg database_name "$DATABASE_NAME" \ + --arg master_secret_arn "$MASTER_SECRET_ARN" \ '{ - hostname: $hostname, - port: ($port | tonumber) - } + (if $master_secret_arn != "" then {master_secret_arn: $master_secret_arn} else {} end)') + hostname: $hostname, + port: ($port | tonumber), + username: $username, + password: $password, + database_name: $database_name, + master_secret_arn: $master_secret_arn + }') -echo "Updating service $SERVICE_ID attributes:" -echo " hostname: $SERVER_HOSTNAME" -echo " port: ${SERVER_PORT:-5432}" +echo "Updating service ${SERVICE_ID} attributes:" +echo " hostname: $HOSTNAME" +echo " port: $PORT" +echo " username: $USERNAME" +echo " database_name: $DATABASE_NAME" +echo " password: ****" np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}" echo "Service attributes updated successfully." diff --git a/rds-postgres-db/specs/links/connect.json.tpl b/rds-postgres-db/specs/links/connect.json.tpl index b4d8c41..8bab9c7 100644 --- a/rds-postgres-db/specs/links/connect.json.tpl +++ b/rds-postgres-db/specs/links/connect.json.tpl @@ -16,14 +16,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "required": [], "properties": { - "server_service_id": { - "type": "string", - "title": "RDS Server Service ID", - "description": "ID of the rds-postgres-server service. Auto-discovered from the namespace (override only if multiple exist).", - "editableOn": [], - "visibleOn": [], - "order": 1 - }, "access_level": { "enum": ["read", "write", "read-write"], "type": "string", @@ -31,7 +23,7 @@ "default": "read-write", "editableOn": ["create", "update"], "description": "Permission level: read (SELECT), write (INSERT/UPDATE/DELETE), read-write (both)", - "order": 2 + "order": 1 }, "hostname": { "type": "string", @@ -40,7 +32,7 @@ "visibleOn": ["read"], "editableOn": [], "description": "RDS endpoint hostname", - "order": 3 + "order": 2 }, "port": { "type": "number", @@ -49,7 +41,7 @@ "visibleOn": ["read"], "editableOn": [], "description": "RDS port", - "order": 4 + "order": 3 }, "username": { "type": "string", @@ -57,8 +49,8 @@ "export": true, "visibleOn": ["read"], "editableOn": [], - "description": "Database username (derived from application ID)", - "order": 5 + "description": "Database username", + "order": 4 }, "password": { "type": "string", @@ -66,8 +58,8 @@ "export": {"type": "environment_variable", "secret": true}, "visibleOn": ["read"], "editableOn": [], - "description": "Database password (auto-generated)", - "order": 6 + "description": "Database password (auto-generated at service create)", + "order": 5 }, "database_name": { "type": "string", @@ -75,8 +67,8 @@ "export": true, "visibleOn": ["read"], "editableOn": [], - "description": "Database name (derived from application ID)", - "order": 7 + "description": "Database name", + "order": 6 }, "master_secret_arn": { "type": "string", diff --git a/rds-postgres-db/specs/service-spec.json.tpl b/rds-postgres-db/specs/service-spec.json.tpl index b465bd4..09aca5e 100644 --- a/rds-postgres-db/specs/service-spec.json.tpl +++ b/rds-postgres-db/specs/service-spec.json.tpl @@ -17,7 +17,55 @@ "type": "object", "$schema": "http://json-schema.org/draft-07/schema#", "required": [], - "properties": {} + "properties": { + "hostname": { + "type": "string", + "title": "Hostname", + "visibleOn": ["read"], + "editableOn": [], + "description": "RDS endpoint hostname", + "order": 1 + }, + "port": { + "type": "number", + "title": "Port", + "visibleOn": ["read"], + "editableOn": [], + "description": "RDS port", + "order": 2 + }, + "username": { + "type": "string", + "title": "DB Username", + "visibleOn": ["read"], + "editableOn": [], + "description": "Database username", + "order": 3 + }, + "password": { + "type": "string", + "title": "DB Password", + "visibleOn": [], + "editableOn": [], + "description": "Database password (internal use — exposed to apps via link)", + "order": 4 + }, + "database_name": { + "type": "string", + "title": "Database", + "visibleOn": ["read"], + "editableOn": [], + "description": "Database name", + "order": 5 + }, + "master_secret_arn": { + "type": "string", + "visibleOn": [], + "editableOn": [], + "description": "ARN of the Secrets Manager secret for master credentials (internal use)", + "order": 6 + } + } }, "values": {} } diff --git a/rds-postgres-db/workflows/aws/create.yaml b/rds-postgres-db/workflows/aws/create.yaml index 72642ad..7c727d9 100644 --- a/rds-postgres-db/workflows/aws/create.yaml +++ b/rds-postgres-db/workflows/aws/create.yaml @@ -5,3 +5,42 @@ steps: output: - name: TFSTATE_BUCKET type: environment + - name: OUTPUT_DIR + type: environment + - name: SERVER_HOSTNAME + type: environment + - name: SERVER_PORT + type: environment + - name: SERVER_MASTER_SECRET_ARN + type: environment + - name: DB_NAME + type: environment + - name: DB_USERNAME + type: environment + + - name: build db setup context + type: script + file: $SERVICE_PATH/scripts/aws/build_db_setup_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + - name: TOFU_IMPORT_DB_NAME + type: environment + - name: SETUP_SKIPPED + type: environment + + - name: tofu + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: apply + + - name: write service outputs + type: script + file: $SERVICE_PATH/scripts/aws/write_service_outputs diff --git a/rds-postgres-db/workflows/aws/delete.yaml b/rds-postgres-db/workflows/aws/delete.yaml index c17f301..b5d57d2 100644 --- a/rds-postgres-db/workflows/aws/delete.yaml +++ b/rds-postgres-db/workflows/aws/delete.yaml @@ -5,6 +5,46 @@ steps: output: - name: TFSTATE_BUCKET type: environment + - name: OUTPUT_DIR + type: environment + - name: SERVER_HOSTNAME + type: environment + - name: SERVER_PORT + type: environment + - name: SERVER_MASTER_SECRET_ARN + type: environment + - name: DB_NAME + type: environment + - name: DB_USERNAME + type: environment + + - name: build db setup context + type: script + file: $SERVICE_PATH/scripts/aws/build_db_setup_context + output: + - name: OUTPUT_DIR + type: environment + - name: TOFU_MODULE_DIR + type: environment + - name: TOFU_INIT_VARIABLES + type: environment + - name: TOFU_VARIABLES + type: environment + - name: SETUP_SKIPPED + type: environment + + - name: reassign owned objects + type: script + file: $SERVICE_PATH/scripts/aws/reassign_owned + + - name: tofu destroy role + type: script + file: $SERVICE_PATH/scripts/aws/do_tofu + configuration: + TOFU_ACTION: destroy + # Destroy only the user. The database is preserved with master as owner + # so data remains available for potential future use. + TOFU_TARGETS: "postgresql_role.app_user,random_password.user" - name: cleanup tfstate bucket type: script diff --git a/rds-postgres-db/workflows/aws/link.yaml b/rds-postgres-db/workflows/aws/link.yaml index 25e19d2..f8e238e 100644 --- a/rds-postgres-db/workflows/aws/link.yaml +++ b/rds-postgres-db/workflows/aws/link.yaml @@ -23,6 +23,10 @@ steps: type: environment - name: SERVER_MASTER_SECRET_ARN type: environment + - name: DB_NAME + type: environment + - name: DB_USERNAME + type: environment - name: build permissions context type: script @@ -36,8 +40,6 @@ steps: type: environment - name: TOFU_VARIABLES type: environment - - name: TOFU_IMPORT_DB_NAME - type: environment - name: tofu type: script diff --git a/rds-postgres-db/workflows/aws/unlink.yaml b/rds-postgres-db/workflows/aws/unlink.yaml index dbfa35c..fdf14b1 100644 --- a/rds-postgres-db/workflows/aws/unlink.yaml +++ b/rds-postgres-db/workflows/aws/unlink.yaml @@ -23,6 +23,10 @@ steps: type: environment - name: SERVER_MASTER_SECRET_ARN type: environment + - name: DB_NAME + type: environment + - name: DB_USERNAME + type: environment - name: LINK_NEVER_CREATED type: environment @@ -39,15 +43,11 @@ steps: - name: TOFU_VARIABLES type: environment - - name: reassign owned objects - type: script - file: $SERVICE_PATH/scripts/aws/reassign_owned - - name: tofu type: script file: $SERVICE_PATH/scripts/aws/do_tofu configuration: TOFU_ACTION: destroy - # Destroy only access resources (role + grants). The database is preserved - # so data survives unlink and is available if the app re-links later. - TOFU_TARGETS: "postgresql_default_privileges.sequences,postgresql_grant.sequences,postgresql_default_privileges.tables,postgresql_grant.tables,postgresql_grant.schema_usage,postgresql_grant.connect,postgresql_role.app_user,random_password.user" + # Destroy only grant resources. The database and user are preserved at + # service level — data survives unlink and access can be re-granted later. + TOFU_TARGETS: "postgresql_default_privileges.sequences,postgresql_grant.sequences,postgresql_default_privileges.tables,postgresql_grant.tables,postgresql_grant.schema_usage,postgresql_grant.connect" From d87b7c8acba7c161f2c96ac38279d17e95138267 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 19 Mar 2026 22:06:09 -0300 Subject: [PATCH 03/34] feat(rds-postgres-server): resolve vpc_id dynamically from nullplatform provider Replace hardcoded vpc_id in values.yaml with a runtime lookup from the nullplatform VPC provider. build_context now derives the account NRN from the service NRN, queries the provider that stores vpc.id, and reads the value via np provider read. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/build_context | 36 ++++++++++++++++--- rds-postgres-server/values.yaml | 3 -- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context index 99faf06..065d56a 100755 --- a/rds-postgres-server/scripts/aws/build_context +++ b/rds-postgres-server/scripts/aws/build_context @@ -39,18 +39,44 @@ yaml_value() { } REGION=$(yaml_value "region" "us-east-1" "$VALUES") -VPC_ID=$(yaml_value "vpc_id" "" "$VALUES") AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") -if [ -z "$VPC_ID" ]; then - echo "ERROR: vpc_id is required in values.yaml" >&2 +if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then + export AWS_PROFILE="${AWS_PROFILE_VAL}" +fi + +# --- Resolve VPC ID from nullplatform provider ------------------------------- +# The VPC provider (type: runtime_configuration) stores vpc.id at account +# scope. We derive the account NRN from the service NRN by stripping from +# :namespace= onward. + +ACCOUNT_NRN=$(echo "$CONTEXT" | jq -r '.service.nrn // ""' | sed 's/:namespace=.*$//') + +if [ -z "$ACCOUNT_NRN" ]; then + echo "ERROR: could not derive account NRN from .service.nrn in context" >&2 exit 1 fi -if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then - export AWS_PROFILE="${AWS_PROFILE_VAL}" +echo "Looking up VPC provider for account: ${ACCOUNT_NRN}" + +VPC_PROVIDER_ID=$(np provider list --nrn "$ACCOUNT_NRN" --format json \ + | jq -r '[.results[] | select(.data_source.stored_keys | contains(["vpc.id"]))] | first | .id // ""') + +if [ -z "$VPC_PROVIDER_ID" ] || [ "$VPC_PROVIDER_ID" = "null" ]; then + echo "ERROR: no VPC provider found for account ${ACCOUNT_NRN}" >&2 + exit 1 fi +VPC_PROVIDER_DATA=$(np provider read --id "$VPC_PROVIDER_ID" --format json) +VPC_ID=$(echo "$VPC_PROVIDER_DATA" | jq -r '.attributes.vpc.id // ""') + +if [ -z "$VPC_ID" ]; then + echo "ERROR: vpc.id not found in provider ${VPC_PROVIDER_ID}" >&2 + exit 1 +fi + +echo "Using VPC: ${VPC_ID}" + # --- Ensure per-instance tfstate bucket exists ------------------------------ # Each service instance gets its own S3 bucket so state is isolated and the # bucket name is deterministic (reconstructable from SERVICE_ID alone). diff --git a/rds-postgres-server/values.yaml b/rds-postgres-server/values.yaml index 63b1d2b..c55f7e5 100644 --- a/rds-postgres-server/values.yaml +++ b/rds-postgres-server/values.yaml @@ -8,9 +8,6 @@ # AWS region where RDS instances are created region: us-east-1 -# VPC ID where RDS instances will be deployed -vpc_id: "vpc-0a5dfe8e463dee15d" - # Named AWS profile for local testing (e.g. SSO profile with RDS access) # If set and AWS_PROFILE is not already in the environment, build_context # will export it so Terraform and AWS CLI use the correct credentials. From aa84e452ec54f128582c613954581d862bc62627 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 19 Mar 2026 22:09:28 -0300 Subject: [PATCH 04/34] feat(rds-postgres-server): set linkable_to to [] after service is deployed After writing service attributes, patch the service to set linkable_to: [] so it is not linkable to any scope type once created. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/write_service_outputs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rds-postgres-server/scripts/aws/write_service_outputs b/rds-postgres-server/scripts/aws/write_service_outputs index 02f5bc6..ec2f6fd 100755 --- a/rds-postgres-server/scripts/aws/write_service_outputs +++ b/rds-postgres-server/scripts/aws/write_service_outputs @@ -45,3 +45,6 @@ echo " db_instance_identifier: $DB_INSTANCE_IDENTIFIER" np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}" echo "Service attributes updated successfully." + +np service patch --id "$SERVICE_ID" --body '{"linkable_to": []}' +echo "Service linkable_to set to []." From 73f7457c6bee46befcbd97d69762c536cef35c68 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 19 Mar 2026 22:12:41 -0300 Subject: [PATCH 05/34] feat(databases-postgress): add requirements folder --- rds-postgres-server/requirements/main.tf | 168 ++++++++++++++++++ rds-postgres-server/requirements/output.tf | 14 ++ rds-postgres-server/requirements/variables.tf | 10 ++ 3 files changed, 192 insertions(+) create mode 100644 rds-postgres-server/requirements/main.tf create mode 100644 rds-postgres-server/requirements/output.tf create mode 100644 rds-postgres-server/requirements/variables.tf diff --git a/rds-postgres-server/requirements/main.tf b/rds-postgres-server/requirements/main.tf new file mode 100644 index 0000000..c5cb268 --- /dev/null +++ b/rds-postgres-server/requirements/main.tf @@ -0,0 +1,168 @@ +################################################################################ +# Policy attachments (only when role_name is provided) +################################################################################ + +resource "aws_iam_role_policy_attachment" "rds" { + count = var.role_name != null ? 1 : 0 + role = var.role_name + policy_arn = aws_iam_policy.nullplatform_rds_policy.arn +} + +resource "aws_iam_role_policy_attachment" "rds_sg" { + count = var.role_name != null ? 1 : 0 + role = var.role_name + policy_arn = aws_iam_policy.nullplatform_rds_sg_policy.arn +} + +resource "aws_iam_role_policy_attachment" "rds_secretsmanager" { + count = var.role_name != null ? 1 : 0 + role = var.role_name + policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn +} + +resource "aws_iam_role_policy_attachment" "rds_s3" { + count = var.role_name != null ? 1 : 0 + role = var.role_name + policy_arn = aws_iam_policy.nullplatform_rds_s3_policy.arn +} + +################################################################################ +# RDS IAM policy +################################################################################ + +# Grant permissions to manage RDS instances and subnet groups +resource "aws_iam_policy" "nullplatform_rds_policy" { + name = "nullplatform_${var.name}_rds_policy" + description = "Policy for managing RDS instances and subnet groups" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "rds:CreateDBInstance", + "rds:DeleteDBInstance", + "rds:ModifyDBInstance", + "rds:DescribeDBInstances", + "rds:CreateDBSubnetGroup", + "rds:DeleteDBSubnetGroup", + "rds:DescribeDBSubnetGroups", + "rds:ModifyDBSubnetGroup", + "rds:AddTagsToResource", + "rds:ListTagsForResource", + "rds:RemoveTagsFromResource", + "rds:DescribeDBParameterGroups", + "rds:DescribeDBParameters", + "rds:DescribeDBEngineVersions", + "rds:DescribeOrderableDBInstanceOptions", + "rds:DescribeOptionGroups", + "iam:CreateServiceLinkedRole" + ], + "Resource" : "*" + } + ] + }) +} + +################################################################################ +# EC2 Security Group IAM policy +################################################################################ + +# Grant permissions to manage EC2 security groups for RDS +resource "aws_iam_policy" "nullplatform_rds_sg_policy" { + name = "nullplatform_${var.name}_rds_sg_policy" + description = "Policy for managing EC2 security groups for RDS" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:DescribeSecurityGroups", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupEgress", + "ec2:DescribeVpcs", + "ec2:DescribeVpcAttribute", + "ec2:DescribeSubnets", + "ec2:CreateTags", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeSecurityGroupRules" + ], + "Resource" : "*" + } + ] + }) +} + +################################################################################ +# S3 IAM policy (per-service tfstate buckets: np-service-) +################################################################################ + +# Grant permissions to manage the per-link S3 bucket used to store tofu state +resource "aws_iam_policy" "nullplatform_rds_s3_policy" { + name = "nullplatform_${var.name}_rds_s3_policy" + description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "s3:CreateBucket", + "s3:HeadBucket", + "s3:PutBucketVersioning", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:DeleteBucket" + ], + "Resource" : [ + "arn:aws:s3:::np-service-*", + "arn:aws:s3:::np-service-*/*" + ] + } + ] + }) +} + +################################################################################ +# Secrets Manager IAM policy +################################################################################ + +# Grant permissions to manage Secrets Manager secrets for RDS master password +resource "aws_iam_policy" "nullplatform_rds_secretsmanager_policy" { + name = "nullplatform_${var.name}_rds_secretsmanager_policy" + description = "Policy for managing Secrets Manager secrets for RDS master password" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "secretsmanager:CreateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:UpdateSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:GetResourcePolicy", + "secretsmanager:ListSecretVersionIds" + ], + "Resource" : "*" + } + ] + }) +} diff --git a/rds-postgres-server/requirements/output.tf b/rds-postgres-server/requirements/output.tf new file mode 100644 index 0000000..e42de8b --- /dev/null +++ b/rds-postgres-server/requirements/output.tf @@ -0,0 +1,14 @@ +output "rds_policy_arn" { + description = "ARN of the RDS management policy" + value = aws_iam_policy.nullplatform_rds_policy.arn +} + +output "rds_sg_policy_arn" { + description = "ARN of the EC2 security group policy" + value = aws_iam_policy.nullplatform_rds_sg_policy.arn +} + +output "rds_secretsmanager_policy_arn" { + description = "ARN of the Secrets Manager policy" + value = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn +} diff --git a/rds-postgres-server/requirements/variables.tf b/rds-postgres-server/requirements/variables.tf new file mode 100644 index 0000000..f760a50 --- /dev/null +++ b/rds-postgres-server/requirements/variables.tf @@ -0,0 +1,10 @@ +variable "name" { + description = "Unique identifier for policy naming. Must be unique per AWS account (IAM policy names are account-global). Example: \"prod-us-east-1\"." + type = string +} + +variable "role_name" { + description = "IAM role name to attach the RDS policies to. If set, Terraform manages the attachments and will detach them automatically on destroy." + type = string + default = null +} From c4c7c8dd124020b1f2a9064f4a208a133f5d1573 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 20 Mar 2026 09:52:33 -0300 Subject: [PATCH 06/34] feat(rds-postgres-server): resolve region dynamically from nullplatform account provider Replace static region from values.yaml with a dynamic lookup using np provider list/read, reusing the single provider list call already made for vpc_id resolution. Export REGION from build_context so subsequent workflow steps receive it via environment output. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/build_context | 36 +++++++++++++++---- .../scripts/aws/build_permissions_context | 7 +++- .../scripts/aws/delete_tfstate_bucket | 13 +++---- rds-postgres-server/values.yaml | 3 -- rds-postgres-server/workflows/aws/create.yaml | 2 ++ rds-postgres-server/workflows/aws/delete.yaml | 2 ++ rds-postgres-server/workflows/aws/link.yaml | 2 ++ rds-postgres-server/workflows/aws/unlink.yaml | 2 ++ rds-postgres-server/workflows/aws/update.yaml | 2 ++ 9 files changed, 50 insertions(+), 19 deletions(-) diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context index 065d56a..22a118e 100755 --- a/rds-postgres-server/scripts/aws/build_context +++ b/rds-postgres-server/scripts/aws/build_context @@ -38,17 +38,16 @@ yaml_value() { echo "${val:-$default}" } -REGION=$(yaml_value "region" "us-east-1" "$VALUES") AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then export AWS_PROFILE="${AWS_PROFILE_VAL}" fi -# --- Resolve VPC ID from nullplatform provider ------------------------------- -# The VPC provider (type: runtime_configuration) stores vpc.id at account -# scope. We derive the account NRN from the service NRN by stripping from -# :namespace= onward. +# --- Resolve AWS context from nullplatform provider ------------------------- +# Both region and VPC are stored in runtime_configuration providers scoped to +# the account. We derive the account NRN from the service NRN by stripping +# everything from :namespace= onward, then query each provider by stored_keys. ACCOUNT_NRN=$(echo "$CONTEXT" | jq -r '.service.nrn // ""' | sed 's/:namespace=.*$//') @@ -57,9 +56,32 @@ if [ -z "$ACCOUNT_NRN" ]; then exit 1 fi -echo "Looking up VPC provider for account: ${ACCOUNT_NRN}" +NP_PROVIDERS=$(np provider list --nrn "$ACCOUNT_NRN" --format json) -VPC_PROVIDER_ID=$(np provider list --nrn "$ACCOUNT_NRN" --format json \ +echo "Resolving region and VPC for account: ${ACCOUNT_NRN}" + +# Resolve region from the account provider (stored key: account.region) +ACCOUNT_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ + | jq -r '[.results[] | select(.data_source.stored_keys | contains(["account.region"]))] | first | .id // ""') + +if [ -z "$ACCOUNT_PROVIDER_ID" ] || [ "$ACCOUNT_PROVIDER_ID" = "null" ]; then + echo "ERROR: no account provider with account.region found for ${ACCOUNT_NRN}" >&2 + exit 1 +fi + +ACCOUNT_PROVIDER_DATA=$(np provider read --id "$ACCOUNT_PROVIDER_ID" --format json) +REGION=$(echo "$ACCOUNT_PROVIDER_DATA" | jq -r '.attributes.account.region // ""') + +if [ -z "$REGION" ]; then + echo "ERROR: account.region not found in provider ${ACCOUNT_PROVIDER_ID}" >&2 + exit 1 +fi + +echo "Using region: ${REGION}" +export REGION + +# Resolve VPC ID from the VPC provider (stored key: vpc.id) +VPC_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ | jq -r '[.results[] | select(.data_source.stored_keys | contains(["vpc.id"]))] | first | .id // ""') if [ -z "$VPC_PROVIDER_ID" ] || [ "$VPC_PROVIDER_ID" = "null" ]; then diff --git a/rds-postgres-server/scripts/aws/build_permissions_context b/rds-postgres-server/scripts/aws/build_permissions_context index 9cb853b..2a2b799 100755 --- a/rds-postgres-server/scripts/aws/build_permissions_context +++ b/rds-postgres-server/scripts/aws/build_permissions_context @@ -20,7 +20,12 @@ yaml_value() { echo "${val:-$default}" } -REGION=$(yaml_value "region" "us-east-1" "$VALUES") +# REGION is exported by build_context (resolved from the nullplatform account provider). +if [ -z "${REGION:-}" ]; then + echo "ERROR: REGION is not set. Expected to be exported by build_context." >&2 + exit 1 +fi + TFSTATE_BUCKET=$(yaml_value "tfstate_bucket" "" "$VALUES") AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") diff --git a/rds-postgres-server/scripts/aws/delete_tfstate_bucket b/rds-postgres-server/scripts/aws/delete_tfstate_bucket index 15ca776..bce95a6 100755 --- a/rds-postgres-server/scripts/aws/delete_tfstate_bucket +++ b/rds-postgres-server/scripts/aws/delete_tfstate_bucket @@ -13,14 +13,11 @@ set -euo pipefail # TFSTATE_BUCKET — name of the bucket to delete (np-service-) # --------------------------------------------------------------------------- -yaml_value() { - local key="$1" default="$2" file="$3" - local val - val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) - echo "${val:-$default}" -} - -REGION=$(yaml_value "region" "us-east-1" "$VALUES") +# REGION is exported by build_context (resolved from the nullplatform account provider). +if [ -z "${REGION:-}" ]; then + echo "ERROR: REGION is not set. Expected to be exported by build_context." >&2 + exit 1 +fi if [ -z "${TFSTATE_BUCKET:-}" ]; then echo "TFSTATE_BUCKET is not set, skipping bucket cleanup." diff --git a/rds-postgres-server/values.yaml b/rds-postgres-server/values.yaml index c55f7e5..ca9962f 100644 --- a/rds-postgres-server/values.yaml +++ b/rds-postgres-server/values.yaml @@ -5,9 +5,6 @@ # NOTE: In scripts, $VALUES is a FILE PATH (set by np service workflow exec --values). # It is NOT JSON content. Read values with yaml_value() from build_context. -# AWS region where RDS instances are created -region: us-east-1 - # Named AWS profile for local testing (e.g. SSO profile with RDS access) # If set and AWS_PROFILE is not already in the environment, build_context # will export it so Terraform and AWS CLI use the correct credentials. diff --git a/rds-postgres-server/workflows/aws/create.yaml b/rds-postgres-server/workflows/aws/create.yaml index 28bc308..afdf15c 100644 --- a/rds-postgres-server/workflows/aws/create.yaml +++ b/rds-postgres-server/workflows/aws/create.yaml @@ -5,6 +5,8 @@ steps: output: - name: OUTPUT_DIR type: environment + - name: REGION + type: environment - name: TOFU_MODULE_DIR type: environment - name: TOFU_INIT_VARIABLES diff --git a/rds-postgres-server/workflows/aws/delete.yaml b/rds-postgres-server/workflows/aws/delete.yaml index 9faf6ae..29a097d 100644 --- a/rds-postgres-server/workflows/aws/delete.yaml +++ b/rds-postgres-server/workflows/aws/delete.yaml @@ -5,6 +5,8 @@ steps: output: - name: OUTPUT_DIR type: environment + - name: REGION + type: environment - name: TOFU_MODULE_DIR type: environment - name: TOFU_INIT_VARIABLES diff --git a/rds-postgres-server/workflows/aws/link.yaml b/rds-postgres-server/workflows/aws/link.yaml index fe267fa..272e422 100644 --- a/rds-postgres-server/workflows/aws/link.yaml +++ b/rds-postgres-server/workflows/aws/link.yaml @@ -5,6 +5,8 @@ steps: output: - name: OUTPUT_DIR type: environment + - name: REGION + type: environment - name: LINK_ID type: environment - name: LINK_NAME diff --git a/rds-postgres-server/workflows/aws/unlink.yaml b/rds-postgres-server/workflows/aws/unlink.yaml index 061851c..062c305 100644 --- a/rds-postgres-server/workflows/aws/unlink.yaml +++ b/rds-postgres-server/workflows/aws/unlink.yaml @@ -5,6 +5,8 @@ steps: output: - name: OUTPUT_DIR type: environment + - name: REGION + type: environment - name: LINK_ID type: environment - name: LINK_NAME diff --git a/rds-postgres-server/workflows/aws/update.yaml b/rds-postgres-server/workflows/aws/update.yaml index 28bc308..afdf15c 100644 --- a/rds-postgres-server/workflows/aws/update.yaml +++ b/rds-postgres-server/workflows/aws/update.yaml @@ -5,6 +5,8 @@ steps: output: - name: OUTPUT_DIR type: environment + - name: REGION + type: environment - name: TOFU_MODULE_DIR type: environment - name: TOFU_INIT_VARIABLES From c713409ecd3c19451410d8406d7d8219b2d78c60 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 20 Mar 2026 13:19:36 -0300 Subject: [PATCH 07/34] fix(rds-postgres-server): fallback to entity_nrn when service.nrn is absent Dependency service contexts do not include .service.nrn; use .entity_nrn as fallback so account NRN resolution works for create/delete/update/link/unlink. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/build_context | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context index 22a118e..569cd96 100755 --- a/rds-postgres-server/scripts/aws/build_context +++ b/rds-postgres-server/scripts/aws/build_context @@ -49,7 +49,7 @@ fi # the account. We derive the account NRN from the service NRN by stripping # everything from :namespace= onward, then query each provider by stored_keys. -ACCOUNT_NRN=$(echo "$CONTEXT" | jq -r '.service.nrn // ""' | sed 's/:namespace=.*$//') +ACCOUNT_NRN=$(echo "$CONTEXT" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:namespace=.*$//') if [ -z "$ACCOUNT_NRN" ]; then echo "ERROR: could not derive account NRN from .service.nrn in context" >&2 From 6c6258924404f05b9634a011f200f9fc9e3bdd8c Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 20 Mar 2026 13:24:18 -0300 Subject: [PATCH 08/34] fix(rds-postgres-server): guard against null stored_keys in provider jq queries Some providers have null data_source.stored_keys; using (... // []) before contains() prevents jq from throwing "Cannot iterate over null" when resolving account.region and vpc.id providers. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/build_context | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context index 569cd96..1937d89 100755 --- a/rds-postgres-server/scripts/aws/build_context +++ b/rds-postgres-server/scripts/aws/build_context @@ -62,7 +62,7 @@ echo "Resolving region and VPC for account: ${ACCOUNT_NRN}" # Resolve region from the account provider (stored key: account.region) ACCOUNT_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ - | jq -r '[.results[] | select(.data_source.stored_keys | contains(["account.region"]))] | first | .id // ""') + | jq -r '[.results[] | select((.data_source.stored_keys // []) | contains(["account.region"]))] | first | .id // ""') if [ -z "$ACCOUNT_PROVIDER_ID" ] || [ "$ACCOUNT_PROVIDER_ID" = "null" ]; then echo "ERROR: no account provider with account.region found for ${ACCOUNT_NRN}" >&2 @@ -82,7 +82,7 @@ export REGION # Resolve VPC ID from the VPC provider (stored key: vpc.id) VPC_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ - | jq -r '[.results[] | select(.data_source.stored_keys | contains(["vpc.id"]))] | first | .id // ""') + | jq -r '[.results[] | select((.data_source.stored_keys // []) | contains(["vpc.id"]))] | first | .id // ""') if [ -z "$VPC_PROVIDER_ID" ] || [ "$VPC_PROVIDER_ID" = "null" ]; then echo "ERROR: no VPC provider found for account ${ACCOUNT_NRN}" >&2 From 3f878c84773713da57ef8cc4ad2ac9d54b583ebd Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 20 Mar 2026 13:32:20 -0300 Subject: [PATCH 09/34] fix(rds-postgres-server): guard .results null in provider queries + debug output Guard against null .results in np provider list JSON response. Add temporary debug log to inspect raw provider list output. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/build_context | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context index 1937d89..7b57529 100755 --- a/rds-postgres-server/scripts/aws/build_context +++ b/rds-postgres-server/scripts/aws/build_context @@ -59,10 +59,11 @@ fi NP_PROVIDERS=$(np provider list --nrn "$ACCOUNT_NRN" --format json) echo "Resolving region and VPC for account: ${ACCOUNT_NRN}" +echo "DEBUG np provider list output: ${NP_PROVIDERS}" # Resolve region from the account provider (stored key: account.region) ACCOUNT_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ - | jq -r '[.results[] | select((.data_source.stored_keys // []) | contains(["account.region"]))] | first | .id // ""') + | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["account.region"]))] | first | .id // ""') if [ -z "$ACCOUNT_PROVIDER_ID" ] || [ "$ACCOUNT_PROVIDER_ID" = "null" ]; then echo "ERROR: no account provider with account.region found for ${ACCOUNT_NRN}" >&2 @@ -82,7 +83,7 @@ export REGION # Resolve VPC ID from the VPC provider (stored key: vpc.id) VPC_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ - | jq -r '[.results[] | select((.data_source.stored_keys // []) | contains(["vpc.id"]))] | first | .id // ""') + | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["vpc.id"]))] | first | .id // ""') if [ -z "$VPC_PROVIDER_ID" ] || [ "$VPC_PROVIDER_ID" = "null" ]; then echo "ERROR: no VPC provider found for account ${ACCOUNT_NRN}" >&2 From f9067e63897bfd8418fee8b9e2a1aa6de31e7f85 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 20 Mar 2026 13:36:02 -0300 Subject: [PATCH 10/34] fix(rds-postgres-server): pass explicit --limit to np provider list When running inside a workflow, np provider list sends limit=0 which the API rejects with 400 (querystring/limit must be >= 1). Pass --limit 100 to ensure a valid paginated request. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/build_context | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context index 7b57529..8c6b288 100755 --- a/rds-postgres-server/scripts/aws/build_context +++ b/rds-postgres-server/scripts/aws/build_context @@ -56,10 +56,9 @@ if [ -z "$ACCOUNT_NRN" ]; then exit 1 fi -NP_PROVIDERS=$(np provider list --nrn "$ACCOUNT_NRN" --format json) +NP_PROVIDERS=$(np provider list --nrn "$ACCOUNT_NRN" --format json --limit 100) echo "Resolving region and VPC for account: ${ACCOUNT_NRN}" -echo "DEBUG np provider list output: ${NP_PROVIDERS}" # Resolve region from the account provider (stored key: account.region) ACCOUNT_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ From 9af05cb0580958707cd00325ac3dd0548430f9f0 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 20 Mar 2026 14:42:56 -0300 Subject: [PATCH 11/34] fix(rds-postgres-server): disable the linkable_to patch in write_service_outputs Comments out the `np service patch --body '{"linkable_to": []}'` call instead of removing it, keeping the toggle easy to re-enable. --- rds-postgres-server/scripts/aws/write_service_outputs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rds-postgres-server/scripts/aws/write_service_outputs b/rds-postgres-server/scripts/aws/write_service_outputs index ec2f6fd..48b7886 100755 --- a/rds-postgres-server/scripts/aws/write_service_outputs +++ b/rds-postgres-server/scripts/aws/write_service_outputs @@ -46,5 +46,5 @@ echo " db_instance_identifier: $DB_INSTANCE_IDENTIFIER" np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}" echo "Service attributes updated successfully." -np service patch --id "$SERVICE_ID" --body '{"linkable_to": []}' -echo "Service linkable_to set to []." +#np service patch --id "$SERVICE_ID" --body '{"linkable_to": []}' +#echo "Service linkable_to set to []." From 6a3be98fda553cf6a45c2373ada135f73939d4b0 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 25 Mar 2026 10:46:10 -0300 Subject: [PATCH 12/34] docs(rds-postgres): add README for rds-postgres-db and rds-postgres-server Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-db/README.md | 165 ++++++++++++++++++++++++++++++++++ rds-postgres-server/README.md | 147 ++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 rds-postgres-db/README.md create mode 100644 rds-postgres-server/README.md diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md new file mode 100644 index 0000000..74de90a --- /dev/null +++ b/rds-postgres-db/README.md @@ -0,0 +1,165 @@ +# rds-postgres-db + +A nullplatform dependency service that provisions and manages a **PostgreSQL database** within an existing RDS instance managed by [`rds-postgres-server`](../rds-postgres-server). It handles database creation, app-level user management, and per-link fine-grained access control — without creating any AWS infrastructure itself. + +## What It Does + +- Auto-discovers a compatible `rds-postgres-server` in the same nullplatform namespace using dimension matching +- Creates a dedicated PostgreSQL database and application-level user within that server +- Manages per-link permissions: each link to an application gets its own PostgreSQL user with scoped grants (`read`, `write`, or `read-write`) +- Stores connection credentials in nullplatform service and link attributes for injection into applications + +## Architecture + +``` +nullplatform Application + │ + │ link (creates user + grants) + ▼ + rds-postgres-db ──────► rds-postgres-server ──────► AWS RDS PostgreSQL + (this service) (auto-discovered) │ + │ ├─ database: app_ + │ ├─ user: app_ (service-level) + └─ per link: └─ user: np_ (per link) + postgresql_role. + postgresql_grant.* +``` + +Unlike `rds-postgres-server`, this service creates no AWS resources. It only manages PostgreSQL-level objects (databases, roles, grants) on the shared RDS instance. + +## Nullplatform Integration + +- **Dependency service type**: registered as a `dependency` service in nullplatform +- **Auto-discovery**: at creation time, queries nullplatform for `dependency` services in the same namespace with `status=active` and attributes `hostname` + `master_secret_arn` set, filtered by matching dimensions +- **Service attributes**: writes connection metadata back to nullplatform via `np service patch` +- **Link attributes**: writes per-link credentials to nullplatform via `np link patch` for injection into application environment + +### Service Attributes (written after create) + +| Attribute | Visibility | Description | +|---|---|---| +| `hostname` | exported | RDS endpoint hostname | +| `port` | exported | RDS port (5432) | +| `username` | exported | Service-level PostgreSQL user | +| `password` | hidden | Service-level PostgreSQL password | +| `database_name` | exported | PostgreSQL database name | +| `master_secret_arn` | internal | Secrets Manager ARN (used for link operations) | + +### Link Attributes (written per link) + +| Attribute | Description | +|---|---| +| `username` | Per-link PostgreSQL user (`np_`) | +| `password` | Per-link PostgreSQL password | +| `database_name` | Database name (same as service-level database) | + +## Link Parameters + +| Parameter | Type | Required | Default | Allowed Values | +|---|---|---|---|---| +| `access_level` | enum | No | `read-write` | `read`, `write`, `read-write` | + +### Access Level Grants + +| Level | Grants | +|---|---| +| `read` | `CONNECT` on database, `USAGE` on schema, `SELECT` on tables and sequences | +| `write` | `CONNECT` on database, `USAGE` on schema, `INSERT`, `UPDATE`, `DELETE` on tables, `USAGE` on sequences | +| `read-write` | All of the above + `CREATE` on schema (allows running migrations) | + +All access levels include `DEFAULT PRIVILEGES` so future tables created after the link also inherit the grants automatically. + +## Workflows + +| Workflow | Trigger | What It Does | +|---|---|---| +| `create` | Service created | Auto-discovers server, creates database + app user, writes service attributes | +| `update` | Service updated | No-op (no configurable parameters) | +| `delete` | Service deleted | Reassigns owned objects to master, destroys app user; **database is preserved** | +| `link` | Application linked | Creates per-link PostgreSQL user with scoped grants | +| `unlink` | Application unlinked | Revokes grants only; user and database are **preserved** | + +## Database and Username Derivation + +Database and username values are derived deterministically from nullplatform metadata: + +**Service level** (one per service): +``` +database_name = "app_" +username = "app_" +``` + +**Link level** (one per link): +``` +username = "np_" +# e.g., link_id = "a1b2c3d4-e5f6-..." → username = "np_a1b2c3d4e5f6..." +``` + +This ensures usernames are stable and reproducible even if the service is recreated. + +## Requirements + +### nullplatform Prerequisites + +- An active **`rds-postgres-server`** service in the same nullplatform namespace with: + - `status: active` + - Matching dimensions (e.g., both services must have `cluster: prod`) + - Attributes `hostname` and `master_secret_arn` already set (i.e., RDS instance successfully provisioned) +- The `rds-postgres-server` must expose a Secrets Manager secret with master PostgreSQL credentials + +### AWS IAM Permissions + +This service requires minimal AWS permissions compared to `rds-postgres-server`. The agent only needs: + +- **Secrets Manager**: `GetSecretValue` — to retrieve the master PostgreSQL password from the ARN stored in service attributes + +No RDS, EC2, or S3 permissions are needed. + +### Runtime Dependencies + +These tools are required inside the agent container: + +- **OpenTofu 1.9.0** — auto-downloaded to `/tmp/np-tofu-bin/` if not available in `PATH` +- **AWS CLI** — for Secrets Manager queries +- **jq** — for JSON parsing +- **PostgreSQL client (`psql`)** — installed via `apk add postgresql-client`, used for the `reassign_owned` step during service deletion + +## Important Considerations + +### Auto-Discovery Behavior + +At creation time, the service queries nullplatform for compatible `rds-postgres-server` instances. The discovery fails if: + +- **0 servers found**: No active `rds-postgres-server` exists with matching dimensions in the namespace. Create one first. +- **More than 1 server found**: Multiple candidates match. The error output lists all matching servers. Add or adjust dimensions to make the match unambiguous. + +### Database Is Never Destroyed + +The PostgreSQL database has `lifecycle { prevent_destroy = true }` in Terraform. Even on service deletion, only the app-level user is destroyed — the database and all its data persist on the RDS instance. This is intentional to prevent accidental data loss. + +To fully drop the database, connect directly to the RDS instance using the master credentials from Secrets Manager. + +### Two-Phase Deletion + +Service deletion runs in two steps: +1. **`reassign_owned`**: Transfers ownership of all database objects (tables, sequences, etc.) from the app user to the master user. This is required before dropping the app user, since PostgreSQL prevents dropping roles that own objects. +2. **`tofu destroy`** (targeted): Destroys only `postgresql_role.app_user` and `random_password.user`. The database is not touched. + +### Stable Passwords + +- The service-level password is stable for the lifetime of the service (keyed by `service_id`) +- Per-link passwords are stable across unlink/relink cycles (keyed by `link_id`) + +Neither password changes unless the underlying Terraform resource is tainted or recreated. + +### `read-write` Allows Schema Modifications + +The `read-write` access level includes `CREATE` on the `public` schema. This is intentional to allow applications to run database migrations. If you need to prevent schema changes, use `read` or `write` instead. + +### Dimension Alignment Is Critical + +This service uses dimensions to match the correct `rds-postgres-server`. If dimensions are not aligned between the two services, discovery fails at creation time with a clear error. Ensure both services are created with the same dimension values. + +### Service Must Exist Before Linking + +If the service was created but the `hostname` attribute is empty (e.g., provisioning failed), link operations exit cleanly without performing any database changes. Ensure the service is fully created before attempting to link applications. diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md new file mode 100644 index 0000000..4f40ce4 --- /dev/null +++ b/rds-postgres-server/README.md @@ -0,0 +1,147 @@ +# rds-postgres-server + +A nullplatform dependency service that provisions and manages a shared **Amazon RDS PostgreSQL instance** on AWS. It acts as the infrastructure layer in a two-tier database architecture, creating the actual RDS instance that one or more [`rds-postgres-db`](../rds-postgres-db) services consume. + +## What It Does + +- Provisions an RDS PostgreSQL instance inside a VPC using Terraform (via OpenTofu) +- Stores the master password in AWS Secrets Manager +- Creates a dedicated security group allowing port 5432 within the VPC +- Manages per-link databases and users: each link to an application creates a dedicated PostgreSQL database and user with scoped grants +- Stores connection metadata in nullplatform service attributes so linked services can discover the endpoint + +## Architecture + +``` +nullplatform Application + │ + │ link (creates DB + user) + ▼ +rds-postgres-server ──────► AWS RDS PostgreSQL Instance + (this service) │ └─ Security Group (port 5432, VPC-scoped) + │ │ └─ Secrets Manager (master password) + │ │ └─ S3 Bucket (Terraform state) + └─ per link: + postgresql_database. + postgresql_role. + postgresql_grant.* +``` + +## Nullplatform Integration + +This service integrates with nullplatform through: + +- **Dependency service type**: registered as a `dependency` service in nullplatform +- **Provider resolution**: reads `account.region` and `vpc.id` from account-level nullplatform providers at creation time +- **Service attributes**: writes RDS connection metadata back to nullplatform via `np service patch` after provisioning +- **Link attributes**: writes per-link DB credentials to link attributes via `np link patch` so applications can consume them as environment variables +- **Dimension matching**: supports nullplatform dimensions so multiple environments (e.g., `cluster: prod`, `cluster: staging`) can have isolated RDS instances + +### Service Attributes (written after create) + +| Attribute | Visibility | Description | +|---|---|---| +| `hostname` | exported | RDS endpoint hostname | +| `port` | exported | RDS port (5432) | +| `db_instance_identifier` | internal | AWS RDS resource identifier | +| `master_secret_arn` | internal | Secrets Manager ARN for master credentials | + +### Link Attributes (written per link) + +| Attribute | Description | +|---|---| +| `username` | PostgreSQL user for this link | +| `password` | PostgreSQL password (injected as secret) | +| `database_name` | PostgreSQL database name for this link | +| `hostname` | RDS endpoint hostname | +| `port` | RDS port | + +## Configuration Parameters + +Exposed in the nullplatform UI when creating or updating the service: + +| Parameter | Type | Default | Allowed Values | Editable After Create | +|---|---|---|---|---| +| `instance_class` | string | `db.t3.micro` | `db.t3.micro`, `db.t3.small`, `db.t3.medium`, `db.m5.large` | Yes | +| `allocated_storage` | number | `20` | 20–1000 (GB) | Yes | +| `postgres_version` | string | `16` | `14`, `15`, `16` | No | + +> `postgres_version` cannot be changed after creation because PostgreSQL major version upgrades require manual intervention and are not managed by this service. + +## Workflows + +| Workflow | Trigger | What It Does | +|---|---|---| +| `create` | Service created | Provisions RDS instance, security group, Secrets Manager secret, S3 tfstate bucket | +| `update` | Service updated | Applies Terraform changes (instance class, storage) | +| `delete` | Service deleted | Destroys RDS instance and all associated resources; **no final snapshot is taken** | +| `link` | Application linked | Creates a PostgreSQL database + user with `CONNECT`, `USAGE`, and DML grants | +| `unlink` | Application unlinked | Revokes grants only; database and user are **preserved** for data retention | + +## Infrastructure Resources Created + +| Resource | Description | +|---|---| +| `aws_db_instance` | The RDS PostgreSQL instance (gp3 storage, encrypted, no public access) | +| `aws_db_subnet_group` | Subnet group using VPC subnets tagged `nullplatform/subnet-type=private` | +| `aws_security_group` | Allows port 5432 ingress from within the VPC | +| `aws_secretsmanager_secret` | Stores the master PostgreSQL password | +| `aws_s3_bucket` | `np-service-` — versioned bucket for Terraform state | +| `postgresql_database` | One per link — isolated database per application link | +| `postgresql_role` | One per link — isolated PostgreSQL user per application link | + +## Requirements + +### nullplatform Prerequisites + +- An active nullplatform account with at least one **provider** exposing: + - `account.region` — the AWS region where the RDS instance will be created + - `vpc.id` — the VPC where the RDS instance will be placed +- The VPC must have private subnets tagged with `nullplatform/subnet-type=private` + +### AWS IAM Permissions + +The agent executing this service needs the following IAM permissions (see `requirements/main.tf`): + +- **RDS**: `CreateDBInstance`, `DeleteDBInstance`, `ModifyDBInstance`, `DescribeDBInstances`, subnet group management, tagging +- **EC2**: Security group management, `DescribeVpcs`, `DescribeSubnets` +- **Secrets Manager**: Full lifecycle (`CreateSecret`, `DeleteSecret`, `GetSecretValue`, `PutSecretValue`, etc.) +- **S3**: Full lifecycle on the `np-service-` bucket +- **IAM**: `CreateServiceLinkedRole` (for RDS) + +The `requirements/` Terraform module can be used to create and attach the necessary IAM policies to an existing role. + +### Runtime Dependencies + +These tools are required inside the agent container: + +- **OpenTofu 1.9.0** — auto-downloaded to `/tmp/np-tofu-bin/` if not available in `PATH` +- **AWS CLI** — for Secrets Manager and S3 operations +- **jq** — for JSON parsing +- **PostgreSQL client (`psql`)** — installed via `apk add postgresql-client` when needed for link operations + +## Important Considerations + +### Data Loss on Delete + +The service uses `skip_final_snapshot = true`. **Deleting the service permanently destroys all data** in the RDS instance with no automated backup. Ensure manual snapshots are taken before deletion if data recovery is needed. + +### Unlink Preserves Data + +When an application is unlinked, only the PostgreSQL **grants are revoked** — the database and user are preserved. This prevents accidental data loss when relinking or migrating applications. + +### Dimension Alignment + +For `rds-postgres-db` services to auto-discover this server, both services must share the same nullplatform dimensions (e.g., `cluster: prod`). Mismatched dimensions will cause discovery to fail. + +### Secrets Manager Deletion + +The master password secret is deleted immediately on service destroy (`recovery_window_in_days = 0`). There is no recovery window. + +### Storage Encryption + +All RDS instances are created with `storage_encrypted = true` using the default AWS-managed key. + +### Terraform State + +Terraform state is stored in an S3 bucket named `np-service-` with versioning enabled. This bucket is created before provisioning and deleted (including all versions) after the RDS instance is destroyed. From 029780b6e2e63dcb3a5d070a5f7aab42d6ca1ad4 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Mon, 20 Apr 2026 17:46:30 -0300 Subject: [PATCH 13/34] fix(rds-postgres-server): propagate TFSTATE_BUCKET through link/unlink workflows build_permissions_context was reading tfstate_bucket from values.yaml (which never defined it), causing an empty bucket name on terraform init during link and unlink actions. Align with rds-postgres-db pattern: export TFSTATE_BUCKET from build_context via workflow output and consume it from the environment. Added an explicit guard in build_permissions_context to fail fast if the var is missing. Co-Authored-By: Claude Sonnet 4.6 --- rds-postgres-server/scripts/aws/build_permissions_context | 6 +++++- rds-postgres-server/workflows/aws/link.yaml | 2 ++ rds-postgres-server/workflows/aws/unlink.yaml | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/rds-postgres-server/scripts/aws/build_permissions_context b/rds-postgres-server/scripts/aws/build_permissions_context index 2a2b799..d7a6cac 100755 --- a/rds-postgres-server/scripts/aws/build_permissions_context +++ b/rds-postgres-server/scripts/aws/build_permissions_context @@ -26,13 +26,17 @@ if [ -z "${REGION:-}" ]; then exit 1 fi -TFSTATE_BUCKET=$(yaml_value "tfstate_bucket" "" "$VALUES") AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then export AWS_PROFILE="${AWS_PROFILE_VAL}" fi +if [ -z "${TFSTATE_BUCKET:-}" ]; then + echo "ERROR: TFSTATE_BUCKET is not set. Expected to be exported by build_context." >&2 + exit 1 +fi + # --- Read service outputs (set by write_service_outputs after RDS creation) - SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') diff --git a/rds-postgres-server/workflows/aws/link.yaml b/rds-postgres-server/workflows/aws/link.yaml index 272e422..7b27a17 100644 --- a/rds-postgres-server/workflows/aws/link.yaml +++ b/rds-postgres-server/workflows/aws/link.yaml @@ -7,6 +7,8 @@ steps: type: environment - name: REGION type: environment + - name: TFSTATE_BUCKET + type: environment - name: LINK_ID type: environment - name: LINK_NAME diff --git a/rds-postgres-server/workflows/aws/unlink.yaml b/rds-postgres-server/workflows/aws/unlink.yaml index 062c305..6c0bebe 100644 --- a/rds-postgres-server/workflows/aws/unlink.yaml +++ b/rds-postgres-server/workflows/aws/unlink.yaml @@ -7,6 +7,8 @@ steps: type: environment - name: REGION type: environment + - name: TFSTATE_BUCKET + type: environment - name: LINK_ID type: environment - name: LINK_NAME From 67a0a59ada1c83dd4d59aec306bffad9ad298f5d Mon Sep 17 00:00:00 2001 From: Javier Castiarena Date: Tue, 21 Apr 2026 09:43:44 -0300 Subject: [PATCH 14/34] fix(rds-postgres-server): skip db_name check on unlink of never-created link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a link transitions to a failed state before fully materializing, its .link.attributes never receives the db_name parameter. Subsequent unlink actions then hit `ERROR: db_name is required to create a link` and the link gets stuck — user cannot retry nor remove it cleanly. Mirror the pattern from rds-postgres-db/scripts/aws/build_context (which sets LINK_NEVER_CREATED=true when .type == "delete" and there is no server hostname): on a delete action with missing db_name, log a warning and exit cleanly instead of failing the unlink workflow. The error is preserved for create/link actions, where missing db_name is still a user error. Reproduced on the Galicia POC (CLIEN-759): a second link test with invalid parameters left the link in failed state; the unlink could never clean it up. Pairs with #12 (TFSTATE_BUCKET propagation). Co-Authored-By: Claude Opus 4.7 (1M context) --- rds-postgres-server/scripts/aws/build_permissions_context | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rds-postgres-server/scripts/aws/build_permissions_context b/rds-postgres-server/scripts/aws/build_permissions_context index 2a2b799..ecdf72f 100755 --- a/rds-postgres-server/scripts/aws/build_permissions_context +++ b/rds-postgres-server/scripts/aws/build_permissions_context @@ -36,6 +36,7 @@ fi # --- Read service outputs (set by write_service_outputs after RDS creation) - SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') +ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type // ""') SERVICE_ATTRS=$(echo "$CONTEXT" | jq -r '.service.attributes // {}') DB_HOST=$(echo "$SERVICE_ATTRS" | jq -r '.hostname // ""') @@ -77,6 +78,10 @@ LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // DB_NAME=$(echo "$LINK_ATTRS" | jq -r '.db_name // ""') if [ -z "$DB_NAME" ]; then + if [ "$ACTION_TYPE" = "delete" ]; then + echo "WARNING: unlink without db_name — link likely never fully created. Exiting cleanly." + exit 0 + fi echo "ERROR: db_name is required to create a link" >&2 exit 1 fi From d257d5c784c25961ef1fbdd803a701325056bd06 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 2 Jul 2026 10:40:02 -0300 Subject: [PATCH 15/34] feat(rds-postgres-server): create dedicated AssumeRole IAM role in requirements/ --- rds-postgres-server/README.md | 8 +++- .../requirements/.terraform.lock.hcl | 25 ++++++++++ rds-postgres-server/requirements/data.tf | 1 + rds-postgres-server/requirements/locals.tf | 12 +++++ rds-postgres-server/requirements/main.tf | 48 ++++++++++++++----- rds-postgres-server/requirements/output.tf | 15 ++++++ rds-postgres-server/requirements/variables.tf | 42 ++++++++++++++-- rds-postgres-server/requirements/versions.tf | 8 ++++ 8 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 rds-postgres-server/requirements/.terraform.lock.hcl create mode 100644 rds-postgres-server/requirements/data.tf create mode 100644 rds-postgres-server/requirements/locals.tf create mode 100644 rds-postgres-server/requirements/versions.tf diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md index 4f40ce4..a4b6aea 100644 --- a/rds-postgres-server/README.md +++ b/rds-postgres-server/README.md @@ -109,7 +109,13 @@ The agent executing this service needs the following IAM permissions (see `requi - **S3**: Full lifecycle on the `np-service-` bucket - **IAM**: `CreateServiceLinkedRole` (for RDS) -The `requirements/` Terraform module can be used to create and attach the necessary IAM policies to an existing role. +The `requirements/` Terraform module creates a dedicated IAM role +(`nullplatform--rds-postgres-server-role`) holding these +policies, with a trust policy allowing the nullplatform agent role to +`sts:AssumeRole` on it. Pass `cluster_name` (required) and optionally +`agent_role_arn` (defaults to `nullplatform--agent-role`) when +applying it. Granting the agent itself permission to assume this role is +handled separately, outside this module. ### Runtime Dependencies diff --git a/rds-postgres-server/requirements/.terraform.lock.hcl b/rds-postgres-server/requirements/.terraform.lock.hcl new file mode 100644 index 0000000..f8d1ed5 --- /dev/null +++ b/rds-postgres-server/requirements/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.53.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=", + "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c", + "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8", + "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69", + "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87", + "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f", + "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc", + "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760", + "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147", + "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b", + "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b", + "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be", + "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46", + "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138", + "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc", + "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6", + ] +} diff --git a/rds-postgres-server/requirements/data.tf b/rds-postgres-server/requirements/data.tf new file mode 100644 index 0000000..8fc4b38 --- /dev/null +++ b/rds-postgres-server/requirements/data.tf @@ -0,0 +1 @@ +data "aws_caller_identity" "current" {} diff --git a/rds-postgres-server/requirements/locals.tf b/rds-postgres-server/requirements/locals.tf new file mode 100644 index 0000000..7ad074e --- /dev/null +++ b/rds-postgres-server/requirements/locals.tf @@ -0,0 +1,12 @@ +locals { + iam_module_name = "requirements-rds-postgres-server" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-server-role" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-server" + Module = local.iam_module_name + }) +} diff --git a/rds-postgres-server/requirements/main.tf b/rds-postgres-server/requirements/main.tf index c5cb268..a78a664 100644 --- a/rds-postgres-server/requirements/main.tf +++ b/rds-postgres-server/requirements/main.tf @@ -1,28 +1,50 @@ ################################################################################ -# Policy attachments (only when role_name is provided) +# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole) +################################################################################ + +resource "aws_iam_role" "nullplatform_rds_postgres_server" { + count = local.iam_create ? 1 : 0 + + name = local.role_name + description = "Permissions role assumed by the nullplatform agent role for rds-postgres-server in cluster ${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} + +################################################################################ +# Policy attachments ################################################################################ resource "aws_iam_role_policy_attachment" "rds" { - count = var.role_name != null ? 1 : 0 - role = var.role_name + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name policy_arn = aws_iam_policy.nullplatform_rds_policy.arn } resource "aws_iam_role_policy_attachment" "rds_sg" { - count = var.role_name != null ? 1 : 0 - role = var.role_name + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name policy_arn = aws_iam_policy.nullplatform_rds_sg_policy.arn } resource "aws_iam_role_policy_attachment" "rds_secretsmanager" { - count = var.role_name != null ? 1 : 0 - role = var.role_name + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn } resource "aws_iam_role_policy_attachment" "rds_s3" { - count = var.role_name != null ? 1 : 0 - role = var.role_name + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name policy_arn = aws_iam_policy.nullplatform_rds_s3_policy.arn } @@ -32,7 +54,7 @@ resource "aws_iam_role_policy_attachment" "rds_s3" { # Grant permissions to manage RDS instances and subnet groups resource "aws_iam_policy" "nullplatform_rds_policy" { - name = "nullplatform_${var.name}_rds_policy" + name = "nullplatform-${var.cluster_name}-rds-policy" description = "Policy for managing RDS instances and subnet groups" policy = jsonencode({ @@ -71,7 +93,7 @@ resource "aws_iam_policy" "nullplatform_rds_policy" { # Grant permissions to manage EC2 security groups for RDS resource "aws_iam_policy" "nullplatform_rds_sg_policy" { - name = "nullplatform_${var.name}_rds_sg_policy" + name = "nullplatform-${var.cluster_name}-rds-sg-policy" description = "Policy for managing EC2 security groups for RDS" policy = jsonencode({ @@ -106,7 +128,7 @@ resource "aws_iam_policy" "nullplatform_rds_sg_policy" { # Grant permissions to manage the per-link S3 bucket used to store tofu state resource "aws_iam_policy" "nullplatform_rds_s3_policy" { - name = "nullplatform_${var.name}_rds_s3_policy" + name = "nullplatform-${var.cluster_name}-rds-s3-policy" description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" policy = jsonencode({ @@ -141,7 +163,7 @@ resource "aws_iam_policy" "nullplatform_rds_s3_policy" { # Grant permissions to manage Secrets Manager secrets for RDS master password resource "aws_iam_policy" "nullplatform_rds_secretsmanager_policy" { - name = "nullplatform_${var.name}_rds_secretsmanager_policy" + name = "nullplatform-${var.cluster_name}-rds-secretsmanager-policy" description = "Policy for managing Secrets Manager secrets for RDS master password" policy = jsonencode({ diff --git a/rds-postgres-server/requirements/output.tf b/rds-postgres-server/requirements/output.tf index e42de8b..a2aac00 100644 --- a/rds-postgres-server/requirements/output.tf +++ b/rds-postgres-server/requirements/output.tf @@ -12,3 +12,18 @@ output "rds_secretsmanager_policy_arn" { description = "ARN of the Secrets Manager policy" value = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn } + +output "permissions_role_arn" { + description = "ARN of the rds-postgres-server permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)." + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].arn : "" +} + +output "permissions_role_name" { + description = "Name of the rds-postgres-server permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].name : "" +} + +output "permissions_role_id" { + description = "ID of the rds-postgres-server permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].id : "" +} diff --git a/rds-postgres-server/requirements/variables.tf b/rds-postgres-server/requirements/variables.tf index f760a50..83d0c00 100644 --- a/rds-postgres-server/requirements/variables.tf +++ b/rds-postgres-server/requirements/variables.tf @@ -1,10 +1,44 @@ -variable "name" { - description = "Unique identifier for policy naming. Must be unique per AWS account (IAM policy names are account-global). Example: \"prod-us-east-1\"." +variable "cluster_name" { + description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy names, and the default agent role ARN. Must be unique per AWS account (IAM policy names are account-global). Example: \"prod-us-east-1\"." type = string } +variable "agent_role_arn" { + description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role." + type = string + default = "" + + validation { + condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) + error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/" + } +} + +variable "additional_agent_role_arns" { + description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none." + type = list(string) + default = [] + + validation { + condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))]) + error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/" + } +} + variable "role_name" { - description = "IAM role name to attach the RDS policies to. If set, Terraform manages the attachments and will detach them automatically on destroy." + description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-server-role." type = string - default = null + default = "" +} + +variable "iam_create_role" { + description = "Whether to create the permissions role and its policies. When false, the module produces no resources." + type = bool + default = true +} + +variable "iam_resource_tags_json" { + description = "Tags to apply to IAM resources created by this module." + type = map(string) + default = {} } diff --git a/rds-postgres-server/requirements/versions.tf b/rds-postgres-server/requirements/versions.tf new file mode 100644 index 0000000..da078fc --- /dev/null +++ b/rds-postgres-server/requirements/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} From 659c57d0c99e561ee3b73618b75321c295f475b0 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 2 Jul 2026 10:49:55 -0300 Subject: [PATCH 16/34] fix(rds-postgres-server): gate existing IAM policies on iam_create_role Co-Authored-By: Claude Sonnet 5 --- rds-postgres-server/requirements/main.tf | 16 ++++++++++++---- rds-postgres-server/requirements/output.tf | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/rds-postgres-server/requirements/main.tf b/rds-postgres-server/requirements/main.tf index a78a664..eb4448e 100644 --- a/rds-postgres-server/requirements/main.tf +++ b/rds-postgres-server/requirements/main.tf @@ -27,25 +27,25 @@ resource "aws_iam_role" "nullplatform_rds_postgres_server" { resource "aws_iam_role_policy_attachment" "rds" { count = local.iam_create ? 1 : 0 role = aws_iam_role.nullplatform_rds_postgres_server[0].name - policy_arn = aws_iam_policy.nullplatform_rds_policy.arn + policy_arn = aws_iam_policy.nullplatform_rds_policy[0].arn } resource "aws_iam_role_policy_attachment" "rds_sg" { count = local.iam_create ? 1 : 0 role = aws_iam_role.nullplatform_rds_postgres_server[0].name - policy_arn = aws_iam_policy.nullplatform_rds_sg_policy.arn + policy_arn = aws_iam_policy.nullplatform_rds_sg_policy[0].arn } resource "aws_iam_role_policy_attachment" "rds_secretsmanager" { count = local.iam_create ? 1 : 0 role = aws_iam_role.nullplatform_rds_postgres_server[0].name - policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn + policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn } resource "aws_iam_role_policy_attachment" "rds_s3" { count = local.iam_create ? 1 : 0 role = aws_iam_role.nullplatform_rds_postgres_server[0].name - policy_arn = aws_iam_policy.nullplatform_rds_s3_policy.arn + policy_arn = aws_iam_policy.nullplatform_rds_s3_policy[0].arn } ################################################################################ @@ -54,6 +54,8 @@ resource "aws_iam_role_policy_attachment" "rds_s3" { # Grant permissions to manage RDS instances and subnet groups resource "aws_iam_policy" "nullplatform_rds_policy" { + count = local.iam_create ? 1 : 0 + name = "nullplatform-${var.cluster_name}-rds-policy" description = "Policy for managing RDS instances and subnet groups" @@ -93,6 +95,8 @@ resource "aws_iam_policy" "nullplatform_rds_policy" { # Grant permissions to manage EC2 security groups for RDS resource "aws_iam_policy" "nullplatform_rds_sg_policy" { + count = local.iam_create ? 1 : 0 + name = "nullplatform-${var.cluster_name}-rds-sg-policy" description = "Policy for managing EC2 security groups for RDS" @@ -128,6 +132,8 @@ resource "aws_iam_policy" "nullplatform_rds_sg_policy" { # Grant permissions to manage the per-link S3 bucket used to store tofu state resource "aws_iam_policy" "nullplatform_rds_s3_policy" { + count = local.iam_create ? 1 : 0 + name = "nullplatform-${var.cluster_name}-rds-s3-policy" description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" @@ -163,6 +169,8 @@ resource "aws_iam_policy" "nullplatform_rds_s3_policy" { # Grant permissions to manage Secrets Manager secrets for RDS master password resource "aws_iam_policy" "nullplatform_rds_secretsmanager_policy" { + count = local.iam_create ? 1 : 0 + name = "nullplatform-${var.cluster_name}-rds-secretsmanager-policy" description = "Policy for managing Secrets Manager secrets for RDS master password" diff --git a/rds-postgres-server/requirements/output.tf b/rds-postgres-server/requirements/output.tf index a2aac00..041efb6 100644 --- a/rds-postgres-server/requirements/output.tf +++ b/rds-postgres-server/requirements/output.tf @@ -1,16 +1,16 @@ output "rds_policy_arn" { description = "ARN of the RDS management policy" - value = aws_iam_policy.nullplatform_rds_policy.arn + value = local.iam_create ? aws_iam_policy.nullplatform_rds_policy[0].arn : "" } output "rds_sg_policy_arn" { description = "ARN of the EC2 security group policy" - value = aws_iam_policy.nullplatform_rds_sg_policy.arn + value = local.iam_create ? aws_iam_policy.nullplatform_rds_sg_policy[0].arn : "" } output "rds_secretsmanager_policy_arn" { description = "ARN of the Secrets Manager policy" - value = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn + value = local.iam_create ? aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn : "" } output "permissions_role_arn" { From 59e1c70a0ee1c89ab319c238b53054fdf993fb9a Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 2 Jul 2026 10:57:18 -0300 Subject: [PATCH 17/34] feat(rds-postgres-db): add requirements/ module with AssumeRole IAM role --- rds-postgres-db/README.md | 8 ++++ .../requirements/.terraform.lock.hcl | 25 ++++++++++ rds-postgres-db/requirements/data.tf | 1 + rds-postgres-db/requirements/locals.tf | 12 +++++ rds-postgres-db/requirements/main.tf | 47 +++++++++++++++++++ rds-postgres-db/requirements/output.tf | 19 ++++++++ rds-postgres-db/requirements/variables.tf | 44 +++++++++++++++++ rds-postgres-db/requirements/versions.tf | 8 ++++ 8 files changed, 164 insertions(+) create mode 100644 rds-postgres-db/requirements/.terraform.lock.hcl create mode 100644 rds-postgres-db/requirements/data.tf create mode 100644 rds-postgres-db/requirements/locals.tf create mode 100644 rds-postgres-db/requirements/main.tf create mode 100644 rds-postgres-db/requirements/output.tf create mode 100644 rds-postgres-db/requirements/variables.tf create mode 100644 rds-postgres-db/requirements/versions.tf diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index 74de90a..457d4f4 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -115,6 +115,14 @@ This service requires minimal AWS permissions compared to `rds-postgres-server`. No RDS, EC2, or S3 permissions are needed. +The `requirements/` Terraform module creates a dedicated IAM role +(`nullplatform--rds-postgres-db-role`) holding this policy, +with a trust policy allowing the nullplatform agent role to `sts:AssumeRole` +on it. Pass `cluster_name` (required) and optionally `agent_role_arn` +(defaults to `nullplatform--agent-role`) when applying it. +Granting the agent itself permission to assume this role is handled +separately, outside this module. + ### Runtime Dependencies These tools are required inside the agent container: diff --git a/rds-postgres-db/requirements/.terraform.lock.hcl b/rds-postgres-db/requirements/.terraform.lock.hcl new file mode 100644 index 0000000..f8d1ed5 --- /dev/null +++ b/rds-postgres-db/requirements/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.53.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=", + "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c", + "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8", + "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69", + "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87", + "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f", + "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc", + "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760", + "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147", + "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b", + "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b", + "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be", + "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46", + "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138", + "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc", + "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6", + ] +} diff --git a/rds-postgres-db/requirements/data.tf b/rds-postgres-db/requirements/data.tf new file mode 100644 index 0000000..8fc4b38 --- /dev/null +++ b/rds-postgres-db/requirements/data.tf @@ -0,0 +1 @@ +data "aws_caller_identity" "current" {} diff --git a/rds-postgres-db/requirements/locals.tf b/rds-postgres-db/requirements/locals.tf new file mode 100644 index 0000000..3cc3f89 --- /dev/null +++ b/rds-postgres-db/requirements/locals.tf @@ -0,0 +1,12 @@ +locals { + iam_module_name = "requirements-rds-postgres-db" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-db-role" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-db" + Module = local.iam_module_name + }) +} diff --git a/rds-postgres-db/requirements/main.tf b/rds-postgres-db/requirements/main.tf new file mode 100644 index 0000000..3a0baac --- /dev/null +++ b/rds-postgres-db/requirements/main.tf @@ -0,0 +1,47 @@ +################################################################################ +# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole) +################################################################################ + +resource "aws_iam_role" "nullplatform_rds_postgres_db" { + count = local.iam_create ? 1 : 0 + + name = local.role_name + description = "Permissions role assumed by the nullplatform agent role for rds-postgres-db in cluster ${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} + +################################################################################ +# Secrets Manager IAM policy — read-only access to the RDS master password +################################################################################ + +resource "aws_iam_policy" "nullplatform_rds_postgres_db_secretsmanager_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-secretsmanager-policy" + description = "Policy for reading the RDS master password from Secrets Manager" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = "arn:aws:secretsmanager:*:${data.aws_caller_identity.current.account_id}:secret:nullplatform/rds/*" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "rds_postgres_db_secretsmanager" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_db[0].name + policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn +} diff --git a/rds-postgres-db/requirements/output.tf b/rds-postgres-db/requirements/output.tf new file mode 100644 index 0000000..f75ac5a --- /dev/null +++ b/rds-postgres-db/requirements/output.tf @@ -0,0 +1,19 @@ +output "permissions_role_arn" { + description = "ARN of the rds-postgres-db permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)." + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].arn : "" +} + +output "permissions_role_name" { + description = "Name of the rds-postgres-db permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].name : "" +} + +output "permissions_role_id" { + description = "ID of the rds-postgres-db permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].id : "" +} + +output "secretsmanager_policy_arn" { + description = "ARN of the Secrets Manager read policy" + value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn : "" +} diff --git a/rds-postgres-db/requirements/variables.tf b/rds-postgres-db/requirements/variables.tf new file mode 100644 index 0000000..a0f3af7 --- /dev/null +++ b/rds-postgres-db/requirements/variables.tf @@ -0,0 +1,44 @@ +variable "cluster_name" { + description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy name, and the default agent role ARN." + type = string +} + +variable "agent_role_arn" { + description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role." + type = string + default = "" + + validation { + condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) + error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/" + } +} + +variable "additional_agent_role_arns" { + description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none." + type = list(string) + default = [] + + validation { + condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))]) + error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/" + } +} + +variable "role_name" { + description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-db-role." + type = string + default = "" +} + +variable "iam_create_role" { + description = "Whether to create the permissions role and its policy. When false, the module produces no resources." + type = bool + default = true +} + +variable "iam_resource_tags_json" { + description = "Tags to apply to IAM resources created by this module." + type = map(string) + default = {} +} diff --git a/rds-postgres-db/requirements/versions.tf b/rds-postgres-db/requirements/versions.tf new file mode 100644 index 0000000..da078fc --- /dev/null +++ b/rds-postgres-db/requirements/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} From 1809735d981302de032329c08b34d2f0c415d047 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 2 Jul 2026 11:15:45 -0300 Subject: [PATCH 18/34] docs: fix secret-naming citation and clarify cluster-wide Secrets Manager scope --- rds-postgres-db/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index 457d4f4..8b7a857 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -123,6 +123,14 @@ on it. Pass `cluster_name` (required) and optionally `agent_role_arn` Granting the agent itself permission to assume this role is handled separately, outside this module. +This role and its policy are shared per **cluster**, not per linked +`rds-postgres-server` instance — the `GetSecretValue` grant is scoped to the +`nullplatform/rds/*` secret-name prefix (every master secret in the cluster +following that naming convention), not to the single secret this particular +service instance's link actually uses. Anything that assumes this role can +read the master password of any `rds-postgres-server` in the cluster, not +just the linked one. + ### Runtime Dependencies These tools are required inside the agent container: From 6b14916d66eeb4e8241790d3ccf009621508781e Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 2 Jul 2026 13:16:14 -0300 Subject: [PATCH 19/34] fix(rds-postgres-db): disambiguate secretsmanager policy name to avoid collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both modules used the identical name nullplatform--rds-secretsmanager-policy for two different policies. IAM policy names are account-wide unique, so deploying both modules against the same cluster_name fails with EntityAlreadyExists — caught by an actual tofu apply against a real AWS account with both modules together. --- rds-postgres-db/requirements/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rds-postgres-db/requirements/main.tf b/rds-postgres-db/requirements/main.tf index 3a0baac..6477d5c 100644 --- a/rds-postgres-db/requirements/main.tf +++ b/rds-postgres-db/requirements/main.tf @@ -27,7 +27,7 @@ resource "aws_iam_role" "nullplatform_rds_postgres_db" { resource "aws_iam_policy" "nullplatform_rds_postgres_db_secretsmanager_policy" { count = local.iam_create ? 1 : 0 - name = "nullplatform-${var.cluster_name}-rds-secretsmanager-policy" + name = "nullplatform-${var.cluster_name}-rds-postgres-db-secretsmanager-policy" description = "Policy for reading the RDS master password from Secrets Manager" policy = jsonencode({ From 9f6c51c8b739e97ff3153232bc2e70d4a7e29732 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 2 Jul 2026 16:04:35 -0300 Subject: [PATCH 20/34] feat(rds-postgres-server): assume the AssumeRole IAM role at runtime --- rds-postgres-server/scripts/aws/assume_role | 43 +++++++++++++++++ .../scripts/aws/assume_role_lib | 46 +++++++++++++++++++ .../scripts/aws/assume_role_step | 43 +++++++++++++++++ rds-postgres-server/values.yaml | 6 +++ rds-postgres-server/workflows/aws/create.yaml | 11 +++++ rds-postgres-server/workflows/aws/delete.yaml | 11 +++++ rds-postgres-server/workflows/aws/link.yaml | 11 +++++ rds-postgres-server/workflows/aws/unlink.yaml | 11 +++++ rds-postgres-server/workflows/aws/update.yaml | 11 +++++ 9 files changed, 193 insertions(+) create mode 100755 rds-postgres-server/scripts/aws/assume_role create mode 100755 rds-postgres-server/scripts/aws/assume_role_lib create mode 100755 rds-postgres-server/scripts/aws/assume_role_step diff --git a/rds-postgres-server/scripts/aws/assume_role b/rds-postgres-server/scripts/aws/assume_role new file mode 100755 index 0000000..7162634 --- /dev/null +++ b/rds-postgres-server/scripts/aws/assume_role @@ -0,0 +1,43 @@ +#!/bin/bash +# Sourceable helper — do NOT execute directly. +# Reads RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN from the environment. If set, calls +# sts:AssumeRole and exports temporary credentials so all subsequent AWS calls +# (including tofu) use that role. If empty, does nothing — the agent's +# credentials (pod IRSA) handle auth. +# +# Requires: aws CLI, jq. +# Expects: RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN (set by scripts/aws/assume_role_step), +# SERVICE_ID (optional, used for the session name). + +if [ -n "${RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN:-}" ]; then + echo " 🔑 Assuming role: $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" + + _ar_sts_error=$(mktemp) + if ! ASSUMED_CREDS=$(aws sts assume-role \ + --role-arn "$RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" \ + --role-session-name "np-rds-postgres-server-${SERVICE_ID:-workflow}" \ + --output json 2>"$_ar_sts_error"); then + echo " ❌ sts:AssumeRole failed for $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" >&2 + cat "$_ar_sts_error" >&2 + rm -f "$_ar_sts_error" + return 1 + fi + rm -f "$_ar_sts_error" + + _ar_access_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId // ""') + _ar_secret_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey // ""') + _ar_session_token=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken // ""') + + if [ -z "$_ar_access_key" ] || [ -z "$_ar_secret_key" ] || [ -z "$_ar_session_token" ]; then + echo " ❌ sts:AssumeRole returned incomplete credentials for $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" >&2 + return 1 + fi + + export AWS_ACCESS_KEY_ID="$_ar_access_key" + export AWS_SECRET_ACCESS_KEY="$_ar_secret_key" + export AWS_SESSION_TOKEN="$_ar_session_token" + + echo " ✅ Role assumed successfully" +else + echo " ✅ assume_role=skipped (using agent credentials)" +fi diff --git a/rds-postgres-server/scripts/aws/assume_role_lib b/rds-postgres-server/scripts/aws/assume_role_lib new file mode 100755 index 0000000..a8b25da --- /dev/null +++ b/rds-postgres-server/scripts/aws/assume_role_lib @@ -0,0 +1,46 @@ +#!/bin/bash +# Sourceable library of PURE helpers for assume-role resolution. +# +# Input is the AWS IAM provider exactly as it appears in +# CONTEXT.providers["identity-access-control"] — the platform already resolved it +# for the service's dimensions (most-specific config whose dimensions are a subset +# of the service's wins). These helpers only pick the selector, so they make NO +# np/aws calls and have no side effects on source — fully unit-testable. +# +# Requires (at call time): jq. + +# arn_for_selector +# Given CONTEXT.providers["identity-access-control"], echoes the ARN whose entry +# in .iam_role_arns.arns[] matches , or "" if none. First match wins. +# Returns "" on empty/malformed input (never crashes). +arn_for_selector() { + local json="$1" selector="$2" + [ -n "$json" ] || return 0 + [ -n "$selector" ] || return 0 + printf '%s' "$json" | jq -r --arg sel "$selector" ' + [ .iam_role_arns.arns[]? + | select(.selector == $sel) + | .arn ] + | first // ""' 2>/dev/null || true +} + +# resolve_assume_role_arn +# Echoes the ARN to assume ("" = use agent credentials): +# 1. $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN env var (explicit override) +# 2. AWS IAM provider entry matching (already dimension-resolved +# by the platform via CONTEXT.providers["identity-access-control"]) +# 3. $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT env var (per-account agent default) +# Note: RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN="" (explicitly empty) is treated the same as +# unset — the chain continues to the next source. +resolve_assume_role_arn() { + local iam_json="$1" selector="$2" arn="" + + arn="${RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN:-}" + + if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then + arn=$(arn_for_selector "$iam_json" "$selector") + fi + + arn="${arn:-${RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT:-}}" + printf '%s' "$arn" +} diff --git a/rds-postgres-server/scripts/aws/assume_role_step b/rds-postgres-server/scripts/aws/assume_role_step new file mode 100755 index 0000000..c98015e --- /dev/null +++ b/rds-postgres-server/scripts/aws/assume_role_step @@ -0,0 +1,43 @@ +#!/bin/bash +# Dedicated workflow step: resolve the target IAM role and assume it, exporting +# temporary credentials so every subsequent step (including tofu) inherits them. +# +# Runs FIRST in each AWS-touching workflow. The workflow YAML must declare +# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN as +# output:environment so the engine propagates them to the following steps. +# +# The AWS IAM provider (category "identity-access-control") is read from +# CONTEXT.providers[...], where the platform has ALREADY resolved it for the +# service's dimensions. Requires "identity-access-control" to be listed in +# provider_categories (values.yaml and/or the workflow). +# +# Resolution precedence (see resolve_assume_role_arn in assume_role_lib): +# $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN -> IAM provider by selector +# -> $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT -> agent credentials +# +# Requires: aws CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/assume_role_lib" + +RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR:-rds-postgres-server}" + +# IAM provider as resolved for the service's dimensions by the platform. +IAM_PROVIDER=$(echo "${CONTEXT:-}" | jq -c '.providers["identity-access-control"] // {}' 2>/dev/null) + +RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_PROVIDER" "$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR") +export RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN + +# scripts/aws/assume_role performs sts:AssumeRole and exports AWS_* when an ARN is set, +# or no-ops (leaving agent credentials in place) when empty. Non-zero only when +# sts:AssumeRole itself fails. +if ! source "$SCRIPT_DIR/assume_role"; then + echo " ❌ assume_role step failed: could not assume $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" >&2 + echo "" >&2 + echo "💡 Possible causes:" >&2 + echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2 + echo " • The target role does not exist or does not trust the agent role" >&2 + echo " • There is no role ARN configured for selector=$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR" >&2 + echo "" >&2 + exit 1 +fi diff --git a/rds-postgres-server/values.yaml b/rds-postgres-server/values.yaml index ca9962f..7237d93 100644 --- a/rds-postgres-server/values.yaml +++ b/rds-postgres-server/values.yaml @@ -10,3 +10,9 @@ # will export it so Terraform and AWS CLI use the correct credentials. # Run "aws sso login --profile " before starting np-agent locally. aws_profile: "" + +# Provider categories the platform must resolve into CONTEXT.providers before +# running workflow steps. identity-access-control is required by +# scripts/aws/assume_role_step to look up the AssumeRole target ARN. +provider_categories: + - identity-access-control diff --git a/rds-postgres-server/workflows/aws/create.yaml b/rds-postgres-server/workflows/aws/create.yaml index afdf15c..3a70465 100644 --- a/rds-postgres-server/workflows/aws/create.yaml +++ b/rds-postgres-server/workflows/aws/create.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-server/workflows/aws/delete.yaml b/rds-postgres-server/workflows/aws/delete.yaml index 29a097d..49e99f3 100644 --- a/rds-postgres-server/workflows/aws/delete.yaml +++ b/rds-postgres-server/workflows/aws/delete.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-server/workflows/aws/link.yaml b/rds-postgres-server/workflows/aws/link.yaml index 7b27a17..9b38bfc 100644 --- a/rds-postgres-server/workflows/aws/link.yaml +++ b/rds-postgres-server/workflows/aws/link.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-server/workflows/aws/unlink.yaml b/rds-postgres-server/workflows/aws/unlink.yaml index 6c0bebe..e054535 100644 --- a/rds-postgres-server/workflows/aws/unlink.yaml +++ b/rds-postgres-server/workflows/aws/unlink.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-server/workflows/aws/update.yaml b/rds-postgres-server/workflows/aws/update.yaml index afdf15c..3a70465 100644 --- a/rds-postgres-server/workflows/aws/update.yaml +++ b/rds-postgres-server/workflows/aws/update.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context From 40c0f7ff6d03a27dd387c10bac644e426c267936 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Thu, 2 Jul 2026 16:13:18 -0300 Subject: [PATCH 21/34] feat(rds-postgres-db): assume the AssumeRole IAM role at runtime --- rds-postgres-db/scripts/aws/assume_role | 43 ++++++++++++++++++ rds-postgres-db/scripts/aws/assume_role_lib | 46 ++++++++++++++++++++ rds-postgres-db/scripts/aws/assume_role_step | 43 ++++++++++++++++++ rds-postgres-db/values.yaml | 6 +++ rds-postgres-db/workflows/aws/create.yaml | 11 +++++ rds-postgres-db/workflows/aws/delete.yaml | 11 +++++ rds-postgres-db/workflows/aws/link.yaml | 11 +++++ rds-postgres-db/workflows/aws/unlink.yaml | 11 +++++ rds-postgres-db/workflows/aws/update.yaml | 11 +++++ 9 files changed, 193 insertions(+) create mode 100755 rds-postgres-db/scripts/aws/assume_role create mode 100755 rds-postgres-db/scripts/aws/assume_role_lib create mode 100755 rds-postgres-db/scripts/aws/assume_role_step diff --git a/rds-postgres-db/scripts/aws/assume_role b/rds-postgres-db/scripts/aws/assume_role new file mode 100755 index 0000000..d2c8cbd --- /dev/null +++ b/rds-postgres-db/scripts/aws/assume_role @@ -0,0 +1,43 @@ +#!/bin/bash +# Sourceable helper — do NOT execute directly. +# Reads RDS_POSTGRES_DB_ASSUME_ROLE_ARN from the environment. If set, calls +# sts:AssumeRole and exports temporary credentials so all subsequent AWS calls +# (including tofu) use that role. If empty, does nothing — the agent's +# credentials (pod IRSA) handle auth. +# +# Requires: aws CLI, jq. +# Expects: RDS_POSTGRES_DB_ASSUME_ROLE_ARN (set by scripts/aws/assume_role_step), +# SERVICE_ID (optional, used for the session name). + +if [ -n "${RDS_POSTGRES_DB_ASSUME_ROLE_ARN:-}" ]; then + echo " 🔑 Assuming role: $RDS_POSTGRES_DB_ASSUME_ROLE_ARN" + + _ar_sts_error=$(mktemp) + if ! ASSUMED_CREDS=$(aws sts assume-role \ + --role-arn "$RDS_POSTGRES_DB_ASSUME_ROLE_ARN" \ + --role-session-name "np-rds-postgres-db-${SERVICE_ID:-workflow}" \ + --output json 2>"$_ar_sts_error"); then + echo " ❌ sts:AssumeRole failed for $RDS_POSTGRES_DB_ASSUME_ROLE_ARN" >&2 + cat "$_ar_sts_error" >&2 + rm -f "$_ar_sts_error" + return 1 + fi + rm -f "$_ar_sts_error" + + _ar_access_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId // ""') + _ar_secret_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey // ""') + _ar_session_token=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken // ""') + + if [ -z "$_ar_access_key" ] || [ -z "$_ar_secret_key" ] || [ -z "$_ar_session_token" ]; then + echo " ❌ sts:AssumeRole returned incomplete credentials for $RDS_POSTGRES_DB_ASSUME_ROLE_ARN" >&2 + return 1 + fi + + export AWS_ACCESS_KEY_ID="$_ar_access_key" + export AWS_SECRET_ACCESS_KEY="$_ar_secret_key" + export AWS_SESSION_TOKEN="$_ar_session_token" + + echo " ✅ Role assumed successfully" +else + echo " ✅ assume_role=skipped (using agent credentials)" +fi diff --git a/rds-postgres-db/scripts/aws/assume_role_lib b/rds-postgres-db/scripts/aws/assume_role_lib new file mode 100755 index 0000000..193493e --- /dev/null +++ b/rds-postgres-db/scripts/aws/assume_role_lib @@ -0,0 +1,46 @@ +#!/bin/bash +# Sourceable library of PURE helpers for assume-role resolution. +# +# Input is the AWS IAM provider exactly as it appears in +# CONTEXT.providers["identity-access-control"] — the platform already resolved it +# for the service's dimensions (most-specific config whose dimensions are a subset +# of the service's wins). These helpers only pick the selector, so they make NO +# np/aws calls and have no side effects on source — fully unit-testable. +# +# Requires (at call time): jq. + +# arn_for_selector +# Given CONTEXT.providers["identity-access-control"], echoes the ARN whose entry +# in .iam_role_arns.arns[] matches , or "" if none. First match wins. +# Returns "" on empty/malformed input (never crashes). +arn_for_selector() { + local json="$1" selector="$2" + [ -n "$json" ] || return 0 + [ -n "$selector" ] || return 0 + printf '%s' "$json" | jq -r --arg sel "$selector" ' + [ .iam_role_arns.arns[]? + | select(.selector == $sel) + | .arn ] + | first // ""' 2>/dev/null || true +} + +# resolve_assume_role_arn +# Echoes the ARN to assume ("" = use agent credentials): +# 1. $RDS_POSTGRES_DB_ASSUME_ROLE_ARN env var (explicit override) +# 2. AWS IAM provider entry matching (already dimension-resolved +# by the platform via CONTEXT.providers["identity-access-control"]) +# 3. $RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT env var (per-account agent default) +# Note: RDS_POSTGRES_DB_ASSUME_ROLE_ARN="" (explicitly empty) is treated the same as +# unset — the chain continues to the next source. +resolve_assume_role_arn() { + local iam_json="$1" selector="$2" arn="" + + arn="${RDS_POSTGRES_DB_ASSUME_ROLE_ARN:-}" + + if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then + arn=$(arn_for_selector "$iam_json" "$selector") + fi + + arn="${arn:-${RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT:-}}" + printf '%s' "$arn" +} diff --git a/rds-postgres-db/scripts/aws/assume_role_step b/rds-postgres-db/scripts/aws/assume_role_step new file mode 100755 index 0000000..af5140e --- /dev/null +++ b/rds-postgres-db/scripts/aws/assume_role_step @@ -0,0 +1,43 @@ +#!/bin/bash +# Dedicated workflow step: resolve the target IAM role and assume it, exporting +# temporary credentials so every subsequent step (including tofu) inherits them. +# +# Runs FIRST in each AWS-touching workflow. The workflow YAML must declare +# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN as +# output:environment so the engine propagates them to the following steps. +# +# The AWS IAM provider (category "identity-access-control") is read from +# CONTEXT.providers[...], where the platform has ALREADY resolved it for the +# service's dimensions. Requires "identity-access-control" to be listed in +# provider_categories (values.yaml and/or the workflow). +# +# Resolution precedence (see resolve_assume_role_arn in assume_role_lib): +# $RDS_POSTGRES_DB_ASSUME_ROLE_ARN -> IAM provider by selector +# -> $RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT -> agent credentials +# +# Requires: aws CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/assume_role_lib" + +RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR:-rds-postgres-db}" + +# IAM provider as resolved for the service's dimensions by the platform. +IAM_PROVIDER=$(echo "${CONTEXT:-}" | jq -c '.providers["identity-access-control"] // {}' 2>/dev/null) + +RDS_POSTGRES_DB_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_PROVIDER" "$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR") +export RDS_POSTGRES_DB_ASSUME_ROLE_ARN + +# scripts/aws/assume_role performs sts:AssumeRole and exports AWS_* when an ARN is set, +# or no-ops (leaving agent credentials in place) when empty. Non-zero only when +# sts:AssumeRole itself fails. +if ! source "$SCRIPT_DIR/assume_role"; then + echo " ❌ assume_role step failed: could not assume $RDS_POSTGRES_DB_ASSUME_ROLE_ARN" >&2 + echo "" >&2 + echo "💡 Possible causes:" >&2 + echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2 + echo " • The target role does not exist or does not trust the agent role" >&2 + echo " • There is no role ARN configured for selector=$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR" >&2 + echo "" >&2 + exit 1 +fi diff --git a/rds-postgres-db/values.yaml b/rds-postgres-db/values.yaml index 63b1d2b..74e41d1 100644 --- a/rds-postgres-db/values.yaml +++ b/rds-postgres-db/values.yaml @@ -16,3 +16,9 @@ vpc_id: "vpc-0a5dfe8e463dee15d" # will export it so Terraform and AWS CLI use the correct credentials. # Run "aws sso login --profile " before starting np-agent locally. aws_profile: "" + +# Provider categories the platform must resolve into CONTEXT.providers before +# running workflow steps. identity-access-control is required by +# scripts/aws/assume_role_step to look up the AssumeRole target ARN. +provider_categories: + - identity-access-control diff --git a/rds-postgres-db/workflows/aws/create.yaml b/rds-postgres-db/workflows/aws/create.yaml index 7c727d9..cf5fc40 100644 --- a/rds-postgres-db/workflows/aws/create.yaml +++ b/rds-postgres-db/workflows/aws/create.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-db/workflows/aws/delete.yaml b/rds-postgres-db/workflows/aws/delete.yaml index b5d57d2..19f338a 100644 --- a/rds-postgres-db/workflows/aws/delete.yaml +++ b/rds-postgres-db/workflows/aws/delete.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-db/workflows/aws/link.yaml b/rds-postgres-db/workflows/aws/link.yaml index f8e238e..08194b2 100644 --- a/rds-postgres-db/workflows/aws/link.yaml +++ b/rds-postgres-db/workflows/aws/link.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-db/workflows/aws/unlink.yaml b/rds-postgres-db/workflows/aws/unlink.yaml index fdf14b1..7ed9a47 100644 --- a/rds-postgres-db/workflows/aws/unlink.yaml +++ b/rds-postgres-db/workflows/aws/unlink.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context diff --git a/rds-postgres-db/workflows/aws/update.yaml b/rds-postgres-db/workflows/aws/update.yaml index 72642ad..7ee6a7c 100644 --- a/rds-postgres-db/workflows/aws/update.yaml +++ b/rds-postgres-db/workflows/aws/update.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context From 1f54b2aef6825b6a4ab5cba10a3bc32459476f76 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 15:43:47 -0300 Subject: [PATCH 22/34] fix(rds-postgres): declare provider_categories at workflow level, not just values.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed via a live test against a real cluster: assume_role_step read an empty CONTEXT.providers["identity-access-control"] despite the provider being registered, because values.yaml alone doesn't get the platform to resolve it into CONTEXT for a given workflow run. scopes-static-files declares provider_categories per-workflow (via its initial.yaml, included by others) in addition to values.yaml — ported that here directly since services has no workflow include: mechanism. --- rds-postgres-db/workflows/aws/create.yaml | 2 ++ rds-postgres-db/workflows/aws/delete.yaml | 2 ++ rds-postgres-db/workflows/aws/link.yaml | 2 ++ rds-postgres-db/workflows/aws/unlink.yaml | 2 ++ rds-postgres-db/workflows/aws/update.yaml | 2 ++ rds-postgres-server/workflows/aws/create.yaml | 2 ++ rds-postgres-server/workflows/aws/delete.yaml | 2 ++ rds-postgres-server/workflows/aws/link.yaml | 2 ++ rds-postgres-server/workflows/aws/unlink.yaml | 2 ++ rds-postgres-server/workflows/aws/update.yaml | 2 ++ 10 files changed, 20 insertions(+) diff --git a/rds-postgres-db/workflows/aws/create.yaml b/rds-postgres-db/workflows/aws/create.yaml index cf5fc40..3c41373 100644 --- a/rds-postgres-db/workflows/aws/create.yaml +++ b/rds-postgres-db/workflows/aws/create.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-db/workflows/aws/delete.yaml b/rds-postgres-db/workflows/aws/delete.yaml index 19f338a..9c8b2a8 100644 --- a/rds-postgres-db/workflows/aws/delete.yaml +++ b/rds-postgres-db/workflows/aws/delete.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-db/workflows/aws/link.yaml b/rds-postgres-db/workflows/aws/link.yaml index 08194b2..e062d5c 100644 --- a/rds-postgres-db/workflows/aws/link.yaml +++ b/rds-postgres-db/workflows/aws/link.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-db/workflows/aws/unlink.yaml b/rds-postgres-db/workflows/aws/unlink.yaml index 7ed9a47..8220074 100644 --- a/rds-postgres-db/workflows/aws/unlink.yaml +++ b/rds-postgres-db/workflows/aws/unlink.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-db/workflows/aws/update.yaml b/rds-postgres-db/workflows/aws/update.yaml index 7ee6a7c..f6a5f6e 100644 --- a/rds-postgres-db/workflows/aws/update.yaml +++ b/rds-postgres-db/workflows/aws/update.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-server/workflows/aws/create.yaml b/rds-postgres-server/workflows/aws/create.yaml index 3a70465..ebe9a3a 100644 --- a/rds-postgres-server/workflows/aws/create.yaml +++ b/rds-postgres-server/workflows/aws/create.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-server/workflows/aws/delete.yaml b/rds-postgres-server/workflows/aws/delete.yaml index 49e99f3..1f0e83c 100644 --- a/rds-postgres-server/workflows/aws/delete.yaml +++ b/rds-postgres-server/workflows/aws/delete.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-server/workflows/aws/link.yaml b/rds-postgres-server/workflows/aws/link.yaml index 9b38bfc..3378d47 100644 --- a/rds-postgres-server/workflows/aws/link.yaml +++ b/rds-postgres-server/workflows/aws/link.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-server/workflows/aws/unlink.yaml b/rds-postgres-server/workflows/aws/unlink.yaml index e054535..02f9e42 100644 --- a/rds-postgres-server/workflows/aws/unlink.yaml +++ b/rds-postgres-server/workflows/aws/unlink.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script diff --git a/rds-postgres-server/workflows/aws/update.yaml b/rds-postgres-server/workflows/aws/update.yaml index 3a70465..ebe9a3a 100644 --- a/rds-postgres-server/workflows/aws/update.yaml +++ b/rds-postgres-server/workflows/aws/update.yaml @@ -1,3 +1,5 @@ +provider_categories: + - identity-access-control steps: - name: assume role type: script From 1270aed4e99bc2e5c2d09e3ff9ffe685004cca24 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 17:11:58 -0300 Subject: [PATCH 23/34] fix(rds-postgres): resolve the AssumeRole ARN via np provider CLI, not CONTEXT.providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed via a live end-to-end test against a real agent/cluster (creating a real RDS instance): CONTEXT.providers["identity-access-control"] is never populated by this agent version, regardless of provider_categories declarations in values.yaml or the workflow YAML. build_context already proves np provider list/read work reliably on this same agent for account.region/vpc.id — this ports that exact pattern for the aws-iam-configuration provider (stored key iam_role_arns.arns), looked up at the service's namespace NRN, where the identity-access-control provider is registered. Live-verified end to end: agent assumed arn:aws:iam::235494813897:role/nullplatform-api-private-rds-postgres-server-role and successfully created aws_secretsmanager_secret, aws_db_subnet_group, aws_security_group, and aws_db_instance using that role's permissions (the agent's own role has no RDS/EC2 policies attached, so this could not have happened via the old direct-attachment model). --- rds-postgres-db/scripts/aws/assume_role_step | 30 ++++++++++++++----- .../scripts/aws/assume_role_step | 30 ++++++++++++++----- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/rds-postgres-db/scripts/aws/assume_role_step b/rds-postgres-db/scripts/aws/assume_role_step index af5140e..5e309d9 100755 --- a/rds-postgres-db/scripts/aws/assume_role_step +++ b/rds-postgres-db/scripts/aws/assume_role_step @@ -6,24 +6,40 @@ # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN as # output:environment so the engine propagates them to the following steps. # -# The AWS IAM provider (category "identity-access-control") is read from -# CONTEXT.providers[...], where the platform has ALREADY resolved it for the -# service's dimensions. Requires "identity-access-control" to be listed in -# provider_categories (values.yaml and/or the workflow). +# The AWS IAM provider (type "aws-iam-configuration", stored key +# "iam_role_arns.arns") is looked up directly via the np CLI, at the service's +# namespace NRN, the same way build_context already looks up the +# account.region/vpc.id providers. CONTEXT.providers[...] is NOT used here: +# a live end-to-end test against a real agent confirmed it is never populated +# by this platform version regardless of provider_categories declarations +# (values.yaml and/or the workflow) — provider_categories is still declared +# for forward-compatibility, but this script does not rely on it. # # Resolution precedence (see resolve_assume_role_arn in assume_role_lib): # $RDS_POSTGRES_DB_ASSUME_ROLE_ARN -> IAM provider by selector # -> $RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT -> agent credentials # -# Requires: aws CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional). +# Requires: aws CLI, np CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional). SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/assume_role_lib" RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR:-rds-postgres-db}" -# IAM provider as resolved for the service's dimensions by the platform. -IAM_PROVIDER=$(echo "${CONTEXT:-}" | jq -c '.providers["identity-access-control"] // {}' 2>/dev/null) +# Namespace NRN: same derivation build_context uses for its account NRN, but +# keeping the namespace segment (strip only from :application= onward) since +# the identity-access-control provider is registered at namespace level. +NAMESPACE_NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:application=.*$//') + +IAM_PROVIDER="{}" +if [ -n "$NAMESPACE_NRN" ]; then + NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 2>/dev/null) + IAM_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ + | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["iam_role_arns.arns"]))] | first | .id // ""') + if [ -n "$IAM_PROVIDER_ID" ] && [ "$IAM_PROVIDER_ID" != "null" ]; then + IAM_PROVIDER=$(np provider read --id "$IAM_PROVIDER_ID" --format json 2>/dev/null | jq -c '.attributes // {}') + fi +fi RDS_POSTGRES_DB_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_PROVIDER" "$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR") export RDS_POSTGRES_DB_ASSUME_ROLE_ARN diff --git a/rds-postgres-server/scripts/aws/assume_role_step b/rds-postgres-server/scripts/aws/assume_role_step index c98015e..d182370 100755 --- a/rds-postgres-server/scripts/aws/assume_role_step +++ b/rds-postgres-server/scripts/aws/assume_role_step @@ -6,24 +6,40 @@ # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN as # output:environment so the engine propagates them to the following steps. # -# The AWS IAM provider (category "identity-access-control") is read from -# CONTEXT.providers[...], where the platform has ALREADY resolved it for the -# service's dimensions. Requires "identity-access-control" to be listed in -# provider_categories (values.yaml and/or the workflow). +# The AWS IAM provider (type "aws-iam-configuration", stored key +# "iam_role_arns.arns") is looked up directly via the np CLI, at the service's +# namespace NRN, the same way build_context already looks up the +# account.region/vpc.id providers. CONTEXT.providers[...] is NOT used here: +# a live end-to-end test against a real agent confirmed it is never populated +# by this platform version regardless of provider_categories declarations +# (values.yaml and/or the workflow) — provider_categories is still declared +# for forward-compatibility, but this script does not rely on it. # # Resolution precedence (see resolve_assume_role_arn in assume_role_lib): # $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN -> IAM provider by selector # -> $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT -> agent credentials # -# Requires: aws CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional). +# Requires: aws CLI, np CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional). SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/assume_role_lib" RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR:-rds-postgres-server}" -# IAM provider as resolved for the service's dimensions by the platform. -IAM_PROVIDER=$(echo "${CONTEXT:-}" | jq -c '.providers["identity-access-control"] // {}' 2>/dev/null) +# Namespace NRN: same derivation build_context uses for its account NRN, but +# keeping the namespace segment (strip only from :application= onward) since +# the identity-access-control provider is registered at namespace level. +NAMESPACE_NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:application=.*$//') + +IAM_PROVIDER="{}" +if [ -n "$NAMESPACE_NRN" ]; then + NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 2>/dev/null) + IAM_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ + | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["iam_role_arns.arns"]))] | first | .id // ""') + if [ -n "$IAM_PROVIDER_ID" ] && [ "$IAM_PROVIDER_ID" != "null" ]; then + IAM_PROVIDER=$(np provider read --id "$IAM_PROVIDER_ID" --format json 2>/dev/null | jq -c '.attributes // {}') + fi +fi RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_PROVIDER" "$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR") export RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN From 09659b4d49252a596a5e62b57b2a0c29fa8bb24a Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 17:42:34 -0300 Subject: [PATCH 24/34] fix(rds-postgres-db): add missing S3 tfstate-bucket policy build_context creates its own np-service- tfstate bucket, same as rds-postgres-server, but the role only had the Secrets Manager policy. Confirmed via a live test: assuming this role and running build_context hit AccessDenied on s3:CreateBucket/PutBucketVersioning. Adds the same np-service-* scoped S3 policy rds-postgres-server already has. --- rds-postgres-db/README.md | 7 ++-- rds-postgres-db/requirements/main.tf | 46 ++++++++++++++++++++++++++ rds-postgres-db/requirements/output.tf | 5 +++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index 8b7a857..2ea0dbc 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -109,14 +109,15 @@ This ensures usernames are stable and reproducible even if the service is recrea ### AWS IAM Permissions -This service requires minimal AWS permissions compared to `rds-postgres-server`. The agent only needs: +This service requires minimal AWS permissions compared to `rds-postgres-server`. The agent needs: - **Secrets Manager**: `GetSecretValue` — to retrieve the master PostgreSQL password from the ARN stored in service attributes +- **S3**: full lifecycle on the `np-service-` bucket — `build_context` creates and manages its own per-service Terraform state bucket, same as `rds-postgres-server` -No RDS, EC2, or S3 permissions are needed. +No RDS or EC2 permissions are needed. The `requirements/` Terraform module creates a dedicated IAM role -(`nullplatform--rds-postgres-db-role`) holding this policy, +(`nullplatform--rds-postgres-db-role`) holding these policies, with a trust policy allowing the nullplatform agent role to `sts:AssumeRole` on it. Pass `cluster_name` (required) and optionally `agent_role_arn` (defaults to `nullplatform--agent-role`) when applying it. diff --git a/rds-postgres-db/requirements/main.tf b/rds-postgres-db/requirements/main.tf index 6477d5c..f523df3 100644 --- a/rds-postgres-db/requirements/main.tf +++ b/rds-postgres-db/requirements/main.tf @@ -45,3 +45,49 @@ resource "aws_iam_role_policy_attachment" "rds_postgres_db_secretsmanager" { role = aws_iam_role.nullplatform_rds_postgres_db[0].name policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn } + +################################################################################ +# S3 IAM policy (per-service tfstate buckets: np-service-) +################################################################################ + +# Grant permissions to manage the per-link S3 bucket used to store tofu state. +# build_context creates its own np-service- bucket, same as +# rds-postgres-server — confirmed missing via a live test (AccessDenied on +# s3:CreateBucket while assuming this role). +resource "aws_iam_policy" "nullplatform_rds_postgres_db_s3_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-postgres-db-s3-policy" + description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "s3:CreateBucket", + "s3:HeadBucket", + "s3:PutBucketVersioning", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:DeleteBucket" + ], + "Resource" : [ + "arn:aws:s3:::np-service-*", + "arn:aws:s3:::np-service-*/*" + ] + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "rds_postgres_db_s3" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_db[0].name + policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_s3_policy[0].arn +} diff --git a/rds-postgres-db/requirements/output.tf b/rds-postgres-db/requirements/output.tf index f75ac5a..3c192b9 100644 --- a/rds-postgres-db/requirements/output.tf +++ b/rds-postgres-db/requirements/output.tf @@ -17,3 +17,8 @@ output "secretsmanager_policy_arn" { description = "ARN of the Secrets Manager read policy" value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn : "" } + +output "s3_policy_arn" { + description = "ARN of the per-service tfstate S3 policy" + value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_s3_policy[0].arn : "" +} From 899523c0ccfe742b9f4a1461cc38ce0d53b66630 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 17:46:02 -0300 Subject: [PATCH 25/34] fix(rds-postgres-db): use full entity_nrn for RDS server auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit np service list requires the exact NRN a service is scoped at. Stripping :application=... to derive a namespace-level NRN made auto-discovery always return zero results, even with a healthy matching rds-postgres-server — confirmed live by querying np service list at both levels directly. --- .../scripts/aws/build_db_setup_context | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/rds-postgres-db/scripts/aws/build_db_setup_context b/rds-postgres-db/scripts/aws/build_db_setup_context index e01f3f6..c323457 100644 --- a/rds-postgres-db/scripts/aws/build_db_setup_context +++ b/rds-postgres-db/scripts/aws/build_db_setup_context @@ -49,13 +49,16 @@ else exit 0 fi - # Service create: auto-discover rds-postgres-server with matching dimensions - NAMESPACE_NRN=$(echo "$CONTEXT" | jq -r '.entity_nrn // ""' | sed 's/:application=[^:]*$//') + # Service create: auto-discover rds-postgres-server with matching dimensions. + # Use entity_nrn as-is: np service list requires the exact NRN a service is + # scoped at (confirmed live — a namespace-level NRN with :application=... + # stripped off returns zero results even when matching services exist). + ENTITY_NRN=$(echo "$CONTEXT" | jq -r '.entity_nrn // ""') SERVICE_DIMENSIONS=$(echo "$CONTEXT" | jq -c '.service.dimensions // {}') - echo "Auto-discovering RDS server in ${NAMESPACE_NRN} (dimensions: ${SERVICE_DIMENSIONS})..." + echo "Auto-discovering RDS server in ${ENTITY_NRN} (dimensions: ${SERVICE_DIMENSIONS})..." SERVER_SERVICES=$(np service list \ - --nrn "$NAMESPACE_NRN" \ + --nrn "$ENTITY_NRN" \ --type dependency \ --status active \ --format json | \ @@ -68,11 +71,11 @@ else SERVER_COUNT=$(echo "$SERVER_SERVICES" | jq 'length') if [ "$SERVER_COUNT" -eq 0 ]; then - echo "ERROR: No active RDS server found in ${NAMESPACE_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2 + echo "ERROR: No active RDS server found in ${ENTITY_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2 echo " Create an rds-postgres-server service with matching dimensions first." >&2 exit 1 elif [ "$SERVER_COUNT" -gt 1 ]; then - echo "ERROR: Multiple RDS servers found in ${NAMESPACE_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2 + echo "ERROR: Multiple RDS servers found in ${ENTITY_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2 echo " Available options:" >&2 echo "$SERVER_SERVICES" | jq -r '.[] | " - \(.id) \(.name) (\(.attributes.hostname))"' >&2 exit 1 From 238c001098cbbc0227da4648d7e5bad884543bfd Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 17:50:35 -0300 Subject: [PATCH 26/34] fix(rds-postgres-server): allow RDS ingress from all VPC CIDR associations, not just primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live: the EKS cluster's VPC has a secondary CIDR (100.17.0.0/16) for pod networking alongside the primary (10.16.0.0/16). Agent pods get IPs from the secondary block, but the RDS security group only allowed the primary CIDR (data.aws_vpc.main.cidr_block), silently blocking agent-pod-to-RDS connectivity — the postgresql provider hung indefinitely trying to reach the database during rds-postgres-db's create workflow. --- rds-postgres-server/deployment/main.tf | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rds-postgres-server/deployment/main.tf b/rds-postgres-server/deployment/main.tf index 24f05dc..3aca473 100644 --- a/rds-postgres-server/deployment/main.tf +++ b/rds-postgres-server/deployment/main.tf @@ -11,7 +11,13 @@ resource "aws_security_group" "rds" { from_port = 5432 to_port = 5432 protocol = "tcp" - cidr_blocks = [data.aws_vpc.main.cidr_block] + # Use every CIDR block associated with the VPC, not just the primary one. + # EKS clusters commonly add a secondary CIDR for pod networking (e.g. a + # 100.x.x.x block alongside the primary 10.x.x.x one) — pods get IPs from + # the secondary block, so restricting to the primary CIDR silently blocks + # agent-pod-to-RDS connectivity. Confirmed live: pod IP 100.17.11.188 vs + # RDS SG only allowing 10.16.0.0/16. + cidr_blocks = [for c in data.aws_vpc.main.cidr_block_associations : c.cidr_block] } egress { From 62298b27cdc67326edb4f4a565c560e335e5bd27 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 18:40:47 -0300 Subject: [PATCH 27/34] docs: add AssumeRole setup guide and operational notes from live end-to-end testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the 3 required pieces for AssumeRole to actually work (apply requirements/, grant the agent sts:AssumeRole, register an identity-access-control provider at the namespace NRN), the fail-open behavior when any piece is missing, the multi-CIDR VPC networking requirement, the auto-discovery NRN requirement for rds-postgres-db, and the orphaned-role edge case from partially-failed creates — all confirmed against a real cluster during this branch's validation. --- rds-postgres-db/README.md | 85 +++++++++++++++++++++++++++++++++++ rds-postgres-server/README.md | 80 +++++++++++++++++++++++++++++++-- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index 2ea0dbc..b8d83ec 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -106,6 +106,7 @@ This ensures usernames are stable and reproducible even if the service is recrea - Matching dimensions (e.g., both services must have `cluster: prod`) - Attributes `hostname` and `master_secret_arn` already set (i.e., RDS instance successfully provisioned) - The `rds-postgres-server` must expose a Secrets Manager secret with master PostgreSQL credentials +- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **namespace-level NRN**. Unlike `rds-postgres-server`, this service does not need `aws-configuration`/`aws-networking-configuration` providers — `build_context` reads `region` from `values.yaml` (default `us-east-1`), not from a nullplatform provider. ### AWS IAM Permissions @@ -132,6 +133,72 @@ service instance's link actually uses. Anything that assumes this role can read the master password of any `rds-postgres-server` in the cluster, not just the linked one. +### AssumeRole Setup Guide + +Three separate pieces must all be in place for the agent to actually assume +`nullplatform--rds-postgres-db-role` at runtime — applying +`requirements/` alone is not enough: + +1. **Apply `requirements/`** with `cluster_name` (and optionally + `agent_role_arn`) — creates the role and its trust policy (see above). +2. **Grant the agent permission to assume it.** Not managed by + `requirements/` — add an inline (or managed) policy to the **agent's own** + IAM role: + ```json + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam:::role/nullplatform--rds-postgres-db-role" + } + ``` +3. **Register the role as an `identity-access-control` provider** in + nullplatform, at the **namespace-level NRN** + (`organization=...:account=...:namespace=...` — without `:application=...`), + with selector `rds-postgres-db`: + ```hcl + module "identity_access_control" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref=" + nrn = "organization=:account=:namespace=" + attributes = { + iam_role_arns = { + arns = [{ selector = "rds-postgres-db", arn = "" }] + } + } + } + ``` + +`scripts/aws/assume_role_step` resolves the role by querying +`np provider list` / `np provider read` for this provider at the service's +**namespace NRN** — not by reading `CONTEXT.providers[...]`. This was +confirmed live: this platform's agent never populates `CONTEXT.providers` +regardless of the `provider_categories` declared in `values.yaml` or the +workflow YAMLs, so the lookup goes through the `np` CLI directly instead. + +**If any of the 3 steps is missing**, `assume_role_step` logs +`assume_role=skipped (using agent credentials)` and the workflow proceeds +under the **agent's own role** — which fails with `AccessDenied` on S3/ +Secrets Manager calls unless the agent happens to have those permissions +directly attached. This fail-open behavior is intentional (mirrors +`nullplatform/scopes-static-files`), but it means a misconfigured +AssumeRole setup fails *silently* as what looks like a permissions problem +rather than a missing-provider problem — check the `assume role` step's +log line first when debugging `AccessDenied` errors from later steps. + +### Auto-Discovery NRN Requirement + +`scripts/aws/build_db_setup_context` looks up the active `rds-postgres-server` +via `np service list --nrn `, using the service's **full +`entity_nrn` as-is** (including the `:application=...` segment) — not a +namespace-level NRN with that segment stripped. Confirmed live: querying +`np service list` at the namespace level (`:application=...` removed) +returns **zero results**, even when a healthy, matching `rds-postgres-server` +exists — `np service list --nrn` requires the exact NRN a service is scoped +at, it does not search hierarchically down from a broader NRN. If you see +`ERROR: No active RDS server found in matching dimensions: ...` and +you're certain a matching, active server exists, check that this NRN is +being derived correctly rather than assuming the server itself is +misconfigured. + ### Runtime Dependencies These tools are required inside the agent container: @@ -180,3 +247,21 @@ This service uses dimensions to match the correct `rds-postgres-server`. If dime ### Service Must Exist Before Linking If the service was created but the `hostname` attribute is empty (e.g., provisioning failed), link operations exit cleanly without performing any database changes. Ensure the service is fully created before attempting to link applications. + +### Orphaned PostgreSQL Roles From Failed Creates + +The service-level username/database name are derived from `application_id` +(`app_`), which is the **same across every retry** of +creating this service for a given application — unlike `service_id`, which +is different each time. If a `create` action fails *after* +`postgresql_role.app_user` is created in Postgres but *before* the +workflow reaches `write service outputs` (so the service's `hostname` +attribute never gets set), a later `delete` action can't clean it up: it +checks the stored `hostname` attribute first, finds it empty, and skips +DB cleanup entirely (see "Service Must Exist Before Linking" above) — +leaving the Postgres role behind. The next `create` retry then fails with +`role "app_" already exists`, even though nullplatform has +no record of a working service. If you hit this, connect to the RDS +instance with master credentials and run +`DROP ROLE IF EXISTS app_;` (after reassigning/dropping any +objects it owns, if it had time to create any) before retrying. diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md index a4b6aea..0438b97 100644 --- a/rds-postgres-server/README.md +++ b/rds-postgres-server/README.md @@ -94,10 +94,11 @@ Exposed in the nullplatform UI when creating or updating the service: ### nullplatform Prerequisites -- An active nullplatform account with at least one **provider** exposing: - - `account.region` — the AWS region where the RDS instance will be created - - `vpc.id` — the VPC where the RDS instance will be placed -- The VPC must have private subnets tagged with `nullplatform/subnet-type=private` +- An active nullplatform account with the following providers configured for the target namespace/dimensions: + - **`aws-configuration`** (from `tofu-modules//nullplatform/cloud/aws/cloud`) — exposes `account.region`. `build_context` resolves this via `np provider list --nrn ` filtered by `stored_keys` containing `account.region`. + - **`aws-networking-configuration`** (from `tofu-modules//nullplatform/cloud/aws/vpc`) — exposes `vpc.id`, `vpc.subnets`, `vpc.security_groups`. Same lookup mechanism, filtered by `vpc.id`. +- The VPC must have private subnets tagged with `nullplatform/subnet-type=private`. +- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **namespace-level NRN**. ### AWS IAM Permissions @@ -117,6 +118,77 @@ policies, with a trust policy allowing the nullplatform agent role to applying it. Granting the agent itself permission to assume this role is handled separately, outside this module. +### AssumeRole Setup Guide + +Three separate pieces must all be in place for the agent to actually assume +`nullplatform--rds-postgres-server-role` at runtime — applying +`requirements/` alone is not enough: + +1. **Apply `requirements/`** with `cluster_name` (and optionally + `agent_role_arn`) — creates the role and its trust policy (see above). +2. **Grant the agent permission to assume it.** Not managed by + `requirements/` — add an inline (or managed) policy to the **agent's own** + IAM role: + ```json + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam:::role/nullplatform--rds-postgres-server-role" + } + ``` +3. **Register the role as an `identity-access-control` provider** in + nullplatform, at the **namespace-level NRN** + (`organization=...:account=...:namespace=...` — without `:application=...`), + with selector `rds-postgres-server`: + ```hcl + module "identity_access_control" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref=" + nrn = "organization=:account=:namespace=" + attributes = { + iam_role_arns = { + arns = [{ selector = "rds-postgres-server", arn = "" }] + } + } + } + ``` + +`scripts/aws/assume_role_step` resolves the role by querying +`np provider list` / `np provider read` for this provider at the service's +**namespace NRN** — not by reading `CONTEXT.providers[...]`. This was +confirmed live: this platform's agent never populates `CONTEXT.providers` +regardless of the `provider_categories` declared in `values.yaml` or the +workflow YAMLs, so the lookup goes through the `np` CLI directly instead +(the same mechanism `build_context` already uses for the region/VPC +providers above). + +**If any of the 3 steps is missing**, `assume_role_step` logs +`assume_role=skipped (using agent credentials)` and the workflow proceeds +under the **agent's own role** — which fails with `AccessDenied` on +RDS/EC2/Secrets Manager/S3 calls unless the agent happens to have those +permissions directly attached (the old, pre-AssumeRole model). This +fail-open behavior is intentional (mirrors `nullplatform/scopes-static-files`), +but it means a misconfigured AssumeRole setup fails *silently* as what looks +like a permissions problem rather than a missing-provider problem — check +the `assume role` step's log line first when debugging `AccessDenied` +errors from later steps. + +### Networking Requirements + +`deployment/main.tf`'s RDS security group allows ingress on 5432 from +**every CIDR block associated with the VPC** +(`data.aws_vpc.main.cidr_block_associations`), not just the primary one. +This matters because EKS clusters commonly add a **secondary CIDR block** +for pod networking (e.g. primary `10.x.x.x` for nodes, secondary +`100.x.x.x` for pods via the AWS VPC CNI's custom networking/prefix +delegation) — agent pods get IPs from the secondary range, not the +primary one. If the VPC has more than one CIDR association, all of them +are allowed automatically; no extra configuration is needed here. Symptom +if this were ever restricted to a single CIDR: any step that touches the +`postgresql` Terraform provider (this service's `db_setup`, or +`rds-postgres-db`'s workflows) **hangs indefinitely** — the TCP connection +attempt to the RDS endpoint never completes or times out quickly, it just +stalls — rather than failing fast with a clear error. + ### Runtime Dependencies These tools are required inside the agent container: From 63c8adb435b2f18e8d84f0bd65003d5c2b16bfcc Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 18:45:40 -0300 Subject: [PATCH 28/34] docs: add concrete module examples for providers and requirements/ in setup guides Adds the actual module blocks (service_requirements_*, vpc_provider, aws_cloud_provider) alongside the identity_access_control example already present, so the setup guide is a complete, copy-pasteable reference matching the pattern already validated in the services-testing sandbox. --- rds-postgres-db/README.md | 13 ++++++++++- rds-postgres-server/README.md | 44 ++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index b8d83ec..6872b0e 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -140,7 +140,18 @@ Three separate pieces must all be in place for the agent to actually assume `requirements/` alone is not enough: 1. **Apply `requirements/`** with `cluster_name` (and optionally - `agent_role_arn`) — creates the role and its trust policy (see above). + `agent_role_arn`) — creates the role and its trust policy (see above): + ```hcl + module "service_requirements_rds_postgres_db" { + source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-db/requirements?ref=" + + cluster_name = "" + # agent_role_arn = "" # optional override; defaults to + # arn:aws:iam:::role/nullplatform--agent-role + } + ``` + Read `module.service_requirements_rds_postgres_db.permissions_role_arn` + for the ARN needed in steps 2 and 3 below. 2. **Grant the agent permission to assume it.** Not managed by `requirements/` — add an inline (or managed) policy to the **agent's own** IAM role: diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md index 0438b97..843ac41 100644 --- a/rds-postgres-server/README.md +++ b/rds-postgres-server/README.md @@ -100,6 +100,37 @@ Exposed in the nullplatform UI when creating or updating the service: - The VPC must have private subnets tagged with `nullplatform/subnet-type=private`. - For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **namespace-level NRN**. +Example registering the `aws-configuration` and `aws-networking-configuration` +providers (typically applied once per cluster/account, at the account-level +NRN — no `:namespace=...`): + +```hcl +module "aws_cloud_provider" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/cloud?ref=" + + nrn = "organization=:account=" + domain_name = "" + hosted_private_zone_id = "" +} + +module "vpc_provider" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/vpc?ref=" + + nrn = "organization=:account=" + vpc_id = "" + vpc_subnets = ["", "", "..."] + vpc_security_groups = ["", "..."] +} +``` + +`vpc_subnets`/`vpc_security_groups` don't need to be scoped down to only +what this service uses — pass whatever the cluster's VPC provider already +uses for other scopes/services (e.g. all node/pod subnets and the cluster +security group). This service only reads `vpc.id` from this provider; the +actual subnets it deploys into come separately from +`data.aws_subnets.private` (filtered by the `nullplatform/subnet-type=private` +tag, not from this provider's `vpc_subnets` list). + ### AWS IAM Permissions The agent executing this service needs the following IAM permissions (see `requirements/main.tf`): @@ -125,7 +156,18 @@ Three separate pieces must all be in place for the agent to actually assume `requirements/` alone is not enough: 1. **Apply `requirements/`** with `cluster_name` (and optionally - `agent_role_arn`) — creates the role and its trust policy (see above). + `agent_role_arn`) — creates the role and its trust policy (see above): + ```hcl + module "service_requirements_rds_postgres_server" { + source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-server/requirements?ref=" + + cluster_name = "" + # agent_role_arn = "" # optional override; defaults to + # arn:aws:iam:::role/nullplatform--agent-role + } + ``` + Read `module.service_requirements_rds_postgres_server.permissions_role_arn` + for the ARN needed in steps 2 and 3 below. 2. **Grant the agent permission to assume it.** Not managed by `requirements/` — add an inline (or managed) policy to the **agent's own** IAM role: From 94fdf2817995fae732b52c525482b627a52c167a Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Fri, 3 Jul 2026 18:51:05 -0300 Subject: [PATCH 29/34] style(rds-postgres-server): run tofu fmt on the security group ingress block --- rds-postgres-server/deployment/main.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rds-postgres-server/deployment/main.tf b/rds-postgres-server/deployment/main.tf index 3aca473..fea42ff 100644 --- a/rds-postgres-server/deployment/main.tf +++ b/rds-postgres-server/deployment/main.tf @@ -8,9 +8,9 @@ resource "aws_security_group" "rds" { vpc_id = var.vpc_id ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" + from_port = 5432 + to_port = 5432 + protocol = "tcp" # Use every CIDR block associated with the VPC, not just the primary one. # EKS clusters commonly add a secondary CIDR for pod networking (e.g. a # 100.x.x.x block alongside the primary 10.x.x.x one) — pods get IPs from From 7d06deea2edf8e11a97a32532f55ec9630d0839e Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Mon, 6 Jul 2026 10:47:24 -0300 Subject: [PATCH 30/34] feat(rds-postgres): align AssumeRole with services-s-3 conventions - Generalize assume_role_lib to take override/default env var names as params (indirect expansion), making it identical and copy-paste reusable across rds-postgres-server and rds-postgres-db. - Pass --dimensions (from .service.dimensions) to the IAM provider lookup in assume_role_step, so a future per-dimension AssumeRole role at the same namespace NRN resolves the most-specific match instead of an arbitrary one. - Add policies_name_prefix override variable to requirements/ (server and db), preserving current default policy names. - Move requirements/ under specs/requirements/aws/ to match the services-s-3 layout convention. Co-Authored-By: Claude Sonnet 5 --- rds-postgres-db/README.md | 19 ++++++-- rds-postgres-db/requirements/locals.tf | 12 ----- rds-postgres-db/scripts/aws/assume_role_lib | 45 ++++++++++--------- rds-postgres-db/scripts/aws/assume_role_step | 24 ++++++++-- .../requirements/aws}/.terraform.lock.hcl | 0 .../requirements/aws}/data.tf | 0 .../specs/requirements/aws/locals.tf | 13 ++++++ .../requirements/aws}/main.tf | 4 +- .../requirements/aws}/output.tf | 0 .../requirements/aws}/variables.tf | 6 +++ .../requirements/aws}/versions.tf | 0 rds-postgres-server/README.md | 17 +++++-- rds-postgres-server/requirements/locals.tf | 12 ----- .../scripts/aws/assume_role_lib | 45 ++++++++++--------- .../scripts/aws/assume_role_step | 24 ++++++++-- .../requirements/aws}/.terraform.lock.hcl | 0 .../requirements/aws}/data.tf | 0 .../specs/requirements/aws/locals.tf | 13 ++++++ .../requirements/aws}/main.tf | 8 ++-- .../requirements/aws}/output.tf | 0 .../requirements/aws}/variables.tf | 6 +++ .../requirements/aws}/versions.tf | 0 22 files changed, 161 insertions(+), 87 deletions(-) delete mode 100644 rds-postgres-db/requirements/locals.tf rename rds-postgres-db/{requirements => specs/requirements/aws}/.terraform.lock.hcl (100%) rename rds-postgres-db/{requirements => specs/requirements/aws}/data.tf (100%) create mode 100644 rds-postgres-db/specs/requirements/aws/locals.tf rename rds-postgres-db/{requirements => specs/requirements/aws}/main.tf (95%) rename rds-postgres-db/{requirements => specs/requirements/aws}/output.tf (100%) rename rds-postgres-db/{requirements => specs/requirements/aws}/variables.tf (91%) rename rds-postgres-db/{requirements => specs/requirements/aws}/versions.tf (100%) delete mode 100644 rds-postgres-server/requirements/locals.tf rename rds-postgres-server/{requirements => specs/requirements/aws}/.terraform.lock.hcl (100%) rename rds-postgres-server/{requirements => specs/requirements/aws}/data.tf (100%) create mode 100644 rds-postgres-server/specs/requirements/aws/locals.tf rename rds-postgres-server/{requirements => specs/requirements/aws}/main.tf (95%) rename rds-postgres-server/{requirements => specs/requirements/aws}/output.tf (100%) rename rds-postgres-server/{requirements => specs/requirements/aws}/variables.tf (91%) rename rds-postgres-server/{requirements => specs/requirements/aws}/versions.tf (100%) diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index 6872b0e..92131de 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -121,9 +121,11 @@ The `requirements/` Terraform module creates a dedicated IAM role (`nullplatform--rds-postgres-db-role`) holding these policies, with a trust policy allowing the nullplatform agent role to `sts:AssumeRole` on it. Pass `cluster_name` (required) and optionally `agent_role_arn` -(defaults to `nullplatform--agent-role`) when applying it. -Granting the agent itself permission to assume this role is handled -separately, outside this module. +(defaults to `nullplatform--agent-role`), `role_name` (defaults +to `nullplatform--rds-postgres-db-role`) and +`policies_name_prefix` (defaults to `nullplatform-`) when +applying it. Granting the agent itself permission to assume this role is +handled separately, outside this module. This role and its policy are shared per **cluster**, not per linked `rds-postgres-server` instance — the `GetSecretValue` grant is scoped to the @@ -143,7 +145,7 @@ Three separate pieces must all be in place for the agent to actually assume `agent_role_arn`) — creates the role and its trust policy (see above): ```hcl module "service_requirements_rds_postgres_db" { - source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-db/requirements?ref=" + source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-db/specs/requirements/aws?ref=" cluster_name = "" # agent_role_arn = "" # optional override; defaults to @@ -185,6 +187,15 @@ confirmed live: this platform's agent never populates `CONTEXT.providers` regardless of the `provider_categories` declared in `values.yaml` or the workflow YAMLs, so the lookup goes through the `np` CLI directly instead. +The lookup also passes `--dimensions` (derived from `.service.dimensions` in +`CONTEXT`, e.g. `cluster:prod`) so that if more than one +`identity-access-control` provider is ever registered at the same namespace +NRN for different dimensions, `np` resolves the most-specific match instead +of an arbitrary one being picked client-side. Today the setup above +registers a single, dimension-less provider per namespace, so this is a +no-op — it only matters if per-dimension AssumeRole roles are introduced +later. + **If any of the 3 steps is missing**, `assume_role_step` logs `assume_role=skipped (using agent credentials)` and the workflow proceeds under the **agent's own role** — which fails with `AccessDenied` on S3/ diff --git a/rds-postgres-db/requirements/locals.tf b/rds-postgres-db/requirements/locals.tf deleted file mode 100644 index 3cc3f89..0000000 --- a/rds-postgres-db/requirements/locals.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - iam_module_name = "requirements-rds-postgres-db" - iam_create = var.iam_create_role - - role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-db-role" - agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" - - iam_default_tags = merge(var.iam_resource_tags_json, { - ManagedBy = "rds-postgres-db" - Module = local.iam_module_name - }) -} diff --git a/rds-postgres-db/scripts/aws/assume_role_lib b/rds-postgres-db/scripts/aws/assume_role_lib index 193493e..38dc5aa 100755 --- a/rds-postgres-db/scripts/aws/assume_role_lib +++ b/rds-postgres-db/scripts/aws/assume_role_lib @@ -1,18 +1,15 @@ #!/bin/bash # Sourceable library of PURE helpers for assume-role resolution. +# Makes NO np/aws calls and has no source side effects — fully unit-testable. +# Identical across rds-postgres-server and rds-postgres-db so it can be +# copied as-is into any new AWS service module. # -# Input is the AWS IAM provider exactly as it appears in -# CONTEXT.providers["identity-access-control"] — the platform already resolved it -# for the service's dimensions (most-specific config whose dimensions are a subset -# of the service's wins). These helpers only pick the selector, so they make NO -# np/aws calls and have no side effects on source — fully unit-testable. -# -# Requires (at call time): jq. +# Requires (at call time): jq, bash >= 4 (uses ${!var} indirect expansion). -# arn_for_selector -# Given CONTEXT.providers["identity-access-control"], echoes the ARN whose entry -# in .iam_role_arns.arns[] matches , or "" if none. First match wins. -# Returns "" on empty/malformed input (never crashes). +# arn_for_selector +# Given the IAM provider attributes (.iam_role_arns.arns[...]), echoes the ARN +# whose entry matches , or "" if none. First match wins. Never +# crashes on empty/malformed input. arn_for_selector() { local json="$1" selector="$2" [ -n "$json" ] || return 0 @@ -24,23 +21,27 @@ arn_for_selector() { | first // ""' 2>/dev/null || true } -# resolve_assume_role_arn -# Echoes the ARN to assume ("" = use agent credentials): -# 1. $RDS_POSTGRES_DB_ASSUME_ROLE_ARN env var (explicit override) -# 2. AWS IAM provider entry matching (already dimension-resolved -# by the platform via CONTEXT.providers["identity-access-control"]) -# 3. $RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT env var (per-account agent default) -# Note: RDS_POSTGRES_DB_ASSUME_ROLE_ARN="" (explicitly empty) is treated the same as -# unset — the chain continues to the next source. +# resolve_assume_role_arn +# Echoes the ARN to assume ("" = use agent credentials), in precedence order: +# 1. ${!override_env_name} — explicit per-run override +# 2. iam_attributes_json entry matching (caller pre-resolved the +# provider via `np provider list` for the service's NRN + dimensions) +# 3. ${!default_env_name} — per-account agent default +# Empty override/default env names are treated as unset (chain continues). resolve_assume_role_arn() { - local iam_json="$1" selector="$2" arn="" + local iam_json="$1" selector="$2" override_env="$3" default_env="$4" arn="" - arn="${RDS_POSTGRES_DB_ASSUME_ROLE_ARN:-}" + if [ -n "$override_env" ]; then + arn="${!override_env:-}" + fi if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then arn=$(arn_for_selector "$iam_json" "$selector") fi - arn="${arn:-${RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT:-}}" + if [ -z "$arn" ] && [ -n "$default_env" ]; then + arn="${!default_env:-}" + fi + printf '%s' "$arn" } diff --git a/rds-postgres-db/scripts/aws/assume_role_step b/rds-postgres-db/scripts/aws/assume_role_step index 5e309d9..a497cf8 100755 --- a/rds-postgres-db/scripts/aws/assume_role_step +++ b/rds-postgres-db/scripts/aws/assume_role_step @@ -15,6 +15,13 @@ # (values.yaml and/or the workflow) — provider_categories is still declared # for forward-compatibility, but this script does not rely on it. # +# The lookup also passes --dimensions (from .service.dimensions in CONTEXT, +# e.g. cluster:prod) so that if more than one identity-access-control provider +# is ever registered at the same namespace NRN for different dimensions, np +# resolves the most-specific match instead of us picking an arbitrary one +# client-side. Today only one such provider is documented/registered per +# namespace (no dimensions of its own), so this is a no-op in practice. +# # Resolution precedence (see resolve_assume_role_arn in assume_role_lib): # $RDS_POSTGRES_DB_ASSUME_ROLE_ARN -> IAM provider by selector # -> $RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT -> agent credentials @@ -31,9 +38,16 @@ RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR:-rd # the identity-access-control provider is registered at namespace level. NAMESPACE_NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:application=.*$//') +# Dimensions (if any) as key:value,key:value — lets np resolve the +# most-specific IAM provider the same way it would for a k8s scope. +DIMENSIONS=$(echo "${CONTEXT:-}" | jq -r ' + if (.service.dimensions | type) == "object" and ((.service.dimensions | length) > 0) + then [ .service.dimensions | to_entries[] | "\(.key):\(.value)" ] | join(",") + else empty end' 2>/dev/null) + IAM_PROVIDER="{}" if [ -n "$NAMESPACE_NRN" ]; then - NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 2>/dev/null) + NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 ${DIMENSIONS:+--dimensions "$DIMENSIONS"} 2>/dev/null) IAM_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["iam_role_arns.arns"]))] | first | .id // ""') if [ -n "$IAM_PROVIDER_ID" ] && [ "$IAM_PROVIDER_ID" != "null" ]; then @@ -41,7 +55,11 @@ if [ -n "$NAMESPACE_NRN" ]; then fi fi -RDS_POSTGRES_DB_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_PROVIDER" "$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR") +RDS_POSTGRES_DB_ASSUME_ROLE_ARN=$(resolve_assume_role_arn \ + "$IAM_PROVIDER" \ + "$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR" \ + "RDS_POSTGRES_DB_ASSUME_ROLE_ARN" \ + "RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT") export RDS_POSTGRES_DB_ASSUME_ROLE_ARN # scripts/aws/assume_role performs sts:AssumeRole and exports AWS_* when an ARN is set, @@ -53,7 +71,7 @@ if ! source "$SCRIPT_DIR/assume_role"; then echo "💡 Possible causes:" >&2 echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2 echo " • The target role does not exist or does not trust the agent role" >&2 - echo " • There is no role ARN configured for selector=$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR" >&2 + echo " • There is no role ARN configured for selector=$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR at NRN=$NAMESPACE_NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2 echo "" >&2 exit 1 fi diff --git a/rds-postgres-db/requirements/.terraform.lock.hcl b/rds-postgres-db/specs/requirements/aws/.terraform.lock.hcl similarity index 100% rename from rds-postgres-db/requirements/.terraform.lock.hcl rename to rds-postgres-db/specs/requirements/aws/.terraform.lock.hcl diff --git a/rds-postgres-db/requirements/data.tf b/rds-postgres-db/specs/requirements/aws/data.tf similarity index 100% rename from rds-postgres-db/requirements/data.tf rename to rds-postgres-db/specs/requirements/aws/data.tf diff --git a/rds-postgres-db/specs/requirements/aws/locals.tf b/rds-postgres-db/specs/requirements/aws/locals.tf new file mode 100644 index 0000000..3a3e39e --- /dev/null +++ b/rds-postgres-db/specs/requirements/aws/locals.tf @@ -0,0 +1,13 @@ +locals { + iam_module_name = "requirements-rds-postgres-db" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-db-role" + policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform-${var.cluster_name}" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-db" + Module = local.iam_module_name + }) +} diff --git a/rds-postgres-db/requirements/main.tf b/rds-postgres-db/specs/requirements/aws/main.tf similarity index 95% rename from rds-postgres-db/requirements/main.tf rename to rds-postgres-db/specs/requirements/aws/main.tf index f523df3..879a052 100644 --- a/rds-postgres-db/requirements/main.tf +++ b/rds-postgres-db/specs/requirements/aws/main.tf @@ -27,7 +27,7 @@ resource "aws_iam_role" "nullplatform_rds_postgres_db" { resource "aws_iam_policy" "nullplatform_rds_postgres_db_secretsmanager_policy" { count = local.iam_create ? 1 : 0 - name = "nullplatform-${var.cluster_name}-rds-postgres-db-secretsmanager-policy" + name = "${local.policies_name_prefix}-rds-postgres-db-secretsmanager-policy" description = "Policy for reading the RDS master password from Secrets Manager" policy = jsonencode({ @@ -57,7 +57,7 @@ resource "aws_iam_role_policy_attachment" "rds_postgres_db_secretsmanager" { resource "aws_iam_policy" "nullplatform_rds_postgres_db_s3_policy" { count = local.iam_create ? 1 : 0 - name = "nullplatform-${var.cluster_name}-rds-postgres-db-s3-policy" + name = "${local.policies_name_prefix}-rds-postgres-db-s3-policy" description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" policy = jsonencode({ diff --git a/rds-postgres-db/requirements/output.tf b/rds-postgres-db/specs/requirements/aws/output.tf similarity index 100% rename from rds-postgres-db/requirements/output.tf rename to rds-postgres-db/specs/requirements/aws/output.tf diff --git a/rds-postgres-db/requirements/variables.tf b/rds-postgres-db/specs/requirements/aws/variables.tf similarity index 91% rename from rds-postgres-db/requirements/variables.tf rename to rds-postgres-db/specs/requirements/aws/variables.tf index a0f3af7..54b9784 100644 --- a/rds-postgres-db/requirements/variables.tf +++ b/rds-postgres-db/specs/requirements/aws/variables.tf @@ -31,6 +31,12 @@ variable "role_name" { default = "" } +variable "policies_name_prefix" { + description = "Override for the IAM policy name prefix. Defaults to nullplatform-{cluster_name}." + type = string + default = "" +} + variable "iam_create_role" { description = "Whether to create the permissions role and its policy. When false, the module produces no resources." type = bool diff --git a/rds-postgres-db/requirements/versions.tf b/rds-postgres-db/specs/requirements/aws/versions.tf similarity index 100% rename from rds-postgres-db/requirements/versions.tf rename to rds-postgres-db/specs/requirements/aws/versions.tf diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md index 843ac41..5c2a1b0 100644 --- a/rds-postgres-server/README.md +++ b/rds-postgres-server/README.md @@ -133,7 +133,7 @@ tag, not from this provider's `vpc_subnets` list). ### AWS IAM Permissions -The agent executing this service needs the following IAM permissions (see `requirements/main.tf`): +The agent executing this service needs the following IAM permissions (see `specs/requirements/aws/main.tf`): - **RDS**: `CreateDBInstance`, `DeleteDBInstance`, `ModifyDBInstance`, `DescribeDBInstances`, subnet group management, tagging - **EC2**: Security group management, `DescribeVpcs`, `DescribeSubnets` @@ -145,7 +145,9 @@ The `requirements/` Terraform module creates a dedicated IAM role (`nullplatform--rds-postgres-server-role`) holding these policies, with a trust policy allowing the nullplatform agent role to `sts:AssumeRole` on it. Pass `cluster_name` (required) and optionally -`agent_role_arn` (defaults to `nullplatform--agent-role`) when +`agent_role_arn` (defaults to `nullplatform--agent-role`), +`role_name` (defaults to `nullplatform--rds-postgres-server-role`) +and `policies_name_prefix` (defaults to `nullplatform-`) when applying it. Granting the agent itself permission to assume this role is handled separately, outside this module. @@ -159,7 +161,7 @@ Three separate pieces must all be in place for the agent to actually assume `agent_role_arn`) — creates the role and its trust policy (see above): ```hcl module "service_requirements_rds_postgres_server" { - source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-server/requirements?ref=" + source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-server/specs/requirements/aws?ref=" cluster_name = "" # agent_role_arn = "" # optional override; defaults to @@ -203,6 +205,15 @@ workflow YAMLs, so the lookup goes through the `np` CLI directly instead (the same mechanism `build_context` already uses for the region/VPC providers above). +The lookup also passes `--dimensions` (derived from `.service.dimensions` in +`CONTEXT`, e.g. `cluster:prod`) so that if more than one +`identity-access-control` provider is ever registered at the same namespace +NRN for different dimensions, `np` resolves the most-specific match instead +of an arbitrary one being picked client-side. Today the setup above +registers a single, dimension-less provider per namespace, so this is a +no-op — it only matters if per-dimension AssumeRole roles are introduced +later. + **If any of the 3 steps is missing**, `assume_role_step` logs `assume_role=skipped (using agent credentials)` and the workflow proceeds under the **agent's own role** — which fails with `AccessDenied` on diff --git a/rds-postgres-server/requirements/locals.tf b/rds-postgres-server/requirements/locals.tf deleted file mode 100644 index 7ad074e..0000000 --- a/rds-postgres-server/requirements/locals.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - iam_module_name = "requirements-rds-postgres-server" - iam_create = var.iam_create_role - - role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-server-role" - agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" - - iam_default_tags = merge(var.iam_resource_tags_json, { - ManagedBy = "rds-postgres-server" - Module = local.iam_module_name - }) -} diff --git a/rds-postgres-server/scripts/aws/assume_role_lib b/rds-postgres-server/scripts/aws/assume_role_lib index a8b25da..38dc5aa 100755 --- a/rds-postgres-server/scripts/aws/assume_role_lib +++ b/rds-postgres-server/scripts/aws/assume_role_lib @@ -1,18 +1,15 @@ #!/bin/bash # Sourceable library of PURE helpers for assume-role resolution. +# Makes NO np/aws calls and has no source side effects — fully unit-testable. +# Identical across rds-postgres-server and rds-postgres-db so it can be +# copied as-is into any new AWS service module. # -# Input is the AWS IAM provider exactly as it appears in -# CONTEXT.providers["identity-access-control"] — the platform already resolved it -# for the service's dimensions (most-specific config whose dimensions are a subset -# of the service's wins). These helpers only pick the selector, so they make NO -# np/aws calls and have no side effects on source — fully unit-testable. -# -# Requires (at call time): jq. +# Requires (at call time): jq, bash >= 4 (uses ${!var} indirect expansion). -# arn_for_selector -# Given CONTEXT.providers["identity-access-control"], echoes the ARN whose entry -# in .iam_role_arns.arns[] matches , or "" if none. First match wins. -# Returns "" on empty/malformed input (never crashes). +# arn_for_selector +# Given the IAM provider attributes (.iam_role_arns.arns[...]), echoes the ARN +# whose entry matches , or "" if none. First match wins. Never +# crashes on empty/malformed input. arn_for_selector() { local json="$1" selector="$2" [ -n "$json" ] || return 0 @@ -24,23 +21,27 @@ arn_for_selector() { | first // ""' 2>/dev/null || true } -# resolve_assume_role_arn -# Echoes the ARN to assume ("" = use agent credentials): -# 1. $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN env var (explicit override) -# 2. AWS IAM provider entry matching (already dimension-resolved -# by the platform via CONTEXT.providers["identity-access-control"]) -# 3. $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT env var (per-account agent default) -# Note: RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN="" (explicitly empty) is treated the same as -# unset — the chain continues to the next source. +# resolve_assume_role_arn +# Echoes the ARN to assume ("" = use agent credentials), in precedence order: +# 1. ${!override_env_name} — explicit per-run override +# 2. iam_attributes_json entry matching (caller pre-resolved the +# provider via `np provider list` for the service's NRN + dimensions) +# 3. ${!default_env_name} — per-account agent default +# Empty override/default env names are treated as unset (chain continues). resolve_assume_role_arn() { - local iam_json="$1" selector="$2" arn="" + local iam_json="$1" selector="$2" override_env="$3" default_env="$4" arn="" - arn="${RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN:-}" + if [ -n "$override_env" ]; then + arn="${!override_env:-}" + fi if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then arn=$(arn_for_selector "$iam_json" "$selector") fi - arn="${arn:-${RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT:-}}" + if [ -z "$arn" ] && [ -n "$default_env" ]; then + arn="${!default_env:-}" + fi + printf '%s' "$arn" } diff --git a/rds-postgres-server/scripts/aws/assume_role_step b/rds-postgres-server/scripts/aws/assume_role_step index d182370..7795216 100755 --- a/rds-postgres-server/scripts/aws/assume_role_step +++ b/rds-postgres-server/scripts/aws/assume_role_step @@ -15,6 +15,13 @@ # (values.yaml and/or the workflow) — provider_categories is still declared # for forward-compatibility, but this script does not rely on it. # +# The lookup also passes --dimensions (from .service.dimensions in CONTEXT, +# e.g. cluster:prod) so that if more than one identity-access-control provider +# is ever registered at the same namespace NRN for different dimensions, np +# resolves the most-specific match instead of us picking an arbitrary one +# client-side. Today only one such provider is documented/registered per +# namespace (no dimensions of its own), so this is a no-op in practice. +# # Resolution precedence (see resolve_assume_role_arn in assume_role_lib): # $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN -> IAM provider by selector # -> $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT -> agent credentials @@ -31,9 +38,16 @@ RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_SERVER_ASSUME_ROLE_SELE # the identity-access-control provider is registered at namespace level. NAMESPACE_NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:application=.*$//') +# Dimensions (if any) as key:value,key:value — lets np resolve the +# most-specific IAM provider the same way it would for a k8s scope. +DIMENSIONS=$(echo "${CONTEXT:-}" | jq -r ' + if (.service.dimensions | type) == "object" and ((.service.dimensions | length) > 0) + then [ .service.dimensions | to_entries[] | "\(.key):\(.value)" ] | join(",") + else empty end' 2>/dev/null) + IAM_PROVIDER="{}" if [ -n "$NAMESPACE_NRN" ]; then - NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 2>/dev/null) + NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 ${DIMENSIONS:+--dimensions "$DIMENSIONS"} 2>/dev/null) IAM_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["iam_role_arns.arns"]))] | first | .id // ""') if [ -n "$IAM_PROVIDER_ID" ] && [ "$IAM_PROVIDER_ID" != "null" ]; then @@ -41,7 +55,11 @@ if [ -n "$NAMESPACE_NRN" ]; then fi fi -RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_PROVIDER" "$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR") +RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn \ + "$IAM_PROVIDER" \ + "$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR" \ + "RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" \ + "RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT") export RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN # scripts/aws/assume_role performs sts:AssumeRole and exports AWS_* when an ARN is set, @@ -53,7 +71,7 @@ if ! source "$SCRIPT_DIR/assume_role"; then echo "💡 Possible causes:" >&2 echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2 echo " • The target role does not exist or does not trust the agent role" >&2 - echo " • There is no role ARN configured for selector=$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR" >&2 + echo " • There is no role ARN configured for selector=$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR at NRN=$NAMESPACE_NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2 echo "" >&2 exit 1 fi diff --git a/rds-postgres-server/requirements/.terraform.lock.hcl b/rds-postgres-server/specs/requirements/aws/.terraform.lock.hcl similarity index 100% rename from rds-postgres-server/requirements/.terraform.lock.hcl rename to rds-postgres-server/specs/requirements/aws/.terraform.lock.hcl diff --git a/rds-postgres-server/requirements/data.tf b/rds-postgres-server/specs/requirements/aws/data.tf similarity index 100% rename from rds-postgres-server/requirements/data.tf rename to rds-postgres-server/specs/requirements/aws/data.tf diff --git a/rds-postgres-server/specs/requirements/aws/locals.tf b/rds-postgres-server/specs/requirements/aws/locals.tf new file mode 100644 index 0000000..01328b1 --- /dev/null +++ b/rds-postgres-server/specs/requirements/aws/locals.tf @@ -0,0 +1,13 @@ +locals { + iam_module_name = "requirements-rds-postgres-server" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-server-role" + policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform-${var.cluster_name}" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-server" + Module = local.iam_module_name + }) +} diff --git a/rds-postgres-server/requirements/main.tf b/rds-postgres-server/specs/requirements/aws/main.tf similarity index 95% rename from rds-postgres-server/requirements/main.tf rename to rds-postgres-server/specs/requirements/aws/main.tf index eb4448e..b068cf2 100644 --- a/rds-postgres-server/requirements/main.tf +++ b/rds-postgres-server/specs/requirements/aws/main.tf @@ -56,7 +56,7 @@ resource "aws_iam_role_policy_attachment" "rds_s3" { resource "aws_iam_policy" "nullplatform_rds_policy" { count = local.iam_create ? 1 : 0 - name = "nullplatform-${var.cluster_name}-rds-policy" + name = "${local.policies_name_prefix}-rds-policy" description = "Policy for managing RDS instances and subnet groups" policy = jsonencode({ @@ -97,7 +97,7 @@ resource "aws_iam_policy" "nullplatform_rds_policy" { resource "aws_iam_policy" "nullplatform_rds_sg_policy" { count = local.iam_create ? 1 : 0 - name = "nullplatform-${var.cluster_name}-rds-sg-policy" + name = "${local.policies_name_prefix}-rds-sg-policy" description = "Policy for managing EC2 security groups for RDS" policy = jsonencode({ @@ -134,7 +134,7 @@ resource "aws_iam_policy" "nullplatform_rds_sg_policy" { resource "aws_iam_policy" "nullplatform_rds_s3_policy" { count = local.iam_create ? 1 : 0 - name = "nullplatform-${var.cluster_name}-rds-s3-policy" + name = "${local.policies_name_prefix}-rds-s3-policy" description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" policy = jsonencode({ @@ -171,7 +171,7 @@ resource "aws_iam_policy" "nullplatform_rds_s3_policy" { resource "aws_iam_policy" "nullplatform_rds_secretsmanager_policy" { count = local.iam_create ? 1 : 0 - name = "nullplatform-${var.cluster_name}-rds-secretsmanager-policy" + name = "${local.policies_name_prefix}-rds-secretsmanager-policy" description = "Policy for managing Secrets Manager secrets for RDS master password" policy = jsonencode({ diff --git a/rds-postgres-server/requirements/output.tf b/rds-postgres-server/specs/requirements/aws/output.tf similarity index 100% rename from rds-postgres-server/requirements/output.tf rename to rds-postgres-server/specs/requirements/aws/output.tf diff --git a/rds-postgres-server/requirements/variables.tf b/rds-postgres-server/specs/requirements/aws/variables.tf similarity index 91% rename from rds-postgres-server/requirements/variables.tf rename to rds-postgres-server/specs/requirements/aws/variables.tf index 83d0c00..d20b293 100644 --- a/rds-postgres-server/requirements/variables.tf +++ b/rds-postgres-server/specs/requirements/aws/variables.tf @@ -31,6 +31,12 @@ variable "role_name" { default = "" } +variable "policies_name_prefix" { + description = "Override for the IAM policy name prefix. Defaults to nullplatform-{cluster_name}." + type = string + default = "" +} + variable "iam_create_role" { description = "Whether to create the permissions role and its policies. When false, the module produces no resources." type = bool diff --git a/rds-postgres-server/requirements/versions.tf b/rds-postgres-server/specs/requirements/aws/versions.tf similarity index 100% rename from rds-postgres-server/requirements/versions.tf rename to rds-postgres-server/specs/requirements/aws/versions.tf From 28be40389ec5e49cad60f389e0985e3b76d73e32 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Mon, 6 Jul 2026 14:00:35 -0300 Subject: [PATCH 31/34] feat(rds-postgres): add specs/install/aws to register the service on nullplatform Mirrors the specs/install convention from nullplatform/scopes-static-files: requirements/ provisions the AWS AssumeRole IAM role/policies (the agent's half), install/ registers the service_definition + agent association (the platform's half) so np service create actually routes to an agent. Modeled on the module blocks already validated live against PAE/services-testing. Co-Authored-By: Claude Sonnet 5 --- rds-postgres-db/README.md | 3 ++ rds-postgres-db/specs/install/README.md | 46 ++++++++++++++++++ .../specs/install/aws/.terraform.lock.hcl | 47 +++++++++++++++++++ rds-postgres-db/specs/install/aws/main.tf | 40 ++++++++++++++++ rds-postgres-db/specs/install/aws/outputs.tf | 9 ++++ .../install/aws/terraform.tfvars.example | 10 ++++ .../specs/install/aws/variables.tf | 46 ++++++++++++++++++ rds-postgres-server/README.md | 3 ++ rds-postgres-server/specs/install/README.md | 44 +++++++++++++++++ .../specs/install/aws/.terraform.lock.hcl | 47 +++++++++++++++++++ rds-postgres-server/specs/install/aws/main.tf | 40 ++++++++++++++++ .../specs/install/aws/outputs.tf | 9 ++++ .../install/aws/terraform.tfvars.example | 10 ++++ .../specs/install/aws/variables.tf | 46 ++++++++++++++++++ 14 files changed, 400 insertions(+) create mode 100644 rds-postgres-db/specs/install/README.md create mode 100644 rds-postgres-db/specs/install/aws/.terraform.lock.hcl create mode 100644 rds-postgres-db/specs/install/aws/main.tf create mode 100644 rds-postgres-db/specs/install/aws/outputs.tf create mode 100644 rds-postgres-db/specs/install/aws/terraform.tfvars.example create mode 100644 rds-postgres-db/specs/install/aws/variables.tf create mode 100644 rds-postgres-server/specs/install/README.md create mode 100644 rds-postgres-server/specs/install/aws/.terraform.lock.hcl create mode 100644 rds-postgres-server/specs/install/aws/main.tf create mode 100644 rds-postgres-server/specs/install/aws/outputs.tf create mode 100644 rds-postgres-server/specs/install/aws/terraform.tfvars.example create mode 100644 rds-postgres-server/specs/install/aws/variables.tf diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index 92131de..4f89d88 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -101,6 +101,9 @@ This ensures usernames are stable and reproducible even if the service is recrea ### nullplatform Prerequisites +- The service itself must be registered on the nullplatform account first — see + [`specs/install/README.md`](specs/install/README.md) for the Terraform that + registers the service specification and agent association. - An active **`rds-postgres-server`** service in the same nullplatform namespace with: - `status: active` - Matching dimensions (e.g., both services must have `cluster: prod`) diff --git a/rds-postgres-db/specs/install/README.md b/rds-postgres-db/specs/install/README.md new file mode 100644 index 0000000..8f08241 --- /dev/null +++ b/rds-postgres-db/specs/install/README.md @@ -0,0 +1,46 @@ +# Install — registering the rds-postgres-db service + +This directory holds the reference OpenTofu/Terraform used to **install** +rds-postgres-db on a nullplatform account: registering its service +specification, link specification, and agent association (notification +channel) so `np service create` starts routing actions to an agent. + +This is separate from `../requirements/aws`, which provisions the AWS +AssumeRole IAM role/policies the *agent* needs to operate the service — see +that module's README and the "AssumeRole Setup Guide" in the top-level +[`README.md`](../../README.md) for that half of the setup. + +## Layout + +``` +install/ +├── README.md (this file) +└── aws/ Working example + ├── main.tf + ├── variables.tf + ├── outputs.tf + └── terraform.tfvars.example +``` + +## Using the example + +```bash +cp -r databases/rds-postgres-db/specs/install/aws /path/to/your/infra/rds-postgres-db +cd /path/to/your/infra/rds-postgres-db +cp terraform.tfvars.example terraform.tfvars +$EDITOR terraform.tfvars + +tofu init +tofu apply +``` + +`tags_selectors` must match the tag selectors of the agent(s) that should +pick up rds-postgres-db actions (the same selectors passed as +`tags_selectors` to the `nullplatform/agent` tofu-module). + +Run this once per nullplatform namespace, alongside the matching +rds-postgres-server install (see that service's +[`specs/install/README.md`](../../rds-postgres-server/specs/install/README.md)). +It only registers the service with the platform — it does not create any +AWS infrastructure by itself (that happens per-instance, at `create` time, +via `deployment/` and the AssumeRole role from `requirements/aws`). diff --git a/rds-postgres-db/specs/install/aws/.terraform.lock.hcl b/rds-postgres-db/specs/install/aws/.terraform.lock.hcl new file mode 100644 index 0000000..95ac674 --- /dev/null +++ b/rds-postgres-db/specs/install/aws/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/http" { + version = "3.6.0" + constraints = "~> 3.0" + hashes = [ + "h1:5oU7Zm+2gAVGmxqtJ9E8uTudUkYy/DEn/y3IWphdv4k=", + "zh:0f719fa5426bc883e9fa6abf7f6498e48025edafbc29015e2f5c028f1cca3b9d", + "zh:1b4d7dafefd6c61764b2f9ed6943ceb9a200dee3590d18747e3a5f6b20ce85e0", + "zh:1d23a712984866d29f7b07028a4e99c783c71f1a5dddf08bc3d4e7da9d91a1fa", + "zh:257d23d58c3bb024b6bc8eb88736eaf912e934ad47c639d0c3c742bddda849a1", + "zh:479860e1a5468f5e04013b9364c9496d7ed0804bf9a1acd8e07558d57609993d", + "zh:4cb5e681bf599b411b27c4a2c4066a5fb2ed79aaa3a1a3cb5a30002fec062ce9", + "zh:4fb35c3f643dae9f3670d719397a415f815a0b95f8ed7bd8a72f27a94ba78092", + "zh:59ba40825ab38db5b4a0989a2db0df35cc15d8984f898176011ba352f27d77b7", + "zh:61fc1252eb88088638f4c69ea4e2171cde2e5089fa632ac1e943b13787348f73", + "zh:7c5d6dd5f7cbc460e95d368be35c29b4e0402069b8912dbd5d1cd7fa9acef216", + "zh:7f76d756240d4284642f359ad470226e5378670239aadc366ef54d9d914d4d2e", + "zh:8133ad0814098177e0d067c816ccf1bf48bbadacd18f6f2c808c90447505723b", + "zh:c93be06269bb728f1968f8c50506de56c887017ac1d6e4be1f925651d8437eb6", + "zh:ef47b78a10a82e6cf53344a6a85a94041c28286c10a70541c564d762f1cfede0", + "zh:f5796a53a74999135bd9087aff50fddda59129d09b2f9b1902ff8c0c1e047e48", + ] +} + +provider "registry.opentofu.org/nullplatform/nullplatform" { + version = "0.0.96" + constraints = ">= 0.0.86" + hashes = [ + "h1:3qYPvX16dlnA2Bh2Z5xBQDeWWkKLikWsPGf5wr28MWQ=", + "zh:0024f491c4d8eabb19f58e05cf3217bbbdac73469c3cb8dee8e2646d1636e960", + "zh:00c482219d99e53b6802f2f7a2b1581ddcae88911fcbf09a7a26b18e62cad846", + "zh:19114e9acae41bbb79f5308a754492ace94c2df3ee21e0c99de51c5dcab3f775", + "zh:2568f82d9e587f6253d5e8bd9b9cf20ad7cfb403488af0928ee6dfd116f6635f", + "zh:35c046dabe79ede321b6d5a4fd37564a2cfd00f8d49be01ff17dbdb86fd847b0", + "zh:46b4300356f11726fd6675b6a7fe9c7293094f38ec1be5d6d5d659863eee3e8a", + "zh:46d5029dcdb6febd2c10b19da7b565c14915909da0e7576e33b56c4697c5bfe3", + "zh:46ffdfa96722e184b64bd04198170c91bf59976a5171aad5371b655481da346a", + "zh:91bc64b2f7da208e1f0d5a059026b6fb78d284e2f03ab15d1e7eb16cf707eed0", + "zh:ad1b94910840d6662ef21dea503998618e6ad5b0a340c63044bb5e913e926eeb", + "zh:bf3ecf8a22fdfab30f7eaac8b56bd321e5fd4e3a0a57fda15a3078c84e769a71", + "zh:e9bd846753e194c8fc2f2f3f53f15fd33f9fd56467963fd2766d1f713865bcad", + "zh:ecf36a184c1a01f9297e331d0b1671a99e3fbc179ceb2653efe0a74c61ec97ea", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/rds-postgres-db/specs/install/aws/main.tf b/rds-postgres-db/specs/install/aws/main.tf new file mode 100644 index 0000000..8463caf --- /dev/null +++ b/rds-postgres-db/specs/install/aws/main.tf @@ -0,0 +1,40 @@ +################################################################################ +# Install — registers the rds-postgres-db service definition and its +# agent association (notification channel) on a nullplatform account. +# +# This is the platform-registration half of adopting the service; the +# AWS AssumeRole IAM role/policies live in ../../requirements/aws and are +# applied separately (see that module's README and the top-level +# "AssumeRole Setup Guide" in ../../../README.md). +################################################################################ + +locals { + service_path = "databases/rds-postgres-db" + available_links = ["connect"] + available_actions = [] +} + +module "service_definition" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition?ref=v4.5.1" + + nrn = var.nrn + repository_org = var.repository_org + repository_name = var.repository_name + repository_branch = var.repository_branch + repository_token = var.repository_token + service_path = local.service_path + service_name = var.service_name + available_links = local.available_links + available_actions = local.available_actions +} + +module "service_definition_agent_association" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition_agent_association?ref=v4.5.1" + + nrn = var.nrn + repository_service_spec_repo = "${var.repository_org}/${var.repository_name}" + service_path = local.service_path + service_specification_slug = module.service_definition.service_specification_slug + api_key = var.np_api_key + tags_selectors = var.tags_selectors +} diff --git a/rds-postgres-db/specs/install/aws/outputs.tf b/rds-postgres-db/specs/install/aws/outputs.tf new file mode 100644 index 0000000..4c340cd --- /dev/null +++ b/rds-postgres-db/specs/install/aws/outputs.tf @@ -0,0 +1,9 @@ +output "service_specification_id" { + description = "ID of the registered rds-postgres-db service specification." + value = module.service_definition.service_specification_id +} + +output "service_specification_slug" { + description = "Slug of the registered rds-postgres-db service specification." + value = module.service_definition.service_specification_slug +} diff --git a/rds-postgres-db/specs/install/aws/terraform.tfvars.example b/rds-postgres-db/specs/install/aws/terraform.tfvars.example new file mode 100644 index 0000000..ce07569 --- /dev/null +++ b/rds-postgres-db/specs/install/aws/terraform.tfvars.example @@ -0,0 +1,10 @@ +nrn = "" # namespace-level NRN, e.g. organization=:account=:namespace= +np_api_key = "" + +tags_selectors = { + "environment" = "" +} + +# repository_org = "nullplatform" +# repository_name = "services" +# repository_branch = "main" diff --git a/rds-postgres-db/specs/install/aws/variables.tf b/rds-postgres-db/specs/install/aws/variables.tf new file mode 100644 index 0000000..88dc24f --- /dev/null +++ b/rds-postgres-db/specs/install/aws/variables.tf @@ -0,0 +1,46 @@ +variable "nrn" { + description = "NullPlatform Resource Name (namespace-level, e.g. organization=:account=:namespace=) where the service definition is registered." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the agent association to authenticate against the nullplatform API." + type = string + sensitive = true +} + +variable "tags_selectors" { + description = "Agent tag selectors for the notification channel (must match the tags the target agent registers with)." + type = map(string) +} + +variable "service_name" { + description = "Display name for the rds-postgres-db service in nullplatform." + type = string + default = "RDS Postgres DB" +} + +variable "repository_org" { + description = "GitHub organization owning the services repository." + type = string + default = "nullplatform" +} + +variable "repository_name" { + description = "Repository name containing the rds-postgres-db service spec templates." + type = string + default = "services" +} + +variable "repository_branch" { + description = "Branch of the services repository to register the service spec/links/entrypoint from." + type = string + default = "main" +} + +variable "repository_token" { + description = "Access token for private repositories. Unnecessary for the public nullplatform/services repository." + type = string + default = null + sensitive = true +} diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md index 5c2a1b0..42668de 100644 --- a/rds-postgres-server/README.md +++ b/rds-postgres-server/README.md @@ -94,6 +94,9 @@ Exposed in the nullplatform UI when creating or updating the service: ### nullplatform Prerequisites +- The service itself must be registered on the nullplatform account first — see + [`specs/install/README.md`](specs/install/README.md) for the Terraform that + registers the service specification and agent association. - An active nullplatform account with the following providers configured for the target namespace/dimensions: - **`aws-configuration`** (from `tofu-modules//nullplatform/cloud/aws/cloud`) — exposes `account.region`. `build_context` resolves this via `np provider list --nrn ` filtered by `stored_keys` containing `account.region`. - **`aws-networking-configuration`** (from `tofu-modules//nullplatform/cloud/aws/vpc`) — exposes `vpc.id`, `vpc.subnets`, `vpc.security_groups`. Same lookup mechanism, filtered by `vpc.id`. diff --git a/rds-postgres-server/specs/install/README.md b/rds-postgres-server/specs/install/README.md new file mode 100644 index 0000000..1bdbad9 --- /dev/null +++ b/rds-postgres-server/specs/install/README.md @@ -0,0 +1,44 @@ +# Install — registering the rds-postgres-server service + +This directory holds the reference OpenTofu/Terraform used to **install** +rds-postgres-server on a nullplatform account: registering its service +specification, link specification, and agent association (notification +channel) so `np service create` starts routing actions to an agent. + +This is separate from `../requirements/aws`, which provisions the AWS +AssumeRole IAM role/policies the *agent* needs to operate the service — see +that module's README and the "AssumeRole Setup Guide" in the top-level +[`README.md`](../../README.md) for that half of the setup. + +## Layout + +``` +install/ +├── README.md (this file) +└── aws/ Working example + ├── main.tf + ├── variables.tf + ├── outputs.tf + └── terraform.tfvars.example +``` + +## Using the example + +```bash +cp -r databases/rds-postgres-server/specs/install/aws /path/to/your/infra/rds-postgres-server +cd /path/to/your/infra/rds-postgres-server +cp terraform.tfvars.example terraform.tfvars +$EDITOR terraform.tfvars + +tofu init +tofu apply +``` + +`tags_selectors` must match the tag selectors of the agent(s) that should +pick up rds-postgres-server actions (the same selectors passed as +`tags_selectors` to the `nullplatform/agent` tofu-module). + +Run this once per nullplatform namespace. It only registers the service +with the platform — it does not create any AWS infrastructure by itself +(that happens per-instance, at `create` time, via `deployment/` and the +AssumeRole role from `requirements/aws`). diff --git a/rds-postgres-server/specs/install/aws/.terraform.lock.hcl b/rds-postgres-server/specs/install/aws/.terraform.lock.hcl new file mode 100644 index 0000000..95ac674 --- /dev/null +++ b/rds-postgres-server/specs/install/aws/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/http" { + version = "3.6.0" + constraints = "~> 3.0" + hashes = [ + "h1:5oU7Zm+2gAVGmxqtJ9E8uTudUkYy/DEn/y3IWphdv4k=", + "zh:0f719fa5426bc883e9fa6abf7f6498e48025edafbc29015e2f5c028f1cca3b9d", + "zh:1b4d7dafefd6c61764b2f9ed6943ceb9a200dee3590d18747e3a5f6b20ce85e0", + "zh:1d23a712984866d29f7b07028a4e99c783c71f1a5dddf08bc3d4e7da9d91a1fa", + "zh:257d23d58c3bb024b6bc8eb88736eaf912e934ad47c639d0c3c742bddda849a1", + "zh:479860e1a5468f5e04013b9364c9496d7ed0804bf9a1acd8e07558d57609993d", + "zh:4cb5e681bf599b411b27c4a2c4066a5fb2ed79aaa3a1a3cb5a30002fec062ce9", + "zh:4fb35c3f643dae9f3670d719397a415f815a0b95f8ed7bd8a72f27a94ba78092", + "zh:59ba40825ab38db5b4a0989a2db0df35cc15d8984f898176011ba352f27d77b7", + "zh:61fc1252eb88088638f4c69ea4e2171cde2e5089fa632ac1e943b13787348f73", + "zh:7c5d6dd5f7cbc460e95d368be35c29b4e0402069b8912dbd5d1cd7fa9acef216", + "zh:7f76d756240d4284642f359ad470226e5378670239aadc366ef54d9d914d4d2e", + "zh:8133ad0814098177e0d067c816ccf1bf48bbadacd18f6f2c808c90447505723b", + "zh:c93be06269bb728f1968f8c50506de56c887017ac1d6e4be1f925651d8437eb6", + "zh:ef47b78a10a82e6cf53344a6a85a94041c28286c10a70541c564d762f1cfede0", + "zh:f5796a53a74999135bd9087aff50fddda59129d09b2f9b1902ff8c0c1e047e48", + ] +} + +provider "registry.opentofu.org/nullplatform/nullplatform" { + version = "0.0.96" + constraints = ">= 0.0.86" + hashes = [ + "h1:3qYPvX16dlnA2Bh2Z5xBQDeWWkKLikWsPGf5wr28MWQ=", + "zh:0024f491c4d8eabb19f58e05cf3217bbbdac73469c3cb8dee8e2646d1636e960", + "zh:00c482219d99e53b6802f2f7a2b1581ddcae88911fcbf09a7a26b18e62cad846", + "zh:19114e9acae41bbb79f5308a754492ace94c2df3ee21e0c99de51c5dcab3f775", + "zh:2568f82d9e587f6253d5e8bd9b9cf20ad7cfb403488af0928ee6dfd116f6635f", + "zh:35c046dabe79ede321b6d5a4fd37564a2cfd00f8d49be01ff17dbdb86fd847b0", + "zh:46b4300356f11726fd6675b6a7fe9c7293094f38ec1be5d6d5d659863eee3e8a", + "zh:46d5029dcdb6febd2c10b19da7b565c14915909da0e7576e33b56c4697c5bfe3", + "zh:46ffdfa96722e184b64bd04198170c91bf59976a5171aad5371b655481da346a", + "zh:91bc64b2f7da208e1f0d5a059026b6fb78d284e2f03ab15d1e7eb16cf707eed0", + "zh:ad1b94910840d6662ef21dea503998618e6ad5b0a340c63044bb5e913e926eeb", + "zh:bf3ecf8a22fdfab30f7eaac8b56bd321e5fd4e3a0a57fda15a3078c84e769a71", + "zh:e9bd846753e194c8fc2f2f3f53f15fd33f9fd56467963fd2766d1f713865bcad", + "zh:ecf36a184c1a01f9297e331d0b1671a99e3fbc179ceb2653efe0a74c61ec97ea", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/rds-postgres-server/specs/install/aws/main.tf b/rds-postgres-server/specs/install/aws/main.tf new file mode 100644 index 0000000..9d3c48a --- /dev/null +++ b/rds-postgres-server/specs/install/aws/main.tf @@ -0,0 +1,40 @@ +################################################################################ +# Install — registers the rds-postgres-server service definition and its +# agent association (notification channel) on a nullplatform account. +# +# This is the platform-registration half of adopting the service; the +# AWS AssumeRole IAM role/policies live in ../../requirements/aws and are +# applied separately (see that module's README and the top-level +# "AssumeRole Setup Guide" in ../../../README.md). +################################################################################ + +locals { + service_path = "databases/rds-postgres-server" + available_links = ["connect"] + available_actions = [] +} + +module "service_definition" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition?ref=v4.5.1" + + nrn = var.nrn + repository_org = var.repository_org + repository_name = var.repository_name + repository_branch = var.repository_branch + repository_token = var.repository_token + service_path = local.service_path + service_name = var.service_name + available_links = local.available_links + available_actions = local.available_actions +} + +module "service_definition_agent_association" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition_agent_association?ref=v4.5.1" + + nrn = var.nrn + repository_service_spec_repo = "${var.repository_org}/${var.repository_name}" + service_path = local.service_path + service_specification_slug = module.service_definition.service_specification_slug + api_key = var.np_api_key + tags_selectors = var.tags_selectors +} diff --git a/rds-postgres-server/specs/install/aws/outputs.tf b/rds-postgres-server/specs/install/aws/outputs.tf new file mode 100644 index 0000000..e9e66d4 --- /dev/null +++ b/rds-postgres-server/specs/install/aws/outputs.tf @@ -0,0 +1,9 @@ +output "service_specification_id" { + description = "ID of the registered rds-postgres-server service specification." + value = module.service_definition.service_specification_id +} + +output "service_specification_slug" { + description = "Slug of the registered rds-postgres-server service specification." + value = module.service_definition.service_specification_slug +} diff --git a/rds-postgres-server/specs/install/aws/terraform.tfvars.example b/rds-postgres-server/specs/install/aws/terraform.tfvars.example new file mode 100644 index 0000000..ce07569 --- /dev/null +++ b/rds-postgres-server/specs/install/aws/terraform.tfvars.example @@ -0,0 +1,10 @@ +nrn = "" # namespace-level NRN, e.g. organization=:account=:namespace= +np_api_key = "" + +tags_selectors = { + "environment" = "" +} + +# repository_org = "nullplatform" +# repository_name = "services" +# repository_branch = "main" diff --git a/rds-postgres-server/specs/install/aws/variables.tf b/rds-postgres-server/specs/install/aws/variables.tf new file mode 100644 index 0000000..5853621 --- /dev/null +++ b/rds-postgres-server/specs/install/aws/variables.tf @@ -0,0 +1,46 @@ +variable "nrn" { + description = "NullPlatform Resource Name (namespace-level, e.g. organization=:account=:namespace=) where the service definition is registered." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the agent association to authenticate against the nullplatform API." + type = string + sensitive = true +} + +variable "tags_selectors" { + description = "Agent tag selectors for the notification channel (must match the tags the target agent registers with)." + type = map(string) +} + +variable "service_name" { + description = "Display name for the rds-postgres-server service in nullplatform." + type = string + default = "RDS Postgres Server" +} + +variable "repository_org" { + description = "GitHub organization owning the services repository." + type = string + default = "nullplatform" +} + +variable "repository_name" { + description = "Repository name containing the rds-postgres-server service spec templates." + type = string + default = "services" +} + +variable "repository_branch" { + description = "Branch of the services repository to register the service spec/links/entrypoint from." + type = string + default = "main" +} + +variable "repository_token" { + description = "Access token for private repositories. Unnecessary for the public nullplatform/services repository." + type = string + default = null + sensitive = true +} From 4b78db5c636f707d70bbef77d35de48805afd63a Mon Sep 17 00:00:00 2001 From: Agustin Celentano <12614595+agustincelentano@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:59:27 -0300 Subject: [PATCH 32/34] fix(rds-postgres): resolve IAM provider via --categories up the NRN hierarchy The assume_role_step stripped the NRN to namespace level and did a plain 'np provider list' filtered by stored_keys. A plain list does not inherit providers from ascendant NRN levels, so the identity-access-control provider (registered at account level) was never found and the step fell back to agent credentials (assume_role=skipped). Align with the aws-s3-bucket service: use the full NRN as-is and query with --categories identity-access-control, which resolves up the hierarchy and returns the effective provider attributes. Applies to both server and db. --- rds-postgres-db/scripts/aws/assume_role_step | 30 ++++++++++--------- .../scripts/aws/assume_role_step | 30 ++++++++++--------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/rds-postgres-db/scripts/aws/assume_role_step b/rds-postgres-db/scripts/aws/assume_role_step index a497cf8..0ad4f80 100755 --- a/rds-postgres-db/scripts/aws/assume_role_step +++ b/rds-postgres-db/scripts/aws/assume_role_step @@ -33,10 +33,11 @@ source "$SCRIPT_DIR/assume_role_lib" RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR:-rds-postgres-db}" -# Namespace NRN: same derivation build_context uses for its account NRN, but -# keeping the namespace segment (strip only from :application= onward) since -# the identity-access-control provider is registered at namespace level. -NAMESPACE_NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:application=.*$//') +# NRN of the service from CONTEXT (falls back to scope / generic event). Use the +# full NRN as-is — do NOT strip it. Resolution walks UP the NRN hierarchy, so the +# identity-access-control provider (registered at account level) is found via the +# --categories query below regardless of the depth the service runs at. +NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .scope.nrn // .entity_nrn // ""' 2>/dev/null) # Dimensions (if any) as key:value,key:value — lets np resolve the # most-specific IAM provider the same way it would for a k8s scope. @@ -45,15 +46,16 @@ DIMENSIONS=$(echo "${CONTEXT:-}" | jq -r ' then [ .service.dimensions | to_entries[] | "\(.key):\(.value)" ] | join(",") else empty end' 2>/dev/null) -IAM_PROVIDER="{}" -if [ -n "$NAMESPACE_NRN" ]; then - NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 ${DIMENSIONS:+--dimensions "$DIMENSIONS"} 2>/dev/null) - IAM_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ - | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["iam_role_arns.arns"]))] | first | .id // ""') - if [ -n "$IAM_PROVIDER_ID" ] && [ "$IAM_PROVIDER_ID" != "null" ]; then - IAM_PROVIDER=$(np provider read --id "$IAM_PROVIDER_ID" --format json 2>/dev/null | jq -c '.attributes // {}') - fi -fi +# Resolve the IAM provider for this NRN + dimensions via the category query, which +# resolves up the NRN hierarchy and returns the effective provider .attributes. +# NOTE: --limit is incompatible with --categories (np rejects it), so it is NOT +# passed here. +IAM_PROVIDER=$(np provider list \ + --nrn "$NRN" \ + --categories identity-access-control \ + ${DIMENSIONS:+--dimensions "$DIMENSIONS"} \ + --format json 2>/dev/null \ + | jq -c '(.results // [])[0].attributes // {}') RDS_POSTGRES_DB_ASSUME_ROLE_ARN=$(resolve_assume_role_arn \ "$IAM_PROVIDER" \ @@ -71,7 +73,7 @@ if ! source "$SCRIPT_DIR/assume_role"; then echo "💡 Possible causes:" >&2 echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2 echo " • The target role does not exist or does not trust the agent role" >&2 - echo " • There is no role ARN configured for selector=$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR at NRN=$NAMESPACE_NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2 + echo " • There is no role ARN configured for selector=$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR at NRN=$NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2 echo "" >&2 exit 1 fi diff --git a/rds-postgres-server/scripts/aws/assume_role_step b/rds-postgres-server/scripts/aws/assume_role_step index 7795216..89a63d5 100755 --- a/rds-postgres-server/scripts/aws/assume_role_step +++ b/rds-postgres-server/scripts/aws/assume_role_step @@ -33,10 +33,11 @@ source "$SCRIPT_DIR/assume_role_lib" RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR:-rds-postgres-server}" -# Namespace NRN: same derivation build_context uses for its account NRN, but -# keeping the namespace segment (strip only from :application= onward) since -# the identity-access-control provider is registered at namespace level. -NAMESPACE_NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:application=.*$//') +# NRN of the service from CONTEXT (falls back to scope / generic event). Use the +# full NRN as-is — do NOT strip it. Resolution walks UP the NRN hierarchy, so the +# identity-access-control provider (registered at account level) is found via the +# --categories query below regardless of the depth the service runs at. +NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .scope.nrn // .entity_nrn // ""' 2>/dev/null) # Dimensions (if any) as key:value,key:value — lets np resolve the # most-specific IAM provider the same way it would for a k8s scope. @@ -45,15 +46,16 @@ DIMENSIONS=$(echo "${CONTEXT:-}" | jq -r ' then [ .service.dimensions | to_entries[] | "\(.key):\(.value)" ] | join(",") else empty end' 2>/dev/null) -IAM_PROVIDER="{}" -if [ -n "$NAMESPACE_NRN" ]; then - NP_PROVIDERS=$(np provider list --nrn "$NAMESPACE_NRN" --format json --limit 100 ${DIMENSIONS:+--dimensions "$DIMENSIONS"} 2>/dev/null) - IAM_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ - | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["iam_role_arns.arns"]))] | first | .id // ""') - if [ -n "$IAM_PROVIDER_ID" ] && [ "$IAM_PROVIDER_ID" != "null" ]; then - IAM_PROVIDER=$(np provider read --id "$IAM_PROVIDER_ID" --format json 2>/dev/null | jq -c '.attributes // {}') - fi -fi +# Resolve the IAM provider for this NRN + dimensions via the category query, which +# resolves up the NRN hierarchy and returns the effective provider .attributes. +# NOTE: --limit is incompatible with --categories (np rejects it), so it is NOT +# passed here. +IAM_PROVIDER=$(np provider list \ + --nrn "$NRN" \ + --categories identity-access-control \ + ${DIMENSIONS:+--dimensions "$DIMENSIONS"} \ + --format json 2>/dev/null \ + | jq -c '(.results // [])[0].attributes // {}') RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn \ "$IAM_PROVIDER" \ @@ -71,7 +73,7 @@ if ! source "$SCRIPT_DIR/assume_role"; then echo "💡 Possible causes:" >&2 echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2 echo " • The target role does not exist or does not trust the agent role" >&2 - echo " • There is no role ARN configured for selector=$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR at NRN=$NAMESPACE_NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2 + echo " • There is no role ARN configured for selector=$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR at NRN=$NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2 echo "" >&2 exit 1 fi From 367fb466bbdf556825f685b2ff941c5bcbbd22c0 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Tue, 7 Jul 2026 09:56:47 -0300 Subject: [PATCH 33/34] chore: adapt repo for standalone services-postgresql-rds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-extraction cleanup after splitting rds-postgres-server/rds-postgres-db out of the nullplatform/services monorepo into their own repository: - Drop the databases/ path prefix (service_path, cp -r examples, module source URLs) now that both services live at the repo root. - Point requirements/install module source examples and repository_name defaults at this repo instead of nullplatform/services. - Add specs/install/aws support for the account-level aws-configuration, aws-networking-configuration and aws-iam-configuration providers (vpc_provider, aws_cloud_provider, identity_access_control), toggleable via create_account_providers/create_identity_access_control. Only rds-postgres-server's install creates identity_access_control — folds in rds-postgres-db's role ARN via a variable instead of letting db register its own second provider at the same NRN (which would make assume_role_step's lookup nondeterministic). - Fix README/install docs and a stale assume_role_step comment that still said the aws-iam-configuration provider is registered at namespace level: it's actually account level, per the --categories fix already merged to main (0ce7aa7) which walks up the NRN hierarchy from the service's NRN. - Add root .gitignore (Terraform/IDE artifacts) and README.md, and the PR-checks workflow (shellcheck + tofu fmt) that lived at the monorepo root and wasn't carried over by the path extraction. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/pr-checks.yml | 34 ++++++++ .gitignore | 21 +++++ README.md | 20 +++++ rds-postgres-db/README.md | 42 ++++++--- rds-postgres-db/scripts/aws/assume_role_step | 11 +-- rds-postgres-db/specs/install/README.md | 12 ++- rds-postgres-db/specs/install/aws/main.tf | 2 +- .../install/aws/terraform.tfvars.example | 2 +- .../specs/install/aws/variables.tf | 4 +- rds-postgres-server/README.md | 34 +++++--- .../scripts/aws/assume_role_step | 11 +-- rds-postgres-server/specs/install/README.md | 29 +++++-- .../specs/install/aws/.terraform.lock.hcl | 24 +++++- rds-postgres-server/specs/install/aws/main.tf | 86 +++++++++++++++++-- .../install/aws/terraform.tfvars.example | 21 ++++- .../specs/install/aws/variables.tf | 62 ++++++++++++- 16 files changed, 355 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/pr-checks.yml create mode 100644 .gitignore create mode 100644 README.md diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..d1a1264 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,34 @@ +name: PR Checks + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Run ShellCheck + run: | + scripts=$(grep -rlE '^#!.*\b(bash|sh)\b' --exclude-dir=.git .) + if [ -n "$scripts" ]; then + echo "$scripts" | xargs shellcheck --severity=error + else + echo "No shell scripts found" + fi + + terraform-fmt: + name: Terraform Format + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: opentofu/setup-opentofu@v1 + with: + tofu_version: v1.10.5 + - name: Check formatting + run: tofu fmt -check -recursive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b85eb15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# IntelliJ project files +.idea +*.iml +out +gen + +# VSCode project files +.vscode/ + +# Terraform / OpenTofu +*.tfstate +*.tfstate.backup +.terraform/ + +# .terraform.lock.hcl is intentionally tracked (pins provider versions) — +# do not add it here. + +*.tfvars +!terraform.tfvars.example + +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e64c53 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# services-postgresql-rds + +nullplatform service definitions for AWS RDS PostgreSQL: + +- [`rds-postgres-server/`](rds-postgres-server/README.md) — provisions the RDS + PostgreSQL instance itself. +- [`rds-postgres-db/`](rds-postgres-db/README.md) — provisions a database + + application user on an existing `rds-postgres-server` instance, linked via + the `connect` link. + +Each service directory is self-contained: `entrypoint/`, `workflows/`, +`scripts/`, and `specs/` are read directly by the nullplatform agent at +runtime. `specs/requirements/aws/` and `specs/install/aws/` are one-time +setup Terraform, applied out-of-band by an account operator — see each +service's own README for the full setup guide ("AssumeRole Setup Guide") and +`specs/install/README.md`. + +This repository was extracted from `nullplatform/services` (the +`databases/rds-postgres-server` and `databases/rds-postgres-db` directories), +preserving their commit history. diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md index 4f89d88..448a9f9 100644 --- a/rds-postgres-db/README.md +++ b/rds-postgres-db/README.md @@ -109,7 +109,7 @@ This ensures usernames are stable and reproducible even if the service is recrea - Matching dimensions (e.g., both services must have `cluster: prod`) - Attributes `hostname` and `master_secret_arn` already set (i.e., RDS instance successfully provisioned) - The `rds-postgres-server` must expose a Secrets Manager secret with master PostgreSQL credentials -- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **namespace-level NRN**. Unlike `rds-postgres-server`, this service does not need `aws-configuration`/`aws-networking-configuration` providers — `build_context` reads `region` from `values.yaml` (default `us-east-1`), not from a nullplatform provider. +- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **account-level NRN** (or any ascendant of the service's NRN — resolution walks up the hierarchy). Unlike `rds-postgres-server`, this service does not need `aws-configuration`/`aws-networking-configuration` providers — `build_context` reads `region` from `values.yaml` (default `us-east-1`), not from a nullplatform provider. ### AWS IAM Permissions @@ -148,7 +148,7 @@ Three separate pieces must all be in place for the agent to actually assume `agent_role_arn`) — creates the role and its trust policy (see above): ```hcl module "service_requirements_rds_postgres_db" { - source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-db/specs/requirements/aws?ref=" + source = "git::https://github.com/nullplatform/services-postgresql-rds.git//rds-postgres-db/specs/requirements/aws?ref=" cluster_name = "" # agent_role_arn = "" # optional override; defaults to @@ -168,34 +168,48 @@ Three separate pieces must all be in place for the agent to actually assume } ``` 3. **Register the role as an `identity-access-control` provider** in - nullplatform, at the **namespace-level NRN** - (`organization=...:account=...:namespace=...` — without `:application=...`), - with selector `rds-postgres-db`: + nullplatform, at the **account-level NRN** + (`organization=...:account=...` — no `:namespace=...`), with selector + `rds-postgres-db`. If `rds-postgres-server` is also installed in this + account, fold both selectors into the **same** provider instead of + registering a second one at the same NRN (see the comment in + `rds-postgres-server`'s `specs/install/aws/main.tf` for why a second, + independent registration breaks `assume_role_step`'s lookup): ```hcl module "identity_access_control" { source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref=" - nrn = "organization=:account=:namespace=" + nrn = "organization=:account=" attributes = { iam_role_arns = { - arns = [{ selector = "rds-postgres-db", arn = "" }] + arns = [ + { selector = "rds-postgres-db", arn = "" }, + # { selector = "rds-postgres-server", arn = "" }, + ] } } } ``` `scripts/aws/assume_role_step` resolves the role by querying -`np provider list` / `np provider read` for this provider at the service's -**namespace NRN** — not by reading `CONTEXT.providers[...]`. This was -confirmed live: this platform's agent never populates `CONTEXT.providers` +`np provider list --categories identity-access-control` with the service's +**full NRN as-is** (not stripped) — not by reading `CONTEXT.providers[...]`. +`--categories` resolves up the NRN hierarchy, so it finds the provider +registered at the account level above regardless of how deep the service's +own NRN is. This was confirmed live: an earlier version of this script +stripped the NRN to namespace level and did a plain `np provider list` +filtered by `stored_keys`, which does **not** inherit providers from +ascendant NRN levels — the account-level provider was never found and the +step silently fell back to agent credentials. Separately, `CONTEXT.providers[...]` +itself was also confirmed live to never populate on this platform version regardless of the `provider_categories` declared in `values.yaml` or the -workflow YAMLs, so the lookup goes through the `np` CLI directly instead. +workflow YAMLs, so the lookup goes through the `np` CLI directly either way. The lookup also passes `--dimensions` (derived from `.service.dimensions` in `CONTEXT`, e.g. `cluster:prod`) so that if more than one -`identity-access-control` provider is ever registered at the same namespace -NRN for different dimensions, `np` resolves the most-specific match instead +`identity-access-control` provider is ever registered at the same NRN +for different dimensions, `np` resolves the most-specific match instead of an arbitrary one being picked client-side. Today the setup above -registers a single, dimension-less provider per namespace, so this is a +registers a single, dimension-less provider per account, so this is a no-op — it only matters if per-dimension AssumeRole roles are introduced later. diff --git a/rds-postgres-db/scripts/aws/assume_role_step b/rds-postgres-db/scripts/aws/assume_role_step index 0ad4f80..16e4eb5 100755 --- a/rds-postgres-db/scripts/aws/assume_role_step +++ b/rds-postgres-db/scripts/aws/assume_role_step @@ -7,9 +7,10 @@ # output:environment so the engine propagates them to the following steps. # # The AWS IAM provider (type "aws-iam-configuration", stored key -# "iam_role_arns.arns") is looked up directly via the np CLI, at the service's -# namespace NRN, the same way build_context already looks up the -# account.region/vpc.id providers. CONTEXT.providers[...] is NOT used here: +# "iam_role_arns.arns") is looked up directly via the np CLI, using the +# service's full NRN (resolution walks up the hierarchy to the account level, +# where this provider is actually registered — see below). CONTEXT.providers[...] +# is NOT used here: # a live end-to-end test against a real agent confirmed it is never populated # by this platform version regardless of provider_categories declarations # (values.yaml and/or the workflow) — provider_categories is still declared @@ -17,10 +18,10 @@ # # The lookup also passes --dimensions (from .service.dimensions in CONTEXT, # e.g. cluster:prod) so that if more than one identity-access-control provider -# is ever registered at the same namespace NRN for different dimensions, np +# is ever registered at the same NRN for different dimensions, np # resolves the most-specific match instead of us picking an arbitrary one # client-side. Today only one such provider is documented/registered per -# namespace (no dimensions of its own), so this is a no-op in practice. +# account (no dimensions of its own), so this is a no-op in practice. # # Resolution precedence (see resolve_assume_role_arn in assume_role_lib): # $RDS_POSTGRES_DB_ASSUME_ROLE_ARN -> IAM provider by selector diff --git a/rds-postgres-db/specs/install/README.md b/rds-postgres-db/specs/install/README.md index 8f08241..67ec62a 100644 --- a/rds-postgres-db/specs/install/README.md +++ b/rds-postgres-db/specs/install/README.md @@ -10,6 +10,16 @@ AssumeRole IAM role/policies the *agent* needs to operate the service — see that module's README and the "AssumeRole Setup Guide" in the top-level [`README.md`](../../README.md) for that half of the setup. +Unlike `rds-postgres-server`'s install, this one does **not** register the +`aws-iam-configuration` provider (the AssumeRole target). That provider is +account-scoped and only additive-by-recreation — a second, independent +registration at the same account NRN would make +`scripts/aws/assume_role_step`'s lookup nondeterministic between the two. +Instead, pass this service's permissions role ARN (the `permissions_role_arn` +output of `../requirements/aws`) as `rds_postgres_db_role_arn` to +[`rds-postgres-server`'s install](../../rds-postgres-server/specs/install/README.md), +which folds both selectors into a single provider. + ## Layout ``` @@ -25,7 +35,7 @@ install/ ## Using the example ```bash -cp -r databases/rds-postgres-db/specs/install/aws /path/to/your/infra/rds-postgres-db +cp -r rds-postgres-db/specs/install/aws /path/to/your/infra/rds-postgres-db cd /path/to/your/infra/rds-postgres-db cp terraform.tfvars.example terraform.tfvars $EDITOR terraform.tfvars diff --git a/rds-postgres-db/specs/install/aws/main.tf b/rds-postgres-db/specs/install/aws/main.tf index 8463caf..dfcb38d 100644 --- a/rds-postgres-db/specs/install/aws/main.tf +++ b/rds-postgres-db/specs/install/aws/main.tf @@ -9,7 +9,7 @@ ################################################################################ locals { - service_path = "databases/rds-postgres-db" + service_path = "rds-postgres-db" available_links = ["connect"] available_actions = [] } diff --git a/rds-postgres-db/specs/install/aws/terraform.tfvars.example b/rds-postgres-db/specs/install/aws/terraform.tfvars.example index ce07569..b60961a 100644 --- a/rds-postgres-db/specs/install/aws/terraform.tfvars.example +++ b/rds-postgres-db/specs/install/aws/terraform.tfvars.example @@ -6,5 +6,5 @@ tags_selectors = { } # repository_org = "nullplatform" -# repository_name = "services" +# repository_name = "services-postgresql-rds" # repository_branch = "main" diff --git a/rds-postgres-db/specs/install/aws/variables.tf b/rds-postgres-db/specs/install/aws/variables.tf index 88dc24f..86057ff 100644 --- a/rds-postgres-db/specs/install/aws/variables.tf +++ b/rds-postgres-db/specs/install/aws/variables.tf @@ -29,7 +29,7 @@ variable "repository_org" { variable "repository_name" { description = "Repository name containing the rds-postgres-db service spec templates." type = string - default = "services" + default = "services-postgresql-rds" } variable "repository_branch" { @@ -39,7 +39,7 @@ variable "repository_branch" { } variable "repository_token" { - description = "Access token for private repositories. Unnecessary for the public nullplatform/services repository." + description = "Access token for private repositories. Unnecessary for a public repository." type = string default = null sensitive = true diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md index 42668de..a2124c7 100644 --- a/rds-postgres-server/README.md +++ b/rds-postgres-server/README.md @@ -101,7 +101,7 @@ Exposed in the nullplatform UI when creating or updating the service: - **`aws-configuration`** (from `tofu-modules//nullplatform/cloud/aws/cloud`) — exposes `account.region`. `build_context` resolves this via `np provider list --nrn ` filtered by `stored_keys` containing `account.region`. - **`aws-networking-configuration`** (from `tofu-modules//nullplatform/cloud/aws/vpc`) — exposes `vpc.id`, `vpc.subnets`, `vpc.security_groups`. Same lookup mechanism, filtered by `vpc.id`. - The VPC must have private subnets tagged with `nullplatform/subnet-type=private`. -- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **namespace-level NRN**. +- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **account-level NRN** (or any ascendant of the service's NRN — resolution walks up the hierarchy). Example registering the `aws-configuration` and `aws-networking-configuration` providers (typically applied once per cluster/account, at the account-level @@ -164,7 +164,7 @@ Three separate pieces must all be in place for the agent to actually assume `agent_role_arn`) — creates the role and its trust policy (see above): ```hcl module "service_requirements_rds_postgres_server" { - source = "git::https://github.com/nullplatform/services.git//databases/rds-postgres-server/specs/requirements/aws?ref=" + source = "git::https://github.com/nullplatform/services-postgresql-rds.git//rds-postgres-server/specs/requirements/aws?ref=" cluster_name = "" # agent_role_arn = "" # optional override; defaults to @@ -184,13 +184,14 @@ Three separate pieces must all be in place for the agent to actually assume } ``` 3. **Register the role as an `identity-access-control` provider** in - nullplatform, at the **namespace-level NRN** - (`organization=...:account=...:namespace=...` — without `:application=...`), + nullplatform, at the **account-level NRN** + (`organization=...:account=...` — no `:namespace=...`, matching where + `aws-configuration`/`aws-networking-configuration` are registered above), with selector `rds-postgres-server`: ```hcl module "identity_access_control" { source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref=" - nrn = "organization=:account=:namespace=" + nrn = "organization=:account=" attributes = { iam_role_arns = { arns = [{ selector = "rds-postgres-server", arn = "" }] @@ -200,20 +201,25 @@ Three separate pieces must all be in place for the agent to actually assume ``` `scripts/aws/assume_role_step` resolves the role by querying -`np provider list` / `np provider read` for this provider at the service's -**namespace NRN** — not by reading `CONTEXT.providers[...]`. This was -confirmed live: this platform's agent never populates `CONTEXT.providers` +`np provider list --categories identity-access-control` with the service's +**full NRN as-is** (not stripped) — not by reading `CONTEXT.providers[...]`. +`--categories` resolves up the NRN hierarchy, so it finds the provider +registered at the account level above regardless of how deep the service's +own NRN is. This was confirmed live: an earlier version of this script +stripped the NRN to namespace level and did a plain `np provider list` +filtered by `stored_keys`, which does **not** inherit providers from +ascendant NRN levels — the account-level provider was never found and the +step silently fell back to agent credentials. Separately, `CONTEXT.providers[...]` +itself was also confirmed live to never populate on this platform version regardless of the `provider_categories` declared in `values.yaml` or the -workflow YAMLs, so the lookup goes through the `np` CLI directly instead -(the same mechanism `build_context` already uses for the region/VPC -providers above). +workflow YAMLs, so the lookup goes through the `np` CLI directly either way. The lookup also passes `--dimensions` (derived from `.service.dimensions` in `CONTEXT`, e.g. `cluster:prod`) so that if more than one -`identity-access-control` provider is ever registered at the same namespace -NRN for different dimensions, `np` resolves the most-specific match instead +`identity-access-control` provider is ever registered at the same NRN +for different dimensions, `np` resolves the most-specific match instead of an arbitrary one being picked client-side. Today the setup above -registers a single, dimension-less provider per namespace, so this is a +registers a single, dimension-less provider per account, so this is a no-op — it only matters if per-dimension AssumeRole roles are introduced later. diff --git a/rds-postgres-server/scripts/aws/assume_role_step b/rds-postgres-server/scripts/aws/assume_role_step index 89a63d5..43f710f 100755 --- a/rds-postgres-server/scripts/aws/assume_role_step +++ b/rds-postgres-server/scripts/aws/assume_role_step @@ -7,9 +7,10 @@ # output:environment so the engine propagates them to the following steps. # # The AWS IAM provider (type "aws-iam-configuration", stored key -# "iam_role_arns.arns") is looked up directly via the np CLI, at the service's -# namespace NRN, the same way build_context already looks up the -# account.region/vpc.id providers. CONTEXT.providers[...] is NOT used here: +# "iam_role_arns.arns") is looked up directly via the np CLI, using the +# service's full NRN (resolution walks up the hierarchy to the account level, +# where this provider is actually registered — see below). CONTEXT.providers[...] +# is NOT used here: # a live end-to-end test against a real agent confirmed it is never populated # by this platform version regardless of provider_categories declarations # (values.yaml and/or the workflow) — provider_categories is still declared @@ -17,10 +18,10 @@ # # The lookup also passes --dimensions (from .service.dimensions in CONTEXT, # e.g. cluster:prod) so that if more than one identity-access-control provider -# is ever registered at the same namespace NRN for different dimensions, np +# is ever registered at the same NRN for different dimensions, np # resolves the most-specific match instead of us picking an arbitrary one # client-side. Today only one such provider is documented/registered per -# namespace (no dimensions of its own), so this is a no-op in practice. +# account (no dimensions of its own), so this is a no-op in practice. # # Resolution precedence (see resolve_assume_role_arn in assume_role_lib): # $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN -> IAM provider by selector diff --git a/rds-postgres-server/specs/install/README.md b/rds-postgres-server/specs/install/README.md index 1bdbad9..76be2be 100644 --- a/rds-postgres-server/specs/install/README.md +++ b/rds-postgres-server/specs/install/README.md @@ -1,14 +1,29 @@ # Install — registering the rds-postgres-server service This directory holds the reference OpenTofu/Terraform used to **install** -rds-postgres-server on a nullplatform account: registering its service -specification, link specification, and agent association (notification -channel) so `np service create` starts routing actions to an agent. +rds-postgres-server on a nullplatform account: + +- Registers its service specification, link specification, and agent + association (notification channel) so `np service create` starts routing + actions to an agent. +- Optionally registers the `aws-configuration` / `aws-networking-configuration` + providers (account.region, vpc.id) that `build_context` needs — toggle with + `create_account_providers`, off if another stack already registers them. +- Optionally registers the `aws-iam-configuration` provider (the AssumeRole + target for `scripts/aws/assume_role_step`) — toggle with + `create_identity_access_control`. **This is the only place that should + create it for the account.** If `rds-postgres-db` is also installed, + pass its permissions role ARN via `rds_postgres_db_role_arn` so both + selectors land in the same provider — see the comment at the top of + `aws/main.tf` for why registering it a second time (from rds-postgres-db's + own install) would break `assume_role_step`'s lookup. This is separate from `../requirements/aws`, which provisions the AWS -AssumeRole IAM role/policies the *agent* needs to operate the service — see -that module's README and the "AssumeRole Setup Guide" in the top-level -[`README.md`](../../README.md) for that half of the setup. +AssumeRole IAM role/policies the *agent* needs to operate the service — the +`rds_postgres_server_role_arn` / `rds_postgres_db_role_arn` variables here +are that module's `permissions_role_arn` output. See that module's README +and the "AssumeRole Setup Guide" in the top-level [`README.md`](../../README.md) +for the full picture. ## Layout @@ -25,7 +40,7 @@ install/ ## Using the example ```bash -cp -r databases/rds-postgres-server/specs/install/aws /path/to/your/infra/rds-postgres-server +cp -r rds-postgres-server/specs/install/aws /path/to/your/infra/rds-postgres-server cd /path/to/your/infra/rds-postgres-server cp terraform.tfvars.example terraform.tfvars $EDITOR terraform.tfvars diff --git a/rds-postgres-server/specs/install/aws/.terraform.lock.hcl b/rds-postgres-server/specs/install/aws/.terraform.lock.hcl index 95ac674..266f917 100644 --- a/rds-postgres-server/specs/install/aws/.terraform.lock.hcl +++ b/rds-postgres-server/specs/install/aws/.terraform.lock.hcl @@ -1,6 +1,28 @@ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.53.0" + hashes = [ + "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=", + "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c", + "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8", + "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69", + "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87", + "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f", + "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc", + "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760", + "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147", + "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b", + "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b", + "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be", + "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46", + "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138", + "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc", + "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6", + ] +} + provider "registry.opentofu.org/hashicorp/http" { version = "3.6.0" constraints = "~> 3.0" @@ -26,7 +48,7 @@ provider "registry.opentofu.org/hashicorp/http" { provider "registry.opentofu.org/nullplatform/nullplatform" { version = "0.0.96" - constraints = ">= 0.0.86" + constraints = ">= 0.0.86, ~> 0.0.86" hashes = [ "h1:3qYPvX16dlnA2Bh2Z5xBQDeWWkKLikWsPGf5wr28MWQ=", "zh:0024f491c4d8eabb19f58e05cf3217bbbdac73469c3cb8dee8e2646d1636e960", diff --git a/rds-postgres-server/specs/install/aws/main.tf b/rds-postgres-server/specs/install/aws/main.tf index 9d3c48a..2e073b3 100644 --- a/rds-postgres-server/specs/install/aws/main.tf +++ b/rds-postgres-server/specs/install/aws/main.tf @@ -1,17 +1,46 @@ ################################################################################ # Install — registers the rds-postgres-server service definition and its -# agent association (notification channel) on a nullplatform account. +# agent association (notification channel) on a nullplatform account, and +# (optionally) the account-level providers the "nullplatform Prerequisites" +# section of ../../../README.md documents as required: # -# This is the platform-registration half of adopting the service; the -# AWS AssumeRole IAM role/policies live in ../../requirements/aws and are -# applied separately (see that module's README and the top-level -# "AssumeRole Setup Guide" in ../../../README.md). +# - aws-configuration / aws-networking-configuration (account-level NRN): +# build_context resolves account.region and vpc.id from these. Toggle +# with create_account_providers — skip if another stack in this account +# already registers them (they are shared, not specific to this service). +# - aws-iam-configuration (account-level NRN): the AssumeRole target for +# scripts/aws/assume_role_step, which resolves it via `np provider list +# --categories identity-access-control` using the service's full NRN — +# that call walks UP the NRN hierarchy, so registering this provider at +# the account level (rather than namespace/application) is what makes it +# resolve for every service/namespace under the account. This module is +# the ONLY place that should create it for the account — the underlying +# nullplatform_provider_config resource is a plain create with no +# merge/upsert semantics, so a second, independent identity_access_control +# resource (e.g. one created by rds-postgres-db's own install) would +# register a SECOND aws-iam-configuration provider at the same NRN, and +# assume_role_step's lookup would then nondeterministically pick one of +# the two. rds-postgres-db does NOT create its own — pass its role ARN +# via rds_postgres_db_role_arn to fold its selector into this single +# provider instead. +# +# The AWS AssumeRole IAM role/policies themselves (the permissions role this +# provider points at) live in ../../requirements/aws and are applied +# separately — see that module's README and the "AssumeRole Setup Guide" in +# ../../../README.md. ################################################################################ locals { - service_path = "databases/rds-postgres-server" + service_path = "rds-postgres-server" available_links = ["connect"] available_actions = [] + + account_nrn = replace(var.nrn, "/:namespace=[^:]*/", "") + + iam_role_arns = concat( + [{ selector = "rds-postgres-server", arn = var.rds_postgres_server_role_arn }], + var.rds_postgres_db_role_arn != "" ? [{ selector = "rds-postgres-db", arn = var.rds_postgres_db_role_arn }] : [] + ) } module "service_definition" { @@ -38,3 +67,48 @@ module "service_definition_agent_association" { api_key = var.np_api_key tags_selectors = var.tags_selectors } + +# --- account-level providers: account.region + vpc.id ------------------------ +# Registered at the account NRN (var.nrn with :namespace=... stripped), same +# as the manual examples in ../../../README.md. Typically applied once per +# cluster/account — set create_account_providers = false if another stack +# already registers these (e.g. shared with non-RDS services). + +module "aws_cloud_provider" { + count = var.create_account_providers ? 1 : 0 + + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/cloud?ref=v5.3.1" + + nrn = local.account_nrn + domain_name = var.domain_name + hosted_private_zone_id = var.hosted_private_zone_id +} + +module "vpc_provider" { + count = var.create_account_providers ? 1 : 0 + + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/vpc?ref=v5.3.1" + + nrn = local.account_nrn + vpc_id = var.vpc_id + vpc_subnets = var.vpc_subnets + vpc_security_groups = var.vpc_security_groups +} + +# --- account-level provider: AssumeRole target ------------------------------- +# See the comment at the top of this file — this is the single source of the +# aws-iam-configuration provider for the account; fold rds-postgres-db's role +# in via rds_postgres_db_role_arn instead of registering it separately. + +module "identity_access_control" { + count = var.create_identity_access_control ? 1 : 0 + + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref=v5.3.1" + + nrn = local.account_nrn + attributes = { + iam_role_arns = { + arns = local.iam_role_arns + } + } +} diff --git a/rds-postgres-server/specs/install/aws/terraform.tfvars.example b/rds-postgres-server/specs/install/aws/terraform.tfvars.example index ce07569..c12526b 100644 --- a/rds-postgres-server/specs/install/aws/terraform.tfvars.example +++ b/rds-postgres-server/specs/install/aws/terraform.tfvars.example @@ -6,5 +6,24 @@ tags_selectors = { } # repository_org = "nullplatform" -# repository_name = "services" +# repository_name = "services-postgresql-rds" # repository_branch = "main" + +# --- account-level providers (account.region + vpc.id) ----------------------- +# Set create_account_providers = false if another stack in this account +# already registers aws-configuration/aws-networking-configuration. +create_account_providers = true +domain_name = "" +hosted_private_zone_id = "" +vpc_id = "" +vpc_subnets = ["", ""] +vpc_security_groups = [""] + +# --- account-level provider (AssumeRole target) ------------------------------- +# rds_postgres_server_role_arn: output of ../../requirements/aws (apply that +# module first). rds_postgres_db_role_arn: only set if rds-postgres-db is also +# installed in this account — leave "" otherwise. See main.tf for why +# rds-postgres-db should NOT register its own aws-iam-configuration provider. +create_identity_access_control = true +rds_postgres_server_role_arn = "" +# rds_postgres_db_role_arn = "" diff --git a/rds-postgres-server/specs/install/aws/variables.tf b/rds-postgres-server/specs/install/aws/variables.tf index 5853621..982270f 100644 --- a/rds-postgres-server/specs/install/aws/variables.tf +++ b/rds-postgres-server/specs/install/aws/variables.tf @@ -29,7 +29,7 @@ variable "repository_org" { variable "repository_name" { description = "Repository name containing the rds-postgres-server service spec templates." type = string - default = "services" + default = "services-postgresql-rds" } variable "repository_branch" { @@ -39,8 +39,66 @@ variable "repository_branch" { } variable "repository_token" { - description = "Access token for private repositories. Unnecessary for the public nullplatform/services repository." + description = "Access token for private repositories. Unnecessary for a public repository." type = string default = null sensitive = true } + +# --- account-level providers (account.region + vpc.id) ----------------------- + +variable "create_account_providers" { + description = "Whether to register the aws-configuration/aws-networking-configuration providers at the account-level NRN. Set to false if another stack in this account already registers them (they are shared account-wide, not specific to rds-postgres-server)." + type = bool + default = true +} + +variable "domain_name" { + description = "Domain name for the aws-configuration provider. Required when create_account_providers is true." + type = string + default = "" +} + +variable "hosted_private_zone_id" { + description = "Private Route53 hosted zone ID for the aws-configuration provider. Required when create_account_providers is true." + type = string + default = "" +} + +variable "vpc_id" { + description = "VPC ID for the aws-networking-configuration provider. Required when create_account_providers is true." + type = string + default = "" +} + +variable "vpc_subnets" { + description = "Subnet IDs for the aws-networking-configuration provider. Pass whatever the cluster's VPC provider already uses for other scopes/services — this service only reads vpc.id from it, not this list. Required when create_account_providers is true." + type = list(string) + default = [] +} + +variable "vpc_security_groups" { + description = "Security group IDs for the aws-networking-configuration provider (e.g. the node/cluster security group). Required when create_account_providers is true." + type = list(string) + default = [] +} + +# --- account-level provider (AssumeRole target) ------------------------------- + +variable "create_identity_access_control" { + description = "Whether to register the aws-iam-configuration provider at the account-level NRN (derived from var.nrn). This is the only place that should create it for the account — see the comment in main.tf. Set to false if it is already managed elsewhere." + type = bool + default = true +} + +variable "rds_postgres_server_role_arn" { + description = "ARN of the rds-postgres-server permissions role (the permissions_role_arn output of ../../requirements/aws). Required when create_identity_access_control is true." + type = string + default = "" +} + +variable "rds_postgres_db_role_arn" { + description = "ARN of the rds-postgres-db permissions role (the permissions_role_arn output of the rds-postgres-db requirements/aws module), if that service is also installed in this account. When set, its selector is folded into this same aws-iam-configuration provider instead of rds-postgres-db registering its own — see the comment in main.tf for why." + type = string + default = "" +} From 9ac0562dc0a8b0ad2a824a88df8770014157ddef Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Tue, 7 Jul 2026 10:07:55 -0300 Subject: [PATCH 34/34] ci: adopt the standard nullplatform-actions CI stack, matching services-s-3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ad-hoc shellcheck+tofu-fmt workflow carried over from the services monorepo with the same reusable-workflow stack services-s-3 uses: branch-validation, conventional-commit, release (release-please), shellcheck, and trivy IaC scanning — all delegating to nullplatform/actions-nullplatform. Add .trivyignore suppressing AVD-AWS-0104 (unrestricted RDS security-group egress) on both services, mirroring services-s-3's own documented-suppression pattern: standard for an RDS instance's own SG, inbound is already restricted to 5432 from the VPC's CIDRs. Verified locally with trivy 0.68.2 (0 misconfigurations after the suppression) and shellcheck 0.11.0 (clean) using the same discovery logic as the reusable workflows. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/branch-validation.yml | 10 +++++++ .github/workflows/conventional-commit.yml | 10 +++++++ .github/workflows/pr-checks.yml | 34 ----------------------- .github/workflows/release.yml | 15 ++++++++++ .github/workflows/shellcheck.yml | 10 +++++++ .github/workflows/trivy.yml | 14 ++++++++++ .trivyignore | 9 ++++++ 7 files changed, 68 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/branch-validation.yml create mode 100644 .github/workflows/conventional-commit.yml delete mode 100644 .github/workflows/pr-checks.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/shellcheck.yml create mode 100644 .github/workflows/trivy.yml create mode 100644 .trivyignore diff --git a/.github/workflows/branch-validation.yml b/.github/workflows/branch-validation.yml new file mode 100644 index 0000000..6d75b77 --- /dev/null +++ b/.github/workflows/branch-validation.yml @@ -0,0 +1,10 @@ +name: branch-validation + +on: + pull_request: + branches: + - main + +jobs: + branch-validation: + uses: nullplatform/actions-nullplatform/.github/workflows/branch-validation.yml@main diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml new file mode 100644 index 0000000..92952e1 --- /dev/null +++ b/.github/workflows/conventional-commit.yml @@ -0,0 +1,10 @@ +name: conventional-commit + +on: + pull_request: + branches: + - main + +jobs: + conventional-commit: + uses: nullplatform/actions-nullplatform/.github/workflows/conventional-commit.yml@main diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml deleted file mode 100644 index d1a1264..0000000 --- a/.github/workflows/pr-checks.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: PR Checks - -on: - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Run ShellCheck - run: | - scripts=$(grep -rlE '^#!.*\b(bash|sh)\b' --exclude-dir=.git .) - if [ -n "$scripts" ]; then - echo "$scripts" | xargs shellcheck --severity=error - else - echo "No shell scripts found" - fi - - terraform-fmt: - name: Terraform Format - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: opentofu/setup-opentofu@v1 - with: - tofu_version: v1.10.5 - - name: Check formatting - run: tofu fmt -check -recursive diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8a65e73 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,15 @@ +name: release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release: + uses: nullplatform/actions-nullplatform/.github/workflows/release.yml@main + secrets: inherit diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..abf7bd9 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,10 @@ +name: shellcheck + +on: + pull_request: + branches: + - main + +jobs: + shellcheck: + uses: nullplatform/actions-nullplatform/.github/workflows/shellcheck.yml@main diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 0000000..1a3c210 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,14 @@ +name: trivy + +on: + pull_request: + branches: + - main + +permissions: + contents: read + security-events: write + +jobs: + trivy: + uses: nullplatform/actions-nullplatform/.github/workflows/trivy-tofu-scan.yml@main diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..5f8c40e --- /dev/null +++ b/.trivyignore @@ -0,0 +1,9 @@ +# Trivy misconfiguration suppressions for this repo. +# Each entry is intentional and documented; revisit if the module changes. + +# AVD-AWS-0104 — RDS security group allows unrestricted egress (0.0.0.0/0). +# Standard for an RDS instance's own security group (matches the AWS default +# VPC SG behavior): the instance needs outbound access for things like CA +# bundle/extension fetches and Secrets Manager rotation. Inbound is already +# restricted to PostgreSQL (5432) from the VPC's own CIDR blocks. +AVD-AWS-0104