diff --git a/api/v1alpha1/collectionagent/annotations.go b/api/v1alpha1/collectionagent/annotations.go new file mode 100644 index 00000000..fc47428a --- /dev/null +++ b/api/v1alpha1/collectionagent/annotations.go @@ -0,0 +1,37 @@ +package collectionagent + +import "github.com/signoz/foundry/api/v1alpha1" + +// Cluster annotations for the ECS/EC2 deployment of the CollectionAgent Kind. +// The two roles die with the stack, so an absent one is created, not looked up. +var ( + ECSRegion = v1alpha1.Annotation{ + Key: "foundry.signoz.io/ecs-region", + Mode: v1alpha1.ModeEC2, + Description: "AWS region holding the cluster.", + } + ECSClusterARN = v1alpha1.Annotation{ + Key: "foundry.signoz.io/ecs-cluster-arn", + Mode: v1alpha1.ModeEC2, + Description: "ARN of the ECS cluster to run the agent on.", + } + ECSTaskRoleARN = v1alpha1.Annotation{ + Key: "foundry.signoz.io/ecs-task-role-arn", + Mode: v1alpha1.ModeEC2, + Description: "IAM role ARN assumed by the agent task; needs read access to AWS AppConfig. Created when absent.", + } + ECSTaskExecutionRoleARN = v1alpha1.Annotation{ + Key: "foundry.signoz.io/ecs-task-execution-role-arn", + Mode: v1alpha1.ModeEC2, + Description: "IAM role ARN the ECS agent assumes to pull images and start tasks. Created when absent.", + } +) + +func Annotations() []v1alpha1.Annotation { + return []v1alpha1.Annotation{ + ECSRegion, + ECSClusterARN, + ECSTaskRoleARN, + ECSTaskExecutionRoleARN, + } +} diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/README.md b/docs/examples/collectionagent/ecs/ec2/terraform/README.md new file mode 100644 index 00000000..22595ded --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/README.md @@ -0,0 +1,240 @@ +# ECS EC2 Collection Agent + +| Field | Value | +| --- | --- | +| **Kind** | `CollectionAgent` | +| **Platform** | `ecs` | +| **Mode** | `ec2` | +| **Flavor** | `terraform` | + +## Overview + +Deploys a SigNoz Collection Agent onto an existing Amazon ECS cluster backed by EC2 container instances, as an ECS **daemon service**: one agent task on every instance registered to the cluster. The agent runs the OpenTelemetry Collector, collects each instance's telemetry, and exports it, along with anything the tasks on that instance send it, to any SigNoz: Self-Hosted Community, Self-Hosted Enterprise, or SigNoz Cloud. + +- Container metrics from the instance's Docker Engine API through the `docker_stats` receiver, with the ECS cluster, task ARN, family and revision carried onto every metric from the labels ECS stamps on each container +- Instance metrics from the mounted host filesystem through the `hostmetrics` receiver +- Container logs from the Docker log files through the `filelog` receiver +- Task and instance identity through the `resourcedetection` processor's `ecs` and `ec2` detectors +- OTLP intake for your tasks on `localhost:4317` (gRPC) and `localhost:4318` (HTTP) + +The task uses the `host` network mode, so the agent binds its ports on the instance itself and tasks on that instance reach it on localhost. + +Foundry generates Terraform; it does not create the cluster. Everything the agent needs from AWS is either named by annotation or created by the stack. + +## Prerequisites + +- An ECS cluster with registered EC2 container instances (the EC2 launch type; Fargate has no host to run a daemon on) +- Terraform 1.4 or newer, and AWS credentials in the environment with permission to create AppConfig applications, IAM roles, task definitions and services +- A running SigNoz to receive the telemetry: [Self-Hosted Community](../../../../docker/compose/README.md), Self-Hosted Enterprise, or [SigNoz Cloud](https://signoz.io/teams/) + +## Configuration + +The default casting (this directory's `casting.yaml`): + +```yaml +apiVersion: v1alpha1 +kind: CollectionAgent +metadata: + name: signoz + annotations: + foundry.signoz.io/ecs-region: us-east-1 + foundry.signoz.io/ecs-cluster-arn: arn:aws:ecs:us-east-1:123456789012:cluster/signoz +spec: + deployment: + platform: ecs + mode: ec2 + flavor: terraform + collector: + kind: agent + spec: + env: + SIGNOZ_INGESTION_ENDPOINT: "https://ingest.us.signoz.cloud:443" +``` + +### Annotations + +| Annotation | Meaning | +| --- | --- | +| `foundry.signoz.io/ecs-region` | AWS region holding the cluster. Required. | +| `foundry.signoz.io/ecs-cluster-arn` | ARN of the ECS cluster to run the agent on. Required. | +| `foundry.signoz.io/ecs-task-role-arn` | IAM role the agent task assumes. Created when absent. | +| `foundry.signoz.io/ecs-task-execution-role-arn` | IAM role the ECS agent assumes to pull images. Created when absent. | + +The two roles hold no data and die with the stack, so an absent one is created under the workload's own name (`signoz-collectionagent-iam-task`, `signoz-collectionagent-iam-exec`) rather than looked up. State an ARN instead and nothing is created; that role then needs `appconfig:StartConfigurationSession` and `appconfig:GetLatestConfiguration`, which the created role gets automatically. + +`metadata.name` feeds every derived name, and AWS caps them: above 29 +characters the AppConfig deployment strategy name overflows its 64-character +limit, and above 39 the IAM role names overflow theirs. Nothing refuses this at +forge yet, so terraform is where it surfaces. + +### Point the agent at your SigNoz + +Set the endpoint and environment through `spec.collector.spec.env`, which becomes the task's container environment. For SigNoz Cloud or Self-Hosted Enterprise, add the [ingestion key](https://signoz.io/docs/ingestion/signoz-cloud/keys/) as an exporter header through `spec.collector.spec.config.data`: + +```yaml + collector: + kind: agent + spec: + env: + SIGNOZ_INGESTION_ENDPOINT: "https://ingest.us.signoz.cloud:443" + SIGNOZ_INGESTION_KEY: "" + OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=production" + config: + data: + collector/agent/agent.yaml: | + exporters: + otlphttp/signoz: + headers: + signoz-ingestion-key: ${env:SIGNOZ_INGESTION_KEY} +``` + +`spec.collector.spec.env` values land in the task definition in plain text. For a Self-Hosted Community installation the OTLP HTTP ingest is port `4318` on the SigNoz host and there is no ingestion key. + +## Config delivery + +The collector config travels through [AWS AppConfig](https://docs.aws.amazon.com/appconfig/), the ECS analog of a ConfigMap, not through a bucket: + +1. Terraform creates an AppConfig application (`signoz-collectionagent-appconfig`), a `default` environment, and a configuration profile (`collector-agent`) holding the generated `agent.yaml` as a hosted configuration version. +2. An [AppConfig agent](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-containers-agent.html) sidecar in the task fetches that profile and writes it to `/conf/agent.yaml` on a task volume. The collector container waits for the sidecar to report healthy before it starts. +3. The task definition carries a `FOUNDRY_CONFIG_DIGEST` of the config. The collector reads its config once at start, so a changed config has to replace the task; the digest is what makes the revision change. + +The AppConfig application carries the Kind in its name, so a CollectionAgent and an Installation of the same `metadata.name` hold their own configuration on one account. + +## Giving your logs a service name + +The agent reads container logs off the instance's disk, where the only identity +is a 64-character container ID. For a log line to carry the service that +produced it, **the producing task definition** has to ask Docker to write the +ECS labels into every line: + +```json +"logConfiguration": { + "logDriver": "json-file", + "options": { + "max-size": "10m", + "max-file": "3", + "labels": "com.amazonaws.ecs.task-definition-family,com.amazonaws.ecs.container-name,com.amazonaws.ecs.task-arn,com.amazonaws.ecs.cluster" + } +} +``` + +ECS already stamps those four labels on every container. The `labels` option +copies their values into an `attrs` object on each line, and the agent lifts +them onto the record: + +| Label | Becomes | +| --- | --- | +| `…task-definition-family` | `service.name` | +| `…container-name` | `aws.ecs.container.name` | +| `…task-arn` | `aws.ecs.task.arn` | +| `…cluster` | `aws.ecs.cluster.name` | +| container ID from the file path | `container.id` | + +Two things follow from this. + +- **A task without that block still reports**, but with only `container.id`. It + is not dropped. +- **Containers using the `awslogs` driver are invisible to the agent.** That + driver sends straight to CloudWatch and writes nothing to the instance, so + there is no file to read and no error to see. Reading those back requires the + CloudWatch receiver and the CloudWatch cost, which is outside this agent. + +SigNoz's own components deployed by the ECS installation casting do not carry +the block yet, so their logs arrive with only a container ID for now. + +## Reading the agent's own logs + +Both agent containers run with `logDriver: none` by default. That is deliberate: +the agent tails `/var/lib/docker/containers`, so anything it wrote there it +would read back and re-export, and every log line it emitted would produce +another one. + +To read them while testing, patch the driver to `awslogs`: + +```yaml +spec: + patches: + - target: "collectionagent/collector.tf.json" + operations: + - op: replace + path: /locals/containers_collector/1/logConfiguration + value: + logDriver: awslogs + options: + awslogs-group: /signoz/collectionagent/collector/agent + awslogs-region: us-east-1 + awslogs-stream-prefix: ecs +``` + +The log group has to exist first, and the execution role already carries +`AmazonECSTaskExecutionRolePolicy`, which grants the writes. + +## Deploy + +```bash +foundryctl cast -f casting.yaml --yes +``` + +Or step by step: + +```bash +# Validate prerequisites +foundryctl gauge -f casting.yaml + +# Generate the deployment files +foundryctl forge -f casting.yaml + +# Apply them +cd pours/collectionagent && terraform init && terraform apply +``` + +`cast` runs `terraform init`, writes a plan, then applies it. Terraform prompts for approval on its own, so foundry requires `--yes` before it will apply. + +## Generated output + +```text +pours/collectionagent/ + versions.tf.json # required terraform and provider versions + providers.tf.json # the aws provider, in var.aws_region + backend.tf.json # local state, beside the pours + variables.tf.json # every identifier, defaulted to what the casting resolved + terraform.tfvars.json # the region + main.tf.json # AppConfig application, environment, strategy; the two roles + collector.tf.json # config profile and deployment, task definition, daemon service + collector/ + agent/ + agent.yaml # the collector config, uploaded from here +``` + +Terraform state lives beside the pours. Keep it: `melt` needs it to know what to remove. + +## Ports on the instance + +Host networking means these are the instance's own ports, not the task's: + +| Port | Bound by | +| --- | --- | +| 4317 / 4318 | OTLP intake, gRPC and HTTP | +| 13133 | the collector's health check endpoint | +| 2772 | the AppConfig agent's local HTTP endpoint | + +## After deployment + +```bash +# One task per container instance +aws ecs describe-services --cluster --services signoz-collector-agent + +# Agent health, from the instance +curl -fsS localhost:13133/healthz && echo " OK" + +# Remove the daemon service, its task definition and its AppConfig application +foundryctl melt -f casting.yaml --yes +``` + +Point [instrumented applications](https://signoz.io/docs/instrumentation/) at the agent with `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317` (gRPC) or `http://localhost:4318` (HTTP) for tasks using the `host` network mode. Tasks using `awsvpc` do not share the instance's loopback and must address the instance's private IP instead. + +In SigNoz, the instances appear under [Infrastructure Monitoring](https://signoz.io/docs/infrastructure-monitoring/hostmetrics/) and per-container metrics under [Docker container metrics](https://signoz.io/docs/metrics-management/docker-container-metrics/). + +## Customization + +Override any collector setting through `spec.collector.spec.config.data`; user keys win over generated ones, and the merged result is what AppConfig delivers. For changes to the generated Terraform itself (task size, extra mounts, log configuration), use [patches](../../../../../concepts/patches.md). diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/casting.yaml b/docs/examples/collectionagent/ecs/ec2/terraform/casting.yaml new file mode 100644 index 00000000..096a8507 --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/casting.yaml @@ -0,0 +1,17 @@ +apiVersion: v1alpha1 +kind: CollectionAgent +metadata: + name: signoz + annotations: + foundry.signoz.io/ecs-region: us-east-1 + foundry.signoz.io/ecs-cluster-arn: arn:aws:ecs:us-east-1:123456789012:cluster/signoz +spec: + deployment: + platform: ecs + mode: ec2 + flavor: terraform + collector: + kind: agent + spec: + env: + SIGNOZ_INGESTION_ENDPOINT: "https://ingest.us.signoz.cloud:443" diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/casting.yaml.lock b/docs/examples/collectionagent/ecs/ec2/terraform/casting.yaml.lock new file mode 100644 index 00000000..fdd3ab00 --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/casting.yaml.lock @@ -0,0 +1,471 @@ +apiVersion: v1alpha1 +kind: CollectionAgent +metadata: + annotations: + foundry.signoz.io/ecs-cluster-arn: arn:aws:ecs:us-east-1:123456789012:cluster/signoz + foundry.signoz.io/ecs-region: us-east-1 + name: signoz +spec: + collector: + kind: agent + spec: + cluster: + replicas: 1 + config: + data: + collector/agent/agent.yaml: | + exporters: + otlphttp/signoz: + endpoint: ${env:SIGNOZ_INGESTION_ENDPOINT} + extensions: + health_check: + endpoint: 0.0.0.0:13133 + path: /healthz + processors: + batch: + send_batch_max_size: 2048 + send_batch_size: 1000 + timeout: 10s + memory_limiter: + check_interval: 5s + limit_mib: 4000 + spike_limit_mib: 800 + resourcedetection: + detectors: + - env + - ec2 + timeout: 5s + receivers: + docker_stats: + api_version: "1.44" + collection_interval: 60s + container_labels_to_metric_labels: + com.amazonaws.ecs.cluster: aws.ecs.cluster.name + com.amazonaws.ecs.task-arn: aws.ecs.task.arn + com.amazonaws.ecs.task-definition-family: aws.ecs.task.family + com.amazonaws.ecs.task-definition-version: aws.ecs.task.revision + endpoint: unix:///var/run/docker.sock + metrics: + container.blockio.io_service_bytes_recursive: + enabled: true + container.cpu.utilization: + enabled: true + container.memory.percent: + enabled: true + container.memory.usage.limit: + enabled: true + container.memory.usage.total: + enabled: true + container.network.io.usage.rx_bytes: + enabled: true + container.network.io.usage.rx_dropped: + enabled: true + container.network.io.usage.tx_bytes: + enabled: true + container.network.io.usage.tx_dropped: + enabled: true + timeout: 20s + filelog: + include: + - /var/lib/docker/containers/*/*-json.log + include_file_name: false + include_file_path: true + operators: + - on_error: send + parse_to: body + timestamp: + layout: 2006-01-02T15:04:05.999999999Z07:00 + layout_type: gotime + parse_from: body.time + type: json_parser + - combine_field: body.log + combine_with: "" + force_flush_period: 5s + is_last_entry: body.log endsWith "\n" + on_error: send + source_identifier: attributes["log.file.path"] + type: recombine + - from: body.attrs["com.amazonaws.ecs.task-definition-family"] + on_error: send_quiet + to: resource["service.name"] + type: move + - from: body.attrs["com.amazonaws.ecs.container-name"] + on_error: send_quiet + to: resource["aws.ecs.container.name"] + type: move + - from: body.attrs["com.amazonaws.ecs.task-arn"] + on_error: send_quiet + to: resource["aws.ecs.task.arn"] + type: move + - from: body.attrs["com.amazonaws.ecs.cluster"] + on_error: send_quiet + to: resource["aws.ecs.cluster.name"] + type: move + - field: body.attrs + on_error: send_quiet + type: remove + - on_error: send + parse_from: attributes["log.file.path"] + regex: /var/lib/docker/containers/(?P[a-f0-9]{64})/ + type: regex_parser + - from: attributes.container_id + on_error: send_quiet + to: resource["container.id"] + type: move + - from: attributes["log.file.path"] + to: resource["log.file.path"] + type: move + - from: body.stream + to: attributes["log.iostream"] + type: move + - field: body.time + type: remove + - from: body.log + to: body + type: move + start_at: end + filelog/host: + include: + - /hostfs/var/log/messages + - /hostfs/var/log/secure + - /hostfs/var/log/ecs/*.log + include_file_name: true + include_file_path: true + resource: + service.name: ecs-host + start_at: end + hostmetrics: + collection_interval: 60s + root_path: /hostfs + scrapers: + cpu: {} + disk: {} + filesystem: + exclude_fs_types: + fs_types: + - autofs + - binfmt_misc + - bpf + - cgroup2 + - configfs + - debugfs + - devpts + - devtmpfs + - fusectl + - hugetlbfs + - iso9660 + - mqueue + - nsfs + - overlay + - proc + - procfs + - pstore + - rpc_pipefs + - securityfs + - selinuxfs + - squashfs + - sysfs + - tracefs + match_type: strict + exclude_mount_points: + match_type: regexp + mount_points: + - /dev/* + - /proc/* + - /sys/* + - /run/* + - /var/lib/docker/* + load: {} + memory: {} + network: {} + paging: {} + process: + mute_process_exe_error: true + mute_process_io_error: true + mute_process_name_error: true + mute_process_user_error: true + processes: {} + system: {} + otlp/grpc: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + max_recv_msg_size_mib: 16 + otlp/http: + protocols: + http: + endpoint: 0.0.0.0:4318 + service: + extensions: + - health_check + pipelines: + logs: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + - filelog + - filelog/host + metrics: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + - docker_stats + - hostmetrics + traces: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + enabled: true + env: + OTEL_COLLECTOR_ROLE: agent + SIGNOZ_INGESTION_ENDPOINT: https://ingest.us.signoz.cloud:443 + image: otel/opentelemetry-collector-contrib:0.139.0 + version: 0.139.0 + status: + config: + data: + collector/agent/agent.yaml: | + exporters: + otlphttp/signoz: + endpoint: ${env:SIGNOZ_INGESTION_ENDPOINT} + extensions: + health_check: + endpoint: 0.0.0.0:13133 + path: /healthz + processors: + batch: + send_batch_max_size: 2048 + send_batch_size: 1000 + timeout: 10s + memory_limiter: + check_interval: 5s + limit_mib: 4000 + spike_limit_mib: 800 + resourcedetection: + detectors: + - env + - ec2 + timeout: 5s + receivers: + docker_stats: + api_version: "1.44" + collection_interval: 60s + container_labels_to_metric_labels: + com.amazonaws.ecs.cluster: aws.ecs.cluster.name + com.amazonaws.ecs.task-arn: aws.ecs.task.arn + com.amazonaws.ecs.task-definition-family: aws.ecs.task.family + com.amazonaws.ecs.task-definition-version: aws.ecs.task.revision + endpoint: unix:///var/run/docker.sock + metrics: + container.blockio.io_service_bytes_recursive: + enabled: true + container.cpu.utilization: + enabled: true + container.memory.percent: + enabled: true + container.memory.usage.limit: + enabled: true + container.memory.usage.total: + enabled: true + container.network.io.usage.rx_bytes: + enabled: true + container.network.io.usage.rx_dropped: + enabled: true + container.network.io.usage.tx_bytes: + enabled: true + container.network.io.usage.tx_dropped: + enabled: true + timeout: 20s + filelog: + include: + - /var/lib/docker/containers/*/*-json.log + include_file_name: false + include_file_path: true + operators: + - on_error: send + parse_to: body + timestamp: + layout: 2006-01-02T15:04:05.999999999Z07:00 + layout_type: gotime + parse_from: body.time + type: json_parser + - combine_field: body.log + combine_with: "" + force_flush_period: 5s + is_last_entry: body.log endsWith "\n" + on_error: send + source_identifier: attributes["log.file.path"] + type: recombine + - from: body.attrs["com.amazonaws.ecs.task-definition-family"] + on_error: send_quiet + to: resource["service.name"] + type: move + - from: body.attrs["com.amazonaws.ecs.container-name"] + on_error: send_quiet + to: resource["aws.ecs.container.name"] + type: move + - from: body.attrs["com.amazonaws.ecs.task-arn"] + on_error: send_quiet + to: resource["aws.ecs.task.arn"] + type: move + - from: body.attrs["com.amazonaws.ecs.cluster"] + on_error: send_quiet + to: resource["aws.ecs.cluster.name"] + type: move + - field: body.attrs + on_error: send_quiet + type: remove + - on_error: send + parse_from: attributes["log.file.path"] + regex: /var/lib/docker/containers/(?P[a-f0-9]{64})/ + type: regex_parser + - from: attributes.container_id + on_error: send_quiet + to: resource["container.id"] + type: move + - from: attributes["log.file.path"] + to: resource["log.file.path"] + type: move + - from: body.stream + to: attributes["log.iostream"] + type: move + - field: body.time + type: remove + - from: body.log + to: body + type: move + start_at: end + filelog/host: + include: + - /hostfs/var/log/messages + - /hostfs/var/log/secure + - /hostfs/var/log/ecs/*.log + include_file_name: true + include_file_path: true + resource: + service.name: ecs-host + start_at: end + hostmetrics: + collection_interval: 60s + root_path: /hostfs + scrapers: + cpu: {} + disk: {} + filesystem: + exclude_fs_types: + fs_types: + - autofs + - binfmt_misc + - bpf + - cgroup2 + - configfs + - debugfs + - devpts + - devtmpfs + - fusectl + - hugetlbfs + - iso9660 + - mqueue + - nsfs + - overlay + - proc + - procfs + - pstore + - rpc_pipefs + - securityfs + - selinuxfs + - squashfs + - sysfs + - tracefs + match_type: strict + exclude_mount_points: + match_type: regexp + mount_points: + - /dev/* + - /proc/* + - /sys/* + - /run/* + - /var/lib/docker/* + load: {} + memory: {} + network: {} + paging: {} + process: + mute_process_exe_error: true + mute_process_io_error: true + mute_process_name_error: true + mute_process_user_error: true + processes: {} + system: {} + otlp/grpc: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + max_recv_msg_size_mib: 16 + otlp/http: + protocols: + http: + endpoint: 0.0.0.0:4318 + service: + extensions: + - health_check + pipelines: + logs: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + - filelog + - filelog/host + metrics: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + - docker_stats + - hostmetrics + traces: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + env: + OTEL_COLLECTOR_ROLE: agent + SIGNOZ_INGESTION_ENDPOINT: https://ingest.us.signoz.cloud:443 + deployment: + flavor: terraform + mode: ec2 + platform: ecs diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/backend.tf.json b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/backend.tf.json new file mode 100644 index 00000000..fff9005b --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/backend.tf.json @@ -0,0 +1,9 @@ +{ + "terraform": { + "backend": { + "local": { + "path": "terraform.tfstate" + } + } + } +} diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/collector.tf.json b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/collector.tf.json new file mode 100644 index 00000000..6f65b80d --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/collector.tf.json @@ -0,0 +1,149 @@ +{ + "locals": { + "containers_collector": [ + { + "name": "signoz-collector-appconfig-agent", + "image": "public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x", + "essential": true, + "user": "0", + "environment": [ + {"name": "PREFETCH_LIST", "value": "signoz-collectionagent-appconfig:default:collector-agent"}, + {"name": "POLL_INTERVAL", "value": "45s"}, + {"name": "MANIFEST", "value": "{\"signoz-collectionagent-appconfig:default:collector-agent\":{\"writeTo\":{\"path\":\"/conf/agent.yaml\"}}}"} + ], + "mountPoints": [ + { + "sourceVolume": "collector-config", + "containerPath": "/conf" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "test -s /conf/agent.yaml"], + "interval": 5, + "timeout": 3, + "retries": 10, + "startPeriod": 30 + }, + "logConfiguration": {"logDriver": "none"}, + "memoryReservation": 102 + }, + { + "name": "signoz-collector-agent", + "image": "otel/opentelemetry-collector-contrib:0.139.0", + "essential": true, + "user": "0", + "command": ["--config=/conf/agent.yaml"], + "environment": [{"name":"FOUNDRY_CONFIG_DIGEST","value":"19e7f29008b5eceb0379c293cb5b46481879df97ad66be91de2c34364e3efcc6"},{"name":"OTEL_COLLECTOR_ROLE","value":"agent"},{"name":"SIGNOZ_INGESTION_ENDPOINT","value":"https://ingest.us.signoz.cloud:443"}], + "portMappings": [ + {"containerPort": 4317, "hostPort": 4317, "protocol": "tcp"}, + {"containerPort": 4318, "hostPort": 4318, "protocol": "tcp"} + ], + "mountPoints": [ + { + "sourceVolume": "collector-config", + "containerPath": "/conf", + "readOnly": true + }, + { + "sourceVolume": "docker-socket", + "containerPath": "/var/run/docker.sock", + "readOnly": true + }, + { + "sourceVolume": "docker-containers", + "containerPath": "/var/lib/docker/containers", + "readOnly": true + }, + { + "sourceVolume": "hostfs", + "containerPath": "/hostfs", + "readOnly": true + } + ], + "dependsOn": [ + {"containerName": "signoz-collector-appconfig-agent", "condition": "HEALTHY"} + ], + "logConfiguration": {"logDriver": "none"}, + "cpu": 256, + "memoryReservation": 512 + } + ] + }, + "resource": { + "aws_appconfig_configuration_profile": { + "collector": { + "application_id": "${aws_appconfig_application.main.id}", + "name": "collector-agent", + "location_uri": "hosted", + "type": "AWS.Freeform", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + } + }, + "aws_appconfig_hosted_configuration_version": { + "collector": { + "application_id": "${aws_appconfig_application.main.id}", + "configuration_profile_id": "${aws_appconfig_configuration_profile.collector.configuration_profile_id}", + "content_type": "application/x-yaml", + "content": "${file(\"${path.module}/collector/agent/agent.yaml\")}" + } + }, + "aws_appconfig_deployment": { + "collector": { + "application_id": "${aws_appconfig_application.main.id}", + "environment_id": "${aws_appconfig_environment.main.environment_id}", + "configuration_profile_id": "${aws_appconfig_configuration_profile.collector.configuration_profile_id}", + "configuration_version": "${aws_appconfig_hosted_configuration_version.collector.version_number}", + "deployment_strategy_id": "${aws_appconfig_deployment_strategy.main.id}", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + } + }, + "aws_ecs_task_definition": { + "collector": { + "family": "signoz-collector-agent", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"}, + "network_mode": "host", + "requires_compatibilities": ["EC2"], + "task_role_arn": "${local.task_role_arn}", + "execution_role_arn": "${local.execution_role_arn}", + "container_definitions": "${jsonencode(local.containers_collector)}", + "volume": [ + { + "name": "collector-config", + "docker_volume_configuration": { + "scope": "task", + "driver": "local" + } + }, + { + "name": "docker-socket", + "host_path": "/var/run/docker.sock" + }, + { + "name": "docker-containers", + "host_path": "/var/lib/docker/containers" + }, + { + "name": "hostfs", + "host_path": "/" + } + ], + "depends_on": ["aws_appconfig_deployment.collector"] + } + }, + "aws_ecs_service": { + "collector": { + "name": "signoz-collector-agent", + "cluster": "${var.cluster_arn}", + "task_definition": "${aws_ecs_task_definition.collector.arn}", + "scheduling_strategy": "DAEMON", + "deployment_minimum_healthy_percent": 0, + "launch_type": "EC2", + "deployment_circuit_breaker": { + "enable": true, + "rollback": true + }, + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + } + } + } +} diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/collector/agent/agent.yaml b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/collector/agent/agent.yaml new file mode 100644 index 00000000..e7802dac --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/collector/agent/agent.yaml @@ -0,0 +1,219 @@ +exporters: + otlphttp/signoz: + endpoint: ${env:SIGNOZ_INGESTION_ENDPOINT} +extensions: + health_check: + endpoint: 0.0.0.0:13133 + path: /healthz +processors: + batch: + send_batch_max_size: 2048 + send_batch_size: 1000 + timeout: 10s + memory_limiter: + check_interval: 5s + limit_mib: 4000 + spike_limit_mib: 800 + resourcedetection: + detectors: + - env + - ec2 + timeout: 5s +receivers: + docker_stats: + api_version: "1.44" + collection_interval: 60s + container_labels_to_metric_labels: + com.amazonaws.ecs.cluster: aws.ecs.cluster.name + com.amazonaws.ecs.task-arn: aws.ecs.task.arn + com.amazonaws.ecs.task-definition-family: aws.ecs.task.family + com.amazonaws.ecs.task-definition-version: aws.ecs.task.revision + endpoint: unix:///var/run/docker.sock + metrics: + container.blockio.io_service_bytes_recursive: + enabled: true + container.cpu.utilization: + enabled: true + container.memory.percent: + enabled: true + container.memory.usage.limit: + enabled: true + container.memory.usage.total: + enabled: true + container.network.io.usage.rx_bytes: + enabled: true + container.network.io.usage.rx_dropped: + enabled: true + container.network.io.usage.tx_bytes: + enabled: true + container.network.io.usage.tx_dropped: + enabled: true + timeout: 20s + filelog: + include: + - /var/lib/docker/containers/*/*-json.log + include_file_name: false + include_file_path: true + operators: + - on_error: send + parse_to: body + timestamp: + layout: 2006-01-02T15:04:05.999999999Z07:00 + layout_type: gotime + parse_from: body.time + type: json_parser + - combine_field: body.log + combine_with: "" + force_flush_period: 5s + is_last_entry: body.log endsWith "\n" + on_error: send + source_identifier: attributes["log.file.path"] + type: recombine + - from: body.attrs["com.amazonaws.ecs.task-definition-family"] + on_error: send_quiet + to: resource["service.name"] + type: move + - from: body.attrs["com.amazonaws.ecs.container-name"] + on_error: send_quiet + to: resource["aws.ecs.container.name"] + type: move + - from: body.attrs["com.amazonaws.ecs.task-arn"] + on_error: send_quiet + to: resource["aws.ecs.task.arn"] + type: move + - from: body.attrs["com.amazonaws.ecs.cluster"] + on_error: send_quiet + to: resource["aws.ecs.cluster.name"] + type: move + - field: body.attrs + on_error: send_quiet + type: remove + - on_error: send + parse_from: attributes["log.file.path"] + regex: /var/lib/docker/containers/(?P[a-f0-9]{64})/ + type: regex_parser + - from: attributes.container_id + on_error: send_quiet + to: resource["container.id"] + type: move + - from: attributes["log.file.path"] + to: resource["log.file.path"] + type: move + - from: body.stream + to: attributes["log.iostream"] + type: move + - field: body.time + type: remove + - from: body.log + to: body + type: move + start_at: end + filelog/host: + include: + - /hostfs/var/log/messages + - /hostfs/var/log/secure + - /hostfs/var/log/ecs/*.log + include_file_name: true + include_file_path: true + resource: + service.name: ecs-host + start_at: end + hostmetrics: + collection_interval: 60s + root_path: /hostfs + scrapers: + cpu: {} + disk: {} + filesystem: + exclude_fs_types: + fs_types: + - autofs + - binfmt_misc + - bpf + - cgroup2 + - configfs + - debugfs + - devpts + - devtmpfs + - fusectl + - hugetlbfs + - iso9660 + - mqueue + - nsfs + - overlay + - proc + - procfs + - pstore + - rpc_pipefs + - securityfs + - selinuxfs + - squashfs + - sysfs + - tracefs + match_type: strict + exclude_mount_points: + match_type: regexp + mount_points: + - /dev/* + - /proc/* + - /sys/* + - /run/* + - /var/lib/docker/* + load: {} + memory: {} + network: {} + paging: {} + process: + mute_process_exe_error: true + mute_process_io_error: true + mute_process_name_error: true + mute_process_user_error: true + processes: {} + system: {} + otlp/grpc: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + max_recv_msg_size_mib: 16 + otlp/http: + protocols: + http: + endpoint: 0.0.0.0:4318 +service: + extensions: + - health_check + pipelines: + logs: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + - filelog + - filelog/host + metrics: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc + - docker_stats + - hostmetrics + traces: + exporters: + - otlphttp/signoz + processors: + - memory_limiter + - resourcedetection + - batch + receivers: + - otlp/http + - otlp/grpc diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/main.tf.json b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/main.tf.json new file mode 100644 index 00000000..cc8f1b94 --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/main.tf.json @@ -0,0 +1,57 @@ +{ + "locals": { + "task_role_arn": "${aws_iam_role.task.arn}", + "execution_role_arn": "${aws_iam_role.exec.arn}" + }, + "resource": { + "aws_iam_role": { + "task": { + "name": "${var.task_role_name}", + "assume_role_policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Action\" = \"sts:AssumeRole\", \"Effect\" = \"Allow\", \"Principal\" = {\"Service\" = \"ecs-tasks.amazonaws.com\"}}]})}", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + }, + "exec": { + "name": "${var.execution_role_name}", + "assume_role_policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Action\" = \"sts:AssumeRole\", \"Effect\" = \"Allow\", \"Principal\" = {\"Service\" = \"ecs-tasks.amazonaws.com\"}}]})}", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + } + }, + "aws_iam_role_policy_attachment": { + "exec": { + "policy_arn": "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + "role": "${aws_iam_role.exec.name}" + } + }, + "aws_iam_role_policy": { + "task_appconfig_read": { + "name": "${var.task_role_name}-appconfig-read", + "role": "${aws_iam_role.task.id}", + "policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Effect\" = \"Allow\", \"Action\" = [\"appconfig:StartConfigurationSession\"], \"Resource\" = [format(\"%s/environment/*/configuration/*\", aws_appconfig_application.main.arn)]}, {\"Effect\" = \"Allow\", \"Action\" = [\"appconfig:GetLatestConfiguration\"], \"Resource\" = \"*\"}]})}" + } + }, + "aws_appconfig_application": { + "main": { + "name": "signoz-collectionagent-appconfig", + "description": "SigNoz collection agent configuration", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + } + }, + "aws_appconfig_environment": { + "main": { + "name": "default", + "application_id": "${aws_appconfig_application.main.id}", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + } + }, + "aws_appconfig_deployment_strategy": { + "main": { + "name": "signoz-collectionagent-appconfig-strategy", + "deployment_duration_in_minutes": 0, + "final_bake_time_in_minutes": 0, + "growth_factor": 100, + "replicate_to": "NONE", + "tags": {"foundry.signoz.io/kind":"CollectionAgent","foundry.signoz.io/managed-by":"foundry","foundry.signoz.io/name":"signoz"} + } + } + } +} diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/providers.tf.json b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/providers.tf.json new file mode 100644 index 00000000..fcaa4a46 --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/providers.tf.json @@ -0,0 +1,7 @@ +{ + "provider": { + "aws": { + "region": "${var.aws_region}" + } + } +} diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/terraform.tfvars.json b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/terraform.tfvars.json new file mode 100644 index 00000000..860b4336 --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/terraform.tfvars.json @@ -0,0 +1,3 @@ +{ + "aws_region": "us-east-1" +} diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/variables.tf.json b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/variables.tf.json new file mode 100644 index 00000000..310e3bf1 --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/variables.tf.json @@ -0,0 +1,31 @@ +{ + "variable": { + "aws_region": { + "nullable": false, + "validation": { + "condition": "${can(regex(\"^[a-z]{2}(-gov)?-[a-z]+-[0-9]$\", var.aws_region))}", + "error_message": "aws_region must be a region identifier such as us-east-1." + }, + "description": "AWS region holding the cluster", + "type": "string" + }, + "cluster_arn": { + "nullable": false, + "description": "ARN of the ECS cluster the agent runs a task on every instance of", + "type": "string", + "default": "arn:aws:ecs:us-east-1:123456789012:cluster/signoz" + }, + "task_role_name": { + "nullable": false, + "description": "Name of the IAM role this stack creates for its task", + "type": "string", + "default": "signoz-collectionagent-iam-task" + }, + "execution_role_name": { + "nullable": false, + "description": "Name of the IAM role this stack creates for the ECS agent to pull images and write logs", + "type": "string", + "default": "signoz-collectionagent-iam-exec" + } + } +} diff --git a/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/versions.tf.json b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/versions.tf.json new file mode 100644 index 00000000..a9eded70 --- /dev/null +++ b/docs/examples/collectionagent/ecs/ec2/terraform/pours/collectionagent/versions.tf.json @@ -0,0 +1,11 @@ +{ + "terraform": { + "required_version": ">= 1.4.0", + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "~> 5.0" + } + } + } +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/casting.go b/internal/casting/collectionagent/ecsterraformcasting/casting.go new file mode 100644 index 00000000..ef3bee79 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/casting.go @@ -0,0 +1,174 @@ +package ecsterraformcasting + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "log/slog" + "path/filepath" + "strings" + + "github.com/signoz/foundry/api/v1alpha1/collectionagent" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + collectionagentmolding "github.com/signoz/foundry/internal/molding/collectionagent" + "github.com/signoz/foundry/internal/pourer" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/terraformtooler" +) + +// The sidecar writes the config here and the collector reads it. +const configMount = "/conf" + +type ecsCasting struct { + logger *slog.Logger +} + +func New(logger *slog.Logger) *ecsCasting { + return &ecsCasting{logger: logger} +} + +func (c *ecsCasting) Enricher(ctx context.Context, config *collectionagent.Casting) (collectionagentmolding.MoldingEnricher, error) { + return newEcsMoldingEnricher(), nil +} + +func (c *ecsCasting) Forge(ctx context.Context, config collectionagent.Casting, p *pourer.Pourer) error { + data, err := c.templateData(config) + if err != nil { + return err + } + + for _, tmpl := range []*domain.Template{versionsTF, providersTF, backendTF, variablesTF, tfvarsTF, mainTF, collectorTF} { + material, err := tmpl.Render(data, strings.TrimSuffix(tmpl.Name(), ".gotmpl")) + if err != nil { + return err + } + + p.AddJSON(material.FmtContents(), material.Path()) + } + + // AppConfig reads the config off disk at plan time, so the pour is the + // source the hosted configuration version is built from. + for path, content := range config.Spec.Collector.Spec.Config.Data { + p.AddYAML([]byte(content), path) + } + + return nil +} + +func (c *ecsCasting) Cast(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error { + terraform, err := terraformtooler.Lookup(toolers) + if err != nil { + return err + } + + return terraform.Apply(ctx, release(config, outputPath, p)) +} + +// Melt destroys the daemon service, its task definition and its AppConfig +// application. +func (c *ecsCasting) Melt(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error { + terraform, err := terraformtooler.Lookup(toolers) + if err != nil { + return err + } + + return terraform.Destroy(ctx, release(config, outputPath, p)) +} + +func release(config collectionagent.Casting, outputPath string, p *pourer.Pourer) terraformtooler.Release { + return terraformtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + Root: filepath.Join(outputPath, p.Dir()), + } +} + +// Resolves the annotation-derived identifiers the templates render. +func (c *ecsCasting) templateData(config collectionagent.Casting) (templateData, error) { + annotations := config.Metadata.Annotations + + region := collectionagent.ECSRegion.Resolve(annotations) + if region == "" { + return templateData{}, foundryerrors.Newf(foundryerrors.TypeInvalidInput, "no region is stated: state the %q annotation", collectionagent.ECSRegion.Key) + } + + cluster := Reference{Stated: collectionagent.ECSClusterARN.Resolve(annotations)} + if !cluster.IsStated() { + return templateData{}, foundryerrors.Newf(foundryerrors.TypeInvalidInput, "no cluster is stated: state the %q annotation", collectionagent.ECSClusterARN.Key) + } + + // Several workloads share one cluster, so a cluster-derived name collides on + // the second apply. + workload := config.Metadata.Name + "-" + strings.ToLower(config.Kind().String()) + + configKey := config.Spec.Collector.Kind.ConfigKey() + + return templateData{ + Casting: config, + Region: region, + Cluster: cluster, + TaskRole: Reference{ + Stated: collectionagent.ECSTaskRoleARN.Resolve(annotations), + Name: workload + "-iam-task", + }, + ExecutionRole: Reference{ + Stated: collectionagent.ECSTaskExecutionRoleARN.Resolve(annotations), + Name: workload + "-iam-exec", + }, + Application: workload + "-appconfig", + Environment: "default", + Profile: strings.ReplaceAll(filepath.Dir(configKey), "/", "-"), + Source: configKey, + Target: filepath.Join(configMount, filepath.Base(configKey)), + Digest: digest(config.Spec.Collector.Spec.Config.Data[configKey]), + ConfigMount: configMount, + }, nil +} + +func digest(content string) string { + sum := sha256.Sum256([]byte(content)) + + return hex.EncodeToString(sum[:]) +} + +// Reference is one identifier, stated by an operator or created under the +// workload's own name. Exactly one side is populated. +type Reference struct { + Stated string + Name string +} + +func (r Reference) IsStated() bool { + return r.Stated != "" +} + +// Embeds the casting so .Spec and .Metadata stay reachable from templates. +type templateData struct { + collectionagent.Casting + + Region string + + Cluster Reference + + // The roles are the workload's own identity, created and destroyed with + // this stack. + TaskRole Reference + ExecutionRole Reference + + // Application carries the Kind, so a CollectionAgent and an Installation of + // the same metadata.name do not collide on one account. + Application string + Environment string + + // Source is where the pour keeps the config, relative to the root; Target is + // where the sidecar writes it in the task. + Profile string + Source string + Target string + + // The collector reads its config once at start, so a changed config has to + // replace the task. + Digest string + + ConfigMount string +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/embed.go b/internal/casting/collectionagent/ecsterraformcasting/embed.go new file mode 100644 index 00000000..f27703b0 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/embed.go @@ -0,0 +1,22 @@ +package ecsterraformcasting + +import ( + "embed" + + "github.com/signoz/foundry/internal/domain" +) + +//go:embed templates/*.gotmpl +var templates embed.FS + +var ( + versionsTF = domain.MustNewTemplateFromFS(templates, "templates/versions.tf.json.gotmpl", domain.FormatJSON) + providersTF = domain.MustNewTemplateFromFS(templates, "templates/providers.tf.json.gotmpl", domain.FormatJSON) + backendTF = domain.MustNewTemplateFromFS(templates, "templates/backend.tf.json.gotmpl", domain.FormatJSON) + variablesTF = domain.MustNewTemplateFromFS(templates, "templates/variables.tf.json.gotmpl", domain.FormatJSON) + tfvarsTF = domain.MustNewTemplateFromFS(templates, "templates/terraform.tfvars.json.gotmpl", domain.FormatJSON) + mainTF = domain.MustNewTemplateFromFS(templates, "templates/main.tf.json.gotmpl", domain.FormatJSON) + collectorTF = domain.MustNewTemplateFromFS(templates, "templates/collector.tf.json.gotmpl", domain.FormatJSON) + + agentYAMLTemplate = domain.MustNewTemplateFromFS(templates, "templates/agent.yaml.gotmpl", domain.FormatYAML) +) diff --git a/internal/casting/collectionagent/ecsterraformcasting/embed_test.go b/internal/casting/collectionagent/ecsterraformcasting/embed_test.go new file mode 100644 index 00000000..8836dc9c --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/embed_test.go @@ -0,0 +1,281 @@ +package ecsterraformcasting + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/collectionagent" + "github.com/signoz/foundry/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// statedCasting is a casting whose every axis is stated, so no role is created. +func statedCasting(t *testing.T) *collectionagent.Casting { + t.Helper() + + config := collectionagent.Default() + config.Metadata.Annotations = map[string]string{ + collectionagent.ECSRegion.Key: "us-east-1", + collectionagent.ECSClusterARN.Key: "arn:aws:ecs:us-east-1:123456789012:cluster/signoz", + collectionagent.ECSTaskRoleARN.Key: "arn:aws:iam::123456789012:role/task", + collectionagent.ECSTaskExecutionRoleARN.Key: "arn:aws:iam::123456789012:role/exec", + } + + return config +} + +// derivedCasting states only what has nothing to derive it, so roles are created. +func derivedCasting(t *testing.T) *collectionagent.Casting { + t.Helper() + + config := collectionagent.Default() + config.Metadata.Annotations = map[string]string{ + collectionagent.ECSRegion.Key: "us-east-1", + collectionagent.ECSClusterARN.Key: "arn:aws:ecs:us-east-1:123456789012:cluster/signoz", + } + + return config +} + +func data(t *testing.T, config *collectionagent.Casting) templateData { + t.Helper() + + data, err := New(nil).templateData(*config) + require.NoError(t, err) + + return data +} + +func TestNotEmptyAndValid(t *testing.T) { + for _, config := range map[string]*collectionagent.Casting{ + "Stated": statedCasting(t), + "Derived": derivedCasting(t), + } { + for name, tmpl := range map[string]*domain.Template{ + "versionsTF": versionsTF, + "providersTF": providersTF, + "backendTF": backendTF, + "variablesTF": variablesTF, + "tfvarsTF": tfvarsTF, + "mainTF": mainTF, + "collectorTF": collectorTF, + } { + buf := bytes.NewBuffer(nil) + err := tmpl.Execute(buf, data(t, config)) + + assert.NoError(t, err, "error executing %s", name) + assert.NotEmpty(t, buf.String(), "%s output should not be empty", name) + } + } + + assert.NotEmpty(t, agentYAMLTemplate) + + buf := bytes.NewBuffer(nil) + err := agentYAMLTemplate.Execute(buf, nil) + + assert.NoError(t, err) + assert.NotEmpty(t, buf.String()) +} + +func TestTemplateData(t *testing.T) { + for _, test := range []struct { + name string + annotations map[string]string + pass bool + }{ + { + name: "AllStated_Valid", + annotations: statedCasting(t).Metadata.Annotations, + pass: true, + }, + { + name: "RolesUnstated_Valid", + annotations: derivedCasting(t).Metadata.Annotations, + pass: true, + }, + { + name: "UnstatedRegion_Invalid", + annotations: map[string]string{collectionagent.ECSClusterARN.Key: "arn:aws:ecs:us-east-1:123456789012:cluster/signoz"}, + pass: false, + }, + { + name: "UnstatedCluster_Invalid", + annotations: map[string]string{collectionagent.ECSRegion.Key: "us-east-1"}, + pass: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + config := collectionagent.Default() + config.Metadata.Annotations = test.annotations + + _, err := New(nil).templateData(*config) + + if !test.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +func TestCollectorTemplateAppConfigDelivery(t *testing.T) { + config := statedCasting(t) + config.Spec.Collector.Spec.Config.Data = map[string]string{ + config.Spec.Collector.Kind.ConfigKey(): "receivers: {}\n", + } + + buf := bytes.NewBuffer(nil) + require.NoError(t, collectorTF.Execute(buf, data(t, config))) + + material, err := domain.NewJSONMaterial(buf.Bytes(), "collector.tf.json") + require.NoError(t, err) + + for path, expected := range map[string]string{ + "resource.aws_ecs_service.collector.scheduling_strategy": "DAEMON", + + // A daemon rolls a revision only if it may drop to zero healthy on an + // instance, and a bad revision has to roll itself back. + "resource.aws_ecs_service.collector.deployment_minimum_healthy_percent": "0", + "resource.aws_ecs_service.collector.deployment_circuit_breaker.enable": "true", + "resource.aws_ecs_service.collector.deployment_circuit_breaker.rollback": "true", + + "resource.aws_ecs_task_definition.collector.network_mode": "host", + "resource.aws_appconfig_configuration_profile.collector.name": "collector-agent", + "resource.aws_appconfig_configuration_profile.collector.location_uri": "hosted", + "resource.aws_appconfig_hosted_configuration_version.collector.content_type": "application/x-yaml", + + // json-file on either container makes the collector tail its own log. + "locals.containers_collector.0.logConfiguration.logDriver": "none", + "locals.containers_collector.1.logConfiguration.logDriver": "none", + } { + value, err := material.GetBytes(path) + + assert.NoError(t, err, "reading %s", path) + assert.Equal(t, expected, string(value), "at %s", path) + } + + // The provider refuses deployment_maximum_percent on a DAEMON service. + _, err = material.GetBytes("resource.aws_ecs_service.collector.deployment_maximum_percent") + assert.Error(t, err, "deployment_maximum_percent is not valid with DAEMON") + + // The config reaches the task through the AppConfig agent, never a bucket. + assert.Contains(t, buf.String(), "aws-appconfig-agent") + assert.Contains(t, buf.String(), "FOUNDRY_CONFIG_DIGEST") + assert.NotContains(t, buf.String(), "aws_s3_object") + + // A changed config has to replace the task, so the digest is in the + // definition. + other := statedCasting(t) + other.Spec.Collector.Spec.Config.Data = map[string]string{ + other.Spec.Collector.Kind.ConfigKey(): "receivers: {otlp: {}}\n", + } + + otherBuf := bytes.NewBuffer(nil) + require.NoError(t, collectorTF.Execute(otherBuf, data(t, other))) + + assert.NotEqual(t, buf.String(), otherBuf.String()) +} + +func TestMainTemplateRoleOwnership(t *testing.T) { + derived := bytes.NewBuffer(nil) + require.NoError(t, mainTF.Execute(derived, data(t, derivedCasting(t)))) + + assert.Contains(t, derived.String(), "aws_iam_role") + assert.Contains(t, derived.String(), "appconfig:StartConfigurationSession") + assert.Contains(t, derived.String(), v1alpha1.LabelManagedBy.Value) + + stated := bytes.NewBuffer(nil) + require.NoError(t, mainTF.Execute(stated, data(t, statedCasting(t)))) + + // Nothing is created for a role the operator brought. + assert.NotContains(t, stated.String(), "aws_iam_role") + assert.NotContains(t, stated.String(), "appconfig:StartConfigurationSession") + assert.Contains(t, stated.String(), "aws_appconfig_application") + + material, err := domain.NewJSONMaterial(stated.Bytes(), "main.tf.json") + require.NoError(t, err) + + for path, expected := range map[string]string{ + "resource.aws_appconfig_application.main.name": "signoz-collectionagent-appconfig", + "resource.aws_appconfig_deployment_strategy.main.name": "signoz-collectionagent-appconfig-strategy", + } { + value, err := material.GetBytes(path) + + assert.NoError(t, err, "reading %s", path) + assert.Equal(t, expected, string(value), "at %s", path) + } +} + +// The ecs and docker detectors answer for the collector's own task, so on a +// DAEMON they stamp every container's telemetry with it. +func TestAgentConfigDetectors(t *testing.T) { + material, err := agentYAMLTemplate.Render(nil, "agent.yaml") + require.NoError(t, err) + + structured, ok := material.(domain.StructuredMaterial) + require.True(t, ok) + + for path, expected := range map[string]string{ + "processors.resourcedetection.detectors.0": "env", + "processors.resourcedetection.detectors.1": "ec2", + } { + value, err := structured.GetBytes(path) + + assert.NoError(t, err, "reading %s", path) + assert.Equal(t, expected, string(value), "at %s", path) + } + + _, err = structured.GetBytes("processors.resourcedetection.detectors.2") + assert.Error(t, err, "detectors must hold exactly env and ec2") +} + +// A task without the labels option writes no attrs. Those records must still +// arrive with container.id, and the miss must not be logged. +func TestAgentConfigMoveOperatorsTolerateMissingLabels(t *testing.T) { + material, err := agentYAMLTemplate.Render(nil, "agent.yaml") + require.NoError(t, err) + + structured, ok := material.(domain.StructuredMaterial) + require.True(t, ok) + + guarded := 0 + + for i := range 32 { + kind, err := structured.GetBytes(fmt.Sprintf("receivers.filelog.operators.%d.type", i)) + if err != nil { + break + } + + if string(kind) != "move" { + continue + } + + from, err := structured.GetBytes(fmt.Sprintf("receivers.filelog.operators.%d.from", i)) + require.NoError(t, err, "operator %d is a move with no from", i) + + // body.stream, body.log and log.file.path are always present on a + // docker json line; only the label lifts and the carved id can miss. + source := string(from) + if !strings.HasPrefix(source, "body.attrs") && source != "attributes.container_id" { + continue + } + + guarded++ + + onError, err := structured.GetBytes(fmt.Sprintf("receivers.filelog.operators.%d.on_error", i)) + if !assert.NoError(t, err, "%q must tolerate a miss", source) { + continue + } + + // send would log one error per operator per line for every unlabelled task. + assert.Equal(t, "send_quiet", string(onError), "at %q", source) + } + + assert.Equal(t, 5, guarded, "four ecs label lifts plus the carved container id") +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/enricher.go b/internal/casting/collectionagent/ecsterraformcasting/enricher.go new file mode 100644 index 00000000..feae40ca --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/enricher.go @@ -0,0 +1,38 @@ +package ecsterraformcasting + +import ( + "bytes" + "context" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/collectionagent" + foundryerrors "github.com/signoz/foundry/internal/errors" + collectionagentmolding "github.com/signoz/foundry/internal/molding/collectionagent" +) + +var _ collectionagentmolding.MoldingEnricher = (*ecsMoldingEnricher)(nil) + +type ecsMoldingEnricher struct{} + +func newEcsMoldingEnricher() *ecsMoldingEnricher { + return &ecsMoldingEnricher{} +} + +func (e *ecsMoldingEnricher) EnrichStatus(ctx context.Context, kind v1alpha1.MoldingKind, config *collectionagent.Casting) error { + if kind != v1alpha1.MoldingKindCollector { + return nil + } + + if config.Spec.Collector.Kind != collectionagent.CollectorKindAgent { + return nil + } + + buf := bytes.NewBuffer(nil) + if err := agentYAMLTemplate.Execute(buf, nil); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to execute agent template") + } + + config.Spec.Collector.Status.Config.Set(config.Spec.Collector.Kind.ConfigKey(), buf.Bytes()) + + return nil +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/agent.yaml.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/agent.yaml.gotmpl new file mode 100644 index 00000000..2b3156e6 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/agent.yaml.gotmpl @@ -0,0 +1,173 @@ +receivers: + docker_stats: + endpoint: unix:///var/run/docker.sock + api_version: "1.44" + collection_interval: 60s + timeout: 20s + container_labels_to_metric_labels: + com.amazonaws.ecs.cluster: aws.ecs.cluster.name + com.amazonaws.ecs.task-arn: aws.ecs.task.arn + com.amazonaws.ecs.task-definition-family: aws.ecs.task.family + com.amazonaws.ecs.task-definition-version: aws.ecs.task.revision + metrics: + container.cpu.utilization: + enabled: true + container.memory.percent: + enabled: true + container.memory.usage.limit: + enabled: true + container.memory.usage.total: + enabled: true + container.network.io.usage.rx_bytes: + enabled: true + container.network.io.usage.tx_bytes: + enabled: true + container.network.io.usage.rx_dropped: + enabled: true + container.network.io.usage.tx_dropped: + enabled: true + container.blockio.io_service_bytes_recursive: + enabled: true + hostmetrics: + collection_interval: 60s + root_path: /hostfs + scrapers: + cpu: {} + memory: {} + disk: {} + filesystem: + exclude_fs_types: + match_type: strict + fs_types: + - autofs + - binfmt_misc + - bpf + - cgroup2 + - configfs + - debugfs + - devpts + - devtmpfs + - fusectl + - hugetlbfs + - iso9660 + - mqueue + - nsfs + - overlay + - proc + - procfs + - pstore + - rpc_pipefs + - securityfs + - selinuxfs + - squashfs + - sysfs + - tracefs + exclude_mount_points: + match_type: regexp + mount_points: + - /dev/* + - /proc/* + - /sys/* + - /run/* + - /var/lib/docker/* + network: {} + load: {} + paging: {} + process: + mute_process_name_error: true + mute_process_exe_error: true + mute_process_io_error: true + mute_process_user_error: true + processes: {} + system: {} + filelog: + include: + - /var/lib/docker/containers/*/*-json.log + start_at: end + include_file_name: false + include_file_path: true + operators: + # json-file's shape is always present, so a failure means the input changed. + # Without the timestamp the record carries the collector's read time. + - type: json_parser + parse_to: body + on_error: send + timestamp: + parse_from: body.time + layout_type: gotime + layout: '2006-01-02T15:04:05.999999999Z07:00' + - type: recombine + combine_field: body.log + combine_with: "" + is_last_entry: body.log endsWith "\n" + source_identifier: attributes["log.file.path"] + force_flush_period: 5s + on_error: send + # Anything foundry did not generate carries no labels and so no attrs; + # send_quiet keeps the record instead of logging per operator per line. + - type: move + from: body.attrs["com.amazonaws.ecs.task-definition-family"] + to: resource["service.name"] + on_error: send_quiet + - type: move + from: body.attrs["com.amazonaws.ecs.container-name"] + to: resource["aws.ecs.container.name"] + on_error: send_quiet + - type: move + from: body.attrs["com.amazonaws.ecs.task-arn"] + to: resource["aws.ecs.task.arn"] + on_error: send_quiet + - type: move + from: body.attrs["com.amazonaws.ecs.cluster"] + to: resource["aws.ecs.cluster.name"] + on_error: send_quiet + - type: remove + field: body.attrs + on_error: send_quiet + - type: regex_parser + parse_from: attributes["log.file.path"] + regex: '/var/lib/docker/containers/(?P[a-f0-9]{64})/' + on_error: send + - type: move + from: attributes.container_id + to: resource["container.id"] + on_error: send_quiet + - type: move + from: attributes["log.file.path"] + to: resource["log.file.path"] + - type: move + from: body.stream + to: attributes["log.iostream"] + - type: remove + field: body.time + - type: move + from: body.log + to: body + + # The task already mounts / at /hostfs, so these paths need no second mount. + filelog/host: + include: + - /hostfs/var/log/messages + - /hostfs/var/log/secure + - /hostfs/var/log/ecs/*.log + start_at: end + include_file_path: true + # All three sources share one service.name, so the file name separates them. + include_file_name: true + resource: + service.name: ecs-host + +processors: + # Never ecs or docker: on a DAEMON both answer for the collector's own task. + resourcedetection: + detectors: + - env + - ec2 + timeout: 5s + +service: + pipelines: + metrics: + receivers: [docker_stats, hostmetrics] + logs: + receivers: [filelog, filelog/host] diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/backend.tf.json.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/backend.tf.json.gotmpl new file mode 100644 index 00000000..fff9005b --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/backend.tf.json.gotmpl @@ -0,0 +1,9 @@ +{ + "terraform": { + "backend": { + "local": { + "path": "terraform.tfstate" + } + } + } +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/collector.tf.json.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/collector.tf.json.gotmpl new file mode 100644 index 00000000..f9d5ddad --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/collector.tf.json.gotmpl @@ -0,0 +1,168 @@ +{{- $name := $.Metadata.Name -}} +{{- $kind := $.Spec.Collector.Kind -}} +{{- $service := printf "%s-collector-%s" $name $kind.String -}} +{{- $sidecar := printf "%s-collector-appconfig-agent" $name -}} +{{- $profile := printf "%s:%s:%s" $.Application $.Environment $.Profile -}} +{ + "locals": { + "containers_collector": [ + {{- /* The agent writes 0600 and writeTo has no mode setting, so only + root reads what it wrote. */}} + { + "name": "{{ $sidecar }}", + "image": "public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x", + "essential": true, + "user": "0", + "environment": [ + {"name": "PREFETCH_LIST", "value": "{{ $profile }}"}, + {"name": "POLL_INTERVAL", "value": "45s"}, + {"name": "MANIFEST", "value": "{\"{{ $profile }}\":{\"writeTo\":{\"path\":\"{{ $.Target }}\"}}}"} + ], + "mountPoints": [ + { + "sourceVolume": "collector-config", + "containerPath": "{{ $.ConfigMount }}" + } + ], + "healthCheck": { + "command": ["CMD-SHELL", "test -s {{ $.Target }}"], + "interval": 5, + "timeout": 3, + "retries": 10, + "startPeriod": 30 + }, + {{- /* Without this ECS falls back to json-file and the collector + tails its own log through the docker-containers mount. */}} + "logConfiguration": {"logDriver": "none"}, + "memoryReservation": 102 + }, + { + "name": "{{ $service }}", + "image": "{{ $.Spec.Collector.Spec.Image }}", + "essential": true, + "user": "0", + "command": ["--config={{ $.Target }}"], + {{- $env := list (dict "name" "FOUNDRY_CONFIG_DIGEST" "value" $.Digest) }} + {{- range $key, $value := $.Spec.Collector.Spec.Env }} + {{- $env = append $env (dict "name" $key "value" $value) }} + {{- end }} + "environment": {{ toJson $env }}, + "portMappings": [ + {"containerPort": 4317, "hostPort": 4317, "protocol": "tcp"}, + {"containerPort": 4318, "hostPort": 4318, "protocol": "tcp"} + ], + "mountPoints": [ + { + "sourceVolume": "collector-config", + "containerPath": "{{ $.ConfigMount }}", + "readOnly": true + }, + { + "sourceVolume": "docker-socket", + "containerPath": "/var/run/docker.sock", + "readOnly": true + }, + { + "sourceVolume": "docker-containers", + "containerPath": "/var/lib/docker/containers", + "readOnly": true + }, + { + "sourceVolume": "hostfs", + "containerPath": "/hostfs", + "readOnly": true + } + ], + "dependsOn": [ + {"containerName": "{{ $sidecar }}", "condition": "HEALTHY"} + ], + "logConfiguration": {"logDriver": "none"}, + "cpu": 256, + "memoryReservation": 512 + } + ] + }, + "resource": { + "aws_appconfig_configuration_profile": { + "collector": { + "application_id": "${aws_appconfig_application.main.id}", + "name": "{{ $.Profile }}", + "location_uri": "hosted", + "type": "AWS.Freeform", + "tags": {{ toJson $.Labels }} + } + }, + "aws_appconfig_hosted_configuration_version": { + "collector": { + "application_id": "${aws_appconfig_application.main.id}", + "configuration_profile_id": "${aws_appconfig_configuration_profile.collector.configuration_profile_id}", + "content_type": "application/x-yaml", + "content": "${file(\"${path.module}/{{ $.Source }}\")}" + } + }, + "aws_appconfig_deployment": { + "collector": { + "application_id": "${aws_appconfig_application.main.id}", + "environment_id": "${aws_appconfig_environment.main.environment_id}", + "configuration_profile_id": "${aws_appconfig_configuration_profile.collector.configuration_profile_id}", + "configuration_version": "${aws_appconfig_hosted_configuration_version.collector.version_number}", + "deployment_strategy_id": "${aws_appconfig_deployment_strategy.main.id}", + "tags": {{ toJson $.Labels }} + } + }, + "aws_ecs_task_definition": { + "collector": { + "family": "{{ $service }}", + "tags": {{ toJson $.Labels }}, + {{- /* The OTLP ports are the instance's own, so a task on it reaches + the agent on localhost. */}} + "network_mode": "host", + "requires_compatibilities": ["EC2"], + "task_role_arn": "${local.task_role_arn}", + "execution_role_arn": "${local.execution_role_arn}", + "container_definitions": "${jsonencode(local.containers_collector)}", + "volume": [ + { + "name": "collector-config", + "docker_volume_configuration": { + "scope": "task", + "driver": "local" + } + }, + { + "name": "docker-socket", + "host_path": "/var/run/docker.sock" + }, + { + "name": "docker-containers", + "host_path": "/var/lib/docker/containers" + }, + { + "name": "hostfs", + "host_path": "/" + } + ], + "depends_on": ["aws_appconfig_deployment.collector"] + } + }, + "aws_ecs_service": { + "collector": { + "name": "{{ $service }}", + "cluster": "${var.cluster_arn}", + "task_definition": "${aws_ecs_task_definition.collector.arn}", + {{- /* A daemon takes no desired count and no maximum percent; the + minimum is what lets ECS replace the task on an instance. */}} + "scheduling_strategy": "DAEMON", + "deployment_minimum_healthy_percent": 0, + "launch_type": "EC2", + {{- /* The digest replaces the task on every config change, so a bad + revision would otherwise reach every instance and stay. */}} + "deployment_circuit_breaker": { + "enable": true, + "rollback": true + }, + "tags": {{ toJson $.Labels }} + } + } + } +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/main.tf.json.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/main.tf.json.gotmpl new file mode 100644 index 00000000..f4b79707 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/main.tf.json.gotmpl @@ -0,0 +1,76 @@ +{ + "locals": { + "task_role_arn": "{{ if $.TaskRole.IsStated }}${var.task_role_arn}{{ else }}${aws_iam_role.task.arn}{{ end }}", + "execution_role_arn": "{{ if $.ExecutionRole.IsStated }}${var.execution_role_arn}{{ else }}${aws_iam_role.exec.arn}{{ end }}" + }, + "resource": { + {{- /* A role holds no data and dies with this stack. A stated ARN + creates none. */}} + {{- if or (not $.TaskRole.IsStated) (not $.ExecutionRole.IsStated) }} + "aws_iam_role": { + {{- $first := true }} + {{- if not $.TaskRole.IsStated }}{{ if not $first }},{{ end }}{{ $first = false }} + "task": { + "name": "${var.task_role_name}", + "assume_role_policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Action\" = \"sts:AssumeRole\", \"Effect\" = \"Allow\", \"Principal\" = {\"Service\" = \"ecs-tasks.amazonaws.com\"}}]})}", + "tags": {{ toJson $.Labels }} + } + {{- end }} + {{- if not $.ExecutionRole.IsStated }}{{ if not $first }},{{ end }}{{ $first = false }} + "exec": { + "name": "${var.execution_role_name}", + "assume_role_policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Action\" = \"sts:AssumeRole\", \"Effect\" = \"Allow\", \"Principal\" = {\"Service\" = \"ecs-tasks.amazonaws.com\"}}]})}", + "tags": {{ toJson $.Labels }} + } + {{- end }} + }, + {{- end }} + {{- if not $.ExecutionRole.IsStated }} + "aws_iam_role_policy_attachment": { + "exec": { + "policy_arn": "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + "role": "${aws_iam_role.exec.name}" + } + }, + {{- end }} + {{- if not $.TaskRole.IsStated }} + {{- /* GetLatestConfiguration acts on a session token and cannot be + scoped to the application. */}} + "aws_iam_role_policy": { + "task_appconfig_read": { + "name": "${var.task_role_name}-appconfig-read", + "role": "${aws_iam_role.task.id}", + "policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Effect\" = \"Allow\", \"Action\" = [\"appconfig:StartConfigurationSession\"], \"Resource\" = [format(\"%s/environment/*/configuration/*\", aws_appconfig_application.main.arn)]}, {\"Effect\" = \"Allow\", \"Action\" = [\"appconfig:GetLatestConfiguration\"], \"Resource\" = \"*\"}]})}" + } + }, + {{- end }} + {{- /* The application name carries the Kind, so an Installation of the + same name on the same account holds its own. */}} + "aws_appconfig_application": { + "main": { + "name": "{{ $.Application }}", + "description": "SigNoz collection agent configuration", + "tags": {{ toJson $.Labels }} + } + }, + "aws_appconfig_environment": { + "main": { + "name": "{{ $.Environment }}", + "application_id": "${aws_appconfig_application.main.id}", + "tags": {{ toJson $.Labels }} + } + }, + {{- /* AppConfig serializes deployments per environment, so bake time is + paid once per component on every apply. */}} + "aws_appconfig_deployment_strategy": { + "main": { + "name": "{{ $.Application }}-strategy", + "deployment_duration_in_minutes": 0, + "final_bake_time_in_minutes": 0, + "growth_factor": 100, + "replicate_to": "NONE", + "tags": {{ toJson $.Labels }} + } + } + } +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/providers.tf.json.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/providers.tf.json.gotmpl new file mode 100644 index 00000000..fcaa4a46 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/providers.tf.json.gotmpl @@ -0,0 +1,7 @@ +{ + "provider": { + "aws": { + "region": "${var.aws_region}" + } + } +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/terraform.tfvars.json.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/terraform.tfvars.json.gotmpl new file mode 100644 index 00000000..02158340 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/terraform.tfvars.json.gotmpl @@ -0,0 +1,3 @@ +{ + "aws_region": "{{ $.Region }}" +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/variables.tf.json.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/variables.tf.json.gotmpl new file mode 100644 index 00000000..b7e42bb1 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/variables.tf.json.gotmpl @@ -0,0 +1,51 @@ +{{- /* Every identifier is a variable defaulted to what the casting resolved. + A stated role swaps its name variable for the ARN itself. */ -}} +{ + "variable": { + "aws_region": { + "nullable": false, + "validation": { + "condition": "${can(regex(\"^[a-z]{2}(-gov)?-[a-z]+-[0-9]$\", var.aws_region))}", + "error_message": "aws_region must be a region identifier such as us-east-1." + }, + "description": "AWS region holding the cluster", + "type": "string" + }, + "cluster_arn": { + "nullable": false, + "description": "ARN of the ECS cluster the agent runs a task on every instance of", + "type": "string", + "default": "{{ $.Cluster.Stated }}" + }, + {{- if $.TaskRole.IsStated }} + "task_role_arn": { + "nullable": false, + "description": "ARN of the IAM role the agent task assumes", + "type": "string", + "default": "{{ $.TaskRole.Stated }}" + }, + {{- else }} + "task_role_name": { + "nullable": false, + "description": "Name of the IAM role this stack creates for its task", + "type": "string", + "default": "{{ $.TaskRole.Name }}" + }, + {{- end }} + {{- if $.ExecutionRole.IsStated }} + "execution_role_arn": { + "nullable": false, + "description": "ARN of the IAM role the ECS agent assumes", + "type": "string", + "default": "{{ $.ExecutionRole.Stated }}" + } + {{- else }} + "execution_role_name": { + "nullable": false, + "description": "Name of the IAM role this stack creates for the ECS agent to pull images and write logs", + "type": "string", + "default": "{{ $.ExecutionRole.Name }}" + } + {{- end }} + } +} diff --git a/internal/casting/collectionagent/ecsterraformcasting/templates/versions.tf.json.gotmpl b/internal/casting/collectionagent/ecsterraformcasting/templates/versions.tf.json.gotmpl new file mode 100644 index 00000000..a9eded70 --- /dev/null +++ b/internal/casting/collectionagent/ecsterraformcasting/templates/versions.tf.json.gotmpl @@ -0,0 +1,11 @@ +{ + "terraform": { + "required_version": ">= 1.4.0", + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "~> 5.0" + } + } + } +} diff --git a/internal/casting/collectionagent/registry.go b/internal/casting/collectionagent/registry.go index f4b5399a..c80dad50 100644 --- a/internal/casting/collectionagent/registry.go +++ b/internal/casting/collectionagent/registry.go @@ -6,10 +6,12 @@ import ( "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/internal/casting/collectionagent/dockercomposecasting" "github.com/signoz/foundry/internal/casting/collectionagent/dockerswarmcasting" + "github.com/signoz/foundry/internal/casting/collectionagent/ecsterraformcasting" foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/tooler" "github.com/signoz/foundry/internal/tooler/dockercomposetooler" "github.com/signoz/foundry/internal/tooler/dockerswarmtooler" + "github.com/signoz/foundry/internal/tooler/terraformtooler" ) type CastingItem struct { @@ -39,6 +41,14 @@ func NewRegistry(logger *slog.Logger) *Registry { Casting: dockerswarmcasting.New(logger), Toolers: []tooler.Tooler{dockerswarmtooler.New(logger)}, }, + { + Platform: v1alpha1.PlatformECS, + Mode: v1alpha1.ModeEC2, + Flavor: v1alpha1.FlavorTerraform, + }: { + Casting: ecsterraformcasting.New(logger), + Toolers: []tooler.Tooler{terraformtooler.New(logger)}, + }, }, } }