fix server creation run binding

This commit is contained in:
npc0-hue
2026-08-04 17:14:28 +08:00
parent 980f1786dd
commit 5fbe8092b9
13 changed files with 80 additions and 53 deletions
+16 -10
View File
@@ -783,14 +783,22 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{
ID: "server-create",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "SCUM Create",
IdempotencyKey: "idem-create",
ProfileKey: "local",
}, adminSession)
if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall {
if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateDraft || created.Job.ID != "" || created.Instance.RunEndpointID != "" {
t.Fatalf("expected create workflow response, got %+v", created)
}
legacyCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{
ID: "server-create-legacy",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "SCUM Legacy Create",
IdempotencyKey: "idem-create-legacy",
ProfileKey: "local",
Bindings: map[string]string{"server-root": "runtime.server-root"},
}, adminSession)
assertErrorResponse(t, legacyCreate, http.StatusBadRequest, errorCodeValidation)
ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-ready",
@@ -844,7 +852,7 @@ func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) {
endpoint := validRunEndpointRequest()
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDeploymentPlan)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "deployment-reveal", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Reveal", IdempotencyKey: "deployment-reveal", ProfileKey: "local", Deployment: dto.ServerDeploymentRequest{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/reveal", WorkingDirectory: "/srv/reveal", StartCommand: "./start-server"}}, adminSession)
postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "deployment-reveal", PluginID: "server.scum", Name: "Reveal", IdempotencyKey: "deployment-reveal", Deployment: dto.ServerDeploymentRequest{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/reveal", WorkingDirectory: "/srv/reveal", StartCommand: "./start-server"}}, adminSession)
redactedRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment", "", adminSession)
assertStatus(t, redactedRecorder, http.StatusOK)
@@ -852,8 +860,8 @@ func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) {
t.Fatalf("normal deployment view leaked protected inputs: %s", body)
}
redacted := decodeBody[dto.ServerDeploymentResponse](t, redactedRecorder)
if redacted.LatestDispatch == nil || !redacted.LatestDispatch.DeploymentDefinitionIncluded || redacted.LatestDispatch.JobID != created.Job.ID || redacted.LatestDispatch.DeploymentRevision != 1 {
t.Fatalf("expected safe deployment dispatch evidence, got %+v", redacted.LatestDispatch)
if redacted.LatestDispatch != nil {
t.Fatalf("create-time deployment settings must not dispatch to Run, got %+v", redacted.LatestDispatch)
}
revealed := getJSONWithAuth[dto.ServerDeploymentRevealResponse](t, router, "/api/v1/server-instances/deployment-reveal/deployment/reveal", adminSession)
@@ -1631,10 +1639,8 @@ func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
undeclared := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"host.socket": "runtime.socket"}}, adminSession)
assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation)
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
if created.Job.TargetKey != "actions/install.json" {
t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job)
}
legacyCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
assertErrorResponse(t, legacyCreate, http.StatusBadRequest, errorCodeValidation)
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-incomplete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Incomplete", IdempotencyKey: "runtime-create-incomplete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
assertErrorResponse(t, incompleteCreate, http.StatusBadRequest, errorCodeValidation)
missingServer := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/runtime-create-incomplete", "", adminSession)
+1 -1
View File
@@ -137,7 +137,7 @@ Artifact bridge execution returns safe metadata and platform content routes only
## Implemented Server Lifecycle Actions
- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser must not require a deployment target, run endpoint, or runtime profile at creation; those bindings remain post-creation/runtime-registration concerns. Legacy `runEndpointId` and `deploymentTargetId` inputs remain accepted for compatible programmatic flows, but are never prerequisites for creation and do not select a distribution builder.
- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser must not require or submit a deployment target, run endpoint, runtime profile, or runtime bindings during creation; those bindings remain post-creation/runtime-registration concerns. Requests that include `deploymentTargetId`, `runEndpointId`, `profileKey`, `bindings`, `deployment.runEndpointId`, `deployment.profileKey`, or `deployment.runtimeBindings` are rejected.
- `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`.
- `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`.
@@ -29,6 +29,10 @@ func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *ht
writeDecodeError(w, err)
return
}
if err := request.ValidateCreateOnly(); err != nil {
writeServiceError(w, err)
return
}
result, err := h.core.CreateServerInstanceWorkflowForSession(bearerToken(r), request.ToDomain())
if err != nil {
writeServiceError(w, err)
+36 -5
View File
@@ -1,9 +1,11 @@
package dto
import (
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
type ServerDeploymentRequest struct {
@@ -78,7 +80,7 @@ type ServerLifecycleCreateRequest struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
DeploymentTargetID string `json:"deploymentTargetId,omitempty"`
RunEndpointID string `json:"runEndpointId"`
RunEndpointID string `json:"runEndpointId,omitempty"`
Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
@@ -87,6 +89,35 @@ type ServerLifecycleCreateRequest struct {
Deployment ServerDeploymentRequest `json:"deployment,omitempty"`
}
func (request ServerLifecycleCreateRequest) ValidateCreateOnly() error {
var violations []string
if strings.TrimSpace(request.DeploymentTargetID) != "" {
violations = append(violations, "deploymentTargetId must not be provided during server creation")
}
if strings.TrimSpace(request.RunEndpointID) != "" {
violations = append(violations, "runEndpointId must not be provided during server creation")
}
if strings.TrimSpace(request.ProfileKey) != "" {
violations = append(violations, "profileKey must not be provided during server creation")
}
if len(request.Bindings) > 0 {
violations = append(violations, "bindings must not be provided during server creation")
}
if strings.TrimSpace(request.Deployment.RunEndpointID) != "" {
violations = append(violations, "deployment.runEndpointId must not be provided during server creation")
}
if strings.TrimSpace(request.Deployment.ProfileKey) != "" {
violations = append(violations, "deployment.profileKey must not be provided during server creation")
}
if len(request.Deployment.RuntimeBindings) > 0 {
violations = append(violations, "deployment.runtimeBindings must not be provided during server creation")
}
if len(violations) > 0 {
return validator.ValidationError{Violations: violations}
}
return nil
}
type ServerLifecycleCommandRequest struct {
ExpectedConfigVersion int `json:"expectedConfigVersion"`
IdempotencyKey string `json:"idempotencyKey"`
@@ -103,13 +134,13 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre
return domain.ServerLifecycleCreate{
ID: request.ID,
PluginID: request.PluginID,
DeploymentTargetID: request.DeploymentTargetID,
RunEndpointID: request.RunEndpointID,
DeploymentTargetID: "",
RunEndpointID: "",
Name: request.Name,
OwnerUserID: request.OwnerUserID,
IdempotencyKey: request.IdempotencyKey,
ProfileKey: request.ProfileKey,
Bindings: domain.CopyStringMap(request.Bindings),
ProfileKey: "",
Bindings: nil,
Deployment: request.Deployment.deploymentDefinition(),
}
}
+1 -1
View File
@@ -621,7 +621,7 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] });
await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum });
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create", profileKey: "local", bindings: {} })).resolves.toMatchObject({
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({
action: "create"
});
await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" });
+1 -1
View File
@@ -22,7 +22,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
## Server Management Workflows
- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It does not require deployment target, run endpoint, or runtime profile selection during creation.
- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It must not include deployment target, run endpoint, runtime profile, or runtime bindings during creation; those are established only after creation through generated Run registration, runtime binding, or deployment update flows.
- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values.
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
-4
View File
@@ -505,12 +505,8 @@ export interface ServerInstanceListResponse {
export interface ServerLifecycleCreateRequest {
id: string;
pluginId: string;
deploymentTargetId?: string;
runEndpointId?: string;
name: string;
idempotencyKey: string;
profileKey?: string;
bindings?: Record<string, string>;
deployment?: ServerDeploymentRequest;
}
@@ -116,13 +116,17 @@ describe("ServerDeploymentWorkflow", () => {
pluginId: "game.runtime",
name: "Custom Runtime Server",
idempotencyKey: "web:create:server-custom-runtime-server-17:17",
runEndpointId: undefined,
deployment: {
mode: "custom-command",
serverRoot: "/srv/custom-runtime",
startCommand: "./start-runtime.sh"
}
});
expect(submitted && "runEndpointId" in submitted).toBe(false);
expect(submitted && "profileKey" in submitted).toBe(false);
expect(submitted && "bindings" in submitted).toBe(false);
expect(submitted?.deployment && "profileKey" in submitted.deployment).toBe(false);
expect(submitted?.deployment && "runtimeBindings" in submitted.deployment).toBe(false);
});
});
+5
View File
@@ -11,6 +11,7 @@ import { UsersPage } from "./UsersPage";
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
import serverLiveOperationsSource from "../components/ServerLiveOperations.tsx?raw";
import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw";
import serversPageSource from "./ServersPage.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { PageComponentProps } from "../contracts/page";
@@ -239,6 +240,10 @@ describe("first-party console pages", () => {
expect(serverDeploymentWorkflowSource).toContain('const configurationStep = kind === "create" ? 2 : needsTargetSelection ? 1 : 0;');
expect(serverDeploymentWorkflowSource).toContain('const needsTargetSelection = kind === "edit" && !initialForm.runEndpointId;');
expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)");
expect(serverCreateSchemaSource).not.toContain("runEndpointId: form.runEndpointId");
expect(serverCreateSchemaSource).not.toContain("deploymentTargetId: form.deploymentTargetId");
expect(serverCreateSchemaSource).not.toContain("profileKey: form.profileKey");
expect(serverCreateSchemaSource).not.toContain("runtimeBindings:");
});
it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => {
+1 -3
View File
@@ -107,9 +107,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
pluginId: plugin?.id ?? "",
profileKey,
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
? current.runEndpointId
: endpointResponse.items[0]?.id || ""
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId) ? current.runEndpointId : ""
};
});
if (showLoading) {
@@ -53,7 +53,7 @@ describe("runtime profile server creation contracts", () => {
expect(runtimeBindingFields(plugin, "local").some((field) => field.key === "ftp.profile")).toBe(false);
});
it("selects the plugin profile and submits real profile bindings", () => {
it("submits deployment inputs without binding a Run or runtime profile", () => {
const form = defaultServerCreateForm([plugin], []);
expect(form.profileKey).toBe("local");
expect(
@@ -69,15 +69,10 @@ describe("runtime profile server creation contracts", () => {
).toEqual({
id: "server-1",
pluginId: "game.runtime",
runEndpointId: undefined,
name: "Runtime Server",
idempotencyKey: "web:create:server-1:17",
profileKey: "local",
bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" },
deployment: {
mode: "guided-install",
profileKey: "local",
runtimeBindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" },
createInputs: { gamePort: "7777", maxPlayers: "64" }
}
});
@@ -117,7 +112,11 @@ describe("runtime profile server creation contracts", () => {
it("keeps complete paths and commands in a write-only deployment payload", () => {
const form = defaultServerCreateForm([plugin], []);
const request = serverCreateRequestFromForm({ ...form, name: "Venv Server", deploymentMode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py", shell: "" }, 19);
expect(request.runEndpointId).toBeUndefined();
expect("runEndpointId" in request).toBe(false);
expect("profileKey" in request).toBe(false);
expect("bindings" in request).toBe(false);
expect(request.deployment).toMatchObject({ mode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py" });
expect("profileKey" in (request.deployment ?? {})).toBe(false);
expect("runtimeBindings" in (request.deployment ?? {})).toBe(false);
});
});
-6
View File
@@ -26,16 +26,10 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
return {
id,
pluginId: form.pluginId.trim(),
deploymentTargetId: form.deploymentTargetId.trim() || undefined,
runEndpointId: form.runEndpointId.trim() || undefined,
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),
profileKey: form.profileKey.trim() || undefined,
bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")),
deployment: {
mode: form.deploymentMode,
profileKey: form.profileKey.trim() || undefined,
runtimeBindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")),
createInputs: Object.fromEntries(Object.entries(form.createInputs).map(([key, value]) => [key, value.trim()])),
serverRoot: form.serverRoot.trim() || undefined,
workingDirectory: form.workingDirectory.trim() || undefined,
+4 -14
View File
@@ -847,11 +847,8 @@ cat >"$WORK_DIR/create-scum-beta.request.json" <<JSON
{
"id": "$SCUM_BETA_ID",
"pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID",
"name": "SCUM Beta $SMOKE_INVOCATION_ID",
"idempotencyKey": "local-debug-scum-beta-create-$SMOKE_INVOCATION_ID",
"profileKey": "run-local",
"bindings": {}
"idempotencyKey": "local-debug-scum-beta-create-$SMOKE_INVOCATION_ID"
}
JSON
@@ -859,11 +856,8 @@ cat >"$WORK_DIR/create-scum-dynamic.request.json" <<JSON
{
"id": "$SCUM_DYNAMIC_ID",
"pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID",
"name": "SCUM Dynamic $SMOKE_INVOCATION_ID",
"idempotencyKey": "local-debug-scum-dynamic-create-$SMOKE_INVOCATION_ID",
"profileKey": "run-local",
"bindings": {}
"idempotencyKey": "local-debug-scum-dynamic-create-$SMOKE_INVOCATION_ID"
}
JSON
@@ -1000,8 +994,6 @@ curl -fsS -X PUT -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
reject_forbidden_fragments "$WORK_DIR/scum-alpha-runtime-binding.response.json"
require_file_contains "$WORK_DIR/scum-alpha-runtime-binding.response.json" '"status"[[:space:]]*:[[:space:]]*"complete"'
wait_for_lifecycle_install_success "$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json"
printf 'checking SCUM runtime distribution action\n'
json_get "$API_URL/server-instances/$SCUM_ALPHA_ID/runtime/actions" "$WORK_DIR/scum-alpha-runtime-actions.response.json" "${AUTH_HEADER[@]}"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-runtime-actions.response.json"
@@ -1047,8 +1039,6 @@ printf 'checking jobs, logs, artifacts, and marketplace refs\n'
json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SERVER_ID" "$WORK_DIR/jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SCUM_ALPHA_ID" "$WORK_DIR/scum-alpha-jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SCUM_BETA_ID" "$WORK_DIR/scum-beta-jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/log-streams" "$WORK_DIR/log-streams.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/artifacts" "$WORK_DIR/artifacts.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/plugin-marketplace/plugins" "$WORK_DIR/marketplace.response.json" "${AUTH_HEADER[@]}"
@@ -1056,8 +1046,8 @@ json_get "$API_URL/plugin-marketplace/plugins?serverType=scum&keyword=scum" "$WO
require_file_contains "$WORK_DIR/scum-marketplace.response.json" '"id"[[:space:]]*:[[:space:]]*"game.scum"'
require_file_contains "$WORK_DIR/artifacts.response.json" "\"id\"[[:space:]]*:[[:space:]]*\"artifact-run-dist-$SCUM_ALPHA_ID"
require_file_contains "$WORK_DIR/scum-alpha-jobs.response.json" "\"serverInstanceId\"[[:space:]]*:[[:space:]]*\"$SCUM_ALPHA_ID\""
require_file_contains "$WORK_DIR/scum-beta-jobs.response.json" "\"serverInstanceId\"[[:space:]]*:[[:space:]]*\"$SCUM_BETA_ID\""
require_file_contains "$WORK_DIR/scum-dynamic-jobs.response.json" '"state"[[:space:]]*:[[:space:]]*"succeeded"'
require_file_contains "$WORK_DIR/server-instances.response.json" "\"id\"[[:space:]]*:[[:space:]]*\"$SCUM_BETA_ID\""
require_file_contains "$WORK_DIR/server-instances.response.json" "\"id\"[[:space:]]*:[[:space:]]*\"$SCUM_DYNAMIC_ID\""
for file in \
"$WORK_DIR"/server-instances.response.json \