Skip to content
Open
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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1851,12 +1851,13 @@ jobs:
# `podman machine init` can fit inside.
# `-run` alone exits 0 when it matches nothing (a rename → false
# green), so require each group's own PASS line — a rename or skip
# reds. The filter is explicit rather than the whole package because
# the package also holds GUI E2E tests that need a display.
# reds. The filter is explicit rather than the whole compass-app
# package because that package also holds GUI E2E tests that need a
# display; the machine and preflight tests live in internal/embedded.
CGO_ENABLED=1 go -C go test -trimpath \
-run 'TestDistDirForExecutable|TestMachineReady|TestEnsureMachineReady|TestMachineResourceFloorIsExplicit|TestRealPreflightDeps|TestClassifyPreflight|TestBringUpTimeout' \
-count=1 -v \
./cmd/compass-app/ | tee /tmp/darwin-unit.log
./cmd/compass-app/ ./internal/embedded/ | tee /tmp/darwin-unit.log
for t in TestDistDirForExecutable \
TestMachineReadyRunning \
TestMachineReadyNoMachine \
Expand Down
18 changes: 9 additions & 9 deletions app-bundle/SMOKE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ real agent container. The packaged-app smoke therefore remains manual.
Embedded mode is the zero-config path. The app runs host preflight, brings up
the local stack, resolves the caller identity, and then opens the board. The
pipeline order is preflight, `compass-stack up`, then `WhoAmI`
(`runEmbedded` in `go/cmd/compass-app/embedded.go`: "pipeline ... WhoAmI").
(`Pipeline.Run` in `go/internal/embedded/embedded.go`: "preflight → stack up → WhoAmI").

### 1. Check embedded prerequisites

Embedded mode requires Linux or macOS, rootless podman, and podman 4.3 or
newer. These are fatal host checks. The agent image is checked locally but is
pulled from GHCR by the stack when it is missing
(`Deps.Run` in `go/internal/preflight/preflight.go`, `runEmbedded` in
`go/cmd/compass-app/embedded.go`). Confirm rootless podman and the
(`Deps.Run` in `go/internal/preflight/preflight.go`, `Pipeline.Run` in
`go/internal/embedded/embedded.go`). Confirm rootless podman and the
image before the smoke to avoid a cold pull:

```bash
Expand Down Expand Up @@ -85,7 +85,7 @@ so residue from an unpinned launch is not mistaken for a clean teardown

The stack binary resolution order is the `--compass-stack` flag,
`COMPASS_STACK_BIN`, a `compass-stack` sibling of the running `compass-app`,
then `PATH` (`resolveStackBin` in `go/cmd/compass-app/embedded.go`). For this smoke, do not
then `PATH` (`ResolveStackBin` in `go/internal/embedded/embedded.go`). For this smoke, do not
pass `--compass-stack` and require all launch overrides to be unset:

```bash
Expand All @@ -100,9 +100,9 @@ from outside the bundle. `COMPASS_DATABASE_DSN` must also be clear so the smoke
uses the bundle's state-directory database configuration.

The app resolves `compass-stack` as a sibling of the running `compass-app`
executable, preferred over PATH (`resolveStackBin` in `go/cmd/compass-app/embedded.go`), and
executable, preferred over PATH (`ResolveStackBin` in `go/internal/embedded/embedded.go`), and
prepends that same `bin/` directory for the supervised sidecars
(`prependExecDirToPath` in `go/cmd/compass-app/embedded.go`). So the bundle's staged
(`prependExecDirToPath` in `go/internal/embedded/embedded.go`). So the bundle's staged
`compass-stack` wins even when an ambient one is on PATH, which is what the
launch below relies on.

Expand Down Expand Up @@ -131,15 +131,15 @@ compass-stack up --state-dir <state-dir> --image ghcr.io/rigelbuild/compass-agen
```

`stackUpArgs` passes only `up`, `--state-dir`, `--image`, and `--socket`
(`stackUpArgs` in `go/cmd/compass-app/embedded.go`). It deliberately does not pass
(`stackUpArgs` in `go/internal/embedded/embedded.go`). It deliberately does not pass
`--database`, `--postgres-image`, `--collector-image`, or `--listen`. The image
ref is the locked GHCR default unless `--image` or `$COMPASS_AGENT_IMAGE`
overrides it (`resolveImage` in `go/cmd/compass-app/embedded.go`).
overrides it (`ResolveImage` in `go/internal/embedded/embedded.go`).

### 4. Confirm the embedded board and run one session

Wait for the app to bring the stack to Ready. It then resolves the caller with
`WhoAmI` over the local socket (`runEmbedded` in `go/cmd/compass-app/embedded.go`).
`WhoAmI` over the local socket (`Pipeline.Run` in `go/internal/embedded/embedded.go`).
Confirm that the app opens the board directly, without a client connect screen
or bearer entry. Embedded mode has no client `server_url` or `ca_cert`
configuration (`Parse` in `go/internal/appconfig/appconfig.go`:
Expand Down
198 changes: 198 additions & 0 deletions go/cmd/compass-app/embedded_launch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//go:build (linux && gtk4) || darwin

package main

// App-side embedded launch gate: runEmbedded runs the pipeline and wires the
// quit controller, exercised with injected seams and no real exec.

import (
"context"
"errors"
"slices"
"strings"
"testing"

"github.com/RigelBuild/compass/go/internal/embedded"
)

// baseParams is a representative resolved launch input the argv/dial assertions
// key off. The socket is a fixed path (the tests never dial it except in the
// WhoAmI-server case, which overrides it).
var baseParams = embedded.Params{
Socket: "/run/compass/server.sock",
StateDir: "/state/compass",
Image: "ghcr.io/rigelbuild/compass-agent:latest",
}

// stubPipeline builds an embedded.Pipeline whose three seams are deterministic
// stubs, recording what the orchestration invoked. Each seam defaults to a
// success no-op; a test overrides the ones it drives.
type recorder struct {
preflightCalled bool
stackUpCalled bool
stackUpArgs []string
whoAmICalled bool
whoAmISocket string
}

func stubPipeline(rec *recorder, preflightErr, stackUpErr, whoAmIErr error, accountID string) embedded.Pipeline {
return embedded.Pipeline{
Preflight: func(_ context.Context) error {
rec.preflightCalled = true
return preflightErr
},
StackUp: func(_ context.Context, args []string) error {
rec.stackUpCalled = true
rec.stackUpArgs = args
return stackUpErr
},
WhoAmI: func(_ context.Context, socket string) (string, error) {
rec.whoAmICalled = true
rec.whoAmISocket = socket
return accountID, whoAmIErr
},
}
}

// runEmbeddedStub runs runEmbedded with a recording stackDown seam so the quit
// controller wiring is observable without a real exec.
func runEmbeddedStub(
t *testing.T, pipeline embedded.Pipeline,
) (string, *quitController, error) {
t.Helper()
stackDown := func(_ context.Context, _ []string) error { return nil }
return runEmbedded(context.Background(), pipeline, baseParams, stackDown)
}

// TestRunEmbeddedHappyPath: embedded mode runs preflight → stack up → WhoAmI in
// order, passes the SAME socket to the dial that the argv carries, returns the
// resolved account id, and builds a quit controller wired to the params. Asserting
// the argv (up, --socket, --state-dir, --image) is the stack-invocation contract;
// asserting whoAmISocket == socket is the single-socket invariant (the value
// passed to --socket IS the value dialed).
func TestRunEmbeddedHappyPath(t *testing.T) {
rec := &recorder{}
pipeline := stubPipeline(rec, nil, nil, nil, "acc-42")

id, quitter, err := runEmbeddedStub(t, pipeline)
if err != nil {
t.Fatalf("embedded happy path err = %v, want nil", err)
}
if id != "acc-42" {
t.Errorf("account id = %q, want acc-42", id)
}
if quitter == nil {
t.Fatal("embedded mode returned a nil quit controller, want one wired to the stack teardown")
}
if quitter.params != baseParams {
t.Errorf("quit controller params = %+v, want %+v", quitter.params, baseParams)
}
if !rec.preflightCalled || !rec.stackUpCalled || !rec.whoAmICalled {
t.Fatalf("not every stage ran: %+v", rec)
}
assertArg(t, rec.stackUpArgs, "up")
assertArgPair(t, rec.stackUpArgs, "--socket", baseParams.Socket)
assertArgPair(t, rec.stackUpArgs, "--state-dir", baseParams.StateDir)
assertArgPair(t, rec.stackUpArgs, "--image", baseParams.Image)
if rec.whoAmISocket != baseParams.Socket {
t.Errorf("WhoAmI dialed %q, want the SAME socket passed to --socket %q",
rec.whoAmISocket, baseParams.Socket)
}
}

// TestRunEmbeddedPreflightShortCircuits: a preflight failure returns the
// aggregated legible error VERBATIM, never proceeds to stack-up or WhoAmI, and
// builds no quit controller. Mutation that reddens it: running the checks after a
// failure, or reformatting Results.Err's copy.
func TestRunEmbeddedPreflightShortCircuits(t *testing.T) {
rec := &recorder{}
preflightErr := errors.New("embedded-mode preflight failed:\n - windows is not supported")
pipeline := stubPipeline(rec, preflightErr, nil, nil, "acc-x")

id, quitter, err := runEmbeddedStub(t, pipeline)
if !errors.Is(err, preflightErr) {
t.Fatalf("preflight-fail err = %v, want the preflight error verbatim", err)
}
if id != "" {
t.Errorf("account id = %q, want empty on preflight failure", id)
}
if quitter != nil {
t.Error("preflight failure returned a quit controller, want nil")
}
if !rec.preflightCalled {
t.Error("preflight did not run")
}
if rec.stackUpCalled || rec.whoAmICalled {
t.Errorf("pipeline proceeded past a failed preflight: %+v", rec)
}
}

// TestRunEmbeddedStackUpFails: a non-zero compass-stack up exit is surfaced and
// the pipeline stops before WhoAmI. The stackUp seam already folds stderr into
// its error (see TestRunStackUpNonZeroExitSurfacesStderr); here the contract is
// that runEmbedded propagates it and does not dial.
func TestRunEmbeddedStackUpFails(t *testing.T) {
rec := &recorder{}
stackErr := errors.New("compass-stack up failed: exit status 1: postgres refused")
pipeline := stubPipeline(rec, nil, stackErr, nil, "acc-x")

id, quitter, err := runEmbeddedStub(t, pipeline)
if !errors.Is(err, stackErr) {
t.Fatalf("stack-up-fail err = %v, want the stack-up error", err)
}
if id != "" {
t.Errorf("account id = %q, want empty on stack-up failure", id)
}
if quitter != nil {
t.Error("stack-up failure returned a quit controller, want nil")
}
if rec.whoAmICalled {
t.Error("pipeline dialed WhoAmI after a failed stack-up")
}
}

// TestRunEmbeddedWhoAmIFails: a WhoAmI error is surfaced (wrapped with the socket
// for context) and no account id is returned. Mutation that reddens it:
// swallowing the WhoAmI error and returning an empty id as success.
func TestRunEmbeddedWhoAmIFails(t *testing.T) {
rec := &recorder{}
whoErr := errors.New("connect: connection refused")
pipeline := stubPipeline(rec, nil, nil, whoErr, "")

id, quitter, err := runEmbeddedStub(t, pipeline)
if !errors.Is(err, whoErr) {
t.Fatalf("whoami-fail err = %v, want the WhoAmI error wrapped", err)
}
if id != "" {
t.Errorf("account id = %q, want empty on WhoAmI failure", id)
}
if quitter != nil {
t.Error("WhoAmI failure returned a quit controller, want nil")
}
if !strings.Contains(err.Error(), baseParams.Socket) {
t.Errorf("WhoAmI error %q does not name the socket for context", err.Error())
}
}

// assertArg fails unless want appears as a token in args.
func assertArg(t *testing.T, args []string, want string) {
t.Helper()
if !slices.Contains(args, want) {
t.Errorf("argv %v missing token %q", args, want)
}
}

// assertArgPair fails unless flag is immediately followed by value in args.
func assertArgPair(t *testing.T, args []string, flag, value string) {
t.Helper()
for i, a := range args {
if a == flag {
if i+1 < len(args) && args[i+1] == value {
return
}
t.Errorf("argv %v: flag %q not followed by %q", args, flag, value)
return
}
}
t.Errorf("argv %v missing flag %q", args, flag)
}
13 changes: 4 additions & 9 deletions go/cmd/compass-app/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,9 @@ import (
"context"
"log/slog"
"time"
)

// stackDownTimeout bounds the explicit teardown (compass-stack down: attach,
// SIGTERM the child tree, wait the server drain, release the lock). The bring-up
// context is already cancelled by the time the window is open, so
// stopStackAndQuit roots a FRESH bounded context off the caller's rather than
// reusing it.
const stackDownTimeout = 60 * time.Second
"github.com/RigelBuild/compass/go/internal/embedded"
)

// quitController is the explicit "Quit and stop stack" orchestration over its
// injected effects. It holds the teardown seam (stackDown), the argv inputs
Expand All @@ -43,7 +38,7 @@ const stackDownTimeout = 60 * time.Second
// verified with no real exec and no display.
type quitController struct {
stackDown func(ctx context.Context, args []string) error
params embeddedParams
params embedded.Params
quit func()
timeout time.Duration
logger *slog.Logger
Expand All @@ -65,7 +60,7 @@ func (c quitController) stopStackAndQuit(ctx context.Context) {
}
downCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
if err := c.stackDown(downCtx, stackDownArgs(c.params)); err != nil {
if err := c.stackDown(downCtx, embedded.StackDownArgs(c.params)); err != nil {
// Quit-anyway (OQ-6): log and fall through to quit.
logger.Error("stopping the embedded stack failed; quitting anyway "+
"(the stack lingers, which is the safe plain-quit default)", "error", err)
Expand Down
20 changes: 11 additions & 9 deletions go/cmd/compass-app/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,25 @@ import (
"slices"
"testing"
"time"

"github.com/RigelBuild/compass/go/internal/embedded"
)

// TestStackDownArgs: the pure argv builder emits the exact `compass-stack down`
// invocation (down + --state-dir + --image + --socket) and OMITS both --database
// (compass-stack recomputes the default DSN from --state-dir) and --linger (down
// is not lingerable).
func TestStackDownArgs(t *testing.T) {
args := stackDownArgs(baseParams)
args := embedded.StackDownArgs(baseParams)

want := []string{
"down",
"--state-dir", baseParams.stateDir,
"--image", baseParams.image,
"--socket", baseParams.socket,
"--state-dir", baseParams.StateDir,
"--image", baseParams.Image,
"--socket", baseParams.Socket,
}
if !slices.Equal(args, want) {
t.Errorf("stackDownArgs = %v, want %v", args, want)
t.Errorf("StackDownArgs = %v, want %v", args, want)
}
if slices.Contains(args, "--database") {
t.Errorf("argv carries --database, want it omitted (compass-stack defaults the DSN): %v", args)
Expand All @@ -41,7 +43,7 @@ func TestStackDownArgs(t *testing.T) {
}

// TestStopStackAndQuitHappyPath: a successful teardown runs down with EXACTLY
// the stackDownArgs(params) argv and then quits the app exactly once.
// the StackDownArgs(params) argv and then quits the app exactly once.
func TestStopStackAndQuitHappyPath(t *testing.T) {
var gotArgs []string
quitCount := 0
Expand All @@ -52,12 +54,12 @@ func TestStopStackAndQuitHappyPath(t *testing.T) {
},
params: baseParams,
quit: func() { quitCount++ },
timeout: stackDownTimeout,
timeout: embedded.StackDownTimeout,
}

c.stopStackAndQuit(context.Background())

if want := stackDownArgs(baseParams); !slices.Equal(gotArgs, want) {
if want := embedded.StackDownArgs(baseParams); !slices.Equal(gotArgs, want) {
t.Errorf("stackDown argv = %v, want %v", gotArgs, want)
}
if quitCount != 1 {
Expand All @@ -76,7 +78,7 @@ func TestStopStackAndQuitQuitsAnywayOnDownFailure(t *testing.T) {
},
params: baseParams,
quit: func() { quitCount++ },
timeout: stackDownTimeout,
timeout: embedded.StackDownTimeout,
}

c.stopStackAndQuit(context.Background())
Expand Down
Loading
Loading