diff --git a/.github/workflows/published.yml b/.github/workflows/published.yml new file mode 100644 index 0000000..fd57790 --- /dev/null +++ b/.github/workflows/published.yml @@ -0,0 +1,113 @@ +name: Published consumers +on: + workflow_run: + workflows: [v2] + types: [completed] + branches: [master] + workflow_dispatch: + inputs: + version: + description: Published v2 tag to validate + required: true + type: string +permissions: + contents: read + attestations: read +jobs: + release: + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.pin.outputs.version }} + revision: ${{ steps.pin.outputs.revision }} + steps: + - id: pin + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_VERSION: ${{ inputs.version }} + SOURCE_REVISION: ${{ github.event.workflow_run.head_sha }} + shell: python + run: | + import json, os, re, subprocess + repo = os.environ['GITHUB_REPOSITORY'] + def api(path): + return json.loads(subprocess.check_output(['gh', 'api', f'repos/{repo}/{path}'], text=True)) + version = os.environ['REQUESTED_VERSION'] + if version: + assert re.fullmatch(r'v2\.\d+\.\d+(?:-[a-z]+\.\d+)?', version) + release = api('releases/tags/' + version) + else: + releases = api('releases?per_page=100') + matches = [r for r in releases if not r['draft'] and r['target_commitish'] == os.environ['SOURCE_REVISION']] + assert len(matches) == 1, 'expected one published release for successful master commit' + release = matches[0] + version = release['tag_name'] + assert not release['draft'] + revision = api('commits/' + version)['sha'] + if os.environ.get('SOURCE_REVISION'): + assert revision == os.environ['SOURCE_REVISION'] + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'version={version}\nrevision={revision}\n') + native: + needs: release + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15-intel, macos-15, windows-2025, windows-11-arm] + go: ['1.26.x', '1.27.x'] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ matrix.go }} + cache: false + - name: Verify actual public module and CLI from fresh caches + # Launch directly from the runner, without Git Bash's token environment. + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.release.outputs.version }} + RELEASE_REVISION: ${{ needs.release.outputs.revision }} + shell: python + run: | + import os, subprocess, sys + subprocess.run([sys.executable, 'scripts/check-published.py', os.environ['RELEASE_VERSION'], os.environ['RELEASE_REVISION']], check=True) + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: published-${{ matrix.os }}-go-${{ matrix.go }} + path: published-evidence + if-no-files-found: error + musl: + needs: release + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm] + go: ['1.26', '1.27'] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verify public consumers on native Alpine + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.release.outputs.version }} + RELEASE_REVISION: ${{ needs.release.outputs.revision }} + GO_VERSION: ${{ matrix.go }} + run: | + docker build --build-arg GO_VERSION="$GO_VERSION" -f scripts/published.Dockerfile -t published-consumer . + mkdir published-evidence + sudo chown 1000:1000 published-evidence + docker run --rm -e GH_TOKEN -v "$PWD/published-evidence:/evidence" published-consumer "$RELEASE_VERSION" "$RELEASE_REVISION" /evidence/results + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: published-musl-${{ matrix.os }}-go-${{ matrix.go }} + path: published-evidence + if-no-files-found: error diff --git a/.github/workflows/v2.yml b/.github/workflows/v2.yml index 53ef7af..4e4806f 100644 --- a/.github/workflows/v2.yml +++ b/.github/workflows/v2.yml @@ -60,6 +60,34 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: ./scripts/test_installer.ps1 + - name: PowerShell restricted-token and PostgreSQL lifecycle regressions + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:EP_SUPERVISOR = Join-Path $PWD 'ci-output/consumer-postgres.exe' + $env:EP_TEST_BIN = Join-Path $PWD 'pg/bin' + $env:EP_TEST_VERSION = '18.6.1' + foreach ($version in @('go1.26.9', 'go1.27.2')) { + $env:GOTOOLCHAIN = $version + # Match the shipped executable: no coverage instrumentation whose + # missing GOCOVERDIR warning would contaminate psql probe output. + go build -o $env:EP_SUPERVISOR ./cmd/embedded-postgres + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test -v -count=1 -p=1 -timeout=4m -run '^(TestWindowsRestrictedChildCanOpenItsToken|TestWindowsNativeFailuresReleaseHandles|TestWindowsUserDACLRejectsInvalidToken|TestParallelLifecycle|TestLifetimeCancellation|TestPersistentDataNeverErased|TestCLIParentPipe|TestCLIBackground|TestCLIExec|TestWindowsExecCancellation)$' . ./internal/platform ./internal/cli + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + if: runner.os == 'Windows' + with: + node-version: '24' + package-manager-cache: false + - name: Detached hidden Node launch from PowerShell + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:EP_SUPERVISOR = Join-Path $PWD 'ci-output/consumer-postgres.exe' + $env:EP_TEST_BIN = Join-Path $PWD 'pg/bin' + node scripts/test-node-launch.mjs - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 7a86220..d534745 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ main /dist/ /embedded-postgres /scripts/__pycache__/ +/scripts/testdata/published/__pycache__/ +/published-evidence/ diff --git a/docs/non-root.md b/docs/non-root.md index feed55b..46f257b 100644 --- a/docs/non-root.md +++ b/docs/non-root.md @@ -22,6 +22,13 @@ Explicit IDs must fit Go's native `int`: at most 2147483647 on 32-bit hosts. Windows uses restricted security tokens plus a kill-on-close Job Object; UID/GID selection is a Unix feature. +On Windows, supplied cache and workspace directories must grant access to the +actual user account. An ACL that grants access only through Administrators or +administrator ownership will not work after that group is disabled. The library +does not rewrite caller-owned directory permissions. Restricted child tokens and +the objects they create grant access to that user and SYSTEM, so tools can reopen +their own tokens without restoring administrator or power-user membership. + The library creates a private workspace, assigns its ownership, and copies the immutable binary installation and supervisor there when RunAs is used. This avoids requiring access through `/root` or changing permissions on a caller's home/cache. diff --git a/docs/releases.md b/docs/releases.md index d021a06..77a9424 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -65,6 +65,25 @@ same commit resumes its draft or leaves an already published release unchanged. PR, manual and scheduled workflows do not publish. Alpha releases do not replace v1 as GitHub's latest stable release. +After publication, the public **Published consumers** workflow installs the exact +Go module from the public proxy, verifies its source revision and checksum, +downloads the automatic matching CLI, and checks the CLI's checksum and build +attestation. Fresh-cache SQL, offline reuse and cleanup run on all six native +hosts with both supported Go versions, plus native x64/ARM64 Alpine. Windows +checks start outside Git Bash. Logs and pinned identities remain public workflow +artifacts; this validation does not depend on a private repository. + +To reproduce one host locally with Go, Python and an authenticated GitHub CLI: + +```bash +python scripts/check-published.py RELEASE_TAG FULL_SOURCE_COMMIT +``` + +The command writes `published-evidence/`, requires an unused evidence directory, +and uses temporary caches. It changes permissions only on its own new Windows +test directory so the actual user retains access after administrator groups are +disabled. It never changes an existing consumer directory's ACL. + Release settings live in [`.github/release.json`](../.github/release.json): - `base: "2.0.0"`, `prerelease: "alpha"` produces `v2.0.0-alpha.1`, `.2`, and so on. diff --git a/internal/platform/process_windows.go b/internal/platform/process_windows.go index 619d16c..ea2fe54 100644 --- a/internal/platform/process_windows.go +++ b/internal/platform/process_windows.go @@ -27,12 +27,59 @@ type windowsCalls struct { openToken func(syscall.Handle, uint32, *syscall.Token) error sid func(string) (*syscall.SID, error) restrict func(syscall.Token, []syscall.SIDAndAttributes, *syscall.Token) (uintptr, error) + secure func(syscall.Token) error create func() (uintptr, error) limit func(uintptr, *extendedLimits) (uintptr, error) assign func(uintptr, syscall.Handle) (uintptr, error) } -var nativeWindows = windowsCalls{syscall.OpenProcessToken, syscall.StringToSid, nativeRestrict, nativeCreate, nativeLimit, nativeAssign} +var nativeWindows = windowsCalls{syscall.OpenProcessToken, syscall.StringToSid, nativeRestrict, nativeUserDACL, nativeCreate, nativeLimit, nativeAssign} + +// A token created by an elevated PowerShell process can inherit an +// administrators-only DACL. Once that SID is disabled, the child cannot reopen +// its own token (as initdb must). Grant only the actual user and SYSTEM access +// to this token and to objects it creates. No caller token or directory ACL is +// modified, and the administrator/power-user SIDs remain deny-only. +func nativeUserDACL(token syscall.Token) error { + user, err := token.GetTokenUser() + if err != nil { + return fmt.Errorf("query token user: %w", err) + } + sid, err := user.User.Sid.String() + if err != nil { + return err + } + sddl, err := syscall.UTF16PtrFromString("D:P(A;;GA;;;SY)(A;;GA;;;" + sid + ")") + if err != nil { + return err + } + var descriptor uintptr + ok, _, callErr := advapi.NewProc("ConvertStringSecurityDescriptorToSecurityDescriptorW").Call(uintptr(unsafe.Pointer(sddl)), 1, uintptr(unsafe.Pointer(&descriptor)), 0) + runtime.KeepAlive(sddl) + if ok == 0 { + return fmt.Errorf("convert user security descriptor: %w", callErr) + } + defer syscall.LocalFree(syscall.Handle(descriptor)) + var present, defaulted uint32 + var dacl unsafe.Pointer + ok, _, callErr = advapi.NewProc("GetSecurityDescriptorDacl").Call(descriptor, uintptr(unsafe.Pointer(&present)), uintptr(unsafe.Pointer(&dacl)), uintptr(unsafe.Pointer(&defaulted))) + if ok == 0 { + return fmt.Errorf("read user DACL: %w", callErr) + } + if present == 0 || dacl == nil { + return errors.New("restricted token requires a non-null user DACL") + } + // TOKEN_DEFAULT_DACL consists of one ACL pointer. + ok, _, callErr = advapi.NewProc("SetTokenInformation").Call(uintptr(token), syscall.TokenDefaultDacl, uintptr(unsafe.Pointer(&dacl)), unsafe.Sizeof(dacl)) + if ok == 0 { + return fmt.Errorf("set token default DACL: %w", callErr) + } + ok, _, callErr = advapi.NewProc("SetKernelObjectSecurity").Call(uintptr(token), 4 /* DACL_SECURITY_INFORMATION */, descriptor) + if ok == 0 { + return fmt.Errorf("set token object DACL: %w", callErr) + } + return nil +} // Typed pointer arguments keep pointer lifetimes visible to Go across the // injectable boundary. Convert to uintptr only at the annotated syscall call. @@ -68,7 +115,10 @@ func configureWindows(cmd *exec.Cmd, id *Identity, calls windowsCalls) (func(), current, _ := syscall.GetCurrentProcess() // Go returns the constant current-process pseudo-handle. var err error var original syscall.Token - if err = calls.openToken(current, syscall.TOKEN_DUPLICATE|syscall.TOKEN_ASSIGN_PRIMARY|syscall.TOKEN_QUERY, &original); err != nil { + // CreateRestrictedToken preserves the original handle's access mask. Request + // the rights needed to secure the new token; the original is never modified. + const writeDACL = 0x00040000 // WRITE_DAC + if err = calls.openToken(current, syscall.TOKEN_DUPLICATE|syscall.TOKEN_ASSIGN_PRIMARY|syscall.TOKEN_QUERY|syscall.TOKEN_ADJUST_DEFAULT|writeDACL, &original); err != nil { return nil, err } defer original.Close() @@ -86,6 +136,10 @@ func configureWindows(cmd *exec.Cmd, id *Identity, calls windowsCalls) (func(), if ok == 0 { return nil, fmt.Errorf("create restricted token: %w", callErr) } + if err := calls.secure(token); err != nil { + token.Close() + return nil, fmt.Errorf("secure restricted token: %w", err) + } cmd.SysProcAttr = &syscall.SysProcAttr{Token: token, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, HideWindow: true} return func() { _ = token.Close() }, nil } diff --git a/internal/platform/process_windows_test.go b/internal/platform/process_windows_test.go index 1993313..29d90a3 100644 --- a/internal/platform/process_windows_test.go +++ b/internal/platform/process_windows_test.go @@ -4,12 +4,55 @@ package platform import ( "errors" + "os" "os/exec" + "strconv" "syscall" "testing" "unsafe" ) +func TestWindowsRestrictedChildCanOpenItsToken(t *testing.T) { + depth, _ := strconv.Atoi(os.Getenv("EP_WINDOWS_TOKEN_DEPTH")) + if depth > 0 { + current, _ := syscall.GetCurrentProcess() + var token syscall.Token + if err := syscall.OpenProcessToken(current, syscall.TOKEN_ALL_ACCESS, &token); err != nil { + t.Fatal("restricted child cannot reopen its own token", depth, err) + } + token.Close() + for _, value := range []string{"S-1-5-32-544", "S-1-5-32-547"} { + sid, err := syscall.StringToSid(value) + if err != nil { + t.Fatal(err) + } + var member uint32 + ok, _, err := advapi.NewProc("CheckTokenMembership").Call(0, uintptr(unsafe.Pointer(sid)), uintptr(unsafe.Pointer(&member))) + if ok == 0 || member != 0 { + t.Fatal("restricted child retained privileged group membership", value, member, err) + } + } + } + if depth == 2 { + return + } + cmd, release, err := Command(t.Context(), nil, os.Args[0], "-test.run=^TestWindowsRestrictedChildCanOpenItsToken$", "-test.v") + if err != nil { + t.Fatal(err) + } + defer release() + cmd.Env = append(cmd.Env, "EP_WINDOWS_TOKEN_DEPTH="+strconv.Itoa(depth+1)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("nested restricted child failed: %v\n%s", err, out) + } +} + +func TestWindowsUserDACLRejectsInvalidToken(t *testing.T) { + if err := nativeUserDACL(0); err == nil { + t.Fatal("invalid token accepted") + } +} + func TestWindowsNativeFailuresReleaseHandles(t *testing.T) { failure := syscall.ERROR_ACCESS_DENIED @@ -25,7 +68,7 @@ func TestWindowsNativeFailuresReleaseHandles(t *testing.T) { } before := handleCount() for range 3 { - for _, kind := range []string{"open", "admin-sid", "power-sid", "restrict"} { + for _, kind := range []string{"open", "admin-sid", "power-sid", "restrict", "secure"} { calls := nativeWindows switch kind { case "open": @@ -39,6 +82,8 @@ func TestWindowsNativeFailuresReleaseHandles(t *testing.T) { } case "restrict": calls.restrict = func(syscall.Token, []syscall.SIDAndAttributes, *syscall.Token) (uintptr, error) { return 0, failure } + case "secure": + calls.secure = func(syscall.Token) error { return failure } } release, e := configureWindows(exec.Command("unused"), nil, calls) if e == nil { diff --git a/scripts/check-published.py b/scripts/check-published.py new file mode 100644 index 0000000..ff8d78f --- /dev/null +++ b/scripts/check-published.py @@ -0,0 +1,102 @@ +"""Check a public Go module and CLI in an isolated consumer (no source override). + +Usage: python scripts/check-published.py v2.0.0-alpha.5 COMMIT [EVIDENCE_DIR] +Requires Go, Python and authenticated gh for public build-attestation verification. +""" +import hashlib +import json +import os +import pathlib +import re +import shutil +import socket +import subprocess +import sys +import tempfile + +source = pathlib.Path(__file__).resolve().parent / "testdata/published" +tag, revision = sys.argv[1:3] +if not re.fullmatch(r"v2\.\d+\.\d+(?:-[a-z]+\.\d+)?", tag) or not re.fullmatch(r"[0-9a-f]{40}", revision): + raise SystemExit("expected an explicit v2 tag and full source commit") +evidence = pathlib.Path(sys.argv[3] if len(sys.argv) > 3 else "published-evidence").resolve() +evidence.mkdir(parents=True, exist_ok=False) +work = pathlib.Path(tempfile.mkdtemp(prefix="ep-published-")) +# Python's Windows private directory can be owned by Administrators. Grant the +# actual user explicitly so disabling Administrators does not deny the fixture. +# This changes only this test's new directory, never a consumer's existing ACL. +if os.name == "nt": + identity = json.loads(subprocess.check_output([ + "pwsh", "-NoProfile", "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value | ConvertTo-Json", + ], text=True)) + if not re.fullmatch(r"S-1-[0-9-]+", identity): + raise SystemExit("unexpected current user SID") + subprocess.run(["icacls", str(work), "/grant", f"*{identity}:(OI)(CI)F"], check=True) +env = {k: v for k, v in os.environ.items() if not k.startswith("EP_") and k != "EMBEDDED_POSTGRES_CLI"} +env.update(GOMODCACHE=str(work / "modules"), GOCACHE=str(work / "build-cache"), + GOWORK="off", GOPROXY="https://proxy.golang.org,direct", GOSUMDB="sum.golang.org") +commands = [] + + +def run(args, label, cwd, extra=None): + with (evidence / (label + ".log")).open("w") as log: + result = subprocess.run(args, cwd=cwd, env=dict(env, **(extra or {})), + stdout=log, stderr=subprocess.STDOUT, timeout=600) + commands.append({"command": args, "label": label, "exit": result.returncode}) + (evidence / "commands.json").write_text(json.dumps(commands, indent=2) + "\n") + if result.returncode: + raise RuntimeError(f"{label} failed; see {evidence / (label + '.log')}") + + +try: + go = work / "go" + go.mkdir() + shutil.copy2(source / "release_test.go", go / "release_test.go") + (go / "go.mod").write_text("module example.com/published-consumer\n\ngo 1.26.0\n") + run(["go", "version"], "go-version", go) + module = "github.com/fergusstrange/embedded-postgres/v2@" + tag + run(["go", "get", module], "go-get", go) + run(["go", "mod", "verify"], "go-mod-verify", go) + run(["go", "mod", "download", "-json", module], "go-module", go) + metadata = json.loads((evidence / "go-module.log").read_text()) + assert metadata["Origin"]["Hash"] == revision, metadata + shutil.copy2(go / "go.sum", evidence / "go.sum") + run(["go", "test", "-v", "-count=1", "-timeout=6m", "./..."], "go-consumer", go, + {"EP_CONSUMER_EVIDENCE": str(go), "EP_CONSUMER_TAG": tag}) + system, arch = subprocess.check_output(["go", "env", "GOOS", "GOARCH"], env=env, text=True).split() + asset = f"embedded-postgres_{system}_{arch}" + (".exe" if system == "windows" else "") + cli = go / "cache" / (tag + "-" + asset) + digest = hashlib.sha256(cli.read_bytes()).hexdigest() + run(["gh", "release", "download", tag, "--repo", "fergusstrange/embedded-postgres", + "--pattern", "checksums.txt", "--dir", str(evidence)], "release-checksums", go) + checksums = {line.split()[1].lstrip("*"): line.split()[0] + for line in (evidence / "checksums.txt").read_text().splitlines()} + assert checksums[asset] == digest + run(["gh", "attestation", "verify", str(cli), "--repo", "fergusstrange/embedded-postgres", + "--source-ref", "refs/heads/master", "--source-digest", revision, + "--signer-workflow", "fergusstrange/embedded-postgres/.github/workflows/v2.yml", "--signer-digest", revision, + "--deny-self-hosted-runners", "--format", "json"], "cli-attestation", go) + version = json.loads(subprocess.check_output([str(cli), "version"], text=True)) + assert version["version"] == tag + cli_work = work / "cli" + cli_work.mkdir() + query_record = cli_work / "query.json" + run([str(cli), "exec", "--cache-dir", str(cli_work / "cache"), "--work-dir", str(cli_work / "work"), + "--", sys.executable, str(source / "cli_query.py"), str(cli_work / "cache"), str(query_record)], + "cli-exec", cli_work) + query = json.loads(query_record.read_text()) + shutil.copy2(query_record, evidence / "cli-query.json") + with socket.socket() as connection: + connection.settimeout(2) + assert connection.connect_ex(("127.0.0.1", query["port"])) != 0, "CLI port survived cleanup" + assert list((cli_work / "work").iterdir()) == [], "CLI workspace survived cleanup" + record = {"status": "passed", "tag": tag, "revision": revision, "module_sum": metadata["Sum"], + "system": system, "arch": arch, "cli_asset": asset, "cli_sha256": digest, + "postgres_version": "18.6.1", "commands": commands} +finally: + # Go makes downloaded modules read-only. Use its supported cache cleanup + # before removing this script's isolated temporary directory. + subprocess.run(["go", "clean", "-modcache"], cwd=work, env=env, check=True, timeout=120) + shutil.rmtree(work) +(evidence / "result.json").write_text(json.dumps(record, indent=2) + "\n") +print("Published module, automatic supervisor, CLI, fresh downloads, SQL, offline reuse and cleanup passed") diff --git a/scripts/published.Dockerfile b/scripts/published.Dockerfile new file mode 100644 index 0000000..f57230c --- /dev/null +++ b/scripts/published.Dockerfile @@ -0,0 +1,10 @@ +ARG GO_VERSION=1.27 +FROM golang:${GO_VERSION}-alpine +ENV CGO_ENABLED=0 +RUN apk add --no-cache python3 git github-cli ca-certificates \ + && adduser -D consumer +WORKDIR /consumer +COPY scripts/check-published.py scripts/check-published.py +COPY scripts/testdata/published scripts/testdata/published +USER consumer +ENTRYPOINT ["python3", "scripts/check-published.py"] diff --git a/scripts/test-node-launch.mjs b/scripts/test-node-launch.mjs new file mode 100644 index 0000000..29d5b35 --- /dev/null +++ b/scripts/test-node-launch.mjs @@ -0,0 +1,60 @@ +// Reproduce the detached, hidden Windows spawn used by Node consumers. +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createInterface } from 'node:readline'; + +const work = mkdtempSync(join(tmpdir(), 'ep-node-launch-')); +const helper = process.env.EP_SUPERVISOR; +const binaries = process.env.EP_TEST_BIN; +assert.ok(helper && binaries, 'build the CLI and set EP_SUPERVISOR and EP_TEST_BIN'); +let ready = false; +let stopped = false; +let port; +let failure; +let stderr = ''; +const child = spawn(helper, ['run', '--binaries', dirname(binaries), '--work-dir', work, '--json', '--parent-stdin'], { + stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, detached: process.platform === 'win32', shell: false, +}); +const timer = setTimeout(() => { failure = new Error('Node-launched CLI timed out'); child.kill('SIGKILL'); }, 90_000); +child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-8192); }); +const lines = createInterface({ input: child.stdout }); +lines.on('line', line => { + try { + const event = JSON.parse(line); + if (event.event === 'error') throw new Error(event.error); + if (event.event === 'ready') { + ready = true; + port = event.port; + const suffix = process.platform === 'win32' ? '.exe' : ''; + const url = new URL(event.connection_url); + const env = { ...process.env, PGHOST: url.hostname, PGPORT: url.port, + PGUSER: decodeURIComponent(url.username), PGPASSWORD: decodeURIComponent(url.password), + PGDATABASE: decodeURIComponent(url.pathname.slice(1)) }; + const result = execFileSync(join(binaries, `psql${suffix}`), ['-Atc', 'SELECT 42;'], { env, encoding: 'utf8', timeout: 10_000 }); + assert.equal(result.trim(), '42'); + child.stdin.end(); + } + if (event.event === 'stopped') stopped = true; + } catch (error) { failure = error; child.stdin.end(); } +}); +try { + const exit = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + if (failure) throw failure; + assert.deepEqual(exit, { code: 0, signal: null }, stderr); + assert.ok(ready && stopped, 'CLI must report both readiness and cleanup'); + assert.deepEqual(readdirSync(work), []); + await new Promise((resolve, reject) => { + const socket = createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { socket.destroy(); reject(new Error('database survived CLI exit')); }); + socket.once('error', resolve); + socket.setTimeout(2000, () => { socket.destroy(); reject(new Error('cleanup check timed out')); }); + }); + console.log('Detached hidden Node launch: SQL, parent-stdin shutdown, port and workspace cleanup passed.'); +} finally { clearTimeout(timer); lines.close(); rmSync(work, { recursive: true, force: true }); } diff --git a/scripts/testdata/published/cli_query.py b/scripts/testdata/published/cli_query.py new file mode 100644 index 0000000..7dd4b8b --- /dev/null +++ b/scripts/testdata/published/cli_query.py @@ -0,0 +1,8 @@ +import json,os,pathlib,subprocess,sys +cache=pathlib.Path(sys.argv[1]);record=pathlib.Path(sys.argv[2]) +psql=[x for x in cache.rglob('psql*') if x.name in ('psql','psql.exe') and 'postgresql-18.6.1-'in str(x)] +assert len(psql)==1 +result=subprocess.check_output([str(psql[0]),os.environ['DATABASE_URL'],'-Atc','SELECT 42; SHOW server_version_num;'],text=True) +assert result.strip()=='42\n180006',result +record.write_text(json.dumps({'port':int(os.environ['PGPORT']),'sql':42,'server_version_num':180006})+'\n') +print('Published CLI: default PostgreSQL 18.6.1 and SQL verified') diff --git a/scripts/testdata/published/release_test.go b/scripts/testdata/published/release_test.go new file mode 100644 index 0000000..aa40e15 --- /dev/null +++ b/scripts/testdata/published/release_test.go @@ -0,0 +1,87 @@ +package consumer_test + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + postgres "github.com/fergusstrange/embedded-postgres/v2" +) + +func TestPublishedDefault(t *testing.T) { + root := os.Getenv("EP_CONSUMER_EVIDENCE") + if root == "" { + t.Fatal("missing evidence directory") + } + cache, work := filepath.Join(root, "cache"), filepath.Join(root, "work") + if _, err := os.Stat(cache); !os.IsNotExist(err) { + t.Fatal("expected a fresh cache", err) + } + if postgres.V18 != "18.6.1" || postgres.V17 != "17.11.1" || postgres.V16 != "16.15.1" || postgres.V15 != "15.19.1" { + t.Fatal("incorrect published pins") + } + cfg := postgres.DefaultConfig().Port(0).Storage(postgres.Storage{CacheDir: cache, WorkDir: work}) + for _, offline := range []bool{false, true} { + c := cfg + if offline { + c = c.Provider(postgres.DownloadProvider{CacheDir: cache, Offline: true}) + } + pg := postgres.NewDatabase(c) + if err := pg.Start(); err != nil { + t.Fatal(err, pg.Logs()) + } + t.Cleanup(func() { _ = pg.Close() }) + var psql string + err := filepath.WalkDir(cache, func(path string, d os.DirEntry, e error) error { + if e != nil { + return e + } + if !d.IsDir() && (d.Name() == "psql" || d.Name() == "psql.exe") && strings.Contains(path, "postgresql-18.6.1-") { + psql = path + } + return nil + }) + if err != nil || psql == "" { + t.Fatal("corrected release cache not found", err) + } + if runtime.GOOS == "windows" && !strings.Contains(psql, "x86_64-pc-windows-msvc") { + t.Fatal("Windows shipping path did not select x64 PostgreSQL", psql) + } + out, err := exec.Command(psql, pg.ConnectionURL(), "-Atc", "SELECT 42; SHOW server_version_num;").CombinedOutput() + if err != nil || strings.TrimSpace(strings.ReplaceAll(string(out), "\r\n", "\n")) != "42\n180006" { + t.Fatal("query", string(out), err) + } + port := pg.GetPort() + if err := pg.Close(); err != nil { + t.Fatal(err) + } + if err := pg.Err(); err != nil { + t.Fatal(err) + } + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second) + if err == nil { + conn.Close() + t.Fatal("database port survived cleanup") + } + entries, err := os.ReadDir(work) + if err != nil || len(entries) != 0 { + t.Fatal("workspace survived cleanup", entries, err) + } + t.Logf("Published module default PostgreSQL 18.6.1: SQL, offline=%v, cleanup passed", offline) + } + suffix := "" + if runtime.GOOS == "windows" { + suffix = ".exe" + } + tag := os.Getenv("EP_CONSUMER_TAG") + companion := filepath.Join(cache, tag+"-embedded-postgres_"+runtime.GOOS+"_"+runtime.GOARCH+suffix) + if _, err := os.Stat(companion); err != nil { + t.Fatal("automatic matching supervisor missing", err) + } +}