Compare commits

...
7 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 e91586b968 Discourse: move from Bitnami chart to launcher-built image
The Bitnami chart and images stop at 3.5.0. Run Discourse 2026.10.0 from
an image built with the official launcher, on PostgreSQL 18, reusing the
existing uploads volume, Redis and secrets.

Update the Dex sign-in step for the new image paths, drop the removed
LDAP setting, and keep chat off now that it is bundled in core. The image
adds a real-IP rule so visitors are not all rate limited as the ingress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 15:32:46 -05:00
claudeandClaude Opus 5.5 507b2225b1 mail: relay outbound mail through Amazon SES
Gmail and other large providers refuse mail sent directly from our
residential IP. Send outbound mail through an SES SMTP relay configured
from the vault (mail_relay_host, mail_relay_user, mail_relay_password).
Unset values fall back to direct delivery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:36:15 -05:00
claudeandClaude Opus 5.5 ff90cb5966 mail: stop treating member email domains as local; pin DMS 15.1.0
The LDAP domain filter matched every entry with a mail attribute, so
membership accounts with external addresses (e.g. @gmail.com) made
those domains local and outbound mail to them bounced with 550 5.1.1.
Restrict the filter to the Postfix Users group, like the user filter.

Pin docker-mailserver to 15.1.0: v17 renamed the LDAP_QUERY_FILTER_*
variables and chowns /var/mail to 5000:5000 at startup, which breaks
this LDAP uid-per-mailbox setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:59:39 -05:00
ericandClaude Opus 5.5 7647dca7c3 Discourse: sign in only through Dex, sign-up to /members
Drop discourse-ldap-auth (its authenticator ignores ldap_enabled) and
add an idempotent settings script, run after deploy, that disables
LDAP login and installs a theme component sending Sign Up to
www.eom.dev/members.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:23:50 -05:00
ericandClaude Opus 5.5 0651bd4bb7 Set Prometheus server resources
The chart defaulted to the "nano" preset (192Mi memory limit), which
Prometheus outgrew, leaving it OOM-killed in a crash loop for ~25 days.
Request 250m/1Gi and limit 1 CPU/2Gi instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:30:06 -05:00
ericandClaude Opus 5.5 95b4b5abc9 Move Grafana to ansible-role-grafana
Grafana now has its own role (DevOps/ansible-role-grafana). Remove its
tasks and LDAP template from this role and drop it from full.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:09:23 -05:00
eric 816aaea3e0 Dex 2026-10-26 18:55:21 -04:00
12 changed files with 359 additions and 209 deletions
+45
View File
@@ -0,0 +1,45 @@
# Idempotent: sign-in goes only through Dex (discourse-openid-connect),
# and the Sign Up button goes to the eom.dev membership site.
# Run with: RAILS_ENV=production bundle exec rails runner <this file>
SIGNUP_URL = "https://www.eom.dev/members"
# OIDC is the only login method left, so with auth_immediately the
# Log In button redirects straight to Dex. Accounts link by email.
SiteSetting.enable_local_logins = false
SiteSetting.auth_immediately = true
# New members sign up at www.eom.dev; Discourse must still create their
# account on first Dex login.
SiteSetting.allow_new_registrations = true
# Chat is bundled in core and on by default; this forum does not use it.
SiteSetting.chat_enabled = false
head_tag = <<~HTML
<script>
(function () {
var url = #{SIGNUP_URL.to_json};
if (location.pathname === "/signup") { location.replace(url); return; }
document.addEventListener("click", function (e) {
var b = e.target.closest(".sign-up-button, a[href='/signup']");
if (!b) return;
e.preventDefault();
e.stopImmediatePropagation();
location.href = url;
}, true);
})();
</script>
HTML
name = "eom.dev sign-up redirect"
component = Theme.find_by(name: name) ||
Theme.create!(name: name, user_id: Discourse.system_user.id, component: true)
component.set_field(target: :common, name: :head_tag, value: head_tag)
component.save!
Theme.where(component: false).find_each do |theme|
next if theme.child_themes.include?(component)
theme.add_relative_theme!(:child, component)
end
puts "enable_local_logins=#{SiteSetting.enable_local_logins} component=#{component.id} " \
"parents=#{component.parent_themes.pluck(:name).join(', ')}"
+107
View File
@@ -0,0 +1,107 @@
# Discourse v2026.10.0, built with the official launcher (see web_only.yml).
# Reuses the existing Bitnami-era uploads volume, Redis and Kubernetes secrets.
apiVersion: apps/v1
kind: Deployment
metadata:
name: discourse-web
namespace: discourse
labels:
app.kubernetes.io/name: discourse-web
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: discourse-web
template:
metadata:
labels:
app.kubernetes.io/name: discourse-web
spec:
# The image is 3.77 GB and most nodes have only 3-8 GB free; pulling it
# elsewhere risks DiskPressure and evicting the MetalLB speakers.
nodeSelector:
kubernetes.io/hostname: alpha-worker-0
initContainers:
# The launcher image runs Discourse as uid 1000; Bitnami used 999.
- name: fix-ownership
image: gitea.eom.dev/devops/discourse-web:v2026.10.0
command: ["sh", "-c", "chown -R 1000:1000 /shared/uploads /shared/backups"]
volumeMounts:
- name: discourse-data
mountPath: /shared/uploads
subPath: discourse/public/uploads
- name: discourse-data
mountPath: /shared/backups
subPath: discourse/public/backups
containers:
- name: discourse
image: gitea.eom.dev/devops/discourse-web:v2026.10.0
command: ["/sbin/boot"]
ports:
- name: http
containerPort: 80
env:
- { name: RAILS_ENV, value: production }
- { name: LANG, value: en_US.UTF-8 }
- { name: LC_ALL, value: en_US.UTF-8 }
- { name: MIGRATE_ON_BOOT, value: "1" }
- { name: PRECOMPILE_ON_BOOT, value: "1" }
- { name: CREATE_DB_ON_BOOT, value: "0" }
- { name: UNICORN_WORKERS, value: "2" }
- { name: UNICORN_SIDEKIQS, value: "1" }
- { name: DISCOURSE_HOSTNAME, value: discourse.eom.dev }
- { name: DISCOURSE_DB_SOCKET, value: "" }
- { name: DISCOURSE_DB_HOST, value: discourse-postgres18 }
- { name: DISCOURSE_DB_NAME, value: bitnami_application }
- { name: DISCOURSE_DB_USERNAME, value: bn_discourse }
- name: DISCOURSE_DB_PASSWORD
valueFrom:
secretKeyRef: { name: discourse-postgresql, key: password }
- { name: DISCOURSE_REDIS_HOST, value: discourse-redis-master }
- name: DISCOURSE_REDIS_PASSWORD
valueFrom:
secretKeyRef: { name: discourse-redis, key: redis-password }
- { name: DISCOURSE_SMTP_ADDRESS, value: postfix.eom.dev }
- { name: DISCOURSE_SMTP_PORT, value: "587" }
- { name: DISCOURSE_SMTP_USER_NAME, value: discourse }
- name: DISCOURSE_SMTP_PASSWORD
valueFrom:
secretKeyRef: { name: discourse-discourse, key: smtp-password }
startupProbe:
httpGet: &status
path: /srv/status
port: http
httpHeaders:
- { name: Host, value: discourse.eom.dev }
- { name: X-Forwarded-Proto, value: https }
periodSeconds: 10
failureThreshold: 180
readinessProbe:
httpGet: *status
periodSeconds: 10
failureThreshold: 6
livenessProbe:
httpGet: *status
periodSeconds: 20
failureThreshold: 6
resources:
requests:
cpu: 250m
memory: 1Gi
volumeMounts:
- name: shared
mountPath: /shared
- name: discourse-data
mountPath: /shared/uploads
subPath: discourse/public/uploads
- name: discourse-data
mountPath: /shared/backups
subPath: discourse/public/backups
volumes:
- name: shared
emptyDir: {}
- name: discourse-data
persistentVolumeClaim:
claimName: discourse
+73
View File
@@ -0,0 +1,73 @@
# PostgreSQL 18 with pgvector for Discourse. Runs alongside the old Bitnami
# PostgreSQL 17 StatefulSet, whose volume stays untouched for rollback.
apiVersion: v1
kind: Service
metadata:
name: discourse-postgres18
namespace: discourse
labels:
app.kubernetes.io/name: discourse-postgres18
spec:
clusterIP: None
selector:
app.kubernetes.io/name: discourse-postgres18
ports:
- name: postgresql
port: 5432
targetPort: postgresql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: discourse-postgres18
namespace: discourse
labels:
app.kubernetes.io/name: discourse-postgres18
spec:
serviceName: discourse-postgres18
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: discourse-postgres18
template:
metadata:
labels:
app.kubernetes.io/name: discourse-postgres18
spec:
containers:
- name: postgresql
image: pgvector/pgvector:pg18
ports:
- name: postgresql
containerPort: 5432
env:
- name: POSTGRES_USER
value: postgres
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: discourse-postgresql
key: postgres-password
readinessProbe:
exec:
command: ["pg_isready", "-U", "postgres", "-h", "127.0.0.1"]
periodSeconds: 10
failureThreshold: 6
livenessProbe:
exec:
command: ["pg_isready", "-U", "postgres", "-h", "127.0.0.1"]
initialDelaySeconds: 60
periodSeconds: 10
failureThreshold: 6
volumeMounts:
- name: data
mountPath: /var/lib/postgresql
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: nfs-client
resources:
requests:
storage: 2Ti
+36
View File
@@ -0,0 +1,36 @@
# Build-only config for the eom.dev Discourse image. No secrets belong here:
# database, Redis and SMTP credentials are supplied at runtime by Kubernetes.
templates:
- "templates/web.template.yml"
- "templates/web.ratelimited.template.yml"
params:
version: v2026.10.0
env:
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
LANGUAGE: en_US.UTF-8
DISCOURSE_HOSTNAME: discourse.eom.dev
DISABLE_LETSENCRYPT: 1
# Everything else the forum uses (openid-connect, reactions, math, post-voting,
# adplugin, subscriptions, chat) is bundled in core at this version.
hooks:
after_code:
- exec:
cd: $home/plugins
cmd:
- git clone https://github.com/discourse/discourse-activity-pub.git
- git clone https://github.com/discourse/discourse-prometheus.git
run:
# ingress-nginx terminates TLS, so trust its X-Forwarded-For only from the pod
# network. Without this every visitor shares the ingress IP and hits rate limits.
- file:
path: /etc/nginx/conf.d/outlets/server/real-ip.conf
chmod: 644
contents: |
set_real_ip_from 10.244.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
+5
View File
@@ -42,6 +42,11 @@
config: config:
issuer: https://dex.eom.dev/ issuer: https://dex.eom.dev/
staticClients: staticClients:
- id: "{{ www_dex_client_id }}"
name: WWW
secret: "{{ www_dex_client_secret }}"
redirectURIs:
- "https://www.eom.dev/members/oidc/callback/"
- id: "{{ matrix_dex_oidc_client_id }}" - id: "{{ matrix_dex_oidc_client_id }}"
name: Matrix Auth Service name: Matrix Auth Service
secret: "{{ matrix_dex_oidc_client_secret }}" secret: "{{ matrix_dex_oidc_client_secret }}"
+72 -74
View File
@@ -1,78 +1,76 @@
--- ---
# tasks file for discourse # tasks file for discourse
#
# Discourse runs from an image built with the official launcher; the Bitnami
# chart stops at 3.5.0. To upgrade, change params.version in
# files/discourse/web_only.yml, then from a discourse_docker checkout:
# cp <role>/files/discourse/web_only.yml containers/web_only.yml
# launcher build --set base_image=<base_image from templates/web.template.yml>-web-only \
# --tag gitea.eom.dev/devops/discourse-web:<version> web_only --output=type=docker
# Push it, update the image tag in files/discourse/discourse-web.yaml, and re-run.
#
# Pushing through the gitea ingress needs proxy-body-size raised and
# proxy-request-buffering "off" for the push, then restored. With buffering on,
# ingress-nginx writes layers to its node's small disk; on 2026-11-08 that caused
# DiskPressure, evicted the MetalLB speaker, and took the public sites down.
#
# Redis, the secrets, the uploads PVC, the Service and the Ingress still come from
# the original Helm release, which must stay installed but is no longer upgraded.
- name: Deploy PostgreSQL 18 for Discourse
kubernetes.core.k8s:
state: present
src: "{{ role_path }}/files/discourse/postgres18.yaml"
- name: Deploy Discourse - name: Deploy Discourse
kubernetes.core.helm: kubernetes.core.k8s:
state: present
src: "{{ role_path }}/files/discourse/discourse-web.yaml"
wait: true
wait_condition:
type: Available
status: "True"
wait_timeout: 1800
- name: Point the Discourse Service at the launcher-built pods
kubernetes.core.k8s:
state: present
merge_type: merge
definition:
apiVersion: v1
kind: Service
metadata:
name: discourse name: discourse
chart_ref: bitnami/discourse namespace: discourse
release_namespace: discourse spec:
create_namespace: true selector:
values: app.kubernetes.io/name: discourse-web
auth: app.kubernetes.io/instance: null
email: discourse@eom.dev
username: discourse_admin - name: Find the Discourse pod
password: "{{ discourse_admin_password }}" kubernetes.core.k8s_info:
host: "https://discourse.eom.dev" kind: Pod
siteName: 'Discourse' namespace: discourse
smtp: label_selectors:
enabled: true - app.kubernetes.io/name=discourse-web
host: "postfix.eom.dev" field_selectors:
port: "587" - status.phase=Running
protocol: "tls" register: discourse_pods
user: "discourse"
password: "{{ discourse_admin_password }}" - name: Copy the Dex sign-in / membership sign-up settings script
image: kubernetes.core.k8s_cp:
debug: false namespace: discourse
repository: bitnamilegacy/discourse pod: "{{ discourse_pods.resources[0].metadata.name }}"
tag: 3.4.7-debian-12-r0 container: discourse
service: local_path: "{{ role_path }}/files/discourse-sso.rb"
externalTrafficPolicy: Local remote_path: /tmp/discourse-sso.rb
discourse:
skipInstall: false - name: Apply Dex sign-in / membership sign-up settings
plugins: kubernetes.core.k8s_exec:
- https://github.com/discourse/discourse-adplugin namespace: discourse
- https://github.com/discourse/discourse-subscriptions pod: "{{ discourse_pods.resources[0].metadata.name }}"
- https://github.com/discourse/discourse-activity-pub container: discourse
- https://github.com/discourse/discourse-openid-connect command: >-
- https://github.com/jonmbake/discourse-ldap-auth bash -c 'cd /var/www/discourse && su discourse -c
- https://github.com/discourse/discourse-math "RAILS_ENV=production bundle exec rails runner /tmp/discourse-sso.rb"'
- https://github.com/discourse/discourse-post-voting register: discourse_sso
- https://github.com/discourse/discourse-prometheus changed_when: false
- https://github.com/discourse/discourse-reactions
command:
- /bin/bash
args:
- -c
- |
rm -r /opt/bitnami/discourse/plugins/chat
chown -R discourse:root /opt/bitnami/discourse/plugins
/opt/bitnami/scripts/discourse/entrypoint.sh /opt/bitnami/scripts/discourse/run.sh
persistPlugins: false
compatiblePlugins: true
resourcesPreset: none
persistence:
size: 2Ti
ingress:
enabled: true
ingressClassName: "nginx"
hostname: discourse.eom.dev
annotations:
cert-manager.io/cluster-issuer: ca-issuer
nginx.ingress.kubernetes.io/proxy-set-header: "X-Forwarded-Proto $scheme"
tls: true
postgresql:
enabled: true
image:
repository: bitnamilegacy/postgresql
auth:
enablePostgresUser: true
postgresPassword: "{{ discourse_admin_password }}"
password: "{{ discourse_admin_password }}"
primary:
persistence:
size: 2Ti
redis:
enabled: true
image:
repository: bitnamilegacy/redis
auth:
password: "{{ discourse_admin_password }}"
-1
View File
@@ -32,4 +32,3 @@
include_tasks: "{{ item }}" include_tasks: "{{ item }}"
loop: loop:
- prometheus.yaml - prometheus.yaml
- grafana.yaml
-61
View File
@@ -1,61 +0,0 @@
---
# tasks file for grafana
- name: Deploy Grafana
kubernetes.core.helm:
name: grafana
chart_ref: bitnami/grafana
release_namespace: grafana
create_namespace: true
values:
metrics:
enabled: true
admin:
user: grafana_admin
password: "{{ grafana_admin_password }}"
persistence:
size: 64Gi
grafana:
extraEnvVars:
- name: GF_SERVER_DOMAIN
value: grafana.eom.dev
- name: GF_SERVER_ROOT_URL
value: https://grafana.eom.dev/
smtp:
enabled: true
user: grafana
password: "{{ grafana_admin_password }}"
host: mail.eom.dev:587
fromAddress: grafana@eom.dev
fromName: Grafana
ldap:
enabled: true
allowSignUp: true
configuration: "{{ lookup('template', 'ldap.toml.j2') }}"
ingress:
enabled: true
pathType: Prefix
hostname: grafana.eom.dev
annotations:
cert-manager.io/cluster-issuer: ca-issuer
ingressClassName: nginx
tls: true
datasources:
secretDefinition:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
orgId: 1
url: http://prometheus-server.prometheus.svc.cluster.local
version: 1
editable: true
isDefault: true
- name: Alertmanager
uid: alertmanager
type: alertmanager
access: proxy
orgId: 1
url: http://prometheus-alertmanager.prometheus.svc.cluster.local
version: 1
editable: true
+11 -2
View File
@@ -142,7 +142,7 @@
spec: spec:
containers: containers:
- name: mail - name: mail
image: mailserver/docker-mailserver image: mailserver/docker-mailserver:15.1.0 # pinned: v17 renamed LDAP env vars and breaks this config
securityContext: securityContext:
# `allowPrivilegeEscalation: true` is required to support SGID via the `postdrop` # `allowPrivilegeEscalation: true` is required to support SGID via the `postdrop`
# executable in `/var/mail-state` for Postfix (maildrop + public dirs): # executable in `/var/mail-state` for Postfix (maildrop + public dirs):
@@ -212,7 +212,8 @@
- name: LDAP_BIND_PW - name: LDAP_BIND_PW
value: "{{ openldap_readonly_password }}" value: "{{ openldap_readonly_password }}"
- name: LDAP_QUERY_FILTER_DOMAIN - name: LDAP_QUERY_FILTER_DOMAIN
value: "(mail=*@%s)" # Only mailbox users define local domains; member accounts carry external addresses (e.g. @gmail.com).
value: "(&(mail=*@%s)(memberOf=cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev))"
- name: LDAP_QUERY_FILTER_USER - name: LDAP_QUERY_FILTER_USER
value: "(&(mail=%s)(memberOf=cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev))" value: "(&(mail=%s)(memberOf=cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev))"
- name: LDAP_QUERY_FILTER_ALIAS - name: LDAP_QUERY_FILTER_ALIAS
@@ -221,6 +222,14 @@
value: "(&(objectClass=posixAccount)(mailGroupMember=%s))" value: "(&(objectClass=posixAccount)(mailGroupMember=%s))"
- name: LDAP_QUERY_FILTER_SENDERS - name: LDAP_QUERY_FILTER_SENDERS
value: "(&(objectClass=posixAccount)(|(mail=%s)(mailAlias=%s)(mailGroupMember=%s)))" value: "(&(objectClass=posixAccount)(|(mail=%s)(mailAlias=%s)(mailGroupMember=%s)))"
# Outbound relay (Amazon SES): residential IPs are refused by Gmail et al.
# Leave mail_relay_host unset to deliver directly.
- name: DEFAULT_RELAY_HOST
value: "{{ mail_relay_host | default('') }}"
- name: RELAY_USER
value: "{{ mail_relay_user | default('') }}"
- name: RELAY_PASSWORD
value: "{{ mail_relay_password | default('') }}"
- name: SPOOF_PROTECTION - name: SPOOF_PROTECTION
value: "1" value: "1"
- name: DOVECOT_AUTH_BIND - name: DOVECOT_AUTH_BIND
+1 -1
View File
@@ -3,4 +3,4 @@
- name: Deploy - name: Deploy
include_tasks: "{{ item }}" include_tasks: "{{ item }}"
loop: loop:
- monero.yaml - dex.yaml
+8
View File
@@ -15,6 +15,14 @@
image: image:
repository: bitnamilegacy/prometheus repository: bitnamilegacy/prometheus
tag: 3.5.0-debian-12-r3 tag: 3.5.0-debian-12-r3
resourcesPreset: none
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
cpu: 1
memory: 2Gi
persistence: persistence:
size: 256Gi size: 256Gi
extraScrapeConfigs: extraScrapeConfigs:
-69
View File
@@ -1,69 +0,0 @@
[[servers]]
# Ldap server host (specify multiple hosts space separated)
host = "openldap.openldap.svc.cluster.local"
# Default port is 389 or 636 if use_ssl = true
port = 389
# Set to true if LDAP server should use an encrypted TLS connection (either with STARTTLS or LDAPS)
use_ssl = false
# If set to true, use LDAP with STARTTLS instead of LDAPS
start_tls = false
# The value of an accepted TLS cipher. By default, this value is empty. Example value: ["TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"])
# For a complete list of supported ciphers and TLS versions, refer to: https://go.dev/src/crypto/tls/cipher_suites.go
# Starting with Grafana v11.0 only ciphers with ECDHE support are accepted for TLS 1.2 connections.
tls_ciphers = []
# This is the minimum TLS version allowed. By default, this value is empty. Accepted values are: TLS1.1 (only for Grafana v10.4 or earlier), TLS1.2, TLS1.3.
min_tls_version = ""
# set to true if you want to skip SSL cert validation
ssl_skip_verify = false
# set to the path to your root CA certificate or leave unset to use system defaults
# root_ca_cert = "/path/to/certificate.crt"
# Authentication against LDAP servers requiring client certificates
# client_cert = "/path/to/client.crt"
# client_key = "/path/to/client.key"
# Search user bind dn
bind_dn = "cn=readonly,dc=eom,dc=dev"
# Search user bind password
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
bind_password = "{{ openldap_readonly_password }}"
# We recommend using variable expansion for the bind_password, for more info https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#variable-expansion
# bind_password = '$__env{LDAP_BIND_PASSWORD}'
# Timeout in seconds. Applies to each host specified in the 'host' entry (space separated).
timeout = 30
# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)"
# Allow login from email or username, example "(|(sAMAccountName=%s)(userPrincipalName=%s))"
search_filter = "(&(objectClass=posixAccount)(uid=%s)(memberOf=cn=Grafana Users,ou=Grafana,ou=Services,dc=eom,dc=dev))"
# An array of base dns to search through
search_base_dns = ["dc=eom,dc=dev"]
# Specify names of the LDAP attributes your LDAP uses
[servers.attributes]
username = "uid"
email = "mail"
name = "givenName"
surname = "sn"
member_of = "memberOf"
[[servers.group_mappings]]
group_dn = "cn=Grafana Administrators,ou=Grafana,ou=Services,dc=eom,dc=dev"
org_id = 1
org_role = "Admin"
grafana_admin = true
[[servers.group_mappings]]
group_dn = "cn=Grafana Users,ou=Grafana,ou=Services,dc=eom,dc=dev"
org_id = 1
org_role = "Viewer"
[[servers.group_mappings]]
group_dn = "cn=DevOps Owners,ou=Teams,dc=eom,dc=dev"
org_id = 2
org_role = "Admin"
[[servers.group_mappings]]
group_dn = "cn=Finance Owners,ou=Teams,dc=eom,dc=dev"
org_id = 3
org_role = "Admin"