From f48ca65b2dbcbeba2cef0fa81b2bfe9873bc9a96 Mon Sep 17 00:00:00 2001 From: Dilmand Zoro Date: Mon, 29 Jun 2026 20:09:01 +0200 Subject: [PATCH] fix(deployments): persist non-SSH app credentials per group and teacher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, deploy_tasks built credentials_for_db with only SSH/Linux credentials — the per-group loop filtered on s['linux']['password'], and the teacher block only emitted an SSH admin entry. For templates whose app.yaml declares per_group credentials of any other type (postgres, pgadmin, web_url, ...), the resulting user_json had an empty 'instance.credentials' list and no 'applications' section, so DeploymentInstanceAccess rows were only ever written for the teacher's auto-generated SSH key. Students saw nothing in the lecturer UI either (no Gruppen tab) because no group_id-stamped rows existed. Now deploy_tasks discovers every non-bookkeeping key in generated['deployment_groups'][*] and generated['teacher'] (i.e. everything except username/email/group_name/group_index/course_group_id/ students/linux — linux is still handled via the dedicated SSH section) and emits one applications[] entry per credential type. Each entry carries group_id pulled from course_group_id for groups, and explicit None for the teacher's admin_credentials. SSH handling is unchanged. In the credential service, _extract_access_entries now reads group_id from each application credential and sets it explicitly to None for admin_credentials. Without this, even a populated applications[] would have written rows with group_id NULL and remained invisible to students. Verified against the ansible-postgres-group-db template (per_group: postgres + pgadmin, teacher: postgres + pgadmin, no linux): two groups × two cred types now yields four group-stamped DATABASE rows and two admin DATABASE rows, plus the existing SSH admin row. --- src/services/deployment_credential_service.py | 10 ++ src/tasks/deploy_tasks.py | 115 ++++++++++++++---- 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/services/deployment_credential_service.py b/src/services/deployment_credential_service.py index 9ce699c..2f0402b 100644 --- a/src/services/deployment_credential_service.py +++ b/src/services/deployment_credential_service.py @@ -109,6 +109,13 @@ def _extract_access_entries( "access_type": AccessType.DATABASE, "username": cred.get("email") or cred.get("db_user") or cred.get("username"), "password": cred.get("password"), + # course_groups.id stamped by deploy_tasks from the + # per-group ``course_group_id`` carried through + # ``generated["deployment_groups"]``. Without this, + # student self-service would never see app credentials + # (postgres/pgAdmin/...) because the filter requires a + # non-NULL group_id matching the student's membership. + "group_id": cred.get("group_id"), "connection_url": pgadmin_url if is_pgadmin else None, "port": 80 if is_pgadmin else None, }) @@ -119,6 +126,9 @@ def _extract_access_entries( "access_type": AccessType.DATABASE, "username": app_admin.get("email") or app_admin.get("db_user") or app_admin.get("username"), "password": app_admin.get("password"), + # Admin (teacher) app credentials: group_id intentionally + # NULL — mirrors the SSH admin block. Lecturer-only. + "group_id": None, "connection_url": pgadmin_url if is_pgadmin else None, "port": 80 if is_pgadmin else None, }) diff --git a/src/tasks/deploy_tasks.py b/src/tasks/deploy_tasks.py index df5af54..265dcae 100644 --- a/src/tasks/deploy_tasks.py +++ b/src/tasks/deploy_tasks.py @@ -251,31 +251,102 @@ def deploy_stack(self, deployment_id: str) -> dict: ) try: - # Build user_json from `generated` so DB passwords match what Ansible sets + # Build user_json from `generated` so DB passwords match what Ansible sets. + # + # Two-section layout: + # - ``instance.credentials`` / ``instance.admin_credentials``: + # SSH (Linux) credentials. Only emitted for templates whose + # app.yaml declares ``per_group.linux``; teacher always has + # an auto-generated linux block (admin key), so the admin + # SSH row is written for every template. + # - ``applications[]``: every NON-linux credential type + # declared in app.yaml (postgres, pgadmin, web_url, …). + # One ``applications`` entry per credential type, with + # a ``credentials`` list for the groups and an + # ``admin_credentials`` block for the teacher. Without + # this, templates like ansible-postgres-group-db that + # declare only ``per_group.postgres`` + ``per_group.pgadmin`` + # would produce zero student-visible access rows. + NON_APP_KEYS = { + "username", "email", "group_name", "group_index", + "course_group_id", "students", "linux", + } + + group_entries = generated.get("deployment_groups", []) or [] + teacher_entry = generated.get("teacher", {}) or {} + + # SSH rows — same shape as before. Filter on linux.password + # is preserved: templates without per_group.linux simply + # don't get SSH access rows for their groups, which is + # correct (those students log into the app, not the VM). + ssh_credentials = [ + { + "username": s["linux"]["username"], + "password": s["linux"]["password"], + "ssh_private_key": (s.get("linux", {}).get("ssh_key") or {}).get("private_key"), + # course_groups.id this group corresponds to + # (passed in from the wizard via GroupInfo.course_group_id). + # Stamped onto DeploymentInstanceAccess.group_id so + # student self-service can filter on it. None when + # the wizard didn't supply it — row stays NULL and + # remains invisible to students. + "group_id": s.get("course_group_id"), + } + for s in group_entries + if s.get("linux", {}).get("password") + ] + ssh_admin = ( + { + "username": teacher_entry["linux"]["username"], + "password": teacher_entry["linux"]["password"], + "ssh_private_key": (teacher_entry["linux"].get("ssh_key") or {}).get("private_key"), + } + if teacher_entry.get("linux", {}).get("password") + else None + ) + + # App-credentials section — collected per credential type + # by union of keys across all group entries and the teacher + # entry (minus the bookkeeping keys above and ``linux``, + # which has its own SSH section). + app_cred_types: list[str] = [] + for source in (*group_entries, teacher_entry): + for key in source.keys(): + if key in NON_APP_KEYS or key in app_cred_types: + continue + app_cred_types.append(key) + + applications = [] + for cred_type in app_cred_types: + group_creds = [] + for s in group_entries: + cred = s.get(cred_type) + if not isinstance(cred, dict) or not cred.get("password"): + continue + group_creds.append({ + **cred, + "group_id": s.get("course_group_id"), + }) + admin_cred = teacher_entry.get(cred_type) + admin_block = ( + admin_cred + if isinstance(admin_cred, dict) and admin_cred.get("password") + else None + ) + if not group_creds and not admin_block: + continue + applications.append({ + "name": cred_type, + "credentials": group_creds, + "admin_credentials": admin_block, + }) + credentials_for_db = { "instance": { - "credentials": [ - { - "username": s["linux"]["username"], - "password": s["linux"]["password"], - "ssh_private_key": (s.get("linux", {}).get("ssh_key") or {}).get("private_key"), - # course_groups.id this group corresponds to - # (passed in from the wizard via GroupInfo.course_group_id). - # Stamped onto DeploymentInstanceAccess.group_id so - # student self-service can filter on it. None when - # the wizard didn't supply it — row stays NULL and - # remains invisible to students. - "group_id": s.get("course_group_id"), - } - for s in generated.get("deployment_groups", []) - if s.get("linux", {}).get("password") - ], - "admin_credentials": { - "username": generated["teacher"]["linux"]["username"], - "password": generated["teacher"]["linux"]["password"], - "ssh_private_key": (generated["teacher"]["linux"].get("ssh_key") or {}).get("private_key"), - } if generated.get("teacher", {}).get("linux", {}).get("password") else None, + "credentials": ssh_credentials, + "admin_credentials": ssh_admin, }, + "applications": applications, } # Bind the returned DeploymentInstance so the post-Ansible # activation-link fetch below can append rows to it. None