Add SCUM file management workbench
This commit is contained in:
@@ -110,6 +110,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverDeclaredFileReadSnapshot)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig)
|
||||
@@ -1430,6 +1431,33 @@ func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config))
|
||||
}
|
||||
|
||||
// serverDeclaredFileReadSnapshot godoc
|
||||
// @Summary Read the latest declared file snapshot
|
||||
// @Description Returns a redacted bounded result only for an authorized plugin-declared logical file key.
|
||||
// @Tags server-instances
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param key query string true "Plugin-declared logical file key"
|
||||
// @Success 200 {object} dto.DeclaredFileReadSnapshotResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/files/read-snapshot [get]
|
||||
func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
|
||||
}
|
||||
|
||||
// serverInstanceConfigDiff godoc
|
||||
// @Summary Preview server config diff
|
||||
// @Description Compares current logical server config with proposed content without dispatching a write job.
|
||||
|
||||
@@ -301,6 +301,105 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
||||
ID: "user-owner-file-snapshot-api",
|
||||
DisplayName: "File Snapshot API Owner",
|
||||
Email: "owner-file-snapshot-api@example.test",
|
||||
Roles: []string{"server-owner"},
|
||||
Password: "secret-password",
|
||||
}, adminSession)
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
||||
ID: "user-other-file-snapshot-api",
|
||||
DisplayName: "File Snapshot API Other",
|
||||
Email: "other-file-snapshot-api@example.test",
|
||||
Roles: []string{"server-admin"},
|
||||
Password: "secret-password",
|
||||
}, adminSession)
|
||||
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
|
||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
|
||||
|
||||
pluginRequest := validGamePluginRequest()
|
||||
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead)
|
||||
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
|
||||
pluginRequest.Permissions.Files = true
|
||||
pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{
|
||||
DefaultDirectoryKey: "scum-config",
|
||||
Directories: []dto.PluginLogicalDirectoryBody{
|
||||
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
|
||||
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
|
||||
},
|
||||
Files: []dto.PluginLogicalFileBody{
|
||||
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
|
||||
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
|
||||
},
|
||||
ConfigFields: []dto.PluginConfigFieldBody{
|
||||
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
|
||||
},
|
||||
}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-file-snapshot-api",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "File Snapshot API Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, ownerSession)
|
||||
|
||||
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
|
||||
if snapshot.State != "not-read" || snapshot.Content != "" {
|
||||
t.Fatalf("expected not-read snapshot, got %+v", snapshot)
|
||||
}
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation)
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
|
||||
ID: "job-file-snapshot-api-read",
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: "run-local",
|
||||
Capability: domain.JobCapabilityFilesRead,
|
||||
TargetKey: "scum-server-settings",
|
||||
IdempotencyKey: "idem-file-snapshot-api-read",
|
||||
})
|
||||
helloRequest := validRunControlHelloRequest()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead)
|
||||
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
|
||||
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Capabilities: []string{domain.JobCapabilityFilesRead},
|
||||
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
|
||||
}))
|
||||
if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" {
|
||||
t.Fatalf("expected file read job claim, got %+v", claim)
|
||||
}
|
||||
content := "ServerName=API\nRconPassword=secret\n"
|
||||
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
State: domain.JobStateSucceeded,
|
||||
Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"},
|
||||
Message: "file read completed",
|
||||
ExecutionResult: dto.RunJobExecutionResultBody{
|
||||
Kind: "file.read",
|
||||
Version: 9,
|
||||
SizeBytes: int64(len(content)),
|
||||
Content: content,
|
||||
},
|
||||
})
|
||||
assertStatus(t, resultRecorder, http.StatusOK)
|
||||
ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
|
||||
if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=<redacted>") || strings.Contains(ready.Content, "secret") {
|
||||
t.Fatalf("expected ready redacted snapshot, got %+v", ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
releaseBuilds := make(chan struct{})
|
||||
t.Cleanup(func() { close(releaseBuilds) })
|
||||
|
||||
@@ -1030,6 +1030,22 @@ type FileOperationDispatchResult struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
// DeclaredFileReadSnapshot is the redacted, bounded projection of a completed
|
||||
// files.read job for one plugin-declared logical file.
|
||||
type DeclaredFileReadSnapshot struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
Key string
|
||||
State string
|
||||
Content string
|
||||
Version int
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
JobID string
|
||||
ReadAt time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
type RunCapacity struct {
|
||||
MaxJobs int
|
||||
RunningJobs int
|
||||
|
||||
@@ -114,10 +114,13 @@ Installed `GamePlugin` records persist the validated manifest `runtimeProfiles`
|
||||
Plugin lifecycle assets are registered as manifest-declared files plus a
|
||||
content-bearing registration payload. Platform packages those assets into
|
||||
generated Run workspaces so plugin action refs such as `actions/install.json`
|
||||
and script refs such as `bin/scum-start.cmd` are available before the first
|
||||
install/start job. Game-specific install/update/start policy, including SCUM
|
||||
SteamCMD app IDs and launch flags, stays in the plugin asset bundle rather than
|
||||
in platform services or Run executors.
|
||||
and helper refs such as `bin/scum-install-update.cmd` are available before the
|
||||
first bootstrap job. Guided deployments whose selected lifecycle profile
|
||||
supports `process.start` may bootstrap through the plugin-owned start action so
|
||||
the script can install-if-missing and then launch the supervised process whose
|
||||
stdout/stderr feed the live terminal. Game-specific install/update/start policy,
|
||||
including SCUM SteamCMD app IDs and launch flags, stays in the plugin asset
|
||||
bundle rather than in platform services or Run executors.
|
||||
|
||||
Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations.
|
||||
|
||||
@@ -161,8 +164,8 @@ Dependency checks and installs are queued as run jobs with logical `dependencies
|
||||
|
||||
Lifecycle workflow jobs use fixed capabilities:
|
||||
|
||||
- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`.
|
||||
- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`.
|
||||
- `process.install`: dispatched by install bootstrap workflows and projects successful terminal results to `ready`.
|
||||
- `process.start`: dispatched by server start workflow or guided supervised bootstrap and projects successful terminal results to `running`.
|
||||
- `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`.
|
||||
- `run.self-update`: dispatched by runtime distribution APIs with an approved artifact ref and checksum.
|
||||
- `dependencies.check`: dispatched by dependency check APIs for a declared probe key.
|
||||
|
||||
@@ -703,6 +703,20 @@ type FileOperationDispatchResponse struct {
|
||||
Job JobResponse `json:"job"`
|
||||
}
|
||||
|
||||
type DeclaredFileReadSnapshotResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
Key string `json:"key"`
|
||||
State string `json:"state"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
ReadAt time.Time `json:"readAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type RunCapacityResponse struct {
|
||||
MaxJobs int `json:"maxJobs"`
|
||||
RunningJobs int `json:"runningJobs"`
|
||||
@@ -1756,6 +1770,22 @@ func FileOperationDispatchFromDomain(result domain.FileOperationDispatchResult)
|
||||
}
|
||||
}
|
||||
|
||||
func DeclaredFileReadSnapshotFromDomain(snapshot domain.DeclaredFileReadSnapshot) DeclaredFileReadSnapshotResponse {
|
||||
return DeclaredFileReadSnapshotResponse{
|
||||
ServerInstanceID: snapshot.ServerInstanceID,
|
||||
PluginID: snapshot.PluginID,
|
||||
Key: snapshot.Key,
|
||||
State: snapshot.State,
|
||||
Content: snapshot.Content,
|
||||
Version: snapshot.Version,
|
||||
Checksum: snapshot.Checksum,
|
||||
SizeBytes: snapshot.SizeBytes,
|
||||
JobID: snapshot.JobID,
|
||||
ReadAt: snapshot.ReadAt,
|
||||
Reason: snapshot.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse {
|
||||
endpoint = domain.CopyRunEndpoint(endpoint)
|
||||
return RunEndpointResponse{
|
||||
|
||||
@@ -6,11 +6,12 @@ when a server uses `custom-command` mode and Run executes the operator-reviewed
|
||||
argv-oriented command inside its local policy.
|
||||
|
||||
Guided or existing-server game setup is plugin-owned. Platform dispatches the
|
||||
plugin-declared lifecycle action reference, such as `actions/install.json`, and
|
||||
may include generic deployment context (`mode`, `profileKey`, `serverRoot`, and
|
||||
`createInputs`) so the plugin action can resolve its own behavior. Platform does
|
||||
not create a game-specific `serverDeploymentPlan`, and SCUM no longer requires a
|
||||
`deployment.scum.v1` capability.
|
||||
plugin-declared lifecycle action reference, such as `actions/install.json` or a
|
||||
supervised `actions/start.json` bootstrap, and may include generic deployment
|
||||
context (`mode`, `profileKey`, `serverRoot`, and `createInputs`) so the plugin
|
||||
action can resolve its own behavior. Platform does not create a game-specific
|
||||
`serverDeploymentPlan`, and SCUM no longer requires a `deployment.scum.v1`
|
||||
capability.
|
||||
|
||||
## Capability and policy
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ A server instance is created from one installed game management plugin and bound
|
||||
|
||||
### States
|
||||
|
||||
- `draft`: instance record exists but install job has not completed.
|
||||
- `installing`: run install job is active.
|
||||
- `ready`: install succeeded and the server can start.
|
||||
- `draft`: instance record exists but the first bootstrap job has not completed.
|
||||
- `installing`: the first plugin-owned bootstrap job is active.
|
||||
- `ready`: install/bootstrap succeeded without starting a supervised process, and the server can start.
|
||||
- `running`: server process is running.
|
||||
- `stopped`: server process is stopped.
|
||||
- `failed`: last lifecycle operation failed.
|
||||
@@ -36,7 +36,7 @@ A server instance is created from one installed game management plugin and bound
|
||||
|
||||
## Lifecycle Actions
|
||||
|
||||
- `create`: validate plugin, create instance record, dispatch install job.
|
||||
- `create`: validate plugin, create instance record, dispatch the plugin-owned bootstrap job.
|
||||
- `start`: dispatch process start job through the bound run endpoint.
|
||||
- `stop`: dispatch process stop job through the bound run endpoint.
|
||||
- `restart`: dispatch stop/start or plugin-defined restart job.
|
||||
@@ -48,7 +48,7 @@ A server instance is created from one installed game management plugin and bound
|
||||
- `GET /api/v1/plugin-marketplace/plugins` lists plugin marketplace summaries from registry metadata with status, server type, capability, and keyword filters.
|
||||
- `GET /api/v1/plugin-marketplace/plugins/{id}` returns one registry-backed marketplace detail.
|
||||
- `POST /api/v1/plugin-marketplace/plugins/{id}/state` applies metadata-only `install`, `enable`, or `disable` state changes.
|
||||
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues a `process.install` job.
|
||||
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues either `process.install` or, for guided deployments whose selected lifecycle profile supports supervised start, `process.start` so the plugin start script can install-if-missing and stream process logs.
|
||||
- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job.
|
||||
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
|
||||
- `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata.
|
||||
|
||||
@@ -130,7 +130,8 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
|
||||
|
||||
// queueManagedGuidedDeploymentAfterRegistration advances only a newly-created,
|
||||
// dedicated guided server. Selecting guided-install is the owner's prior
|
||||
// authorization for this bounded write; reconnects remain idempotent.
|
||||
// authorization for the plugin-declared bootstrap action; reconnects remain
|
||||
// idempotent.
|
||||
func (svc *CoreService) queueManagedGuidedDeploymentAfterRegistration(hello domain.RunControlHello) error {
|
||||
if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" {
|
||||
return nil
|
||||
|
||||
@@ -291,16 +291,16 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
|
||||
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
|
||||
stored, err := svc.GetServerInstance(guided.Instance.ID)
|
||||
if err != nil || stored.State != domain.ServerInstanceStateInstalling {
|
||||
t.Fatalf("guided registration should queue install, server=%+v err=%v", stored, err)
|
||||
t.Fatalf("guided registration should queue bootstrap start, server=%+v err=%v", stored, err)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall {
|
||||
t.Fatalf("expected one automatic install job, jobs=%+v err=%v", jobs, err)
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].TargetKey != "actions/start.json" || len(jobs[0].ExecutionInput.LogSources) != 2 {
|
||||
t.Fatalf("expected one automatic supervised start job, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID)
|
||||
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID})
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("Run reconnect must not duplicate automatic install, jobs=%+v", jobs)
|
||||
t.Fatalf("Run reconnect must not duplicate automatic bootstrap, jobs=%+v", jobs)
|
||||
}
|
||||
|
||||
generatedRunDraft := domain.ServerInstance{
|
||||
@@ -319,11 +319,11 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
|
||||
registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID)
|
||||
storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID)
|
||||
if err != nil || storedGenerated.State != domain.ServerInstanceStateInstalling {
|
||||
t.Fatalf("generated Run registration should queue install without deployment target, server=%+v err=%v", storedGenerated, err)
|
||||
t.Fatalf("generated Run registration should queue supervised start without deployment target, server=%+v err=%v", storedGenerated, err)
|
||||
}
|
||||
jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: generatedRunDraft.ID})
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall || jobs[0].ExecutionInput.WorkspaceScope != "local" {
|
||||
t.Fatalf("expected one scoped automatic generated Run install job, jobs=%+v err=%v", jobs, err)
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].ExecutionInput.WorkspaceScope != "local" {
|
||||
t.Fatalf("expected one scoped automatic generated Run start job, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
|
||||
existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}})
|
||||
@@ -337,7 +337,7 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T) {
|
||||
func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin := scumDeploymentTestPlugin()
|
||||
plugin.Name = "SCUM"
|
||||
@@ -424,22 +424,22 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T
|
||||
|
||||
stored, err := svc.GetServerInstance(instance.ID)
|
||||
if err != nil || stored.State != domain.ServerInstanceStateInstalling {
|
||||
t.Fatalf("generated SCUM registration should queue install, server=%+v err=%v", stored, err)
|
||||
t.Fatalf("generated SCUM registration should queue supervised bootstrap start, server=%+v err=%v", stored, err)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(jobs) != 1 {
|
||||
t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err)
|
||||
t.Fatalf("expected one SCUM start job, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
job := jobs[0]
|
||||
if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("expected SCUM install job with scoped plugin action and generic deployment inputs, job=%+v", job)
|
||||
if job.Capability != domain.LifecycleCapabilityStart || job.TargetKey != "actions/start.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil || len(job.ExecutionInput.LogSources) == 0 {
|
||||
t.Fatalf("expected SCUM supervised start job with scoped plugin action and generic deployment inputs, job=%+v", job)
|
||||
}
|
||||
if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
|
||||
t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs)
|
||||
t.Fatalf("SCUM start job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("generated SCUM Run should claim scoped plugin-owned install action, claim=%+v err=%v", claim, err)
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("generated SCUM Run should claim scoped plugin-owned start action, claim=%+v err=%v", claim, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ type Core interface {
|
||||
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
|
||||
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
|
||||
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
|
||||
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
|
||||
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
|
||||
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
|
||||
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
|
||||
@@ -1883,6 +1884,120 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
|
||||
return domain.CopyServerConfig(config), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.DeclaredFileReadSnapshot{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.DeclaredFileReadSnapshot{}, err
|
||||
}
|
||||
if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) {
|
||||
return domain.DeclaredFileReadSnapshot{}, ErrForbidden
|
||||
}
|
||||
file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, domain.FileOperationDispatchRequest{Operation: domain.FileOperationRead, Key: fileKey})
|
||||
if !constrained || !allowed || file.Key == "" {
|
||||
return domain.DeclaredFileReadSnapshot{}, validationError("file key must reference a plugin-declared file")
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
return domain.DeclaredFileReadSnapshot{}, err
|
||||
}
|
||||
var completed *domain.Job
|
||||
var pending *domain.Job
|
||||
for i := range jobs {
|
||||
job := jobs[i]
|
||||
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key {
|
||||
continue
|
||||
}
|
||||
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
|
||||
if completed == nil || newerJob(job, *completed) {
|
||||
copy := job
|
||||
completed = ©
|
||||
}
|
||||
continue
|
||||
}
|
||||
if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) {
|
||||
copy := job
|
||||
pending = ©
|
||||
}
|
||||
}
|
||||
base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: file.Key}
|
||||
if completed != nil {
|
||||
return domain.DeclaredFileReadSnapshot{
|
||||
ServerInstanceID: base.ServerInstanceID,
|
||||
PluginID: base.PluginID,
|
||||
Key: base.Key,
|
||||
State: "ready",
|
||||
Content: redactDeclaredFileReadContent(completed.ExecutionResult.Content),
|
||||
Version: completed.ExecutionResult.Version,
|
||||
Checksum: completed.ExecutionResult.Checksum,
|
||||
SizeBytes: completed.ExecutionResult.SizeBytes,
|
||||
JobID: completed.ID,
|
||||
ReadAt: jobCompletedAt(*completed),
|
||||
}, nil
|
||||
}
|
||||
if pending != nil {
|
||||
base.State = "pending"
|
||||
base.JobID = pending.ID
|
||||
base.Reason = "等待运行端完成文件读取。"
|
||||
return base, nil
|
||||
}
|
||||
base.State = "not-read"
|
||||
base.Reason = "尚未读取此声明文件。"
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func declaredFileReadPendingState(state domain.JobState) bool {
|
||||
switch state {
|
||||
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func newerJob(left domain.Job, right domain.Job) bool {
|
||||
leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right)
|
||||
if !leftTime.Equal(rightTime) {
|
||||
return leftTime.After(rightTime)
|
||||
}
|
||||
return left.ID > right.ID
|
||||
}
|
||||
|
||||
func jobCompletedAt(job domain.Job) time.Time {
|
||||
if !job.TerminalAt.IsZero() {
|
||||
return job.TerminalAt
|
||||
}
|
||||
if !job.UpdatedAt.IsZero() {
|
||||
return job.UpdatedAt
|
||||
}
|
||||
return job.CreatedAt
|
||||
}
|
||||
|
||||
func redactDeclaredFileReadContent(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
for index, line := range lines {
|
||||
key, _, found := strings.Cut(line, "=")
|
||||
if !found || !secretLikeFileAssignmentKey(key) {
|
||||
continue
|
||||
}
|
||||
lines[index] = key + "=<redacted>"
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func secretLikeFileAssignmentKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(key), "_", ""), "-", ""))
|
||||
for _, marker := range []string{"password", "passwd", "secret", "token", "apikey", "accesskey", "privatekey", "rcon"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) {
|
||||
if request.Key == "" {
|
||||
request.Key = "server.properties"
|
||||
@@ -2022,6 +2137,12 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
if file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request); constrained && !allowed {
|
||||
if file.Key == "" {
|
||||
return domain.FileOperationDispatchResult{}, validationError("file key must reference a plugin-declared file")
|
||||
}
|
||||
return domain.FileOperationDispatchResult{}, validationError("file key is not writable by plugin declaration")
|
||||
}
|
||||
}
|
||||
capability := domain.JobCapabilityFilesRead
|
||||
message := "file read queued"
|
||||
@@ -2054,6 +2175,22 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
}), nil
|
||||
}
|
||||
|
||||
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
|
||||
if len(workspace.Files) == 0 {
|
||||
return domain.PluginLogicalFile{}, false, true
|
||||
}
|
||||
for _, file := range workspace.Files {
|
||||
if file.Key != request.Key {
|
||||
continue
|
||||
}
|
||||
if request.Operation == domain.FileOperationWrite && (file.Kind != "config" || !file.Editable) {
|
||||
return file, true, false
|
||||
}
|
||||
return file, true, true
|
||||
}
|
||||
return domain.PluginLogicalFile{}, true, false
|
||||
}
|
||||
|
||||
func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string {
|
||||
binding, err := svc.runtimeBindingForServer(serverInstanceID)
|
||||
if err != nil {
|
||||
|
||||
@@ -839,6 +839,165 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
plugin.FileWorkspace = scumTestFileWorkspace()
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin workspace: %v", err)
|
||||
}
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
||||
ID: "user-owner-file-workspace",
|
||||
DisplayName: "File Workspace Owner",
|
||||
Email: "file-workspace-owner@example.test",
|
||||
Roles: []string{"server-owner"},
|
||||
PasswordHash: "secret-password",
|
||||
})
|
||||
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
||||
ID: "server-file-workspace",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "File Workspace Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
createCompleteRuntimeBinding(t, svc, instance, "local")
|
||||
|
||||
allowed, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
Operation: domain.FileOperationRead,
|
||||
Key: "scum-server-settings",
|
||||
IdempotencyKey: "idem-file-workspace-read",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch declared file read: %v", err)
|
||||
}
|
||||
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead {
|
||||
t.Fatalf("unexpected declared file dispatch: %+v", allowed)
|
||||
}
|
||||
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
Operation: domain.FileOperationRead,
|
||||
Key: "logs/latest.log",
|
||||
IdempotencyKey: "idem-file-workspace-unknown",
|
||||
}); err == nil || !strings.Contains(err.Error(), "plugin-declared file") {
|
||||
t.Fatalf("expected undeclared file key rejection, got %v", err)
|
||||
}
|
||||
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
Operation: domain.FileOperationWrite,
|
||||
Key: "scum-chat-log",
|
||||
InputRef: "input://file-workspace/update",
|
||||
Content: "line",
|
||||
IdempotencyKey: "idem-file-workspace-log-write",
|
||||
}); err == nil || !strings.Contains(err.Error(), "not writable") {
|
||||
t.Fatalf("expected log write rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
plugin.FileWorkspace = scumTestFileWorkspace()
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin workspace: %v", err)
|
||||
}
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-owner", DisplayName: "File Snapshot Owner", Email: "file-snapshot-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-other", DisplayName: "File Snapshot Other", Email: "file-snapshot-other@example.test", Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
|
||||
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-snapshot", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Snapshot Server", State: domain.ServerInstanceStateRunning})
|
||||
if err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
||||
if err != nil || snapshot.State != "not-read" {
|
||||
t.Fatalf("expected not-read without jobs, snapshot=%+v err=%v", snapshot, err)
|
||||
}
|
||||
queued := createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-queued", domain.JobStateQueued, 1, "")
|
||||
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
||||
if err != nil || snapshot.State != "pending" || snapshot.JobID != queued.ID {
|
||||
t.Fatalf("expected pending queued job, snapshot=%+v err=%v", snapshot, err)
|
||||
}
|
||||
queued.State = domain.JobStateFailed
|
||||
queued.UpdatedAt = fixedTime.Add(2 * time.Minute)
|
||||
queued.TerminalAt = fixedTime.Add(2 * time.Minute)
|
||||
if err := svc.store.Jobs().Update(queued); err != nil {
|
||||
t.Fatalf("update failed read job: %v", err)
|
||||
}
|
||||
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-cancelled", domain.JobStateCancelled, 3, "")
|
||||
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
||||
if err != nil || snapshot.State != "not-read" {
|
||||
t.Fatalf("failed/cancelled reads must not mask not-read, snapshot=%+v err=%v", snapshot, err)
|
||||
}
|
||||
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n")
|
||||
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "")
|
||||
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
||||
if err != nil || snapshot.State != "ready" || snapshot.JobID != "job-file-snapshot-success-old" || !strings.Contains(snapshot.Content, "RconPassword=<redacted>") {
|
||||
t.Fatalf("expected older successful redacted result, snapshot=%+v err=%v", snapshot, err)
|
||||
}
|
||||
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-new", domain.JobStateSucceeded, 6, "ServerName=New\nApiToken=secret\n")
|
||||
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
||||
if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") {
|
||||
t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err)
|
||||
}
|
||||
if _, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log"); err == nil || !strings.Contains(err.Error(), "plugin-declared file") {
|
||||
t.Fatalf("expected unknown logical key rejection, got %v", err)
|
||||
}
|
||||
if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected unrelated session forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func scumTestFileWorkspace() domain.PluginFileWorkspace {
|
||||
return domain.PluginFileWorkspace{
|
||||
DefaultDirectoryKey: "scum-config",
|
||||
Directories: []domain.PluginLogicalDirectory{
|
||||
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
|
||||
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
|
||||
},
|
||||
Files: []domain.PluginLogicalFile{
|
||||
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
|
||||
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
|
||||
},
|
||||
ConfigFields: []domain.PluginConfigField{
|
||||
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createDeclaredFileReadJob(t *testing.T, svc *CoreService, instance domain.ServerInstance, endpoint domain.RunEndpoint, id string, state domain.JobState, minuteOffset int, content string) domain.Job {
|
||||
t.Helper()
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: id,
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Capability: domain.JobCapabilityFilesRead,
|
||||
TargetKey: "scum-server-settings",
|
||||
IdempotencyKey: id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create declared file read job: %v", err)
|
||||
}
|
||||
stamp := fixedTime.Add(time.Duration(minuteOffset) * time.Minute)
|
||||
job.State = state
|
||||
job.UpdatedAt = stamp
|
||||
if state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled {
|
||||
job.TerminalAt = stamp
|
||||
}
|
||||
if state == domain.JobStateSucceeded {
|
||||
job.ExecutionResult = domain.JobExecutionResult{Kind: "file.read", Version: minuteOffset, Checksum: validator.BytesChecksum([]byte(content)), SizeBytes: int64(len(content)), Content: content}
|
||||
}
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
t.Fatalf("update declared file read job: %v", err)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
|
||||
@@ -85,8 +85,8 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command
|
||||
}
|
||||
|
||||
// deployServerInstance is the platform-owned transition from a saved deployment
|
||||
// definition to one fenced install job. Callers must already have established
|
||||
// the authority to act for the server.
|
||||
// definition to one fenced plugin-owned bootstrap job. Callers must already
|
||||
// have established the authority to act for the server.
|
||||
func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
@@ -118,16 +118,17 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment)
|
||||
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
|
||||
bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance)
|
||||
if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil {
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil {
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityInstall); err != nil {
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(instance.Deployment.ProfileKey) != "" && deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
|
||||
@@ -153,7 +154,7 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, command.IdempotencyKey)
|
||||
job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, command.IdempotencyKey)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestCoreServiceUpdatesDeploymentWhileServerIsActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) {
|
||||
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedSupervisedStartAction(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
runHello := validRunControlHello()
|
||||
runHello.RunEndpointID = "run-scum-guided"
|
||||
@@ -132,15 +132,15 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testin
|
||||
if err != nil {
|
||||
t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err)
|
||||
}
|
||||
if created.Job.TargetKey != "actions/install.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil {
|
||||
t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job)
|
||||
if created.Job.Capability != domain.LifecycleCapabilityStart || created.Job.TargetKey != "actions/start.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil || len(created.Job.ExecutionInput.LogSources) == 0 {
|
||||
t.Fatalf("expected plugin-owned supervised start action without SCUM deployment plan, job=%+v", created.Job)
|
||||
}
|
||||
if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" {
|
||||
t.Fatalf("SCUM install job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs)
|
||||
t.Fatalf("SCUM start job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
t.Fatalf("claimed SCUM start must use plugin action without SCUM plan, claim=%+v err=%v", claim, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +163,11 @@ func TestCoreServiceDeploymentLifecycleFailureDoesNotRequireExecutionReceipt(t *
|
||||
if err != nil {
|
||||
t.Fatalf("create deployment lifecycle server: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil {
|
||||
t.Fatalf("claim deployment lifecycle job: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil {
|
||||
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil {
|
||||
t.Fatalf("ack deployment lifecycle job: %v", err)
|
||||
}
|
||||
result, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
@@ -215,11 +215,11 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
|
||||
if err != nil {
|
||||
t.Fatalf("create guided lifecycle server: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil {
|
||||
t.Fatalf("claim guided lifecycle job: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil {
|
||||
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil {
|
||||
t.Fatalf("ack guided lifecycle job: %v", err)
|
||||
}
|
||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
@@ -232,8 +232,8 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t
|
||||
if err != nil {
|
||||
t.Fatalf("get ready guided instance: %v", err)
|
||||
}
|
||||
if instance.State != domain.ServerInstanceStateReady {
|
||||
t.Fatalf("expected guided plugin lifecycle success to mark ready, got %+v", instance)
|
||||
if instance.State != domain.ServerInstanceStateRunning {
|
||||
t.Fatalf("expected guided plugin lifecycle success to mark running, got %+v", instance)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,13 +109,17 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err)
|
||||
}
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil {
|
||||
bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance)
|
||||
if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
var binding domain.RuntimeBinding
|
||||
@@ -135,7 +139,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc
|
||||
}
|
||||
}
|
||||
|
||||
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey)
|
||||
job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, create.IdempotencyKey)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
@@ -358,6 +362,20 @@ func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []dom
|
||||
return sources
|
||||
}
|
||||
|
||||
func deploymentBootstrapLifecycleAction(plugin domain.GamePlugin, instance domain.ServerInstance) domain.ServerLifecycleAction {
|
||||
if instance.Deployment.Mode != domain.ServerDeploymentModeGuided {
|
||||
return domain.ServerLifecycleActionCreate
|
||||
}
|
||||
profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, instance.Deployment.ProfileKey)
|
||||
if !exists || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) {
|
||||
return domain.ServerLifecycleActionCreate
|
||||
}
|
||||
if strings.TrimSpace(runtimeProfileActionRef(profile.ActionRefs, domain.ServerLifecycleActionStart)) == "" && strings.TrimSpace(plugin.LifecycleActions.Start) == "" {
|
||||
return domain.ServerLifecycleActionCreate
|
||||
}
|
||||
return domain.ServerLifecycleActionStart
|
||||
}
|
||||
|
||||
func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress {
|
||||
if deployment.Mode != "" {
|
||||
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}
|
||||
|
||||
Reference in New Issue
Block a user