feat: 自动更新

This commit is contained in:
npc0-hue
2026-07-15 19:43:06 +08:00
parent f64eb0831f
commit f3b14b7945
54 changed files with 3207 additions and 589 deletions
+35
View File
@@ -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.
+67 -39
View File
@@ -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,
+27
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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,
+58
View File
@@ -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,
+1
View File
@@ -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"`
+2 -2
View File
@@ -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},
+229
View File
@@ -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
}
+182 -83
View File
@@ -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()
+167 -49
View File
@@ -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) {
+9
View File
@@ -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
+2 -1
View File
@@ -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
+7 -2
View File
@@ -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")
}
+6
View File
@@ -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)