Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .gds/bundle.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ bundle:
version: "0.8.0-dev"
release_sequence: 0
channel: "development"
source_tree_digest: "sha256:ca04e2e010b59a80218836f47fe9838f06a78b518f55c903f1299ce185c76247"
digest: "sha256:3834cde36107650e45f5614d4221d6a9eace7a063933438ca6a1d781f3dce3fa"
source_tree_digest: "sha256:6a2841ed1461b416ed9e3c393f5e7bd09cad1910ca4a99ed141aa55961860b0f"
digest: "sha256:d238ecef552753c9e73f67daf6efc4b7784198e6115463b1745429d43c1b1930"

projection:
input_digest: "sha256:443879da16dbc62efbe4fcb917d17542f2d76cedd899e745ff7b9bbcf4bb2659"
output_digest: "sha256:5d3da922fd206d208571be474d78ad1e1796700c3e1c9ed7e19058806203a65a"
input_digest: "sha256:7f1cedba841019882c248c7a6fbd70894d86745bba56853f9fcd298994253f06"
output_digest: "sha256:6957de5ca56f78ea1b18802a04e3ef25f53558025ef64292f4329bff8731601c"
files:
- path: ".gds/compiled-policy.json"
digest: "sha256:807282f820294914e1c7e6ad1bf27c54a799d56305c58630254ab50ab286f379"
- path: ".github/workflows/gds-ci.yml"
digest: "sha256:43589121d2564b8cf14c270a143fc2b910bb016edab2a72a77a5ec981d749965"
digest: "sha256:9356f7db1950f27c280da32e72588d78f3fcbfa8224cdb2690d752bc53b61c0c"
4 changes: 2 additions & 2 deletions .github/workflows/gds-ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GENERATED FILE - DO NOT EDIT DIRECTLY
# generator: gds
# bundle: 0.8.0-dev
# source-tree-digest: sha256:ca04e2e010b59a80218836f47fe9838f06a78b518f55c903f1299ce185c76247
# input-digest: sha256:443879da16dbc62efbe4fcb917d17542f2d76cedd899e745ff7b9bbcf4bb2659
# source-tree-digest: sha256:6a2841ed1461b416ed9e3c393f5e7bd09cad1910ca4a99ed141aa55961860b0f
# input-digest: sha256:7f1cedba841019882c248c7a6fbd70894d86745bba56853f9fcd298994253f06
# output-digest: sha256:a911ec3d1d5728bbe37fec78e04cc8452b9dbb1e8b394d0b8cace067804eff9a
# edit-source:
# - .gds/repository.yaml
Expand Down
34 changes: 34 additions & 0 deletions core/app/module_command_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package app

import "sync"

// Keep a bounded tail while the process runs, rather than accumulating an
// arbitrary build log and truncating only after the command has exited.
type moduleCommandOutput struct {
mu sync.Mutex
tail []byte
}

const moduleCommandOutputLimit = 16 << 10

func (output *moduleCommandOutput) Write(value []byte) (int, error) {
output.mu.Lock()
defer output.mu.Unlock()
written := len(value)
if len(value) >= moduleCommandOutputLimit {
output.tail = append(output.tail[:0], value[len(value)-moduleCommandOutputLimit:]...)
return written, nil
}
if discard := len(output.tail) + len(value) - moduleCommandOutputLimit; discard > 0 {
copy(output.tail, output.tail[discard:])
output.tail = output.tail[:len(output.tail)-discard]
}
output.tail = append(output.tail, value...)
return written, nil
}

func (output *moduleCommandOutput) String() string {
output.mu.Lock()
defer output.mu.Unlock()
return string(output.tail)
}
27 changes: 23 additions & 4 deletions core/app/module_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type CommandReport struct {
Status string `json:"status"`
ExitCode int `json:"exit_code"`
DurationMS int64 `json:"duration_ms"`
// Diagnostic is the tail of stderr, bounded and redacted. It is the whole
// Diagnostic is the combined output tail, bounded and redacted. It is the whole
// difference between a usable report and a guess: a failing command may be a
// defect in the module or a tool missing from this device, and nothing in
// the exit code separates those. `python3 -m pytest` exiting 1 in 14ms with
Expand Down Expand Up @@ -275,7 +275,20 @@ func runDeclaredCommand(
command := exec.CommandContext(bounded, "bash", "-euo", "pipefail", "-c", declared)
command.Dir = directory
command.Stdin = nil
diagnostic := &strings.Builder{}
// This selector belongs to the controller operation. Module commands prove
// their own source checkout, and must not silently select its consumer's
// estate. A declared command can still explicitly select an estate itself.
environment := os.Environ()
command.Env = make([]string, 0, len(environment))
for _, value := range environment {
if !strings.HasPrefix(value, "GDS_ESTATE_ROOT=") {
command.Env = append(command.Env, value)
}
}
// Bound inherited output pipes as well as the command itself.
command.WaitDelay = 2 * time.Second
diagnostic := &moduleCommandOutput{}
command.Stdout = diagnostic
command.Stderr = diagnostic
err := command.Run()
report := CommandReport{
Expand All @@ -286,17 +299,23 @@ func runDeclaredCommand(
return report
}
report.Diagnostic = boundedDiagnostic(diagnostic.String())
if report.Diagnostic == "" {
report.Diagnostic = boundedDiagnostic(err.Error())
}
if errors.Is(bounded.Err(), context.DeadlineExceeded) {
report.Status = "timeout"
report.ExitCode = -1
return report
}
report.Status = "failed"
report.ExitCode = command.ProcessState.ExitCode()
report.ExitCode = -1
if command.ProcessState != nil {
report.ExitCode = command.ProcessState.ExitCode()
}
return report
}

// boundedDiagnostic keeps the last lines of stderr, redacted and bounded.
// boundedDiagnostic keeps the last output lines, redacted and bounded.
//
// The tail rather than the head: a failing build prints its progress first and
// its reason last, so the head is the part nobody needs. The bound exists
Expand Down
43 changes: 43 additions & 0 deletions core/app/module_verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,46 @@ func TestBoundedDiagnosticKeepsTheTail(t *testing.T) {
t.Fatalf("diagnostic = %q", bounded[:40])
}
}

func TestDeclaredCommandDoesNotInheritControllerEstateSelection(t *testing.T) {
t.Setenv("GDS_ESTATE_ROOT", t.TempDir())
t.Setenv("GDS_MODULE_TEST_VALUE", "preserved")
report := runDeclaredCommand(context.Background(), t.TempDir(),
`test -z "${GDS_ESTATE_ROOT+x}" && test "$GDS_MODULE_TEST_VALUE" = preserved`, 30*time.Second)
if report.Status != "passed" {
t.Fatalf("controller selection leaked into module: %#v", report)
}
}

func TestDeclaredCommandPreservesStdoutFailure(t *testing.T) {
t.Parallel()
report := runDeclaredCommand(context.Background(), t.TempDir(), `printf 'FAIL: module assertion\n'; exit 1`, 30*time.Second)
if report.Status != "failed" || !strings.Contains(report.Diagnostic, "FAIL: module assertion") {
t.Fatalf("stdout failure lost: %#v", report)
}
}

func TestModuleCommandOutputBoundsBothStreamsDuringExecution(t *testing.T) {
t.Parallel()
output := &moduleCommandOutput{}
for _, value := range []string{strings.Repeat("first", 20000), strings.Repeat("second", 20000), "last diagnostic"} {
n, err := output.Write([]byte(value))
if err != nil || n != len(value) {
t.Fatalf("write=%d %v", n, err)
}
if len(output.String()) > moduleCommandOutputLimit {
t.Fatal("unbounded process output")
}
}
if !strings.HasSuffix(output.String(), "last diagnostic") {
t.Fatal("tail lost")
}
}

func TestDeclaredCommandReportsUnavailableShellWithoutPanic(t *testing.T) {
t.Setenv("PATH", t.TempDir())
report := runDeclaredCommand(context.Background(), t.TempDir(), "true", time.Second)
if report.Status != "failed" || report.ExitCode != -1 || report.Diagnostic == "" {
t.Fatalf("launch failure=%#v", report)
}
}
5 changes: 5 additions & 0 deletions docs/contracts/lifecycles-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ sends the reader looking for a concurrent writer that does not exist. Any
command whose deadline expires now also carries
`GDS_COMMAND_DEADLINE_EXCEEDED`, naming the deadline and the flag.

Module commands run without the controller's ambient `GDS_ESTATE_ROOT` selector;
their source checks must resolve their own checkout. A declared command may
explicitly select an estate when its contract requires one. Failures retain a
bounded, redacted tail from both stdout and stderr, including launch errors.

Applying a pin needs no approval. The only mutation is a gitlink rewrite in the
consumer's own working tree: it writes no provider, replaces no credential and
publishes nothing, and the consumer's pull request and checks are its real gate.
Expand Down