feat(infra): distribute terraform modules under infra/terraform
POC: lift the homelab-grown nextcloud-mcp-server and nextcloud-mcp-deployer-role Terraform modules into this repo so external operators can consume them via a `git::` source. Includes a top-level README documenting the two-phase deploy flow (bootstrap deployer role with a copy-pasteable IAM policy, then assume the role to deploy the MCP server) and supports both in-VPC Qdrant and external/managed Qdrant modes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0c2d3e1086
commit
ccf4b91bf9
@@ -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
|
||||||
@@ -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::<account-id>: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 `<two-words>.<your-zone>` 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": "<oidc-client-id>",
|
||||||
|
"client_secret": "<oidc-client-secret>",
|
||||||
|
"token_encryption_key": "<random 32-byte url-safe base64>",
|
||||||
|
"webhook_secret": "<random shared secret>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `use_external_qdrant = true`, also include:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"qdrant_url": "https://<your-qdrant-cluster>.cloud.qdrant.io:6333",
|
||||||
|
"qdrant_api_key": "<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)
|
||||||
@@ -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::<your-account-id>: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::<client-account-id>:role/clients/nextcloud-mcp-deployer"
|
||||||
|
# external_id = "..." # optional, recommended for cross-account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- BEGIN_TF_DOCS -->
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Name | Version |
|
||||||
|
| ---- | ------- |
|
||||||
|
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | ~> 6.0 |
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
| Name | Version |
|
||||||
|
| ---- | ------- |
|
||||||
|
| <a name="provider_aws"></a> [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 |
|
||||||
|
| ---- | ----------- | ---- | ------- | :------: |
|
||||||
|
| <a name="input_allow_secret_create"></a> [allow\_secret\_create](#input\_allow\_secret\_create) | When true, the deployer can create/update/delete Secrets Manager<br/>secrets matching `secret_name_prefix`. Set true if the secret is<br/>managed alongside the module in the same Terraform run; leave false if<br/>the secret is provisioned out of band (console / separate root TF) and<br/>only the ARN is passed in. | `bool` | `false` | no |
|
||||||
|
| <a name="input_module_name_prefix"></a> [module\_name\_prefix](#input\_module\_name\_prefix) | The `var.name` value passed to the nextcloud-mcp-server module. Used to<br/>scope IAM/logs/secrets ARNs. Defaults match the module default; change<br/>only if the module is instantiated with a non-default name. | `string` | `"nextcloud-mcp-server"` | no |
|
||||||
|
| <a name="input_role_name"></a> [role\_name](#input\_role\_name) | Name of the deployer IAM role. | `string` | `"nextcloud-mcp-deployer"` | no |
|
||||||
|
| <a name="input_role_path"></a> [role\_path](#input\_role\_path) | IAM path for the deployer role and its policy. | `string` | `"/clients/"` | no |
|
||||||
|
| <a name="input_route53_zone_ids"></a> [route53\_zone\_ids](#input\_route53\_zone\_ids) | Route53 hosted zone IDs the deployer is allowed to mutate. Only needed<br/>in the module's custom-domain mode. Leave empty (the default) for the<br/>CloudFront-default-cert path, which requires no DNS or ACM permissions. | `list(string)` | `[]` | no |
|
||||||
|
| <a name="input_secret_name_prefix"></a> [secret\_name\_prefix](#input\_secret\_name\_prefix) | Secrets Manager name prefix the deployer can read (and optionally<br/>create, see `allow_secret_create`). The module accepts a secret ARN as<br/>input; this prefix scopes the deployer's access to secrets matching<br/>that name pattern. | `string` | `"nextcloud-mcp"` | no |
|
||||||
|
| <a name="input_trusted_principal_arns"></a> [trusted\_principal\_arns](#input\_trusted\_principal\_arns) | Principal ARNs allowed to assume this role. For testing in your own<br/>account: the user/role you want to assume from. For client deployments:<br/>typically a single root-account ARN of the deploying party (e.g.<br/>"arn:aws:iam::<your-account-id>:root"), with MFA or external-id<br/>conditions added at the trust-policy level if required. | `list(string)` | n/a | yes |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| <a name="output_policy_arn"></a> [policy\_arn](#output\_policy\_arn) | ARN of the inline-style managed policy attached to the role. |
|
||||||
|
| <a name="output_role_arn"></a> [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. |
|
||||||
|
| <a name="output_role_name"></a> [role\_name](#output\_role\_name) | Name of the deployer role. |
|
||||||
|
<!-- END_TF_DOCS -->
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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::<your-account-id>: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 = []
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!-- BEGIN_TF_DOCS -->
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Name | Version |
|
||||||
|
| ---- | ------- |
|
||||||
|
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | ~> 6.0 |
|
||||||
|
| <a name="requirement_random"></a> [random](#requirement\_random) | ~> 3.6 |
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
| Name | Version |
|
||||||
|
| ---- | ------- |
|
||||||
|
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.43.0 |
|
||||||
|
| <a name="provider_random"></a> [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 |
|
||||||
|
| ---- | ----------- | ---- | ------- | :------: |
|
||||||
|
| <a name="input_allowed_mcp_clients"></a> [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 |
|
||||||
|
| <a name="input_allowed_mgmt_client"></a> [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 |
|
||||||
|
| <a name="input_bedrock_embedding_model"></a> [bedrock\_embedding\_model](#input\_bedrock\_embedding\_model) | Bedrock model ID used for semantic search embeddings | `string` | `"amazon.titan-embed-text-v2:0"` | no |
|
||||||
|
| <a name="input_container_port"></a> [container\_port](#input\_container\_port) | Port the server listens on inside the container | `number` | `8004` | no |
|
||||||
|
| <a name="input_cpu"></a> [cpu](#input\_cpu) | Fargate task vCPU units (1024 = 1 vCPU) | `number` | `512` | no |
|
||||||
|
| <a name="input_image"></a> [image](#input\_image) | Container image (without tag) | `string` | `"ghcr.io/cbcoutinho/nextcloud-mcp-server"` | no |
|
||||||
|
| <a name="input_image_tag"></a> [image\_tag](#input\_image\_tag) | Container image tag. Pin to a specific release; avoid :latest. | `string` | n/a | yes |
|
||||||
|
| <a name="input_log_retention_days"></a> [log\_retention\_days](#input\_log\_retention\_days) | CloudWatch log retention in days | `number` | `30` | no |
|
||||||
|
| <a name="input_memory"></a> [memory](#input\_memory) | Fargate task memory (MiB) | `number` | `1024` | no |
|
||||||
|
| <a name="input_name"></a> [name](#input\_name) | Logical name prefix for resources | `string` | `"nextcloud-mcp-server"` | no |
|
||||||
|
| <a name="input_nextcloud_url"></a> [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 |
|
||||||
|
| <a name="input_private_subnet_ids"></a> [private\_subnet\_ids](#input\_private\_subnet\_ids) | Private subnet IDs (for EFS mount targets only). | `list(string)` | n/a | yes |
|
||||||
|
| <a name="input_public_subnet_ids"></a> [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 |
|
||||||
|
| <a name="input_qdrant_collection"></a> [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 |
|
||||||
|
| <a name="input_qdrant_cpu"></a> [qdrant\_cpu](#input\_qdrant\_cpu) | Qdrant Fargate task vCPU units (1024 = 1 vCPU) | `number` | `512` | no |
|
||||||
|
| <a name="input_qdrant_image"></a> [qdrant\_image](#input\_qdrant\_image) | Qdrant container image (without tag) | `string` | `"qdrant/qdrant"` | no |
|
||||||
|
| <a name="input_qdrant_image_tag"></a> [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 |
|
||||||
|
| <a name="input_qdrant_memory"></a> [qdrant\_memory](#input\_qdrant\_memory) | Qdrant Fargate task memory (MiB) | `number` | `1024` | no |
|
||||||
|
| <a name="input_secret_arn"></a> [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 |
|
||||||
|
| <a name="input_use_external_qdrant"></a> [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 |
|
||||||
|
| <a name="input_vector_sync_processor_workers"></a> [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 |
|
||||||
|
| <a name="input_vector_sync_scan_interval"></a> [vector\_sync\_scan\_interval](#input\_vector\_sync\_scan\_interval) | Seconds between background vector sync scans | `number` | `60` | no |
|
||||||
|
| <a name="input_vpc_id"></a> [vpc\_id](#input\_vpc\_id) | VPC ID to deploy into | `string` | n/a | yes |
|
||||||
|
| <a name="input_zone_id"></a> [zone\_id](#input\_zone\_id) | Route53 hosted zone ID for the random subdomain | `string` | n/a | yes |
|
||||||
|
| <a name="input_zone_name"></a> [zone\_name](#input\_zone\_name) | Route53 hosted zone name (without trailing dot), e.g. astrolabeonline.com | `string` | n/a | yes |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| <a name="output_alb_dns_name"></a> [alb\_dns\_name](#output\_alb\_dns\_name) | n/a |
|
||||||
|
| <a name="output_ecs_cluster_name"></a> [ecs\_cluster\_name](#output\_ecs\_cluster\_name) | n/a |
|
||||||
|
| <a name="output_ecs_service_name"></a> [ecs\_service\_name](#output\_ecs\_service\_name) | n/a |
|
||||||
|
| <a name="output_efs_id"></a> [efs\_id](#output\_efs\_id) | n/a |
|
||||||
|
| <a name="output_fqdn"></a> [fqdn](#output\_fqdn) | Fully-qualified domain name |
|
||||||
|
| <a name="output_log_group_name"></a> [log\_group\_name](#output\_log\_group\_name) | n/a |
|
||||||
|
| <a name="output_qdrant_dns_name"></a> [qdrant\_dns\_name](#output\_qdrant\_dns\_name) | Internal DNS name where mcp-server reaches qdrant |
|
||||||
|
| <a name="output_qdrant_service_name"></a> [qdrant\_service\_name](#output\_qdrant\_service\_name) | Qdrant ECS service name (null when use\_external\_qdrant = true) |
|
||||||
|
| <a name="output_subdomain"></a> [subdomain](#output\_subdomain) | Generated random subdomain (label only, without the zone) |
|
||||||
|
| <a name="output_task_role_arn"></a> [task\_role\_arn](#output\_task\_role\_arn) | n/a |
|
||||||
|
| <a name="output_url"></a> [url](#output\_url) | Public HTTPS URL of the MCP server |
|
||||||
|
<!-- END_TF_DOCS -->
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" {}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user