Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
843ad48
Add the groups service, and deployment for the Grouper importer
Jul 27, 2026
545c29a
Add a build of groups
Aug 3, 2026
734b032
Add new notes
Aug 3, 2026
a2574a4
Give the groups service a Keycloak client and the private-CA trust
Aug 3, 2026
7d78ddc
Document the groups Keycloak client and the CA split
Aug 3, 2026
f4c0a64
Pin the importer CronJob to the built image digest
Aug 3, 2026
68a0ab7
Pin the local cluster's API address, and add a repair for when it drifts
Aug 3, 2026
fbc7917
Carry the API address on a dummy interface, not the host's
Aug 3, 2026
2ec55c9
Stop the migration path announcing a fault it ruled out
Aug 3, 2026
9c0c5c8
Add a local Grouper dataset worth testing the importer against
Aug 3, 2026
db11bab
Point apps and group-propagator at the groups service
Aug 4, 2026
7f79129
Record the importer's native-group report
Aug 4, 2026
7d101f4
Rebuild the groups image with the native-group import report
Aug 4, 2026
56179e9
Add the community-tag migration, and fix the import playbook's image
Aug 4, 2026
b34d0d0
Let the inventory select terrain's groups backend
Aug 5, 2026
a96338c
Pin the groups image carrying the error-handling fix
Aug 5, 2026
83485e6
Pin the groups image that logs withheld constraints
Aug 5, 2026
7b58d3d
Add a synthetic production-scale dataset and its benchmark
Aug 5, 2026
799bce7
Name synthetic group resources by the group's external id
Aug 5, 2026
f62f245
Document the public-group marker and correct a wrong claim
Aug 5, 2026
9d378ea
Document that public does not mean the members are public
Aug 5, 2026
4955b9e
Document access-filtered listings and the nested-delete fix
Aug 5, 2026
7df9e67
Make de_grouper an admin of the groups service by default
Aug 5, 2026
fd65971
Record the admin-level rule and the redaction marker
Aug 5, 2026
9bbde27
Pin the groups image carrying the redaction marker
Aug 5, 2026
061dd40
Document joinable as a privilege distinct from member visibility
Aug 5, 2026
1eaf012
Document that groups are returned from the subject search
Aug 5, 2026
699c8da
Record the two closure concurrency races and their fixes
Aug 5, 2026
173191e
Fix review findings in the groups deployment scaffolding
Aug 5, 2026
ecc2af0
Update wiki for the review-fix behavior changes
Aug 5, 2026
eaff77a
Bump the groups image to the review-fix build
Aug 5, 2026
57c8d5e
Bump the groups image for the tag-unit normalization
Aug 5, 2026
c4fd830
Update the community-tags wiki page for unit normalization
Aug 5, 2026
943abdc
Merge main into groups
Sep 3, 2026
0418833
Merge remote-tracking branch 'origin/main' into groups
Sep 3, 2026
abd59ca
Drive the Grouper cutover from Ansible
Sep 3, 2026
c9df09f
Drop the shipped one-time migrations from the release procedure
Sep 3, 2026
17b2963
Say why permissions cannot be deployed ahead of the window
Sep 3, 2026
2322657
Sharpen the cutover's gates and rollback in the release procedure
Sep 3, 2026
add4eb8
Name the Python libraries Ansible needs up front
Sep 3, 2026
fab4b01
Warn that a changed_by with spaces needs the JSON form
Sep 3, 2026
d537ae2
Fix issues found reviewing the groups cutover branch
Sep 3, 2026
a42aae0
Probe groups liveness on an endpoint that ignores the database
Sep 3, 2026
cb17430
Let the groups service read user attributes from portal-conductor
Sep 4, 2026
7eeaf9e
Read the groups service's user attributes from portal-conductor
Sep 4, 2026
1f42168
Retire the groups service's Keycloak client
Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ansible/build_it.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@
apply:
tags: openldap-docker
tags: openldap-docker
- ansible.builtin.include_role:
name: services/groups
tasks_from: build
apply:
tags: groups
tags: groups
- ansible.builtin.include_role:
name: services/permissions
tasks_from: build
Expand Down
143 changes: 143 additions & 0 deletions ansible/community_tags.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
# Rewrites the community tags on DE apps so they name a community by its ID
# rather than by its name.
#
# ansible-playbook -i <inventory> community_tags.yml -e dry_run=true
# ansible-playbook -i <inventory> community_tags.yml
#
# Run it once, after the group import and before apps starts writing tags of its
# own. Running it again is a no-op: a value that is already a community ID is
# left alone. Values naming a community that no longer exists are reported and
# left exactly as they are -- they resolve to nothing, and deleting the rows
# would discard the only remaining record of what an app was tagged with.
#
# The attribute must match apps' workspace.metadata.communities.attr, or this
# rewrites nothing and reports a clean run.
- name: Rewrite app community tags to community IDs
hosts: localhost
connection: local
gather_facts: false
roles:
- role: common
vars:
dry_run: false
community_tags_job_name: "community-tags-manual"
community_tags_timeout: 1800
tasks:
# Read from the build descriptor rather than naming the image: this Job is
# applied directly, so nothing substitutes a digest the way skaffold does for
# a Deployment, and a bare name resolves to :latest, which is never pushed.
- name: read the groups build descriptor
ansible.builtin.set_fact:
groups_image: >-
{{ (lookup('file', playbook_dir ~ '/roles/services/groups/files/groups.json')
| from_json).builds[0].tag }}
when: groups_image is not defined

- name: remove any previous community-tags job
environment:
KUBECONFIG: "{{ kubeconfig }}"
kubernetes.core.k8s:
state: absent
api_version: batch/v1
kind: Job
name: "{{ community_tags_job_name }}"
namespace: "{{ ns }}"
wait: true

# Credentials come from the environment rather than the command line, so
# they are not visible to anyone who can read the process table in the pod.
- name: create the community-tags secret
environment:
KUBECONFIG: "{{ kubeconfig }}"
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: Secret
metadata:
name: community-tags-configs
namespace: "{{ ns }}"
stringData:
GROUPS_TAGS_DB_URI: >-
postgresql://{{ dbms_connection_user }}:{{ dbms_connection_pass | urlencode }}@{{ db_login_host }}:{{ pg_listen_port }}/{{ de_db_name }}?sslmode=disable
GROUPS_TAGS_METADATA_URI: >-
postgresql://{{ dbms_connection_user }}:{{ dbms_connection_pass | urlencode }}@{{ db_login_host }}:{{ pg_listen_port }}/{{ metadata_db_name }}?sslmode=disable
GROUPS_TAGS_ATTRIBUTE: "{{ apps_communities_attr }}"

- name: start the community-tags job
environment:
KUBECONFIG: "{{ kubeconfig }}"
kubernetes.core.k8s:
state: present
namespace: "{{ ns }}"
definition:
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ community_tags_job_name }}"
namespace: "{{ ns }}"
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: community-tags
image: "{{ groups_image }}"
command:
- community-tags
args: "{{ ['-dry-run'] if (dry_run | bool) else [] }}"
envFrom:
- secretRef:
name: community-tags-configs
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "1"
memory: "512Mi"

# Not fatal on its own: see the same guard in grouper_import.yml. A rewrite
# that outlives its timeout still has an orphan list worth reading.
- name: wait for the rewrite to finish
environment:
KUBECONFIG: "{{ kubeconfig }}"
kubernetes.core.k8s_info:
api_version: batch/v1
kind: Job
name: "{{ community_tags_job_name }}"
namespace: "{{ ns }}"
register: community_tags_job
until: >-
(community_tags_job.resources[0].status.succeeded | default(0) | int) > 0
or (community_tags_job.resources[0].status.failed | default(0) | int) > 0
retries: "{{ (community_tags_timeout | int) // 10 }}"
delay: 10
failed_when: false

# The orphan list is the point of the run: those tag values name no
# community, and they are the apps that will not appear in any collection.
- name: show the report
environment:
KUBECONFIG: "{{ kubeconfig }}"
ansible.builtin.command:
cmd: >-
kubectl --namespace {{ ns }} logs job/{{ community_tags_job_name }}
register: community_tags_log
changed_when: false

- name: report
ansible.builtin.debug:
var: community_tags_log.stdout_lines

- name: fail if the rewrite did not succeed
ansible.builtin.fail:
msg: >-
{{ 'the community-tag rewrite failed; see the report above'
if (community_tags_job.resources[0].status.failed | default(0) | int) > 0
else 'the community-tag rewrite did not finish within ' ~ community_tags_timeout ~ 's
and may still be running; see the report above and
kubectl --namespace ' ~ ns ~ ' get job/' ~ community_tags_job_name }}
when: (community_tags_job.resources[0].status.succeeded | default(0) | int) < 1
2 changes: 2 additions & 0 deletions ansible/deploy_it.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
tags: notifications
- role: services/openldap-docker
tags: openldap-docker
- role: services/groups
tags: groups
- role: services/permissions
tags: permissions
- role: services/requests
Expand Down
38 changes: 38 additions & 0 deletions ansible/grouper_cutover.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
# Moves the DE's group data from Grouper to the permissions schema of the DE
# database. The rows are already there by the time this runs -- grouper_import.yml
# puts them there -- and this playbook flips the marker that decides which store
# is authoritative, having first checked that everything the flip depends on is
# in place.
#
# ansible-playbook -i <inventory> grouper_cutover.yml --tags preflight
# ansible-playbook -i <inventory> grouper_cutover.yml
# ansible-playbook -i <inventory> grouper_cutover.yml --tags rollback
#
# ORDERING: the marker must move after the import and before terrain is pointed
# at the new backend.
#
# 1. apply the de-database migrations (--tags=update-databases kubernetes.yml)
# 2. deploy groups and permissions (--tags=groups,permissions deploy_it.yml)
# 3. run grouper_import.yml and read its report
# 4. run community_tags.yml -- after the import, whose legacy_name values are
# the mapping, and before the new apps image writes ID-based tags of its own
# 5. run this playbook
# 6. set terrain_groups_backend: groups in the inventory, re-run
# configure-services, and deploy terrain, apps, group-propagator, and sonora
#
# Flipping earlier than 3 cuts the DE over to group data nothing has populated.
# Flipping later than 6 means terrain writes natively while the importer is still
# willing to reconcile those writes away.
#
# The preflight tags are read-only and worth running well before the window:
# every check that fails on the day is one that could have been answered a week
# earlier. Step 4 is deliberately not checked here -- community_tags.yml reports
# on its own rewrite, and re-deriving which tag values are rewritable would mean
# a second copy of the classification rules in the community-tags command.
- name: Cut group management over from Grouper to the DE database
hosts: localhost
connection: local
gather_facts: false
roles:
- role: grouper_cutover
132 changes: 132 additions & 0 deletions ansible/grouper_import.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
# Runs the Grouper importer once, as a Job, and reports what it did.
#
# Use this for the initial import and for dry runs. The recurring import that
# keeps the new store in step with Grouper during the soak is the grouper-import
# CronJob, deployed by `deploy_it.yml --tags groups`.
#
# ansible-playbook -i <inventory> grouper_import.yml -e dry_run=true
# ansible-playbook -i <inventory> grouper_import.yml
# ansible-playbook -i <inventory> grouper_import.yml -e import_phase=groups
#
# The importer is convergent: it reconciles membership and grants to Grouper's
# current state, including removals, so running it repeatedly is expected. It
# refuses to run once group_data_source says this database is authoritative,
# because reconciling after cutover would delete group data created natively.
- name: Run the Grouper importer
hosts: localhost
connection: local
gather_facts: false
roles:
- role: common
vars:
dry_run: false
import_phase: all
import_job_name: "grouper-import-manual"
import_timeout: 1800
tasks:
# Read from the build descriptor rather than naming the image: this Job is
# applied directly, so nothing substitutes a digest the way skaffold does for
# a Deployment, and a bare name resolves to :latest, which is never pushed.
- name: read the groups build descriptor
ansible.builtin.set_fact:
groups_image: >-
{{ (lookup('file', playbook_dir ~ '/roles/services/groups/files/groups.json')
| from_json).builds[0].tag }}
when: groups_image is not defined

- name: remove any previous manual import job
environment:
KUBECONFIG: "{{ kubeconfig }}"
kubernetes.core.k8s:
state: absent
api_version: batch/v1
kind: Job
name: "{{ import_job_name }}"
namespace: "{{ ns }}"
wait: true

- name: start the import job
environment:
KUBECONFIG: "{{ kubeconfig }}"
kubernetes.core.k8s:
state: present
namespace: "{{ ns }}"
definition:
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ import_job_name }}"
namespace: "{{ ns }}"
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: grouper-import
image: "{{ groups_image }}"
command:
- grouper-import
args: "{{ ['-dry-run'] if (dry_run | bool) else [] }}"
envFrom:
- secretRef:
name: grouper-import-configs
env:
- name: GROUPS_IMPORT_PHASE
value: "{{ import_phase }}"
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "1"
memory: "1Gi"

# Not fatal on its own. A run that outlives import_timeout still has a
# report worth reading, and letting the retry loop abort the play would
# replace it with a bare "retries exceeded" during a maintenance window.
# The explicit failure below covers both the failed and the timed-out case.
- name: wait for the import to finish
environment:
KUBECONFIG: "{{ kubeconfig }}"
kubernetes.core.k8s_info:
api_version: batch/v1
kind: Job
name: "{{ import_job_name }}"
namespace: "{{ ns }}"
register: import_job
until: >-
(import_job.resources[0].status.succeeded | default(0) | int) > 0
or (import_job.resources[0].status.failed | default(0) | int) > 0
retries: "{{ (import_timeout | int) // 10 }}"
delay: 10
failed_when: false

# The report is the point of the run: it names the groups that vanished from
# Grouper, the identifiers it had to trim, the privileges it could not
# translate, and whether the effective-membership closure matches Grouper's
# own expansion. A run that reports nothing is not the same as a run that
# changed nothing.
- name: show the importer's report
environment:
KUBECONFIG: "{{ kubeconfig }}"
ansible.builtin.command:
cmd: >-
kubectl --namespace {{ ns }} logs job/{{ import_job_name }}
register: import_log
changed_when: false

- name: report
ansible.builtin.debug:
var: import_log.stdout_lines

- name: fail if the import did not succeed
ansible.builtin.fail:
msg: >-
{{ 'the Grouper import failed; see the report above'
if (import_job.resources[0].status.failed | default(0) | int) > 0
else 'the Grouper import did not finish within ' ~ import_timeout ~ 's and may
still be running; see the report above and
kubectl --namespace ' ~ ns ~ ' get job/' ~ import_job_name }}
when: (import_job.resources[0].status.succeeded | default(0) | int) < 1
1 change: 1 addition & 0 deletions ansible/kubernetes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
- role: services/kifshare
- role: services/metadata
- role: services/notifications
- role: services/groups
- role: services/permissions
- role: services/requests
- role: services/resource-usage-api
Expand Down
Loading