Skip to main content

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 CoursePhaseParticipationsTable with 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 pending or in_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​

ProviderResource typesCredentialsNotes
GitLabgroup, projectbase_url, private_token, optional parent_group_idMembers 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.
Slackchannelbot_tokenCreates private channels. Requests are form-encoded, which is what the Web API documents for its read methods. Adoption is limited: see Slack adoption.
Outlinecollectionapi_key, optional base_urlCreates a private collection and grants access through a bound group. See Outline access.
Rancherprojectrancher_url, access_key, secret_key, cluster_idUsers 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.
Keycloakgroupkeycloak_url, realm, client_id, client_secretThe 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 https URLs with a host and without credentials, a query or a fragment (providers append API paths to the raw string). A literal internal address or a localhost name 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.254 metadata 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, and HTTP(S)_PROXY is 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:

ProviderKeyMeaning
GitLabparent_group_templateRequired for project. Names the team subgroup the project is created in.
Outlinegroup_name_templateOptional. Display name of the group bound to the collection; defaults to the collection name.

Non-templated extra-config keys, passed through verbatim:

ProviderKeyMeaning
GitLabvisibilityProject visibility. Defaults to private.
GitLabinitialize_with_readmeCreate 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:

RoleWhy
manage-usersCreating a group and adding a member to it. There is no manage-groups role; group writes sit under manage-users.
view-usersResolving 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_template to the same template as the Keycloak group's nameTemplate keeps 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. Setting permission to read, as an earlier version did, lets every member of the workspace read it.
  • The bound group is keyed on Outline's externalId using 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_group always sends the permission explicitly: Outline defaults that call to read_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}. Text is what the lecturer reads in the instance's error details; Members names 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 granted when the resource exists, an email could be resolved for them, and no warning names them. A failed run records its people with granted = 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​

StatusMeaning
pendingQueued, not yet picked up.
in_progressClaimed by a worker.
createdThe resource exists and every member was granted access.
partialThe resource exists, but at least one member could not be added. error_message lists them, external_id/external_url are kept. Retryable.
failedThe 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​

PlaceholderDescription
{{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:

  1. 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).
  2. Resolves targets from core, once per scope. This is HTTP, so it happens before the transaction.
  3. 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.
  4. 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 instanceWhat the trigger does
nonecreates a pending instance
failedqueues it for another attempt
partialqueues it for another attempt
createdleaves it alone
pending / in_progressnothing: 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_config rows with empty credentials. The instructor must re-enter the secrets.
  • resource_config rows. 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.

MethodPathDescription
GET/setup-configRead the phase settings (semester tag)
PUT/setup-configUpdate the phase settings
GET/provider-configsList providers with a configured flag; credentials are never returned
PUT/provider-configsCreate or replace credentials for one provider
DELETE/provider-configs/:providerTypeRemove credentials; resource configs and instances cascade
POST/provider-configs/:providerType/validateTest the stored credentials against the provider
GET/provider-configs/:providerType/fieldsCredential fields the provider needs
GET/provider-configs/:providerType/resource-typesResource kinds the provider supports
GET/resource-configsList resource configs
POST/resource-configsCreate a resource config
GET/resource-configs/:resourceConfigIDRead a resource config
PUT/resource-configs/:resourceConfigIDUpdate a resource config
DELETE/resource-configs/:resourceConfigIDDelete a resource config
GET/instancesList instances, their status and their members
POST/executeCreate pending instances and start provisioning (202, or 409 while a run is active)
GET/execute/previewWhat a trigger would do now, without doing it (see Preview)
GET/my-resourcesThe caller's own and team resources, for a student; no error text
POST/instances/:instanceID/retryRe-queue a failed or partial instance
DELETE/instances/:instanceIDDelete the PROMPT row; the external resource is untouched
GET/configReadiness flags consumed by core
POST/deleteDrop everything stored for the phase, called when core deletes it

The SDK-registered routes below are not phase-scoped and protect themselves:

MethodPathDescription
POST/infrastructure-setup/api/copyPhase copy handler
POST/infrastructure-setup/api/privacy/data-exportPrivacy export, called by core per phase type
POST/infrastructure-setup/api/privacy/data-deletionPrivacy deletion, admin token only
GET/infrastructure-setup/api/infoPublic service info consumed by the management console

Environment variables​

VariableRequiredDescription
ENCRYPTION_KEYYesBase64-encoded 32-byte AES key. Validated at startup.
INFRASTRUCTURE_SETUP_ALLOWED_PRIVATE_HOSTSNoComma-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_SETUPYesPostgreSQL host (the compose file maps the first onto the second)
DB_PORT_INFRASTRUCTURE_SETUPYesPostgreSQL port (5441 on the host in local dev)
DB_NAME, DB_USER, DB_PASSWORDYesDatabase credentials
KEYCLOAK_HOST, KEYCLOAK_REALM_NAMEYesAuth middleware configuration
CORE_HOSTYesClient origin, used for CORS
SERVER_CORE_HOSTYesCore service URL for inter-service calls
INFRASTRUCTURE_SETUP_HOSTYes (client)Where the micro-frontend sends its requests
SERVER_ADDRESSNoBind address (default localhost:8091)
SENTRY_DSN_INFRASTRUCTURE_SETUP, SENTRY_ENABLEDNoError reporting
DEBUGNoDebug 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 httptest server 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.