Infrastructure Setup Phase
Overview
The Infrastructure Setup Phase automates the creation of external resources (GitLab groups and projects, Slack channels, Outline collections, Rancher projects, Keycloak groups) per team or per student. It replaces the manual, error-prone setup that large courses would otherwise repeat every semester.
Teams come from a preceding Team Allocation phase through the phase configurator. The service then creates and configures external resources from instructor-defined name templates and permission mappings.
Architecture
Backend: servers/infrastructure_setup/ (Go, Gin, port 8091)
servers/infrastructure_setup/
├── main.go # routes, provider registry, claim recovery
├── sqlc.yaml
├── db/
│ ├── migration/
│ │ ├── 0001_schema.up.sql # enums and tables
│ │ ├── 0002_partial_status.up.sql # adds the 'partial' resource status
│ │ ├── 0003_resource_config_identity.up.sql
│ │ ├── 0004_instance_identity.up.sql
│ │ ├── 0005_instance_labels.up.sql
│ │ └── 0006_instance_members.up.sql # who each run was for, and who got in
│ ├── query/ # sqlc sources
│ └── sqlc/ # generated, committed
├── encryption/aes.go # AES-256-GCM credential encryption
├── provider/
│ ├── interface.go # Provider interface and shared types
│ ├── gitlab/ slack/ outline/ rancher/ keycloak/
├── providerconfig/ # credentials CRUD, validation, provider metadata
├── resourceconfig/ # what to provision, per phase
├── phaseconfig/ # the phase's own settings (semester tag)
├── execution/ # instance lifecycle: trigger, worker, templates
├── copy/ # the SDK PhaseCopyHandler
├── config/ # the SDK PhaseConfigHandler (setup completeness)
├── privacy/ # the SDK privacy export and deletion handlers
└── coursePhaseDeletion/ # the SDK CoursePhaseDeletionHandler
Frontend: clients/infrastructure_setup_component/ (React, TypeScript, port 3012)
The phase root is the student page; the three lecturer pages sit behind it in the sidebar.
- StudentResourcesPage (phase root): what was provisioned for the student and their team,
from
GET /my-resources. Each entry says Ready, Not added yet (with what to do about it), Being set up, Not available yet (a failure the lecturer sees on Provisioning) or Not set up yet, and links to the resource where there is something to open. Error text never reaches this page. A lecturer sees the usual not-a-student notice and a preview built from the resource configs; an editor sees the notice only. - ParticipantsPage: the shared
CoursePhaseParticipationsTablewith one column per resource config, derived from the instances and their members. The columns are filterable, so a lecturer filters a resource by Ready, selects those students and sets them passed with the table's bulk action. A summary counts the participants who can reach every resource. - ConfigurationPage: the three steps of the setup as sections of one page: the semester tag, the providers (configure and validate credentials; a provider without credentials is marked credentials required) and the resources (provider, resource type, scope, name template, permission mapping). Each section has an anchor the provisioning checklist links to.
- ProvisioningPage: a readiness checklist (providers, resources, semester tag, and the teams
and students the server's dry run resolved), what the next run does, the Provision resources
button, and the status list. The list polls every 3s while any instance is
pendingorin_progress. Each row names the team or student, the provider, the resource kind and the resolved resource name; the status counts double as filters and a search box narrows the list, which is what makes 30 teams times three configs readable.
Toasts raised in the remote render through core's <Toaster />, which relies on
@tumaet/prompt-ui-components being in the singleton share scope (see
.claude/rules/module-federation/remotes.md).
Providers
| Provider | Resource types | Credentials | Notes |
|---|---|---|---|
| GitLab | group, project | base_url, private_token, optional parent_group_id | Members are added through the group invitations endpoint, which works with a non-admin PAT and covers users who have not signed in yet. With a parent configured, only that parent's subgroups are searched. parent_group_id is entered as a string and parsed, like every other credential. |
| Slack | channel | bot_token | Creates private channels. Requests are form-encoded, which is what the Web API documents for its read methods. Adoption is limited: see Slack adoption. |
| Outline | collection | api_key, optional base_url | Creates a private collection and grants access through a bound group. See Outline access. |
| Rancher | project | rancher_url, access_key, secret_key, cluster_id | Users are resolved through the principals search endpoint and confirmed against the requested address; the returned principal ID is used as-is. A duplicate binding is recognised by status 409 or a 422 carrying NotUnique; any other 422 (an unknown roleTemplateId, a malformed principal) is reported as a member warning. |
| Keycloak | group | keycloak_url, realm, client_id, client_secret | The service account needs the realm-management roles manage-users and view-users, not realm-admin. Realm users only exist after their first sign-in, so a fresh cohort commonly lands partial until the students have logged in once. |
All providers are idempotent: a re-run adopts what already exists rather than creating a duplicate. Adoption is by exact name or path within a scope the course owns, except for Outline, where a collection is adopted only if PROMPT created it (collections share one flat workspace namespace), and Slack, which cannot see every channel that holds a name (see Slack adoption). Because PROMPT can rarely prove ownership, it never deletes a resource (see Delete semantics).
Provider URLs
base_url, rancher_url and keycloak_url are lecturer input that the service sends requests
to, so they are treated as untrusted:
- On save they must be absolute
httpsURLs with a host and without credentials, a query or a fragment (providers append API paths to the raw string). A literal internal address or alocalhostname is rejected. - The provider HTTP client checks the address every connection uses, after DNS resolution, and
refuses loopback, private, link-local (including the
169.254.169.254metadata endpoint), unspecified, multicast and carrier-grade NAT ranges. This also covers hostnames that resolve internally, DNS changes after saving and every redirect hop. Redirects are re-validated and capped at five, andHTTP(S)_PROXYis ignored. - The validate endpoint reports transport failures generically, so it cannot be used to probe which internal hosts or ports answer. The log keeps the full error.
A provider that really runs on an internal network is allowlisted with
INFRASTRUCTURE_SETUP_ALLOWED_PRIVATE_HOSTS; an allowlisted host may also use plain http.
Slack adoption
Slack lists a private channel only to an app that is a member of it. There is no bot-token call
that reveals the rest, so a private channel a human created holds the name without PROMPT ever
seeing it: conversations.create answers name_taken and the lookup that follows finds nothing.
That is reported as a failed instance naming the channel and saying to add the app to it or rename
it, which is the whole of what can be done from here.
A public channel with the same name is found and deliberately not adopted: this phase provisions private channels, and taking over a public one would quietly widen who can read the team's material. Rename one of the two.
Templated extra config
Most resourceExtraConfig values reach the provider verbatim. Only the keys a provider declares as
templates are resolved with the name-template variables, and those are validated when the config is
saved:
| Provider | Key | Meaning |
|---|---|---|
| GitLab | parent_group_template | Required for project. Names the team subgroup the project is created in. |
| Outline | group_name_template | Optional. Display name of the group bound to the collection; defaults to the collection name. |
Non-templated extra-config keys, passed through verbatim:
| Provider | Key | Meaning |
|---|---|---|
| GitLab | visibility | Project visibility. Defaults to private. |
| GitLab | initialize_with_readme | Create a first commit. Defaults to false; teams push their own repository. |
Every other key stays literal, so a value such as Rancher's roleTemplateId can contain braces.
GitLab groups and projects
A group resource creates one subgroup per team under parent_group_id and invites the members.
A project resource creates the team's subgroup and a project (a repository) inside it. It
requires both parent_group_id on the provider and a parent_group_template resolving to a
subgroup name; without them the project would land in the token owner's personal namespace or need a
top-level group, so both are refused rather than guessed. Typical iPraktikum layout:
ios2526/ <- parent_group_id
ios2526-team-1/ <- parent_group_template: {{semesterTag}}-{{teamName}}
ios2526-team-1-app <- nameTemplate: {{semesterTag}}-{{teamName}}-app
The subgroup is an explicit side effect of the project resource, not a tracked dependency: instances carry no ordering, so a project cannot wait for a separate group row. Both paths adopt by exact path under the same parent, so a group config and a project config for the same team converge on one subgroup.
Members are added to the subgroup only. GitLab group members inherit access to every project in the group, so per-project invitations would add nothing.
Projects are created private explicitly, because the instance-wide
default_project_visibility may be public, and with an empty repository: the phase provisions
the project, the team pushes its own repository. An initial commit made here would give the project a
default branch and a history the team's first push has to reconcile with. Set
initialize_with_readme: true in extra config for a course that wants a starting commit, and
visibility to override the default.
Keycloak service account
Create a confidential client in the realm with Service accounts enabled, then grant its
service-account user these realm-management client roles:
| Role | Why |
|---|---|
manage-users | Creating a group and adding a member to it. There is no manage-groups role; group writes sit under manage-users. |
view-users | Resolving a member's email to a user ID. |
realm-admin also works but grants far more than the phase needs.
Validating the credentials checks both that the client can obtain a token and that the token carries one of those roles, so a service account that can sign in but cannot create groups is reported when the credentials are saved rather than as a 403 on every instance during a run.
Verified against Keycloak 26.6.3: with view-users alone, group creation answers 403.
Outline access
Read this before promising anything to students.
Outline cannot decide collection visibility from a login token. Its OIDC plugin reads no groups claim, and there is no group synchronisation, so a Keycloak group membership never reaches Outline by itself. What actually happens:
- Keycloak (OIDC) authenticates the user, so they can sign in to Outline.
- Outline authorises against its own group membership.
- On each run, and only for the providers that are configured, PROMPT puts the same snapshot of team members into the Keycloak group and into an Outline group, and grants that Outline group access to the collection.
Consequences worth stating plainly:
- Nothing is synchronised after a run, and no membership is ever removed. A student who leaves a team keeps their access until someone removes it by hand. Settle the teams before provisioning.
- The Keycloak and Outline groups are separate objects that happen to hold the same people. Setting
group_name_templateto the same template as the Keycloak group'snameTemplatekeeps the two names aligned, which is a convenience for humans, not a technical link. - An Outline collection config with no Keycloak group config at the same scope still works; the collections are created and access granted through their Outline groups. The Resource Configurations page says so.
Mechanics:
- The collection is created with no
permission, which makes it private. Settingpermissiontoread, as an earlier version did, lets every member of the workspace read it. - The bound group is keyed on Outline's
externalIdusing the instance's stable key, not on a display name, so renaming the collection or editing a template still resolves to the same group. - Members whose roles map to different permissions get one group each, since a group carries a
single collection permission. Split groups are named
<name> (read),<name> (read_write). collections.add_groupalways sends the permission explicitly: Outline defaults that call toread_write.
A provider declares which resource kinds it supports and which extra-config keys a kind cannot be
created without, so a GitLab project without a parent_group_template is rejected when it is
saved rather than once per instance during a run. The API rejects any other resource type, and the
resource config dialog offers only the supported kinds.
Database schema
CREATE TYPE provider_type AS ENUM ('gitlab', 'slack', 'outline', 'rancher', 'keycloak');
CREATE TYPE resource_scope AS ENUM ('per_team', 'per_student');
CREATE TYPE resource_status AS ENUM ('pending', 'in_progress', 'created', 'failed', 'partial');
-- The phase's own settings.
CREATE TABLE course_phase_config (
course_phase_id uuid PRIMARY KEY,
semester_tag text NOT NULL DEFAULT ''
);
-- Encrypted provider credentials, one row per (phase, provider type).
CREATE TABLE provider_config (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
course_phase_id uuid NOT NULL,
provider_type provider_type NOT NULL,
credentials bytea NOT NULL DEFAULT ''::bytea, -- 12-byte nonce || ciphertext
CONSTRAINT uq_provider_config_phase_type UNIQUE (course_phase_id, provider_type)
);
-- What to create, with name templates and permission mappings.
CREATE TABLE resource_config (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
course_phase_id uuid NOT NULL,
provider_type provider_type NOT NULL,
resource_type text NOT NULL,
scope resource_scope NOT NULL,
name_template text NOT NULL,
permission_mapping jsonb NOT NULL DEFAULT '{}',
resource_extra_config jsonb NOT NULL DEFAULT '{}',
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_resource_config_provider
FOREIGN KEY (course_phase_id, provider_type)
REFERENCES provider_config (course_phase_id, provider_type) ON DELETE CASCADE,
CONSTRAINT uq_resource_config_identity
UNIQUE (course_phase_id, provider_type, resource_type, scope, name_template)
);
-- One row per provisioned resource, with its lifecycle status.
CREATE TABLE resource_instance (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
resource_config_id uuid NOT NULL REFERENCES resource_config(id) ON DELETE CASCADE,
course_phase_id uuid NOT NULL,
team_id uuid,
course_participation_id uuid,
status resource_status NOT NULL DEFAULT 'pending',
external_id text,
external_url text,
error_message text,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Labels for the execution list: the team or student the row is about, and the name
-- the provider was last asked to create. Both are resolved at run time and would
-- otherwise cost the list an HTTP round trip to core on every poll.
target_name text NOT NULL DEFAULT '',
resolved_name text NOT NULL DEFAULT ''
);
-- Exactly one instance per (config, team) and (config, student), whatever its status.
CREATE UNIQUE INDEX uq_resource_instance_team
ON resource_instance (resource_config_id, team_id)
WHERE team_id IS NOT NULL;
CREATE UNIQUE INDEX uq_resource_instance_student
ON resource_instance (resource_config_id, course_participation_id)
WHERE course_participation_id IS NOT NULL;
-- Who the latest run of an instance was for, and whether each person was granted access.
CREATE TABLE resource_instance_member (
resource_instance_id uuid NOT NULL REFERENCES resource_instance(id) ON DELETE CASCADE,
course_participation_id uuid NOT NULL,
granted boolean NOT NULL,
PRIMARY KEY (resource_instance_id, course_participation_id)
);
Instance members
An instance names only its target, a team or one student, so on its own nothing links a student to
their team's resources, or says which members a partial run left out. Every run therefore records
the people its target stands for in resource_instance_member, replacing the previous run's rows:
- The resolver lists every team member and tutor, including those it could not turn into a provider member (no participation in this phase, no email address).
- Providers report warnings as
provider.Warning{Text, Members}.Textis what the lecturer reads in the instance's error details;Membersnames the emails the warning left without access. A per-member failure names that member; a failure that locks out a whole group (Outline's user lookup or group binding) names every member of it. - A person is recorded as
grantedwhen the resource exists, an email could be resolved for them, and no warning names them. A failed run records its people withgranted = false. - A trigger already writes the rows when it queues an instance, with
granted = false, so a team's members see their resource being set up while it waits for a worker instead of finding nothing.
The student page and the participants columns derive one state per person and resource from the
instance status and that flag. An instance whose run predates the table has no member rows: a
personal one reads by its status (a partial one as partly set up), and a team one reaches its
members once it runs again. A failed or partial one does on its next retry; a created one is
queued again by the next trigger once its team has members (the provider adopts the existing
resource, so nothing is duplicated).
Permission mapping
A permission mapping is keyed on the roles resolution assigns, and there are exactly two:
student and tutor. A row keyed on anything else is never matched, so it silently does nothing;
the resource config dialog therefore offers only those two.
keycloak and slack ignore the mapping altogether: a group member and a channel member have no
per-member permission level. GitLab, Outline and Rancher use it, and a member whose role the mapping
does not cover becomes an instance warning (Rancher falls back to the roleTemplateId extra config
first).
Members that cannot be granted access
Emails are resolved from this phase's participations. A team member or tutor who is not a
participant of the phase (tutors are assigned at course level), or whose participation carries no
email address, cannot be turned into a member. Those people are reported as instance warnings, so
the resource is created, the instance comes back partial and error_message names them. They are
never dropped silently: a tutor permission mapping that reaches nobody would otherwise look like a
clean success.
To grant a tutor access, add them to this phase's participants as well.
Instance statuses
| Status | Meaning |
|---|---|
pending | Queued, not yet picked up. |
in_progress | Claimed by a worker. |
created | The resource exists and every member was granted access. |
partial | The resource exists, but at least one member could not be added. error_message lists them, external_id/external_url are kept. Retryable. |
failed | The resource could not be created. error_message explains why. Retryable. |
Credential encryption
Credentials are stored as AES-256-GCM encrypted bytea, formatted as 12-byte nonce || ciphertext
with a fresh random nonce per call. The key comes from ENCRYPTION_KEY, a base64-encoded 32-byte
value, and is validated at startup so a misconfigured deployment fails immediately rather than on
the first credential write.
openssl rand -base64 32
The .env templates ship a placeholder key so a fresh checkout starts. Because it is committed and
therefore public, the service refuses it unless DEBUG=true: a deployment created with
cp .env.template .env would otherwise encrypt GitLab tokens, Slack bot tokens and Rancher secret
keys with a key anyone can read out of the repository.
The API never returns credentials. GET /provider-configs exposes only the provider type and a
configured boolean. Rotating the key requires re-encrypting every provider_config.credentials
row by hand.
Name templates
| Placeholder | Description |
|---|---|
{{teamName}} | Team name (per_team scope) |
{{semesterTag}} | The phase's semester tag (e.g. ss25) |
{{studentName}} | Full name (per_student scope) |
{{studentFirstName}} | First name (per_student scope) |
{{studentLastName}} | Last name (per_student scope) |
{{studentEmail}} | Email address (per_student scope) |
{{studentLogin}} | University login (per_student scope) |
{{.TeamName}}, {{.StudentLogin}}, {{.Semester}} and {{.SemesterTag}} are accepted as
aliases. Anything else is rejected: when the resource config is saved, and again before the
worker calls a provider, so an unresolved {{...}} can never end up in the name of a real
resource.
A template that uses {{semesterTag}} is refused at trigger time (400) while the phase has no
semester tag saved. The setup page prefills the field from the parent course as a suggestion, not
as a value: without this guard a phase whose setup page was never saved would name a team's GitLab
group -ios-team-1, since resolution replaces the empty tag with nothing.
A placeholder the config's scope does not fill is rejected too. Resolution replaces it with an empty
string, so a per_team config named team-{{studentLogin}} would resolve to the same team for
every team and put all of them into one external resource. The scope each placeholder belongs to is
declared once, in the table execution/template.go resolves from.
Values are sanitized generically and then again per provider (a Slack channel name is slugified and truncated to 80 characters, a GitLab path is turned into a slug). A name that sanitizes to an empty identifier is an error rather than a request.
Execution
POST .../execute:
- Loads the phase's resource configs. A phase with none answers 400, and so does one whose providers have no credentials (the state a copied phase starts in).
- Resolves targets from core, once per scope. This is HTTP, so it happens before the transaction.
- Opens a transaction, takes a per-phase advisory lock, checks that no non-terminal instance exists, and converges the instances on the configs. The lock makes the check and the writes atomic: a second trigger arriving at the same time gets 409 instead of starting a second run.
- Commits, then starts the background worker if anything was queued.
Preview
GET .../execute/preview runs the same checks and target resolution as a trigger and plans the
same convergence, but writes nothing. It answers with the counts a trigger would produce, the
number of instances a run is still working on, and how many teams and students were resolved:
{ "queued": 3, "requeued": 12, "upToDate": 15, "running": 0, "teams": 30, "students": null }
A refusal is the trigger's: 400 for a phase with no resource configs, a provider without
credentials, a template needing an unsaved semester tag, or a per-team config in a phase no teams
reach through the course's phase graph. The Provisioning page shows it as the blocking item of its
checklist. The trigger and the preview share planInstances, so they cannot disagree.
Convergence
A (resource config, target) pair carries exactly one instance for the life of the phase, and
the database enforces it. Triggering again therefore converges on the rows that are already there:
| Existing instance | What the trigger does |
|---|---|
| none | creates a pending instance |
failed | queues it for another attempt |
partial | queues it for another attempt |
created | leaves it alone |
pending / in_progress | nothing: the phase answers 409, a run is already going |
This matters because the usual first outcome of a run is a mix: a fresh cohort lands partial
until the students have signed in once. Pressing the button again retries exactly those and leaves
the finished resources untouched. A target that appeared since the last run (a team added later)
gets its instance without touching the others.
The response says what happened, so a trigger that had nothing to do reports that rather than success:
{ "queued": 3, "requeued": 12, "upToDate": 15 }
Membership is still not reconciled: an instance that is already created is not re-run, so a
student who joins a team after a successful run does not reach the resource. See
Outline access.
The worker claims all pending instances of the phase in a single
UPDATE ... FOR UPDATE SKIP LOCKED, so two workers can never process the same row. It then
processes up to 5 instances concurrently. Each one resolves its name, calls
provider.CreateResource, and is recorded as created, partial or failed. Provider calls are
retried up to 3 times with exponential backoff (1s, 2s, 4s) plus jitter.
Only failures that could plausibly succeed next time are retried. An upstream 4xx describes the
request (a revoked token, an invalid name, a parent group that does not exist) and answers the same
way three times over, so it fails the instance immediately; 408 and 429 are the exceptions, and
an error carrying no HTTP status (a dial failure, a timeout) is always retried.
Timeouts: every upstream request has a 30s client timeout and every CreateResource attempt a
5 minute deadline of its own. A provider host that accepts the connection and never answers would
otherwise hold a worker goroutine until the run's own 30 minute context expired, so one unreachable
provider would consume the whole run without ever reaching the retry logic.
Claim recovery: a claim is only ever released by the process that took it. If resolving the
configs or the targets fails after the instances were claimed, that run marks them failed with the
reason, so the phase can be triggered again and the lecturer can retry. A crash cannot do that, so a
sweeper (at boot and every 10 minutes) marks any instance claimed longer than the worker's own
timeout as failed too. The age cutoff is what keeps the sweep off another replica's live work, and
failed rather than pending because nothing picks a pending row up on its own: the worker needs
the lecturer's token to resolve targets.
Retry (POST .../instances/:instanceID/retry) accepts failed and partial instances. An
unknown instance is a 404; one that is created or already queued is a 409. Since providers are
idempotent, retrying a partial instance heals it once the missing users exist upstream. It is the
single-instance form of what a trigger does to every unfinished instance at once.
Delete semantics
DELETE .../instances/:instanceID removes only the PROMPT row. The external resource is never
touched, and the confirmation dialog says so.
This is deliberate. Providers adopt existing resources by name or path, so PROMPT usually cannot
tell whether a group it points at was created for this course or already belonged to someone else;
deleting could remove a shared resource. Deleting a resource config or a provider config cascades to
the instance rows in SQL, which would bypass any per-instance cleanup anyway. external_id and
external_url are kept on the instance so the resource can be found and removed by hand.
Because those rows are the only record of resources nothing else will clean up, deleting a resource
config that still has provisioned instances takes an explicit confirm=true; without it the
endpoint answers 409. The UI sends it once the lecturer has confirmed the dialog, which spells
out that the external resources stay behind.
Privacy and phase deletion
Core fans a privacy request out to every phase type of the courses a subject is in, so these
endpoints are not optional: without them one unanswered service marks the whole deletion request
failed. What the phase stores about a person is the instances of its per_student configs and
their membership in any instance, so the export carries both (with the provider and resource type
joined in) and the deletion removes both. A team-scoped instance stays: it belongs to the team, not
to one member, and only the subject's own member row goes.
Deleting the phase itself removes every row the service holds for it, the encrypted provider credentials included. Both deletions stop at PROMPT's own records: the external resources survive, so a group named after a student outlives their deletion request and has to be removed in the provider by hand.
The same applies to memberships: nothing is ever removed from a group, a channel or a collection. Reconciling PROMPT's team data against upstream memberships is a separate feature with its own questions (which side is authoritative, what happens to a member somebody added by hand), and it is not implemented.
Editing a configuration after a run
Once a config has an instance that is not failed, the resource may exist upstream and what it is
called and where it lives is fixed: resource_type, scope, name_template and
resource_extra_config can no longer change. The instance would keep pointing at the object that
exists while the config described a differently named one, with nothing in the UI to reveal the
mismatch.
The permission mapping stays editable as long as no instance of that config is pending or
in_progress. A role the mapping does not cover becomes a member warning, which is the most common
reason a first run comes back partial, and a retry re-reads the stored mapping: refusing that edit
would mean deleting every instance of the config to fix a typo. It does not reach memberships that
were already granted, so it heals the members that were missed, not the ones added under the old
mapping.
The dialog says which fields are locked and the config card is badged provisioned. To change the rest, delete the config's instances first.
Because the resource-config uniqueness constraint covers
(course_phase_id, provider_type, resource_type, scope, name_template) and not the extra config,
two configs that differ only by parent_group_template are rejected as duplicates.
Phase copy
Copying a phase carries over, in one transaction:
course_phase_config, including the semester tag. Without this row the config endpoint reports nothing as configured at all.provider_configrows with empty credentials. The instructor must re-enter the secrets.resource_configrows. Copying the same source twice does not duplicate them.
resource_instance rows are not copied; provisioning is triggered again on the new phase.
Until the credentials are re-entered, the copied phase reports providerConfig: false, its
providers show credentials required, resource configs cannot be created against them, and
triggering execution is refused.
API reference
Phase-scoped routes live under /infrastructure-setup/api/course_phase/:coursePhaseID and require
PromptAdmin or CourseLecturer. These routes carry external credentials, so CourseEditor is
deliberately not granted access. The one exception is GET /my-resources, which requires
CourseStudent and answers for the caller's own course participation only.
| Method | Path | Description |
|---|---|---|
| GET | /setup-config | Read the phase settings (semester tag) |
| PUT | /setup-config | Update the phase settings |
| GET | /provider-configs | List providers with a configured flag; credentials are never returned |
| PUT | /provider-configs | Create or replace credentials for one provider |
| DELETE | /provider-configs/:providerType | Remove credentials; resource configs and instances cascade |
| POST | /provider-configs/:providerType/validate | Test the stored credentials against the provider |
| GET | /provider-configs/:providerType/fields | Credential fields the provider needs |
| GET | /provider-configs/:providerType/resource-types | Resource kinds the provider supports |
| GET | /resource-configs | List resource configs |
| POST | /resource-configs | Create a resource config |
| GET | /resource-configs/:resourceConfigID | Read a resource config |
| PUT | /resource-configs/:resourceConfigID | Update a resource config |
| DELETE | /resource-configs/:resourceConfigID | Delete a resource config |
| GET | /instances | List instances, their status and their members |
| POST | /execute | Create pending instances and start provisioning (202, or 409 while a run is active) |
| GET | /execute/preview | What a trigger would do now, without doing it (see Preview) |
| GET | /my-resources | The caller's own and team resources, for a student; no error text |
| POST | /instances/:instanceID/retry | Re-queue a failed or partial instance |
| DELETE | /instances/:instanceID | Delete the PROMPT row; the external resource is untouched |
| GET | /config | Readiness flags consumed by core |
| POST | /delete | Drop everything stored for the phase, called when core deletes it |
The SDK-registered routes below are not phase-scoped and protect themselves:
| Method | Path | Description |
|---|---|---|
| POST | /infrastructure-setup/api/copy | Phase copy handler |
| POST | /infrastructure-setup/api/privacy/data-export | Privacy export, called by core per phase type |
| POST | /infrastructure-setup/api/privacy/data-deletion | Privacy deletion, admin token only |
| GET | /infrastructure-setup/api/info | Public service info consumed by the management console |
Environment variables
| Variable | Required | Description |
|---|---|---|
ENCRYPTION_KEY | Yes | Base64-encoded 32-byte AES key. Validated at startup. |
INFRASTRUCTURE_SETUP_ALLOWED_PRIVATE_HOSTS | No | Comma-separated hostnames, IPs or CIDR ranges of providers on an internal network. See Provider URLs. Empty by default. |
DB_INFRASTRUCTURE_SETUP_HOST / DB_HOST_INFRASTRUCTURE_SETUP | Yes | PostgreSQL host (the compose file maps the first onto the second) |
DB_PORT_INFRASTRUCTURE_SETUP | Yes | PostgreSQL port (5441 on the host in local dev) |
DB_NAME, DB_USER, DB_PASSWORD | Yes | Database credentials |
KEYCLOAK_HOST, KEYCLOAK_REALM_NAME | Yes | Auth middleware configuration |
CORE_HOST | Yes | Client origin, used for CORS |
SERVER_CORE_HOST | Yes | Core service URL for inter-service calls |
INFRASTRUCTURE_SETUP_HOST | Yes (client) | Where the micro-frontend sends its requests |
SERVER_ADDRESS | No | Bind address (default localhost:8091) |
SENTRY_DSN_INFRASTRUCTURE_SETUP, SENTRY_ENABLED | No | Error reporting |
DEBUG | No | Debug logging |
Tests
make test-infrastructure-setup
DB-backed tests use testcontainers-go and build their schema from db/migration, so the tests
always run against the production schema.
- encryption — round-trip, wrong key, nonce uniqueness, corrupted ciphertext.
- execution/template — every placeholder and alias, sanitization, and rejection of unknown placeholders.
- execution/worker — created, partial (with the external ID preserved), retry to exhaustion, success after a transient failure, a vanished target, an unresolvable template, a resolver failure handing the claimed instances back, and the sweep over abandoned claims. Two workers racing on one phase call the provider exactly once. Each run records who it was for and who was let in, a failed run included, and the next run replaces those rows.
- execution/preview, execution/my_resources: the preview reports what a trigger would do and writes nothing; a student sees their own and their team's resources, never another team's, never error text and never a Keycloak admin link.
- execution/service — instances per team and per student, 409 for a second trigger, two concurrent triggers on separate connections creating one run, and retry returning 404/409.
- execution/target_resolver — malformed upstream payloads are skipped, not fatal.
- provider/* — an
httptestserver per provider covering create, idempotency, member handling and each provider-specific fix. - providerconfig / resourceconfig — credentials are encrypted at rest and never returned, non-string and unknown credential fields are rejected, and resource types are validated against the provider.
- privacy / coursePhaseDeletion — the export and deletion cover the subject's own instances and their memberships, and leave the team's instance and the other members alone; deleting a phase empties every table it owns (counted on the raw tables, so a weakened cascade fails) and is idempotent.
- copy / config / phaseconfig — the copy endpoint answers with exactly one JSON body, a copied phase reports its semester tag and resource configs but not its providers, copying twice does not duplicate anything, and a phase with no config row reads as unconfigured rather than failing.
End-to-end coverage lives in e2e/tests/infrastructure-setup/. The UI journey provisions Keycloak
groups in the stack's own realm through the seeded prompt-server service account, so one run
ends with a participant who was let in and one who was not.
Infrastructure
Compose (docker-compose.yml) adds server-infrastructure-setup (host port 8091),
client-infrastructure-setup-component (host port 3012) and db-infrastructure-setup (host port
5441). The server waits for both its database and Keycloak to become healthy, since it performs OIDC
discovery at startup.
Core registers the phase in servers/core/coursePhaseType/initializeTypes.go as
Infrastructure Setup, declaring the team and team-allocation inputs as required, with a base URL
of http://localhost:8091/infrastructure-setup/api in dev and {CORE_HOST}/infrastructure-setup/api
in production.
Known limitations
- Outline cannot authorise from a token. See Outline access. Group membership in Outline is provisioned by PROMPT, not derived from the OIDC login.
- Nothing is ever removed. No external resource is deleted and no membership is revoked, so teams must be settled before provisioning. See Delete semantics.
- A config is frozen once it has provisioned. Editing requires deleting the instances first; there is no config versioning. See Editing a configuration after a run.
- Slack, Outline and Rancher have not been exercised against real tenants. They are covered by mocked HTTP tests written against the published API contracts. GitLab and Keycloak are the two reasoned through end to end.
- Only GitLab and Outline re-attach by recorded ID. Retrying an instance whose name template changed re-uses the resource it points at there; Slack, Keycloak and Rancher resolve by name, so a renamed resource is adopted only if the name still matches.