package service import ( "archive/tar" "bufio" "bytes" "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 SourceRepositoryURL string SourceRevision string WorkspaceDir string CacheDir 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) GitCommandRunner func(ctx context.Context, name string, args ...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 sourceMu sync.Mutex } 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.GitCommandRunner == nil { config.GitCommandRunner = 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(source); err != nil && !errors.Is(err, os.ErrNotExist) { return false, "platform builder run source directory is not accessible" } if strings.TrimSpace(builder.config.SourceRepositoryURL) == "" { if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil { return false, "platform builder run source repository is not configured" } } 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") } ctx, cancel := context.WithTimeout(context.Background(), builder.config.Timeout) defer cancel() if input.ComponentKind == domain.DistributionComponentRun { // Keep a shared checkout stable while Docker reads it. The checkout is // refreshed and exported below for this build, rather than captured when // the platform process starts. builder.sourceMu.Lock() defer builder.sourceMu.Unlock() } cacheDir, err := builder.cacheDir(workspaceDir) if err != nil { return nil, validationError("platform builder cache 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") sourceMountDir := filepath.Join(jobDir, "source") goBuildCacheDir := filepath.Join(cacheDir, "go-build") goModCacheDir := filepath.Join(cacheDir, "go-mod") for _, directory := range []string{outputDir, inputDir, buildDir, sourceMountDir, goBuildCacheDir, goModCacheDir} { 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") if input.ComponentKind == domain.DistributionComponentRun { preparedSourceDir, err := builder.prepareRunSource(ctx, sourceDir, sourceMountDir, input, progress) if err != nil { return nil, err } sourceMountDir = preparedSourceDir } // 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") } args := builder.containerArgs(input, sourceMountDir, inputDir, buildDir, outputDir, goBuildCacheDir, goModCacheDir, 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: " + safeBuilderFailureWithCommandError(output, err, 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") } return binary, nil } // prepareRunSource implements the source phase of a Jenkins-style build. A // configured checkout is created when absent, fetched on every build, and // exported at FETCH_HEAD into the per-job workspace. The archive keeps local // uncommitted files and the checkout's .git directory out of the executable. func (builder *DockerDistributionBuilder) prepareRunSource(ctx context.Context, sourceDir, destination string, input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (string, error) { repository := strings.TrimSpace(input.RepositoryURL) if repository == "" { repository = strings.TrimSpace(builder.config.SourceRepositoryURL) } revision := strings.TrimSpace(input.SourceRevision) if revision == "" { revision = strings.TrimSpace(builder.config.SourceRevision) } if revision == "" { revision = "main" } if _, err := os.Stat(filepath.Join(sourceDir, ".git")); errors.Is(err, os.ErrNotExist) { if repository == "" { if _, sourceErr := os.Stat(filepath.Join(sourceDir, "go.mod")); sourceErr != nil { return "", validationError("platform builder has no Run source checkout or repository") } return sourceDir, nil } if err := os.MkdirAll(filepath.Dir(sourceDir), 0o700); err != nil { return "", validationError("platform builder could not create the Run source parent") } reportBuilderProgress(progress, 20, "git_sync: creating Run source checkout") if _, err := builder.config.GitCommandRunner(ctx, "git", "clone", "--no-checkout", repository, sourceDir); err != nil { return "", validationError("platform builder could not clone the Run source") } } else if err != nil { return "", validationError("platform builder could not inspect the Run source checkout") } reportBuilderProgress(progress, 26, "git_sync: fetching Run source revision") if _, err := builder.config.GitCommandRunner(ctx, "git", "-C", sourceDir, "fetch", "--depth", "1", "origin", revision); err != nil { return "", validationError("platform builder could not update the Run source revision") } archivePayload, err := builder.config.GitCommandRunner(ctx, "git", "-C", sourceDir, "archive", "--format=tar", "FETCH_HEAD") if err != nil { return "", validationError("platform builder could not export the Run source revision") } if err := extractRunSourceArchive(archivePayload, destination); err != nil { return "", validationError("platform builder could not prepare the Run source workspace") } reportBuilderProgress(progress, 34, "git_sync: Run source revision prepared") return destination, nil } func extractRunSourceArchive(payload []byte, destination string) error { reader := tar.NewReader(bytes.NewReader(payload)) for { header, err := reader.Next() if errors.Is(err, io.EOF) { return nil } if err != nil { return err } name := filepath.Clean(filepath.FromSlash(header.Name)) if name == "." || filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) { return errors.New("Run source archive contains an unsafe path") } target := filepath.Join(destination, name) if header.FileInfo().IsDir() { if err := os.MkdirAll(target, header.FileInfo().Mode().Perm()); err != nil { return err } continue } if !header.FileInfo().Mode().IsRegular() { return errors.New("Run source archive contains an unsupported file") } if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { return err } file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, header.FileInfo().Mode().Perm()) if err != nil { return err } _, copyErr := io.Copy(file, reader) closeErr := file.Close() if copyErr != nil { return copyErr } if closeErr != nil { return closeErr } } } func (builder *DockerDistributionBuilder) cacheDir(workspaceDir string) (string, error) { configured := strings.TrimSpace(builder.config.CacheDir) if configured == "" { configured = filepath.Join(workspaceDir, "_cache") } return filepath.Abs(configured) } 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 find /workspace/source -mindepth 1 -maxdepth 1 ! -name pax_global_header -exec cp -R {} /workspace/build/run-source/ \; run_command_dir="$(find /workspace/build/run-source -type d -path '*/cmd/run' -print -quit)" run_module_dir="${run_command_dir%/cmd/run}" if [ -z "$run_command_dir" ] || [ ! -f "$run_module_dir/go.mod" ]; then printf 'Run source snapshot must contain a Go module with cmd/run\n' >&2 exit 2 fi progress 34 'env_check: injecting run build metadata' seed_b64="$(base64 /workspace/input/workspace-seed.json | tr -d '\n')" mkdir -p "$run_module_dir/config" cat > "$run_module_dir/config/workspace_seed_generated.go" <&2 exit 2 ` func (builder *DockerDistributionBuilder) containerArgs(input domain.DistributionBuildInput, sourceDir string, inputDir string, buildDir string, outputDir string, goBuildCacheDir string, goModCacheDir 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", "-v", goBuildCacheDir + ":/workspace/cache/go-build", "-v", goModCacheDir + ":/workspace/cache/go-mod", "-e", "CGO_ENABLED=0", "-e", "GOOS=" + input.TargetOS, "-e", "GOARCH=" + input.TargetArch, "-e", "GOCACHE=/workspace/cache/go-build", "-e", "GOMODCACHE=/workspace/cache/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", } } // 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 safeBuilderFailureWithCommandError(output []byte, commandErr error, sensitiveValues ...string) string { message := safeBuilderFailure(output, sensitiveValues...) if commandErr == nil || !strings.HasPrefix(message, "build command reported no ") { return message } return safeBuilderFailure([]byte(commandErr.Error()), sensitiveValues...) } 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 }