From 099c733bbd2e4b4fa8869c9c2c782aff6b80dd35 Mon Sep 17 00:00:00 2001 From: Andrei Soroker Date: Tue, 3 Sep 2024 17:21:30 -0700 Subject: [PATCH 1/3] Sync with upstream: commit f6175dac4c3205d5a60a4eced5acb80a0f346e5d Author: Div Arora AuthorDate: Fri Aug 2 12:18:17 2024 +0800 Commit: Div Arora CommitDate: Fri Aug 2 12:18:17 2024 +0800 chore: increase scrape interval to 60s Most of our metrics currently cache for 60s; there scraping more frequently than that. --- supabase-grafana/prometheus/prometheus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase-grafana/prometheus/prometheus.yml b/supabase-grafana/prometheus/prometheus.yml index 512aeca..b6846f4 100644 --- a/supabase-grafana/prometheus/prometheus.yml +++ b/supabase-grafana/prometheus/prometheus.yml @@ -1,4 +1,4 @@ global: - scrape_interval: 5s + scrape_interval: 60s scrape_configs: From c0806204c7779482699fdff26d10320a26e16522 Mon Sep 17 00:00:00 2001 From: Andrei Soroker Date: Thu, 12 Sep 2024 19:10:32 -0700 Subject: [PATCH 2/3] Alerts --- infra/modules/grafana-template.bicep | 18 +- infra/secrets/supafana-absdev1.env | 8 +- infra/templates/grafana-template.json | 24 +- infra/templates/grafana-template.version | 2 +- nix/hosts/grafana/grafana-container.nix | 2 +- scripts/azure-upload-image-gallery.sh | 2 +- server/config/runtime.exs | 6 + server/lib/supafana/azure/api.ex | 24 +- server/lib/supafana/azure/template_spec.ex | 2 +- server/lib/supafana/data/alert.ex | 27 + .../lib/supafana/data/email_alert_contact.ex | 27 + server/lib/supafana/data/grafana.ex | 4 +- server/lib/supafana/grafana/alerts.ex | 457 ++++++++++++++ server/lib/supafana/grafana/api.ex | 269 ++++++++ server/lib/supafana/web/router.ex | 428 ++++++++++++- server/lib/supafana/web/z_type.ex | 19 + server/priv/repo/db-dump.sql | 46 +- .../repo/migrations/20240914002713_alerts.exs | 43 ++ storefront/src/types/z_types.ts | 12 + storefront/src/ui/Project.tsx | 434 +------------ storefront/src/ui/SupabaseProject.tsx | 124 ++++ storefront/src/ui/SupafanaProject.tsx | 597 ++++++++++++++++++ storefront/src/ui/client.tsx | 2 + .../ui/landing/assets/grafana-logo-icon.svg | 57 ++ supabase-grafana/Dockerfile | 16 +- 25 files changed, 2198 insertions(+), 452 deletions(-) create mode 100644 server/lib/supafana/data/alert.ex create mode 100644 server/lib/supafana/data/email_alert_contact.ex create mode 100644 server/lib/supafana/grafana/alerts.ex create mode 100644 server/lib/supafana/grafana/api.ex create mode 100644 server/priv/repo/migrations/20240914002713_alerts.exs create mode 100644 storefront/src/ui/SupabaseProject.tsx create mode 100644 storefront/src/ui/SupafanaProject.tsx create mode 100644 storefront/src/ui/landing/assets/grafana-logo-icon.svg diff --git a/infra/modules/grafana-template.bicep b/infra/modules/grafana-template.bicep index 4bb6f14..aafac33 100644 --- a/infra/modules/grafana-template.bicep +++ b/infra/modules/grafana-template.bicep @@ -2,7 +2,12 @@ param location string = resourceGroup().location param env string param supafanaDomain string param supabaseProjectRef string +param supabaseProjectName string param supabaseServiceRoleKey string +param smtpHost string +param smtpUser string +param smtpPassword string +param smtpFromAddress string @secure() param grafanaPassword string @@ -13,7 +18,7 @@ param grafanaSubnetName string = 'supafana-${env}-grafana-subnet' param commonResourceGroupName string = 'supafana-common-rg' param imageGalleryName string = 'supafanasig' param imageName string = 'grafana' -param imageVersion string = '0.0.5' +param imageVersion string = '0.0.13' param projectId string = supabaseProjectRef @@ -27,6 +32,8 @@ var networkInterfaceName = '${vmName}-nic' param privateDnsZoneName string = 'supafana-${env}.local' +var smtpFromName = 'Grafana alerts for ${supabaseProjectName}' + var customDataRaw = format(''' #cloud-config write_files: @@ -36,8 +43,15 @@ write_files: GF_SERVER_ROOT_URL=https://{3}/dashboard/{2} GF_SERVER_SERVE_FROM_SUB_PATH=true GRAFANA_PASSWORD={4} + GF_SMTP_ENABLED=true + GF_SMTP_HOST={5} + GF_SMTP_USER={6} + GF_SMTP_PASSWORD={7} + GF_SMTP_FROM_ADDRESS={8} + GF_SMTP_FROM_NAME={9} + SUPABASE_PROJECT_NAME={10} path: /var/lib/supafana/supafana.env -''', supabaseProjectRef, supabaseServiceRoleKey, projectId, supafanaDomain, grafanaPassword) +''', supabaseProjectRef, supabaseServiceRoleKey, projectId, supafanaDomain, grafanaPassword, smtpHost, smtpUser, smtpPassword, smtpFromAddress, smtpFromName, supabaseProjectName) resource vnet 'Microsoft.Network/virtualNetworks@2021-05-01' existing = { name: virtualNetworkName diff --git a/infra/secrets/supafana-absdev1.env b/infra/secrets/supafana-absdev1.env index 3a1d0c9..e3c7573 100644 --- a/infra/secrets/supafana-absdev1.env +++ b/infra/secrets/supafana-absdev1.env @@ -9,12 +9,16 @@ STRIPE_SECRET_KEY=ENC[AES256_GCM,data:F9PGDFuDGa0RXLTseKbjtof/8fytgn1ranSeNw4TZO STRIPE_PRICE_ID=ENC[AES256_GCM,data:4tMJqku3zF/FI8yTdV3InKQy+MDSusCtloGnM400lko=,iv:+5xJgKWqMOBC5C0daV6qZaGoONKAnSyKIGEIHnCKYQQ=,tag:6e/Pz52iDTE2TypD/L+K0w==,type:str] SUPAFANA_AZURE_TENANT_ID=ENC[AES256_GCM,data:AU7MFFvgB4gdb+yHADeL9qWxZMazRAplNqzCpcGPZpit7tjddcY=,iv:VecaWNabawmd+4LRmUCwEw4Ql9FoG4lumA3pHKkcRm8=,tag:bz5NJwVrgZMxtq9V/GhE2w==,type:str] SUPAFANA_AZURE_SUBSCRIPTION_ID=ENC[AES256_GCM,data:xi7PMVajtfU1FXfRmfaIUReDpt206iyWWxt4EAG36UwfinmBe6I=,iv:ZZ0L/Fx94RC6CbfaeaIkCavBRlH+XBWzAProfkbFg2k=,tag:tq463QBsEemui1HG9T7WWg==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:zqqDBY8l+jf7Q7VV9ZqrPvpan/O3+sV5QTjwmANSYLot/1ZhghRC6A0=,iv:W4/cTo4Ys5atsBCO6/KNJlz7FdIYafp7CsOXzJgQkI8=,tag:H9KpIQOmRl+lKq9hx5dGpg==,type:str] +SMTP_USER=ENC[AES256_GCM,data:SYAaPh9CX+C5MywlEnD5C3zrC5nTyg==,iv:jdB/U9OD0L1XVKpBUG190Zfw6tDz8xRedbj7RGZxnE0=,tag:UxZG8Os83gqSQZeAm+r7EA==,type:str] +SMTP_PASSWORD=ENC[AES256_GCM,data:UisslpYl/y5vOnBr7uqE1gyT08XHR8wHnIt3K5wocNxRRwD02M1geRE5uxeXmg==,iv:R726lsmsYzhqIymp7YJ7lZM8/Kbw+JZX3vOnQTsQ5W0=,tag:OStkDbYhdwVxKmVv6U4ztA==,type:str] +SMTP_FROM_ADDRESS=ENC[AES256_GCM,data:Ey1USM7/xl1w4NIEQLSnSNUcWoRLQBCjKhC5,iv:GxRdYI548M8Lut5ByVGU2uw+WgRiizvC23j1JlJusb8=,tag:smxJnTuncCnFm+ESnQyUhw==,type:str] sops_azure_kv__list_0__map_created_at=2024-08-18T11:00:17Z sops_azure_kv__list_0__map_enc=AfDvX65YS0aBsDY_ES7t8Iv5S2F3z48Gjvbu6iTK09B20db8tnhcEh-WcWFFuPzUJzYp1eliRD_J6TPHqloIFw26kNjizNz-OqUttQmyTwdROsD9WnpIM4XBIDxWv88sYl3h_GCkZrRsx8zaBDWBQALt5kL8_wRvNIHm0Pt1QL9iCbyiHsISgfe_Psg45zyiPI-IyeHeb9_4MlZjVKPdcn6O3JsmLJrNaPe2f0tBrIgRu7y0kQdqtsHcjUsV0DandG2tylSgTVOkYjzgvKTena09DJQ7iIqN5kLZuayE76vuGE7Wk_7aU3-yIx_Z9Ip6NNEj3T8qZqWjTbvkqsaF-A sops_azure_kv__list_0__map_name=sops-key sops_azure_kv__list_0__map_vault_url=https://supafana-absdev1-vault.vault.azure.net sops_azure_kv__list_0__map_version=bacf2c0de40e4c5e9423f8b27e92381d -sops_lastmodified=2024-08-18T11:00:20Z -sops_mac=ENC[AES256_GCM,data:NN/S8iYCtUO/AwDPtj+LIzy3mX5f4M/nCYQ0oOGwsh7heNxq0PJ79ED1X3sweEX3Uk+IB2ZhEZx1ND6547TVckY68Qc7mT2GvRKtKYOmTZQ1GYyc0M8PD2PL8zYHh1XBUJC5pyNWj3KzLnZpA9q9w46ziCGu63ak2RzRZrYfaqQ=,iv:do3Uernjw0H7lfJpXkku8Id/Rm22tfVTOsqKsrMOnMg=,tag:OY6pvRUgvUXb6qvhiqtxXQ==,type:str] +sops_lastmodified=2024-09-12T23:46:25Z +sops_mac=ENC[AES256_GCM,data:xjb8ETZ642Ms+PUWQYbl7heFjXG/rpBh7K+PQetwkBLKHwqRv+Nrck9WILm+ui8508cJiKuJDIFwusRI+x8YaYcxlncxGLWZOpfnFrblVsSDKFAklGIfkvt0TLO6W16FKVWixvdHeevqAz/+QcLaeK4Q+VOqwHbAzq5wtr/NaKw=,iv:29+lKPmmAZS9ZcyTVj9RhNoZsUEMw1q70NR026V9Dms=,tag:3PWpksbisR1knhwBgw7aOQ==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.8.1 diff --git a/infra/templates/grafana-template.json b/infra/templates/grafana-template.json index 6c9ec2d..d67e327 100644 --- a/infra/templates/grafana-template.json +++ b/infra/templates/grafana-template.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.26", - "templateHash": "17905435322716412113" + "version": "0.28.1.47646", + "templateHash": "12594621635294195744" } }, "parameters": { @@ -22,9 +22,24 @@ "supabaseProjectRef": { "type": "string" }, + "supabaseProjectName": { + "type": "string" + }, "supabaseServiceRoleKey": { "type": "string" }, + "smtpHost": { + "type": "string" + }, + "smtpUser": { + "type": "string" + }, + "smtpPassword": { + "type": "string" + }, + "smtpFromAddress": { + "type": "string" + }, "grafanaPassword": { "type": "securestring" }, @@ -50,7 +65,7 @@ }, "imageVersion": { "type": "string", - "defaultValue": "0.0.5" + "defaultValue": "0.0.13" }, "projectId": { "type": "string", @@ -80,7 +95,8 @@ "variables": { "osDiskName": "[format('{0}-os-disk', parameters('vmName'))]", "networkInterfaceName": "[format('{0}-nic', parameters('vmName'))]", - "customDataRaw": "[format('#cloud-config\nwrite_files:\n- content: |\n SUPABASE_PROJECT_REF={0}\n SUPABASE_SERVICE_ROLE_KEY={1}\n GF_SERVER_ROOT_URL=https://{3}/dashboard/{2}\n GF_SERVER_SERVE_FROM_SUB_PATH=true\n GRAFANA_PASSWORD={4}\n path: /var/lib/supafana/supafana.env\n', parameters('supabaseProjectRef'), parameters('supabaseServiceRoleKey'), parameters('projectId'), parameters('supafanaDomain'), parameters('grafanaPassword'))]", + "smtpFromName": "[format('Grafana alerts for {0}', parameters('supabaseProjectName'))]", + "customDataRaw": "[format('#cloud-config\nwrite_files:\n- content: |\n SUPABASE_PROJECT_REF={0}\n SUPABASE_SERVICE_ROLE_KEY={1}\n GF_SERVER_ROOT_URL=https://{3}/dashboard/{2}\n GF_SERVER_SERVE_FROM_SUB_PATH=true\n GRAFANA_PASSWORD={4}\n GF_SMTP_ENABLED=true\n GF_SMTP_HOST={5}\n GF_SMTP_USER={6}\n GF_SMTP_PASSWORD={7}\n GF_SMTP_FROM_ADDRESS={8}\n GF_SMTP_FROM_NAME={9}\n SUPABASE_PROJECT_NAME={10}\n path: /var/lib/supafana/supafana.env\n', parameters('supabaseProjectRef'), parameters('supabaseServiceRoleKey'), parameters('projectId'), parameters('supafanaDomain'), parameters('grafanaPassword'), parameters('smtpHost'), parameters('smtpUser'), parameters('smtpPassword'), parameters('smtpFromAddress'), variables('smtpFromName'), parameters('supabaseProjectName'))]", "grafanaSubnetId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('virtualNetworkName'), parameters('grafanaSubnetName'))]", "tags": { "vm": "[parameters('vmName')]" diff --git a/infra/templates/grafana-template.version b/infra/templates/grafana-template.version index f15297b..f0aabf3 100644 --- a/infra/templates/grafana-template.version +++ b/infra/templates/grafana-template.version @@ -1 +1 @@ -2024.8.2 +2024.9.1 diff --git a/nix/hosts/grafana/grafana-container.nix b/nix/hosts/grafana/grafana-container.nix index b142fe8..d624693 100644 --- a/nix/hosts/grafana/grafana-container.nix +++ b/nix/hosts/grafana/grafana-container.nix @@ -7,7 +7,7 @@ in networking.firewall.allowedTCPPorts = [ 8080 9090]; virtualisation.oci-containers.containers.grafana = { - image = "supafanacr.azurecr.io/supabase-grafana:2024.07.18"; + image = "supafanacr.azurecr.io/supabase-grafana:2024.09.06"; login.registry = "https://supafanacr.azurecr.io"; # readonly principal login.username = "df1f34b8-bc26-4606-b4cc-c1e08511e709"; diff --git a/scripts/azure-upload-image-gallery.sh b/scripts/azure-upload-image-gallery.sh index 1a791f6..7fd529b 100755 --- a/scripts/azure-upload-image-gallery.sh +++ b/scripts/azure-upload-image-gallery.sh @@ -214,7 +214,7 @@ then --output tsv )" - azcopy copy "${img_file}" "${sasurl}" \ + AZCOPY_CONCURRENCY_VALUE=10 AZCOPY_REQUEST_TRY_TIMEOUT=100 azcopy copy "${img_file}" "${sasurl}" \ --blob-type PageBlob # https://docs.microsoft.com/en-us/cli/azure/disk?view=azure-cli-latest#az-disk-revoke-access diff --git a/server/config/runtime.exs b/server/config/runtime.exs index cb022ff..27d32f5 100644 --- a/server/config/runtime.exs +++ b/server/config/runtime.exs @@ -56,6 +56,12 @@ config :supafana, azure_resource_group: System.get_env("SUPAFANA_AZURE_RESOURCE_GROUP"), azure_subscription_id: System.get_env("SUPAFANA_AZURE_SUBSCRIPTION_ID") +config :supafana, + smtp_host: System.get_env("SMTP_HOST"), + smtp_user: System.get_env("SMTP_USER"), + smtp_password: System.get_env("SMTP_PASSWORD"), + smtp_from_address: System.get_env("SMTP_FROM_ADDRESS") + config :logger, :console, level: (System.get_env("SUPAFANA_LOG_LEVEL") || "debug") |> String.to_atom(), format: "\n$time [$level] $message $metadata\n", diff --git a/server/lib/supafana/azure/api.ex b/server/lib/supafana/azure/api.ex index f535d71..0164b3e 100644 --- a/server/lib/supafana/azure/api.ex +++ b/server/lib/supafana/azure/api.ex @@ -219,14 +219,22 @@ defmodule Supafana.Azure.Api do end end - def create_deployment(project_ref, service_role_key, password) do + def create_deployment(project_ref, project_name, service_role_key, password) do supafana_domain = Supafana.env(:supafana_domain) supafana_env = Supafana.env(:supafana_env) + smtp_host = Supafana.env(:smtp_host) + smtp_user = Supafana.env(:smtp_user) + smtp_password = Supafana.env(:smtp_password) + smtp_from_address = Supafana.env(:smtp_from_address) + parameters = %{ "supabaseProjectRef" => %{ "value" => project_ref }, + "supabaseProjectName" => %{ + "value" => project_name + }, "supabaseServiceRoleKey" => %{ "value" => service_role_key }, @@ -238,6 +246,18 @@ defmodule Supafana.Azure.Api do }, "env" => %{ "value" => supafana_env + }, + "smtpHost" => %{ + "value" => smtp_host + }, + "smtpUser" => %{ + "value" => smtp_user + }, + "smtpPassword" => %{ + "value" => smtp_password + }, + "smtpFromAddress" => %{ + "value" => smtp_from_address } } @@ -278,7 +298,7 @@ defmodule Supafana.Azure.Api do }} when code in ["ExpiredAuthenticationToken", "InvalidAuthenticationToken"] -> {:ok, _} = Azure.Auth.api_access_token(:renew) - create_deployment(project_ref, service_role_key, password) + create_deployment(project_ref, project_name, service_role_key, password) end end diff --git a/server/lib/supafana/azure/template_spec.ex b/server/lib/supafana/azure/template_spec.ex index e2931cd..b776770 100644 --- a/server/lib/supafana/azure/template_spec.ex +++ b/server/lib/supafana/azure/template_spec.ex @@ -1,7 +1,7 @@ defmodule Supafana.Azure.TemplateSpec do @template_group "supafana-common-rg" @template_name "grafana-template" - @template_version "2024.8.2" + @template_version "2024.9.1" def grafana() do subscription_id = Supafana.env(:azure_subscription_id) diff --git a/server/lib/supafana/data/alert.ex b/server/lib/supafana/data/alert.ex new file mode 100644 index 0000000..4455e72 --- /dev/null +++ b/server/lib/supafana/data/alert.ex @@ -0,0 +1,27 @@ +defmodule Supafana.Data.Alert do + use Supafana.Data + alias Data.Grafana + + @primary_key false + schema "alert" do + belongs_to(:grafana, Grafana, type: Ecto.UUID) + + field(:supabase_id, :string) + field(:title, :string) + field(:enabled, :boolean) + + timestamps() + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :grafana_id, + :supabase_id, + :title, + :enabled + ]) + |> validate_required([:grafana_id, :supabase_id, :title, :enabled]) + |> unique_constraint([:grafana_id, :title], name: :unique_alert_per_grafana) + end +end diff --git a/server/lib/supafana/data/email_alert_contact.ex b/server/lib/supafana/data/email_alert_contact.ex new file mode 100644 index 0000000..303f020 --- /dev/null +++ b/server/lib/supafana/data/email_alert_contact.ex @@ -0,0 +1,27 @@ +defmodule Supafana.Data.EmailAlertContact do + use Supafana.Data + alias Data.Grafana + + @primary_key false + schema "email_alert_contact" do + belongs_to(:grafana, Grafana, type: Ecto.UUID) + + field(:supabase_id, :string) + field(:email, :string) + field(:severity, :string) + + timestamps() + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :grafana_id, + :supabase_id, + :email, + :severity + ]) + |> validate_required([:grafana_id, :supabase_id, :email, :severity]) + |> unique_constraint([:grafana_id, :email], name: :unique_email_alert_contact_per_grafana) + end +end diff --git a/server/lib/supafana/data/grafana.ex b/server/lib/supafana/data/grafana.ex index 4c73298..fd75c28 100644 --- a/server/lib/supafana/data/grafana.ex +++ b/server/lib/supafana/data/grafana.ex @@ -13,6 +13,7 @@ defmodule Supafana.Data.Grafana do field(:password, :string) field(:first_start_at, :utc_datetime_usec) field(:stripe_subscription_id, :string) + field(:max_client_connections, :integer) timestamps() end @@ -27,7 +28,8 @@ defmodule Supafana.Data.Grafana do :state, :password, :first_start_at, - :stripe_subscription_id + :stripe_subscription_id, + :max_client_connections ]) |> validate_required([:supabase_id, :org_id]) end diff --git a/server/lib/supafana/grafana/alerts.ex b/server/lib/supafana/grafana/alerts.ex new file mode 100644 index 0000000..684f05a --- /dev/null +++ b/server/lib/supafana/grafana/alerts.ex @@ -0,0 +1,457 @@ +defmodule Supafana.Grafana.Alerts do + require Logger + + def specs(supabase_project_ref, folder_uid, datasource_uid, max_connections \\ 200) do + basic = [ + low_cpu_alert(supabase_project_ref, folder_uid, datasource_uid), + high_cpu_alert(supabase_project_ref, folder_uid, datasource_uid), + low_memory_alert(supabase_project_ref, folder_uid, datasource_uid), + high_disk_usage_alert(supabase_project_ref, folder_uid, datasource_uid), + deadlock_alert(supabase_project_ref, folder_uid, datasource_uid) + ] + + case max_connections do + nil -> + basic + + max_connections when is_number(max_connections) -> + basic ++ + [ + connection_count_alert( + supabase_project_ref, + folder_uid, + datasource_uid, + max_connections + ) + ] + end + end + + # TEST + def low_cpu_alert(supabase_project_ref, folder_uid, datasource_uid) do + %{ + "title" => "Low CPU Usage Alert", + "ruleGroup" => "CPU_Alerts", + "folderUID" => folder_uid, + "noDataState" => "OK", + "execErrState" => "OK", + "for" => "5m", + "orgId" => 1, + "condition" => "B", + "annotations" => %{ + "summary" => "Low CPU usage detected" + }, + "labels" => %{ + "severity" => "critical" + }, + "data" => [ + %{ + "refId" => "A", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 600, + "to" => 0 + }, + "datasourceUid" => datasource_uid, + "model" => %{ + "expr" => + "100 * avg(1 - rate(node_cpu_seconds_total{mode=\"idle\",supabase_project_ref=\"#{supabase_project_ref}\"}[5m])) < 10", + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "A" + } + }, + %{ + "refId" => "B", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 0, + "to" => 0 + }, + "datasourceUid" => "-100", + "model" => %{ + "conditions" => [ + %{ + "evaluator" => %{ + "params" => [10], + "type" => "lt" + }, + "operator" => %{ + "type" => "and" + }, + "query" => %{ + "params" => ["A"] + }, + "reducer" => %{ + "params" => [], + "type" => "last" + }, + "type" => "query" + } + ], + "datasource" => %{ + "type" => "__expr__", + "uid" => "-100" + }, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "B", + "type" => "classic_conditions" + } + } + ] + } + end + + def high_cpu_alert(supabase_project_ref, folder_uid, datasource_uid) do + %{ + "title" => "High CPU Usage Alert", + "ruleGroup" => "CPU_Alerts", + "folderUID" => folder_uid, + "noDataState" => "OK", + "execErrState" => "OK", + "for" => "5m", + "orgId" => 1, + "condition" => "B", + "annotations" => %{ + "summary" => "High CPU usage detected" + }, + "labels" => %{ + "severity" => "critical" + }, + "data" => [ + %{ + "refId" => "A", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 600, + "to" => 0 + }, + "datasourceUid" => datasource_uid, + "model" => %{ + "expr" => + "100 * avg(1 - rate(node_cpu_seconds_total{mode=\"idle\",supabase_project_ref=\"#{supabase_project_ref}\"}[5m])) > 85", + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "A" + } + }, + %{ + "refId" => "B", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 0, + "to" => 0 + }, + "datasourceUid" => "-100", + "model" => %{ + "conditions" => [ + %{ + "evaluator" => %{ + "params" => [85], + "type" => "gt" + }, + "operator" => %{ + "type" => "and" + }, + "query" => %{ + "params" => ["A"] + }, + "reducer" => %{ + "params" => [], + "type" => "last" + }, + "type" => "query" + } + ], + "datasource" => %{ + "type" => "__expr__", + "uid" => "-100" + }, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "B", + "type" => "classic_conditions" + } + } + ] + } + end + + def low_memory_alert(supabase_project_ref, folder_uid, datasource_uid) do + %{ + "title" => "Low Available Memory Alert", + "ruleGroup" => "Memory_Alerts", + "folderUID" => folder_uid, + "noDataState" => "OK", + "execErrState" => "OK", + "for" => "5m", + "orgId" => 1, + "condition" => "B", + "annotations" => %{ + "summary" => "Low available memory detected" + }, + "labels" => %{ + "severity" => "critical" + }, + "data" => [ + %{ + "refId" => "A", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 600, + "to" => 0 + }, + "datasourceUid" => datasource_uid, + "model" => %{ + "expr" => """ + 100 - ((node_memory_MemAvailable_bytes{supabase_project_ref=\"#{supabase_project_ref}\"} * 100) / node_memory_MemTotal_bytes{supabase_project_ref=\"#{supabase_project_ref}\"}) < 10 + """, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "A" + } + }, + %{ + "refId" => "B", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 0, + "to" => 0 + }, + "datasourceUid" => "-100", + "model" => %{ + "conditions" => [ + %{ + "evaluator" => %{ + "params" => [10], + "type" => "lt" + }, + "operator" => %{ + "type" => "and" + }, + "query" => %{ + "params" => ["A"] + }, + "reducer" => %{ + "params" => [], + "type" => "last" + }, + "type" => "query" + } + ], + "datasource" => %{ + "type" => "__expr__", + "uid" => "-100" + }, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "B", + "type" => "classic_conditions" + } + } + ] + } + end + + def high_disk_usage_alert(supabase_project_ref, folder_uid, datasource_uid) do + %{ + "title" => "High Disk Usage Alert", + "ruleGroup" => "Disk_Alerts", + "folderUID" => folder_uid, + "noDataState" => "OK", + "execErrState" => "OK", + "for" => "5m", + "orgId" => 1, + "condition" => "B", + "annotations" => %{ + "summary" => "Disk usage exceeding 90%" + }, + "labels" => %{ + "severity" => "critical" + }, + "data" => [ + %{ + "refId" => "A", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 600, + "to" => 0 + }, + "datasourceUid" => datasource_uid, + "model" => %{ + "expr" => """ + 100 - ((node_filesystem_avail_bytes{supabase_project_ref=\"#{supabase_project_ref}\",mountpoint=\"/\",fstype!=\"rootfs\"} * 100) / node_filesystem_size_bytes{supabase_project_ref=\"#{supabase_project_ref}\",mountpoint=\"/\",fstype!=\"rootfs\"}) > 90 + """, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "A" + } + }, + %{ + "refId" => "B", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 0, + "to" => 0 + }, + "datasourceUid" => "-100", + "model" => %{ + "conditions" => [ + %{ + "evaluator" => %{ + "params" => [90], + "type" => "gt" + }, + "operator" => %{ + "type" => "and" + }, + "query" => %{ + "params" => ["A"] + }, + "reducer" => %{ + "params" => [], + "type" => "last" + }, + "type" => "query" + } + ], + "datasource" => %{ + "type" => "__expr__", + "uid" => "-100" + }, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "B", + "type" => "classic_conditions" + } + } + ] + } + end + + def connection_count_alert(supabase_project_ref, folder_uid, datasource_uid, max_connections) do + %{ + "title" => "Connection Count Alert", + "ruleGroup" => "Connection_Alerts", + "folderUID" => folder_uid, + "noDataState" => "OK", + "execErrState" => "OK", + "for" => "5m", + "orgId" => 1, + "condition" => "B", + "annotations" => %{ + "summary" => "Active connections are exceeding 75% of max connections" + }, + "labels" => %{ + "severity" => "critical" + }, + "data" => [ + %{ + "refId" => "A", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 600, + "to" => 0 + }, + "datasourceUid" => datasource_uid, + "model" => %{ + "expr" => """ + sum(supavisor_connections_active{supabase_project_ref=\"#{supabase_project_ref}\"}) / #{max_connections} * 100 > 75 + """, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "A" + } + }, + %{ + "refId" => "B", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 0, + "to" => 0 + }, + "datasourceUid" => "-100", + "model" => %{ + "conditions" => [ + %{ + "evaluator" => %{ + "params" => [75], + "type" => "gt" + }, + "operator" => %{ + "type" => "and" + }, + "query" => %{ + "params" => ["A"] + }, + "reducer" => %{ + "params" => [], + "type" => "last" + }, + "type" => "query" + } + ], + "datasource" => %{ + "type" => "__expr__", + "uid" => "-100" + }, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "B", + "type" => "classic_conditions" + } + } + ] + } + end + + def deadlock_alert(supabase_project_ref, folder_uid, datasource_uid) do + %{ + "title" => "Deadlock Alert", + "ruleGroup" => "Transaction_Alerts", + "folderUID" => folder_uid, + "noDataState" => "OK", + "execErrState" => "OK", + "for" => "5m", + "orgId" => 1, + "condition" => "B", + "annotations" => %{ + "summary" => "Deadlock detected in the database" + }, + "labels" => %{ + "severity" => "critical" + }, + "data" => [ + %{ + "refId" => "A", + "queryType" => "", + "relativeTimeRange" => %{ + "from" => 600, + "to" => 0 + }, + "datasourceUid" => datasource_uid, + "model" => %{ + "expr" => """ + rate(pg_stat_database_deadlocks_total{supabase_project_ref=\"#{supabase_project_ref}\"}[5m]) > 0 + """, + "hide" => false, + "intervalMs" => 1000, + "maxDataPoints" => 43200, + "refId" => "A" + } + } + ] + } + end +end diff --git a/server/lib/supafana/grafana/api.ex b/server/lib/supafana/grafana/api.ex new file mode 100644 index 0000000..1512b49 --- /dev/null +++ b/server/lib/supafana/grafana/api.ex @@ -0,0 +1,269 @@ +defmodule Supafana.Grafana.Api do + require Logger + + def set_alert(url, alert) do + r = + client(url, [{"X-Disable-Provenance", "true"}]) + |> Tesla.post( + "/api/v1/provisioning/alert-rules", + alert + ) + + case r do + {:ok, %Tesla.Env{status: 201, body: body}} -> + {:ok, body} + + {:ok, %Tesla.Env{status: 400, body: %{"message" => "[alerting.alert-rule.conflict]" <> _}}} = + body -> + {:ok, body} + end + end + + def delete_alert(url, uid) do + r = + client(url) + |> Tesla.delete("/api/v1/provisioning/alert-rules/#{uid}") + + case r do + {:ok, %Tesla.Env{status: 204}} -> + :ok + end + end + + def get_alert_definitions(url) do + r = + client(url) + |> Tesla.get("/api/v1/provisioning/alert-rules") + + case r do + {:ok, %Tesla.Env{status: 200, body: body}} -> + {:ok, body} + end + end + + def get_folders(url) do + r = + client(url) + |> Tesla.get("/api/folders") + + case r do + {:ok, %Tesla.Env{status: 200, body: body}} -> + {:ok, body} + end + end + + def get_dashboards(url) do + r = + client(url) + |> Tesla.get("/api/search?query=&type=dash-db") + + case r do + {:ok, %Tesla.Env{status: 200, body: body}} -> + {:ok, body} + end + end + + def get_datasources(url) do + r = + client(url) + |> Tesla.get("/api/datasources") + + case r do + {:ok, %Tesla.Env{status: 200, body: body}} -> + {:ok, body} + end + end + + def get_prometheus_datasource_id(url) do + {:ok, [%{"name" => "prometheus", "uid" => uid}]} = get_datasources(url) + {:ok, uid} + end + + def create_folder(url, name) do + r = + client(url) + |> Tesla.post( + "/api/folders", + %{ + "title" => name + } + ) + + case r do + {:ok, %Tesla.Env{status: 200, body: body}} -> + {:ok, body} + end + end + + def delete_folder(url, uid) do + r = + client(url) + |> Tesla.delete("/api/folders/#{uid}") + + case r do + {:ok, %Tesla.Env{status: 200}} -> + :ok + end + end + + def get_contact_points(url) do + r = + client(url) + |> Tesla.get("/api/v1/provisioning/contact-points") + + case r do + {:ok, %Tesla.Env{status: 200, body: body}} -> + {:ok, body} + end + end + + def delete_contact_point(url, uid) do + r = + client(url) + |> Tesla.delete("/api/v1/provisioning/contact-points/#{uid}") + + case r do + {:ok, %Tesla.Env{status: 202}} -> + :ok + end + end + + def create_contact_point(url, email) do + r = + client(url, [{"X-Disable-Provenance", "true"}]) + |> Tesla.post( + "/api/v1/provisioning/contact-points", + %{ + "name" => email, + "type" => "email", + "settings" => %{ + "addresses" => email, + "singleEmail" => true + } + } + ) + + case r do + {:ok, %Tesla.Env{status: 202}} -> + :ok + end + end + + def create_policy(url, email) do + {:ok, policy} = get_policies(url) + + routes = policy |> Map.get("routes", []) |> Enum.filter(&(&1["receiver"] !== email)) + + new_routes = [ + %{ + "object_matchers" => [["severity", "=", "critical"]], + "provenance" => "api", + "receiver" => email + } + | routes + ] + + new_policy = Map.merge(policy, %{"routes" => new_routes}) + + r = + client(url, [{"X-Disable-Provenance", "true"}]) + |> Tesla.put( + "/api/v1/provisioning/policies", + new_policy + ) + + case r do + {:ok, %Tesla.Env{status: 202}} -> + :ok + end + end + + def get_policies(url) do + r = + client(url) + |> Tesla.get("/api/v1/provisioning/policies") + + case r do + {:ok, %Tesla.Env{status: 200, body: body}} -> + {:ok, body} + end + end + + def delete_policy(url, email) do + {:ok, policies} = get_policies(url) + + routes = policies |> Map.get("routes", []) + + policy_to_delete = %{ + "object_matchers" => [["severity", "=", "critical"]], + "provenance" => "api", + "receiver" => email + } + + new_routes = routes |> List.delete(policy_to_delete) + + new_policy = Map.merge(policies, %{"routes" => new_routes}) + + r = + client(url, [{"X-Disable-Provenance", "true"}]) + |> Tesla.put( + "/api/v1/provisioning/policies", + new_policy + ) + + case r do + {:ok, %Tesla.Env{status: 202}} -> + :ok + + {:ok, %Tesla.Env{status: 404}} -> + :ok + end + end + + def delete_policies(url) do + r = + client(url) + |> Tesla.delete("/api/v1/provisioning/policies") + + case r do + {:ok, %Tesla.Env{status: 202, body: body}} -> + {:ok, body} + end + end + + defp client(base_url, headers \\ []) do + base_url = {Tesla.Middleware.BaseUrl, base_url} + json = Tesla.Middleware.JSON + form = Tesla.Middleware.FormUrlencoded + query = Tesla.Middleware.Query + + headers = + {Tesla.Middleware.Headers, + [ + { + "accept", + "application/json" + } + ] ++ headers} + + _x = """ + retry = + {Tesla.Middleware.Retry, + [ + delay: 1000, + max_retries: 5, + max_delay: 4_000, + should_retry: fn + {:ok, %{status: status}} when status in [500] -> true + {:ok, _} -> false + {:error, :timeout} -> true + {:error, _} -> false + end + ]} + """ + + middleware = [base_url, json, form, query, headers] + + Tesla.client(middleware) + end +end diff --git a/server/lib/supafana/web/router.ex b/server/lib/supafana/web/router.ex index b8834a1..d827e55 100644 --- a/server/lib/supafana/web/router.ex +++ b/server/lib/supafana/web/router.ex @@ -7,7 +7,7 @@ defmodule Supafana.Web.Router do use Plug.Router - alias Supafana.{Data, Repo, Utils, Z} + alias Supafana.{Data, Grafana, Repo, Utils, Z} plug(:match) @@ -48,6 +48,288 @@ defmodule Supafana.Web.Router do conn |> ok_json(me) end + post "/grafanas/:project_ref" do + access_token = conn.assigns[:supabase_access_token] + + case ensure_own_project(access_token, project_ref) do + false -> + forbid(conn, "Project #{project_ref} does not belong to your Supabase organization") + + {:ok, _} -> + max_client_connections = conn.params["maxClientConnections"] + + case max_client_connections do + x when is_integer(x) -> + Process.sleep(1000) + + Data.Grafana + |> Repo.get_by(supabase_id: project_ref) + |> Data.Grafana.update(max_client_connections: x) + |> Repo.update!() + + {:ok, grafana_api_access_url, _, _, _} = get_grafana_api_params(project_ref) + + {:ok, alert_definitions} = Grafana.Api.get_alert_definitions(grafana_api_access_url) + + case alert_definitions |> Enum.find(&(&1["title"] == "Connection Count Alert")) do + nil -> + :ok + + %{"uid" => alert_uid} -> + :ok = Grafana.Api.delete_alert(grafana_api_access_url, alert_uid) + end + + conn |> ok_no_content() + + _ -> + send_resp( + conn, + 400, + "Expected maxClientConnections parameter of type integer" + ) + end + end + end + + post "/grafanas/:project_ref/alerts/:title" do + access_token = conn.assigns[:supabase_access_token] + + case ensure_own_project(access_token, project_ref) do + false -> + forbid(conn, "Project #{project_ref} does not belong to your Supabase organization") + + {:ok, _} -> + me = get_session(conn)["me"] + enabled = conn.params["enabled"] + + update = fn -> + %Data.Grafana{id: grafana_id} = + from( + g in Data.Grafana, + where: g.supabase_id == ^project_ref + ) + |> Repo.one!() + + Data.Alert.new(%{ + grafana_id: grafana_id, + supabase_id: project_ref, + title: title, + enabled: enabled + }) + |> Repo.insert!( + on_conflict: {:replace, [:enabled]}, + conflict_target: [:grafana_id, :title] + ) + + :ok + end + + case me do + %{"role_name" => role} when role in ["Owner", "Administrator"] -> + update.() + ok_no_content(conn) + + _ -> + send_resp( + conn, + 400, + "Only Owners and Administrators can update alerts" + ) + end + end + end + + get "/grafanas/:project_ref/alerts" do + access_token = conn.assigns[:supabase_access_token] + + case ensure_own_project(access_token, project_ref) do + false -> + forbid(conn, "Project #{project_ref} does not belong to your Supabase organization") + + {:ok, _} -> + {:ok, grafana_api_access_url, grafana, folder_uid, prometheus_uid} = + get_grafana_api_params(project_ref) + + case {folder_uid, prometheus_uid} do + {nil, _} -> + forbid(conn, "Project #{project_ref} does not have an Alerts folder") + + {_, nil} -> + forbid(conn, "Project #{project_ref} does not have a Prometheus data source") + + _ -> + {:ok, alert_definitions} = Grafana.Api.get_alert_definitions(grafana_api_access_url) + + alert_specs = + Grafana.Alerts.specs( + project_ref, + folder_uid, + prometheus_uid, + grafana.max_client_connections + ) + + alert_titles = alert_specs |> Enum.map(& &1["title"]) + + {_, _} = + Repo.insert_all( + Data.Alert, + alert_specs + |> Enum.map(fn alert -> + %{ + grafana_id: grafana.id, + supabase_id: project_ref, + title: alert["title"], + inserted_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + } + end), + on_conflict: :nothing, + conflict_target: [:grafana_id, :title] + ) + + active_alerts = + from( + a in Data.Alert, + where: a.grafana_id == ^grafana.id, + where: a.enabled == true and a.title in ^alert_titles + ) + |> Repo.all() + + active_alerts + |> Enum.each(fn %{title: title} -> + if is_nil(alert_definitions |> Enum.find(&(&1["title"] == title))) do + spec = alert_specs |> Enum.find(&(&1["title"] == title)) + {:ok, _} = Grafana.Api.set_alert(grafana_api_access_url, spec) + end + end) + + inactive_alerts = + from( + a in Data.Alert, + where: a.grafana_id == ^grafana.id, + where: a.enabled == false or a.title not in ^alert_titles + ) + |> Repo.all() + + inactive_alerts + |> Enum.each(fn %{title: title} -> + case alert_definitions |> Enum.find(&(&1["title"] == title)) do + nil -> + :ok + + %{"uid" => uid} -> + Grafana.Api.delete_alert(grafana_api_access_url, uid) + end + end) + + alerts = + from( + a in Data.Alert, + where: a.grafana_id == ^grafana.id + ) + |> Repo.all() + + conn + |> ok_json( + alerts + |> Enum.map(fn a -> + %Z.Alert{ + title: a.title, + enabled: a.enabled + } + end) + ) + end + end + end + + post "/grafanas/:project_ref/email-alert-contacts/:email" do + access_token = conn.assigns[:supabase_access_token] + + case ensure_own_project(access_token, project_ref) do + false -> + forbid(conn, "Project #{project_ref} does not belong to your Supabase organization") + + {:ok, _} -> + me = get_session(conn)["me"] + enabled = conn.params["enabled"] + + update = fn -> + %Data.Grafana{id: grafana_id, password: password} = + from( + g in Data.Grafana, + where: g.supabase_id == ^project_ref + ) + |> Repo.one!() + + Data.EmailAlertContact.new(%{ + grafana_id: grafana_id, + supabase_id: project_ref, + email: email, + severity: + case enabled do + true -> + "critical" + + false -> + "none" + end + }) + |> Repo.insert!( + on_conflict: {:replace, [:severity]}, + conflict_target: [:grafana_id, :email] + ) + + :ok = update_contact_point(password, project_ref, email, enabled) + end + + case me do + %{"role_name" => role} when role in ["Owner", "Administrator"] -> + update.() + ok_no_content(conn) + + %{"email" => ^email} -> + update.() + ok_no_content(conn) + + _ -> + send_resp( + conn, + 400, + "Only Owners and Administrators can update alert contacts for others" + ) + end + end + end + + get "/grafanas/:project_ref/email-alert-contacts" do + access_token = conn.assigns[:supabase_access_token] + + case ensure_own_project(access_token, project_ref) do + false -> + forbid(conn, "Project #{project_ref} does not belong to your Supabase organization") + + {:ok, _} -> + data = + from( + n in Data.EmailAlertContact, + where: n.supabase_id == ^project_ref + ) + |> Repo.all() + + email_alert_contacts = + data + |> Enum.map(fn c -> + %Z.EmailAlertContact{ + email: c.email, + severity: c.severity + } + end) + + conn |> ok_json(email_alert_contacts) + end + end + post "/email-notifications/:user_id" do org_id = conn.assigns[:org_id] me = get_session(conn)["me"] @@ -221,6 +503,66 @@ defmodule Supafana.Web.Router do conflict_target: [:org_id, :user_id] ) + project_emails = members |> Enum.filter(&(not is_nil(&1["email"]))) |> Enum.map(& &1["email"]) + + from( + g in Data.Grafana, + where: g.org_id == ^org_id, + where: g.state == "Running" + ) + |> Repo.all() + |> Enum.each(fn g -> + {_, _} = + Repo.insert_all( + Data.EmailAlertContact, + project_emails + |> Enum.map(fn email -> + %{ + grafana_id: g.id, + supabase_id: g.supabase_id, + email: email, + severity: "critical", + inserted_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + } + end), + on_conflict: :nothing, + conflict_target: [:grafana_id, :email] + ) + + enabled_emails = + from( + c in Data.EmailAlertContact, + where: c.grafana_id == ^g.id, + where: c.severity == "critical", + select: c.email + ) + |> Repo.all() + + enabled_emails + |> Enum.each(fn email -> + :ok = update_contact_point(g.password, g.supabase_id, email, true) + end) + + {_, deleted_members} = + from( + m in Data.EmailAlertContact, + where: m.grafana_id == ^g.id, + where: m.email not in ^project_emails, + select: m + ) + |> Repo.delete_all() + + deleted_members + |> Enum.each(fn + %Data.EmailAlertContact{email: email} when not is_nil(email) -> + :ok = update_contact_point(g.password, g.supabase_id, email, false) + + _ -> + :ok + end) + end) + case show_emails do "false" -> conn |> ok_json(members |> Enum.map(&(&1 |> Map.delete("email")))) @@ -291,12 +633,26 @@ defmodule Supafana.Web.Router do %Data.Grafana{} = Repo.Grafana.set_state(project_ref, org_id, "Provisioning") + project_name = + case Supafana.Supabase.Management.projects(access_token) do + {:ok, projects} -> + case projects |> Enum.find(&(&1["id"] === project_ref)) do + project when not is_nil(project) -> + project["name"] + end + end + password = Supafana.Password.generate() %Data.Grafana{} = Repo.Grafana.set_password(project_ref, org_id, password) - case Supafana.Azure.Api.create_deployment(project_ref, service_key, password) do + case Supafana.Azure.Api.create_deployment( + project_ref, + project_name, + service_key, + password + ) do {:ok, %{"properties" => %{"provisioningState" => "Accepted"}}} -> Logger.info("#{project_ref}: Accepted") :ok @@ -353,9 +709,75 @@ defmodule Supafana.Web.Router do first_start_at -> to_unix(first_start_at) + Supafana.env(:trial_length_min) * 60 * 1000 - (DateTime.now("Etc/UTC") |> elem(1) |> DateTime.to_unix(:millisecond)) - end + end, + max_client_connections: g.max_client_connections } |> Z.Grafana.to_json!() |> Z.Grafana.from_json!() end + + defp get_grafana_api_access_url(project_ref) do + %Data.Grafana{password: password} = + grafana = + from( + g in Data.Grafana, + where: g.supabase_id == ^project_ref + ) + |> Repo.one() + + {:ok, "https://admin:#{password}@#{Supafana.env(:supafana_domain)}/dashboard/#{project_ref}/", + grafana} + end + + defp update_contact_point(password, project_ref, email, enabled) do + url = "https://admin:#{password}@#{Supafana.env(:supafana_domain)}/dashboard/#{project_ref}/" + + {:ok, existing_contact_points} = Grafana.Api.get_contact_points(url) + + contact = existing_contact_points |> Enum.find(&(&1["settings"]["addresses"] === email)) + + case {enabled, is_nil(contact)} do + {true, true} -> + :ok = Grafana.Api.create_contact_point(url, email) + :ok = Grafana.Api.create_policy(url, email) + + {false, false} -> + :ok = Grafana.Api.delete_policy(url, email) + :ok = Grafana.Api.delete_contact_point(url, contact["uid"]) + + _ -> + :ok + end + + :ok + end + + defp get_grafana_api_params(project_ref) do + {:ok, grafana_api_access_url, grafana} = get_grafana_api_access_url(project_ref) + + {:ok, folders} = Grafana.Api.get_folders(grafana_api_access_url) + + folder_uid = + case folders |> Enum.find(&(&1["title"] === "Alerts")) do + nil -> + {:ok, %{"uid" => uid}} = Grafana.Api.create_folder(grafana_api_access_url, "Alerts") + uid + + %{"uid" => uid} -> + uid + end + + {:ok, datasources} = Grafana.Api.get_datasources(grafana_api_access_url) + + prometheus_uid = + case datasources |> Enum.find(&(&1["name"] === "prometheus")) do + nil -> + nil + + %{"uid" => prometheus_uid} -> + prometheus_uid + end + + {:ok, grafana_api_access_url, grafana, folder_uid, prometheus_uid} + end end diff --git a/server/lib/supafana/web/z_type.ex b/server/lib/supafana/web/z_type.ex index 59783d6..1c6cda1 100644 --- a/server/lib/supafana/web/z_type.ex +++ b/server/lib/supafana/web/z_type.ex @@ -135,6 +135,7 @@ defmodule Supafana.Z.Grafana do field(:trial_length_min, :integer, [:required]) field(:trial_remaining_msec, :integer, []) field(:stripe_subscription_id, :string, []) + field(:max_client_connections, :integer, [:required]) end end @@ -191,3 +192,21 @@ defmodule Supafana.Z.UserNotification do field(:email, :string, [:required]) end end + +defmodule Supafana.Z.EmailAlertContact do + use Supafana.Z + + schema do + field(:email, :string, [:required]) + field(:severity, :string, [:required]) + end +end + +defmodule Supafana.Z.Alert do + use Supafana.Z + + schema do + field(:title, :string, [:required]) + field(:enabled, :boolean, [:required]) + end +end diff --git a/server/priv/repo/db-dump.sql b/server/priv/repo/db-dump.sql index 71e3bca..52cbc46 100644 --- a/server/priv/repo/db-dump.sql +++ b/server/priv/repo/db-dump.sql @@ -20,6 +20,34 @@ SET default_tablespace = ''; SET default_table_access_method = heap; +-- +-- Name: alert; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.alert ( + grafana_id uuid NOT NULL, + supabase_id text NOT NULL, + enabled boolean DEFAULT true NOT NULL, + title text NOT NULL, + inserted_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: email_alert_contact; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_alert_contact ( + grafana_id uuid NOT NULL, + supabase_id text NOT NULL, + email text NOT NULL, + severity text DEFAULT 'critical'::text NOT NULL, + inserted_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + -- -- Name: grafana; Type: TABLE; Schema: public; Owner: - -- @@ -34,7 +62,8 @@ CREATE TABLE public.grafana ( inserted_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL, first_start_at timestamp without time zone, - stripe_subscription_id text + stripe_subscription_id text, + max_client_connections integer DEFAULT 200 ); @@ -126,6 +155,20 @@ ALTER TABLE ONLY public.schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +-- +-- Name: alert_grafana_id_title_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX alert_grafana_id_title_index ON public.alert USING btree (grafana_id, title); + + +-- +-- Name: email_alert_contact_grafana_id_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_alert_contact_grafana_id_email_index ON public.email_alert_contact USING btree (grafana_id, email); + + -- -- Name: grafana_supabase_id_index; Type: INDEX; Schema: public; Owner: - -- @@ -171,3 +214,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20240720230903); INSERT INTO public."schema_migrations" (version) VALUES (20240727002752); INSERT INTO public."schema_migrations" (version) VALUES (20240804050719); INSERT INTO public."schema_migrations" (version) VALUES (20240815015612); +INSERT INTO public."schema_migrations" (version) VALUES (20240914002713); diff --git a/server/priv/repo/migrations/20240914002713_alerts.exs b/server/priv/repo/migrations/20240914002713_alerts.exs new file mode 100644 index 0000000..e5eeed9 --- /dev/null +++ b/server/priv/repo/migrations/20240914002713_alerts.exs @@ -0,0 +1,43 @@ +defmodule Supafana.Repo.Migrations.AddEmailAlertContactTable do + use Ecto.Migration + + def change do + create table(:email_alert_contact, primary_key: false) do + add(:grafana_id, :uuid, null: false) + add(:supabase_id, :text, null: false) + add(:email, :text, null: false) + add(:severity, :text, null: false, default: "critical") + + timestamps() + end + + create( + unique_index( + :email_alert_contact, + [:grafana_id, :email], + title: :unique_email_alert_contact_per_grafana + ) + ) + + create table(:alert, primary_key: false) do + add(:grafana_id, :uuid, null: false) + add(:supabase_id, :text, null: false) + add(:enabled, :boolean, null: false, default: true) + add(:title, :text, null: false) + + timestamps() + end + + create( + unique_index( + :alert, + [:grafana_id, :title], + title: :unique_alert_per_grafana + ) + ) + + alter table(:grafana) do + add(:max_client_connections, :integer, default: 200) + end + end +end diff --git a/storefront/src/types/z_types.ts b/storefront/src/types/z_types.ts index 96a80d0..ad29089 100644 --- a/storefront/src/types/z_types.ts +++ b/storefront/src/types/z_types.ts @@ -1,5 +1,11 @@ // This file is generated by Supafana.Z.generate_file() +export type Alert = { + title: string; + enabled: boolean; + limit: null | number; +}; + export type Billing = { delinquent: boolean; unpaid_instances: number; @@ -10,6 +16,11 @@ export type Billing = { payment_profiles: PaymentProfile[]; }; +export type EmailAlertContact = { + email: string; + severity: string; +}; + export type Grafana = { id: string; supabase_id: string; @@ -23,6 +34,7 @@ export type Grafana = { trial_length_min: number; trial_remaining_msec: null | number; stripe_subscription_id: null | string; + max_client_connections: number; }; export type PaymentProfile = { diff --git a/storefront/src/ui/Project.tsx b/storefront/src/ui/Project.tsx index 83aeced..3d8f750 100644 --- a/storefront/src/ui/Project.tsx +++ b/storefront/src/ui/Project.tsx @@ -7,12 +7,10 @@ import React from "react"; import { HiExternalLink as ExternalLink } from "react-icons/hi"; import { SiDungeonsanddragons as DividerGlyph } from "react-icons/si"; -import { nbsp } from "./Utils"; - import { apiServer, queryClient, queryKeys } from "./client"; -import SupafanaLogo from "./landing/assets/logo.svg?url"; -import SupabaseLogo from "./landing/assets/supabase-logo-icon.svg?url"; +import SupabaseProject from "./SupabaseProject"; +import SupafanaProject from "./SupafanaProject"; import type { Project as ProjectT } from "../types/supabase"; import type { Grafana as GrafanaT } from "../types/z_types"; @@ -60,432 +58,4 @@ const Project = ({ project, grafana }: { project: ProjectT; grafana: GrafanaT | ); }; -const SupabaseProject = ({ - project, - grafana, -}: { - project: ProjectT; - grafana: undefined | GrafanaT; -}) => { - return ( -
- - - Database - - - - - - - -
- {project.id} -
-
- - Version -
- {project.database?.version} -
-
- - Region -
- {project.region} -
-
- - Status -
- - {project.status} - - {project.status === "INACTIVE" && !grafana && ( - - You’ll have to{" "} - - restore - {" "} - this project to provision Supafana - - )} - - -
-
- - Created -
- - {dayjs(project.created_at).fromNow()} - -
-
-
- ); -}; - -const SupafanaProject = ({ - project, - grafana, -}: { - project: ProjectT; - grafana: GrafanaT | undefined; -}) => { - const state = grafana?.state ?? "Ready"; - const plan = grafana?.plan ?? "Trial"; - const created = grafana?.inserted_at ? dayjs(grafana.inserted_at).fromNow() : null; - - const provisionGrafanaMutation = useMutation({ - mutationFn: () => { - return apiServer.url(`/grafanas/${project.id}`).put().text(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.grafanas(project.organization_id) }); - }, - }); - - const upgradeGrafanaMutation = useMutation({ - mutationFn: () => { - return apiServer - .url(`/billing/subscriptions/${project.id}`) - .put() - .json<{ status: string; url?: string }>(); - }, - onSuccess: res => { - if (res.status === "redirect" && res.url) { - window.location.href = res.url; - } else { - queryClient.invalidateQueries({ queryKey: queryKeys.grafanas(project.organization_id) }); - } - }, - }); - - const deleteGrafanaMutation = useMutation({ - mutationFn: () => { - return apiServer.url(`/grafanas/${project.id}`).delete().text(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.grafanas(project.organization_id) }); - }, - }); - - const intervalRef = React.useRef>(); - - React.useEffect(() => { - if (grafana) { - if (["Provisioning", "Deleting", "Creating", "Starting", "Unknown"].includes(grafana.state)) { - if (!intervalRef.current) { - intervalRef.current = setInterval(() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.grafanas(project.organization_id), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.billing(project.organization_id), - }); - }, 9000); - } - } else if (grafana.plan === "Trial") { - if (!intervalRef.current) { - intervalRef.current = setInterval(() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.grafanas(project.organization_id), - }); - }, 30000); - } - } else { - clearInterval(intervalRef.current); - intervalRef.current = undefined; - } - } - }, [grafana]); - - const [passwordCopied, setPasswordCopied] = React.useState(false); - - React.useEffect(() => { - if (passwordCopied) { - setTimeout(() => { - setPasswordCopied(false); - }, 500); - } - }, [passwordCopied]); - - const trialEnded = - (grafana && - dayjs(grafana.first_start_at).add(grafana.trial_length_min, "minute").isBefore(dayjs())) ?? - false; - - if (!grafana) { - if (project.status.startsWith("ACTIVE")) { - return ( -
- {provisionGrafanaMutation.isPending || !provisionGrafanaMutation.isIdle ? ( - - ) : ( - - )} -
- ); - } else { - return null; - } - } - - return ( - - - - - Grafana - - - {state === "Running" && ( - - )} - - - State - - {state === "Running" && ( - - )} - {(["Failed"].includes(state) || - (plan === "Supafana Pro" && ["Deleted"].includes(state))) && ( - - )} - {plan === "Trial" && trialEnded && state === "Deleted" && ( - - )} - {plan === "Trial" && !trialEnded && state === "Deleted" && ( - - )} - - {plan !== "Trial" && ( - - Plan - - - )} - {plan === "Trial" && grafana.first_start_at && ( - - {trialEnded ? ( - Trial ended - ) : ( - Trial ends - )} - - - - )} - {/* - - Version - - - */} - {created && ( - - Created - - - )} - {grafana.state === "Running" && ( - - User/pass - - - - )} - -
- - Supabase logo - {project.name} - - - - - - -
- {state} - - - - - - - - -
- {plan} -
- - {dayjs(grafana.first_start_at).add(grafana.trial_length_min, "minute").fromNow()} - - - -
- supafana-version -
- {created} -
- - admin - - -
- ); -}; - -const ProjectRow = ({ children }: { children: JSX.Element[] }) => { - return ( -
- {children} -
- ); -}; - -const RowTdHeader = ({ - children, - className, -}: { - children: JSX.Element | string; - className?: string; -}) => { - return ( - - {children} - - ); -}; - -const RowDivHeader = ({ - children, - className, -}: { - children: JSX.Element | string; - className?: string; -}) => { - return ( -
- {children} -
- ); -}; - -const copyTextToClipboard = (text: string, onSuccess: () => void, onError?: () => void) => { - navigator.clipboard.writeText(text).then( - () => { - onSuccess(); - }, - () => { - if (onError) { - onError(); - } - } - ); -}; - export default Project; diff --git a/storefront/src/ui/SupabaseProject.tsx b/storefront/src/ui/SupabaseProject.tsx new file mode 100644 index 0000000..e726a97 --- /dev/null +++ b/storefront/src/ui/SupabaseProject.tsx @@ -0,0 +1,124 @@ +import classNames from "classnames"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +import { HiExternalLink as ExternalLink } from "react-icons/hi"; + +import SupabaseLogo from "./landing/assets/supabase-logo-icon.svg?url"; +import { nbsp } from "./Utils"; + +import type { Project as ProjectT } from "../types/supabase"; +import type { Grafana as GrafanaT } from "../types/z_types"; + +dayjs.extend(relativeTime); + +const SupabaseProject = ({ + project, + grafana, +}: { + project: ProjectT; + grafana: undefined | GrafanaT; +}) => { + return ( +
+ + + Database + + + + + + + +
+ {project.id} +
+
+ + Version +
+ {project.database?.version} +
+
+ + Region +
+ {project.region} +
+
+ + Status +
+ + {project.status} + + {project.status === "INACTIVE" && !grafana && ( + + You’ll have to{" "} + + restore + {" "} + this project to provision Supafana + + )} + + +
+
+ + Created +
+ + {dayjs(project.created_at).fromNow()} + +
+
+
+ ); +}; + +const ProjectRow = ({ children }: { children: JSX.Element[] }) => { + return ( +
+ {children} +
+ ); +}; + +const RowDivHeader = ({ + children, + className, +}: { + children: JSX.Element | string; + className?: string; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default SupabaseProject; + diff --git a/storefront/src/ui/SupafanaProject.tsx b/storefront/src/ui/SupafanaProject.tsx new file mode 100644 index 0000000..546fce7 --- /dev/null +++ b/storefront/src/ui/SupafanaProject.tsx @@ -0,0 +1,597 @@ +import classNames from "classnames"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import React from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +import { HiExternalLink as ExternalLink } from "react-icons/hi"; + +import { apiServer, queryClient, queryKeys, useMembers } from "./client"; + +// import SupafanaLogo from "./landing/assets/logo.svg?url"; +import GrafanaLogo from "./landing/assets/grafana-logo-icon.svg?url"; + +import { nbsp } from "./Utils"; + +dayjs.extend(relativeTime); + +import type { Project as ProjectT } from "../types/supabase"; +import type { Grafana as GrafanaT, Alert, EmailAlertContact } from "../types/z_types"; + +const SupafanaProject = ({ + project, + grafana, +}: { + project: ProjectT; + grafana: GrafanaT | undefined; +}) => { + const state = grafana?.state ?? "Ready"; + const plan = grafana?.plan ?? "Trial"; + const created = grafana?.inserted_at ? dayjs(grafana.inserted_at).fromNow() : null; + + const provisionGrafanaMutation = useMutation({ + mutationFn: () => { + return apiServer.url(`/grafanas/${project.id}`).put().text(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.grafanas(project.organization_id) }); + }, + }); + + const upgradeGrafanaMutation = useMutation({ + mutationFn: () => { + return apiServer + .url(`/billing/subscriptions/${project.id}`) + .put() + .json<{ status: string; url?: string }>(); + }, + onSuccess: res => { + if (res.status === "redirect" && res.url) { + window.location.href = res.url; + } else { + queryClient.invalidateQueries({ queryKey: queryKeys.grafanas(project.organization_id) }); + } + }, + }); + + const deleteGrafanaMutation = useMutation({ + mutationFn: () => { + return apiServer.url(`/grafanas/${project.id}`).delete().text(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.grafanas(project.organization_id) }); + }, + }); + + const intervalRef = React.useRef>(); + + React.useEffect(() => { + if (grafana) { + if (["Provisioning", "Deleting", "Creating", "Starting", "Unknown"].includes(grafana.state)) { + if (!intervalRef.current) { + intervalRef.current = setInterval(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.grafanas(project.organization_id), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.billing(project.organization_id), + }); + }, 9000); + } + } else if (grafana.plan === "Trial") { + if (!intervalRef.current) { + intervalRef.current = setInterval(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.grafanas(project.organization_id), + }); + }, 30000); + } + } else { + clearInterval(intervalRef.current); + intervalRef.current = undefined; + } + } + }, [grafana]); + + const [passwordCopied, setPasswordCopied] = React.useState(false); + + React.useEffect(() => { + if (passwordCopied) { + setTimeout(() => { + setPasswordCopied(false); + }, 500); + } + }, [passwordCopied]); + + const trialEnded = + (grafana && + dayjs(grafana.first_start_at).add(grafana.trial_length_min, "minute").isBefore(dayjs())) ?? + false; + + const updateGrafanaMutation = useMutation({ + mutationFn: ({ maxClientConnections }: { maxClientConnections: string }) => { + return apiServer + .url(`/grafanas/${project.id}`) + .post({ maxClientConnections: Number(maxClientConnections) }) + .text(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.grafanas(project.organization_id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.alerts(project.id) }); + }, + onError: () => { + setMaxClientConnections("200"); + }, + }); + + const [maxClientConnections, setMaxClientConnections] = React.useState( + `${grafana?.max_client_connections ?? "200"}` + ); + + if (!grafana) { + if (project.status.startsWith("ACTIVE")) { + return ( +
+ {provisionGrafanaMutation.isPending || !provisionGrafanaMutation.isIdle ? ( + + ) : ( + + )} +
+ ); + } else { + return null; + } + } + + return ( + + + + + Grafana + + + {state === "Running" && ( + + )} + + + State + + {state === "Running" && ( + + )} + {(["Failed"].includes(state) || + (plan === "Supafana Pro" && ["Deleted"].includes(state))) && ( + + )} + {plan === "Trial" && trialEnded && state === "Deleted" && ( + + )} + {plan === "Trial" && !trialEnded && state === "Deleted" && ( + + )} + + {plan !== "Trial" && ( + + Plan + + + )} + {plan === "Trial" && grafana.first_start_at && ( + + {trialEnded ? ( + Trial ended + ) : ( + Trial ends + )} + + + + )} + {/* + + Version + + + */} + {created && ( + + Created + + + )} + {grafana.state === "Running" && ( + + User/pass + + + + )} + {grafana.state === "Running" && ( + + Max client connections + + + + )} + {grafana.state === "Running" && ( + + + + )} + +
+ + Supabase logo + {project.name} + + + + + + +
+ {state} + + + + + + + + +
+ {plan} +
+ + {dayjs(grafana.first_start_at).add(grafana.trial_length_min, "minute").fromNow()} + + + +
+ supafana-version +
+ {created} +
+ + admin + + +
+
+ { + setMaxClientConnections(e.target.value); + }} + /> + +
+
+
+ Note: Supabase API doesn’t offer this information—please{" "} + + check the value on your Supabase dashboard + {" "} + and update accordingly +
+
+ +
+ ); +}; + +const Alerting = ({ project }: { project: ProjectT }) => { + const { data: allMembers, isLoading: membersLoading } = useMembers({ + enabled: !!project, + organizationId: project.organization_id as string, + showEmails: true, + }); + + const { + data: emailAlertContacts, + isLoading: emailAlertContactsLoading, + isFetching: emailAlertContactsFetching, + isPending: emailAlertContactsPending, + } = useQuery({ + queryKey: queryKeys.emailAlertContacts(project.id), + initialData: [], + queryFn: async () => { + return await apiServer + .url(`/grafanas/${project.id}/email-alert-contacts`) + .get() + .json(); + }, + enabled: !!project, + }); + + const emailAlertContactsInProgress = + emailAlertContactsLoading || emailAlertContactsFetching || emailAlertContactsPending; + + const { + data: alerts, + isLoading: alertsLoading, + isFetching: alertsFetching, + isPending: alertsPending, + } = useQuery({ + queryKey: queryKeys.alerts(project.id), + initialData: [], + queryFn: async () => { + return await apiServer.url(`/grafanas/${project.id}/alerts`).get().json(); + }, + enabled: !!project, + }); + + const alertsInProgress = alertsLoading || alertsFetching || alertsPending; + + const members = (allMembers ?? []).filter(m => !!m.email); + + const [clickedEmail, setClickedEmail] = React.useState(null); + + const updateEmailAlertContactMutation = useMutation({ + mutationFn: ({ email, enabled }: { email: string; enabled: boolean }) => { + return apiServer + .url(`/grafanas/${project.id}/email-alert-contacts/${email}`) + .post({ enabled }) + .text(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.emailAlertContacts(project.id), + }); + setClickedEmail(null); + }, + }); + + const [clickedAlert, setClickedAlert] = React.useState(null); + + const updateAlertMutation = useMutation({ + mutationFn: ({ title, enabled }: { title: string; enabled: boolean }) => { + return apiServer.url(`/grafanas/${project.id}/alerts/${title}`).post({ enabled }).text(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.alerts(project.id), + }); + setClickedAlert(null); + }, + }); + + return ( +
+
Alerting
+
+ {membersLoading ? ( + + ) : ( + + + + + + Supabase logo + Contacts + + + + + + + + + Supabase logo + Alerts + + + + + + +
+ {members.map(m => { + const checked = !!emailAlertContacts.find( + c => c.email === m.email && c.severity === "critical" + ); + + return ( +
+
{m.email}
+
+ {clickedEmail === m.email && + (updateEmailAlertContactMutation.isPending || + emailAlertContactsInProgress) ? ( + + ) : ( + { + setClickedEmail(m.email as string); + updateEmailAlertContactMutation.mutate({ + email: m.email as string, + enabled: e.target.checked, + }); + }} + value={"checked"} + checked={checked} + className="checkbox checkbox-info checkbox-sm" + /> + )} +
+
+ ); + })} +
+ {alerts.map(a => { + return ( +
+
{a.title}
+
+ {clickedAlert === a.title && + (updateAlertMutation.isPending || alertsInProgress) ? ( + + ) : ( + { + setClickedAlert(a.title); + updateAlertMutation.mutate({ + title: a.title, + enabled: e.target.checked, + }); + }} + value={"checked"} + checked={a.enabled} + className="checkbox checkbox-info checkbox-sm" + /> + )} +
+
+ ); + })} +
+ )} +
+
+ ); +}; + +const RowTdHeader = ({ + children, + className, +}: { + children: JSX.Element | string; + className?: string; +}) => { + return ( + + {children} + + ); +}; + +const copyTextToClipboard = (text: string, onSuccess: () => void, onError?: () => void) => { + navigator.clipboard.writeText(text).then( + () => { + onSuccess(); + }, + () => { + if (onError) { + onError(); + } + } + ); +}; + +export default SupafanaProject; diff --git a/storefront/src/ui/client.tsx b/storefront/src/ui/client.tsx index bf0380f..53429e5 100644 --- a/storefront/src/ui/client.tsx +++ b/storefront/src/ui/client.tsx @@ -22,6 +22,8 @@ export const queryKeys = { ], billing: (organizationId: string) => ["billing", organizationId], notifications: (organizationId: string) => ["notifications", organizationId], + emailAlertContacts: (projectId: string) => ["email_alert_contacts", projectId], + alerts: (projectId: string) => ["alerts", projectId], } as any; export const apiServer = wretch(getServerUrl(), { diff --git a/storefront/src/ui/landing/assets/grafana-logo-icon.svg b/storefront/src/ui/landing/assets/grafana-logo-icon.svg new file mode 100644 index 0000000..e91f3ab --- /dev/null +++ b/storefront/src/ui/landing/assets/grafana-logo-icon.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/supabase-grafana/Dockerfile b/supabase-grafana/Dockerfile index a5588e1..e366d77 100644 --- a/supabase-grafana/Dockerfile +++ b/supabase-grafana/Dockerfile @@ -1,6 +1,6 @@ FROM prom/prometheus:v2.50.1 as prometheus -FROM grafana/grafana:10.2.4-ubuntu as grafana +FROM grafana/grafana:11.2.0-ubuntu as grafana USER root @@ -36,13 +36,27 @@ COPY entrypoint.sh /entrypoint.sh ARG GRAFANA_URL ARG SUPABASE_PROJECT_REF +ARG SUPABASE_PROJECT_NAME ARG SUPABASE_SERVICE_ROLE_KEY ARG GRAFANA_PASSWORD +ARG GF_SMTP_HOST +ARG GF_SMTP_USER +ARG GF_SMTP_PASSWORD +ARG GF_SMTP_FROM_ADDRESS +ARG GF_SMTP_FROM_NAME +ARG GF_SMTP_ENABLED ENV SUPABASE_PROJECT_REF=${SUPABASE_PROJECT_REF} +ENV SUPABASE_PROJECT_NAME=${SUPABASE_PROJECT_NAME} ENV SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} ENV PASSWORD_PROTECTED=true ENV GRAFANA_PASSWORD=${GRAFANA_PASSWORD} +ENV GF_SMTP_HOST=${GF_SMTP_HOST} +ENV GF_SMTP_USER=${GF_SMTP_USER} +ENV GF_SMTP_PASSWORD=${GF_SMTP_PASSWORD} +ENV GF_SMTP_FROM_ADDRESS=${GF_SMTP_FROM_ADDRESS} +ENV GF_SMTP_FROM_NAME=${GF_SMTP_FROM_NAME} +ENV GF_SMTP_ENABLED=${GF_SMTP_ENABLED} EXPOSE 8080 From 8f8fb6bb9a4e90b53c9a43e899e06bfd07e087b1 Mon Sep 17 00:00:00 2001 From: Andrei Soroker Date: Sun, 15 Sep 2024 22:47:34 -0700 Subject: [PATCH 3/3] Add SMTP config to secrets --- infra/secrets/supafana-kndev.env | 8 ++++++-- infra/secrets/supafana-mkdev.env | 8 ++++++-- infra/secrets/supafana-prod.env | 8 ++++++-- infra/secrets/supafana-test.env | 8 ++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/infra/secrets/supafana-kndev.env b/infra/secrets/supafana-kndev.env index b528e27..d5d6b41 100644 --- a/infra/secrets/supafana-kndev.env +++ b/infra/secrets/supafana-kndev.env @@ -11,12 +11,16 @@ SUPAFANA_AZURE_CLIENT_ID=ENC[AES256_GCM,data:e0AkWtPySfe/saS0dtKrgbB3rundL2dcKXr SUPAFANA_AZURE_CLIENT_SECRET=ENC[AES256_GCM,data:01qApafs0tw664rHEKZqb0FEu8jYi8m8HGnvdfd56ugjaJKWQo/CpBgG,iv:zgeXF75XGumZIPRLYhhafRiIe7ysSgE4/ko0ep2i+nM=,tag:0GMT3HxV6A1fOQkh5kW2+A==,type:str] SUPAFANA_AZURE_TENANT_ID=ENC[AES256_GCM,data:/wQhk5MSjXpRhOaBPVRIXHZ+fgs37PpP2Cgj2rsBkQMtrpzIpHY=,iv:9FA5ls0fnKzr5VRE+MBZ/oSKvNrCRxMNsdjFp5OMq44=,tag:l+NAQpY6lEB6Fubldaat7Q==,type:str] SUPAFANA_AZURE_SUBSCRIPTION_ID=ENC[AES256_GCM,data:tgPYTQsPxAMxU9rfaUL1O2LT5TznbC4XNNljHegSqae4CulI0S0=,iv:Tne78fps2giXqzDVAJh+TYTbRbxjqMC/QyGcMvgtBAs=,tag:OZ1c0l0o9vHrxaSF+YRB1w==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:WSnxJaazew99wrE/aXzP6HTXtfqyoAcufS77LVsyx+6B2k2bRIR/Ggs=,iv:6C4A7Z/a/Yk/ZX2hazK/RJEe4MmCs0wtDfElxnWlWzY=,tag:IgRcb+HFG3z+xDP3V+U/ug==,type:str] +SMTP_USER=ENC[AES256_GCM,data:8Ud5ALDFErKvGAjb6LIF2mFuVPG18w==,iv:olvXs4X4ddVwUrKxlUODIgfZtsDXAacvhyek7y495CY=,tag:ZupXrs16MrkJMbSUAn+ssw==,type:str] +SMTP_PASSWORD=ENC[AES256_GCM,data:0qt6ej+tyxtRe4iXnFWSD8FA/q6b1cfCO866JOvrmMOd0stFpWVTJ79l5WJ/3g==,iv:vC00imZGRP9PKY8XxEEYNX8PKJLNRPBj7oTwyzt3v5w=,tag:nxqjxWUR4hFNSsfA7SqyzQ==,type:str] +SMTP_FROM_ADDRESS=ENC[AES256_GCM,data:8lP9qDDFEDuRUOBV3CBnSXzJFgz6hVyRtGbO,iv:w6gRR4h42kuxLD8igGUieyi08aO0xNG/YDRGb6bEU4s=,tag:Qz4egfP5tCvGZJInGBN4+A==,type:str] sops_azure_kv__list_0__map_created_at=2024-08-14T17:30:18Z sops_azure_kv__list_0__map_enc=R6TjugB40dZYEPKwJMi9sdOUj1c_vC5YMbpdoZnAogwL5PD98P8irzBDVvjB9KGEVzF3UyHg6wOPnphkAteBpgaAUu51buViNCykIpHT-PFDFSPu441SYAOcA8rRpxX1hs-hdLXRyUMMFLrSNxJxYXSd4VHDRLUWbXYE-de8NjV1ac-9PVOmt5Kq4aim7O-_S3qRKyYiMQ7G83zeUnrZBjOuY-jsbpdkFzHjd_4IFhW_cTW-BtwSXsSAwwyrMqaiO9VOTHliFbb-JLLKG7sWI6IQiNWB5uB8p_pjmUi1Xq95BeAas7Cl91d7qvMidE6Of5caaWJOavbkjRsAP8iqBg sops_azure_kv__list_0__map_name=sops-key sops_azure_kv__list_0__map_vault_url=https://supafana-kndev-vault.vault.azure.net sops_azure_kv__list_0__map_version=f3d8dc8cd993493a86e924eba85255c4 -sops_lastmodified=2024-08-14T17:30:22Z -sops_mac=ENC[AES256_GCM,data:XUFlEVgrcnHMGJ6+lvw+TEh1l5XSKkZVXhQs65PJZR9N0Bq71vW9DgwGx4pygZgy0Cx/YRXC1eA0QyjxIbtWdFdB3+AbI13wYHu6YloovwfKlyXwiRPhOW6ClpttSo7HLRs0VhXIoYiB/pJckAhKXQvDfl2Z1OmYQjgAm7LZrt8=,iv:T0AJYhxlJo00Q4oQ++Le/UuR8S6GD9RTYS6eYG6XWuE=,tag:tmwmZGioYLIGEIdPEaoaDA==,type:str] +sops_lastmodified=2024-09-16T05:46:40Z +sops_mac=ENC[AES256_GCM,data:ywxNfOvHfIq5IrKwZtIuTKE2HIyrVvLprmcQtaruNPo3xAs7YJZ21i+5S5aL++SJK6cIZfSGl3+uwiDOmWN4kBG/qgLCljxALRXxsTCOn3eVB4/iddSdXYccnESU/XqvOLAZ/ULCUV0wjHcXhy6+SAWlYb+cWLN2+nji6QPPgbc=,iv:0Jtc6uYvHpiI6cQrgLCdAkrcXfsWpMCoW60yHu5OL9I=,tag:8oS0zvk1nHkK5J2Ze2U3Ew==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.8.1 diff --git a/infra/secrets/supafana-mkdev.env b/infra/secrets/supafana-mkdev.env index 7195849..31b70e2 100644 --- a/infra/secrets/supafana-mkdev.env +++ b/infra/secrets/supafana-mkdev.env @@ -9,12 +9,16 @@ STRIPE_SECRET_KEY=ENC[AES256_GCM,data:owJKZJmlu0JIhky8AsWgLUMPHpulZg1KW0iKYxeFSd STRIPE_PRICE_ID=ENC[AES256_GCM,data:SjGMWbiz8ttkFUsQuPQk3P+qw28x4iEwFhRdEmjFyPI=,iv:8nIT+xocI5YeGJkiobQlQQi6m8dcgEs4vShxaQs4luI=,tag:DFRnBN0iXU/8SZAQqRAZjw==,type:str] SUPAFANA_AZURE_TENANT_ID=ENC[AES256_GCM,data:wxllgOWAZIQZREq7/OtijLylcLVSGe4LsV194mBfA7lr6WgXaQI=,iv:1PCDdHSaZZ2GRgWmEKc8Hp9sXcu9jqKtslm5cS/wV9k=,tag:udVqyy0hmhwsGTGy3SX8Gw==,type:str] SUPAFANA_AZURE_SUBSCRIPTION_ID=ENC[AES256_GCM,data:0c9/C37pDm/zIA1Qz24le0Aa+NprK6oK7PZ6NIJ2Mk3vRkGlPJ8=,iv:KSmLq2smDhAlEWBFfu8Il6jzSJChGZ2s47XlAylaLEU=,tag:TyuvMNei9czb+qxeGMiRuA==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:7BT+Pfn/crshXame58bwoOx9fFmZO5gchKhtY/Q+sb73/P+NnqMsx7U=,iv:5h5rE6TZaLa92x27jhXSgPv5sUG0buFjGS+DjXw4Gpk=,tag:W89kWDuHjB3V3rXpRxiu3Q==,type:str] +SMTP_USER=ENC[AES256_GCM,data:wwJsgAND2WxKJPgDVdfJ0VXLygxSsA==,iv:reyXnWq+F3RHc/ngdffR/jwbXwMeWltoIJKYbv5pXeA=,tag:89AF/Cxp5duz7c/eZMiwRw==,type:str] +SMTP_PASSWORD=ENC[AES256_GCM,data:dJpeMxHvtx/hnGH8OueLS7rCA4PbFr4hEFD97lcF0jA177VQKhTnNv7iO50mtQ==,iv:WkUpRWkD9ppvyfj0aDzRW4VX3irLQqzS2dAY3+96jyY=,tag:cwXU1+yrTlBkg+UuVpK4hw==,type:str] +SMTP_FROM_ADDRESS=ENC[AES256_GCM,data:sm1jesf9UH5e+7NHdIsTP80aB1aFc99WevED,iv:zHj5C88VkOnPBdE0oVM6C7zEqqjTJAXQ+2PHivQEK7E=,tag:Z83gp/3GgyH8pibRCS4zdA==,type:str] sops_azure_kv__list_0__map_created_at=2024-08-02T15:52:18Z sops_azure_kv__list_0__map_enc=CUwnn1KGEukhxQy9cnhnkoEYREFLK8kbvxevtsZLzt2A3P55mJLmufy7FxQ3nXgDn-ZCAwQZujsYYpPYRWBQAZuUHprTUjyOn9dAet5h7ywManarMVtIVz7m97o58U2Arsh9u_EDfdelNstvk6yCm5XYZI1wvLtZn748BwxLoyEaJ3IypDwC5XN5F13a_dGzjLpXH4q_xiSKKlbwMvVkBi4mbV4bE2Mmh_4Vo4GHx-FvfwU2ndEa4ICLQBjb_qG4rk4wqZHxGCT5cu3GexCHr0aOvgbHzJcZAbanxhcYy0FhKWKqbMn_b36SCAr8LcvJqY8HZA0H2cjpJ4rs2rEB4w sops_azure_kv__list_0__map_name=sops-key sops_azure_kv__list_0__map_vault_url=https://supafana-mkdev-vault.vault.azure.net sops_azure_kv__list_0__map_version=ca3be5c0b9c14439a2b149037133b193 -sops_lastmodified=2024-08-15T16:15:09Z -sops_mac=ENC[AES256_GCM,data:KawqxrtFqojoSLAja1VpfEShIYT8CW3pYvmwiX2lhxIUmg66tcBZMALH0XZGI6H6Q8I7S7MMcWJ3rADja6vUI4AHD+QJZHh3kOr6p5QM0G+IXTzET+TVw2nXS2MtLanm+HSX6A1HfwFoM0YUKRGnrlreUtz3HrOo1u8pU2CKCRI=,iv:1FuDoKRJDhjTPUV159Hfuv9pcnU05nHgBUVJVB4Ej9M=,tag:6s7Z5N/+iIpaaGMNvF68gg==,type:str] +sops_lastmodified=2024-09-16T05:47:00Z +sops_mac=ENC[AES256_GCM,data:zEb4TY397WAfOJxW9RoYbfj7wGhhGcP1Cg7XxSiTfcGlA8qkSH8Sglx/qx4P3+pnZT0E+XJzduPDRvbz3f6T6TQkeS6TsQf7bLlgbqMfcnjKjiN8v+1Nl3B0uO5tx35teBTK3HYf17Pvg3IAtVlq74D2owx3y0bxPeLFuIVqlzQ=,iv:xtv+4n7UFMdmRl2IVrC8DNKB3Ym7ynbl+GDYztmOrnA=,tag:BFWrBmijUMPtAKryFtccvA==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.8.1 diff --git a/infra/secrets/supafana-prod.env b/infra/secrets/supafana-prod.env index a3fa825..7aba0e6 100644 --- a/infra/secrets/supafana-prod.env +++ b/infra/secrets/supafana-prod.env @@ -9,12 +9,16 @@ STRIPE_SECRET_KEY=ENC[AES256_GCM,data:zAODgk4php8c/Bi4Q2RnluH5EBYWZhtq6+qCmGIDY3 STRIPE_PRICE_ID=ENC[AES256_GCM,data:J9N7djRGfmzm2dXQEaM/4AlWSnDrq//AmCJHJ2bIQLM=,iv:hfq2se/zE87waBMT4srVNEaN1MkgIGPlzhCpLk98z54=,tag:QvE6uxQAbQ2F8blxxT7bUg==,type:str] SUPAFANA_AZURE_TENANT_ID=ENC[AES256_GCM,data:N8gHBbAUtdXpYkIvivcoSnwJ28aRspVuKlsbqnvsBjnPYCFAOM0=,iv:nTmT80vjMLBoH7/tQpcC4jkN1Ye1jK4f/uTGGls/54Q=,tag:HF03BMlaISZ+1vF12A8MzQ==,type:str] SUPAFANA_AZURE_SUBSCRIPTION_ID=ENC[AES256_GCM,data:hqzHusHv+g8BAgJWPMo8AyvL9tIv25kMC0FO8wJwL06YrGNG9us=,iv:ft1y1fmzs9o2QvLUNW7MBzv/xX0cgIjvEI98B2AYtcA=,tag:W2+MyaAjGJhJgKozs1Rdbg==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:W6nCzjGOpd7Gp3VAIBbOROeAhqRIM0VXUwoYnNwVrwmeLJ/Oa4Gtq6k=,iv:YgZqQ2LYUr9FflMcry3ppeKVJIiKQzTLc8a+EaNLRwE=,tag:cfX2ku7Bkjau5auGmPUaog==,type:str] +SMTP_USER=ENC[AES256_GCM,data:ExV+it7Xzpw5AJm72a3aojDO8sWksA==,iv:SxVi/Gw023ZCm8LujhQDhr2Vo1Ev/W1/bfIH+RBHhJ8=,tag:ngte5pPlWtmiXbsQwHXl/g==,type:str] +SMTP_PASSWORD=ENC[AES256_GCM,data:93g7oYYyM1HBpXUpzHd+0jB5bZnN8zGqPLyF/fD/kvzeN2De5hHGajkfkekLoA==,iv:/rck0HRZpjRJqUck8HvulWXqTwsetJ5QsFEQ49V/XFc=,tag:FF+c+Cewwx8Tf8U/nOFKog==,type:str] +SMTP_FROM_ADDRESS=ENC[AES256_GCM,data:cpLiAFot0FH7iwTSxkFjKuYr/5Dofqne1uVW,iv:Wuf1vHIqLjF1iE0P/HA47gCbyX4Zi3xMtFaaa5NSrn4=,tag:7PUcteq03/Az3sKVVNCOTw==,type:str] sops_azure_kv__list_0__map_created_at=2024-08-18T14:52:41Z sops_azure_kv__list_0__map_enc=nhzmifiW9JMx6ladg8OrJQMMyU116XjpD9OK1rIhPv5TN4wNA91H3_KIGIvueDJB7W8SajqmM2urGFITkmQ_4Lan4YSobHZpLHyfO1KnMebxHPeYL9YBNxAd_zNxlB9xhSH-mBV7SLYea51pyNAymt_S6eb7M9BEIq3N-9TMYKhtCYhqtv7z2ywWIptpnn-cbkuriiduw8ZiCXjKWlteI3eFUDN55KqT3-dnFltuaKIHj1pelHR3m7xsPRkG8w20EaYCYtu6pWTaTf4vkJXCQ_5BIeiPfFy16ikrzN70rtu-hqv3TCJVgsl5FUh-MJzdvsvB5rxpzTZGynxu_CWpOA sops_azure_kv__list_0__map_name=sops-key sops_azure_kv__list_0__map_vault_url=https://supafana-prod-vault.vault.azure.net sops_azure_kv__list_0__map_version=a2b94928dca54f3e9444e0b39a9ac79e -sops_lastmodified=2024-08-18T19:23:14Z -sops_mac=ENC[AES256_GCM,data:NoqE8YD+5BMvwQggpt7OrWswT+KE6HdhNogn+nCdymsiieVEcVZDnQEZw/cP/xpoCISE1gLDK+m16wQ1b0Gb/8Ej8kJDmB9lq73nMA9jsCHyKhzli3iM+4/F7C6JCVcO74e20mV/CVTWQe8b4XTZcm/WNB1COW9Lf4yhls+pk9E=,iv:SMQSTvz+C1p6DoVsm6wbnDU7l4Y1xccWwa1joJdVJXc=,tag:MCWl7n6h8QVtYcMIMOs0Zw==,type:str] +sops_lastmodified=2024-09-16T05:46:23Z +sops_mac=ENC[AES256_GCM,data:/beZ1DLZj089yYJvK1D6BX4pVVPO6CHZxbNUPzbAMAcpPvKr+yhXjNRPqC9+ZyTfsY5aibNk9jl46p4V+8GHyhsBALfxcRcZRfGDSTu+CgI4JowNgannLKHZsZ4P9SiCbyGqVSG971p94CCFFft0FcLGHS1Tayd2bZTfxJwq/gM=,iv:uhR0E1I58QfZkzXMJ3wQvVJTn6WLvz4S3JBneiqgtbc=,tag:TOhRE0W5dZB4rdhE0bCnRg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.8.1 diff --git a/infra/secrets/supafana-test.env b/infra/secrets/supafana-test.env index 93219cd..38bd2b9 100644 --- a/infra/secrets/supafana-test.env +++ b/infra/secrets/supafana-test.env @@ -9,12 +9,16 @@ STRIPE_SECRET_KEY=ENC[AES256_GCM,data:AuyIBz4NfZXgwRCSz5rag7DwNkdwbKU09MZGuMR5Hl STRIPE_PRICE_ID=ENC[AES256_GCM,data:3c1xkIqthRet53Va0R4ZUVPG8vtqsb5491E4mB94uRc=,iv:8dGyOO2QckJPg7ZvQnV5/i2Wr7yap+F6mc3CLZnT+CE=,tag:smVFwcBx7B93EvyFmFAezw==,type:str] SUPAFANA_AZURE_TENANT_ID=ENC[AES256_GCM,data:Sk7DMqCGZBkNf+pAGH/4wFZevLlPilNVPZdMGwCih2onq1gu4zA=,iv:+ljwUAxE5ajLdhXFOx2hs0GkAEte0jxIk2N1kAFdlaE=,tag:oMGGApHbtM7zqJUmSGaAdA==,type:str] SUPAFANA_AZURE_SUBSCRIPTION_ID=ENC[AES256_GCM,data:roSDxKMiHXUe4HsvQ+eElxTwyQ8INnDynVAvyYoH9ymc47dxZh4=,iv:uCfOOLX8mCG9VgYWKWeH1Ha4eNBELX2bwJQGXXUt0yc=,tag:iXMCTa2d5yKbNbnFPz0Q6w==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:XGE4fiZZS3mubR/P7kVeltJ18zGAJ33x+mljZpFwDAbWBr/0xO4/iR0=,iv:esIBDHmUi3eiWNDkSxnxMZqtS5O7OXxMQyadmQkdF6o=,tag:Er541Mo3Z+TnJLmc0mUylQ==,type:str] +SMTP_USER=ENC[AES256_GCM,data:1JBPTzYwa4RBE+xACbDcBwhFBQ41wA==,iv:9d+MQzQALWOBCsDytRnaUj2lKruyJ2BWzuZDTmk+28o=,tag:xM6RingmxeUhvo+HtOZdCQ==,type:str] +SMTP_PASSWORD=ENC[AES256_GCM,data:PotZXsDYJLzoOu4DFcl72CmWCOM1z/OKQj56KrbD07sEBp6h+FclzcIIdLXwaA==,iv:8wJmne5cjnKnIAg3Eqfad/MkOuMsyblR07MIdADbdcQ=,tag:Z0I0CX4iOHRfB4AUISgeZw==,type:str] +SMTP_FROM_ADDRESS=ENC[AES256_GCM,data:hdchTTfohEJZKk7tVzasOG69LM5X4Z0BEzXO,iv:l13PRRwopyWVBM79kISUjc13cohX1lVn8QHdH7If8hs=,tag:8hXk02e8NrrLRURQXy8Ymw==,type:str] sops_azure_kv__list_0__map_created_at=2024-08-18T10:52:40Z sops_azure_kv__list_0__map_enc=ephcBU8yikLxrCULJUhWYahbzQBGlB-xqQ3JYDcf_-mTZk5MCLIurwUcS0xpQbZbjArOJQoiVFnTeifrXQMmv02Zq8B7Dlk0CldSqdCi2TG37t1oWTW9xuWh8y8qKRy1WW8SDx-AW6xkole3WVfEwGjQGq59R5AEcoKOwTEs2rCYsuB4KuUh2ta_HiFvHta_FpbrH9Kakj1E7EBNQqg7Rt94T9627lU-sm-BCDDeZBmJexdFiw5dHGDvI30h0PnId8OWinnaPCOU4zloAEWh9tyuhrHRkvmJcN8_1xa5FAAI2i7hAPXjeZAZKPohIIeaffIyqJwZAAkEWD8B5NFtlg sops_azure_kv__list_0__map_name=sops-key sops_azure_kv__list_0__map_vault_url=https://supafana-test-vault.vault.azure.net sops_azure_kv__list_0__map_version=6e31977c366f4306947102b155257e04 -sops_lastmodified=2024-08-18T14:52:06Z -sops_mac=ENC[AES256_GCM,data:02gYnGw9BXYymjib30yp0Va1HMeVqrPqkaFa5ofRnUm3h+Al9IzNbsCgt3Y33Enu8peltLexfSFtW4y+KL+AoNEDo946Zq6f2/2cBZBGI9BoLBwDkbfBWhZAm28gYvbQ3gZSfVk4EByEl6d6W7j8beYPUbYGwZjEUAhk69D4rmw=,iv:Dh7QKwOmwUa/iFm+cpf/NfHysckCO6TuFnR9M5CVsEQ=,tag:bdX41cggLU4PzKiHuWF4Hg==,type:str] +sops_lastmodified=2024-09-16T05:47:14Z +sops_mac=ENC[AES256_GCM,data:ulOTk2KpHJ4FwYmq2Q0FssGjBAjUuo0vEukk38n+f+5nl2sYtL2hqKp2XAaNZMGyHisX2E+mviYDd01FgkLOTBU3z0ftCb/WqzsI1fGDZEQ/nx6bWJGZv5i40KoH5DziTDTQsH05mwUM+FoJqAObyxD4KwlKecZu02iBXg20v1w=,iv:cspKwEBiCcTlhbCzfIUbiec5EKNBrVrUNqPCO4Yufos=,tag:AfdQHZZ1ziQElDbttlvk/A==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.8.1