Complete platform management workflows
This commit is contained in:
@@ -49,6 +49,18 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/update", h.serverRunUpdate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/generate", h.serverClientManagerGenerate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/download", h.serverClientManagerDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/key/reset", h.serverClientManagerKeyReset)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
|
||||
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)
|
||||
@@ -893,26 +905,53 @@ func (h *coreHandlers) serverInstances(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// serverInstanceDetail godoc
|
||||
// @Summary Get server instance
|
||||
// @Description Returns one server instance by ID.
|
||||
// @Summary Get, update, or archive server instance
|
||||
// @Description Returns one server instance by ID, updates safe metadata, or archives it by marking the instance deleted after safety validation.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ServerInstanceUpdateRequest false "Server metadata update request"
|
||||
// @Success 204
|
||||
// @Success 200 {object} dto.ServerInstanceResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id} [get]
|
||||
// @Router /api/v1/server-instances/{id} [put]
|
||||
// @Router /api/v1/server-instances/{id} [delete]
|
||||
func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
||||
case http.MethodPut:
|
||||
request, err := decodeJSON[dto.ServerInstanceUpdateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.UpdateServerInstanceForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
||||
case http.MethodDelete:
|
||||
_, err := h.core.ArchiveServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet+", "+http.MethodPut+", "+http.MethodDelete)
|
||||
}
|
||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
||||
}
|
||||
|
||||
// platformMetrics godoc
|
||||
|
||||
@@ -211,7 +211,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-config-api@example.test", Password: "secret-password"}).SessionID
|
||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-config-api@example.test", Password: "secret-password"}).SessionID
|
||||
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
pluginRequest := validGamePluginRequest()
|
||||
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityConfigWrite, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
|
||||
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-config-api",
|
||||
@@ -286,6 +288,101 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
serverID := createRuntimeAPIFixtures(t, router, adminSession)
|
||||
|
||||
actions := getJSONWithAuth[dto.ServerRuntimeActionsResponse](t, router, "/api/v1/server-instances/"+serverID+"/runtime/actions", adminSession)
|
||||
availability := map[string]bool{}
|
||||
for _, action := range actions.Actions {
|
||||
availability[action.Key] = action.Available
|
||||
}
|
||||
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install", "historical-logs"} {
|
||||
if !availability[key] {
|
||||
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
|
||||
if runDistribution.ArtifactID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" {
|
||||
t.Fatalf("unexpected run distribution: %+v", runDistribution)
|
||||
}
|
||||
runDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
|
||||
if runDownload.ArtifactID != runDistribution.ArtifactID || runDownload.DownloadURL == "" {
|
||||
t.Fatalf("unexpected run download: %+v", runDownload)
|
||||
}
|
||||
updateRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/update", dto.RunUpdateRequest{ArtifactID: runDistribution.ArtifactID, Checksum: runDistribution.Checksum, IdempotencyKey: "api-run-update"}, adminSession)
|
||||
assertStatus(t, updateRecorder, http.StatusAccepted)
|
||||
update := decodeBody[dto.RunUpdateJobResponse](t, updateRecorder)
|
||||
if update.JobID == "" || update.ArtifactID != runDistribution.ArtifactID || update.Status != string(domain.DistributionJobStatusQueued) {
|
||||
t.Fatalf("unexpected run update job: %+v", update)
|
||||
}
|
||||
|
||||
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
|
||||
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
|
||||
}
|
||||
clientDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
||||
if clientDownload.ArtifactID != clientDistribution.ArtifactID {
|
||||
t.Fatalf("unexpected client download: %+v", clientDownload)
|
||||
}
|
||||
|
||||
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession)
|
||||
assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
|
||||
dependencyCheck := decodeBody[dto.JobResponse](t, dependencyCheckRecorder)
|
||||
if dependencyCheck.Capability != domain.JobCapabilityDependenciesCheck || dependencyCheck.TargetKey != "dependencies/java-runtime" {
|
||||
t.Fatalf("unexpected dependency check job: %+v", dependencyCheck)
|
||||
}
|
||||
dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", IdempotencyKey: "api-dependency-install"}, adminSession)
|
||||
assertStatus(t, dependencyInstallRecorder, http.StatusAccepted)
|
||||
dependencyInstall := decodeBody[dto.JobResponse](t, dependencyInstallRecorder)
|
||||
if dependencyInstall.Capability != domain.JobCapabilityDependenciesInstall || dependencyInstall.TargetKey != "dependencies/install/java-install" {
|
||||
t.Fatalf("unexpected dependency install job: %+v", dependencyInstall)
|
||||
}
|
||||
unsafeDependency := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "bash -c whoami", IdempotencyKey: "api-dependency-unsafe"}, adminSession)
|
||||
assertErrorResponse(t, unsafeDependency, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
backfillRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession)
|
||||
assertStatus(t, backfillRecorder, http.StatusAccepted)
|
||||
backfill := decodeBody[dto.JobResponse](t, backfillRecorder)
|
||||
if backfill.Capability != domain.JobCapabilityLogsBackfill || backfill.ResultRef != "" || backfill.InputRef == "" {
|
||||
t.Fatalf("unexpected log backfill job: %+v", backfill)
|
||||
}
|
||||
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
|
||||
if liveLogs.Count != 1 || liveLogs.Items[0].StreamKey != "stdout" {
|
||||
t.Fatalf("unexpected live logs: %+v", liveLogs)
|
||||
}
|
||||
|
||||
runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession)
|
||||
if runReset.Generation != 2 || runReset.SecretRef == "" {
|
||||
t.Fatalf("unexpected run key reset: %+v", runReset)
|
||||
}
|
||||
clientReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession)
|
||||
if clientReset.Generation != 2 || clientReset.SecretRef == runReset.SecretRef {
|
||||
t.Fatalf("unexpected client key reset: %+v", clientReset)
|
||||
}
|
||||
|
||||
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runDownload), mustJSON(t, clientDownload), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} {
|
||||
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audits := getJSONWithAuth[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?resourceId="+serverID, adminSession)
|
||||
auditActions := map[string]bool{}
|
||||
for _, audit := range audits.Items {
|
||||
auditActions[audit.Action] = true
|
||||
}
|
||||
for _, action := range []string{"run.generate", "run.download", "run.update", "client-manager.build", "client-manager.download", "dependency.install", "logs.backfill", "runtime-key.reset"} {
|
||||
if !auditActions[action] {
|
||||
t.Fatalf("expected audit action %q in %+v", action, audits.Items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIErrorResponses(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
@@ -562,6 +659,54 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
|
||||
assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation)
|
||||
}
|
||||
|
||||
func TestServerInstanceManagementAPI(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
|
||||
ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-management",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM Ops",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
}, adminSession)
|
||||
|
||||
newName := "SCUM Ops Renamed"
|
||||
updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-management", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession)
|
||||
if updated.Name != newName || updated.PluginID != ready.PluginID || updated.RunEndpointID != ready.RunEndpointID {
|
||||
t.Fatalf("unexpected server update: %+v", updated)
|
||||
}
|
||||
|
||||
running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-running-archive",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM Running Archive",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, adminSession)
|
||||
unsafeArchive := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, "", adminSession)
|
||||
assertErrorResponse(t, unsafeArchive, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
archived := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", "", adminSession)
|
||||
assertStatus(t, archived, http.StatusNoContent)
|
||||
activeList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession)
|
||||
for _, item := range activeList.Items {
|
||||
if item.ID == "server-management" {
|
||||
t.Fatalf("archived server should be hidden from normal list: %+v", activeList)
|
||||
}
|
||||
}
|
||||
deletedList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?state=deleted", adminSession)
|
||||
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
||||
t.Fatalf("expected explicit deleted filter to return archived server, got %+v", deletedList)
|
||||
}
|
||||
|
||||
blank := ""
|
||||
invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-archive", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession)
|
||||
assertErrorResponse(t, invalidUpdate, http.StatusBadRequest, errorCodeValidation)
|
||||
}
|
||||
|
||||
func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
@@ -1313,6 +1458,67 @@ func anyJSON(t *testing.T, value any) map[string]any {
|
||||
return body
|
||||
}
|
||||
|
||||
func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession string) string {
|
||||
t.Helper()
|
||||
pluginRequest := validGamePluginRequest()
|
||||
pluginRequest.ID = "server.runtime"
|
||||
pluginRequest.Name = "Runtime Test Plugin"
|
||||
pluginRequest.ServerType = "runtime-test"
|
||||
pluginRequest.SupportedOS = []string{"linux", "windows"}
|
||||
pluginRequest.RequiredRunCapabilities = []string{
|
||||
"process.install",
|
||||
"process.start",
|
||||
"process.stop",
|
||||
"logs.read",
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
}
|
||||
pluginRequest.DeclaredPermissions = []string{
|
||||
"server.read",
|
||||
"server.logs.read",
|
||||
"server.run.distribution",
|
||||
"server.client-manager.manage",
|
||||
"server.dependencies.manage",
|
||||
"server.artifacts.read",
|
||||
}
|
||||
pluginRequest.BridgeActions = []string{
|
||||
string(domain.PluginBridgeActionRunDistribution),
|
||||
string(domain.PluginBridgeActionClientManager),
|
||||
string(domain.PluginBridgeActionDependenciesRequest),
|
||||
string(domain.PluginBridgeActionLogsBackfillRequest),
|
||||
}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||
|
||||
endpoint := validRunEndpointRequest()
|
||||
endpoint.ID = "run-runtime"
|
||||
endpoint.Capabilities = append(endpoint.Capabilities,
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
)
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
|
||||
|
||||
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-runtime-api",
|
||||
PluginID: "server.runtime",
|
||||
RunEndpointID: "run-runtime",
|
||||
Name: "Runtime API Server",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
}, adminSession)
|
||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||
ID: "log-runtime-api",
|
||||
ServerInstanceID: server.ID,
|
||||
Source: domain.LogStreamSourceProcess,
|
||||
StreamKey: "stdout",
|
||||
StorageBackend: domain.LogStorageBackendLocalSegments,
|
||||
RetentionPolicy: "default",
|
||||
})
|
||||
return server.ID
|
||||
}
|
||||
|
||||
func validAIProviderRequest() dto.AIProviderCreateRequest {
|
||||
return dto.AIProviderCreateRequest{
|
||||
ID: "ai.openai",
|
||||
|
||||
+21
-2
@@ -13,7 +13,8 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
| Game plugins | `GET /api/v1/game-plugins`, `POST /api/v1/game-plugins` | `GET /api/v1/game-plugins/{id}` | `GamePluginCreateRequest`, `GamePluginResponse`, `GamePluginListResponse` |
|
||||
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
|
||||
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
|
||||
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
||||
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
||||
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
|
||||
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
||||
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
|
||||
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
||||
@@ -31,6 +32,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
- `GET /api/v1/game-plugins?serverType=scum&status=installed`
|
||||
- `GET /api/v1/plugin-marketplace/plugins?serverType=scum&status=installed&capability=logs.read&keyword=scum`
|
||||
- `GET /api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft`
|
||||
- `GET /api/v1/server-instances?state=deleted`
|
||||
- `GET /api/v1/metrics/server-instances`
|
||||
- `GET /api/v1/run/endpoints?status=online`
|
||||
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
|
||||
@@ -120,6 +122,23 @@ Artifact bridge execution returns safe metadata and platform content routes only
|
||||
|
||||
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
|
||||
|
||||
## Implemented Runtime Distribution And Client Manager Actions
|
||||
|
||||
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
|
||||
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, and redacted secret ref only.
|
||||
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
|
||||
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and queues `dependencies.install` only for typed plugin-declared plans.
|
||||
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
|
||||
|
||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and run endpoint capability support for run-side jobs. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
|
||||
## Implemented Run Control Actions
|
||||
|
||||
- `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token.
|
||||
@@ -191,7 +210,7 @@ These route groups remain documented future work beyond the currently implemente
|
||||
- Browser artifact upload, external artifact storage backends, presigned URLs, and production throttling policies.
|
||||
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
|
||||
- Live AI provider connectivity tests and remote model discovery.
|
||||
- Server restart/update/delete routes.
|
||||
- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions.
|
||||
|
||||
## Core Service Boundary
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) serverRuntimeActions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
actions, err := h.core.GetServerRuntimeActionsForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerRuntimeActionsFromDomain(actions))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverRunGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.RunDistributionGenerateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
distribution, err := h.core.GenerateRunDistributionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.RunDistributionFromDomain(distribution))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverRunDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
reference, err := h.core.OpenLatestRunDistributionDownloadForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverRunKeyReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
key, err := h.core.ResetComponentKeyForSession(bearerToken(r), domain.ComponentKeyResetRequest{ServerInstanceID: r.PathValue("id"), ComponentKind: domain.DistributionComponentRun})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ComponentKeyFromDomain(key))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverRunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.RunUpdateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
job, err := h.core.PushRunUpdateForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.RunUpdateJobFromDomain(job))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerBuildRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
distribution, err := h.core.GenerateClientManagerDistributionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.ClientManagerDistributionFromDomain(distribution))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverClientManagerDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerDownloadRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
reference, err := h.core.OpenLatestClientManagerDistributionDownloadForSession(bearerToken(r), r.PathValue("id"), request.ProfileKey)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverClientManagerKeyReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ComponentKeyResetRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
reset := request.ToDomain(r.PathValue("id"))
|
||||
reset.ComponentKind = domain.DistributionComponentClientManager
|
||||
key, err := h.core.ResetComponentKeyForSession(bearerToken(r), reset)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ComponentKeyFromDomain(key))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverDependenciesCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.DependencyJobRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
job, err := h.core.QueueDependencyJobForSession(bearerToken(r), request.ToDomain(r.PathValue("id"), false))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.JobFromDomain(job))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverDependenciesInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.DependencyJobRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
job, err := h.core.QueueDependencyJobForSession(bearerToken(r), request.ToDomain(r.PathValue("id"), true))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.JobFromDomain(job))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverLiveLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.LogStreamListFromDomain(streams))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverLogsBackfill(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.LogBackfillRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
job, err := h.core.QueueLogBackfillForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.JobFromDomain(job))
|
||||
}
|
||||
Reference in New Issue
Block a user