782 lines
26 KiB
Go
782 lines
26 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
const powerShellEncodedCommandPrefix = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand "
|
|
|
|
func TestVersion(t *testing.T) {
|
|
var out bytes.Buffer
|
|
app := App{Stdout: &out, Stderr: &bytes.Buffer{}}
|
|
if err := app.Run(context.Background(), []string{"--version"}); err != nil {
|
|
t.Fatalf("Run(--version) error: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(out.String()); got != version {
|
|
t.Fatalf("Run(--version)=%q want %q", got, version)
|
|
}
|
|
}
|
|
|
|
func TestRemoteCommandQuotesWorkdirEnvAndArgs(t *testing.T) {
|
|
got := remoteCommand("/work/crabbox/cbx_1/openclaw", map[string]string{"NODE_OPTIONS": "--max-old-space-size=8192"}, []string{"pnpm", "check:changed"})
|
|
for _, want := range []string{
|
|
"cd '/work/crabbox/cbx_1/openclaw'",
|
|
"NODE_OPTIONS='--max-old-space-size=8192'",
|
|
"bash -lc",
|
|
"'exec \"$@\"' bash 'pnpm' 'check:changed'",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("remoteCommand() missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteShellCommandRunsScript(t *testing.T) {
|
|
got := remoteShellCommand("/work/crabbox/cbx_1/repo", map[string]string{"CI": "1"}, "pnpm install && pnpm test")
|
|
for _, want := range []string{
|
|
"cd '/work/crabbox/cbx_1/repo'",
|
|
"CI='1'",
|
|
"bash -lc 'pnpm install && pnpm test'",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("remoteShellCommand() missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShellScriptFromArgvPreservesArgumentsAroundOperators(t *testing.T) {
|
|
got := shellScriptFromArgv([]string{"NODE_OPTIONS=--max old", "printf", "%s\n", "a b", "&&", "echo", "done"})
|
|
want := "NODE_OPTIONS='--max old' 'printf' '%s\n' 'a b' && 'echo' 'done'"
|
|
if got != want {
|
|
t.Fatalf("shellScriptFromArgv()=%q want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRemoteCommandSourcesActionsEnvFile(t *testing.T) {
|
|
got := remoteCommandWithEnvFile("/home/runner/work/repo/repo", map[string]string{"CI": "1"}, "/home/runner/.crabbox/actions/cbx-123.env.sh", []string{"pnpm", "test"})
|
|
for _, want := range []string{
|
|
"cd '/home/runner/work/repo/repo'",
|
|
"if [ -f '/home/runner/.crabbox/actions/cbx-123.env.sh' ]; then . '/home/runner/.crabbox/actions/cbx-123.env.sh'; fi",
|
|
"CI='1'",
|
|
"'exec \"$@\"' bash 'pnpm' 'test'",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("remoteCommandWithEnvFile() missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWindowsNativeRemoteCommandUsesPowerShell(t *testing.T) {
|
|
got := windowsRemoteCommandWithEnvFile(`C:\crabbox\cbx\repo`, map[string]string{"CI": "1"}, "", []string{"pwsh", "-NoProfile", "-Command", "echo ok"})
|
|
if !strings.HasPrefix(got, powerShellEncodedCommandPrefix) {
|
|
t.Fatalf("windows command should use encoded powershell: %q", got)
|
|
}
|
|
decoded := decodePowerShellCommand(t, got)
|
|
if !strings.HasPrefix(decoded, "$ProgressPreference = \"SilentlyContinue\"\n") {
|
|
t.Fatalf("windows command should suppress PowerShell progress records: %q", decoded)
|
|
}
|
|
}
|
|
|
|
func TestWindowsNativeRemoteShellRunsScriptDirectly(t *testing.T) {
|
|
got := windowsRemoteShellCommandWithEnvFile(`C:\crabbox\cbx\repo`, map[string]string{"CRABBOX_BROWSER": "1"}, "", `Write-Output ("COMPUTER=" + $env:COMPUTERNAME)`)
|
|
decoded := decodePowerShellCommand(t, got)
|
|
for _, want := range []string{
|
|
`Set-Location -LiteralPath 'C:\crabbox\cbx\repo'`,
|
|
`$env:CRABBOX_BROWSER = '1'`,
|
|
`Write-Output ("COMPUTER=" + $env:COMPUTERNAME)`,
|
|
} {
|
|
if !strings.Contains(decoded, want) {
|
|
t.Fatalf("windows shell command missing %q in %q", want, decoded)
|
|
}
|
|
}
|
|
if strings.Contains(decoded, `& 'powershell.exe'`) {
|
|
t.Fatalf("windows shell command should not spawn nested powershell: %q", decoded)
|
|
}
|
|
}
|
|
|
|
func decodePowerShellCommand(t *testing.T, command string) string {
|
|
t.Helper()
|
|
const prefix = powerShellEncodedCommandPrefix
|
|
if !strings.HasPrefix(command, prefix) {
|
|
t.Fatalf("command missing encoded powershell prefix: %q", command)
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(command, prefix))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(raw)%2 != 0 {
|
|
t.Fatalf("odd UTF-16LE byte length: %d", len(raw))
|
|
}
|
|
units := make([]uint16, len(raw)/2)
|
|
for i := range units {
|
|
units[i] = uint16(raw[i*2]) | uint16(raw[i*2+1])<<8
|
|
}
|
|
return string(utf16.Decode(units))
|
|
}
|
|
|
|
func TestWSL2WrapsRemoteCommand(t *testing.T) {
|
|
target := SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2}
|
|
remote := `printf "ok\n"; echo 'quoted'`
|
|
got := wrapRemoteForTarget(target, remote)
|
|
if !strings.HasPrefix(got, powerShellEncodedCommandPrefix) {
|
|
t.Fatalf("WSL2 command should use encoded PowerShell: %q", got)
|
|
}
|
|
decoded := decodePowerShellCommand(t, got)
|
|
for _, want := range []string{
|
|
`[Convert]::FromBase64String("`,
|
|
`[System.IO.File]::WriteAllBytes($path, $scriptBytes)`,
|
|
`& wsl.exe --exec bash $wslPath`,
|
|
`$code = $LASTEXITCODE`,
|
|
`exit $code`,
|
|
} {
|
|
if !strings.Contains(decoded, want) {
|
|
t.Fatalf("WSL2 command missing %q in %q", want, decoded)
|
|
}
|
|
}
|
|
start := strings.Index(decoded, `[Convert]::FromBase64String("`)
|
|
if start < 0 {
|
|
t.Fatalf("WSL2 command missing base64 payload: %q", decoded)
|
|
}
|
|
start += len(`[Convert]::FromBase64String("`)
|
|
end := strings.Index(decoded[start:], `")`)
|
|
if end < 0 {
|
|
t.Fatalf("WSL2 command has unterminated base64 payload: %q", decoded)
|
|
}
|
|
payload := decoded[start : start+end]
|
|
raw, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
t.Fatalf("WSL2 command payload is not base64: %v", err)
|
|
}
|
|
if string(raw) != remote {
|
|
t.Fatalf("WSL2 command payload=%q want %q", string(raw), remote)
|
|
}
|
|
}
|
|
|
|
func TestStaticLeaseBypassesCoordinatorAndUsesTargetServerType(t *testing.T) {
|
|
cfg := baseConfig()
|
|
cfg.Provider = "ssh"
|
|
cfg.Coordinator = "https://broker.example.test"
|
|
cfg.TargetOS = targetMacOS
|
|
cfg.Static.Host = "mac.local"
|
|
cfg.ServerType = "c7a.48xlarge"
|
|
cfg.ServerTypeExplicit = false
|
|
coord, ok, err := newTargetCoordinatorClient(cfg)
|
|
if err != nil || ok || coord != nil {
|
|
t.Fatalf("static coordinator=%v ok=%t err=%v", coord, ok, err)
|
|
}
|
|
server, _, _, err := staticLease(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if server.ServerType.Name != "macos" || server.Labels["server_type"] != "macos" {
|
|
t.Fatalf("static type=%q label=%q", server.ServerType.Name, server.Labels["server_type"])
|
|
}
|
|
}
|
|
|
|
func TestShouldUseShellForControlOperators(t *testing.T) {
|
|
if !shouldUseShell([]string{"pnpm", "install", "&&", "pnpm", "test"}) {
|
|
t.Fatal("expected shell mode for && token")
|
|
}
|
|
if !shouldUseShell([]string{"pnpm install && pnpm test"}) {
|
|
t.Fatal("expected shell mode for single shell string")
|
|
}
|
|
if !shouldUseShell([]string{"pnpm test"}) {
|
|
t.Fatal("expected shell mode for single command string with spaces")
|
|
}
|
|
if shouldUseShell([]string{"pnpm", "test"}) {
|
|
t.Fatal("plain argv command should not use shell")
|
|
}
|
|
}
|
|
|
|
func TestEnvAllowlist(t *testing.T) {
|
|
if !envAllowed("CUSTOM_TOKEN", []string{"CI", "CUSTOM_*"}) {
|
|
t.Fatal("wildcard env allow failed")
|
|
}
|
|
if envAllowed("PROJECT_TOKEN", []string{"CI", "NODE_OPTIONS"}) {
|
|
t.Fatal("unexpected env forwarding without config")
|
|
}
|
|
}
|
|
|
|
func TestSSHArgsIncludeReliabilityOptions(t *testing.T) {
|
|
t.Setenv("HOME", "/tmp/crabbox-home")
|
|
got := strings.Join(sshArgs(SSHTarget{
|
|
User: "crabbox",
|
|
Host: "203.0.113.10",
|
|
Key: "/tmp/crabbox-lease/id_ed25519",
|
|
Port: "2222",
|
|
}, "true"), "\n")
|
|
for _, want := range []string{
|
|
"ConnectTimeout=10",
|
|
"ConnectionAttempts=3",
|
|
"IdentitiesOnly=yes",
|
|
"ServerAliveInterval=15",
|
|
"ServerAliveCountMax=2",
|
|
"ControlMaster=auto",
|
|
"ControlPersist=60s",
|
|
"ControlPath=",
|
|
"crabbox-ssh-",
|
|
"-%C",
|
|
`UserKnownHostsFile=/tmp/crabbox-lease/known_hosts`,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("sshArgs() missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHArgsAllowTokenUserWithoutIdentityFile(t *testing.T) {
|
|
t.Setenv("HOME", "/tmp/crabbox-home")
|
|
got := strings.Join(sshArgs(SSHTarget{
|
|
User: "tok_live_secret",
|
|
Host: "ssh.app.daytona.io",
|
|
Port: "22",
|
|
}, "true"), "\n")
|
|
for _, unwanted := range []string{"-i\n", "IdentitiesOnly=yes"} {
|
|
if strings.Contains(got, unwanted) {
|
|
t.Fatalf("sshArgs() should omit key-only option %q when target key is empty: %q", unwanted, got)
|
|
}
|
|
}
|
|
if !strings.Contains(got, "tok_live_secret@ssh.app.daytona.io") {
|
|
t.Fatalf("sshArgs() missing token user target: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSSHArgsAuthSecretDisablesControlMaster(t *testing.T) {
|
|
t.Setenv("HOME", "/tmp/crabbox-home")
|
|
got := strings.Join(sshArgs(SSHTarget{
|
|
User: "tok_live_secret",
|
|
Host: "ssh.app.daytona.io",
|
|
Port: "22",
|
|
AuthSecret: true,
|
|
}, "true"), "\n")
|
|
for _, unwanted := range []string{"ControlMaster=auto", "ControlPersist=", "ControlPath="} {
|
|
if strings.Contains(got, unwanted) {
|
|
t.Fatalf("sshArgs() should omit mux option %q for secret auth target: %q", unwanted, got)
|
|
}
|
|
}
|
|
if !strings.Contains(got, "ControlMaster=no") {
|
|
t.Fatalf("sshArgs() missing ControlMaster=no for secret auth target: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestShouldRetrySSHPortOnlyForTransportExit(t *testing.T) {
|
|
if !shouldRetrySSHPort(exec.Command("sh", "-c", "exit 255").Run()) {
|
|
t.Fatal("ssh transport exit 255 should retry fallback ports")
|
|
}
|
|
if shouldRetrySSHPort(exec.Command("sh", "-c", "exit 7").Run()) {
|
|
t.Fatal("remote command failure should not retry fallback ports")
|
|
}
|
|
}
|
|
|
|
func TestSSHCommandLineRedactsSecretAuthUser(t *testing.T) {
|
|
target := SSHTarget{
|
|
User: "tok_live_secret",
|
|
Host: "ssh.app.daytona.io",
|
|
Port: "22",
|
|
AuthSecret: true,
|
|
}
|
|
redacted := sshCommandLine(target, true)
|
|
if strings.Contains(redacted, target.User) {
|
|
t.Fatalf("redacted command leaked token: %q", redacted)
|
|
}
|
|
if !strings.Contains(redacted, "<token>@ssh.app.daytona.io") {
|
|
t.Fatalf("redacted command missing placeholder user: %q", redacted)
|
|
}
|
|
full := sshCommandLine(target, false)
|
|
if !strings.Contains(full, target.User+"@ssh.app.daytona.io") {
|
|
t.Fatalf("full command missing token user: %q", full)
|
|
}
|
|
}
|
|
|
|
func TestSSHTransportProbeDoesNotRequireCrabboxReady(t *testing.T) {
|
|
got := sshTransportProbeCommand(SSHTarget{Host: "100.64.0.10", Port: "2222"})
|
|
if strings.Contains(got, "crabbox-ready") || strings.Contains(got, "git --version") || strings.Contains(got, "/work/crabbox") {
|
|
t.Fatalf("transport probe should not run readiness checks: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSSHReadyCommandUsesAbsoluteCrabboxReadyPath(t *testing.T) {
|
|
got := sshReadyCommand(SSHTarget{})
|
|
if !strings.Contains(got, "/usr/local/bin/crabbox-ready >/tmp/crabbox-ready.log") {
|
|
t.Fatalf("sshReadyCommand() should use absolute crabbox-ready path: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSSHArgsQuoteKnownHostsPathWithSpaces(t *testing.T) {
|
|
got := strings.Join(sshArgs(SSHTarget{
|
|
User: "crabbox",
|
|
Host: "203.0.113.10",
|
|
Key: "/tmp/Application Support/crabbox/id_ed25519",
|
|
Port: "2222",
|
|
}, "true"), "\n")
|
|
if !strings.Contains(got, `UserKnownHostsFile="/tmp/Application Support/crabbox/known_hosts"`) {
|
|
t.Fatalf("sshArgs() should quote known_hosts path with spaces: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSSHControlPathIsScopedByKey(t *testing.T) {
|
|
left := sshControlPath(SSHTarget{User: "crabbox", Key: "/tmp/lease-a/id_ed25519"})
|
|
right := sshControlPath(SSHTarget{User: "crabbox", Key: "/tmp/lease-b/id_ed25519"})
|
|
if left == right {
|
|
t.Fatalf("control paths should differ for different lease keys: %q", left)
|
|
}
|
|
if !strings.HasPrefix(filepath.Base(left), "crabbox-ssh-") || !strings.HasSuffix(left, "-%C") {
|
|
t.Fatalf("unexpected control path %q", left)
|
|
}
|
|
}
|
|
|
|
func TestSSHWaitProgressIncludesElapsedAndRemaining(t *testing.T) {
|
|
got := sshWaitProgressMessage(
|
|
&SSHTarget{Host: "203.0.113.10", Port: "2222"},
|
|
"bootstrap",
|
|
"2222",
|
|
"2222",
|
|
95*time.Second,
|
|
10*time.Minute,
|
|
)
|
|
for _, want := range []string{
|
|
"waiting for 203.0.113.10:2222 bootstrap ready-check...",
|
|
"elapsed=1m35s",
|
|
"remaining=10m0s",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("progress message missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHWaitProgressDistinguishesAuthFromReadiness(t *testing.T) {
|
|
target := &SSHTarget{Host: "203.0.113.10", Port: "2222"}
|
|
got := sshWaitProgressMessage(target, "bootstrap", "2222", "", 5*time.Second, time.Minute)
|
|
if !strings.Contains(got, "bootstrap ssh-auth") {
|
|
t.Fatalf("TCP-only progress should report ssh-auth stage: %q", got)
|
|
}
|
|
got = sshWaitProgressMessage(target, "bootstrap", "2222", "2222", 5*time.Second, time.Minute)
|
|
if !strings.Contains(got, "bootstrap ready-check") {
|
|
t.Fatalf("SSH transport progress should report ready-check stage: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSSHPortCandidatesPreferConfiguredPortWithFallback(t *testing.T) {
|
|
tests := map[string][]string{
|
|
"": {"22"},
|
|
"22": {"22"},
|
|
"2222": {"2222", "22"},
|
|
}
|
|
for in, want := range tests {
|
|
got := sshPortCandidates(in, nil)
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("sshPortCandidates(%q)=%v want %v", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHPortCandidatesUseConfiguredFallbacks(t *testing.T) {
|
|
got := sshPortCandidates("2222", []string{"2022", "22", "2222", ""})
|
|
want := []string{"2222", "2022", "22"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("sshPortCandidates()=%v want %v", got, want)
|
|
}
|
|
if got := sshPortCandidates("2222", []string{}); strings.Join(got, ",") != "2222" {
|
|
t.Fatalf("sshPortCandidates(disabled fallback)=%v want [2222]", got)
|
|
}
|
|
}
|
|
|
|
func TestRemotePruneSyncManifestDeletesOnlyManagedPaths(t *testing.T) {
|
|
got := remotePruneSyncManifest("/work/repo")
|
|
for _, want := range []string{
|
|
"sync-deleted.new",
|
|
"manifest_removed_paths",
|
|
"python3 -",
|
|
"rm -f --",
|
|
"rmdir --",
|
|
"sync-manifest.new",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("remotePruneSyncManifest missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemotePruneSyncManifestUsesDeletedListBeforeOldManifestDiff(t *testing.T) {
|
|
got := remotePruneSyncManifest("/work/repo")
|
|
deletedIndex := strings.Index(got, `delete_paths < "$deleted"`)
|
|
oldIndex := strings.Index(got, "manifest_removed_paths | delete_paths")
|
|
if deletedIndex < 0 || oldIndex < 0 || deletedIndex > oldIndex {
|
|
t.Fatalf("deleted list should be applied before old manifest diff: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRemotePruneSyncManifestPrunesManagedFiles(t *testing.T) {
|
|
workdir := t.TempDir()
|
|
mustWriteTestFile(t, filepath.Join(workdir, ".crabbox", "sync-manifest"), "keep.txt\x00kept-dir/keep.txt\x00stale.txt\x00old-empty/remove.txt\x00non-empty/remove.txt\x00")
|
|
mustWriteTestFile(t, filepath.Join(workdir, ".crabbox", "sync-manifest.new"), "keep.txt\x00kept-dir/keep.txt\x00")
|
|
mustWriteTestFile(t, filepath.Join(workdir, ".crabbox", "sync-deleted.new"), "explicit-delete.txt\x00../outside.txt\x00/absolute.txt\x00")
|
|
for _, rel := range []string{
|
|
"keep.txt",
|
|
"kept-dir/keep.txt",
|
|
"stale.txt",
|
|
"old-empty/remove.txt",
|
|
"non-empty/remove.txt",
|
|
"non-empty/unmanaged.txt",
|
|
"explicit-delete.txt",
|
|
"unmanaged.txt",
|
|
} {
|
|
mustWriteTestFile(t, filepath.Join(workdir, filepath.FromSlash(rel)), rel)
|
|
}
|
|
outside := filepath.Join(filepath.Dir(workdir), "outside.txt")
|
|
mustWriteTestFile(t, outside, "outside")
|
|
|
|
cmd := exec.Command("bash", "-lc", remotePruneSyncManifest(workdir))
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("remote prune failed: %v\n%s", err, out)
|
|
}
|
|
|
|
for _, rel := range []string{"keep.txt", "kept-dir/keep.txt", "non-empty/unmanaged.txt", "unmanaged.txt"} {
|
|
if _, err := os.Stat(filepath.Join(workdir, filepath.FromSlash(rel))); err != nil {
|
|
t.Fatalf("%s should survive prune: %v", rel, err)
|
|
}
|
|
}
|
|
for _, rel := range []string{"stale.txt", "old-empty/remove.txt", "non-empty/remove.txt", "explicit-delete.txt"} {
|
|
if _, err := os.Stat(filepath.Join(workdir, filepath.FromSlash(rel))); !os.IsNotExist(err) {
|
|
t.Fatalf("%s should be pruned, stat err=%v", rel, err)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workdir, "old-empty")); !os.IsNotExist(err) {
|
|
t.Fatalf("empty parent dir should be pruned, stat err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workdir, "non-empty")); err != nil {
|
|
t.Fatalf("non-empty parent dir should survive: %v", err)
|
|
}
|
|
if _, err := os.Stat(outside); err != nil {
|
|
t.Fatalf("unsafe deleted path should not escape workdir: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoteApplySyncManifestOnlyCommitsManifest(t *testing.T) {
|
|
got := remoteApplySyncManifest("/work/repo")
|
|
if strings.Contains(got, "manifest_removed_paths") || strings.Contains(got, "delete_paths") {
|
|
t.Fatalf("remoteApplySyncManifest should not delete after rsync: %q", got)
|
|
}
|
|
if !strings.Contains(got, "mv \"$new\" \"$meta_dir/sync-manifest\"") {
|
|
t.Fatalf("remoteApplySyncManifest should commit new manifest: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoteFinalizeSyncCommitsMetadataInOneCommand(t *testing.T) {
|
|
workdir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(workdir, ".git"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
metaDir := filepath.Join(workdir, ".git", "crabbox")
|
|
if err := os.MkdirAll(metaDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(metaDir, "sync-manifest.new"), []byte("tracked.txt\x00"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(metaDir, "sync-deleted.new"), []byte("deleted.txt\x00"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := exec.Command("bash", "-lc", remoteFinalizeSync(workdir, remoteSyncFinalizeOptions{
|
|
BaseRef: "main",
|
|
BaseSHA: "abc123",
|
|
Fingerprint: "fp123",
|
|
}))
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("remote finalize failed: %v\n%s", err, out)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(metaDir, "sync-deleted.new")); !os.IsNotExist(err) {
|
|
t.Fatalf("deleted manifest should be removed, stat err=%v", err)
|
|
}
|
|
manifest, err := os.ReadFile(filepath.Join(metaDir, "sync-manifest"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(manifest) != "tracked.txt\x00" {
|
|
t.Fatalf("unexpected manifest: %q", manifest)
|
|
}
|
|
marker, err := os.ReadFile(filepath.Join(metaDir, "git-hydrate-base"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(marker) != "main abc123\n" {
|
|
t.Fatalf("unexpected hydrate marker: %q", marker)
|
|
}
|
|
fingerprint, err := os.ReadFile(filepath.Join(metaDir, "sync-fingerprint"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(fingerprint) != "fp123" {
|
|
t.Fatalf("unexpected fingerprint: %q", fingerprint)
|
|
}
|
|
}
|
|
|
|
func TestRemoteGitSeedRemovesFailedCheckout(t *testing.T) {
|
|
got := remoteGitSeed("/work/repo", "https://github.com/openclaw/crabbox.git", "missing-sha")
|
|
for _, want := range []string{
|
|
"if (cd \"$tmp\"",
|
|
"git checkout --quiet 'missing-sha' || git checkout --quiet FETCH_HEAD",
|
|
"else rm -rf \"$tmp\"; fi",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("remoteGitSeed missing %q in %q", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "git checkout --quiet FETCH_HEAD || true") {
|
|
t.Fatalf("remoteGitSeed should not keep failed checkouts: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoteGitHydrateStatusUsesMarkerAndRemoteBase(t *testing.T) {
|
|
got := remoteGitHydrateStatus("/work/repo", "main", "abc123")
|
|
for _, want := range []string{
|
|
"git-hydrate-base",
|
|
"marker base current",
|
|
"remote base current",
|
|
"remote base contains local",
|
|
"merge-base --is-ancestor",
|
|
"refs/remotes/origin/main",
|
|
"abc123",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("remoteGitHydrateStatus missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteWriteSyncManifestNew(t *testing.T) {
|
|
got := remoteWriteSyncManifestNew("/work/repo")
|
|
if !strings.Contains(got, "cat > \"$meta_dir/sync-manifest.new\"") {
|
|
t.Fatalf("unexpected manifest write command: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoteWriteSyncDeletedNew(t *testing.T) {
|
|
got := remoteWriteSyncDeletedNew("/work/repo")
|
|
if !strings.Contains(got, "cat > \"$meta_dir/sync-deleted.new\"") {
|
|
t.Fatalf("unexpected deleted manifest write command: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoteWriteSyncManifestsNew(t *testing.T) {
|
|
workdir := t.TempDir()
|
|
manifest := "keep.txt\x00"
|
|
deleted := "old.txt\x00"
|
|
input := fmt.Sprintf("%d\n", len(manifest)) + manifest + deleted
|
|
cmd := exec.Command("bash", "-lc", remoteWriteSyncManifestsNew(workdir))
|
|
cmd.Stdin = strings.NewReader(input)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("write manifests failed: %v\n%s", err, out)
|
|
}
|
|
metaDir := filepath.Join(workdir, ".crabbox")
|
|
gotManifest, err := os.ReadFile(filepath.Join(metaDir, "sync-manifest.new"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(gotManifest) != manifest {
|
|
t.Fatalf("unexpected manifest: %q", gotManifest)
|
|
}
|
|
gotDeleted, err := os.ReadFile(filepath.Join(metaDir, "sync-deleted.new"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(gotDeleted) != deleted {
|
|
t.Fatalf("unexpected deleted manifest: %q", gotDeleted)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSyncMetadataUsesGitDirForGitWorktree(t *testing.T) {
|
|
workdir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(workdir, ".git"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := exec.Command("bash", "-lc", remoteWriteSyncManifestNew(workdir))
|
|
cmd.Stdin = strings.NewReader("tracked.txt\x00")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("write manifest failed: %v\n%s", err, out)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workdir, ".git", "crabbox", "sync-manifest.new")); err != nil {
|
|
t.Fatalf("manifest should be written under .git/crabbox: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workdir, ".crabbox")); !os.IsNotExist(err) {
|
|
t.Fatalf("worktree .crabbox should not be created, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestIsBootstrapWaitError(t *testing.T) {
|
|
if !isBootstrapWaitError(exit(5, "timed out waiting for SSH on 203.0.113.10 during bootstrap")) {
|
|
t.Fatal("expected SSH timeout to be retryable")
|
|
}
|
|
if isBootstrapWaitError(exit(6, "rsync failed")) {
|
|
t.Fatal("sync failure must not be treated as retryable bootstrap")
|
|
}
|
|
}
|
|
|
|
func TestAcquireAttemptsRetriesWarmupBootstrapFailures(t *testing.T) {
|
|
if got := acquireAttempts(true); got != 2 {
|
|
t.Fatalf("warmup keep=true attempts=%d want 2", got)
|
|
}
|
|
if got := acquireAttempts(false); got != 2 {
|
|
t.Fatalf("one-shot attempts=%d want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestBootstrapWaitTimeoutExtendsForDesktopBrowser(t *testing.T) {
|
|
if got := bootstrapWaitTimeout(Config{}); got != 20*time.Minute {
|
|
t.Fatalf("plain bootstrap timeout=%s want 20m", got)
|
|
}
|
|
if got := bootstrapWaitTimeout(Config{Desktop: true}); got != 45*time.Minute {
|
|
t.Fatalf("desktop bootstrap timeout=%s want 45m", got)
|
|
}
|
|
if got := bootstrapWaitTimeout(Config{Browser: true}); got != 45*time.Minute {
|
|
t.Fatalf("browser bootstrap timeout=%s want 45m", got)
|
|
}
|
|
}
|
|
|
|
func TestServerProviderKeyUsesOnlyCrabboxLeaseKeys(t *testing.T) {
|
|
server := Server{Labels: map[string]string{"lease": "cbx_123456abcdef"}}
|
|
if got := serverProviderKey(server); got != "crabbox-cbx-123456abcdef" {
|
|
t.Fatalf("serverProviderKey()=%q", got)
|
|
}
|
|
if !validCrabboxProviderKey("crabbox-cbx-123456abcdef") {
|
|
t.Fatal("expected per-lease provider key to be valid")
|
|
}
|
|
if validCrabboxProviderKey("crabbox-steipete") {
|
|
t.Fatal("shared key must not be treated as per-lease cleanup key")
|
|
}
|
|
}
|
|
|
|
func TestMoveStoredTestboxKeyHandlesCoordinatorRenamedLease(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
oldPath, err := testboxKeyPath("cbx_111111111111")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(oldPath), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(oldPath, []byte("key"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(oldPath+".pub", []byte("pub"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := moveStoredTestboxKey("cbx_111111111111", "cbx_222222222222"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newPath, err := testboxKeyPath("cbx_222222222222")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(newPath); err != nil {
|
|
t.Fatalf("moved key missing: %v", err)
|
|
}
|
|
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
|
t.Fatalf("old key still exists or unexpected stat error: %v", err)
|
|
}
|
|
}
|
|
|
|
func mustWriteTestFile(t *testing.T, path, value string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(value), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestServerTypeForClass(t *testing.T) {
|
|
tests := map[string]string{
|
|
"standard": "ccx33",
|
|
"fast": "ccx43",
|
|
"large": "ccx53",
|
|
"beast": "ccx63",
|
|
"ccx23": "ccx23",
|
|
}
|
|
for in, want := range tests {
|
|
if got := serverTypeForClass(in); got != want {
|
|
t.Fatalf("serverTypeForClass(%q)=%q want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAWSServerTypeForClass(t *testing.T) {
|
|
tests := map[string]string{
|
|
"standard": "c7a.8xlarge",
|
|
"fast": "c7a.16xlarge",
|
|
"large": "c7a.24xlarge",
|
|
"beast": "c7a.48xlarge",
|
|
"c8a.24xlarge": "c8a.24xlarge",
|
|
}
|
|
for in, want := range tests {
|
|
if got := serverTypeForProviderClass("aws", in); got != want {
|
|
t.Fatalf("serverTypeForProviderClass(%q)=%q want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAWSLaunchCandidatesAddsPolicyFallbackUnlessExact(t *testing.T) {
|
|
got := awsLaunchCandidates(Config{Provider: "aws", Class: "beast", ServerType: "c7a.48xlarge"})
|
|
if got[len(got)-1] != "t3.small" {
|
|
t.Fatalf("last fallback=%q want t3.small in %v", got[len(got)-1], got)
|
|
}
|
|
wsl2 := awsLaunchCandidates(Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2, Class: "standard", ServerType: "m8i.large"})
|
|
for _, candidate := range wsl2 {
|
|
if strings.HasPrefix(candidate, "t3.") || strings.HasPrefix(candidate, "m7") {
|
|
t.Fatalf("WSL2 candidate %q does not support nested virtualization: %v", candidate, wsl2)
|
|
}
|
|
}
|
|
exact := awsLaunchCandidates(Config{Provider: "aws", Class: "beast", ServerType: "t3.small", ServerTypeExplicit: true})
|
|
if len(exact) != 1 || exact[0] != "t3.small" {
|
|
t.Fatalf("exact candidates=%v", exact)
|
|
}
|
|
}
|
|
|
|
func TestAWSRegionAndAvailabilityZoneCandidates(t *testing.T) {
|
|
cfg := Config{
|
|
AWSRegion: "eu-west-1",
|
|
Capacity: CapacityConfig{
|
|
Regions: []string{"us-east-1", "eu-west-1"},
|
|
AvailabilityZones: []string{"us-east-1a", "eu-west-1b"},
|
|
},
|
|
}
|
|
got := awsRegionCandidates(cfg, "eu-west-2")
|
|
want := []string{"eu-west-2", "eu-west-1", "us-east-1"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("awsRegionCandidates=%v want %v", got, want)
|
|
}
|
|
if zone := awsAvailabilityZoneForRegion(cfg, "eu-west-1"); zone != "eu-west-1b" {
|
|
t.Fatalf("awsAvailabilityZoneForRegion=%q want eu-west-1b", zone)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSyncSanityReportsDeletionSample(t *testing.T) {
|
|
got := remoteSyncSanity("/work/repo", false)
|
|
for _, want := range []string{
|
|
"remote sync sanity failed: $deletions tracked deletions",
|
|
`awk '/^ D|^D / { print " " substr($0,4) }'`,
|
|
"head -20",
|
|
"exit 66",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("remoteSyncSanity() missing %q in %q", want, got)
|
|
}
|
|
}
|
|
}
|