diff --git a/intents/openbao/facets.yaml b/intents/openbao/facets.yaml new file mode 100644 index 00000000..eb5a5290 --- /dev/null +++ b/intents/openbao/facets.yaml @@ -0,0 +1,5 @@ +name: openbao +type: Secrets +displayName: OpenBao +description: OpenBao is an open-source fork of HashiCorp Vault providing secure secrets management and data protection for cloud-native applications +iconUrl: https://openbao.org/assets/img/logo.svg diff --git a/modules/openbao/default/1.0/README.md b/modules/openbao/default/1.0/README.md new file mode 100644 index 00000000..1f43cc95 --- /dev/null +++ b/modules/openbao/default/1.0/README.md @@ -0,0 +1,381 @@ +# OpenBao Cluster with Static Seal Auto-Unseal + +This Facets module deploys OpenBao with automatic unsealing using a static seal mechanism. The module uses Helm to deploy OpenBao and a Kubernetes Job to handle initialization, Raft cluster setup, and policy configuration. + +## Features + +- **Static Seal Auto-Unseal**: Pods automatically unseal on restart without manual intervention +- **Helm-based Deployment**: Uses the official OpenBao Helm chart +- **Kubernetes Job Initialization**: Automated initialization and cluster setup +- **High Availability**: Raft consensus with configurable replicas +- **Kubernetes Auth Integration**: Pre-configured Kubernetes authentication backend +- **Custom Policy Support**: Define policies, roles, and service account bindings +- **KV v2 Secrets Engine**: Pre-enabled at `cp-secrets/` path +- **Resource Management**: Configurable CPU/memory requests and limits +- **Persistent Storage**: Automatic PVC creation for each replica + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Random Key │───▶│ K8s Secret │───▶│ Helm Release │ +│ Generation │ │ (Unseal Key) │ │ (OpenBao Pods) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────┐ + │ Kubernetes Init Job │ + │ 1. Initialize leader (openbao-0) │ + │ 2. Store root token & recovery keys │ + │ 3. Join Raft nodes (HA mode) │ + │ 4. Configure Kubernetes auth │ + │ 5. Enable KV v2 secrets engine │ + │ 6. Create custom policies & roles │ + └─────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────┐ + │ Auto-Unsealed OpenBao Cluster │ + │ - Static seal with env var key │ + │ - Pods auto-unseal on restart │ + │ - Raft HA (if replicas > 1) │ + │ - Ready for secret management │ + └─────────────────────────────────────────┘ +``` + +## How Static Seal Works + +Unlike traditional Shamir seal (which requires manual unseal keys), static seal: +- Uses a pre-generated encryption key stored in a Kubernetes secret +- Injects the key into pods via environment variable (`OPENBAO_UNSEAL_KEY`) +- OpenBao automatically unseals on startup using the static key +- Pods can restart without manual intervention +- Recovery keys (not unseal keys) are generated during initialization for emergency access + +## Environment as Dimension + +The module is environment-aware through `var.environment`: +- **Namespace**: Deployed in the namespace specified in metadata +- **Tolerations**: Uses `var.environment.default_tolerations` for spot instance support +- **Tags**: Applies `var.environment.cloud_tags` to all resources +- **Storage**: PVCs are created per-environment, allowing different storage configurations + +## Resources Created + +- **Helm Release**: OpenBao cluster with static seal configuration +- **Random ID**: 32-byte encryption key for static seal +- **Kubernetes Secret**: Stores the static unseal key +- **PersistentVolumeClaims**: One per replica for data storage +- **ServiceAccount + RBAC**: For the initialization job +- **Kubernetes Job**: Handles initialization and cluster setup +- **Secret (created by job)**: Stores root token and recovery keys + +## Usage + +### Basic Standalone Deployment + +```yaml +kind: openbao-cluster +flavor: default +version: "1.0" +spec: + namespace: "openbao" + release_name: "openbao" + storage_type: "file" + server_replicas: 1 +``` + +### High Availability Deployment + +```yaml +kind: openbao-cluster +flavor: default +version: "1.0" +spec: + namespace: "openbao-prod" + release_name: "openbao-ha" + storage_type: "raft" + server_replicas: 3 + storage_size: "20Gi" + + server_resources: + requests: + cpu: "1000m" + memory: "512Mi" + limits: + cpu: "2000m" + memory: "1Gi" +``` + +### With Custom Policies + +```yaml +kind: openbao-cluster +flavor: default +version: "1.0" +spec: + namespace: "openbao" + release_name: "openbao" + storage_type: "raft" + server_replicas: 3 + + openbao: + policies: + app-readonly: + service_account_name: "my-app-sa" + role_name: "app-role" + policy: | + # Read-only access to app secrets + path "cp-secrets/data/app/*" { + capabilities = ["read", "list"] + } + + admin-full: + service_account_name: "admin-sa" + role_name: "admin-role" + policy: | + # Full access to all secrets + path "cp-secrets/*" { + capabilities = ["create", "read", "update", "delete", "list"] + } +``` + +## Configuration Reference + +### Required Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `namespace` | string | Kubernetes namespace for deployment | +| `release_name` | string | Helm release name | +| `storage_type` | string | Storage backend: `raft` (HA) or `file` (standalone) | + +### Optional Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `chart_version` | string | "0.18.4" | OpenBao Helm chart version | +| `server_replicas` | integer | 1 | Number of OpenBao replicas (1-10) | +| `server_resources` | object | See defaults | CPU/memory requests and limits | +| `ui_enabled` | boolean | true | Enable OpenBao web UI | +| `storage_size` | string | "10Gi" | PVC size per replica | +| `pvc_labels` | object | {} | Additional labels for PVCs | +| `unseal_secret_name` | string | `{instance_name}-unseal-key` | Name of unseal key secret | +| `openbao.policies` | object | {} | Custom policies and roles | +| `openbao.values` | object | {} | Additional Helm values | + +### Policy Configuration + +Define custom policies with Kubernetes auth integration: + +```yaml +openbao: + policies: + {policy-name}: + service_account_name: "k8s-service-account" + role_name: "openbao-auth-role" + policy: | + path "cp-secrets/data/my-app/*" { + capabilities = ["read"] + } +``` + +Each policy creates: +1. An OpenBao policy with the specified HCL +2. A Kubernetes auth role bound to the service account +3. Automatic binding allowing the SA to authenticate and assume the policy + +## Outputs + +| Output | Description | +|--------|-------------| +| `namespace` | Deployment namespace | +| `release_name` | Helm release name | +| `service_name` | Kubernetes service name | +| `service_url` | Internal service URL | +| `ui_enabled` | Whether UI is enabled | +| `ui_url` | UI access URL (if enabled) | +| `health_check_url` | Health check endpoint | +| `unseal_secret_name` | Name of unseal key secret | +| `init_keys_secret_name` | Name of init keys secret (root token & recovery keys) | +| `storage_type` | Storage backend type | +| `server_replicas` | Number of replicas | +| `root_token` | Root token (sensitive) | +| `recovery_keys` | Recovery keys (sensitive) | + +## Security Considerations + +### Static Unseal Key + +- The 32-byte unseal key is generated using Terraform's `random_id` resource +- Stored in a Kubernetes secret with `prevent_destroy = true` lifecycle +- Injected into pods via environment variable +- Key is never exposed in logs or Terraform state (base64 encoded) +- **Important**: Backup this secret - losing it means losing access to sealed data + +### Root Token & Recovery Keys + +- Root token is stored in `{release_name}-init-keys` secret +- Recovery keys are for emergency access if static seal fails +- Rotate the root token regularly using OpenBao's token rotation +- Limit RBAC access to these secrets + +### Network Security + +- Service uses ClusterIP type (internal only) +- TLS is disabled by default (enable for production) +- Use Kubernetes network policies to restrict access +- Consider using a service mesh for mTLS + +### RBAC + +- Init job uses a dedicated ServiceAccount with minimal permissions +- Only has access to: pods, pods/exec, secrets, statefulsets +- Permissions scoped to the deployment namespace only + +## Operations + +### Accessing OpenBao + +```bash +# Get the root token +kubectl get secret openbao-init-keys -n openbao -o jsonpath='{.data.root-token}' | base64 -d + +# Port forward to access UI +kubectl port-forward svc/openbao -n openbao 8200:8200 + +# Access via browser +open http://localhost:8200/ui +``` + +### Using Kubernetes Auth (from a Pod) + +```bash +# Inside a pod with the configured service account +export VAULT_ADDR="http://openbao.openbao.svc.cluster.local:8200" +export SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + +# Login +vault write auth/kubernetes/login role=app-role jwt=$SA_TOKEN + +# Use the token to access secrets +vault kv get cp-secrets/my-app/config +``` + +### Scaling the Cluster + +To add more replicas: +1. Update `server_replicas` in your configuration +2. Apply the changes +3. The init job will automatically join new nodes to the Raft cluster + +### Backup and Recovery + +```bash +# Backup critical secrets +kubectl get secret openbao-unseal-key -n openbao -o yaml > unseal-key-backup.yaml +kubectl get secret openbao-init-keys -n openbao -o yaml > init-keys-backup.yaml + +# Take Raft snapshots (using root token) +export VAULT_TOKEN="" +vault operator raft snapshot save backup.snap +``` + +## Troubleshooting + +### Init Job Fails + +```bash +# Check job logs +kubectl logs -n openbao job/openbao-init-xxxxx + +# Common issues: +# - Pod not ready: Increase timeout or check pod status +# - RBAC issues: Verify ServiceAccount permissions +# - Network issues: Check service connectivity +``` + +### Pods Not Auto-Unsealing + +```bash +# Check if unseal key secret exists +kubectl get secret openbao-unseal-key -n openbao + +# Verify environment variable is injected +kubectl exec -n openbao openbao-0 -- env | grep OPENBAO_UNSEAL_KEY + +# Check pod logs for seal errors +kubectl logs -n openbao openbao-0 +``` + +### Raft Node Not Joining + +```bash +# Check node status +kubectl exec -n openbao openbao-1 -- bao status + +# Manually join if needed (using root token) +kubectl exec -n openbao openbao-1 -- bao operator raft join \ + http://openbao-0.openbao-internal.openbao.svc.cluster.local:8200 +``` + +### Check Cluster Status + +```bash +# View Raft peers +export VAULT_TOKEN="" +vault operator raft list-peers + +# Check seal status +vault status +``` + +## Prerequisites + +- Kubernetes cluster (1.19+) +- Helm 3.x +- Persistent volume provisioner (for PVCs) +- RBAC enabled +- Kubernetes provider configured via inputs + +## Limitations + +- Only supports Azure cloud (as configured) +- TLS is not enabled by default +- Static seal key rotation requires manual process +- Single Kubernetes cluster deployments only +- Raft storage requires persistent volumes + +## Advanced Configuration + +### Custom Helm Values + +Override any Helm chart value: + +```yaml +openbao: + values: + server: + extraEnvironmentVars: + FOO: "bar" + annotations: + custom-annotation: "value" +``` + +### Storage Backend Selection + +**File Storage** (Standalone): +- Single replica only +- Data stored in PVC +- No HA capabilities +- Simpler setup + +**Raft Storage** (HA): +- Multiple replicas supported +- Distributed consensus +- Automatic failover +- Requires internal service for inter-pod communication + +## License + +This module is part of the Facets platform and follows the same licensing terms. diff --git a/modules/openbao/default/1.0/facets.yaml b/modules/openbao/default/1.0/facets.yaml new file mode 100644 index 00000000..60b69ada --- /dev/null +++ b/modules/openbao/default/1.0/facets.yaml @@ -0,0 +1,204 @@ +intent: openbao +flavor: default +version: '1.0' +clouds: +- azure +description: Deploys OpenBao with static seal auto-unseal for Kubernetes +metadata: + title: Metadata of service + type: object + properties: + namespace: + type: string + title: Kubernetes Namespace + description: Kubernetes namespace to deploy OpenBao (will be created if it doesn't + exist) + default: default + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ +spec: + title: Auto-Unsealing OpenBao Cluster + description: Deploys OpenBao with static seal auto-unseal - pods auto-unseal on + restart + type: object + properties: + release_name: + type: string + title: Helm Release Name + description: Helm release name for the OpenBao deployment + default: openbao + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + chart_version: + type: string + title: Helm Chart Version + description: Version of the OpenBao Helm chart to deploy + default: 0.18.4 + server_replicas: + type: integer + title: Server Replicas + description: Number of OpenBao server replicas + default: 1 + minimum: 1 + maximum: 10 + server_resources: + type: object + title: Server Resources + description: CPU and memory resource requests/limits for OpenBao servers + default: + requests: + cpu: 500m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + properties: + requests: + type: object + title: Resource Requests + description: Minimum resources required + default: + cpu: 500m + memory: 256Mi + properties: + cpu: + type: string + title: CPU Request + default: 500m + memory: + type: string + title: Memory Request + default: 256Mi + limits: + type: object + title: Resource Limits + description: Maximum resources allowed + default: + cpu: 1000m + memory: 512Mi + properties: + cpu: + type: string + title: CPU Limit + default: 1000m + memory: + type: string + title: Memory Limit + default: 512Mi + ui_enabled: + type: boolean + title: Enable UI + description: Whether to enable OpenBao web UI + default: true + storage_type: + type: string + title: Storage Backend + description: Storage backend for OpenBao (raft for HA, file for standalone) + default: raft + enum: + - file + - raft + storage_size: + type: string + title: PVC Storage Size + description: Size of persistent volume for each OpenBao server + default: 10Gi + pvc_labels: + type: object + title: PVC Labels + description: Additional labels to apply to PersistentVolumeClaims + default: {} + unseal_secret_name: + type: string + title: Unseal Secret Name + description: Name of the Kubernetes secret storing the unseal key (defaults + to instance name + '-unseal-key' for uniqueness) + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + openbao: + type: object + title: Advanced OpenBao Configuration + description: Advanced configuration options for OpenBao Helm chart + properties: + policies: + type: object + title: OpenBao Policies + description: Define custom policies, roles, and service account bindings + for OpenBao + default: {} + patternProperties: + .*: + type: object + properties: + service_account_name: + type: string + title: Service Account Name + description: Kubernetes service account name to bind to this policy + role_name: + type: string + title: Role Name + description: OpenBao Kubernetes auth role name + policy: + type: string + title: Policy HCL + description: OpenBao policy in HCL format + x-ui-editor: true + x-ui-editor-language: hcl + required: + - service_account_name + - role_name + - policy + values: + type: object + title: Custom Helm Values + description: Additional Helm values to merge with constructed values + default: {} + x-ui-editor: true + required: + - namespace + - release_name + - storage_type +inputs: + kubernetes_cluster: + type: '@outputs/kubernetes' + optional: false + displayName: Kubernetes Cluster + description: Kubernetes cluster to deploy OpenBao + providers: + - helm + - kubernetes +outputs: + default: + type: '@outputs/openbao' + title: OpenBao Cluster + description: OpenBao cluster connection information +iac: + validated_files: + - main.tf + - variables.tf + - outputs.tf +sample: + kind: openbao + flavor: default + version: '1.0' + disabled: true + spec: + namespace: default + release_name: openbao + storage_type: raft + openbao: + policies: + control-plane-rw: + service_account_name: control-plane-service-sa + role_name: control-plane-role + policy: "# Full read-write access to all secrets\npath \"cp-secrets/*\"\ + \ {\n capabilities = [\"create\", \"read\", \"update\", \"delete\", \"\ + list\"]\n}\n\npath \"cp-secrets/data/*\" {\n capabilities = [\"create\"\ + , \"read\", \"update\", \"delete\", \"list\"]\n}\n\npath \"cp-secrets/metadata/*\"\ + \ {\n capabilities = [\"list\", \"read\", \"delete\"]\n}\n\n# System\ + \ health check\npath \"sys/health\" {\n capabilities = [\"read\"]\n}\n" + facets-release-readonly: + service_account_name: facets-release-pod + role_name: facets-release-role + policy: "# Read-only access to all secrets\npath \"cp-secrets/*\" {\n capabilities\ + \ = [\"read\", \"list\"]\n}\n\npath \"cp-secrets/data/*\" {\n capabilities\ + \ = [\"read\", \"list\"]\n}\n\npath \"cp-secrets/metadata/*\" {\n capabilities\ + \ = [\"read\", \"list\"]\n}\n\n# System health check\npath \"sys/health\"\ + \ {\n capabilities = [\"read\"]\n}\n" diff --git a/modules/openbao/default/1.0/main.tf b/modules/openbao/default/1.0/main.tf new file mode 100644 index 00000000..8289fa48 --- /dev/null +++ b/modules/openbao/default/1.0/main.tf @@ -0,0 +1,576 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = "~> 2.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +# Local values for common configurations +locals { + metadata = lookup(var.instance, "metadata", {}) + spec = lookup(var.instance, "spec", {}) + openbao = lookup(local.spec, "openbao", {}) + namespace = lookup(local.metadata, "namespace", "default") + release_name = lookup(local.spec, "release_name", "openbao") + + # Hardcoded chart details + chart_repo = "https://openbao.github.io/openbao-helm" + chart_name = "openbao" + chart_version = lookup(local.spec, "chart_version", "0.18.4") + + # Generate unique resource names - use instance_name to ensure uniqueness across multiple instances + unseal_secret_name = lookup(local.spec, "unseal_secret_name", "${var.instance_name}-unseal-key") + + # Common labels + labels = merge(var.environment.cloud_tags, { + "app.kubernetes.io/name" = "openbao" + "app.kubernetes.io/instance" = var.instance_name + "app.kubernetes.io/managed-by" = "facets" + }) + + # Determine HA mode based on storage type + is_ha_mode = lookup(local.spec, "storage_type", "raft") == "raft" + + # OpenBao configuration with static seal for HA mode + openbao_config_ha = <<-EOF + disable_mlock = true + + ui = ${lookup(local.spec, "ui_enabled", true)} + + seal "static" { + current_key_id = "openbao-unseal-key-v1" + current_key = "env://OPENBAO_UNSEAL_KEY" + } + + listener "tcp" { + tls_disable = true + address = "[::]:8200" + cluster_address = "[::]:8201" + } + + storage "raft" { + path = "/openbao/data" + } + + cluster_addr = "http://$(POD_IP):8201" + api_addr = "http://$(POD_IP):8200" + + log_level = "info" + EOF + + # OpenBao configuration with static seal for standalone mode + openbao_config_standalone = <<-EOF + disable_mlock = true + + ui = ${lookup(local.spec, "ui_enabled", true)} + + seal "static" { + current_key_id = "openbao-unseal-key-v1" + current_key = "env://OPENBAO_UNSEAL_KEY" + } + + listener "tcp" { + tls_disable = true + address = "[::]:8200" + } + + storage "file" { + path = "/openbao/data" + } + + log_level = "info" + EOF + + # Helm values configuration + constructed_helm_values = { + global = { + enabled = true + } + + server = { + enabled = true + + replicas = lookup(local.spec, "server_replicas", 1) + + resources = { + requests = lookup(lookup(local.spec, "server_resources", {}), "requests", {}) + limits = lookup(lookup(local.spec, "server_resources", {}), "limits", {}) + } + + tolerations = lookup(var.environment, "default_tolerations", [{ + key = "kubernetes.azure.com/scalesetpriority" + value = "spot" + operator = "Equal" + effect = "NoSchedule" + }]) + + dataStorage = { + enabled = true + } + + auditStorage = { + enabled = false + } + + extraSecretEnvironmentVars = [ + { + envName = "OPENBAO_UNSEAL_KEY" + secretName = local.unseal_secret_name + secretKey = "unseal-key" + } + ] + + standalone = { + enabled = !local.is_ha_mode + config = local.openbao_config_standalone + } + + ha = { + enabled = local.is_ha_mode + replicas = lookup(local.spec, "server_replicas", 1) + raft = { + enabled = local.is_ha_mode + setNodeId = local.is_ha_mode + config = local.openbao_config_ha + } + } + + service = { + enabled = true + type = "ClusterIP" + port = 8200 + } + + serviceAccount = { + create = true + name = "${local.release_name}-sa" + } + + readinessProbe = { + enabled = true + path = "/v1/sys/health?standbyok=true&sealedcode=204&uninitcode=204" + } + + livenessProbe = { + enabled = true + path = "/v1/sys/health?standbyok=true" + initialDelaySeconds = 60 + } + } + + ui = { + enabled = lookup(local.spec, "ui_enabled", true) + serviceType = "ClusterIP" + } + + injector = { + enabled = false + tolerations = lookup(var.environment, "default_tolerations", [{ + key = "kubernetes.azure.com/scalesetpriority" + value = "spot" + operator = "Equal" + effect = "NoSchedule" + }]) + } + } + # Handle values field - can be either YAML string or object + user_defined_helm_values_raw = lookup(local.openbao, "values", {}) + user_defined_helm_values = try(yamldecode(local.user_defined_helm_values_raw), local.user_defined_helm_values_raw) + + # Get configurable policies from spec + policies = lookup(local.openbao, "policies", {}) +} + +# Generate a random 32-byte key for static seal +resource "random_id" "unseal_key" { + byte_length = 32 + + lifecycle { + ignore_changes = [byte_length] + } +} + +# Create Kubernetes secret to store the unseal key +resource "kubernetes_secret" "unseal_key" { + metadata { + name = local.unseal_secret_name + namespace = local.namespace + labels = local.labels + } + + data = { + unseal-key = random_id.unseal_key.b64_std + } + + type = "Opaque" + + lifecycle { + prevent_destroy = true + } +} + +# Create PVC for server +module "openbao_pvc" { + count = lookup(local.spec, "server_replicas", 1) + source = "github.com/Facets-cloud/facets-utility-modules//pvc" + name = "data-${local.release_name}-${count.index}" + namespace = local.namespace + provisioned_for = "${local.release_name}-${count.index}" + instance_name = local.release_name + volume_size = lookup(local.spec, "storage_size", "10Gi") + access_modes = ["ReadWriteOnce"] + kind = "openbao" + additional_labels = lookup(local.spec, "pvc_labels", {}) +} + +# Deploy OpenBao using Helm +resource "helm_release" "openbao" { + name = local.release_name + repository = local.chart_repo + chart = local.chart_name + version = local.chart_version + namespace = local.namespace + + create_namespace = true + + values = [yamlencode(local.constructed_helm_values), yamlencode(local.user_defined_helm_values)] + + timeout = 600 + + depends_on = [kubernetes_secret.unseal_key, module.openbao_pvc] + + lifecycle { + create_before_destroy = true + } +} + +# Create ServiceAccount for the init job +resource "kubernetes_service_account" "openbao_init" { + metadata { + name = "${local.release_name}-init-sa" + namespace = local.namespace + labels = local.labels + } + + depends_on = [helm_release.openbao] +} + +# Create Role with permissions to exec into pods and manage secrets +resource "kubernetes_role" "openbao_init" { + metadata { + name = "${local.release_name}-init-role" + namespace = local.namespace + labels = local.labels + } + + rule { + api_groups = [""] + resources = ["pods", "pods/exec"] + verbs = ["get", "list", "create"] + } + + rule { + api_groups = [""] + resources = ["secrets"] + verbs = ["create", "get", "update", "patch"] + } + + rule { + api_groups = ["apps"] + resources = ["statefulsets"] + verbs = ["get", "list"] + } + + depends_on = [helm_release.openbao] +} + +# Bind the role to the service account +resource "kubernetes_role_binding" "openbao_init" { + metadata { + name = "${local.release_name}-init-binding" + namespace = local.namespace + labels = local.labels + } + + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "Role" + name = kubernetes_role.openbao_init.metadata[0].name + } + + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.openbao_init.metadata[0].name + namespace = local.namespace + } +} + +# Kubernetes Job to initialize and maintain OpenBao cluster +# Job is recreated whenever replicas change to join new nodes +resource "kubernetes_job_v1" "openbao_init" { + metadata { + name = "${local.release_name}-init-${md5("${lookup(local.spec, "server_replicas", 1)}")}" + namespace = local.namespace + labels = local.labels + } + + spec { + template { + metadata { + labels = merge(local.labels, { + "app" = "${local.release_name}-init" + }) + } + + spec { + service_account_name = kubernetes_service_account.openbao_init.metadata[0].name + restart_policy = "Never" + + dynamic "toleration" { + for_each = lookup(var.environment, "default_tolerations", [{ + key = "kubernetes.azure.com/scalesetpriority" + value = "spot" + operator = "Equal" + effect = "NoSchedule" + }]) + + content { + key = toleration.value.key + operator = toleration.value.operator + value = toleration.value.value + effect = toleration.value.effect + } + } + + container { + name = "openbao-init" + image = "alpine/k8s:1.28.3" + + command = ["/bin/bash", "-c"] + args = [<<-EOF + set -e + + echo "=== OpenBao Auto-Init and Raft Join Script ===" + echo "Checking for OpenBao pods..." + + # Wait for at least one OpenBao pod + kubectl wait --for=condition=ready pod/${local.release_name}-0 -n ${local.namespace} --timeout=300s || { + echo "ERROR: openbao server pod not ready" + exit 1 + } + + POD_NAME="${local.release_name}-0" + echo "Found leader pod: $POD_NAME" + + # Check if already initialized + echo "Checking if OpenBao leader is initialized..." + if kubectl exec -n ${local.namespace} $POD_NAME -- bao status 2>&1 | grep -q "Initialized.*true"; then + echo "OpenBao is already initialized." + + # Retrieve root token from existing secret + if kubectl get secret ${local.release_name}-init-keys -n ${local.namespace} &>/dev/null; then + echo "Retrieving root token from existing secret..." + ROOT_TOKEN=$(kubectl get secret ${local.release_name}-init-keys -n ${local.namespace} -o jsonpath='{.data.root-token}' | base64 -d) + else + echo "ERROR: OpenBao is initialized but root token secret not found!" + exit 1 + fi + else + echo "Initializing OpenBao with auto-unseal (static seal)..." + INIT_OUTPUT=$(kubectl exec -n ${local.namespace} $POD_NAME -- bao operator init -format=json) + + echo "OpenBao initialized successfully!" + + # Extract recovery keys and root token using jq + ROOT_TOKEN=$(echo "$INIT_OUTPUT" | jq -r '.root_token') + RECOVERY_KEYS=$(echo "$INIT_OUTPUT" | jq -r '.recovery_keys_b64 | @json') + + # Store in Kubernetes Secret + kubectl create secret generic ${local.release_name}-init-keys -n ${local.namespace} \ + --from-literal=root-token="$ROOT_TOKEN" \ + --from-literal=recovery-keys="$RECOVERY_KEYS" \ + --dry-run=client -o yaml | kubectl apply -f - + + echo "Recovery keys and root token stored in secret '${local.release_name}-init-keys'" + + echo "OpenBao is now initialized and auto-unsealed via static seal!" + fi + + # Join additional Raft nodes if running in HA mode + echo "" + echo "=== Raft Cluster Setup ===" + REPLICA_COUNT=$(kubectl get statefulset ${local.release_name} -n ${local.namespace} -o jsonpath='{.spec.replicas}' 2>/dev/null || echo "1") + echo "Detected replica count: $REPLICA_COUNT" + + if [ "$REPLICA_COUNT" -gt 1 ]; then + echo "HA mode enabled. Processing additional nodes..." + + # Join all non-leader nodes to the cluster + for i in $(seq 1 $(($REPLICA_COUNT - 1))); do + POD="${local.release_name}-$i" + echo "" + echo "Processing $POD..." + + # Wait for pod to be running (allow time for node provisioning if needed) + echo "Waiting for $POD to be ready (timeout: 5 minutes)..." + kubectl wait --for=condition=ready pod/$POD -n ${local.namespace} --timeout=300s || { + echo "Warning: $POD not ready after 5 minutes, will retry on next run" + continue + } + + # Check current status + POD_STATUS=$(kubectl exec -n ${local.namespace} $POD -- bao status 2>&1 || true) + + if echo "$POD_STATUS" | grep -q "Initialized.*true"; then + echo "$POD is already initialized and part of cluster" + continue + fi + + # Node is uninitialized, join it to the cluster + echo "$POD is uninitialized. Joining to Raft cluster..." + if kubectl exec -n ${local.namespace} $POD -- bao operator raft join \ + http://${local.release_name}-0.${local.release_name}-internal.${local.namespace}.svc.cluster.local:8200; then + echo "Successfully joined $POD to cluster" + + # Give it a moment to auto-unseal with static seal + sleep 3 + + # Verify it unsealed + NEW_STATUS=$(kubectl exec -n ${local.namespace} $POD -- bao status 2>&1 || true) + if echo "$NEW_STATUS" | grep -q "Sealed.*false"; then + echo "$POD is now unsealed and operational" + else + echo "Warning: $POD joined but may need time to unseal" + fi + else + echo "Failed to join $POD - will retry on next run" + fi + done + + echo "" + echo "Raft cluster setup complete!" + else + echo "Single replica deployment - no additional nodes to join" + fi + + # Configure OpenBao policies and auth + echo "" + echo "Configuring OpenBao policies and Kubernetes auth..." + + # Enable Kubernetes auth method + echo "Enabling Kubernetes auth method..." + kubectl exec -n ${local.namespace} $POD_NAME -- env BAO_TOKEN="$ROOT_TOKEN" bao auth enable kubernetes || echo "Kubernetes auth already enabled" + + # Detect Kubernetes issuer from JWT token + echo "Detecting Kubernetes issuer from JWT token..." + TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + PAYLOAD=$(echo "$TOKEN" | cut -d. -f2) + + # Add padding for base64 decoding if needed + MOD=$(($(echo -n "$PAYLOAD" | wc -c) % 4)) + if [ $MOD -eq 2 ]; then + PAYLOAD="$${PAYLOAD}==" + elif [ $MOD -eq 3 ]; then + PAYLOAD="$${PAYLOAD}=" + fi + + # Extract issuer from JWT payload + ISSUER=$(echo "$PAYLOAD" | base64 -d 2>/dev/null | grep -o '"iss":"[^"]*"' | cut -d'"' -f4) + + if [ -z "$ISSUER" ]; then + echo "Warning: Could not detect issuer from JWT token, using default configuration" + ISSUER_PARAM="" + else + echo "Detected issuer: $ISSUER" + ISSUER_PARAM="issuer='$ISSUER'" + fi + + # Configure Kubernetes auth backend with auto-detected issuer + echo "Configuring Kubernetes auth backend..." + kubectl exec -n ${local.namespace} $POD_NAME -- sh -c " + export BAO_TOKEN='$ROOT_TOKEN' + bao write auth/kubernetes/config \ + kubernetes_host='https://kubernetes.default.svc:443' \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token \ + $ISSUER_PARAM + " + + # Enable KV v2 secrets engine at 'cp-secrets/' path + echo "Enabling KV v2 secrets engine at cp-secrets/ path..." + kubectl exec -n ${local.namespace} $POD_NAME -- env BAO_TOKEN="$ROOT_TOKEN" bao secrets enable -path=cp-secrets -version=2 kv || echo "KV v2 secrets engine already enabled at cp-secrets/" + + # Create dynamic policies and roles + echo "" + echo "=== Creating Custom Policies and Roles ===" + %{for policy_key, policy_config in local.policies~} + + # Create policy: ${policy_key} + echo "Creating policy '${policy_key}' for service account '${policy_config.service_account_name}'..." + cat <<'POLICY_EOF' | kubectl exec -i -n ${local.namespace} $POD_NAME -- env BAO_TOKEN="$ROOT_TOKEN" bao policy write ${policy_key} - +${policy_config.policy} + +POLICY_EOF + + # Create auth role: ${policy_config.role_name} + echo "Creating auth role '${policy_config.role_name}'..." + kubectl exec -n ${local.namespace} $POD_NAME -- env BAO_TOKEN="$ROOT_TOKEN" bao write auth/kubernetes/role/${policy_config.role_name} \ + bound_service_account_names=${policy_config.service_account_name} \ + bound_service_account_namespaces='*' \ + policies=${policy_key} \ + ttl=72h + + %{endfor~} + echo "" + echo "OpenBao configuration complete!" + echo "- Policies created: ${join(", ", keys(local.policies))}" + echo "- Roles created: ${join(", ", [for p in local.policies : p.role_name])}" + EOF + ] + + env { + name = "BAO_ADDR" + value = "http://${local.release_name}-openbao.${local.namespace}.svc.cluster.local:8200" + } + } + } + } + + backoff_limit = 3 + } + + wait_for_completion = true + + timeouts { + create = "15m" + } + + depends_on = [ + helm_release.openbao, + kubernetes_role_binding.openbao_init + ] + + lifecycle { + replace_triggered_by = [helm_release.openbao] + } +} + +# Data source to read the init keys secret (for outputs) +data "kubernetes_secret_v1" "openbao_init_keys" { + metadata { + name = "${local.release_name}-init-keys" + namespace = local.namespace + } + + depends_on = [kubernetes_job_v1.openbao_init] +} diff --git a/modules/openbao/default/1.0/outputs.tf b/modules/openbao/default/1.0/outputs.tf new file mode 100644 index 00000000..dc18ba51 --- /dev/null +++ b/modules/openbao/default/1.0/outputs.tf @@ -0,0 +1,20 @@ +locals { + output_attributes = { + namespace = lookup(local.metadata, "namespace", "default") + release_name = lookup(local.spec, "release_name", "openbao") + service_name = "${lookup(local.spec, "release_name", "openbao")}" + service_url = "http://${lookup(local.spec, "release_name", "openbao")}.${lookup(local.metadata, "namespace", "default")}.svc.cluster.local:8200" + ui_enabled = lookup(local.spec, "ui_enabled", true) + ui_url = lookup(local.spec, "ui_enabled", true) ? "http://${lookup(local.spec, "release_name", "openbao")}.${lookup(local.metadata, "namespace", "default")}.svc.cluster.local:8200/ui" : null + health_check_url = "http://${lookup(local.spec, "release_name", "openbao")}.${lookup(local.metadata, "namespace", "default")}.svc.cluster.local:8200/v1/sys/health" + unseal_secret_name = "${lookup(local.spec, "release_name", "openbao")}-unseal-key" + init_keys_secret_name = "${lookup(local.spec, "release_name", "openbao")}-init-keys" + storage_type = lookup(local.spec, "storage_type", "raft") + server_replicas = lookup(local.spec, "server_replicas", 1) + auto_unseal_enabled = true + root_token = sensitive(try(data.kubernetes_secret_v1.openbao_init_keys.data["root-token"], "")) + recovery_keys = sensitive(try(data.kubernetes_secret_v1.openbao_init_keys.data["recovery-keys"], "")) + secrets = ["root_token", "recovery_keys"] + } + output_interfaces = {} +} \ No newline at end of file diff --git a/modules/openbao/default/1.0/variables.tf b/modules/openbao/default/1.0/variables.tf new file mode 100644 index 00000000..6acca48a --- /dev/null +++ b/modules/openbao/default/1.0/variables.tf @@ -0,0 +1,80 @@ +# Standard Facets module variables +variable "instance_name" { + description = "The unique name for this module instance" + type = string +} + +variable "instance" { + description = "The instance configuration" + type = object({ + kind = string + flavor = string + version = string + metadata = optional(object({ + namespace = optional(string, "default") + }), {}) + spec = object({ + namespace = optional(string, "default") + release_name = optional(string, "openbao") + chart_version = optional(string, "0.18.4") + server_replicas = optional(number, 1) + server_resources = optional(object({ + requests = optional(object({ + cpu = optional(string, "500m") + memory = optional(string, "256Mi") + }), {}) + limits = optional(object({ + cpu = optional(string, "1000m") + memory = optional(string, "512Mi") + }), {}) + }), {}) + ui_enabled = optional(bool, true) + storage_type = optional(string, "raft") + storage_size = optional(string, "10Gi") + pvc_labels = optional(map(string), {}) + unseal_secret_name = optional(string) + openbao = optional(object({ + policies = optional(map(object({ + service_account_name = string + role_name = string + policy = string + })), {}) + values = optional(any, {}) + }), {}) + }) + }) + + validation { + condition = var.instance.spec.server_replicas >= 1 && var.instance.spec.server_replicas <= 10 + error_message = "Server replicas must be between 1 and 10." + } + + validation { + condition = contains(["file", "raft"], var.instance.spec.storage_type) + error_message = "Storage type must be one of: file, raft." + } + + validation { + condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.instance.spec.namespace)) + error_message = "Namespace must match pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + } + + validation { + condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.instance.spec.release_name)) + error_message = "Release name must match pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + } +} + +variable "environment" { + description = "Environment configuration" + type = object({ + unique_name = string + cloud_tags = map(string) + }) +} + +variable "inputs" { + description = "Inputs from other modules" + type = map(any) + default = {} +} diff --git a/outputs/openbao/output.facets.yaml b/outputs/openbao/output.facets.yaml new file mode 100644 index 00000000..6fe029e0 --- /dev/null +++ b/outputs/openbao/output.facets.yaml @@ -0,0 +1,55 @@ +name: openbao +out: + type: object + title: OpenBao Cluster Outputs + description: Outputs from the OpenBao cluster module including connection details and credentials + properties: + attributes: + type: object + description: Attributes of the OpenBao cluster outputs + properties: + namespace: + type: string + description: Kubernetes namespace where OpenBao is deployed + release_name: + type: string + description: Helm release name for the OpenBao deployment + service_name: + type: string + description: Kubernetes service name for OpenBao + service_url: + type: string + description: Internal service URL for OpenBao API access + ui_enabled: + type: boolean + description: Whether the OpenBao web UI is enabled + ui_url: + type: string + description: URL for accessing the OpenBao web UI (null if UI disabled) + health_check_url: + type: string + description: Health check endpoint URL for OpenBao + unseal_secret_name: + type: string + description: Name of the Kubernetes secret containing the unseal key + init_keys_secret_name: + type: string + description: Name of the Kubernetes secret containing initialization keys and root token + storage_type: + type: string + description: Storage backend type (raft or file) + server_replicas: + type: integer + description: Number of OpenBao server replicas + root_token: + type: string + description: Root token for OpenBao (sensitive) + recovery_keys: + type: string + description: Recovery keys for OpenBao (sensitive) + secrets: + type: array + description: List of sensitive field names + items: + type: string + interfaces: {}