Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3dfdde6
feat(deployments): per-group + admin SSH keys with download (#153)
nicowre Jun 23, 2026
34d58fc
ci: remove prod GitHub App env sync step from deploy-production
Dilmand Jun 23, 2026
88eed42
Merge pull request #154 from DoziLab/fix/remove-prod-github-app-sync
Dilmand Jun 23, 2026
d911463
fix(deployments): expose access.id in /credentials response (#155)
nicowre Jun 23, 2026
c6a29da
feat(student): self-service endpoint for credentials (#156)
nicowre Jun 23, 2026
98a67e0
fix(template-version-files): enforce permission check on PATCH/DELETE…
nicowre Jun 23, 2026
0aa0f17
fix(mypy): normalise file_id to UUID before BaseRepository.delete (#158)
nicowre Jun 23, 2026
10478ba
fix(ansible): rename extra-var key 'groups' to 'deployment_groups' (#…
nicowre Jun 23, 2026
5aa11dc
feat(app-manifest): accept lists for shell_scripts and config_files
nicowre Jun 23, 2026
2641a01
feat(templates): approval flow applies only to public templates (#161)
nicowre Jun 25, 2026
7f5c8aa
feat(deployments): cooperative cancel for in-flight deploys via DELET…
nicowre Jun 25, 2026
61061c2
feat: credentials API exposes group_id+group_name; live SSE; seed rew…
Dilmand Jun 25, 2026
6338ac8
test: align fixtures + backfill migration with new sanitize behavior
Dilmand Jun 25, 2026
f5bfdfb
Merge pull request #163 from DoziLab/feat/credentials-ownership-and-s…
Dilmand Jun 25, 2026
860de18
feat(overleaf): persist activation links via post-Ansible SSH fetch
DarkSerme Jun 29, 2026
61e03e6
Merge pull request #165 from DoziLab/feature/overleaf-activation-links
DarkSerme Jun 29, 2026
61dfaf4
fix(templates): cascade-delete versions when deleting a template (#166)
nicowre Jun 29, 2026
cd5b2e3
fix(alembic): chain cascade migration onto current head 034d40e1dad3 …
nicowre Jun 29, 2026
34da6e9
fix(deployments): clean up Heat stacks on FAILED-deployment delete (#…
nicowre Jun 29, 2026
25c37b7
feat(deployments): auto-resolve course groups on deployment create
Dilmand Jun 29, 2026
186dbfe
Merge pull request #170 from DoziLab/feat/auto-resolve-course-groups
Dilmand Jun 29, 2026
4034535
feat: publish-request flow + semver version validation
DarkSerme Jun 29, 2026
650e1ec
chore(tests): clean up unused imports + variables flagged by ruff
DarkSerme Jun 29, 2026
51a4690
chore: fix mypy type errors in exceptions + version_validator
DarkSerme Jun 29, 2026
1ab3fb6
Merge pull request #171 from DoziLab/feat/publish-flow-and-version-va…
DarkSerme Jun 29, 2026
6367bbe
fix(deployments): create User/CourseMember/GroupMember from wizard st…
nicowre Jun 29, 2026
f48ca65
fix(deployments): persist non-SSH app credentials per group and teacher
Dilmand Jun 29, 2026
1be4c60
Merge pull request #173 from DoziLab/fix/persist-app-credentials-for-…
Dilmand Jun 29, 2026
c46896a
fix(student): surface group_id/group_name on student credential rows …
nicowre Jun 29, 2026
387192e
fix(deployments): garbage-collect orphan student users on deployment …
nicowre Jun 30, 2026
f8482f4
feat(deployments): allow admins to deploy private templates of other …
nicowre Jun 30, 2026
721a3e9
feat(course-filters): admin-managed string filters for course names (…
nicowre Jun 30, 2026
4898ae2
feat(course-filters): tighten PATCH/POST contract (required name, for…
nicowre Jun 30, 2026
c2cd3eb
feat(deployments): redeploy whole deployment or single VM with config…
nicowre Jun 30, 2026
b1ec2da
fix(deployments): close orphan-stack window during (re)deploy (#180)
nicowre Jun 30, 2026
3134442
Feat/lecturer management endpoints (#181)
nicowre Jul 1, 2026
416915d
Feat/template icon upload (#182)
nicowre Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 0 additions & 51 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,6 @@ loki-data/
grafana-data/
promtail-positions.yaml

.DS_Store
.DS_Store
# Local SQLite databases (test fixtures, scratch DBs)
*.db
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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')
138 changes: 138 additions & 0 deletions alembic/versions/b6d52b9f8ea3_backfill_access_group_id.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading