400 lines
17 KiB
Go
400 lines
17 KiB
Go
package runtime
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
func TestWorkerDistributionBuildCompilesAndUploadsRawRunExecutable(t *testing.T) {
|
|
client := newFakeWorkerClient()
|
|
cfg := workerTestConfig(t)
|
|
cfg.BuildSourceRoot = ".."
|
|
worker, err := NewWorker(cfg, client)
|
|
if err != nil {
|
|
t.Fatalf("new worker: %v", err)
|
|
}
|
|
worker.state.SessionToken = "session-token"
|
|
|
|
output := "run"
|
|
if runtime.GOOS == "windows" {
|
|
output = "run.exe"
|
|
}
|
|
assignment := protocol.RunJobAssignment{
|
|
JobID: "job-distribution-build-test",
|
|
ServerInstanceID: "server-build-test",
|
|
RunEndpointID: "run-test",
|
|
Capability: protocol.RunCapabilityDistributionBuild,
|
|
TargetKey: "distribution/run",
|
|
InputRef: "input://distribution-build/run-test",
|
|
IdempotencyKey: "distribution-build:test",
|
|
State: "running",
|
|
LeaseToken: "lease-test",
|
|
Attempt: 1,
|
|
}
|
|
client.claimJob = assignment
|
|
client.buildInput = protocol.DistributionBuildInputResponse{
|
|
JobID: assignment.JobID,
|
|
ComponentKind: "run",
|
|
ServerInstanceID: assignment.ServerInstanceID,
|
|
PluginID: "game.scum",
|
|
RunEndpointID: assignment.RunEndpointID,
|
|
TargetOS: runtime.GOOS,
|
|
TargetArch: runtime.GOARCH,
|
|
TargetRelease: "run-release-test",
|
|
PlatformURL: "https://scum.npc0.com",
|
|
PackageFormat: "raw-executable",
|
|
ArtifactID: "artifact-built-run",
|
|
OutputFilename: output,
|
|
SecretRef: "secret://runtime-keys/server-build-test/run/current",
|
|
KeyGeneration: 1,
|
|
AuthKey: "test-component-key",
|
|
}
|
|
|
|
result := worker.executeDistributionBuild(context.Background(), assignment)
|
|
if result.State != lifecycleResultStateSucceeded || result.ResultRef != "artifact://artifact-built-run" {
|
|
t.Fatalf("expected successful real build, got %+v", result)
|
|
}
|
|
if len(client.artifactPayload) < 1024 {
|
|
t.Fatalf("expected compiled executable payload, got %d bytes", len(client.artifactPayload))
|
|
}
|
|
extracted := t.TempDir()
|
|
binaryPath := filepath.Join(extracted, output)
|
|
if err := os.WriteFile(binaryPath, client.artifactPayload, 0o700); err != nil {
|
|
t.Fatalf("write uploaded run executable: %v", err)
|
|
}
|
|
command := exec.Command(binaryPath)
|
|
command.Env = append(os.Environ(), "RUN_MODE=smoke")
|
|
smokeOutput, err := command.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("execute generated run package smoke mode: %v output=%s", err, smokeOutput)
|
|
}
|
|
var summary map[string]any
|
|
if err := json.Unmarshal(smokeOutput, &summary); err != nil {
|
|
t.Fatalf("decode generated package smoke output: %v body=%s", err, smokeOutput)
|
|
}
|
|
if summary["status"] != "ok" || summary["mode"] != "smoke" {
|
|
t.Fatalf("expected generated package to run smoke mode, got %+v", summary)
|
|
}
|
|
if summary["platformUrl"] != "https://scum.npc0.com" {
|
|
t.Fatalf("expected generated executable to use compiled platform URL, got %+v", summary)
|
|
}
|
|
joinedProgress := make([]string, 0, len(client.progressRequests))
|
|
for _, request := range client.progressRequests {
|
|
joinedProgress = append(joinedProgress, request.Progress.Message)
|
|
}
|
|
progress := strings.Join(joinedProgress, "\n")
|
|
for _, stage := range []string{"git_sync:", "env_check:", "deps_download:", "build_compile:", "package_finalize:"} {
|
|
if !strings.Contains(progress, stage) {
|
|
t.Fatalf("expected real progress stage %q in %q", stage, progress)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkerDistributionBuildCrossCompilesWindowsAMD64Run(t *testing.T) {
|
|
client := newFakeWorkerClient()
|
|
cfg := workerTestConfig(t)
|
|
cfg.BuildSourceRoot = ".."
|
|
worker, err := NewWorker(cfg, client)
|
|
if err != nil {
|
|
t.Fatalf("new worker: %v", err)
|
|
}
|
|
worker.state.SessionToken = "session-token"
|
|
assignment := protocol.RunJobAssignment{JobID: "job-distribution-build-windows", ServerInstanceID: "server-build-windows", RunEndpointID: "run-builder", Capability: protocol.RunCapabilityDistributionBuild, TargetKey: "distribution/run", InputRef: "input://distribution-build/windows", IdempotencyKey: "distribution-build:windows", State: "running", LeaseToken: "lease-windows", Attempt: 1}
|
|
client.claimJob = assignment
|
|
client.buildInput = protocol.DistributionBuildInputResponse{JobID: assignment.JobID, ComponentKind: "run", ServerInstanceID: assignment.ServerInstanceID, PluginID: "game.scum", RunEndpointID: "server-run-server-build-windows", TargetOS: "windows", TargetArch: "amd64", TargetRelease: "run-release-windows", PlatformURL: "https://scum.npc0.com", PackageFormat: "raw-executable", ArtifactID: "artifact-built-windows-run", OutputFilename: "run.exe", SecretRef: "secret://runtime-keys/server-build-windows/run/current", KeyGeneration: 1, AuthKey: "test-component-key"}
|
|
|
|
result := worker.executeDistributionBuild(context.Background(), assignment)
|
|
if result.State != lifecycleResultStateSucceeded || result.ResultRef != "artifact://artifact-built-windows-run" {
|
|
t.Fatalf("expected successful Windows build, got %+v", result)
|
|
}
|
|
if len(client.artifactPayload) < 1024 || !bytes.HasPrefix(client.artifactPayload, []byte("MZ")) {
|
|
t.Fatalf("expected Windows PE executable payload, got %d bytes", len(client.artifactPayload))
|
|
}
|
|
}
|
|
|
|
func TestDistributionBuildIsolationUsesPluginJobWorkspaceAndDistinctPackageState(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
firstAssignment := protocol.RunJobAssignment{JobID: "job-distribution-build-scum-alpha", ServerInstanceID: "scum-alpha", RunEndpointID: "run-test"}
|
|
secondAssignment := protocol.RunJobAssignment{JobID: "job-distribution-build-scum-beta", ServerInstanceID: "scum-beta", RunEndpointID: "run-test"}
|
|
firstInput := protocol.DistributionBuildInputResponse{
|
|
JobID: firstAssignment.JobID,
|
|
ComponentKind: "run",
|
|
ServerInstanceID: firstAssignment.ServerInstanceID,
|
|
PluginID: "game.scum",
|
|
RunEndpointID: firstAssignment.RunEndpointID,
|
|
ProfileKey: "run-local",
|
|
TargetOS: "linux",
|
|
TargetArch: "amd64",
|
|
PlatformURL: "https://scum.npc0.com",
|
|
PackageFormat: "raw-executable",
|
|
ArtifactID: "artifact-run-dist-scum-alpha",
|
|
OutputFilename: "run",
|
|
SecretRef: "secret://runtime-keys/scum-alpha/run/current",
|
|
KeyGeneration: 1,
|
|
AuthKey: "alpha-component-key",
|
|
}
|
|
secondInput := firstInput
|
|
secondInput.JobID = secondAssignment.JobID
|
|
secondInput.ServerInstanceID = secondAssignment.ServerInstanceID
|
|
secondInput.ArtifactID = "artifact-run-dist-scum-beta"
|
|
secondInput.SecretRef = "secret://runtime-keys/scum-beta/run/current"
|
|
secondInput.AuthKey = "beta-component-key"
|
|
|
|
firstWorkspace := distributionBuildWorkspace(workspaceRoot, firstInput.PluginID, firstAssignment.JobID)
|
|
secondWorkspace := distributionBuildWorkspace(workspaceRoot, secondInput.PluginID, secondAssignment.JobID)
|
|
if firstWorkspace == secondWorkspace {
|
|
t.Fatalf("expected same-plugin builds to use distinct job workspaces")
|
|
}
|
|
if filepath.Dir(firstWorkspace) != filepath.Dir(secondWorkspace) || filepath.Base(filepath.Dir(firstWorkspace)) != "game-scum" {
|
|
t.Fatalf("expected workspaces under the same plugin queue directory, first=%s second=%s", firstWorkspace, secondWorkspace)
|
|
}
|
|
firstFlags := buildRunLDFlags(firstInput, "https://scum.npc0.com")
|
|
secondFlags := buildRunLDFlags(secondInput, "https://scum.npc0.com")
|
|
if !strings.Contains(firstFlags, "BuildServerInstanceID=scum-alpha") || !strings.Contains(firstFlags, "BuildRegistrationToken=alpha-component-key") {
|
|
t.Fatalf("expected first build flags to carry first server identity, got %q", firstFlags)
|
|
}
|
|
if strings.Contains(firstFlags, "BuildComponentKey=run-local") || !strings.Contains(firstFlags, "BuildComponentKey=") {
|
|
t.Fatalf("expected run component identity to stay separate from lifecycle profile, got %q", firstFlags)
|
|
}
|
|
if !strings.Contains(secondFlags, "BuildServerInstanceID=scum-beta") || !strings.Contains(secondFlags, "BuildRegistrationToken=beta-component-key") {
|
|
t.Fatalf("expected second build flags to carry second server identity, got %q", secondFlags)
|
|
}
|
|
if firstFlags == secondFlags {
|
|
t.Fatalf("expected same-plugin run builds to stay per-server")
|
|
}
|
|
|
|
client := newFakeWorkerClient()
|
|
worker := &Worker{cfg: workerTestConfig(t), client: client}
|
|
worker.state.RunEndpointID = "run-test"
|
|
worker.state.SessionToken = "session-token"
|
|
client.buildInput = firstInput
|
|
if err := worker.uploadDistributionArtifact(context.Background(), firstAssignment, firstInput.ArtifactID, []byte("alpha archive")); err != nil {
|
|
t.Fatalf("upload first artifact: %v", err)
|
|
}
|
|
client.artifactPayload = nil
|
|
client.buildInput = secondInput
|
|
if err := worker.uploadDistributionArtifact(context.Background(), secondAssignment, secondInput.ArtifactID, []byte("beta archive")); err != nil {
|
|
t.Fatalf("upload second artifact: %v", err)
|
|
}
|
|
if len(client.artifactOpenRequests) != 2 {
|
|
t.Fatalf("expected two artifact transfer opens, got %+v", client.artifactOpenRequests)
|
|
}
|
|
if client.artifactOpenRequests[0].ArtifactID != firstInput.ArtifactID || client.artifactOpenRequests[0].OwnerID != firstAssignment.JobID {
|
|
t.Fatalf("first artifact upload used wrong scope: %+v", client.artifactOpenRequests[0])
|
|
}
|
|
if client.artifactOpenRequests[1].ArtifactID != secondInput.ArtifactID || client.artifactOpenRequests[1].OwnerID != secondAssignment.JobID {
|
|
t.Fatalf("second artifact upload used wrong scope: %+v", client.artifactOpenRequests[1])
|
|
}
|
|
}
|
|
|
|
func TestCreateDistributionArchiveIncludesExecutableAndConfigForSupportedFormats(t *testing.T) {
|
|
for _, format := range []string{"tar.gz", "zip"} {
|
|
t.Run(format, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
executableName := "run"
|
|
if format == "zip" {
|
|
executableName = "run.exe"
|
|
}
|
|
binaryPath := filepath.Join(root, executableName)
|
|
configPath := filepath.Join(root, "config.json")
|
|
if err := os.WriteFile(binaryPath, []byte("binary"), 0o700); err != nil {
|
|
t.Fatalf("write binary: %v", err)
|
|
}
|
|
if err := os.WriteFile(configPath, []byte(`{"kind":"run"}`), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
archivePath := filepath.Join(root, "package."+strings.ReplaceAll(format, ".", ""))
|
|
if err := createDistributionArchive(archivePath, format, binaryPath, configPath); err != nil {
|
|
t.Fatalf("create archive: %v", err)
|
|
}
|
|
payload, err := os.ReadFile(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("read archive: %v", err)
|
|
}
|
|
entries := archiveEntries(t, format, payload)
|
|
if !entries[executableName] || !entries["config.json"] {
|
|
t.Fatalf("expected executable and config in %s archive, got %+v", format, entries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareDistributionSourceCopiesTrustedRunSourceIntoWorkspace(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(sourceRoot, "go.mod"), []byte("module example.test/trusted\n\ngo 1.24\n"), 0o600); err != nil {
|
|
t.Fatalf("write go.mod: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourceRoot, "main.go"), []byte("package main\nfunc main() {}\n"), 0o600); err != nil {
|
|
t.Fatalf("write main.go: %v", err)
|
|
}
|
|
workspace := t.TempDir()
|
|
worker := &Worker{cfg: workerTestConfig(t)}
|
|
worker.cfg.BuildSourceRoot = sourceRoot
|
|
seed := base64.StdEncoding.EncodeToString([]byte(`[{"path":"actions/install.json","content":"{}"}]`))
|
|
|
|
prepared, err := worker.prepareDistributionSource(context.Background(), workspace, protocol.DistributionBuildInputResponse{ComponentKind: "run", WorkspaceSeed: seed})
|
|
if err != nil {
|
|
t.Fatalf("prepare trusted run source: %v", err)
|
|
}
|
|
if prepared != filepath.Join(workspace, "source") {
|
|
t.Fatalf("expected isolated source under workspace, got %s", prepared)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(prepared, "main.go")); err != nil {
|
|
t.Fatalf("expected copied source file: %v", err)
|
|
}
|
|
seedConfig, err := os.ReadFile(filepath.Join(prepared, "config", "workspace_seed_generated.go"))
|
|
if err != nil || !strings.Contains(string(seedConfig), seed) {
|
|
t.Fatalf("expected generated workspace seed config, body=%q err=%v", seedConfig, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(prepared, "main.go"), []byte("package main\n// isolated mutation\nfunc main() {}\n"), 0o600); err != nil {
|
|
t.Fatalf("mutate isolated copy: %v", err)
|
|
}
|
|
original, err := os.ReadFile(filepath.Join(sourceRoot, "main.go"))
|
|
if err != nil || strings.Contains(string(original), "isolated mutation") {
|
|
t.Fatalf("trusted source was mutated, body=%q err=%v", original, err)
|
|
}
|
|
}
|
|
|
|
func TestValidateDistributionBuildInputRejectsUnapprovedClientSource(t *testing.T) {
|
|
assignment := protocol.RunJobAssignment{JobID: "job-build", ServerInstanceID: "server-build", RunEndpointID: "run-build"}
|
|
base := protocol.DistributionBuildInputResponse{
|
|
JobID: assignment.JobID, ComponentKind: "client-manager", ServerInstanceID: assignment.ServerInstanceID, RunEndpointID: assignment.RunEndpointID,
|
|
PluginID: "game.scum", TargetOS: "linux", TargetArch: "amd64", PackageFormat: "tar.gz", ArtifactID: "artifact-build", OutputFilename: "manager", AuthKey: "key", SourceRevision: "main",
|
|
}
|
|
for _, repository := range []string{"http://example.test/manager.git", "https://token@example.test/manager.git", "https://example.test/manager.git?ref=main"} {
|
|
input := base
|
|
input.RepositoryURL = repository
|
|
if err := validateDistributionBuildInput(assignment, input); err == nil {
|
|
t.Fatalf("expected repository %q to be rejected", repository)
|
|
}
|
|
}
|
|
base.RepositoryURL = "https://example.test/manager.git"
|
|
base.SourceRevision = ""
|
|
if err := validateDistributionBuildInput(assignment, base); err == nil {
|
|
t.Fatal("expected an unpinned client-manager source to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestValidateDistributionBuildInputAllowsDedicatedRunIdentity(t *testing.T) {
|
|
assignment := protocol.RunJobAssignment{JobID: "job-build", ServerInstanceID: "server-build", RunEndpointID: "run-local-debug"}
|
|
input := protocol.DistributionBuildInputResponse{JobID: assignment.JobID, ComponentKind: "run", ServerInstanceID: assignment.ServerInstanceID, RunEndpointID: "server-run-server-build", PluginID: "game.scum", TargetOS: "windows", TargetArch: "amd64", TargetRelease: "release-1", PlatformURL: "https://scum.npc0.com", PackageFormat: "raw-executable", ArtifactID: "artifact-build", OutputFilename: "run.exe", AuthKey: "key"}
|
|
if err := validateDistributionBuildInput(assignment, input); err != nil {
|
|
t.Fatalf("dedicated Run identity must be accepted for a builder job: %v", err)
|
|
}
|
|
input.WorkspaceSeed = "not-base64"
|
|
if err := validateDistributionBuildInput(assignment, input); err == nil || !strings.Contains(err.Error(), "workspace seed") {
|
|
t.Fatalf("expected invalid run workspace seed rejection, got %v", err)
|
|
}
|
|
input.WorkspaceSeed = ""
|
|
input.ComponentKind = "client-manager"
|
|
input.PackageFormat = "zip"
|
|
input.RepositoryURL = "https://example.test/manager.git"
|
|
input.SourceRevision = "main"
|
|
if err := validateDistributionBuildInput(assignment, input); err == nil {
|
|
t.Fatal("client-manager build must remain bound to its assigned builder")
|
|
}
|
|
}
|
|
|
|
func archiveEntries(t *testing.T, format string, payload []byte) map[string]bool {
|
|
t.Helper()
|
|
entries := map[string]bool{}
|
|
if format == "zip" {
|
|
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
|
if err != nil {
|
|
t.Fatalf("open zip: %v", err)
|
|
}
|
|
for _, file := range reader.File {
|
|
entries[file.Name] = true
|
|
}
|
|
return entries
|
|
}
|
|
gzipReader, err := gzip.NewReader(bytes.NewReader(payload))
|
|
if err != nil {
|
|
t.Fatalf("open gzip: %v", err)
|
|
}
|
|
defer gzipReader.Close()
|
|
reader := tar.NewReader(gzipReader)
|
|
for {
|
|
header, err := reader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("read tar: %v", err)
|
|
}
|
|
entries[header.Name] = true
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func extractArchive(t *testing.T, format string, payload []byte, destination string) {
|
|
t.Helper()
|
|
if format == "zip" {
|
|
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
|
if err != nil {
|
|
t.Fatalf("open zip: %v", err)
|
|
}
|
|
for _, file := range reader.File {
|
|
input, err := file.Open()
|
|
if err != nil {
|
|
t.Fatalf("open zip entry: %v", err)
|
|
}
|
|
body, err := io.ReadAll(input)
|
|
closeErr := input.Close()
|
|
if err != nil || closeErr != nil {
|
|
t.Fatalf("read zip entry: err=%v close=%v", err, closeErr)
|
|
}
|
|
mode := os.FileMode(0o600)
|
|
if file.Name == "run" || strings.HasSuffix(file.Name, ".exe") {
|
|
mode = 0o700
|
|
}
|
|
if err := os.WriteFile(filepath.Join(destination, file.Name), body, mode); err != nil {
|
|
t.Fatalf("write zip entry: %v", err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
gzipReader, err := gzip.NewReader(bytes.NewReader(payload))
|
|
if err != nil {
|
|
t.Fatalf("open gzip: %v", err)
|
|
}
|
|
defer gzipReader.Close()
|
|
reader := tar.NewReader(gzipReader)
|
|
for {
|
|
header, err := reader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("read tar: %v", err)
|
|
}
|
|
mode := os.FileMode(header.Mode)
|
|
if err := os.WriteFile(filepath.Join(destination, header.Name), mustReadAll(t, reader), mode); err != nil {
|
|
t.Fatalf("write tar entry: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustReadAll(t *testing.T, reader io.Reader) []byte {
|
|
t.Helper()
|
|
body, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatalf("read archive entry: %v", err)
|
|
}
|
|
return body
|
|
}
|