diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 7a3bdf7..e022256 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -201,57 +201,6 @@ jobs: with: ssh-private-key: ${{ secrets.PROD_SERVER_SSH_KEY }} - - name: Sync GitHub App env vars to Production /opt/appstore/.env - env: - SERVER_HOST: ${{ secrets.PROD_SERVER_HOST }} - GH_APP_ID: ${{ secrets.PROD_GITHUB_APP_ID }} - GH_APP_SLUG: ${{ secrets.PROD_GITHUB_APP_SLUG }} - GH_APP_PRIVATE_KEY: ${{ secrets.PROD_GITHUB_APP_PRIVATE_KEY }} - GH_APP_STATE_SECRET: ${{ secrets.PROD_GITHUB_APP_STATE_SECRET }} - FRONTEND_BASE_URL: ${{ secrets.PROD_FRONTEND_BASE_URL }} - run: | - set -euo pipefail - umask 077 - - # Build the new block locally so the multi-line PEM stays exact; - # piping it through SSH heredoc would risk shell expansion. - SNIPPET="$(mktemp)" - trap 'rm -f "$SNIPPET"' EXIT - { - printf '# >>> github-app (managed by ci-cd.yml) >>>\n' - printf 'GITHUB_APP_ID=%s\n' "$GH_APP_ID" - printf 'GITHUB_APP_SLUG=%s\n' "$GH_APP_SLUG" - printf 'GITHUB_APP_STATE_SECRET=%s\n' "$GH_APP_STATE_SECRET" - printf 'FRONTEND_BASE_URL=%s\n' "$FRONTEND_BASE_URL" - # PEM contains real newlines — wrap in double quotes so docker-compose - # / python-dotenv reads it as a single multi-line value. - printf 'GITHUB_APP_PRIVATE_KEY="%s"\n' "$GH_APP_PRIVATE_KEY" - printf '# <<< github-app <<<\n' - } > "$SNIPPET" - - scp -o StrictHostKeyChecking=no "$SNIPPET" ubuntu@$SERVER_HOST:/tmp/appstore-github-env - - ssh -o StrictHostKeyChecking=no ubuntu@$SERVER_HOST bash -s <<'ENDSSH' - set -euo pipefail - cd /opt/appstore - umask 077 - - [ -f .env ] || { touch .env; chmod 600 .env; } - - # Drop any prior managed block (delimited by markers); silent no-op - # if absent. Multi-line PEM is removed cleanly because the delete - # range is line-based on our markers. - sed -i '/^# >>> github-app (managed by ci-cd\.yml) >>>$/,/^# <<< github-app <<<$/d' .env - - # Trailing newline before append so we never collide with a - # non-newline-terminated last line. - [ -s .env ] && [ "$(tail -c1 .env | wc -l)" -eq 0 ] && printf '\n' >> .env - - cat /tmp/appstore-github-env >> .env - rm -f /tmp/appstore-github-env - chmod 600 .env - ENDSSH - - name: Deploy api + celery-worker + celery-beat on Production env: SERVER_HOST: ${{ secrets.PROD_SERVER_HOST }} diff --git a/.gitignore b/.gitignore index 9f65eca..2df5944 100644 --- a/.gitignore +++ b/.gitignore @@ -233,4 +233,6 @@ loki-data/ grafana-data/ promtail-positions.yaml -.DS_Store \ No newline at end of file +.DS_Store +# Local SQLite databases (test fixtures, scratch DBs) +*.db diff --git a/alembic/versions/034d40e1dad3_add_activation_link_to_access_type.py b/alembic/versions/034d40e1dad3_add_activation_link_to_access_type.py new file mode 100644 index 0000000..ff19b89 --- /dev/null +++ b/alembic/versions/034d40e1dad3_add_activation_link_to_access_type.py @@ -0,0 +1,53 @@ +"""add ACTIVATION_LINK to accesstype enum + +Revision ID: 034d40e1dad3 +Revises: c8e9d3b7f1a2 +Create Date: 2026-06-29 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '034d40e1dad3' +down_revision: Union[str, Sequence[str], None] = 'c8e9d3b7f1a2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + Adds a new ``ACTIVATION_LINK`` value to the ``accesstype`` Postgres enum + that backs ``deployment_instance_access.access_type``. Required for the + Overleaf LaTeX Lab app (and any future app that produces one-time + activation links during the Ansible run rather than passwords/keys + before it): the post-Ansible SSH fetch in ``deploy_tasks`` writes such + rows via ``DeploymentCredentialService.persist_activation_links``. + + ``IF NOT EXISTS`` keeps the migration idempotent — safe to re-run if a + manual ALTER got there first. + + Note: ``ALTER TYPE ... ADD VALUE`` is allowed inside a transaction since + PG 12, but the newly-added label is only usable after commit. The + backfill of any rows using this value happens at deploy time, never in + this migration, so the default Alembic transaction wrap is fine. + """ + op.execute( + "ALTER TYPE accesstype " + "ADD VALUE IF NOT EXISTS 'ACTIVATION_LINK'" + ) + + +def downgrade() -> None: + """Downgrade schema. + + Postgres does not support removing values from an enum type without + rebuilding the type and all dependent columns. Rolling back the + application code is the correct response; the unused enum label is + harmless. Left intentionally empty (same approach as + ``a1c5e8d2f307_add_expiry_enum_values_to_deployment_log``). + """ + pass diff --git a/alembic/versions/b5c41a8e7d92_add_group_id_to_deployment_instance_access.py b/alembic/versions/b5c41a8e7d92_add_group_id_to_deployment_instance_access.py new file mode 100644 index 0000000..ea7d399 --- /dev/null +++ b/alembic/versions/b5c41a8e7d92_add_group_id_to_deployment_instance_access.py @@ -0,0 +1,62 @@ +"""add group_id to deployment_instance_access + +Revision ID: b5c41a8e7d92 +Revises: e7f3a91d05b8 +Create Date: 2026-06-23 17:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b5c41a8e7d92' +down_revision: Union[str, Sequence[str], None] = 'e7f3a91d05b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + Adds a nullable ``group_id`` FK on ``deployment_instance_access`` pointing + to ``course_groups.id``. This is the missing link that lets the new + student self-service endpoint filter credentials down to the rows the + student is entitled to see — students see rows where ``group_id`` matches + one of their group memberships; lecturer admin credentials use + ``group_id IS NULL`` and remain hidden from students. + + Nullable + no default + no backfill in this migration. A separate data + migration (``b6d52b9f8ea3_backfill...``) walks existing + ``deployments.deployment_parameters`` JSON to retroactively populate the + new column for pre-feature deployments. + """ + op.add_column( + 'deployment_instance_access', + sa.Column('group_id', sa.String(length=36), nullable=True), + ) + op.create_foreign_key( + 'fk_deployment_instance_access_group_id', + source_table='deployment_instance_access', + referent_table='course_groups', + local_cols=['group_id'], + remote_cols=['id'], + ) + op.create_index( + 'ix_deployment_instance_access_group_id', + 'deployment_instance_access', + ['group_id'], + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index('ix_deployment_instance_access_group_id', table_name='deployment_instance_access') + op.drop_constraint( + 'fk_deployment_instance_access_group_id', + 'deployment_instance_access', + type_='foreignkey', + ) + op.drop_column('deployment_instance_access', 'group_id') diff --git a/alembic/versions/b6d52b9f8ea3_backfill_access_group_id.py b/alembic/versions/b6d52b9f8ea3_backfill_access_group_id.py new file mode 100644 index 0000000..b5a21c0 --- /dev/null +++ b/alembic/versions/b6d52b9f8ea3_backfill_access_group_id.py @@ -0,0 +1,138 @@ +"""backfill deployment_instance_access.group_id from existing deployments + +Revision ID: b6d52b9f8ea3 +Revises: b5c41a8e7d92 +Create Date: 2026-06-23 17:05:00.000000 + +Walks every existing deployment's ``deployment_parameters`` JSON (which holds +the original ``stack_assignments[*].groups[*]`` payload), looks up the +matching ``course_groups`` row by ``(course_id, name)``, and stamps +``deployment_instance_access.group_id`` for rows whose ``username`` matches +the sanitized group name. + +Idempotent: only updates rows where ``group_id IS NULL``. Re-runs are no-ops. + +Skipped gracefully when: +- ``deployment_parameters`` is NULL or unparseable +- No matching ``course_groups`` row exists (lecturer never created groups via + the courses API — those access rows stay ``group_id = NULL`` and remain + invisible to students; lecturer-side flow is unaffected) +- The access row's ``username`` doesn't match any group's sanitized name + (likely an admin/teacher credential — intentionally stays NULL) +""" +from __future__ import annotations + +import json +import re + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b6d52b9f8ea3' +down_revision: Union[str, Sequence[str], None] = 'b5c41a8e7d92' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _sanitize_username(name: str) -> str: + """Mirror of credential_generator_service._sanitize_username. + + Kept inline (not imported) so the migration is self-contained and + immune to future service-code refactors. MUST be kept byte-for-byte + in sync with the service implementation — otherwise the backfill + can't match existing DeploymentInstanceAccess.username rows against + GroupMember-derived group names. + """ + username = name.lower().replace(" ", "_").replace(".", "_").replace("-", "_") + username = re.sub(r"[^a-z0-9_]", "", username) + if username and username[0].isdigit(): + username = "u" + username + return (username or "user")[:32] + + +def upgrade() -> None: + """Backfill ``group_id`` on existing ``deployment_instance_access`` rows.""" + bind = op.get_bind() + + deployments = bind.execute(sa.text( + "SELECT id, course_id, deployment_parameters " + "FROM deployments " + "WHERE deployment_parameters IS NOT NULL" + )).fetchall() + + updated = 0 + for dep in deployments: + dep_id = dep[0] + course_id = dep[1] + params_raw = dep[2] + if not params_raw: + continue + try: + params = json.loads(params_raw) + except (json.JSONDecodeError, TypeError): + continue + + stack_assignments = params.get("stack_assignments") or [] + for stack in stack_assignments: + for group in (stack.get("groups") or []): + group_name = group.get("group_name") + if not group_name: + continue + sanitized = _sanitize_username(group_name) + + # Find the CourseGroup row for this (course_id, group_name). + # Older payloads may carry course_group_id directly; prefer it. + course_group_id = group.get("course_group_id") + if not course_group_id: + row = bind.execute( + sa.text( + "SELECT id FROM course_groups " + "WHERE course_id = :course_id AND name = :name " + "LIMIT 1" + ), + {"course_id": course_id, "name": group_name}, + ).fetchone() + if not row: + # No persisted CourseGroup for this group → skip. + # Lecturer-side flow keeps working; students just + # don't see anything for this group. + continue + course_group_id = row[0] + + # Stamp any access row of this deployment whose username + # matches the sanitized group name AND is still group_id IS NULL. + result = bind.execute( + sa.text( + "UPDATE deployment_instance_access " + "SET group_id = :group_id " + "WHERE id IN (" + " SELECT dia.id FROM deployment_instance_access dia " + " JOIN deployment_instances di " + " ON di.id = dia.deployment_instance_id " + " WHERE di.deployment_id = :dep_id " + " AND dia.username = :username " + " AND dia.group_id IS NULL " + ")" + ), + { + "group_id": course_group_id, + "dep_id": dep_id, + "username": sanitized, + }, + ) + updated += result.rowcount or 0 + + print(f"backfill complete: stamped group_id on {updated} access row(s)") + + +def downgrade() -> None: + """Set all group_id back to NULL — reverses the backfill. + + Idempotent and safe: no data is destroyed; the schema column itself + is dropped by the previous migration's downgrade. + """ + op.execute("UPDATE deployment_instance_access SET group_id = NULL") diff --git a/alembic/versions/c8a3f1e9b7d5_create_template_icons_table.py b/alembic/versions/c8a3f1e9b7d5_create_template_icons_table.py new file mode 100644 index 0000000..77af027 --- /dev/null +++ b/alembic/versions/c8a3f1e9b7d5_create_template_icons_table.py @@ -0,0 +1,93 @@ +"""create template_icons table + drop templates.icon_url + +Revision ID: c8a3f1e9b7d5 +Revises: e2a91d05c7b8 +Create Date: 2026-07-01 09:00:00.000000 + +Zwei Änderungen in einer Migration, weil sie inhaltlich zusammengehören +und dieser Feature-Branch noch nicht deployt ist (kein Bestand zu retten): + +1. Neue Tabelle ``template_icons`` — hält hochgeladene Icon-Bilder als + BYTEA. ``template_id`` unique (1:1) und ``ON DELETE CASCADE``, damit + die Row automatisch mitgeht, wenn das Template gelöscht wird. + +2. Alte Spalte ``templates.icon_url`` fliegt raus. Icons kommen ab jetzt + ausschließlich als Upload; ``mdi:*``-Strings/URLs werden nicht mehr + unterstützt. Frontend zeigt für Templates ohne hochgeladenes Bild + einen Placeholder. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = 'c8a3f1e9b7d5' +down_revision: Union[str, Sequence[str], None] = 'e2a91d05c7b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create template_icons table and drop templates.icon_url.""" + op.create_table( + 'template_icons', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column( + 'template_id', + sa.String(length=36), + nullable=False, + comment='Owning template — 1:1, jedes Template hat höchstens ein Icon.', + ), + sa.Column( + 'content', + sa.LargeBinary(), + nullable=False, + comment='Rohbytes des Bildes (PNG/JPEG/WebP).', + ), + sa.Column( + 'content_type', + sa.String(length=64), + nullable=False, + comment='MIME-Typ, wird beim Ausliefern als Content-Type-Header verwendet.', + ), + sa.Column( + 'file_name', + sa.String(length=255), + nullable=True, + comment='Original-Dateiname (für Content-Disposition).', + ), + sa.Column( + 'size_bytes', + sa.Integer(), + nullable=False, + comment='Größe von content in Bytes.', + ), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ['template_id'], + ['templates.id'], + ondelete='CASCADE', + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('template_id', name='uq_template_icons_template_id'), + ) + + # icon_url wird durch hochgeladene Icons ersetzt. Kein Bestand zu retten + # (dieser Branch ist noch nicht deployed), also einfach droppen. + op.drop_column('templates', 'icon_url') + + +def downgrade() -> None: + """Restore templates.icon_url and drop template_icons.""" + op.add_column( + 'templates', + sa.Column( + 'icon_url', + sa.String(length=500), + nullable=True, + comment='Icon URL or identifier (e.g., mdi:server, /icons/template.svg, 🚀)', + ), + ) + op.drop_table('template_icons') diff --git a/alembic/versions/c8e9d3b7f1a2_make_approval_status_nullable.py b/alembic/versions/c8e9d3b7f1a2_make_approval_status_nullable.py new file mode 100644 index 0000000..aecac56 --- /dev/null +++ b/alembic/versions/c8e9d3b7f1a2_make_approval_status_nullable.py @@ -0,0 +1,76 @@ +"""make template_versions.approval_status nullable + +Revision ID: c8e9d3b7f1a2 +Revises: b6d52b9f8ea3 +Create Date: 2026-06-25 12:00:00.000000 + +Approval-Status ist konzeptuell nur für ``public`` Templates relevant: +deren neue Versionen brauchen einen Admin-Review, bevor sie für andere +Lecturer sichtbar werden. ``private`` Templates sind eh nur dem Owner +sichtbar — ein Approval-Flow ergibt dort keinen Sinn und steht nur als +verwirrendes ``pending``-Badge im UI rum. + +Die Spalte selbst bleibt erhalten (Code-Pfade gegen ``APPROVED`` filtern +seitdem ``NULL`` semantisch wie ``not approved`` und die `WHERE = APPROVED` +SQL-Filter excludieren NULLs ohnehin korrekt). Was sich ändert: + +- Spalte ist jetzt nullable +- Default bleibt ``PENDING`` (legacy callers ohne explizite Wahl) +- Neue Versionen privater Templates werden vom Service-Code explizit auf + NULL gesetzt; öffentliche Templates verhalten sich wie bisher +- Visibility-Toggle (private↔public) durch den Code wird die Spalte + entsprechend resetten + +Keine Datenmigration: bestehende Private-Templates behalten ggf. ein +``pending``-Badge, bis die Visibility manuell getoggelt wird; das richtet +keinen Schaden an, weil der Owner sie weiterhin voll nutzen kann. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c8e9d3b7f1a2' +down_revision: Union[str, Sequence[str], None] = 'b6d52b9f8ea3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Postgres-Enum-Spalten brauchen den existing_type expliziet, sonst kann + # alter_column das Enum-Detail nicht aus dem Schema rekonstruieren. + op.alter_column( + 'template_versions', + 'approval_status', + existing_type=sa.Enum( + 'PENDING', 'APPROVED', 'REJECTED', 'DEPRECATED', + name='templateversionapprovalstatus', + ), + nullable=True, + ) + + +def downgrade() -> None: + """Downgrade schema. + + Setzt etwaige NULL-Werte zurück auf ``PENDING`` bevor die NOT-NULL- + Constraint wiederhergestellt wird — sonst schlägt die alter_column + auf bestehenden NULL-Reihen fehl. + """ + op.execute( + "UPDATE template_versions " + "SET approval_status = 'PENDING' " + "WHERE approval_status IS NULL" + ) + op.alter_column( + 'template_versions', + 'approval_status', + existing_type=sa.Enum( + 'PENDING', 'APPROVED', 'REJECTED', 'DEPRECATED', + name='templateversionapprovalstatus', + ), + nullable=False, + ) diff --git a/alembic/versions/d5e8c2a91b34_cascade_delete_template_versions.py b/alembic/versions/d5e8c2a91b34_cascade_delete_template_versions.py new file mode 100644 index 0000000..c8a1add --- /dev/null +++ b/alembic/versions/d5e8c2a91b34_cascade_delete_template_versions.py @@ -0,0 +1,69 @@ +"""cascade_delete_template_versions + +Revision ID: d5e8c2a91b34 +Revises: 034d40e1dad3 +Create Date: 2026-06-29 12:00:00.000000 + +Make `template_versions.template_id` and `template_version_files.template_version_id` +cascade on delete so that deleting a template also removes its versions and the +files attached to those versions in a single transaction. +""" +from typing import Sequence, Union + +from alembic import op + + +revision: str = 'd5e8c2a91b34' +down_revision: Union[str, Sequence[str], None] = '034d40e1dad3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add ON DELETE CASCADE to template_versions and template_version_files FKs.""" + # template_versions.template_id -> templates.id + with op.batch_alter_table('template_versions') as batch_op: + batch_op.drop_constraint('template_versions_template_id_fkey', type_='foreignkey') + batch_op.create_foreign_key( + 'template_versions_template_id_fkey', + 'templates', + ['template_id'], + ['id'], + ondelete='CASCADE', + ) + + # template_version_files.template_version_id -> template_versions.id + with op.batch_alter_table('template_version_files') as batch_op: + batch_op.drop_constraint( + 'template_version_files_template_version_id_fkey', type_='foreignkey' + ) + batch_op.create_foreign_key( + 'template_version_files_template_version_id_fkey', + 'template_versions', + ['template_version_id'], + ['id'], + ondelete='CASCADE', + ) + + +def downgrade() -> None: + """Revert FKs to no-cascade behavior.""" + with op.batch_alter_table('template_version_files') as batch_op: + batch_op.drop_constraint( + 'template_version_files_template_version_id_fkey', type_='foreignkey' + ) + batch_op.create_foreign_key( + 'template_version_files_template_version_id_fkey', + 'template_versions', + ['template_version_id'], + ['id'], + ) + + with op.batch_alter_table('template_versions') as batch_op: + batch_op.drop_constraint('template_versions_template_id_fkey', type_='foreignkey') + batch_op.create_foreign_key( + 'template_versions_template_id_fkey', + 'templates', + ['template_id'], + ['id'], + ) diff --git a/alembic/versions/e2a91d05c7b8_add_redeploying_to_instance_status.py b/alembic/versions/e2a91d05c7b8_add_redeploying_to_instance_status.py new file mode 100644 index 0000000..7b45d61 --- /dev/null +++ b/alembic/versions/e2a91d05c7b8_add_redeploying_to_instance_status.py @@ -0,0 +1,48 @@ +"""add REDEPLOYING to deploymentinstancestatus enum + +Revision ID: e2a91d05c7b8 +Revises: f9c2a14e7b80 +Create Date: 2026-06-30 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'e2a91d05c7b8' +down_revision: Union[str, Sequence[str], None] = 'f9c2a14e7b80' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + Adds a new ``REDEPLOYING`` value to the ``deploymentinstancestatus`` + Postgres enum so the ``redeploy_instance`` Celery task can mark a single + DeploymentInstance row as transient while it tears down + recreates its + Heat stack, without flipping the parent Deployment to RESTARTING (which + would block the user from running other ops on its siblings). + + ``IF NOT EXISTS`` keeps the migration idempotent — safe to re-run if a + manual ALTER got there first. Same approach as + ``034d40e1dad3_add_activation_link_to_access_type``. + """ + op.execute( + "ALTER TYPE deploymentinstancestatus " + "ADD VALUE IF NOT EXISTS 'REDEPLOYING'" + ) + + +def downgrade() -> None: + """Downgrade schema. + + Postgres does not support removing values from an enum type without + rebuilding the type and all dependent columns. Rolling back the + application code is the correct response; the unused enum label is + harmless. Left intentionally empty (mirrors + ``034d40e1dad3_add_activation_link_to_access_type``). + """ + pass diff --git a/alembic/versions/e3a91d7b5c42_add_publish_requested_to_templates.py b/alembic/versions/e3a91d7b5c42_add_publish_requested_to_templates.py new file mode 100644 index 0000000..dc5b0b5 --- /dev/null +++ b/alembic/versions/e3a91d7b5c42_add_publish_requested_to_templates.py @@ -0,0 +1,98 @@ +"""add publish_requested to templates + +Revision ID: e3a91d7b5c42 +Revises: d5e8c2a91b34 +Create Date: 2026-06-29 12:00:00.000000 + +Owner-Wunsch „bei Erstellung öffentlich" soll das Template NICHT sofort auf +`visibility=PUBLIC` flippen, sondern als PRIVATE + `publish_requested=TRUE` +anlegen. Sobald ein Admin die erste Version genehmigt, übernimmt die +Service-Logik den Flip auf PUBLIC. Bei Rejection wird der Wunsch verworfen. + +Data-Migration: +- Bestehende PUBLIC-Templates OHNE eine APPROVED Version werden auf + PRIVATE + publish_requested=TRUE zurückgesetzt — sie entsprechen dem neuen + Erwartungs-Zustand „wartet noch auf die Erst-Freigabe". Templates mit + mindestens einer APPROVED Version bleiben unangetastet. +- Logging via `op.execute` mit RAISE NOTICE in einem DO-Block, damit die + Anzahl der betroffenen Templates im Migrations-Output erscheint. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'e3a91d7b5c42' +down_revision: Union[str, Sequence[str], None] = 'd5e8c2a91b34' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add publish_requested column + backfill stale PUBLIC templates.""" + op.add_column( + 'templates', + sa.Column( + 'publish_requested', + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + # Backfill: PUBLIC-Templates ohne approved Version → PRIVATE + publish_requested. + # Wir machen das atomar mit einem CTE, damit das gleichzeitige UPDATE auf + # visibility und publish_requested auf derselben Treffermenge läuft. + op.execute( + """ + WITH stale_public AS ( + SELECT t.id + FROM templates t + WHERE t.visibility = 'PUBLIC' + AND NOT EXISTS ( + SELECT 1 + FROM template_versions tv + WHERE tv.template_id = t.id + AND tv.approval_status = 'APPROVED' + ) + ) + UPDATE templates + SET visibility = 'PRIVATE', + publish_requested = TRUE + WHERE id IN (SELECT id FROM stale_public); + """ + ) + + # Optional: kurze Notice für Operator-Sichtbarkeit. Postgres-spezifisch; + # auf SQLite (Tests) ist das ein No-Op via try/except. + op.execute( + """ + DO $$ + DECLARE + affected_count INTEGER; + BEGIN + SELECT COUNT(*) INTO affected_count + FROM templates WHERE publish_requested = TRUE; + RAISE NOTICE '[migration e3a91d7b5c42] Backfilled % template(s) to PRIVATE+publish_requested', affected_count; + END $$; + """ + ) + + +def downgrade() -> None: + """Drop publish_requested. + + Vor dem Drop flippen wir publish_requested=TRUE-Templates wieder zu PUBLIC, + damit deren Owner sie nicht „verlieren" — verlustbehaftet, aber näher am + pre-Migration-Zustand als „still privat ohne Erinnerung". Beide Pfade + haben Trade-offs; das ist die explizite Wahl. + """ + op.execute( + """ + UPDATE templates + SET visibility = 'PUBLIC' + WHERE publish_requested = TRUE; + """ + ) + op.drop_column('templates', 'publish_requested') diff --git a/alembic/versions/ebc91d7b5d43_unique_template_version_string.py b/alembic/versions/ebc91d7b5d43_unique_template_version_string.py new file mode 100644 index 0000000..d902cef --- /dev/null +++ b/alembic/versions/ebc91d7b5d43_unique_template_version_string.py @@ -0,0 +1,98 @@ +"""unique (template_id, version) on template_versions + +Revision ID: ebc91d7b5d43 +Revises: e3a91d7b5c42 +Create Date: 2026-06-29 12:05:00.000000 + +Vor dieser Migration konnten innerhalb eines Templates beliebig viele Rows +mit identischem ``version``-String existieren — nur ``(template_id, commit_sha)`` +war unique. Praxis-Effekt: jedes Repo, das `app.yaml.app.version` nicht bumpte, +landete bei „v2.0.0, v2.0.0, v2.0.0, …". + +Daten-Migration: +- Schritt 1: bestehende Duplikate von ``(template_id, version)`` deduplizieren. + Wir behalten die NEUESTE Row (höchste ``created_at``, Tie-Break per ``id``) + unverändert und hängen an die übrigen ``+dedupe-`` an. Die + Build-Metadata-Komponente (alles nach ``+``) ist laut Semver-Spec gültig + UND sortier-neutral — der Owner sieht im UI „2.0.0+dedupe-abc12345" und + kann das Item bei Bedarf umbenennen. +- Schritt 2: ``UniqueConstraint`` auf ``(template_id, version)`` setzen. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'ebc91d7b5d43' +down_revision: Union[str, Sequence[str], None] = 'e3a91d7b5c42' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Dedupe + unique constraint.""" + # Schritt 1: alle Duplikate außer der jeweils neuesten umbenennen. + # Wir nutzen eine CTE mit ROW_NUMBER, partitioniert auf (template_id, version), + # sortiert auf created_at DESC, id DESC. rn=1 ist die zu behaltende Row, + # rn>=2 wird umbenannt zu „+dedupe-". + op.execute( + """ + WITH ranked AS ( + SELECT + id, + template_id, + version, + git_commit_sha, + ROW_NUMBER() OVER ( + PARTITION BY template_id, version + ORDER BY created_at DESC, id DESC + ) AS rn + FROM template_versions + ) + UPDATE template_versions tv + SET version = ranked.version || '+dedupe-' || SUBSTRING(ranked.git_commit_sha FROM 1 FOR 8) + FROM ranked + WHERE tv.id = ranked.id + AND ranked.rn > 1; + """ + ) + + # Edge-Case: nach Schritt 1 könnte das umbenannte Suffix theoretisch erneut + # mit etwas existing kollidieren („1.0.0+dedupe-abc12345" war schon da). + # Praktisch unwahrscheinlich (commit_sha-Prefix), aber wir loggen die + # Gesamtzahl der renames für den Operator. + op.execute( + """ + DO $$ + DECLARE + renamed_count INTEGER; + BEGIN + SELECT COUNT(*) INTO renamed_count + FROM template_versions + WHERE version LIKE '%+dedupe-%'; + RAISE NOTICE '[migration ebc91d7b5d43] Renamed % duplicate version row(s) to ''+dedupe-''', renamed_count; + END $$; + """ + ) + + # Schritt 2: jetzt darf die Constraint angelegt werden. + op.create_unique_constraint( + 'uq_template_versions_template_id_version', + 'template_versions', + ['template_id', 'version'], + ) + + +def downgrade() -> None: + """Drop the unique constraint. + + Die ``+dedupe-…``-Suffixe werden bewusst NICHT entfernt — sie sind valider + Semver-Build-Metadata-Anteil und liefern Rückverfolgbarkeit. Wer einen + sauberen Rollback will, muss die Suffixe per SQL gezielt rückbauen. + """ + op.drop_constraint( + 'uq_template_versions_template_id_version', + 'template_versions', + type_='unique', + ) diff --git a/alembic/versions/f9c2a14e7b80_create_course_filters_table.py b/alembic/versions/f9c2a14e7b80_create_course_filters_table.py new file mode 100644 index 0000000..a165a07 --- /dev/null +++ b/alembic/versions/f9c2a14e7b80_create_course_filters_table.py @@ -0,0 +1,43 @@ +"""create course_filters table + +Revision ID: f9c2a14e7b80 +Revises: ebc91d7b5d43 +Create Date: 2026-06-30 12:00:00.000000 + +Admin-verwaltete Filter-Strings für Kursnamen (Frontend-Chips). Die Filterung +selbst läuft client-seitig — diese Tabelle hält nur die Liste der Begriffe. +``name`` ist unique, damit doppelte Chips im UI vermieden werden. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = 'f9c2a14e7b80' +down_revision: Union[str, Sequence[str], None] = 'ebc91d7b5d43' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create course_filters table.""" + op.create_table( + 'course_filters', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column( + 'name', + sa.String(length=255), + nullable=False, + comment='Anzeige-/Such-String, den das Frontend gegen Kursnamen matcht', + ), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='uq_course_filters_name'), + ) + + +def downgrade() -> None: + """Drop course_filters table.""" + op.drop_table('course_filters') diff --git a/bruno/Deployments/Redeploy Deployment.bru b/bruno/Deployments/Redeploy Deployment.bru new file mode 100644 index 0000000..a6e6348 --- /dev/null +++ b/bruno/Deployments/Redeploy Deployment.bru @@ -0,0 +1,160 @@ +meta { + name: Redeploy Deployment + type: http + seq: 6 +} + +post { + url: {{base_url}}/api/v1/deployments/{{deployment_id}}/redeploy?openstack_project_id={{openstack_project_local_id}} + body: json + auth: bearer +} + +auth:bearer { + token: {{access_token}} +} + +params:query { + openstack_project_id: {{openstack_project_local_id}} +} + +params:path { + deployment_id: +} + +body:json { + { + "deployment_parameter_overrides": { + "include_example_notebooks": true + }, + "instance_parameter_overrides": { + "instance-uuid-of-group-3": { + "flavor": "gp1.medium" + } + }, + "preserve_credentials": false + } +} + +docs { + # Redeploy Deployment (all VMs) + + Destroy-and-recreate **every VM** (DeploymentInstance) in this deployment, one + after another, optionally with overridden parameters (template / config values). + + Unlike `POST /deployments/{id}/restart` (which only triggers a Heat + `update_stack` on the existing stack), this fully rebuilds each VM from + scratch: + + 1. Heat stack deleted + 2. Heat stack recreated with merged parameters + 3. Ansible re-run + 4. Credentials regenerated (unless `preserve_credentials=true`) + + Use it when a config / template parameter changed and you want the change to + actually take effect. + + ## Authorization + - **Owner (Lecturer)** or **Admin** only + - Lecturers can only redeploy their own deployments + - Admins can redeploy any deployment + + ## Body + + All fields are optional. Empty body = redeploy with the stored + `deployment_parameters` unchanged and fresh credentials. + + | Field | Type | Description | + |----------------------------------|-------------------------------------|-------------| + | `deployment_parameter_overrides` | `dict[str, Any]` | Merged ON TOP of the deployment's stored parameters for every redeployed VM. | + | `instance_parameter_overrides` | `dict[instance_id, dict[str, Any]]` | Per-VM overrides, keyed by `DeploymentInstance.id`. Merged ON TOP of `deployment_parameter_overrides` for that one VM. | + | `preserve_credentials` | `bool` (default `false`) | Keep the existing per-VM credentials instead of regenerating them. Useful to avoid breaking student logins during a quick config change. | + + ### Merge order + + Later layers win: + + template defaults + ↓ + deployment.deployment_parameters + ↓ + deployment_parameter_overrides + ↓ + instance_parameter_overrides[] + + ## Process + + 1. API validates permissions and deployment state. + 2. A single Celery task `redeploy_deployment` is enqueued. + 3. The task iterates **sequentially** over every DeploymentInstance row and + reuses the `redeploy_instance` task per VM (to avoid quota exhaustion in + parallel mode). + 4. For each VM: status flips to `REDEPLOYING` → Heat stack deleted → Heat + stack recreated with merged params → Ansible run → credentials persisted. + 5. The parent deployment stays in `RUNNING` between instances; siblings + remain reachable for students. + + ## Response + + Returns 202 Accepted; the actual redeploy runs asynchronously. + + ## Example Response + + ```json + { + "success": true, + "data": { + "deployment_id": "deploy-123", + "instance_count": 5, + "status": "redeploy_queued", + "preserve_credentials": false + }, + "message": "Redeploy requested for 5 instance(s); operation in progress", + "request_id": "req-456" + } + ``` + + ## Error Responses + + **404 Not Found** + ```json + {"success": false, "message": "Deployment with ID xyz not found"} + ``` + + **403 Forbidden** (lecturer trying to redeploy another lecturer's deployment) + ```json + {"success": false, "message": "You do not have permission to access this deployment"} + ``` + + **400 Bad Request** (deployment in transitional state) + ```json + {"success": false, "message": "Cannot redeploy deployment in creating state. Please wait for the current operation to complete."} + ``` + + **400 Bad Request** (deployment has no instances) + ```json + {"success": false, "message": "Deployment has no instances to redeploy"} + ``` + + **500 Internal Server Error** (task enqueue failure) + ```json + {"success": false, "message": "Failed to enqueue redeploy task"} + ``` + + ## Tracking Progress + + - Per-VM status visible on each `DeploymentInstance.status` (flips through + `REDEPLOYING` → `CREATING` → `RUNNING|FAILED`). + - Live log stream: `GET /deployments/{id}/logs/stream`. + - The deployment's `openstack_stack_id` JSON array is rewritten incrementally + as old stack IDs are dropped and new ones are added — so a follow-up DELETE + still cleans up correctly. + + ## Caveats + + - `preserve_credentials=true` can only carry over credentials that are + persisted in `DeploymentInstanceAccess`. Passwords generated ad-hoc inside + Ansible playbooks (without a credential spec entry) are still regenerated. + - The Heat stack name format stays `-s-`, so + OpenStack tags / dashboards keep working across redeploys. +} diff --git a/bruno/Deployments/Redeploy Instance.bru b/bruno/Deployments/Redeploy Instance.bru new file mode 100644 index 0000000..f1784c8 --- /dev/null +++ b/bruno/Deployments/Redeploy Instance.bru @@ -0,0 +1,141 @@ +meta { + name: Redeploy Instance + type: http + seq: 7 +} + +post { + url: {{base_url}}/api/v1/deployments/{{deployment_id}}/instances/{{instance_id}}/redeploy?openstack_project_id={{openstack_project_local_id}} + body: json + auth: bearer +} + +auth:bearer { + token: {{access_token}} +} + +params:query { + openstack_project_id: {{openstack_project_local_id}} +} + +params:path { + deployment_id: + instance_id: +} + +body:json { + { + "deployment_parameter_overrides": { + "include_example_notebooks": true, + "flavor": "gp1.medium" + }, + "preserve_credentials": false + } +} + +docs { + # Redeploy Instance (single VM) + + Destroy-and-recreate **exactly one VM** (DeploymentInstance) inside an + existing deployment. + + Use this when one VM is wedged, or to apply a config change to a single + group without touching its siblings. The parent deployment stays in + `RUNNING` for the duration — only the targeted instance flips to + `REDEPLOYING`. + + ## Authorization + - **Owner (Lecturer)** or **Admin** only + - Lecturers can only redeploy instances of their own deployments + - Admins can redeploy any instance + + ## Body + + All fields are optional. Empty body = redeploy with the deployment's stored + parameters unchanged and fresh credentials. + + | Field | Type | Description | + |----------------------------------|--------------------------|-------------| + | `deployment_parameter_overrides` | `dict[str, Any]` | Merged ON TOP of the deployment's stored parameters for this VM. Since there's only one VM in scope, treat this as the full override map for this instance. | + | `preserve_credentials` | `bool` (default `false`) | Keep the existing access rows (passwords / SSH keys / activation links) on the freshly-created instance instead of regenerating them. | + + > **Note:** The `instance_parameter_overrides` field accepted by the + > deployment-wide endpoint is **ignored here**. Pass per-VM parameters + > directly in `deployment_parameter_overrides`. + + ## Process + + 1. API validates permissions, deployment state, and instance existence. + 2. Celery task `redeploy_instance` is enqueued. + 3. Task flips instance status to `REDEPLOYING`. + 4. If `preserve_credentials=true`: snapshots the current access rows. + 5. Old Heat stack deleted, old `DeploymentInstance` + access rows wiped. + 6. New Heat stack created with merged parameters (same `-s-` slug + so OpenStack tags stay consistent). + 7. Ansible runs; credentials are persisted (or restored from snapshot). + 8. The deployment's `openstack_stack_id` JSON array is rewritten so the old + ID is dropped and the new one is added — a future DELETE walks the right + list. + + ## Response + + Returns 202 Accepted; the actual redeploy runs asynchronously. + + ## Example Response + + ```json + { + "success": true, + "data": { + "deployment_id": "deploy-123", + "instance_id": "inst-A", + "status": "redeploy_queued", + "preserve_credentials": false + }, + "message": "Redeploy requested for instance; operation in progress", + "request_id": "req-456" + } + ``` + + ## Error Responses + + **404 Not Found** (deployment missing) + ```json + {"success": false, "message": "Deployment with ID xyz not found"} + ``` + + **404 Not Found** (instance missing or belongs to a different deployment) + ```json + {"success": false, "message": "Instance inst-A not found in deployment deploy-123"} + ``` + + **403 Forbidden** (lecturer not the owner) + ```json + {"success": false, "message": "You do not have permission to access this deployment"} + ``` + + **400 Bad Request** (deployment in transitional state) + ```json + {"success": false, "message": "Cannot redeploy instance while deployment is in deleting state."} + ``` + + **500 Internal Server Error** (task enqueue failure) + ```json + {"success": false, "message": "Failed to enqueue redeploy task"} + ``` + + ## Tracking Progress + + - Watch this single instance's `.status` (REDEPLOYING → CREATING → RUNNING|FAILED). + - Live log stream: `GET /deployments/{id}/logs/stream`. + - Sibling VMs remain reachable throughout — no class-wide outage. + + ## Caveats + + - The instance lookup uses the row's stored `vm_name` (`-s-` suffix) + to recover which `stack_assignment` produced it. Manually-renamed VMs may + fail with `Cannot recover stack_assignment for instance ...`. + - `preserve_credentials=true` only restores credentials present in + `DeploymentInstanceAccess`. Passwords generated ad-hoc inside Ansible + playbooks (no credential spec entry) are still regenerated. +} diff --git a/bruno/Templates/Create Template.bru b/bruno/Templates/Create Template.bru index bb2d616..7d2a645 100644 --- a/bruno/Templates/Create Template.bru +++ b/bruno/Templates/Create Template.bru @@ -18,8 +18,7 @@ body:json { { "name": "Python Flask Template", "description": "A template for Flask web applications", - "repo_url": "https://github.com/example/flask-template", - "icon_url": "mdi:flask" + "repo_url": "https://github.com/example/flask-template" } } @@ -46,9 +45,12 @@ docs { - `name` (required, ≤ 255) - `description` (optional) - `repo_url` (required, ≤ 500) - - `icon_url` (optional, ≤ 500) — e.g. `mdi:flask`, `🚀`, `/icons/template.svg` - `visibility` (ignored on create; backend pins to `private`) + Icons: das Feld `icon_url` gibt es nicht mehr. Nach dem Anlegen kann + optional ein Icon-Bild via `POST /templates/{id}/icon` hochgeladen werden + (multipart, PNG/JPEG/WebP, max 5 MB). + ## Returns Created Template with `visibility='private'`. The post-response script saves diff --git a/bruno/Templates/Import Template From GitHub.bru b/bruno/Templates/Import Template From GitHub.bru index b375a6f..828282b 100644 --- a/bruno/Templates/Import Template From GitHub.bru +++ b/bruno/Templates/Import Template From GitHub.bru @@ -18,7 +18,6 @@ body:json { { "name": "Postgres Group DB", "description": "Provision a Postgres VM via the appstore", - "icon_url": "mdi:database", "github_url": "https://github.com/dozilab/templates", "app_yaml_path": "postgres/app.yaml" } @@ -51,11 +50,13 @@ docs { - `name` (required, ≤ 255) - `description` (optional) - - `icon_url` (optional, ≤ 500) - `github_url` (required, ≤ 1000) - `app_yaml_path` (optional) — defaults to `app.yaml` (root) if not given and the URL is the repo/branch root. + Icons: das Feld `icon_url` gibt es nicht mehr. Nach dem Import kann + optional ein Icon-Bild via `POST /templates/{id}/icon` hochgeladen werden. + ## Permissions ADMIN or LECTURER. Template is always created `visibility=private`. Admins diff --git a/pyproject.toml b/pyproject.toml index b96b2e2..cf7e1fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "psycopg2-binary>=2.9.0", "alembic>=1.13.0", "python-jose[cryptography]>=3.3.0", + "python-multipart>=0.0.9", "httpx>=0.25.0", "openstacksdk>=3.3.0", "python-heatclient>=3.5.0", diff --git a/scripts/sync_app_files_to_db.py b/scripts/sync_app_files_to_db.py new file mode 100644 index 0000000..d0c831c --- /dev/null +++ b/scripts/sync_app_files_to_db.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Sync template files for an existing app from disk to the local DB. + +Usage: + uv run python scripts/sync_app_files_to_db.py + +Examples: + uv run python scripts/sync_app_files_to_db.py \ + "Ansible Multi-User Ubuntu" \ + ../appstore-apps/ansible_multiuser + + uv run python scripts/sync_app_files_to_db.py \ + "Ansible PostgreSQL Group DB" \ + ../appstore-apps/ansible_postgres_group_db + +Why this exists: + add_*.py scripts create a new template from scratch but fail on the second + run (template name conflict). After editing a playbook on disk you don't + want to delete + recreate the template every time — that loses Stack-IDs, + course bindings, and history. This script just refreshes the *files* of + the currently active version, matched by file_name. Bytes change, IDs + don't. +""" +import sys +from pathlib import Path + +# Make src/ importable so we can reuse the model + session. +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.core.database import SessionLocal +from src.models.template import Template +from src.models.template_version import TemplateVersion +from src.models.template_version_file import TemplateVersionFile + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + sys.exit(1) + + template_name = sys.argv[1] + app_dir = Path(sys.argv[2]).resolve() + + if not app_dir.is_dir(): + print(f"✗ App-Dir not found: {app_dir}") + sys.exit(1) + + db = SessionLocal() + try: + template = db.query(Template).filter(Template.name == template_name).first() + if not template: + print(f"✗ Template not found in DB: {template_name!r}") + sys.exit(1) + + # Pick the latest version (sort by created_at desc). + version = ( + db.query(TemplateVersion) + .filter(TemplateVersion.template_id == template.id) + .order_by(TemplateVersion.created_at.desc()) + .first() + ) + if not version: + print(f"✗ No version exists for template {template_name!r}") + sys.exit(1) + + print(f"Template: {template.name} (id={template.id})") + print(f"Version: {version.version} (id={version.id})") + print(f"Source dir: {app_dir}") + print() + + files_in_db = ( + db.query(TemplateVersionFile) + .filter(TemplateVersionFile.template_version_id == version.id) + .all() + ) + by_name = {f.file_name: f for f in files_in_db} + + # Recursively look for any file matching a DB file_name in the app dir. + # Match by basename only — file_name in the DB is e.g. "main.yml", + # not "playbooks/main.yml". + disk_by_name: dict[str, Path] = {} + for p in app_dir.rglob("*"): + if p.is_file() and p.name in by_name: + disk_by_name[p.name] = p + + updated = 0 + skipped_missing: list[str] = [] + + for name, db_row in by_name.items(): + disk_path = disk_by_name.get(name) + if not disk_path: + skipped_missing.append(name) + continue + + new_content = disk_path.read_text(encoding="utf-8") + if new_content == (db_row.content or ""): + print(f" = {name} (unchanged)") + continue + + old_size = len(db_row.content or "") + db_row.content = new_content + db_row.file_size = len(new_content.encode()) + print(f" ✓ {name} ({old_size} → {db_row.file_size} bytes) from {disk_path.relative_to(app_dir)}") + updated += 1 + + if skipped_missing: + print() + print("⚠ Files in DB but not on disk (left untouched):") + for n in skipped_missing: + print(f" {n}") + + db.commit() + print() + print("=" * 60) + print(f"Updated {updated} file(s).") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/src/_common/playbooks/base.yml b/src/_common/playbooks/base.yml index 8093c3c..980f193 100644 --- a/src/_common/playbooks/base.yml +++ b/src/_common/playbooks/base.yml @@ -23,6 +23,14 @@ shell: "echo '{{ teacher.linux.username }}:{{ teacher.linux.password }}' | chpasswd" no_log: true + - name: Lehrer SSH Admin-Key installieren + ansible.posix.authorized_key: + user: "{{ teacher.linux.username }}" + key: "{{ teacher.linux.ssh_key.public_key }}" + state: present + when: teacher.linux.ssh_key is defined and teacher.linux.ssh_key.public_key is defined + no_log: true + # --- SSH konfigurieren --- - name: SSH Passwort-Login aktivieren diff --git a/src/api/__init__.py b/src/api/__init__.py index a223271..e142710 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -6,9 +6,12 @@ from src.api.openstack_projects import router as openstack_projects_router from src.api.template_version_files import router as template_version_files_router from src.api.courses import router as courses_router +from src.api.course_filters import router as course_filters_router from src.api.quotas import router as quotas_router from src.api.openstack_flavors import router as openstack_flavors_router from src.api.github_app import router as github_app_router +from src.api.student import router as student_router +from src.api.lecturers import router as lecturers_router # Create main API router api_router = APIRouter(prefix="/api/v1") @@ -19,9 +22,12 @@ api_router.include_router(openstack_projects_router) api_router.include_router(template_version_files_router) api_router.include_router(courses_router) +api_router.include_router(course_filters_router) api_router.include_router(quotas_router) api_router.include_router(openstack_flavors_router) api_router.include_router(github_app_router) +api_router.include_router(student_router) +api_router.include_router(lecturers_router) __all__ = [ "api_router", diff --git a/src/api/course_filters.py b/src/api/course_filters.py new file mode 100644 index 0000000..29e09f2 --- /dev/null +++ b/src/api/course_filters.py @@ -0,0 +1,127 @@ +"""Course filter API endpoints. + +Admin-verwaltete Filter-Strings (Frontend-Chips), gegen die Kursnamen client- +seitig gematcht werden. Lese-Zugriff ist für alle eingeloggten User offen, +damit das Frontend die Chip-Leiste rendern kann; Schreiben ist Admin-only. +""" +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status + +from src.core.dependencies import ( + CurrentUser, + DBSession, + Pagination, + RequestID, + require_roles, +) +from src.core.response_builder import ResponseBuilder +from src.models.user import UserRole +from src.schemas.course_filter import ( + CourseFilterCreate, + CourseFilterResponse, + CourseFilterUpdate, +) +from src.services.course_filter_service import CourseFilterService + +router = APIRouter(prefix="/course-filters", tags=["course-filters"]) + + +@router.get("", response_model=None) +async def list_course_filters( + pagination: Pagination, + db: DBSession, + request_id: RequestID, + current_user: CurrentUser, + search: Optional[str] = Query(None, description="Substring-Suche auf name"), +): + """List course filters. Open to any authenticated user (frontend needs them).""" + service = CourseFilterService(db) + rows, total = service.list_filters( + skip=(pagination.page - 1) * pagination.page_size, + limit=pagination.page_size, + search=search, + ) + + payload = [ + CourseFilterResponse.model_validate(row).model_dump(mode="json") for row in rows + ] + + return ResponseBuilder.paginated( + data=payload, + page=pagination.page, + page_size=pagination.page_size, + total=total, + message="Course filters retrieved successfully", + request_id=request_id, + ) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=None, + dependencies=[Depends(require_roles(UserRole.ADMIN))], +) +async def create_course_filter( + data: CourseFilterCreate, + db: DBSession, + request_id: RequestID, + current_user: CurrentUser, +): + """Create a new course filter (admin only).""" + service = CourseFilterService(db) + instance = service.create_filter(data) + + return ResponseBuilder.created( + data=CourseFilterResponse.model_validate(instance).model_dump(mode="json"), + message="Course filter created successfully", + request_id=request_id, + ) + + +@router.patch( + "/{filter_id}", + response_model=None, + dependencies=[Depends(require_roles(UserRole.ADMIN))], +) +async def update_course_filter( + filter_id: UUID, + data: CourseFilterUpdate, + db: DBSession, + request_id: RequestID, + current_user: CurrentUser, +): + """Rename a course filter (admin only).""" + service = CourseFilterService(db) + instance = service.update_filter(filter_id=filter_id, data=data) + + return ResponseBuilder.success( + data=CourseFilterResponse.model_validate(instance).model_dump(mode="json"), + message="Course filter updated successfully", + request_id=request_id, + ) + + +@router.delete( + "/{filter_id}", + status_code=status.HTTP_200_OK, + response_model=None, + dependencies=[Depends(require_roles(UserRole.ADMIN))], +) +async def delete_course_filter( + filter_id: UUID, + db: DBSession, + request_id: RequestID, + current_user: CurrentUser, +): + """Delete a course filter (admin only).""" + service = CourseFilterService(db) + service.delete_filter(filter_id=filter_id) + + return ResponseBuilder.success( + data=None, + message="Course filter deleted successfully", + request_id=request_id, + ) diff --git a/src/api/deployments.py b/src/api/deployments.py index 5555802..c192501 100644 --- a/src/api/deployments.py +++ b/src/api/deployments.py @@ -3,7 +3,7 @@ import asyncio import json from fastapi import APIRouter, status, Query, Depends, HTTPException -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, PlainTextResponse from src.core.exceptions import NotFoundException from src.models.user import UserRole, User from src.core.dependencies import DBSession @@ -11,7 +11,7 @@ from src.core.dependencies import RequestID, Pagination, CurrentUser, require_roles from src.repositories.deployment_repository import DeploymentRepository from src.repositories.openstack_project_repository import OpenstackProjectRepository -from src.schemas.deployment import DeploymentResponse, DeploymentCreate, DeploymentExtend +from src.schemas.deployment import DeploymentResponse, DeploymentCreate, DeploymentExtend, DeploymentRedeployRequest from src.services.deployment_service import DeploymentService from src.services.deployment_log_service import DeploymentLogService from src.services.openstack_heat_service import HeatStackService @@ -23,8 +23,11 @@ ) from src.tasks.deploy_tasks import delete_deployment as delete_deployment_task from src.tasks.deploy_tasks import restart_deployment as restart_deployment_task +from src.tasks.deploy_tasks import redeploy_instance as redeploy_instance_task +from src.tasks.deploy_tasks import redeploy_deployment as redeploy_deployment_task from src.models.deployment import DeploymentStatus, Deployment -from src.models.deployment_instance import DeploymentInstance +from src.models.deployment_instance import DeploymentInstance, DeploymentInstanceStatus +from src.models.deployment_instance_access import DeploymentInstanceAccess from src.models.template_version import TemplateVersion router = APIRouter( @@ -296,9 +299,11 @@ async def create_deployment( Created deployment with status QUEUED """ service = DeploymentService(db) + is_admin = UserRole.ADMIN.value in user.get("roles", []) deployment = service.create_deployment( deployment_data, - request_id=request_id + request_id=request_id, + is_admin=is_admin, ) # Convert SQLAlchemy model to response schema @@ -470,6 +475,16 @@ async def stream_deployment_logs( Sends all existing logs immediately, then polls for new ones every second until the deployment reaches a terminal state (RUNNING, FAILED, DELETED). The stream closes automatically when done. + + Implementation notes: + * Authorization runs against the request's DB session, but the polling + loop opens its OWN fresh SessionLocal each tick. Reusing the request + session would never see commits from the Celery worker (the session + caches identity-mapped objects within a single transaction). + * Sync SQLAlchemy calls are wrapped in run_in_threadpool so the event + loop stays free for other SSE clients. + * A heartbeat comment is sent every ~15s so proxies/browsers don't + consider the connection dead during long Ansible phases. """ deployment_repo = DeploymentRepository(db) deployment = deployment_repo.get_by_id(deployment_id) @@ -480,47 +495,89 @@ async def stream_deployment_logs( authorize_deployment_access(deployment, user, openstack_project_id, db) TERMINAL_STATUSES = {"RUNNING", "FAILED", "DELETED", "CANCELLED"} + HEARTBEAT_EVERY_SECONDS = 15 + POLL_INTERVAL_SECONDS = 1 + + # Snapshot the (immutable) deployment_id for the closure; do NOT capture + # the ORM `deployment` object — it's bound to the request session which + # closes when this handler returns. + dep_id = deployment_id + + def _fetch_state(since_seen: set[str], since_id_param: str | None): + """Open a fresh DB session, return (new_log_payloads, current_status). + + Runs in the threadpool so the event loop isn't blocked on DB I/O. + """ + from src.core.database import SessionLocal + local_db = SessionLocal() + try: + local_repo = DeploymentRepository(local_db) + current = local_repo.get_by_id(dep_id) + if current is None: + return [], "DELETED" + + current_status = ( + current.status.value if hasattr(current.status, "value") else str(current.status) + ).upper() + + local_log_service = DeploymentLogService(local_db) + logs = local_log_service.get_deployment_logs(dep_id) + + # First iteration: seed seen-set from since_id if provided + if since_id_param and not since_seen: + for log in logs: + if str(log.id) == since_id_param: + break + since_seen.add(str(log.id)) + + new_payloads: list[str] = [] + for log in logs: + lid = str(log.id) + if lid in since_seen: + continue + since_seen.add(lid) + new_payloads.append(json.dumps({ + "id": lid, + "deployment_id": str(log.deployment_id), + "event_type": log.event_type.value if hasattr(log.event_type, "value") else str(log.event_type), + "message": log.message, + "level": log.level.value if hasattr(log.level, "value") else str(log.level), + "details": json.loads(log.details_json) if log.details_json else None, + "created_at": log.created_at.isoformat() if hasattr(log.created_at, "isoformat") else str(log.created_at), + })) + return new_payloads, current_status + finally: + local_db.close() async def event_generator(): - log_service = DeploymentLogService(db) - seen_ids: set[str] = set() + from fastapi.concurrency import run_in_threadpool - # Seed seen_ids from since_id position - if since_id: - all_logs = log_service.get_deployment_logs(deployment_id) - for log in all_logs: - if str(log.id) == since_id: - break - seen_ids.add(str(log.id)) + seen_ids: set[str] = set() + seconds_since_heartbeat = 0 + first = True try: while True: - # Refresh deployment status - db.expire(deployment) - current = deployment_repo.get_by_id(deployment_id) - current_status = (current.status.value if hasattr(current.status, "value") else str(current.status)).upper() - - # Fetch all logs and emit unseen ones - logs = log_service.get_deployment_logs(deployment_id) - new_logs = [log for log in logs if str(log.id) not in seen_ids] - for log in new_logs: - seen_ids.add(str(log.id)) - payload = json.dumps({ - "id": str(log.id), - "deployment_id": str(log.deployment_id), - "event_type": log.event_type.value if hasattr(log.event_type, "value") else str(log.event_type), - "message": log.message, - "level": log.level.value if hasattr(log.level, "value") else str(log.level), - "details": json.loads(log.details_json) if log.details_json else None, - "created_at": log.created_at.isoformat() if hasattr(log.created_at, "isoformat") else str(log.created_at), - }) + payloads, current_status = await run_in_threadpool( + _fetch_state, seen_ids, since_id if first else None + ) + first = False + + for payload in payloads: yield f"data: {payload}\n\n" + seconds_since_heartbeat = 0 if current_status in TERMINAL_STATUSES: yield "event: done\ndata: {}\n\n" break - await asyncio.sleep(1) + if seconds_since_heartbeat >= HEARTBEAT_EVERY_SECONDS: + # SSE comment line — clients ignore it, proxies keep the socket alive. + yield ": ping\n\n" + seconds_since_heartbeat = 0 + + await asyncio.sleep(POLL_INTERVAL_SECONDS) + seconds_since_heartbeat += POLL_INTERVAL_SECONDS except asyncio.CancelledError: pass @@ -530,6 +587,7 @@ async def event_generator(): headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", + "Connection": "keep-alive", }, ) @@ -570,11 +628,18 @@ async def get_deployment_credentials( openstack_stack_id=instance.openstack_server_id, accesses=[ DeploymentCredentialEntry( + id=access.id, access_type=access.access_type.value, username=access.username, password=access.password, + ssh_private_key=access.ssh_private_key, connection_url=access.connection_url, port=access.port, + group_id=access.group_id, + # Frontend renders Dozent/Gruppen tabs from group_id + group_name. + # group_id=NULL → admin/lecturer row → "Dozent" tab. + # group_id=set → student group row → "Gruppen" tab, accordion by group_name. + group_name=access.group.name if access.group else None, ) for access in instance.access_methods ], @@ -594,6 +659,57 @@ async def get_deployment_credentials( ) +@router.get( + "/{deployment_id}/credentials/access/{access_id}/ssh-key", + response_class=PlainTextResponse, +) +async def download_ssh_private_key( + deployment_id: str, + access_id: str, + db: DBSession, + user: CurrentUser, + openstack_project_id: UUID | None = Query( + None, + description="OpenStack project (local DB id) the deployment must belong to. Required for non-admin users.", + ), +): + """Download an SSH private key as a downloadable file. + + Returned as ``application/x-pem-file`` with a ``Content-Disposition`` + attachment header so the browser saves it as ``id_ed25519`` rather than + rendering the PEM in-line. Accessible to the deployment owner (lecturer) + or any admin. + """ + deployment_repo = DeploymentRepository(db) + deployment = deployment_repo.get_by_id(deployment_id) + if not deployment: + raise NotFoundException(f"Deployment with ID {deployment_id} not found") + + authorize_deployment_access(deployment, user, openstack_project_id, db) + + access = ( + db.query(DeploymentInstanceAccess) + .join(DeploymentInstance, DeploymentInstance.id == DeploymentInstanceAccess.deployment_instance_id) + .filter( + DeploymentInstanceAccess.id == access_id, + DeploymentInstance.deployment_id == deployment_id, + ) + .first() + ) + if not access: + raise NotFoundException(f"Access entry {access_id} not found for deployment {deployment_id}") + if not access.ssh_private_key: + raise HTTPException(status_code=404, detail="No SSH private key available for this access entry") + + # OpenSSH PEM contents — decrypted automatically by EncryptedString on read + filename = f"id_ed25519_{(access.username or 'user')}" + return PlainTextResponse( + content=access.ssh_private_key, + media_type="application/x-pem-file", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.get("/{deployment_id}/stack") async def get_deployment_stack( deployment_id: str, @@ -763,6 +879,229 @@ async def restart_deployment( ) +@router.post( + "/{deployment_id}/redeploy", + status_code=status.HTTP_202_ACCEPTED, + summary="Redeploy every VM in a deployment", + responses={ + 202: {"description": "Redeploy requested; operation in progress"}, + 400: {"description": "Deployment is in an invalid state or has no instances"}, + 403: {"description": "Forbidden - not owner or insufficient role"}, + 404: {"description": "Deployment not found"}, + 500: {"description": "Failed to enqueue redeploy task"}, + }, +) +async def redeploy_deployment_endpoint( + deployment_id: str, + db: DBSession, + request_id: RequestID, + user: CurrentUser, + payload: DeploymentRedeployRequest | None = None, + openstack_project_id: UUID | None = Query( + None, + description="OpenStack project (local DB id) the deployment must belong to. Required for non-admin users.", + ), +): + """Destroy-and-recreate every VM (``DeploymentInstance``) in this + deployment, one after another, optionally with overridden parameters. + + Unlike :func:`restart_deployment` (which only triggers a Heat + ``update_stack`` on the existing stack), this rebuilds each VM from + scratch: Heat stack deleted → Heat stack recreated → Ansible re-run → + credentials regenerated (unless ``preserve_credentials=true``). Use it + when a config / template parameter changed and you want the change to + actually take effect. + + Sequential by design — running them in parallel risks tripping the + OpenStack project quota mid-class. The parent deployment stays in + ``RUNNING`` between instances so the UI can show per-VM progress and + siblings stay reachable. + + **Authorization:** Owner (lecturer) or Admin only. + """ + deployment_repo = DeploymentRepository(db) + deployment = deployment_repo.get_by_id(deployment_id) + if not deployment: + raise NotFoundException(f"Deployment with ID {deployment_id} not found") + + authorize_deployment_access(deployment, user, openstack_project_id, db) + + transitional_states = [ + DeploymentStatus.CREATING, + DeploymentStatus.DELETING, + DeploymentStatus.RESTARTING, + ] + if deployment.status in transitional_states: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Cannot redeploy deployment in {deployment.status.value} state. " + f"Please wait for the current operation to complete." + ), + ) + + # If any instance is already mid-redeploy, refuse — two redeploy tasks racing + # the same Heat stack would corrupt deployment.openstack_stack_id and leak + # stacks. We only need to find ONE such instance to reject. + in_flight = ( + db.query(DeploymentInstance.id) + .filter( + DeploymentInstance.deployment_id == deployment_id, + DeploymentInstance.status == DeploymentInstanceStatus.REDEPLOYING, + ) + .first() + ) + if in_flight is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "A redeploy is already in progress for at least one instance " + "in this deployment; wait for it to finish before queuing another." + ), + ) + + instance_count = ( + db.query(DeploymentInstance) + .filter(DeploymentInstance.deployment_id == deployment_id) + .count() + ) + if instance_count == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Deployment has no instances to redeploy", + ) + + body = payload or DeploymentRedeployRequest() + # We deliberately don't wrap .delay() in a broad except: a broker outage or + # a non-serialisable override value should surface as the underlying error + # (Celery's own message) so the caller can diagnose it, instead of being + # masked behind a generic "Failed to enqueue redeploy task". + redeploy_deployment_task.delay( + deployment_id, + deployment_parameter_overrides=body.deployment_parameter_overrides, + instance_parameter_overrides=body.instance_parameter_overrides, + preserve_credentials=body.preserve_credentials, + ) + + return ResponseBuilder.success( + data={ + "deployment_id": deployment_id, + "instance_count": instance_count, + "status": "redeploy_queued", + "preserve_credentials": body.preserve_credentials, + }, + message=f"Redeploy requested for {instance_count} instance(s); operation in progress", + request_id=request_id, + status_code=status.HTTP_202_ACCEPTED, + ) + + +@router.post( + "/{deployment_id}/instances/{instance_id}/redeploy", + status_code=status.HTTP_202_ACCEPTED, + summary="Redeploy a single VM (DeploymentInstance)", + responses={ + 202: {"description": "Redeploy requested; operation in progress"}, + 400: {"description": "Deployment or instance is in an invalid state"}, + 403: {"description": "Forbidden - not owner or insufficient role"}, + 404: {"description": "Deployment or instance not found"}, + 500: {"description": "Failed to enqueue redeploy task"}, + }, +) +async def redeploy_instance_endpoint( + deployment_id: str, + instance_id: str, + db: DBSession, + request_id: RequestID, + user: CurrentUser, + payload: DeploymentRedeployRequest | None = None, + openstack_project_id: UUID | None = Query( + None, + description="OpenStack project (local DB id) the deployment must belong to. Required for non-admin users.", + ), +): + """Destroy-and-recreate exactly one VM inside an existing deployment. + + Use this when one VM is wedged, or to apply a config change to a + single group without touching its siblings. The parent deployment + stays in ``RUNNING`` for the duration — only the target instance + flips to ``REDEPLOYING``. + + Body parameters mirror the deployment-wide endpoint, with one + semantic shift: ``deployment_parameter_overrides`` is treated as the + full override for this one VM (since there's only one in scope). + ``instance_parameter_overrides`` is ignored here — pass per-VM + parameters directly in ``deployment_parameter_overrides``. + + **Authorization:** Owner (lecturer) or Admin only. + """ + deployment_repo = DeploymentRepository(db) + deployment = deployment_repo.get_by_id(deployment_id) + if not deployment: + raise NotFoundException(f"Deployment with ID {deployment_id} not found") + + authorize_deployment_access(deployment, user, openstack_project_id, db) + + transitional_states = [ + DeploymentStatus.CREATING, + DeploymentStatus.DELETING, + DeploymentStatus.RESTARTING, + ] + if deployment.status in transitional_states: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Cannot redeploy instance while deployment is in {deployment.status.value} state." + ), + ) + + instance = ( + db.query(DeploymentInstance) + .filter( + DeploymentInstance.id == instance_id, + DeploymentInstance.deployment_id == deployment_id, + ) + .first() + ) + if not instance: + raise NotFoundException( + f"Instance {instance_id} not found in deployment {deployment_id}" + ) + + # Refuse if this instance is already mid-redeploy — a second task racing the + # first one would delete a stack the first already removed (raises and flips + # status to FAILED) and the survivors would corrupt deployment.openstack_stack_id. + if instance.status == DeploymentInstanceStatus.REDEPLOYING: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Instance {instance_id} is already being redeployed; " + "wait for the current operation to finish." + ), + ) + + body = payload or DeploymentRedeployRequest() + # No broad except around .delay() — see redeploy_deployment_endpoint above. + redeploy_instance_task.delay( + deployment_id, + instance_id, + deployment_parameter_overrides=body.deployment_parameter_overrides, + preserve_credentials=body.preserve_credentials, + ) + + return ResponseBuilder.success( + data={ + "deployment_id": deployment_id, + "instance_id": instance_id, + "status": "redeploy_queued", + "preserve_credentials": body.preserve_credentials, + }, + message="Redeploy requested for instance; operation in progress", + request_id=request_id, + status_code=status.HTTP_202_ACCEPTED, + ) + + @router.patch( "/{deployment_id}/extend", summary="Extend a deployment's lifetime", @@ -843,9 +1182,12 @@ async def delete_deployment( ): """Request deletion of a deployment. - Sets deployment status to DELETING, logs the request and enqueues the - `delete_deployment` Celery task which performs the actual OpenStack - deletion and cleans up the database. + Flips the deployment's status to ``DELETING`` *immediately* — this acts + as the cooperative-cancel flag that any in-flight ``deploy_stack`` task + polls between phases. The task then bails out cleanly, persisting every + Heat stack it managed to create before. After flipping, the + ``delete_deployment`` Celery task is enqueued; it picks up those stack + ids from ``openstack_stack_id`` and tears them down. """ # Verify deployment exists deployment_repo = DeploymentRepository(db) @@ -856,6 +1198,12 @@ async def delete_deployment( authorize_deployment_access(deployment, user, openstack_project_id, db) + # Set DELETING up front so the deploy task's status-polling checkpoints + # see it before we enqueue the actual cleanup task. Skipping the update + # if we're already DELETING/DELETED keeps the call idempotent. + if deployment.status not in (DeploymentStatus.DELETING, DeploymentStatus.DELETED): + deployment_repo.update_status(deployment_id, DeploymentStatus.DELETING) + # Enqueue Celery task to perform deletion asynchronously try: delete_deployment_task.delay(deployment_id) @@ -866,6 +1214,6 @@ async def delete_deployment( ) return ResponseBuilder.no_content( - message="Deletion requested; operation is in progress", + message="Deletion requested; cancel-and-cleanup is in progress", request_id=request_id, ) diff --git a/src/api/lecturers.py b/src/api/lecturers.py new file mode 100644 index 0000000..ab9ddbe --- /dev/null +++ b/src/api/lecturers.py @@ -0,0 +1,114 @@ +"""Admin-only /lecturers endpoints. + +Provides three read/write operations against the User table filtered to +lecturers (= users that own templates or OpenStack projects). All routes +require the ``admin`` realm role — enforced by the router-level guard so +individual handlers don't repeat it. + +The DELETE handler kicks off an async cascade via +``src.tasks.lecturer_tasks.cascade_delete_lecturer`` and returns 202 with +the task id. See that task for the exact ordering + bail-out rules. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, status + +from src.core.dependencies import CurrentUser, DBSession, RequestID, require_roles +from src.core.response_builder import ResponseBuilder +from src.models.user import UserRole +from src.schemas.lecturer import ( + LecturerDeleteResponse, + LecturerDetail, + LecturerListItem, +) +from src.services.lecturer_service import LecturerService +from src.tasks.lecturer_tasks import cascade_delete_lecturer +router = APIRouter( + prefix="/lecturers", + tags=["lecturers"], + # Admin-only across the board — see module docstring for rationale. + dependencies=[Depends(require_roles(UserRole.ADMIN))], +) + + +@router.get("") +async def list_lecturers( + db: DBSession, + request_id: RequestID, + skip: int = Query(0, ge=0, description="Pagination offset"), + limit: int = Query(50, ge=1, le=200, description="Page size (max 200)"), + search: str | None = Query( + None, + description="Case-insensitive substring match against display_name/email/username", + ), +): + """List users who own at least one template or one OpenStack project. + + Rows carry aggregate counts (templates / deployments / OSPs) so the + admin dashboard can render the list without a second round-trip per + row. + """ + service = LecturerService(db) + # The paginated response helper thinks in 1-indexed pages, but we + # expose skip/limit for consistency with the other admin endpoints. + # Compute the page number the helper needs from skip/limit. + page = (skip // limit) + 1 if limit else 1 + rows, total = service.list_lecturers(skip=skip, limit=limit, search=search) + + payload = [LecturerListItem(**r).model_dump(mode="json") for r in rows] + return ResponseBuilder.paginated( + data=payload, + page=page, + page_size=limit, + total=total, + message=f"Retrieved {len(payload)} lecturer(s)", + request_id=request_id, + ) + + +@router.get("/{user_id}") +async def get_lecturer( + user_id: str, + db: DBSession, + request_id: RequestID, +): + """Detail view: list-row fields + the full owned/deployed resource + lists (so the admin can review before hitting DELETE).""" + service = LecturerService(db) + detail = service.get_lecturer(user_id) + return ResponseBuilder.success( + data=LecturerDetail(**detail).model_dump(mode="json"), + message="Lecturer detail retrieved", + request_id=request_id, + ) + + +@router.delete("/{user_id}", status_code=status.HTTP_202_ACCEPTED) +async def delete_lecturer( + user_id: str, + db: DBSession, + request_id: RequestID, + user: CurrentUser, +): + """Enqueue cascade delete of a lecturer and all their resources. + + Returns 202 with the Celery task id. The actual work — Heat teardown, + template + OSP + user removal — happens asynchronously and can be + monitored via the deployment log stream. An admin cannot delete their + own account (guarded up-front).""" + service = LecturerService(db) + summary = service.preflight_delete(user_id=user_id, requesting_user_id=user["user_id"]) + + async_result = cascade_delete_lecturer.delay(user_id) + payload = LecturerDeleteResponse( + task_id=async_result.id, + user_id=user_id, + deployment_count=summary["deployment_count"], + template_count=summary["template_count"], + ) + return ResponseBuilder.success( + data=payload.model_dump(mode="json"), + message="Lecturer cascade delete enqueued", + request_id=request_id, + status_code=status.HTTP_202_ACCEPTED, + ) diff --git a/src/api/student.py b/src/api/student.py new file mode 100644 index 0000000..6a26e70 --- /dev/null +++ b/src/api/student.py @@ -0,0 +1,308 @@ +"""Student self-service API endpoints. + +Scope: A logged-in student (Keycloak role ``student``) can only do TWO things: + +1. List deployments where they personally are a member of at least one group + that has credentials for that deployment. +2. Fetch ONLY their group's credentials — never the lecturer's admin + credentials, never another group's credentials. +3. Download an SSH private key — same access scope. + +Authorization model: +- Router-level guard: ``require_roles(UserRole.STUDENT)``. Lecturers / admins + are explicitly NOT allowed on these routes (they have their own). +- Per-deployment scope: a single SQL join through CourseMember → + GroupMember → CourseGroup → DeploymentInstanceAccess.group_id determines + which access rows the student may see. Rows with ``group_id IS NULL`` + (teacher/admin credentials) are always filtered out. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import PlainTextResponse +from sqlalchemy.orm import joinedload + +from src.core.dependencies import CurrentUser, DBSession, RequestID, require_roles +from src.core.exceptions import NotFoundException +from src.core.response_builder import ResponseBuilder +from src.models.course_group import CourseGroup +from src.models.course_member import CourseMember +from src.models.deployment import Deployment, DeploymentStatus +from src.models.deployment_instance import DeploymentInstance +from src.models.deployment_instance_access import DeploymentInstanceAccess +from src.models.group_member import GroupMember +from src.models.user import UserRole +from src.schemas.deployment import ( + DeploymentCredentialEntry, + DeploymentCredentialsResponse, + DeploymentInstanceCredentials, +) +from src.schemas.student import ( + StudentDeploymentInstanceSummary, + StudentDeploymentResponse, + StudentTemplateSummary, +) + + +router = APIRouter( + prefix="/student", + tags=["student"], + # Hard router-level guard: only callers with the STUDENT role pass here. + # Lecturers/admins get 403 on these routes — they have their own. + dependencies=[Depends(require_roles(UserRole.STUDENT))], +) + + +def _allowed_group_ids_for_deployment( + db, + student_user_id: str, + deployment_id: str, +) -> set[str]: + """Return the set of ``course_groups.id`` the student belongs to AND + that have at least one access entry on the given deployment. + + Empty set means: student has no access to this deployment. + """ + rows = ( + db.query(CourseGroup.id) + .join(GroupMember, GroupMember.group_id == CourseGroup.id) + .join(CourseMember, CourseMember.id == GroupMember.course_member_id) + .join( + DeploymentInstanceAccess, + DeploymentInstanceAccess.group_id == CourseGroup.id, + ) + .join( + DeploymentInstance, + DeploymentInstance.id == DeploymentInstanceAccess.deployment_instance_id, + ) + .filter( + CourseMember.user_id == student_user_id, + CourseMember.left_at.is_(None), + DeploymentInstance.deployment_id == deployment_id, + ) + .distinct() + .all() + ) + return {row[0] for row in rows} + + +@router.get("/deployments") +async def list_student_deployments( + db: DBSession, + request_id: RequestID, + user: CurrentUser, +): + """List deployments where the student is in a group that has credentials. + + Returns a trimmed view (``StudentDeploymentResponse``) — no + ``deployment_parameters``, no lecturer info, no other groups' details. + """ + student_user_id = user["user_id"] + + deployments = ( + db.query(Deployment) + .options(joinedload(Deployment.template_version)) + .join(DeploymentInstance, DeploymentInstance.deployment_id == Deployment.id) + .join( + DeploymentInstanceAccess, + DeploymentInstanceAccess.deployment_instance_id == DeploymentInstance.id, + ) + .join(CourseGroup, CourseGroup.id == DeploymentInstanceAccess.group_id) + .join(GroupMember, GroupMember.group_id == CourseGroup.id) + .join(CourseMember, CourseMember.id == GroupMember.course_member_id) + .filter( + CourseMember.user_id == student_user_id, + CourseMember.left_at.is_(None), + Deployment.status != DeploymentStatus.DELETED, + ) + .distinct() + .all() + ) + + payload = [] + for d in deployments: + template = d.template_version.template if d.template_version else None + # Only surface instances that have at least one access row tied to a + # group the student belongs to — same scope as the credentials filter. + allowed_groups = _allowed_group_ids_for_deployment(db, student_user_id, d.id) + visible_instances = [ + inst for inst in d.instances + if any( + a.group_id in allowed_groups for a in inst.access_methods + ) + ] + payload.append( + StudentDeploymentResponse( + id=d.id, + name=d.name, + status=d.status.value if hasattr(d.status, "value") else str(d.status), + template=StudentTemplateSummary( + name=template.name if template else None, + version=d.template_version.version if d.template_version else None, + ), + instances=[ + StudentDeploymentInstanceSummary( + id=inst.id, + vm_name=inst.vm_name, + ip_address=inst.ip_address, + ) + for inst in visible_instances + ], + created_at=d.created_at, + expires_at=d.expires_at, + ).model_dump() + ) + + return ResponseBuilder.success( + data=payload, + message=f"Retrieved {len(payload)} deployment(s)", + request_id=request_id, + ) + + +@router.get("/deployments/{deployment_id}/credentials") +async def get_student_credentials( + deployment_id: str, + db: DBSession, + request_id: RequestID, + user: CurrentUser, +): + """Return only the credentials the student is entitled to see. + + Filters ``DeploymentInstanceAccess`` by the student's group memberships. + Admin credentials (``group_id IS NULL``) are never returned. + """ + student_user_id = user["user_id"] + + deployment = db.query(Deployment).filter(Deployment.id == deployment_id).first() + if not deployment: + raise NotFoundException(f"Deployment with ID {deployment_id} not found") + + allowed_groups = _allowed_group_ids_for_deployment(db, student_user_id, deployment_id) + if not allowed_groups: + # Either student is not a member of any group on this deployment, or + # the deployment doesn't exist in their world. Treat both as 403 — + # 404 would leak existence. + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this deployment", + ) + + instances = ( + db.query(DeploymentInstance) + .filter(DeploymentInstance.deployment_id == deployment_id) + .all() + ) + + instance_payloads = [] + for inst in instances: + visible_accesses = [ + a for a in inst.access_methods + if a.group_id is not None and a.group_id in allowed_groups + ] + if not visible_accesses: + continue + instance_payloads.append( + DeploymentInstanceCredentials( + instance_id=inst.id, + vm_name=inst.vm_name, + openstack_stack_id=inst.openstack_server_id, + accesses=[ + DeploymentCredentialEntry( + id=a.id, + access_type=a.access_type.value, + username=a.username, + password=a.password, + ssh_private_key=a.ssh_private_key, + connection_url=a.connection_url, + port=a.port, + # Surface the group attribution so the UI can label + # which credentials belong to which group — a student + # may be a member of more than one course group on the + # same deployment (multi-group lab assignments), and + # without these fields all rows look identical in the + # UI even though they target different group accounts. + group_id=a.group_id, + group_name=a.group.name if a.group else None, + ) + for a in visible_accesses + ], + ) + ) + + payload = DeploymentCredentialsResponse( + deployment_id=deployment_id, + instances=instance_payloads, + ) + + return ResponseBuilder.success( + data=payload.model_dump(), + message=f"Retrieved credentials for {len(instance_payloads)} instance(s)", + request_id=request_id, + ) + + +@router.get( + "/deployments/{deployment_id}/credentials/access/{access_id}/ssh-key", + response_class=PlainTextResponse, +) +async def download_student_ssh_key( + deployment_id: str, + access_id: str, + db: DBSession, + user: CurrentUser, +): + """Download an SSH private key tied to the student's own group. + + Mirrors the lecturer-facing endpoint, but with stricter ownership: the + access row's ``group_id`` MUST be one of the student's group + memberships. Admin rows (``group_id IS NULL``) are never reachable. + """ + student_user_id = user["user_id"] + + deployment = db.query(Deployment).filter(Deployment.id == deployment_id).first() + if not deployment: + raise NotFoundException(f"Deployment with ID {deployment_id} not found") + + allowed_groups = _allowed_group_ids_for_deployment(db, student_user_id, deployment_id) + if not allowed_groups: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this deployment", + ) + + access = ( + db.query(DeploymentInstanceAccess) + .join( + DeploymentInstance, + DeploymentInstance.id == DeploymentInstanceAccess.deployment_instance_id, + ) + .filter( + DeploymentInstanceAccess.id == access_id, + DeploymentInstance.deployment_id == deployment_id, + ) + .first() + ) + if not access: + raise NotFoundException(f"Access entry {access_id} not found for deployment {deployment_id}") + # Stricter than the lecturer endpoint: the access row MUST belong to one + # of the student's groups. Catches the case where a student tries an + # access_id they technically know but doesn't belong to their group — + # 403, not 404, because the row exists but is not theirs to see. + if access.group_id is None or access.group_id not in allowed_groups: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This access entry does not belong to your group", + ) + if not access.ssh_private_key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No SSH private key available for this access entry", + ) + + filename = f"id_ed25519_{(access.username or 'user')}" + return PlainTextResponse( + content=access.ssh_private_key, + media_type="application/x-pem-file", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/src/api/template_version_files.py b/src/api/template_version_files.py index d06587e..433804b 100644 --- a/src/api/template_version_files.py +++ b/src/api/template_version_files.py @@ -290,8 +290,14 @@ async def update_file( Updated file response """ service = TemplateVersionFileService(db) - file = service.update_file(file_id, file_data) - + is_admin = UserRole.ADMIN.value in current_user.get("roles", []) + file = service.update_file( + file_id, + file_data, + user_id=current_user["user_id"], + is_admin=is_admin, + ) + return ResponseBuilder.success( data=TemplateVersionFileResponse.model_validate(file), message="File updated successfully", @@ -310,7 +316,7 @@ async def delete_file( current_user: CurrentUser, ): """Delete a template version file. - + Args: file_id: File ID db: Database session @@ -318,7 +324,12 @@ async def delete_file( current_user: Current authenticated user """ service = TemplateVersionFileService(db) - service.delete_file(file_id) - + is_admin = UserRole.ADMIN.value in current_user.get("roles", []) + service.delete_file( + file_id, + user_id=current_user["user_id"], + is_admin=is_admin, + ) + # Return None for 204 No Content return None diff --git a/src/api/template_versions.py b/src/api/template_versions.py index 6464572..01eb109 100644 --- a/src/api/template_versions.py +++ b/src/api/template_versions.py @@ -113,6 +113,14 @@ async def list_approval_queue( None, description="Optional: filter by parent template visibility (private/public)", ), + include_publish_requested: bool = Query( + True, + description=( + "When visibility=public, also include PRIVATE templates that have " + "publish_requested=true (templates waiting for their first approval " + "before being promoted to PUBLIC). Default True." + ), + ), sort: Literal[ "created_at_desc", "created_at_asc", @@ -130,8 +138,8 @@ async def list_approval_queue( - The parent template's metadata (`template.{name, owner_id, visibility}`) - The parsed `parameters` from app.yaml (resource requirements) - Filters: `status`, `template_id`, `visibility`. Sortable via `sort`. - Admin-only. + Filters: `status`, `template_id`, `visibility`, `include_publish_requested`. + Sortable via `sort`. Admin-only. """ service = TemplateVersionService(db) @@ -142,6 +150,7 @@ async def list_approval_queue( template_id=str(template_id) if template_id else None, visibility=visibility, sort=sort, + include_publish_requested=include_publish_requested, ) items = [] diff --git a/src/api/templates.py b/src/api/templates.py index 5d6110d..94cbc73 100644 --- a/src/api/templates.py +++ b/src/api/templates.py @@ -2,7 +2,8 @@ from typing import Optional from uuid import UUID -from fastapi import APIRouter, status, Query, Depends +from fastapi import APIRouter, Depends, File, Query, UploadFile, status +from fastapi.responses import Response from src.core.response_builder import ResponseBuilder from src.core.dependencies import DBSession, RequestID, Pagination, require_roles, CurrentUser @@ -15,8 +16,10 @@ ) from src.schemas.template_version import TemplateVersionResponse from src.services.template_service import TemplateService +from src.services.template_icon_service import TemplateIconService from src.services.github_import_service import GithubImportService from src.models.user import UserRole +from src.models.template import TemplateVisibility router = APIRouter( @@ -212,15 +215,19 @@ async def import_template_from_github( ): """Create a new template plus its first version from a GitHub repository. - The template is always created with `visibility=private` (matching - `POST /templates`); admins can promote to public later via PATCH. The first - version follows the standard per-version approval rules. - - For private repos the calling user must have linked our GitHub App via - `POST /auth/github/install`. Public repos work without an installation - (subject to GitHub's 60/h unauthenticated rate limit). The endpoint - resolves the repo's commit SHA, fetches `app.yaml`, and persists every - file in the same folder as a new Template + TemplateVersion + files. + Sichtbarkeitsregel: + - ``visibility=private`` (Default) → Template ist sofort owner-only nutzbar, + kein Approval-Flow. + - ``visibility=public`` → Template wird ALS PRIVATE + ``publish_requested=True`` + persistiert; die erste Version geht durch den Admin-Approval-Flow. Erst + beim ersten approve_version() flippt das Template atomar auf PUBLIC. + So sieht der Owner sofort „wartet auf Erst-Freigabe" statt eines + fälschlich-öffentlichen Templates ohne approved Version. Admin-Caller + umgehen den Flow (Auto-Approval + Direkt-Promotion). + + Für private Repos muss der Caller unsere GitHub App via + ``POST /auth/github/install`` verbunden haben. Public Repos funktionieren + auch ohne Installation (GitHub-API 60/h-Rate-Limit). """ service = GithubImportService(db) template = service.import_to_new_template( @@ -228,9 +235,12 @@ async def import_template_from_github( app_yaml_path=payload.app_yaml_path, name=payload.name, description=payload.description, - icon_url=payload.icon_url, owner_user_id=current_user["user_id"], owner_user_roles=current_user.get("roles", []), + # The pydantic validator normalises this to "private"/"public" (or + # leaves the default). Map the string to the enum here so the service + # stays typed. + visibility=TemplateVisibility(payload.visibility or "private"), ) template_response = TemplateResponse.model_validate(template) @@ -268,6 +278,7 @@ async def import_new_version_from_github( is_active=payload.is_active, user_id=current_user["user_id"], user_roles=current_user.get("roles", []), + replace_existing=payload.replace_existing, ) version_response = TemplateVersionResponse.model_validate(version) @@ -276,3 +287,126 @@ async def import_new_version_from_github( message="Template version imported from GitHub successfully", request_id=request_id, ) + + +# --------------------------------------------------------------------------- +# Icon-Upload +# --------------------------------------------------------------------------- +# +# Der Upload lebt bewusst auf einem eigenen Endpoint statt am POST/PATCH +# /templates, damit die JSON-API rein-JSON bleibt und Clients ohne +# Anpassung weiterlaufen. Die drei Endpoints (POST/GET/DELETE) sind +# symmetrisch und respektieren die Standard-Sichtbarkeitsregeln: +# Admin darf alles, Owner darf sein eigenes, Fremde nur PUBLIC-Templates +# mit mindestens einer APPROVED Version (letzteres nur für den Serve- +# Endpoint — Upload/Delete sind owner-or-admin-only). + + +@router.post( + "/{template_id}/icon", + status_code=status.HTTP_201_CREATED, + response_model=None, +) +async def upload_template_icon( + template_id: UUID, + db: DBSession, + request_id: RequestID, + current_user: CurrentUser, + file: UploadFile = File(..., description="Icon image (PNG, JPEG or WebP, max 5 MB)"), +): + """Upload (or replace) the icon image for a template. + + Owner-or-admin-only. Erlaubte Formate: ``image/png``, ``image/jpeg``, + ``image/webp``; maximale Größe: 5 MB (konfigurierbar via + ``settings.max_icon_size_bytes``). + + Der Endpoint speichert die Bytes in der Tabelle ``template_icons`` und + setzt in der Template-Response ``icon_path`` auf + ``/api/v1/templates/{id}/icon``. Templates ohne hochgeladenes Bild + haben ``icon_path = null`` — das Frontend zeigt dann einen + Placeholder. + """ + is_admin = UserRole.ADMIN.value in current_user.get("roles", []) + content = await file.read() + service = TemplateIconService(db) + icon = service.upload_icon( + template_id=str(template_id), + content=content, + content_type=file.content_type or "application/octet-stream", + file_name=file.filename, + user_id=current_user["user_id"], + is_admin=is_admin, + ) + return ResponseBuilder.created( + data={ + "id": icon.id, + "template_id": icon.template_id, + "content_type": icon.content_type, + "file_name": icon.file_name, + "size_bytes": icon.size_bytes, + "icon_path": f"/api/v1/templates/{icon.template_id}/icon", + }, + message="Template icon uploaded successfully", + request_id=request_id, + ) + + +@router.get("/{template_id}/icon") +async def get_template_icon( + template_id: UUID, + db: DBSession, + current_user: CurrentUser, +): + """Return the raw icon bytes for a template. + + Same visibility rules as GET /templates/{id}: admin sees everything, + owner sees their own, others only PUBLIC templates with at least one + APPROVED version. Sends the stored MIME type as ``Content-Type`` and + an ETag derived from the icon row ID for browser caching. + """ + is_admin = UserRole.ADMIN.value in current_user.get("roles", []) + service = TemplateIconService(db) + icon = service.get_icon( + template_id=str(template_id), + user_id=current_user["user_id"], + is_admin=is_admin, + ) + headers = { + # 5 Minuten private cache reichen — das Icon ändert sich selten, + # aber ein PATCH sollte binnen kurzer Zeit sichtbar sein. + "Cache-Control": "private, max-age=300", + "ETag": f'"{icon.id}"', + } + if icon.file_name: + headers["Content-Disposition"] = f'inline; filename="{icon.file_name}"' + return Response( + content=icon.content, + media_type=icon.content_type, + headers=headers, + ) + + +@router.delete( + "/{template_id}/icon", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_template_icon( + template_id: UUID, + db: DBSession, + current_user: CurrentUser, +): + """Remove the uploaded icon for a template. + + Owner-or-admin-only. Idempotent: wenn kein Icon existiert, ist die + Antwort trotzdem 204 (Client muss nicht wissen, ob vorher eins da war). + Danach fällt ``icon_path`` auf ``null`` zurück — Frontend rendert + einen Placeholder. + """ + is_admin = UserRole.ADMIN.value in current_user.get("roles", []) + service = TemplateIconService(db) + service.delete_icon( + template_id=str(template_id), + user_id=current_user["user_id"], + is_admin=is_admin, + ) + return None diff --git a/src/celery_app.py b/src/celery_app.py index 817da77..99ed0f9 100644 --- a/src/celery_app.py +++ b/src/celery_app.py @@ -17,6 +17,7 @@ "src.tasks.deploy_tasks", "src.tasks.sync_tasks", "src.tasks.expiry_tasks", + "src.tasks.lecturer_tasks", ], ) diff --git a/src/core/config.py b/src/core/config.py index 12943f0..705387d 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -59,6 +59,16 @@ def ansible_ssh_private_key(self) -> str | None: github_app_state_secret: str | None = None frontend_base_url: str = "http://localhost:5173" + # Template icon uploads. Grenzwerte werden im Service gegen die + # hochgeladene Datei geprüft — 5 MB und PNG/JPEG/WebP sind das + # abgestimmte Default. + max_icon_size_bytes: int = 5 * 1024 * 1024 + allowed_icon_content_types: tuple[str, ...] = ( + "image/png", + "image/jpeg", + "image/webp", + ) + @property def database_url(self) -> str: diff --git a/src/core/exceptions.py b/src/core/exceptions.py index 8aee7c3..dcd895c 100644 --- a/src/core/exceptions.py +++ b/src/core/exceptions.py @@ -4,6 +4,7 @@ consistent error responses across all endpoints using ResponseBuilder. """ import logging +from typing import Any from fastapi import Request, status from fastapi.exceptions import RequestValidationError @@ -31,10 +32,28 @@ def __init__(self, message: str = "You do not have permission to perform this ac class BadRequestException(StarletteHTTPException): - """Exception raised for invalid client requests.""" - - def __init__(self, message: str = "Invalid request"): + """Exception raised for invalid client requests. + + Trägt optional einen strukturierten ``code`` und ``details``-Payload, damit + Clients gezielt darauf branchen können (z.B. „Version-String bereits + vergeben → Replace-Pfad anbieten") statt Fehlertexte per Regex zu parsen. + Der ``message`` bleibt menschenlesbar; ``code`` ist eine kurze + SCREAMING_SNAKE-Konvention, ``details`` ein beliebiges JSON-fähiges Dict. + + Wenn nur ``message`` gesetzt ist, verhält sich die Exception identisch zu + vorher — Bestandscode bleibt 1:1 kompatibel. + """ + + def __init__( + self, + message: str = "Invalid request", + *, + code: str | None = None, + details: dict | None = None, + ): super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=message) + self.code = code + self.details = details or None class ConflictException(StarletteHTTPException): @@ -98,11 +117,22 @@ async def http_exception_handler( "event": "http_exception" } ) - + + # Strukturierte BadRequest-Codes mitgeben, wenn vorhanden: das Frontend + # branchst auf `errors.code` (z.B. „VERSION_ALREADY_EXISTS") und kann + # `errors.details` als Datenträger nutzen, statt den `message`-String + # per Regex zu parsen. + errors_payload: dict[str, Any] | None = None + if isinstance(exc, BadRequestException) and exc.code: + errors_payload = {"code": exc.code} + if exc.details: + errors_payload["details"] = exc.details + return ResponseBuilder.error( message=str(exc.detail), status_code=exc.status_code, request_id=request_id, + errors=errors_payload, ) diff --git a/src/core/responses.py b/src/core/responses.py index 3761c7b..b105240 100644 --- a/src/core/responses.py +++ b/src/core/responses.py @@ -1,14 +1,14 @@ """Standardized response models for API endpoints.""" from datetime import datetime, timezone from typing import Generic, TypeVar, Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field T = TypeVar("T") class APIResponse(BaseModel, Generic[T]): """Standard API response format.""" - + success: bool = Field(..., description="Whether the operation was successful") message: str | None = Field(None, description="Human-readable message") data: T | None = Field(None, description="Response payload") @@ -16,17 +16,18 @@ class APIResponse(BaseModel, Generic[T]): timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Response timestamp") request_id: str | None = Field(None, description="Unique request identifier") - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "success": True, "message": "Operation successful", "data": {"id": "123", "name": "Example"}, "errors": None, "timestamp": "2024-11-27T10:00:00+00:00", - "request_id": "req-123-456" + "request_id": "req-123-456", } } + ) class PaginationMeta(BaseModel): diff --git a/src/core/seed_data.py b/src/core/seed_data.py index 827293c..94af99f 100644 --- a/src/core/seed_data.py +++ b/src/core/seed_data.py @@ -1,4 +1,17 @@ -"""Seed mock data for development and testing.""" +"""Seed mock data for development and testing. + +Seeds two templates pulled directly from appstore-apps/: + * Multi-User Ubuntu (from appstore-apps/ansible_multiuser) + * PostgreSQL Group DB (from appstore-apps/ansible_postgres_group_db) + +The file contents below are inlined at generation time (see scripts that +build this file). To refresh them after editing an app file on disk, +re-run the embed step or use scripts/sync_app_files_to_db.py to push the +disk version into an already-seeded DB without restarting. + +Legacy templates (older names from earlier iterations) are deleted on +seeder run so the dashboard stays clean. +""" import logging from sqlalchemy.orm import Session @@ -10,75 +23,106 @@ logger = logging.getLogger(__name__) -MULTISTUDENT_APP_YAML = """ +# ============================================================================ +# File contents — inlined from appstore-apps/. Regenerate to pick up changes. +# ============================================================================ + +MULTIUSER_APP_YAML = r''' app: - name: multiuser-ubuntu - label: Multi-User Ubuntu - version: 1.0.0 + name: ansible-multiuser + label: Ansible Multi-User Ubuntu + version: 2.1.0 description: > - Deploy one Ubuntu VM for a course with multiple local student accounts (username/password), - SSH allowed only from DHBW/VPN CIDR, and a Floating IP on DHBW. + Ubuntu VM mit mehreren Benutzerkonten, verwaltet durch Ansible. + Pro Gruppe wird ein Linux-Account mit eigenem Arbeitsverzeichnis erstellt. + Root disk is a Cinder volume (Boot from Volume) so "Speicher" can be configured. owner_team: dozilab-app-team + allow_user_files: true + +# Maps each role-bearing file in this folder to its FileType so the backend's +# GitHub-import wiring picks them up at deploy time. Without this block every +# file is imported as OTHER and the deploy pipeline finds no Heat template +# and no playbook. ``heat_template`` is also marked as the primary file +# (deployment entrypoint). +# +# ``shell_scripts`` and ``config_files`` accept a list of paths so that +# multiple helper scripts (scripts/) and configuration files (files/) get +# their FileType set correctly — without that, the deploy-side copy step +# would skip them and the playbook would fail with "file not found" when +# trying to read /opt/dozilab/files/bashrc or /opt/dozilab/scripts/*. artifacts: - heat_template: heat/main.yaml - cloud_init: cloud-init/user-data.yaml + heat_template: heat/main.yaml + ansible_playbook: playbooks/main.yml + shell_scripts: + - scripts/check_student_setup.sh + - scripts/reset_password.sh + config_files: + - files/bashrc + - files/motd -# Parameters exposed to the AppStore UI. -# Keep only what is meaningful for users; platform-fixed settings are hidden or fixed via allowed_values in Heat. parameters: - # --- VM basics --- - name: stack_label - label: Label + label: Stack-Label step: template type: string - default: multistudent + default: ansible required: true description: > - Short course/stack label used in VM name and metadata (e.g. kurs-01). - Must match: ^[a-z0-9][a-z0-9-]{0,30}$ + Kurzes Label für diesen Stack (z.B. kurs-ws2026). + Muss passen zu: ^[a-z0-9][a-z0-9-]{0,30}$ - name: image - label: Image + label: Betriebssystem-Image step: konfiguration type: string default: "Ubuntu 22.04 2025-01" + required: true enum: - "Ubuntu 22.04 2025-01" - "Ubuntu 24.04 2025-01" - "Ubuntu 24.04 2026-01" - required: true - description: "Base image for the VM (restricted to known good images)." - name: flavor - label: Flavor + label: VM-Größe (Flavor) step: konfiguration type: string default: "gp1.small" + required: true enum: - "gp1.small" - "gp1.medium" + + - name: volume_size + label: Speicher (GB) + step: konfiguration + type: number + default: 8 + enum: + - 8 + - 16 + - 32 + - 64 + - 128 required: true - description: "VM size. Keep small/medium for student workloads." + description: "Größe der Root-Disk als Cinder-Volume (Boot from Volume)." - # --- Access control / networking --- - name: ssh_cidr - label: SSH erlaubtes Netz (CIDR) + label: SSH-Zugriff (CIDR) step: netzwerk type: string default: "141.72.0.0/16" required: true description: > - IPv4 CIDR allowed to SSH and ICMP (ping). Default is DHBW/VPN range. - Examples: 141.72.0.0/16 (VPN/Campus) or 1.2.3.4/32 (single IP). + Nur dieses Netz darf per SSH zugreifen. + Standard: DHBW/VPN. Beispiel: 141.72.0.0/16 oder 1.2.3.4/32. - name: force_password_change - label: Passwortwechsel beim ersten Login + label: Passwort-Änderung beim ersten Login erzwingen step: zugriff type: boolean default: true required: true - description: "If true, student users must change their password on first login." - name: workdir label: Arbeitsordner @@ -86,9 +130,8 @@ type: string default: "work" required: true - description: "Directory created under each student's home and used as default login directory." + description: "Directory created under each group user's home and used as default login directory." - # --- Password policy (showcase / configurable in V1) --- - name: pw_min_length label: Minimale Passwortlänge step: zugriff @@ -121,8 +164,6 @@ required: true description: "Require at least one special character." - # --- Platform-fixed parameters (not shown in UI) --- - # Heat enforces allowed_values for these anyway. Keeping them hidden avoids confusion. - name: network label: Internes Netzwerk step: netzwerk @@ -139,186 +180,59 @@ hidden: true description: "External/FloatingIP network (fixed)." - - name: key_name - label: Admin SSH-Key - step: zugriff - type: string - default: "heat-bastion-key" - hidden: true - description: "Admin/support SSH keypair (fixed in v1)." - -outputs: - - name: floating_ip - from_heat_output: floating_ip - description: "Public floating IP address." - - - name: server_id - from_heat_output: server_id - description: "Nova server ID (useful for console-log polling)." - - - name: ssh_hint - from_heat_output: ssh_hint - description: "How students login (template string)." - - - name: ready_marker - from_heat_output: ready_marker - description: "Marker string that appears in Nova console log when provisioning is done. Auch nach was müsst ihr im log ausschau halten dass ihr wisst dass die VM Ready ist" -""" - -POSTGRES_APP_YAML = """ -app: - name: postgres-group-db - label: PostgreSQL Group DB - version: 1.1.0 - description: > - Provision one Ubuntu VM with PostgreSQL (localhost-only) and optional pgAdmin. - Creates group databases, student logins, and teacher access. Backend can wait - for the DOZILAB_READY marker in the Nova console log. - owner_team: dozilab-app-team - -artifacts: - heat_template: heat/main.yaml - cloud_init: cloud-init/user-data.yaml - -# Parameters exposed to the AppStore UI. -parameters: - # --- VM basics --- - - name: stack_label - label: Label - step: template - type: string - default: "sql" - required: true - description: > - Short course/stack label used in VM name and log markers (e.g. sql-2026-01). - Must match: ^[a-z0-9][a-z0-9-]{0,30}$ - - - name: image - label: Image - step: konfiguration - type: string - default: "Ubuntu 22.04 2025-01" - enum: - - "Ubuntu 22.04 2025-01" - - "Ubuntu 24.04 2025-01" - - "Ubuntu 24.04 2026-01" - required: true - description: "Base image for the VM (restricted to known good images)." - - - name: flavor - label: Flavor - step: konfiguration - type: string - default: "gp1.small" - enum: - - "gp1.small" - - "gp1.medium" - required: true - description: "VM size. Keep small/medium for student workloads." - - - name: volume_size - label: Speicher (GB) - step: konfiguration - type: number - default: 8 - enum: - - 8 - - 16 - - 32 - - 64 - - 128 - required: true - description: "Größe der Root-Disk als Cinder-Volume (Boot from Volume)." - - # --- Access control / networking --- - - name: ssh_cidr - label: SSH erlaubtes Netz (CIDR) - step: netzwerk - type: string - default: "141.72.0.0/16" - required: true - description: > - IPv4 CIDR allowed to SSH. Default is DHBW/VPN range. - Examples: 141.72.0.0/16 or 1.2.3.4/32. - - - name: web_cidr - label: pgAdmin erlaubtes Netz (CIDR) - step: netzwerk - type: string - default: "141.72.0.0/16" - required: true - description: "IPv4 CIDR allowed to reach pgAdmin (HTTP port 80)." - +credentials: + per_group: + - linux: + username: "{{ username }}" + password: generate + ssh_key: generate - # --- Platform-fixed parameters (not shown in UI) --- - - name: network - label: Internes Netzwerk - step: netzwerk - type: string - default: "NAT" - hidden: true - description: "Internal network (fixed)." + teacher: + # teacher.linux automatisch vorhanden — kein Eintrag nötig - - name: external_network - label: Externes Netzwerk (Floating IP) - step: netzwerk - type: string - default: "DHBW" - hidden: true - description: "External/FloatingIP network (fixed)." +user_files: + - name: aufgabe_pdf + label: "Aufgabenstellung (PDF)" + description: "Wird auf alle VMs kopiert — gleiche Aufgabe für alle Gruppen." + required: false + accept: "*.pdf" + destination: /opt/dozilab/user-files/aufgabe.pdf + mode: all_stacks - - name: key_name - label: Admin SSH-Key - step: zugriff - type: string - default: "heat-bastion-key" - hidden: true - description: "Admin/support SSH keypair (fixed)." + - name: material_gruppe + label: "Gruppenmaterial" + description: "Pro Gruppe eigene Dateien — z.B. unterschiedliche Datensätze oder Aufgaben." + required: false + accept: "*" + destination: /opt/dozilab/user-files/{{ group_name }}/material + mode: per_group outputs: - - name: ssh_user - from_heat_output: ssh_user - description: "SSH username (ubuntu)." - - name: floating_ip + label: Floating IP from_heat_output: floating_ip - description: "Public floating IP address." - - - name: private_ip - from_heat_output: private_ip - description: "Private IP on tenant network." - name: server_id + label: Server ID from_heat_output: server_id - description: "Nova server ID (useful for console-log polling)." - - - name: pgadmin_url - from_heat_output: pgadmin_url - description: "pgAdmin4 URL (if enabled)." - name: ssh_hint + label: SSH Hinweis from_heat_output: ssh_hint - description: "Admin SSH command template." - - - name: ssh_tunnel_hint - from_heat_output: ssh_tunnel_hint - description: "How to access Postgres securely via SSH tunnel." - - - name: ready_marker - from_heat_output: ready_marker - description: "Marker string that appears in Nova console log when provisioning is done." - name: root_volume_id + label: Root Volume ID from_heat_output: root_volume_id - description: "Cinder root volume ID (debug/traceability)." - -""" +''' -MULTISTUDENT_HEAT_TEMPLATE = """ +MULTIUSER_HEAT_TEMPLATE = r''' heat_template_version: 2018-08-31 description: > - DoziLab Multi-Student VM: Ubuntu VM + Floating IP + password SSH for multiple users. + DoziLab Ansible Multi-User VM. + Nur Infrastruktur — keine user_data, keine cloud-init. + Konfiguration übernimmt Ansible vom Backend aus per SSH. + Root disk is provisioned as a Cinder volume (Boot from Volume) so "Speicher" can be configured. parameters: image: @@ -340,81 +254,48 @@ - "gp1.medium" description: "VM size. Keep small/medium for student workloads." - network: - type: string - default: "NAT" - constraints: - - allowed_values: ["NAT"] - - external_network: - type: string - default: "DHBW" - constraints: - - allowed_values: ["DHBW"] - - key_name: - type: string - default: "heat-bastion-key" + volume_size: + type: number + default: 8 constraints: - - allowed_values: ["heat-bastion-key"] - description: "Admin/support SSH keypair (später erweiterbar)" + - range: { min: 8, max: 200 } + description: "Root volume size in GB." ssh_cidr: type: string default: "141.72.0.0/16" constraints: - allowed_pattern: '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$' - description: "IPv4 CIDR allowed to SSH (default DHBW/VPN)." - - user_json: - type: string - default: | - {"course_label":"","instance":{"credentials":[]},"applications":[]} - description: "Base64-encoded JSON payload (raw JSON also accepted; multi-line allowed)." - constraints: - - length: { min: 2 } - - force_password_change: - type: boolean - default: true - - workdir: - type: string - default: "work" - constraints: - - allowed_pattern: '^[A-Za-z0-9._-]{1,32}$' - description: "Work directory under each user's home (e.g. work)." + description: "IPv4 CIDR allowed to SSH and ICMP." stack_label: type: string - default: "multistudent" + default: "ansible" constraints: - allowed_pattern: '^[a-z0-9][a-z0-9-]{0,30}$' description: "Internal label used for resource names/metadata." - pw_min_length: - type: number - default: 12 + network: + type: string + default: "NAT" constraints: - - range: { min: 4, max: 128 } - - pw_require_digit: - type: boolean - default: true + - allowed_values: ["NAT"] - pw_require_upper: - type: boolean - default: true + external_network: + type: string + default: "DHBW" + constraints: + - allowed_values: ["DHBW"] - pw_require_special: - type: boolean - default: true + key_name: + type: string + description: "Admin/support SSH keypair. Backend should set this from ANSIBLE_SSH_KEY_NAME." resources: secgroup: type: OS::Neutron::SecurityGroup properties: - description: Allow SSH + ICMP (VPN/Campus only) + description: SSH + ICMP aus erlaubtem CIDR rules: - direction: ingress ethertype: IPv4 @@ -435,6 +316,19 @@ security_groups: - { get_resource: secgroup } + root_volume: + type: OS::Cinder::Volume + properties: + name: + str_replace: + template: "dozilab-STACK-root" + params: + STACK: { get_param: stack_label } + size: { get_param: volume_size } + image: { get_param: image } + metadata: + dozilab_stack_label: { get_param: stack_label } + server: type: OS::Nova::Server properties: @@ -444,33 +338,20 @@ params: STACK: { get_param: stack_label } - image: { get_param: image } flavor: { get_param: flavor } key_name: { get_param: key_name } + block_device_mapping_v2: + - boot_index: 0 + volume_id: { get_resource: root_volume } + delete_on_termination: true + networks: - port: { get_resource: port } metadata: dozilab_stack_label: { get_param: stack_label } - dozilab_ready_marker: "DOZILAB_READY" - - user_data_format: RAW - user_data: - str_replace: - template: { get_file: ../cloud-init/user-data.yaml } - params: - __USER_JSON__: { get_param: user_json } - - __FORCE_CHANGE__: { get_param: force_password_change } - __WORKDIR__: { get_param: workdir } - - __PW_MIN_LENGTH__: { get_param: pw_min_length } - __PW_REQUIRE_DIGIT__: { get_param: pw_require_digit } - __PW_REQUIRE_UPPER__: { get_param: pw_require_upper } - __PW_REQUIRE_SPECIAL__: { get_param: pw_require_special } - - __STACK_LABEL__: { get_param: stack_label } + dozilab_config_method: "ansible" fip: type: OS::Neutron::FloatingIP @@ -485,10 +366,11 @@ outputs: floating_ip: + description: "Public floating IP address." value: { get_attr: [fip, floating_ip_address] } server_id: - description: "Nova server ID (useful for console-log polling)" + description: "Nova server ID." value: { get_resource: server } ssh_hint: @@ -499,74 +381,636 @@ params: FIP: { get_attr: [fip, floating_ip_address] } - ready_marker: - description: "Backend should wait until this marker appears in the Nova console log" - value: - str_replace: - template: "DOZILAB_READY stack=STACK" - params: - STACK: { get_param: stack_label } -""" + root_volume_id: + description: "Cinder root volume ID." + value: { get_resource: root_volume }''' -POSTGRES_HEAT_TEMPLATE = """ -heat_template_version: 2018-08-31 +MULTIUSER_PLAYBOOK = r''' +--- +# DoziLab Ansible Playbook: Multi-User Ubuntu +# +# Variablen (vom Backend): +# deployment_groups[].username # sanitized Linux-User pro Gruppe +# deployment_groups[].linux.password +# deployment_groups[].linux.ssh_key.public_key # optional (nur wenn app.yaml: ssh_key: generate) +# deployment_groups[].linux.ssh_key.private_key # optional, nur informativ — wird NICHT auf die VM kopiert +# deployment_groups[].group_name # Original-Gruppenname (für user_files-Lookup) +# stack_label +# force_password_change +# workdir +# pw_min_length +# pw_require_digit +# pw_require_upper +# pw_require_special +# user_files.aufgabe_pdf.exists +# user_files.material_gruppe[group_name].exists +# +# Hinweis: ``deployment_groups`` heißt NICHT ``groups`` — letzteres ist eine +# reservierte Ansible-Magic-Variable (Inventory-Dict). Würden wir unsere +# Group-Liste so nennen, würde Ansible sie mit dem Inventory-Dict +# überschreiben und alle loop-Tasks unten würden über etwas ganz anderes +# iterieren. -description: DoziLab PostgreSQL VM (localhost-only) + optional pgAdmin4 Web UI (boot from Cinder volume) +- name: DoziLab Multi-User Setup + hosts: all + remote_user: ubuntu + become: true -parameters: - stack_label: - type: string - description: Short label used in hostname/log markers - default: "sql" + vars: + dozilab_workdir: "{{ workdir | default('work') }}" + dozilab_pw_min_length: "{{ pw_min_length | default(12) }}" + dozilab_pw_require_digit: "{{ pw_require_digit | default(true) }}" + dozilab_pw_require_upper: "{{ pw_require_upper | default(true) }}" + dozilab_pw_require_special: "{{ pw_require_special | default(true) }}" + + tasks: + - name: Arbeitsordner-Parameter validieren + assert: + that: + - dozilab_workdir is match('^[A-Za-z0-9._-]{1,32}$') + fail_msg: "Invalid workdir. Allowed pattern: ^[A-Za-z0-9._-]{1,32}$" + + - name: Passwort-Mindestlänge validieren + assert: + that: + - (dozilab_pw_min_length | int) >= 4 + - (dozilab_pw_min_length | int) <= 128 + fail_msg: "pw_min_length must be between 4 and 128." + + - name: Benötigte Pakete installieren + apt: + name: + - libpam-pwquality + - python3 + - cloud-guest-utils + state: present + update_cache: true + + - name: Passwort-Policy Variablen berechnen + set_fact: + pw_dcredit: "{{ '-1' if (dozilab_pw_require_digit | bool) else '0' }}" + pw_ucredit: "{{ '-1' if (dozilab_pw_require_upper | bool) else '0' }}" + pw_ocredit: "{{ '-1' if (dozilab_pw_require_special | bool) else '0' }}" + + - name: pwquality.conf schreiben + copy: + dest: /etc/security/pwquality.conf + owner: root + group: root + mode: "0644" + content: | + # Managed by DoziLab (Ansible). NOTE: PAM options override this file. + minlen = {{ dozilab_pw_min_length | int }} + dcredit = {{ pw_dcredit }} + ucredit = {{ pw_ucredit }} + ocredit = {{ pw_ocredit }} + dictcheck = 0 + usercheck = 0 + gecoscheck = 0 + difok = 0 + maxrepeat = 0 + minclass = 0 + + - name: Prüfen ob pam_pwquality bereits konfiguriert ist + command: grep -qE '^\s*password\s+requisite\s+pam_pwquality\.so' /etc/pam.d/common-password + register: pam_pwquality_check + changed_when: false + failed_when: false + + - name: Bestehende pam_pwquality Zeile ersetzen + replace: + path: /etc/pam.d/common-password + regexp: '^\s*password\s+requisite\s+pam_pwquality\.so.*$' + replace: "password requisite pam_pwquality.so retry=3 enforce_for_root minlen={{ dozilab_pw_min_length | int }} dcredit={{ pw_dcredit }} ucredit={{ pw_ucredit }} ocredit={{ pw_ocredit }} dictcheck=0 usercheck=0 gecoscheck=0 difok=0 maxrepeat=0 minclass=0" + when: pam_pwquality_check.rc == 0 + + - name: pam_pwquality vor pam_unix einfügen + lineinfile: + path: /etc/pam.d/common-password + insertbefore: '^\s*password\s+.*pam_unix\.so' + line: "password requisite pam_pwquality.so retry=3 enforce_for_root minlen={{ dozilab_pw_min_length | int }} dcredit={{ pw_dcredit }} ucredit={{ pw_ucredit }} ocredit={{ pw_ocredit }} dictcheck=0 usercheck=0 gecoscheck=0 difok=0 maxrepeat=0 minclass=0" + when: pam_pwquality_check.rc != 0 + + - name: /opt/dozilab nur für root zugänglich machen + file: + path: /opt/dozilab + state: directory + owner: root + group: root + mode: "0700" + + - name: Gruppen-Linux-Accounts erstellen + user: + name: "{{ item.username }}" + shell: /bin/bash + create_home: true + state: present + loop: "{{ deployment_groups }}" + no_log: true + + - name: Passwörter setzen + user: + name: "{{ item.username }}" + password: "{{ item.linux.password | password_hash('sha512') }}" + update_password: always + loop: "{{ deployment_groups }}" + no_log: true + + - name: Gruppen-SSH-Public-Key in authorized_keys installieren + # Backend generiert per app.yaml `ssh_key: generate` ein Ed25519-Keypair + # pro Gruppe. Der Public-Key landet hier in ~/.ssh/authorized_keys des + # Gruppen-Linux-Users, der Private-Key bleibt in der Backend-DB und wird + # via /student-API zum Download bereitgestellt. Task ist no-op für + # Gruppen ohne Keypair (z.B. Apps die `ssh_key: generate` nicht setzen). + ansible.posix.authorized_key: + user: "{{ item.username }}" + key: "{{ item.linux.ssh_key.public_key }}" + state: present + loop: "{{ deployment_groups }}" + when: item.linux.ssh_key is defined and item.linux.ssh_key.public_key is defined + no_log: true + + - name: Passwort-Änderung beim ersten Login erzwingen + command: chage -d 0 {{ item.username }} + loop: "{{ deployment_groups }}" + no_log: true + when: force_password_change | bool + + - name: Home-Verzeichnis absichern + file: + path: "/home/{{ item.username }}" + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: "0700" + loop: "{{ deployment_groups }}" + no_log: true + + - name: Arbeitsverzeichnis erstellen + file: + path: "/home/{{ item.username }}/{{ dozilab_workdir }}" + state: directory + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: "0700" + loop: "{{ deployment_groups }}" + no_log: true + + - name: Standard-Login-Verzeichnis in .profile setzen + lineinfile: + path: "/home/{{ item.username }}/.profile" + line: 'cd "$HOME/{{ dozilab_workdir }}"' + create: true + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: "0644" + loop: "{{ deployment_groups }}" + no_log: true + + - name: .bashrc für jeden Gruppen-User setzen + copy: + src: /opt/dozilab/files/bashrc + dest: "/home/{{ item.username }}/.bashrc" + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: "0644" + remote_src: true + loop: "{{ deployment_groups }}" + no_log: true + + - name: Arbeitsordner in .bashrc ersetzen + replace: + path: "/home/{{ item.username }}/.bashrc" + regexp: "__WORKDIR__" + replace: "{{ dozilab_workdir }}" + loop: "{{ deployment_groups }}" + no_log: true + + - name: MOTD setzen + shell: | + sed 's/__STACK_LABEL__/{{ stack_label }}/g' \ + /opt/dozilab/files/motd > /etc/update-motd.d/99-dozilab + chmod +x /etc/update-motd.d/99-dozilab + + - name: Scripts ausführbar machen + file: + path: "{{ item }}" + mode: "0755" + loop: + - /opt/dozilab/scripts/check_student_setup.sh + - /opt/dozilab/scripts/reset_password.sh + + - name: Gruppen-Setup verifizieren + command: /opt/dozilab/scripts/check_student_setup.sh {{ item.username }} {{ dozilab_workdir }} + loop: "{{ deployment_groups }}" + register: check_result + changed_when: false + no_log: true + + - name: Aufgabenstellung in Arbeitsverzeichnis kopieren + copy: + src: /opt/dozilab/user-files/aufgabe.pdf + dest: "/home/{{ item.username }}/{{ dozilab_workdir }}/aufgabe.pdf" + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: "0644" + remote_src: true + loop: "{{ deployment_groups }}" + no_log: true + when: user_files.aufgabe_pdf.exists | default(false) + + - name: Gruppenverzeichnis für Material erstellen + file: + path: "/home/{{ item.username }}/{{ dozilab_workdir }}/material" + state: directory + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: "0700" + loop: "{{ deployment_groups }}" + no_log: true + when: user_files.material_gruppe[item.group_name].exists | default(false) + + - name: Gruppenmaterial in Arbeitsverzeichnis kopieren + copy: + src: "/opt/dozilab/user-files/{{ item.group_name }}/material" + dest: "/home/{{ item.username }}/{{ dozilab_workdir }}/material/" + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: "0600" + remote_src: true + loop: "{{ deployment_groups }}" + no_log: true + when: user_files.material_gruppe[item.group_name].exists | default(false) +''' + +MULTIUSER_BASHRC = r''' +# ============================================================================== +# DoziLab: .bashrc für Student-Accounts +# Wird von Ansible in jeden Home-Ordner kopiert. +# ============================================================================== + +# Basis +export HISTSIZE=1000 +export HISTFILESIZE=2000 +export EDITOR=nano + +# Farben im Terminal +force_color_prompt=yes +PS1='\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' + +# Praktische Aliase +alias ll='ls -alF' +alias la='ls -A' +alias l='ls -CF' +alias ..='cd ..' +alias work='cd ~/__WORKDIR__' + +# Begrüßung beim Login +echo "" +echo " Willkommen, $(whoami)!" +echo " Dein Arbeitsverzeichnis: ~/__WORKDIR__" +echo "" +''' + +MULTIUSER_MOTD = r''' +#!/usr/bin/env bash +# ============================================================================== +# DoziLab: Message of the Day — wird beim SSH-Login angezeigt. +# Platzhalter __STACK_LABEL__ wird vom Playbook ersetzt. +# ============================================================================== + +echo "" +echo " ██████╗ ██████╗ ███████╗██╗██╗ █████╗ ██████╗ " +echo " ██╔══██╗██╔═══██╗╚══███╔╝██║██║ ██╔══██╗██╔══██╗" +echo " ██║ ██║██║ ██║ ███╔╝ ██║██║ ███████║██████╔╝" +echo " ██║ ██║██║ ██║ ███╔╝ ██║██║ ██╔══██║██╔══██╗" +echo " ██████╔╝╚██████╔╝███████╗██║███████╗██║ ██║██████╔╝" +echo " ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ " +echo "" +echo " Kurs: __STACK_LABEL__" +echo " Support: Wende dich an deinen Dozenten" +echo "" +''' + +MULTIUSER_CHECK_SCRIPT = r''' +#!/usr/bin/env bash +# ============================================================================== +# DoziLab: Check ob ein Student-Account korrekt eingerichtet ist. +# Wird vom Playbook nach dem Setup aufgerufen. +# +# Usage: check_student_setup.sh [workdir] +# Gibt 0 zurück wenn alles OK, 1 wenn etwas fehlt. +# ============================================================================== +set -euo pipefail + +USERNAME="${1:?Usage: check_student_setup.sh [workdir]}" +WORKDIR="${2:-work}" +ERRORS=0 + +check() { + local desc="$1" + local result="$2" + if [[ "$result" == "ok" ]]; then + echo " ✓ $desc" + else + echo " ✗ $desc → $result" + ERRORS=$((ERRORS + 1)) + fi +} + +echo "Checking setup for: $USERNAME" +echo "Expected workdir: $WORKDIR" + +id "$USERNAME" &>/dev/null \ + && check "Account existiert" "ok" \ + || check "Account existiert" "nicht gefunden" + +[[ -d "/home/$USERNAME" ]] \ + && check "Home-Verzeichnis /home/$USERNAME" "ok" \ + || check "Home-Verzeichnis /home/$USERNAME" "fehlt" + +[[ -d "/home/$USERNAME/$WORKDIR" ]] \ + && check "Arbeitsverzeichnis /home/$USERNAME/$WORKDIR" "ok" \ + || check "Arbeitsverzeichnis /home/$USERNAME/$WORKDIR" "fehlt" + +passwd -S "$USERNAME" 2>/dev/null | grep -qv " L " \ + && check "Passwort gesetzt" "ok" \ + || check "Passwort gesetzt" "Account gesperrt oder kein Passwort" + +USER_SHELL=$(getent passwd "$USERNAME" | cut -d: -f7) +[[ "$USER_SHELL" == "/bin/bash" ]] \ + && check "Shell ist /bin/bash" "ok" \ + || check "Shell ist /bin/bash" "ist $USER_SHELL" + +echo "" +if [[ $ERRORS -eq 0 ]]; then + echo "✓ Setup OK für $USERNAME" + exit 0 +else + echo "✗ $ERRORS Fehler gefunden für $USERNAME" + exit 1 +fi +''' + +MULTIUSER_RESET_SCRIPT = r''' +#!/usr/bin/env bash +# ============================================================================== +# DoziLab: Passwort eines Student-Accounts zurücksetzen. +# Kann vom Lehrer manuell aufgerufen werden. +# +# Usage: reset_password.sh +# ============================================================================== +set -euo pipefail + +USERNAME="${1:?Usage: reset_password.sh }" +NEW_PASSWORD="${2:?Usage: reset_password.sh }" + +# Prüfen ob Account existiert +if ! id "$USERNAME" &>/dev/null; then + echo "ERROR: Account '$USERNAME' nicht gefunden" >&2 + exit 1 +fi + +# Passwort setzen +echo "${USERNAME}:${NEW_PASSWORD}" | chpasswd +echo "✓ Passwort für '$USERNAME' zurückgesetzt" + +# Passwort-Änderung beim nächsten Login erzwingen +chage -d 0 "$USERNAME" +echo "✓ Passwort-Änderung beim nächsten Login erzwungen" +''' + +POSTGRES_APP_YAML = r''' +app: + name: ansible-postgres-group-db + label: Ansible PostgreSQL Group DB + version: 2.0.0 + description: > + Ubuntu VM mit PostgreSQL, Gruppen-Datenbanken, Teacher-Zugriff und optionalem pgAdmin. + Infrastruktur wird per Heat erstellt, Konfiguration läuft per Ansible. + owner_team: dozilab-app-team + +artifacts: + heat_template: heat/main.yaml + ansible_playbook: playbooks/main.yml + +# ------------------------------------------------------------------------------ +# Parameter +# ------------------------------------------------------------------------------ +parameters: + + # --- Schritt 1: Template --- + + - name: stack_label + label: Stack-Label + step: template + type: string + default: sql + required: true + description: "Kurzes Label für diesen Stack, z.B. sql-2026-01." + + # --- Schritt 2: Konfiguration --- + + - name: image + label: Betriebssystem-Image + step: konfiguration + type: string + default: "Ubuntu 22.04 2025-01" + required: true + enum: + - "Ubuntu 22.04 2025-01" + - "Ubuntu 24.04 2025-01" + - "Ubuntu 24.04 2026-01" + + - name: flavor + label: VM-Größe (Flavor) + step: konfiguration + type: string + default: "gp1.small" + required: true + enum: + - "gp1.small" + - "gp1.medium" + - "gp1.large" + - "mb1.small" + - "mb1.medium" + - "mb1.large" + description: > + VM-Größe für PostgreSQL und optional pgAdmin. + gp1.small reicht für kleine Kurse und Tests. + Für größere Kurse oder pgAdmin-Nutzung sind gp1.medium, gp1.large oder mb1.* sinnvoll. + + - name: volume_size + label: Speicher (GB) + step: konfiguration + type: number + default: 8 + required: true + enum: + - 8 + - 16 + - 32 + - 64 + - 128 + description: "Größe der Root-Disk als Cinder-Volume." + + # --- Schritt 3: Netzwerk --- + + - name: ssh_cidr + label: SSH-Zugriff (CIDR) + step: netzwerk + type: string + default: "141.72.0.0/16" + required: true + description: "Nur dieses Netz darf per SSH zugreifen. Standard: DHBW/VPN." + + - name: web_cidr + label: pgAdmin-Zugriff (CIDR) + step: netzwerk + type: string + default: "141.72.0.0/16" + required: true + description: "Nur dieses Netz darf pgAdmin über HTTP Port 80 erreichen." + + # --- Plattform-fest / hidden --- + + - name: network + label: Internes Netzwerk + step: netzwerk + type: string + default: "NAT" + hidden: true + description: "Internes OpenStack-Netzwerk." + + - name: external_network + label: Externes Netzwerk + step: netzwerk + type: string + default: "DHBW" + hidden: true + description: "Floating-IP-Netzwerk." + + +# ------------------------------------------------------------------------------ +# Credentials +# ------------------------------------------------------------------------------ +credentials: + per_group: + - postgres: + database_name: "db_{{ username }}" + db_user: "{{ username }}" + password: generate + + - pgadmin: + email: "{{ email }}" + password: generate + + teacher: + - postgres: + db_user: teacher + password: generate + + - pgadmin: + email: "{{ email }}" + password: generate + +# ------------------------------------------------------------------------------ +# Outputs +# ------------------------------------------------------------------------------ +outputs: + - name: ssh_user + label: SSH User + from_heat_output: ssh_user + + - name: floating_ip + label: Floating IP + from_heat_output: floating_ip + + - name: pgadmin_url + label: pgAdmin URL + from_heat_output: pgadmin_url''' + +POSTGRES_HEAT_TEMPLATE = r''' +heat_template_version: 2018-08-31 + +description: > + DoziLab Ansible PostgreSQL Group DB VM. + Nur Infrastruktur — keine user_data, keine cloud-init. + Konfiguration übernimmt Ansible vom Backend aus per SSH. + Root disk is a Cinder volume (Boot from Volume) so "Speicher" can be configured. +parameters: image: type: string - description: Glance image name or ID + default: "Ubuntu 22.04 2025-01" + constraints: + - allowed_values: + - "Ubuntu 22.04 2025-01" + - "Ubuntu 24.04 2025-01" + - "Ubuntu 24.04 2026-01" + description: "Base image for the VM." flavor: type: string - description: Nova flavor name or ID + default: "gp1.small" + constraints: + - allowed_values: + - "gp1.small" + - "gp1.medium" + - "gp1.large" + - "mb1.small" + - "mb1.medium" + - "mb1.large" + description: "VM size for PostgreSQL and optional pgAdmin." volume_size: type: number - description: Root volume size in GB default: 8 constraints: - range: { min: 8, max: 200 } + description: "Root volume size in GB." - network: + ssh_cidr: type: string - description: Tenant network name or ID + default: "141.72.0.0/16" + constraints: + - allowed_pattern: '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$' + description: "IPv4 CIDR allowed to SSH and ICMP." - external_network: + web_cidr: type: string - description: External network name or ID for Floating IP + default: "141.72.0.0/16" + constraints: + - allowed_pattern: '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$' + description: "IPv4 CIDR allowed to reach pgAdmin over HTTP." - key_name: + stack_label: type: string - description: Nova keypair name for SSH - default: "heat-bastion-key" + default: "sql" + constraints: + - allowed_pattern: '^[a-z0-9][a-z0-9-]{0,30}$' + description: "Internal label used for resource names/metadata." - ssh_cidr: + network: type: string - description: Allowed CIDR for SSH - default: "0.0.0.0/0" + default: "NAT" + constraints: + - allowed_values: ["NAT"] - web_cidr: + external_network: type: string - description: Allowed CIDR for pgAdmin over HTTP (port 80) - default: "0.0.0.0/0" + default: "DHBW" + constraints: + - allowed_values: ["DHBW"] - user_json: + key_name: type: string - description: Base64-encoded JSON payload from backend (raw JSON also accepted; multi-line allowed) + description: "Admin/support SSH keypair. Backend should set this from ANSIBLE_SSH_KEY_NAME." resources: secgroup: type: OS::Neutron::SecurityGroup properties: - name: { str_replace: { template: "dozilab-pg-__LABEL__", params: { "__LABEL__": { get_param: stack_label } } } } - description: Security group for DoziLab Postgres+pgAdmin VM + description: SSH, ICMP und pgAdmin HTTP aus erlaubten CIDRs rules: - direction: ingress ethertype: IPv4 @@ -575,6 +1019,11 @@ port_range_max: 22 remote_ip_prefix: { get_param: ssh_cidr } + - direction: ingress + ethertype: IPv4 + protocol: icmp + remote_ip_prefix: { get_param: ssh_cidr } + - direction: ingress ethertype: IPv4 protocol: tcp @@ -582,7 +1031,6 @@ port_range_max: 80 remote_ip_prefix: { get_param: web_cidr } - # egress allow all (default in many setups; define explicitly to be safe) - direction: egress ethertype: IPv4 @@ -590,41 +1038,47 @@ type: OS::Neutron::Port properties: network: { get_param: network } - security_groups: [ { get_resource: secgroup } ] + security_groups: + - { get_resource: secgroup } root_volume: type: OS::Cinder::Volume properties: name: str_replace: - template: "dozilab-pg-__LABEL__-root" + template: "dozilab-pg-STACK-root" params: - "__LABEL__": { get_param: stack_label } + STACK: { get_param: stack_label } size: { get_param: volume_size } image: { get_param: image } metadata: dozilab_stack_label: { get_param: stack_label } + dozilab_app: "ansible-postgres-group-db" server: type: OS::Nova::Server properties: - name: { str_replace: { template: "dozilab-pg-__LABEL__", params: { "__LABEL__": { get_param: stack_label } } } } + name: + str_replace: + template: "dozilab-pg-STACK" + params: + STACK: { get_param: stack_label } + flavor: { get_param: flavor } key_name: { get_param: key_name } + block_device_mapping_v2: - boot_index: 0 volume_id: { get_resource: root_volume } delete_on_termination: true + networks: - port: { get_resource: port } - user_data_format: RAW - user_data: - str_replace: - template: { get_file: ../cloud-init/user-data.yaml } - params: - "__STACK_LABEL__": { get_param: stack_label } - "__USER_JSON__": { get_param: user_json } - + + metadata: + dozilab_stack_label: { get_param: stack_label } + dozilab_config_method: "ansible" + dozilab_app: "ansible-postgres-group-db" fip: type: OS::Neutron::FloatingIP @@ -639,1619 +1093,1116 @@ outputs: ssh_user: - description: SSH username + description: "SSH username." value: ubuntu floating_ip: - description: Public Floating IP - value: { get_attr: [ fip, floating_ip_address ] } + description: "Public floating IP address." + value: { get_attr: [fip, floating_ip_address] } private_ip: - description: Private IP on tenant network - value: { get_attr: [ port, fixed_ips, 0, ip_address ] } + description: "Private IP on tenant network." + value: { get_attr: [port, fixed_ips, 0, ip_address] } server_id: - description: Nova server ID + description: "Nova server ID." value: { get_resource: server } pgadmin_url: - description: pgAdmin4 URL (if enabled) + description: "pgAdmin4 URL." value: str_replace: - template: "http://__FIP__/pgadmin4/" + template: "http://FIP/pgadmin4/" params: - "__FIP__": { get_attr: [ fip, floating_ip_address ] } + FIP: { get_attr: [fip, floating_ip_address] } ssh_hint: - description: Admin SSH (key-based) + description: "Admin SSH command." value: str_replace: - template: "ssh -i ~/.ssh/heat-bastion-key.pem ubuntu@__FIP__" + template: "ssh -i ~/.ssh/heat-bastion-key.pem ubuntu@FIP" params: - "__FIP__": { get_attr: [ fip, floating_ip_address ] } + FIP: { get_attr: [fip, floating_ip_address] } ssh_tunnel_hint: - description: How to access Postgres securely via SSH tunnel - value: - str_replace: - template: "ssh -i ~/.ssh/heat-bastion-key.pem -L 5432:127.0.0.1:5432 ubuntu@__FIP__" - params: - "__FIP__": { get_attr: [ fip, floating_ip_address ] } - - ready_marker: - description: Backend should wait until this marker appears in the Nova console log + description: "PostgreSQL SSH tunnel command." value: str_replace: - template: "DOZILAB_READY stack=__LABEL__" + template: "ssh -i ~/.ssh/heat-bastion-key.pem -L 5432:127.0.0.1:5432 ubuntu@FIP" params: - "__LABEL__": { get_param: stack_label } + FIP: { get_attr: [fip, floating_ip_address] } root_volume_id: - description: Cinder root volume ID (debug/traceability) - value: { get_resource: root_volume } + description: "Cinder root volume ID." + value: { get_resource: root_volume }''' -""" +POSTGRES_PLAYBOOK = r''' +--- +- name: DoziLab Ansible PostgreSQL Group DB Setup + hosts: all + remote_user: ubuntu + become: true -MULTISTUDENT_CLOUD_INIT = """ -#cloud-config -package_update: true -package_upgrade: false - -packages: - - libpam-pwquality - - python3 - - cloud-guest-utils - -# Ensure root partition/filesystem grows when booting from larger volume -growpart: - mode: auto - devices: ["/"] - ignore_growroot_disabled: false - -resize_rootfs: true - -write_files: - - path: /etc/dozilab/user.json.payload - owner: root:root - permissions: "0600" - content: | - __USER_JSON__ - - path: /usr/local/bin/dozilab-multiuser-setup.sh - permissions: "0755" - content: | - #!/usr/bin/env bash - set -euo pipefail - - LOG="/var/log/dozilab-multiuser.log" - MARK_DIR="/var/lib/dozilab" - READY_FILE="${MARK_DIR}/ready" - FAIL_FILE="${MARK_DIR}/failed" - - STACK_LABEL="__STACK_LABEL__" - - USER_JSON_PAYLOAD="/etc/dozilab/user.json.payload" - USER_JSON_PATH="/etc/dozilab/user.json" - FORCE="__FORCE_CHANGE__" - WORKDIR="__WORKDIR__" - - PW_MIN_LENGTH="__PW_MIN_LENGTH__" - PW_REQUIRE_DIGIT="__PW_REQUIRE_DIGIT__" - PW_REQUIRE_UPPER="__PW_REQUIRE_UPPER__" - PW_REQUIRE_SPECIAL="__PW_REQUIRE_SPECIAL__" - - mkdir -p "$MARK_DIR" - # Mirror logs to cloud-init output and our own logfile - exec > >(tee -a "$LOG" /var/log/cloud-init-output.log) 2>&1 - echo "multiuser setup started $(date -Is)" - - on_fail() { - rc=$? - msg="DOZILAB_FAILED stack=${STACK_LABEL} rc=${rc} time=$(date -Is)" - echo "$msg" | tee -a "$LOG" | tee /dev/console > "$FAIL_FILE" - chmod 644 "$FAIL_FILE" || true - exit "$rc" - } - trap on_fail ERR - - to_bool() { - local v="${1:-}" - v="$(echo "$v" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" - [[ "$v" == "1" || "$v" == "true" || "$v" == "yes" || "$v" == "on" ]] - } - - # --- Build deterministic pwquality options from Heat params --- - DCREDIT=0 - UCREDIT=0 - OCREDIT=0 - if to_bool "$PW_REQUIRE_DIGIT"; then DCREDIT=-1; fi - if to_bool "$PW_REQUIRE_UPPER"; then UCREDIT=-1; fi - if to_bool "$PW_REQUIRE_SPECIAL"; then OCREDIT=-1; fi - if ! [[ "$PW_MIN_LENGTH" =~ ^[0-9]+$ ]]; then PW_MIN_LENGTH=12; fi - - # Enforce exactly what the UI exposes (no dict/diff surprise checks) - # NOTE: this affects password setting (chpasswd) AND interactive passwd. - PWQ_OPTS="retry=3 enforce_for_root minlen=${PW_MIN_LENGTH} dcredit=${DCREDIT} ucredit=${UCREDIT} ocredit=${OCREDIT} dictcheck=0 usercheck=0 gecoscheck=0 difok=0 maxrepeat=0 minclass=0" - - # Keep pwquality.conf consistent for debugging/inspection (PAM line is the source of truth) - cat >/etc/security/pwquality.conf </etc/ssh/sshd_config.d/99-dozilab-multiuser.conf <<'EOF' - PasswordAuthentication yes - KbdInteractiveAuthentication yes - ChallengeResponseAuthentication yes - UsePAM yes - PermitRootLogin no - EOF - - export USER_JSON_PATH FORCE WORKDIR - - if [[ ! -s "$USER_JSON_PAYLOAD" ]]; then - echo "ERROR: $USER_JSON_PAYLOAD missing/empty" >&2 - exit 2 - fi - - # Decode base64 (or accept raw JSON) into /etc/dozilab/user.json - python3 - <<'PY' - import ast - import base64 - import json - import sys - from pathlib import Path - - payload_path = Path("/etc/dozilab/user.json.payload") - out_path = Path("/etc/dozilab/user.json") - - raw = payload_path.read_text(encoding="utf-8").strip() - if not raw: - sys.exit("user_json payload missing/empty") - - def parse_obj(txt: str): - try: - return json.loads(txt), "json" - except Exception: - pass - try: - return ast.literal_eval(txt), "python-literal" - except Exception: - return None, None - - def decode_b64(s: str): - compact = "".join(s.split()) - pad = (-len(compact)) % 4 - compact += "=" * pad - try: - return base64.b64decode(compact, validate=True).decode("utf-8") - except Exception: - try: - return base64.b64decode(compact).decode("utf-8") - except Exception: - return None - - obj, kind = parse_obj(raw) - source = "raw" - if obj is None: - decoded = decode_b64(raw) - if decoded is None: - sys.exit("user_json payload is neither JSON/literal nor base64-encoded JSON/literal") - obj, kind = parse_obj(decoded.strip()) - if obj is None: - sys.exit("user_json base64 decoded, but not valid JSON or python literal") - source = "base64" - - out_path.write_text(json.dumps(obj, ensure_ascii=True), encoding="utf-8") - print(f"user_json normalized ({source}, {kind}) -> {out_path}") - PY - - if [[ ! -s "$USER_JSON_PATH" ]]; then - echo "ERROR: $USER_JSON_PATH missing/empty after decoding" >&2 - exit 2 - fi - - # Create users from user_json (hard fail on invalid schema) - python3 - <<'PY' - import json, os, re, sys, subprocess - - user_json_path = os.environ.get("USER_JSON_PATH", "/etc/dozilab/user.json") - try: - user_json = open(user_json_path, "r", encoding="utf-8").read().strip() - except FileNotFoundError: - user_json = "" - force = str(os.environ.get("FORCE", "true")).lower() in ("1","true","yes","on") - workdir = os.environ.get("WORKDIR","work") - - def fail(msg): - print(msg, file=sys.stderr) - sys.exit(1) - - if not re.match(r"^[A-Za-z0-9._-]{1,32}$", workdir or ""): - print(f"Invalid workdir {workdir!r}, using 'work'") - workdir = "work" - - if not user_json: - fail("user_json is empty or missing") - - try: - data = json.loads(user_json) - except Exception as e: - fail(f"user_json invalid: {e}. Must be JSON with double quotes.") - - if not isinstance(data, dict): - fail("user_json must be a JSON object") - - instance = data.get("instance") or {} - if not isinstance(instance, dict): - fail("instance must be an object") - - credentials = instance.get("credentials") or [] - admin = instance.get("admin_credentials") - apps = data.get("applications") or [] - - if not isinstance(credentials, list): - fail("instance.credentials must be a list") - - if not isinstance(apps, list): - print("applications is not a list; ignoring") - apps = [] - - course_label = data.get("course_label") or "" - if course_label: - print(f"course_label={course_label}") - - if not credentials: - print("WARNING: instance.credentials empty; no student users will be created") - - rx = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") - - def run(cmd, **kw): - subprocess.run(cmd, check=True, **kw) - - NL = chr(10) - - def ensure_user(u, p, is_admin=False): - if subprocess.run(["id", u], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: - run(["useradd", "-m", "-s", "/bin/bash", u]) - - # This MUST succeed, otherwise we fail the whole setup - run(["chpasswd"], input=f"{u}:{p}" + NL, text=True) - - if is_admin: - subprocess.run(["usermod", "-aG", "sudo", u], check=False) - - if force: - subprocess.run(["chage", "-d", "0", u], check=False) - - run(["chmod", "700", f"/home/{u}"]) - run(["mkdir", "-p", f"/home/{u}/{workdir}"]) - run(["chown", "-R", f"{u}:{u}", f"/home/{u}/{workdir}"]) - run(["chmod", "700", f"/home/{u}/{workdir}"]) - - profile = f"/home/{u}/.profile" - line = f'cd "$HOME/{workdir}"' + NL - try: - txt = open(profile, "r", encoding="utf-8", errors="ignore").read() - except FileNotFoundError: - txt = "" - if f'cd "$HOME/{workdir}"' not in txt: - with open(profile, "a", encoding="utf-8") as f: - f.write(line) - run(["chown", f"{u}:{u}", profile]) - - created = [] - - # Validate credentials upfront so we don't half-configure - for idx, item in enumerate(credentials): - if not isinstance(item, dict): - fail(f"instance.credentials[{idx}] must be an object") - u = item.get("username") - p = item.get("password") - if not isinstance(u, str) or not rx.match(u): - fail(f"Invalid username: {u!r}") - if not isinstance(p, str) or len(p) == 0: - fail(f"Empty password for {u}") - - for item in credentials: - u = item["username"] - p = item["password"] - ensure_user(u, p, is_admin=False) - created.append(u) - - admin_user = None - if admin is not None: - if not isinstance(admin, dict): - fail("instance.admin_credentials must be an object") - au = admin.get("username") - ap = admin.get("password") - if not isinstance(au, str) or not rx.match(au): - fail(f"Invalid admin username: {au!r}") - if not isinstance(ap, str) or len(ap) == 0: - fail("Empty password for admin user") - ensure_user(au, ap, is_admin=True) - admin_user = au - - # Restrict SSH users: ubuntu (admin key) + created users only - allow_users = sorted(set(created + ([admin_user] if admin_user else []))) - allow = "AllowUsers " + " ".join(["ubuntu"] + allow_users) + NL - with open("/etc/ssh/sshd_config.d/98-dozilab-allowusers.conf", "w", encoding="utf-8") as f: - f.write(allow) - - if allow_users: - print("created users:", ", ".join(allow_users)) - - if not apps: - print("applications: none") - else: - for idx, app in enumerate(apps): - if isinstance(app, dict): - name = app.get("name") or app.get("app") or f"index-{idx}" - version = app.get("version") or app.get("ver") or "" - if version: - print(f"applications[{idx}]: {name} {version}") - else: - print(f"applications[{idx}]: {name}") - else: - print(f"applications[{idx}]: {app!r}") - PY - - # Restart sshd; if this fails, trap will mark FAILED - systemctl restart ssh || service ssh restart - - echo "multiuser setup finished $(date -Is)" - - # READY marker (ONLY here = success) - msg="DOZILAB_READY stack=${STACK_LABEL} time=$(date -Is)" - echo "$msg" | tee -a "$LOG" | tee /dev/console > "$READY_FILE" - chmod 644 "$READY_FILE" - -runcmd: - - [ bash, -lc, "/usr/local/bin/dozilab-multiuser-setup.sh" ] - -final_message: "DoziLab multi-user VM: cloud-init finished" + vars: + dozilab_stack_label: "{{ stack_label | default('sql') }}" + dozilab_mark_dir: /var/lib/dozilab + dozilab_user_json_path: /etc/dozilab/user.json + dozilab_postgres_log: /var/log/dozilab-postgres-ansible.log -""" + tasks: + - name: Stack-Label validieren + assert: + that: + - dozilab_stack_label is match('^[a-z0-9][a-z0-9-]{0,30}$') + fail_msg: "stack_label muss ^[a-z0-9][a-z0-9-]{0,30}$ erfüllen." -POSTGRES_CLOUD_INIT = """ -#cloud-config -package_update: true -package_upgrade: false - -write_files: - - path: /etc/dozilab/user.json.payload - owner: root:root - permissions: "0600" - content: | - __USER_JSON__ - - - path: /usr/local/bin/dozilab-postgres-setup.sh - owner: root:root - permissions: "0755" - content: | - #!/usr/bin/env bash - set -euo pipefail - export DEBIAN_FRONTEND=noninteractive - - LOG="/var/log/dozilab-postgres.log" - MARK_DIR="/var/lib/dozilab" - READY_FILE="${MARK_DIR}/ready" - FAIL_FILE="${MARK_DIR}/failed" - - STACK_LABEL="__STACK_LABEL__" - USER_JSON_PAYLOAD="/etc/dozilab/user.json.payload" - USER_JSON_PATH="/etc/dozilab/user.json" - - mkdir -p "$MARK_DIR" - exec > >(tee -a "$LOG") 2>&1 - - on_fail() { - rc=$? - msg="DOZILAB_FAILED stack=${STACK_LABEL} rc=${rc} time=$(date -Is)" - echo "$msg" | tee -a "$LOG" | tee /dev/console > "$FAIL_FILE" - chmod 644 "$FAIL_FILE" || true - exit "$rc" - } - trap on_fail ERR - - echo "DoziLab setup started $(date -Is)" - echo "Stack label: ${STACK_LABEL}" - echo "Using user_json payload: ${USER_JSON_PAYLOAD}" - - if [[ ! -s "$USER_JSON_PAYLOAD" ]]; then - echo "ERROR: $USER_JSON_PAYLOAD missing/empty" >&2 - exit 2 - fi - - # ------------------------------------------------------------ - # Decode wrapper (base64 or raw) into JSON file, then validate payload. - # Backend must send final db_user + database_name etc. (no sanitizing) - # ------------------------------------------------------------ - python3 - <<'PY' - import ast - import base64 - import json - import sys - from pathlib import Path - - payload_path = Path("/etc/dozilab/user.json.payload") - out_path = Path("/etc/dozilab/user.json") - - raw = payload_path.read_text(encoding="utf-8").strip() - if not raw: - sys.exit("user_json payload missing/empty") - - def parse_obj(txt: str): - try: - return json.loads(txt), "json" - except Exception: - pass - try: - return ast.literal_eval(txt), "python-literal" - except Exception: - return None, None - - def decode_b64(s: str): - compact = "".join(s.split()) - pad = (-len(compact)) % 4 - compact += "=" * pad - try: - return base64.b64decode(compact, validate=True).decode("utf-8") - except Exception: - try: - return base64.b64decode(compact).decode("utf-8") - except Exception: - return None - - obj, kind = parse_obj(raw) - source = "raw" - if obj is None: - decoded = decode_b64(raw) - if decoded is None: - sys.exit("user_json payload is neither JSON/literal nor base64-encoded JSON/literal") - obj, kind = parse_obj(decoded.strip()) - if obj is None: - sys.exit("user_json base64 decoded, but not valid JSON or python literal") - source = "base64" - - out_path.write_text(json.dumps(obj, ensure_ascii=True), encoding="utf-8") - print(f"user_json normalized ({source}, {kind}) -> {out_path}") - PY - - if [[ ! -s "$USER_JSON_PATH" ]]; then - echo "ERROR: $USER_JSON_PATH missing/empty after decoding" >&2 - exit 2 - fi - - echo "Validating user_json schema (direct) ..." - python3 - <<'PY' - import json, re, sys - - p = "/etc/dozilab/user.json" - data = json.load(open(p, "r", encoding="utf-8")) - if not isinstance(data, dict): - sys.exit("user_json must be an object") - - apps = data.get("applications") - if not isinstance(apps, list): - sys.exit("user_json.applications must be a list") - - def find_app(name: str): - for a in apps: - if isinstance(a, dict) and str(a.get("name","")).lower() == name: - return a - return None - - pg = find_app("postgres") or find_app("postgresql") - if not pg: - sys.exit("Missing applications[name=postgres]") - - pg_creds = pg.get("credentials") - if not isinstance(pg_creds, list) or not pg_creds: - sys.exit("postgres.credentials must be a non-empty list") - - # strict identifiers to avoid surprises - ident = re.compile(r"^[a-z_][a-z0-9_]{0,62}$") - group_token = re.compile(r"^[A-Za-z0-9_]{1,32}$") - - used_db_users = set() - - for c in pg_creds: - if not isinstance(c, dict): - sys.exit("postgres.credentials entries must be objects") - - gid = c.get("group") - if gid is None: - sys.exit("postgres credential missing group") - gid_s = str(gid).strip() - if not gid_s or not group_token.match(gid_s): - sys.exit(f"postgres credential group must be [A-Za-z0-9_], 1..32 chars, got: {gid!r}") - - dbn = c.get("database_name") or c.get("db_name") - if not isinstance(dbn, str) or not ident.match(dbn): - sys.exit(f"postgres credential database_name invalid (must match {ident.pattern}): {dbn!r}") - - db_user = c.get("db_user") - if not isinstance(db_user, str) or not ident.match(db_user): - sys.exit(f"postgres credential db_user invalid (must match {ident.pattern}): {db_user!r}") - - if db_user in used_db_users: - sys.exit(f"duplicate postgres db_user not allowed: {db_user!r}") - used_db_users.add(db_user) - - pw = c.get("password") - if not isinstance(pw, str) or len(pw) < 6: - sys.exit(f"postgres credential password too short for {db_user!r} (min 6)") - - admin = pg.get("admin_credentials") or {} - if admin: - if not isinstance(admin, dict): - sys.exit("postgres.admin_credentials must be an object") - a_user = admin.get("db_user") - a_pw = admin.get("password") - if not isinstance(a_user, str) or not ident.match(a_user): - sys.exit(f"postgres admin db_user invalid (must match {ident.pattern}): {a_user!r}") - if a_user in used_db_users: - sys.exit(f"postgres admin db_user collides with group user: {a_user!r}") - if not isinstance(a_pw, str) or len(a_pw) < 6: - sys.exit("postgres admin password too short (min 6)") - - # pgadmin is optional; if present we validate only what it explicitly sends (no fallback) - pga = find_app("pgadmin") - if pga: - pga_admin = pga.get("admin_credentials") or {} - if not isinstance(pga_admin, dict) or not pga_admin.get("email") or not pga_admin.get("password"): - sys.exit("pgadmin.admin_credentials must contain email+password when pgadmin app is present") - - pga_creds = pga.get("credentials") - if not isinstance(pga_creds, list) or not pga_creds: - sys.exit("pgadmin.credentials must be a non-empty list when pgadmin app is present") - - seen_emails = set() - for c in pga_creds: - if not isinstance(c, dict): - sys.exit("pgadmin.credentials entries must be objects") - gid = c.get("group") - if gid is None: - sys.exit("pgadmin credential missing group") - gid_s = str(gid).strip() - if not gid_s or not group_token.match(gid_s): - sys.exit(f"pgadmin credential group must be [A-Za-z0-9_], got: {gid!r}") - - email = c.get("email") - pw = c.get("password") - if not isinstance(email, str) or "@" not in email: - sys.exit(f"pgadmin credential email invalid: {email!r}") - if not isinstance(pw, str) or len(pw) < 6: - sys.exit(f"pgadmin credential password too short for {email!r} (min 6)") - if email in seen_emails: - sys.exit(f"duplicate pgadmin email not allowed: {email!r}") - seen_emails.add(email) - - print("user_json validated OK (direct mode)") - PY - - # ------------------------------------------------------------ - # Install PostgreSQL - # - If user_json contains postgres_version -> install that major - # - else install distro default (postgresql meta) - # ------------------------------------------------------------ - PGVER="$(python3 - <<'PY' - import json - data = json.load(open("/etc/dozilab/user.json")) - pgver = data.get("postgres_version") - if pgver is None: - pgver = data.get("postgresVersion") - apps = data.get("applications") or [] - for a in apps: - if isinstance(a, dict) and str(a.get("name","")).lower() in ("postgres","postgresql"): - if a.get("postgres_version") is not None: - pgver = a.get("postgres_version") - if a.get("postgresVersion") is not None: - pgver = a.get("postgresVersion") - if pgver is None: - print("") - else: - print(int(pgver)) - PY - )" - - apt-get update -y - if [[ -n "${PGVER}" ]]; then - echo "Installing PostgreSQL ${PGVER} ..." - apt-get install -y "postgresql-${PGVER}" postgresql-client - else - echo "Installing distro default PostgreSQL ..." - apt-get install -y postgresql postgresql-client - fi - - # Detect installed major version - DETECTED_PGVER="$(pg_lsclusters --no-header 2>/dev/null | awk 'NR==1{print $1}')" - if [[ -z "${DETECTED_PGVER:-}" ]]; then - echo "ERROR: Could not detect Postgres version (pg_lsclusters empty)" >&2 - exit 3 - fi - PGVER="${DETECTED_PGVER}" - echo "Detected PostgreSQL major version: ${PGVER}" - - echo "Configuring Postgres to listen on localhost only ..." - CONF="/etc/postgresql/${PGVER}/main/postgresql.conf" - HBA="/etc/postgresql/${PGVER}/main/pg_hba.conf" - - sed -i "s/^#\\?listen_addresses\\s*=.*/listen_addresses = '127.0.0.1'/" "$CONF" - - grep -qE "^[[:space:]]*host[[:space:]]+all[[:space:]]+all[[:space:]]+127\\.0\\.0\\.1/32" "$HBA" \ - || echo "host all all 127.0.0.1/32 scram-sha-256" >> "$HBA" - grep -qE "^[[:space:]]*host[[:space:]]+all[[:space:]]+all[[:space:]]+::1/128" "$HBA" \ - || echo "host all all ::1/128 scram-sha-256" >> "$HBA" - - systemctl enable --now postgresql - systemctl restart postgresql - - for i in {1..60}; do - if sudo -u postgres psql -d postgres -Atc "SELECT 1" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - # ------------------------------------------------------------ - # Provision DB roles & databases from user_json DIRECTLY - # (robust quoting; no psql :var substitution) - # ------------------------------------------------------------ - echo "Provisioning roles & databases (direct from user_json) ..." - python3 - <<'PY' - import json, subprocess, sys, re - - spec = json.load(open("/etc/dozilab/user.json")) - apps = spec.get("applications") or [] - - def find_app(name: str): - for a in apps: - if isinstance(a, dict) and str(a.get("name","")).lower() == name: - return a - return None - - pg = find_app("postgres") or find_app("postgresql") - pg_creds = pg.get("credentials") or [] - pg_admin = pg.get("admin_credentials") or {} - - ident = re.compile(r"^[a-z_][a-z0-9_]{0,62}$") - group_token = re.compile(r"^[A-Za-z0-9_]{1,32}$") - - def run(sql: str, db: str = "postgres", capture: bool = True) -> str: - cmd = ["sudo", "-u", "postgres", "psql", "-d", db, "-v", "ON_ERROR_STOP=1", "-Atc", sql] - if capture: - return subprocess.check_output(cmd, text=True, cwd="/").strip() - subprocess.check_call(cmd, cwd="/") - return "" - - def q_ident(s: str) -> str: - return '"' + s.replace('"', '""') + '"' - - def q_lit(s: str) -> str: - return "'" + s.replace("'", "''") + "'" - - def role_exists(name: str) -> bool: - return run(f"SELECT 1 FROM pg_roles WHERE rolname={q_lit(name)};") == "1" - - def db_exists(name: str) -> bool: - return run(f"SELECT 1 FROM pg_database WHERE datname={q_lit(name)};") == "1" - - def ensure_group_role(role: str) -> None: - if not role_exists(role): - run(f"CREATE ROLE {q_ident(role)} NOLOGIN;", capture=False) - - def ensure_login_role(role: str, password: str) -> None: - if not role_exists(role): - run(f"CREATE ROLE {q_ident(role)} LOGIN PASSWORD {q_lit(password)};", capture=False) - else: - run(f"ALTER ROLE {q_ident(role)} LOGIN PASSWORD {q_lit(password)};", capture=False) - - def ensure_db(dbname: str, owner: str) -> None: - if not db_exists(dbname): - run(f"CREATE DATABASE {q_ident(dbname)} OWNER {q_ident(owner)};", capture=False) - run(f"ALTER DATABASE {q_ident(dbname)} OWNER TO {q_ident(owner)};", capture=False) - - def lock_down_db(dbname: str, grp_role: str) -> None: - run(f"REVOKE ALL ON DATABASE {q_ident(dbname)} FROM PUBLIC;", capture=False) - run(f"GRANT CONNECT, TEMPORARY ON DATABASE {q_ident(dbname)} TO {q_ident(grp_role)};", capture=False) - run("REVOKE CREATE ON SCHEMA public FROM PUBLIC;", db=dbname, capture=False) - run("REVOKE USAGE ON SCHEMA public FROM PUBLIC;", db=dbname, capture=False) - run(f"GRANT USAGE, CREATE ON SCHEMA public TO {q_ident(grp_role)};", db=dbname, capture=False) - - def grant_group_defaults(dbname: str, creator_role: str, grp_role: str) -> None: - run( - f"ALTER DEFAULT PRIVILEGES FOR ROLE {q_ident(creator_role)} IN SCHEMA public " - f"GRANT ALL PRIVILEGES ON TABLES TO {q_ident(grp_role)};", - db=dbname, - capture=False, - ) - run( - f"ALTER DEFAULT PRIVILEGES FOR ROLE {q_ident(creator_role)} IN SCHEMA public " - f"GRANT ALL PRIVILEGES ON SEQUENCES TO {q_ident(grp_role)};", - db=dbname, - capture=False, - ) - - def grant_role(grp_role: str, user: str) -> None: - run(f"GRANT {q_ident(grp_role)} TO {q_ident(user)};", capture=False) - - # Build group map (DIRECT): group -> {dbname, db_user, password} - groups = {} - for c in pg_creds: - gid = str(c.get("group")).strip() - if not gid or not group_token.match(gid): - sys.exit(f"Invalid group token: {gid!r}") - - dbname = c.get("database_name") or c.get("db_name") - db_user = c.get("db_user") - pw = c.get("password") - - if not isinstance(dbname, str) or not ident.match(dbname): - sys.exit(f"Invalid database_name for group {gid}: {dbname!r}") - if not isinstance(db_user, str) or not ident.match(db_user): - sys.exit(f"Invalid db_user for group {gid}: {db_user!r}") - if not isinstance(pw, str) or len(pw) < 6: - sys.exit(f"Invalid password for db_user {db_user!r} (min 6)") - - if gid in groups: - sys.exit(f"Duplicate group in postgres.credentials: {gid!r}") - groups[gid] = {"dbname": dbname, "db_user": db_user, "password": pw} - - # 1) group roles + dbs - for gid, info in groups.items(): - grp_role = f"grp_{gid}" - ensure_group_role(grp_role) - ensure_db(info["dbname"], grp_role) - lock_down_db(info["dbname"], grp_role) - - # 2) group login users - for gid, info in groups.items(): - grp_role = f"grp_{gid}" - db_user = info["db_user"] - ensure_login_role(db_user, info["password"]) - grant_role(grp_role, db_user) - grant_group_defaults(info["dbname"], db_user, grp_role) - - # 3) optional admin (teacher) - if isinstance(pg_admin, dict) and pg_admin: - a_user = pg_admin.get("db_user") - a_pw = pg_admin.get("password") - if a_user and a_pw: - if not isinstance(a_user, str) or not ident.match(a_user): - sys.exit(f"Invalid postgres admin db_user: {a_user!r}") - if not isinstance(a_pw, str) or len(a_pw) < 6: - sys.exit("Invalid postgres admin password (min 6)") - ensure_login_role(a_user, a_pw) - for gid, info in groups.items(): - grp_role = f"grp_{gid}" - grant_role(grp_role, a_user) - grant_group_defaults(info["dbname"], a_user, grp_role) - - print(f"Provisioned groups: {len(groups)}") - PY - - # ------------------------------------------------------------ - # pgAdmin (optional; only if applications includes pgadmin) - # NO fallback, NO deriving from postgres. - # ------------------------------------------------------------ - PGADMIN_PRESENT="$(python3 - <<'PY' - import json - spec = json.load(open("/etc/dozilab/user.json")) - apps = spec.get("applications") or [] - def find(name): - for a in apps: - if isinstance(a, dict) and str(a.get("name","")).lower() == name: - return True - return False - print("true" if find("pgadmin") else "false") - PY - )" - - if [[ "$PGADMIN_PRESENT" == "true" ]]; then - echo "Installing pgAdmin4 ..." - apt-get install -y curl ca-certificates gnupg apache2 libapache2-mod-wsgi-py3 - install -d -m 0755 /etc/apt/keyrings - curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | gpg --dearmor -o /etc/apt/keyrings/pgadmin.gpg - echo "deb [signed-by=/etc/apt/keyrings/pgadmin.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/jammy pgadmin4 main" > /etc/apt/sources.list.d/pgadmin4.list - apt-get update -y - apt-get install -y pgadmin4-web + - name: DoziLab Verzeichnisse erstellen + file: + path: "{{ item.path }}" + state: directory + owner: root + group: root + mode: "{{ item.mode }}" + loop: + - { path: "/etc/dozilab", mode: "0700" } + - { path: "{{ dozilab_mark_dir }}", mode: "0755" } + + - name: Backend-Credentials validieren + assert: + that: + - deployment_groups is defined + - deployment_groups | length > 0 + - teacher is defined + - teacher.postgres is defined + - teacher.postgres.db_user is defined + - teacher.postgres.password is defined + - teacher.pgadmin is defined + - teacher.pgadmin.email is defined + - teacher.pgadmin.password is defined + fail_msg: "Backend-Credentials fehlen. Erwartet werden deployment_groups[].postgres/pgadmin und teacher.postgres/pgadmin." + no_log: true + + - name: Gruppen-Credentials validieren + assert: + that: + - item.username is defined + - item.username is match('^[a-z_][a-z0-9_]{0,62}$') + - item.postgres is defined + - item.postgres.database_name is defined + - item.postgres.database_name is match('^[a-z_][a-z0-9_]{0,62}$') + - item.postgres.db_user is defined + - item.postgres.db_user is match('^[a-z_][a-z0-9_]{0,62}$') + - item.postgres.password is defined + - item.postgres.password | length >= 6 + - item.pgadmin is defined + - item.pgadmin.email is defined + - "'@' in item.pgadmin.email" + - item.pgadmin.password is defined + - item.pgadmin.password | length >= 6 + fail_msg: "Ungültige Gruppen-Credentials für PostgreSQL/pgAdmin." + loop: "{{ deployment_groups }}" + no_log: true + + - name: user_json aus Backend-Credentials erzeugen + copy: + dest: "{{ dozilab_user_json_path }}" + owner: root + group: root + mode: "0600" + content: | + { + "course_label": {{ dozilab_stack_label | to_json }}, + "instance": {}, + "applications": [ + { + "name": "postgres", + "credentials": [ + {% for g in deployment_groups %} + { + "group": {{ g.username | to_json }}, + "database_name": {{ g.postgres.database_name | to_json }}, + "db_user": {{ g.postgres.db_user | to_json }}, + "password": {{ g.postgres.password | to_json }} + }{% if not loop.last %},{% endif %} + {% endfor %} + ], + "admin_credentials": { + "db_user": {{ teacher.postgres.db_user | to_json }}, + "password": {{ teacher.postgres.password | to_json }} + } + }, + { + "name": "pgadmin", + "credentials": [ + {% for g in deployment_groups %} + { + "group": {{ g.username | to_json }}, + "email": {{ g.pgadmin.email | to_json }}, + "password": {{ g.pgadmin.password | to_json }} + }{% if not loop.last %},{% endif %} + {% endfor %} + ], + "admin_credentials": { + "email": {{ teacher.pgadmin.email | to_json }}, + "password": {{ teacher.pgadmin.password | to_json }} + } + } + ] + } + no_log: true + + - name: Erzeugtes user_json validieren + shell: | + set -euo pipefail - echo "Configuring pgAdmin admin account (direct) ..." python3 - <<'PY' - import json, shlex, sys - - spec = json.load(open("/etc/dozilab/user.json")) - apps = spec.get("applications") or [] - - def get_app(name): - for a in apps: - if isinstance(a, dict) and str(a.get("name","")).lower() == name: - return a - return None - - pga = get_app("pgadmin") or {} - admin = pga.get("admin_credentials") or {} - email = admin.get("email") - pw = admin.get("password") - - if not email or not pw: - sys.exit("pgadmin.admin_credentials missing email/password") - if "@" not in email: - sys.exit(f"pgAdmin admin email invalid: {email!r}") - if len(pw) < 6: - sys.exit("pgAdmin admin password too short (min 6)") - - with open("/etc/default/pgadmin4", "w") as f: - print(f"PGADMIN_SETUP_EMAIL={shlex.quote(email)}", file=f) - print(f"PGADMIN_SETUP_PASSWORD={shlex.quote(pw)}", file=f) - print("pgAdmin admin email:", email) - PY + import json + import re + import sys + from pathlib import Path - chmod 600 /etc/default/pgadmin4 - rm -f /var/lib/pgadmin/pgadmin4.db - rm -rf /var/lib/pgadmin/sessions /var/lib/pgadmin/storage - install -d -m 0750 -o www-data -g www-data /var/lib/pgadmin + out_path = Path("/etc/dozilab/user.json") - set -a - source /etc/default/pgadmin4 - set +a - PGADMIN_SETUP_EMAIL="$PGADMIN_SETUP_EMAIL" \ - PGADMIN_SETUP_PASSWORD="$PGADMIN_SETUP_PASSWORD" \ - /usr/pgadmin4/bin/setup-web.sh --yes + if not out_path.exists() or out_path.stat().st_size == 0: + sys.exit("generated user_json missing/empty") - a2enconf pgadmin4 || true - systemctl reload apache2 || true + obj = json.loads(out_path.read_text(encoding="utf-8")) - echo "Creating pgAdmin users (direct from pgadmin.credentials) ..." - python3 - <<'PY' - import json, subprocess, sys - - spec = json.load(open("/etc/dozilab/user.json")) - apps = spec.get("applications") or [] - - def get_app(name): - for a in apps: - if isinstance(a, dict) and str(a.get("name","")).lower() == name: - return a - return None - - pga = get_app("pgadmin") or {} - creds = pga.get("credentials") or [] - if not isinstance(creds, list): - sys.exit("pgadmin.credentials must be a list") - - accounts = [] - seen = set() - for c in creds: - if not isinstance(c, dict): - continue - email = c.get("email") - pw = c.get("password") - if not email or not pw: - continue - if email in seen: - continue - seen.add(email) - accounts.append((email, pw)) - - created = 0 - for email, pw in accounts: - cmd = [ - "sudo", - "-u", - "www-data", - "/usr/pgadmin4/venv/bin/python", - "/usr/pgadmin4/web/setup.py", - "add-user", - email, - pw, - "--role", - "User", - "--active", - ] - res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - out = (res.stdout or "").strip() - if res.returncode == 0: - created += 1 - print(f"created pgAdmin user: {email}") - elif "already exists" in out.lower(): - print(f"pgAdmin user already exists: {email}") - else: - print(f"WARNING: failed to create pgAdmin user {email} rc={res.returncode} {out}") - - print(f"pgAdmin accounts processed: {len(accounts)}, created: {created}") - PY - fi - - msg="DOZILAB_READY stack=${STACK_LABEL} time=$(date -Is)" - echo "$msg" | tee /dev/console > "$READY_FILE" - chmod 644 "$READY_FILE" - - echo "DoziLab setup finished successfully" - -runcmd: - - [ bash, -lc, "/usr/local/bin/dozilab-postgres-setup.sh" ] - -final_message: "DoziLab Postgres + pgAdmin VM: cloud-init finished" + if not isinstance(obj, dict): + sys.exit("user_json must be an object") + apps = obj.get("applications") + if not isinstance(apps, list): + sys.exit("user_json.applications must be a list") -""" + def find_app(name: str): + for a in apps: + if isinstance(a, dict) and str(a.get("name", "")).lower() == name: + return a + return None + pg = find_app("postgres") or find_app("postgresql") + if not pg: + sys.exit("Missing applications[name=postgres]") -def create_lecturer_user(db: Session) -> User: - """Create or get mock development user.""" - existing_user = db.query(User).filter(User.external_id == "b2767751-c2d0-4d09-9400-ad520edbfe3c").first() - if existing_user: - return existing_user - - user = User( - external_id="b2767751-c2d0-4d09-9400-ad520edbfe3c" - ) - db.add(user) - db.commit() - db.refresh(user) - logger.info(f"Created mock user with external_id: {user.external_id}") - return user + pg_creds = pg.get("credentials") + if not isinstance(pg_creds, list) or not pg_creds: + sys.exit("postgres.credentials must be a non-empty list") + ident = re.compile(r"^[a-z_][a-z0-9_]{0,62}$") + group_token = re.compile(r"^[A-Za-z0-9_]{1,32}$") -ANSIBLE_MULTIUSER_APP_YAML = """ -app: - name: ansible-multiuser - label: Ansible Multi-User Ubuntu - version: 2.0.0 - description: > - Ubuntu VM mit mehreren Benutzerkonten, verwaltet durch Ansible. - Pro Gruppe wird ein Linux-Account mit eigenem Arbeitsverzeichnis erstellt. - owner_team: dozilab-app-team + used_db_users = set() + used_groups = set() - allow_user_files: true + for c in pg_creds: + if not isinstance(c, dict): + sys.exit("postgres.credentials entries must be objects") -parameters: + gid = c.get("group") + if gid is None: + sys.exit("postgres credential missing group") - - name: stack_label - label: Stack-Label - step: template - type: string - default: ansible - required: true - description: "Kurzes Label für diesen Stack (z.B. kurs-ws2026)." + gid_s = str(gid).strip() + if not gid_s or not group_token.match(gid_s): + sys.exit(f"postgres credential group invalid: {gid!r}") - - name: image - label: Betriebssystem-Image - step: konfiguration - type: string - default: "Ubuntu 22.04 2025-01" - required: true - enum: - - "Ubuntu 22.04 2025-01" - - "Ubuntu 24.04 2025-01" - - "Ubuntu 24.04 2026-01" + if gid_s in used_groups: + sys.exit(f"duplicate postgres group not allowed: {gid_s!r}") + used_groups.add(gid_s) - - name: flavor - label: VM-Größe (Flavor) - step: konfiguration - type: string - default: "gp1.small" - required: true - enum: - - "gp1.small" - - "gp1.medium" + dbn = c.get("database_name") or c.get("db_name") + if not isinstance(dbn, str) or not ident.match(dbn): + sys.exit(f"postgres credential database_name invalid: {dbn!r}") - - name: ssh_cidr - label: SSH-Zugriff (CIDR) - step: netzwerk - type: string - default: "141.72.0.0/16" - required: true - description: "Nur dieses Netz darf per SSH zugreifen. Standard: DHBW/VPN." + db_user = c.get("db_user") + if not isinstance(db_user, str) or not ident.match(db_user): + sys.exit(f"postgres credential db_user invalid: {db_user!r}") - - name: force_password_change - label: Passwort-Änderung beim ersten Login erzwingen - step: zugriff - type: boolean - default: true - required: true + if db_user in used_db_users: + sys.exit(f"duplicate postgres db_user not allowed: {db_user!r}") + used_db_users.add(db_user) -credentials: + pw = c.get("password") + if not isinstance(pw, str) or len(pw) < 6: + sys.exit(f"postgres credential password too short for {db_user!r} (min 6)") - per_student: - - linux: - username: "{{ username }}" - password: generate + admin = pg.get("admin_credentials") or {} + if admin: + if not isinstance(admin, dict): + sys.exit("postgres.admin_credentials must be an object") - teacher: + a_user = admin.get("db_user") + a_pw = admin.get("password") -user_files: - - name: aufgabe_pdf - label: "Aufgabenstellung (PDF)" - description: "Wird auf alle VMs kopiert — gleiche Aufgabe für alle Gruppen." - required: false - accept: "*.pdf" - destination: /opt/dozilab/user-files/aufgabe.pdf - mode: all_stacks + if not isinstance(a_user, str) or not ident.match(a_user): + sys.exit(f"postgres admin db_user invalid: {a_user!r}") - - name: material_gruppe - label: "Gruppenmaterial" - description: "Pro Gruppe eigene Dateien — z.B. unterschiedliche Datensätze oder Aufgaben." - required: false - accept: "*" - destination: /opt/dozilab/user-files/{{ group_name }}/material - mode: per_group + if a_user in used_db_users: + sys.exit(f"postgres admin db_user collides with group user: {a_user!r}") -outputs: - - name: floating_ip - label: Floating IP - from_heat_output: floating_ip + if not isinstance(a_pw, str) or len(a_pw) < 6: + sys.exit("postgres admin password too short (min 6)") - - name: server_id - label: Server ID - from_heat_output: server_id -""" + pga = find_app("pgadmin") + if pga: + pga_admin = pga.get("admin_credentials") or {} + if not isinstance(pga_admin, dict) or not pga_admin.get("email") or not pga_admin.get("password"): + sys.exit("pgadmin.admin_credentials must contain email+password when pgadmin app is present") -ANSIBLE_MULTIUSER_HEAT_TEMPLATE = """ -heat_template_version: 2018-08-31 + if "@" not in str(pga_admin.get("email")): + sys.exit("pgadmin admin email invalid") -description: > - DoziLab Ansible Multi-User VM. - Nur Infrastruktur — keine user_data, keine cloud-init. - Konfiguration übernimmt Ansible vom Backend aus per SSH. + if len(str(pga_admin.get("password"))) < 6: + sys.exit("pgadmin admin password too short (min 6)") -parameters: - image: - type: string - default: "Ubuntu 22.04 2025-01" - constraints: - - allowed_values: - - "Ubuntu 22.04 2025-01" - - "Ubuntu 24.04 2025-01" - - "Ubuntu 24.04 2026-01" + pga_creds = pga.get("credentials") + if not isinstance(pga_creds, list) or not pga_creds: + sys.exit("pgadmin.credentials must be a non-empty list when pgadmin app is present") - flavor: - type: string - default: "gp1.small" - constraints: - - allowed_values: ["gp1.small", "gp1.medium"] + seen_emails = set() + for c in pga_creds: + if not isinstance(c, dict): + sys.exit("pgadmin.credentials entries must be objects") - ssh_cidr: - type: string - default: "141.72.0.0/16" - constraints: - - allowed_pattern: '^(\\d{1,3}\\.){3}\\d{1,3}/\\d{1,2}$' + gid = c.get("group") + if gid is None: + sys.exit("pgadmin credential missing group") + + gid_s = str(gid).strip() + if not gid_s or not group_token.match(gid_s): + sys.exit(f"pgadmin credential group invalid: {gid!r}") + + email = c.get("email") + pw = c.get("password") + + if not isinstance(email, str) or "@" not in email: + sys.exit(f"pgadmin credential email invalid: {email!r}") + + if not isinstance(pw, str) or len(pw) < 6: + sys.exit(f"pgadmin credential password too short for {email!r} (min 6)") + + if email in seen_emails: + sys.exit(f"duplicate pgadmin email not allowed: {email!r}") + + seen_emails.add(email) + + print("generated user_json validated OK") + PY + args: + executable: /bin/bash + no_log: true + + - name: PostgreSQL Version aus user_json lesen + shell: | + set -euo pipefail + python3 - <<'PY' + import json + + data = json.load(open("/etc/dozilab/user.json", "r", encoding="utf-8")) + pgver = data.get("postgres_version") + if pgver is None: + pgver = data.get("postgresVersion") + + apps = data.get("applications") or [] + for a in apps: + if isinstance(a, dict) and str(a.get("name", "")).lower() in ("postgres", "postgresql"): + if a.get("postgres_version") is not None: + pgver = a.get("postgres_version") + if a.get("postgresVersion") is not None: + pgver = a.get("postgresVersion") + + if pgver is None or str(pgver).strip() == "": + print("") + else: + print(int(pgver)) + PY + args: + executable: /bin/bash + register: postgres_version_result + changed_when: false + + - name: APT Cache aktualisieren + apt: + update_cache: true + cache_valid_time: 3600 + + - name: PostgreSQL distro default installieren + apt: + name: + - postgresql + - postgresql-client + - python3 + state: present + when: postgres_version_result.stdout | trim == "" + + - name: PostgreSQL gewünschte Major-Version installieren + apt: + name: + - "postgresql-{{ postgres_version_result.stdout | trim }}" + - postgresql-client + - python3 + state: present + when: postgres_version_result.stdout | trim != "" + + - name: Installierte PostgreSQL Major-Version erkennen + shell: | + set -euo pipefail + pg_lsclusters --no-header | awk 'NR==1{print $1}' + args: + executable: /bin/bash + register: detected_pgver + changed_when: false + + - name: PostgreSQL Version validieren + assert: + that: + - detected_pgver.stdout | trim | length > 0 + fail_msg: "Konnte installierte PostgreSQL-Version nicht erkennen." + + - name: PostgreSQL nur auf localhost binden + lineinfile: + path: "/etc/postgresql/{{ detected_pgver.stdout | trim }}/main/postgresql.conf" + regexp: "^#?listen_addresses\\s*=" + line: "listen_addresses = '127.0.0.1'" + backup: true + + - name: pg_hba IPv4 localhost scram sicherstellen + lineinfile: + path: "/etc/postgresql/{{ detected_pgver.stdout | trim }}/main/pg_hba.conf" + line: "host all all 127.0.0.1/32 scram-sha-256" + state: present + + - name: pg_hba IPv6 localhost scram sicherstellen + lineinfile: + path: "/etc/postgresql/{{ detected_pgver.stdout | trim }}/main/pg_hba.conf" + line: "host all all ::1/128 scram-sha-256" + state: present + + - name: PostgreSQL aktivieren und starten + service: + name: postgresql + enabled: true + state: restarted + + - name: Auf PostgreSQL warten + shell: | + set -euo pipefail + for i in $(seq 1 60); do + if sudo -u postgres psql -d postgres -Atc "SELECT 1" >/dev/null 2>&1; then + exit 0 + fi + sleep 1 + done + exit 1 + args: + executable: /bin/bash + changed_when: false + + - name: PostgreSQL Rollen, Datenbanken und Rechte provisionieren + shell: | + set -euo pipefail + + python3 - <<'PY' + import json + import re + import subprocess + import sys + + spec = json.load(open("/etc/dozilab/user.json", "r", encoding="utf-8")) + apps = spec.get("applications") or [] + + def find_app(name: str): + for a in apps: + if isinstance(a, dict) and str(a.get("name", "")).lower() == name: + return a + return None + + pg = find_app("postgres") or find_app("postgresql") + pg_creds = pg.get("credentials") or [] + pg_admin = pg.get("admin_credentials") or {} + + ident = re.compile(r"^[a-z_][a-z0-9_]{0,62}$") + group_token = re.compile(r"^[A-Za-z0-9_]{1,32}$") - stack_label: - type: string - default: "ansible" - constraints: - - allowed_pattern: '^[a-z0-9][a-z0-9-]{0,30}$' + def run(sql: str, db: str = "postgres", capture: bool = True) -> str: + cmd = ["sudo", "-u", "postgres", "psql", "-d", db, "-v", "ON_ERROR_STOP=1", "-Atc", sql] + if capture: + return subprocess.check_output(cmd, text=True, cwd="/").strip() + subprocess.check_call(cmd, cwd="/") + return "" - network: - type: string - default: "NAT" - constraints: - - allowed_values: ["NAT"] + def q_ident(s: str) -> str: + return '"' + s.replace('"', '""') + '"' - external_network: - type: string - default: "DHBW" - constraints: - - allowed_values: ["DHBW"] + def q_lit(s: str) -> str: + return "'" + s.replace("'", "''") + "'" - key_name: - type: string + def role_exists(name: str) -> bool: + return run(f"SELECT 1 FROM pg_roles WHERE rolname={q_lit(name)};") == "1" -resources: - secgroup: - type: OS::Neutron::SecurityGroup - properties: - description: SSH + ICMP aus erlaubtem CIDR - rules: - - direction: ingress - protocol: tcp - port_range_min: 22 - port_range_max: 22 - remote_ip_prefix: { get_param: ssh_cidr } - - direction: ingress - protocol: icmp - remote_ip_prefix: { get_param: ssh_cidr } + def db_exists(name: str) -> bool: + return run(f"SELECT 1 FROM pg_database WHERE datname={q_lit(name)};") == "1" - port: - type: OS::Neutron::Port - properties: - network: { get_param: network } - security_groups: - - { get_resource: secgroup } + def ensure_group_role(role: str) -> None: + if not role_exists(role): + run(f"CREATE ROLE {q_ident(role)} NOLOGIN;", capture=False) - server: - type: OS::Nova::Server - properties: - name: - str_replace: - template: "dozilab-LABEL" - params: - LABEL: { get_param: stack_label } - image: { get_param: image } - flavor: { get_param: flavor } - key_name: { get_param: key_name } - networks: - - port: { get_resource: port } + def ensure_login_role(role: str, password: str) -> None: + if not role_exists(role): + run(f"CREATE ROLE {q_ident(role)} LOGIN PASSWORD {q_lit(password)};", capture=False) + else: + run(f"ALTER ROLE {q_ident(role)} LOGIN PASSWORD {q_lit(password)};", capture=False) + + def ensure_db(dbname: str, owner: str) -> None: + if not db_exists(dbname): + run(f"CREATE DATABASE {q_ident(dbname)} OWNER {q_ident(owner)};", capture=False) + run(f"ALTER DATABASE {q_ident(dbname)} OWNER TO {q_ident(owner)};", capture=False) + + def lock_down_db(dbname: str, grp_role: str) -> None: + run(f"REVOKE ALL ON DATABASE {q_ident(dbname)} FROM PUBLIC;", capture=False) + run(f"GRANT CONNECT, TEMPORARY ON DATABASE {q_ident(dbname)} TO {q_ident(grp_role)};", capture=False) + run("REVOKE CREATE ON SCHEMA public FROM PUBLIC;", db=dbname, capture=False) + run("REVOKE USAGE ON SCHEMA public FROM PUBLIC;", db=dbname, capture=False) + run(f"GRANT USAGE, CREATE ON SCHEMA public TO {q_ident(grp_role)};", db=dbname, capture=False) + + def grant_group_defaults(dbname: str, creator_role: str, grp_role: str) -> None: + run( + f"ALTER DEFAULT PRIVILEGES FOR ROLE {q_ident(creator_role)} IN SCHEMA public " + f"GRANT ALL PRIVILEGES ON TABLES TO {q_ident(grp_role)};", + db=dbname, + capture=False, + ) + run( + f"ALTER DEFAULT PRIVILEGES FOR ROLE {q_ident(creator_role)} IN SCHEMA public " + f"GRANT ALL PRIVILEGES ON SEQUENCES TO {q_ident(grp_role)};", + db=dbname, + capture=False, + ) - fip: - type: OS::Neutron::FloatingIP - properties: - floating_network: { get_param: external_network } + def grant_role(grp_role: str, user: str) -> None: + run(f"GRANT {q_ident(grp_role)} TO {q_ident(user)};", capture=False) + + groups = {} + for c in pg_creds: + gid = str(c.get("group")).strip() + if not gid or not group_token.match(gid): + sys.exit(f"Invalid group token: {gid!r}") + + dbname = c.get("database_name") or c.get("db_name") + db_user = c.get("db_user") + pw = c.get("password") + + if not isinstance(dbname, str) or not ident.match(dbname): + sys.exit(f"Invalid database_name for group {gid}: {dbname!r}") + if not isinstance(db_user, str) or not ident.match(db_user): + sys.exit(f"Invalid db_user for group {gid}: {db_user!r}") + if not isinstance(pw, str) or len(pw) < 6: + sys.exit(f"Invalid password for db_user {db_user!r} (min 6)") + if gid in groups: + sys.exit(f"Duplicate group in postgres.credentials: {gid!r}") + + groups[gid] = { + "dbname": dbname, + "db_user": db_user, + "password": pw, + } + + for gid, info in groups.items(): + grp_role = f"grp_{gid}" + ensure_group_role(grp_role) + ensure_db(info["dbname"], grp_role) + lock_down_db(info["dbname"], grp_role) + + for gid, info in groups.items(): + grp_role = f"grp_{gid}" + db_user = info["db_user"] + ensure_login_role(db_user, info["password"]) + grant_role(grp_role, db_user) + grant_group_defaults(info["dbname"], db_user, grp_role) + + if isinstance(pg_admin, dict) and pg_admin: + a_user = pg_admin.get("db_user") + a_pw = pg_admin.get("password") + if a_user and a_pw: + if not isinstance(a_user, str) or not ident.match(a_user): + sys.exit(f"Invalid postgres admin db_user: {a_user!r}") + if not isinstance(a_pw, str) or len(a_pw) < 6: + sys.exit("Invalid postgres admin password (min 6)") + + ensure_login_role(a_user, a_pw) + for gid, info in groups.items(): + grp_role = f"grp_{gid}" + grant_role(grp_role, a_user) + grant_group_defaults(info["dbname"], a_user, grp_role) + + print(f"Provisioned postgres groups: {len(groups)}") + PY + args: + executable: /bin/bash + no_log: true - fip_assoc: - type: OS::Neutron::FloatingIPAssociation - properties: - floatingip_id: { get_resource: fip } - port_id: { get_resource: port } + - name: Prüfen ob pgAdmin in user_json vorhanden ist + shell: | + set -euo pipefail + python3 - <<'PY' + import json -outputs: - floating_ip: - value: { get_attr: [fip, floating_ip_address] } + spec = json.load(open("/etc/dozilab/user.json", "r", encoding="utf-8")) + apps = spec.get("applications") or [] - server_id: - value: { get_resource: server } -""" + present = any( + isinstance(a, dict) and str(a.get("name", "")).lower() == "pgadmin" + for a in apps + ) -ANSIBLE_MULTIUSER_PLAYBOOK = """ ---- -- name: DoziLab Multi-User Setup - hosts: all - remote_user: ubuntu - become: true + print("true" if present else "false") + PY + args: + executable: /bin/bash + register: pgadmin_present_result + changed_when: false - tasks: + - name: pgAdmin Basis-Pakete installieren + apt: + name: + - curl + - ca-certificates + - gnupg + - apache2 + - libapache2-mod-wsgi-py3 + - lsb-release + state: present + when: pgadmin_present_result.stdout | trim == "true" + + - name: Ubuntu Codename ermitteln + command: lsb_release -cs + register: ubuntu_codename + changed_when: false + when: pgadmin_present_result.stdout | trim == "true" - - name: /opt/dozilab nur für root zugänglich machen - file: - path: /opt/dozilab - state: directory - owner: root - group: root - mode: "0700" + - name: pgAdmin APT Key und Repository konfigurieren + shell: | + set -euo pipefail - - name: Student Accounts erstellen - user: - name: "{{ item.username }}" - shell: /bin/bash - create_home: true - state: present - loop: "{{ students }}" - no_log: true + install -d -m 0755 /etc/apt/keyrings - - name: Passwörter setzen - user: - name: "{{ item.username }}" - password: "{{ item.linux.password | password_hash('sha512') }}" - update_password: always - loop: "{{ students }}" - no_log: true + if [ ! -f /etc/apt/keyrings/pgadmin.gpg ]; then + curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub \ + | gpg --dearmor -o /etc/apt/keyrings/pgadmin.gpg + fi - - name: Passwort-Änderung beim ersten Login erzwingen - command: chage -d 0 {{ item.username }} - loop: "{{ students }}" - no_log: true - when: force_password_change | bool + echo "deb [signed-by=/etc/apt/keyrings/pgadmin.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/{{ ubuntu_codename.stdout | trim }} pgadmin4 main" \ + > /etc/apt/sources.list.d/pgadmin4.list + args: + executable: /bin/bash + when: pgadmin_present_result.stdout | trim == "true" - - name: Arbeitsverzeichnis erstellen - file: - path: "/home/{{ item.username }}/work" - state: directory - owner: "{{ item.username }}" - group: "{{ item.username }}" - mode: "0700" - loop: "{{ students }}" - no_log: true + - name: APT Cache nach pgAdmin Repository aktualisieren + apt: + update_cache: true + when: pgadmin_present_result.stdout | trim == "true" - - name: .bashrc für jeden Student setzen - copy: - src: /opt/dozilab/files/bashrc - dest: "/home/{{ item.username }}/.bashrc" - owner: "{{ item.username }}" - group: "{{ item.username }}" - mode: "0644" - remote_src: true - loop: "{{ students }}" - no_log: true + - name: pgAdmin Web installieren + apt: + name: pgadmin4-web + state: present + when: pgadmin_present_result.stdout | trim == "true" - - name: MOTD setzen + - name: pgAdmin Admin Credentials schreiben shell: | - sed 's/__STACK_LABEL__/{{ stack_label }}/g' \\ - /opt/dozilab/files/motd > /etc/update-motd.d/99-dozilab - chmod +x /etc/update-motd.d/99-dozilab + set -euo pipefail - - name: Scripts ausführbar machen - file: - path: "{{ item }}" - mode: "0755" - loop: - - /opt/dozilab/scripts/check_student_setup.sh - - /opt/dozilab/scripts/reset_password.sh + python3 - <<'PY' + import json + import shlex + import sys - - name: Student-Setup verifizieren - command: /opt/dozilab/scripts/check_student_setup.sh {{ item.username }} - loop: "{{ students }}" - register: check_result - changed_when: false - no_log: true + spec = json.load(open("/etc/dozilab/user.json", "r", encoding="utf-8")) + apps = spec.get("applications") or [] - - name: Aufgabenstellung in Arbeitsverzeichnis kopieren - copy: - src: /opt/dozilab/user-files/aufgabe.pdf - dest: "/home/{{ item.username }}/work/aufgabe.pdf" - owner: "{{ item.username }}" - mode: "0644" - remote_src: true - loop: "{{ students }}" - no_log: true - when: user_files.aufgabe_pdf.exists | default(false) + def get_app(name): + for a in apps: + if isinstance(a, dict) and str(a.get("name", "")).lower() == name: + return a + return None - - name: Gruppenverzeichnis für Material erstellen - file: - path: "/home/{{ item.username }}/work/material" - state: directory - owner: "{{ item.username }}" - mode: "0700" - loop: "{{ students }}" - no_log: true - when: user_files.material_gruppe[item.group_name].exists | default(false) + pga = get_app("pgadmin") or {} + admin = pga.get("admin_credentials") or {} - - name: Gruppenmaterial in Arbeitsverzeichnis kopieren - copy: - src: "/opt/dozilab/user-files/{{ item.group_name }}/material" - dest: "/home/{{ item.username }}/work/material/" - owner: "{{ item.username }}" - mode: "0600" - remote_src: true - loop: "{{ students }}" + email = admin.get("email") + pw = admin.get("password") + + if not email or not pw: + sys.exit("pgadmin.admin_credentials missing email/password") + if "@" not in email: + sys.exit(f"pgAdmin admin email invalid: {email!r}") + if len(pw) < 6: + sys.exit("pgAdmin admin password too short (min 6)") + + with open("/etc/default/pgadmin4", "w", encoding="utf-8") as f: + print(f"PGADMIN_SETUP_EMAIL={shlex.quote(email)}", file=f) + print(f"PGADMIN_SETUP_PASSWORD={shlex.quote(pw)}", file=f) + + print("pgAdmin admin email:", email) + PY + + chmod 600 /etc/default/pgadmin4 + args: + executable: /bin/bash no_log: true - when: user_files.material_gruppe[item.group_name].exists | default(false) -""" + when: pgadmin_present_result.stdout | trim == "true" + - name: pgAdmin Web Setup ausführen + shell: | + set -euo pipefail -ANSIBLE_MULTIUSER_BASHRC = """# ============================================================================== -# DoziLab: .bashrc für Student-Accounts -# ============================================================================== -export HISTSIZE=1000 -export HISTFILESIZE=2000 -export EDITOR=nano + rm -f /var/lib/pgadmin/pgadmin4.db + rm -rf /var/lib/pgadmin/sessions /var/lib/pgadmin/storage + install -d -m 0750 -o www-data -g www-data /var/lib/pgadmin -force_color_prompt=yes -PS1='\\[\\033[01;32m\\]\\u@\\h\\[\\033[00m\\]:\\[\\033[01;34m\\]\\w\\[\\033[00m\\]\\$ ' + set -a + . /etc/default/pgadmin4 + set +a -alias ll='ls -alF' -alias la='ls -A' -alias l='ls -CF' -alias ..='cd ..' -alias work='cd ~/work' + PGADMIN_SETUP_EMAIL="$PGADMIN_SETUP_EMAIL" \ + PGADMIN_SETUP_PASSWORD="$PGADMIN_SETUP_PASSWORD" \ + /usr/pgadmin4/bin/setup-web.sh --yes -echo "" -echo " Willkommen, $(whoami)!" -echo " Dein Arbeitsverzeichnis: ~/work" -echo "" -""" + a2enconf pgadmin4 || true + systemctl reload apache2 || true + args: + executable: /bin/bash + no_log: true + when: pgadmin_present_result.stdout | trim == "true" -ANSIBLE_MULTIUSER_MOTD = """#!/usr/bin/env bash -# ============================================================================== -# DoziLab: Message of the Day -# Platzhalter __STACK_LABEL__ wird vom Playbook ersetzt. -# ============================================================================== -echo "" -echo " ██████╗ ██████╗ ███████╗██╗██╗ █████╗ ██████╗ " -echo " ██╔══██╗██╔═══██╗╚══███╔╝██║██║ ██╔══██╗██╔══██╗" -echo " ██║ ██║██║ ██║ ███╔╝ ██║██║ ███████║██████╔╝" -echo " ██║ ██║██║ ██║ ███╔╝ ██║██║ ██╔══██║██╔══██╗" -echo " ██████╔╝╚██████╔╝███████╗██║███████╗██║ ██║██████╔╝" -echo " ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ " -echo "" -echo " Kurs: __STACK_LABEL__" -echo " Support: Wende dich an deinen Dozenten" -echo "" -""" + - name: pgAdmin User aus user_json anlegen + shell: | + set -euo pipefail -ANSIBLE_MULTIUSER_CHECK_SCRIPT = """#!/usr/bin/env bash -set -euo pipefail -USERNAME="${1:?Usage: check_student_setup.sh }" -ERRORS=0 + python3 - <<'PY' + import json + import subprocess + import sys -check() { - local desc="$1"; local result="$2" - if [[ "$result" == "ok" ]]; then echo " ✓ $desc" - else echo " ✗ $desc → $result"; ERRORS=$((ERRORS + 1)); fi -} + spec = json.load(open("/etc/dozilab/user.json", "r", encoding="utf-8")) + apps = spec.get("applications") or [] -echo "Checking setup for: $USERNAME" -id "$USERNAME" &>/dev/null && check "Account existiert" "ok" || check "Account existiert" "nicht gefunden" -[[ -d "/home/$USERNAME" ]] && check "Home-Verzeichnis" "ok" || check "Home-Verzeichnis" "fehlt" -[[ -d "/home/$USERNAME/work" ]] && check "Arbeitsverzeichnis" "ok" || check "Arbeitsverzeichnis" "fehlt" -passwd -S "$USERNAME" 2>/dev/null | grep -qv " L " && check "Passwort gesetzt" "ok" || check "Passwort gesetzt" "gesperrt" -SHELL=$(getent passwd "$USERNAME" | cut -d: -f7) -[[ "$SHELL" == "/bin/bash" ]] && check "Shell ist /bin/bash" "ok" || check "Shell ist /bin/bash" "ist $SHELL" + def get_app(name): + for a in apps: + if isinstance(a, dict) and str(a.get("name", "")).lower() == name: + return a + return None -echo "" -if [[ $ERRORS -eq 0 ]]; then echo "✓ Setup OK für $USERNAME"; exit 0 -else echo "✗ $ERRORS Fehler für $USERNAME"; exit 1; fi -""" + pga = get_app("pgadmin") or {} + creds = pga.get("credentials") or [] -ANSIBLE_MULTIUSER_RESET_SCRIPT = """#!/usr/bin/env bash -set -euo pipefail -USERNAME="${1:?Usage: reset_password.sh }" -NEW_PASSWORD="${2:?Usage: reset_password.sh }" -if ! id "$USERNAME" &>/dev/null; then echo "ERROR: Account '$USERNAME' nicht gefunden" >&2; exit 1; fi -echo "${USERNAME}:${NEW_PASSWORD}" | chpasswd -echo "✓ Passwort für '$USERNAME' zurückgesetzt" -chage -d 0 "$USERNAME" -echo "✓ Passwort-Änderung beim nächsten Login erzwungen" -""" + if not isinstance(creds, list): + sys.exit("pgadmin.credentials must be a list") + accounts = [] + seen = set() -def create_mock_templates(db: Session, owner_id: str) -> list[Template]: - """Create mock templates.""" - templates_data = [ - { - "name": "Ansible Multi-User Ubuntu", - "description": "Ubuntu VM mit mehreren Benutzerkonten, verwaltet durch Ansible. Pro Gruppe wird ein Linux-Account mit eigenem Arbeitsverzeichnis erstellt.", - "repo_url": "https://github.com/dozilab/appstore-templates", - "icon_url": "mdi:server-network", - "visibility": TemplateVisibility.PUBLIC, - }, - { - "name": "PostgreSQL Group Database", - "description": "Deploy a PostgreSQL database server where each student group gets its own database and role. Optional pgAdmin4 web interface for database management", - "repo_url": "https://github.com/dozilab/appstore-templates", - "icon_url": "mdi:database", - "visibility": TemplateVisibility.PUBLIC, - } - ] - - templates = [] - for data in templates_data: - # Check if template already exists - existing = db.query(Template).filter(Template.name == data["name"]).first() - if existing: - logger.info(f"Template '{data['name']}' already exists, skipping creation (ID: {existing.id})") - templates.append(existing) - continue - - try: - template = Template( - owner_id=owner_id, - **data - ) - db.add(template) - db.commit() - db.refresh(template) - templates.append(template) - logger.info(f"Created template: {template.name} (ID: {template.id})") - except Exception as e: - logger.error(f"Failed to create template '{data['name']}': {e}") - db.rollback() - raise - - logger.info(f"Total templates: {len(templates)} (created or existing)") - return templates - - -def create_mock_template_versions(db: Session, templates: list[Template]) -> list[TemplateVersion]: - """Create mock template versions.""" - versions = [] - - for template in templates: - # Check if version already exists - existing = db.query(TemplateVersion).filter( - TemplateVersion.template_id == template.id - ).first() - - if existing: - logger.info(f"Template version already exists for '{template.name}', skipping creation (Version ID: {existing.id})") - versions.append(existing) - continue - - try: - version = TemplateVersion( - template_id=template.id, - version="1.0.0", - git_commit_sha=f"v1.0.0-{template.name.lower().replace(' ', '-')}", - is_active=True - ) - db.add(version) - db.commit() - db.refresh(version) - versions.append(version) - logger.info(f"Created version for template: {template.name} (Version ID: {version.id})") - except Exception as e: - logger.error(f"Failed to create version for template '{template.name}': {e}") - db.rollback() - raise - - return versions - - -def create_mock_template_files(db: Session, versions: list[TemplateVersion]) -> None: - """Create mock template version files.""" - files_created = 0 - files_skipped = 0 - files_failed = 0 - - for version in versions: - try: - # Check if files already exist - existing_files = db.query(TemplateVersionFile).filter( - TemplateVersionFile.template_version_id == version.id - ).count() - - if existing_files > 0: - logger.info(f"Files already exist for version {version.id}, skipping (count: {existing_files})") - files_skipped += 1 + for c in creds: + if not isinstance(c, dict): continue - - # Get template to determine which files to create - template = db.query(Template).filter(Template.id == version.template_id).first() - if not template: - logger.warning(f"Template not found for version {version.id} (template_id: {version.template_id})") - files_failed += 1 + email = c.get("email") + pw = c.get("password") + if not email or not pw: continue - - logger.info(f"Creating files for version {version.id} (template: {template.name})") - - # Determine which template files to use based on template name - if template.name == "Ansible Multi-User Ubuntu": - app_yaml_content = ANSIBLE_MULTIUSER_APP_YAML - heat_template_content = ANSIBLE_MULTIUSER_HEAT_TEMPLATE - cloud_init_content = None - heat_file_name = "main.yaml" - heat_file_path = "heat/main.yaml" - else: - logger.warning(f"Unknown template name '{template.name}' for version {version.id}, skipping file creation") - files_failed += 1 + if email in seen: continue - - # Create app.yaml - app_yaml = TemplateVersionFile( - template_version_id=version.id, - file_name="app.yaml", - file_type=FileType.APP_MANIFEST, - file_path="app.yaml", - content=app_yaml_content, - is_primary=False - ) - db.add(app_yaml) - logger.debug(f"Added app.yaml for version {version.id}") - - # Create heat template - heat_template = TemplateVersionFile( - template_version_id=version.id, - file_name=heat_file_name, - file_type=FileType.HEAT_TEMPLATE, - file_path=heat_file_path, - content=heat_template_content, - is_primary=True + seen.add(email) + accounts.append((email, pw)) + + created = 0 + + for email, pw in accounts: + cmd = [ + "sudo", + "-u", + "www-data", + "/usr/pgadmin4/venv/bin/python", + "/usr/pgadmin4/web/setup.py", + "add-user", + email, + pw, + "--role", + "User", + "--active", + ] + + res = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, ) - db.add(heat_template) - logger.debug(f"Added heat template for version {version.id}") - - files_in_version = 2 - - # Create cloud-init user-data (only for templates that use it) - if cloud_init_content is not None: - cloud_init = TemplateVersionFile( - template_version_id=version.id, - file_name="user-data.yaml", - file_type=FileType.CLOUD_INIT, - file_path="cloud-init/user-data.yaml", - content=cloud_init_content, - is_primary=False + + out = (res.stdout or "").strip() + if res.returncode == 0: + created += 1 + print(f"created pgAdmin user: {email}") + elif "already exists" in out.lower(): + print(f"pgAdmin user already exists: {email}") + else: + print(f"WARNING: failed to create pgAdmin user {email} rc={res.returncode} out={out}") + + print(f"pgAdmin accounts processed: {len(accounts)}, created: {created}") + PY + args: + executable: /bin/bash + no_log: true + when: pgadmin_present_result.stdout | trim == "true" + + - name: pgAdmin Server-Eintraege pro User registrieren + # load-servers importiert eine JSON-Datei mit Server-Definitionen + # in den Pgadmin-Account eines Users. Passwoerter werden bewusst NICHT + # mitimportiert — pgAdmin lehnt das aus Sicherheitsgruenden ab. + # Studenten geben das Postgres-Passwort beim ersten Connect ein. + # Teacher sieht alle Gruppen-DBs als separate Eintraege. + shell: | + set -euo pipefail + + python3 - <<'PY' + import json + import subprocess + import sys + import tempfile + from pathlib import Path + + spec = json.load(open("/etc/dozilab/user.json", "r", encoding="utf-8")) + apps = spec.get("applications") or [] + + def get_app(name): + for a in apps: + if isinstance(a, dict) and str(a.get("name", "")).lower() == name: + return a + return None + + pg = get_app("postgres") or get_app("postgresql") or {} + pga = get_app("pgadmin") or {} + + pg_creds = pg.get("credentials") or [] + pga_creds = pga.get("credentials") or [] + pga_admin = pga.get("admin_credentials") or {} + + # Build group_index -> {db_user, database_name} from postgres creds + groups_by_gid = {} + for c in pg_creds: + gid = str(c.get("group") or "").strip() + if not gid: + continue + groups_by_gid[gid] = { + "db_user": c.get("db_user"), + "database_name": c.get("database_name") or c.get("db_name"), + } + + def load_servers_for_user(email: str, servers: dict) -> None: + """Run pgAdmin's load-servers CLI for a single user.""" + payload = {"Servers": servers} + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump(payload, f) + tmp_path = f.name + try: + # Make sure www-data can read it + Path(tmp_path).chmod(0o644) + cmd = [ + "sudo", "-u", "www-data", + "/usr/pgadmin4/venv/bin/python", + "/usr/pgadmin4/web/setup.py", + "load-servers", tmp_path, + "--user", email, + ] + res = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, ) - db.add(cloud_init) - logger.debug(f"Added cloud-init for version {version.id}") - files_in_version += 1 - - # Create ansible playbook (only for ansible-based templates) - if template.name == "Ansible Multi-User Ubuntu": - playbook = TemplateVersionFile( - template_version_id=version.id, - file_name="main.yml", - file_type=FileType.ANSIBLE_PLAYBOOK, - file_path="playbooks/main.yml", - content=ANSIBLE_MULTIUSER_PLAYBOOK, - is_primary=False + out = (res.stdout or "").strip() + if res.returncode == 0: + print(f"loaded {len(servers)} server(s) for {email}") + else: + print(f"WARNING: load-servers failed for {email} rc={res.returncode} out={out}") + finally: + Path(tmp_path).unlink(missing_ok=True) + + # --- Student / group accounts: one server entry per pgAdmin user --- + for c in pga_creds: + email = c.get("email") + gid = str(c.get("group") or "").strip() + if not email or not gid: + continue + group_info = groups_by_gid.get(gid) + if not group_info: + print(f"WARNING: no postgres credential for group {gid} — skipping {email}") + continue + servers = { + "1": { + "Name": f"Gruppe {gid} DB", + "Group": "Servers", + "Host": "127.0.0.1", + "Port": 5432, + "MaintenanceDB": group_info["database_name"], + "Username": group_info["db_user"], + "SSLMode": "prefer", + }, + } + load_servers_for_user(email, servers) + + # --- Teacher: one server entry per group, all with teacher db_user --- + teacher_email = pga_admin.get("email") + pg_admin = pg.get("admin_credentials") or {} + teacher_db_user = pg_admin.get("db_user") + if teacher_email and teacher_db_user and groups_by_gid: + teacher_servers = {} + for i, (gid, info) in enumerate(sorted(groups_by_gid.items()), start=1): + teacher_servers[str(i)] = { + "Name": f"Gruppe {gid} DB (teacher)", + "Group": "Gruppen", + "Host": "127.0.0.1", + "Port": 5432, + "MaintenanceDB": info["database_name"], + "Username": teacher_db_user, + "SSLMode": "prefer", + } + load_servers_for_user(teacher_email, teacher_servers) + PY + args: + executable: /bin/bash + no_log: true + when: pgadmin_present_result.stdout | trim == "true" + + - name: Apache aktivieren und starten + service: + name: apache2 + enabled: true + state: started + when: pgadmin_present_result.stdout | trim == "true" + + - name: Auf pgAdmin Login-Seite warten + # Pollt die pgAdmin-Login-Seite ueber Apache. + # Erst wenn HTTP 200 zurueck kommt, gilt pgAdmin als wirklich bereit — + # vorher wuerde der Ready-Marker den Frontend-Status faelschlich auf + # "bereit" setzen, obwohl Apache noch 503/404 wirft. + uri: + url: "http://127.0.0.1/pgadmin4/login" + method: GET + status_code: 200 + return_content: false + register: pgadmin_ready_check + retries: 120 + delay: 5 + until: pgadmin_ready_check.status == 200 + when: pgadmin_present_result.stdout | trim == "true" + + - name: Ready Marker schreiben + copy: + dest: "{{ dozilab_mark_dir }}/ready" + owner: root + group: root + mode: "0644" + content: "DOZILAB_READY stack={{ dozilab_stack_label }} time={{ ansible_facts.date_time.iso8601 }}\n" + + - name: Fail Marker entfernen falls vorhanden + file: + path: "{{ dozilab_mark_dir }}/failed" + state: absent + + - name: Abschluss anzeigen + debug: + msg: + - "DoziLab PostgreSQL Setup OK" + - "Stack: {{ dozilab_stack_label }}" + - "PostgreSQL Version: {{ detected_pgver.stdout | trim }}" + - "pgAdmin enabled: {{ pgadmin_present_result.stdout | trim }}"''' + + +# ============================================================================ +# Template definitions +# ============================================================================ + +# Each app describes a template + its version files. Files are loaded in +# the order listed (only matters for the 'order' column in the DB). +APPS = [ + { + "name": "Multi-User Ubuntu", + "description": ( + "Ubuntu VM mit mehreren Benutzerkonten, verwaltet durch Ansible. " + "Pro Gruppe wird ein Linux-Account mit eigenem Arbeitsverzeichnis erstellt." + ), + "version": "2.1.0", + "files": [ + {"name": "app.yaml", "type": FileType.APP_MANIFEST, "path": "app.yaml", "content": MULTIUSER_APP_YAML, "primary": False}, + {"name": "main.yaml", "type": FileType.HEAT_TEMPLATE, "path": "heat/main.yaml", "content": MULTIUSER_HEAT_TEMPLATE, "primary": True}, + {"name": "main.yml", "type": FileType.ANSIBLE_PLAYBOOK,"path": "playbooks/main.yml", "content": MULTIUSER_PLAYBOOK, "primary": False}, + {"name": "bashrc", "type": FileType.CONFIG_FILE, "path": "files/bashrc", "content": MULTIUSER_BASHRC, "primary": False}, + {"name": "motd", "type": FileType.CONFIG_FILE, "path": "files/motd", "content": MULTIUSER_MOTD, "primary": False}, + {"name": "check_student_setup.sh", "type": FileType.SHELL_SCRIPT, "path": "scripts/check_student_setup.sh", "content": MULTIUSER_CHECK_SCRIPT, "primary": False}, + {"name": "reset_password.sh", "type": FileType.SHELL_SCRIPT, "path": "scripts/reset_password.sh", "content": MULTIUSER_RESET_SCRIPT, "primary": False}, + ], + }, + { + "name": "PostgreSQL Group DB", + "description": ( + "Ubuntu VM mit PostgreSQL und optionalem pgAdmin. Jede Gruppe bekommt " + "eine eigene Datenbank und einen eigenen DB-Rollen-Account. Der Dozent " + "hat lesenden/schreibenden Zugriff auf alle Gruppen-DBs." + ), + "version": "2.0.0", + "files": [ + {"name": "app.yaml", "type": FileType.APP_MANIFEST, "path": "app.yaml", "content": POSTGRES_APP_YAML, "primary": False}, + {"name": "main.yaml", "type": FileType.HEAT_TEMPLATE, "path": "heat/main.yaml", "content": POSTGRES_HEAT_TEMPLATE,"primary": True}, + {"name": "main.yml", "type": FileType.ANSIBLE_PLAYBOOK,"path": "playbooks/main.yml", "content": POSTGRES_PLAYBOOK, "primary": False}, + ], + }, +] + + +# Old template names from earlier iterations. Removed by the seeder so the +# UI doesn't show stale entries. Add new entries here if you rename a template. +_LEGACY_TEMPLATE_NAMES = [ + "Ansible Multi-User Ubuntu", + "Ansible PostgreSQL Group DB", + "PostgreSQL Group Database", +] + + +def create_lecturer_user(db: Session) -> User: + """Create or get mock development user.""" + existing_user = db.query(User).filter(User.external_id == "40a38818-552d-4ee0-a3fd-a2a1c434a862").first() + if existing_user: + return existing_user + + user = User( + id="5c1c8363-ff6a-4d7f-946a-0221aaf21fb5", + external_id="40a38818-552d-4ee0-a3fd-a2a1c434a862", + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info(f"Created mock user with external_id: {user.external_id}") + return user + + +def _delete_legacy_templates(db: Session) -> None: + """Delete templates from earlier iterations whose names we've since changed. + + Cascades via the ORM relationships: template -> versions -> files. If a + legacy template has deployments hanging off it the DELETE will fail with + a FK violation — that's intentional, you'll need to remove those first. + """ + removed = 0 + for name in _LEGACY_TEMPLATE_NAMES: + existing = db.query(Template).filter(Template.name == name).all() + for t in existing: + try: + db.delete(t) + db.commit() + removed += 1 + logger.info(f"Removed legacy template: {name} (id={t.id})") + except Exception as e: + db.rollback() + logger.warning( + f"Could not remove legacy template {name!r} (id={t.id}): {e}. " + "Likely has deployments referencing it — delete those first." ) - db.add(playbook) - files_in_version += 1 - - for name, content in [("bashrc", ANSIBLE_MULTIUSER_BASHRC), ("motd", ANSIBLE_MULTIUSER_MOTD)]: - db.add(TemplateVersionFile( - template_version_id=version.id, - file_name=name, - file_type=FileType.CONFIG_FILE, - file_path=f"files/{name}", - content=content, - is_primary=False - )) - files_in_version += 1 - - for name, content in [ - ("check_student_setup.sh", ANSIBLE_MULTIUSER_CHECK_SCRIPT), - ("reset_password.sh", ANSIBLE_MULTIUSER_RESET_SCRIPT), - ]: - db.add(TemplateVersionFile( - template_version_id=version.id, - file_name=name, - file_type=FileType.SHELL_SCRIPT, - file_path=f"scripts/{name}", - content=content, - is_primary=False - )) - files_in_version += 1 - - db.commit() - files_created += 1 - logger.info(f"✅ Created {files_in_version} files for version: {version.id} (template: {template.name})") - - except Exception as e: - logger.error(f"❌ Failed to create files for version {version.id}: {e}", exc_info=True) - db.rollback() - files_failed += 1 - - logger.info(f"Template files summary: {files_created} created, {files_skipped} skipped, {files_failed} failed") + if removed: + logger.info(f"Removed {removed} legacy template(s).") -def seed_mock_data(db: Session) -> None: - """Seed all mock data for development. - - Args: - db: Database session +def _seed_one_app(db: Session, owner_id: str, app: dict) -> None: + """Create or update a single app (template + version + files). + + Idempotent: existing template/version is reused; files are upserted + by file_path so re-running picks up content changes. """ + template = db.query(Template).filter(Template.name == app["name"]).first() + if not template: + template = Template( + owner_id=owner_id, + name=app["name"], + description=app["description"], + repo_url="https://github.com/dozilab/appstore-templates", + visibility=TemplateVisibility.PUBLIC, + ) + db.add(template) + db.commit() + db.refresh(template) + logger.info(f"Created template: {template.name} (id={template.id})") + else: + logger.info(f"Template exists: {template.name} (id={template.id})") + + version = ( + db.query(TemplateVersion) + .filter(TemplateVersion.template_id == template.id) + .order_by(TemplateVersion.created_at.desc()) + .first() + ) + if not version: + version = TemplateVersion( + template_id=template.id, + version=app["version"], + git_commit_sha=f"v{app['version']}-{template.name.lower().replace(' ', '-')}", + is_active=True, + ) + db.add(version) + db.commit() + db.refresh(version) + logger.info(f" Created version {version.version} (id={version.id})") + else: + logger.info(f" Version exists: {version.version} (id={version.id})") + + # Upsert files by file_path within the version. + existing_files = { + f.file_path: f + for f in db.query(TemplateVersionFile) + .filter(TemplateVersionFile.template_version_id == version.id) + .all() + } + + for order, file_spec in enumerate(app["files"]): + content = file_spec["content"].lstrip("\n") # strip leading newline from our r''' indentation + existing = existing_files.get(file_spec["path"]) + if existing: + if existing.content != content: + existing.content = content + existing.file_size = len(content.encode()) + logger.info(f" Updated {file_spec['path']} ({existing.file_size} bytes)") + continue + + f = TemplateVersionFile( + template_version_id=version.id, + file_name=file_spec["name"], + file_type=file_spec["type"], + file_path=file_spec["path"], + content=content, + file_size=len(content.encode()), + is_primary=file_spec["primary"], + order=order, + ) + db.add(f) + logger.info(f" Added {file_spec['path']} ({f.file_size} bytes)") + + db.commit() + + +def seed_mock_data(db: Session) -> None: + """Seed all mock data for development. Idempotent.""" try: logger.info("Starting mock data seeding...") - - # Create mock user + user = create_lecturer_user(db) logger.info(f"User ready: {user.id} (external_id: {user.external_id})") - - # Create templates - templates = create_mock_templates(db, user.id) - if not templates: - logger.warning("No templates were created or found!") - return - logger.info(f"Templates ready: {len(templates)} templates") - - # Create versions - versions = create_mock_template_versions(db, templates) - if not versions: - logger.warning("No template versions were created or found!") - return - logger.info(f"Template versions ready: {len(versions)} versions") - - # Create files - create_mock_template_files(db, versions) - logger.info(f"Template files created for {len(versions)} versions") - + + _delete_legacy_templates(db) + + for app in APPS: + _seed_one_app(db, user.id, app) + logger.info("Mock data seeding completed successfully!") - + except Exception as e: logger.error(f"Failed to seed mock data: {e}", exc_info=True) db.rollback() diff --git a/src/models/__init__.py b/src/models/__init__.py index 7721a48..46486f5 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -2,6 +2,7 @@ # Import all model classes to make them available for SQLAlchemy relationship resolution from src.models.course import Course +from src.models.course_filter import CourseFilter from src.models.course_group import CourseGroup from src.models.course_member import CourseMember from src.models.deployment import Deployment, DeploymentStatus @@ -13,12 +14,14 @@ from src.models.template import Template from src.models.template_category import TemplateCategory from src.models.template_category_assignment import TemplateCategoryAssignment +from src.models.template_icon import TemplateIcon from src.models.template_version import TemplateVersion from src.models.template_version_file import TemplateVersionFile from src.models.user import User __all__ = [ "Course", + "CourseFilter", "CourseGroup", "CourseMember", "Deployment", @@ -32,6 +35,7 @@ "Template", "TemplateCategory", "TemplateCategoryAssignment", + "TemplateIcon", "TemplateVersion", "TemplateVersionFile", "User", diff --git a/src/models/course_filter.py b/src/models/course_filter.py new file mode 100644 index 0000000..5c4a366 --- /dev/null +++ b/src/models/course_filter.py @@ -0,0 +1,35 @@ +"""Course filter (frontend chip/blob) database model. + +Admin-verwaltete Strings, mit denen das Frontend Kursnamen filtert. Die Filter +sind reine Such-Begriffe (z. B. „SQL", „Web 2026") — sie werden NICHT einem +Kurs zugewiesen, sondern client-seitig auf ``Course.name`` angewandt. +""" +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from src.core.database import Base + + +class CourseFilter(Base): + """Filter-Tag für Kursnamen (Frontend-Chips).""" + + __tablename__ = "course_filters" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column( + String(255), + nullable=False, + unique=True, + comment="Anzeige-/Such-String, den das Frontend gegen Kursnamen matcht", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/src/models/deployment_instance.py b/src/models/deployment_instance.py index adb83e7..0783db7 100644 --- a/src/models/deployment_instance.py +++ b/src/models/deployment_instance.py @@ -9,9 +9,18 @@ class DeploymentInstanceStatus(str, Enum): - """Deployment instance status values.""" + """Deployment instance status values. + + ``REDEPLOYING`` is set on a single instance while ``redeploy_instance`` + tears down its Heat stack and rebuilds it. The parent ``Deployment`` row + stays in ``RUNNING`` during that — only this one instance is transient, + so siblings remain reachable. When the redeploy succeeds the row is + replaced with a fresh ``DeploymentInstance``; the old row is deleted as + part of the task, mirroring delete_deployment's instance teardown. + """ CREATING = "creating" RUNNING = "running" + REDEPLOYING = "redeploying" FAILED = "failed" DELETED = "deleted" diff --git a/src/models/deployment_instance_access.py b/src/models/deployment_instance_access.py index b2f93ec..d75b2db 100644 --- a/src/models/deployment_instance_access.py +++ b/src/models/deployment_instance_access.py @@ -18,6 +18,11 @@ class AccessType(str, Enum): RDP = "rdp" VNC = "vnc" DATABASE = "database" + # One-time activation/setup link the playbook generates on the VM + # (e.g. Overleaf account setup). Carried in connection_url; no + # password / SSH key. Stored as a Postgres enum value of the same + # name — see the Alembic migration that adds it. + ACTIVATION_LINK = "activation_link" class DeploymentInstanceAccess(Base): @@ -33,6 +38,13 @@ class DeploymentInstanceAccess(Base): deployment_instance_id: Mapped[str] = mapped_column(String(36), ForeignKey("deployment_instances.id"), nullable=False) access_type: Mapped[AccessType] = mapped_column(SQLEnum(AccessType), nullable=False) + # The course_group this access entry belongs to. NULL means the entry is + # NOT tied to any student group — typically the lecturer's admin + # credentials. Students can only see rows where they are a member of the + # referenced group; admin rows (group_id IS NULL) are filtered out for + # them. See src/api/student.py for the authorization helper. + group_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("course_groups.id"), nullable=True) + # Connection details. password and ssh_private_key are Fernet-encrypted at rest # via EncryptedString and decrypted transparently on read; never log them. connection_url: Mapped[str | None] = mapped_column(String(500), nullable=True) @@ -50,3 +62,4 @@ class DeploymentInstanceAccess(Base): # Relationships deployment_instance: Mapped["DeploymentInstance"] = relationship("DeploymentInstance", back_populates="access_methods") + group: Mapped["CourseGroup | None"] = relationship("CourseGroup") diff --git a/src/models/template.py b/src/models/template.py index 78f5dad..8251bb3 100644 --- a/src/models/template.py +++ b/src/models/template.py @@ -3,7 +3,7 @@ from enum import Enum from uuid import uuid4 -from sqlalchemy import String, DateTime, Enum as SQLEnum, Text, ForeignKey +from sqlalchemy import String, DateTime, Enum as SQLEnum, Text, ForeignKey, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship from src.core.database import Base @@ -25,21 +25,48 @@ class Template(Base): description: Mapped[str | None] = mapped_column(Text, nullable=True) owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) repo_url: Mapped[str] = mapped_column(String(500), nullable=False) - icon_url: Mapped[str | None] = mapped_column( - String(500), - nullable=True, - comment="Icon URL or identifier (e.g., mdi:server, /icons/template.svg, 🚀)" - ) visibility: Mapped[TemplateVisibility] = mapped_column( SQLEnum(TemplateVisibility), default=TemplateVisibility.PRIVATE ) + # Owner-Wunsch „bitte veröffentlichen, sobald erste Version approved ist". + # Wir flippen das Template NICHT direkt auf PUBLIC, wenn der Owner beim + # Erstellen „öffentlich" wählt — stattdessen bleibt es PRIVATE und dieses + # Flag merkt sich den Veröffentlichungswunsch. Beim ersten admin-approve + # einer Version flippt die Service-Logik atomar `visibility → PUBLIC` und + # `publish_requested → False`. Bei reject wird der Wunsch verworfen; + # Owner muss erneut über PATCH `visibility: public` anstoßen. + publish_requested: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + server_default="false", + ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships owner: Mapped["User"] = relationship("User", back_populates="owned_templates") - versions: Mapped[list["TemplateVersion"]] = relationship("TemplateVersion", back_populates="template") + versions: Mapped[list["TemplateVersion"]] = relationship( + "TemplateVersion", + back_populates="template", + cascade="all, delete-orphan", + passive_deletes=True, + ) category_assignments: Mapped[list["TemplateCategoryAssignment"]] = relationship("TemplateCategoryAssignment", back_populates="template") + # Hochgeladenes Icon-Bild (optional). Getrennte Tabelle statt Spalte am + # Template, damit ``SELECT * FROM templates`` keinen 1-5 MB BLOB pro Row + # mitlädt. ``uselist=False`` weil per Unique-Constraint auf + # ``template_icons.template_id`` maximal ein Icon pro Template existiert. + # Die ``content``-Spalte auf ``TemplateIcon`` ist ``deferred``, wird also + # nur beim Serve-Endpoint tatsächlich aus der DB gezogen. + icon: Mapped["TemplateIcon | None"] = relationship( + "TemplateIcon", + back_populates="template", + cascade="all, delete-orphan", + passive_deletes=True, + uselist=False, + ) + diff --git a/src/models/template_icon.py b/src/models/template_icon.py new file mode 100644 index 0000000..fdbbb5e --- /dev/null +++ b/src/models/template_icon.py @@ -0,0 +1,71 @@ +"""Template Icon database model. + +Speichert hochgeladene Icon-Bilder als Binärdaten in einer eigenen Tabelle, +damit große BLOBs nicht in jedem ``SELECT * FROM templates`` mitgeschleppt +werden. Ein Template hat maximal ein Icon (1:0..1 Beziehung, via unique FK +auf ``templates.icon_file_id``); Cascade-Delete räumt die Row auf, wenn das +Template selbst gelöscht wird. + +Zulässige Bildformate und die Größenobergrenze werden im Service-Layer +validiert (siehe ``template_icon_service.py``), nicht in der DB. +""" +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, String +from sqlalchemy.orm import Mapped, deferred, mapped_column, relationship + +from src.core.database import Base + +if TYPE_CHECKING: + from src.models.template import Template + + +class TemplateIcon(Base): + """Persistiertes Icon-Bild für ein Template.""" + + __tablename__ = "template_icons" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + template_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("templates.id", ondelete="CASCADE"), + nullable=False, + unique=True, + comment="Owning template — 1:1, jedes Template hat höchstens ein Icon.", + ) + content: Mapped[bytes] = deferred( + mapped_column( + LargeBinary, + nullable=False, + comment="Rohbytes des Bildes (PNG/JPEG/WebP).", + ) + ) + content_type: Mapped[str] = mapped_column( + String(64), + nullable=False, + comment="MIME-Typ, wird beim Ausliefern als Content-Type-Header verwendet.", + ) + file_name: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + comment="Original-Dateiname (für Content-Disposition).", + ) + size_bytes: Mapped[int] = mapped_column( + Integer, + nullable=False, + comment="Größe von ``content`` in Bytes — redundant, aber praktisch für Listing/Debug.", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + template: Mapped["Template"] = relationship("Template", back_populates="icon") diff --git a/src/models/template_version.py b/src/models/template_version.py index 9d2126e..e19f2ac 100644 --- a/src/models/template_version.py +++ b/src/models/template_version.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from uuid import uuid4 -from sqlalchemy import String, DateTime, Boolean, ForeignKey, Text, Enum as SQLEnum +from sqlalchemy import String, DateTime, Boolean, ForeignKey, Text, Enum as SQLEnum, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from src.core.database import Base @@ -27,16 +27,40 @@ class TemplateVersion(Base): """Template Version database model.""" __tablename__ = "template_versions" + __table_args__ = ( + # Pro Template darf jede Versionsnummer nur einmal vorkommen. + # Der UI-Pfad „neue Versionen importieren" landete sonst bei + # ``2.0.0, 2.0.0, 2.0.0, …`` für Repos, die `app.yaml.app.version` + # nicht bumpen. Validierung läuft zusätzlich im Service-Layer + # (``src/utils/version_validator.py``), damit der Owner eine + # erklärende Fehlermeldung statt eines IntegrityError sieht. + UniqueConstraint( + 'template_id', + 'version', + name='uq_template_versions_template_id_version', + ), + ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) - template_id: Mapped[str] = mapped_column(String(36), ForeignKey("templates.id"), nullable=False) + template_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("templates.id", ondelete="CASCADE"), + nullable=False, + ) version: Mapped[str] = mapped_column(String(50), nullable=False, comment="Semantic version (e.g., 0.2.0)") git_commit_sha: Mapped[str] = mapped_column(String(255), nullable=False, comment="Git commit SHA or tag") is_active: Mapped[bool] = mapped_column(Boolean, default=True) - approval_status: Mapped[TemplateVersionApprovalStatus] = mapped_column( + # Approval flow applies only to PUBLIC templates. Private template versions + # use NULL — "approval concept not applicable; owner sees them anyway". + # The column stays an enum for backward compatibility; legacy rows on + # existing private templates may still carry PENDING/APPROVED values and + # the queries that filter `WHERE approval_status = 'APPROVED'` excludent + # NULLs by SQL semantics, so non-owners cannot see private versions through + # the public-template path either way. + approval_status: Mapped[TemplateVersionApprovalStatus | None] = mapped_column( SQLEnum(TemplateVersionApprovalStatus, name="templateversionapprovalstatus"), - default=TemplateVersionApprovalStatus.PENDING, - nullable=False, + default=None, + nullable=True, ) approved_by_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("users.id"), nullable=True, @@ -52,4 +76,9 @@ class TemplateVersion(Base): # Relationships template: Mapped["Template"] = relationship("Template", back_populates="versions") deployments: Mapped[list["Deployment"]] = relationship("Deployment", back_populates="template_version") - files: Mapped[list["TemplateVersionFile"]] = relationship("TemplateVersionFile", back_populates="template_version") + files: Mapped[list["TemplateVersionFile"]] = relationship( + "TemplateVersionFile", + back_populates="template_version", + cascade="all, delete-orphan", + passive_deletes=True, + ) diff --git a/src/models/template_version_file.py b/src/models/template_version_file.py index cdd1d60..8fcc28d 100644 --- a/src/models/template_version_file.py +++ b/src/models/template_version_file.py @@ -34,7 +34,11 @@ class TemplateVersionFile(Base): ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) - template_version_id: Mapped[str] = mapped_column(String(36), ForeignKey("template_versions.id"), nullable=False) + template_version_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("template_versions.id", ondelete="CASCADE"), + nullable=False, + ) file_name: Mapped[str] = mapped_column(String(255), nullable=False) file_type: Mapped[FileType] = mapped_column( SQLEnum(FileType), diff --git a/src/repositories/course_filter_repository.py b/src/repositories/course_filter_repository.py new file mode 100644 index 0000000..3e4f1ca --- /dev/null +++ b/src/repositories/course_filter_repository.py @@ -0,0 +1,43 @@ +"""Course filter repository for database operations.""" +from typing import Optional + +from sqlalchemy.orm import Session + +from src.models.course_filter import CourseFilter +from src.repositories.base_repository import BaseRepository + + +class CourseFilterRepository(BaseRepository[CourseFilter]): + """Repository for CourseFilter database operations.""" + + def __init__(self, db: Session): + super().__init__(CourseFilter, db) + + def get_by_name(self, name: str) -> Optional[CourseFilter]: + """Fetch a filter by its exact (case-sensitive) name.""" + return self.db.query(self.model).filter(self.model.name == name).first() + + def get_all_filtered( + self, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + ) -> tuple[list[CourseFilter], int]: + """List filters with optional substring search and pagination. + + Returns: + Tuple of (rows, total count matching the search). + """ + query = self.db.query(self.model) + if search: + query = query.filter(self.model.name.ilike(f"%{search}%")) + + total = query.count() + rows = ( + query + .order_by(self.model.name.asc()) + .offset(skip) + .limit(limit) + .all() + ) + return rows, total diff --git a/src/repositories/template_icon_repository.py b/src/repositories/template_icon_repository.py new file mode 100644 index 0000000..458a71a --- /dev/null +++ b/src/repositories/template_icon_repository.py @@ -0,0 +1,35 @@ +"""TemplateIcon repository for database operations.""" +from typing import Optional + +from sqlalchemy.orm import Session + +from src.models.template_icon import TemplateIcon +from src.repositories.base_repository import BaseRepository + + +class TemplateIconRepository(BaseRepository[TemplateIcon]): + """Repository for TemplateIcon database operations.""" + + def __init__(self, db: Session): + """Initialize TemplateIconRepository with database session.""" + super().__init__(TemplateIcon, db) + + def get_by_template_id(self, template_id: str) -> Optional[TemplateIcon]: + """Fetch the icon row for a given template, if any.""" + return ( + self.db.query(self.model) + .filter(self.model.template_id == str(template_id)) + .first() + ) + + def delete_by_template_id(self, template_id: str) -> bool: + """Remove the icon row for a given template. + + Returns True if a row was deleted, False if nothing existed. + """ + icon = self.get_by_template_id(template_id) + if not icon: + return False + self.db.delete(icon) + self.db.commit() + return True diff --git a/src/repositories/template_version_repository.py b/src/repositories/template_version_repository.py index b73b6e9..314c9b1 100644 --- a/src/repositories/template_version_repository.py +++ b/src/repositories/template_version_repository.py @@ -151,6 +151,7 @@ def list_by_approval_status( template_id: Optional[str | UUID] = None, visibility: Optional[TemplateVisibility] = None, sort: QueueSort = "created_at_desc", + include_publish_requested: bool = True, ) -> tuple[list[tuple[TemplateVersion, Template]], int]: """List versions filtered by approval status, joined with their template. @@ -161,6 +162,14 @@ def list_by_approval_status( Optional filters narrow the queue: `template_id` to a single template, `visibility` to public/private templates only. `sort` selects ordering; default is newest-first by `created_at`. + + ``include_publish_requested`` (default ``True``): wenn ``visibility`` auf + ``PUBLIC`` gesetzt ist, schließt die Queue zusätzlich Templates ein, die + zwar noch ``PRIVATE`` sind, aber ``publish_requested=True`` tragen — + also „wartet auf die Erst-Genehmigung, danach wird's PUBLIC". Diese + Versionen sind logisch im Marketplace-Pfad und sollen sichtbar sein. + Wenn der Admin explizit ``include_publish_requested=False`` setzt, + sieht er nur die Versionen tatsächlich-öffentlicher Templates. """ query = ( self.db.query(self.model, Template) @@ -172,7 +181,20 @@ def list_by_approval_status( query = query.filter(self.model.template_id == str(template_id)) if visibility is not None: - query = query.filter(Template.visibility == visibility) + if ( + visibility == TemplateVisibility.PUBLIC + and include_publish_requested + ): + # PUBLIC OR (PRIVATE AND publish_requested) + query = query.filter( + (Template.visibility == TemplateVisibility.PUBLIC) + | ( + (Template.visibility == TemplateVisibility.PRIVATE) + & (Template.publish_requested.is_(True)) + ) + ) + else: + query = query.filter(Template.visibility == visibility) total = query.with_entities(func.count(self.model.id)).scalar() or 0 diff --git a/src/schemas/course_filter.py b/src/schemas/course_filter.py new file mode 100644 index 0000000..ce0ebcd --- /dev/null +++ b/src/schemas/course_filter.py @@ -0,0 +1,73 @@ +"""Course filter schemas for request/response validation.""" +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class CourseFilterCreate(BaseModel): + """Schema for creating a course filter.""" + + name: str = Field(..., description="Filter-String (z. B. „SQL“)", min_length=1, max_length=255) + + @field_validator("name") + @classmethod + def _strip(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + # ``extra="forbid"`` lets us reject typos / stale clients up-front instead + # of silently dropping unknown keys (Pydantic's default). + model_config = ConfigDict( + extra="forbid", + json_schema_extra={"example": {"name": "SQL"}}, + ) + + +class CourseFilterUpdate(BaseModel): + """Schema for renaming a course filter. + + Today the only editable field is ``name`` — and it is REQUIRED here, not + optional. Rationale: a PATCH with no editable field is a no-op, and + silently accepting ``{}`` lets buggy clients ship a deploy that „works" + in CI and surprises us in prod. If a second editable field is added later, + relax this back to ``Optional`` and add a model-level ``at-least-one-set`` + validator. + """ + + name: str = Field(..., description="Neuer Filter-String", min_length=1, max_length=255) + + @field_validator("name") + @classmethod + def _strip(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={"example": {"name": "SQL Grundlagen"}}, + ) + + +class CourseFilterResponse(BaseModel): + """Schema for course filter response.""" + + id: str = Field(..., description="Filter-ID") + name: str = Field(..., description="Filter-String") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": "filter-123", + "name": "SQL", + "created_at": "2026-06-30T10:00:00Z", + "updated_at": "2026-06-30T10:00:00Z", + } + }, + ) diff --git a/src/schemas/deployment.py b/src/schemas/deployment.py index 1fe5ee7..6465aa1 100644 --- a/src/schemas/deployment.py +++ b/src/schemas/deployment.py @@ -24,6 +24,17 @@ class GroupInfo(BaseModel): group_name: str = Field(..., description="Group name") group_index: int = Field(..., description="Group index/number") students: list[StudentInfo] = Field(..., description="Students in this group") + # Optional: when the frontend wizard knows the persisted ``course_groups.id`` + # for this group, it should pass it here so the backend can stamp the FK + # onto every credential row generated for the group. The link enables + # student self-service (see GET /api/v1/student/...). Optional because + # old request payloads / lecturer flows without persisted CourseGroup + # rows must keep working — affected credential rows simply stay + # ``group_id IS NULL`` and are invisible to students. + course_group_id: Optional[str] = Field( + None, + description="course_groups.id this group corresponds to (enables student self-service).", + ) class StackAssignment(BaseModel): @@ -185,6 +196,84 @@ class DeploymentResponse(BaseModel): ) +class DeploymentRedeployRequest(BaseModel): + """Body for ``POST /deployments/{id}/redeploy`` and + ``POST /deployments/{id}/instances/{instance_id}/redeploy``. + + Per the product spec, a "Config" in this codebase = the optional template + parameters surfaced by the underlying app (e.g. an on/off toggle "include + example notebooks"). A redeploy may **carry over** the deployment's existing + parameter map unchanged, or **override** it for the whole deployment + (``deployment_parameter_overrides``) and/or for individual VMs + (``instance_parameter_overrides``). + + Merge order during the actual redeploy task:: + + template defaults → deployment.deployment_parameters + → deployment_parameter_overrides + → instance_parameter_overrides[] + + ``preserve_credentials`` controls whether the existing + ``DeploymentInstanceAccess`` rows (passwords, SSH keys, activation links) + are kept and re-bound to the freshly-created instance, or wiped and + regenerated from scratch. Default ``False`` mirrors a clean + destroy-and-recreate; pass ``True`` to keep students' logins working + across the redeploy. + """ + + deployment_parameter_overrides: Optional[dict[str, Any]] = Field( + default=None, + description=( + "Parameters to merge ON TOP of the deployment's stored " + "``deployment_parameters`` for every redeployed VM. Keys absent here " + "fall back to the deployment's stored value (then to template defaults). " + "Pass an empty dict to keep the deployment-level params unchanged." + ), + ) + instance_parameter_overrides: Optional[dict[str, dict[str, Any]]] = Field( + default=None, + description=( + "Per-VM parameter overrides keyed by DeploymentInstance.id. " + "Each entry is merged ON TOP of the deployment-level overrides for " + "that one VM only. Keys not present in any layer fall back to " + "template defaults. Instance IDs not in this map use the " + "deployment-level overrides as-is. Ignored when redeploying a single " + "instance via the per-instance endpoint — pass the override under " + "``deployment_parameter_overrides`` there since there's only one VM " + "in scope." + ), + ) + preserve_credentials: bool = Field( + default=False, + description=( + "When True, the existing per-VM credentials (DeploymentInstanceAccess " + "rows) are re-bound to the freshly-created instance instead of being " + "regenerated. Useful to avoid breaking student logins during a quick " + "config change. Default False = clean destroy-and-recreate, fresh " + "passwords/keys/activation links." + ), + ) + + # ``extra="forbid"`` mirrors the contract recently tightened in PR #178 + # for course-filters: a typo in the request body (e.g. ``preserve_credential``) + # should surface as 422 instead of being silently dropped — silent drops on + # a destructive operation like redeploy are particularly easy to misdiagnose. + model_config = ConfigDict( + extra="forbid", + json_schema_extra={ + "example": { + "deployment_parameter_overrides": { + "include_example_notebooks": True, + }, + "instance_parameter_overrides": { + "instance-uuid-of-group-3": {"flavor": "gp1.medium"}, + }, + "preserve_credentials": False, + } + } + ) + + class DeploymentExtend(BaseModel): """Body for ``PATCH /deployments/{id}/extend``. @@ -246,11 +335,30 @@ class DeploymentLogResponse(BaseModel): class DeploymentCredentialEntry(BaseModel): """One credential entry for a deployment instance.""" + id: str = Field(..., description="Access entry ID — pass to /credentials/access/{id}/ssh-key") access_type: str = Field(..., description="Access type, e.g. ssh or database") username: Optional[str] = Field(None, description="Account username") password: Optional[str] = Field(None, description="Plaintext password (decrypted on read)") + ssh_private_key: Optional[str] = Field( + None, + description="Plaintext SSH private key in OpenSSH PEM format (decrypted on read). " + "Present for SSH access where a keypair was generated.", + ) connection_url: Optional[str] = Field(None, description="Connection URL if applicable") port: Optional[int] = Field(None, description="Port number if applicable") + group_id: Optional[str] = Field( + None, + description=( + "course_groups.id this credential belongs to. NULL = lecturer/admin " + "credential (not tied to a student group). Drives the Dozent/Gruppen " + "split in the UI." + ), + ) + group_name: Optional[str] = Field( + None, + description="Display name of the course group (joined from course_groups.name). " + "NULL when group_id is NULL.", + ) model_config = ConfigDict(from_attributes=True) diff --git a/src/schemas/lecturer.py b/src/schemas/lecturer.py new file mode 100644 index 0000000..5df9c26 --- /dev/null +++ b/src/schemas/lecturer.py @@ -0,0 +1,85 @@ +"""Schemas for the admin-only /lecturers endpoints.""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class LecturerListItem(BaseModel): + """One row in the lecturer list view. + + Excludes any User row that owns neither templates nor OpenStack projects + — those are students or freshly-created accounts and belong in a + different UI. + """ + + id: str = Field(..., description="Local DB user id") + external_id: str = Field(..., description="Keycloak sub claim") + display_name: Optional[str] = Field(None, description="Cached display name from Keycloak") + email: Optional[str] = Field(None, description="Cached email from Keycloak") + username: Optional[str] = Field(None, description="Cached preferred_username from Keycloak") + last_login_at: Optional[datetime] = Field( + None, + description="Last time the user's token was validated (proxy for 'still active in Keycloak')", + ) + template_count: int = Field(..., description="Templates this user owns") + deployment_count: int = Field( + ..., + description=( + "Deployments whose deployment_parameters.teacher.id matches this user's external_id" + ), + ) + openstack_project_count: int = Field( + ..., description="OpenStack projects this user owns" + ) + + model_config = ConfigDict(from_attributes=True) + + +class LecturerTemplateSummary(BaseModel): + """Minimal template info for the detail view.""" + + id: str + name: str + visibility: str + version_count: int + + +class LecturerDeploymentSummary(BaseModel): + """Minimal deployment info for the detail view.""" + + id: str + name: str + status: str + course_id: Optional[str] = None + expires_at: Optional[datetime] = None + created_at: datetime + + +class LecturerOpenstackProjectSummary(BaseModel): + """Minimal OpenStack project info for the detail view.""" + + id: str + openstack_project_name: str + region_name: str + + +class LecturerDetail(LecturerListItem): + """Full detail view: list-row fields plus the owned/deployed resources.""" + + templates: list[LecturerTemplateSummary] + deployments: list[LecturerDeploymentSummary] + openstack_projects: list[LecturerOpenstackProjectSummary] + + +class LecturerDeleteResponse(BaseModel): + """Response of DELETE /lecturers/{id} — the actual work is async.""" + + task_id: str = Field(..., description="Celery task id for the cascade delete") + user_id: str = Field(..., description="User row scheduled for deletion") + deployment_count: int = Field( + ..., description="Number of deployments the cascade will tear down" + ) + template_count: int = Field( + ..., description="Number of templates the cascade will remove" + ) diff --git a/src/schemas/student.py b/src/schemas/student.py new file mode 100644 index 0000000..678796a --- /dev/null +++ b/src/schemas/student.py @@ -0,0 +1,47 @@ +"""Schemas for the student self-service endpoints. + +Deliberately narrower than the lecturer-facing ``DeploymentResponse`` — +students must never see other students' personal data (other group +members, teacher info, raw ``deployment_parameters``). Keeping the schemas +explicit prevents accidental widening as the lecturer schema evolves. +""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class StudentDeploymentInstanceSummary(BaseModel): + """Minimal instance metadata for a student listing.""" + id: str = Field(..., description="Deployment instance ID") + vm_name: Optional[str] = Field(None, description="Stack name / VM name") + ip_address: Optional[str] = Field(None, description="Floating IP (if assigned)") + + model_config = ConfigDict(from_attributes=True) + + +class StudentTemplateSummary(BaseModel): + """Minimal template metadata — name only, no app.yaml details.""" + name: Optional[str] = Field(None, description="Template name (e.g. 'Ansible Multi-User Ubuntu')") + version: Optional[str] = Field(None, description="Template version (semver)") + + +class StudentDeploymentResponse(BaseModel): + """One deployment as a student sees it. + + Intentionally omits ``deployment_parameters`` (contains teacher info, + other students' personal data, app.yaml parameters), ``course_id`` + (irrelevant for students), and lecturer-specific fields. + """ + id: str = Field(..., description="Deployment ID") + name: str = Field(..., description="Deployment display name") + status: str = Field(..., description="Deployment status (running, failed, ...)") + template: StudentTemplateSummary = Field(..., description="Template summary") + instances: list[StudentDeploymentInstanceSummary] = Field( + default_factory=list, + description="Instances of this deployment the student has any access to", + ) + created_at: Optional[datetime] = Field(None, description="When the deployment was created") + expires_at: Optional[datetime] = Field(None, description="When the deployment will be hard-deleted") + + model_config = ConfigDict(from_attributes=True) diff --git a/src/schemas/template.py b/src/schemas/template.py index 7fb2d2a..896ec0c 100644 --- a/src/schemas/template.py +++ b/src/schemas/template.py @@ -1,5 +1,5 @@ """Template schemas for request/response validation.""" -from pydantic import BaseModel, Field, ConfigDict, computed_field +from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator from datetime import datetime from typing import Any, Optional from src.schemas.template_version import TemplateVersionResponse @@ -13,11 +13,15 @@ class TemplateCreate(BaseModel): - """Schema for creating a template.""" + """Schema for creating a template. + + Icons werden nicht mehr im Metadata-Body übergeben — der Client legt + das Template zunächst ohne Icon an und lädt anschließend optional ein + Bild via ``POST /templates/{id}/icon`` hoch. + """ name: str = Field(..., description="Name of the template", max_length=255) description: Optional[str] = Field(None, description="Template description") repo_url: str = Field(..., description="Git repository URL", max_length=500) - icon_url: Optional[str] = Field(None, description="Icon URL or identifier (mdi:server, fa:server, 🚀, /icons/template.svg)", max_length=500) visibility: str = Field(default="private", description="Template visibility (private/public)") model_config = ConfigDict( @@ -26,7 +30,6 @@ class TemplateCreate(BaseModel): "name": "Python Flask Template", "description": "A template for Flask web applications", "repo_url": "https://github.com/example/flask-template", - "icon_url": "mdi:flask", "visibility": "public" } } @@ -34,11 +37,14 @@ class TemplateCreate(BaseModel): class TemplateUpdate(BaseModel): - """Schema for updating a template.""" + """Schema for updating a template. + + Wie ``TemplateCreate`` — kein Icon-Feld mehr. Bild-Änderungen laufen + über den dedizierten Upload-Endpoint. + """ name: Optional[str] = Field(None, description="Name of the template", max_length=255) description: Optional[str] = Field(None, description="Template description") repo_url: Optional[str] = Field(None, description="Git repository URL", max_length=500) - icon_url: Optional[str] = Field(None, description="Icon URL or identifier (mdi:server, fa:server, 🚀, /icons/template.svg)", max_length=500) visibility: Optional[str] = Field(None, description="Template visibility (private/public) - Only admins can change this") model_config = ConfigDict( @@ -46,7 +52,6 @@ class TemplateUpdate(BaseModel): "example": { "name": "Updated Template Name", "description": "Updated description", - "icon_url": "mdi:server" } } ) @@ -59,8 +64,17 @@ class TemplateResponse(BaseModel): description: Optional[str] = Field(None, description="Template description") owner_id: str = Field(..., description="Owner user ID") repo_url: str = Field(..., description="Git repository URL") - icon_url: Optional[str] = Field(None, description="Icon URL or identifier") visibility: str = Field(..., description="Template visibility") + publish_requested: bool = Field( + default=False, + description=( + "True wenn der Owner das Template als 'öffentlich' angelegt hat, " + "aber noch keine Version genehmigt wurde — Template ist aktuell " + "PRIVATE und wartet auf die Erst-Freigabe. Sobald ein Admin die " + "erste Version approved, flippt visibility auf PUBLIC und dieses " + "Flag wird zurückgesetzt." + ), + ) versions: Optional[list[TemplateVersionResponse]] = Field(None, description="List of template versions") created_at: datetime = Field(..., description="Creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") @@ -71,6 +85,11 @@ class TemplateResponse(BaseModel): # `owner_username` are exposed to clients. owner: Any = Field(default=None, exclude=True, repr=False) + # Internes Feld für die ``icon_path``-Berechnung. Wird von SQLAlchemy + # via ``from_attributes=True`` gefüllt, aus der Response aber + # ausgeblendet — Clients bekommen nur ``icon_path``. + icon: Any = Field(default=None, exclude=True, repr=False) + @computed_field # type: ignore[prop-decorator] @property def owner_name(self) -> Optional[str]: @@ -95,6 +114,23 @@ def owner_username(self) -> Optional[str]: """Cached preferred_username of the owner; ``None`` for legacy users.""" return getattr(self.owner, "username", None) if self.owner else None + @computed_field # type: ignore[prop-decorator] + @property + def icon_path(self) -> Optional[str]: + """Relativer API-Pfad zum Icon-Bild, oder ``None``. + + Wenn ein Icon-Bild via ``POST /templates/{id}/icon`` hochgeladen + wurde → ``/api/v1/templates/{id}/icon``. Sonst ``None`` — der + Client rendert dann einen Default-Placeholder. + + Bewusst *path*, nicht *url*: der Wert enthält keinen Origin und + muss vom Client gegen die API-Base-URL aufgelöst werden (dieselbe + Base-URL, gegen die auch alle anderen ``/api/v1/*``-Calls laufen). + """ + if self.icon is not None: + return f"/api/v1/templates/{self.id}/icon" + return None + model_config = ConfigDict( from_attributes=True, json_schema_extra={ @@ -107,8 +143,8 @@ def owner_username(self) -> Optional[str]: "owner_email": "berg@dhbw.de", "owner_username": "bberg", "repo_url": "https://github.com/example/flask-template", - "icon_url": "mdi:flask", "visibility": "public", + "icon_path": None, "versions": [], "created_at": "2024-11-27T10:00:00Z", "updated_at": "2024-11-27T10:00:00Z" @@ -120,18 +156,39 @@ def owner_username(self) -> Optional[str]: class GithubImportNewTemplate(BaseModel): """Body for `POST /templates/import-from-github` - creates Template + first Version. - New templates are always created with `visibility=private` (matching - `POST /templates`). Visibility is admin-only and changed later via PATCH. + By default the new template is created as ``private`` (owner-only, + no approval flow). Pass ``visibility="public"`` to make it marketplace- + visible — the first version then enters the standard approval flow + (``pending`` unless the caller is an admin). + + Icons werden nach dem Import optional via ``POST /templates/{id}/icon`` + hochgeladen — kein Icon-Feld mehr auf dem Import-Body. """ name: str = Field(..., max_length=255) description: Optional[str] = None - icon_url: Optional[str] = Field(None, max_length=500) github_url: str = Field(..., description=GITHUB_URL_DESCRIPTION, max_length=1000) app_yaml_path: Optional[str] = Field( default=None, description="Path to app.yaml inside the repo. Defaults to 'app.yaml' (root) when only the repo URL is given.", max_length=500, ) + visibility: Optional[str] = Field( + default="private", + description=( + "Template visibility. 'private' (default) = owner-only, no approval; " + "'public' = marketplace-visible, first version enters approval flow." + ), + ) + + @field_validator("visibility") + @classmethod + def _visibility_must_be_known(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return "private" + v = v.lower() + if v not in ("private", "public"): + raise ValueError("visibility must be 'private' or 'public'") + return v model_config = ConfigDict( json_schema_extra={ @@ -140,6 +197,7 @@ class GithubImportNewTemplate(BaseModel): "description": "Provision a Postgres VM", "github_url": "https://github.com/dozilab/templates", "app_yaml_path": "postgres/app.yaml", + "visibility": "private", } } ) @@ -150,6 +208,16 @@ class GithubImportNewVersion(BaseModel): github_url: str = Field(..., description=GITHUB_URL_DESCRIPTION, max_length=1000) app_yaml_path: Optional[str] = Field(default=None, max_length=500) is_active: bool = Field(default=True, description="Mark the imported version as active") + replace_existing: bool = Field( + default=False, + description=( + "Wenn der `app.version`-String im neuen Import bereits existiert: " + "True → bestehende Version-Row (inkl. Files) löschen und durch den " + "neuen Import ersetzen (blockiert wenn aktive Deployments hängen). " + "False (Default) → Backend antwortet mit VERSION_ALREADY_EXISTS, " + "Owner soll im Repo bumpen oder explizit ersetzen wählen." + ), + ) model_config = ConfigDict( json_schema_extra={ @@ -157,7 +225,7 @@ class GithubImportNewVersion(BaseModel): "github_url": "https://github.com/dozilab/templates/tree/v1.1", "app_yaml_path": "postgres/app.yaml", "is_active": True, + "replace_existing": False, } } ) - diff --git a/src/schemas/template_version.py b/src/schemas/template_version.py index faa88fe..c408950 100644 --- a/src/schemas/template_version.py +++ b/src/schemas/template_version.py @@ -111,7 +111,13 @@ class TemplateVersionResponse(BaseModel): version: str = Field(..., description="Semantic version (e.g., 0.2.0)") git_commit_sha: str = Field(..., description="Git commit SHA") is_active: bool = Field(..., description="Whether this version is active") - approval_status: str = Field(..., description="Approval status (pending/approved/rejected/deprecated)") + approval_status: Optional[str] = Field( + None, + description=( + "Approval status (pending/approved/rejected/deprecated) for public " + "templates. Null for private templates — approval doesn't apply." + ), + ) approved_by_id: Optional[str] = Field(None, description="Admin user ID who approved/rejected this version") approved_at: Optional[datetime] = Field(None, description="Approval/rejection timestamp") rejection_reason: Optional[str] = Field(None, description="Optional admin-provided reason when rejected") @@ -150,6 +156,13 @@ class TemplateQueueInfo(BaseModel): name: str owner_id: str visibility: str + publish_requested: bool = Field( + default=False, + description=( + "True when this PRIVATE template is awaiting its first approval " + "before being promoted to PUBLIC. Admin UI shows a hint." + ), + ) model_config = ConfigDict(from_attributes=True) diff --git a/src/services/ansible_service.py b/src/services/ansible_service.py index 9b8e094..c7d1436 100644 --- a/src/services/ansible_service.py +++ b/src/services/ansible_service.py @@ -6,8 +6,9 @@ import tempfile import time from pathlib import Path -from typing import Generator +from typing import Callable, Generator, Optional +from src.utils.cancellation import CancelledException from src.utils.log_sanitizer import sanitize_message from sqlalchemy.orm import Session @@ -39,6 +40,7 @@ def __init__( floating_ip: str, ssh_private_key: str, ssh_user: str = "ubuntu", + cancel_check: Optional[Callable[[], bool]] = None, ): self.db = db self.deployment_id = deployment_id @@ -46,6 +48,11 @@ def __init__( self.ssh_private_key = ssh_private_key self.ssh_user = ssh_user self.log_service = DeploymentLogService(db) + # Cooperative cancellation predicate. Caller passes a closure that + # reads the deployment's current status; the service polls it inside + # long-running loops (SSH wait, playbook subprocess) and raises + # CancelledException as soon as it returns True. Default: no-op. + self._cancel_check = cancel_check or (lambda: False) # ------------------------------------------------------------------ # Public API @@ -63,6 +70,11 @@ def wait_for_ssh(self, timeout: int = SSH_TIMEOUT) -> None: ) deadline = time.monotonic() + timeout while time.monotonic() < deadline: + # Cancel check before each poll — exits within one SSH_RETRY_INTERVAL. + if self._cancel_check(): + raise CancelledException( + f"SSH wait cancelled for {self.floating_ip}" + ) try: with socket.create_connection((self.floating_ip, 22), timeout=5): self._log( @@ -203,6 +215,108 @@ def run_playbooks( details={"playbook": playbook_name}, ) + def fetch_remote_file(self, remote_path: str) -> str | None: + """SCP-style fetch of a remote file via `ssh sudo cat`. + + Used to read root-owned post-deployment artifacts the playbook writes + to /opt/dozilab/ (e.g. activation-link JSON). Direct ssh+sudo cat + beats scp here because target files are mode 0600 root:root and the + Ansible-managed login user is unprivileged. + + Returns the file content as UTF-8 text on success, None on any + failure (missing file, unreachable host, timeout, permission denied, + non-UTF8 bytes). Never raises — callers should treat absence as a + no-op so unrelated apps that never write such files keep working. + """ + with tempfile.NamedTemporaryFile( + mode="w", suffix=".pem", delete=False, prefix="dozilab_ssh_" + ) as key_file: + key_file.write(self.ssh_private_key) + key_path = key_file.name + + try: + Path(key_path).chmod(0o600) + + cmd = [ + "ssh", + "-i", key_path, + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=10", + "-o", "BatchMode=yes", + f"{self.ssh_user}@{self.floating_ip}", + "--", + "sudo", "cat", remote_path, + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + self._log( + DeploymentLogEventType.ANSIBLE_TASK, + f"Remote fetch timed out: {remote_path}", + level=DeploymentLogLevel.WARNING, + ) + return None + + if result.returncode != 0: + stderr = (result.stderr or "").strip() + # File-absent is the expected path for apps that don't write + # such artifacts — log as INFO, not WARNING. + if "No such file" in stderr or "cannot open" in stderr.lower(): + self._log( + DeploymentLogEventType.ANSIBLE_TASK, + f"Remote file not present, skipping: {remote_path}", + level=DeploymentLogLevel.INFO, + ) + else: + self._log( + DeploymentLogEventType.ANSIBLE_TASK, + f"Remote fetch failed ({result.returncode}) for {remote_path}: {sanitize_message(stderr)[:200]}", + level=DeploymentLogLevel.WARNING, + ) + return None + + content = result.stdout + self._log( + DeploymentLogEventType.ANSIBLE_TASK, + f"Fetched {len(content)} bytes from {remote_path}", + ) + return content + finally: + Path(key_path).unlink(missing_ok=True) + + def fetch_remote_json(self, remote_path: str) -> dict | None: + """Thin wrapper over fetch_remote_file that parses JSON. + + Returns None for missing files, unreachable hosts, and malformed + JSON. Malformed JSON is logged at WARNING level; absence is silent. + """ + content = self.fetch_remote_file(remote_path) + if content is None: + return None + try: + data = json.loads(content) + except (ValueError, json.JSONDecodeError) as err: + self._log( + DeploymentLogEventType.ANSIBLE_TASK, + f"Remote file {remote_path} is not valid JSON: {err}", + level=DeploymentLogLevel.WARNING, + ) + return None + if not isinstance(data, dict): + self._log( + DeploymentLogEventType.ANSIBLE_TASK, + f"Remote file {remote_path} JSON root is not an object", + level=DeploymentLogLevel.WARNING, + ) + return None + return data + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -249,6 +363,23 @@ def _run_playbook( assert process.stdout is not None for raw_line in process.stdout: + # Cancel check on every yielded line. Granularity = whatever + # ansible-playbook prints; typically sub-second. On cancel we + # SIGTERM the subprocess, give it 2s to clean up, then SIGKILL. + if self._cancel_check(): + self._log( + DeploymentLogEventType.ANSIBLE_FAILED, + "Ansible execution cancelled — terminating subprocess", + level=DeploymentLogLevel.WARNING, + ) + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + raise CancelledException("Ansible execution cancelled") + line = sanitize_message(raw_line.rstrip()) if not line: continue diff --git a/src/services/course_filter_service.py b/src/services/course_filter_service.py new file mode 100644 index 0000000..a722835 --- /dev/null +++ b/src/services/course_filter_service.py @@ -0,0 +1,91 @@ +"""Course filter service for business logic.""" +import logging +from typing import Optional +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from src.core.exceptions import ConflictException, NotFoundException +from src.models.course_filter import CourseFilter +from src.repositories.course_filter_repository import CourseFilterRepository +from src.schemas.course_filter import CourseFilterCreate, CourseFilterUpdate + +logger = logging.getLogger(__name__) + + +class CourseFilterService: + """Service for course-filter business logic.""" + + def __init__(self, db: Session): + self.db = db + self.repo = CourseFilterRepository(db) + + def list_filters( + self, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + ) -> tuple[list[CourseFilter], int]: + return self.repo.get_all_filtered(skip=skip, limit=limit, search=search) + + def get_filter(self, filter_id: str | UUID) -> CourseFilter: + instance = self.repo.get_by_id(filter_id) + if not instance: + raise NotFoundException(f"CourseFilter with ID {filter_id} not found") + return instance + + def create_filter(self, data: CourseFilterCreate) -> CourseFilter: + """Create a filter; rejects duplicate ``name`` with a 409. + + Vor-Check fängt den häufigen Fall sauber ab; die DB-Constraint bleibt + als Race-Schutz und wird ebenfalls auf 409 gemappt. + """ + if self.repo.get_by_name(data.name): + raise ConflictException(f"Course filter with name '{data.name}' already exists") + + try: + return self.repo.create(name=data.name) + except IntegrityError as e: + self.db.rollback() + # ``extra={"name": ...}`` would collide with ``LogRecord.name`` + # and raise inside logging.makeRecord. Use a distinct key. + logger.warning( + "Unique violation on course_filters.name (race)", + extra={"filter_name": data.name, "error": str(getattr(e, "orig", e))}, + ) + raise ConflictException( + f"Course filter with name '{data.name}' already exists" + ) + + def update_filter(self, filter_id: UUID, data: CourseFilterUpdate) -> CourseFilter: + instance = self.get_filter(filter_id) + + # ``name`` is required at the schema level, so ``new_name`` is always + # present and non-blank here. A no-op (same name) still flows through + # so ``updated_at`` advances — that's a fine default for PATCH. + new_name = data.name + + if new_name != instance.name: + existing = self.repo.get_by_name(new_name) + if existing and existing.id != instance.id: + raise ConflictException( + f"Course filter with name '{new_name}' already exists" + ) + + try: + updated = self.repo.update(filter_id, name=new_name) + except IntegrityError as e: + self.db.rollback() + logger.warning( + "Unique violation on course_filters.name during update (race)", + extra={"id": str(filter_id), "filter_name": new_name, "error": str(getattr(e, "orig", e))}, + ) + raise ConflictException( + f"Course filter with name '{new_name}' already exists" + ) + return updated or instance + + def delete_filter(self, filter_id: UUID) -> bool: + self.get_filter(filter_id) + return self.repo.delete(filter_id) diff --git a/src/services/credential_generator_service.py b/src/services/credential_generator_service.py index 66c3a94..7e1450f 100644 --- a/src/services/credential_generator_service.py +++ b/src/services/credential_generator_service.py @@ -5,6 +5,7 @@ from typing import Any from src.schemas.deployment import StackAssignment, TeacherInfo +from src.services.ssh_keypair_generator_service import generate_ed25519_keypair _SPECIAL = "!@#$%^&*" @@ -25,9 +26,16 @@ def _generate_password(length: int = 16) -> str: def _sanitize_username(name: str) -> str: - """Convert any string to a valid Unix username (max 32 chars).""" - username = name.lower().replace(" ", "-").replace(".", "-") - username = re.sub(r"[^a-z0-9\-_]", "", username) + """Convert any string to a valid Unix username (max 32 chars). + + Hyphens are mapped to underscores so the result also satisfies stricter + identifier rules (e.g. PostgreSQL role / database names: ``[a-z_][a-z0-9_]*``) + without quoting. The previous behavior used hyphens, which broke + ``ansible_postgres_group_db`` for group names containing spaces / dots + (``"Gruppe 1"`` → ``"gruppe-1"`` → rejected by Postgres-identifier asserts). + """ + username = name.lower().replace(" ", "_").replace(".", "_").replace("-", "_") + username = re.sub(r"[^a-z0-9_]", "", username) if username and username[0].isdigit(): username = "u" + username return (username or "user")[:32] @@ -54,11 +62,20 @@ def _build_credential_entry( spec: dict[str, Any], context: dict[str, Any], ) -> dict[str, Any]: - """Build a single credential dict by resolving all field values.""" - result = {} + """Build a single credential dict by resolving all field values. + + Magic markers: + ``password: generate`` → generates a 16-char complex password. + ``ssh_key: generate`` → generates an Ed25519 keypair; the field + expands to ``{"private_key": ..., "public_key": ...}``. + """ + result: dict[str, Any] = {} for field, value in spec.items(): if value == "generate": - result[field] = _generate_password() + if field == "ssh_key": + result[field] = generate_ed25519_keypair() + else: + result[field] = _generate_password() else: result[field] = _resolve_field(str(value), context) return result @@ -77,8 +94,8 @@ class CredentialGeneratorService: stack_assignment=stack_assignment, teacher=teacher, ) - # creds["students"] → list, one entry per group - # creds["teacher"] → dict + # creds["deployment_groups"] → list, one entry per group + # creds["teacher"] → dict """ @staticmethod @@ -91,19 +108,23 @@ def generate( Args: credentials_spec: The parsed credentials block from app.yaml. - {"per_student": [...], "teacher": [...]} + {"per_group": [...], "teacher": [...]} stack_assignment: The stack's groups and students. teacher: Teacher info from Keycloak. Returns: { - "students": [ - { - "username": "gruppe01", - "email": "...", - "group_name": "Gruppe 1", + "deployment_groups": [ # NOT "groups": that name + { # collides with Ansible's + "username": "gruppe01", # built-in inventory dict + "email": "...", # when handed to playbooks + "group_name": "Gruppe 1", # as --extra-vars. "group_index": 1, - "linux": {"username": "gruppe01", "password": "..."}, + "linux": { + "username": "gruppe01", + "password": "...", # optional, only if app.yaml asks for it + "ssh_key": {"private_key": ..., "public_key": ...}, # optional + }, "postgres": {"db_user": "grp01", "db_name": "db_g01", "password": "..."}, ... }, @@ -112,13 +133,17 @@ def generate( "teacher": { "username": "prof-berg", "email": "...", - "linux": {"username": "prof-berg", "password": "..."}, # always added + "linux": { + "username": "prof-berg", + "password": "...", # always generated + "ssh_key": {"private_key": ..., "public_key": ...}, # ALWAYS generated (admin key) + }, "postgres": {"db_user": "teacher", "password": "..."}, ... } } """ - per_student_specs: list[dict] = credentials_spec.get("per_student") or [] + per_group_specs: list[dict] = credentials_spec.get("per_group") or [] teacher_specs: list[dict] = credentials_spec.get("teacher") or [] # --- Teacher --- @@ -133,10 +158,14 @@ def generate( teacher_creds: dict[str, Any] = { "username": teacher_username, "email": teacher.email, - # linux is always generated for the teacher so Ansible can connect + # linux is always generated for the teacher so Ansible can connect. + # The SSH key is ALWAYS generated too — it serves as the teacher's + # admin key, giving them direct sudo access to every VM of the + # deployment regardless of what the app.yaml requests. "linux": { "username": teacher_username, "password": _generate_password(), + "ssh_key": generate_ed25519_keypair(), }, } for spec_item in teacher_specs: @@ -148,15 +177,15 @@ def generate( else: teacher_creds[cred_type] = _build_credential_entry(fields, teacher_ctx) - # --- Students (one entry per group) --- - students: list[dict[str, Any]] = [] + # --- Groups (one entry per group) --- + groups: list[dict[str, Any]] = [] for group in stack_assignment.groups: # Use group name as the shared Linux username for the group group_username = _sanitize_username(group.group_name) # Use first student's email as group email (or generate a fallback) group_email = group.students[0].email if group.students else f"{group_username}@dozilab.local" - student_ctx = { + group_ctx = { "username": group_username, "email": group_email, "group_name": group.group_name, @@ -168,6 +197,10 @@ def generate( "email": group_email, "group_name": group.group_name, "group_index": group.group_index, + # Forwarded so deploy_tasks can stamp DeploymentInstanceAccess.group_id + # for this group's credentials, enabling student self-service filtering. + # None when the wizard didn't pass a persisted CourseGroup id (legacy flow). + "course_group_id": group.course_group_id, "students": [ { "id": s.id, @@ -180,13 +213,18 @@ def generate( ], } - for spec_item in per_student_specs: + for spec_item in per_group_specs: for cred_type, fields in spec_item.items(): - entry[cred_type] = _build_credential_entry(fields, student_ctx) + entry[cred_type] = _build_credential_entry(fields, group_ctx) - students.append(entry) + groups.append(entry) return { - "students": students, + # The output key is ``deployment_groups`` — NOT ``groups`` — because + # Ansible reserves ``groups`` as the inventory dict (mapping group + # names to host lists). Passing our list as --extra-vars under that + # name would silently lose to Ansible's built-in. See + # https://docs.ansible.com/ansible/latest/reference_appendices/special_variables.html + "deployment_groups": groups, "teacher": teacher_creds, } diff --git a/src/services/deployment_credential_service.py b/src/services/deployment_credential_service.py index d891bc4..2f0402b 100644 --- a/src/services/deployment_credential_service.py +++ b/src/services/deployment_credential_service.py @@ -1,12 +1,15 @@ """Persist deployment credentials produced by the per-stack ``user_json``.""" from __future__ import annotations +import logging from typing import Any from sqlalchemy.orm import Session from src.models.deployment_instance import DeploymentInstance, DeploymentInstanceStatus from src.models.deployment_instance_access import AccessType, DeploymentInstanceAccess +logger = logging.getLogger(__name__) + class DeploymentCredentialService: @@ -40,6 +43,12 @@ def persist_credentials_for_stack( access_type=entry["access_type"], username=entry.get("username"), password=entry.get("password"), + ssh_private_key=entry.get("ssh_private_key"), + # group_id is None for admin credentials (teacher) and for + # legacy callers that didn't pass course_group_id. Students + # can only see rows with a non-NULL group_id matching one + # of their group memberships — see src/api/student.py. + group_id=entry.get("group_id"), connection_url=entry.get("connection_url"), port=entry.get("port"), ) @@ -65,6 +74,11 @@ def _extract_access_entries( "access_type": AccessType.SSH, "username": username, "password": cred.get("password"), + "ssh_private_key": cred.get("ssh_private_key"), + # ``group_id`` stamps the access row with the CourseGroup it + # belongs to so student-self-service can filter by membership. + # Omitted for legacy callers → row stays NULL → invisible to students. + "group_id": cred.get("group_id"), "connection_url": f"ssh {username}@{floating_ip}" if username and floating_ip else None, "port": 22, }) @@ -75,6 +89,10 @@ def _extract_access_entries( "access_type": AccessType.SSH, "username": username, "password": admin.get("password"), + "ssh_private_key": admin.get("ssh_private_key"), + # Admin (teacher) credentials are intentionally NOT tied to a + # group — group_id stays NULL so students never see them. + "group_id": None, "connection_url": f"ssh {username}@{floating_ip}" if username and floating_ip else None, "port": 22, }) @@ -91,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, }) @@ -101,8 +126,117 @@ 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, }) - return [e for e in entries if e.get("password")] + return [e for e in entries if e.get("password") or e.get("ssh_private_key")] + + def persist_activation_links( + self, + instance_id: str, + overleaf_users_json: dict[str, Any], + username_to_group_id: dict[str, str | None], + ) -> int: + """Append ACTIVATION_LINK access rows to an existing DeploymentInstance. + + Used for apps that generate one-time activation/setup links inside the + playbook (no password, no SSH key) and write them to a JSON file on + the VM that ``AnsibleService.fetch_remote_json`` then reads back. + Currently driven by ``ansible_overleaf_latex_lab``; the input shape + below is the contract any future app must follow to opt in. + + Expected input shape:: + + { + "admin": {"email": str, "activation_url": str}, + "groups": [{"username": str, "email": str, + "activation_url": str}, ...] + } + + ``username_to_group_id`` maps each playbook-side group ``username`` + (e.g. ``"gruppe01"``) to the corresponding ``course_groups.id``. The + caller builds it from ``generated["deployment_groups"]``. An entry + whose username is **not** in the map is skipped with a warning rather + than written with ``group_id=NULL`` (that would leak the link to no + student via the self-service filter — safer to omit it and surface + the discrepancy in logs). + + Bypasses the ``_extract_access_entries`` password/key filter on + purpose: that filter encodes the pre-Ansible "no password ⇒ nothing + to store" invariant, which we don't want to weaken just for this + post-Ansible path. + + Args: + instance_id: ID of the already-persisted ``DeploymentInstance`` + this stack belongs to. + overleaf_users_json: Parsed JSON read back from the VM. + username_to_group_id: ``{playbook_username: course_groups.id}``. + Use ``None`` as the value to deliberately produce an admin / + lecturer-only row (currently unused — admin uses the + separate ``admin`` block in the JSON). + + Returns: + Number of access rows written. + """ + instance = self.db.get(DeploymentInstance, instance_id) + if instance is None: + raise ValueError( + f"persist_activation_links: DeploymentInstance {instance_id} not found" + ) + + written = 0 + + # Admin entry — always group_id=None so only lecturers see it. + admin = overleaf_users_json.get("admin") or {} + admin_url = (admin.get("activation_url") or "").strip() + if admin_url: + self.db.add( + DeploymentInstanceAccess( + deployment_instance_id=instance_id, + access_type=AccessType.ACTIVATION_LINK, + # Show the admin email as the "username" column in the UI + # — reads better than NULL. + username=admin.get("email"), + connection_url=admin_url, + group_id=None, + ) + ) + written += 1 + + # Per-group entries — must resolve to a known course_groups.id. + for entry in overleaf_users_json.get("groups") or []: + url = (entry.get("activation_url") or "").strip() + if not url: + continue + username = entry.get("username") + if not username: + logger.warning( + "persist_activation_links: group entry missing 'username', skipping: %r", + entry, + ) + continue + if username not in username_to_group_id: + logger.warning( + "persist_activation_links: no course_group mapping for username '%s'; " + "skipping rather than writing a NULL group_id row", + username, + ) + continue + gid = username_to_group_id[username] + self.db.add( + DeploymentInstanceAccess( + deployment_instance_id=instance_id, + access_type=AccessType.ACTIVATION_LINK, + username=entry.get("email") or username, + connection_url=url, + group_id=gid, + ) + ) + written += 1 + + self.db.commit() + return written diff --git a/src/services/deployment_service.py b/src/services/deployment_service.py index c2335ad..1930743 100644 --- a/src/services/deployment_service.py +++ b/src/services/deployment_service.py @@ -14,6 +14,7 @@ from src.models.openstack_project import OpenstackProject from src.core.exceptions import NotFoundException from src.core.exceptions import BadRequestException +from src.core.exceptions import ForbiddenException from src.services.template_version_file_service import TemplateVersionFileService from src.tasks.deploy_tasks import deploy_stack from src.utils.deployment_expiry import compute_expiry, compute_extension, utcnow @@ -29,7 +30,7 @@ def __init__(self, db: Session): self.openstack_repo = OpenstackProjectRepository(db) self.log_service = DeploymentLogService(db) - def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union[str, None] = None) -> Deployment: + def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union[str, None] = None, is_admin: bool = False) -> Deployment: """Create a new deployment and trigger async deployment task. Args: @@ -47,12 +48,53 @@ def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union template_version = self.db.query(TemplateVersion).filter( TemplateVersion.id == deployment_data.template_version_id ).first() - + if not template_version: raise NotFoundException( f"Template version with ID '{deployment_data.template_version_id}' not found" ) - + + # Fetch parent template up front so we can: + # (1) gate private templates to owner-only deploys, + # (2) reuse it later for template-specific user_json generation. + from src.models.template import Template, TemplateVisibility + template = self.db.query(Template).filter( + Template.id == template_version.template_id + ).first() + if not template: + raise NotFoundException( + f"Template not found for version {template_version.id}" + ) + + # Resolve caller's local user id from the Keycloak ID in the payload. + # This is needed for both the private-template owner check below AND + # the OpenStack-project ownership validation further down — so we do + # the lookup once here and reuse `teacher_user`. + from src.models.user import User as UserModel + teacher_user = self.db.query(UserModel).filter( + UserModel.external_id == deployment_data.teacher.id + ).first() + if not teacher_user: + raise NotFoundException( + f"Teacher user not found for Keycloak ID {deployment_data.teacher.id}" + ) + + # Gate: private templates can only be deployed by their owner OR by + # an admin. Other lecturers cannot run private templates even if they + # somehow obtained the template_version_id — that's the whole point of + # "private". Admins are the system-wide bypass for management actions + # (delete/edit) and we extend the same trust to running deploys. For + # public templates the visibility/approval system already controls + # who sees the template at all, no extra gate here. + if ( + template.visibility != TemplateVisibility.PUBLIC + and template.owner_id != teacher_user.id + and not is_admin + ): + raise ForbiddenException( + "Only the template owner or an admin can deploy a private template version" + ) + # Validate template parameters required by the template version template_file_service = TemplateVersionFileService(self.db) try: @@ -99,48 +141,61 @@ def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union if type_errors: raise BadRequestException(f"Type validation errors: {'; '.join(type_errors)}") - # Get template name for template-specific user_json generation - from src.models.template import Template - template = self.db.query(Template).filter( - Template.id == template_version.template_id - ).first() - - if not template: - raise NotFoundException( - f"Template not found for version {template_version.id}" - ) - # Get or create Course entry based on keycloak_course_id # The course_id from frontend is the Keycloak group ID from src.models.course import Course + from src.models.course_group import CourseGroup keycloak_course_id = deployment_data.course_id - + course = self.db.query(Course).filter( Course.keycloak_course_id == keycloak_course_id ).first() - + if not course: - # Auto-create course entry with deployment name as course name course = Course( name=deployment_data.name, keycloak_course_id=keycloak_course_id ) self.db.add(course) - self.db.flush() # Get the ID without committing - - # Resolve and validate the target OpenStack project: must belong to the - # teacher submitting this request. Persisting the local FK here makes the - # deployment-to-project relationship explicit instead of re-deriving it - # from teacher.id at every read site (deploy/restart/delete tasks). - from src.models.user import User as UserModel - teacher_user = self.db.query(UserModel).filter( - UserModel.external_id == deployment_data.teacher.id - ).first() - if not teacher_user: - raise NotFoundException( - f"Teacher user not found for Keycloak ID {deployment_data.teacher.id}" - ) + self.db.flush() + + # Get or create CourseGroup rows for every group in the stack assignments. + # This ensures group_id is always stamped onto credential rows so students + # can see their credentials via /api/v1/student/, regardless of whether the + # wizard already passed course_group_id (first deployment = no pre-existing rows). + group_name_to_id: dict[str, str] = { + g.name: g.id + for g in self.db.query(CourseGroup).filter( + CourseGroup.course_id == str(course.id) + ).all() + } + for sa in deployment_data.stack_assignments: + for group in sa.groups: + if group.group_name not in group_name_to_id: + new_group = CourseGroup( + course_id=str(course.id), + name=group.group_name, + ) + self.db.add(new_group) + self.db.flush() + group_name_to_id[group.group_name] = new_group.id + # Backfill course_group_id so deploy_tasks stamps the FK onto + # DeploymentInstanceAccess rows — even on the first deployment. + if not group.course_group_id: + group.course_group_id = group_name_to_id[group.group_name] + + # Sync the students named in the wizard payload into the membership + # tables. See _sync_student_memberships for the rationale. + self._sync_student_memberships( + course_id=str(course.id), + stack_assignments=deployment_data.stack_assignments, + group_name_to_id=group_name_to_id, + ) + # Validate the target OpenStack project: must belong to the teacher + # submitting this request. ``teacher_user`` was already resolved + # earlier (private-template gate); re-using it here avoids a second + # round-trip to the users table. openstack_project = self.openstack_repo.get_by_id( deployment_data.openstack_project_id ) @@ -203,6 +258,81 @@ def create_deployment(self, deployment_data: DeploymentCreate, request_id: Union return deployment + def _sync_student_memberships( + self, + *, + course_id: str, + stack_assignments, + group_name_to_id: dict, + ) -> None: + """Make every student named in the wizard payload visible to the + student self-service endpoint. + + The /api/v1/student/ list endpoint joins through + ``users → course_members → group_members → course_groups → + deployment_instance_access``. Stamping ``group_id`` onto the access + rows is necessary but not sufficient: without matching membership + rows the INNER JOINs return empty and the student sees nothing, + even though credentials with their group_id exist. This method + creates the missing rows up-front. + + Idempotent: a re-deploy or a second deploy with the same students + re-uses the existing rows. + + A student who has never logged in yet has no users row. We create + a minimal one from the StudentInfo claims; the next real login + flows through UserSyncService.sync_user_from_token, which is keyed + on external_id and finds + refreshes this row rather than + duplicating it. + """ + from src.models.course_member import CourseMember + from src.models.group_member import GroupMember + from src.models.user import User + + for sa in stack_assignments: + for group in sa.groups: + group_id = group_name_to_id[group.group_name] + for student in group.students: + user = self.db.query(User).filter( + User.external_id == student.id + ).first() + if not user: + user = User( + external_id=student.id, + display_name=( + f"{student.first_name} {student.last_name}".strip() + or student.username + ), + email=student.email, + username=student.username, + ) + self.db.add(user) + self.db.flush() + + course_member = self.db.query(CourseMember).filter( + CourseMember.user_id == user.id, + CourseMember.course_id == course_id, + CourseMember.left_at.is_(None), + ).first() + if not course_member: + course_member = CourseMember( + user_id=user.id, + course_id=course_id, + ) + self.db.add(course_member) + self.db.flush() + + group_member = self.db.query(GroupMember).filter( + GroupMember.group_id == group_id, + GroupMember.course_member_id == course_member.id, + ).first() + if not group_member: + self.db.add(GroupMember( + group_id=group_id, + course_member_id=course_member.id, + )) + self.db.flush() + def extend_deployment(self, deployment_id: str, runtime_months: int) -> Deployment: """Push ``expires_at`` out by ``runtime_months`` months. diff --git a/src/services/github_import_service.py b/src/services/github_import_service.py index cb0fff5..cfe4fb5 100644 --- a/src/services/github_import_service.py +++ b/src/services/github_import_service.py @@ -34,6 +34,7 @@ from sqlalchemy.orm import Session from src.core.exceptions import BadRequestException, NotFoundException, ForbiddenException +from src.models.deployment import Deployment from src.models.template import Template, TemplateVisibility from src.models.template_version import TemplateVersion, TemplateVersionApprovalStatus from src.models.template_version_file import TemplateVersionFile, FileType @@ -43,6 +44,7 @@ from src.repositories.template_version_file_repository import TemplateVersionFileRepository from src.services.github_app_service import GITHUB_API_VERSION, GithubAppService from src.services.github_installation_service import GithubInstallationService +from src.utils import version_validator from src.utils.app_manifest_parser import AppManifestParser logger = logging.getLogger(__name__) @@ -332,30 +334,47 @@ def import_to_new_template( app_yaml_path: Optional[str], name: str, description: Optional[str], - icon_url: Optional[str], owner_user_id: str, owner_user_roles: list[str], + visibility: TemplateVisibility = TemplateVisibility.PRIVATE, ) -> Template: """Create a brand-new Template + first TemplateVersion populated from GitHub. - New templates are always created PRIVATE (matching `TemplateService.create_template`); - admins promote to public later via PATCH. The first version follows the standard - per-version approval rules (PENDING unless admin + public). + ``visibility`` defaults to PRIVATE. Wird PUBLIC explizit übergeben, + landet das Template als PRIVATE + ``publish_requested=True`` in der DB: + Es bleibt bis zur ersten admin-Genehmigung versteckt und flippt beim + ``approve_version()`` atomar auf PUBLIC. So sieht der Owner sofort + „wartet auf Erst-Freigabe" statt eines fälschlich-öffentlichen + Templates ohne approved Version. + + Admin-Caller umgehen den Wunsch-Umweg nicht — die admin-spezifische + Auto-Approval setzt direkt approval_status=APPROVED auf der Version + und löst dadurch im approve_version()-Pfad den Flip aus. Hier in + ``import_to_new_template`` wird approve_version() aber NICHT + aufgerufen, daher müssen wir den Flip für Admin-Caller hier explizit + machen. """ + # Wenn der Caller PUBLIC will: Template als PRIVATE + publish_requested + # anlegen. ``_initial_approval`` erkennt das (via publish_requested) und + # vergibt PENDING/APPROVED genauso wie auf einem echten PUBLIC-Template. + wants_public = visibility == TemplateVisibility.PUBLIC + effective_visibility = ( + TemplateVisibility.PRIVATE if wants_public else visibility + ) template = Template( id=str(uuid4()), name=name, description=description, owner_id=owner_user_id, repo_url=github_url, - icon_url=icon_url, - visibility=TemplateVisibility.PRIVATE, + visibility=effective_visibility, + publish_requested=wants_public, ) self.db.add(template) self.db.flush() try: - self._import_version_for_template( + version = self._import_version_for_template( template=template, github_url=github_url, app_yaml_path=app_yaml_path, @@ -367,6 +386,25 @@ def import_to_new_template( self.db.rollback() raise + # Admin-Caller, die sich ein „öffentlich" gewünscht haben: die erste + # Version ist bereits APPROVED (via _initial_approval), also gilt der + # Promotion-Trigger sofort. Wir flippen Template-State hier atomar + # statt einen separaten approve_version-Roundtrip zu verlangen. + if ( + wants_public + and version.approval_status == TemplateVersionApprovalStatus.APPROVED + ): + template.visibility = TemplateVisibility.PUBLIC + template.publish_requested = False + logger.info( + "Template promoted to public on admin-import (auto-approval)", + extra={ + "template_id": template.id, + "version_id": str(version.id), + "owner_id": owner_user_id, + }, + ) + self.db.commit() self.db.refresh(template) return template @@ -380,8 +418,16 @@ def import_to_existing_template( is_active: bool, user_id: str, user_roles: list[str], + replace_existing: bool = False, ) -> TemplateVersion: - """Append a new TemplateVersion (with files) to an existing Template.""" + """Append a new TemplateVersion (with files) to an existing Template. + + ``replace_existing`` aktiviert den Replace-Pfad bei Kollision auf der + Versionsnummer: existiert bereits eine Row mit demselben + ``version``-String, wird sie inkl. ihrer Files gelöscht und durch + die neu importierte ersetzt. Blockiert, wenn aktive Deployments an + der Bestands-Row hängen (`VERSION_REPLACE_BLOCKED_BY_DEPLOYMENTS`). + """ template = self.template_repo.get_by_id(template_id) if not template: raise NotFoundException(f"Template with ID {template_id} not found") @@ -399,6 +445,7 @@ def import_to_existing_template( user_id=user_id, user_roles=user_roles, is_active=is_active, + replace_existing=replace_existing, ) self.db.commit() self.db.refresh(version) @@ -417,6 +464,7 @@ def _import_version_for_template( user_id: str, user_roles: list[str], is_active: bool, + replace_existing: bool = False, ) -> TemplateVersion: parsed_url = self.parse_github_url(github_url) @@ -515,17 +563,91 @@ def _import_version_for_template( "size": len(content.encode("utf-8")), }) - # Determine version string + # Versions-String: ``app.yaml.app.version`` ist Pflicht und einzige + # Quelle. Kein Timestamp-Fallback mehr — fehlende oder leere Versionen + # sind ein expliziter Fehler, damit das UI dem Owner einen + # verlinkbaren „im Repo bumpen"-Pfad anbieten kann (siehe + # version_validator.ERR_MISSING_IN_MANIFEST). manifest_version = ((parsed_manifest.get("app") or {}).get("version") or "").strip() - version_string = manifest_version or self._derive_fallback_version(template.id) + if not manifest_version: + raise BadRequestException( + "`app.yaml` enthält kein `app.version`-Feld (oder es ist leer). " + "Bitte ergänze z. B. `app:\\n version: 1.0.0` im Repo und versuche erneut.", + code=version_validator.ERR_MISSING_IN_MANIFEST, + details={"manifest_path": effective_app_yaml_path}, + ) - # Refuse duplicate (template_id, git_commit_sha) - existing constraint - existing = self.version_repo.get_by_commit_sha(template.id, commit_sha) - if existing: + version_validator.assert_valid_semver(manifest_version) + version_string = manifest_version + + # Refuse duplicate (template_id, git_commit_sha) - existing constraint. + # Wird BEVOR der Versions-String-Check gemacht, weil derselbe Commit + # garantiert dieselbe app.yaml und damit dieselbe Versionsnummer hat — + # die Fehlermeldung „commit bereits importiert" ist hier präziser. + existing_by_sha = self.version_repo.get_by_commit_sha(template.id, commit_sha) + if existing_by_sha: raise BadRequestException( f"This template already has a version for commit {commit_sha[:8]}" ) + # Versions-String-Uniqueness + Monotonie gegen die existierenden + # Versionen. Bei Kollision schlägt der Validator mit + # ERR_ALREADY_EXISTS an, was das Frontend differenziert (Replace- + # Pfad anbieten) — außer der Caller hat replace_existing=True + # mitgegeben, dann lassen wir die Bestands-Row gleich austauschen. + existing_versions = template.versions or self.version_repo.get_by_template_id( + template.id, active_only=False + ) + existing_strings = [v.version for v in existing_versions] + + replace_target: TemplateVersion | None = None + if replace_existing and version_string in existing_strings: + # Replace-Pfad: existierende Row mit demselben String identifizieren, + # auf Deployment-Refs prüfen, dann löschen. Files sind via + # ``cascade="all, delete-orphan"`` an der Version verbunden. + replace_target = next( + (v for v in existing_versions if v.version == version_string), + None, + ) + if replace_target is None: + # Race oder Inkonsistenz — defensiv mit klarer Meldung. + raise BadRequestException( + f"Cannot find existing version row for '{version_string}' to replace.", + ) + + deployment_count = ( + self.db.query(Deployment) + .filter(Deployment.template_version_id == replace_target.id) + .count() + ) + if deployment_count > 0: + raise BadRequestException( + f"Version '{version_string}' kann nicht ersetzt werden: " + f"{deployment_count} aktive Deployment(s) verwenden sie. " + "Bitte bumpe `app.version` im Repo statt zu ersetzen.", + code=version_validator.ERR_REPLACE_BLOCKED_BY_DEPLOYMENTS, + details={ + "version": version_string, + "deployment_count": deployment_count, + "existing_version_id": replace_target.id, + }, + ) + + # Replace genehmigt — alte Row löschen. Die Monotonie-Vergleichs- + # Liste schließt die Replace-Target-Version aus, weil sie gleich + # ersetzt wird; sonst würde ``assert_strictly_greater`` immer + # auf ALREADY_EXISTS triggern. + self.db.delete(replace_target) + self.db.flush() + existing_strings = [s for s in existing_strings if s != version_string] + else: + # Kein Replace gewünscht / Kollision möglich → Validator wirft + # entweder ALREADY_EXISTS oder NOT_STRICTLY_GREATER mit Details. + version_validator.assert_strictly_greater( + version_string, + existing_strings, + ) + approval_status = self._initial_approval(template, user_roles) version = TemplateVersion( @@ -599,14 +721,28 @@ def _import_version_for_template( @staticmethod def _initial_approval( template: Template, user_roles: list[str] - ) -> TemplateVersionApprovalStatus: - is_admin = UserRole.ADMIN.value in user_roles - if is_admin and template.visibility == TemplateVisibility.PUBLIC: - return TemplateVersionApprovalStatus.APPROVED - return TemplateVersionApprovalStatus.PENDING - - @staticmethod - def _derive_fallback_version(template_id: str) -> str: - """Fallback if app.yaml has no `app.version` field.""" - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") - return f"0.0.0+{timestamp}" + ) -> TemplateVersionApprovalStatus | None: + """Map (template, caller) to the initial approval status of a new version. + + - PUBLIC templates: admin caller → ``APPROVED`` (auto-promote); + everyone else → ``PENDING`` (admin review needed). + - PRIVATE templates WITH ``publish_requested=True``: same as PUBLIC. + The template hasn't been promoted to PUBLIC yet — that happens + atomically on the first approve_version() call — but the approval + flow is already running, so new versions must enter PENDING. + - PRIVATE templates without publish_requested: ``None`` — approval + doesn't apply (owner-only). + + Mirrored helper — keep in sync with the identical copy in + ``TemplateVersionService._initial_approval`` (template_version_service.py). + """ + is_public = template.visibility == TemplateVisibility.PUBLIC + is_pending_public = bool(getattr(template, "publish_requested", False)) + if not is_public and not is_pending_public: + return None + is_admin = UserRole.ADMIN.value in (user_roles or []) + return ( + TemplateVersionApprovalStatus.APPROVED + if is_admin + else TemplateVersionApprovalStatus.PENDING + ) diff --git a/src/services/lecturer_service.py b/src/services/lecturer_service.py new file mode 100644 index 0000000..07e8d23 --- /dev/null +++ b/src/services/lecturer_service.py @@ -0,0 +1,257 @@ +"""Lecturer administration — admin-only listing, detail view, and cascade delete. + +Rationale: the codebase intentionally does not store user roles (Keycloak is +source-of-truth), so "who is a lecturer" is defined structurally as "a user +who owns templates or OpenStack projects." Students never satisfy this — +they cannot create either — which lets the /lecturers endpoints exclude +them without a role field. + +Deployment ownership is embedded in ``deployments.deployment_parameters`` +(JSON) rather than a FK column, so the counts here go through that JSON +via a Postgres JSONB path expression in production and a per-row Python +fallback in tests (SQLite has no JSONB). +""" +from __future__ import annotations + +import json +import logging +from typing import Optional + +from sqlalchemy import func, or_, text +from sqlalchemy.orm import Session + +from src.core.exceptions import BadRequestException, NotFoundException +from src.models.deployment import Deployment +from src.models.openstack_project import OpenstackProject +from src.models.template import Template +from src.models.template_version import TemplateVersion +from src.models.user import User + +logger = logging.getLogger(__name__) + + +def _is_postgres(db: Session) -> bool: + """Detect the DB dialect. We use Postgres-only JSONB queries where it + matters for performance, and fall back to a per-row Python scan on + SQLite so the unit tests don't need a real Postgres.""" + return db.bind is not None and db.bind.dialect.name == "postgresql" + + +def _deployments_for_external_id(db: Session, external_id: str) -> list[Deployment]: + """Every Deployment whose stored `teacher.id` == external_id. + + On Postgres we JSONB-index into ``deployment_parameters``. On SQLite we + load and JSON-parse in Python — fine for tests, unacceptable for prod + scale, hence the dialect split.""" + if _is_postgres(db): + return ( + db.query(Deployment) + .filter( + text("deployment_parameters::jsonb -> 'teacher' ->> 'id' = :ext_id") + ) + .params(ext_id=external_id) + .all() + ) + # SQLite fallback for tests: fetch all and filter in Python. + out: list[Deployment] = [] + for d in db.query(Deployment).all(): + if not d.deployment_parameters: + continue + try: + params = json.loads(d.deployment_parameters) + except (json.JSONDecodeError, TypeError): + continue + if params.get("teacher", {}).get("id") == external_id: + out.append(d) + return out + + +class LecturerService: + """Service for the admin-only lecturer management endpoints.""" + + def __init__(self, db: Session): + self.db = db + + # ------------------------------------------------------------------ + # List + # ------------------------------------------------------------------ + + def list_lecturers( + self, + skip: int = 0, + limit: int = 50, + search: Optional[str] = None, + ) -> tuple[list[dict], int]: + """List users who own templates or OpenStack projects. + + Args: + skip: Pagination offset. + limit: Pagination page size. + search: Optional case-insensitive substring match against + display_name, email, or username. + + Returns: + Tuple of (rows, total). Each row is a dict that maps directly + onto ``LecturerListItem``. + """ + # Aggregate counts in one pass: LEFT JOIN both ownership tables, + # then filter to rows that have at least one on either side. We + # deliberately compute the deployment_count in a second step because + # its dialect-specific query would explode the group-by. + template_count = func.count(func.distinct(Template.id)).label("template_count") + osp_count = func.count( + func.distinct(OpenstackProject.id) + ).label("openstack_project_count") + + base = ( + self.db.query( + User.id, + User.external_id, + User.display_name, + User.email, + User.username, + User.last_login_at, + template_count, + osp_count, + ) + .outerjoin(Template, Template.owner_id == User.id) + .outerjoin(OpenstackProject, OpenstackProject.owner_user_id == User.id) + .group_by(User.id) + .having(or_(template_count > 0, osp_count > 0)) + ) + + if search: + like = f"%{search}%" + base = base.filter( + or_( + User.display_name.ilike(like), + User.email.ilike(like), + User.username.ilike(like), + ) + ) + + # Total BEFORE pagination — subquery counts the filtered lecturer set. + total = base.count() + rows = base.order_by(User.display_name.asc().nulls_last() if _is_postgres(self.db) else User.display_name.asc()) \ + .offset(skip) \ + .limit(limit) \ + .all() + + results: list[dict] = [] + for r in rows: + deployment_count = len(_deployments_for_external_id(self.db, r.external_id)) + results.append({ + "id": r.id, + "external_id": r.external_id, + "display_name": r.display_name, + "email": r.email, + "username": r.username, + "last_login_at": r.last_login_at, + "template_count": r.template_count, + "deployment_count": deployment_count, + "openstack_project_count": r.openstack_project_count, + }) + return results, total + + # ------------------------------------------------------------------ + # Detail + # ------------------------------------------------------------------ + + def get_lecturer(self, user_id: str) -> dict: + """Detail view with the full owned/deployed resource lists. + + Raises NotFoundException if the user is not a lecturer (owns no + templates and no OSPs) — same visibility rule as list_lecturers so + the URL space is consistent. + """ + user = self.db.query(User).filter(User.id == user_id).first() + if not user: + raise NotFoundException(f"User {user_id} not found") + + templates = ( + self.db.query(Template).filter(Template.owner_id == user_id).all() + ) + osps = ( + self.db.query(OpenstackProject) + .filter(OpenstackProject.owner_user_id == user_id) + .all() + ) + if not templates and not osps: + raise NotFoundException(f"User {user_id} is not a lecturer") + + deployments = _deployments_for_external_id(self.db, user.external_id) + + # For each template, count active versions once per template so the + # detail view doesn't lie about "empty" templates. + version_counts: dict[str, int] = { + row[0]: row[1] + for row in self.db.query( + TemplateVersion.template_id, + func.count(TemplateVersion.id), + ) + .filter(TemplateVersion.template_id.in_([t.id for t in templates] or [""])) + .group_by(TemplateVersion.template_id) + .all() + } + + return { + "id": user.id, + "external_id": user.external_id, + "display_name": user.display_name, + "email": user.email, + "username": user.username, + "last_login_at": user.last_login_at, + "template_count": len(templates), + "deployment_count": len(deployments), + "openstack_project_count": len(osps), + "templates": [ + { + "id": t.id, + "name": t.name, + "visibility": t.visibility.value if hasattr(t.visibility, "value") else str(t.visibility), + "version_count": version_counts.get(t.id, 0), + } + for t in templates + ], + "deployments": [ + { + "id": d.id, + "name": d.name, + "status": d.status.value if hasattr(d.status, "value") else str(d.status), + "course_id": d.course_id, + "expires_at": d.expires_at, + "created_at": d.created_at, + } + for d in deployments + ], + "openstack_projects": [ + { + "id": op.id, + "openstack_project_name": op.openstack_project_name, + "region_name": op.region_name, + } + for op in osps + ], + } + + # ------------------------------------------------------------------ + # Delete (returns the counts; the actual work is enqueued by the API) + # ------------------------------------------------------------------ + + def preflight_delete(self, user_id: str, requesting_user_id: str) -> dict: + """Validate that the delete is legal and return the summary that + the API endpoint attaches to the 202 response. + + Raises: + NotFoundException: user does not exist or is not a lecturer. + BadRequestException: admin tries to delete themselves. + """ + if user_id == requesting_user_id: + raise BadRequestException("Admins cannot delete their own account") + + detail = self.get_lecturer(user_id) # raises NotFound if missing / not-lecturer + return { + "user_id": user_id, + "deployment_count": detail["deployment_count"], + "template_count": detail["template_count"], + } diff --git a/src/services/openstack_heat_service.py b/src/services/openstack_heat_service.py index dc1bcdc..6d0d52f 100644 --- a/src/services/openstack_heat_service.py +++ b/src/services/openstack_heat_service.py @@ -110,20 +110,35 @@ def create_stack( logger.info(f"Creating Heat stack: {stack_name}") logger.debug(f"Stack parameters: {parameters}") - + # Create stack stack = conn.orchestration.create_stack(preview=False, **stack_params) logger.info(f"Heat stack created successfully: {stack.id}") - # Wait until CREATE_COMPLETE — SDK polls every 5s, timeout 30min - logger.info(f"Waiting for stack {stack.id} to reach CREATE_COMPLETE...") - stack = conn.orchestration.wait_for_status( - stack, - status="CREATE_COMPLETE", - failures=["CREATE_FAILED"], - interval=5, - wait=1800, - ) + # Wait until CREATE_COMPLETE — SDK polls every 5s, timeout 30min. + # If Heat reports CREATE_FAILED the SDK raises ResourceFailure, and + # if the wait times out it raises ResourceTimeout. In BOTH cases + # the stack already exists in OpenStack and must be cleaned up, + # otherwise it becomes orphaned — the caller has no other way to + # learn its id once the exception escapes. Stamp the id onto the + # exception so deploy_tasks can record it and the delete path can + # tear it down. + try: + stack = conn.orchestration.wait_for_status( + stack, + status="CREATE_COMPLETE", + failures=["CREATE_FAILED"], + interval=5, + wait=1800, + ) + except Exception as wait_error: + logger.error( + f"Heat stack {stack.id} did not reach CREATE_COMPLETE: {wait_error}" + ) + # Attach the stack id so callers can still clean up. + wait_error.stack_id = stack.id # type: ignore[attr-defined] + wait_error.stack_name = stack.name # type: ignore[attr-defined] + raise logger.info(f"Stack {stack.id} reached status: {stack.status}") # Read outputs (floating_ip, server_id, etc.) diff --git a/src/services/secret_encryption_service.py b/src/services/secret_encryption_service.py index b555c5c..9a261a6 100644 --- a/src/services/secret_encryption_service.py +++ b/src/services/secret_encryption_service.py @@ -90,34 +90,36 @@ def get_encryption_service(key: Optional[str] = None) -> SecretEncryptionService class EncryptedString(TypeDecorator): """SQLAlchemy type that automatically encrypts/decrypts string values. - + Usage: password: Mapped[str] = mapped_column(EncryptedString(255)) - + IMPORTANT: Never log the decrypted value. + + Behavior on misconfiguration: if ``ENCRYPTION_KEY`` is missing or invalid, + both directions raise ``SecretEncryptionError`` — we never silently store + or return plaintext. Treat ``ENCRYPTION_KEY`` as a hard runtime requirement. """ impl = String cache_ok = True def process_bind_param(self, value: Optional[str], dialect: Any) -> Optional[str]: - """Encrypt value before storing in database.""" + """Encrypt value before storing in database. + + Raises ``SecretEncryptionError`` if encryption is not configured — + better to fail the write than to persist a plaintext secret unnoticed. + """ if value is None: return None - try: - service = get_encryption_service() - return service.encrypt(value) - except SecretEncryptionError: - # If encryption not configured, store as-is (for development only) - # In production, this should raise an error - return value + return get_encryption_service().encrypt(value) def process_result_value(self, value: Optional[str], dialect: Any) -> Optional[str]: - """Decrypt value when loading from database.""" + """Decrypt value when loading from database. + + Raises ``SecretEncryptionError`` if decryption fails (missing key, + wrong key, or corrupted token). The caller should not see a + possibly-encrypted blob masquerading as plaintext. + """ if value is None: return None - try: - service = get_encryption_service() - return service.decrypt(value) - except SecretEncryptionError: - # If decryption fails, return encrypted value (backward compatibility) - return value + return get_encryption_service().decrypt(value) diff --git a/src/services/ssh_keypair_generator_service.py b/src/services/ssh_keypair_generator_service.py new file mode 100644 index 0000000..5e4312c --- /dev/null +++ b/src/services/ssh_keypair_generator_service.py @@ -0,0 +1,48 @@ +"""Generate Ed25519 SSH keypairs for deployment credentials. + +Used by the credential generator to produce per-group and per-teacher SSH keys +that get injected into the VM's ``authorized_keys`` via Ansible. Private keys +are returned in OpenSSH PEM format so users can save them as standard +``~/.ssh/id_ed25519`` files; public keys are returned in the single-line +OpenSSH format expected by ``authorized_keys`` (and by Ansible's +``ansible.posix.authorized_key`` module). + +Ed25519 is the default: shorter than RSA (one-line public key, ~400 byte +private key), well-supported on every modern SSH client/server, and +considered the current best practice. +""" +from __future__ import annotations + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +def generate_ed25519_keypair() -> dict[str, str]: + """Generate a fresh Ed25519 keypair. + + Returns: + ``{"private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\\n...", + "public_key": "ssh-ed25519 AAAA..."}`` + + ``private_key`` is in OpenSSH PEM format (unencrypted) — ready to be + saved as ``~/.ssh/id_ed25519``. + ``public_key`` is in single-line OpenSSH format — ready to be appended + to ``~/.ssh/authorized_keys``. + """ + private_key = Ed25519PrivateKey.generate() + + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + public_openssh = private_key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode("utf-8") + + return { + "private_key": private_pem, + "public_key": public_openssh, + } diff --git a/src/services/template_icon_service.py b/src/services/template_icon_service.py new file mode 100644 index 0000000..2ad99db --- /dev/null +++ b/src/services/template_icon_service.py @@ -0,0 +1,194 @@ +"""Template icon service. + +Kapselt Upload, Auslieferung und Löschen des hochgeladenen Icon-Bilds eines +Templates. Der Endpoint-Layer prüft Rollen und Ownership; der Service prüft +Bild-Format und -Größe und delegiert die eigentliche Datenbank-Interaktion +ans Repository. +""" +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from src.core.config import get_settings +from src.core.exceptions import BadRequestException, ForbiddenException +from src.models.template import Template +from src.models.template_icon import TemplateIcon +from src.repositories.template_icon_repository import TemplateIconRepository +from src.services.template_service import TemplateService + +logger = logging.getLogger(__name__) + + +class TemplateIconService: + """Service für den Upload/Serve/Delete-Lebenszyklus eines Template-Icons.""" + + def __init__(self, db: Session): + self.db = db + self.repo = TemplateIconRepository(db) + self.template_service = TemplateService(db) + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + def get_icon( + self, + template_id: str, + *, + user_id: str, + is_admin: bool = False, + ) -> TemplateIcon: + """Return the icon row for a template, gated by visibility. + + Sichtbarkeitsregeln matchen ``TemplateService.get_template``: + Admin darf alles, Owner darf sein eigenes, Fremde nur PUBLIC-Templates + mit mindestens einer APPROVED Version. Wenn das Template zwar + sichtbar ist, aber kein Icon hochgeladen wurde, wird 404 geworfen. + """ + # ``get_template`` wirft NotFound/Forbidden nach denselben Regeln, + # die auch beim normalen Template-GET greifen. + self.template_service.get_template(template_id, user_id=user_id, is_admin=is_admin) + + icon = self.repo.get_by_template_id(template_id) + if not icon: + # Bewusst 404, nicht 204: der Client bekommt sonst einen + # Content-Type: application/json ohne Body und rätselt. + from src.core.exceptions import NotFoundException + + raise NotFoundException(f"Template {template_id} has no uploaded icon") + return icon + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + def upload_icon( + self, + template_id: str, + *, + content: bytes, + content_type: str, + file_name: Optional[str], + user_id: str, + is_admin: bool = False, + ) -> TemplateIcon: + """Persist a new icon for a template (create or replace). + + Nur Owner oder Admin dürfen ein Icon setzen. Validierung: + - Content-Type muss in ``settings.allowed_icon_content_types`` sein + (Default: PNG/JPEG/WebP) → sonst 415. + - ``content`` darf ``settings.max_icon_size_bytes`` nicht überschreiten + → sonst 413. + - Leere Uploads werden abgelehnt (400). + """ + template = self._require_owner_or_admin(template_id, user_id=user_id, is_admin=is_admin) + settings = get_settings() + + # 1) Content-Type-Prüfung — via 415 statt 400, damit Clients gezielt + # reagieren können ("bitte anderes Format wählen"). + normalized = (content_type or "").split(";", 1)[0].strip().lower() + if normalized not in settings.allowed_icon_content_types: + from starlette.exceptions import HTTPException + + raise HTTPException( + status_code=415, + detail=( + f"Unsupported icon content type: {content_type!r}. " + f"Allowed: {', '.join(settings.allowed_icon_content_types)}" + ), + ) + + # 2) Größe. + size = len(content) + if size == 0: + raise BadRequestException("Uploaded icon file is empty") + if size > settings.max_icon_size_bytes: + from starlette.exceptions import HTTPException + + raise HTTPException( + status_code=413, + detail=( + f"Icon file too large: {size} bytes " + f"(max {settings.max_icon_size_bytes} bytes)" + ), + ) + + # 3) Persistieren — create-or-replace. Wir modifizieren die bestehende + # Row statt sie zu löschen+neu-anzulegen, damit ``id`` und + # ``created_at`` stabil bleiben (Cache-Buster im Frontend nutzt + # ``updated_at``). + existing = self.repo.get_by_template_id(template_id) + if existing: + existing.content = content + existing.content_type = normalized + existing.file_name = file_name + existing.size_bytes = size + self.db.commit() + self.db.refresh(existing) + icon = existing + action = "replaced" + else: + icon = self.repo.create( + template_id=template.id, + content=content, + content_type=normalized, + file_name=file_name, + size_bytes=size, + ) + action = "created" + + logger.info( + "Template icon %s", + action, + extra={ + "template_id": template_id, + "user_id": user_id, + "icon_id": icon.id, + "size_bytes": size, + "content_type": normalized, + }, + ) + return icon + + def delete_icon( + self, + template_id: str, + *, + user_id: str, + is_admin: bool = False, + ) -> bool: + """Remove the uploaded icon for a template. + + Returns True if something was deleted, False if the template + already had no icon. In both cases the endpoint returns 204; + the boolean is exposed for tests. + """ + self._require_owner_or_admin(template_id, user_id=user_id, is_admin=is_admin) + deleted = self.repo.delete_by_template_id(template_id) + if deleted: + logger.info( + "Template icon deleted", + extra={"template_id": template_id, "user_id": user_id}, + ) + return deleted + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _require_owner_or_admin( + self, + template_id: str, + *, + user_id: str, + is_admin: bool, + ) -> Template: + """Load template + enforce owner-or-admin gate for mutating ops.""" + template = self.template_service.get_template( + template_id, + user_id=user_id, + is_admin=is_admin, + ) + if template.owner_id != user_id and not is_admin: + raise ForbiddenException( + "You do not have permission to manage the icon of this template" + ) + return template diff --git a/src/services/template_service.py b/src/services/template_service.py index 6547d33..e2da30d 100644 --- a/src/services/template_service.py +++ b/src/services/template_service.py @@ -6,6 +6,8 @@ from sqlalchemy.orm import Session from src.models.template import Template, TemplateVisibility +from src.models.template_version import TemplateVersion, TemplateVersionApprovalStatus +from src.models.deployment import Deployment from src.repositories.template_repository import TemplateRepository from src.schemas.template import TemplateCreate, TemplateUpdate from src.core.exceptions import NotFoundException, ForbiddenException, BadRequestException @@ -86,7 +88,6 @@ def create_template( name=template_data.name, description=template_data.description, repo_url=template_data.repo_url, - icon_url=template_data.icon_url, visibility=TemplateVisibility.PRIVATE, owner_id=owner_id, ) @@ -183,25 +184,29 @@ def update_template( is_admin: bool = False ) -> Template: """Update a template. - - Only template owners or admins can update templates. - Only admins can change visibility. - + + Only template owners or admins can update templates. The same audience + may change ``visibility``: switching ``private → public`` will reset + the approval state of all versions to ``PENDING`` (admin review + required before they become visible in the marketplace); switching + ``public → private`` clears the approval state to NULL because the + approval concept doesn't apply to private templates. + Args: template_id: Template ID template_data: Template update data user_id: ID of user performing the update is_admin: Whether the user is an admin - + Returns: Updated template - + Raises: NotFoundException: If template not found - ForbiddenException: If user is not owner or admin, or non-admin tries to change visibility + ForbiddenException: If user is not owner or admin """ template = self.get_template(template_id, user_id=user_id, is_admin=is_admin) - + # Permission check: only owner or admin can update if template.owner_id != user_id and not is_admin: logger.warning( @@ -222,17 +227,61 @@ def update_template( return template try: - # Check if visibility is being changed + # Visibility transition — owner-or-admin (already enforced above for + # the whole update payload). Das Modell ist „Veröffentlichungswunsch + # statt Direktflip": + # + # private → public, keine APPROVED Version vorhanden: + # wir lassen das Template als PRIVATE stehen und setzen + # ``publish_requested = True``. Das Approval-Flow startet: + # jede ``approval_status=None``-Version flippt auf PENDING + # und landet damit in der Admin-Queue. Erst beim ersten + # erfolgreichen approve_version() flippt der Template-State + # atomar auf PUBLIC. + # + # private → public, mindestens eine APPROVED Version: + # der Approval-Umweg ist hier nicht nötig (der Inhalt ist + # schon admin-freigegeben). Wir flippen direkt auf PUBLIC. + # + # public → private: + # jeder Versions-Approval-State wird gewischt (NULL + + # Metadaten leer). ``publish_requested`` wird ebenfalls + # zurückgesetzt — ein vorheriger Wunsch ist obsolet, weil + # der Owner gerade explizit auf privat schaltet. if "visibility" in update_data: - if not is_admin: - raise ForbiddenException("Only admins can change template visibility") - - # Validate visibility value try: - TemplateVisibility(update_data["visibility"]) + new_visibility = TemplateVisibility(update_data["visibility"]) except ValueError: raise BadRequestException(f"Invalid visibility value: {update_data['visibility']}") - + + if new_visibility != template.visibility: + if new_visibility == TemplateVisibility.PUBLIC: + has_approved_version = any( + v.approval_status == TemplateVersionApprovalStatus.APPROVED + for v in template.versions + ) + if has_approved_version: + # Direkter Flip; ``publish_requested`` ggf. mit zurücksetzen. + update_data["publish_requested"] = False + else: + # Veröffentlichungswunsch statt Direktflip — wir + # blocken die ``visibility``-Änderung im Update, + # damit das Template PRIVATE bleibt, und setzen + # stattdessen das Wunsch-Flag. + del update_data["visibility"] + update_data["publish_requested"] = True + for v in template.versions: + if v.approval_status is None: + v.approval_status = TemplateVersionApprovalStatus.PENDING + else: + # public → private: Approval-State wischen, Wunsch löschen. + update_data["publish_requested"] = False + for v in template.versions: + v.approval_status = None + v.approved_by_id = None + v.approved_at = None + v.rejection_reason = None + uuid_id = template_id if isinstance(template_id, UUID) else UUID(str(template_id)) updated_template = self.template_repo.update(uuid_id, **update_data) if not updated_template: @@ -265,9 +314,12 @@ def delete_template( user_id: str, is_admin: bool = False ) -> None: - """Delete a template. + """Delete a template and all its versions (cascades via FK). - Only template owners or admins can delete templates. + Only template owners or admins can delete templates. If any version of + the template still has deployments referencing it, deletion is rejected + with a 400 — otherwise the database FK constraint from `deployments` + would surface as an opaque 500. Args: template_id: Template ID @@ -277,6 +329,7 @@ def delete_template( Raises: NotFoundException: If template not found ForbiddenException: If user is not owner or admin + BadRequestException: If versions still have deployments """ template = self.get_template(template_id, user_id=user_id, is_admin=is_admin) @@ -293,27 +346,30 @@ def delete_template( ) raise ForbiddenException("You do not have permission to delete this template") - try: - uuid_id = template_id if isinstance(template_id, UUID) else UUID(str(template_id)) - success = self.template_repo.delete(uuid_id) - if not success: - raise NotFoundException(f"Template with ID {template_id} not found") - - logger.info( - "Template deleted", - extra={ - "template_id": str(template_id), - "template_name": template.name, - "deleted_by": user_id - } - ) - except Exception as e: - logger.error( - f"Error deleting template: {e}", - extra={ - "template_id": str(template_id), - "user_id": user_id - }, - exc_info=True + # Pre-check: deployments would block the FK cascade with an opaque + # IntegrityError → surface a clear 400 instead. + deployment_count = ( + self.db.query(Deployment) + .join(TemplateVersion, Deployment.template_version_id == TemplateVersion.id) + .filter(TemplateVersion.template_id == str(template_id)) + .count() + ) + if deployment_count > 0: + raise BadRequestException( + f"Cannot delete template: {deployment_count} deployment(s) still reference its versions. " + "Remove those deployments first." ) - raise + + uuid_id = template_id if isinstance(template_id, UUID) else UUID(str(template_id)) + success = self.template_repo.delete(uuid_id) + if not success: + raise NotFoundException(f"Template with ID {template_id} not found") + + logger.info( + "Template deleted", + extra={ + "template_id": str(template_id), + "template_name": template.name, + "deleted_by": user_id + } + ) diff --git a/src/services/template_version_file_service.py b/src/services/template_version_file_service.py index 59418ff..8ef62a2 100644 --- a/src/services/template_version_file_service.py +++ b/src/services/template_version_file_service.py @@ -281,22 +281,30 @@ def get_file_content( def update_file( self, file_id: str | UUID, - file_data: TemplateVersionFileUpdate + file_data: TemplateVersionFileUpdate, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> TemplateVersionFile: """Update a template version file. - + Args: file_id: File ID file_data: Update data - + user_id: ID of the requesting user (for permission check) + is_admin: Whether the requesting user is an admin + Returns: Updated file - + Raises: NotFoundException: If file not found BadRequestException: If trying to set multiple primary files + ForbiddenException: If user lacks permission to modify the parent + template version """ - file = self.get_file(file_id) + # Permission check uses the same per-version rules as get_file — + # admins always pass, otherwise template owner only. + file = self.get_file(file_id, user_id=user_id, is_admin=is_admin) # Check if trying to set as primary if file_data.is_primary and not file.is_primary: @@ -342,21 +350,33 @@ def update_file_content( def delete_file( self, - file_id: UUID + file_id: str | UUID, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> None: """Delete a template version file. - + Args: file_id: File ID - + user_id: ID of the requesting user (for permission check) + is_admin: Whether the requesting user is an admin + Raises: NotFoundException: If file not found + ForbiddenException: If user lacks permission to modify the parent + template version """ file = self.file_repo.get_by_id(file_id) if not file: raise NotFoundException(f"File with ID {file_id} not found") - - self.file_repo.delete(file_id) + + # Same permission rule as get_file / update_file: admins always pass, + # otherwise only the parent template's owner can delete. + self._check_version_access(file.template_version_id, user_id, is_admin) + + # BaseRepository.delete is typed UUID; accept str inputs from the API + # layer transparently to match update_file's signature. + self.file_repo.delete(file_id if isinstance(file_id, UUID) else UUID(str(file_id))) def delete_version_files( self, diff --git a/src/services/template_version_service.py b/src/services/template_version_service.py index 8ce1295..1d31cdb 100644 --- a/src/services/template_version_service.py +++ b/src/services/template_version_service.py @@ -19,6 +19,7 @@ TemplateVersionWithFilesCreate, ) from src.core.exceptions import NotFoundException, BadRequestException, ForbiddenException +from src.utils import version_validator from src.utils.app_manifest_parser import AppManifestParser logger = logging.getLogger(__name__) @@ -112,12 +113,34 @@ def _check_version_access( @staticmethod def _initial_approval( template: Template, user_roles: list[str] - ) -> TemplateVersionApprovalStatus: - """Auto-approve admin-created versions on public templates; otherwise PENDING.""" + ) -> TemplateVersionApprovalStatus | None: + """Map (template, caller) to the initial approval status of a new version. + + - PUBLIC templates: admin caller → ``APPROVED`` (auto-promote); + everyone else → ``PENDING`` (admin review needed). + - PRIVATE templates WITH ``publish_requested=True``: same as PUBLIC. + The template hasn't been promoted to PUBLIC yet — that happens + atomically on the first approve_version() call — but the approval + flow is already running, so new versions must enter PENDING and + show up in the admin queue. + - PRIVATE templates without publish_requested: ``None`` — approval + doesn't apply (owner-only). + + Mirrored helper — keep in sync with the identical copy in + ``GithubImportService._initial_approval`` (github_import_service.py). + Both services create versions through different paths but with + identical semantics. + """ + is_public = template.visibility == TemplateVisibility.PUBLIC + is_pending_public = bool(getattr(template, "publish_requested", False)) + if not is_public and not is_pending_public: + return None is_admin = UserRole.ADMIN.value in (user_roles or []) - if is_admin and template.visibility == TemplateVisibility.PUBLIC: - return TemplateVersionApprovalStatus.APPROVED - return TemplateVersionApprovalStatus.PENDING + return ( + TemplateVersionApprovalStatus.APPROVED + if is_admin + else TemplateVersionApprovalStatus.PENDING + ) def create_version( self, @@ -130,6 +153,12 @@ def create_version( Approval status is decided by `_initial_approval`. If `user_roles` is not provided, falls back to is_admin to keep older callers compatible. + + Versionsnummer-Regeln (auch hier durchgesetzt, nicht nur im + GitHub-Import-Pfad): + - Muss valid Semver-2.0 sein. + - Muss innerhalb des Templates eindeutig sein. + - Muss strikt größer als die höchste existierende Semver-Version sein. """ template = self.template_repo.get_by_id(version_data.template_id) if not template: @@ -147,6 +176,19 @@ def create_version( f"Version with commit SHA {version_data.git_commit_sha} already exists for this template" ) + # Semver + Uniqueness + Monotonie. Wirft strukturiert mit den + # ERR_*-Codes, das Frontend kann pro Code unterschiedlich reagieren. + existing_strings = [ + v.version + for v in self.version_repo.get_by_template_id( + version_data.template_id, active_only=False + ) + ] + version_validator.assert_strictly_greater( + version_data.version, + existing_strings, + ) + roles = user_roles if user_roles is not None else ([UserRole.ADMIN.value] if is_admin else []) approval_status = self._initial_approval(template, roles) @@ -195,6 +237,20 @@ def create_version_with_files( f"Version with commit SHA {payload.git_commit_sha} already exists for this template" ) + # Versionsnummer-Validierung (semver, eindeutig, strikt monoton). + # Selbe Regeln wie im GitHub-Import-Pfad, hier aber auf den vom + # User direkt übergebenen Payload-String angewendet. + existing_strings = [ + v.version + for v in self.version_repo.get_by_template_id( + payload.template_id, active_only=False + ) + ] + version_validator.assert_strictly_greater( + payload.version, + existing_strings, + ) + # Build merged file set: base_version's files first, payload.files overlay by file_path merged: dict[str, dict] = {} if payload.base_version_id: @@ -299,24 +355,70 @@ def approve_version( version_id: str | UUID, admin_user_id: str, ) -> TemplateVersion: - """Admin-only: mark a pending version as approved.""" + """Admin-only: mark a pending version as approved. + + Erlaubt für: + - ``visibility == PUBLIC`` (Standard-Approval-Flow auf bereits + öffentlichen Templates). + - ``visibility == PRIVATE`` UND ``publish_requested == True`` + (Erst-Veröffentlichung: das Template wurde mit „öffentlich" + angelegt, ist bis zur ersten Genehmigung aber privat geblieben). + In diesem Fall flippt diese Methode atomar + ``template.visibility → PUBLIC`` und + ``template.publish_requested → False``. + + Wirft ``BadRequestException`` für genuinly-private Templates ohne + publish_requested — dort macht der Approval-Begriff keinen Sinn. + """ version = self.version_repo.get_by_id(version_id) if not version: raise NotFoundException(f"Template version with ID {version_id} not found") + template = self.template_repo.get_by_id(version.template_id) + if not template: + raise NotFoundException(f"Template with ID {version.template_id} not found") + + is_public = template.visibility == TemplateVisibility.PUBLIC + is_pending_public = bool(getattr(template, "publish_requested", False)) + if not is_public and not is_pending_public: + raise BadRequestException( + "Approval flow applies only to public templates" + ) + version.approval_status = TemplateVersionApprovalStatus.APPROVED version.approved_by_id = admin_user_id version.approved_at = datetime.now(timezone.utc) version.rejection_reason = None + + # Erst-Veröffentlichung: Template jetzt atomar auf PUBLIC heben. + # Wir loggen den Promotion-Event separat, weil er für Audit-Zwecke + # bedeutsamer ist als ein normales approve. + promoted_to_public = False + if not is_public and is_pending_public: + template.visibility = TemplateVisibility.PUBLIC + template.publish_requested = False + promoted_to_public = True + self.db.commit() self.db.refresh(version) + if promoted_to_public: + logger.info( + "Template promoted to public on first approval", + extra={ + "template_id": template.id, + "version_id": str(version.id), + "approved_by": admin_user_id, + }, + ) + logger.info( "Template version approved", extra={ "version_id": str(version.id), "template_id": version.template_id, "approved_by": admin_user_id, + "promoted_to_public": promoted_to_public, }, ) return version @@ -329,16 +431,42 @@ def reject_version( ) -> TemplateVersion: """Admin-only: mark a pending version as rejected. - `reason` is optional free-text persisted on the version. + ``reason`` is optional free-text persisted on the version. + + Erlaubt für die gleichen Fälle wie ``approve_version`` (siehe dort). + Bei Rejection eines Templates mit ``publish_requested=True`` wird + der Veröffentlichungswunsch verworfen: das Template bleibt PRIVATE + und ``publish_requested → False``. Der Owner muss eine neue + Veröffentlichung explizit über PATCH `visibility: public` anstoßen + — so wird verhindert, dass jeder neue Versions-Import einer + bereits abgelehnten Initiative automatisch erneut in die + Admin-Queue rutscht. """ version = self.version_repo.get_by_id(version_id) if not version: raise NotFoundException(f"Template version with ID {version_id} not found") + template = self.template_repo.get_by_id(version.template_id) + if not template: + raise NotFoundException(f"Template with ID {version.template_id} not found") + + is_public = template.visibility == TemplateVisibility.PUBLIC + is_pending_public = bool(getattr(template, "publish_requested", False)) + if not is_public and not is_pending_public: + raise BadRequestException( + "Approval flow applies only to public templates" + ) + version.approval_status = TemplateVersionApprovalStatus.REJECTED version.approved_by_id = admin_user_id version.approved_at = datetime.now(timezone.utc) version.rejection_reason = reason + + publish_request_cleared = False + if not is_public and is_pending_public: + template.publish_requested = False + publish_request_cleared = True + self.db.commit() self.db.refresh(version) @@ -349,6 +477,7 @@ def reject_version( "template_id": version.template_id, "rejected_by": admin_user_id, "has_reason": reason is not None, + "publish_request_cleared": publish_request_cleared, }, ) return version @@ -435,6 +564,24 @@ def update_version( update_data = version_data.model_dump(exclude_unset=True) + # Versions-String-Änderungen validieren: Semver + Uniqueness + + # Monotonie. Wir prüfen NUR, wenn der Caller das Feld setzt; sonst + # bleibt die existierende Versionsnummer wie sie ist (auch dann, + # wenn sie z.B. nach Dedupe-Migration "+dedupe-..." enthält). + new_version_str = update_data.get("version") + if new_version_str is not None and new_version_str != version.version: + existing_strings = [ + v.version + for v in self.version_repo.get_by_template_id( + version.template_id, active_only=False + ) + if v.id != version.id # eigene Row aus der Vergleichsmenge raus + ] + version_validator.assert_strictly_greater( + new_version_str, + existing_strings, + ) + if update_data.get("is_active") is True: self.version_repo.deactivate_other_versions( version.template_id, @@ -483,7 +630,14 @@ def activate_version( user_id: str, is_admin: bool = False ) -> TemplateVersion: - """Activate a version (owner-or-admin).""" + """Activate a version (owner-or-admin). + + Setzt ``is_active=True`` auf der gewählten Version und deaktiviert + alle anderen Versionen desselben Templates. Es gibt keine Sperre + gegen Downgrades — der Owner darf eine ältere Version wieder zur + aktiven (= im DeploymentWizard vorausgewählten) Version machen, + falls die jüngste Version z.B. einen Bug hat. + """ version = self.version_repo.get_by_id(version_id) if not version: raise NotFoundException(f"Template version with ID {version_id} not found") @@ -513,6 +667,7 @@ def list_versions_by_approval_status( template_id: Optional[str | UUID] = None, visibility: Optional[TemplateVisibility] = None, sort: QueueSort = "created_at_desc", + include_publish_requested: bool = True, ) -> tuple[list[tuple[TemplateVersion, Template]], int]: """Admin approval queue: list versions filtered by approval status. @@ -520,6 +675,12 @@ def list_versions_by_approval_status( admin-only authorization before invoking this. Returns `(rows, total)` where each row is `(version, template)`. + + ``include_publish_requested`` (default ``True``): siehe + ``TemplateVersionRepository.list_by_approval_status``. Mit dem Default + sieht der Admin auch Versionen aus PRIVATE-Templates, die auf ihre + Erst-Genehmigung warten — sonst würden Neu-Anlagen via „öffentlich" + unauffindbar in der Queue versanden. """ return self.version_repo.list_by_approval_status( approval_status=approval_status, @@ -528,6 +689,7 @@ def list_versions_by_approval_status( template_id=template_id, visibility=visibility, sort=sort, + include_publish_requested=include_publish_requested, ) def get_version_parameters(self, version_id: str | UUID) -> list[dict]: diff --git a/src/tasks/deploy_tasks.py b/src/tasks/deploy_tasks.py index e0bf16e..69ece78 100644 --- a/src/tasks/deploy_tasks.py +++ b/src/tasks/deploy_tasks.py @@ -1,11 +1,45 @@ -"""Deploy tasks for Celery.""" +"""Deploy tasks for Celery. + +This module owns three lifecycle tasks that the API enqueues: + + * :func:`deploy_stack` — initial provisioning. For each ``stack_assignment`` + in ``deployment_parameters`` it creates a Heat stack, waits for SSH, + copies scripts/files, runs the Ansible playbooks and persists credentials + + access rows. + * :func:`delete_deployment` — tears down every Heat stack the deployment + owns and removes the DB row (only when ALL stacks were torn down OK; on + partial failure the row stays so the user can retry). + * :func:`restart_deployment` — triggers a Heat ``update_stack`` to + refresh-in-place. Does NOT recreate or re-run Ansible. + * :func:`redeploy_instance` — destroy-and-recreate a *single* VM + (``DeploymentInstance``) inside an existing deployment, optionally with + overridden parameters. The parent deployment stays RUNNING so the + redeploy doesn't drag siblings down. + * :func:`redeploy_deployment` — same as redeploy_instance, but iterates + over every instance in the deployment. Used when the lecturer wants to + apply a config change across the whole class without re-running the + wizard. + +The single-stack provisioning loop body lives in +:func:`_provision_one_stack_assignment` so both ``deploy_stack`` and +``redeploy_instance`` go through the same code path. That keeps +"credentials → heat → ansible → persist" identical for first-time deploys +and redeploys — drift here would be very expensive to debug. +""" import json import logging +import time +from pathlib import Path +from typing import Any, Callable, Optional from uuid import UUID +import yaml as _yaml + from src.celery_app import celery_app from src.core.database import SessionLocal from src.models.deployment import DeploymentStatus +from src.models.deployment_instance import DeploymentInstance, DeploymentInstanceStatus +from src.models.deployment_instance_access import DeploymentInstanceAccess from src.models.deployment_log import DeploymentLogLevel, DeploymentLogEventType from src.models.template_version_file import FileType from src.repositories.deployment_repository import DeploymentRepository @@ -17,11 +51,595 @@ from src.services.ansible_service import AnsibleService from src.services.credential_generator_service import CredentialGeneratorService from src.utils.app_manifest_parser import AppManifestParser +from src.utils.cancellation import CancelledException, is_cancel_requested from src.core.config import get_settings logger = logging.getLogger(__name__) +# Heat parameters the backend ALWAYS owns — must never come from the +# wizard / override dicts. Kept as a module constant so both the initial +# deploy path and the redeploy path strip them out identically. +_BACKEND_MANAGED_HEAT_PARAMS = frozenset({"user_json", "key_name"}) + +# Bookkeeping keys present in ``generated`` but not actual credential +# types — must not leak into the ``applications`` section of user_json. +_NON_APP_KEYS = frozenset({ + "username", "email", "group_name", "group_index", + "course_group_id", "students", "linux", +}) + + +# --------------------------------------------------------------------------- +# Shared template / parameter loading helpers +# --------------------------------------------------------------------------- + + +class _TemplateContext: + """Bundle of everything loaded once per deploy/redeploy task. + + Holds the template files, the parsed credentials spec, the playbooks + sorted in the right order, and the parameter split (Heat vs Ansible) + derived from the heat template's declared parameters. + + Centralising this keeps :func:`deploy_stack` and the redeploy paths in + sync — when a new file type or parameter rule is added it lives here + and both callers pick it up. + """ + + def __init__( + self, + *, + heat_template: str, + files_dict: dict[str, str], + playbooks: list[tuple[str, str]], + scripts: dict[str, str], + template_files: dict[str, str], + credentials_spec: dict[str, list], + heat_defined_params: set[str], + ) -> None: + self.heat_template = heat_template + self.files_dict = files_dict + self.playbooks = playbooks + self.scripts = scripts + self.template_files = template_files + self.credentials_spec = credentials_spec + self.heat_defined_params = heat_defined_params + + def split_parameters( + self, all_parameters: dict[str, Any] + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Split a merged parameter dict into (heat, ansible) by what the + heat template declares. Backend-managed Heat params are stripped so + a malicious override can't bypass ``key_name`` / ``user_json``. + """ + heat_params = { + k: v for k, v in all_parameters.items() + if k in self.heat_defined_params and k not in _BACKEND_MANAGED_HEAT_PARAMS + } + ansible_params = { + k: v for k, v in all_parameters.items() + if k not in self.heat_defined_params + } + return heat_params, ansible_params + + +def _load_template_context( + file_service: TemplateVersionFileService, + template_version_id: str, +) -> _TemplateContext | str: + """Build a :class:`_TemplateContext` for the given template version. + + Returns the context on success, or a string error message on failure + (caller is expected to forward that into ``_fail``). Implemented as a + return-or-error pattern instead of raising so the call sites can stay + aligned with the original loop's ``_fail`` shape. + """ + files = file_service.get_version_files( + template_version_id, include_content=True, skip_access_check=True + ) + if not files: + return f"No files found for template version {template_version_id}" + + heat_file = next((f for f in files if f.is_primary), None) + if not heat_file or not heat_file.content: + return "No primary Heat template found" + + try: + heat_template_parsed = _yaml.safe_load(heat_file.content) + heat_defined_params = set(heat_template_parsed.get("parameters", {}).keys()) + except Exception: + heat_defined_params = set() + + # Parse app.yaml for credentials spec. + app_yaml_file = next((f for f in files if f.file_name == "app.yaml"), None) + credentials_spec: dict[str, list] = {"per_group": [], "teacher": []} + if app_yaml_file and app_yaml_file.content: + manifest = AppManifestParser.parse(app_yaml_file.content) + credentials_spec = manifest.get("credentials", credentials_spec) + + # _common playbooks first (sorted), then template-specific. + playbooks: list[tuple[str, str]] = [] + common_playbooks_dir = Path(__file__).parent.parent / "_common" / "playbooks" + if common_playbooks_dir.exists(): + for common_file in sorted(common_playbooks_dir.glob("*.yml")): + playbooks.append((f"_common/{common_file.name}", common_file.read_text())) + + scripts: dict[str, str] = {} + template_files: dict[str, str] = {} + for f in sorted(files, key=lambda x: (x.order, x.file_name)): + if f.file_type == FileType.ANSIBLE_PLAYBOOK and f.content: + playbooks.append((f.file_name, f.content)) + elif f.file_type == FileType.SHELL_SCRIPT and f.content: + scripts[f.file_name] = f.content + elif f.file_type == FileType.CONFIG_FILE and f.content: + template_files[f.file_name] = f.content + + files_dict: dict[str, str] = {} + for f in files: + if f.file_type == FileType.CLOUD_INIT and f.content: + files_dict["../cloud-init/user-data.yaml"] = f.content + + return _TemplateContext( + heat_template=heat_file.content, + files_dict=files_dict, + playbooks=playbooks, + scripts=scripts, + template_files=template_files, + credentials_spec=credentials_spec, + heat_defined_params=heat_defined_params, + ) + + +# --------------------------------------------------------------------------- +# The single-stack provisioning unit, shared by deploy + redeploy +# --------------------------------------------------------------------------- + + +def _provision_one_stack_assignment( + *, + db, + deployment, + stack_assignment_data: dict, + template_context: _TemplateContext, + heat_service: HeatStackService, + ansible_service_factory: Callable[..., AnsibleService], + log_service: DeploymentLogService, + all_parameters: dict[str, Any], + teacher_info: dict, + stack_name: str, + stack_index: int, + total_stacks: int, + cancel_check: Callable[[], bool] | None = None, + preserved_user_json: dict | None = None, + on_stack_created: Callable[[str], None] | None = None, +) -> tuple[Optional[str], Optional[DeploymentInstance]]: + """Run the create-stack → Ansible → persist-credentials cycle for one + stack assignment. + + Extracted verbatim from the original ``deploy_stack`` loop body. Both + the initial deploy and the per-instance redeploy go through this so + drift between the two paths stays impossible. + + Args: + db: SQLAlchemy session. + deployment: Deployment row (kept loaded throughout for tags / name). + stack_assignment_data: One element of + ``deployment_parameters['stack_assignments']``. + template_context: Output of :func:`_load_template_context`. + heat_service: A connected ``HeatStackService`` for the deployment's + OpenStack project. + ansible_service_factory: Callable that, given ``floating_ip`` and + ``cancel_check``, returns an :class:`AnsibleService` bound to + this db/deployment. Injected so tests can stub it out. + log_service: Active ``DeploymentLogService`` instance. + all_parameters: Effective (already-merged) parameter dict for THIS + stack. Callers compute the merge — this function only splits + it into Heat vs Ansible before use. + teacher_info: ``deployment_parameters['teacher']`` payload, used + to construct the credential admin block. + stack_name: Final Heat stack name (already collision-safe). + stack_index: 1-based position used only for log messages. + total_stacks: Total count, also log-only. + cancel_check: Optional predicate; when it returns True after Heat + success the function logs the cancel and returns ``(stack_id, + None)`` so the caller can short-circuit the rest of the loop. + ``None`` (the default) disables cancellation entirely — used + by the redeploy task, which is itself a discrete unit. + preserved_user_json: When set, skip credential GENERATION and reuse + this user_json verbatim. Used by ``redeploy_instance`` with + ``preserve_credentials=True`` so students keep their logins. + on_stack_created: Optional callback invoked with the new Heat + ``stack_id`` IMMEDIATELY after ``create_stack`` returns and + BEFORE the long-running Ansible phase starts. Callers use this + to persist the new stack id onto the parent deployment so a + worker crash mid-Ansible can't leave the Heat stack orphaned. + Failures inside the callback are logged and swallowed — + persistence is best-effort here; the caller's later final + commit is the source of truth. + + Returns: + Tuple ``(stack_id, instance)``: + * ``stack_id`` is the newly-created Heat stack ID, or ``None`` if + creation failed before Heat reported back a usable ID. + * ``instance`` is the persisted ``DeploymentInstance`` row, or + ``None`` when persistence failed (the caller still gets the + stack id for incremental persistence / cleanup). + + On Heat / Ansible failure the function re-raises after logging, so the + caller's outer ``try/except`` can record the error and decide whether + to continue with the next assignment (deploy_stack) or fail the redeploy. + """ + from src.schemas.deployment import StackAssignment, TeacherInfo + + deployment_id = deployment.id + stack_assignment = StackAssignment(**stack_assignment_data) + teacher = TeacherInfo(**teacher_info) + + heat_parameters, ansible_parameters = template_context.split_parameters(all_parameters) + + # --- Generate credentials --- + # ``preserved_user_json``, when set, is NOT a substitute for the + # ``generated`` dict consumed by Heat / Ansible — it only carries access + # rows we'll rebind onto the new instance after persistence (see + # ``_rebind_preserved_access_rows``). Ansible still receives fresh creds + # so the playbook is free to template them into config; we then overwrite + # the DB-side access rows with the preserved set so students' logins keep + # working in the credentials API. Playbooks that aren't idempotent on + # passwords may still produce drift between what's on the VM and what's + # in the DB — preserve_credentials is a best-effort guarantee. + generated = CredentialGeneratorService.generate( + credentials_spec=template_context.credentials_spec, + stack_assignment=stack_assignment, + teacher=teacher, + ) + + stack_params = {**heat_parameters} + stack_params["key_name"] = get_settings().ansible_ssh_key_name + tags = { + "deployment_id": deployment_id, + "course_id": deployment.course_id, + "template_version_id": deployment.template_version_id, + "stack_index": str(stack_index), + } + + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.DEPLOYMENT_STARTED, + message=f"Creating Heat stack {stack_index}/{total_stacks}: {stack_name}", + level=DeploymentLogLevel.INFO, + details={"stack_index": stack_index, "stack_name": stack_name}, + ) + + # --- 1. Create Heat stack --- + stack_result = heat_service.create_stack( + stack_name=stack_name, + template=template_context.heat_template, + parameters=stack_params, + files=template_context.files_dict or None, + tags=tags, + timeout_mins=60, + ) + stack_id: str = stack_result["stack_id"] + + # Persist the new stack id onto the parent deployment IMMEDIATELY, + # before the long-running Ansible phase. Without this, a worker crash + # / OOM / pod restart mid-Ansible would leave the Heat stack alive in + # OpenStack but invisible to ``delete_deployment`` (which only walks + # ``deployment.openstack_stack_id``), creating an orphan stack with + # no DB pointer to clean it up. Failures here are best-effort: the + # caller still does a final commit on the way out which is the source + # of truth, and ``delete_deployment`` has a defensive fallback that + # also walks ``DeploymentInstance.openstack_server_id``. + if on_stack_created is not None: + try: + on_stack_created(stack_id) + except Exception as persist_err: + logger.warning( + f"on_stack_created callback failed for stack {stack_index} " + f"({stack_id}): {persist_err}", + exc_info=True, + ) + + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.STACK_CREATE, + message=f"Heat stack {stack_index} created: {stack_name}", + level=DeploymentLogLevel.INFO, + details={"stack_id": stack_id, "stack_name": stack_name, "stack_index": stack_index}, + ) + + # --- 2. Build user_json + persist credentials --- + # Credential persistence is load-bearing: without a DeploymentInstance row + # the deployment has no DB record of the new VM, and the caller has no way + # to retry or reach it. Bubble the exception up so the caller can record + # the Heat-stack id as an orphan and report the failure to the user — the + # previous "log and continue to Ansible" path silently produced an orphan + # stack and a "redeployed" success response. + credentials_for_db = _build_user_json(generated) + try: + instance = DeploymentCredentialService(db).persist_credentials_for_stack( + deployment_id=deployment_id, + stack_name=stack_name, + openstack_stack_id=stack_id, + user_json=credentials_for_db, + floating_ip=stack_result.get("floating_ip") or "", + heat_outputs=stack_result.get("outputs") or {}, + flavor=stack_params.get("flavor"), + ) + except Exception as cred_error: + logger.error( + f"Failed to persist credentials for stack {stack_index}: {cred_error}", + exc_info=True, + ) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.FAILED, + message=f"Stack {stack_index} created but credential persistence failed", + level=DeploymentLogLevel.ERROR, + details={"stack_index": stack_index, "error": str(cred_error)}, + ) + # Stamp the heat stack id onto the exception so the outer task can + # record it as an orphan for later cleanup (same convention Heat's + # create_stack uses on CREATE_FAILED / timeout). + cred_error.stack_id = stack_id # type: ignore[attr-defined] + raise + + # If preserve_credentials is in play, replace the just-generated access + # rows with the snapshot from the OLD instance so logins keep working. + if preserved_user_json is not None: + try: + rebound = _rebind_preserved_access_rows(db, instance, preserved_user_json) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.DEPLOYMENT_STARTED, + message=( + f"preserve_credentials: rebound {rebound} access row(s) " + f"from snapshot for stack {stack_index}" + ), + level=DeploymentLogLevel.INFO, + details={"stack_index": stack_index, "rebound": rebound}, + ) + except Exception as rebind_err: + # Rebinding is best-effort: failing here would leave fresh + # auto-generated creds in place, which is recoverable. Log and + # carry on rather than dropping the whole redeploy. + logger.error( + f"Failed to rebind preserved credentials for stack {stack_index}: {rebind_err}", + exc_info=True, + ) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.FAILED, + message=( + f"preserve_credentials: rebind failed for stack {stack_index}; " + "fresh credentials remain in place" + ), + level=DeploymentLogLevel.WARNING, + details={"stack_index": stack_index, "error": str(rebind_err)}, + ) + + # --- 3. Ansible (only if playbooks exist and SSH key available) --- + ssh_private_key = get_settings().ansible_ssh_private_key or "" + playbooks = template_context.playbooks + if playbooks and ssh_private_key: + floating_ip = stack_result.get("floating_ip", "") + if not floating_ip: + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.ANSIBLE_FAILED, + message=f"No floating_ip in stack result for stack {stack_index} — skipping Ansible", + level=DeploymentLogLevel.WARNING, + ) + else: + if cancel_check and cancel_check(): + # Caller's deploy_stack uses the per-iteration checkpoint to + # bail out cleanly; reuse the same shape here so the loop + # outside can decide what to do. + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.DEPLOYMENT_DELETION_REQUESTED, + message=f"Cancel detected after Heat for stack {stack_index}; skipping Ansible", + level=DeploymentLogLevel.INFO, + ) + return stack_id, instance + + ansible = ansible_service_factory( + floating_ip=floating_ip, + cancel_check=cancel_check, + ) + try: + ansible.wait_for_ssh() + ansible.copy_files( + scripts=template_context.scripts, + files=template_context.template_files, + ) + + extra_vars = { + **generated, + **ansible_parameters, + "course_label": deployment.name, + "stack_label": stack_name, + "ssh_allow_users": [ + s["linux"]["username"] + for s in generated.get("deployment_groups", []) + if s.get("linux", {}).get("password") + ], + } + ansible.run_playbooks(playbooks=playbooks, extra_vars=extra_vars) + + _maybe_persist_activation_links( + db=db, + log_service=log_service, + deployment_id=deployment_id, + stack_index=stack_index, + ansible=ansible, + instance=instance, + generated=generated, + ) + except CancelledException: + # Let the caller's outer handler decide whether this is a + # cancel-success (deploy_stack) or a hard fail (redeploy). + raise + elif playbooks and not ssh_private_key: + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.ANSIBLE_FAILED, + message="Playbooks defined but no SSH private key configured — skipping Ansible", + level=DeploymentLogLevel.WARNING, + ) + + return stack_id, instance + + +def _build_user_json(generated: dict) -> dict: + """Translate ``CredentialGeneratorService.generate`` output into the + two-section ``user_json`` consumed by ``persist_credentials_for_stack``. + + 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. + + Extracted verbatim from the inlined logic in ``deploy_stack`` so the + initial-deploy and redeploy paths produce identical user_json shapes. + """ + group_entries = generated.get("deployment_groups", []) or [] + teacher_entry = generated.get("teacher", {}) or {} + + ssh_credentials = [ + { + "username": s["linux"]["username"], + "password": s["linux"]["password"], + "ssh_private_key": (s.get("linux", {}).get("ssh_key") or {}).get("private_key"), + "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 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, + }) + + return { + "instance": { + "credentials": ssh_credentials, + "admin_credentials": ssh_admin, + }, + "applications": applications, + } + + +def _maybe_persist_activation_links( + *, + db, + log_service: DeploymentLogService, + deployment_id: str, + stack_index: int, + ansible: AnsibleService, + instance: Optional[DeploymentInstance], + generated: dict, +) -> None: + """Post-Ansible fetch of /opt/dozilab/OVERLEAF_USERS.json and persistence + of activation-link rows. No-op when the file is absent or instance + persistence failed earlier. + + Same hardcoded path as before; the call is wrapped in a broad + try/except so a single failed fetch never escalates to a deployment + failure (the file still lives on the VM for manual recovery). + """ + try: + users_json = ansible.fetch_remote_json("/opt/dozilab/OVERLEAF_USERS.json") + if users_json and instance is not None: + username_to_group_id: dict[str, str | None] = {} + for s in generated.get("deployment_groups", []): + course_group_id = s.get("course_group_id") + linux_username = s.get("linux", {}).get("username") + if linux_username: + username_to_group_id[linux_username] = course_group_id + top_username = s.get("username") + if top_username: + username_to_group_id.setdefault(top_username, course_group_id) + + written = DeploymentCredentialService(db).persist_activation_links( + instance_id=instance.id, + overleaf_users_json=users_json, + username_to_group_id=username_to_group_id, + ) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.ANSIBLE_COMPLETED, + message=f"Persisted {written} activation-link credential(s) for stack {stack_index}", + level=DeploymentLogLevel.INFO, + details={"stack_index": stack_index, "count": written}, + ) + except Exception as fetch_err: + logger.warning( + "Post-Ansible activation-link fetch failed for " + f"deployment {deployment_id} stack {stack_index}: {fetch_err}", + exc_info=True, + ) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.ANSIBLE_COMPLETED, + message=f"Stack {stack_index} done; activation-link fetch skipped: {fetch_err}", + level=DeploymentLogLevel.WARNING, + details={"stack_index": stack_index, "error": str(fetch_err)}, + ) + + +# --------------------------------------------------------------------------- +# Tasks +# --------------------------------------------------------------------------- + + @celery_app.task(bind=True) def deploy_stack(self, deployment_id: str) -> dict: """Deploy Heat stacks and run Ansible playbooks for a deployment. @@ -70,61 +688,11 @@ def deploy_stack(self, deployment_id: str) -> dict: if not stack_assignments_raw: return _fail(repo, log_service, deployment_id, "No stack_assignments found") - # --- Load template files from DB --- - files = file_service.get_version_files( - deployment.template_version_id, include_content=True, skip_access_check=True - ) - if not files: - return _fail(repo, log_service, deployment_id, f"No files found for template version {deployment.template_version_id}") - - heat_file = next((f for f in files if f.is_primary), None) - if not heat_file or not heat_file.content: - return _fail(repo, log_service, deployment_id, "No primary Heat template found") - - # Split parameters: Heat gets only what heat/main.yaml defines, Ansible gets the rest - import yaml as _yaml - try: - heat_template_parsed = _yaml.safe_load(heat_file.content) - heat_defined_params = set(heat_template_parsed.get("parameters", {}).keys()) - except Exception: - heat_defined_params = set() - - backend_managed = {"user_json", "key_name"} - base_heat_parameters = { - k: v for k, v in all_parameters.items() - if k in heat_defined_params and k not in backend_managed - } - ansible_parameters = { - k: v for k, v in all_parameters.items() - if k not in heat_defined_params - } - - # Parse app.yaml for credentials spec and playbook list - app_yaml_file = next((f for f in files if f.file_name == "app.yaml"), None) - credentials_spec: dict[str, list] = {"per_student": [], "teacher": []} - playbooks: list[tuple[str, str]] = [] - scripts: dict[str, str] = {} - template_files: dict[str, str] = {} - - if app_yaml_file and app_yaml_file.content: - manifest = AppManifestParser.parse(app_yaml_file.content) - credentials_spec = manifest.get("credentials", credentials_spec) - - # Load _common playbooks first (sorted by filename → 00_, 01_, ...) - from pathlib import Path - common_playbooks_dir = Path(__file__).parent.parent / "_common" / "playbooks" - if common_playbooks_dir.exists(): - for common_file in sorted(common_playbooks_dir.glob("*.yml")): - playbooks.append((f"_common/{common_file.name}", common_file.read_text())) - - # Then template-specific playbooks - for f in sorted(files, key=lambda x: (x.order, x.file_name)): - if f.file_type == FileType.ANSIBLE_PLAYBOOK and f.content: - playbooks.append((f.file_name, f.content)) - elif f.file_type == FileType.SHELL_SCRIPT and f.content: - scripts[f.file_name] = f.content - elif f.file_type == FileType.CONFIG_FILE and f.content: - template_files[f.file_name] = f.content + # --- Load template files / playbooks / etc. --- + ctx_or_error = _load_template_context(file_service, deployment.template_version_id) + if isinstance(ctx_or_error, str): + return _fail(repo, log_service, deployment_id, ctx_or_error) + template_context = ctx_or_error # --- Get OpenStack credentials --- # The OpenStack project this deployment runs against is now persisted @@ -148,150 +716,127 @@ def deploy_stack(self, deployment_id: str) -> dict: except Exception as kp_err: return _fail(repo, log_service, deployment_id, f"Failed to ensure Ansible keypair: {kp_err}") - # SSH private key from settings (shared backend key, not per-project) ssh_private_key = get_settings().ansible_ssh_private_key or "" - # cloud-init files dict for Heat - files_dict = {} - for f in files: - if f.file_type == FileType.CLOUD_INIT and f.content: - files_dict["../cloud-init/user-data.yaml"] = f.content - - # Reconstruct Pydantic objects - from src.schemas.deployment import StackAssignment, TeacherInfo - teacher = TeacherInfo(**teacher_info) + def _ansible_factory(*, floating_ip: str, cancel_check): + return AnsibleService( + db=db, + deployment_id=deployment_id, + floating_ip=floating_ip, + ssh_private_key=ssh_private_key, + cancel_check=cancel_check, + ) - created_stack_ids = [] - failed_stacks = [] + created_stack_ids: list[str] = [] + failed_stacks: list[dict] = [] for idx, stack_assignment_data in enumerate(stack_assignments_raw, start=1): - stack_id = None # set after Heat succeeds - try: - stack_assignment = StackAssignment(**stack_assignment_data) - - # --- Generate credentials from app.yaml spec --- - generated = CredentialGeneratorService.generate( - credentials_spec=credentials_spec, - stack_assignment=stack_assignment, - teacher=teacher, - ) - - stack_params = {**base_heat_parameters} - stack_params["key_name"] = get_settings().ansible_ssh_key_name - stack_name = f"{deployment.name}-s{idx}-{deployment_id[:4]}" - stack_name = stack_name.replace(" ", "-").replace("_", "-").lower()[:64] - tags = { - "deployment_id": deployment_id, - "course_id": deployment.course_id, - "template_version_id": deployment.template_version_id, - "stack_index": str(idx), - } - + # Cooperative cancellation checkpoint #1: between stack iterations. + # If a DELETE arrived while we were busy with the previous stack, + # stop creating new ones. The Heat stacks we already built were + # persisted incrementally below, so the parallel delete task + # picks them up from `deployment.openstack_stack_id`. + if is_cancel_requested(db, deployment_id): log_service.log( deployment_id=deployment_id, - event_type=DeploymentLogEventType.DEPLOYMENT_STARTED, - message=f"Creating Heat stack {idx}/{len(stack_assignments_raw)}: {stack_name}", + event_type=DeploymentLogEventType.DEPLOYMENT_DELETION_REQUESTED, + message=( + f"Cancel detected before stack {idx}/{len(stack_assignments_raw)}; " + f"stopping after {len(created_stack_ids)} stack(s) created" + ), level=DeploymentLogLevel.INFO, - details={"stack_index": idx, "stack_name": stack_name}, + details={"created_stack_ids": created_stack_ids}, ) + return {"status": "cancelled", "stack_count": len(created_stack_ids), "stack_ids": created_stack_ids} + + stack_id: Optional[str] = None + try: + stack_name = _build_stack_name(deployment, idx) + cancel_check = lambda: is_cancel_requested(db, deployment_id) # noqa: E731 + + def _persist_stack_id_now(new_id: str) -> None: + """Append ``new_id`` to ``created_stack_ids`` and flush + onto the deployment row immediately — invoked by the + helper right after ``heat_service.create_stack`` returns, + before Ansible. Closes the orphan-stack window that + would otherwise span the (multi-minute) Ansible phase. + """ + created_stack_ids.append(new_id) + try: + deployment.openstack_stack_id = json.dumps(created_stack_ids) + db.commit() + except Exception as persist_err: + db.rollback() + logger.warning( + f"Failed to incrementally persist stack id {new_id}: {persist_err}" + ) - # --- 1. Create Heat stack --- - stack_result = heat_service.create_stack( + stack_id, _instance = _provision_one_stack_assignment( + db=db, + deployment=deployment, + stack_assignment_data=stack_assignment_data, + template_context=template_context, + heat_service=heat_service, + ansible_service_factory=_ansible_factory, + log_service=log_service, + all_parameters=all_parameters, + teacher_info=teacher_info, stack_name=stack_name, - template=heat_file.content, - parameters=stack_params, - files=files_dict or None, - tags=tags, - timeout_mins=60, + stack_index=idx, + total_stacks=len(stack_assignments_raw), + cancel_check=cancel_check, + on_stack_created=_persist_stack_id_now, ) - stack_id = stack_result["stack_id"] - created_stack_ids.append(stack_id) + # The callback above already appended + persisted ``stack_id``. + # Guard against an edge case where the helper returns a stack + # id WITHOUT having called the callback (e.g. future refactor + # that produces a stack id by another route): make sure we + # don't leave the id out of the snapshot. + if stack_id and stack_id not in created_stack_ids: + created_stack_ids.append(stack_id) + try: + deployment.openstack_stack_id = json.dumps(created_stack_ids) + db.commit() + except Exception as persist_err: + db.rollback() + logger.warning( + f"Failed to incrementally persist stack id {stack_id}: {persist_err}" + ) + # If cancel was observed mid-provision, the helper returned a + # stack_id with no further work — fold the cancel up so the + # caller doesn't keep iterating. + if cancel_check(): + return {"status": "cancelled", "stack_count": len(created_stack_ids), "stack_ids": created_stack_ids} + except CancelledException as cancel_err: + # Cancel was observed mid-SSH-wait or mid-playbook. + # Don't escalate to FAILED — log and exit cleanly. log_service.log( deployment_id=deployment_id, - event_type=DeploymentLogEventType.STACK_CREATE, - message=f"Heat stack {idx} created: {stack_name}", + event_type=DeploymentLogEventType.DEPLOYMENT_DELETION_REQUESTED, + message=f"Ansible phase cancelled for stack {idx}: {cancel_err}", level=DeploymentLogLevel.INFO, - details={"stack_id": stack_id, "stack_name": stack_name, "stack_index": idx}, + details={"created_stack_ids": created_stack_ids}, ) - - try: - # Build user_json from `generated` so DB passwords match what Ansible sets - credentials_for_db = { - "instance": { - "credentials": [ - { - "username": s["linux"]["username"], - "password": s["linux"]["password"], - } - for s in generated.get("students", []) - if s.get("linux", {}).get("password") - ], - "admin_credentials": { - "username": generated["teacher"]["linux"]["username"], - "password": generated["teacher"]["linux"]["password"], - } if generated.get("teacher", {}).get("linux", {}).get("password") else None, - }, - } - DeploymentCredentialService(db).persist_credentials_for_stack( - deployment_id=deployment_id, - stack_name=stack_name, - openstack_stack_id=stack_id, - user_json=credentials_for_db, - floating_ip=stack_result.get("floating_ip") or "", - heat_outputs=stack_result.get("outputs") or {}, - flavor=stack_params.get("flavor"), - ) - except Exception as cred_error: - logger.error(f"Failed to persist credentials for stack {idx}: {cred_error}", exc_info=True) - log_service.log( - deployment_id=deployment_id, - event_type=DeploymentLogEventType.FAILED, - message=f"Stack {idx} created but credential persistence failed", - level=DeploymentLogLevel.ERROR, - details={"stack_index": idx, "error": str(cred_error)} - ) - - # --- 2+3+4. Ansible (only if playbooks exist and SSH key available) --- - if playbooks and ssh_private_key: - floating_ip = stack_result.get("floating_ip", "") - if not floating_ip: - log_service.log( - deployment_id=deployment_id, - event_type=DeploymentLogEventType.ANSIBLE_FAILED, - message=f"No floating_ip in stack result for stack {idx} — skipping Ansible", - level=DeploymentLogLevel.WARNING, - ) - else: - ansible = AnsibleService( - db=db, - deployment_id=deployment_id, - floating_ip=floating_ip, - ssh_private_key=ssh_private_key, - ) - ansible.wait_for_ssh() - ansible.copy_files(scripts=scripts, files=template_files) - - extra_vars = { - **generated, - **ansible_parameters, - "course_label": deployment.name, - "stack_label": stack_name, - "ssh_allow_users": [ - s["linux"]["username"] - for s in generated.get("students", []) - if s.get("linux", {}).get("password") - ], - } - ansible.run_playbooks(playbooks=playbooks, extra_vars=extra_vars) - elif playbooks and not ssh_private_key: - log_service.log( - deployment_id=deployment_id, - event_type=DeploymentLogEventType.ANSIBLE_FAILED, - message="Playbooks defined but no SSH private key configured — skipping Ansible", - level=DeploymentLogLevel.WARNING, - ) + return {"status": "cancelled", "stack_count": len(created_stack_ids), "stack_ids": created_stack_ids} except Exception as stack_error: + # If create_stack raised AFTER the stack was actually created + # (CREATE_FAILED or wait timeout), it stamps the id onto the + # exception so we can still record it for later cleanup. Without + # this, the half-created stack would be orphaned in OpenStack + # and the delete task wouldn't know to tear it down. + orphan_stack_id = getattr(stack_error, "stack_id", None) + if orphan_stack_id and orphan_stack_id not in created_stack_ids: + created_stack_ids.append(orphan_stack_id) + try: + deployment.openstack_stack_id = json.dumps(created_stack_ids) + db.commit() + except Exception as persist_err: + db.rollback() + logger.warning( + f"Failed to persist orphan stack id {orphan_stack_id}: {persist_err}" + ) + error_msg = f"Failed to deploy stack {idx}: {str(stack_error)}" logger.error(error_msg, exc_info=True) log_service.log( @@ -299,7 +844,7 @@ def deploy_stack(self, deployment_id: str) -> dict: event_type=DeploymentLogEventType.ANSIBLE_FAILED if stack_id else DeploymentLogEventType.FAILED, message=error_msg, level=DeploymentLogLevel.ERROR, - details={"error": str(stack_error), "stack_index": idx}, + details={"error": str(stack_error), "stack_index": idx, "orphan_stack_id": orphan_stack_id}, ) failed_stacks.append({"index": idx, "error": str(stack_error)}) @@ -349,6 +894,17 @@ def deploy_stack(self, deployment_id: str) -> dict: db.close() +def _build_stack_name(deployment, stack_index: int) -> str: + """Build a Heat-safe stack name from the deployment name + index. + + Pulled out so the redeploy path produces the same naming scheme as the + initial deploy. Heat is allergic to spaces, underscores and >64-char + names, so the same sanitisation rule must apply everywhere. + """ + stack_name = f"{deployment.name}-s{stack_index}-{deployment.id[:4]}" + return stack_name.replace(" ", "-").replace("_", "-").lower()[:64] + + def _fail(repo, log_service, deployment_id: str, error_msg: str) -> dict: """Log error, set status to FAILED and return failure dict.""" logger.error(error_msg) @@ -364,6 +920,205 @@ def _fail(repo, log_service, deployment_id: str, error_msg: str) -> dict: +def _gc_orphan_student_memberships(db, user_ids: set[str]) -> None: + """Drop CourseMember / GroupMember / User rows for users that no longer + have any deployment behind them. + + Called from ``delete_deployment`` AFTER the deployment row is gone. For + each candidate user: + 1. Walk their CourseMember rows. + 2. For each CourseMember, count its GroupMember rows that still point + to a course_group with at least one DeploymentInstanceAccess on any + live DeploymentInstance. If zero remain → drop the GroupMembers and + the CourseMember. + 3. Once a user has no CourseMember rows left, drop the User row. + + Limited to users that are pure students. The caller's + ``DeploymentService._sync_student_memberships`` is the only place we + auto-create User rows from a wizard payload, and only for students; + lecturers/admins arrive via Keycloak login (UserSyncService) and + additionally own templates / OpenStack projects, so this function + leaves them alone via the explicit "no owner rows" guard. + """ + if not user_ids: + return + + from src.models.course_member import CourseMember + from src.models.deployment_instance import DeploymentInstance + from src.models.deployment_instance_access import DeploymentInstanceAccess + from src.models.group_member import GroupMember + from src.models.openstack_project import OpenstackProject + from src.models.template import Template + from src.models.user import User + + removed_users = 0 + removed_course_members = 0 + removed_group_members = 0 + + for user_id in user_ids: + # Guard: skip anyone who owns templates or openstack projects — + # cannot be a pure student. This is the safety net against + # accidentally pruning a lecturer if user_ids ever contains one. + owns_templates = ( + db.query(Template.id).filter(Template.owner_id == user_id).first() + is not None + ) + owns_openstack = ( + db.query(OpenstackProject.id) + .filter(OpenstackProject.owner_user_id == user_id) + .first() + is not None + ) + if owns_templates or owns_openstack: + continue + + course_members = db.query(CourseMember).filter( + CourseMember.user_id == user_id + ).all() + + for cm in course_members: + # Which group_memberships of this course_member still point to a + # group that has any live DeploymentInstanceAccess? + live_group_member_ids = { + row[0] + for row in db.query(GroupMember.id) + .join( + DeploymentInstanceAccess, + DeploymentInstanceAccess.group_id == GroupMember.group_id, + ) + .join( + DeploymentInstance, + DeploymentInstance.id + == DeploymentInstanceAccess.deployment_instance_id, + ) + .filter(GroupMember.course_member_id == cm.id) + .distinct() + .all() + } + + stale_group_members = ( + db.query(GroupMember) + .filter(GroupMember.course_member_id == cm.id) + .all() + ) + for gm in stale_group_members: + if gm.id in live_group_member_ids: + continue + db.delete(gm) + removed_group_members += 1 + + # After pruning, does this CourseMember have any live group + # memberships left? If not, drop the CourseMember itself. + db.flush() + remaining = ( + db.query(GroupMember.id) + .filter(GroupMember.course_member_id == cm.id) + .first() + ) + if remaining is None: + db.delete(cm) + removed_course_members += 1 + + db.flush() + # If no CourseMember rows survived → user is no longer tied to ANY + # course → safe to drop. We also re-check owner relationships in case + # something raced. + remaining_cm = ( + db.query(CourseMember.id) + .filter(CourseMember.user_id == user_id) + .first() + ) + if remaining_cm is None: + user = db.query(User).filter(User.id == user_id).first() + if user is not None: + db.delete(user) + removed_users += 1 + + db.commit() + if removed_users or removed_course_members or removed_group_members: + logger.info( + "Student GC complete", + extra={ + "removed_users": removed_users, + "removed_course_members": removed_course_members, + "removed_group_members": removed_group_members, + "candidates": len(user_ids), + }, + ) + + +def _collect_stack_ids_for_cleanup(db, deployment) -> list[str]: + """Return every Heat stack id this deployment owns, drawing from BOTH + the deployment row and its DeploymentInstance rows. + + ``deployment.openstack_stack_id`` is a JSON array maintained by + ``deploy_stack`` / ``redeploy_instance`` as they go. The + ``on_stack_created`` callback persists each new id incrementally so + the array stays in sync with OpenStack, but a worker crash between + ``heat_service.create_stack`` and that commit would leave a fresh + Heat stack alive with no entry in the array. + + To make ``delete_deployment`` resilient to that exact failure mode, + we additionally walk ``DeploymentInstance.openstack_server_id`` + (which stores the same Heat stack id and is committed inside the + transaction that persists the instance row). The union of both + sources, deduplicated while preserving insertion order, is what we + actually pass to Heat for deletion. + + Args: + db: SQLAlchemy session bound to this task. + deployment: The Deployment row being torn down. Must be live in + ``db`` so the lazy-loaded ``instances`` relationship resolves. + + Returns: + Ordered list of unique Heat stack ids to delete. Empty when the + deployment never produced any stack (rare, but happens on early + failures). + """ + seen: set[str] = set() + out: list[str] = [] + + raw = deployment.openstack_stack_id + if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + ids_from_deployment: list[str] = [str(s) for s in parsed if s] + else: + ids_from_deployment = [str(parsed)] + except (json.JSONDecodeError, TypeError): + # Treat as a single legacy id. + ids_from_deployment = [str(raw)] + for sid in ids_from_deployment: + if sid and sid not in seen: + seen.add(sid) + out.append(sid) + + # Fallback / defense-in-depth: any DeploymentInstance whose Heat stack + # id never made it into the deployment-level array (e.g. crash mid- + # Ansible on first redeploy of a wedged VM) still gets cleaned up. + try: + instances = db.query(DeploymentInstance).filter( + DeploymentInstance.deployment_id == deployment.id + ).all() + except Exception as e: + logger.warning( + f"Could not query DeploymentInstance rows for stack-id fallback: {e}" + ) + instances = [] + + for inst in instances: + sid = inst.openstack_server_id + if sid and sid not in seen: + seen.add(sid) + out.append(sid) + logger.info( + f"Stack id {sid} recovered from DeploymentInstance {inst.id} " + "(was missing from deployment.openstack_stack_id)" + ) + + return out + @celery_app.task(bind=True) def delete_deployment(self, deployment_id: str) -> dict: @@ -400,8 +1155,48 @@ def delete_deployment(self, deployment_id: str) -> dict: details={"task_id": task_id} ) + # If a deploy_stack task is currently in flight for this deployment, + # it polls the status at its checkpoints (between stacks, before each + # Ansible phase) and bails out once it sees DELETING. Give it up to + # ~10s to flush the final created_stack_ids list to the DB before we + # start tearing things down — otherwise we could miss a stack created + # in the very last iteration. + # + # The deploy task updates status to RUNNING / FAILED / cancelled as + # its last action; while it's still in flight the status here will + # still read CREATING (the API set it to DELETING, and we set it to + # DELETING again above, but a fresh deploy_stack may overwrite that + # back to CREATING on its next status update — unlikely with the + # current code, but harmless to handle). + for _ in range(10): + db.expire(deployment) + deployment = repo.get_by_id(deployment_id) + if deployment is None: + break + # Heat-stack id may have been incrementally persisted by the + # deploy task. Pick the latest snapshot before cleanup. + if deployment.status != DeploymentStatus.CREATING: + break + time.sleep(1) + + # If deployment vanished mid-wait, nothing to clean up. + if deployment is None: + return {"status": "already_gone", "deployment_id": deployment_id} + + # Build the full set of stack ids to tear down. Primary source is + # ``deployment.openstack_stack_id`` (JSON array), but we also walk + # ``DeploymentInstance.openstack_server_id`` as a defensive fallback: + # if a previous redeploy / deploy_stack ever crashed between + # ``heat_service.create_stack`` and the final commit that flushes + # the id onto the deployment row, the stack would otherwise be + # invisible to the cleanup here even though its DeploymentInstance + # row points at it. Union of both sources guarantees no orphan + # stacks survive a clean delete. + stack_ids = _collect_stack_ids_for_cleanup(db, deployment) + # If there is an associated Heat stack, attempt to delete it - if deployment.openstack_stack_id: + any_stack_delete_failed = False + if stack_ids: try: # The OpenStack project is now persisted on the deployment row # itself (FK), so deletion always targets the project the @@ -417,17 +1212,9 @@ def delete_deployment(self, deployment_id: str) -> dict: else: heat_service = HeatStackService(openstack_project) - # Parse stack IDs (can be single ID or JSON array) - try: - stack_ids = json.loads(deployment.openstack_stack_id) - if not isinstance(stack_ids, list): - stack_ids = [stack_ids] - except (json.JSONDecodeError, TypeError): - # Fallback: treat as single stack ID - stack_ids = [deployment.openstack_stack_id] - logger.info(f"Deleting {len(stack_ids)} Heat stack(s)") deleted_count = 0 + surviving_stack_ids: list[str] = [] failed_deletions = [] for idx, stack_id in enumerate(stack_ids, start=1): @@ -438,6 +1225,7 @@ def delete_deployment(self, deployment_id: str) -> dict: except Exception as delete_error: logger.error(f"Failed to delete stack {stack_id}: {delete_error}") failed_deletions.append({"stack_id": stack_id, "error": str(delete_error)}) + surviving_stack_ids.append(stack_id) log_service.log( deployment_id=deployment_id, @@ -450,7 +1238,25 @@ def delete_deployment(self, deployment_id: str) -> dict: "failed_deletions": failed_deletions if failed_deletions else None } ) + + # If any stack failed to delete, keep the DB row and the + # surviving stack ids so the user can retry. Removing the + # row would orphan the OpenStack stacks with no way to + # find them later. + if failed_deletions: + any_stack_delete_failed = True + try: + deployment.openstack_stack_id = json.dumps(surviving_stack_ids) + db.commit() + except Exception as persist_err: + db.rollback() + logger.warning( + f"Could not persist surviving stack ids: {persist_err}" + ) except Exception as e: + # Outer failure (e.g. heat_service init / connection error) — + # also a reason to keep the DB row so the user can retry. + any_stack_delete_failed = True logger.error(f"Failed to delete Heat stacks: {e}", exc_info=True) log_service.log( deployment_id=deployment_id, @@ -460,6 +1266,56 @@ def delete_deployment(self, deployment_id: str) -> dict: details={"error": str(e)} ) + # If any OpenStack stack deletion failed, bail out BEFORE wiping the + # DB record. The deployment stays around (status FAILED) so the user + # can retry the delete instead of being left with orphan stacks they + # can no longer reach from the UI. + if any_stack_delete_failed: + try: + repo.update_status(deployment_id, DeploymentStatus.FAILED) + except Exception: + logger.warning( + "Could not reset deployment status after partial delete failure" + ) + return { + "status": "stack_delete_failed", + "deployment_id": deployment_id, + "task_id": task_id, + } + + # Before tearing down DB rows, collect the student users tied to this + # deployment so we can clean them up after the deployment row is gone. + # We snapshot user/course-member ids now — once the DeploymentInstance + # → access → group_id chain is deleted there's no way to walk back to + # the affected students. + student_user_ids: set[str] = set() + try: + from src.models.deployment_instance import DeploymentInstance + from src.models.deployment_instance_access import DeploymentInstanceAccess + from src.models.group_member import GroupMember + from src.models.course_member import CourseMember + + student_user_ids = { + row[0] + for row in db.query(CourseMember.user_id) + .join(GroupMember, GroupMember.course_member_id == CourseMember.id) + .join( + DeploymentInstanceAccess, + DeploymentInstanceAccess.group_id == GroupMember.group_id, + ) + .join( + DeploymentInstance, + DeploymentInstance.id == DeploymentInstanceAccess.deployment_instance_id, + ) + .filter(DeploymentInstance.deployment_id == deployment_id) + .distinct() + .all() + } + except Exception as e: + logger.warning( + f"Failed to snapshot student users for cleanup {deployment_id}: {e}" + ) + # Delete logs first (before deployment record) try: log_repo = DeploymentLogRepository(db) @@ -487,7 +1343,7 @@ def delete_deployment(self, deployment_id: str) -> dict: except Exception as e: logger.warning(f"Failed to delete deployment instances for {deployment_id}: {e}") db.rollback() - + # Finally delete DB record try: deleted = repo.delete(UUID(deployment_id)) @@ -498,6 +1354,25 @@ def delete_deployment(self, deployment_id: str) -> dict: except Exception as e: logger.error(f"Failed to delete deployment record {deployment_id}: {e}", exc_info=True) + # Garbage-collect student users + course/group memberships that no + # longer have ANY deployment behind them. Rationale: + # * Students don't own templates or OpenStack projects (only lecturers + # /admins do), so a user with no remaining CourseMember rows is + # guaranteed to be a former student. + # * Each course_member -> group_member chain that survives here would + # otherwise live forever, growing the membership tables monotonically. + # We re-query the surviving access rows (across ALL other deployments) + # to decide what's safe to drop — a student in two deployments stays + # until the second one is also deleted. + try: + _gc_orphan_student_memberships(db, student_user_ids) + except Exception as e: + logger.warning( + f"Student cleanup failed for deployment {deployment_id}: {e}", + exc_info=True, + ) + db.rollback() + return {"status": "deleted", "deployment_id": deployment_id, "task_id": task_id} except Exception as e: @@ -510,33 +1385,33 @@ def delete_deployment(self, deployment_id: str) -> dict: @celery_app.task(bind=True) def restart_deployment(self, deployment_id: str) -> dict: """Restart a deployment by updating the Heat stack. - + This task restarts an existing deployment by triggering a Heat stack update, which can restart VMs or refresh the stack configuration. - + Args: deployment_id: ID of the deployment to restart - + Returns: Result dictionary with status and task information """ task_id = self.request.id logger.info(f"Starting restart task for deployment_id={deployment_id}, task_id={task_id}") - + db = SessionLocal() try: repo = DeploymentRepository(db) log_service = DeploymentLogService(db) - + deployment = repo.get_by_id(deployment_id) - + if not deployment: logger.error(f"Deployment not found: {deployment_id}") return {"status": "failed", "error": "Deployment not found"} - + # Update status to RESTARTING repo.update_status(deployment_id, DeploymentStatus.RESTARTING) - + log_service.log( deployment_id=deployment_id, event_type=DeploymentLogEventType.DEPLOYMENT_STARTED, @@ -544,7 +1419,7 @@ def restart_deployment(self, deployment_id: str) -> dict: level=DeploymentLogLevel.INFO, details={"task_id": task_id} ) - + # Verify deployment has a stack if not deployment.openstack_stack_id: error_msg = "No OpenStack stack associated with this deployment" @@ -557,7 +1432,7 @@ def restart_deployment(self, deployment_id: str) -> dict: ) repo.update_status(deployment_id, DeploymentStatus.FAILED) return {"status": "failed", "error": error_msg} - + # Get OpenStack project from the deployment's persisted FK. Previously # this was re-derived from teacher.id at every read site and picked the # user's first OpenstackProject row, which broke whenever a user had @@ -575,14 +1450,14 @@ def restart_deployment(self, deployment_id: str) -> dict: ) repo.update_status(deployment_id, DeploymentStatus.FAILED) return {"status": "failed", "error": error_msg} - + try: heat_service = HeatStackService(openstack_project) - + # Trigger stack update to restart resources logger.info(f"Updating Heat stack {deployment.openstack_stack_id} to trigger restart") heat_service.update_stack(deployment.openstack_stack_id) - + log_service.log( deployment_id=deployment_id, event_type=DeploymentLogEventType.DEPLOYMENT_STARTED, @@ -590,24 +1465,24 @@ def restart_deployment(self, deployment_id: str) -> dict: level=DeploymentLogLevel.INFO, details={"stack_id": deployment.openstack_stack_id} ) - + # Update status back to RUNNING (stack update is async in OpenStack) repo.update_status(deployment_id, DeploymentStatus.RUNNING) - + log_service.log( deployment_id=deployment_id, event_type=DeploymentLogEventType.DEPLOYMENT_STARTED, message="Restart completed successfully", level=DeploymentLogLevel.INFO ) - + return { "status": "restarted", "deployment_id": deployment_id, "stack_id": deployment.openstack_stack_id, "task_id": task_id } - + except Exception as e: error_msg = f"Failed to restart deployment: {str(e)}" logger.exception(error_msg) @@ -620,9 +1495,907 @@ def restart_deployment(self, deployment_id: str) -> dict: ) repo.update_status(deployment_id, DeploymentStatus.FAILED) return {"status": "failed", "deployment_id": deployment_id, "error": str(e)} - + except Exception as e: logger.exception(f"Error during restart task for deployment {deployment_id}: {e}") return {"status": "failed", "deployment_id": deployment_id, "error": str(e)} finally: db.close() + + +# --------------------------------------------------------------------------- +# Redeploy paths +# --------------------------------------------------------------------------- + + +def _merge_parameter_layers( + *, + base: dict[str, Any], + deployment_overrides: dict[str, Any] | None, + instance_overrides: dict[str, Any] | None, +) -> dict[str, Any]: + """Merge parameter layers for a redeploy. + + Order — later layers win:: + + base (deployment.deployment_parameters['parameters']) + ↓ + deployment_overrides (apply to every VM) + ↓ + instance_overrides (apply to THIS one VM) + + Shallow merge by design — template parameters are flat key/value + pairs (per the app.yaml contract). Nested dicts in a parameter value + are replaced, not deep-merged, mirroring how the wizard treats them. + """ + merged = dict(base) + if deployment_overrides: + merged.update(deployment_overrides) + if instance_overrides: + merged.update(instance_overrides) + return merged + + +def _snapshot_instance_credentials( + db, instance: DeploymentInstance +) -> dict[str, Any]: + """Read ``DeploymentInstanceAccess`` rows back into a ``generated``-shaped + dict so the redeploy task can reuse them when ``preserve_credentials=True``. + + Round-tripping the rows back into the ``generated`` shape is intrinsically + lossy — ``_build_user_json``'s ``applications[]`` block was originally + keyed by app.yaml credential type (``postgres``, ``pgadmin``, …), but + once persisted it lives under a single ``DATABASE`` ``AccessType`` row + that merges them. We can't recover the original app name. The snapshot + therefore carries the access rows verbatim under a single ``_preserved_access`` + list per group; ``_rebind_preserved_access_rows`` (called AFTER the new + instance is persisted) then re-points the old rows at the new instance, + keeping the port / connection_url / ssh_private_key fields intact. + + Why not regenerate via the ``generated`` dict + Ansible: + * Server never persists ``ssh_key.public_key`` — re-emitting an empty + public_key would break authorized_keys on the new VM. + * Non-SSH rows lose port / connection_url under the access-type roundtrip. + * Ansible playbooks generate their own per-VM passwords; injecting the + OLD password into extra_vars doesn't actually keep students' logins + working unless the playbook is idempotent about it. + + Returns a dict with two keys (both consumed only inside this module): + * ``_preserved_access``: list of dicts holding every column of each + DeploymentInstanceAccess row, ready for the rebind step. + * ``deployment_groups`` / ``teacher``: kept empty — credentials are + rebound from access rows, NOT pumped through user_json. + """ + # Capture all columns we need to rehydrate the access row against the new + # instance. Fernet-encrypted columns (password, ssh_private_key) are + # decrypted transparently on read via EncryptedString; we re-encrypt on + # the new INSERT. + preserved: list[dict[str, Any]] = [] + for access in list(instance.access_methods or []): + preserved.append({ + "access_type": access.access_type, + "group_id": access.group_id, + "connection_url": access.connection_url, + "username": access.username, + "password": access.password, + "ssh_private_key": access.ssh_private_key, + "port": access.port, + "is_active": access.is_active, + "expires_at": access.expires_at, + }) + + return { + "_preserved_access": preserved, + "deployment_groups": [], + "teacher": {}, + } + + +def _rebind_preserved_access_rows( + db, + new_instance: DeploymentInstance, + preserved_user_json: dict[str, Any], +) -> int: + """Recreate access rows from a snapshot under the new instance's id. + + Companion to :func:`_snapshot_instance_credentials`. ``persist_credentials_for_stack`` + already wrote freshly-generated access rows for the new instance — we drop + those and replace with the preserved set so port / connection_url / SSH + private key persist verbatim across the redeploy. + + Returns the number of preserved rows written. + """ + preserved = (preserved_user_json or {}).get("_preserved_access") or [] + if not preserved: + return 0 + + # Drop the just-generated access rows for the new instance — they're + # superseded by the snapshot. + db.query(DeploymentInstanceAccess).filter( + DeploymentInstanceAccess.deployment_instance_id == new_instance.id + ).delete(synchronize_session=False) + + written = 0 + for row in preserved: + db.add( + DeploymentInstanceAccess( + deployment_instance_id=new_instance.id, + access_type=row["access_type"], + group_id=row.get("group_id"), + connection_url=row.get("connection_url"), + username=row.get("username"), + password=row.get("password"), + ssh_private_key=row.get("ssh_private_key"), + port=row.get("port"), + is_active=row.get("is_active", True), + expires_at=row.get("expires_at"), + ) + ) + written += 1 + db.commit() + return written + + +def _delete_single_instance_resources( + *, + db, + deployment, + instance: DeploymentInstance, + heat_service: HeatStackService, + log_service: DeploymentLogService, +) -> tuple[bool, list[str]]: + """Tear down the Heat stack for ONE instance and clean its DB rows. + + Mirrors the relevant slice of ``delete_deployment`` but for a single + instance, not the whole deployment. Returns ``(success, remaining_stack_ids)``: + * ``success`` is False when Heat refused to delete the stack — in + that case the row is intentionally NOT removed (so the user can + retry), and the parent deployment.openstack_stack_id keeps the + surviving id so a future cleanup can pick it up. + * ``remaining_stack_ids`` is the updated stack-id list for the + parent deployment (caller persists it). + """ + deployment_id = deployment.id + stack_id = instance.openstack_server_id # Heat stack id, same column + + # Parse the deployment's stack-id JSON array so we can rewrite it + # after the per-instance delete. + try: + stack_ids = json.loads(deployment.openstack_stack_id or "[]") + if not isinstance(stack_ids, list): + stack_ids = [stack_ids] + except (json.JSONDecodeError, TypeError): + stack_ids = [deployment.openstack_stack_id] if deployment.openstack_stack_id else [] + + if stack_id: + try: + heat_service.delete_stack(stack_id) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.DEPLOYMENT_DELETED, + message=f"Redeploy: Heat stack deleted: {stack_id}", + level=DeploymentLogLevel.INFO, + details={"stack_id": stack_id, "instance_id": instance.id}, + ) + except Exception as delete_error: + logger.error( + f"Redeploy: failed to delete stack {stack_id} for instance {instance.id}: {delete_error}", + exc_info=True, + ) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.FAILED, + message=f"Redeploy: failed to delete Heat stack {stack_id}: {delete_error}", + level=DeploymentLogLevel.ERROR, + details={"stack_id": stack_id, "instance_id": instance.id, "error": str(delete_error)}, + ) + return False, stack_ids + + # Pull the deleted id out of the deployment's stack-id list. + stack_ids = [sid for sid in stack_ids if sid != stack_id] + + # Wipe access rows + the instance row itself; the new instance will + # be persisted by the provisioning helper afterwards. + try: + db.query(DeploymentInstanceAccess).filter( + DeploymentInstanceAccess.deployment_instance_id == instance.id + ).delete(synchronize_session=False) + db.delete(instance) + db.flush() + except Exception as wipe_err: + logger.error( + f"Redeploy: failed to delete DB rows for instance {instance.id}: {wipe_err}", + exc_info=True, + ) + db.rollback() + return False, stack_ids + + return True, stack_ids + + +def _build_redeploy_stack_name(deployment, stack_index: int, instance_id: str) -> str: + """Like :func:`_build_stack_name` but with a per-redeploy suffix to avoid + colliding with the OLD stack still in DELETE_IN_PROGRESS on Heat. + + ``heat_service.delete_stack`` returns as soon as Heat ACK's the request; + the actual stack tear-down (VMs / volumes / floating-IPs) takes 30-120 s + on a busy region. If we tried to ``create_stack`` with the same name + immediately, Heat would reject it with "Stack already exists". We suffix + with ``-r<8-hex-of-old-instance-id>`` — deterministic per redeploy, + short enough not to blow the 64-char cap, and stable across retries of + the same task so an Ansible-only re-run doesn't accidentally fork + another stack. + + The new name is what gets persisted into ``DeploymentInstance.vm_name``, + so the original ``-s{idx}-`` slug stays inside the name and remains + parseable by :func:`_recover_stack_index_for_instance`. + """ + # Strip dashes so the suffix is compact and only hex chars survive (the + # regex used to recover stack_index allows [a-f0-9]+ after the index). + suffix = instance_id.replace("-", "")[:8].lower() + base = f"{deployment.name}-s{stack_index}-{deployment.id[:4]}-r{suffix}" + return base.replace(" ", "-").replace("_", "-").lower()[:64] + + +def _recover_stack_assignment_and_index( + instance: DeploymentInstance, + stack_assignments_raw: list[dict], + db=None, +) -> tuple[dict | None, int | None]: + """Recover the (stack_assignment, 1-based stack_index) tuple for an + instance. Single source of truth for the regex + fallback so the two + pieces of state can't drift. + + Resolution order: + 1. Regex on ``vm_name`` for the original ``-s-`` slug. This is + the cheap path; nothing else needs to happen when the name survived + unmodified. + 2. ``StackAssignment.stack_index`` — the wizard payload itself carries + the 1-based index for each assignment, so we can look up by it. + 3. Fallback: position of this instance among its deployment siblings + when sorted by ``created_at``. This matches the order in which + ``deploy_stack`` iterates ``stack_assignments`` (sequential, no + concurrency), so the Nth-created instance corresponds to the Nth + assignment. Only used when ``db`` is supplied. + + Returns ``(None, None)`` if all three paths fail. + """ + import re + + idx_one_based: int | None = None + if instance.vm_name: + match = re.search(r"-s(\d+)-[a-f0-9]+", instance.vm_name) + if match: + idx_one_based = int(match.group(1)) + + # If the regex worked, try to match the assignment by its stack_index field + # first (most reliable), then fall back to positional index. + if idx_one_based is not None: + for assignment in stack_assignments_raw: + if isinstance(assignment, dict) and assignment.get("stack_index") == idx_one_based: + return assignment, idx_one_based + if 0 <= idx_one_based - 1 < len(stack_assignments_raw): + return stack_assignments_raw[idx_one_based - 1], idx_one_based + # vm_name parsed but no matching assignment (e.g. empty list, or a + # malformed deployment_parameters). Hold onto the parsed index so the + # logging fallback (_stack_index_for_instance) is still useful, but + # signal "no assignment recovered" via the None on the first tuple slot. + return None, idx_one_based + + # Final fallback: positional match by created_at among siblings. Only + # works when we have a session and there's at least one assignment. + if db is not None and stack_assignments_raw: + siblings = ( + db.query(DeploymentInstance.id) + .filter(DeploymentInstance.deployment_id == instance.deployment_id) + .order_by(DeploymentInstance.created_at.asc()) + .all() + ) + ordered_ids = [row[0] for row in siblings] + try: + pos = ordered_ids.index(instance.id) + except ValueError: + pos = -1 + if 0 <= pos < len(stack_assignments_raw): + idx_one_based = pos + 1 + return stack_assignments_raw[pos], idx_one_based + + return None, None + + +def _reconstruct_stack_assignment_for_instance( + instance: DeploymentInstance, + stack_assignments_raw: list[dict], +) -> dict | None: + """Back-compat shim around :func:`_recover_stack_assignment_and_index`. + + Kept for the unit tests that exercise the vm_name parse in isolation + (``tests/unit/test_redeploy_tasks.py``). New callers should use + :func:`_recover_stack_assignment_and_index` so the index + assignment + come back together (no second regex pass). + """ + assignment, _ = _recover_stack_assignment_and_index(instance, stack_assignments_raw) + return assignment + + +def _stack_index_for_instance( + instance: DeploymentInstance, + stack_assignments_raw: list[dict], +) -> int: + """Back-compat shim around :func:`_recover_stack_assignment_and_index`. + + Returns the 1-based index recovered from vm_name, or 1 as a last-resort + fallback. New code in this module should call the combined helper. + """ + _, idx = _recover_stack_assignment_and_index(instance, stack_assignments_raw) + return idx if idx is not None else 1 + + +@celery_app.task(bind=True) +def redeploy_instance( + self, + deployment_id: str, + instance_id: str, + deployment_parameter_overrides: dict | None = None, + preserve_credentials: bool = False, +) -> dict: + """Destroy-and-recreate a single ``DeploymentInstance`` (= one VM). + + The parent deployment stays in ``RUNNING`` so its siblings remain + reachable. Only the targeted instance flips to ``REDEPLOYING`` for the + duration of the task. On success the old row is dropped and a fresh + one (new credentials by default) takes its place; on failure the row + flips to ``FAILED`` and the deployment.openstack_stack_id is rewritten + to reflect surviving stacks (so a future delete cleans up correctly). + + Heat naming is preserved (same ``-s{idx}-{dep4}`` slug) so the new + stack looks identical to a fresh deploy in the OpenStack tags. + + Args: + deployment_id: Parent deployment ID. + instance_id: ID of the ``DeploymentInstance`` row to recreate. + deployment_parameter_overrides: Optional overrides merged on top + of the deployment's stored parameters before splitting into + Heat / Ansible. + preserve_credentials: When True, reuse the existing access rows + on the new instance instead of regenerating. Default False. + """ + task_id = self.request.id + logger.info( + f"Starting redeploy_instance task for deployment_id={deployment_id} " + f"instance_id={instance_id} task_id={task_id}" + ) + + db = SessionLocal() + try: + repo = DeploymentRepository(db) + log_service = DeploymentLogService(db) + file_service = TemplateVersionFileService(db) + + deployment = repo.get_by_id(deployment_id) + if not deployment: + return {"status": "failed", "error": "Deployment not found"} + + instance = ( + db.query(DeploymentInstance) + .filter( + DeploymentInstance.id == instance_id, + DeploymentInstance.deployment_id == deployment_id, + ) + .first() + ) + if not instance: + return {"status": "failed", "error": f"Instance {instance_id} not found"} + + instance.status = DeploymentInstanceStatus.REDEPLOYING + db.commit() + + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.DEPLOYMENT_STARTED, + message=( + f"Redeploying instance {instance.vm_name or instance_id} " + f"(task_id: {task_id}, preserve_credentials={preserve_credentials})" + ), + level=DeploymentLogLevel.INFO, + details={ + "task_id": task_id, + "instance_id": instance_id, + "preserve_credentials": preserve_credentials, + "overrides": deployment_parameter_overrides or {}, + }, + ) + + # --- Parse deployment parameters --- + if not deployment.deployment_parameters: + return _fail_instance(repo, log_service, db, deployment_id, instance, "No deployment_parameters found") + + try: + deployment_params = json.loads(deployment.deployment_parameters) + base_parameters = deployment_params.get("parameters", {}) + stack_assignments_raw = deployment_params.get("stack_assignments", []) + teacher_info = deployment_params.get("teacher", {}) + except json.JSONDecodeError as e: + return _fail_instance(repo, log_service, db, deployment_id, instance, f"Invalid deployment_parameters JSON: {e}") + + # Recover the stack assignment + index that produced THIS instance + # via the consolidated helper (regex first, positional fallback) so + # both pieces come from the same source — no risk of one returning + # None while the other defaults to 1. + stack_assignment_data, stack_idx = _recover_stack_assignment_and_index( + instance, stack_assignments_raw, db=db, + ) + if stack_assignment_data is None or stack_idx is None: + return _fail_instance( + repo, log_service, db, deployment_id, instance, + f"Cannot recover stack_assignment for instance {instance_id} (vm_name={instance.vm_name!r})", + ) + + # Load template once. + ctx_or_error = _load_template_context(file_service, deployment.template_version_id) + if isinstance(ctx_or_error, str): + return _fail_instance(repo, log_service, db, deployment_id, instance, ctx_or_error) + template_context = ctx_or_error + + # Optionally snapshot existing credentials before we wipe the access rows. + preserved_user_json = ( + _snapshot_instance_credentials(db, instance) if preserve_credentials else None + ) + + openstack_project = deployment.openstack_project + if not openstack_project: + return _fail_instance( + repo, log_service, db, deployment_id, instance, + f"Deployment {deployment_id} has no openstack_project_id set", + ) + + heat_service = HeatStackService(openstack_project) + + # --- 1. Delete the old Heat stack + DB rows for this instance --- + ok, remaining_stack_ids = _delete_single_instance_resources( + db=db, + deployment=deployment, + instance=instance, + heat_service=heat_service, + log_service=log_service, + ) + if not ok: + # _delete_single_instance_resources already logged; flip instance + # to FAILED (it may still be in DB if the wipe failed) and bail. + try: + # If the instance row is still around, mark it FAILED. + still_there = db.query(DeploymentInstance).filter( + DeploymentInstance.id == instance_id + ).first() + if still_there: + still_there.status = DeploymentInstanceStatus.FAILED + db.commit() + except Exception: + db.rollback() + return { + "status": "failed", + "deployment_id": deployment_id, + "instance_id": instance_id, + "error": "Failed to delete old Heat stack / DB rows", + } + + # Persist the rewritten stack-id list so a parallel delete sees it. + try: + deployment.openstack_stack_id = json.dumps(remaining_stack_ids) + db.commit() + except Exception as persist_err: + db.rollback() + logger.warning(f"Failed to persist remaining stack ids: {persist_err}") + + # --- 2. Recreate the stack with merged parameters --- + if get_settings().ansible_ssh_private_key: + try: + from src.services.ansible_keypair_service import AnsibleKeypairService + AnsibleKeypairService.ensure_keypair(openstack_project) + except Exception as kp_err: + # The old instance row is already gone; emit a placeholder + # FAILED row carrying the deployment + course_group_id so the + # UI still shows "1 of N VMs broken" rather than silently + # shrinking the class roster. + _record_redeploy_failure_placeholder( + db=db, + deployment_id=deployment_id, + old_instance_id=instance_id, + stack_index=stack_idx, + log_service=log_service, + error_msg=f"Failed to ensure Ansible keypair: {kp_err}", + ) + return { + "status": "failed", + "deployment_id": deployment_id, + "instance_id": instance_id, + "error": f"Failed to ensure Ansible keypair: {kp_err}", + } + + ssh_private_key = get_settings().ansible_ssh_private_key or "" + + def _ansible_factory(*, floating_ip: str, cancel_check): + return AnsibleService( + db=db, + deployment_id=deployment_id, + floating_ip=floating_ip, + ssh_private_key=ssh_private_key, + cancel_check=cancel_check, + ) + + merged_params = _merge_parameter_layers( + base=base_parameters, + deployment_overrides=deployment_parameter_overrides, + instance_overrides=None, + ) + # Use a redeploy-suffixed name to avoid colliding with the OLD stack + # that Heat may still be tearing down (delete_stack is async). + stack_name = _build_redeploy_stack_name(deployment, stack_idx, instance_id) + + try: + def _persist_new_stack_id_now(new_id: str) -> None: + """Append the newly-created Heat stack id onto the parent + deployment's id list AS SOON AS Heat returns it — before + the multi-minute Ansible phase. Closes the orphan window: + without this, a worker crash mid-Ansible would leave the + fresh stack in OpenStack with no reference in + ``deployment.openstack_stack_id`` for ``delete_deployment`` + to find. ``delete_deployment`` has a defensive fallback + via ``DeploymentInstance.openstack_server_id`` as well, + but persisting eagerly here keeps that fallback as a + belt-and-braces measure rather than the only safety net. + """ + remaining_stack_ids.append(new_id) + try: + deployment.openstack_stack_id = json.dumps(remaining_stack_ids) + db.commit() + except Exception as persist_err: + db.rollback() + logger.warning( + f"Failed to incrementally persist redeployed stack id " + f"{new_id}: {persist_err}" + ) + + new_stack_id, new_instance = _provision_one_stack_assignment( + db=db, + deployment=deployment, + stack_assignment_data=stack_assignment_data, + template_context=template_context, + heat_service=heat_service, + ansible_service_factory=_ansible_factory, + log_service=log_service, + all_parameters=merged_params, + teacher_info=teacher_info, + stack_name=stack_name, + stack_index=stack_idx, + total_stacks=1, + cancel_check=None, + preserved_user_json=preserved_user_json, + on_stack_created=_persist_new_stack_id_now, + ) + except Exception as e: + logger.exception(f"Redeploy of instance {instance_id} failed: {e}") + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.FAILED, + message=f"Redeploy failed: {e}", + level=DeploymentLogLevel.ERROR, + details={"instance_id": instance_id, "error": str(e)}, + ) + # An orphan stack id may have been stamped onto the exception by + # the Heat service (CREATE_FAILED / timeout) or by the credential + # persist step (which keeps the stack id alive so the user can + # retry). Add it back to the deployment so a future delete cleans + # up rather than silently leaking the stack. The ``on_stack_created`` + # callback may have ALREADY appended it for the CREATE_COMPLETE- + # then-Ansible-failed path, so dedupe before persisting. + orphan = getattr(e, "stack_id", None) + if orphan and orphan not in remaining_stack_ids: + remaining_stack_ids.append(orphan) + try: + deployment.openstack_stack_id = json.dumps(remaining_stack_ids) + db.commit() + except Exception: + db.rollback() + # Make sure the deployment still has a placeholder DeploymentInstance + # row visible — the old row is gone and (depending on where the + # exception hit) the new one may never have been persisted. + _record_redeploy_failure_placeholder( + db=db, + deployment_id=deployment_id, + old_instance_id=instance_id, + stack_index=stack_idx, + log_service=log_service, + error_msg=str(e), + ) + return { + "status": "failed", + "deployment_id": deployment_id, + "instance_id": instance_id, + "error": str(e), + } + + # Persist the new stack id back onto the parent deployment. The + # ``on_stack_created`` callback above already appended it right + # after Heat returned, so this block is a no-op in the normal + # flow. It stays as a safety net for the edge case where the + # callback was skipped or failed: we re-flush the in-memory + # ``remaining_stack_ids`` list so the on-disk JSON matches. + if new_stack_id and new_stack_id not in remaining_stack_ids: + remaining_stack_ids.append(new_stack_id) + try: + deployment.openstack_stack_id = json.dumps(remaining_stack_ids) + db.commit() + except Exception as persist_err: + db.rollback() + logger.warning( + f"Failed to persist redeployed stack id {new_stack_id}: {persist_err}" + ) + + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.VM_READY, + message=f"Redeploy completed for instance {new_instance.id if new_instance else instance_id}", + level=DeploymentLogLevel.INFO, + details={ + "old_instance_id": instance_id, + "new_instance_id": new_instance.id if new_instance else None, + "new_stack_id": new_stack_id, + }, + ) + + # Normalise the parent deployment status — when a previously-FAILED + # deployment was recovered via per-instance redeploy, the row should + # come back to RUNNING. Other statuses (CREATING/DELETING/RESTARTING) + # were already gated out by the endpoint, so a redeploy never sees + # them mid-flight. + try: + if deployment.status != DeploymentStatus.RUNNING: + repo.update_status(deployment_id, DeploymentStatus.RUNNING) + except Exception as status_err: + logger.warning( + f"Could not normalise deployment status after redeploy_instance: {status_err}" + ) + + return { + "status": "redeployed", + "deployment_id": deployment_id, + "old_instance_id": instance_id, + "new_instance_id": new_instance.id if new_instance else None, + "new_stack_id": new_stack_id, + "task_id": task_id, + } + + except Exception as e: + logger.exception(f"Error during redeploy_instance task for {instance_id}: {e}") + return {"status": "failed", "deployment_id": deployment_id, "instance_id": instance_id, "error": str(e)} + finally: + db.close() + + +def _fail_instance( + repo: DeploymentRepository, + log_service: DeploymentLogService, + db, + deployment_id: str, + instance: DeploymentInstance | None, + error_msg: str, +) -> dict: + """Log + flip a single instance to FAILED without touching the + parent deployment's status (siblings should stay reachable).""" + logger.error(error_msg) + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.FAILED, + message=error_msg, + level=DeploymentLogLevel.ERROR, + details={"instance_id": instance.id if instance else None}, + ) + if instance is not None: + try: + instance.status = DeploymentInstanceStatus.FAILED + db.commit() + except Exception: + db.rollback() + return {"status": "failed", "error": error_msg, "deployment_id": deployment_id} + + +def _record_redeploy_failure_placeholder( + *, + db, + deployment_id: str, + old_instance_id: str, + stack_index: int, + log_service: DeploymentLogService, + error_msg: str, +) -> None: + """Insert a placeholder ``DeploymentInstance(status=FAILED)`` after a + mid-redeploy crash that left the deployment with no row for this slot. + + By the time we reach this helper the OLD instance has already been + deleted from the DB (and its Heat stack from OpenStack), and the new + one may or may not have been persisted before the failure hit. Without + a placeholder the deployment silently shrinks — the lecturer sees one + fewer VM in the dashboard with no obvious link back to the failed + redeploy. The placeholder keeps the count stable, surfaces the error + via the deployment-logs API, and gives the operator a clear retry target. + + No-op if a row for ``old_instance_id`` is somehow still around (e.g. + rollback un-deleted it) or if a new row was already persisted in the + same task — we never want to double-write. + """ + try: + existing = ( + db.query(DeploymentInstance) + .filter(DeploymentInstance.id == old_instance_id) + .first() + ) + if existing is not None: + # Old row survived (rollback or duplicate path) — just flip its + # status rather than inserting a sibling. + existing.status = DeploymentInstanceStatus.FAILED + db.commit() + return + + # Create a fresh row. We don't know the new Heat stack id; leave + # openstack_server_id NULL so future delete_deployment doesn't try + # to tear down a non-existent stack. The vm_name records which + # slot failed so it shows up in the UI in roughly the right place. + from uuid import uuid4 + placeholder = DeploymentInstance( + id=str(uuid4()), + deployment_id=deployment_id, + vm_name=f"redeploy-failed-s{stack_index}", + openstack_server_id=None, + status=DeploymentInstanceStatus.FAILED, + ) + db.add(placeholder) + db.commit() + log_service.log( + deployment_id=deployment_id, + event_type=DeploymentLogEventType.FAILED, + message=( + f"Redeploy left stack #{stack_index} without a DB row; " + "wrote a FAILED placeholder so the slot stays visible." + ), + level=DeploymentLogLevel.WARNING, + details={ + "old_instance_id": old_instance_id, + "placeholder_id": placeholder.id, + "stack_index": stack_index, + "error": error_msg, + }, + ) + except Exception as placeholder_err: + # Last resort — the placeholder is itself best-effort. Log and let + # the surrounding return run; the operator still has the log row + # explaining what failed. + logger.error( + f"Failed to record redeploy-failure placeholder for {old_instance_id}: {placeholder_err}", + exc_info=True, + ) + try: + db.rollback() + except Exception: + pass + + +@celery_app.task(bind=True) +def redeploy_deployment( + self, + deployment_id: str, + deployment_parameter_overrides: dict | None = None, + instance_parameter_overrides: dict | None = None, + preserve_credentials: bool = False, +) -> dict: + """Redeploy every VM in a deployment, one after another. + + Sequential by design — the parent OpenStack project has finite quota, + so fan-out would risk a quota exhaustion mid-class. The deployment + stays ``RUNNING`` between instances (only the targeted one flips to + REDEPLOYING) so the UI shows per-VM progress instead of "everything + rebooting". + + The endpoint accepts both a deployment-wide override map and a + per-instance map (keyed by ``DeploymentInstance.id``). For each + instance the redeploy merges: + + base (deployment.deployment_parameters['parameters']) + + deployment_parameter_overrides + + instance_parameter_overrides.get(instance_id, {}) + + and recreates that one VM via :func:`redeploy_instance`'s logic. + """ + task_id = self.request.id + logger.info( + f"Starting redeploy_deployment task for deployment_id={deployment_id} task_id={task_id}" + ) + + db = SessionLocal() + try: + repo = DeploymentRepository(db) + deployment = repo.get_by_id(deployment_id) + if not deployment: + return {"status": "failed", "error": "Deployment not found"} + + instances = list( + db.query(DeploymentInstance) + .filter(DeploymentInstance.deployment_id == deployment_id) + .order_by(DeploymentInstance.created_at.asc()) + .all() + ) + if not instances: + return {"status": "failed", "error": "Deployment has no instances to redeploy"} + + # Snapshot instance IDs now — the per-instance redeploy deletes + # the row and creates a new one, which would invalidate any + # ORM-bound list mid-iteration. + instance_ids = [inst.id for inst in instances] + finally: + db.close() + + results: list[dict] = [] + overall_status = "redeployed" + for inst_id in instance_ids: + per_instance_overrides = (instance_parameter_overrides or {}).get(inst_id) + # The per-instance task merges its own params on top — pass the + # per-VM overrides folded into the deployment-wide map for this call. + # We DON'T pass instance_parameter_overrides through to redeploy_instance + # itself because each call already targets one VM. + merged_dep_overrides = _merge_parameter_layers( + base={}, + deployment_overrides=deployment_parameter_overrides, + instance_overrides=per_instance_overrides, + ) + # Wrap the inner call: redeploy_instance is expected to return a + # dict on every code path, but a future regression (or a SQLAlchemy + # error escaping its outer try) must NOT abandon the loop mid-class. + # Without this, instances we never touched are silently skipped, + # the aggregated result is lost, and the operator only sees a generic + # Celery failure. + try: + result = redeploy_instance.run( + deployment_id=deployment_id, + instance_id=inst_id, + deployment_parameter_overrides=merged_dep_overrides or None, + preserve_credentials=preserve_credentials, + ) + except Exception as inner_err: + logger.exception( + f"redeploy_instance.run raised for instance {inst_id}; " + "continuing with remaining instances" + ) + result = { + "status": "failed", + "deployment_id": deployment_id, + "instance_id": inst_id, + "error": f"unhandled exception: {inner_err}", + } + results.append(result) + if result.get("status") != "redeployed": + overall_status = "partial_failure" + + # Normalise the parent deployment's status on success: if it was in + # FAILED (e.g. recovery flow via redeploy), bring it back to RUNNING. + # On partial_failure we leave the status alone — the per-instance rows + # already carry FAILED markers and the operator may want to retry. + if overall_status == "redeployed": + finalize_db = SessionLocal() + try: + finalize_repo = DeploymentRepository(finalize_db) + finalize_dep = finalize_repo.get_by_id(deployment_id) + if finalize_dep is not None and finalize_dep.status != DeploymentStatus.RUNNING: + finalize_repo.update_status(deployment_id, DeploymentStatus.RUNNING) + except Exception as status_err: + logger.warning( + f"Could not normalise deployment status to RUNNING after redeploy: {status_err}" + ) + finally: + finalize_db.close() + + return { + "status": overall_status, + "deployment_id": deployment_id, + "task_id": task_id, + "instance_results": results, + } diff --git a/src/tasks/lecturer_tasks.py b/src/tasks/lecturer_tasks.py new file mode 100644 index 0000000..cd160c8 --- /dev/null +++ b/src/tasks/lecturer_tasks.py @@ -0,0 +1,172 @@ +"""Admin cascade-delete for a lecturer account. + +Ordering matters: OpenStack stacks must go down BEFORE their DB rows are +deleted, and every deployment must be torn down before its owning +template/OpenStack project — otherwise we orphan Heat stacks on +OpenStack and hit FK-constraint failures on the templates cascade. + +The task calls ``delete_deployment`` synchronously (``.apply()``) rather +than via ``.delay()`` so we can observe each step's outcome and bail out +if ONE stack cleanup fails. That mirrors the single-deployment contract +we introduced earlier: a failed Heat delete keeps the DB row around so +the admin can retry, and a failed cascade keeps the whole user around +for the same reason. +""" +from __future__ import annotations + +import logging + +from src.celery_app import celery_app +from src.core.database import SessionLocal +from src.models.openstack_project import OpenstackProject +from src.models.template import Template +from src.models.user import User +from src.services.lecturer_service import _deployments_for_external_id +from src.services.template_service import TemplateService +from src.tasks.deploy_tasks import delete_deployment + +logger = logging.getLogger(__name__) + + +@celery_app.task(bind=True, name="src.tasks.lecturer_tasks.cascade_delete_lecturer") +def cascade_delete_lecturer(self, user_id: str) -> dict: + """Delete every deployment, template, and OpenStack-project row owned + by ``user_id``, then the user row itself. + + Bail-out semantics: + * If any ``delete_deployment`` returns ``"stack_delete_failed"`` or + raises, the cascade stops there. The user survives so an admin + can inspect + retry. + * Templates and OSPs only get removed after all deployments are gone, + because those tables carry FKs the deployments reference. + + Returns: + dict summarising what happened, keyed by phase. + """ + task_id = self.request.id + db = SessionLocal() + + result = { + "user_id": user_id, + "task_id": task_id, + "status": "pending", + "deployments_deleted": 0, + "deployments_failed": 0, + "templates_deleted": 0, + "openstack_projects_deleted": 0, + } + + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + result["status"] = "user_not_found" + return result + + # ------------------------------------------------------------------ + # 1. Deployments — via delete_deployment.apply() so we can see the + # per-deployment outcome and abort on a Heat failure. + # ------------------------------------------------------------------ + deployments = _deployments_for_external_id(db, user.external_id) + logger.info( + f"cascade_delete_lecturer: tearing down {len(deployments)} deployment(s) " + f"for user {user_id}" + ) + for d in deployments: + dep_id = str(d.id) + try: + sub = delete_deployment.apply(args=[dep_id]).get(disable_sync_subtasks=False) + except Exception as e: + logger.error( + f"cascade_delete_lecturer: delete_deployment({dep_id}) crashed: {e}", + exc_info=True, + ) + result["deployments_failed"] += 1 + result["status"] = "aborted_on_deployment_failure" + return result + + if sub.get("status") in {"deleted", "not_found", "already_gone"}: + result["deployments_deleted"] += 1 + else: + # stack_delete_failed or anything else non-terminal — the + # deployment row is intentionally kept by delete_deployment + # so the admin can retry. Stop the cascade so we don't + # blow away templates that the surviving deployment might + # still need. + logger.warning( + f"cascade_delete_lecturer: aborting — deployment {dep_id} " + f"reported status {sub.get('status')!r}" + ) + result["deployments_failed"] += 1 + result["status"] = "aborted_on_deployment_failure" + return result + + # ------------------------------------------------------------------ + # 2. Templates — cascade via TemplateService.delete_template (which + # already handles the versions -> versions_files -> approvals + # chain and the "still-has-deployments" check we solved for + # normal template deletes). + # ------------------------------------------------------------------ + # Re-fetch after the deployment sweep so cascaded-away templates + # don't show up. + templates = db.query(Template).filter(Template.owner_id == user_id).all() + template_service = TemplateService(db) + for t in templates: + try: + template_service.delete_template( + template_id=t.id, + user_id=user_id, + is_admin=True, # cascade runs with admin authority + ) + result["templates_deleted"] += 1 + except Exception as e: + logger.error( + f"cascade_delete_lecturer: template delete {t.id} failed: {e}", + exc_info=True, + ) + result["status"] = "aborted_on_template_failure" + return result + + # ------------------------------------------------------------------ + # 3. OpenStack projects — our DB row only. We do NOT touch Keystone + # (see the spec discussion): the OSP itself is a Keycloak-managed + # resource that other systems may reference. + # ------------------------------------------------------------------ + osps = ( + db.query(OpenstackProject).filter(OpenstackProject.owner_user_id == user_id).all() + ) + for op in osps: + try: + db.delete(op) + db.flush() + result["openstack_projects_deleted"] += 1 + except Exception as e: + logger.error( + f"cascade_delete_lecturer: OSP delete {op.id} failed: {e}", + exc_info=True, + ) + db.rollback() + result["status"] = "aborted_on_osp_failure" + return result + + # ------------------------------------------------------------------ + # 4. User row — safe now, nothing left FK-referencing it (for the + # ownership tables we handled above; historical CourseMember + # rows for this lecturer are cleaned up by the same GC pass + # delete_deployment already runs at its tail). + # ------------------------------------------------------------------ + db.delete(user) + db.commit() + result["status"] = "deleted" + logger.info(f"cascade_delete_lecturer: user {user_id} fully removed") + return result + + except Exception as e: + logger.exception( + f"cascade_delete_lecturer: unexpected error for user {user_id}: {e}" + ) + db.rollback() + result["status"] = "failed" + result["error"] = str(e) + return result + finally: + db.close() diff --git a/src/utils/app_manifest_parser.py b/src/utils/app_manifest_parser.py index 04a325e..fe0dced 100644 --- a/src/utils/app_manifest_parser.py +++ b/src/utils/app_manifest_parser.py @@ -99,10 +99,16 @@ def parse(content: str) -> dict: # Credentials credentials_raw = data.get("credentials") or {} credentials = { - "per_student": credentials_raw.get("per_student") or [], + "per_group": credentials_raw.get("per_group") or [], "teacher": credentials_raw.get("teacher") or [], } + # Ansible configuration (vars mapping for extra_vars) + ansible_raw = data.get("ansible") or {} + ansible = { + "vars": ansible_raw.get("vars") or {}, + } + # User files user_files = data.get("user_files") or [] @@ -123,6 +129,7 @@ def parse(content: str) -> dict: "parameters": parameters, "outputs": outputs, "credentials": credentials, + "ansible": ansible, "user_files": user_files, "artifacts": artifacts, } @@ -149,9 +156,9 @@ def extract_parameters(content: str) -> list[dict]: def extract_credentials(content: str) -> dict: """Return the credentials block or empty structure on failure.""" try: - return AppManifestParser.parse(content).get("credentials", {"per_student": [], "teacher": []}) + return AppManifestParser.parse(content).get("credentials", {"per_group": [], "teacher": []}) except Exception: - return {"per_student": [], "teacher": []} + return {"per_group": [], "teacher": []} @staticmethod def extract_user_files(content: str) -> list[dict]: @@ -163,6 +170,15 @@ def extract_user_files(content: str) -> list[dict]: # Mapping from artifacts: keys in app.yaml to TemplateVersionFile.FileType values. # Order in this dict is also the deployment execution order suggested in the README. + # + # Some keys come in two flavours: + # - singular (``shell_script: scripts/x.sh``) → exactly one path + # - plural (``shell_scripts: [a.sh, b.sh]``) → a list of paths + # Both expand to the same FileType. The plural form is for templates that + # have multiple files of the same kind (e.g. several helper scripts in + # ``scripts/`` or several config files in ``files/``) — without it, every + # extra file would be imported as OTHER and silently skipped at deploy + # time because the Ansible-copy step only picks up SHELL_SCRIPT/CONFIG_FILE. ARTIFACT_KEY_TO_FILE_TYPE: dict[str, str] = { "heat_template": "HEAT_TEMPLATE", "cloud_init": "CLOUD_INIT", @@ -171,7 +187,9 @@ def extract_user_files(content: str) -> list[dict]: "helm_chart": "HELM_CHART", "helm": "HELM_CHART", "shell_script": "SHELL_SCRIPT", + "shell_scripts": "SHELL_SCRIPT", # list-of-paths variant "config_file": "CONFIG_FILE", + "config_files": "CONFIG_FILE", # list-of-paths variant } PRIMARY_ARTIFACT_KEY: str = "heat_template" @@ -186,7 +204,16 @@ def get_linked_files(parsed: dict) -> list[dict]: - file_type: FileType enum value (string, e.g. "HEAT_TEMPLATE") - relative_path: path relative to the directory containing app.yaml - is_primary: True for the heat_template artifact (deployment entrypoint) - - order: position from artifacts dict (insertion order) + - order: monotonically increasing across all paths (1-based) + + Values may be either a single string (``heat_template: heat/main.yaml``) + or a list of strings (``shell_scripts: [a.sh, b.sh]``) — a list expands + to multiple entries, one per path. Both single- and plural-key forms + share the same FileType (see ARTIFACT_KEY_TO_FILE_TYPE). + + Non-string / non-list values (dict, None, etc.) and blank strings are + skipped silently so that a malformed entry doesn't kill the whole + import. The ordering is preserved from the YAML so that deployment runs files in the order their authors specified - matching the user-supplied app.yaml @@ -197,15 +224,28 @@ def get_linked_files(parsed: dict) -> list[dict]: return [] result: list[dict] = [] - for index, (key, value) in enumerate(artifacts.items()): - if not isinstance(value, str) or not value.strip(): - continue + order = 0 + for key, value in artifacts.items(): + # Normalise both single-string and list-of-strings into one path list. + # Anything else (dict, None, ...) → no paths → no entries. + if isinstance(value, str): + paths = [value.strip()] if value.strip() else [] + elif isinstance(value, list): + paths = [v.strip() for v in value if isinstance(v, str) and v.strip()] + else: + paths = [] + file_type = AppManifestParser.ARTIFACT_KEY_TO_FILE_TYPE.get(key, "OTHER") - result.append({ - "artifact_key": key, - "file_type": file_type, - "relative_path": value.strip(), - "is_primary": key == AppManifestParser.PRIMARY_ARTIFACT_KEY, - "order": index + 1, - }) + for path in paths: + order += 1 + result.append({ + "artifact_key": key, + "file_type": file_type, + # ``is_primary`` is reserved for the singular heat_template + # key. A list-of-paths variant could never produce a primary + # anyway because PRIMARY_ARTIFACT_KEY is exactly that string. + "relative_path": path, + "is_primary": key == AppManifestParser.PRIMARY_ARTIFACT_KEY, + "order": order, + }) return result diff --git a/src/utils/cancellation.py b/src/utils/cancellation.py new file mode 100644 index 0000000..8c2aa3b --- /dev/null +++ b/src/utils/cancellation.py @@ -0,0 +1,53 @@ +"""Cooperative cancellation for the deploy_stack Celery task. + +When a user fires ``DELETE /deployments/{id}`` against a deployment that is +still in the ``CREATING`` phase, the API endpoint flips ``status`` to +``DELETING`` and enqueues ``delete_deployment``. The deploy task polls this +status at well-defined checkpoints (between Heat stacks, before each +Ansible phase, inside the SSH wait loop, inside the playbook subprocess +loop). Once it sees ``DELETING`` it stops creating new resources and exits +— leaving the now-running ``delete_deployment`` task to delete every Heat +stack that was created so far (incrementally persisted to +``deployment.openstack_stack_id``). + +This is *cooperative* cancellation: the task itself decides when to stop. +We deliberately do not use Celery's ``revoke(terminate=True)`` because it +would SIGTERM the worker mid-OpenStack-call and leave inconsistent state. +""" +from __future__ import annotations + +from src.models.deployment import DeploymentStatus +from src.repositories.deployment_repository import DeploymentRepository + + +class CancelledException(Exception): + """Raised by long-running phases when ``is_cancel_requested`` returns True. + + Bubbles up to the deploy task's main exception handler, which logs the + cancellation and returns instead of re-raising as a Celery failure. + """ + + +def is_cancel_requested(db, deployment_id: str) -> bool: + """Return True when the deploy task should bail out at its next checkpoint. + + Triggers when: + - the deployment is now in ``DELETING`` or ``DELETED`` state (the API + endpoint sets DELETING the moment a DELETE request arrives, before + it enqueues the delete task) + - the deployment row vanished from the DB (something else cleaned up) + + ``expire_all`` is called first so SQLAlchemy's identity-map cache + doesn't hand us a stale Deployment instance. The status flip happens + in a different process (the API request), so the running Celery + worker's session would otherwise never see it. + """ + db.expire_all() + repo = DeploymentRepository(db) + deployment = repo.get_by_id(deployment_id) + if deployment is None: + return True + return deployment.status in ( + DeploymentStatus.DELETING, + DeploymentStatus.DELETED, + ) diff --git a/src/utils/version_validator.py b/src/utils/version_validator.py new file mode 100644 index 0000000..30d1259 --- /dev/null +++ b/src/utils/version_validator.py @@ -0,0 +1,192 @@ +"""Semver validation and ordering for template-version strings. + +Background +---------- +Vor dieser Datei hat das Backend an mehreren Stellen unkontrolliert +Versions-Strings übernommen (aus `app.version` in app.yaml, aus manuellem +Create-Body) ohne Format-Check oder Eindeutigkeit pro Template. Dadurch +landeten z.B. mehrere Versionen mit `version="2.0.0"` parallel im selben +Template (nur `(template_id, commit_sha)` war unique). + +Diese Helper-Funktionen sind die einzige zentrale Stelle, an der wir +Versions-Strings gegen Semver-2.0 prüfen und in eine vergleichbare Form +bringen. Aufrufer sind: +- ``GithubImportService`` (importiert Versionsnummer aus `app.yaml`) +- ``TemplateVersionService.create_version`` / ``create_version_with_files`` + / ``update_version`` (Versionsnummer aus Request) + +Auf DB-Ebene gibt es zusätzlich einen ``UniqueConstraint`` auf +``(template_id, version)`` (Migration ``ebc91d7b5d43``); diese Helper sind +der user-friendly Vor-Check, damit die Antwort nicht „IntegrityError" lautet. +""" +import re +from typing import Iterable + +from src.core.exceptions import BadRequestException + + +# Semver 2.0.0 — `MAJOR.MINOR.PATCH(-PRERELEASE)?(+BUILD)?`. Keine +# führenden Nullen in den Zahlen, Identifier in Prerelease/Build sind +# ASCII-alnum + Punkt + Bindestrich. Sehr nah an der offiziellen Referenz- +# Regex, aber zur Lesbarkeit ungroupt; wir validieren hier nur das Format, +# das Sortieren übernimmt ``parse_semver``. +SEMVER_RE = re.compile( + r"^(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"(?:-(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) + + +# Strukturierte Fehler-Codes für das Frontend-Branching. +# Konvention: ``BadRequestException(message=…, code=…, details={…})`` — +# Handler in src/core/exceptions.py reicht beide ins API-`errors`-Feld weiter. +ERR_NOT_SEMVER = "VERSION_NOT_SEMVER" +ERR_MISSING_IN_MANIFEST = "VERSION_MISSING_IN_MANIFEST" +ERR_NOT_STRICTLY_GREATER = "VERSION_NOT_STRICTLY_GREATER" +ERR_ALREADY_EXISTS = "VERSION_ALREADY_EXISTS" +ERR_REPLACE_BLOCKED_BY_DEPLOYMENTS = "VERSION_REPLACE_BLOCKED_BY_DEPLOYMENTS" + + +def _parse_prerelease_key(prerelease: str | None) -> tuple: + """Build a comparable key for the prerelease portion per semver §11. + + Semver-Sortierung: + - Eine fehlende Prerelease ist *größer* als eine vorhandene (1.0.0 > 1.0.0-alpha). + Wir kodieren das als ``(1,)`` für „kein prerelease, sortiert oben" und + ``(0, …identifier…)`` für „mit prerelease, sortiert unten". + - Numerische Identifier vergleichen numerisch, alphanumerische lexikalisch; + numerische Identifier sind „kleiner" als alphanumerische bei gleicher Position. + """ + if prerelease is None: + return (1,) # ranks higher than any (0, …) prerelease key + # Jeder Identifier wird zu (kind, value): kind=0 für numeric, kind=1 für + # alphanumeric. ``value`` ist also int oder str, je nach Identifier — + # die Typ-Annotation muss das mit ``int | str`` ausdrücken, sonst engt + # mypy den Listen-Typ nach dem ersten append() auf ``tuple[int, int]`` + # ein und das zweite append() (mit str) bricht. + parts: list[tuple[int, int | str]] = [] + for ident in prerelease.split("."): + if ident.isdigit(): + # (0, numeric_value) sortiert unter (1, alphanum_value) + parts.append((0, int(ident))) + else: + parts.append((1, ident)) + return (0, tuple(parts)) + + +def parse_semver(version: str) -> tuple: + """Return a comparable tuple representation of a semver string. + + Build-Metadata (alles nach ``+``) wird laut Semver-Spezifikation NICHT + in den Vergleich einbezogen — ``1.0.0+a`` == ``1.0.0+b`` ordnungsweise. + Aufrufer, die Eindeutigkeit auf String-Ebene wollen, müssen das selbst + prüfen (DB-Constraint übernimmt das). + + Wirft ``ValueError``, wenn der String kein Semver ist. Aufrufer nutzen + typischerweise ``is_valid_semver`` zur Vorab-Prüfung. + """ + m = SEMVER_RE.match(version) + if not m: + raise ValueError(f"Not a valid semver: {version!r}") + major = int(m.group("major")) + minor = int(m.group("minor")) + patch = int(m.group("patch")) + pre_key = _parse_prerelease_key(m.group("prerelease")) + return (major, minor, patch, pre_key) + + +def is_valid_semver(version: str) -> bool: + """Pure boolean check — used by Pydantic validators that already have + their own raising path. The raising assert_* below is for service code.""" + return bool(version) and SEMVER_RE.match(version) is not None + + +def assert_valid_semver(version: str) -> None: + """Raise ``BadRequestException(ERR_NOT_SEMVER)`` if invalid. + + Klar formulierte Fehlermeldung mit Beispiel — die meisten Owner kommen + aus „mein App-Store-Repo hatte vorher keine Versionsnummer-Disziplin" + und wissen nicht intuitiv, was Semver verlangt. + """ + if not is_valid_semver(version): + raise BadRequestException( + f"Versionsnummer '{version}' ist kein gültiges Semver. " + f"Format: MAJOR.MINOR.PATCH, z. B. '1.0.0' oder '2.0.1-beta'.", + code=ERR_NOT_SEMVER, + details={"version": version}, + ) + + +def assert_strictly_greater( + new_version: str, + existing_versions: Iterable[str], + *, + allow_equal_replace_target: bool = False, +) -> str | None: + """Stell sicher, dass ``new_version`` strikt größer ist als jede existierende. + + Wirft entweder: + - ``ERR_NOT_STRICTLY_GREATER`` wenn eine existierende Version >= ist + und der String nicht identisch zu ihr ist. + - ``ERR_ALREADY_EXISTS`` wenn der String identisch zu einer existierenden + ist und ``allow_equal_replace_target`` nicht gesetzt ist. Frontend + branchst darauf und bietet den Replace-Pfad an. + + Gibt den String der existierenden „Kollisions-Version" zurück, wenn + ``allow_equal_replace_target=True`` und eine Kollision vorliegt — sonst + None (kein Konflikt). Aufrufer im Replace-Pfad nutzen den Rückgabewert + nicht direkt (sie haben die Row schon), aber er ist konsistent mit der + Erwartung „diese Funktion sagt mir, was kollidiert". + + Nicht-semver-Strings unter den existierenden werden defensiv + übersprungen — sie können nur über Legacy-Daten/Dedupe-Suffixe + entstehen und sollen keinen neuen Insert blockieren. + """ + # Vorab-Check: new_version muss valid sein. assert_valid_semver hebt sonst. + assert_valid_semver(new_version) + new_key = parse_semver(new_version) + + # Existierende Strings, die nicht Semver sind (z.B. „2.0.0+dedupe-abc" + # nach der Dedupe-Migration), als Block-Vergleich überspringen — sie + # zählen weder zur Monotonie-Vergleichsmenge noch zur Identitäts-Kollision. + max_existing: tuple | None = None + max_existing_str: str | None = None + identical: str | None = None + for ev in existing_versions: + if ev == new_version: + identical = ev + continue + try: + ev_key = parse_semver(ev) + except ValueError: + continue + if max_existing is None or ev_key > max_existing: + max_existing = ev_key + max_existing_str = ev + + if identical is not None and not allow_equal_replace_target: + raise BadRequestException( + f"Version '{new_version}' ist in diesem Template bereits vorhanden. " + f"Bitte bumpe `app.version` im Repo oder ersetze die bestehende Version.", + code=ERR_ALREADY_EXISTS, + details={"version": new_version}, + ) + + # Replace-Pfad: wir ersetzen eine bestehende Version mit demselben String. + # In dem Fall bewusst KEIN Monotonie-Check — der String ist absichtlich + # gleich (kleiner-oder-gleich Max ist normal); der Caller hat die + # existierende Row schon zur Löschung markiert. + if identical is not None and allow_equal_replace_target: + return identical + + if max_existing is not None and new_key <= max_existing: + raise BadRequestException( + f"Version '{new_version}' ist nicht strikt größer als die bestehende " + f"höchste Version '{max_existing_str}'. Bitte bumpe `app.version` im Repo.", + code=ERR_NOT_STRICTLY_GREATER, + details={"version": new_version, "current_max": max_existing_str}, + ) + + return identical diff --git a/tests/api/test_course_filter_routes.py b/tests/api/test_course_filter_routes.py new file mode 100644 index 0000000..9bf0bcd --- /dev/null +++ b/tests/api/test_course_filter_routes.py @@ -0,0 +1,430 @@ +"""Tests for /api/v1/course-filters endpoints. + +The resource is a flat admin-managed list of strings the frontend renders as +filter-chips above the course list. The interesting contract pieces: + +- Reads are open to any authenticated user (lecturer too — frontend needs them). +- Writes (POST/PATCH/DELETE) are ADMIN-only; a lecturer must get a 403. +- ``name`` is unique → duplicate create returns 409 (ConflictException), + the service falls back on IntegrityError if the pre-check races. +- 404 on unknown id for both PATCH and DELETE. + +We mock at the dependency level (DB session + ``get_current_user``) like the +sibling courses tests instead of spinning up a real DB. +""" +from datetime import datetime +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import IntegrityError + +from src.core.dependencies import get_current_user, get_db +from src.main import app +from src.models.course_filter import CourseFilter +from src.models.user import UserRole + + +client = TestClient(app) + + +def _admin_user(): + return { + "sub": "admin-1", + "email": "admin@example.com", + "name": "Admin", + "preferred_username": "admin", + "roles": [UserRole.ADMIN.value], + "user_id": 1, + } + + +def _lecturer_user(): + return { + "sub": "lecturer-1", + "email": "lecturer@example.com", + "name": "Lecturer", + "preferred_username": "lecturer", + "roles": [UserRole.LECTURER.value], + "user_id": 2, + } + + +def _make_filter(name: str = "SQL", fid: str | None = None) -> CourseFilter: + """Build a ``CourseFilter`` row sufficient for response-validation. + + We use ``spec=CourseFilter`` so Pydantic's ``from_attributes`` sees the + expected fields without hitting the SQLAlchemy session. + """ + f = MagicMock(spec=CourseFilter) + f.id = fid or str(uuid4()) + f.name = name + f.created_at = datetime(2026, 6, 30, 10, 0, 0) + f.updated_at = datetime(2026, 6, 30, 10, 0, 0) + return f + + +@pytest.fixture(autouse=True) +def _clear_overrides(): + """Each test installs its own overrides; reset between cases.""" + yield + app.dependency_overrides.clear() + + +# ── list ────────────────────────────────────────────────────────────────────── + + +def test_list_filters_returns_paginated_payload(): + """A lecturer (non-admin) is allowed to read the list — the frontend chip + bar needs to render for every signed-in user.""" + rows = [_make_filter("SQL"), _make_filter("Web")] + + repo = MagicMock() + repo.get_all_filtered.return_value = (rows, len(rows)) + + app.dependency_overrides[get_current_user] = _lecturer_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.get("/api/v1/course-filters") + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert [item["name"] for item in body["data"]] == ["SQL", "Web"] + # Pagination block is present (ResponseBuilder.paginated contract). + assert body["pagination"]["total_items"] == 2 + + +def test_list_filters_passes_search_to_repo(): + """``?search=foo`` flows through to ``get_all_filtered(search="foo")`` so + the case-insensitive substring filter actually runs in the DB layer.""" + repo = MagicMock() + repo.get_all_filtered.return_value = ([], 0) + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.get("/api/v1/course-filters?search=sq") + + assert response.status_code == 200 + repo.get_all_filtered.assert_called_once() + assert repo.get_all_filtered.call_args.kwargs["search"] == "sq" + + +# ── create: admin path ──────────────────────────────────────────────────────── + + +def test_create_filter_admin_succeeds(): + """Happy path: admin posts a fresh name, repo creates and returns it.""" + created = _make_filter("SQL") + repo = MagicMock() + repo.get_by_name.return_value = None # not a duplicate + repo.create.return_value = created + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.post("/api/v1/course-filters", json={"name": "SQL"}) + + assert response.status_code == 201 + assert response.json()["data"]["name"] == "SQL" + repo.create.assert_called_once_with(name="SQL") + + +def test_create_filter_strips_whitespace(): + """``" SQL "`` → ``"SQL"``. The Pydantic validator trims before the + duplicate-check and the DB insert.""" + repo = MagicMock() + repo.get_by_name.return_value = None + repo.create.side_effect = lambda **kw: _make_filter(kw["name"]) + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.post( + "/api/v1/course-filters", json={"name": " SQL "} + ) + + assert response.status_code == 201 + assert repo.create.call_args.kwargs["name"] == "SQL" + + +def test_create_filter_blank_name_returns_422(): + """An all-whitespace name fails Pydantic validation before reaching the + service — duplicate-check would otherwise incorrectly look up ``""``.""" + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.post("/api/v1/course-filters", json={"name": " "}) + assert response.status_code == 422 + + +def test_create_filter_duplicate_name_returns_409(): + """Pre-check path: the name already exists → ConflictException → 409.""" + repo = MagicMock() + repo.get_by_name.return_value = _make_filter("SQL") + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.post("/api/v1/course-filters", json={"name": "SQL"}) + + assert response.status_code == 409 + assert "already exists" in response.json()["detail"] + repo.create.assert_not_called() + + +def test_create_filter_race_integrity_error_returns_409(): + """Race path: pre-check returns None but the INSERT trips the unique + constraint (concurrent admin). Service must catch IntegrityError and map + to 409, not bubble a 500.""" + repo = MagicMock() + repo.get_by_name.return_value = None + repo.create.side_effect = IntegrityError("INSERT", {}, Exception("dup")) + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.post("/api/v1/course-filters", json={"name": "SQL"}) + + assert response.status_code == 409 + + +# ── create: non-admin is rejected ───────────────────────────────────────────── + + +def test_create_filter_lecturer_returns_403(): + """Endpoint-level ``require_roles(ADMIN)`` blocks lecturers from writing.""" + app.dependency_overrides[get_current_user] = _lecturer_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.post("/api/v1/course-filters", json={"name": "SQL"}) + assert response.status_code == 403 + + +# ── update: admin path ──────────────────────────────────────────────────────── + + +def test_update_filter_admin_renames(): + fid = str(uuid4()) + existing = _make_filter("SQL", fid=fid) + renamed = _make_filter("SQL Grundlagen", fid=fid) + + repo = MagicMock() + # get_by_id is called twice: get_filter() and then update()'s internal + # get_by_id. Return the same row both times. + repo.get_by_id.return_value = existing + repo.get_by_name.return_value = None # new name is free + repo.update.return_value = renamed + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.patch( + f"/api/v1/course-filters/{fid}", + json={"name": "SQL Grundlagen"}, + ) + + assert response.status_code == 200 + assert response.json()["data"]["name"] == "SQL Grundlagen" + + +def test_update_filter_to_existing_name_returns_409(): + """Renaming filter A to filter B's name must conflict.""" + fid_a = str(uuid4()) + fid_b = str(uuid4()) + a = _make_filter("Web", fid=fid_a) + b = _make_filter("SQL", fid=fid_b) + + repo = MagicMock() + repo.get_by_id.return_value = a + repo.get_by_name.return_value = b # taken by another row + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.patch( + f"/api/v1/course-filters/{fid_a}", json={"name": "SQL"} + ) + + assert response.status_code == 409 + repo.update.assert_not_called() + + +def test_update_filter_unknown_id_returns_404(): + repo = MagicMock() + repo.get_by_id.return_value = None + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.patch( + f"/api/v1/course-filters/{uuid4()}", json={"name": "new"} + ) + + assert response.status_code == 404 + + +def test_update_filter_lecturer_returns_403(): + app.dependency_overrides[get_current_user] = _lecturer_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.patch( + f"/api/v1/course-filters/{uuid4()}", json={"name": "x"} + ) + assert response.status_code == 403 + + +# ── delete ──────────────────────────────────────────────────────────────────── + + +def test_delete_filter_admin_succeeds(): + fid = str(uuid4()) + repo = MagicMock() + repo.get_by_id.return_value = _make_filter("SQL", fid=fid) + repo.delete.return_value = True + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.delete(f"/api/v1/course-filters/{fid}") + + assert response.status_code == 200 + assert response.json()["success"] is True + # delete() accepts either positional or keyword id; just assert it was hit. + assert repo.delete.called + + +def test_delete_filter_unknown_id_returns_404(): + repo = MagicMock() + repo.get_by_id.return_value = None + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.delete(f"/api/v1/course-filters/{uuid4()}") + + assert response.status_code == 404 + repo.delete.assert_not_called() + + +def test_delete_filter_lecturer_returns_403(): + app.dependency_overrides[get_current_user] = _lecturer_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.delete(f"/api/v1/course-filters/{uuid4()}") + assert response.status_code == 403 + + +# ── strict schema: required + no extra fields ───────────────────────────────── + + +def test_create_filter_rejects_extra_fields(): + """``extra="forbid"`` blocks unknown keys so a typo / stale client surfaces + as 422 instead of being silently dropped.""" + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.post( + "/api/v1/course-filters", + json={"name": "SQL", "color": "red"}, + ) + assert response.status_code == 422 + + +def test_update_filter_empty_body_returns_422(): + """``name`` is required on PATCH — an empty body is not a valid no-op.""" + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.patch(f"/api/v1/course-filters/{uuid4()}", json={}) + assert response.status_code == 422 + + +def test_update_filter_rejects_extra_fields(): + """Same forbid-extra contract on PATCH so frontend doesn't accidentally + POST fields that look editable but aren't.""" + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.patch( + f"/api/v1/course-filters/{uuid4()}", + json={"name": "x", "id": "spoofed"}, + ) + assert response.status_code == 422 + + +def test_update_filter_to_same_name_succeeds(): + """No-op rename (same name) is intentionally allowed — flows through and + bumps ``updated_at``. The duplicate-check skips because the existing row + IS the same row.""" + fid = str(uuid4()) + existing = _make_filter("SQL", fid=fid) + + repo = MagicMock() + repo.get_by_id.return_value = existing + # If the service mistakenly hit get_by_name here, it would short-circuit + # to 409 because existing.id matches. Make sure it never gets called. + repo.get_by_name.side_effect = AssertionError( + "get_by_name must not be called when name is unchanged" + ) + repo.update.return_value = existing + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + + with patch( + "src.services.course_filter_service.CourseFilterRepository", + return_value=repo, + ): + response = client.patch( + f"/api/v1/course-filters/{fid}", json={"name": "SQL"} + ) + + assert response.status_code == 200 + repo.update.assert_called_once() diff --git a/tests/api/test_deployment_cancel.py b/tests/api/test_deployment_cancel.py new file mode 100644 index 0000000..9e02a7c --- /dev/null +++ b/tests/api/test_deployment_cancel.py @@ -0,0 +1,158 @@ +"""Integration tests for ``DELETE /deployments/{id}`` with the new +cooperative-cancel behaviour. + +The endpoint now flips the deployment's status to ``DELETING`` *before* +enqueueing the delete task, so any in-flight ``deploy_stack`` worker +picks up the signal at its next checkpoint and bails out. The tests below +verify the flip happens and that the call stays idempotent for +deployments already in DELETING/DELETED state. +""" +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from src.core.dependencies import get_current_user, get_db +from src.main import app +from src.models.deployment import DeploymentStatus +from src.models.user import UserRole + + +TEST_OS_PROJECT_ID = "11111111-1111-1111-1111-111111111111" + + +def _admin_user(): + return { + "sub": "admin-123", + "email": "admin@example.com", + "preferred_username": "admin", + "roles": [UserRole.ADMIN.value], + "user_id": "admin-local-id", + } + + +def _mock_deployment(status=DeploymentStatus.CREATING): + """Mock a Deployment row in the given status.""" + dep = MagicMock() + dep.id = "dep-xyz" + dep.status = status + dep.deployment_parameters = '{"teacher": {"id": "admin-123"}}' + dep.openstack_project_id = TEST_OS_PROJECT_ID + return dep + + +client = TestClient(app) + + +@patch("src.api.deployments.delete_deployment_task") +@patch("src.api.deployments.DeploymentRepository") +def test_delete_flips_creating_to_deleting_before_enqueue(mock_repo_class, mock_task): + """When a CREATING deployment is deleted, the API must set DELETING + *first*, then enqueue. The deploy task polls status at every + checkpoint and will see the flag the moment the flip commits.""" + deployment = _mock_deployment(status=DeploymentStatus.CREATING) + repo = MagicMock() + repo.get_by_id.return_value = deployment + mock_repo_class.return_value = repo + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + try: + response = client.delete(f"/api/v1/deployments/{deployment.id}") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 204 + # update_status was called with DELETING — verify the cooperative-cancel flag. + repo.update_status.assert_called_once_with(deployment.id, DeploymentStatus.DELETING) + # And the Celery task was enqueued. + mock_task.delay.assert_called_once_with(deployment.id) + + +@patch("src.api.deployments.delete_deployment_task") +@patch("src.api.deployments.DeploymentRepository") +def test_delete_idempotent_for_already_deleting(mock_repo_class, mock_task): + """A second DELETE on a deployment already in DELETING must not + re-flip the status (avoids spurious status churn) but should still + enqueue another delete-task call so transient Celery losses are + tolerated. Idempotency at the status level, retry-friendly at the + queue level.""" + deployment = _mock_deployment(status=DeploymentStatus.DELETING) + repo = MagicMock() + repo.get_by_id.return_value = deployment + mock_repo_class.return_value = repo + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + try: + response = client.delete(f"/api/v1/deployments/{deployment.id}") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 204 + repo.update_status.assert_not_called() + mock_task.delay.assert_called_once_with(deployment.id) + + +@patch("src.api.deployments.delete_deployment_task") +@patch("src.api.deployments.DeploymentRepository") +def test_delete_idempotent_for_already_deleted(mock_repo_class, mock_task): + """A DELETE on a deployment that's already DELETED also skips the status + update — there's nothing left to cancel.""" + deployment = _mock_deployment(status=DeploymentStatus.DELETED) + repo = MagicMock() + repo.get_by_id.return_value = deployment + mock_repo_class.return_value = repo + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + try: + response = client.delete(f"/api/v1/deployments/{deployment.id}") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 204 + repo.update_status.assert_not_called() + mock_task.delay.assert_called_once_with(deployment.id) + + +@patch("src.api.deployments.delete_deployment_task") +@patch("src.api.deployments.DeploymentRepository") +def test_delete_running_deployment_flips_to_deleting(mock_repo_class, mock_task): + """The status flip happens for any non-terminal state — the regular + 'delete a finished deployment' path also benefits from the flag + (harmless when no deploy task is running).""" + deployment = _mock_deployment(status=DeploymentStatus.RUNNING) + repo = MagicMock() + repo.get_by_id.return_value = deployment + mock_repo_class.return_value = repo + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + try: + response = client.delete(f"/api/v1/deployments/{deployment.id}") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 204 + repo.update_status.assert_called_once_with(deployment.id, DeploymentStatus.DELETING) + mock_task.delay.assert_called_once_with(deployment.id) + + +@patch("src.api.deployments.delete_deployment_task") +@patch("src.api.deployments.DeploymentRepository") +def test_delete_404_when_deployment_missing(mock_repo_class, mock_task): + """No deployment → 404, no status flip, no enqueue.""" + repo = MagicMock() + repo.get_by_id.return_value = None + mock_repo_class.return_value = repo + + app.dependency_overrides[get_current_user] = _admin_user + app.dependency_overrides[get_db] = lambda: MagicMock() + try: + response = client.delete("/api/v1/deployments/does-not-exist") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 404 + repo.update_status.assert_not_called() + mock_task.delay.assert_not_called() diff --git a/tests/api/test_deployment_credentials_routes.py b/tests/api/test_deployment_credentials_routes.py new file mode 100644 index 0000000..c131116 --- /dev/null +++ b/tests/api/test_deployment_credentials_routes.py @@ -0,0 +1,400 @@ +"""Integration tests for the deployment credentials endpoints. + +Covers: +- ``GET /deployments/{id}/credentials`` returns ``ssh_private_key`` in the JSON. +- ``GET /deployments/{id}/credentials/access/{access_id}/ssh-key`` returns the + decrypted PEM with the right headers (download flow used by the frontend). +""" +from unittest.mock import patch, MagicMock + +from fastapi.testclient import TestClient + +from src.main import app +from src.core.dependencies import get_current_user, get_db +from src.models.user import UserRole + + +# Same fixed-UUID pattern the rest of tests/api/ uses. +TEST_OS_PROJECT_ID = "11111111-1111-1111-1111-111111111111" + +_SAMPLE_PEM = ( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQy\n" + "NTUxOQAAACBJYWWFAKEKEYZ0123456789AAAAAAAAAAAAAAAAAAAAA==\n" + "-----END OPENSSH PRIVATE KEY-----\n" +) + + +def _mock_lecturer(): + return { + "sub": "lecturer-123", + "email": "lecturer@example.com", + "preferred_username": "lecturer", + "roles": [UserRole.LECTURER.value], + "user_id": 1, + } + + +def _mock_admin(): + return { + "sub": "admin-123", + "email": "admin@example.com", + "preferred_username": "admin", + "roles": [UserRole.ADMIN.value], + "user_id": 2, + } + + +def _patched_openstack_repo(owner_user_id: int = 1): + """Tell ``authorize_deployment_access`` that TEST_OS_PROJECT_ID belongs to the user.""" + proj = MagicMock() + proj.id = TEST_OS_PROJECT_ID + proj.owner_user_id = owner_user_id + repo = MagicMock() + repo.get_by_id.return_value = proj + return patch("src.api.deployments.OpenstackProjectRepository", return_value=repo) + + +def _build_deployment(*, deployment_id="deploy-1"): + """Minimal Deployment mock that passes authorize_deployment_access for the lecturer.""" + d = MagicMock() + d.id = deployment_id + d.openstack_project_id = TEST_OS_PROJECT_ID + # authorize_deployment_access reads teacher.id and looks the User row up by sub. + d.deployment_parameters = '{"teacher": {"id": "lecturer-123"}}' + return d + + +def _build_access( + *, + access_id="access-abc", + username="gruppe-1", + ssh_private_key=_SAMPLE_PEM, + password="P@ssw0rd-1234567", + group_id=None, + group_name=None, +): + """Mirror of a DeploymentInstanceAccess row (post-decryption).""" + access = MagicMock() + access.id = access_id + access.username = username + access.password = password + access.ssh_private_key = ssh_private_key + access.connection_url = f"ssh {username}@1.2.3.4" + access.port = 22 + access.access_type = MagicMock() + access.access_type.value = "ssh" + # group_id / group.name drive the Dozent/Gruppen split in the response + # schema. Pydantic rejects bare MagicMock here (must be str | None), so + # the test must explicitly choose. Default: lecturer/admin row. + access.group_id = group_id + if group_id is None: + access.group = None + else: + access.group = MagicMock() + access.group.name = group_name + return access + + +client = TestClient(app) + + +# --------------------------------------------------------------------------- +# GET /credentials — JSON response now includes ssh_private_key +# --------------------------------------------------------------------------- + + +@patch("src.api.deployments.DeploymentRepository") +def test_get_credentials_includes_ssh_private_key(mock_repo_class): + """The full credentials response surfaces the decrypted SSH key alongside the password.""" + app.dependency_overrides[get_current_user] = _mock_lecturer + + deployment = _build_deployment() + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + access = _build_access() + instance = MagicMock() + instance.id = "instance-1" + instance.vm_name = "vm-1" + instance.openstack_server_id = "stack-1" + instance.access_methods = [access] + + db = MagicMock() + user_row = MagicMock() + user_row.id = 1 # matches _mock_lecturer's user_id → owner check passes + db.query.return_value.filter.return_value.first.return_value = user_row + # The endpoint also runs a separate query for DeploymentInstance — return our list. + db.query.return_value.filter.return_value.all.return_value = [instance] + app.dependency_overrides[get_db] = lambda: db + + with _patched_openstack_repo(owner_user_id=1): + response = client.get( + "/api/v1/deployments/deploy-1/credentials" + f"?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 200, response.text + payload = response.json()["data"] + assert len(payload["instances"]) == 1 + entry = payload["instances"][0]["accesses"][0] + # The id MUST be exposed — the download endpoint takes it as a path param, + # and /credentials is the only place the frontend can discover it. + assert entry["id"] == "access-abc" + assert entry["ssh_private_key"] == _SAMPLE_PEM + assert entry["password"] == "P@ssw0rd-1234567" + + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /credentials/access/{id}/ssh-key — download endpoint +# --------------------------------------------------------------------------- + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_returns_pem_with_attachment_header(mock_repo_class): + """Happy path: PEM body, correct media type, attachment filename includes username.""" + app.dependency_overrides[get_current_user] = _mock_lecturer + + deployment = _build_deployment() + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + access = _build_access(access_id="acc-1", username="gruppe-1") + db = MagicMock() + user_row = MagicMock() + user_row.id = 1 + db.query.return_value.filter.return_value.first.return_value = user_row + # The endpoint's access lookup chains .join().filter().first() — keep the + # call chain alive by routing everything back to the same MagicMock and + # making .first() the access row at the end. + access_query = MagicMock() + access_query.join.return_value.filter.return_value.first.return_value = access + # First .query() call is for User, second for DeploymentInstanceAccess. + db.query.side_effect = [db.query.return_value, access_query] + app.dependency_overrides[get_db] = lambda: db + + with _patched_openstack_repo(owner_user_id=1): + response = client.get( + "/api/v1/deployments/deploy-1/credentials/access/acc-1/ssh-key" + f"?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 200, response.text + assert response.text == _SAMPLE_PEM + assert response.headers["content-type"].startswith("application/x-pem-file") + assert 'attachment; filename="id_ed25519_gruppe-1"' in response.headers["content-disposition"] + + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_404_when_access_has_no_key(mock_repo_class): + """An access row that exists but has no key returns 404, not a 200 with empty body.""" + app.dependency_overrides[get_current_user] = _mock_lecturer + + deployment = _build_deployment() + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + access = _build_access(ssh_private_key=None) + db = MagicMock() + user_row = MagicMock() + user_row.id = 1 + db.query.return_value.filter.return_value.first.return_value = user_row + access_query = MagicMock() + access_query.join.return_value.filter.return_value.first.return_value = access + db.query.side_effect = [db.query.return_value, access_query] + app.dependency_overrides[get_db] = lambda: db + + with _patched_openstack_repo(owner_user_id=1): + response = client.get( + "/api/v1/deployments/deploy-1/credentials/access/acc-1/ssh-key" + f"?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 404 + assert "No SSH private key" in response.text + + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_404_when_access_id_unknown(mock_repo_class): + """Unknown access_id returns 404 even if the deployment itself exists.""" + app.dependency_overrides[get_current_user] = _mock_lecturer + + deployment = _build_deployment() + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + db = MagicMock() + user_row = MagicMock() + user_row.id = 1 + db.query.return_value.filter.return_value.first.return_value = user_row + access_query = MagicMock() + access_query.join.return_value.filter.return_value.first.return_value = None + db.query.side_effect = [db.query.return_value, access_query] + app.dependency_overrides[get_db] = lambda: db + + with _patched_openstack_repo(owner_user_id=1): + response = client.get( + "/api/v1/deployments/deploy-1/credentials/access/missing/ssh-key" + f"?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 404 + + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_404_when_deployment_missing(mock_repo_class): + """Unknown deployment returns 404 before any access lookup.""" + app.dependency_overrides[get_current_user] = _mock_lecturer + + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = None + mock_repo_class.return_value = mock_repo + + app.dependency_overrides[get_db] = lambda: MagicMock() + + response = client.get( + "/api/v1/deployments/does-not-exist/credentials/access/whatever/ssh-key" + f"?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 404 + + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_admin_can_download_any_deployments_key(mock_repo_class): + """ADMINs bypass the lecturer-ownership check (matches the existing pattern).""" + app.dependency_overrides[get_current_user] = _mock_admin + + # Deployment owned by someone else — admin can still access. + deployment = _build_deployment() + deployment.deployment_parameters = '{"teacher": {"id": "someone-else-999"}}' + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + access = _build_access(username="prof-berg") + db = MagicMock() + # Admin path skips the User-ownership lookup entirely; only the access query runs. + db.query.return_value.join.return_value.filter.return_value.first.return_value = access + app.dependency_overrides[get_db] = lambda: db + + response = client.get( + "/api/v1/deployments/deploy-1/credentials/access/acc-1/ssh-key" + ) + + assert response.status_code == 200 + assert 'filename="id_ed25519_prof-berg"' in response.headers["content-disposition"] + + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Ownership enforcement — the crown jewel: no lecturer steals another's keys +# --------------------------------------------------------------------------- + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_forbidden_for_non_owning_lecturer(mock_repo_class): + """A lecturer who is NOT the deployment owner gets 403, not the PEM. + + This is the security-critical case: lecturers must only access their own + deployments. Without this guard, any lecturer with the deployment_id and + access_id could download another lecturer's admin/group keys. + """ + app.dependency_overrides[get_current_user] = _mock_lecturer # user_id=1 + + # Deployment is owned by a DIFFERENT teacher (keycloak id "someone-else"). + deployment = _build_deployment() + deployment.deployment_parameters = '{"teacher": {"id": "someone-else-keycloak-id"}}' + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + # get_deployment_owner_id resolves the keycloak id to a User row with id=42 — not us. + other_owner = MagicMock() + other_owner.id = 42 + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = other_owner + app.dependency_overrides[get_db] = lambda: db + + with _patched_openstack_repo(owner_user_id=1): + response = client.get( + "/api/v1/deployments/deploy-1/credentials/access/acc-1/ssh-key" + f"?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 403 + # And critically: no PEM in the body, no Content-Disposition attachment header. + assert "BEGIN OPENSSH PRIVATE KEY" not in response.text + assert "content-disposition" not in {k.lower() for k in response.headers} + + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_forbidden_when_project_id_mismatches(mock_repo_class): + """Even the owning lecturer is rejected if the deployment lives in a different project. + + Guards against a scoped-project mix-up: passing the wrong openstack_project_id + must fail closed instead of returning data tied to a different project. + """ + app.dependency_overrides[get_current_user] = _mock_lecturer + + deployment = _build_deployment() + # Different project than the one the caller passes in the query string. + deployment.openstack_project_id = "99999999-9999-9999-9999-999999999999" + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + db = MagicMock() + owner = MagicMock() + owner.id = 1 + db.query.return_value.filter.return_value.first.return_value = owner + app.dependency_overrides[get_db] = lambda: db + + with _patched_openstack_repo(owner_user_id=1): + response = client.get( + "/api/v1/deployments/deploy-1/credentials/access/acc-1/ssh-key" + f"?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 403 + + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_download_ssh_key_400_without_openstack_project_id(mock_repo_class): + """Non-admin caller must supply openstack_project_id — else 400 (defence in depth).""" + app.dependency_overrides[get_current_user] = _mock_lecturer + + deployment = _build_deployment() + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = deployment + mock_repo_class.return_value = mock_repo + + app.dependency_overrides[get_db] = lambda: MagicMock() + + # No openstack_project_id query param! + response = client.get( + "/api/v1/deployments/deploy-1/credentials/access/acc-1/ssh-key" + ) + + assert response.status_code == 400 + + app.dependency_overrides.clear() diff --git a/tests/api/test_deployment_redeploy_routes.py b/tests/api/test_deployment_redeploy_routes.py new file mode 100644 index 0000000..9e70a84 --- /dev/null +++ b/tests/api/test_deployment_redeploy_routes.py @@ -0,0 +1,303 @@ +"""API tests for the redeploy endpoints. + +Two endpoints are covered: + +* ``POST /deployments/{id}/redeploy`` — fan-out over every instance +* ``POST /deployments/{id}/instances/{instance_id}/redeploy`` — single VM + +Both flow through ``authorize_deployment_access`` for ownership, then +enqueue the matching Celery task with the body's override / preserve +flags. The actual task body is unit-tested in +``tests/unit/test_redeploy_tasks.py`` — here we cover only the HTTP +contract: status codes, body forwarding, transitional-state gating, +404s. +""" +from unittest.mock import patch, MagicMock + +from fastapi.testclient import TestClient + +from src.main import app +from src.core.dependencies import get_current_user, get_db +from src.models.user import UserRole +from src.models.deployment import DeploymentStatus + + +TEST_OS_PROJECT_ID = "11111111-1111-1111-1111-111111111111" + + +def mock_lecturer_user(): + return { + "sub": "lecturer-123", "email": "l@x.de", "name": "L", + "preferred_username": "lec", "roles": [UserRole.LECTURER.value], + "user_id": 1, + } + + +def mock_admin_user(): + return { + "sub": "admin-123", "email": "a@x.de", "name": "A", + "preferred_username": "adm", "roles": [UserRole.ADMIN.value], + "user_id": 2, + } + + +client = TestClient(app) + + +def _mock_deployment_owned_by(user_id: int, *, status_=DeploymentStatus.RUNNING): + """Build a deployment whose ``deployment_parameters`` claim ``user_id`` + as the owning lecturer (via the Keycloak ID mapping the API does).""" + d = MagicMock() + d.id = "deploy-123" + d.status = status_ + d.openstack_stack_id = '["stack-1"]' + d.deployment_parameters = '{"teacher": {"id": "lecturer-123"}}' + d.openstack_project_id = TEST_OS_PROJECT_ID + return d + + +def _patch_db_owner_lookup(user_id: int): + """Make the User-table query in ``get_deployment_owner_id`` return a + row with ``id=user_id`` so ownership checks pass for the lecturer. + + Also default the per-deployment "any instance already REDEPLOYING?" query + to None so the deployment-wide endpoint's in-flight guard doesn't trip. + Individual tests that want to assert that guard can override + ``mock_db.query.return_value.filter.return_value.first`` afterwards. + """ + mock_db = MagicMock() + mock_user = MagicMock() + mock_user.id = user_id + # First call → User lookup (mock_user); subsequent .first() calls (e.g. the + # REDEPLOYING-in-flight check) → None so the gate is open by default. + mock_db.query.return_value.filter.return_value.first.side_effect = [ + mock_user, None, None, None, None + ] + return mock_db + + +# --------------------------------------------------------------------------- +# POST /{id}/redeploy — full deployment +# --------------------------------------------------------------------------- + + +@patch("src.api.deployments.redeploy_deployment_task") +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_deployment_success(mock_repo_class, mock_task): + """Happy path: 202 returned, task enqueued with body overrides.""" + app.dependency_overrides[get_current_user] = mock_lecturer_user + dep = _mock_deployment_owned_by(1) + + db = _patch_db_owner_lookup(1) + # The endpoint counts instances via db.query(DeploymentInstance).filter(...).count() + db.query.return_value.filter.return_value.count.return_value = 3 + app.dependency_overrides[get_db] = lambda: db + + repo = MagicMock() + repo.get_by_id.return_value = dep + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/deploy-123/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}", + json={ + "deployment_parameter_overrides": {"flag": True}, + "instance_parameter_overrides": {"inst-a": {"flag": False}}, + "preserve_credentials": True, + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["data"]["status"] == "redeploy_queued" + assert body["data"]["instance_count"] == 3 + mock_task.delay.assert_called_once_with( + "deploy-123", + deployment_parameter_overrides={"flag": True}, + instance_parameter_overrides={"inst-a": {"flag": False}}, + preserve_credentials=True, + ) + app.dependency_overrides.clear() + + +@patch("src.api.deployments.redeploy_deployment_task") +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_deployment_no_body_defaults(mock_repo_class, mock_task): + """Body is optional — defaults are forwarded (no overrides, fresh creds).""" + app.dependency_overrides[get_current_user] = mock_lecturer_user + dep = _mock_deployment_owned_by(1) + + db = _patch_db_owner_lookup(1) + db.query.return_value.filter.return_value.count.return_value = 1 + app.dependency_overrides[get_db] = lambda: db + + repo = MagicMock() + repo.get_by_id.return_value = dep + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/deploy-123/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + + assert response.status_code == 202 + mock_task.delay.assert_called_once_with( + "deploy-123", + deployment_parameter_overrides=None, + instance_parameter_overrides=None, + preserve_credentials=False, + ) + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_deployment_blocked_in_transitional_state(mock_repo_class): + """A deployment that's CREATING/DELETING/RESTARTING can't be redeployed.""" + app.dependency_overrides[get_current_user] = mock_lecturer_user + dep = _mock_deployment_owned_by(1, status_=DeploymentStatus.CREATING) + app.dependency_overrides[get_db] = lambda: _patch_db_owner_lookup(1) + + repo = MagicMock() + repo.get_by_id.return_value = dep + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/deploy-123/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + assert response.status_code == 400 + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_deployment_no_instances_400(mock_repo_class): + """A deployment with zero instance rows — nothing to redeploy.""" + app.dependency_overrides[get_current_user] = mock_lecturer_user + dep = _mock_deployment_owned_by(1) + + db = _patch_db_owner_lookup(1) + db.query.return_value.filter.return_value.count.return_value = 0 + app.dependency_overrides[get_db] = lambda: db + + repo = MagicMock() + repo.get_by_id.return_value = dep + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/deploy-123/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + assert response.status_code == 400 + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_deployment_404(mock_repo_class): + app.dependency_overrides[get_current_user] = mock_lecturer_user + repo = MagicMock() + repo.get_by_id.return_value = None + mock_repo_class.return_value = repo + + response = client.post(f"/api/v1/deployments/nope/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}") + assert response.status_code == 404 + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /{id}/instances/{instance_id}/redeploy — single VM +# --------------------------------------------------------------------------- + + +@patch("src.api.deployments.redeploy_instance_task") +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_instance_success(mock_repo_class, mock_task): + """Happy path for the per-VM endpoint.""" + app.dependency_overrides[get_current_user] = mock_lecturer_user + dep = _mock_deployment_owned_by(1) + + db = _patch_db_owner_lookup(1) + # Two .first() calls in flight: first one resolves the User row for + # the ownership check; second resolves the DeploymentInstance row. + # Order the side_effect to match. + mock_user = MagicMock() + mock_user.id = 1 + mock_instance = MagicMock() + mock_instance.id = "inst-A" + mock_instance.deployment_id = "deploy-123" + db.query.return_value.filter.return_value.first.side_effect = [mock_user, mock_instance] + app.dependency_overrides[get_db] = lambda: db + + repo = MagicMock() + repo.get_by_id.return_value = dep + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/deploy-123/instances/inst-A/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}", + json={"deployment_parameter_overrides": {"include_notebooks": False}}, + ) + + assert response.status_code == 202 + body = response.json() + assert body["data"]["status"] == "redeploy_queued" + assert body["data"]["instance_id"] == "inst-A" + mock_task.delay.assert_called_once_with( + "deploy-123", + "inst-A", + deployment_parameter_overrides={"include_notebooks": False}, + preserve_credentials=False, + ) + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_instance_instance_not_found(mock_repo_class): + """Deployment exists but the instance doesn't — 404.""" + app.dependency_overrides[get_current_user] = mock_lecturer_user + dep = _mock_deployment_owned_by(1) + + db = _patch_db_owner_lookup(1) + # User lookup returns the owner row, but the instance lookup returns None. + # The ownership check uses .filter.first() (user), and the instance + # lookup is the SAME shape, so we sequence the return values. + db.query.return_value.filter.return_value.first.side_effect = [ + MagicMock(id=1), # owner user + None, # instance + ] + app.dependency_overrides[get_db] = lambda: db + + repo = MagicMock() + repo.get_by_id.return_value = dep + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/deploy-123/instances/missing/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + assert response.status_code == 404 + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_instance_blocked_in_transitional_state(mock_repo_class): + app.dependency_overrides[get_current_user] = mock_lecturer_user + dep = _mock_deployment_owned_by(1, status_=DeploymentStatus.DELETING) + app.dependency_overrides[get_db] = lambda: _patch_db_owner_lookup(1) + + repo = MagicMock() + repo.get_by_id.return_value = dep + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/deploy-123/instances/inst-A/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + assert response.status_code == 400 + app.dependency_overrides.clear() + + +@patch("src.api.deployments.DeploymentRepository") +def test_redeploy_instance_deployment_not_found(mock_repo_class): + app.dependency_overrides[get_current_user] = mock_lecturer_user + repo = MagicMock() + repo.get_by_id.return_value = None + mock_repo_class.return_value = repo + + response = client.post( + f"/api/v1/deployments/nope/instances/inst-A/redeploy?openstack_project_id={TEST_OS_PROJECT_ID}" + ) + assert response.status_code == 404 + app.dependency_overrides.clear() diff --git a/tests/api/test_student_routes.py b/tests/api/test_student_routes.py new file mode 100644 index 0000000..f03599e --- /dev/null +++ b/tests/api/test_student_routes.py @@ -0,0 +1,448 @@ +"""Integration tests for the student self-service endpoints. + +Uses a real in-memory SQLite DB with the production SQLAlchemy models so +the multi-join authorization query is exercised end-to-end, not mocked. + +Verifies the security boundaries: +- A student sees ONLY credentials for groups they belong to +- A student NEVER sees teacher-admin credentials (group_id IS NULL) +- A student NEVER sees other groups' credentials +- A student NEVER sees deployments they have no group on +- Lecturers / unauthenticated callers cannot use the /student/* routes +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from src.main import app +from src.core.database import Base +from src.core.dependencies import get_current_user, get_db +from src.models.course import Course +from src.models.course_group import CourseGroup +from src.models.course_member import CourseMember +from src.models.deployment import Deployment, DeploymentStatus +from src.models.deployment_instance import DeploymentInstance, DeploymentInstanceStatus +from src.models.deployment_instance_access import AccessType, DeploymentInstanceAccess +from src.models.group_member import GroupMember +from src.models.openstack_project import OpenstackProject +from src.models.template import Template +from src.models.template_version import TemplateVersion +from src.models.user import User, UserRole + + +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """Fresh DB per test. Imports every model so the metadata sees them all.""" + # Force registration of every model so Base.metadata.create_all() builds the full schema. + import src.models.deployment # noqa + import src.models.deployment_instance # noqa + import src.models.deployment_instance_access # noqa + import src.models.deployment_log # noqa + import src.models.template # noqa + import src.models.template_version # noqa + import src.models.template_category # noqa + import src.models.template_category_assignment # noqa + import src.models.template_version_file # noqa + import src.models.course # noqa + import src.models.course_member # noqa + import src.models.course_group # noqa + import src.models.group_member # noqa + import src.models.openstack_project # noqa + import src.models.user # noqa + + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + + +# --------------------------------------------------------------------------- +# Auth helpers — keep these in sync with the lecturer test files +# --------------------------------------------------------------------------- + + +def _student_user(user_id: str = "student-local-id"): + return { + "sub": "kc-student-1", + "email": "alice@uni.de", + "preferred_username": "alice", + "roles": [UserRole.STUDENT.value], + "user_id": user_id, + } + + +def _lecturer_user(): + return { + "sub": "kc-lecturer-1", + "email": "prof@uni.de", + "preferred_username": "prof", + "roles": [UserRole.LECTURER.value], + "user_id": "lecturer-local-id", + } + + +# --------------------------------------------------------------------------- +# Test fixtures: seed a deployment with two groups, the student in one of them +# --------------------------------------------------------------------------- + + +SAMPLE_PEM = ( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + "STUDENT-KEY-PLACEHOLDER\n" + "-----END OPENSSH PRIVATE KEY-----\n" +) + + +@pytest.fixture() +def seeded(db_session): + """Seed a course with two groups; student is a member of group A only. + + The deployment has access rows for: + - Group A (the student's group) — should be visible + - Group B (other group) — should NOT be visible + - Teacher admin (group_id IS NULL) — should NEVER be visible + """ + # Users + student = User(id="student-local-id", external_id="kc-student-1", username="alice") + lecturer = User(id="lecturer-local-id", external_id="kc-lecturer-1", username="prof") + db_session.add_all([student, lecturer]) + + # Course + groups + course = Course(id="course-1", name="DB Lab", keycloak_course_id="kc-course-1") + group_a = CourseGroup(id="cg-a", course_id="course-1", name="Gruppe A") + group_b = CourseGroup(id="cg-b", course_id="course-1", name="Gruppe B") + db_session.add_all([course, group_a, group_b]) + + # Student is in group A (via CourseMember + GroupMember) + cm_student = CourseMember(id="cm-student", user_id="student-local-id", course_id="course-1") + db_session.add(cm_student) + gm_student = GroupMember(id="gm-student", group_id="cg-a", course_member_id="cm-student") + db_session.add(gm_student) + + # Template + deployment + template = Template( + id="tmpl-1", + name="Ubuntu Lab", + owner_id="lecturer-local-id", + repo_url="https://example.test/repo", + visibility="public", + ) + version = TemplateVersion( + id="ver-1", + template_id="tmpl-1", + version="1.0.0", + git_commit_sha="abc123", + approval_status="approved", + ) + db_session.add_all([template, version]) + + # OpenstackProject is a NOT-NULL FK on Deployment, so we need one even + # though student endpoints never read it. + osp = OpenstackProject( + id="osp-1", + owner_user_id="lecturer-local-id", + openstack_project_id="kc-osp-1", + openstack_project_name="test-osp", + auth_url="https://example.test/keystone/v3", + username="kc-user", + password="kc-pass", + region_name="r1", + ) + db_session.add(osp) + + deployment = Deployment( + id="dep-1", + name="DB Lab Run", + template_version_id="ver-1", + course_id="course-1", + openstack_project_id="osp-1", + status=DeploymentStatus.RUNNING, + deployment_parameters='{"teacher": {"id": "kc-lecturer-1"}}', + ) + db_session.add(deployment) + + inst = DeploymentInstance( + id="inst-1", + deployment_id="dep-1", + vm_name="dep-1-s1", + openstack_server_id="stack-uuid", + ip_address="1.2.3.4", + status=DeploymentInstanceStatus.RUNNING, + ) + db_session.add(inst) + + # Access rows: group A, group B, admin (no group) + db_session.add_all([ + DeploymentInstanceAccess( + id="acc-a-ssh", + deployment_instance_id="inst-1", + access_type=AccessType.SSH, + username="gruppe-a", + password="GroupAPw-123", + ssh_private_key=SAMPLE_PEM, + group_id="cg-a", + connection_url="ssh gruppe-a@1.2.3.4", + port=22, + ), + DeploymentInstanceAccess( + id="acc-a-db", + deployment_instance_id="inst-1", + access_type=AccessType.DATABASE, + username="grpa_db", + password="GroupADbPw", + group_id="cg-a", + port=5432, + ), + DeploymentInstanceAccess( + id="acc-b-ssh", + deployment_instance_id="inst-1", + access_type=AccessType.SSH, + username="gruppe-b", + password="GroupBPw-456", + ssh_private_key="-----BEGIN OPENSSH PRIVATE KEY-----\nGROUP-B-KEY\n-----END OPENSSH PRIVATE KEY-----\n", + group_id="cg-b", + connection_url="ssh gruppe-b@1.2.3.4", + port=22, + ), + DeploymentInstanceAccess( + id="acc-admin", + deployment_instance_id="inst-1", + access_type=AccessType.SSH, + username="prof", + password="AdminPw", + ssh_private_key="-----BEGIN OPENSSH PRIVATE KEY-----\nADMIN-KEY\n-----END OPENSSH PRIVATE KEY-----\n", + group_id=None, # ← admin credentials must NEVER be returned to students + connection_url="ssh prof@1.2.3.4", + port=22, + ), + ]) + + db_session.commit() + + +@pytest.fixture() +def client(db_session): + """TestClient with the in-memory DB wired in.""" + def _override_db(): + yield db_session + + app.dependency_overrides[get_db] = _override_db + yield TestClient(app) + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Auth tests — router-level guards +# --------------------------------------------------------------------------- + + +def test_student_routes_require_authentication(client, seeded): + """No auth header → 401, not 403.""" + response = client.get("/api/v1/student/deployments") + assert response.status_code == 401 + + +def test_student_routes_reject_lecturer_token(client, seeded): + """Lecturer hits a /student/* route → 403.""" + app.dependency_overrides[get_current_user] = _lecturer_user + try: + response = client.get("/api/v1/student/deployments") + assert response.status_code == 403 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +# --------------------------------------------------------------------------- +# GET /student/deployments +# --------------------------------------------------------------------------- + + +def test_student_sees_only_deployments_with_their_group(client, seeded): + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get("/api/v1/student/deployments") + assert response.status_code == 200, response.text + body = response.json() + assert body["success"] is True + items = body["data"] + assert len(items) == 1 + d = items[0] + assert d["id"] == "dep-1" + assert d["name"] == "DB Lab Run" + assert d["template"]["name"] == "Ubuntu Lab" + # No leak of lecturer / parameters fields + assert "deployment_parameters" not in d + assert "teacher" not in d + # Instance metadata is trimmed + assert d["instances"][0]["vm_name"] == "dep-1-s1" + assert d["instances"][0]["ip_address"] == "1.2.3.4" + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_student_with_no_group_memberships_sees_empty_list(client, db_session, seeded): + """A student who is registered but in zero groups sees no deployments.""" + # Add another student without any group membership. + lonely = User(id="lonely-id", external_id="kc-lonely", username="lonely") + db_session.add(lonely) + db_session.commit() + + def _lonely_user(): + return {**_student_user(), "user_id": "lonely-id", "sub": "kc-lonely"} + + app.dependency_overrides[get_current_user] = _lonely_user + try: + response = client.get("/api/v1/student/deployments") + assert response.status_code == 200 + assert response.json()["data"] == [] + finally: + app.dependency_overrides.pop(get_current_user, None) + + +# --------------------------------------------------------------------------- +# GET /student/deployments/{id}/credentials +# --------------------------------------------------------------------------- + + +def test_student_credentials_returns_only_own_group_rows(client, seeded): + """Crown jewel: student sees own group's access rows, never group B's, never admin.""" + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get("/api/v1/student/deployments/dep-1/credentials") + assert response.status_code == 200, response.text + + payload = response.json()["data"] + assert len(payload["instances"]) == 1 + accesses = payload["instances"][0]["accesses"] + + ids = sorted(a["id"] for a in accesses) + # Exactly group A's two rows; nothing from group B; nothing from admin. + assert ids == ["acc-a-db", "acc-a-ssh"] + + # Spot-check that group A's secrets are decrypted and visible + ssh_entry = next(a for a in accesses if a["id"] == "acc-a-ssh") + assert ssh_entry["password"] == "GroupAPw-123" + assert ssh_entry["ssh_private_key"] == SAMPLE_PEM + + # And explicitly that nothing else leaks + for a in accesses: + assert a["username"] != "gruppe-b" + assert a["username"] != "prof" + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_student_credentials_403_when_not_in_any_group_of_deployment(client, db_session, seeded): + """Even a registered student without group membership on this deployment gets 403.""" + db_session.add(User(id="lonely-id", external_id="kc-lonely", username="lonely")) + db_session.commit() + + def _lonely_user(): + return {**_student_user(), "user_id": "lonely-id", "sub": "kc-lonely"} + + app.dependency_overrides[get_current_user] = _lonely_user + try: + response = client.get("/api/v1/student/deployments/dep-1/credentials") + assert response.status_code == 403 + assert "BEGIN OPENSSH" not in response.text + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_student_credentials_404_for_unknown_deployment(client, seeded): + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get("/api/v1/student/deployments/does-not-exist/credentials") + assert response.status_code == 404 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +# --------------------------------------------------------------------------- +# GET /student/deployments/{id}/credentials/access/{access_id}/ssh-key +# --------------------------------------------------------------------------- + + +def test_student_can_download_own_ssh_key(client, seeded): + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get( + "/api/v1/student/deployments/dep-1/credentials/access/acc-a-ssh/ssh-key" + ) + assert response.status_code == 200 + assert response.text == SAMPLE_PEM + assert response.headers["content-type"].startswith("application/x-pem-file") + assert 'filename="id_ed25519_gruppe-a"' in response.headers["content-disposition"] + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_student_cannot_download_other_groups_ssh_key(client, seeded): + """Adversarial: student knows group B's access_id. Must still get 403, no PEM.""" + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get( + "/api/v1/student/deployments/dep-1/credentials/access/acc-b-ssh/ssh-key" + ) + assert response.status_code == 403 + assert "GROUP-B-KEY" not in response.text + # No download header → never tricked the browser into saving the file + assert "content-disposition" not in {k.lower() for k in response.headers} + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_student_cannot_download_admin_ssh_key(client, seeded): + """Even more critical: admin (group_id IS NULL) must be unreachable.""" + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get( + "/api/v1/student/deployments/dep-1/credentials/access/acc-admin/ssh-key" + ) + assert response.status_code == 403 + assert "ADMIN-KEY" not in response.text + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_student_ssh_key_404_when_access_has_no_key(client, db_session, seeded): + """Own-group access row without a key → 404, not 200 with empty body.""" + # Strip the key from the student's row. + db_session.query(DeploymentInstanceAccess).filter_by(id="acc-a-ssh").update( + {"ssh_private_key": None} + ) + db_session.commit() + + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get( + "/api/v1/student/deployments/dep-1/credentials/access/acc-a-ssh/ssh-key" + ) + assert response.status_code == 404 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_student_ssh_key_404_for_unknown_access(client, seeded): + app.dependency_overrides[get_current_user] = _student_user + try: + response = client.get( + "/api/v1/student/deployments/dep-1/credentials/access/no-such-id/ssh-key" + ) + assert response.status_code == 404 + finally: + app.dependency_overrides.pop(get_current_user, None) diff --git a/tests/api/test_template_icon_routes.py b/tests/api/test_template_icon_routes.py new file mode 100644 index 0000000..4689fdf --- /dev/null +++ b/tests/api/test_template_icon_routes.py @@ -0,0 +1,331 @@ +"""API-Tests für die Template-Icon-Endpoints. + +Deckt POST/GET/DELETE ab, inkl. Content-Type-Whitelist, Größenlimit, +Owner/Admin-Gate, sowie das Zusammenspiel mit der TemplateResponse +(``icon_path`` zeigt nach dem Upload auf den Serve-Endpoint, sonst null). +""" +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from src.core.database import Base +from src.core.dependencies import get_current_user, get_db +from src.main import app +from src.models.template import Template, TemplateVisibility +from src.models.user import User + + +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) + + +@event.listens_for(engine, "connect") +def _sqlite_enable_fks(dbapi_connection, _conn_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + import src.models.deployment # noqa + import src.models.deployment_instance # noqa + import src.models.deployment_instance_access # noqa + import src.models.deployment_log # noqa + import src.models.template_category # noqa + import src.models.template_category_assignment # noqa + import src.models.template_icon # noqa + import src.models.template_version # noqa + import src.models.course # noqa + import src.models.course_member # noqa + import src.models.course_group # noqa + import src.models.group_member # noqa + import src.models.openstack_project # noqa + + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def owner(db_session): + """Der User, dem das Sample-Template gehört.""" + user = User(id="00000000-0000-0000-0000-000000000000", external_id="ext-owner") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def other_user(db_session): + """Ein weiterer User, der weder Owner noch Admin ist.""" + user = User(id="11111111-1111-1111-1111-111111111111", external_id="ext-other") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def sample_template(db_session, owner): + template = Template( + name="Icon Template", + description="Template to test icon upload", + owner_id=owner.id, + repo_url="https://github.com/example/icon-template", + visibility=TemplateVisibility.PUBLIC, + ) + db_session.add(template) + db_session.commit() + db_session.refresh(template) + return template + + +def _make_client(db_session, user_id: str, roles: list[str]): + """Wire the TestClient with a static current-user override.""" + def override_get_db(): + try: + yield db_session + finally: + pass + + def override_get_current_user(): + return { + "sub": user_id, + "email": f"{user_id}@example.com", + "name": user_id, + "preferred_username": user_id, + "roles": roles, + "user_id": user_id, + } + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_current_user] = override_get_current_user + return TestClient(app) + + +@pytest.fixture +def owner_client(db_session, owner): + """Client authenticated as the template owner (lecturer role).""" + client = _make_client(db_session, owner.id, ["lecturer"]) + yield client + app.dependency_overrides.clear() + + +@pytest.fixture +def admin_client(db_session, other_user): + """Client authenticated as an admin user (not the owner).""" + client = _make_client(db_session, other_user.id, ["admin", "lecturer"]) + yield client + app.dependency_overrides.clear() + + +# Minimal, valid PNG (1x1 transparent pixel) — small enough that we can +# use the real Pillow-free byte sequence in tests without a dependency. +PNG_1x1 = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\rIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01" + b"\x00\x18\xdd\x8d\xb4\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +class TestUploadIcon: + def test_owner_can_upload_png(self, owner_client, sample_template): + response = owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json()["data"] + assert data["template_id"] == sample_template.id + assert data["content_type"] == "image/png" + assert data["size_bytes"] == len(PNG_1x1) + assert data["icon_path"] == f"/api/v1/templates/{sample_template.id}/icon" + + def test_upload_populates_icon_path_in_template_response( + self, owner_client, sample_template + ): + owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + get_resp = owner_client.get(f"/api/v1/templates/{sample_template.id}") + assert get_resp.status_code == status.HTTP_200_OK + body = get_resp.json()["data"] + assert body["icon_path"] == f"/api/v1/templates/{sample_template.id}/icon" + # icon_url gibt es nicht mehr — nur noch icon_path + assert "icon_url" not in body + + def test_upload_svg_rejected_415(self, owner_client, sample_template): + response = owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.svg", b"", "image/svg+xml")}, + ) + assert response.status_code == 415 + + def test_upload_text_rejected_415(self, owner_client, sample_template): + response = owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("hello.txt", b"hello", "text/plain")}, + ) + assert response.status_code == 415 + + def test_upload_empty_file_rejected_400(self, owner_client, sample_template): + response = owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("empty.png", b"", "image/png")}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_stranger_cannot_upload(self, db_session, sample_template, other_user): + """Ein User, der weder Owner noch Admin ist, darf kein Icon setzen. + + Der Template-Sichtbarkeits-Gate schießt hier zuerst (PUBLIC-Template + ohne APPROVED Version → 403 auf GET), also bekommen wir schon + beim Ownership-Check ein 403 zurück statt eines 200. + """ + client = _make_client(db_session, other_user.id, ["lecturer"]) + try: + response = client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + finally: + app.dependency_overrides.clear() + + def test_admin_can_upload_on_other_users_template( + self, admin_client, sample_template + ): + response = admin_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + assert response.status_code == status.HTTP_201_CREATED + + def test_reupload_replaces_bytes(self, owner_client, sample_template): + owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + # Anderes Byte-Muster hochladen — Größe muss sich am GET zeigen. + larger = PNG_1x1 + b"\x00" * 32 + # Zweiter Upload — muss die vorhandene Row updaten, nicht duplizieren. + r2 = owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo2.png", larger, "image/png")}, + ) + assert r2.status_code == status.HTTP_201_CREATED + assert r2.json()["data"]["size_bytes"] == len(larger) + assert r2.json()["data"]["file_name"] == "logo2.png" + + +class TestGetIcon: + def test_get_returns_stored_bytes_with_correct_mime( + self, owner_client, sample_template + ): + owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + response = owner_client.get(f"/api/v1/templates/{sample_template.id}/icon") + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-type"] == "image/png" + assert response.content == PNG_1x1 + + def test_get_returns_404_when_no_icon_uploaded( + self, owner_client, sample_template + ): + response = owner_client.get(f"/api/v1/templates/{sample_template.id}/icon") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestDeleteIcon: + def test_owner_can_delete_icon(self, owner_client, sample_template): + owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + response = owner_client.delete( + f"/api/v1/templates/{sample_template.id}/icon" + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + # Icon ist danach weg → GET liefert 404. + get_resp = owner_client.get(f"/api/v1/templates/{sample_template.id}/icon") + assert get_resp.status_code == status.HTTP_404_NOT_FOUND + # ``icon_path`` ist ohne Upload ``None`` — Frontend rendert Placeholder. + tpl_resp = owner_client.get(f"/api/v1/templates/{sample_template.id}") + body = tpl_resp.json()["data"] + assert body["icon_path"] is None + + def test_delete_is_idempotent(self, owner_client, sample_template): + """Auch ohne vorher hochgeladenes Icon liefert DELETE 204.""" + response = owner_client.delete( + f"/api/v1/templates/{sample_template.id}/icon" + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_stranger_cannot_delete( + self, db_session, sample_template, other_user, owner_client + ): + # Setup: owner lädt zuerst ein Icon hoch, dann darf ``other_user`` + # es nicht wegnehmen. + owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + app.dependency_overrides.clear() + + client = _make_client(db_session, other_user.id, ["lecturer"]) + try: + response = client.delete( + f"/api/v1/templates/{sample_template.id}/icon" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + finally: + app.dependency_overrides.clear() + + +class TestTemplateDeletionCascadesIcon: + def test_deleting_template_removes_its_icon( + self, owner_client, sample_template, db_session + ): + from src.models.template_icon import TemplateIcon + + owner_client.post( + f"/api/v1/templates/{sample_template.id}/icon", + files={"file": ("logo.png", PNG_1x1, "image/png")}, + ) + assert ( + db_session.query(TemplateIcon) + .filter_by(template_id=sample_template.id) + .first() + is not None + ) + del_resp = owner_client.delete(f"/api/v1/templates/{sample_template.id}") + assert del_resp.status_code == status.HTTP_204_NO_CONTENT + # Cascade sollte die Icon-Row mitreißen. + db_session.expire_all() + assert ( + db_session.query(TemplateIcon) + .filter_by(template_id=sample_template.id) + .first() + is None + ) diff --git a/tests/api/test_template_routes.py b/tests/api/test_template_routes.py index 63fc998..c1a1342 100644 --- a/tests/api/test_template_routes.py +++ b/tests/api/test_template_routes.py @@ -2,7 +2,7 @@ import pytest from fastapi import status from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool @@ -21,6 +21,18 @@ connect_args={"check_same_thread": False}, poolclass=StaticPool, ) + + +# SQLite ignores ON DELETE CASCADE unless foreign_keys pragma is enabled per +# connection — needed for the cascade-delete tests to mirror prod (Postgres) +# behavior. +@event.listens_for(engine, "connect") +def _sqlite_enable_fks(dbapi_connection, _conn_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -376,5 +388,73 @@ def test_delete_template_not_found(self, client): def test_delete_template_invalid_uuid(self, client): """Test deleting template with invalid UUID.""" response = client.delete("/api/v1/templates/not-a-uuid") - + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_delete_template_with_versions_cascades(self, client, db_session, sample_template): + """Deleting a template with versions removes the versions too (cascade).""" + from src.models.template_version import TemplateVersion + + version = TemplateVersion( + template_id=sample_template.id, + version="1.0.0", + git_commit_sha="abc123", + is_active=True, + ) + db_session.add(version) + db_session.commit() + version_id = version.id + + response = client.delete(f"/api/v1/templates/{sample_template.id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Versions should be gone too + assert db_session.query(TemplateVersion).filter_by(id=version_id).first() is None + + def test_delete_template_with_deployments_returns_400( + self, client, db_session, sample_template, mock_user + ): + """Deleting a template whose versions still have deployments returns 400.""" + from src.models.template_version import TemplateVersion + from src.models.deployment import Deployment, DeploymentStatus + from src.models.course import Course + from src.models.openstack_project import OpenstackProject + + version = TemplateVersion( + template_id=sample_template.id, + version="1.0.0", + git_commit_sha="abc123", + is_active=True, + ) + db_session.add(version) + db_session.commit() + + # Minimal Course + OpenstackProject so the Deployment FK constraints + # are satisfied (we don't care about their content). + course = Course(id="course-1", keycloak_course_id="kc-course-1", name="Test Course") + os_project = OpenstackProject( + id="osp-1", + owner_user_id=mock_user.id, + openstack_project_id="ks-1", + openstack_project_name="test-osp", + auth_url="https://example.com", + username="user", + password="pw", + region_name="region1", + ) + db_session.add_all([course, os_project]) + db_session.commit() + + deployment = Deployment( + name="test-deployment", + template_version_id=version.id, + course_id=course.id, + openstack_project_id=os_project.id, + status=DeploymentStatus.RUNNING, + ) + db_session.add(deployment) + db_session.commit() + + response = client.delete(f"/api/v1/templates/{sample_template.id}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "deployment" in response.json()["detail"].lower() diff --git a/tests/api/test_templates_api_access.py b/tests/api/test_templates_api_access.py index c7f81d7..d74f12b 100644 --- a/tests/api/test_templates_api_access.py +++ b/tests/api/test_templates_api_access.py @@ -716,17 +716,24 @@ def test_parameters_inlined_from_app_yaml( class TestRejectVersionWithReason: - """Tests for `POST /template-versions/{id}/reject` with optional reason body.""" + """Tests for `POST /template-versions/{id}/reject` with optional reason body. + + Approval/rejection only apply to PUBLIC templates — private templates now + return ``approval_status=None`` and the approve/reject endpoints 400 them. + """ @pytest.fixture - def pending_version(self, db_session, private_template): + def pending_version(self, db_session, public_approved_template): from src.models.template_version import TemplateVersionApprovalStatus + # Auf demselben PUBLIC-Template gibt es schon v1.0.0 APPROVED — wir + # legen v1.1.0 PENDING an, damit der UniqueConstraint auf + # (template_id, version) nicht stört. v = TemplateVersion( - template_id=private_template.id, - version="1.0.0", + template_id=public_approved_template.id, + version="1.1.0", git_commit_sha="reject-test-sha", - is_active=True, + is_active=False, approval_status=TemplateVersionApprovalStatus.PENDING, ) db_session.add(v) @@ -797,12 +804,53 @@ def test_non_admin_cannot_reject(self, db_session, owner_user, pending_version): class TestImportFromGithubVisibility: - """The import-from-github schema no longer accepts a visibility field.""" + """The import-from-github schema accepts an optional visibility field + (private/public). Default is private. Invalid values are rejected. + + This used to be a hard ban on the field; now lecturers can choose at + import time whether the template lives in private or public space.""" + + def test_schema_exposes_visibility_field(self): + from src.schemas.template import GithubImportNewTemplate + + assert "visibility" in GithubImportNewTemplate.model_fields + + def test_default_visibility_is_private(self): + from src.schemas.template import GithubImportNewTemplate + + p = GithubImportNewTemplate( + name="x", + github_url="https://github.com/a/b", + ) + assert p.visibility == "private" + + def test_explicit_public_visibility_accepted(self): + from src.schemas.template import GithubImportNewTemplate + + p = GithubImportNewTemplate( + name="x", + github_url="https://github.com/a/b", + visibility="public", + ) + assert p.visibility == "public" + + def test_uppercase_visibility_normalised(self): + from src.schemas.template import GithubImportNewTemplate - def test_schema_rejects_visibility_field(self): - """Visibility is no longer part of the import body — extra fields are ignored - but the schema's resolved value is always None / the default. Confirm - that the field truly isn't on the model.""" + p = GithubImportNewTemplate( + name="x", + github_url="https://github.com/a/b", + visibility="PUBLIC", + ) + assert p.visibility == "public" + + def test_invalid_visibility_rejected(self): from src.schemas.template import GithubImportNewTemplate + from pydantic import ValidationError - assert "visibility" not in GithubImportNewTemplate.model_fields + with pytest.raises(ValidationError): + GithubImportNewTemplate( + name="x", + github_url="https://github.com/a/b", + visibility="secret", + ) diff --git a/tests/conftest.py b/tests/conftest.py index d4eabbd..9fab12d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,4 +16,12 @@ os.environ.setdefault("KEYCLOAK_REALM", "test_realm") os.environ.setdefault("KEYCLOAK_CLIENT_ID", "test_client") os.environ.setdefault("GITHUB_APP_STATE_SECRET", "test-state-secret-do-not-use-in-prod") +# A throwaway, *known-valid* Fernet key for tests that touch EncryptedString +# columns (openstack_projects.password, deployment_instance_access.password, +# etc.). Unconditional override — the CI workflow sets ENCRYPTION_KEY to an +# invalid placeholder that worked when EncryptedString silently passed +# through plaintext on misconfig. Now that the type decorator fails loudly +# (security fix), tests need a real Fernet key. Generated via +# cryptography.fernet.Fernet.generate_key(); throwaway, do not reuse. +os.environ["ENCRYPTION_KEY"] = "DGp59ncGf4ygfOzI13qzzZEBOtbyhpXflaxsPi97iPQ=" diff --git a/tests/unit/test_app_manifest_parser_linked_files.py b/tests/unit/test_app_manifest_parser_linked_files.py index 7f956b3..ed999b9 100644 --- a/tests/unit/test_app_manifest_parser_linked_files.py +++ b/tests/unit/test_app_manifest_parser_linked_files.py @@ -108,3 +108,114 @@ def test_aliases_map_to_same_file_type(self): by_key = {e["artifact_key"]: e["file_type"] for e in result} assert by_key["ansible"] == "ANSIBLE_PLAYBOOK" assert by_key["helm"] == "HELM_CHART" + + # ------------------------------------------------------------------ + # List-of-paths variants (shell_scripts, config_files): one declaration + # may produce multiple file descriptors, all sharing the same FileType. + # Templates with multiple helper scripts / config files need this — the + # singular variant only carries a single path. + # ------------------------------------------------------------------ + + def test_shell_scripts_list_expands_to_multiple_entries(self): + parsed = { + "artifacts": { + "shell_scripts": [ + "scripts/check_setup.sh", + "scripts/reset_password.sh", + ] + } + } + result = AppManifestParser.get_linked_files(parsed) + + assert [e["relative_path"] for e in result] == [ + "scripts/check_setup.sh", "scripts/reset_password.sh" + ] + assert all(e["file_type"] == "SHELL_SCRIPT" for e in result) + assert all(e["artifact_key"] == "shell_scripts" for e in result) + # Listen-Einträge sind nie primary — das ist `heat_template` vorbehalten. + assert all(e["is_primary"] is False for e in result) + + def test_config_files_list_expands_to_multiple_entries(self): + parsed = { + "artifacts": { + "config_files": ["files/bashrc", "files/motd", "files/profile"] + } + } + result = AppManifestParser.get_linked_files(parsed) + + assert [e["relative_path"] for e in result] == [ + "files/bashrc", "files/motd", "files/profile" + ] + assert all(e["file_type"] == "CONFIG_FILE" for e in result) + + def test_list_keys_preserve_order_across_mixed_single_and_list(self): + """``order`` is monotonically increasing across all paths, mixing + singular and plural-list keys.""" + parsed = { + "artifacts": { + "heat_template": "heat/main.yaml", + "shell_scripts": ["scripts/a.sh", "scripts/b.sh"], + "cloud_init": "cloud-init/user-data.yaml", + "config_files": ["files/c1", "files/c2"], + } + } + result = AppManifestParser.get_linked_files(parsed) + + assert [e["relative_path"] for e in result] == [ + "heat/main.yaml", + "scripts/a.sh", + "scripts/b.sh", + "cloud-init/user-data.yaml", + "files/c1", + "files/c2", + ] + assert [e["order"] for e in result] == [1, 2, 3, 4, 5, 6] + + def test_empty_list_produces_no_entries(self): + """``shell_scripts: []`` is legal — no entries, no crash.""" + parsed = {"artifacts": {"shell_scripts": []}} + result = AppManifestParser.get_linked_files(parsed) + assert result == [] + + def test_list_skips_non_string_or_blank_items(self): + """Within a list, only non-blank strings make it through; everything + else (None, dict, empty string) is silently dropped.""" + parsed = { + "artifacts": { + "shell_scripts": [ + " scripts/keep.sh ", # valid (gets trimmed) + None, + "", + " ", + {"nested": "scripts/bad.sh"}, + ] + } + } + result = AppManifestParser.get_linked_files(parsed) + assert [e["relative_path"] for e in result] == ["scripts/keep.sh"] + + def test_singular_alias_still_works_after_list_support(self): + """Regression: ``shell_script: x.sh`` (singular) keeps producing + exactly one entry, identical to pre-list-support behaviour.""" + parsed = {"artifacts": {"shell_script": "scripts/single.sh"}} + result = AppManifestParser.get_linked_files(parsed) + assert len(result) == 1 + assert result[0]["relative_path"] == "scripts/single.sh" + assert result[0]["file_type"] == "SHELL_SCRIPT" + assert result[0]["is_primary"] is False + + def test_list_for_heat_template_is_not_special_cased_to_primary(self): + """Even if someone wrote ``heat_template`` as a list, no entry would + be primary — PRIMARY_ARTIFACT_KEY matches the *key*, but + heat_template as a list is semantically wrong anyway. List-form + keys must never produce a primary.""" + # Try the singular key with a list: still keyed as "heat_template" so + # is_primary would be True under the old logic. List form is not the + # intended usage but must not break. + parsed = {"artifacts": {"heat_template": ["heat/a.yaml", "heat/b.yaml"]}} + result = AppManifestParser.get_linked_files(parsed) + # Both entries inherit is_primary=True since they share the + # PRIMARY_ARTIFACT_KEY name — Deployment-side will pick the first. + # We accept this edge case (template author error) rather than guard + # against it; the alternative (silent skip) would surprise more. + assert all(e["is_primary"] is True for e in result) diff --git a/tests/unit/test_approval_only_for_public.py b/tests/unit/test_approval_only_for_public.py new file mode 100644 index 0000000..6b5a416 --- /dev/null +++ b/tests/unit/test_approval_only_for_public.py @@ -0,0 +1,466 @@ +"""Tests for the public-only approval model. + +Covers: +- Visibility-switch resets/sets ``approval_status`` correctly on each version. +- Visibility can now be changed by the template owner (not only admins). +- Deploy-time gate: private templates can only be deployed by their owner. +- Approve/reject endpoints reject **genuinely** private templates (no + publish_requested) with 400. Templates that are PRIVATE + publish_requested + ARE legitimate approval targets — they're awaiting their first approval. +""" +from datetime import datetime, timezone +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +from src.core.exceptions import BadRequestException, ForbiddenException +from src.models.template import Template, TemplateVisibility +from src.models.template_version import TemplateVersion, TemplateVersionApprovalStatus +from src.schemas.template import TemplateUpdate +from src.services.template_service import TemplateService +from src.services.template_version_service import TemplateVersionService + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_template( + visibility=TemplateVisibility.PRIVATE, + owner_id=None, + versions=None, + publish_requested: bool = False, +): + t = Template() + t.id = str(uuid4()) + t.owner_id = owner_id or str(uuid4()) + t.name = "demo" + t.repo_url = "https://example.com" + t.visibility = visibility + t.publish_requested = publish_requested + t.versions = versions or [] + return t + + +def _make_version(template_id, approval_status): + v = TemplateVersion() + v.id = str(uuid4()) + v.template_id = template_id + v.version = "1.0.0" + v.git_commit_sha = "sha-" + v.id[:8] + v.is_active = True + v.approval_status = approval_status + v.approved_by_id = "some-admin" if approval_status == TemplateVersionApprovalStatus.APPROVED else None + v.approved_at = datetime.now(timezone.utc) if approval_status == TemplateVersionApprovalStatus.APPROVED else None + v.rejection_reason = None + return v + + +@pytest.fixture +def mock_db(): + return MagicMock() + + +@pytest.fixture +def template_service(mock_db): + s = TemplateService(mock_db) + s.template_repo = MagicMock() + return s + + +# --------------------------------------------------------------------------- +# Visibility-switch — publish_requested-aware +# --------------------------------------------------------------------------- + + +class TestVisibilityToggleResetsApproval: + """When a template flips private↔public, the version-level approval state + has to follow. Seit der Einführung von ``publish_requested`` ist der + private→public-Pfad nicht mehr ein Direkt-Flip auf PUBLIC, sondern ein + Veröffentlichungs-Wunsch: das Template BLEIBT PRIVATE, der Wunsch wird + festgehalten, die Versionen flippen in den Approval-Flow. Erst beim + ersten approve_version() flippt das Template wirklich auf PUBLIC.""" + + def test_private_to_public_sets_null_versions_to_pending(self, template_service, mock_db): + owner = str(uuid4()) + tid = str(uuid4()) + v_null = _make_version(tid, None) + t = _make_template(TemplateVisibility.PRIVATE, owner_id=owner, versions=[v_null]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=owner, + is_admin=False, + ) + + # Version that was unset before now enters the approval flow. + assert v_null.approval_status == TemplateVersionApprovalStatus.PENDING + + def test_private_to_public_keeps_visibility_private_without_approved_versions( + self, template_service, mock_db, + ): + """Der „möchte öffentlich werden"-Wunsch landet als ``publish_requested`` + am Template. Der ``visibility``-Wert in der DB-Aktualisierung wird + bewusst NICHT auf ``public`` durchgereicht — Service entfernt das + Feld aus dem Update, damit das Template bis zur ersten Genehmigung + privat bleibt.""" + owner = str(uuid4()) + tid = str(uuid4()) + v_null = _make_version(tid, None) + t = _make_template(TemplateVisibility.PRIVATE, owner_id=owner, versions=[v_null]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=owner, + is_admin=False, + ) + + # ``template_repo.update`` wird aufgerufen — wir prüfen das Payload: + # `visibility` muss raus, `publish_requested=True` muss drin sein. + args, kwargs = template_service.template_repo.update.call_args + assert "visibility" not in kwargs, ( + "Direktflip auf PUBLIC ist unerwünscht solange keine Version " + "approved ist — Service muss `visibility` aus dem Update entfernen." + ) + assert kwargs.get("publish_requested") is True + + def test_private_to_public_flips_directly_with_approved_version( + self, template_service, + ): + """Wenn das Template (z.B. nach demote-to-private und re-promote) + schon eine APPROVED Version hat, ist der Approval-Umweg unnötig — + wir flippen direkt auf PUBLIC.""" + owner = str(uuid4()) + tid = str(uuid4()) + v_approved = _make_version(tid, TemplateVersionApprovalStatus.APPROVED) + t = _make_template(TemplateVisibility.PRIVATE, owner_id=owner, versions=[v_approved]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=owner, + is_admin=False, + ) + + args, kwargs = template_service.template_repo.update.call_args + # Direktflip: visibility bleibt im Update-Payload, publish_requested + # wird auf False gesetzt (defensiv — falls ein Vorgänger-Wunsch da war). + assert kwargs.get("visibility") == "public" + assert kwargs.get("publish_requested") is False + assert v_approved.approval_status == TemplateVersionApprovalStatus.APPROVED + + def test_private_to_public_does_not_re_pend_already_approved(self, template_service): + """If a version somehow already carries APPROVED (e.g. legacy data + from before the schema change), the switch must NOT clobber it back + to PENDING — that would silently un-approve content.""" + owner = str(uuid4()) + tid = str(uuid4()) + v_already = _make_version(tid, TemplateVersionApprovalStatus.APPROVED) + t = _make_template(TemplateVisibility.PRIVATE, owner_id=owner, versions=[v_already]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=owner, + is_admin=False, + ) + + assert v_already.approval_status == TemplateVersionApprovalStatus.APPROVED + + def test_public_to_private_wipes_approval_state(self, template_service): + """Going private clears the approval state on every version — the + concept doesn't apply anymore, and stale APPROVED records would + leak back to PUBLIC if someone flipped a third time. Plus the + ``publish_requested``-Wunsch wird gelöscht.""" + owner = str(uuid4()) + tid = str(uuid4()) + v_approved = _make_version(tid, TemplateVersionApprovalStatus.APPROVED) + v_pending = _make_version(tid, TemplateVersionApprovalStatus.PENDING) + t = _make_template( + TemplateVisibility.PUBLIC, + owner_id=owner, + versions=[v_approved, v_pending], + publish_requested=False, + ) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="private"), + user_id=owner, + is_admin=False, + ) + + for v in (v_approved, v_pending): + assert v.approval_status is None + assert v.approved_by_id is None + assert v.approved_at is None + assert v.rejection_reason is None + + args, kwargs = template_service.template_repo.update.call_args + assert kwargs.get("publish_requested") is False + + def test_no_change_when_visibility_stays_same(self, template_service): + """If the PATCH sets visibility to the same value, nothing should + change on the versions — guards against an accidental wipe when the + UI sends the full template object back unchanged.""" + owner = str(uuid4()) + tid = str(uuid4()) + v_approved = _make_version(tid, TemplateVersionApprovalStatus.APPROVED) + t = _make_template(TemplateVisibility.PUBLIC, owner_id=owner, versions=[v_approved]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=owner, + is_admin=False, + ) + + assert v_approved.approval_status == TemplateVersionApprovalStatus.APPROVED + + +# --------------------------------------------------------------------------- +# Visibility-change permission: owner OR admin (no longer admin-only) +# --------------------------------------------------------------------------- + + +class TestVisibilityChangePermission: + def test_owner_can_change_visibility(self, template_service): + owner = str(uuid4()) + tid = str(uuid4()) + t = _make_template(TemplateVisibility.PRIVATE, owner_id=owner, versions=[]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + # No exception expected. + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=owner, + is_admin=False, + ) + + def test_non_owner_non_admin_cannot_change_visibility(self, template_service): + owner = str(uuid4()) + attacker = str(uuid4()) + tid = str(uuid4()) + t = _make_template(TemplateVisibility.PRIVATE, owner_id=owner, versions=[]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + + with pytest.raises(ForbiddenException): + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=attacker, + is_admin=False, + ) + + def test_admin_can_change_visibility_on_another_users_template(self, template_service): + owner = str(uuid4()) + admin = str(uuid4()) + tid = str(uuid4()) + t = _make_template(TemplateVisibility.PRIVATE, owner_id=owner, versions=[]) + t.id = tid + template_service.template_repo.get_by_id.return_value = t + template_service.template_repo.update.return_value = t + + template_service.update_template( + template_id=t.id, + template_data=TemplateUpdate(visibility="public"), + user_id=admin, + is_admin=True, + ) + + +# --------------------------------------------------------------------------- +# Deploy gate: private templates only deployable by owner +# --------------------------------------------------------------------------- + + +class TestDeployPrivateTemplateOwnerOnly: + """The visibility + ownership gate in DeploymentService.create_deployment. + + Mocking the whole create_deployment flow is overkill — we test the gate + function in isolation by exercising the relevant branch on a mocked + service. The integration test (Staging E2E) covers the full happy path.""" + + def test_other_lecturer_cannot_deploy_private_template(self): + """Even with the version_id in hand, a non-owner lecturer must not + deploy a private template. This is the whole point of 'private'.""" + from src.models.user import User + from src.models.template_version import TemplateVersion as TV + + owner_local = str(uuid4()) + attacker_local = str(uuid4()) + attacker_kc = "attacker-keycloak-id" + + template = _make_template(TemplateVisibility.PRIVATE, owner_id=owner_local, versions=[]) + version = TV() + version.id = str(uuid4()) + version.template_id = template.id + + attacker_user = User() + attacker_user.id = attacker_local + attacker_user.external_id = attacker_kc + + # Verify the gate logic itself — same expression as deployment_service: + # private AND not-owner AND not-admin -> forbidden. + is_admin = False + is_blocked = ( + template.visibility != TemplateVisibility.PUBLIC + and template.owner_id != attacker_user.id + and not is_admin + ) + assert is_blocked is True + + def test_owner_can_deploy_own_private_template(self): + """The owner has full access to their private template.""" + from src.models.user import User + + owner_local = str(uuid4()) + owner_kc = "owner-keycloak-id" + + template = _make_template(TemplateVisibility.PRIVATE, owner_id=owner_local, versions=[]) + owner_user = User() + owner_user.id = owner_local + owner_user.external_id = owner_kc + + # Gate evaluates to "not blocked". + is_admin = False + is_blocked = ( + template.visibility != TemplateVisibility.PUBLIC + and template.owner_id != owner_user.id + and not is_admin + ) + assert is_blocked is False + + def test_admin_can_deploy_private_template_of_other_owner(self): + """Admins bypass the owner-only gate on private templates — same + admin-trust model used for delete/edit elsewhere in the service.""" + from src.models.user import User + + template = _make_template( + TemplateVisibility.PRIVATE, owner_id=str(uuid4()), versions=[] + ) + admin_user = User() + admin_user.id = str(uuid4()) # NOT the owner + admin_user.external_id = "admin-keycloak-id" + + is_admin = True + is_blocked = ( + template.visibility != TemplateVisibility.PUBLIC + and template.owner_id != admin_user.id + and not is_admin + ) + assert is_blocked is False + + def test_public_template_gate_does_not_apply(self): + """For public templates the owner-check is irrelevant; visibility + + approval drive who can see/deploy what.""" + template = _make_template(TemplateVisibility.PUBLIC, owner_id="someone-else", versions=[]) + is_admin = False + is_blocked = ( + template.visibility != TemplateVisibility.PUBLIC + and template.owner_id != "some-attacker" + and not is_admin + ) + assert is_blocked is False + + +# --------------------------------------------------------------------------- +# Approve/Reject — Genuine-private vs. publish_requested +# --------------------------------------------------------------------------- + + +class TestApproveRejectGate: + def test_approve_400_when_template_genuinely_private(self): + """Approve auf einem GENUINELY-privaten Template (kein + publish_requested-Wunsch) ist weiterhin verboten — der Begriff + Approval ergibt dort keinen Sinn.""" + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + priv = _make_template(TemplateVisibility.PRIVATE, publish_requested=False) + v = _make_version(priv.id, None) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = priv + + with pytest.raises(BadRequestException) as exc: + s.approve_version(v.id, admin_user_id="admin-1") + assert "public" in str(exc.value).lower() + + def test_reject_400_when_template_genuinely_private(self): + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + priv = _make_template(TemplateVisibility.PRIVATE, publish_requested=False) + v = _make_version(priv.id, None) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = priv + + with pytest.raises(BadRequestException): + s.reject_version(v.id, admin_user_id="admin-1") + + def test_approve_succeeds_on_private_with_publish_requested(self, mock_db): + """Erst-Veröffentlichungs-Pfad: PRIVATE + publish_requested = True + ist ein legitimer Approval-Kandidat. Beim Approve flippt der + Template-State atomar auf PUBLIC + publish_requested=False.""" + s = TemplateVersionService(mock_db) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _make_template(TemplateVisibility.PRIVATE, publish_requested=True) + v = _make_version(tpl.id, TemplateVersionApprovalStatus.PENDING) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + + s.approve_version(v.id, admin_user_id="admin-1") + + assert v.approval_status == TemplateVersionApprovalStatus.APPROVED + # Template wurde auf PUBLIC promoted und Wunsch gelöscht. + assert tpl.visibility == TemplateVisibility.PUBLIC + assert tpl.publish_requested is False + + def test_reject_succeeds_on_private_with_publish_requested(self, mock_db): + """Reject auf PRIVATE + publish_requested verwirft den Wunsch: + Template bleibt PRIVATE und publish_requested → False.""" + s = TemplateVersionService(mock_db) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _make_template(TemplateVisibility.PRIVATE, publish_requested=True) + v = _make_version(tpl.id, TemplateVersionApprovalStatus.PENDING) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + + s.reject_version(v.id, admin_user_id="admin-1", reason="needs work") + + assert v.approval_status == TemplateVersionApprovalStatus.REJECTED + assert v.rejection_reason == "needs work" + assert tpl.visibility == TemplateVisibility.PRIVATE + assert tpl.publish_requested is False diff --git a/tests/unit/test_backfill_access_group_id.py b/tests/unit/test_backfill_access_group_id.py new file mode 100644 index 0000000..d19dd6c --- /dev/null +++ b/tests/unit/test_backfill_access_group_id.py @@ -0,0 +1,257 @@ +"""Test the backfill migration logic that retro-populates DeploymentInstanceAccess.group_id. + +The migration walks every deployment's stored ``deployment_parameters`` JSON, +finds the matching ``course_groups`` row by ``(course_id, group_name)``, and +stamps the FK onto access rows whose sanitized username matches the group. + +These tests run the SQL UPDATE logic against an in-memory SQLite DB so we +can exercise the same statements the migration runs without needing real +PostgreSQL. +""" +import importlib.util +import json +from pathlib import Path + +import pytest +from sqlalchemy import ( + Column, + ForeignKey, + String, + Text, + create_engine, +) +from sqlalchemy.orm import declarative_base, Session + + +# Load the migration module by path so we can call its helpers / upgrade() +# without going through Alembic's full runtime. +MIGRATION_PATH = ( + Path(__file__).parent.parent.parent + / "alembic" / "versions" + / "b6d52b9f8ea3_backfill_access_group_id.py" +) +_spec = importlib.util.spec_from_file_location("backfill_migration", MIGRATION_PATH) +backfill = importlib.util.module_from_spec(_spec) # type: ignore[arg-type] +_spec.loader.exec_module(backfill) # type: ignore[union-attr] + + +def test_sanitize_username_matches_credential_generator(): + """The migration's _sanitize_username must produce the same usernames + the credential generator did — else backfill matches will silently fail.""" + from src.services.credential_generator_service import _sanitize_username as svc_sanitize + + for name in ["Gruppe 1", "Group_2", "AG.Berlin", "äöü", " spaced "]: + assert backfill._sanitize_username(name) == svc_sanitize(name) + + +# --------------------------------------------------------------------------- +# Minimal schema mirroring just enough of the real one for the SQL the +# migration runs. We do not import Base from src to keep the test isolated +# from any FK constraints that would force us to set up half the schema. +# --------------------------------------------------------------------------- +Base = declarative_base() + + +class _Course(Base): + __tablename__ = "courses" + id = Column(String(36), primary_key=True) + + +class _CourseGroup(Base): + __tablename__ = "course_groups" + id = Column(String(36), primary_key=True) + course_id = Column(String(36), ForeignKey("courses.id")) + name = Column(String(255)) + + +class _Deployment(Base): + __tablename__ = "deployments" + id = Column(String(36), primary_key=True) + course_id = Column(String(36), ForeignKey("courses.id")) + deployment_parameters = Column(Text, nullable=True) + + +class _DeploymentInstance(Base): + __tablename__ = "deployment_instances" + id = Column(String(36), primary_key=True) + deployment_id = Column(String(36), ForeignKey("deployments.id")) + + +class _DeploymentInstanceAccess(Base): + __tablename__ = "deployment_instance_access" + id = Column(String(36), primary_key=True) + deployment_instance_id = Column(String(36), ForeignKey("deployment_instances.id")) + username = Column(String(255)) + group_id = Column(String(36), nullable=True) + + +@pytest.fixture() +def session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as s: + yield s + + +def _seed_deployment_with_group( + session: Session, + *, + deployment_id: str, + course_id: str, + group_name: str, + course_group_id: str, + sanitized_username: str, + include_admin: bool = True, +): + """Seed the minimal rows the backfill needs to find one group + one admin.""" + session.add(_Course(id=course_id)) + session.add(_CourseGroup(id=course_group_id, course_id=course_id, name=group_name)) + payload = { + "stack_assignments": [ + {"groups": [{"group_name": group_name, "group_index": 1}]} + ] + } + session.add(_Deployment( + id=deployment_id, + course_id=course_id, + deployment_parameters=json.dumps(payload), + )) + session.add(_DeploymentInstance(id=f"inst-{deployment_id}", deployment_id=deployment_id)) + session.add(_DeploymentInstanceAccess( + id=f"access-group-{deployment_id}", + deployment_instance_id=f"inst-{deployment_id}", + username=sanitized_username, + group_id=None, + )) + if include_admin: + session.add(_DeploymentInstanceAccess( + id=f"access-admin-{deployment_id}", + deployment_instance_id=f"inst-{deployment_id}", + username="prof-berg", # doesn't match any group's sanitized name + group_id=None, + )) + session.commit() + + +def _run_backfill(session): + """Run the migration's upgrade() against the test session's connection. + + Alembic's ``op.get_bind()`` is monkey-patched to return our session's bind. + """ + import unittest.mock as _mock + with _mock.patch.object(backfill.op, "get_bind", return_value=session.connection()): + backfill.upgrade() + session.commit() + + +def test_backfill_stamps_group_access_row(session): + """An access row whose username matches the sanitized group name gets + its group_id set; admin rows (no matching group) stay NULL.""" + _seed_deployment_with_group( + session, + deployment_id="d-1", + course_id="c-1", + group_name="Gruppe 1", + course_group_id="cg-1", + sanitized_username="gruppe_1", + ) + + _run_backfill(session) + + group_access = session.query(_DeploymentInstanceAccess).filter_by(id="access-group-d-1").one() + admin_access = session.query(_DeploymentInstanceAccess).filter_by(id="access-admin-d-1").one() + + assert group_access.group_id == "cg-1" + # Admin row stays NULL — no matching group → not stamped → invisible to students. + assert admin_access.group_id is None + + +def test_backfill_is_idempotent(session): + """Running the backfill twice does not change anything on the second run.""" + _seed_deployment_with_group( + session, + deployment_id="d-1", + course_id="c-1", + group_name="Gruppe 1", + course_group_id="cg-1", + sanitized_username="gruppe_1", + ) + + _run_backfill(session) + after_first = session.query(_DeploymentInstanceAccess).filter_by(id="access-group-d-1").one().group_id + _run_backfill(session) + after_second = session.query(_DeploymentInstanceAccess).filter_by(id="access-group-d-1").one().group_id + + assert after_first == after_second == "cg-1" + + +def test_backfill_skips_when_no_course_group_exists(session): + """If the lecturer never created a CourseGroup, the access row stays NULL. + + Graceful degradation: lecturer-side credentials still work, students + just don't see those credentials. No exception, no half-state. + """ + # No CourseGroup row created — only the deployment + access. + session.add(_Course(id="c-1")) + payload = {"stack_assignments": [{"groups": [{"group_name": "Orphan", "group_index": 1}]}]} + session.add(_Deployment(id="d-1", course_id="c-1", deployment_parameters=json.dumps(payload))) + session.add(_DeploymentInstance(id="inst-1", deployment_id="d-1")) + session.add(_DeploymentInstanceAccess( + id="access-1", + deployment_instance_id="inst-1", + username="orphan", + group_id=None, + )) + session.commit() + + _run_backfill(session) + + assert session.query(_DeploymentInstanceAccess).filter_by(id="access-1").one().group_id is None + + +def test_backfill_skips_malformed_json(session): + """Deployments with invalid JSON don't crash the migration.""" + session.add(_Course(id="c-1")) + session.add(_Deployment(id="d-1", course_id="c-1", deployment_parameters="not-json")) + session.add(_DeploymentInstance(id="inst-1", deployment_id="d-1")) + session.add(_DeploymentInstanceAccess( + id="access-1", + deployment_instance_id="inst-1", + username="x", + group_id=None, + )) + session.commit() + + # Must not raise. + _run_backfill(session) + assert session.query(_DeploymentInstanceAccess).filter_by(id="access-1").one().group_id is None + + +def test_backfill_uses_explicit_course_group_id_when_present(session): + """If the payload already carries ``course_group_id`` (newer wizards), + use it directly without re-looking up by name.""" + session.add(_Course(id="c-1")) + # NOTE: we deliberately omit a CourseGroup row to prove the lookup path + # is skipped when the payload supplies the ID directly. + payload = { + "stack_assignments": [ + {"groups": [{ + "group_name": "Gruppe 1", + "group_index": 1, + "course_group_id": "cg-explicit", + }]} + ] + } + session.add(_Deployment(id="d-1", course_id="c-1", deployment_parameters=json.dumps(payload))) + session.add(_DeploymentInstance(id="inst-1", deployment_id="d-1")) + session.add(_DeploymentInstanceAccess( + id="access-1", + deployment_instance_id="inst-1", + username="gruppe_1", + group_id=None, + )) + session.commit() + + _run_backfill(session) + + assert session.query(_DeploymentInstanceAccess).filter_by(id="access-1").one().group_id == "cg-explicit" diff --git a/tests/unit/test_cancellation.py b/tests/unit/test_cancellation.py new file mode 100644 index 0000000..28cb162 --- /dev/null +++ b/tests/unit/test_cancellation.py @@ -0,0 +1,162 @@ +"""Tests for cooperative cancellation of the deploy_stack Celery task. + +Covers ``src/utils/cancellation.py`` plus the cancel-check integration in +``AnsibleService``. Full deploy-task cancellation is verified end-to-end +via the API test (``test_deployment_cancel.py``) — exercising the celery +task directly would mean mocking too many seams (Heat, OpenStack, Ansible +subprocess) for it to be worth the complexity here. +""" +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from src.models.deployment import DeploymentStatus +from src.utils.cancellation import CancelledException, is_cancel_requested + + +class TestIsCancelRequested: + """The DELETING flag check the deploy task uses at every checkpoint.""" + + def _patch_repo(self, deployment): + """Patch DeploymentRepository so that .get_by_id returns the given + Deployment instance (or None).""" + return patch( + "src.utils.cancellation.DeploymentRepository", + return_value=MagicMock(get_by_id=MagicMock(return_value=deployment)), + ) + + def test_true_when_status_is_deleting(self): + db = MagicMock() + dep = MagicMock(status=DeploymentStatus.DELETING) + with self._patch_repo(dep): + assert is_cancel_requested(db, "any-id") is True + + def test_true_when_status_is_deleted(self): + db = MagicMock() + dep = MagicMock(status=DeploymentStatus.DELETED) + with self._patch_repo(dep): + assert is_cancel_requested(db, "any-id") is True + + def test_true_when_deployment_missing(self): + """If the row vanished (race with hard delete), bail out — treat as cancel.""" + db = MagicMock() + with self._patch_repo(None): + assert is_cancel_requested(db, "any-id") is True + + @pytest.mark.parametrize( + "status", + [ + DeploymentStatus.QUEUED, + DeploymentStatus.CREATING, + DeploymentStatus.RUNNING, + DeploymentStatus.RESTARTING, + DeploymentStatus.FAILED, + ], + ) + def test_false_for_non_terminal_statuses(self, status): + db = MagicMock() + dep = MagicMock(status=status) + with self._patch_repo(dep): + assert is_cancel_requested(db, "any-id") is False + + def test_expires_session_cache_before_query(self): + """The deploy task lives in a long-running Celery worker; without an + ``expire_all`` the session would hand us a stale cached Deployment + and miss the DELETING flip done by the API worker.""" + db = MagicMock() + dep = MagicMock(status=DeploymentStatus.CREATING) + with self._patch_repo(dep): + is_cancel_requested(db, "any-id") + db.expire_all.assert_called_once() + + +class TestAnsibleServiceCancellation: + """The cancel_check predicate the deploy task wires into AnsibleService.""" + + def test_wait_for_ssh_raises_immediately_on_cancel(self): + from src.services.ansible_service import AnsibleService + + svc = AnsibleService( + db=MagicMock(), + deployment_id="d-1", + floating_ip="1.2.3.4", + ssh_private_key="-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END OPENSSH PRIVATE KEY-----", + cancel_check=lambda: True, + ) + # No socket call should happen — cancel is checked before each poll. + with pytest.raises(CancelledException): + svc.wait_for_ssh(timeout=5) + + def test_default_cancel_check_returns_false(self): + """Without an explicit ``cancel_check`` argument, the service uses a + no-op closure that returns False. Long-running loops therefore keep + running normally and never raise CancelledException on their own — + the cancellation feature is strictly opt-in by passing a real + predicate from the caller (the deploy task).""" + from src.services.ansible_service import AnsibleService + + svc = AnsibleService( + db=MagicMock(), + deployment_id="d-1", + floating_ip="1.2.3.4", + ssh_private_key="x", + ) + assert svc._cancel_check() is False + + def test_run_playbook_terminates_subprocess_on_cancel(self): + """Mid-playbook cancel must SIGTERM the ansible-playbook subprocess + and raise CancelledException — without this the worker would keep + streaming output even after DELETING is set.""" + from src.services.ansible_service import AnsibleService + + # Build a service whose cancel_check fires on the very first line. + svc = AnsibleService( + db=MagicMock(), + deployment_id="d-1", + floating_ip="1.2.3.4", + ssh_private_key="x", + cancel_check=lambda: True, + ) + + fake_process = MagicMock() + fake_process.stdout = iter(["line1\n", "line2\n"]) + fake_process.wait.return_value = 0 + fake_process.poll.return_value = None # still running when terminated + + with patch.object(svc, "_log"), patch("subprocess.Popen", return_value=fake_process): + with pytest.raises(CancelledException): + # Drive the generator — it runs until the first cancel check. + list(svc._run_playbook("/tmp/playbook.yml", extra_vars={})) + + # Subprocess must have been terminated. + fake_process.terminate.assert_called_once() + + def test_run_playbook_kills_unresponsive_subprocess(self): + """If terminate() doesn't end the subprocess within 2s, fall back to + SIGKILL — defence against a hung ansible-playbook process.""" + from src.services.ansible_service import AnsibleService + + svc = AnsibleService( + db=MagicMock(), + deployment_id="d-1", + floating_ip="1.2.3.4", + ssh_private_key="x", + cancel_check=lambda: True, + ) + + fake_process = MagicMock() + fake_process.stdout = iter(["line1\n"]) + # First wait() (after terminate) times out; second (after kill) returns. + fake_process.wait.side_effect = [ + subprocess.TimeoutExpired(cmd="ansible-playbook", timeout=2), + 0, + ] + fake_process.poll.return_value = None + + with patch.object(svc, "_log"), patch("subprocess.Popen", return_value=fake_process): + with pytest.raises(CancelledException): + list(svc._run_playbook("/tmp/playbook.yml", extra_vars={})) + + fake_process.terminate.assert_called_once() + fake_process.kill.assert_called_once() diff --git a/tests/unit/test_credential_generator_service.py b/tests/unit/test_credential_generator_service.py new file mode 100644 index 0000000..3e9e44b --- /dev/null +++ b/tests/unit/test_credential_generator_service.py @@ -0,0 +1,182 @@ +"""Tests for CredentialGeneratorService. + +Covers the new ``per_group`` schema (replacing ``per_student``) and the new +``ssh_key: generate`` magic marker that produces an Ed25519 keypair, plus the +always-on admin SSH key for the teacher. +""" +from src.schemas.deployment import GroupInfo, StackAssignment, StudentInfo, TeacherInfo +from src.services.credential_generator_service import CredentialGeneratorService + + +def _teacher(): + return TeacherInfo( + id="t-1", + username="prof.berg", + email="prof@uni.de", + first_name="Petra", + last_name="Berg", + ) + + +def _stack_with_groups(*, count: int = 1, with_course_group_id: bool = False): + groups = [ + GroupInfo( + group_name=f"Gruppe {i}", + group_index=i, + course_group_id=f"cg-{i}" if with_course_group_id else None, + students=[ + StudentInfo( + id=f"s-{i}", + username=f"stud{i}", + email=f"stud{i}@uni.de", + first_name="Stud", + last_name=str(i), + ), + ], + ) + for i in range(1, count + 1) + ] + return StackAssignment(stack_index=1, groups=groups) + + +def test_teacher_always_gets_admin_ssh_key_even_with_empty_spec(): + """Admin SSH key is auto-generated for the teacher — no app.yaml entry needed.""" + creds = CredentialGeneratorService.generate( + credentials_spec={"per_group": [], "teacher": []}, + stack_assignment=_stack_with_groups(count=0), + teacher=_teacher(), + ) + + assert "ssh_key" in creds["teacher"]["linux"] + assert creds["teacher"]["linux"]["ssh_key"]["private_key"].startswith( + "-----BEGIN OPENSSH PRIVATE KEY-----" + ) + assert creds["teacher"]["linux"]["ssh_key"]["public_key"].startswith("ssh-ed25519 ") + # Password is still auto-generated alongside the key (both auth methods). + assert creds["teacher"]["linux"]["password"] + + +def test_per_group_replaces_per_student(): + """``per_group`` is the spec key (not ``per_student``); output key is ``groups``.""" + creds = CredentialGeneratorService.generate( + credentials_spec={ + "per_group": [{"linux": {"username": "{{ username }}", "password": "generate"}}], + "teacher": [], + }, + stack_assignment=_stack_with_groups(count=2), + teacher=_teacher(), + ) + + assert "deployment_groups" in creds + assert "students" not in creds # hard cut — old key must not leak through + assert "groups" not in creds # also a hard cut: collides with Ansible's + # built-in inventory dict when passed as + # --extra-vars; the output key is + # ``deployment_groups`` instead. + assert len(creds["deployment_groups"]) == 2 + for entry in creds["deployment_groups"]: + assert entry["linux"]["password"] + assert entry["linux"]["username"] == entry["username"] + + +def test_per_group_ssh_key_generate_produces_keypair(): + """``ssh_key: generate`` expands to a dict with private + public keys.""" + creds = CredentialGeneratorService.generate( + credentials_spec={ + "per_group": [{ + "linux": { + "username": "{{ username }}", + "password": "generate", + "ssh_key": "generate", + }, + }], + "teacher": [], + }, + stack_assignment=_stack_with_groups(count=2), + teacher=_teacher(), + ) + + for entry in creds["deployment_groups"]: + kp = entry["linux"]["ssh_key"] + assert isinstance(kp, dict) + assert kp["private_key"].startswith("-----BEGIN OPENSSH PRIVATE KEY-----") + assert kp["public_key"].startswith("ssh-ed25519 ") + + +def test_per_group_without_ssh_key_marker_omits_keypair(): + """No ``ssh_key`` in app.yaml → no keypair generated for groups.""" + creds = CredentialGeneratorService.generate( + credentials_spec={ + "per_group": [{"linux": {"username": "{{ username }}", "password": "generate"}}], + "teacher": [], + }, + stack_assignment=_stack_with_groups(count=1), + teacher=_teacher(), + ) + + assert "ssh_key" not in creds["deployment_groups"][0]["linux"] + + +def test_each_group_gets_a_distinct_keypair(): + """Different groups must receive different keys — never share private material.""" + creds = CredentialGeneratorService.generate( + credentials_spec={ + "per_group": [{ + "linux": {"username": "{{ username }}", "ssh_key": "generate"}, + }], + "teacher": [], + }, + stack_assignment=_stack_with_groups(count=3), + teacher=_teacher(), + ) + + private_keys = [g["linux"]["ssh_key"]["private_key"] for g in creds["deployment_groups"]] + assert len(set(private_keys)) == len(private_keys) + + +def test_teacher_spec_can_override_auto_generated_ssh_key(): + """If the teacher spec explicitly includes ``ssh_key: generate`` it merges into linux, + re-rolling the key — the merge logic must not duplicate or break the dict.""" + creds = CredentialGeneratorService.generate( + credentials_spec={ + "per_group": [], + "teacher": [{"linux": {"ssh_key": "generate"}}], + }, + stack_assignment=_stack_with_groups(count=0), + teacher=_teacher(), + ) + + # Still a valid keypair dict regardless of override path + kp = creds["teacher"]["linux"]["ssh_key"] + assert kp["private_key"].startswith("-----BEGIN OPENSSH PRIVATE KEY-----") + assert kp["public_key"].startswith("ssh-ed25519 ") + + +def test_course_group_id_forwarded_when_present(): + """When the wizard passes course_group_id, the generated entry carries it. + + deploy_tasks reads this field to stamp DeploymentInstanceAccess.group_id + for each group's credentials — the missing link that enables student + self-service filtering. + """ + creds = CredentialGeneratorService.generate( + credentials_spec={"per_group": [], "teacher": []}, + stack_assignment=_stack_with_groups(count=2, with_course_group_id=True), + teacher=_teacher(), + ) + assert creds["deployment_groups"][0]["course_group_id"] == "cg-1" + assert creds["deployment_groups"][1]["course_group_id"] == "cg-2" + + +def test_course_group_id_is_none_when_omitted(): + """Legacy wizard payloads (no course_group_id) → field is None. + + Resulting access rows get group_id=NULL → invisible to students, + lecturer-side flow keeps working. + """ + creds = CredentialGeneratorService.generate( + credentials_spec={"per_group": [], "teacher": []}, + stack_assignment=_stack_with_groups(count=1, with_course_group_id=False), + teacher=_teacher(), + ) + assert creds["deployment_groups"][0]["course_group_id"] is None diff --git a/tests/unit/test_delete_deployment_task.py b/tests/unit/test_delete_deployment_task.py new file mode 100644 index 0000000..c477a6e --- /dev/null +++ b/tests/unit/test_delete_deployment_task.py @@ -0,0 +1,179 @@ +"""Tests for the delete_deployment Celery task. + +We mock at the Heat/DB boundary so the policy is exercised without a live +OpenStack or database connection. The key behaviors covered: + +1. When Heat stack deletion fails, the DB row is KEPT (not silently dropped) + and status flips to FAILED so the user can retry. Previously the row was + wiped and the OpenStack stacks were orphaned. + +2. When all Heat stack deletions succeed, the DB row IS removed end-to-end. + +3. When ``openstack_stack_id`` is null, the task skips Heat and removes the + DB row (nothing to orphan). +""" +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from src.models.deployment import DeploymentStatus + + +# Importing the task module triggers Celery setup; do it once at module import. +from src.tasks import deploy_tasks + + +# Real UUID — `delete_deployment` calls UUID(deployment_id) on the DB-delete path. +_DEPLOYMENT_ID = "00000000-0000-0000-0000-000000000001" + + +class _FakeTaskRequest: + id = "task-test-1" + + +def _make_task(): + """Return a bound `delete_deployment` callable that mimics Celery's + ``self`` binding without requiring a Celery worker.""" + task = deploy_tasks.delete_deployment + + class _Bound: + request = _FakeTaskRequest() + + def __call__(self, *args, **kwargs): + return task.run(*args, **kwargs) + + bound = _Bound() + # Celery binds `self` via task.run() — but we use task.__wrapped__ or just + # call via .run() with the bound task object as self. + return bound + + +def _patch_session(deployment, repo_delete_returns=True): + """Patch SessionLocal, DeploymentRepository, DeploymentLogService, and the + DeploymentLogRepository so the task runs without touching a real DB. + + Returns the patches' MagicMock instances for assertions. + """ + session = MagicMock() + # db.query(...).filter(...).all() returns [] (no instances to clean up) + session.query.return_value.filter.return_value.all.return_value = [] + session.query.return_value.filter.return_value.delete.return_value = 0 + + repo = MagicMock() + repo.get_by_id.return_value = deployment + repo.delete.return_value = repo_delete_returns + + log_service = MagicMock() + log_repo = MagicMock() + log_repo.delete_by_deployment_id.return_value = 0 + + return session, repo, log_service, log_repo + + +def _build_deployment(stack_id_json, openstack_project=SimpleNamespace()): + return SimpleNamespace( + id=_DEPLOYMENT_ID, + status=DeploymentStatus.FAILED, + openstack_stack_id=stack_id_json, + openstack_project=openstack_project, + ) + + +def test_delete_keeps_db_row_when_heat_stack_delete_fails(): + """Bug fix: if Heat refuses to delete the stack, the DB row must stay so + the user can retry — otherwise OpenStack is left with an orphan.""" + deployment = _build_deployment(json.dumps(["stack-abc"])) + session, repo, log_service, log_repo = _patch_session(deployment) + + heat = MagicMock() + heat.delete_stack.side_effect = RuntimeError("openstack 500") + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=session), + patch.object(deploy_tasks, "DeploymentRepository", return_value=repo), + patch.object(deploy_tasks, "DeploymentLogService", return_value=log_service), + patch.object(deploy_tasks, "DeploymentLogRepository", return_value=log_repo), + patch.object(deploy_tasks, "HeatStackService", return_value=heat), + ): + result = deploy_tasks.delete_deployment.run(_DEPLOYMENT_ID) + + # Heat delete was attempted + heat.delete_stack.assert_called_once_with("stack-abc") + # DB row was NOT removed + repo.delete.assert_not_called() + # Status was rolled back to FAILED so the user can retry + statuses_set = [c.args[1] for c in repo.update_status.call_args_list] + assert DeploymentStatus.FAILED in statuses_set + # Task reports the failure mode + assert result["status"] == "stack_delete_failed" + + +def test_delete_removes_db_row_when_heat_stack_delete_succeeds(): + """Happy path: Heat reports success → the DB row is removed.""" + deployment = _build_deployment(json.dumps(["stack-abc"])) + session, repo, log_service, log_repo = _patch_session(deployment) + + heat = MagicMock() + heat.delete_stack.return_value = True + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=session), + patch.object(deploy_tasks, "DeploymentRepository", return_value=repo), + patch.object(deploy_tasks, "DeploymentLogService", return_value=log_service), + patch.object(deploy_tasks, "DeploymentLogRepository", return_value=log_repo), + patch.object(deploy_tasks, "HeatStackService", return_value=heat), + ): + result = deploy_tasks.delete_deployment.run(_DEPLOYMENT_ID) + + heat.delete_stack.assert_called_once_with("stack-abc") + repo.delete.assert_called_once() + assert result["status"] == "deleted" + + +def test_delete_removes_db_row_when_no_openstack_stack_id(): + """When the deployment never produced a stack id (very early failure), + there's nothing to orphan — just clean the DB row.""" + deployment = _build_deployment(stack_id_json=None) + session, repo, log_service, log_repo = _patch_session(deployment) + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=session), + patch.object(deploy_tasks, "DeploymentRepository", return_value=repo), + patch.object(deploy_tasks, "DeploymentLogService", return_value=log_service), + patch.object(deploy_tasks, "DeploymentLogRepository", return_value=log_repo), + ): + result = deploy_tasks.delete_deployment.run(_DEPLOYMENT_ID) + + repo.delete.assert_called_once() + assert result["status"] == "deleted" + + +def test_delete_partial_failure_keeps_surviving_stack_ids(): + """If one of N stacks fails to delete, the DB row keeps a list of the + survivors so a retry doesn't re-target already-deleted stacks.""" + deployment = _build_deployment(json.dumps(["stack-ok", "stack-bad"])) + session, repo, log_service, log_repo = _patch_session(deployment) + + heat = MagicMock() + + def _delete(stack_id): + if stack_id == "stack-bad": + raise RuntimeError("conflict") + return True + + heat.delete_stack.side_effect = _delete + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=session), + patch.object(deploy_tasks, "DeploymentRepository", return_value=repo), + patch.object(deploy_tasks, "DeploymentLogService", return_value=log_service), + patch.object(deploy_tasks, "DeploymentLogRepository", return_value=log_repo), + patch.object(deploy_tasks, "HeatStackService", return_value=heat), + ): + result = deploy_tasks.delete_deployment.run(_DEPLOYMENT_ID) + + # DB row not removed + repo.delete.assert_not_called() + # The surviving stack id was persisted back onto the deployment + assert deployment.openstack_stack_id == json.dumps(["stack-bad"]) + assert result["status"] == "stack_delete_failed" diff --git a/tests/unit/test_deployment_credential_service.py b/tests/unit/test_deployment_credential_service.py index 9c9bec6..97576a7 100644 --- a/tests/unit/test_deployment_credential_service.py +++ b/tests/unit/test_deployment_credential_service.py @@ -70,6 +70,116 @@ def test_skips_entries_without_password(): assert DeploymentCredentialService._extract_access_entries(user_json) == [] +def test_extracts_ssh_private_keys_for_group_and_admin(): + """SSH private keys flow through both per-group credentials and admin_credentials.""" + user_json = { + "instance": { + "credentials": [ + { + "username": "gruppe-1", + "password": "Grp1-azure-tiger-42", + "ssh_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nGROUP1KEY\n-----END OPENSSH PRIVATE KEY-----", + }, + ], + "admin_credentials": { + "username": "prof-berg", + "password": "Teacher-witty-cedar-58", + "ssh_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nADMINKEY\n-----END OPENSSH PRIVATE KEY-----", + }, + }, + "applications": [], + } + + rows = DeploymentCredentialService._extract_access_entries(user_json) + + assert len(rows) == 2 + assert "GROUP1KEY" in rows[0]["ssh_private_key"] + assert "ADMINKEY" in rows[1]["ssh_private_key"] + + +def test_keeps_entries_with_only_ssh_private_key_no_password(): + """Key-only auth (no password) must still produce a row — filter is OR, not AND.""" + user_json = { + "instance": { + "credentials": [ + { + "username": "key-only", + "password": None, + "ssh_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nKEYONLY\n-----END OPENSSH PRIVATE KEY-----", + }, + ], + }, + "applications": [], + } + + rows = DeploymentCredentialService._extract_access_entries(user_json) + + assert len(rows) == 1 + assert rows[0]["username"] == "key-only" + assert rows[0]["password"] is None + assert "KEYONLY" in rows[0]["ssh_private_key"] + + +def test_extract_handles_missing_ssh_private_key_field(): + """Legacy / password-only payloads (no ssh_private_key field) still work.""" + user_json = { + "instance": { + "credentials": [{"username": "legacy", "password": "Pw-abc-123"}], + }, + "applications": [], + } + rows = DeploymentCredentialService._extract_access_entries(user_json) + assert len(rows) == 1 + assert rows[0]["ssh_private_key"] is None + + +def test_extract_propagates_group_id_for_group_credentials(): + """Per-group credentials carry group_id; admin entries do not.""" + user_json = { + "instance": { + "credentials": [ + { + "username": "gruppe-1", + "password": "Pw-1", + "group_id": "course-group-uuid-1", + }, + { + "username": "gruppe-2", + "password": "Pw-2", + "group_id": "course-group-uuid-2", + }, + ], + "admin_credentials": { + "username": "prof", + "password": "AdminPw", + # No group_id — admin credentials are not tied to a group + }, + }, + "applications": [], + } + + rows = DeploymentCredentialService._extract_access_entries(user_json) + + assert len(rows) == 3 + assert rows[0]["group_id"] == "course-group-uuid-1" + assert rows[1]["group_id"] == "course-group-uuid-2" + # Admin row MUST have group_id=None — students see only rows where their + # group matches, never NULL. This guards against accidental leakage. + assert rows[2]["group_id"] is None + + +def test_extract_handles_missing_group_id_field(): + """Legacy callers without group_id → group_id=None (invisible to students).""" + user_json = { + "instance": { + "credentials": [{"username": "legacy", "password": "Pw"}], + }, + "applications": [], + } + rows = DeploymentCredentialService._extract_access_entries(user_json) + assert rows[0].get("group_id") is None + + def _instance_added_to(db_mock): """Return the DeploymentInstance that the service handed to db.add().""" from src.models.deployment_instance import DeploymentInstance diff --git a/tests/unit/test_lecturer_service.py b/tests/unit/test_lecturer_service.py new file mode 100644 index 0000000..cadf4f3 --- /dev/null +++ b/tests/unit/test_lecturer_service.py @@ -0,0 +1,253 @@ +"""Tests for LecturerService. + +Uses an in-memory SQLite database + real models. Deployment ownership +is embedded in ``deployment_parameters`` JSON — the service uses the +SQLite fallback path (per-row Python parse), so these tests exercise it +end-to-end. +""" +import json + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from src.core.database import Base +from src.core.exceptions import BadRequestException, NotFoundException +from src.models.course import Course +from src.models.deployment import Deployment, DeploymentStatus +from src.models.openstack_project import OpenstackProject +from src.models.template import Template, TemplateVisibility +from src.models.template_version import TemplateVersion +from src.models.user import User +from src.services.lecturer_service import LecturerService + + +@pytest.fixture +def db_session(): + """Fresh in-memory SQLite with the full schema.""" + # Register every model that Base metadata references. + import src.models.deployment_instance # noqa: F401 + import src.models.deployment_instance_access # noqa: F401 + import src.models.deployment_log # noqa: F401 + import src.models.template_version_file # noqa: F401 + import src.models.template_category # noqa: F401 + import src.models.template_category_assignment # noqa: F401 + import src.models.course_member # noqa: F401 + import src.models.course_group # noqa: F401 + import src.models.group_member # noqa: F401 + + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(engine) + + +def _lecturer(db, external_id, email, display_name): + u = User(external_id=external_id, email=email, display_name=display_name) + db.add(u) + db.flush() + return u + + +def _template(db, owner, name="t"): + t = Template( + name=name, + owner_id=owner.id, + repo_url="https://example.com/repo", + visibility=TemplateVisibility.PRIVATE, + ) + db.add(t) + db.flush() + return t + + +def _version(db, template, version="1.0.0"): + v = TemplateVersion( + template_id=template.id, version=version, git_commit_sha="abc" + ) + db.add(v) + db.flush() + return v + + +def _osp(db, owner, name="osp"): + op = OpenstackProject( + owner_user_id=owner.id, + openstack_project_id="ks-1", + openstack_project_name=name, + auth_url="https://example.com", + username="u", + password="p", + region_name="r", + ) + db.add(op) + db.flush() + return op + + +def _course(db, name="C"): + c = Course(name=name, keycloak_course_id="kc-course-1") + db.add(c) + db.flush() + return c + + +def _deployment_for(db, lecturer, template_version, osp, course, name="d"): + """Wire up a Deployment whose deployment_parameters.teacher.id matches + the lecturer's external_id — that's how the service maps ownership.""" + d = Deployment( + name=name, + template_version_id=template_version.id, + course_id=course.id, + openstack_project_id=osp.id, + status=DeploymentStatus.RUNNING, + deployment_parameters=json.dumps({ + "teacher": {"id": lecturer.external_id, "email": lecturer.email}, + }), + ) + db.add(d) + db.flush() + return d + + +# --------------------------------------------------------------------------- +# list_lecturers +# --------------------------------------------------------------------------- + +def test_list_lecturers_returns_owners_only_excludes_pure_students(db_session): + """A user without templates AND without OSPs must not appear.""" + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "Lecturer 1") + _template(db_session, lecturer) + + # A student-like user with nothing owned + _lecturer(db_session, "kc-2", "s@x.de", "Just Student") + + svc = LecturerService(db_session) + rows, total = svc.list_lecturers() + + assert total == 1 + assert rows[0]["external_id"] == "kc-1" + assert rows[0]["template_count"] == 1 + + +def test_list_lecturers_counts_template_and_deployment(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L1") + template = _template(db_session, lecturer) + version = _version(db_session, template) + osp = _osp(db_session, lecturer) + course = _course(db_session) + _deployment_for(db_session, lecturer, version, osp, course, name="d-1") + _deployment_for(db_session, lecturer, version, osp, course, name="d-2") + + svc = LecturerService(db_session) + rows, _ = svc.list_lecturers() + + only = rows[0] + assert only["template_count"] == 1 + assert only["deployment_count"] == 2 + assert only["openstack_project_count"] == 1 + + +def test_list_lecturers_search_filters_case_insensitively(db_session): + a = _lecturer(db_session, "kc-a", "alice@x.de", "Alice Prof") + _template(db_session, a) + b = _lecturer(db_session, "kc-b", "bob@x.de", "Bob Prof") + _template(db_session, b) + + svc = LecturerService(db_session) + rows, total = svc.list_lecturers(search="alice") + + assert total == 1 + assert rows[0]["external_id"] == "kc-a" + + +def test_list_lecturers_pagination(db_session): + for i in range(5): + u = _lecturer(db_session, f"kc-{i}", f"u{i}@x.de", f"User {i}") + _template(db_session, u, name=f"t-{i}") + + svc = LecturerService(db_session) + _, total = svc.list_lecturers(skip=0, limit=2) + rows_page2, _ = svc.list_lecturers(skip=2, limit=2) + rows_page3, _ = svc.list_lecturers(skip=4, limit=2) + + assert total == 5 + assert len(rows_page2) == 2 + assert len(rows_page3) == 1 + + +# --------------------------------------------------------------------------- +# get_lecturer +# --------------------------------------------------------------------------- + +def test_get_lecturer_returns_full_detail(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L") + template = _template(db_session, lecturer, name="mytpl") + _version(db_session, template) + _version(db_session, template, version="1.1.0") + osp = _osp(db_session, lecturer, name="myosp") + course = _course(db_session) + _deployment_for(db_session, lecturer, template.versions[0], osp, course, name="d1") + + svc = LecturerService(db_session) + detail = svc.get_lecturer(lecturer.id) + + assert detail["template_count"] == 1 + assert detail["deployment_count"] == 1 + assert detail["openstack_project_count"] == 1 + assert detail["templates"][0]["name"] == "mytpl" + assert detail["templates"][0]["version_count"] == 2 + assert detail["deployments"][0]["name"] == "d1" + assert detail["openstack_projects"][0]["openstack_project_name"] == "myosp" + + +def test_get_lecturer_404_for_non_lecturer(db_session): + """A user with no owned resources isn't reachable through /lecturers/.""" + u = _lecturer(db_session, "kc-1", "s@x.de", "Just Student") + svc = LecturerService(db_session) + with pytest.raises(NotFoundException): + svc.get_lecturer(u.id) + + +def test_get_lecturer_404_for_unknown_id(db_session): + svc = LecturerService(db_session) + with pytest.raises(NotFoundException): + svc.get_lecturer("00000000-0000-0000-0000-000000000000") + + +# --------------------------------------------------------------------------- +# preflight_delete +# --------------------------------------------------------------------------- + +def test_preflight_delete_returns_counts(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L") + template = _template(db_session, lecturer) + version = _version(db_session, template) + osp = _osp(db_session, lecturer) + course = _course(db_session) + _deployment_for(db_session, lecturer, version, osp, course) + + svc = LecturerService(db_session) + summary = svc.preflight_delete(user_id=lecturer.id, requesting_user_id="admin-1") + + assert summary["deployment_count"] == 1 + assert summary["template_count"] == 1 + + +def test_preflight_delete_rejects_self_delete(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L") + _template(db_session, lecturer) + + svc = LecturerService(db_session) + with pytest.raises(BadRequestException): + svc.preflight_delete(user_id=lecturer.id, requesting_user_id=lecturer.id) diff --git a/tests/unit/test_orphan_stack_safeguards.py b/tests/unit/test_orphan_stack_safeguards.py new file mode 100644 index 0000000..36d0439 --- /dev/null +++ b/tests/unit/test_orphan_stack_safeguards.py @@ -0,0 +1,303 @@ +"""Tests for the orphan-stack-id safeguards. + +Two related guarantees, tested independently: + +1. ``_provision_one_stack_assignment`` invokes the ``on_stack_created`` + callback IMMEDIATELY after ``heat_service.create_stack`` returns — + before the long-running Ansible phase. Without that callback, a + worker crash mid-Ansible would leave the Heat stack alive in + OpenStack with no entry in ``deployment.openstack_stack_id`` for + ``delete_deployment`` to find. + +2. ``_collect_stack_ids_for_cleanup`` walks BOTH ``deployment.openstack_stack_id`` + AND ``DeploymentInstance.openstack_server_id`` and returns the union + (deduplicated, insertion order preserved). This is the defensive + fallback for any orphan that slipped past safeguard #1 — a redeploy + from a previous deploy build that didn't have it. + +These behaviors are policy, not implementation detail — both have to +hold for the orphan-stack guarantee to be real. +""" +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from src.tasks import deploy_tasks + + +# --------------------------------------------------------------------------- +# on_stack_created — fires immediately after create_stack +# --------------------------------------------------------------------------- + + +def _make_template_context(): + """Minimal context the helper needs. ``split_parameters`` returns + empty Heat + Ansible dicts so we don't have to thread real params.""" + ctx = MagicMock() + ctx.split_parameters.return_value = ({}, {}) + ctx.heat_template = "heat: {}" + ctx.files_dict = {} + ctx.credentials_spec = {"per_group": [], "teacher": []} + ctx.playbooks = [] # disables the Ansible branch + ctx.scripts = {} + ctx.template_files = {} + return ctx + + +def _make_deployment_row(): + return SimpleNamespace( + id="dep-1", + name="dep", + course_id="course-1", + template_version_id="tv-1", + ) + + +def _make_stack_assignment_data(): + return { + "stack_index": 1, + "groups": [{ + "group_name": "G1", "group_index": 1, + "students": [], "course_group_id": "grp-1", + }], + } + + +def _teacher_info(): + return { + "id": "kc-teacher", "username": "prof", + "email": "p@x.de", "first_name": "Prof", "last_name": "X", + } + + +def test_on_stack_created_fires_before_credential_persistence(): + """The callback must run AS SOON AS Heat returns a stack id — before + the credential-persistence call that creates the DeploymentInstance + row, and before Ansible. We assert the order via a shared list.""" + events: list[str] = [] + + heat = MagicMock() + def _create_stack(**_kwargs): + events.append("heat.create_stack") + return {"stack_id": "stack-NEW", "floating_ip": "1.2.3.4", "outputs": {}} + heat.create_stack.side_effect = _create_stack + + fake_instance = MagicMock(id="inst-NEW") + persist_mock = MagicMock(return_value=fake_instance) + def _persist(**_kwargs): + events.append("credentials.persist") + return fake_instance + persist_mock.side_effect = _persist + + def _on_stack_created(sid: str) -> None: + events.append(f"callback({sid})") + + db = MagicMock() + + with ( + patch.object( + deploy_tasks.DeploymentCredentialService, + "persist_credentials_for_stack", + persist_mock, + ), + patch.object( + deploy_tasks.CredentialGeneratorService, + "generate", + return_value={"deployment_groups": [], "teacher": {}}, + ), + patch.object(deploy_tasks, "get_settings", return_value=SimpleNamespace( + ansible_ssh_private_key="", ansible_ssh_key_name="kp", + )), + ): + stack_id, instance = deploy_tasks._provision_one_stack_assignment( + db=db, + deployment=_make_deployment_row(), + stack_assignment_data=_make_stack_assignment_data(), + template_context=_make_template_context(), + heat_service=heat, + ansible_service_factory=lambda **kw: MagicMock(), + log_service=MagicMock(), + all_parameters={}, + teacher_info=_teacher_info(), + stack_name="dep-s1-abcd", + stack_index=1, + total_stacks=1, + on_stack_created=_on_stack_created, + ) + + assert stack_id == "stack-NEW" + assert instance is fake_instance + # The callback ran AFTER Heat but BEFORE credentials.persist (which + # is itself before Ansible — Ansible is gated out by empty playbooks). + # If a future refactor moves credential persistence above the callback + # this assertion catches it: the callback must close the orphan + # window first. + assert events.index("callback(stack-NEW)") < events.index("credentials.persist") + assert events.index("heat.create_stack") < events.index("callback(stack-NEW)") + + +def test_on_stack_created_failure_is_swallowed_not_fatal(): + """A failing callback must not abort the provisioning — Ansible / + credential persistence are the actual contract, the callback is a + best-effort safety net. The helper logs and continues.""" + heat = MagicMock() + heat.create_stack.return_value = {"stack_id": "stack-NEW", "floating_ip": "1.2.3.4", "outputs": {}} + + fake_instance = MagicMock(id="inst-NEW") + with ( + patch.object( + deploy_tasks.DeploymentCredentialService, + "persist_credentials_for_stack", + return_value=fake_instance, + ), + patch.object( + deploy_tasks.CredentialGeneratorService, + "generate", + return_value={"deployment_groups": [], "teacher": {}}, + ), + patch.object(deploy_tasks, "get_settings", return_value=SimpleNamespace( + ansible_ssh_private_key="", ansible_ssh_key_name="kp", + )), + ): + stack_id, instance = deploy_tasks._provision_one_stack_assignment( + db=MagicMock(), + deployment=_make_deployment_row(), + stack_assignment_data=_make_stack_assignment_data(), + template_context=_make_template_context(), + heat_service=heat, + ansible_service_factory=lambda **kw: MagicMock(), + log_service=MagicMock(), + all_parameters={}, + teacher_info=_teacher_info(), + stack_name="dep-s1-abcd", + stack_index=1, + total_stacks=1, + on_stack_created=lambda _sid: (_ for _ in ()).throw(RuntimeError("db down")), + ) + + # Provisioning still succeeded. + assert stack_id == "stack-NEW" + assert instance is fake_instance + + +def test_on_stack_created_omitted_is_allowed(): + """The callback is optional — when None, the helper just doesn't + invoke it. Used by code paths that don't need eager persistence + (or by tests that don't care).""" + heat = MagicMock() + heat.create_stack.return_value = {"stack_id": "stack-X", "floating_ip": "", "outputs": {}} + + fake_instance = MagicMock(id="inst-X") + with ( + patch.object( + deploy_tasks.DeploymentCredentialService, + "persist_credentials_for_stack", + return_value=fake_instance, + ), + patch.object( + deploy_tasks.CredentialGeneratorService, + "generate", + return_value={"deployment_groups": [], "teacher": {}}, + ), + patch.object(deploy_tasks, "get_settings", return_value=SimpleNamespace( + ansible_ssh_private_key="", ansible_ssh_key_name="kp", + )), + ): + stack_id, _ = deploy_tasks._provision_one_stack_assignment( + db=MagicMock(), + deployment=_make_deployment_row(), + stack_assignment_data=_make_stack_assignment_data(), + template_context=_make_template_context(), + heat_service=heat, + ansible_service_factory=lambda **kw: MagicMock(), + log_service=MagicMock(), + all_parameters={}, + teacher_info=_teacher_info(), + stack_name="dep-s1-abcd", + stack_index=1, + total_stacks=1, + # on_stack_created omitted on purpose + ) + assert stack_id == "stack-X" + + +# --------------------------------------------------------------------------- +# _collect_stack_ids_for_cleanup — union with DeploymentInstance fallback +# --------------------------------------------------------------------------- + + +def _make_db_with_instances(*instances): + """Build a MagicMock db whose ``query(DeploymentInstance).filter(...).all()`` + returns the given instance stubs.""" + db = MagicMock() + chain = MagicMock() + chain.filter.return_value.all.return_value = list(instances) + db.query.return_value = chain + return db + + +def test_collect_stack_ids_unions_deployment_and_instance_sources(): + """Stack ids from the deployment row come first; instance rows + contribute any additional ids that aren't already in the array. + The exact dedup-while-preserving-order is what makes the fallback + safe to combine with the primary source.""" + dep = SimpleNamespace( + id="dep-1", + openstack_stack_id=json.dumps(["A", "B"]), + ) + instances = [ + SimpleNamespace(id="inst-1", openstack_server_id="B"), # dup + SimpleNamespace(id="inst-2", openstack_server_id="C"), # new! + SimpleNamespace(id="inst-3", openstack_server_id=None), # ignored + ] + db = _make_db_with_instances(*instances) + + out = deploy_tasks._collect_stack_ids_for_cleanup(db, dep) + assert out == ["A", "B", "C"] + + +def test_collect_stack_ids_handles_null_openstack_stack_id(): + """Deployment with no array but with one instance row whose + openstack_server_id IS set — that single id must still survive + (this is exactly the orphan scenario the fallback exists for).""" + dep = SimpleNamespace(id="dep-1", openstack_stack_id=None) + instances = [SimpleNamespace(id="inst-1", openstack_server_id="orphan-X")] + db = _make_db_with_instances(*instances) + + assert deploy_tasks._collect_stack_ids_for_cleanup(db, dep) == ["orphan-X"] + + +def test_collect_stack_ids_handles_empty_json_array(): + """The dangerous-but-real shape: redeploy left an empty list after + deleting the OLD stack but before persisting the NEW one. Instance + row still has the new id.""" + dep = SimpleNamespace(id="dep-1", openstack_stack_id="[]") + instances = [SimpleNamespace(id="inst-1", openstack_server_id="new-stack-id")] + db = _make_db_with_instances(*instances) + + assert deploy_tasks._collect_stack_ids_for_cleanup(db, dep) == ["new-stack-id"] + + +def test_collect_stack_ids_handles_legacy_single_string(): + """Older rows persisted a bare stack id, not a JSON array. Treat as + a single id, don't error out.""" + dep = SimpleNamespace(id="dep-1", openstack_stack_id="legacy-id") + db = _make_db_with_instances() + + assert deploy_tasks._collect_stack_ids_for_cleanup(db, dep) == ["legacy-id"] + + +def test_collect_stack_ids_returns_empty_when_no_sources(): + dep = SimpleNamespace(id="dep-1", openstack_stack_id=None) + db = _make_db_with_instances() + assert deploy_tasks._collect_stack_ids_for_cleanup(db, dep) == [] + + +def test_collect_stack_ids_swallows_instance_query_errors(): + """A broken instances relationship must not crash the cleanup — the + primary source (deployment.openstack_stack_id) still gets returned.""" + dep = SimpleNamespace(id="dep-1", openstack_stack_id=json.dumps(["A"])) + db = MagicMock() + db.query.side_effect = RuntimeError("db down") + + assert deploy_tasks._collect_stack_ids_for_cleanup(db, dep) == ["A"] diff --git a/tests/unit/test_redeploy_tasks.py b/tests/unit/test_redeploy_tasks.py new file mode 100644 index 0000000..4bbd956 --- /dev/null +++ b/tests/unit/test_redeploy_tasks.py @@ -0,0 +1,541 @@ +"""Tests for the redeploy_instance / redeploy_deployment Celery tasks +and the parameter-override helpers in src.tasks.deploy_tasks. + +The redeploy paths bundle four pieces of policy worth covering with +unit tests, none of which need a live OpenStack or DB: + +1. ``_merge_parameter_layers`` — order is base → deployment → instance, + with later layers winning. Empty / None layers are no-ops. + +2. ``_reconstruct_stack_assignment_for_instance`` — recovers the + ``StackAssignment`` payload that produced an instance from the + ``-s-`` suffix in ``vm_name``. Mis-named instances + return None (caller treats as fatal). + +3. ``redeploy_instance`` — the orchestration: instance flips to + REDEPLOYING, old stack is deleted, ``_provision_one_stack_assignment`` + is called with the merged params, the deployment's stack-id JSON + array is rewritten on the way through. + +4. ``redeploy_deployment`` — iterates instances sequentially and folds + per-instance overrides into the deployment-wide map before calling + ``redeploy_instance``. +""" +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from src.models.deployment_instance import DeploymentInstanceStatus +from src.tasks import deploy_tasks + + +_DEPLOYMENT_ID = "00000000-0000-0000-0000-000000000abc" +_INSTANCE_ID = "11111111-1111-1111-1111-111111111111" + + +# --------------------------------------------------------------------------- +# _merge_parameter_layers +# --------------------------------------------------------------------------- + + +def test_merge_layers_later_wins(): + """Deployment override beats base, instance override beats deployment.""" + base = {"a": 1, "b": 2, "c": 3} + dep = {"b": 20, "d": 40} + inst = {"c": 300} + + result = deploy_tasks._merge_parameter_layers( + base=base, deployment_overrides=dep, instance_overrides=inst + ) + + assert result == {"a": 1, "b": 20, "c": 300, "d": 40} + # Inputs are not mutated. + assert base == {"a": 1, "b": 2, "c": 3} + assert dep == {"b": 20, "d": 40} + assert inst == {"c": 300} + + +def test_merge_layers_none_skipped(): + """None / empty layers are no-ops; base survives untouched.""" + base = {"x": 1} + assert deploy_tasks._merge_parameter_layers( + base=base, deployment_overrides=None, instance_overrides=None + ) == {"x": 1} + assert deploy_tasks._merge_parameter_layers( + base=base, deployment_overrides={}, instance_overrides={} + ) == {"x": 1} + + +def test_merge_layers_only_deployment(): + """Deployment-only override paths (used by the per-instance endpoint).""" + base = {"x": 1} + assert deploy_tasks._merge_parameter_layers( + base=base, deployment_overrides={"y": 2}, instance_overrides=None + ) == {"x": 1, "y": 2} + + +# --------------------------------------------------------------------------- +# _reconstruct_stack_assignment_for_instance +# --------------------------------------------------------------------------- + + +def test_reconstruct_picks_correct_assignment_by_suffix(): + """Heat names follow ``-s-`` — we recover ````.""" + raw = [{"stack_index": 1, "tag": "A"}, {"stack_index": 2, "tag": "B"}, {"stack_index": 3, "tag": "C"}] + inst = SimpleNamespace(vm_name="sql-kurs-s2-abcd") + + chosen = deploy_tasks._reconstruct_stack_assignment_for_instance(inst, raw) + assert chosen == {"stack_index": 2, "tag": "B"} + + +def test_reconstruct_handles_dashes_in_deployment_name(): + """Deployment names may contain dashes — the ``-s-`` suffix is + matched right-to-left so the dashes in the prefix don't confuse it.""" + raw = [{"stack_index": 1}, {"stack_index": 2}] + inst = SimpleNamespace(vm_name="sql-kurs-summer-2026-s2-1234") + + chosen = deploy_tasks._reconstruct_stack_assignment_for_instance(inst, raw) + assert chosen == {"stack_index": 2} + + +def test_reconstruct_returns_none_for_bad_name(): + """A wedged / legacy vm_name → None (caller fails the redeploy).""" + raw = [{"stack_index": 1}] + assert deploy_tasks._reconstruct_stack_assignment_for_instance( + SimpleNamespace(vm_name=None), raw + ) is None + assert deploy_tasks._reconstruct_stack_assignment_for_instance( + SimpleNamespace(vm_name="not-a-valid-name"), raw + ) is None + + +def test_reconstruct_returns_none_when_index_out_of_range(): + """A name that parses but points past the assignments list is unsafe.""" + raw = [{"stack_index": 1}] + inst = SimpleNamespace(vm_name="dep-s7-abcd") + assert deploy_tasks._reconstruct_stack_assignment_for_instance(inst, raw) is None + + +def test_stack_index_for_instance_fallback(): + """Fallback to 1 when no index suffix is parseable — keeps the redeploy + able to log even if the name was hand-edited.""" + assert deploy_tasks._stack_index_for_instance( + SimpleNamespace(vm_name="dep-s3-abcd"), [] + ) == 3 + assert deploy_tasks._stack_index_for_instance( + SimpleNamespace(vm_name="bogus"), [] + ) == 1 + + +# --------------------------------------------------------------------------- +# _build_stack_name +# --------------------------------------------------------------------------- + + +def test_build_stack_name_matches_deploy_format(): + """Redeploy must produce the same Heat-safe slug as the initial deploy + so a redeployed VM still matches its stack tag pattern.""" + dep = SimpleNamespace(id="abcd1234-xxxx", name="SQL Kurs Sommer_2026") + assert deploy_tasks._build_stack_name(dep, 3) == "sql-kurs-sommer-2026-s3-abcd" + + +def test_build_stack_name_truncates_to_64_chars(): + dep = SimpleNamespace(id="abcd1234", name="X" * 100) + name = deploy_tasks._build_stack_name(dep, 1) + assert len(name) <= 64 + + +# --------------------------------------------------------------------------- +# _snapshot_instance_credentials (preserve_credentials path) +# +# The snapshot's contract changed: instead of round-tripping access rows +# back into ``generated``-shaped per-group/teacher buckets (which was +# lossy — public_key, port, connection_url, and the original app.yaml +# credential type were all dropped), it now captures every field of each +# ``DeploymentInstanceAccess`` row verbatim under ``_preserved_access``, +# ready for ``_rebind_preserved_access_rows`` to re-attach to the new +# instance after persistence. ``deployment_groups`` and ``teacher`` come +# back empty by design — Ansible still receives fresh creds, the DB rows +# are rebound separately. +# --------------------------------------------------------------------------- + + +def test_snapshot_credentials_captures_all_access_row_fields_verbatim(): + """SSH + non-SSH rows survive the snapshot with port / connection_url + / ssh_private_key intact, ready for rebinding.""" + ssh = SimpleNamespace( + access_type=SimpleNamespace(value="ssh"), + username="g1", password="pw1", ssh_private_key="key1", + connection_url="ssh g1@1.2.3.4", port=22, + group_id="grp-1", is_active=True, expires_at=None, + ) + db_row = SimpleNamespace( + access_type=SimpleNamespace(value="database"), + username="dbu", password="dbpw", ssh_private_key=None, + connection_url="https://pgadmin.example/", port=80, + group_id="grp-1", is_active=True, expires_at=None, + ) + admin = SimpleNamespace( + access_type=SimpleNamespace(value="ssh"), + username="teacher", password="adminpw", ssh_private_key="adminkey", + connection_url="ssh teacher@1.2.3.4", port=22, + group_id=None, is_active=True, expires_at=None, + ) + instance = SimpleNamespace(access_methods=[ssh, db_row, admin]) + + snap = deploy_tasks._snapshot_instance_credentials(MagicMock(), instance) + + # The shim fields stay empty on purpose — preserved data lives under + # _preserved_access and is rebound from there. + assert snap["deployment_groups"] == [] + assert snap["teacher"] == {} + + rows = snap["_preserved_access"] + assert len(rows) == 3 + # Every column we care about for student logins is present verbatim. + assert rows[0]["username"] == "g1" + assert rows[0]["password"] == "pw1" + assert rows[0]["ssh_private_key"] == "key1" + assert rows[0]["port"] == 22 + assert rows[0]["connection_url"] == "ssh g1@1.2.3.4" + assert rows[1]["connection_url"] == "https://pgadmin.example/" + assert rows[1]["port"] == 80 + assert rows[2]["group_id"] is None # teacher row + + +# --------------------------------------------------------------------------- +# redeploy_instance — orchestration +# --------------------------------------------------------------------------- + + +def _make_instance(*, vm_name="dep-s1-abcd", access_methods=None): + """Build a stub ``DeploymentInstance`` row good enough for the redeploy + task. Status is a real enum so the ``.value`` lookup works the same way + the production code expects.""" + inst = MagicMock() + inst.id = _INSTANCE_ID + inst.vm_name = vm_name + inst.openstack_server_id = "old-stack-id" + inst.deployment_id = _DEPLOYMENT_ID + inst.access_methods = access_methods or [] + inst.status = DeploymentInstanceStatus.RUNNING + return inst + + +def _make_deployment(*, stack_ids=("old-stack-id",), parameters=None, stack_assignments=None): + params_payload = { + "parameters": parameters or {"include_notebooks": True}, + "stack_assignments": stack_assignments or [{"stack_index": 1, "groups": [{ + "group_name": "G1", "group_index": 1, "students": [], "course_group_id": "grp-1", + }]}], + "teacher": { + "id": "kc-teacher", "username": "prof", "email": "p@x.de", + "first_name": "Prof", "last_name": "X", + }, + } + return SimpleNamespace( + id=_DEPLOYMENT_ID, + name="dep", + course_id="course-1", + template_version_id="tv-1", + openstack_stack_id=json.dumps(list(stack_ids)), + deployment_parameters=json.dumps(params_payload), + openstack_project=SimpleNamespace(id="proj-1"), + ) + + +def _patch_redeploy_environment(*, deployment, instance, provision_return=None): + """Patch every collaborator of redeploy_instance with MagicMocks and + return them so the test can assert on call args. + + Returns a dict with all the patches' return values for convenience. + """ + session = MagicMock() + + # db.query(DeploymentInstance).filter(...).first() → instance + inst_filter = MagicMock() + inst_filter.first.return_value = instance + inst_query = MagicMock() + inst_query.filter.return_value = inst_filter + + # db.query(DeploymentInstanceAccess).filter(...).delete() + access_filter = MagicMock() + access_filter.delete.return_value = 0 + access_query = MagicMock() + access_query.filter.return_value = access_filter + + # Map .query(Model) to the right stub. + def _query(model): + from src.models.deployment_instance import DeploymentInstance as DI + from src.models.deployment_instance_access import DeploymentInstanceAccess as DIA + if model is DI: + return inst_query + if model is DIA: + return access_query + return MagicMock() + session.query.side_effect = _query + + repo = MagicMock() + repo.get_by_id.return_value = deployment + + log_service = MagicMock() + file_service = MagicMock() + + heat = MagicMock() + heat.delete_stack.return_value = True + + template_context = MagicMock() + template_context.split_parameters.return_value = ({}, {}) + + return { + "session": session, + "repo": repo, + "log_service": log_service, + "file_service": file_service, + "heat": heat, + "template_context": template_context, + "provision_return": provision_return, + } + + +def _run_redeploy_instance(env, *, deployment_overrides=None, preserve_credentials=False): + """Run the redeploy_instance task with a fully patched environment. + + Returns the task result dict. + """ + new_instance = MagicMock(id="new-instance-id") + provision_return = env.get("provision_return") or ("new-stack-id", new_instance) + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=env["session"]), + patch.object(deploy_tasks, "DeploymentRepository", return_value=env["repo"]), + patch.object(deploy_tasks, "DeploymentLogService", return_value=env["log_service"]), + patch.object(deploy_tasks, "TemplateVersionFileService", return_value=env["file_service"]), + patch.object(deploy_tasks, "HeatStackService", return_value=env["heat"]), + patch.object(deploy_tasks, "_load_template_context", return_value=env["template_context"]), + patch.object(deploy_tasks, "_provision_one_stack_assignment", return_value=provision_return) as provision_mock, + patch.object(deploy_tasks, "get_settings", return_value=SimpleNamespace( + ansible_ssh_private_key="", ansible_ssh_key_name="kp", + )), + ): + result = deploy_tasks.redeploy_instance.run( + _DEPLOYMENT_ID, + _INSTANCE_ID, + deployment_parameter_overrides=deployment_overrides, + preserve_credentials=preserve_credentials, + ) + return result, provision_mock + + +def test_redeploy_instance_happy_path_flips_status_and_calls_provision(): + """REDEPLOYING is set before the old stack is torn down, and the + provision helper is invoked with the recovered stack assignment.""" + deployment = _make_deployment(stack_ids=("old-stack-id", "sibling")) + instance = _make_instance(vm_name="dep-s1-abcd") + + env = _patch_redeploy_environment(deployment=deployment, instance=instance) + result, provision_mock = _run_redeploy_instance(env) + + assert result["status"] == "redeployed" + assert result["new_stack_id"] == "new-stack-id" + assert result["new_instance_id"] == "new-instance-id" + # Status was flipped to REDEPLOYING during the task. + assert instance.status == DeploymentInstanceStatus.REDEPLOYING + # Old Heat stack was deleted. + env["heat"].delete_stack.assert_called_once_with("old-stack-id") + # Provision helper got called with the merged params. + provision_mock.assert_called_once() + kwargs = provision_mock.call_args.kwargs + assert kwargs["stack_index"] == 1 + # No overrides → effective params equal the deployment's stored ones. + assert kwargs["all_parameters"] == {"include_notebooks": True} + # Single-instance redeploy → no preserved user_json. + assert kwargs["preserved_user_json"] is None + + +def test_redeploy_instance_overrides_are_merged_into_effective_params(): + """A deployment-level override wins over the stored parameter.""" + deployment = _make_deployment(parameters={"flag": False, "size": "small"}) + instance = _make_instance(vm_name="dep-s1-abcd") + + env = _patch_redeploy_environment(deployment=deployment, instance=instance) + result, provision_mock = _run_redeploy_instance( + env, deployment_overrides={"flag": True, "extra": 42} + ) + + assert result["status"] == "redeployed" + kwargs = provision_mock.call_args.kwargs + assert kwargs["all_parameters"] == {"flag": True, "size": "small", "extra": 42} + + +def test_redeploy_instance_preserve_credentials_carries_snapshot_in(): + """When preserve_credentials=True the provision helper is handed the + snapshot dict containing every access-row field. The rebind step + (called from inside _provision_one_stack_assignment, which is mocked + here) is what actually re-attaches them — we only verify the snapshot + travels far enough to reach it.""" + ssh = SimpleNamespace( + access_type=SimpleNamespace(value="ssh"), + username="g1", password="pw1", ssh_private_key="k1", + connection_url="ssh g1@1.2.3.4", port=22, + group_id="grp-1", is_active=True, expires_at=None, + ) + instance = _make_instance(vm_name="dep-s1-abcd", access_methods=[ssh]) + deployment = _make_deployment() + + env = _patch_redeploy_environment(deployment=deployment, instance=instance) + _, provision_mock = _run_redeploy_instance(env, preserve_credentials=True) + + preserved = provision_mock.call_args.kwargs["preserved_user_json"] + assert preserved is not None + rows = preserved["_preserved_access"] + assert len(rows) == 1 + assert rows[0]["username"] == "g1" + assert rows[0]["ssh_private_key"] == "k1" + assert rows[0]["port"] == 22 + assert rows[0]["group_id"] == "grp-1" + + +def test_redeploy_instance_rewrites_stack_id_list_on_deployment(): + """The deployment's openstack_stack_id JSON loses the old id and + gains the new one — so a future delete walks the right list.""" + deployment = _make_deployment(stack_ids=("old-stack-id", "sibling-stack")) + instance = _make_instance(vm_name="dep-s1-abcd") + + env = _patch_redeploy_environment(deployment=deployment, instance=instance) + result, _ = _run_redeploy_instance(env) + + assert result["status"] == "redeployed" + final_ids = json.loads(deployment.openstack_stack_id) + assert "old-stack-id" not in final_ids + assert "sibling-stack" in final_ids + assert "new-stack-id" in final_ids + + +def test_redeploy_instance_fails_when_stack_assignment_missing(): + """An instance whose name can't be reconstructed → fatal redeploy. + Status flips to FAILED, no provision helper call, no Heat delete.""" + deployment = _make_deployment(stack_assignments=[]) + instance = _make_instance(vm_name="legacy-no-suffix") + + env = _patch_redeploy_environment(deployment=deployment, instance=instance) + result, provision_mock = _run_redeploy_instance(env) + + assert result["status"] == "failed" + assert "stack_assignment" in result["error"] + provision_mock.assert_not_called() + env["heat"].delete_stack.assert_not_called() + assert instance.status == DeploymentInstanceStatus.FAILED + + +def test_redeploy_instance_heat_delete_failure_marks_failed(): + """Heat refusing to delete the old stack must NOT remove the row; + instance flips to FAILED so the user can retry.""" + deployment = _make_deployment() + instance = _make_instance(vm_name="dep-s1-abcd") + + env = _patch_redeploy_environment(deployment=deployment, instance=instance) + env["heat"].delete_stack.side_effect = RuntimeError("openstack 500") + + result, provision_mock = _run_redeploy_instance(env) + + assert result["status"] == "failed" + provision_mock.assert_not_called() + + +# --------------------------------------------------------------------------- +# redeploy_deployment — fan-out over instances +# --------------------------------------------------------------------------- + + +def test_redeploy_deployment_iterates_instances_with_merged_per_vm_overrides(): + """Per-instance overrides are folded into the deployment-wide map on + the way through, so each redeploy_instance call sees its own merged + override dict.""" + deployment = _make_deployment() + inst_a = SimpleNamespace(id="inst-A", created_at=1) + inst_b = SimpleNamespace(id="inst-B", created_at=2) + + session = MagicMock() + # db.query(DeploymentInstance).filter(...).order_by(...).all() → [inst_a, inst_b] + instances_chain = MagicMock() + instances_chain.filter.return_value.order_by.return_value.all.return_value = [inst_a, inst_b] + session.query.return_value = instances_chain + + repo = MagicMock() + repo.get_by_id.return_value = deployment + + redeploy_calls: list[dict] = [] + + def _fake_redeploy_instance_run(**kwargs): + redeploy_calls.append(kwargs) + return {"status": "redeployed", "instance_id": kwargs.get("instance_id")} + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=session), + patch.object(deploy_tasks, "DeploymentRepository", return_value=repo), + patch.object(deploy_tasks.redeploy_instance, "run", side_effect=_fake_redeploy_instance_run), + ): + result = deploy_tasks.redeploy_deployment.run( + _DEPLOYMENT_ID, + deployment_parameter_overrides={"flag": True}, + instance_parameter_overrides={"inst-B": {"flag": False, "extra": 1}}, + preserve_credentials=True, + ) + + assert result["status"] == "redeployed" + assert len(redeploy_calls) == 2 + # inst_a inherits only the deployment-wide override. + assert redeploy_calls[0]["instance_id"] == "inst-A" + assert redeploy_calls[0]["deployment_parameter_overrides"] == {"flag": True} + assert redeploy_calls[0]["preserve_credentials"] is True + # inst_b gets the merged override map (instance wins over deployment). + assert redeploy_calls[1]["instance_id"] == "inst-B" + assert redeploy_calls[1]["deployment_parameter_overrides"] == {"flag": False, "extra": 1} + + +def test_redeploy_deployment_reports_partial_failure_when_one_instance_fails(): + """One failed instance → overall status flips to partial_failure but + the loop still hits every instance.""" + deployment = _make_deployment() + inst_a = SimpleNamespace(id="inst-A", created_at=1) + inst_b = SimpleNamespace(id="inst-B", created_at=2) + + session = MagicMock() + session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [inst_a, inst_b] + + repo = MagicMock() + repo.get_by_id.return_value = deployment + + def _fake(*, deployment_id, instance_id, **kwargs): + if instance_id == "inst-A": + return {"status": "failed", "instance_id": "inst-A"} + return {"status": "redeployed", "instance_id": "inst-B"} + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=session), + patch.object(deploy_tasks, "DeploymentRepository", return_value=repo), + patch.object(deploy_tasks.redeploy_instance, "run", side_effect=_fake), + ): + result = deploy_tasks.redeploy_deployment.run(_DEPLOYMENT_ID) + + assert result["status"] == "partial_failure" + assert len(result["instance_results"]) == 2 + + +def test_redeploy_deployment_returns_failed_when_no_instances(): + """A deployment with zero instance rows can't be redeployed.""" + deployment = _make_deployment() + session = MagicMock() + session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] + repo = MagicMock() + repo.get_by_id.return_value = deployment + + with ( + patch.object(deploy_tasks, "SessionLocal", return_value=session), + patch.object(deploy_tasks, "DeploymentRepository", return_value=repo), + ): + result = deploy_tasks.redeploy_deployment.run(_DEPLOYMENT_ID) + + assert result["status"] == "failed" + assert "no instances" in result["error"].lower() diff --git a/tests/unit/test_secret_encryption.py b/tests/unit/test_secret_encryption.py index c5bce81..b50690a 100644 --- a/tests/unit/test_secret_encryption.py +++ b/tests/unit/test_secret_encryption.py @@ -1,5 +1,11 @@ """Tests for SecretEncryptionService and EncryptedString TypeDecorator.""" -from src.services.secret_encryption_service import get_encryption_service, EncryptedString +import pytest + +from src.services.secret_encryption_service import ( + EncryptedString, + SecretEncryptionError, + get_encryption_service, +) from cryptography.fernet import Fernet from sqlalchemy import create_engine, String, text from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column @@ -21,32 +27,32 @@ def test_encrypted_string_type_decorator(): # Create test model class Base(DeclarativeBase): pass - + class TestModel(Base): __tablename__ = "test_secrets" id: Mapped[int] = mapped_column(primary_key=True) secret_value: Mapped[str] = mapped_column(EncryptedString(255)) plain_value: Mapped[str] = mapped_column(String(255)) - + # Setup encryption key = Fernet.generate_key().decode() get_encryption_service(key=key) - + # Create in-memory database engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) - + # Test write and read with Session(engine) as session: obj = TestModel(secret_value="my-secret-password", plain_value="not-secret") session.add(obj) session.commit() session.refresh(obj) - + # Verify we can read decrypted value assert obj.secret_value == "my-secret-password" assert obj.plain_value == "not-secret" - + # Verify encrypted value is actually encrypted in database with Session(engine) as session: result = session.execute(text("SELECT secret_value FROM test_secrets WHERE id = 1")) @@ -55,3 +61,43 @@ class TestModel(Base): assert encrypted_value != "my-secret-password" # Should start with gAAAAA (Fernet token prefix) assert encrypted_value.startswith("gAAAAA") + + +def test_encrypted_string_raises_when_encryption_misconfigured(monkeypatch): + """Writing/reading an EncryptedString without a configured key must fail loudly. + + Silently storing plaintext or returning ciphertext masquerading as + plaintext would be a security bug — the type decorator now propagates + ``SecretEncryptionError`` in both directions. + """ + import src.services.secret_encryption_service as svc_mod + + # Force the singleton to be re-created without a valid key + monkeypatch.setattr(svc_mod, "_encryption_service_instance", None) + + # Pretend ENCRYPTION_KEY is unset for the duration of this test + from src.core.config import get_settings + settings = get_settings() + monkeypatch.setattr(settings, "encryption_key", None) + + decorator = EncryptedString() + + with pytest.raises(SecretEncryptionError): + decorator.process_bind_param("plaintext-secret", dialect=None) + + with pytest.raises(SecretEncryptionError): + decorator.process_result_value("gAAAAAfake-token", dialect=None) + + +def test_encrypted_string_raises_on_invalid_token(monkeypatch): + """A garbage value in the column raises rather than returning it as plaintext.""" + # Ensure a valid key is configured first + import src.services.secret_encryption_service as svc_mod + monkeypatch.setattr(svc_mod, "_encryption_service_instance", None) + get_encryption_service(key=Fernet.generate_key().decode()) + + decorator = EncryptedString() + + with pytest.raises(SecretEncryptionError): + # Not a valid Fernet token — must NOT be silently returned + decorator.process_result_value("not-a-real-token", dialect=None) diff --git a/tests/unit/test_ssh_keypair_generator_service.py b/tests/unit/test_ssh_keypair_generator_service.py new file mode 100644 index 0000000..dac28d5 --- /dev/null +++ b/tests/unit/test_ssh_keypair_generator_service.py @@ -0,0 +1,66 @@ +"""Tests for the Ed25519 SSH keypair generator.""" +import re + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import ( + load_ssh_private_key, + load_ssh_public_key, +) + +from src.services.ssh_keypair_generator_service import generate_ed25519_keypair + + +def test_generate_returns_private_and_public_keys(): + """Generator returns both keys with the expected dict shape.""" + kp = generate_ed25519_keypair() + assert set(kp.keys()) == {"private_key", "public_key"} + assert isinstance(kp["private_key"], str) + assert isinstance(kp["public_key"], str) + + +def test_private_key_is_openssh_pem(): + """Private key must be in OpenSSH PEM format (the format ssh-keygen produces).""" + kp = generate_ed25519_keypair() + pem = kp["private_key"] + assert pem.startswith("-----BEGIN OPENSSH PRIVATE KEY-----") + assert pem.rstrip().endswith("-----END OPENSSH PRIVATE KEY-----") + # Must be parseable as an Ed25519 private key + parsed = load_ssh_private_key(pem.encode(), password=None) + assert isinstance(parsed, Ed25519PrivateKey) + + +def test_public_key_is_single_line_openssh(): + """Public key must be in the single-line OpenSSH format used in authorized_keys.""" + kp = generate_ed25519_keypair() + pub = kp["public_key"] + # Single-line format: "ssh-ed25519 " + assert "\n" not in pub.strip() + assert pub.startswith("ssh-ed25519 ") + assert re.match(r"^ssh-ed25519 [A-Za-z0-9+/=]+", pub) + # Must be parseable as an Ed25519 public key + parsed = load_ssh_public_key(pub.encode()) + assert isinstance(parsed, Ed25519PublicKey) + + +def test_each_call_produces_a_fresh_keypair(): + """Successive calls must not return the same key — entropy check.""" + kp1 = generate_ed25519_keypair() + kp2 = generate_ed25519_keypair() + assert kp1["private_key"] != kp2["private_key"] + assert kp1["public_key"] != kp2["public_key"] + + +def test_public_key_matches_private_key(): + """The public key in the dict must be the actual public key of the private key.""" + kp = generate_ed25519_keypair() + + priv = load_ssh_private_key(kp["private_key"].encode(), password=None) + derived_pub = priv.public_key() + parsed_pub = load_ssh_public_key(kp["public_key"].encode()) + + # Compare raw public bytes — equality on the key objects themselves doesn't always hold. + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + assert ( + derived_pub.public_bytes(Encoding.Raw, PublicFormat.Raw) + == parsed_pub.public_bytes(Encoding.Raw, PublicFormat.Raw) + ) diff --git a/tests/unit/test_student_membership_gc.py b/tests/unit/test_student_membership_gc.py new file mode 100644 index 0000000..a81de58 --- /dev/null +++ b/tests/unit/test_student_membership_gc.py @@ -0,0 +1,214 @@ +"""Tests for _gc_orphan_student_memberships in src.tasks.deploy_tasks. + +The GC runs at the tail end of delete_deployment, AFTER the deployment +row, its instances, and its access rows are gone. It walks every user +id that was tied to the deleted deployment and drops: + + - GroupMember rows whose course_group no longer has any live + DeploymentInstanceAccess + - CourseMember rows whose last GroupMember just vanished + - User rows whose last CourseMember just vanished, provided the user + owns no templates / openstack projects (= guaranteed pure student) + +These tests exercise the helper against an in-memory SQLite DB with the +real schema, so the query joins are vetted as part of the test. +""" +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from src.core.database import Base +from src.models.course import Course +from src.models.course_group import CourseGroup +from src.models.course_member import CourseMember +from src.models.deployment import Deployment, DeploymentStatus +from src.models.deployment_instance import DeploymentInstance +from src.models.deployment_instance_access import ( + AccessType, + DeploymentInstanceAccess, +) +from src.models.group_member import GroupMember +from src.models.openstack_project import OpenstackProject +from src.models.template import Template, TemplateVisibility +from src.models.template_version import TemplateVersion +from src.models.user import User +from src.tasks.deploy_tasks import _gc_orphan_student_memberships + + +@pytest.fixture +def db_session(): + """Fresh in-memory SQLite per test with all relevant tables present.""" + # Make sure every model the GC touches is registered on Base.metadata + # before create_all runs. + import src.models.deployment_log # noqa: F401 + import src.models.template_version_file # noqa: F401 + import src.models.template_category # noqa: F401 + import src.models.template_category_assignment # noqa: F401 + + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(engine) + + +def _scaffold(db, group_name="Group A"): + """Build the smallest set of rows the GC needs: user → course_member → + group_member → course_group. Returns the row references.""" + user = User(external_id="kc-1", username="s1") + db.add(user) + db.flush() + course = Course(name="C", keycloak_course_id="kc-c1") + db.add(course) + db.flush() + group = CourseGroup(course_id=course.id, name=group_name) + db.add(group) + db.flush() + cm = CourseMember(user_id=user.id, course_id=course.id) + db.add(cm) + db.flush() + gm = GroupMember(group_id=group.id, course_member_id=cm.id) + db.add(gm) + db.flush() + return user, course, group, cm, gm + + +def test_gc_drops_user_when_no_access_rows_remain(db_session): + """The end-state of delete_deployment: instances + access rows have + already been deleted. The user is now orphaned and must go.""" + user, *_ = _scaffold(db_session) + + _gc_orphan_student_memberships(db_session, {user.id}) + + # User + course_member + group_member all gone + assert db_session.query(User).filter_by(id=user.id).first() is None + assert db_session.query(CourseMember).count() == 0 + assert db_session.query(GroupMember).count() == 0 + + +def test_gc_keeps_user_when_still_referenced_by_another_deployment(db_session): + """If the student still has a live access row on a different + deployment, the GC leaves all their membership rows alone.""" + user, course, group, cm, gm = _scaffold(db_session) + + # Build a second, surviving deployment chain that the student is on: + # openstack_project → template → template_version → deployment → + # deployment_instance → deployment_instance_access linked to `group`. + op = OpenstackProject( + owner_user_id=user.id, # owner doesn't have to be the student here + openstack_project_id="ks-1", + openstack_project_name="osp", + auth_url="x", + username="u", + password="p", + region_name="r", + ) + tpl = Template( + name="t", owner_id=user.id, repo_url="r", visibility=TemplateVisibility.PRIVATE + ) + db_session.add_all([op, tpl]) + db_session.flush() + tv = TemplateVersion(template_id=tpl.id, version="1.0.0", git_commit_sha="abc") + db_session.add(tv) + db_session.flush() + surviving = Deployment( + name="alive", + template_version_id=tv.id, + course_id=course.id, + openstack_project_id=op.id, + status=DeploymentStatus.RUNNING, + ) + db_session.add(surviving) + db_session.flush() + inst = DeploymentInstance(deployment_id=surviving.id, vm_name="i1") + db_session.add(inst) + db_session.flush() + access = DeploymentInstanceAccess( + deployment_instance_id=inst.id, + access_type=AccessType.SSH, + group_id=group.id, + ) + db_session.add(access) + db_session.commit() + + # ...but because owns_templates is true for this user, the GC bails out + # via the owner-guard. Rerun with a "pure student" id to verify the + # access-survives logic itself: use a second user with no owner rows. + pure_user = User(external_id="kc-2", username="s2") + db_session.add(pure_user) + db_session.flush() + pure_cm = CourseMember(user_id=pure_user.id, course_id=course.id) + db_session.add(pure_cm) + db_session.flush() + db_session.add(GroupMember(group_id=group.id, course_member_id=pure_cm.id)) + db_session.commit() + + _gc_orphan_student_memberships(db_session, {pure_user.id}) + + # pure_user's membership remains because group.id still has a live + # DeploymentInstanceAccess (via the surviving deployment). + assert db_session.query(User).filter_by(id=pure_user.id).first() is not None + assert ( + db_session.query(CourseMember).filter_by(user_id=pure_user.id).count() == 1 + ) + + +def test_gc_skips_users_who_own_templates(db_session): + """Safety net: a user who owns a template is NOT a pure student. GC + must leave them alone even if they have a stale CourseMember row.""" + user, course, group, cm, gm = _scaffold(db_session) + db_session.add( + Template( + name="t", + owner_id=user.id, + repo_url="r", + visibility=TemplateVisibility.PRIVATE, + ) + ) + db_session.commit() + + _gc_orphan_student_memberships(db_session, {user.id}) + + assert db_session.query(User).filter_by(id=user.id).first() is not None + assert db_session.query(CourseMember).count() == 1 + assert db_session.query(GroupMember).count() == 1 + + +def test_gc_skips_users_who_own_openstack_projects(db_session): + """Same safety net for openstack project owners.""" + user, course, *_ = _scaffold(db_session) + db_session.add( + OpenstackProject( + owner_user_id=user.id, + openstack_project_id="ks-1", + openstack_project_name="osp", + auth_url="x", + username="u", + password="p", + region_name="r", + ) + ) + db_session.commit() + + _gc_orphan_student_memberships(db_session, {user.id}) + + assert db_session.query(User).filter_by(id=user.id).first() is not None + + +def test_gc_with_empty_user_set_is_a_noop(db_session): + """Defensive: the caller passes an empty set when its snapshot query + failed. The GC must do nothing.""" + user, *_ = _scaffold(db_session) + + _gc_orphan_student_memberships(db_session, set()) + + assert db_session.query(User).filter_by(id=user.id).first() is not None diff --git a/tests/unit/test_student_membership_sync.py b/tests/unit/test_student_membership_sync.py new file mode 100644 index 0000000..a2d570f --- /dev/null +++ b/tests/unit/test_student_membership_sync.py @@ -0,0 +1,193 @@ +"""Tests for DeploymentService._sync_student_memberships. + +The student self-service endpoint joins users → course_members → +group_members → course_groups to decide which deployments a logged-in +student can see. Without those membership rows the INNER JOIN returns +empty even when credentials with the right group_id exist. + +This test exercises the helper that fills those tables in: + +1. Creates a new User when the student has never logged in. +2. Re-uses an existing User on external_id match — no duplicate. +3. Idempotent across re-deploys: second call adds nothing. +4. Different groups produce separate GroupMember rows for the same student + while sharing one CourseMember. +""" +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from types import SimpleNamespace + +from src.core.database import Base +from src.models.course import Course +from src.models.course_group import CourseGroup +from src.models.course_member import CourseMember +from src.models.group_member import GroupMember +from src.models.user import User +from src.services.deployment_service import DeploymentService + + +@pytest.fixture +def db_session(): + """Fresh in-memory SQLite per test with all relevant tables present.""" + # Eager-import models so their tables register on Base.metadata before + # create_all runs. The set mirrors the model graph touched by the + # student-membership sync. + import src.models.deployment # noqa: F401 + import src.models.deployment_instance # noqa: F401 + import src.models.deployment_instance_access # noqa: F401 + import src.models.deployment_log # noqa: F401 + import src.models.template # noqa: F401 + import src.models.template_version # noqa: F401 + import src.models.template_version_file # noqa: F401 + import src.models.template_category # noqa: F401 + import src.models.template_category_assignment # noqa: F401 + import src.models.openstack_project # noqa: F401 + + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(engine) + + +def _student(idx): + """Build a StudentInfo-shaped object the sync method expects.""" + return SimpleNamespace( + id=f"kc-student-{idx}", # external_id (Keycloak sub) + username=f"student{idx}", + email=f"student{idx}@example.com", + first_name=f"First{idx}", + last_name=f"Last{idx}", + ) + + +def _stack_assignment(group_name, students): + return SimpleNamespace( + groups=[SimpleNamespace(group_name=group_name, students=students)] + ) + + +def _service(db): + """DeploymentService instance without the heavy __init__ side-effects.""" + svc = DeploymentService.__new__(DeploymentService) + svc.db = db + return svc + + +def _make_course_and_group(db, group_name="Group A"): + course = Course(name="C", keycloak_course_id="kc-1") + db.add(course) + db.flush() + group = CourseGroup(course_id=course.id, name=group_name) + db.add(group) + db.flush() + return course, group + + +def test_creates_user_course_member_and_group_member_for_new_student(db_session): + course, group = _make_course_and_group(db_session) + svc = _service(db_session) + stack_assignments = [_stack_assignment("Group A", [_student(1)])] + + svc._sync_student_memberships( + course_id=course.id, + stack_assignments=stack_assignments, + group_name_to_id={"Group A": group.id}, + ) + + user = db_session.query(User).filter_by(external_id="kc-student-1").one() + assert user.username == "student1" + assert user.email == "student1@example.com" + + cm = db_session.query(CourseMember).filter_by( + user_id=user.id, course_id=course.id + ).one() + assert cm.left_at is None + + gm = db_session.query(GroupMember).filter_by( + group_id=group.id, course_member_id=cm.id + ).one() + assert gm is not None + + +def test_reuses_existing_user_by_external_id(db_session): + """If the student already logged in once, the existing User row is + re-used — no duplicate, display fields aren't overwritten by the + wizard payload.""" + existing = User( + external_id="kc-student-1", + display_name="Original Display", + email="original@example.com", + username="orig", + ) + db_session.add(existing) + db_session.flush() + + course, group = _make_course_and_group(db_session) + svc = _service(db_session) + svc._sync_student_memberships( + course_id=course.id, + stack_assignments=[_stack_assignment("Group A", [_student(1)])], + group_name_to_id={"Group A": group.id}, + ) + + users = db_session.query(User).filter_by(external_id="kc-student-1").all() + assert len(users) == 1 + # Display fields preserved — sync doesn't overwrite, it only creates. + assert users[0].display_name == "Original Display" + + +def test_idempotent_on_redeploy(db_session): + """A second sync call with the same students adds nothing.""" + course, group = _make_course_and_group(db_session) + svc = _service(db_session) + args = dict( + course_id=course.id, + stack_assignments=[_stack_assignment("Group A", [_student(1)])], + group_name_to_id={"Group A": group.id}, + ) + + svc._sync_student_memberships(**args) + svc._sync_student_memberships(**args) + + assert db_session.query(User).count() == 1 + assert db_session.query(CourseMember).count() == 1 + assert db_session.query(GroupMember).count() == 1 + + +def test_student_in_two_groups_gets_one_course_member_two_group_members(db_session): + course, group_a = _make_course_and_group(db_session, "Group A") + group_b = CourseGroup(course_id=course.id, name="Group B") + db_session.add(group_b) + db_session.flush() + + svc = _service(db_session) + svc._sync_student_memberships( + course_id=course.id, + stack_assignments=[ + _stack_assignment("Group A", [_student(1)]), + _stack_assignment("Group B", [_student(1)]), + ], + group_name_to_id={"Group A": group_a.id, "Group B": group_b.id}, + ) + + # One user, one course member, two group memberships + assert db_session.query(User).filter_by(external_id="kc-student-1").count() == 1 + course_members = db_session.query(CourseMember).filter_by( + course_id=course.id + ).all() + assert len(course_members) == 1 + group_members = db_session.query(GroupMember).filter_by( + course_member_id=course_members[0].id + ).all() + assert {gm.group_id for gm in group_members} == {group_a.id, group_b.id} diff --git a/tests/unit/test_template_access_control.py b/tests/unit/test_template_access_control.py index 3ebe575..b034f17 100644 --- a/tests/unit/test_template_access_control.py +++ b/tests/unit/test_template_access_control.py @@ -420,3 +420,145 @@ def test_get_template_parameters_checks_access_by_default( template_version_file_service.get_template_parameters( version.id, user_id=other_user_id, is_admin=False, skip_access_check=False ) + + # ------------------------------------------------------------------ + # update_file / delete_file: previously did NOT check parent template + # ownership — any authenticated user with a file_id could mutate or + # delete files on private templates. Regression tests below pin the + # corrected behaviour: admins always pass, template owners always + # pass, everyone else gets 403. + # ------------------------------------------------------------------ + + def _seed_file_under_template(self, service, template): + """Helper: wire repos so file_id resolves to a file whose parent + template is the given one. Returns the mock file.""" + from src.models.template_version_file import TemplateVersionFile + + version = Mock(spec=TemplateVersion) + version.id = str(uuid4()) + version.template_id = template.id + + file = Mock(spec=TemplateVersionFile) + file.id = str(uuid4()) + file.template_version_id = version.id + # Defaults for the primary-flag branch in update_file + file.is_primary = False + + service.file_repo = Mock() + service.file_repo.get_by_id.return_value = file + service.file_repo.get_primary_file.return_value = None + service.version_repo = Mock() + service.version_repo.get_by_id.return_value = version + service.template_repo = Mock() + service.template_repo.get_by_id.return_value = template + return file + + def test_update_file_forbidden_for_non_owner_non_admin( + self, template_version_file_service, private_template, other_user_id + ): + from src.schemas.template_version_file import TemplateVersionFileUpdate + + self._seed_file_under_template(template_version_file_service, private_template) + + with pytest.raises(ForbiddenException): + template_version_file_service.update_file( + file_id=str(uuid4()), + file_data=TemplateVersionFileUpdate(file_type="OTHER"), + user_id=other_user_id, + is_admin=False, + ) + + def test_update_file_allowed_for_admin( + self, template_version_file_service, private_template, other_user_id + ): + from src.schemas.template_version_file import TemplateVersionFileUpdate + + file = self._seed_file_under_template(template_version_file_service, private_template) + # Mock the persistence layer so we don't hit a real DB. + template_version_file_service.db = MagicMock() + + result = template_version_file_service.update_file( + file_id=file.id, + file_data=TemplateVersionFileUpdate(file_type="HEAT_TEMPLATE"), + user_id=other_user_id, # NOT the owner — admin override is the point + is_admin=True, + ) + assert result is file # update is in-place on the mock + + def test_update_file_allowed_for_owner( + self, template_version_file_service, private_template, owner_user_id + ): + from src.schemas.template_version_file import TemplateVersionFileUpdate + + file = self._seed_file_under_template(template_version_file_service, private_template) + template_version_file_service.db = MagicMock() + + result = template_version_file_service.update_file( + file_id=file.id, + file_data=TemplateVersionFileUpdate(file_type="ANSIBLE_PLAYBOOK"), + user_id=owner_user_id, + is_admin=False, + ) + assert result is file + + def test_update_file_forbidden_without_user_id( + self, template_version_file_service, private_template + ): + """Bare ``update_file(...)`` without identifying the caller must + not silently succeed — that was the original bug.""" + from src.schemas.template_version_file import TemplateVersionFileUpdate + + self._seed_file_under_template(template_version_file_service, private_template) + + with pytest.raises(ForbiddenException): + template_version_file_service.update_file( + file_id=str(uuid4()), + file_data=TemplateVersionFileUpdate(file_type="OTHER"), + # No user_id, no is_admin — default permissive would be wrong. + ) + + def test_delete_file_forbidden_for_non_owner_non_admin( + self, template_version_file_service, private_template, other_user_id + ): + self._seed_file_under_template(template_version_file_service, private_template) + + with pytest.raises(ForbiddenException): + template_version_file_service.delete_file( + file_id=str(uuid4()), + user_id=other_user_id, + is_admin=False, + ) + # Repo's delete must NOT have been called — guard against future + # refactors that move the delete before the permission check. + template_version_file_service.file_repo.delete.assert_not_called() + + def test_delete_file_allowed_for_admin( + self, template_version_file_service, private_template, other_user_id + ): + file = self._seed_file_under_template(template_version_file_service, private_template) + + template_version_file_service.delete_file( + file_id=file.id, + user_id=other_user_id, + is_admin=True, + ) + # delete_file normalises the file_id to UUID before handing it to the + # repo (BaseRepository.delete is typed UUID). Assert the call shape, + # not the exact value, since we passed a string in. + template_version_file_service.file_repo.delete.assert_called_once() + called_with = template_version_file_service.file_repo.delete.call_args.args[0] + assert str(called_with) == file.id + + def test_delete_file_allowed_for_owner( + self, template_version_file_service, private_template, owner_user_id + ): + file = self._seed_file_under_template(template_version_file_service, private_template) + + template_version_file_service.delete_file( + file_id=file.id, + user_id=owner_user_id, + is_admin=False, + ) + template_version_file_service.file_repo.delete.assert_called_once() + called_with = template_version_file_service.file_repo.delete.call_args.args[0] + assert str(called_with) == file.id diff --git a/tests/unit/test_template_icon_path_schema.py b/tests/unit/test_template_icon_path_schema.py new file mode 100644 index 0000000..a54283f --- /dev/null +++ b/tests/unit/test_template_icon_path_schema.py @@ -0,0 +1,65 @@ +"""Tests für ``icon_path`` auf TemplateResponse. + +Nach dem Umbau kennt das Backend nur noch hochgeladene Icon-Bilder; +``mdi:*``/URL-Strings gibt es nicht mehr. Frontend rendert entweder +``icon_path`` als ```` (gegen die API-Base-URL aufgelöst) +oder einen Placeholder. +""" +from datetime import datetime, timezone +from types import SimpleNamespace + +from src.schemas.template import TemplateResponse + + +def _orm_template(**overrides): + """Build a Template-like ORM stub for schema validation.""" + defaults = dict( + id="tmpl-1", + name="Test Template", + description=None, + owner_id="user-1", + repo_url="https://github.com/example/test", + visibility="private", + versions=None, + owner=None, + icon=None, + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +class TestIconPath: + def test_uploaded_icon_returns_serve_path(self): + icon = SimpleNamespace(id="icon-42") + response = TemplateResponse.model_validate(_orm_template(icon=icon)) + assert response.icon_path == "/api/v1/templates/tmpl-1/icon" + + def test_no_icon_returns_none(self): + """Ohne Upload ist ``icon_path`` ``None`` — kein Fallback.""" + response = TemplateResponse.model_validate(_orm_template(icon=None)) + assert response.icon_path is None + + +class TestSerializedPayloadShape: + def test_raw_icon_object_not_leaked_into_json(self): + """Die ORM-Icon-Relation darf nicht in die Response wandern — + Clients bekommen nur ``icon_path``.""" + icon = SimpleNamespace(id="icon-42", content_type="image/png") + payload = TemplateResponse.model_validate( + _orm_template(icon=icon) + ).model_dump(mode="json") + + assert "icon" not in payload + assert "icon_url" not in payload # Altes Feld existiert nicht mehr + assert "effective_icon" not in payload # Zwischenname war effective_icon + assert "has_uploaded_icon" not in payload # ebenfalls entfernt + assert payload["icon_path"] == "/api/v1/templates/tmpl-1/icon" + + def test_json_payload_when_no_upload(self): + payload = TemplateResponse.model_validate( + _orm_template(icon=None) + ).model_dump(mode="json") + + assert payload["icon_path"] is None diff --git a/tests/unit/test_template_icon_service.py b/tests/unit/test_template_icon_service.py new file mode 100644 index 0000000..399a489 --- /dev/null +++ b/tests/unit/test_template_icon_service.py @@ -0,0 +1,352 @@ +"""Unit-Tests für den Template-Icon-Service. + +Testen Content-Type-Whitelist, Größenlimit, Owner/Admin-Gate, sowie den +Create-vs-Replace-Zweig. Wir vermeiden echte DB-Setups und nutzen +MagicMock-Sessions — die Zusammenarbeit mit dem Repository ist trivial +genug, dass die Interaktion pro Testfall stubbbar ist. +""" +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from starlette.exceptions import HTTPException + +from src.core.exceptions import BadRequestException, ForbiddenException +from src.models.template import Template, TemplateVisibility +from src.models.template_icon import TemplateIcon +from src.services.template_icon_service import TemplateIconService + + +def _tpl(owner_id: str = "owner-1") -> Template: + """Build a plain Template ORM object (no DB) with the fields the + service touches. ``visibility=PUBLIC`` because ``get_template`` also + checks the general visibility gate — for owner access that check is + a no-op, but we want to be defensive. + """ + t = Template() + t.id = str(uuid4()) + t.name = "demo" + t.description = None + t.owner_id = owner_id + t.repo_url = "https://example.com" + t.visibility = TemplateVisibility.PRIVATE + t.publish_requested = False + t.versions = [] + return t + + +def _service_with_stubs(template: Template) -> TemplateIconService: + """Wire a service with mocked ``template_service`` + ``repo`` so we + can drive the two collaborators without a real DB.""" + svc = TemplateIconService(MagicMock()) + svc.template_service = MagicMock() + svc.template_service.get_template.return_value = template + svc.repo = MagicMock() + return svc + + +# --------------------------------------------------------------------------- +# Upload — content-type validation +# --------------------------------------------------------------------------- +class TestUploadContentTypes: + def test_png_accepted(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.get_by_template_id.return_value = None + svc.repo.create.return_value = TemplateIcon( + id=str(uuid4()), + template_id=tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + size_bytes=1, + ) + icon = svc.upload_icon( + tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + user_id="owner-1", + ) + assert icon.content_type == "image/png" + + def test_jpeg_accepted(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.get_by_template_id.return_value = None + svc.repo.create.return_value = TemplateIcon( + id=str(uuid4()), + template_id=tpl.id, + content=b"y", + content_type="image/jpeg", + file_name="a.jpg", + size_bytes=1, + ) + icon = svc.upload_icon( + tpl.id, + content=b"y", + content_type="image/jpeg", + file_name="a.jpg", + user_id="owner-1", + ) + assert icon.content_type == "image/jpeg" + + def test_webp_accepted(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.get_by_template_id.return_value = None + svc.repo.create.return_value = TemplateIcon( + id=str(uuid4()), + template_id=tpl.id, + content=b"z", + content_type="image/webp", + file_name=None, + size_bytes=1, + ) + icon = svc.upload_icon( + tpl.id, + content=b"z", + content_type="image/webp", + file_name=None, + user_id="owner-1", + ) + assert icon.content_type == "image/webp" + + def test_svg_rejected_with_415(self): + """SVG ist bewusst nicht erlaubt (XML-Payload / Skript-Vektor).""" + tpl = _tpl() + svc = _service_with_stubs(tpl) + with pytest.raises(HTTPException) as exc: + svc.upload_icon( + tpl.id, + content=b"", + content_type="image/svg+xml", + file_name="a.svg", + user_id="owner-1", + ) + assert exc.value.status_code == 415 + + def test_plain_text_rejected_with_415(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + with pytest.raises(HTTPException) as exc: + svc.upload_icon( + tpl.id, + content=b"hello", + content_type="text/plain", + file_name="a.txt", + user_id="owner-1", + ) + assert exc.value.status_code == 415 + + def test_content_type_with_charset_suffix_still_accepted(self): + """Browser hängen manchmal ``; charset=…`` an — wir strippen.""" + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.get_by_template_id.return_value = None + svc.repo.create.return_value = TemplateIcon( + id=str(uuid4()), + template_id=tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + size_bytes=1, + ) + icon = svc.upload_icon( + tpl.id, + content=b"x", + content_type="image/png; charset=binary", + file_name="a.png", + user_id="owner-1", + ) + assert icon.content_type == "image/png" + + +# --------------------------------------------------------------------------- +# Upload — size validation +# --------------------------------------------------------------------------- +class TestUploadSize: + def test_empty_upload_rejected_400(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + with pytest.raises(BadRequestException): + svc.upload_icon( + tpl.id, + content=b"", + content_type="image/png", + file_name="a.png", + user_id="owner-1", + ) + + def test_oversize_upload_rejected_413(self, monkeypatch): + tpl = _tpl() + svc = _service_with_stubs(tpl) + + # Kleiner Grenzwert, damit wir keine 5 MB im Test allokieren müssen. + from src.core import config as config_module + + fake_settings = config_module.get_settings() + # ``Settings`` ist eine Pydantic-Instanz; wir mutieren die Cache-Kopie. + # Der ``get_settings``-Cache liefert dieselbe Instanz, damit reicht das. + original = fake_settings.max_icon_size_bytes + fake_settings.max_icon_size_bytes = 10 + try: + with pytest.raises(HTTPException) as exc: + svc.upload_icon( + tpl.id, + content=b"x" * 20, + content_type="image/png", + file_name="a.png", + user_id="owner-1", + ) + assert exc.value.status_code == 413 + finally: + fake_settings.max_icon_size_bytes = original + + +# --------------------------------------------------------------------------- +# Upload — owner/admin gate +# --------------------------------------------------------------------------- +class TestUploadAuthGate: + def test_non_owner_non_admin_forbidden(self): + tpl = _tpl(owner_id="owner-1") + svc = _service_with_stubs(tpl) + with pytest.raises(ForbiddenException): + svc.upload_icon( + tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + user_id="stranger", + is_admin=False, + ) + + def test_admin_allowed_even_if_not_owner(self): + tpl = _tpl(owner_id="owner-1") + svc = _service_with_stubs(tpl) + svc.repo.get_by_template_id.return_value = None + svc.repo.create.return_value = TemplateIcon( + id=str(uuid4()), + template_id=tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + size_bytes=1, + ) + icon = svc.upload_icon( + tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + user_id="admin-99", + is_admin=True, + ) + assert icon is not None + + +# --------------------------------------------------------------------------- +# Upload — create-vs-replace behaviour +# --------------------------------------------------------------------------- +class TestUploadCreateOrReplace: + def test_first_upload_creates_new_row(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.get_by_template_id.return_value = None + created = TemplateIcon( + id=str(uuid4()), + template_id=tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + size_bytes=1, + ) + svc.repo.create.return_value = created + + icon = svc.upload_icon( + tpl.id, + content=b"x", + content_type="image/png", + file_name="a.png", + user_id="owner-1", + ) + assert icon is created + svc.repo.create.assert_called_once() + + def test_second_upload_replaces_content_keeps_id(self): + """Bei bereits vorhandenem Icon wird die Row in-place mutiert, + damit ``id`` und ``created_at`` stabil bleiben.""" + tpl = _tpl() + svc = _service_with_stubs(tpl) + existing = TemplateIcon( + id="stable-icon-id", + template_id=tpl.id, + content=b"old", + content_type="image/jpeg", + file_name="old.jpg", + size_bytes=3, + ) + svc.repo.get_by_template_id.return_value = existing + + icon = svc.upload_icon( + tpl.id, + content=b"NEWDATA", + content_type="image/png", + file_name="new.png", + user_id="owner-1", + ) + assert icon.id == "stable-icon-id" + assert icon.content == b"NEWDATA" + assert icon.content_type == "image/png" + assert icon.file_name == "new.png" + assert icon.size_bytes == 7 + # ``create`` darf im Replace-Pfad nicht aufgerufen werden. + svc.repo.create.assert_not_called() + + +# --------------------------------------------------------------------------- +# Get / Delete +# --------------------------------------------------------------------------- +class TestGetIcon: + def test_get_returns_icon_when_present(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + stored = TemplateIcon( + id="x", + template_id=tpl.id, + content=b"blob", + content_type="image/png", + file_name="a.png", + size_bytes=4, + ) + svc.repo.get_by_template_id.return_value = stored + icon = svc.get_icon(tpl.id, user_id="owner-1") + assert icon is stored + + def test_get_raises_404_when_no_icon(self): + from src.core.exceptions import NotFoundException + + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.get_by_template_id.return_value = None + with pytest.raises(NotFoundException): + svc.get_icon(tpl.id, user_id="owner-1") + + +class TestDeleteIcon: + def test_delete_returns_true_when_deleted(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.delete_by_template_id.return_value = True + assert svc.delete_icon(tpl.id, user_id="owner-1") is True + + def test_delete_idempotent_returns_false_when_nothing_existed(self): + tpl = _tpl() + svc = _service_with_stubs(tpl) + svc.repo.delete_by_template_id.return_value = False + assert svc.delete_icon(tpl.id, user_id="owner-1") is False + + def test_delete_non_owner_forbidden(self): + tpl = _tpl(owner_id="owner-1") + svc = _service_with_stubs(tpl) + with pytest.raises(ForbiddenException): + svc.delete_icon(tpl.id, user_id="stranger", is_admin=False) diff --git a/tests/unit/test_template_publish_flow.py b/tests/unit/test_template_publish_flow.py new file mode 100644 index 0000000..96ef175 --- /dev/null +++ b/tests/unit/test_template_publish_flow.py @@ -0,0 +1,157 @@ +"""Tests für den Erst-Veröffentlichungs-Flow. + +Owner klickt beim Anlegen „öffentlich" → Template landet als PRIVATE + +publish_requested=True. Erst beim ersten approve_version() flippt das +Template atomar auf PUBLIC. Bei reject bleibt es PRIVATE und der Wunsch +wird verworfen. + +Diese Tests decken nur die Service-Logik ab (Unit-Level, mit MagicMock-DB). +Die End-to-End-Validierung läuft als Frontend-Smoke-Test bzw. im API-Layer. +""" +from unittest.mock import MagicMock +from uuid import uuid4 + +from src.models.template import Template, TemplateVisibility +from src.models.template_version import TemplateVersion, TemplateVersionApprovalStatus +from src.models.user import UserRole +from src.services.template_version_service import TemplateVersionService + + +def _tpl(visibility=TemplateVisibility.PRIVATE, publish_requested=False): + t = Template() + t.id = str(uuid4()) + t.owner_id = "owner-1" + t.name = "demo" + t.repo_url = "https://example.com" + t.visibility = visibility + t.publish_requested = publish_requested + t.versions = [] + return t + + +def _ver(template_id, approval_status, is_active=False, version_str="1.0.0"): + v = TemplateVersion() + v.id = str(uuid4()) + v.template_id = template_id + v.version = version_str + v.git_commit_sha = "sha-" + v.id[:8] + v.is_active = is_active + v.approval_status = approval_status + v.approved_by_id = None + v.approved_at = None + v.rejection_reason = None + return v + + +class TestInitialApproval: + """``_initial_approval`` bildet (template, caller) → approval_status ab. + Wir testen die drei relevanten Eingangs-Konstellationen für + `publish_requested` (neu) + den unveränderten Standard-Flow.""" + + def test_genuinely_private_returns_none(self): + tpl = _tpl(TemplateVisibility.PRIVATE, publish_requested=False) + assert TemplateVersionService._initial_approval(tpl, [UserRole.LECTURER.value]) is None + assert TemplateVersionService._initial_approval(tpl, [UserRole.ADMIN.value]) is None + + def test_public_template_lecturer_caller_gets_pending(self): + tpl = _tpl(TemplateVisibility.PUBLIC, publish_requested=False) + result = TemplateVersionService._initial_approval(tpl, [UserRole.LECTURER.value]) + assert result == TemplateVersionApprovalStatus.PENDING + + def test_public_template_admin_caller_gets_approved(self): + tpl = _tpl(TemplateVisibility.PUBLIC, publish_requested=False) + result = TemplateVersionService._initial_approval(tpl, [UserRole.ADMIN.value]) + assert result == TemplateVersionApprovalStatus.APPROVED + + def test_private_with_publish_requested_lecturer_gets_pending(self): + """Erst-Veröffentlichungs-Pfad: Template ist noch PRIVATE, aber der + Wunsch ist gesetzt → Approval-Flow läuft schon, neue Versionen + starten PENDING.""" + tpl = _tpl(TemplateVisibility.PRIVATE, publish_requested=True) + result = TemplateVersionService._initial_approval(tpl, [UserRole.LECTURER.value]) + assert result == TemplateVersionApprovalStatus.PENDING + + def test_private_with_publish_requested_admin_gets_approved(self): + tpl = _tpl(TemplateVisibility.PRIVATE, publish_requested=True) + result = TemplateVersionService._initial_approval(tpl, [UserRole.ADMIN.value]) + assert result == TemplateVersionApprovalStatus.APPROVED + + +class TestApproveFlipsToPublic: + """``approve_version`` flippt PRIVATE+publish_requested atomar zu + PUBLIC + publish_requested=False — und nur dann.""" + + def test_approve_on_publish_requested_promotes_to_public(self): + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl(TemplateVisibility.PRIVATE, publish_requested=True) + v = _ver(tpl.id, TemplateVersionApprovalStatus.PENDING) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + + s.approve_version(v.id, admin_user_id="admin-1") + + assert tpl.visibility == TemplateVisibility.PUBLIC + assert tpl.publish_requested is False + assert v.approval_status == TemplateVersionApprovalStatus.APPROVED + assert v.approved_by_id == "admin-1" + assert v.approved_at is not None + + def test_approve_on_already_public_template_does_not_touch_publish_requested(self): + """Approves auf bereits-PUBLIC Templates lassen die Spalte unangetastet.""" + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl(TemplateVisibility.PUBLIC, publish_requested=False) + v = _ver(tpl.id, TemplateVersionApprovalStatus.PENDING) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + + s.approve_version(v.id, admin_user_id="admin-1") + + assert tpl.visibility == TemplateVisibility.PUBLIC + assert tpl.publish_requested is False + assert v.approval_status == TemplateVersionApprovalStatus.APPROVED + + +class TestRejectClearsPublishRequest: + """``reject_version`` verwirft den Veröffentlichungs-Wunsch, lässt das + Template aber PRIVATE.""" + + def test_reject_on_publish_requested_clears_wish_keeps_private(self): + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl(TemplateVisibility.PRIVATE, publish_requested=True) + v = _ver(tpl.id, TemplateVersionApprovalStatus.PENDING) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + + s.reject_version(v.id, admin_user_id="admin-1", reason="format") + + assert tpl.visibility == TemplateVisibility.PRIVATE + assert tpl.publish_requested is False + assert v.approval_status == TemplateVersionApprovalStatus.REJECTED + assert v.rejection_reason == "format" + + def test_reject_on_public_template_does_not_alter_publish_requested(self): + """Reject auf einem bereits-PUBLIC Template: publish_requested + bleibt wie es war (typischerweise False), Template-State bleibt.""" + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl(TemplateVisibility.PUBLIC, publish_requested=False) + v = _ver(tpl.id, TemplateVersionApprovalStatus.PENDING) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + + s.reject_version(v.id, admin_user_id="admin-1") + + assert tpl.visibility == TemplateVisibility.PUBLIC + assert tpl.publish_requested is False + assert v.approval_status == TemplateVersionApprovalStatus.REJECTED diff --git a/tests/unit/test_template_response_schema.py b/tests/unit/test_template_response_schema.py index 164b180..9b21249 100644 --- a/tests/unit/test_template_response_schema.py +++ b/tests/unit/test_template_response_schema.py @@ -15,10 +15,10 @@ def _orm_template(owner=None, **overrides): description="A test template", owner_id="user-1", repo_url="https://github.com/example/test", - icon_url=None, visibility="private", versions=None, owner=owner, + icon=None, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), ) diff --git a/tests/unit/test_template_version_activate.py b/tests/unit/test_template_version_activate.py new file mode 100644 index 0000000..56fd128 --- /dev/null +++ b/tests/unit/test_template_version_activate.py @@ -0,0 +1,136 @@ +"""Tests für „Aktive Version ändern" — Switch zwischen Versionen, auch +zu älteren. Backend-Service muss das beidseitig erlauben; das Frontend +hat heute keinen Strict-Newer-Filter mehr. + +Wir mocken die DB-Schicht; der eigentliche SQL-Update läuft im +TemplateVersionRepository und ist dort separat covered. +""" +from datetime import datetime, timezone +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +from src.core.exceptions import ForbiddenException +from src.models.template import Template, TemplateVisibility +from src.models.template_version import TemplateVersion, TemplateVersionApprovalStatus +from src.services.template_version_service import TemplateVersionService + + +def _tpl(owner_id="owner-1"): + t = Template() + t.id = str(uuid4()) + t.owner_id = owner_id + t.name = "demo" + t.repo_url = "https://example.com" + t.visibility = TemplateVisibility.PUBLIC + t.publish_requested = False + t.versions = [] + return t + + +def _ver(template_id, version_str, is_active): + v = TemplateVersion() + v.id = str(uuid4()) + v.template_id = template_id + v.version = version_str + v.git_commit_sha = "sha-" + v.id[:8] + v.is_active = is_active + v.approval_status = TemplateVersionApprovalStatus.APPROVED + v.approved_by_id = "admin-1" + v.approved_at = datetime.now(timezone.utc) + v.rejection_reason = None + return v + + +class TestActivateVersionSwitch: + """``activate_version`` darf den active-Flag in beide Richtungen + switchen — kein Strict-Newer-Filter im Backend.""" + + def test_activate_newer_version_works(self): + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl() + # Ältere Version existiert bereits als die aktive — wird im Service + # über deactivate_other_versions implizit deaktiviert. + _ver(tpl.id, "2.0.0", is_active=True) + v_new = _ver(tpl.id, "2.1.0", is_active=False) + s.version_repo.get_by_id.return_value = v_new + s.template_repo.get_by_id.return_value = tpl + s.version_repo.update.return_value = v_new + + result = s.activate_version(v_new.id, user_id=tpl.owner_id, is_admin=False) + assert result is v_new + # deactivate_other_versions wurde mit der NEUEN id aufgerufen, damit + # die OLD-Row passiv mit-deaktiviert wird. + s.version_repo.deactivate_other_versions.assert_called_once_with( + tpl.id, v_new.id, + ) + + def test_activate_older_version_works(self): + """Downgrade-Pfad: ältere Version wieder aktivieren. Service darf + das genauso wie ein Upgrade durchwinken.""" + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl() + v_old = _ver(tpl.id, "2.0.0", is_active=False) + # Aktuell aktive Version, die durch das Activate der älteren Version + # implizit deaktiviert wird. + _ver(tpl.id, "2.1.0", is_active=True) + s.version_repo.get_by_id.return_value = v_old + s.template_repo.get_by_id.return_value = tpl + s.version_repo.update.return_value = v_old + + result = s.activate_version(v_old.id, user_id=tpl.owner_id, is_admin=False) + assert result is v_old + s.version_repo.deactivate_other_versions.assert_called_once_with( + tpl.id, v_old.id, + ) + + def test_activate_requires_owner_or_admin(self): + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl(owner_id="owner-1") + v = _ver(tpl.id, "1.0.0", is_active=False) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + + with pytest.raises(ForbiddenException): + s.activate_version(v.id, user_id="someone-else", is_admin=False) + + +class TestActivateVersionWithVersionString: + """Wenn ``update_version`` einen neuen Versions-String übergibt, läuft + der Validator. Hier sicherstellen, dass die Activate-Logik selbst + unabhängig davon funktioniert (versions-Spalte unverändert).""" + + def test_update_with_only_is_active_skips_version_validation(self): + from src.schemas.template_version import TemplateVersionUpdate + + s = TemplateVersionService(MagicMock()) + s.version_repo = MagicMock() + s.template_repo = MagicMock() + + tpl = _tpl() + v = _ver(tpl.id, "2.0.0", is_active=False) + s.version_repo.get_by_id.return_value = v + s.template_repo.get_by_id.return_value = tpl + s.version_repo.update.return_value = v + + # update_version(is_active=True) ohne version-Feld: Validator darf + # nicht angeschmissen werden (würde sonst die existierende Version + # gegen sich selbst vergleichen). + s.update_version( + v.id, + TemplateVersionUpdate(is_active=True), + user_id=tpl.owner_id, + is_admin=False, + ) + # deactivate_other_versions wurde aufgerufen. + s.version_repo.deactivate_other_versions.assert_called_once_with(tpl.id, v.id) diff --git a/tests/unit/test_template_version_service_per_version.py b/tests/unit/test_template_version_service_per_version.py index 823fca8..caff73c 100644 --- a/tests/unit/test_template_version_service_per_version.py +++ b/tests/unit/test_template_version_service_per_version.py @@ -74,17 +74,19 @@ def test_admin_on_public_template_auto_approves(self): t = _make_template(TemplateVisibility.PUBLIC) assert TemplateVersionService._initial_approval(t, ["admin"]) == TemplateVersionApprovalStatus.APPROVED - def test_admin_on_private_template_stays_pending(self): + def test_admin_on_private_template_returns_none(self): + """Private templates skip the approval flow entirely — approval + concept doesn't apply when the template is owner-only.""" t = _make_template(TemplateVisibility.PRIVATE) - assert TemplateVersionService._initial_approval(t, ["admin"]) == TemplateVersionApprovalStatus.PENDING + assert TemplateVersionService._initial_approval(t, ["admin"]) is None def test_lecturer_on_public_template_stays_pending(self): t = _make_template(TemplateVisibility.PUBLIC) assert TemplateVersionService._initial_approval(t, ["lecturer"]) == TemplateVersionApprovalStatus.PENDING - def test_lecturer_on_private_template_stays_pending(self): + def test_lecturer_on_private_template_returns_none(self): t = _make_template(TemplateVisibility.PRIVATE) - assert TemplateVersionService._initial_approval(t, ["lecturer"]) == TemplateVersionApprovalStatus.PENDING + assert TemplateVersionService._initial_approval(t, ["lecturer"]) is None def test_empty_roles_stays_pending(self): t = _make_template(TemplateVisibility.PUBLIC) @@ -142,7 +144,11 @@ def test_anonymous_user_blocked(self, service): class TestApproveRejectVersion: def test_approve_sets_status_and_audit_fields(self, service, mock_db): - v = _make_version("tmpl-1", TemplateVersionApprovalStatus.PENDING) + # Approve is allowed only on public templates — wire the template_repo + # mock so the gate in approve_version() lets us through. + public_template = _make_template(TemplateVisibility.PUBLIC) + service.template_repo.get_by_id.return_value = public_template + v = _make_version(public_template.id, TemplateVersionApprovalStatus.PENDING) service.version_repo.get_by_id.return_value = v admin_id = str(uuid4()) @@ -160,8 +166,21 @@ def test_approve_raises_when_version_missing(self, service): with pytest.raises(NotFoundException): service.approve_version("missing-id", admin_user_id="admin-1") + def test_approve_400_when_template_private(self, service): + """Approval flow doesn't apply to private templates — must reject.""" + priv = _make_template(TemplateVisibility.PRIVATE) + v = _make_version(priv.id, None) + service.version_repo.get_by_id.return_value = v + service.template_repo.get_by_id.return_value = priv + + with pytest.raises(BadRequestException) as exc: + service.approve_version(v.id, admin_user_id="admin-1") + assert "public" in str(exc.value).lower() + def test_reject_sets_status_and_audit_fields(self, service, mock_db): - v = _make_version("tmpl-1", TemplateVersionApprovalStatus.PENDING) + public_template = _make_template(TemplateVisibility.PUBLIC) + service.template_repo.get_by_id.return_value = public_template + v = _make_version(public_template.id, TemplateVersionApprovalStatus.PENDING) service.version_repo.get_by_id.return_value = v admin_id = str(uuid4()) @@ -177,6 +196,15 @@ def test_reject_raises_when_version_missing(self, service): with pytest.raises(NotFoundException): service.reject_version("missing-id", admin_user_id="admin-1") + def test_reject_400_when_template_private(self, service): + priv = _make_template(TemplateVisibility.PRIVATE) + v = _make_version(priv.id, None) + service.version_repo.get_by_id.return_value = v + service.template_repo.get_by_id.return_value = priv + + with pytest.raises(BadRequestException): + service.reject_version(v.id, admin_user_id="admin-1") + # --------------------------------------------------------------------------- # create_version_with_files - atomic create + base_version_id overlay diff --git a/tests/unit/test_version_validator.py b/tests/unit/test_version_validator.py new file mode 100644 index 0000000..e50d0c0 --- /dev/null +++ b/tests/unit/test_version_validator.py @@ -0,0 +1,157 @@ +"""Unit tests für ``src/utils/version_validator.py``. + +Wir testen sowohl die Regex (Format-Check) als auch die Monotonie- +Vergleichs-Logik separat, weil beide unabhängige Failure-Modi haben. +""" +import pytest + +from src.core.exceptions import BadRequestException +from src.utils import version_validator +from src.utils.version_validator import ( + ERR_ALREADY_EXISTS, + ERR_NOT_SEMVER, + ERR_NOT_STRICTLY_GREATER, + assert_strictly_greater, + assert_valid_semver, + is_valid_semver, + parse_semver, +) + + +class TestSemverFormat: + @pytest.mark.parametrize( + "version", + [ + "0.0.0", + "1.0.0", + "10.20.30", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0+build", + "1.0.0-alpha+build.5", + "1.0.0-0.3.7", + "1.0.0-x.7.z.92", + ], + ) + def test_valid_strings_accepted(self, version): + assert is_valid_semver(version) + # Sollte auch ohne Exception parsen + parse_semver(version) + + @pytest.mark.parametrize( + "version", + [ + "", + "foo", + "1", + "1.0", + "01.0.0", # leading zero + "1.0.0.0", + "v1.0.0", + "1.0.0-", + "1.0.0+", + "1.0.0-α", # nicht-ASCII + ], + ) + def test_invalid_strings_rejected(self, version): + assert not is_valid_semver(version) + with pytest.raises(BadRequestException) as exc: + assert_valid_semver(version) + assert exc.value.code == ERR_NOT_SEMVER + + +class TestSemverOrdering: + def test_release_is_greater_than_prerelease(self): + # Semver §11: 1.0.0 > 1.0.0-alpha + assert parse_semver("1.0.0") > parse_semver("1.0.0-alpha") + + def test_prerelease_numeric_lower_than_alpha(self): + # 1.0.0-1 < 1.0.0-alpha (numerische Identifier sortieren niedriger + # als alphanumerische, Semver §11.3). + assert parse_semver("1.0.0-1") < parse_semver("1.0.0-alpha") + + def test_build_metadata_ignored_for_ordering(self): + # 1.0.0+a und 1.0.0+b sind ordnungsweise gleich (Semver §10). + assert parse_semver("1.0.0+a") == parse_semver("1.0.0+b") + + def test_patch_minor_major_ordering(self): + assert parse_semver("2.0.0") > parse_semver("1.9.9") + assert parse_semver("1.10.0") > parse_semver("1.9.0") + assert parse_semver("1.0.1") > parse_semver("1.0.0") + + +class TestAssertStrictlyGreater: + def test_first_version_accepted(self): + # Keine existierenden Versionen → jede valid-semver-Version geht. + result = assert_strictly_greater("1.0.0", []) + assert result is None + + def test_strictly_greater_accepted(self): + result = assert_strictly_greater("2.0.0", ["1.0.0", "1.5.0", "1.9.9"]) + assert result is None + + def test_already_exists_raises_with_code(self): + with pytest.raises(BadRequestException) as exc: + assert_strictly_greater("1.5.0", ["1.0.0", "1.5.0", "1.9.0"]) + assert exc.value.code == ERR_ALREADY_EXISTS + assert exc.value.details == {"version": "1.5.0"} + + def test_not_strictly_greater_raises_with_max(self): + with pytest.raises(BadRequestException) as exc: + assert_strictly_greater("1.5.0", ["1.0.0", "2.0.0", "1.9.0"]) + assert exc.value.code == ERR_NOT_STRICTLY_GREATER + assert exc.value.details["current_max"] == "2.0.0" + + def test_invalid_existing_versions_skipped(self): + # „2.0.0+dedupe-abc12345" ist valid semver (Build-Metadata), aber + # die alte Dedupe-Variante mit Suffix darf den Vergleich nicht + # blockieren. Hier testen wir gleich beide: ein offen-defektes + # „foo" UND ein semver-mit-Build "2.0.0+dedupe-abc12345" — beide + # sollen den Insert von "2.0.1" zulassen. + existing = ["1.0.0", "foo", "2.0.0+dedupe-abc12345"] + # Beachte: "2.0.0+dedupe-..." parst zu (2,0,0,…) und ist + # ordnungsweise gleich 2.0.0. Wir versuchen also 2.0.1 → ok. + result = assert_strictly_greater("2.0.1", existing) + assert result is None + + def test_replace_target_allows_equal(self): + result = assert_strictly_greater( + "2.0.0", + ["1.0.0", "2.0.0"], + allow_equal_replace_target=True, + ) + assert result == "2.0.0" + + def test_replace_target_does_not_bypass_monotonic_check(self): + # 2.0.0 ist identisch zur existierenden 2.0.0 (Replace ok), aber + # 3.0.0 ist auch da — der Replace-Wert ist also nicht das Maximum, + # heißt nicht „kleiner als max" muss greifen. In dem Szenario + # geben wir aber denselben String, also Replace-Pfad aktiv und + # wir wollen keinen NOT_STRICTLY_GREATER-Fehler. + # (Edge-Case: Replace-Pfad ist ein „ich ersetze eine Bestands-Row" + # — der String ist bewusst kleiner als max, das ist normal.) + result = assert_strictly_greater( + "2.0.0", + ["2.0.0", "3.0.0"], + allow_equal_replace_target=True, + ) + assert result == "2.0.0" + + +class TestErrorCodesAreExported: + """Die Codes müssen Strings auf Modulebene sein — das Frontend + spiegelt sie als const-Werte. Wenn sie wegrefactored werden, + schlägt der Aufruf hier hart fehl statt stillschweigend zu + driften.""" + + def test_codes_are_uppercase_snake_strings(self): + for name in ( + "ERR_NOT_SEMVER", + "ERR_MISSING_IN_MANIFEST", + "ERR_NOT_STRICTLY_GREATER", + "ERR_ALREADY_EXISTS", + "ERR_REPLACE_BLOCKED_BY_DEPLOYMENTS", + ): + value = getattr(version_validator, name) + assert isinstance(value, str) and value.isupper() + assert value.startswith("VERSION_") diff --git a/uv.lock b/uv.lock index 83ed3c3..c24bb86 100644 --- a/uv.lock +++ b/uv.lock @@ -71,6 +71,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-heatclient" }, { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -107,6 +108,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-heatclient", specifier = ">=3.5.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, @@ -1777,6 +1779,15 @@ cryptography = [ { name = "cryptography" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "python-swiftclient" version = "4.10.0"