diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 00000000..5516bd14 --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,12 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfvars +*.tfvars.json +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 00000000..d6ceca2b --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,250 @@ +# Terraform modules for `nextcloud-mcp-server` on AWS + +POC Terraform modules to spin up a `nextcloud-mcp-server` on AWS ECS Fargate, +behind an ALB, with EFS-backed task storage. Two Qdrant modes are supported: +an in-VPC Qdrant ECS task (default) or an external/managed Qdrant service +(URL + API key supplied via Secrets Manager). + +This directory ships **two** modules: + +| Module | Purpose | +|---|---| +| [`nextcloud-mcp-deployer-role`](./nextcloud-mcp-deployer-role) | A least-privilege IAM role + policy scoped to deploying the MCP server module. Apply this **first**, in the target AWS account, with a privileged bootstrap principal. | +| [`nextcloud-mcp-server`](./nextcloud-mcp-server) | The actual MCP server: ECS cluster, ALB, EFS, IAM task/execution roles, Route53 record, ACM certificate, optional Qdrant ECS service. Apply this **second**, by assuming the deployer role. | + +## Two-phase deploy + +``` +┌──────────────┐ ┌────────────────────────────┐ ┌─────────────────────┐ +│ Bootstrap │────▶│ Apply deployer-role │────▶│ Apply mcp-server │ +│ principal │ │ (creates an IAM role + │ │ (assume the role, │ +│ (admin) │ │ scoped policy) │ │ provision the │ +│ │ │ │ │ MCP server) │ +└──────────────┘ └────────────────────────────┘ └─────────────────────┘ +``` + +The split lets a customer create a stable, minimum-permission role in their +account once, and then re-apply the MCP server module repeatedly under that +role without ever exposing admin credentials to the deploy pipeline. + +## Phase 1 — bootstrap the deployer role + +### Prerequisite: bootstrap IAM policy + +The principal that applies `nextcloud-mcp-deployer-role` only needs IAM +permissions to manage the role and its attached policy. The policy below is +**scoped to the module defaults** (`role_path = /clients/`, +`role_name = nextcloud-mcp-deployer`); widen the resource ARNs if you +override either input. + +Copy this into the AWS console (IAM → Policies → Create policy → JSON) and +attach it to the user / role that runs `terraform apply` for the deployer-role +module: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "BootstrapManageDeployerRole", + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:GetRole", + "iam:DeleteRole", + "iam:UpdateRole", + "iam:UpdateAssumeRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:ListRoleTags", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:PutRolePolicy", + "iam:GetRolePolicy", + "iam:DeleteRolePolicy" + ], + "Resource": "arn:aws:iam::*:role/clients/nextcloud-mcp-deployer*" + }, + { + "Sid": "BootstrapManageDeployerPolicy", + "Effect": "Allow", + "Action": [ + "iam:CreatePolicy", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyVersions", + "iam:CreatePolicyVersion", + "iam:DeletePolicyVersion", + "iam:DeletePolicy", + "iam:TagPolicy", + "iam:UntagPolicy", + "iam:ListEntitiesForPolicy" + ], + "Resource": "arn:aws:iam::*:policy/clients/nextcloud-mcp-deployer-policy" + }, + { + "Sid": "BootstrapStsCallerIdentity", + "Effect": "Allow", + "Action": ["sts:GetCallerIdentity"], + "Resource": "*" + } + ] +} +``` + +### Apply the deployer-role module + +```hcl +terraform { + required_providers { + aws = { source = "hashicorp/aws", version = "~> 6.0" } + } +} + +provider "aws" { + region = "eu-west-1" +} + +module "nextcloud_mcp_deployer_role" { + source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-deployer-role?ref=master" + + trusted_principal_arns = [ + "arn:aws:iam::123456789012:root", # principal that will assume the role + ] + + # Optional: set true if the Secrets Manager secret is managed in the same TF run as the MCP server. + # allow_secret_create = true + + # Optional: required only if you use the module's custom-domain (Route53 + ACM) mode. + # route53_zone_ids = ["Z0123456789ABCDEFGHIJ"] +} + +output "deployer_role_arn" { + value = module.nextcloud_mcp_deployer_role.role_arn +} +``` + +`terraform apply` produces a role ARN like +`arn:aws:iam:::role/clients/nextcloud-mcp-deployer`. Hand that +ARN to whichever pipeline / human applies Phase 2. + +## Phase 2 — deploy the MCP server + +### Provider configured to assume the deployer role + +```hcl +provider "aws" { + region = "eu-west-1" + assume_role { + role_arn = "arn:aws:iam::123456789012:role/clients/nextcloud-mcp-deployer" + # external_id = "..." # if you set one in the deployer-role trust policy + } +} +``` + +### Pre-existing requirements + +- A VPC with **public subnets** (for the ALB and ECS task ENIs — the module + runs tasks with `assign_public_ip = true`, no NAT gateway needed) and + **private subnets** in matching AZs (for EFS mount targets). +- A Route53 **public hosted zone** the module can write to. The module + generates a random `.` subdomain and provisions the + ACM cert + DNS records itself. +- A Secrets Manager secret with a **JSON value** containing the keys below. + Create it manually, or via Terraform (set `allow_secret_create = true` on + the deployer role and manage it in the same plan). + + Required keys: + ```json + { + "host": "https://your-nextcloud.example.com", + "client_id": "", + "client_secret": "", + "token_encryption_key": "", + "webhook_secret": "" + } + ``` + + When `use_external_qdrant = true`, also include: + ```json + { + "qdrant_url": "https://.cloud.qdrant.io:6333", + "qdrant_api_key": "" + } + ``` + +### Mode A — in-VPC Qdrant (default) + +Runs Qdrant as a second Fargate task on the same ECS cluster, exposed to the +MCP server via Cloud Map private DNS. Storage on EFS. + +```hcl +module "nextcloud_mcp_server" { + source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-server?ref=master" + + vpc_id = "vpc-0123456789abcdef0" + public_subnet_ids = ["subnet-aaa", "subnet-bbb"] + private_subnet_ids = ["subnet-ccc", "subnet-ddd"] + + zone_id = "Z0123456789ABCDEFGHIJ" + zone_name = "example.com" + + nextcloud_url = "https://nextcloud.example.com" + + image_tag = "0.75.2" # pin to a release; never :latest + qdrant_image_tag = "v1.17.1" + + secret_arn = "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nextcloud-mcp-aws-env-XXXXXX" +} + +output "mcp_url" { + value = module.nextcloud_mcp_server.url +} +``` + +### Mode B — external / managed Qdrant + +Skip the Qdrant ECS task entirely; point the MCP server at a managed Qdrant +cluster (e.g. Qdrant Cloud, or a Qdrant you run elsewhere). `qdrant_url` and +`qdrant_api_key` come from the same Secrets Manager secret. + +```hcl +module "nextcloud_mcp_server" { + source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-server?ref=master" + + vpc_id = "vpc-0123456789abcdef0" + public_subnet_ids = ["subnet-aaa", "subnet-bbb"] + private_subnet_ids = ["subnet-ccc", "subnet-ddd"] + + zone_id = "Z0123456789ABCDEFGHIJ" + zone_name = "example.com" + + nextcloud_url = "https://nextcloud.example.com" + + image_tag = "0.75.2" + + secret_arn = "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nextcloud-mcp-aws-env-XXXXXX" + + use_external_qdrant = true + qdrant_image_tag = "unused" # required input but ignored when use_external_qdrant=true + qdrant_collection = "nextcloud-mcp" +} +``` + +## Pinning the module version + +`?ref=master` is fine for a POC but pins to a moving target. Once a release +tag exists in this repo (e.g. `infra-tf-v0.1.0`), pin to it: + +```hcl +source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-server?ref=infra-tf-v0.1.0" +``` + +## Module-specific docs + +Each module's full input/output reference lives in its own `README.md`: + +- [`nextcloud-mcp-server/README.md`](./nextcloud-mcp-server/README.md) +- [`nextcloud-mcp-deployer-role/README.md`](./nextcloud-mcp-deployer-role/README.md) diff --git a/infra/terraform/nextcloud-mcp-deployer-role/README.md b/infra/terraform/nextcloud-mcp-deployer-role/README.md new file mode 100644 index 00000000..f4baf22b --- /dev/null +++ b/infra/terraform/nextcloud-mcp-deployer-role/README.md @@ -0,0 +1,91 @@ +# nextcloud-mcp-deployer-role + +IAM role and least-privilege policy scoped to deploy the +[`nextcloud-mcp-server`](../nextcloud-mcp-server) Terraform module. + +## Use cases + +- **Client account**: a client creates this role in their AWS account with + `trusted_principal_arns = ["arn:aws:iam:::root"]` so you + can assume it cross-account and deploy/maintain the MCP server on their + behalf. +- **Your own testing**: instantiated in your account with your IAM user / + admin role as the trusted principal, lets you run `terraform apply` for + the nextcloud-mcp-server module under the same permission boundary the + client will use — so any "works for me, breaks for them" gap surfaces in + testing rather than at the client. + +## Modes + +The role's permissions are mode-aware via inputs: + +| Mode | Inputs | What gets granted | +|---|---|---| +| **CloudFront default cert** (recommended) | (defaults) | ECS, ALB, EFS, CloudFront, scoped IAM/logs/secrets, EC2 SG + describe | +| **Custom domain** | `route53_zone_ids = [...]` | + Route53 (scoped to listed zones) and ACM | +| **Secret managed in same TF** | `allow_secret_create = true` | + Secrets Manager create/update/delete (scoped to `secret_name_prefix`) | + +## Cross-account assume from your account + +Once the client has applied this module in their account and given you the +output `role_arn`, configure the AWS provider in your client-deployment TF +project: + +```hcl +provider "aws" { + assume_role { + role_arn = "arn:aws:iam:::role/clients/nextcloud-mcp-deployer" + # external_id = "..." # optional, recommended for cross-account + } +} +``` + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [aws](#requirement\_aws) | ~> 6.0 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.43.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_iam_policy.deployer](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.deployer](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.deployer](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.trust](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [allow\_secret\_create](#input\_allow\_secret\_create) | When true, the deployer can create/update/delete Secrets Manager
secrets matching `secret_name_prefix`. Set true if the secret is
managed alongside the module in the same Terraform run; leave false if
the secret is provisioned out of band (console / separate root TF) and
only the ARN is passed in. | `bool` | `false` | no | +| [module\_name\_prefix](#input\_module\_name\_prefix) | The `var.name` value passed to the nextcloud-mcp-server module. Used to
scope IAM/logs/secrets ARNs. Defaults match the module default; change
only if the module is instantiated with a non-default name. | `string` | `"nextcloud-mcp-server"` | no | +| [role\_name](#input\_role\_name) | Name of the deployer IAM role. | `string` | `"nextcloud-mcp-deployer"` | no | +| [role\_path](#input\_role\_path) | IAM path for the deployer role and its policy. | `string` | `"/clients/"` | no | +| [route53\_zone\_ids](#input\_route53\_zone\_ids) | Route53 hosted zone IDs the deployer is allowed to mutate. Only needed
in the module's custom-domain mode. Leave empty (the default) for the
CloudFront-default-cert path, which requires no DNS or ACM permissions. | `list(string)` | `[]` | no | +| [secret\_name\_prefix](#input\_secret\_name\_prefix) | Secrets Manager name prefix the deployer can read (and optionally
create, see `allow_secret_create`). The module accepts a secret ARN as
input; this prefix scopes the deployer's access to secrets matching
that name pattern. | `string` | `"nextcloud-mcp"` | no | +| [trusted\_principal\_arns](#input\_trusted\_principal\_arns) | Principal ARNs allowed to assume this role. For testing in your own
account: the user/role you want to assume from. For client deployments:
typically a single root-account ARN of the deploying party (e.g.
"arn:aws:iam:::root"), with MFA or external-id
conditions added at the trust-policy level if required. | `list(string)` | n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [policy\_arn](#output\_policy\_arn) | ARN of the inline-style managed policy attached to the role. | +| [role\_arn](#output\_role\_arn) | ARN of the deployer role; pass to STS AssumeRole or use as the assume\_role target in a provider block. | +| [role\_name](#output\_role\_name) | Name of the deployer role. | + diff --git a/infra/terraform/nextcloud-mcp-deployer-role/main.tf b/infra/terraform/nextcloud-mcp-deployer-role/main.tf new file mode 100644 index 00000000..1d7e90af --- /dev/null +++ b/infra/terraform/nextcloud-mcp-deployer-role/main.tf @@ -0,0 +1,331 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} + +locals { + account_id = data.aws_caller_identity.current.account_id + partition = data.aws_partition.current.partition + prefix = var.module_name_prefix +} + +### +# Role + trust + +resource "aws_iam_role" "this" { + name = var.role_name + path = var.role_path + assume_role_policy = data.aws_iam_policy_document.trust.json + description = "Deploy + manage the nextcloud-mcp-server Terraform module." +} + +data "aws_iam_policy_document" "trust" { + statement { + sid = "AllowTrustedPrincipals" + actions = ["sts:AssumeRole"] + principals { + type = "AWS" + identifiers = var.trusted_principal_arns + } + } +} + +### +# Deployer policy +# +# Scoping strategy: actions that have no usable resource-ARN form at create +# time (RegisterTaskDefinition, CreateLoadBalancer, CreateFileSystem, all of +# CloudFront, etc.) are granted on `*` — IAM gives no other option there. +# Where ARN scoping IS available and worth the surface reduction (IAM, logs, +# secrets, route53), the policy is scoped tight to the module's name prefix. + +data "aws_iam_policy_document" "deployer" { + + # --- Compute / runtime: ECS, ALB, EFS --- + # + # All three services are gated by SG/SCP/account boundaries already, and + # most of their create-time APIs reject resource-ARN scoping. Granting + # service-wide is the standard pattern for a deployer role. + statement { + sid = "EcsService" + actions = ["ecs:*"] + resources = ["*"] + } + + statement { + sid = "ElbV2Service" + actions = ["elasticloadbalancing:*"] + resources = ["*"] + } + + statement { + sid = "EfsService" + actions = ["elasticfilesystem:*"] + resources = ["*"] + } + + # --- Edge: CloudFront --- + # + # CloudFront has no resource-ARN scoping for distribution create. Cache / + # origin-request policies the module ships are also account-wide objects. + statement { + sid = "CloudFrontService" + actions = ["cloudfront:*"] + resources = ["*"] + } + + # --- Logs --- + # + # Module creates a single log group: `/ecs/${var.name}`. Scope CW Logs + # writes/reads to that prefix; describe APIs need `*` because they don't + # accept resource-ARN scoping. + statement { + sid = "LogsManageGroup" + actions = ["logs:*"] + resources = [ + "arn:${local.partition}:logs:*:${local.account_id}:log-group:/ecs/${local.prefix}*", + "arn:${local.partition}:logs:*:${local.account_id}:log-group:/ecs/${local.prefix}*:*", + "arn:${local.partition}:logs:*:${local.account_id}:log-group:/ecs/${local.prefix}*:log-stream:*", + ] + } + + statement { + sid = "LogsDescribe" + actions = [ + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + ] + resources = ["*"] + } + + # --- IAM --- + # + # The module creates two roles under path `/ecs/`: `${name}-execution` and + # `${name}-task`, plus inline policies on each. Scope to that path+prefix + # so the deployer can't pivot to creating arbitrary roles. + statement { + sid = "IamManageModuleRoles" + actions = [ + "iam:CreateRole", + "iam:GetRole", + "iam:DeleteRole", + "iam:UpdateRole", + "iam:UpdateAssumeRolePolicy", + "iam:PutRolePolicy", + "iam:GetRolePolicy", + "iam:DeleteRolePolicy", + "iam:ListRolePolicies", + "iam:ListAttachedRolePolicies", + "iam:TagRole", + "iam:UntagRole", + "iam:ListRoleTags", + ] + resources = [ + "arn:${local.partition}:iam::${local.account_id}:role/ecs/${local.prefix}-*", + ] + } + + # ECS RunTask + service updates need iam:PassRole on the task/exec roles. + statement { + sid = "IamPassModuleRoles" + actions = ["iam:PassRole"] + resources = [ + "arn:${local.partition}:iam::${local.account_id}:role/ecs/${local.prefix}-*", + ] + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ecs-tasks.amazonaws.com"] + } + } + + # The execution role attaches the AWS-managed AmazonECSTaskExecutionRolePolicy. + # Restrict the Attach/Detach actions to that single managed policy ARN so + # this grant can't be used to attach AdministratorAccess or similar. + statement { + sid = "IamAttachManagedTaskExecPolicy" + actions = [ + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + ] + resources = [ + "arn:${local.partition}:iam::${local.account_id}:role/ecs/${local.prefix}-*", + ] + condition { + test = "ArnEquals" + variable = "iam:PolicyARN" + values = [ + "arn:${local.partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + ] + } + } + + # --- Secrets Manager --- + # + # The module reads a caller-supplied secret ARN at deploy time + # (execution-role policy in iam.tf:26). Optionally allow create/update + # for callers who manage the secret in their own TF. + statement { + sid = "SecretsRead" + actions = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecrets", + ] + resources = [ + "arn:${local.partition}:secretsmanager:*:${local.account_id}:secret:${var.secret_name_prefix}*", + ] + } + + dynamic "statement" { + for_each = var.allow_secret_create ? [1] : [] + content { + sid = "SecretsManage" + actions = [ + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:GetResourcePolicy", + "secretsmanager:PutResourcePolicy", + ] + resources = [ + "arn:${local.partition}:secretsmanager:*:${local.account_id}:secret:${var.secret_name_prefix}*", + ] + } + } + + # --- EC2: Security Groups + describe-only networking --- + # + # SG mutations have no usable resource-ARN scoping at create time + # (CreateSecurityGroup returns the ID), so SG actions are granted on `*`. + # The describe set is needed for the module's data sources and SG-rule + # references (managed prefix list lookup for the CloudFront SG lock). + statement { + sid = "Ec2NetworkDescribe" + actions = [ + "ec2:DescribeVpcs", + "ec2:DescribeSubnets", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeRouteTables", + "ec2:DescribePrefixLists", + "ec2:DescribeManagedPrefixLists", + "ec2:GetManagedPrefixListEntries", + "ec2:DescribeTags", + ] + resources = ["*"] + } + + statement { + sid = "Ec2SecurityGroupManage" + actions = [ + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:ModifySecurityGroupRules", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:UpdateSecurityGroupRuleDescriptionsIngress", + "ec2:UpdateSecurityGroupRuleDescriptionsEgress", + "ec2:CreateTags", + "ec2:DeleteTags", + ] + resources = ["*"] + } + + # --- Route53 + ACM (custom-domain mode only) --- + # + # Default mode (CloudFront with `*.cloudfront.net` cert) needs neither. + # Opt in by passing `route53_zone_ids` for the zones the deployer is + # allowed to mutate. + dynamic "statement" { + for_each = length(var.route53_zone_ids) > 0 ? [1] : [] + content { + sid = "Route53RecordsForZones" + actions = [ + "route53:ChangeResourceRecordSets", + "route53:ListResourceRecordSets", + "route53:GetHostedZone", + ] + resources = [ + for zone_id in var.route53_zone_ids : + "arn:${local.partition}:route53:::hostedzone/${zone_id}" + ] + } + } + + # GetChange takes a change-id, not a zone ARN — must be `*`. + dynamic "statement" { + for_each = length(var.route53_zone_ids) > 0 ? [1] : [] + content { + sid = "Route53GetChange" + actions = ["route53:GetChange"] + resources = ["*"] + } + } + + dynamic "statement" { + for_each = length(var.route53_zone_ids) > 0 ? [1] : [] + content { + sid = "AcmService" + actions = ["acm:*"] + resources = ["*"] + } + } + + # --- KMS describe (AWS-managed keys for default EFS / Secrets encryption) --- + statement { + sid = "KmsDescribeAwsManaged" + actions = [ + "kms:DescribeKey", + "kms:ListAliases", + ] + resources = ["*"] + } + + # --- Bedrock (model discovery) --- + # + # The task role grants `bedrock:InvokeModel` at runtime. The deployer + # itself doesn't invoke; it only needs to validate the model exists when + # rendering the task-role policy document. Read-only. + statement { + sid = "BedrockDescribe" + actions = [ + "bedrock:GetFoundationModel", + "bedrock:ListFoundationModels", + ] + resources = ["*"] + } + + # --- STS --- + statement { + sid = "StsCallerIdentity" + actions = ["sts:GetCallerIdentity"] + resources = ["*"] + } +} + +resource "aws_iam_policy" "deployer" { + name = "${var.role_name}-policy" + path = var.role_path + description = "Least-privilege policy for the nextcloud-mcp-server deployer role." + policy = data.aws_iam_policy_document.deployer.json +} + +resource "aws_iam_role_policy_attachment" "deployer" { + role = aws_iam_role.this.name + policy_arn = aws_iam_policy.deployer.arn +} diff --git a/infra/terraform/nextcloud-mcp-deployer-role/outputs.tf b/infra/terraform/nextcloud-mcp-deployer-role/outputs.tf new file mode 100644 index 00000000..868bfc19 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-deployer-role/outputs.tf @@ -0,0 +1,14 @@ +output "role_arn" { + description = "ARN of the deployer role; pass to STS AssumeRole or use as the assume_role target in a provider block." + value = aws_iam_role.this.arn +} + +output "role_name" { + description = "Name of the deployer role." + value = aws_iam_role.this.name +} + +output "policy_arn" { + description = "ARN of the inline-style managed policy attached to the role." + value = aws_iam_policy.deployer.arn +} diff --git a/infra/terraform/nextcloud-mcp-deployer-role/vars.tf b/infra/terraform/nextcloud-mcp-deployer-role/vars.tf new file mode 100644 index 00000000..e69b477f --- /dev/null +++ b/infra/terraform/nextcloud-mcp-deployer-role/vars.tf @@ -0,0 +1,69 @@ +variable "role_name" { + description = "Name of the deployer IAM role." + type = string + default = "nextcloud-mcp-deployer" +} + +variable "role_path" { + description = "IAM path for the deployer role and its policy." + type = string + default = "/clients/" +} + +variable "trusted_principal_arns" { + description = <<-EOT + Principal ARNs allowed to assume this role. For testing in your own + account: the user/role you want to assume from. For client deployments: + typically a single root-account ARN of the deploying party (e.g. + "arn:aws:iam:::root"), with MFA or external-id + conditions added at the trust-policy level if required. + EOT + type = list(string) + validation { + condition = length(var.trusted_principal_arns) > 0 + error_message = "trusted_principal_arns must contain at least one ARN." + } +} + +variable "module_name_prefix" { + description = <<-EOT + The `var.name` value passed to the nextcloud-mcp-server module. Used to + scope IAM/logs/secrets ARNs. Defaults match the module default; change + only if the module is instantiated with a non-default name. + EOT + type = string + default = "nextcloud-mcp-server" +} + +variable "secret_name_prefix" { + description = <<-EOT + Secrets Manager name prefix the deployer can read (and optionally + create, see `allow_secret_create`). The module accepts a secret ARN as + input; this prefix scopes the deployer's access to secrets matching + that name pattern. + EOT + type = string + default = "nextcloud-mcp" +} + +variable "allow_secret_create" { + description = <<-EOT + When true, the deployer can create/update/delete Secrets Manager + secrets matching `secret_name_prefix`. Set true if the secret is + managed alongside the module in the same Terraform run; leave false if + the secret is provisioned out of band (console / separate root TF) and + only the ARN is passed in. + EOT + type = bool + default = false +} + +variable "route53_zone_ids" { + description = <<-EOT + Route53 hosted zone IDs the deployer is allowed to mutate. Only needed + in the module's custom-domain mode. Leave empty (the default) for the + CloudFront-default-cert path, which requires no DNS or ACM permissions. + EOT + type = list(string) + default = [] +} diff --git a/infra/terraform/nextcloud-mcp-server/README.md b/infra/terraform/nextcloud-mcp-server/README.md new file mode 100644 index 00000000..7bc2bc3c --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/README.md @@ -0,0 +1,128 @@ + +## Requirements + +| Name | Version | +| ---- | ------- | +| [aws](#requirement\_aws) | ~> 6.0 | +| [random](#requirement\_random) | ~> 3.6 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.43.0 | +| [random](#provider\_random) | 3.8.1 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_acm_certificate.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate) | resource | +| [aws_acm_certificate_validation.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validation) | resource | +| [aws_cloudwatch_log_group.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_ecs_cluster.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster) | resource | +| [aws_ecs_service.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | +| [aws_ecs_service.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | +| [aws_ecs_task_definition.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_ecs_task_definition.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_efs_access_point.data](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource | +| [aws_efs_access_point.oauth](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource | +| [aws_efs_access_point.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource | +| [aws_efs_file_system.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_file_system) | resource | +| [aws_efs_mount_target.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_mount_target) | resource | +| [aws_iam_role.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.qdrant_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.execution_secrets](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.qdrant_task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.qdrant_task_exec](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.task_bedrock](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.task_exec](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.execution_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lb.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb) | resource | +| [aws_lb_listener.http_redirect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener) | resource | +| [aws_lb_listener.https](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener) | resource | +| [aws_lb_target_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_target_group) | resource | +| [aws_route53_record.alias](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_route53_record.cert_validation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_security_group.alb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_security_group.efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_security_group.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_security_group.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_service_discovery_private_dns_namespace.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/service_discovery_private_dns_namespace) | resource | +| [aws_service_discovery_service.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/service_discovery_service) | resource | +| [aws_vpc_security_group_egress_rule.alb_all_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_egress_rule.qdrant_all_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_egress_rule.qdrant_all_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_egress_rule.task_all_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_egress_rule.task_all_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.alb_http_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.alb_http_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.alb_https_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.alb_https_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.efs_from_qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.efs_from_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.qdrant_from_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [aws_vpc_security_group_ingress_rule.task_from_alb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | +| [random_pet.subdomain](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/pet) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.ecs_tasks_trust](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.execution_secrets](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.qdrant_task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.task_bedrock](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.task_exec](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [allowed\_mcp\_clients](#input\_allowed\_mcp\_clients) | MCP OAuth client allowlist published as ALLOWED\_MCP\_CLIENTS. Each entry is `id` or `id|redirect_uri`. Empty list keeps the upstream defaults (claude-desktop, test-mcp-client). | `list(string)` | `[]` | no | +| [allowed\_mgmt\_client](#input\_allowed\_mgmt\_client) | Management API client allowlist published as ALLOWED\_MGMT\_CLIENT (comma-separated client IDs). Required from upstream v0.74.0+: when unset/empty the management API is fail-closed and rejects all tokens. Empty string skips publishing the env var. | `string` | `""` | no | +| [bedrock\_embedding\_model](#input\_bedrock\_embedding\_model) | Bedrock model ID used for semantic search embeddings | `string` | `"amazon.titan-embed-text-v2:0"` | no | +| [container\_port](#input\_container\_port) | Port the server listens on inside the container | `number` | `8004` | no | +| [cpu](#input\_cpu) | Fargate task vCPU units (1024 = 1 vCPU) | `number` | `512` | no | +| [image](#input\_image) | Container image (without tag) | `string` | `"ghcr.io/cbcoutinho/nextcloud-mcp-server"` | no | +| [image\_tag](#input\_image\_tag) | Container image tag. Pin to a specific release; avoid :latest. | `string` | n/a | yes | +| [log\_retention\_days](#input\_log\_retention\_days) | CloudWatch log retention in days | `number` | `30` | no | +| [memory](#input\_memory) | Fargate task memory (MiB) | `number` | `1024` | no | +| [name](#input\_name) | Logical name prefix for resources | `string` | `"nextcloud-mcp-server"` | no | +| [nextcloud\_url](#input\_nextcloud\_url) | Public URL of the Nextcloud instance the MCP server pairs with (e.g., https://cloud.example.com). Used to advertise the OIDC discovery endpoint via /api/v1/status so the astrolabe Nextcloud app can discover Nextcloud's oidc\_provider as the IdP instead of falling back to http://localhost. | `string` | n/a | yes | +| [private\_subnet\_ids](#input\_private\_subnet\_ids) | Private subnet IDs (for EFS mount targets only). | `list(string)` | n/a | yes | +| [public\_subnet\_ids](#input\_public\_subnet\_ids) | Public subnet IDs (for the ALB and the ECS task ENI). Tasks run with assign\_public\_ip=true since this VPC has no NAT gateway; the task SG only allows ingress from the ALB SG. | `list(string)` | n/a | yes | +| [qdrant\_collection](#input\_qdrant\_collection) | Qdrant collection name. Set to a stable value (anything other than upstream's default 'nextcloud\_content') so the upstream config doesn't fall through to its hostname-based auto-naming, which churns the collection on every rolling deploy. | `string` | `"nextcloud-mcp"` | no | +| [qdrant\_cpu](#input\_qdrant\_cpu) | Qdrant Fargate task vCPU units (1024 = 1 vCPU) | `number` | `512` | no | +| [qdrant\_image](#input\_qdrant\_image) | Qdrant container image (without tag) | `string` | `"qdrant/qdrant"` | no | +| [qdrant\_image\_tag](#input\_qdrant\_image\_tag) | Qdrant container image tag (e.g., v1.15.0). Pin to a specific release; avoid :latest. Unused when use\_external\_qdrant = true. | `string` | n/a | yes | +| [qdrant\_memory](#input\_qdrant\_memory) | Qdrant Fargate task memory (MiB) | `number` | `1024` | no | +| [secret\_arn](#input\_secret\_arn) | ARN of the Secrets Manager secret holding JSON {host, client\_id, client\_secret, token\_encryption\_key, webhook\_secret} | `string` | n/a | yes | +| [use\_external\_qdrant](#input\_use\_external\_qdrant) | When true, skip the in-AWS Qdrant ECS task and source QDRANT\_URL/QDRANT\_API\_KEY from the Secrets Manager secret (keys: qdrant\_url, qdrant\_api\_key). When false, run an in-AWS Qdrant Fargate task and point the MCP server at it via Cloud Map DNS. | `bool` | `false` | no | +| [vector\_sync\_processor\_workers](#input\_vector\_sync\_processor\_workers) | Concurrent embedding workers. Keep at 1 unless you've verified Bedrock quota headroom. | `number` | `1` | no | +| [vector\_sync\_scan\_interval](#input\_vector\_sync\_scan\_interval) | Seconds between background vector sync scans | `number` | `60` | no | +| [vpc\_id](#input\_vpc\_id) | VPC ID to deploy into | `string` | n/a | yes | +| [zone\_id](#input\_zone\_id) | Route53 hosted zone ID for the random subdomain | `string` | n/a | yes | +| [zone\_name](#input\_zone\_name) | Route53 hosted zone name (without trailing dot), e.g. astrolabeonline.com | `string` | n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [alb\_dns\_name](#output\_alb\_dns\_name) | n/a | +| [ecs\_cluster\_name](#output\_ecs\_cluster\_name) | n/a | +| [ecs\_service\_name](#output\_ecs\_service\_name) | n/a | +| [efs\_id](#output\_efs\_id) | n/a | +| [fqdn](#output\_fqdn) | Fully-qualified domain name | +| [log\_group\_name](#output\_log\_group\_name) | n/a | +| [qdrant\_dns\_name](#output\_qdrant\_dns\_name) | Internal DNS name where mcp-server reaches qdrant | +| [qdrant\_service\_name](#output\_qdrant\_service\_name) | Qdrant ECS service name (null when use\_external\_qdrant = true) | +| [subdomain](#output\_subdomain) | Generated random subdomain (label only, without the zone) | +| [task\_role\_arn](#output\_task\_role\_arn) | n/a | +| [url](#output\_url) | Public HTTPS URL of the MCP server | + \ No newline at end of file diff --git a/infra/terraform/nextcloud-mcp-server/alb.tf b/infra/terraform/nextcloud-mcp-server/alb.tf new file mode 100644 index 00000000..94dbac51 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/alb.tf @@ -0,0 +1,105 @@ +resource "aws_security_group" "alb" { + name = "${var.name}-alb" + description = "Public HTTPS ingress for ${var.name}" + vpc_id = var.vpc_id +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https_v4" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "HTTPS" +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https_v6" { + security_group_id = aws_security_group.alb.id + cidr_ipv6 = "::/0" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "HTTPS (IPv6)" +} + +resource "aws_vpc_security_group_ingress_rule" "alb_http_v4" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + description = "HTTP (redirects to HTTPS)" +} + +resource "aws_vpc_security_group_ingress_rule" "alb_http_v6" { + security_group_id = aws_security_group.alb.id + cidr_ipv6 = "::/0" + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + description = "HTTP (IPv6, redirects to HTTPS)" +} + +resource "aws_vpc_security_group_egress_rule" "alb_all_v4" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +resource "aws_lb" "this" { + name = var.name + load_balancer_type = "application" + internal = false + subnets = var.public_subnet_ids + security_groups = [aws_security_group.alb.id] + + drop_invalid_header_fields = true +} + +resource "aws_lb_target_group" "this" { + name = var.name + port = var.container_port + protocol = "HTTP" + target_type = "ip" + vpc_id = var.vpc_id + + deregistration_delay = 30 + + health_check { + path = "/health/live" + protocol = "HTTP" + matcher = "200" + interval = 30 + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + } +} + +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.this.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate_validation.this.certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.this.arn + } +} + +resource "aws_lb_listener" "http_redirect" { + load_balancer_arn = aws_lb.this.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + protocol = "HTTPS" + port = "443" + status_code = "HTTP_301" + } + } +} diff --git a/infra/terraform/nextcloud-mcp-server/dns.tf b/infra/terraform/nextcloud-mcp-server/dns.tf new file mode 100644 index 00000000..32a39181 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/dns.tf @@ -0,0 +1,52 @@ +resource "random_pet" "subdomain" { + length = 2 + separator = "-" + + # Stable across applies; regenerate only if we point at a different zone. + keepers = { + zone_id = var.zone_id + } +} + +locals { + fqdn = "${random_pet.subdomain.id}.${var.zone_name}" +} + +resource "aws_acm_certificate" "this" { + domain_name = local.fqdn + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = { + Name = "${var.name}-${random_pet.subdomain.id}" + } +} + +resource "aws_route53_record" "cert_validation" { + allow_overwrite = true + zone_id = var.zone_id + name = one(aws_acm_certificate.this.domain_validation_options).resource_record_name + type = one(aws_acm_certificate.this.domain_validation_options).resource_record_type + records = [one(aws_acm_certificate.this.domain_validation_options).resource_record_value] + ttl = 60 +} + +resource "aws_acm_certificate_validation" "this" { + certificate_arn = aws_acm_certificate.this.arn + validation_record_fqdns = [aws_route53_record.cert_validation.fqdn] +} + +resource "aws_route53_record" "alias" { + zone_id = var.zone_id + name = local.fqdn + type = "A" + + alias { + name = aws_lb.this.dns_name + zone_id = aws_lb.this.zone_id + evaluate_target_health = true + } +} diff --git a/infra/terraform/nextcloud-mcp-server/ecs.tf b/infra/terraform/nextcloud-mcp-server/ecs.tf new file mode 100644 index 00000000..a30a47be --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/ecs.tf @@ -0,0 +1,205 @@ +resource "aws_ecs_cluster" "this" { + name = var.name + + setting { + name = "containerInsights" + value = "enabled" + } +} + +resource "aws_cloudwatch_log_group" "this" { + name = "/ecs/${var.name}" + retention_in_days = var.log_retention_days +} + +resource "aws_security_group" "task" { + name = "${var.name}-task" + description = "ECS task ENI for ${var.name}" + vpc_id = var.vpc_id +} + +resource "aws_vpc_security_group_ingress_rule" "task_from_alb" { + security_group_id = aws_security_group.task.id + referenced_security_group_id = aws_security_group.alb.id + from_port = var.container_port + to_port = var.container_port + ip_protocol = "tcp" + description = "Container port from ALB" +} + +resource "aws_vpc_security_group_egress_rule" "task_all_v4" { + security_group_id = aws_security_group.task.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +resource "aws_vpc_security_group_egress_rule" "task_all_v6" { + security_group_id = aws_security_group.task.id + cidr_ipv6 = "::/0" + ip_protocol = "-1" +} + +locals { + container_name = var.name + + # ALLOWED_MCP_CLIENTS is appended only when non-empty; an empty value would + # override the upstream default (claude-desktop, test-mcp-client) and lock + # everyone out. + container_env = concat( + [ + { name = "ENABLE_LOGIN_FLOW", value = "true" }, + { name = "ENABLE_SEMANTIC_SEARCH", value = "true" }, + { name = "ENABLE_BACKGROUND_OPERATIONS", value = "true" }, + # Explicit collection name; otherwise upstream auto-derives one from the + # task hostname, which churns the collection on every rolling deploy. + { name = "QDRANT_COLLECTION", value = var.qdrant_collection }, + { name = "TOKEN_STORAGE_DB", value = "/app/data/tokens.db" }, + { name = "NEXTCLOUD_MCP_SERVER_URL", value = "https://${local.fqdn}" }, + { name = "OIDC_DISCOVERY_URL", value = "${var.nextcloud_url}/.well-known/openid-configuration" }, + { name = "AWS_REGION", value = data.aws_region.current.region }, + { name = "BEDROCK_EMBEDDING_MODEL", value = var.bedrock_embedding_model }, + { name = "VECTOR_SYNC_SCAN_INTERVAL", value = tostring(var.vector_sync_scan_interval) }, + { name = "VECTOR_SYNC_PROCESSOR_WORKERS", value = tostring(var.vector_sync_processor_workers) }, + ], + var.use_external_qdrant ? [] : [ + { name = "QDRANT_URL", value = "http://qdrant.${aws_service_discovery_private_dns_namespace.this.name}:${local.qdrant_port}" }, + ], + length(var.allowed_mcp_clients) > 0 ? [ + { name = "ALLOWED_MCP_CLIENTS", value = join(",", var.allowed_mcp_clients) }, + ] : [], + var.allowed_mgmt_client != "" ? [ + { name = "ALLOWED_MGMT_CLIENT", value = var.allowed_mgmt_client }, + ] : [], + ) + + container_secrets = concat( + [ + { name = "NEXTCLOUD_HOST", valueFrom = "${var.secret_arn}:host::" }, + { name = "NEXTCLOUD_OIDC_CLIENT_ID", valueFrom = "${var.secret_arn}:client_id::" }, + { name = "NEXTCLOUD_OIDC_CLIENT_SECRET", valueFrom = "${var.secret_arn}:client_secret::" }, + { name = "TOKEN_ENCRYPTION_KEY", valueFrom = "${var.secret_arn}:token_encryption_key::" }, + { name = "WEBHOOK_SECRET", valueFrom = "${var.secret_arn}:webhook_secret::" }, + ], + var.use_external_qdrant ? [ + { name = "QDRANT_URL", valueFrom = "${var.secret_arn}:qdrant_url::" }, + { name = "QDRANT_API_KEY", valueFrom = "${var.secret_arn}:qdrant_api_key::" }, + ] : [], + ) +} + +resource "aws_ecs_task_definition" "this" { + family = var.name + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.cpu) + memory = tostring(var.memory) + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.task.arn + + volume { + name = "data" + efs_volume_configuration { + file_system_id = aws_efs_file_system.this.id + transit_encryption = "ENABLED" + authorization_config { + access_point_id = aws_efs_access_point.data.id + iam = "ENABLED" + } + } + } + + volume { + name = "oauth" + efs_volume_configuration { + file_system_id = aws_efs_file_system.this.id + transit_encryption = "ENABLED" + authorization_config { + access_point_id = aws_efs_access_point.oauth.id + iam = "ENABLED" + } + } + } + + container_definitions = jsonencode([ + { + name = local.container_name + image = "${var.image}:${var.image_tag}" + essential = true + + command = [ + "--transport", "streamable-http", + "--oauth", + "--port", tostring(var.container_port), + ] + + portMappings = [ + { + containerPort = var.container_port + protocol = "tcp" + }, + ] + + environment = local.container_env + secrets = local.container_secrets + + mountPoints = [ + { sourceVolume = "data", containerPath = "/app/data", readOnly = false }, + { sourceVolume = "oauth", containerPath = "/app/.oauth", readOnly = false }, + ] + + healthCheck = { + command = ["CMD-SHELL", "curl -fsS http://localhost:${var.container_port}/health/live || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.this.name + awslogs-region = data.aws_region.current.region + awslogs-stream-prefix = "ecs" + } + } + }, + ]) +} + +resource "aws_ecs_service" "this" { + name = var.name + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.this.arn + desired_count = 1 + launch_type = "FARGATE" + + deployment_minimum_healthy_percent = 100 + deployment_maximum_percent = 200 + availability_zone_rebalancing = "ENABLED" + + enable_execute_command = true + + network_configuration { + subnets = var.public_subnet_ids + security_groups = [aws_security_group.task.id] + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.this.arn + container_name = local.container_name + container_port = var.container_port + } + + deployment_circuit_breaker { + enable = true + rollback = true + } + + # The listener must exist before the service registers targets. + depends_on = [ + aws_lb_listener.https, + aws_efs_mount_target.this, + ] +} diff --git a/infra/terraform/nextcloud-mcp-server/efs.tf b/infra/terraform/nextcloud-mcp-server/efs.tf new file mode 100644 index 00000000..a6a45f16 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/efs.tf @@ -0,0 +1,121 @@ +resource "aws_security_group" "efs" { + name = "${var.name}-efs" + description = "NFS ingress from ${var.name} ECS tasks" + vpc_id = var.vpc_id +} + +resource "aws_vpc_security_group_ingress_rule" "efs_from_task" { + security_group_id = aws_security_group.efs.id + referenced_security_group_id = aws_security_group.task.id + from_port = 2049 + to_port = 2049 + ip_protocol = "tcp" + description = "NFS from task" +} + +# EFS mount targets accept NFS from any SG referenced by the EFS SG. The +# mcp-server task SG covers itself; the qdrant task SG (when present) gets +# its own ingress rule. +resource "aws_vpc_security_group_ingress_rule" "efs_from_qdrant" { + count = var.use_external_qdrant ? 0 : 1 + security_group_id = aws_security_group.efs.id + referenced_security_group_id = aws_security_group.qdrant[0].id + from_port = 2049 + to_port = 2049 + ip_protocol = "tcp" + description = "NFS from qdrant task" +} + +resource "aws_efs_file_system" "this" { + encrypted = true + performance_mode = "generalPurpose" + throughput_mode = "bursting" + + lifecycle_policy { + transition_to_ia = "AFTER_30_DAYS" + } + + tags = { + Name = var.name + } +} + +resource "aws_efs_mount_target" "this" { + for_each = toset(var.private_subnet_ids) + + file_system_id = aws_efs_file_system.this.id + subnet_id = each.value + security_groups = [aws_security_group.efs.id] +} + +# Container runs as root (upstream Dockerfile has no USER directive), so the +# access points stamp uid/gid 0 on created files. +resource "aws_efs_access_point" "data" { + file_system_id = aws_efs_file_system.this.id + + posix_user { + uid = 0 + gid = 0 + } + + root_directory { + path = "/data" + creation_info { + owner_uid = 0 + owner_gid = 0 + permissions = "0755" + } + } + + tags = { + Name = "${var.name}-data" + } +} + +resource "aws_efs_access_point" "oauth" { + file_system_id = aws_efs_file_system.this.id + + posix_user { + uid = 0 + gid = 0 + } + + root_directory { + path = "/oauth" + creation_info { + owner_uid = 0 + owner_gid = 0 + permissions = "0755" + } + } + + tags = { + Name = "${var.name}-oauth" + } +} + +# Qdrant container runs as root (debian-slim base, no USER directive), matching +# the other access points. Mounted at /qdrant/storage which is qdrant's default +# storage_path. +resource "aws_efs_access_point" "qdrant" { + count = var.use_external_qdrant ? 0 : 1 + file_system_id = aws_efs_file_system.this.id + + posix_user { + uid = 0 + gid = 0 + } + + root_directory { + path = "/qdrant" + creation_info { + owner_uid = 0 + owner_gid = 0 + permissions = "0755" + } + } + + tags = { + Name = "${var.name}-qdrant" + } +} diff --git a/infra/terraform/nextcloud-mcp-server/iam.tf b/infra/terraform/nextcloud-mcp-server/iam.tf new file mode 100644 index 00000000..44809295 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/iam.tf @@ -0,0 +1,155 @@ +data "aws_iam_policy_document" "ecs_tasks_trust" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +### +# Execution role — used by the ECS agent to pull the image, read secrets, +# and ship logs. Does not have application-level permissions. + +resource "aws_iam_role" "execution" { + name = "${var.name}-execution" + path = "/ecs/" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_trust.json +} + +resource "aws_iam_role_policy_attachment" "execution_managed" { + role = aws_iam_role.execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +data "aws_iam_policy_document" "execution_secrets" { + statement { + actions = ["secretsmanager:GetSecretValue"] + resources = [var.secret_arn] + } +} + +resource "aws_iam_role_policy" "execution_secrets" { + name = "secrets-read" + role = aws_iam_role.execution.id + policy = data.aws_iam_policy_document.execution_secrets.json +} + +### +# Task role — used by the running container. Needs Bedrock for embeddings +# and EFS client access to the two access points. + +resource "aws_iam_role" "task" { + name = "${var.name}-task" + path = "/ecs/" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_trust.json +} + +data "aws_iam_policy_document" "task_bedrock" { + statement { + actions = [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ] + resources = ["*"] + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [data.aws_region.current.region] + } + } +} + +resource "aws_iam_role_policy" "task_bedrock" { + name = "bedrock-invoke" + role = aws_iam_role.task.id + policy = data.aws_iam_policy_document.task_bedrock.json +} + +data "aws_iam_policy_document" "task_efs" { + statement { + actions = [ + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite", + "elasticfilesystem:ClientRootAccess", + ] + resources = [aws_efs_file_system.this.arn] + condition { + test = "StringEquals" + variable = "elasticfilesystem:AccessPointArn" + values = [ + aws_efs_access_point.data.arn, + aws_efs_access_point.oauth.arn, + ] + } + } +} + +resource "aws_iam_role_policy" "task_efs" { + name = "efs-client" + role = aws_iam_role.task.id + policy = data.aws_iam_policy_document.task_efs.json +} + +# ECS Exec support — lets us `aws ecs execute-command` into a running task +# for debugging without SSH. +data "aws_iam_policy_document" "task_exec" { + statement { + actions = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "task_exec" { + name = "ecs-exec" + role = aws_iam_role.task.id + policy = data.aws_iam_policy_document.task_exec.json +} + +### +# Qdrant task role — only needs EFS access to its own access point and +# ECS Exec for debugging. Reuses the shared execution role. + +resource "aws_iam_role" "qdrant_task" { + count = var.use_external_qdrant ? 0 : 1 + name = "${local.qdrant_name}-task" + path = "/ecs/" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_trust.json +} + +data "aws_iam_policy_document" "qdrant_task_efs" { + count = var.use_external_qdrant ? 0 : 1 + statement { + actions = [ + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite", + "elasticfilesystem:ClientRootAccess", + ] + resources = [aws_efs_file_system.this.arn] + condition { + test = "StringEquals" + variable = "elasticfilesystem:AccessPointArn" + values = [aws_efs_access_point.qdrant[0].arn] + } + } +} + +resource "aws_iam_role_policy" "qdrant_task_efs" { + count = var.use_external_qdrant ? 0 : 1 + name = "efs-client" + role = aws_iam_role.qdrant_task[0].id + policy = data.aws_iam_policy_document.qdrant_task_efs[0].json +} + +resource "aws_iam_role_policy" "qdrant_task_exec" { + count = var.use_external_qdrant ? 0 : 1 + name = "ecs-exec" + role = aws_iam_role.qdrant_task[0].id + policy = data.aws_iam_policy_document.task_exec.json +} diff --git a/infra/terraform/nextcloud-mcp-server/main.tf b/infra/terraform/nextcloud-mcp-server/main.tf new file mode 100644 index 00000000..ee8a1009 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} + +data "aws_region" "current" {} +data "aws_caller_identity" "current" {} diff --git a/infra/terraform/nextcloud-mcp-server/outputs.tf b/infra/terraform/nextcloud-mcp-server/outputs.tf new file mode 100644 index 00000000..f1486f0e --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/outputs.tf @@ -0,0 +1,48 @@ +output "url" { + description = "Public HTTPS URL of the MCP server" + value = "https://${local.fqdn}" +} + +output "subdomain" { + description = "Generated random subdomain (label only, without the zone)" + value = random_pet.subdomain.id +} + +output "fqdn" { + description = "Fully-qualified domain name" + value = local.fqdn +} + +output "ecs_cluster_name" { + value = aws_ecs_cluster.this.name +} + +output "ecs_service_name" { + value = aws_ecs_service.this.name +} + +output "efs_id" { + value = aws_efs_file_system.this.id +} + +output "log_group_name" { + value = aws_cloudwatch_log_group.this.name +} + +output "task_role_arn" { + value = aws_iam_role.task.arn +} + +output "alb_dns_name" { + value = aws_lb.this.dns_name +} + +output "qdrant_service_name" { + description = "Qdrant ECS service name (null when use_external_qdrant = true)" + value = var.use_external_qdrant ? null : aws_ecs_service.qdrant[0].name +} + +output "qdrant_dns_name" { + description = "Internal DNS name where mcp-server reaches qdrant" + value = "qdrant.${aws_service_discovery_private_dns_namespace.this.name}" +} diff --git a/infra/terraform/nextcloud-mcp-server/qdrant.tf b/infra/terraform/nextcloud-mcp-server/qdrant.tf new file mode 100644 index 00000000..8b8f08c6 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/qdrant.tf @@ -0,0 +1,151 @@ +locals { + qdrant_name = "${var.name}-qdrant" + qdrant_port = 6333 +} + +resource "aws_cloudwatch_log_group" "qdrant" { + count = var.use_external_qdrant ? 0 : 1 + name = "/ecs/${local.qdrant_name}" + retention_in_days = var.log_retention_days +} + +resource "aws_security_group" "qdrant" { + count = var.use_external_qdrant ? 0 : 1 + name = local.qdrant_name + description = "Qdrant ECS task ENI for ${var.name}" + vpc_id = var.vpc_id +} + +resource "aws_vpc_security_group_ingress_rule" "qdrant_from_task" { + count = var.use_external_qdrant ? 0 : 1 + security_group_id = aws_security_group.qdrant[0].id + referenced_security_group_id = aws_security_group.task.id + from_port = local.qdrant_port + to_port = local.qdrant_port + ip_protocol = "tcp" + description = "Qdrant REST from mcp-server" +} + +resource "aws_vpc_security_group_egress_rule" "qdrant_all_v4" { + count = var.use_external_qdrant ? 0 : 1 + security_group_id = aws_security_group.qdrant[0].id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +resource "aws_vpc_security_group_egress_rule" "qdrant_all_v6" { + count = var.use_external_qdrant ? 0 : 1 + security_group_id = aws_security_group.qdrant[0].id + cidr_ipv6 = "::/0" + ip_protocol = "-1" +} + +resource "aws_service_discovery_service" "qdrant" { + count = var.use_external_qdrant ? 0 : 1 + name = "qdrant" + + dns_config { + namespace_id = aws_service_discovery_private_dns_namespace.this.id + routing_policy = "MULTIVALUE" + + dns_records { + type = "A" + ttl = 10 + } + } +} + +resource "aws_ecs_task_definition" "qdrant" { + count = var.use_external_qdrant ? 0 : 1 + family = local.qdrant_name + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.qdrant_cpu) + memory = tostring(var.qdrant_memory) + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.qdrant_task[0].arn + + volume { + name = "storage" + efs_volume_configuration { + file_system_id = aws_efs_file_system.this.id + transit_encryption = "ENABLED" + authorization_config { + access_point_id = aws_efs_access_point.qdrant[0].id + iam = "ENABLED" + } + } + } + + container_definitions = jsonencode([ + { + name = "qdrant" + image = "${var.qdrant_image}:${var.qdrant_image_tag}" + essential = true + + portMappings = [ + { + containerPort = local.qdrant_port + protocol = "tcp" + }, + ] + + mountPoints = [ + { sourceVolume = "storage", containerPath = "/qdrant/storage", readOnly = false }, + ] + + # No container-level healthCheck: qdrant's image (debian:13-slim) ships + # only ca-certificates/tzdata/libunwind8 — no bash, no curl/wget — and + # the qdrant binary has no health subcommand. ECS still detects process + # exit; Cloud Map only routes to RUNNING tasks. + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.qdrant[0].name + awslogs-region = data.aws_region.current.region + awslogs-stream-prefix = "ecs" + } + } + }, + ]) +} + +# Qdrant uses local file storage with a portalocker file lock; concurrent +# instances can't share the EFS path. Stop the old task before starting the +# new one (brief downtime per qdrant deploy — rare). AZ rebalancing requires +# maxPercent>100, which would re-introduce the overlap, so it's disabled. +# The mcp-server service no longer pays this cost. +resource "aws_ecs_service" "qdrant" { + count = var.use_external_qdrant ? 0 : 1 + name = local.qdrant_name + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.qdrant[0].arn + desired_count = 1 + launch_type = "FARGATE" + + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + availability_zone_rebalancing = "DISABLED" + + enable_execute_command = true + + network_configuration { + subnets = var.public_subnet_ids + security_groups = [aws_security_group.qdrant[0].id] + assign_public_ip = true + } + + service_registries { + registry_arn = aws_service_discovery_service.qdrant[0].arn + } + + deployment_circuit_breaker { + enable = true + rollback = true + } + + depends_on = [ + aws_efs_mount_target.this, + ] +} diff --git a/infra/terraform/nextcloud-mcp-server/service_discovery.tf b/infra/terraform/nextcloud-mcp-server/service_discovery.tf new file mode 100644 index 00000000..a36409c9 --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/service_discovery.tf @@ -0,0 +1,5 @@ +resource "aws_service_discovery_private_dns_namespace" "this" { + name = "${var.name}.local" + description = "Private DNS namespace for ${var.name} internal services" + vpc = var.vpc_id +} diff --git a/infra/terraform/nextcloud-mcp-server/vars.tf b/infra/terraform/nextcloud-mcp-server/vars.tf new file mode 100644 index 00000000..2364a8bf --- /dev/null +++ b/infra/terraform/nextcloud-mcp-server/vars.tf @@ -0,0 +1,140 @@ +variable "name" { + description = "Logical name prefix for resources" + type = string + default = "nextcloud-mcp-server" +} + +variable "vpc_id" { + description = "VPC ID to deploy into" + type = string +} + +variable "public_subnet_ids" { + description = "Public subnet IDs (for the ALB and the ECS task ENI). Tasks run with assign_public_ip=true since this VPC has no NAT gateway; the task SG only allows ingress from the ALB SG." + type = list(string) +} + +variable "private_subnet_ids" { + description = "Private subnet IDs (for EFS mount targets only)." + type = list(string) +} + +variable "nextcloud_url" { + description = "Public URL of the Nextcloud instance the MCP server pairs with (e.g., https://cloud.example.com). Used to advertise the OIDC discovery endpoint via /api/v1/status so the astrolabe Nextcloud app can discover Nextcloud's oidc_provider as the IdP instead of falling back to http://localhost." + type = string +} + +variable "zone_id" { + description = "Route53 hosted zone ID for the random subdomain" + type = string +} + +variable "zone_name" { + description = "Route53 hosted zone name (without trailing dot), e.g. astrolabeonline.com" + type = string +} + +variable "image" { + description = "Container image (without tag)" + type = string + default = "ghcr.io/cbcoutinho/nextcloud-mcp-server" +} + +variable "image_tag" { + description = "Container image tag. Pin to a specific release; avoid :latest." + type = string +} + +variable "secret_arn" { + description = "ARN of the Secrets Manager secret holding JSON {host, client_id, client_secret, token_encryption_key, webhook_secret}" + type = string +} + +variable "allowed_mcp_clients" { + description = "MCP OAuth client allowlist published as ALLOWED_MCP_CLIENTS. Each entry is `id` or `id|redirect_uri`. Empty list keeps the upstream defaults (claude-desktop, test-mcp-client)." + type = list(string) + default = [] +} + +variable "allowed_mgmt_client" { + description = "Management API client allowlist published as ALLOWED_MGMT_CLIENT (comma-separated client IDs). Required from upstream v0.74.0+: when unset/empty the management API is fail-closed and rejects all tokens. Empty string skips publishing the env var." + type = string + default = "" +} + +variable "bedrock_embedding_model" { + description = "Bedrock model ID used for semantic search embeddings" + type = string + default = "amazon.titan-embed-text-v2:0" +} + +variable "container_port" { + description = "Port the server listens on inside the container" + type = number + default = 8004 +} + +variable "cpu" { + description = "Fargate task vCPU units (1024 = 1 vCPU)" + type = number + default = 512 +} + +variable "memory" { + description = "Fargate task memory (MiB)" + type = number + default = 1024 +} + +variable "log_retention_days" { + description = "CloudWatch log retention in days" + type = number + default = 30 +} + +variable "vector_sync_scan_interval" { + description = "Seconds between background vector sync scans" + type = number + default = 60 +} + +variable "vector_sync_processor_workers" { + description = "Concurrent embedding workers. Keep at 1 unless you've verified Bedrock quota headroom." + type = number + default = 1 +} + +variable "qdrant_image" { + description = "Qdrant container image (without tag)" + type = string + default = "qdrant/qdrant" +} + +variable "qdrant_collection" { + description = "Qdrant collection name. Set to a stable value (anything other than upstream's default 'nextcloud_content') so the upstream config doesn't fall through to its hostname-based auto-naming, which churns the collection on every rolling deploy." + type = string + default = "nextcloud-mcp" +} + +variable "qdrant_image_tag" { + description = "Qdrant container image tag (e.g., v1.15.0). Pin to a specific release; avoid :latest. Unused when use_external_qdrant = true." + type = string +} + +variable "use_external_qdrant" { + description = "When true, skip the in-AWS Qdrant ECS task and source QDRANT_URL/QDRANT_API_KEY from the Secrets Manager secret (keys: qdrant_url, qdrant_api_key). When false, run an in-AWS Qdrant Fargate task and point the MCP server at it via Cloud Map DNS." + type = bool + default = false +} + +variable "qdrant_cpu" { + description = "Qdrant Fargate task vCPU units (1024 = 1 vCPU)" + type = number + default = 512 +} + +variable "qdrant_memory" { + description = "Qdrant Fargate task memory (MiB)" + type = number + default = 1024 +}