diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8da370 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,18 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + stack: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: forwarder/build.sh + - run: zip betterstack-oci.zip main.tf variables.tf schema.yaml forwarder.zip + - run: gh release create "$GITHUB_REF_NAME" betterstack-oci.zip --generate-notes + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6e23180 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + forwarder: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: python3 -m unittest test_func + working-directory: forwarder + + stack: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + - run: forwarder/build.sh + - run: terraform fmt -check + - run: terraform init -backend=false + - run: terraform validate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c58cd9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +.terraform/ +.terraform.lock.hcl +*.tfstate* +*.zip diff --git a/README.md b/README.md index ec87599..3e41aa4 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,42 @@ # Better Stack Oracle Cloud integration -Terraform stack that connects an Oracle Cloud Infrastructure tenancy to [Better Stack](https://betterstack.com) for metric and log forwarding. +This Terraform stack sends metrics and logs from an Oracle Cloud Infrastructure tenancy to [Better Stack](https://betterstack.com). ## How it works -The stack runs in OCI Resource Manager inside your tenancy. Connector Hub reads **metrics** from OCI Monitoring and **logs** from OCI Logging, then passes them to a forwarder Function. The Function batches them and sends them to Better Stack over HTTPS. +The stack runs in OCI Resource Manager inside your tenancy. Connector Hub reads **metrics** from OCI Monitoring and **logs** from OCI Logging. It sends them in batches to a forwarder Function. The Function compresses each batch and sends it to Better Stack over HTTPS. -Better Stack never connects to your tenancy. Data only leaves through the Function, and the only secret stored in your tenancy is the Better Stack source token. +Better Stack never connects to your tenancy. Data leaves your tenancy only through the Function. The only secret that the stack keeps in your tenancy is the Better Stack source token, in the configuration of the Function. ## Quick start -[Create a new Oracle Cloud source in Better Stack](https://telemetry.betterstack.com/team/t0/sources/new?platform=oci). The source-creation flow hands you a **Deploy to Oracle Cloud** link with every stack variable pre-filled. Click it, review the variables, and click **Create**. +Before you start, sign in to the **home region** of your tenancy. Use a user who can create compartments and policies in the root compartment. -Sign in to your tenancy's home region as a user who can create dynamic groups and policies in the root compartment. +1. [Create an Oracle Cloud source in Better Stack](https://telemetry.betterstack.com/team/0/sources/new?platform=oracle_cloud). +2. On the **Ingest** tab of the source, click **Deploy to Oracle Cloud**. +3. Accept the Oracle Terms of Use and click **Next**. +4. Paste the **Source token** of your source and click **Next**. The **Ingesting host** is already filled in. +5. Make sure that **Run apply** is selected, then click **Create**. -## What gets deployed +The stack creates a compartment with the name `betterstack`. If your tenancy already has a compartment with this name, remove or rename it first. -- A `betterstack` compartment that holds every resource below. -- In each region you pick, a Function application running the forwarder, a VCN with a private subnet and a NAT gateway so the Function can reach Better Stack, and Connector Hubs for metrics and logs. -- Dynamic groups and policies that let Connector Hub read metrics and logs, and invoke the Function. +The stack sends metrics and logs from the home region only. + +## What the stack creates + +- A `betterstack` compartment for all the resources below. +- A Function application for the forwarder. It runs in a VCN with a private subnet. A NAT gateway lets the Function reach Better Stack. +- One Connector Hub for logs, and one or more Connector Hubs for metrics. Each Connector Hub takes up to 5 compartments and 50 metric namespaces. A large tenancy can need a higher Connector Hub service limit. +- A policy that lets the Connector Hubs read metrics and logs, and call the Function. + +When the stack runs, it finds the compartments that have metrics in the selected namespaces. After you add compartments or use new services, apply the stack again. ## Uninstall -Go to **Developer Services → Resource Manager → Stacks**, select the Better Stack stack, and click **Destroy**. +1. Go to **Developer Services → Resource Manager → Stacks** and select the Better Stack stack. +2. Click **Destroy**. + +This takes about 15 minutes. Most of this time goes to removing the `betterstack` compartment. ## License diff --git a/forwarder/build.sh b/forwarder/build.sh new file mode 100755 index 0000000..0919c44 --- /dev/null +++ b/forwarder/build.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Packs the forwarder for a code-only Function: the managed Python runtime +# expects function/func.py at the archive root and ships the Fn SDK itself. +set -eu +cd "$(dirname "$0")/.." +dir=$(mktemp -d) +mkdir "$dir/function" +cp forwarder/func.py "$dir/function/" +rm -f forwarder.zip +(cd "$dir" && zip -qX "$OLDPWD/forwarder.zip" function/func.py) +rm -rf "$dir" diff --git a/forwarder/func.py b/forwarder/func.py new file mode 100644 index 0000000..ed96210 --- /dev/null +++ b/forwarder/func.py @@ -0,0 +1,33 @@ +import gzip +import io +import os +import urllib.request + +from fdk import response + + +class _NoRedirects(urllib.request.HTTPRedirectHandler): + # Following a redirect turns the POST into a bodyless GET and sends the + # token to another host, so a redirect fails the batch instead. + def redirect_request(self, *args, **kwargs): + return None + + +_opener = urllib.request.build_opener(_NoRedirects) + + +def handler(ctx, data: io.BytesIO = None): + # Connector Hub retries a batch when the function fails, so a non-2xx + # response from Better Stack is left to raise. + request = urllib.request.Request( + f"https://{os.environ['BETTERSTACK_INGESTING_HOST']}/oci", + data=gzip.compress(data.getvalue()), + headers={ + "Authorization": f"Bearer {os.environ['BETTERSTACK_SOURCE_TOKEN']}", + "Content-Type": "application/json", + "Content-Encoding": "gzip", + }, + method="POST", + ) + _opener.open(request, timeout=60) + return response.Response(ctx, response_data="") diff --git a/forwarder/test_func.py b/forwarder/test_func.py new file mode 100644 index 0000000..f9a6678 --- /dev/null +++ b/forwarder/test_func.py @@ -0,0 +1,83 @@ +import gzip +import io +import os +import sys +import threading +import types +import unittest +import urllib.error +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest import mock + +sys.modules["fdk"] = types.SimpleNamespace(response=types.SimpleNamespace(Response=lambda ctx, response_data: response_data)) + +import func # noqa: E402 + + +class Ingesting(BaseHTTPRequestHandler): + status = 202 + received = [] + + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + Ingesting.received.append((self.path, dict(self.headers), body)) + self.send_response(Ingesting.status) + if 300 <= Ingesting.status < 400: + self.send_header("Location", "/elsewhere") + self.end_headers() + + def do_GET(self): + Ingesting.received.append((self.path, dict(self.headers), b"")) + self.send_response(200) + self.end_headers() + + def log_message(self, *args): + pass + + +class HandlerTest(unittest.TestCase): + def setUp(self): + Ingesting.received.clear() + Ingesting.status = 202 + self.server = HTTPServer(("127.0.0.1", 0), Ingesting) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + host = f"127.0.0.1:{self.server.server_port}" + # func.py always speaks HTTPS; the test server is plain HTTP. + real_request = urllib.request.Request + self.patches = [ + mock.patch.dict(os.environ, {"BETTERSTACK_INGESTING_HOST": host, "BETTERSTACK_SOURCE_TOKEN": "token"}), + mock.patch("urllib.request.Request", lambda url, **kw: real_request(url.replace("https://", "http://"), **kw)), + ] + for patch in self.patches: + patch.start() + + def tearDown(self): + for patch in self.patches: + patch.stop() + self.server.shutdown() + + def test_posts_the_batch_gzipped_with_the_source_token(self): + func.handler(None, io.BytesIO(b'[{"namespace":"oci_faas"}]')) + + path, headers, body = Ingesting.received[0] + self.assertEqual("/oci", path) + self.assertEqual("Bearer token", headers["Authorization"]) + self.assertEqual("gzip", headers["Content-Encoding"]) + self.assertEqual(b'[{"namespace":"oci_faas"}]', gzip.decompress(body)) + + def test_raises_on_a_redirect_instead_of_following_it(self): + Ingesting.status = 301 + + with self.assertRaises(urllib.error.HTTPError): + func.handler(None, io.BytesIO(b"[]")) + self.assertEqual(1, len(Ingesting.received)) + + def test_raises_on_a_rejected_batch_so_connector_hub_retries(self): + Ingesting.status = 500 + + with self.assertRaises(urllib.error.HTTPError): + func.handler(None, io.BytesIO(b"[]")) + + +if __name__ == "__main__": + unittest.main() diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..6c91bcc --- /dev/null +++ b/main.tf @@ -0,0 +1,306 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + oci = { + source = "oracle/oci" + version = "~> 9.0" + } + } +} + +provider "oci" { + region = var.region +} + +locals { + + connector_principal = "where all {request.principal.type = 'serviceconnector', request.principal.compartment.id = '${oci_identity_compartment.betterstack.id}'}" + + # The form asks for the host without a scheme; a pasted URL still works. + ingesting_host = trimsuffix(trimprefix(trimprefix(trimspace(var.betterstack_ingesting_host), "https://"), "http://"), "/") + + # OCID fields accept commas, spaces or one OCID per line. + selected_compartment_ids = distinct(regexall("[^,\\s]+", var.metrics_compartment_ids)) + log_group_ids = distinct(regexall("[^,\\s]+", var.log_group_ids)) + + # Metrics are read tenancy-wide only when no compartments are chosen. + metrics_statements = length(local.selected_compartment_ids) > 0 ? [ + for id in local.selected_compartment_ids : "Allow any-user to read metrics in compartment id ${id} ${local.connector_principal}" + ] : ["Allow any-user to read metrics in tenancy ${local.connector_principal}"] +} + +data "oci_identity_region_subscriptions" "home" { + tenancy_id = var.tenancy_ocid + + filter { + name = "is_home_region" + values = [true] + } +} + +resource "oci_identity_compartment" "betterstack" { + compartment_id = var.tenancy_ocid + name = "betterstack" + description = "Better Stack metric and log forwarding" + enable_delete = true + + lifecycle { + precondition { + condition = var.region == data.oci_identity_region_subscriptions.home.region_subscriptions[0].region_name + error_message = "Run the stack in your home region. It creates a compartment and policies, which OCI only accepts there." + } + } +} + +resource "oci_identity_policy" "connectors" { + compartment_id = var.tenancy_ocid + name = "betterstack-connectors" + description = "Lets the Better Stack connectors read metrics and logs and invoke the forwarder" + statements = concat(local.metrics_statements, [ + "Allow any-user to read log-content in tenancy ${local.connector_principal}", + "Allow any-user to use fn-function in compartment id ${oci_identity_compartment.betterstack.id} ${local.connector_principal}", + "Allow any-user to use fn-invocation in compartment id ${oci_identity_compartment.betterstack.id} ${local.connector_principal}", + ]) +} + +# The Function has no public IP, so it reaches Better Stack through a NAT gateway. +resource "oci_core_vcn" "betterstack" { + compartment_id = oci_identity_compartment.betterstack.id + display_name = "betterstack" + cidr_blocks = ["10.0.0.0/16"] + dns_label = "betterstack" +} + +resource "oci_core_nat_gateway" "betterstack" { + compartment_id = oci_identity_compartment.betterstack.id + vcn_id = oci_core_vcn.betterstack.id + display_name = "betterstack" +} + +resource "oci_core_route_table" "forwarder" { + compartment_id = oci_identity_compartment.betterstack.id + vcn_id = oci_core_vcn.betterstack.id + display_name = "forwarder" + + route_rules { + destination = "0.0.0.0/0" + network_entity_id = oci_core_nat_gateway.betterstack.id + } +} + +resource "oci_core_security_list" "forwarder" { + compartment_id = oci_identity_compartment.betterstack.id + vcn_id = oci_core_vcn.betterstack.id + display_name = "forwarder" + + egress_security_rules { + destination = "0.0.0.0/0" + protocol = "6" + + tcp_options { + min = 443 + max = 443 + } + } + + # DNS goes to the VCN resolver only. + egress_security_rules { + destination = "169.254.169.254/32" + protocol = "17" + + udp_options { + min = 53 + max = 53 + } + } +} + +resource "oci_core_subnet" "forwarder" { + compartment_id = oci_identity_compartment.betterstack.id + vcn_id = oci_core_vcn.betterstack.id + display_name = "forwarder" + cidr_block = "10.0.0.0/24" + dns_label = "forwarder" + prohibit_public_ip_on_vnic = true + route_table_id = oci_core_route_table.forwarder.id + security_list_ids = [oci_core_security_list.forwarder.id] +} + +resource "oci_functions_application" "betterstack" { + compartment_id = oci_identity_compartment.betterstack.id + display_name = "betterstack" + subnet_ids = [oci_core_subnet.forwarder.id] + shape = "GENERIC_X86_ARM" +} + +# Applying the stack again moves the forwarder to the runtime's current version. +data "oci_functions_functions_runtimes" "python" { + name = "python312.ol9" +} + +data "oci_functions_functions_runtime_versions" "python" { + functions_runtime_id = data.oci_functions_functions_runtimes.python.functions_runtime_collection[0].items[0].id + is_current_version = true +} + +resource "oci_functions_function" "forwarder" { + application_id = oci_functions_application.betterstack.id + display_name = "forwarder" + memory_in_mbs = 256 + timeout_in_seconds = 300 + + # A code-only function: the managed Python runtime ships the Fn SDK, so the + # archive holds only function/func.py and no container image is involved. + source_details { + source_type = "ARCHIVE" + handler = "func.handler" + + archive_source_details { + archive_source_type = "DIRECT_ARCHIVE" + archive_file = filebase64("${path.module}/forwarder.zip") + } + + runtime_config { + runtime_config_type = "MANUAL" + functions_runtime_name = "python312.ol9" + functions_runtime_version_id = data.oci_functions_functions_runtime_versions.python.functions_runtime_version_collection[0].items[0].id + } + } + + config = { + BETTERSTACK_INGESTING_HOST = local.ingesting_host + BETTERSTACK_SOURCE_TOKEN = var.betterstack_source_token + } +} + +# A monitoring source only covers its own compartment, not its children, so +# every compartment with metrics in a selected namespace becomes a source. +data "oci_identity_compartments" "all" { + compartment_id = var.tenancy_ocid + compartment_id_in_subtree = true + access_level = "ANY" + state = "ACTIVE" +} + +locals { + metrics_compartment_ids = length(local.selected_compartment_ids) > 0 ? local.selected_compartment_ids : concat( + [var.tenancy_ocid], + [for compartment in data.oci_identity_compartments.all.compartments : compartment.id if compartment.name != "betterstack"], + ) +} + +data "oci_monitoring_metrics" "namespaces" { + for_each = toset(local.metrics_compartment_ids) + + compartment_id = each.value + group_by = ["namespace"] +} + +locals { + metric_sources = [ + for id in local.metrics_compartment_ids : { + compartment_id = id + namespaces = sort(setintersection(var.metrics_namespaces, data.oci_monitoring_metrics.namespaces[id].metrics[*].namespace)) + } if length(setintersection(var.metrics_namespaces, data.oci_monitoring_metrics.namespaces[id].metrics[*].namespace)) > 0 + ] + + # A connector takes at most 5 compartments and 50 namespaces across them, so + # compartments are packed with others that select as many namespaces: one + # compartment with many namespaces doesn't shrink every other connector. + metric_source_sizes = sort(distinct([for source in local.metric_sources : format("%02d", length(source.namespaces))])) + metric_connectors = concat([], [ + for size in local.metric_source_sizes : chunklist( + [for source in local.metric_sources : source if length(source.namespaces) == tonumber(size)], + min(5, floor(50 / tonumber(size))), + ) + ]...) +} + +resource "oci_sch_service_connector" "metrics" { + count = length(local.metric_connectors) + + compartment_id = oci_identity_compartment.betterstack.id + display_name = "betterstack-metrics-${count.index}" + + source { + kind = "monitoring" + + dynamic "monitoring_sources" { + for_each = local.metric_connectors[count.index] + + content { + compartment_id = monitoring_sources.value.compartment_id + + namespace_details { + kind = "selected" + + dynamic "namespaces" { + for_each = monitoring_sources.value.namespaces + + content { + namespace = namespaces.value + + metrics { + kind = "all" + } + } + } + } + } + } + } + + target { + kind = "functions" + function_id = oci_functions_function.forwarder.id + batch_size_in_kbs = 5000 + batch_time_in_sec = 60 + } + + depends_on = [oci_identity_policy.connectors] +} + +data "oci_logging_log_group" "selected" { + for_each = toset(local.log_group_ids) + + log_group_id = each.value +} + +resource "oci_sch_service_connector" "logs" { + count = var.forward_audit_logs || length(local.log_group_ids) > 0 ? 1 : 0 + + compartment_id = oci_identity_compartment.betterstack.id + display_name = "betterstack-logs" + + source { + kind = "logging" + + dynamic "log_sources" { + for_each = var.forward_audit_logs ? [var.tenancy_ocid] : [] + + content { + compartment_id = log_sources.value + log_group_id = "_Audit_Include_Subcompartment" + } + } + + dynamic "log_sources" { + for_each = data.oci_logging_log_group.selected + + content { + compartment_id = log_sources.value.compartment_id + log_group_id = log_sources.key + } + } + } + + target { + kind = "functions" + function_id = oci_functions_function.forwarder.id + batch_size_in_kbs = 5000 + batch_time_in_sec = 60 + } + + depends_on = [oci_identity_policy.connectors] +} diff --git a/schema.yaml b/schema.yaml new file mode 100644 index 0000000..5c983af --- /dev/null +++ b/schema.yaml @@ -0,0 +1,118 @@ +title: Better Stack +description: Forward Oracle Cloud metrics and logs to Better Stack. +schemaVersion: 1.1.0 +version: "20261109" +locale: en + +variableGroups: + - title: Better Stack source + variables: + - ${betterstack_ingesting_host} + - ${betterstack_source_token} + - title: Metrics + variables: + - ${metrics_compartment_ids} + - ${metrics_namespaces} + - title: Logs + variables: + - ${forward_audit_logs} + - ${log_group_ids} + - title: Hidden + visible: false + variables: + - ${tenancy_ocid} + - ${region} + +variables: + betterstack_ingesting_host: + title: Ingesting host + description: Shown on your source's Ingest tab, for example s1234.eu-nbg-2.betterstackdata.com. + type: string + required: true + pattern: "^\\s*(https?://)?[A-Za-z0-9.-]+(:[0-9]+)?/?\\s*$" + + betterstack_source_token: + title: Source token + type: password + required: true + sensitive: true + + metrics_compartment_ids: + title: Compartments + description: Compartment OCIDs to forward metrics from, separated by commas or new lines. Leave empty for every compartment. + type: text + multiline: true + required: false + + metrics_namespaces: + title: Metric namespaces + description: Clear the list to forward logs only. + type: enum + additionalProps: + allowMultiple: true + required: false + default: + - oci_autonomous_database + - oci_blockstore + - oci_compute_infrastructure_health + - oci_computeagent + - oci_computecontainerinstance + - oci_database + - oci_database_cluster + - oci_dynamic_routing_gateway + - oci_faas + - oci_fastconnect + - oci_filestorage + - oci_gpu_infrastructure_health + - oci_lbaas + - oci_mysql_database + - oci_nat_gateway + - oci_nlb + - oci_objectstorage + - oci_oke + - oci_queue + - oci_rdma_infrastructure_health + - oci_service_gateway + - oci_vcn + - oci_vpn + - oci_waf + - oracle_oci_database + enum: + - oci_autonomous_database + - oci_blockstore + - oci_compute_infrastructure_health + - oci_computeagent + - oci_computecontainerinstance + - oci_database + - oci_database_cluster + - oci_dynamic_routing_gateway + - oci_faas + - oci_fastconnect + - oci_filestorage + - oci_gpu_infrastructure_health + - oci_lbaas + - oci_mysql_database + - oci_nat_gateway + - oci_nlb + - oci_objectstorage + - oci_oke + - oci_queue + - oci_rdma_infrastructure_health + - oci_service_connector_hub + - oci_service_gateway + - oci_vcn + - oci_vpn + - oci_waf + - oracle_oci_database + + forward_audit_logs: + title: Forward audit logs + description: Audit events from every compartment in the tenancy. + type: boolean + + log_group_ids: + title: Log groups + description: Log group OCIDs to forward, separated by commas or new lines. + type: text + multiline: true + required: false diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..bd74f70 --- /dev/null +++ b/variables.tf @@ -0,0 +1,75 @@ +variable "tenancy_ocid" { + type = string +} + +variable "region" { + type = string +} + +variable "betterstack_ingesting_host" { + type = string + description = "Ingesting host of your Better Stack source, without https://." + + validation { + condition = can(regex("^\\s*(https?://)?[A-Za-z0-9.-]+(:[0-9]+)?/?\\s*$", var.betterstack_ingesting_host)) + error_message = "Use the ingesting host shown on your source's Ingest tab, for example s1234.eu-nbg-2.betterstackdata.com." + } +} + +variable "betterstack_source_token" { + type = string + sensitive = true +} + +variable "metrics_compartment_ids" { + type = string + default = "" + description = "Compartment OCIDs to forward metrics from, separated by commas or new lines. Leave empty for every compartment in the tenancy." +} + +variable "metrics_namespaces" { + type = list(string) + default = [ + "oci_autonomous_database", + "oci_blockstore", + "oci_compute_infrastructure_health", + "oci_computeagent", + "oci_computecontainerinstance", + "oci_database", + "oci_database_cluster", + "oci_dynamic_routing_gateway", + "oci_faas", + "oci_fastconnect", + "oci_filestorage", + "oci_gpu_infrastructure_health", + "oci_lbaas", + "oci_mysql_database", + "oci_nat_gateway", + "oci_nlb", + "oci_objectstorage", + "oci_oke", + "oci_queue", + "oci_rdma_infrastructure_health", + "oci_service_gateway", + "oci_vcn", + "oci_vpn", + "oci_waf", + "oracle_oci_database", + ] + + validation { + condition = length(var.metrics_namespaces) <= 50 + error_message = "A connector takes at most 50 metric namespaces." + } +} + +variable "forward_audit_logs" { + type = bool + default = true +} + +variable "log_group_ids" { + type = string + default = "" + description = "Log group OCIDs to forward logs from, separated by commas or new lines." +}