feat: reveal saved server deployment inputs
This commit is contained in:
@@ -68,6 +68,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/deployment/reveal", h.serverDeploymentReveal)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
|
||||
|
||||
@@ -719,6 +719,36 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
|
||||
assertErrorResponse(t, invalidStop, http.StatusBadRequest, errorCodeValidation)
|
||||
}
|
||||
|
||||
func TestServerDeploymentRevealAPIIsExplicitAndOwnerScoped(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
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)
|
||||
|
||||
redactedRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment", "", adminSession)
|
||||
assertStatus(t, redactedRecorder, http.StatusOK)
|
||||
if body := redactedRecorder.Body.String(); strings.Contains(body, "/srv/reveal") || strings.Contains(body, "./start-server") {
|
||||
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)
|
||||
}
|
||||
|
||||
revealed := getJSONWithAuth[dto.ServerDeploymentRevealResponse](t, router, "/api/v1/server-instances/deployment-reveal/deployment/reveal", adminSession)
|
||||
if revealed.ServerRoot != "/srv/reveal" || revealed.WorkingDirectory != "/srv/reveal" || revealed.StartCommand != "./start-server" || revealed.InstallCommand != "" {
|
||||
t.Fatalf("unexpected explicitly revealed deployment: %+v", revealed)
|
||||
}
|
||||
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "deployment-other", DisplayName: "Other", Email: "deployment-other@example.test", Roles: []string{"server-owner"}, Password: "other-password"}, adminSession)
|
||||
other := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "deployment-other@example.test", Password: "other-password"})
|
||||
denied := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/deployment-reveal/deployment/reveal", "", other.SessionID)
|
||||
assertErrorResponse(t, denied, http.StatusForbidden, errorCodeForbidden)
|
||||
}
|
||||
|
||||
func TestServerInstanceManagementAPI(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
|
||||
@@ -37,6 +37,21 @@ func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *ht
|
||||
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
// serverDeploymentReveal explicitly returns saved deployment paths and commands
|
||||
// to an authorized server manager. Ordinary deployment views remain redacted.
|
||||
func (h *coreHandlers) serverDeploymentReveal(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
reveal, err := h.core.RevealServerDeploymentForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerDeploymentRevealFromDomain(reveal))
|
||||
}
|
||||
|
||||
// serverDeployment reads safe deployment metadata or updates protected deployment input.
|
||||
func (h *coreHandlers) serverDeployment(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
|
||||
@@ -865,6 +865,28 @@ type ServerDeploymentView struct {
|
||||
Revision int
|
||||
UpdatedAt time.Time
|
||||
Projection ServerDeploymentProjection
|
||||
LatestDispatch *ServerDeploymentDispatchEvidence
|
||||
}
|
||||
|
||||
// ServerDeploymentReveal contains an explicitly requested operator view of
|
||||
// persisted execution inputs. Runtime bindings are intentionally excluded.
|
||||
type ServerDeploymentReveal struct {
|
||||
ServerInstanceID string
|
||||
ServerRoot string
|
||||
WorkingDirectory string
|
||||
InstallCommand string
|
||||
StartCommand string
|
||||
StopCommand string
|
||||
StatusCommand string
|
||||
}
|
||||
|
||||
// ServerDeploymentDispatchEvidence proves what Platform placed into the most
|
||||
// recent lifecycle job without exposing its protected contents.
|
||||
type ServerDeploymentDispatchEvidence struct {
|
||||
JobID string
|
||||
JobState JobState
|
||||
DeploymentRevision int
|
||||
DeploymentDefinitionIncluded bool
|
||||
}
|
||||
|
||||
type ConfigDiffLine struct {
|
||||
@@ -1790,6 +1812,10 @@ func CopyServerDeploymentUpdate(update ServerDeploymentUpdate) ServerDeploymentU
|
||||
|
||||
func CopyServerDeploymentView(view ServerDeploymentView) ServerDeploymentView {
|
||||
view.CreateInputs = CopyStringMap(view.CreateInputs)
|
||||
if view.LatestDispatch != nil {
|
||||
copy := *view.LatestDispatch
|
||||
view.LatestDispatch = ©
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
|
||||
@@ -22,20 +22,38 @@ type ServerDeploymentRequest struct {
|
||||
}
|
||||
|
||||
type ServerDeploymentResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Mode domain.ServerDeploymentMode `json:"mode,omitempty"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
CreateInputs map[string]string `json:"createInputs,omitempty"`
|
||||
ServerRootConfigured bool `json:"serverRootConfigured"`
|
||||
WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"`
|
||||
InstallCommandConfigured bool `json:"installCommandConfigured"`
|
||||
StartCommandConfigured bool `json:"startCommandConfigured"`
|
||||
StopCommandConfigured bool `json:"stopCommandConfigured"`
|
||||
StatusCommandConfigured bool `json:"statusCommandConfigured"`
|
||||
Shell domain.ServerCommandShell `json:"shell,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Projection ServerDeploymentProjectionBody `json:"projection,omitempty"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Mode domain.ServerDeploymentMode `json:"mode,omitempty"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
CreateInputs map[string]string `json:"createInputs,omitempty"`
|
||||
ServerRootConfigured bool `json:"serverRootConfigured"`
|
||||
WorkingDirectoryConfigured bool `json:"workingDirectoryConfigured"`
|
||||
InstallCommandConfigured bool `json:"installCommandConfigured"`
|
||||
StartCommandConfigured bool `json:"startCommandConfigured"`
|
||||
StopCommandConfigured bool `json:"stopCommandConfigured"`
|
||||
StatusCommandConfigured bool `json:"statusCommandConfigured"`
|
||||
Shell domain.ServerCommandShell `json:"shell,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Projection ServerDeploymentProjectionBody `json:"projection,omitempty"`
|
||||
LatestDispatch *ServerDeploymentDispatchEvidenceBody `json:"latestDispatch,omitempty"`
|
||||
}
|
||||
|
||||
type ServerDeploymentRevealResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ServerRoot string `json:"serverRoot"`
|
||||
WorkingDirectory string `json:"workingDirectory"`
|
||||
InstallCommand string `json:"installCommand"`
|
||||
StartCommand string `json:"startCommand"`
|
||||
StopCommand string `json:"stopCommand"`
|
||||
StatusCommand string `json:"statusCommand"`
|
||||
}
|
||||
|
||||
type ServerDeploymentDispatchEvidenceBody struct {
|
||||
JobID string `json:"jobId"`
|
||||
JobState domain.JobState `json:"jobState"`
|
||||
DeploymentRevision int `json:"deploymentRevision"`
|
||||
DeploymentDefinitionIncluded bool `json:"deploymentDefinitionIncluded"`
|
||||
}
|
||||
|
||||
type ServerDeploymentProjectionBody struct {
|
||||
@@ -104,7 +122,18 @@ func (request ServerDeploymentRequest) deploymentDefinition() domain.ServerDeplo
|
||||
}
|
||||
|
||||
func ServerDeploymentFromDomain(view domain.ServerDeploymentView) ServerDeploymentResponse {
|
||||
return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt), Projection: deploymentProjectionFromDomain(view.Projection)}
|
||||
return ServerDeploymentResponse{ServerInstanceID: view.ServerInstanceID, Mode: view.Mode, ProfileKey: view.ProfileKey, CreateInputs: domain.CopyStringMap(view.CreateInputs), ServerRootConfigured: view.ServerRootConfigured, WorkingDirectoryConfigured: view.WorkingDirectoryConfigured, InstallCommandConfigured: view.InstallCommandConfigured, StartCommandConfigured: view.StartCommandConfigured, StopCommandConfigured: view.StopCommandConfigured, StatusCommandConfigured: view.StatusCommandConfigured, Shell: view.Shell, Revision: view.Revision, UpdatedAt: optionalTime(view.UpdatedAt), Projection: deploymentProjectionFromDomain(view.Projection), LatestDispatch: deploymentDispatchEvidenceFromDomain(view.LatestDispatch)}
|
||||
}
|
||||
|
||||
func ServerDeploymentRevealFromDomain(reveal domain.ServerDeploymentReveal) ServerDeploymentRevealResponse {
|
||||
return ServerDeploymentRevealResponse{ServerInstanceID: reveal.ServerInstanceID, ServerRoot: reveal.ServerRoot, WorkingDirectory: reveal.WorkingDirectory, InstallCommand: reveal.InstallCommand, StartCommand: reveal.StartCommand, StopCommand: reveal.StopCommand, StatusCommand: reveal.StatusCommand}
|
||||
}
|
||||
|
||||
func deploymentDispatchEvidenceFromDomain(evidence *domain.ServerDeploymentDispatchEvidence) *ServerDeploymentDispatchEvidenceBody {
|
||||
if evidence == nil {
|
||||
return nil
|
||||
}
|
||||
return &ServerDeploymentDispatchEvidenceBody{JobID: evidence.JobID, JobState: evidence.JobState, DeploymentRevision: evidence.DeploymentRevision, DeploymentDefinitionIncluded: evidence.DeploymentDefinitionIncluded}
|
||||
}
|
||||
|
||||
func deploymentProjectionFromDomain(projection domain.ServerDeploymentProjection) ServerDeploymentProjectionBody {
|
||||
|
||||
@@ -86,6 +86,7 @@ type Core interface {
|
||||
CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error)
|
||||
CreateServerInstanceWorkflowForSession(string, domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error)
|
||||
GetServerDeploymentForSession(string, string) (domain.ServerDeploymentView, error)
|
||||
RevealServerDeploymentForSession(string, string) (domain.ServerDeploymentReveal, error)
|
||||
UpdateServerDeploymentForSession(string, string, domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error)
|
||||
DeployServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||
StartServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error)
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
@@ -17,7 +18,16 @@ func (svc *CoreService) GetServerDeploymentForSession(sessionID, serverInstanceI
|
||||
if err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
return deploymentView(instance), nil
|
||||
return svc.deploymentView(instance)
|
||||
}
|
||||
|
||||
func (svc *CoreService) RevealServerDeploymentForSession(sessionID, serverInstanceID string) (domain.ServerDeploymentReveal, error) {
|
||||
_, instance, err := svc.requireServerOwner(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerDeploymentReveal{}, err
|
||||
}
|
||||
definition := instance.Deployment
|
||||
return domain.ServerDeploymentReveal{ServerInstanceID: instance.ID, ServerRoot: definition.ServerRoot, WorkingDirectory: definition.WorkingDirectory, InstallCommand: definition.InstallCommand, StartCommand: definition.StartCommand, StopCommand: definition.StopCommand, StatusCommand: definition.StatusCommand}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstanceID string, update domain.ServerDeploymentUpdate) (domain.ServerDeploymentView, error) {
|
||||
@@ -70,7 +80,7 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
return deploymentView(instance), nil
|
||||
return svc.deploymentView(instance)
|
||||
}
|
||||
|
||||
func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
@@ -216,11 +226,24 @@ func mergeDeploymentDefinition(current domain.ServerDeploymentDefinition, update
|
||||
return definition
|
||||
}
|
||||
|
||||
func deploymentView(instance domain.ServerInstance) domain.ServerDeploymentView {
|
||||
func (svc *CoreService) deploymentView(instance domain.ServerInstance) (domain.ServerDeploymentView, error) {
|
||||
definition := instance.Deployment
|
||||
return domain.CopyServerDeploymentView(domain.ServerDeploymentView{
|
||||
view := domain.ServerDeploymentView{
|
||||
ServerInstanceID: instance.ID, Mode: definition.Mode, ProfileKey: definition.ProfileKey, CreateInputs: domain.CopyStringMap(definition.CreateInputs),
|
||||
ServerRootConfigured: definition.ServerRoot != "", WorkingDirectoryConfigured: definition.WorkingDirectory != "", InstallCommandConfigured: definition.InstallCommand != "", StartCommandConfigured: definition.StartCommand != "", StopCommandConfigured: definition.StopCommand != "", StatusCommandConfigured: definition.StatusCommand != "", Shell: definition.Shell, Revision: definition.Revision, UpdatedAt: definition.UpdatedAt,
|
||||
Projection: instance.DeploymentProjection,
|
||||
})
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
return domain.ServerDeploymentView{}, err
|
||||
}
|
||||
var latestDispatchAt time.Time
|
||||
for _, job := range jobs {
|
||||
if job.ExecutionInput.Deployment == nil || (view.LatestDispatch != nil && !job.CreatedAt.After(latestDispatchAt)) {
|
||||
continue
|
||||
}
|
||||
view.LatestDispatch = &domain.ServerDeploymentDispatchEvidence{JobID: job.ID, JobState: job.State, DeploymentRevision: job.ExecutionInput.Deployment.Revision, DeploymentDefinitionIncluded: true}
|
||||
latestDispatchAt = job.CreatedAt
|
||||
}
|
||||
return domain.CopyServerDeploymentView(view), nil
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
|
||||
if strings.Contains(strings.Join([]string{view.ServerInstanceID, string(view.Mode), view.ProfileKey}, " "), "/srv/") {
|
||||
t.Fatalf("redacted deployment view leaked host path: %+v", view)
|
||||
}
|
||||
revealed, err := svc.RevealServerDeploymentForSession(ownerSession, draft.Instance.ID)
|
||||
if err != nil || revealed.ServerRoot != "/srv/venv-server" || revealed.WorkingDirectory != "/srv/venv-server" || revealed.StartCommand != "/srv/venv-server/.venv/bin/python server.py" {
|
||||
t.Fatalf("expected explicit deployment reveal, reveal=%+v err=%v", revealed, err)
|
||||
}
|
||||
|
||||
if _, err := svc.UpdateServerDeploymentForSession(ownerSession, draft.Instance.ID, domain.ServerDeploymentUpdate{RunEndpointID: "run-local", Mode: domain.ServerDeploymentModeCustom}); err != nil {
|
||||
t.Fatalf("bind draft to run: %v", err)
|
||||
@@ -55,4 +59,8 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
|
||||
if deployed.Job.ExecutionInput.Deployment == nil || deployed.Job.ExecutionInput.Deployment.StartCommand != "/srv/venv-server/.venv/bin/python server.py" || deployed.Job.Progress.Phase != "queued" {
|
||||
t.Fatalf("Run job must carry protected plan and queued phase: %+v", deployed.Job)
|
||||
}
|
||||
view, err = svc.GetServerDeploymentForSession(ownerSession, draft.Instance.ID)
|
||||
if err != nil || view.LatestDispatch == nil || view.LatestDispatch.JobID != deployed.Job.ID || view.LatestDispatch.DeploymentRevision != deployed.Job.ExecutionInput.Deployment.Revision || !view.LatestDispatch.DeploymentDefinitionIncluded {
|
||||
t.Fatalf("expected safe dispatch evidence, view=%+v err=%v", view, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user