328 lines
11 KiB
Go
328 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
func TestPinnedBuilderImageRequiresExplicitTagOrDigest(t *testing.T) {
|
|
validDigest := strings.Repeat("a", 64)
|
|
for _, test := range []struct {
|
|
image string
|
|
want bool
|
|
}{
|
|
{image: "browser-platform-distribution-builder:1.0.0", want: true},
|
|
{image: "registry.example.test:5000/builders/distribution:v1", want: true},
|
|
{image: "registry.example.test/builders/distribution@sha256:" + validDigest, want: true},
|
|
{image: "browser-platform-distribution-builder", want: false},
|
|
{image: "browser-platform-distribution-builder:latest", want: false},
|
|
{image: "registry.example.test/builders/distribution@sha256:", want: false},
|
|
{image: "registry.example.test/builders/distribution@sha256:not-a-digest", want: false},
|
|
} {
|
|
t.Run(test.image, func(t *testing.T) {
|
|
if got := pinnedBuilderImage(test.image); got != test.want {
|
|
t.Fatalf("pinnedBuilderImage(%q) = %t, want %t", test.image, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerDistributionBuilderReadinessNamesPlatformBuilderFailures(t *testing.T) {
|
|
sourceDir := createBuilderSource(t)
|
|
workspaceDir := t.TempDir()
|
|
for _, test := range []struct {
|
|
name string
|
|
config DockerDistributionBuilderConfig
|
|
reason string
|
|
}{
|
|
{
|
|
name: "floating image",
|
|
config: DockerDistributionBuilderConfig{Image: "golang:latest", SourceDir: sourceDir, WorkspaceDir: workspaceDir},
|
|
reason: "platform builder image must be pinned",
|
|
},
|
|
{
|
|
name: "missing source",
|
|
config: DockerDistributionBuilderConfig{Image: "builder:1.0.0", WorkspaceDir: workspaceDir},
|
|
reason: "platform builder run source directory is not configured",
|
|
},
|
|
{
|
|
name: "image unavailable",
|
|
config: DockerDistributionBuilderConfig{
|
|
Image: "builder:1.0.0",
|
|
SourceDir: sourceDir,
|
|
WorkspaceDir: workspaceDir,
|
|
CommandRunner: func(_ context.Context, _ string, args ...string) ([]byte, error) {
|
|
if len(args) > 0 && args[0] == "version" {
|
|
return []byte("27.0.0"), nil
|
|
}
|
|
return nil, errors.New("image missing")
|
|
},
|
|
},
|
|
reason: "platform builder image is unavailable",
|
|
},
|
|
{
|
|
name: "container runtime unavailable",
|
|
config: DockerDistributionBuilderConfig{
|
|
Image: "builder:1.0.0",
|
|
SourceDir: sourceDir,
|
|
WorkspaceDir: workspaceDir,
|
|
CommandRunner: func(context.Context, string, ...string) ([]byte, error) {
|
|
return nil, errors.New("docker unavailable")
|
|
},
|
|
},
|
|
reason: "platform builder container runtime is unavailable",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ready, reason := NewDockerDistributionBuilder(test.config).Readiness()
|
|
if ready || !strings.Contains(reason, test.reason) || !strings.Contains(reason, "platform builder") {
|
|
t.Fatalf("expected platform builder readiness failure %q, ready=%t reason=%q", test.reason, ready, reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerDistributionBuilderKeepsSecretInIsolatedInput(t *testing.T) {
|
|
sourceDir := createBuilderSource(t)
|
|
workspaceDir := t.TempDir()
|
|
secret := "component-auth-key-that-must-not-leave-input"
|
|
var dockerArgs []string
|
|
builder := NewDockerDistributionBuilder(DockerDistributionBuilderConfig{
|
|
DockerBinary: "docker-test",
|
|
Image: "browser-platform-distribution-builder:1.0.0",
|
|
SourceDir: sourceDir,
|
|
WorkspaceDir: workspaceDir,
|
|
CommandRunner: func(_ context.Context, name string, args ...string) ([]byte, error) {
|
|
if name != "docker-test" {
|
|
t.Fatalf("unexpected container runtime %q", name)
|
|
}
|
|
if len(args) > 0 && (args[0] == "version" || args[0] == "image") {
|
|
return []byte("27.0.0"), nil
|
|
}
|
|
dockerArgs = append([]string(nil), args...)
|
|
inputDir := builderMountHostPath(t, args, "/workspace/input:ro")
|
|
outputDir := builderMountHostPath(t, args, "/workspace/output")
|
|
authPath := filepath.Join(inputDir, "auth-key")
|
|
payload, err := os.ReadFile(authPath)
|
|
if err != nil {
|
|
t.Fatalf("read per-job auth input: %v", err)
|
|
}
|
|
if string(payload) != secret {
|
|
t.Fatalf("unexpected per-job auth input %q", payload)
|
|
}
|
|
info, err := os.Stat(authPath)
|
|
if err != nil {
|
|
t.Fatalf("stat per-job auth input: %v", err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("auth input mode = %o, want 600", info.Mode().Perm())
|
|
}
|
|
script, err := os.ReadFile(filepath.Join(inputDir, "build.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read build script: %v", err)
|
|
}
|
|
if bytes.Contains(script, []byte(secret)) {
|
|
t.Fatal("build script must not embed the component auth key")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(outputDir, "run.exe"), []byte("compiled-run"), 0o700); err != nil {
|
|
t.Fatalf("write fake build output: %v", err)
|
|
}
|
|
return nil, nil
|
|
},
|
|
})
|
|
input := domain.DistributionBuildInput{
|
|
JobID: "job/build:one",
|
|
ComponentKind: domain.DistributionComponentRun,
|
|
ServerInstanceID: "server-one",
|
|
PluginID: "game.scum",
|
|
RunEndpointID: "server-run-server-one",
|
|
TargetOS: "windows",
|
|
TargetArch: "amd64",
|
|
TargetRelease: "release-one",
|
|
PlatformURL: "https://platform.example.test",
|
|
OutputFilename: "run.exe",
|
|
KeyGeneration: 1,
|
|
AuthKey: secret,
|
|
}
|
|
payload, err := builder.Build(input)
|
|
if err != nil {
|
|
t.Fatalf("build Run distribution: %v", err)
|
|
}
|
|
if string(payload) != "compiled-run" {
|
|
t.Fatalf("unexpected built payload %q", payload)
|
|
}
|
|
joinedArgs := strings.Join(dockerArgs, "\x00")
|
|
if strings.Contains(joinedArgs, secret) {
|
|
t.Fatal("component auth key leaked into Docker arguments or environment")
|
|
}
|
|
for _, required := range []string{"--read-only", "--pull\x00never", sourceDir + ":/workspace/source:ro", ":/workspace/input:ro", ":/workspace/output"} {
|
|
if !strings.Contains(joinedArgs, required) {
|
|
t.Fatalf("Docker arguments do not contain required isolation %q: %q", required, joinedArgs)
|
|
}
|
|
}
|
|
jobDir := filepath.Join(workspaceDir, "game.scum", "job-build-one")
|
|
if _, err := os.Stat(jobDir); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("per-job workspace was not removed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) {
|
|
sourceDir := createBuilderSource(t)
|
|
workspaceDir := t.TempDir()
|
|
secret := "sensitive-component-key"
|
|
input := domain.DistributionBuildInput{
|
|
JobID: "job-redaction",
|
|
ComponentKind: domain.DistributionComponentRun,
|
|
PluginID: "game.scum",
|
|
TargetOS: "linux",
|
|
TargetArch: "amd64",
|
|
OutputFilename: "run",
|
|
AuthKey: secret,
|
|
}
|
|
builder := NewDockerDistributionBuilder(DockerDistributionBuilderConfig{
|
|
Image: "builder:1.0.0",
|
|
SourceDir: sourceDir,
|
|
WorkspaceDir: workspaceDir,
|
|
CommandRunner: func(_ context.Context, _ string, args ...string) ([]byte, error) {
|
|
if len(args) > 0 && (args[0] == "version" || args[0] == "image") {
|
|
return []byte("27.0.0"), nil
|
|
}
|
|
jobDir := filepath.Join(workspaceDir, "game.scum", "job-redaction")
|
|
return []byte(secret + "\n" + sourceDir + "/go.mod: build failed\n" + jobDir + "/input/auth-key"), errors.New("exit 1")
|
|
},
|
|
})
|
|
_, err := builder.Build(input)
|
|
if err == nil {
|
|
t.Fatal("expected failed builder command")
|
|
}
|
|
if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), sourceDir) || strings.Contains(err.Error(), workspaceDir) || strings.Contains(err.Error(), "auth-key") {
|
|
t.Fatalf("builder failure leaked secret or host path: %v", err)
|
|
}
|
|
|
|
timeoutBuilder := NewDockerDistributionBuilder(DockerDistributionBuilderConfig{
|
|
Image: "builder:1.0.0",
|
|
SourceDir: sourceDir,
|
|
WorkspaceDir: workspaceDir,
|
|
Timeout: 5 * time.Millisecond,
|
|
CommandRunner: func(ctx context.Context, _ string, args ...string) ([]byte, error) {
|
|
if len(args) > 0 && (args[0] == "version" || args[0] == "image") {
|
|
return []byte("27.0.0"), nil
|
|
}
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
},
|
|
})
|
|
_, err = timeoutBuilder.Build(input)
|
|
if err == nil || !strings.Contains(err.Error(), "platform builder timed out") {
|
|
t.Fatalf("expected bounded platform builder timeout, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPackageClientManagerDistributionProducesProtectedArchives(t *testing.T) {
|
|
for _, packageFormat := range []string{"zip", "tar.gz"} {
|
|
t.Run(packageFormat, func(t *testing.T) {
|
|
payload, err := packageClientManagerDistribution(packageFormat, "client-manager.exe", []byte("binary"), []byte("credential: protected"))
|
|
if err != nil {
|
|
t.Fatalf("package client manager: %v", err)
|
|
}
|
|
files := readDistributionArchive(t, packageFormat, payload)
|
|
if string(files["client-manager.exe"].payload) != "binary" || files["client-manager.exe"].mode.Perm() != 0o755 {
|
|
t.Fatalf("unexpected executable archive entry: %+v", files["client-manager.exe"])
|
|
}
|
|
if string(files["config.yaml"].payload) != "credential: protected" || files["config.yaml"].mode.Perm() != 0o600 {
|
|
t.Fatalf("unexpected config archive entry: %+v", files["config.yaml"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type distributionArchiveFile struct {
|
|
mode os.FileMode
|
|
payload []byte
|
|
}
|
|
|
|
func createBuilderSource(t *testing.T) string {
|
|
t.Helper()
|
|
sourceDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "go.mod"), []byte("module browser.local/run\n"), 0o600); err != nil {
|
|
t.Fatalf("write run source go.mod: %v", err)
|
|
}
|
|
return sourceDir
|
|
}
|
|
|
|
func builderMountHostPath(t *testing.T, args []string, containerSuffix string) string {
|
|
t.Helper()
|
|
for index := 0; index+1 < len(args); index++ {
|
|
if args[index] != "-v" || !strings.HasSuffix(args[index+1], ":"+containerSuffix) {
|
|
continue
|
|
}
|
|
return strings.TrimSuffix(args[index+1], ":"+containerSuffix)
|
|
}
|
|
t.Fatalf("Docker arguments do not mount %s: %+v", containerSuffix, args)
|
|
return ""
|
|
}
|
|
|
|
func readDistributionArchive(t *testing.T, packageFormat string, payload []byte) map[string]distributionArchiveFile {
|
|
t.Helper()
|
|
files := map[string]distributionArchiveFile{}
|
|
switch packageFormat {
|
|
case "zip":
|
|
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
|
if err != nil {
|
|
t.Fatalf("open zip: %v", err)
|
|
}
|
|
for _, entry := range reader.File {
|
|
body, err := entry.Open()
|
|
if err != nil {
|
|
t.Fatalf("open zip entry %s: %v", entry.Name, err)
|
|
}
|
|
content, err := io.ReadAll(body)
|
|
if closeErr := body.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("read zip entry %s: %v", entry.Name, err)
|
|
}
|
|
files[entry.Name] = distributionArchiveFile{mode: entry.Mode(), payload: content}
|
|
}
|
|
case "tar.gz":
|
|
gzipReader, err := gzip.NewReader(bytes.NewReader(payload))
|
|
if err != nil {
|
|
t.Fatalf("open gzip: %v", err)
|
|
}
|
|
tarReader := tar.NewReader(gzipReader)
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("read tar header: %v", err)
|
|
}
|
|
content, err := io.ReadAll(tarReader)
|
|
if err != nil {
|
|
t.Fatalf("read tar entry %s: %v", header.Name, err)
|
|
}
|
|
files[header.Name] = distributionArchiveFile{mode: os.FileMode(header.Mode), payload: content}
|
|
}
|
|
if err := gzipReader.Close(); err != nil {
|
|
t.Fatalf("close gzip: %v", err)
|
|
}
|
|
default:
|
|
t.Fatalf("unsupported archive format %q", packageFormat)
|
|
}
|
|
return files
|
|
}
|