feat: 自动更新
This commit is contained in:
@@ -74,6 +74,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/jobs/ack", h.runJobAck)
|
||||
mux.HandleFunc("/api/v1/run/jobs/progress", h.runJobProgress)
|
||||
mux.HandleFunc("/api/v1/run/jobs/result", h.runJobResult)
|
||||
mux.HandleFunc("/api/v1/run/jobs/build-input", h.runJobBuildInput)
|
||||
mux.HandleFunc("/api/v1/run/jobs/cancel", h.runJobCancelPoll)
|
||||
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.runJobReconcile)
|
||||
mux.HandleFunc("/api/v1/run/logs/batches", h.runLogBatchIngest)
|
||||
@@ -462,6 +463,9 @@ func (h *coreHandlers) requirePlatformAdmin(w http.ResponseWriter, r *http.Reque
|
||||
// @Router /api/v1/ai-providers [get]
|
||||
// @Router /api/v1/ai-providers [post]
|
||||
func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
providers, err := h.core.ListAIProviders(domain.AIProviderFilter{
|
||||
@@ -504,6 +508,9 @@ func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
|
||||
// @Router /api/v1/ai-providers/{id} [get]
|
||||
// @Router /api/v1/ai-providers/{id} [put]
|
||||
func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
provider, err := h.core.GetAIProvider(r.PathValue("id"))
|
||||
@@ -548,6 +555,9 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request)
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai-providers/{id}/status [post]
|
||||
func (h *coreHandlers) aiProviderStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
@@ -576,6 +586,9 @@ func (h *coreHandlers) aiProviderStatus(w http.ResponseWriter, r *http.Request)
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai-providers/{id}/test [post]
|
||||
func (h *coreHandlers) aiProviderTest(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
@@ -599,6 +612,9 @@ func (h *coreHandlers) aiProviderTest(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai-providers/{id}/models [get]
|
||||
func (h *coreHandlers) aiProviderModels(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePlatformAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
@@ -1383,6 +1399,25 @@ func (h *coreHandlers) runJobResult(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, dto.RunJobResultFromDomain(result))
|
||||
}
|
||||
|
||||
// runJobBuildInput returns secret-bearing build input only to the active leased run worker.
|
||||
func (h *coreHandlers) runJobBuildInput(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.DistributionBuildInputRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.GetDistributionBuildInput(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.DistributionBuildInputFromDomain(result))
|
||||
}
|
||||
|
||||
// runJobCancelPoll godoc
|
||||
// @Summary Poll run job cancellation
|
||||
// @Description Lets a registered run endpoint poll for cancellation requests on active leased jobs.
|
||||
|
||||
@@ -35,12 +35,12 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
|
||||
users := getJSONWithAuth[dto.UserListResponse](t, router, "/api/v1/users?status=active", adminSession)
|
||||
assertListCount(t, users.Count, 2)
|
||||
|
||||
providerResponse := postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
|
||||
providerResponse := createAIProviderFixture(t, router, adminSession)
|
||||
if providerResponse.APIKeyRef != "secret://providers/openai" {
|
||||
t.Fatalf("expected AI provider key reference, got %+v", providerResponse)
|
||||
}
|
||||
getJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai")
|
||||
providers := getJSON[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active")
|
||||
getJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai", adminSession)
|
||||
providers := getJSONWithAuth[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active", adminSession)
|
||||
assertListCount(t, providers.Count, 1)
|
||||
|
||||
pluginResponse := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
@@ -305,28 +305,18 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
if runDistribution.ArtifactID == "" || runDistribution.BuildJobID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" || runDistribution.Status != string(domain.DistributionStatusBuilding) {
|
||||
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)
|
||||
}
|
||||
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
|
||||
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||
|
||||
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)
|
||||
}
|
||||
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
||||
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||
|
||||
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)
|
||||
@@ -363,7 +353,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
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 _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), 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)
|
||||
@@ -376,7 +366,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
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"} {
|
||||
for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "logs.backfill", "runtime-key.reset"} {
|
||||
if !auditActions[action] {
|
||||
t.Fatalf("expected audit action %q in %+v", action, audits.Items)
|
||||
}
|
||||
@@ -412,9 +402,9 @@ func TestCoreAPIErrorResponses(t *testing.T) {
|
||||
rawKey := validAIProviderRequest()
|
||||
rawKey.ID = "ai.raw"
|
||||
rawKey.APIKeyRef = "sk-raw-secret"
|
||||
providerFailure := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey)
|
||||
providerFailure := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey, adminSession)
|
||||
assertErrorResponse(t, providerFailure, http.StatusBadRequest, errorCodeValidation)
|
||||
missingProvider := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "")
|
||||
missingProvider := requestWithAuth(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "", adminSession)
|
||||
assertErrorResponse(t, missingProvider, http.StatusNotFound, errorCodeNotFound)
|
||||
|
||||
methodFailure := requestWithAuth(t, router, http.MethodDelete, "/api/v1/users", "", adminSession)
|
||||
@@ -791,7 +781,8 @@ func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
||||
|
||||
func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
recorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest())
|
||||
adminSession := createAdminSession(t, router)
|
||||
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
|
||||
assertStatus(t, recorder, http.StatusCreated)
|
||||
|
||||
var body map[string]any
|
||||
@@ -811,38 +802,39 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
||||
|
||||
func TestAIProviderManagementAPI(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
|
||||
adminSession := createAdminSession(t, router)
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
|
||||
update := validAIProviderUpdateRequest()
|
||||
updatedRecorder := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update)
|
||||
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
|
||||
assertStatus(t, updatedRecorder, http.StatusOK)
|
||||
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
|
||||
if updated.Name != "OpenAI Relay" || updated.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive {
|
||||
t.Fatalf("unexpected updated provider: %+v", updated)
|
||||
}
|
||||
|
||||
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled})
|
||||
statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
||||
assertStatus(t, statusRecorder, http.StatusOK)
|
||||
disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder)
|
||||
if disabled.Status != domain.AIProviderStatusDisabled {
|
||||
t.Fatalf("expected disabled provider, got %+v", disabled)
|
||||
}
|
||||
|
||||
testRecorder := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "")
|
||||
testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
||||
assertStatus(t, testRecorder, http.StatusOK)
|
||||
testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
||||
if testResult.Success || testResult.Mode != "metadata" {
|
||||
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
|
||||
}
|
||||
|
||||
models := getJSON[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models")
|
||||
models := getJSONWithAuth[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models", adminSession)
|
||||
if models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 2 {
|
||||
t.Fatalf("unexpected models response: %+v", models)
|
||||
}
|
||||
|
||||
statusRecorder = performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive})
|
||||
statusRecorder = requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive}, adminSession)
|
||||
assertStatus(t, statusRecorder, http.StatusOK)
|
||||
testRecorder = performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "")
|
||||
testRecorder = requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
||||
assertStatus(t, testRecorder, http.StatusOK)
|
||||
testResult = decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
||||
if !testResult.Success {
|
||||
@@ -852,30 +844,60 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
||||
|
||||
func TestAIProviderManagementAPIErrors(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
|
||||
adminSession := createAdminSession(t, router)
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
|
||||
rawUpdate := validAIProviderUpdateRequest()
|
||||
rawUpdate.APIKeyRef = "sk-raw-secret"
|
||||
rawFailure := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate)
|
||||
rawFailure := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate, adminSession)
|
||||
assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
invalidStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError})
|
||||
invalidStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError}, adminSession)
|
||||
assertErrorResponse(t, invalidStatus, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
missingUpdate := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest())
|
||||
missingUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest(), adminSession)
|
||||
assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound)
|
||||
missingStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled})
|
||||
missingStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
||||
assertErrorResponse(t, missingStatus, http.StatusNotFound, errorCodeNotFound)
|
||||
missingTest := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "")
|
||||
missingTest := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "", adminSession)
|
||||
assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound)
|
||||
missingModels := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "")
|
||||
missingModels := requestWithAuth(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "", adminSession)
|
||||
assertErrorResponse(t, missingModels, http.StatusNotFound, errorCodeNotFound)
|
||||
}
|
||||
|
||||
func TestAIProviderManagementRequiresPlatformAdmin(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
|
||||
for _, request := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{name: "list", method: http.MethodGet, path: "/api/v1/ai-providers"},
|
||||
{name: "create", method: http.MethodPost, path: "/api/v1/ai-providers", body: validAIProviderRequest()},
|
||||
{name: "detail", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai"},
|
||||
{name: "update", method: http.MethodPut, path: "/api/v1/ai-providers/ai.openai", body: validAIProviderUpdateRequest()},
|
||||
{name: "status", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/status", body: dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}},
|
||||
{name: "test", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/test"},
|
||||
{name: "models", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai/models"},
|
||||
} {
|
||||
t.Run(request.name, func(t *testing.T) {
|
||||
var recorder *httptest.ResponseRecorder
|
||||
if request.body == nil {
|
||||
recorder = performRaw(t, router, request.method, request.path, "")
|
||||
} else {
|
||||
recorder = performJSON(t, router, request.method, request.path, request.body)
|
||||
}
|
||||
assertErrorResponse(t, recorder, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
registration := validGamePluginManifestRegistrationRequest()
|
||||
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"}
|
||||
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)}
|
||||
@@ -1090,7 +1112,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
|
||||
Password: "secret-password",
|
||||
}, adminSession)
|
||||
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "bridge-owner@example.test", Password: "secret-password"}).SessionID
|
||||
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest())
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
|
||||
registration := validGamePluginManifestRegistrationRequest()
|
||||
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionJobsDispatch))
|
||||
@@ -1404,6 +1426,11 @@ func createAdminSession(t *testing.T, router http.Handler) string {
|
||||
return session.SessionID
|
||||
}
|
||||
|
||||
func createAIProviderFixture(t *testing.T, router http.Handler, adminSession string) dto.AIProviderResponse {
|
||||
t.Helper()
|
||||
return postJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
|
||||
}
|
||||
|
||||
func decodeBody[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
|
||||
t.Helper()
|
||||
var body T
|
||||
@@ -1494,6 +1521,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
endpoint := validRunEndpointRequest()
|
||||
endpoint.ID = "run-runtime"
|
||||
endpoint.Capabilities = append(endpoint.Capabilities,
|
||||
domain.JobCapabilityDistributionBuild,
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
|
||||
@@ -90,6 +90,33 @@ type RunJobResultResult struct {
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type DistributionBuildInputRequest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
JobID string
|
||||
LeaseToken string
|
||||
Attempt int
|
||||
}
|
||||
|
||||
type DistributionBuildInput struct {
|
||||
JobID string
|
||||
ComponentKind DistributionComponentKind
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
RunEndpointID string
|
||||
ProfileKey string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
PackageFormat string
|
||||
RepositoryURL string
|
||||
SourceRevision string
|
||||
ArtifactID string
|
||||
OutputFilename string
|
||||
SecretRef string
|
||||
KeyGeneration int
|
||||
AuthKey string
|
||||
}
|
||||
|
||||
type RunJobCancelRequest struct {
|
||||
JobID string
|
||||
Reason string
|
||||
|
||||
@@ -580,6 +580,7 @@ const (
|
||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
JobCapabilityRunSelfUpdate = "run.self-update"
|
||||
JobCapabilityDistributionBuild = "distribution.build"
|
||||
JobCapabilityDependenciesCheck = "dependencies.check"
|
||||
JobCapabilityDependenciesInstall = "dependencies.install"
|
||||
JobCapabilityLogsBackfill = "logs.backfill"
|
||||
@@ -663,6 +664,7 @@ type RunDistribution struct {
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
PackageFormat string
|
||||
BuildJobID string
|
||||
ArtifactID string
|
||||
Checksum string
|
||||
KeyGeneration int
|
||||
|
||||
@@ -88,6 +88,7 @@ type RunDistributionResponse struct {
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
BuildJobID string `json:"buildJobId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Checksum string `json:"checksum"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
@@ -262,6 +263,7 @@ func RunDistributionFromDomain(distribution domain.RunDistribution) RunDistribut
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: distribution.PackageFormat,
|
||||
BuildJobID: distribution.BuildJobID,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
Checksum: distribution.Checksum,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
|
||||
@@ -89,6 +89,33 @@ type RunJobResultResponse struct {
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type DistributionBuildInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type DistributionBuildInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ComponentKind string `json:"componentKind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
OutputFilename string `json:"outputFilename"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
AuthKey string `json:"authKey"`
|
||||
}
|
||||
|
||||
type RunJobCancelRequestBody struct {
|
||||
JobID string `json:"jobId"`
|
||||
Reason string `json:"reason"`
|
||||
@@ -179,6 +206,16 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
|
||||
}
|
||||
}
|
||||
|
||||
func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuildInputRequest {
|
||||
return domain.DistributionBuildInputRequest{
|
||||
RunEndpointID: request.RunEndpointID,
|
||||
SessionToken: request.SessionToken,
|
||||
JobID: request.JobID,
|
||||
LeaseToken: request.LeaseToken,
|
||||
Attempt: request.Attempt,
|
||||
}
|
||||
}
|
||||
|
||||
func (request RunJobCancelRequestBody) ToDomain() domain.RunJobCancelRequest {
|
||||
return domain.RunJobCancelRequest{
|
||||
JobID: request.JobID,
|
||||
@@ -239,6 +276,27 @@ func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultRespon
|
||||
}
|
||||
}
|
||||
|
||||
func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) DistributionBuildInputResponse {
|
||||
return DistributionBuildInputResponse{
|
||||
JobID: input.JobID,
|
||||
ComponentKind: string(input.ComponentKind),
|
||||
ServerInstanceID: input.ServerInstanceID,
|
||||
PluginID: input.PluginID,
|
||||
RunEndpointID: input.RunEndpointID,
|
||||
ProfileKey: input.ProfileKey,
|
||||
TargetOS: input.TargetOS,
|
||||
TargetArch: input.TargetArch,
|
||||
PackageFormat: input.PackageFormat,
|
||||
RepositoryURL: input.RepositoryURL,
|
||||
SourceRevision: input.SourceRevision,
|
||||
ArtifactID: input.ArtifactID,
|
||||
OutputFilename: input.OutputFilename,
|
||||
SecretRef: input.SecretRef,
|
||||
KeyGeneration: input.KeyGeneration,
|
||||
AuthKey: input.AuthKey,
|
||||
}
|
||||
}
|
||||
|
||||
func RunJobCancelRequestFromDomain(result domain.RunJobCancelRequestResult) RunJobCancelRequestResponse {
|
||||
return RunJobCancelRequestResponse{
|
||||
Accepted: result.Accepted,
|
||||
|
||||
@@ -47,6 +47,7 @@ type RunDistribution struct {
|
||||
TargetOS string `json:"targetOs" db:"target_os"`
|
||||
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||
PackageFormat string `json:"packageFormat" db:"package_format"`
|
||||
BuildJobID string `json:"buildJobId" db:"build_job_id"`
|
||||
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||
Checksum string `json:"checksum" db:"checksum"`
|
||||
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestCoreServiceRegistersNewRunControlSession(t *testing.T) {
|
||||
if endpoint.Status != domain.RunEndpointStatusOnline || !endpoint.LastHeartbeatAt.Equal(fixedTime) {
|
||||
t.Fatalf("expected online endpoint with heartbeat time, got %+v", endpoint)
|
||||
}
|
||||
if len(endpoint.Capabilities) != 2 || endpoint.Capacity.MaxJobs != 4 {
|
||||
if len(endpoint.Capabilities) != 3 || endpoint.Capacity.MaxJobs != 4 {
|
||||
t.Fatalf("expected capabilities and capacity, got %+v", endpoint)
|
||||
}
|
||||
}
|
||||
@@ -224,7 +224,7 @@ func validRunControlHello() domain.RunControlHello {
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
Platform: "darwin/arm64",
|
||||
CapabilityReport: domain.RunCapabilityReport{
|
||||
Capabilities: []string{"control.hello", "control.heartbeat"},
|
||||
Capabilities: []string{"control.hello", "control.heartbeat", domain.JobCapabilityDistributionBuild},
|
||||
Fingerprint: "cap-v1",
|
||||
},
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4},
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error) {
|
||||
if err := validator.ValidateDistributionBuildInputRequest(request); err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(request.RunEndpointID, request.SessionToken); err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
|
||||
svc.jobMu.Lock()
|
||||
job, _, err := svc.activeLeasedJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
||||
svc.jobMu.Unlock()
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
if job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
return domain.DistributionBuildInput{}, validationError("job is not a distribution build")
|
||||
}
|
||||
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
||||
return domain.DistributionBuildInput{}, validationError("distribution build job is not active")
|
||||
}
|
||||
|
||||
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
for _, distribution := range runDistributions {
|
||||
if distribution.BuildJobID != job.ID {
|
||||
continue
|
||||
}
|
||||
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentRun, "")
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
if key.Generation != distribution.KeyGeneration {
|
||||
return domain.DistributionBuildInput{}, validationError("run build key generation is no longer current")
|
||||
}
|
||||
plainKey, err := decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentRun,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: distribution.RunEndpointID,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: distribution.PackageFormat,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: executableFilename("run", distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
for _, distribution := range clientDistributions {
|
||||
if distribution.BuildJobID != job.ID {
|
||||
continue
|
||||
}
|
||||
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
if key.Generation != distribution.KeyGeneration {
|
||||
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
|
||||
}
|
||||
plainKey, err := decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectDistributionBuildProgress(job domain.Job, stamp time.Time) error {
|
||||
if job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
return nil
|
||||
}
|
||||
builds, err := svc.store.ClientManagerBuildJobs().List(domain.ClientManagerBuildJobFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, build := range builds {
|
||||
if build.ID != job.ID || build.Status != domain.DistributionJobStatusQueued {
|
||||
continue
|
||||
}
|
||||
build.Status = domain.DistributionJobStatusRunning
|
||||
build.UpdatedAt = stamp
|
||||
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.store.ClientManagerBuildJobs().Update(build)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp time.Time) error {
|
||||
if job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
return nil
|
||||
}
|
||||
status := domain.DistributionStatusFailed
|
||||
buildStatus := domain.DistributionJobStatusFailed
|
||||
var artifact domain.Artifact
|
||||
if job.State == domain.JobStateSucceeded {
|
||||
artifactID := strings.TrimPrefix(job.ResultRef, "artifact://")
|
||||
if artifactID == "" || artifactID == job.ResultRef {
|
||||
return validationError("distribution build result must reference an artifact")
|
||||
}
|
||||
var err error
|
||||
artifact, err = svc.store.Artifacts().Get(artifactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != job.ID {
|
||||
return validationError("distribution build artifact is unavailable or outside the job scope")
|
||||
}
|
||||
status = domain.DistributionStatusAvailable
|
||||
buildStatus = domain.DistributionJobStatusSucceeded
|
||||
}
|
||||
|
||||
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, distribution := range runDistributions {
|
||||
if distribution.BuildJobID != job.ID {
|
||||
continue
|
||||
}
|
||||
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
|
||||
return validationError("distribution build returned an unexpected artifact")
|
||||
}
|
||||
distribution.Status = status
|
||||
if artifact.ID != "" {
|
||||
distribution.Checksum = artifact.Checksum
|
||||
}
|
||||
distribution.UpdatedAt = stamp
|
||||
if err := validator.ValidateRunDistribution(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.store.RunDistributions().Update(distribution)
|
||||
}
|
||||
|
||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, distribution := range clientDistributions {
|
||||
if distribution.BuildJobID != job.ID {
|
||||
continue
|
||||
}
|
||||
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
|
||||
return validationError("client-manager build returned an unexpected artifact")
|
||||
}
|
||||
distribution.Status = status
|
||||
if artifact.ID != "" {
|
||||
distribution.Checksum = artifact.Checksum
|
||||
}
|
||||
distribution.UpdatedAt = stamp
|
||||
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
build, err := svc.store.ClientManagerBuildJobs().Get(job.ID)
|
||||
if err != nil && !errors.Is(err, repo.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
build.Status = buildStatus
|
||||
if artifact.ID != "" {
|
||||
build.ArtifactID = artifact.ID
|
||||
build.Checksum = artifact.Checksum
|
||||
}
|
||||
build.UpdatedAt = stamp
|
||||
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.ClientManagerBuildJobs().Update(build); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return repo.ErrNotFound
|
||||
}
|
||||
|
||||
func executableFilename(base string, targetOS string) string {
|
||||
if targetOS == "windows" {
|
||||
return base + ".exe"
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -78,6 +77,13 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
|
||||
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
|
||||
if err != nil {
|
||||
@@ -90,26 +96,9 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
|
||||
config := generatedPackageConfig{
|
||||
Kind: string(domain.DistributionComponentRun),
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
SecretRef: key.SecretRef,
|
||||
KeyGeneration: key.Generation,
|
||||
AuthKey: plainKey,
|
||||
}
|
||||
payload, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
artifactID := artifactIDForDistribution(distributionID)
|
||||
artifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
|
||||
if err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
_ = plainKey
|
||||
artifactID := artifactIDForDistribution(distributionID + "-binary")
|
||||
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, distributionID)
|
||||
stamp := svc.now()
|
||||
distribution := domain.RunDistribution{
|
||||
ID: distributionID,
|
||||
@@ -119,11 +108,11 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(request.TargetOS),
|
||||
ArtifactID: artifact.ID,
|
||||
Checksum: artifact.Checksum,
|
||||
BuildJobID: buildJobID,
|
||||
ArtifactID: artifactID,
|
||||
KeyGeneration: key.Generation,
|
||||
SecretRef: key.SecretRef,
|
||||
Status: domain.DistributionStatusAvailable,
|
||||
Status: domain.DistributionStatusBuilding,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
@@ -131,9 +120,35 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
if err := svc.store.RunDistributions().Create(distribution); err != nil {
|
||||
if errors.Is(err, repo.ErrDuplicate) {
|
||||
existing, getErr := svc.store.RunDistributions().Get(distribution.ID)
|
||||
if getErr != nil {
|
||||
return domain.RunDistribution{}, getErr
|
||||
}
|
||||
return domain.CopyRunDistribution(existing), nil
|
||||
}
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultSuccess, "generated run package with redacted runtime key ref"); err != nil {
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: buildJobID,
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityDistributionBuild,
|
||||
TargetKey: "distribution/run",
|
||||
InputRef: "input://distribution-build/" + distribution.ID,
|
||||
IdempotencyKey: "distribution-build:" + distribution.ID,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
|
||||
})
|
||||
if err != nil {
|
||||
distribution.Status = domain.DistributionStatusFailed
|
||||
distribution.UpdatedAt = svc.now()
|
||||
_ = svc.store.RunDistributions().Update(distribution)
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
||||
}
|
||||
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job with redacted runtime key ref"); err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
return domain.CopyRunDistribution(distribution), nil
|
||||
@@ -172,6 +187,13 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
|
||||
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
|
||||
if err != nil {
|
||||
@@ -184,52 +206,12 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
|
||||
config := generatedPackageConfig{
|
||||
Kind: string(domain.DistributionComponentClientManager),
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: request.ProfileKey,
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
SecretRef: key.SecretRef,
|
||||
KeyGeneration: key.Generation,
|
||||
AuthKey: plainKey,
|
||||
}
|
||||
checkout := clientManagerCheckoutPlan{
|
||||
RepositoryURL: request.RepositoryURL,
|
||||
SourceRevision: request.SourceRevision,
|
||||
CheckoutRef: clientManagerCheckoutRef(request.RepositoryURL, request.SourceRevision),
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
}
|
||||
outputArtifacts := []string{clientManagerOutputName(request.ProfileKey, request.TargetOS)}
|
||||
buildLogPayload := []byte(clientManagerBuildLog(checkout, config, outputArtifacts))
|
||||
buildLogArtifactID := artifactIDForDistribution(clientDistributionID + "-build-log")
|
||||
buildLogArtifact, err := svc.createPlatformArtifactPayload(buildLogArtifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, buildLogPayload)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
packagePayload := generatedClientManagerPackage{
|
||||
Kind: "client-manager-package",
|
||||
Checkout: checkout,
|
||||
Config: config,
|
||||
OutputArtifacts: outputArtifacts,
|
||||
BuildLogRef: "artifact://" + buildLogArtifact.ID,
|
||||
KeyFingerprint: fingerprintForString(plainKey),
|
||||
}
|
||||
payload, err := json.MarshalIndent(packagePayload, "", " ")
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
artifactID := artifactIDForDistribution(clientDistributionID)
|
||||
artifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
|
||||
_ = plainKey
|
||||
artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
|
||||
stamp := svc.now()
|
||||
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
|
||||
buildJob := domain.ClientManagerBuildJob{
|
||||
ID: distributionID("client-manager-build", instance.ID, request.ProfileKey, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey),
|
||||
ID: buildJobID,
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: request.ProfileKey,
|
||||
@@ -237,11 +219,9 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
TargetArch: request.TargetArch,
|
||||
RepositoryURL: request.RepositoryURL,
|
||||
SourceRevision: request.SourceRevision,
|
||||
ArtifactID: artifact.ID,
|
||||
Checksum: artifact.Checksum,
|
||||
ArtifactID: artifactID,
|
||||
KeyGeneration: key.Generation,
|
||||
LogsRef: "artifact://" + buildLogArtifact.ID,
|
||||
Status: domain.DistributionJobStatusSucceeded,
|
||||
Status: domain.DistributionJobStatusQueued,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
@@ -249,7 +229,17 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
if !errors.Is(err, repo.ErrDuplicate) {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
existing, getErr := svc.store.ClientManagerBuildJobs().Get(buildJob.ID)
|
||||
if getErr != nil {
|
||||
return domain.ClientManagerDistribution{}, getErr
|
||||
}
|
||||
if !sameClientManagerBuildJobArtifacts(existing, buildJob) {
|
||||
return domain.ClientManagerDistribution{}, validationError("client-manager build job already exists with different artifacts")
|
||||
}
|
||||
buildJob = existing
|
||||
}
|
||||
distribution := domain.ClientManagerDistribution{
|
||||
ID: clientDistributionID,
|
||||
@@ -261,11 +251,10 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
RepositoryURL: request.RepositoryURL,
|
||||
SourceRevision: request.SourceRevision,
|
||||
BuildJobID: buildJob.ID,
|
||||
ArtifactID: artifact.ID,
|
||||
Checksum: artifact.Checksum,
|
||||
ArtifactID: artifactID,
|
||||
KeyGeneration: key.Generation,
|
||||
SecretRef: key.SecretRef,
|
||||
Status: domain.DistributionStatusAvailable,
|
||||
Status: domain.DistributionStatusBuilding,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
@@ -273,9 +262,38 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil {
|
||||
if errors.Is(err, repo.ErrDuplicate) {
|
||||
existing, getErr := svc.store.ClientManagerDistributions().Get(distribution.ID)
|
||||
if getErr != nil {
|
||||
return domain.ClientManagerDistribution{}, getErr
|
||||
}
|
||||
return domain.CopyClientManagerDistribution(existing), nil
|
||||
}
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultSuccess, "generated client-manager artifact with redacted runtime key ref"); err != nil {
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: buildJobID,
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityDistributionBuild,
|
||||
TargetKey: "distribution/client-manager/" + request.ProfileKey,
|
||||
InputRef: "input://distribution-build/" + distribution.ID,
|
||||
IdempotencyKey: "distribution-build:" + distribution.ID,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
|
||||
})
|
||||
if err != nil {
|
||||
buildJob.Status = domain.DistributionJobStatusFailed
|
||||
buildJob.UpdatedAt = svc.now()
|
||||
distribution.Status = domain.DistributionStatusFailed
|
||||
distribution.UpdatedAt = buildJob.UpdatedAt
|
||||
_ = svc.store.ClientManagerBuildJobs().Update(buildJob)
|
||||
_ = svc.store.ClientManagerDistributions().Update(distribution)
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
||||
}
|
||||
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build with redacted runtime key ref"); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
return domain.CopyClientManagerDistribution(distribution), nil
|
||||
@@ -458,11 +476,11 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
RunEndpointID: endpoint.ID,
|
||||
RunStatus: endpoint.Status,
|
||||
Actions: []domain.ServerRuntimeAction{
|
||||
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared", bindingReason)),
|
||||
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
||||
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
|
||||
runtimeAction("push-run-update", "Push run update", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason)),
|
||||
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
|
||||
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared", bindingReason)),
|
||||
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
||||
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
|
||||
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
|
||||
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
|
||||
@@ -546,6 +564,16 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
|
||||
return domain.RunUpdateJob{}, err
|
||||
}
|
||||
if err := svc.store.RunUpdateJobs().Create(updateJob); err != nil {
|
||||
if errors.Is(err, repo.ErrDuplicate) {
|
||||
existing, getErr := svc.store.RunUpdateJobs().Get(updateJob.ID)
|
||||
if getErr != nil {
|
||||
return domain.RunUpdateJob{}, getErr
|
||||
}
|
||||
if !sameRunUpdateJob(existing, updateJob) {
|
||||
return domain.RunUpdateJob{}, validationError("run update job already exists with different target")
|
||||
}
|
||||
return domain.CopyRunUpdateJob(existing), nil
|
||||
}
|
||||
return domain.RunUpdateJob{}, err
|
||||
}
|
||||
if err := svc.recordAuditEvent(user.ID, "run.update", "server-instance", instance.ID, domain.AuditResultQueued, "queued run self-update job with artifact checksum"); err != nil {
|
||||
@@ -793,6 +821,9 @@ func (svc *CoreService) revokeComponentDistributions(serverInstanceID string, ki
|
||||
|
||||
func (svc *CoreService) expireDistributionArtifact(artifactID string) error {
|
||||
artifact, err := svc.store.Artifacts().Get(artifactID)
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -819,14 +850,82 @@ func (svc *CoreService) createPlatformArtifactPayload(artifactID string, ownerKi
|
||||
return domain.Artifact{}, err
|
||||
}
|
||||
if err := svc.store.Artifacts().Create(artifact); err != nil {
|
||||
if !errors.Is(err, repo.ErrDuplicate) {
|
||||
return domain.Artifact{}, err
|
||||
}
|
||||
existing, getErr := svc.store.Artifacts().Get(artifact.ID)
|
||||
if getErr != nil {
|
||||
return domain.Artifact{}, getErr
|
||||
}
|
||||
if err := validateReusablePlatformArtifact(existing, artifact); err != nil {
|
||||
return domain.Artifact{}, err
|
||||
}
|
||||
if err := svc.ensureArtifactPayload(artifact.ID, payload, existing); err != nil {
|
||||
return domain.Artifact{}, err
|
||||
}
|
||||
return domain.CopyArtifact(existing), nil
|
||||
}
|
||||
if err := svc.ensureArtifactPayload(artifact.ID, payload, artifact); err != nil {
|
||||
return domain.Artifact{}, err
|
||||
}
|
||||
svc.artifactMu.Lock()
|
||||
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
|
||||
svc.artifactMu.Unlock()
|
||||
return domain.CopyArtifact(artifact), nil
|
||||
}
|
||||
|
||||
func validateReusablePlatformArtifact(existing domain.Artifact, expected domain.Artifact) error {
|
||||
if existing.OwnerKind != expected.OwnerKind || existing.OwnerID != expected.OwnerID {
|
||||
return validationError("artifact already exists with different owner")
|
||||
}
|
||||
if existing.SizeBytes != expected.SizeBytes || existing.Checksum != expected.Checksum {
|
||||
return validationError("artifact already exists with different content")
|
||||
}
|
||||
if existing.State != domain.ArtifactStateAvailable {
|
||||
return validationError("artifact already exists but is not available")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte, artifact domain.Artifact) error {
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
if existingPayload, exists := svc.artifactPayloads[artifactID]; exists {
|
||||
if int64(len(existingPayload)) != artifact.SizeBytes || validator.BytesChecksum(existingPayload) != artifact.Checksum {
|
||||
return validationError("artifact payload does not match metadata")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
|
||||
return validationError("artifact payload does not match metadata")
|
||||
}
|
||||
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, expected domain.ClientManagerBuildJob) bool {
|
||||
return existing.ServerInstanceID == expected.ServerInstanceID &&
|
||||
existing.PluginID == expected.PluginID &&
|
||||
existing.ProfileKey == expected.ProfileKey &&
|
||||
existing.TargetOS == expected.TargetOS &&
|
||||
existing.TargetArch == expected.TargetArch &&
|
||||
existing.RepositoryURL == expected.RepositoryURL &&
|
||||
existing.SourceRevision == expected.SourceRevision &&
|
||||
existing.ArtifactID == expected.ArtifactID &&
|
||||
existing.Checksum == expected.Checksum &&
|
||||
existing.KeyGeneration == expected.KeyGeneration &&
|
||||
existing.LogsRef == expected.LogsRef &&
|
||||
existing.Status == expected.Status
|
||||
}
|
||||
|
||||
func sameRunUpdateJob(existing domain.RunUpdateJob, expected domain.RunUpdateJob) bool {
|
||||
return existing.ServerInstanceID == expected.ServerInstanceID &&
|
||||
existing.RunEndpointID == expected.RunEndpointID &&
|
||||
existing.ArtifactID == expected.ArtifactID &&
|
||||
existing.Checksum == expected.Checksum &&
|
||||
existing.JobID == expected.JobID &&
|
||||
existing.IdempotencyKey == expected.IdempotencyKey &&
|
||||
existing.Status == expected.Status
|
||||
}
|
||||
|
||||
func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, state domain.DependencyState, message string) error {
|
||||
statusID := distributionID("dependency-status", instance.ID, request.ProbeKey)
|
||||
stamp := svc.now()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) {
|
||||
@@ -21,9 +21,16 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
||||
if err != nil {
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
}
|
||||
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusAvailable {
|
||||
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" || distribution.Checksum != "" {
|
||||
t.Fatalf("unexpected run distribution: %+v", distribution)
|
||||
}
|
||||
job, err := svc.GetJob(distribution.BuildJobID)
|
||||
if err != nil || job.Capability != domain.JobCapabilityDistributionBuild || job.State != domain.JobStateQueued {
|
||||
t.Fatalf("expected queued backend build job, job=%+v err=%v", job, err)
|
||||
}
|
||||
if _, err := svc.GetArtifact(distribution.ArtifactID); !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Fatalf("generation must not publish config JSON as an artifact, got %v", err)
|
||||
}
|
||||
|
||||
keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -76,6 +83,78 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunDistributionRetryReusesPartialArtifact(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure run key: %v", err)
|
||||
}
|
||||
|
||||
idempotencyKey := "web:run.generate:scum-alpha:1784043453685"
|
||||
distributionID := distributionID("run-dist", instance.ID, "windows", "amd64", key.Generation, idempotencyKey)
|
||||
artifactID := artifactIDForDistribution(distributionID)
|
||||
payload := []byte("legacy generated config is not a binary distribution")
|
||||
partialArtifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("create partial artifact: %v", err)
|
||||
}
|
||||
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry run generation should recover partial artifact: %v", err)
|
||||
}
|
||||
if distribution.ID != distributionID || distribution.ArtifactID == partialArtifact.ID || distribution.Checksum != "" {
|
||||
t.Fatalf("expected real binary build to ignore legacy config artifact, distribution=%+v artifact=%+v", distribution, partialArtifact)
|
||||
}
|
||||
if distribution.PackageFormat != "zip" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" {
|
||||
t.Fatalf("unexpected recovered distribution: %+v", distribution)
|
||||
}
|
||||
recoveredConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
|
||||
if recoveredConfig.AuthKey != plainKey || recoveredConfig.KeyGeneration != key.Generation {
|
||||
t.Fatalf("expected recovered artifact payload to remain downloadable, got %+v", recoveredConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "idem-run-before-update",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
}
|
||||
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive"))
|
||||
|
||||
request := domain.RunUpdateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
Checksum: distribution.Checksum,
|
||||
IdempotencyKey: "idem-run-update-repeat",
|
||||
}
|
||||
first, err := svc.PushRunUpdateForSession(session, request)
|
||||
if err != nil {
|
||||
t.Fatalf("push run update: %v", err)
|
||||
}
|
||||
second, err := svc.PushRunUpdateForSession(session, request)
|
||||
if err != nil {
|
||||
t.Fatalf("push run update again should reuse existing update job: %v", err)
|
||||
}
|
||||
if second.ID != first.ID || second.JobID != first.JobID || second.ArtifactID != first.ArtifactID {
|
||||
t.Fatalf("expected repeated push to return existing update job, first=%+v second=%+v", first, second)
|
||||
}
|
||||
if second.Status != domain.DistributionJobStatusQueued {
|
||||
t.Fatalf("expected queued existing update job, got %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
@@ -88,6 +167,7 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
}
|
||||
oldConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
|
||||
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive before reset"))
|
||||
|
||||
reset, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -161,6 +241,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
|
||||
if err != nil {
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
}
|
||||
runDistribution = completeDistributionBuild(t, svc, runDistribution, []byte("compiled run archive"))
|
||||
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
|
||||
t.Fatalf("open run download: %v", err)
|
||||
}
|
||||
@@ -179,7 +260,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
|
||||
t.Fatalf("generate client-manager distribution: %v", err)
|
||||
}
|
||||
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
|
||||
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusAvailable {
|
||||
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusBuilding {
|
||||
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
|
||||
}
|
||||
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||
@@ -189,29 +270,13 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
|
||||
if err != nil {
|
||||
t.Fatalf("get build job: %v", err)
|
||||
}
|
||||
if build.Status != domain.DistributionJobStatusSucceeded || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
||||
t.Fatalf("unexpected build job: %+v", build)
|
||||
}
|
||||
if build.LogsRef == "" || !strings.HasPrefix(build.LogsRef, "artifact://") {
|
||||
t.Fatalf("expected redacted build log artifact ref, got %+v", build)
|
||||
}
|
||||
packagePayload := readClientManagerPackage(t, svc, session, clientDistribution.ArtifactID)
|
||||
if packagePayload.Checkout.CheckoutRef != "branch/main" || packagePayload.Config.AuthKey != clientConfig.AuthKey || packagePayload.KeyFingerprint == "" {
|
||||
t.Fatalf("expected package checkout metadata and injected config, got %+v", packagePayload)
|
||||
}
|
||||
if len(packagePayload.OutputArtifacts) == 0 || packagePayload.BuildLogRef != build.LogsRef {
|
||||
t.Fatalf("expected output artifacts and build log ref, got %+v build=%+v", packagePayload, build)
|
||||
}
|
||||
buildLog := readArtifactString(t, svc, session, strings.TrimPrefix(build.LogsRef, "artifact://"))
|
||||
for _, expected := range []string{"client-manager checkout prepared", "checkoutRef=branch/main", "dependencyCheck=typed build profile accepted", "configInjection=secret ref"} {
|
||||
if !strings.Contains(buildLog, expected) {
|
||||
t.Fatalf("expected build log to contain %q, got %q", expected, buildLog)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
|
||||
if strings.Contains(buildLog, forbidden) {
|
||||
t.Fatalf("build log leaked forbidden fragment %q: %s", forbidden, buildLog)
|
||||
}
|
||||
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
|
||||
build, err = svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
|
||||
if err != nil || build.Status != domain.DistributionJobStatusSucceeded || build.Checksum == "" {
|
||||
t.Fatalf("expected uploaded client build to project as succeeded, build=%+v err=%v", build, err)
|
||||
}
|
||||
|
||||
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
|
||||
@@ -293,6 +358,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
t.Fatalf("update plugin fixture: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities,
|
||||
domain.JobCapabilityDistributionBuild,
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
@@ -323,47 +389,99 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
|
||||
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
|
||||
t.Helper()
|
||||
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
|
||||
_ = session
|
||||
runs, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact content: %v", err)
|
||||
t.Fatalf("list run distributions: %v", err)
|
||||
}
|
||||
var config generatedPackageConfig
|
||||
if err := json.Unmarshal(content.Payload, &config); err != nil {
|
||||
t.Fatalf("unmarshal generated config: %v", err)
|
||||
}
|
||||
if config.AuthKey == "" {
|
||||
var packagePayload generatedClientManagerPackage
|
||||
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
|
||||
t.Fatalf("unmarshal generated client-manager package: %v", err)
|
||||
for _, distribution := range runs {
|
||||
if distribution.ArtifactID != artifactID {
|
||||
continue
|
||||
}
|
||||
config = packagePayload.Config
|
||||
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentRun, "")
|
||||
if err != nil {
|
||||
t.Fatalf("get run key: %v", err)
|
||||
}
|
||||
plain, err := decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt run key: %v", err)
|
||||
}
|
||||
return generatedPackageConfig{Kind: "run", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
|
||||
}
|
||||
if config.AuthKey == "" || config.SecretRef == "" || config.KeyGeneration <= 0 {
|
||||
t.Fatalf("generated package config is incomplete: %+v", config)
|
||||
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list client distributions: %v", err)
|
||||
}
|
||||
return config
|
||||
for _, distribution := range clients {
|
||||
if distribution.ArtifactID != artifactID {
|
||||
continue
|
||||
}
|
||||
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
t.Fatalf("get client key: %v", err)
|
||||
}
|
||||
plain, err := decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt client key: %v", err)
|
||||
}
|
||||
return generatedPackageConfig{Kind: "client-manager", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, ProfileKey: distribution.ProfileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
|
||||
}
|
||||
t.Fatalf("distribution for artifact %s was not found", artifactID)
|
||||
return generatedPackageConfig{}
|
||||
}
|
||||
|
||||
func readClientManagerPackage(t *testing.T, svc *CoreService, session string, artifactID string) generatedClientManagerPackage {
|
||||
func completeDistributionBuild(t *testing.T, svc *CoreService, distribution domain.RunDistribution, payload []byte) domain.RunDistribution {
|
||||
t.Helper()
|
||||
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
|
||||
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("read client-manager package content: %v", err)
|
||||
t.Fatalf("publish run build artifact: %v", err)
|
||||
}
|
||||
var packagePayload generatedClientManagerPackage
|
||||
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
|
||||
t.Fatalf("unmarshal generated client-manager package: %v", err)
|
||||
job, err := svc.GetJob(distribution.BuildJobID)
|
||||
if err != nil {
|
||||
t.Fatalf("get run build job: %v", err)
|
||||
}
|
||||
return packagePayload
|
||||
job.State = domain.JobStateSucceeded
|
||||
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
|
||||
job.ResultRef = "artifact://" + artifact.ID
|
||||
job.UpdatedAt = svc.now()
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
t.Fatalf("update run build job: %v", err)
|
||||
}
|
||||
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
|
||||
t.Fatalf("project run build: %v", err)
|
||||
}
|
||||
updated, err := svc.store.RunDistributions().Get(distribution.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get completed run distribution: %v", err)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func readArtifactString(t *testing.T, svc *CoreService, session string, artifactID string) string {
|
||||
func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, payload []byte) domain.ClientManagerDistribution {
|
||||
t.Helper()
|
||||
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
|
||||
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact content: %v", err)
|
||||
t.Fatalf("publish client build artifact: %v", err)
|
||||
}
|
||||
return string(content.Payload)
|
||||
job, err := svc.GetJob(distribution.BuildJobID)
|
||||
if err != nil {
|
||||
t.Fatalf("get client build job: %v", err)
|
||||
}
|
||||
job.State = domain.JobStateSucceeded
|
||||
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
|
||||
job.ResultRef = "artifact://" + artifact.ID
|
||||
job.UpdatedAt = svc.now()
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
t.Fatalf("update client build job: %v", err)
|
||||
}
|
||||
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
|
||||
t.Fatalf("project client build: %v", err)
|
||||
}
|
||||
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get completed client distribution: %v", err)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
|
||||
|
||||
@@ -126,6 +126,9 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
if err := svc.projectDistributionBuildProgress(job, stamp); err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
lease.UpdatedAt = stamp
|
||||
svc.jobLeases[job.ID] = lease
|
||||
return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
|
||||
@@ -153,6 +156,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectDistributionBuildResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
|
||||
}
|
||||
return domain.RunJobResultResult{}, validationError("terminal result conflicts with existing job result")
|
||||
@@ -171,6 +177,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectDistributionBuildResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
lease.TerminalFingerprint = fingerprint
|
||||
lease.UpdatedAt = stamp
|
||||
svc.jobLeases[job.ID] = lease
|
||||
|
||||
@@ -86,6 +86,7 @@ type Core interface {
|
||||
AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error)
|
||||
UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error)
|
||||
CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error)
|
||||
GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error)
|
||||
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error)
|
||||
PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error)
|
||||
ReconcileRunJobs(domain.RunJobReconcile) (domain.RunJobReconcileResult, error)
|
||||
@@ -1879,7 +1880,7 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
|
||||
if plugin.ID != instance.PluginID {
|
||||
return validationError("job plugin must match server instance")
|
||||
}
|
||||
if !containsString(plugin.RequiredRunCapabilities, job.Capability) {
|
||||
if job.Capability != domain.JobCapabilityDistributionBuild && !containsString(plugin.RequiredRunCapabilities, job.Capability) {
|
||||
return validationError("plugin missing required capability: " + job.Capability)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -91,14 +91,17 @@ func ValidateRunDistribution(distribution domain.RunDistribution) error {
|
||||
violations = appendRequired(violations, "targetOs", distribution.TargetOS)
|
||||
violations = appendRequired(violations, "targetArch", distribution.TargetArch)
|
||||
violations = appendRequired(violations, "packageFormat", distribution.PackageFormat)
|
||||
violations = appendRequired(violations, "buildJobId", distribution.BuildJobID)
|
||||
violations = appendRequired(violations, "artifactId", distribution.ArtifactID)
|
||||
violations = appendRequired(violations, "checksum", distribution.Checksum)
|
||||
violations = appendRequired(violations, "secretRef", distribution.SecretRef)
|
||||
violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch)
|
||||
violations = appendDistributionStatusViolations(violations, distribution.Status)
|
||||
if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
if distribution.Status == domain.DistributionStatusAvailable && distribution.Checksum == "" {
|
||||
violations = append(violations, "checksum is required when distribution is available")
|
||||
}
|
||||
if distribution.KeyGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration must be positive")
|
||||
}
|
||||
@@ -127,7 +130,6 @@ func ValidateClientManagerDistribution(distribution domain.ClientManagerDistribu
|
||||
violations = appendRequired(violations, "sourceRevision", distribution.SourceRevision)
|
||||
violations = appendRequired(violations, "buildJobId", distribution.BuildJobID)
|
||||
violations = appendRequired(violations, "artifactId", distribution.ArtifactID)
|
||||
violations = appendRequired(violations, "checksum", distribution.Checksum)
|
||||
violations = appendRequired(violations, "secretRef", distribution.SecretRef)
|
||||
violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch)
|
||||
violations = appendDistributionStatusViolations(violations, distribution.Status)
|
||||
@@ -137,6 +139,9 @@ func ValidateClientManagerDistribution(distribution domain.ClientManagerDistribu
|
||||
if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
if distribution.Status == domain.DistributionStatusAvailable && distribution.Checksum == "" {
|
||||
violations = append(violations, "checksum is required when distribution is available")
|
||||
}
|
||||
if distribution.KeyGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration must be positive")
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ func ValidateRunJobResult(result domain.RunJobResult) error {
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateDistributionBuildInputRequest(request domain.DistributionBuildInputRequest) error {
|
||||
var violations []string
|
||||
violations = appendLeaseFields(violations, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "jobId", request.JobID)
|
||||
|
||||
Reference in New Issue
Block a user