Tighten opaque plugin content boundaries
This commit is contained in:
@@ -23,6 +23,7 @@ type ArtifactBodyStore interface {
|
||||
SaveTransfer(domain.ArtifactTransferSession) error
|
||||
LoadTransfers() ([]domain.ArtifactTransferSession, error)
|
||||
PutPayload(string, []byte) error
|
||||
PutPayloadFromFile(string, string) error
|
||||
GetPayload(string) ([]byte, error)
|
||||
ReadPayloadRange(string, int64, int) ([]byte, error)
|
||||
OpenPayloadRange(string, int64, int64) (io.ReadCloser, error)
|
||||
@@ -68,6 +69,14 @@ func (store *MemoryArtifactBodyStore) PutPayload(artifactID string, payload []by
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MemoryArtifactBodyStore) PutPayloadFromFile(artifactID string, sourcePath string) error {
|
||||
payload, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read artifact source file: %w", err)
|
||||
}
|
||||
return store.PutPayload(artifactID, payload)
|
||||
}
|
||||
|
||||
func (store *MemoryArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -218,6 +227,12 @@ func (store *FileArtifactBodyStore) PutPayload(artifactID string, payload []byte
|
||||
return writeAtomicFile(store.payloadPath(artifactID), payload, 0o600)
|
||||
}
|
||||
|
||||
func (store *FileArtifactBodyStore) PutPayloadFromFile(artifactID string, sourcePath string) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return copyAtomicFile(store.payloadPath(artifactID), sourcePath, 0o600)
|
||||
}
|
||||
|
||||
func (store *FileArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -379,6 +394,67 @@ func stableStorageKey(value string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func fileSizeAndChecksum(path string) (int64, string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, "", fmt.Errorf("artifact source is not a regular file")
|
||||
}
|
||||
hash := sha256.New()
|
||||
size, err := io.Copy(hash, file)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return size, "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func copyAtomicFile(destinationPath string, sourcePath string, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destinationPath), 0o700); err != nil {
|
||||
return fmt.Errorf("create durable body directory: %w", err)
|
||||
}
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open durable body source file: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
tmp := destinationPath + ".tmp"
|
||||
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open durable body temporary file: %w", err)
|
||||
}
|
||||
if err := file.Chmod(mode); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("chmod durable body temporary file: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(file, source); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("copy durable body: %w", err)
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("sync durable body: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("close durable body: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, destinationPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("replace durable body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomicFile(path string, payload []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create durable body directory: %w", err)
|
||||
|
||||
@@ -99,6 +99,22 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error {
|
||||
return nil
|
||||
}
|
||||
builder := svc.configuredDistributionBuilder()
|
||||
if fileBuilder, ok := builder.(distributionBuilderWithFileOutput); ok {
|
||||
output, buildErr := fileBuilder.BuildFile(input, func(progress DistributionBuildProgress) {
|
||||
_ = svc.updateDistributionBuildProgress(job, progress)
|
||||
})
|
||||
if buildErr != nil {
|
||||
return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr, input.AuthKey))
|
||||
}
|
||||
defer func() { _ = output.Cleanup() }()
|
||||
if _, err := svc.platformDistributionBuildInput(job); err != nil {
|
||||
return svc.failDistributionBuildJob(job, "platform builder discarded output because the component key is no longer current")
|
||||
}
|
||||
if err := svc.storeDistributionBuildArtifactFile(input.ArtifactID, job.ID, output); err != nil {
|
||||
return svc.failDistributionBuildJob(job, "platform builder could not record the distribution artifact")
|
||||
}
|
||||
return svc.succeedDistributionBuildJob(job, input.ArtifactID)
|
||||
}
|
||||
var payload []byte
|
||||
var buildErr error
|
||||
if progressBuilder, ok := builder.(distributionBuilderWithProgress); ok {
|
||||
@@ -422,6 +438,49 @@ func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) storeDistributionBuildArtifactFile(artifactID string, jobID string, output DistributionBuildOutput) error {
|
||||
if strings.TrimSpace(artifactID) == "" {
|
||||
return validationError("distribution build artifact id is required")
|
||||
}
|
||||
if strings.TrimSpace(output.Path) == "" || output.SizeBytes <= 0 || output.Checksum == "" {
|
||||
return validationError("distribution build produced no package file")
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
artifact, err := svc.store.Artifacts().Get(artifactID)
|
||||
if err != nil && !errors.Is(err, repo.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
create := errors.Is(err, repo.ErrNotFound)
|
||||
if create {
|
||||
artifact = domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: jobID, CreatedAt: stamp}
|
||||
}
|
||||
if artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != jobID {
|
||||
return validationError("distribution build artifact is outside the job scope")
|
||||
}
|
||||
artifact.SizeBytes = output.SizeBytes
|
||||
artifact.Checksum = output.Checksum
|
||||
artifact.State = domain.ArtifactStateAvailable
|
||||
artifact.UpdatedAt = stamp
|
||||
if err := validator.ValidateArtifact(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.artifactStore.PutPayloadFromFile(artifact.ID, output.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if create {
|
||||
if err := svc.store.Artifacts().Create(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := svc.store.Artifacts().Update(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(svc.artifactPayloads, artifact.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) markDistributionBuildRunning(job *domain.Job) error {
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
type captureDistributionBuilder struct {
|
||||
@@ -25,6 +29,11 @@ type progressDistributionBuilder struct {
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type fileDistributionBuilder struct {
|
||||
inputs chan domain.DistributionBuildInput
|
||||
outputPath string
|
||||
}
|
||||
|
||||
func (builder captureDistributionBuilder) Readiness() (bool, string) {
|
||||
return true, ""
|
||||
}
|
||||
@@ -58,6 +67,28 @@ func (builder progressDistributionBuilder) BuildWithProgress(_ domain.Distributi
|
||||
return domain.CopyBytes(builder.payload), nil
|
||||
}
|
||||
|
||||
func (builder fileDistributionBuilder) Readiness() (bool, string) {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (builder fileDistributionBuilder) Build(domain.DistributionBuildInput) ([]byte, error) {
|
||||
return nil, errors.New("byte build path should not be used for file output builders")
|
||||
}
|
||||
|
||||
func (builder fileDistributionBuilder) BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error) {
|
||||
if progress != nil {
|
||||
progress(DistributionBuildProgress{Percent: 88, Message: "package_finalize: output file ready"})
|
||||
}
|
||||
if builder.inputs != nil {
|
||||
builder.inputs <- input
|
||||
}
|
||||
sizeBytes, checksum, err := fileSizeAndChecksum(builder.outputPath)
|
||||
if err != nil {
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
return DistributionBuildOutput{Path: builder.outputPath, SizeBytes: sizeBytes, Checksum: checksum}, nil
|
||||
}
|
||||
|
||||
func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
@@ -393,6 +424,47 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T
|
||||
completeDistributionBuild(t, svc, distribution, nil)
|
||||
}
|
||||
|
||||
func TestCoreServiceStoresPlatformBuildFileOutput(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
outputPath := filepath.Join(t.TempDir(), "run")
|
||||
payload := bytes.Repeat([]byte("platform-build-file-output"), 8192)
|
||||
if err := os.WriteFile(outputPath, payload, 0o600); err != nil {
|
||||
t.Fatalf("write builder file output: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
svc.ConfigureDistributionBuilder(fileDistributionBuilder{inputs: inputs, outputPath: outputPath})
|
||||
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "platform-builder-file-output",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate platform distribution: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-inputs:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("platform file builder did not receive internal build input")
|
||||
}
|
||||
distribution = completeDistributionBuild(t, svc, distribution, nil)
|
||||
artifact, err := svc.GetArtifact(distribution.ArtifactID)
|
||||
if err != nil {
|
||||
t.Fatalf("get distribution artifact: %v", err)
|
||||
}
|
||||
if artifact.SizeBytes != int64(len(payload)) || artifact.Checksum != validator.BytesChecksum(payload) {
|
||||
t.Fatalf("artifact metadata did not come from file output: %+v", artifact)
|
||||
}
|
||||
stored, err := svc.artifactStore.GetPayload(distribution.ArtifactID)
|
||||
if err != nil {
|
||||
t.Fatalf("get stored distribution payload: %v", err)
|
||||
}
|
||||
if !bytes.Equal(stored, payload) {
|
||||
t.Fatalf("stored distribution payload changed during file-backed recording")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
// DistributionBuilder executes a distribution build inside a platform-owned
|
||||
@@ -40,10 +41,28 @@ type DistributionBuildProgress struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type DistributionBuildOutput struct {
|
||||
Path string
|
||||
SizeBytes int64
|
||||
Checksum string
|
||||
cleanup func() error
|
||||
}
|
||||
|
||||
func (output DistributionBuildOutput) Cleanup() error {
|
||||
if output.cleanup == nil {
|
||||
return nil
|
||||
}
|
||||
return output.cleanup()
|
||||
}
|
||||
|
||||
type distributionBuilderWithProgress interface {
|
||||
BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error)
|
||||
}
|
||||
|
||||
type distributionBuilderWithFileOutput interface {
|
||||
BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error)
|
||||
}
|
||||
|
||||
// DockerDistributionBuilderConfig configures a container-per-build builder.
|
||||
type DockerDistributionBuilderConfig struct {
|
||||
DockerBinary string
|
||||
@@ -116,8 +135,7 @@ func runCommandStreamCombined(ctx context.Context, name string, args []string, o
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
mu.Lock()
|
||||
output.WriteString(line)
|
||||
output.WriteByte('\n')
|
||||
appendBoundedBuilderOutput(&output, []byte(line+"\n"))
|
||||
mu.Unlock()
|
||||
if onLine != nil {
|
||||
onLine(line)
|
||||
@@ -197,17 +215,33 @@ func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildIn
|
||||
}
|
||||
|
||||
func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error) {
|
||||
output, err := builder.BuildFile(input, progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = output.Cleanup() }()
|
||||
binary, err := os.ReadFile(output.Path)
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder did not produce a readable distribution executable")
|
||||
}
|
||||
if int64(len(binary)) != output.SizeBytes || validator.BytesChecksum(binary) != output.Checksum {
|
||||
return nil, validationError("platform builder output changed before it could be read")
|
||||
}
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func (builder *DockerDistributionBuilder) BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error) {
|
||||
if ready, reason := builder.Readiness(); !ready {
|
||||
return nil, validationError(reason)
|
||||
return DistributionBuildOutput{}, 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")
|
||||
return DistributionBuildOutput{}, 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")
|
||||
return DistributionBuildOutput{}, validationError("platform builder workspace directory is invalid")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), builder.config.Timeout)
|
||||
defer cancel()
|
||||
@@ -220,14 +254,20 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
||||
}
|
||||
cacheDir, err := builder.cacheDir(workspaceDir)
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder cache directory is invalid")
|
||||
return DistributionBuildOutput{}, 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
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
cleanupJob := true
|
||||
defer func() {
|
||||
if cleanupJob {
|
||||
_ = os.RemoveAll(jobDir)
|
||||
}
|
||||
}()
|
||||
outputDir := filepath.Join(jobDir, "output")
|
||||
inputDir := filepath.Join(jobDir, "input")
|
||||
buildDir := filepath.Join(jobDir, "build")
|
||||
@@ -236,15 +276,14 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
||||
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
|
||||
return DistributionBuildOutput{}, 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
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
sourceMountDir = preparedSourceDir
|
||||
}
|
||||
@@ -253,59 +292,61 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
||||
// 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")
|
||||
return DistributionBuildOutput{}, 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
|
||||
return DistributionBuildOutput{}, 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")
|
||||
return DistributionBuildOutput{}, 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
|
||||
return DistributionBuildOutput{}, 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")
|
||||
return DistributionBuildOutput{}, 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
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
|
||||
outputName := strings.TrimSpace(input.OutputFilename)
|
||||
if outputName == "" || filepath.Base(outputName) != outputName {
|
||||
return nil, validationError("distribution build input has an invalid output filename")
|
||||
return DistributionBuildOutput{}, 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 DistributionBuildOutput{}, 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))
|
||||
return DistributionBuildOutput{}, 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))
|
||||
reportBuilderProgress(progress, 92, "package_finalize: validating platform builder output")
|
||||
outputPath := filepath.Join(outputDir, outputName)
|
||||
sizeBytes, checksum, err := fileSizeAndChecksum(outputPath)
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder did not produce a distribution executable")
|
||||
return DistributionBuildOutput{}, validationError("platform builder did not produce a distribution executable")
|
||||
}
|
||||
if len(binary) == 0 {
|
||||
return nil, validationError("platform builder produced an empty distribution executable")
|
||||
if sizeBytes == 0 {
|
||||
return DistributionBuildOutput{}, validationError("platform builder produced an empty distribution executable")
|
||||
}
|
||||
return binary, nil
|
||||
cleanupJob = false
|
||||
return DistributionBuildOutput{Path: outputPath, SizeBytes: sizeBytes, Checksum: checksum, cleanup: func() error { return os.RemoveAll(jobDir) }}, nil
|
||||
}
|
||||
|
||||
// prepareRunSource implements the source phase of a Jenkins-style build. A
|
||||
@@ -422,6 +463,20 @@ func (builder *DockerDistributionBuilder) runBuildCommand(ctx context.Context, a
|
||||
|
||||
const builderProgressMarker = "__platform_builder_progress__|"
|
||||
|
||||
const maxBuilderDiagnosticOutputBytes = 1024 * 1024
|
||||
|
||||
func appendBoundedBuilderOutput(output *bytes.Buffer, payload []byte) {
|
||||
if len(payload) >= maxBuilderDiagnosticOutputBytes {
|
||||
output.Reset()
|
||||
_, _ = output.Write(payload[len(payload)-maxBuilderDiagnosticOutputBytes:])
|
||||
return
|
||||
}
|
||||
if overflow := output.Len() + len(payload) - maxBuilderDiagnosticOutputBytes; overflow > 0 {
|
||||
_ = output.Next(overflow)
|
||||
}
|
||||
_, _ = output.Write(payload)
|
||||
}
|
||||
|
||||
func parseBuilderProgressLine(line string) (DistributionBuildProgress, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, builderProgressMarker) {
|
||||
|
||||
@@ -309,6 +309,18 @@ func TestDockerDistributionBuilderStreamsProgressMarkers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBoundedBuilderOutputKeepsTail(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
appendBoundedBuilderOutput(&output, bytes.Repeat([]byte("a"), maxBuilderDiagnosticOutputBytes))
|
||||
appendBoundedBuilderOutput(&output, []byte("tail"))
|
||||
if output.Len() != maxBuilderDiagnosticOutputBytes {
|
||||
t.Fatalf("builder diagnostic buffer grew beyond cap: %d", output.Len())
|
||||
}
|
||||
if !strings.HasSuffix(output.String(), "tail") {
|
||||
t.Fatalf("builder diagnostic buffer did not keep tail output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) {
|
||||
sourceDir := createBuilderSource(t)
|
||||
workspaceDir := t.TempDir()
|
||||
|
||||
@@ -130,7 +130,7 @@ func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatu
|
||||
}
|
||||
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
|
||||
if len(missing) > 0 {
|
||||
availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ")
|
||||
availability.Reason = "plugin-owned bridge producer is missing " + strings.Join(missing, ", ")
|
||||
}
|
||||
result = append(result, availability)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) {
|
||||
"declarationKey": "rcon",
|
||||
"targetKey": "rcon",
|
||||
"idempotencyKey": "bridge-rcon-1",
|
||||
"input.command": "#ListPlayers",
|
||||
"input.command": "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -201,7 +201,7 @@ func TestBridgeRemoteAccessDispatchesDeclaredSourceRCONCommand(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("consume bridge RCON input: %v", err)
|
||||
}
|
||||
if input.Command != "#ListPlayers" {
|
||||
if input.Command != "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777" {
|
||||
t.Fatalf("expected bridge RCON command to remain verbatim, got %q", input.Command)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user