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>
206 lines
6.5 KiB
Terraform
206 lines
6.5 KiB
Terraform
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,
|
|
]
|
|
}
|