Files
browser/platform/service/distribution_builder.go
T

569 lines
20 KiB
Go

package service
import (
"archive/tar"
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"browser.local/platform/domain"
)
// DistributionBuilder executes a distribution build inside a platform-owned
// container. Builds are a platform responsibility: they must not depend on a
// machine-side run endpoint being registered and online, and the component auth
// key must never leave the platform.
type DistributionBuilder interface {
// Readiness reports whether the platform builder can execute a build. The
// reason must name the platform builder rather than a run endpoint
// capability, so an operator is not sent looking at the wrong subsystem.
Readiness() (bool, string)
// Build assembles the package described by input and returns its bytes.
Build(input domain.DistributionBuildInput) ([]byte, error)
}
type DistributionBuildProgress struct {
Percent int
Message string
}
type distributionBuilderWithProgress interface {
BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error)
}
// DockerDistributionBuilderConfig configures a container-per-build builder.
type DockerDistributionBuilderConfig struct {
DockerBinary string
Image string
SourceDir string
WorkspaceDir string
Timeout time.Duration
PlatformURL string
CommandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
CommandStream func(ctx context.Context, name string, args []string, onLine func(string)) ([]byte, error)
}
// DockerDistributionBuilder runs each build in a container from a pinned image,
// with the run source mounted read-only and a per-job output directory mounted
// writable.
type DockerDistributionBuilder struct {
config DockerDistributionBuilderConfig
}
func NewDockerDistributionBuilder(config DockerDistributionBuilderConfig) *DockerDistributionBuilder {
customRunner := config.CommandRunner != nil
if strings.TrimSpace(config.DockerBinary) == "" {
config.DockerBinary = "docker"
}
if config.Timeout <= 0 {
config.Timeout = 30 * time.Minute
}
if config.CommandRunner == nil {
config.CommandRunner = runCommandCombined
}
if config.CommandStream == nil && !customRunner {
config.CommandStream = runCommandStreamCombined
}
return &DockerDistributionBuilder{config: config}
}
func runCommandCombined(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
func runCommandStreamCombined(ctx context.Context, name string, args []string, onLine func(string)) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
var mu sync.Mutex
var output bytes.Buffer
var wg sync.WaitGroup
capture := func(reader io.Reader) {
defer wg.Done()
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
mu.Lock()
output.WriteString(line)
output.WriteByte('\n')
mu.Unlock()
if onLine != nil {
onLine(line)
}
}
}
wg.Add(2)
go capture(stdout)
go capture(stderr)
err = cmd.Wait()
wg.Wait()
return output.Bytes(), err
}
func (builder *DockerDistributionBuilder) Readiness() (bool, string) {
if strings.TrimSpace(builder.config.Image) == "" {
return false, "platform builder image is not configured"
}
if !pinnedBuilderImage(builder.config.Image) {
return false, "platform builder image must be pinned to an explicit version or digest"
}
source := strings.TrimSpace(builder.config.SourceDir)
if source == "" {
return false, "platform builder run source directory is not configured"
}
source, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir))
if err != nil {
return false, "platform builder run source directory is invalid"
}
if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil {
return false, "platform builder run source directory does not contain a run checkout"
}
if strings.TrimSpace(builder.config.WorkspaceDir) == "" {
return false, "platform builder workspace directory is not configured"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := builder.config.CommandRunner(ctx, builder.config.DockerBinary, "version", "--format", "{{.Server.Version}}"); err != nil {
return false, "platform builder container runtime is unavailable"
}
if _, err := builder.config.CommandRunner(ctx, builder.config.DockerBinary, "image", "inspect", builder.config.Image); err != nil {
return false, "platform builder image is unavailable"
}
return true, ""
}
// pinnedBuilderImage rejects floating references. An unpinned builder image
// silently changes what the platform ships.
func pinnedBuilderImage(image string) bool {
image = strings.TrimSpace(image)
if name, digest, found := strings.Cut(image, "@sha256:"); found {
if strings.TrimSpace(name) == "" || len(digest) != 64 {
return false
}
_, err := hex.DecodeString(digest)
return err == nil
}
reference := image
if slash := strings.LastIndex(image, "/"); slash >= 0 {
reference = image[slash+1:]
}
_, tag, found := strings.Cut(reference, ":")
if !found {
return false
}
tag = strings.TrimSpace(tag)
return tag != "" && tag != "latest"
}
func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildInput) ([]byte, error) {
return builder.BuildWithProgress(input, nil)
}
func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error) {
if ready, reason := builder.Readiness(); !ready {
return nil, validationError(reason)
}
reportBuilderProgress(progress, 8, "env_check: platform builder readiness verified")
sourceDir, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir))
if err != nil {
return nil, validationError("platform builder run source directory is invalid")
}
workspaceDir, err := filepath.Abs(strings.TrimSpace(builder.config.WorkspaceDir))
if err != nil {
return nil, validationError("platform builder workspace directory is invalid")
}
// Workspaces stay isolated per plugin and per job as required by
// run-build-download-flow.
jobDir := filepath.Join(workspaceDir, sanitizeIDPart(input.PluginID), sanitizeIDPart(input.JobID))
if err := os.RemoveAll(jobDir); err != nil {
return nil, err
}
outputDir := filepath.Join(jobDir, "output")
inputDir := filepath.Join(jobDir, "input")
buildDir := filepath.Join(jobDir, "build")
for _, directory := range []string{outputDir, inputDir, buildDir} {
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, err
}
}
defer func() { _ = os.RemoveAll(jobDir) }()
reportBuilderProgress(progress, 14, "env_check: platform builder workspace prepared")
// The auth key reaches the container through a per-job input file, never
// through a job-channel response to a machine-side endpoint or a container
// command-line argument.
if strings.TrimSpace(input.AuthKey) == "" {
return nil, validationError("distribution build input is missing a component auth key")
}
if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil {
return nil, err
}
seedPayload := []byte("[]")
if strings.TrimSpace(input.WorkspaceSeed) != "" {
decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed)
if err != nil {
return nil, validationError("distribution build input has an invalid workspace seed")
}
seedPayload = decoded
}
if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
return nil, err
}
lifecyclePlanPayload := []byte("{}")
if input.AutonomousLifecycle != nil {
encoded, err := json.Marshal(input.AutonomousLifecycle)
if err != nil {
return nil, validationError("distribution build input has an invalid autonomous lifecycle plan")
}
lifecyclePlanPayload = encoded
}
if err := os.WriteFile(filepath.Join(inputDir, "autonomous-lifecycle-plan.json"), lifecyclePlanPayload, 0o600); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
return nil, err
}
outputName := strings.TrimSpace(input.OutputFilename)
if outputName == "" || filepath.Base(outputName) != outputName {
return nil, validationError("distribution build input has an invalid output filename")
}
ctx, cancel := context.WithTimeout(context.Background(), builder.config.Timeout)
defer cancel()
args := builder.containerArgs(input, sourceDir, inputDir, buildDir, outputDir, outputName)
reportBuilderProgress(progress, 18, "git_sync: platform builder container starting")
output, err := builder.runBuildCommand(ctx, args, progress)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, validationError("platform builder timed out while building the distribution")
}
return nil, validationError("platform builder failed: " + safeBuilderFailure(output, input.AuthKey, builder.config.SourceDir, sourceDir, jobDir))
}
reportBuilderProgress(progress, 92, "package_finalize: reading platform builder output")
binary, err := os.ReadFile(filepath.Join(outputDir, outputName))
if err != nil {
return nil, validationError("platform builder did not produce a distribution executable")
}
if len(binary) == 0 {
return nil, validationError("platform builder produced an empty distribution executable")
}
if input.ComponentKind == domain.DistributionComponentRun {
return binary, nil
}
configPayload, err := os.ReadFile(filepath.Join(outputDir, "config.yaml"))
if err != nil {
return nil, validationError("platform builder did not produce client-manager configuration")
}
return packageClientManagerDistribution(input.PackageFormat, outputName, binary, configPayload)
}
func (builder *DockerDistributionBuilder) runBuildCommand(ctx context.Context, args []string, progress func(DistributionBuildProgress)) ([]byte, error) {
if builder.config.CommandStream == nil {
return builder.config.CommandRunner(ctx, builder.config.DockerBinary, args...)
}
return builder.config.CommandStream(ctx, builder.config.DockerBinary, args, func(line string) {
parsed, ok := parseBuilderProgressLine(line)
if ok {
reportBuilderProgress(progress, parsed.Percent, parsed.Message)
}
})
}
const builderProgressMarker = "__platform_builder_progress__|"
func parseBuilderProgressLine(line string) (DistributionBuildProgress, bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, builderProgressMarker) {
return DistributionBuildProgress{}, false
}
rest := strings.TrimPrefix(line, builderProgressMarker)
parts := strings.SplitN(rest, "|", 2)
if len(parts) != 2 {
return DistributionBuildProgress{}, false
}
percent, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return DistributionBuildProgress{}, false
}
message := strings.TrimSpace(parts[1])
if message == "" {
return DistributionBuildProgress{}, false
}
if percent < 1 {
percent = 1
}
if percent > 99 {
percent = 99
}
return DistributionBuildProgress{Percent: percent, Message: message}, true
}
func reportBuilderProgress(progress func(DistributionBuildProgress), percent int, message string) {
if progress == nil {
return
}
if percent < 1 {
percent = 1
}
if percent > 99 {
percent = 99
}
progress(DistributionBuildProgress{Percent: percent, Message: message})
}
const distributionBuildScript = `#!/bin/sh
set -eu
progress() {
printf '__platform_builder_progress__|%s|%s\n' "$1" "$2"
}
auth_key="$(cat /workspace/input/auth-key)"
if [ "$COMPONENT_KIND" = "run" ]; then
progress 24 'git_sync: preparing run source snapshot'
rm -rf /workspace/build/run-source
mkdir -p /workspace/build/run-source
cp -R /workspace/source/. /workspace/build/run-source/
progress 34 'env_check: injecting run build metadata'
seed_b64="$(base64 /workspace/input/workspace-seed.json | tr -d '\n')"
cat > /workspace/build/run-source/config/workspace_seed_generated.go <<EOF
package config
func init() { BuildWorkspaceSeed = "$seed_b64" }
EOF
cd /workspace/build/run-source
ldflags="-s -w"
ldflags="$ldflags -X browser.local/run/config.BuildMode=worker"
ldflags="$ldflags -X browser.local/run/config.BuildPlatformURL=$PLATFORM_URL"
ldflags="$ldflags -X browser.local/run/config.BuildRunEndpointID=$RUN_ENDPOINT_ID"
ldflags="$ldflags -X browser.local/run/config.BuildDisplayName=Run-$SERVER_INSTANCE_ID"
ldflags="$ldflags -X browser.local/run/config.BuildRegistrationToken=$auth_key"
ldflags="$ldflags -X browser.local/run/config.BuildServerInstanceID=$SERVER_INSTANCE_ID"
ldflags="$ldflags -X browser.local/run/config.BuildPluginID=$PLUGIN_ID"
ldflags="$ldflags -X browser.local/run/config.BuildComponentKind=$COMPONENT_KIND"
ldflags="$ldflags -X browser.local/run/config.BuildComponentKey=$PROFILE_KEY"
ldflags="$ldflags -X browser.local/run/config.BuildKeyGeneration=$KEY_GENERATION"
ldflags="$ldflags -X browser.local/run/config.BuildVersion=$TARGET_RELEASE"
progress 48 'build_compile: downloading Go modules'
go mod download
progress 72 'build_compile: compiling run target executable'
go build -trimpath -ldflags "$ldflags" -o "/workspace/output/$OUTPUT_FILENAME" ./cmd/run
progress 88 'package_finalize: run executable written'
exit 0
fi
if [ "$COMPONENT_KIND" != "client-manager" ]; then
printf 'unsupported component kind\n' >&2
exit 2
fi
case "$REPOSITORY_URL" in
https://*) ;;
*) printf 'client-manager repository must use https\n' >&2; exit 2 ;;
esac
cd /workspace/build
progress 24 'git_sync: fetching client-manager source'
git init --quiet
git remote add origin "$REPOSITORY_URL"
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
git checkout --quiet --detach FETCH_HEAD
progress 36 'env_check: writing client-manager configuration'
{
printf 'server_url: "%s"\n' "$PLATFORM_URL"
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
printf 'scum_client_credential: "%s"\n' "$auth_key"
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
printf 'scum_client_version: "platform-build"\n'
printf 'scum_client_machine_label: "managed-client"\n'
printf 'ftp_provider: 3\n'
} > config.yaml
progress 52 'deps_download: downloading Go modules'
go mod download
progress 76 'build_compile: compiling client-manager executable'
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
cp config.yaml /workspace/output/config.yaml
progress 88 'package_finalize: client-manager package inputs written'
`
func (builder *DockerDistributionBuilder) containerArgs(input domain.DistributionBuildInput, sourceDir string, inputDir string, buildDir string, outputDir string, outputName string) []string {
platformURL := strings.TrimSpace(input.PlatformURL)
if platformURL == "" {
platformURL = strings.TrimSpace(builder.config.PlatformURL)
}
return []string{
"run", "--rm",
"--pull", "never",
"--read-only",
"--tmpfs", "/tmp:rw,nosuid,size=2147483648",
"-v", sourceDir + ":/workspace/source:ro",
"-v", inputDir + ":/workspace/input:ro",
"-v", buildDir + ":/workspace/build",
"-v", outputDir + ":/workspace/output",
"-e", "CGO_ENABLED=0",
"-e", "GOOS=" + input.TargetOS,
"-e", "GOARCH=" + input.TargetArch,
"-e", "GOCACHE=/tmp/go-build",
"-e", "GOMODCACHE=/tmp/go-mod",
"-e", "COMPONENT_KIND=" + string(input.ComponentKind),
"-e", "SERVER_INSTANCE_ID=" + input.ServerInstanceID,
"-e", "PLUGIN_ID=" + input.PluginID,
"-e", "RUN_ENDPOINT_ID=" + input.RunEndpointID,
"-e", "PROFILE_KEY=" + input.ProfileKey,
"-e", "TARGET_RELEASE=" + input.TargetRelease,
"-e", "KEY_GENERATION=" + fmt.Sprint(input.KeyGeneration),
"-e", "PLATFORM_URL=" + platformURL,
"-e", "REPOSITORY_URL=" + input.RepositoryURL,
"-e", "SOURCE_REVISION=" + input.SourceRevision,
"-e", "OUTPUT_FILENAME=" + outputName,
builder.config.Image,
"/workspace/input/build.sh",
}
}
func packageClientManagerDistribution(packageFormat string, outputName string, binary []byte, configPayload []byte) ([]byte, error) {
switch packageFormat {
case "zip":
return zipDistributionFiles(outputName, binary, configPayload)
case "tar.gz":
return tarGzipDistributionFiles(outputName, binary, configPayload)
default:
return nil, validationError("platform builder received an unsupported client-manager package format")
}
}
func zipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
files := []struct {
name string
mode os.FileMode
payload []byte
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
for _, file := range files {
header := &zip.FileHeader{Name: file.name, Method: zip.Deflate}
header.SetMode(file.mode)
header.SetModTime(time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC))
entry, err := writer.CreateHeader(header)
if err != nil {
return nil, err
}
if _, err := entry.Write(file.payload); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func tarGzipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
var buffer bytes.Buffer
gzipWriter := gzip.NewWriter(&buffer)
gzipWriter.Header.ModTime = time.Unix(0, 0).UTC()
tarWriter := tar.NewWriter(gzipWriter)
files := []struct {
name string
mode int64
payload []byte
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
for _, file := range files {
header := &tar.Header{Name: file.name, Mode: file.mode, Size: int64(len(file.payload)), ModTime: time.Unix(0, 0).UTC()}
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
if _, err := tarWriter.Write(file.payload); err != nil {
return nil, err
}
}
if err := tarWriter.Close(); err != nil {
return nil, err
}
if err := gzipWriter.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
// safeBuilderFailure keeps host paths and secret values out of reported build
// failures.
func safeBuilderFailure(output []byte, sensitiveValues ...string) string {
text := strings.TrimSpace(string(output))
for _, sensitive := range sensitiveValues {
if strings.TrimSpace(sensitive) != "" {
text = strings.ReplaceAll(text, sensitive, "[redacted]")
}
}
if text == "" {
return "build command reported no diagnostic output"
}
lines := strings.Split(text, "\n")
kept := make([]string, 0, len(lines))
for index := len(lines) - 1; index >= 0 && len(kept) < 3; index-- {
line := strings.TrimSpace(lines[index])
if line == "" || strings.HasPrefix(line, builderProgressMarker) || strings.Contains(line, "/workspace/input") || strings.Contains(line, "auth-key") {
continue
}
line = redactBuilderHostPaths(line)
kept = append([]string{line}, kept...)
}
if len(kept) == 0 {
return "build command reported no shareable diagnostic output"
}
joined := strings.Join(kept, "; ")
if len(joined) > 400 {
joined = joined[:400]
}
return joined
}
func redactBuilderHostPaths(line string) string {
fields := strings.Fields(line)
for index, field := range fields {
trimmed := strings.TrimLeft(field, "(\"'[")
if strings.HasPrefix(trimmed, "/") && !strings.HasPrefix(trimmed, "/workspace/") {
fields[index] = "[redacted-path]"
}
}
return strings.Join(fields, " ")
}
func builderJobFailureMessage(err error, sensitiveValues ...string) string {
if err == nil {
return "platform builder failed"
}
message := safeBuilderFailure([]byte(err.Error()), sensitiveValues...)
if message == "" {
return "platform builder failed"
}
if len(message) > 400 {
message = message[:400]
}
return message
}