Compare commits
No commits in common. "maintainer/semaphore-provider-fix" and "main" have entirely different histories.
maintainer
...
main
@ -5,7 +5,6 @@
|
||||
### Added
|
||||
|
||||
- Added `provider: azure` for managed Azure Linux and native Windows SSH leases, including direct and brokered provisioning, shared Azure networking, SKU fallback, Azure docs, and cleanup support. Thanks @jwmoss.
|
||||
- Added `provider: semaphore` for direct Semaphore CI testbox leases over SSH. Thanks @loadez.
|
||||
|
||||
## 0.7.0 - 2026-05-07
|
||||
|
||||
|
||||
13
README.md
13
README.md
@ -12,7 +12,7 @@ Crabbox is an open-source remote testbox runner for maintainers and AI agents. L
|
||||
crabbox run -- pnpm test
|
||||
```
|
||||
|
||||
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud, AWS EC2, or Azure. Azure supports managed Linux and native Windows VMs. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, use `provider: semaphore` for Semaphore CI environments, or use `provider: ssh` for existing macOS and Windows targets.
|
||||
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud, AWS EC2, or Azure. Azure supports managed Linux and native Windows VMs. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, or use `provider: ssh` for existing macOS and Windows targets.
|
||||
|
||||
---
|
||||
|
||||
@ -77,7 +77,6 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
|
||||
- **Azure Linux and native Windows.** `provider: azure` provisions Linux and native Windows VMs in a configurable Azure subscription using `DefaultAzureCredential` in direct mode or service-principal secrets in the broker. Crabbox creates a shared resource group, vnet, subnet, and NSG on first use, then per-lease public IPs, NICs, and VMs. Linux uses cloud-init; Windows uses VM Agent Custom Script Extension to install OpenSSH/Git and configure the Crabbox user.
|
||||
- **macOS and Windows static hosts.** `provider: ssh` reuses existing machines; it does not create macOS or Windows Crabbox boxes. macOS and Windows WSL2 use the POSIX rsync path; native Windows uses PowerShell plus tar archive sync.
|
||||
- **Blacksmith Testbox wrapper.** Set `provider: blacksmith-testbox` to delegate warmup/run/list/status/stop to the Blacksmith CLI while Crabbox keeps local slugs, repo claims, timing summaries, config conventions, and portal visibility for active external runners.
|
||||
- **Semaphore CI testbox.** Set `provider: semaphore` to lease a Semaphore CI job as a testbox. Same environment as your real pipelines.
|
||||
- **Daytona and Islo sandboxes.** Set `provider: daytona` for Daytona SDK/toolbox execution from a snapshot with explicit SSH access when needed, or `provider: islo` for delegated Islo sandbox execution through the Islo Go SDK.
|
||||
- **Trusted AWS images.** Operators can create AMIs from active brokered AWS leases and promote a known-good image as the coordinator default.
|
||||
- **Cost guardrails.** Per-lease and monthly spend caps. Live pricing from EC2 Spot history or Hetzner server-type prices, with static fallbacks. `crabbox usage` summarizes spend by user, org, provider, and type.
|
||||
@ -199,16 +198,6 @@ islo:
|
||||
workdir: crabbox
|
||||
```
|
||||
|
||||
Optional Semaphore CI testbox:
|
||||
|
||||
```yaml
|
||||
provider: semaphore
|
||||
semaphore:
|
||||
host: myorg.semaphoreci.com
|
||||
token: ...
|
||||
project: my-app
|
||||
```
|
||||
|
||||
Optional static macOS or Windows target:
|
||||
|
||||
```yaml
|
||||
|
||||
@ -73,7 +73,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|semaphore|daytona
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -37,7 +37,7 @@ included.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug> lease to inspect; required for managed providers
|
||||
--provider hetzner|aws|azure|ssh|semaphore|daytona override the configured provider
|
||||
--provider hetzner|aws|azure|ssh|daytona override the configured provider
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host> static SSH host for provider=ssh
|
||||
|
||||
@ -35,7 +35,7 @@ use the normalized Crabbox lease view.
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -89,7 +89,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -38,7 +38,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|semaphore|daytona
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -15,7 +15,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|semaphore|daytona
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -26,7 +26,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -15,7 +15,7 @@ The argument accepts the stable `cbx_...` ID or an active friendly slug. In `bla
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -233,7 +233,7 @@ make sure the Dedicated Host is allocated in the selected AWS region.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|semaphore|daytona
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -77,7 +77,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -16,7 +16,6 @@ static SSH provider for existing machines.
|
||||
| [Hetzner](hetzner.md) | SSH lease | Linux | fast Linux capacity at low cost |
|
||||
| [Static SSH](ssh.md) | SSH lease | Linux, macOS, Windows | reusing an existing host |
|
||||
| [Blacksmith Testbox](blacksmith-testbox.md) | delegated run | Linux | existing Blacksmith Testbox workflows |
|
||||
| [Semaphore](semaphore.md) | SSH lease | Linux | Semaphore CI environments with project secrets and cache |
|
||||
| [Daytona](daytona.md) | hybrid delegated run + SSH | Linux | Daytona snapshot sandboxes |
|
||||
| [Islo](islo.md) | delegated run | Linux | Islo-owned sandbox execution |
|
||||
|
||||
@ -54,9 +53,6 @@ Delegated providers do not use the Crabbox coordinator:
|
||||
- Daytona uses Daytona API and SDK/toolbox APIs.
|
||||
- Islo uses the Islo API and SDK auth.
|
||||
|
||||
Semaphore is an SSH lease provider that provisions via the Semaphore REST API.
|
||||
It does not use the Crabbox coordinator.
|
||||
|
||||
## Feature Matrix
|
||||
|
||||
| Provider | `run` | `warmup` | `ssh` | VNC/code | Crabbox sync | Provider sync |
|
||||
@ -66,7 +62,6 @@ It does not use the Crabbox coordinator.
|
||||
| Hetzner | yes | yes | yes | Linux VNC/code | yes | no |
|
||||
| Static SSH | yes | resolves host | yes | host-dependent | yes | no |
|
||||
| Blacksmith Testbox | yes | yes | no | no | no | yes |
|
||||
| Semaphore | yes | yes | yes | no | yes | no |
|
||||
| Daytona | yes | yes | yes | no | archive via Daytona toolbox | no |
|
||||
| Islo | yes | yes | no | no | no | yes |
|
||||
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
# Provider: Semaphore
|
||||
|
||||
Read when:
|
||||
|
||||
- choosing `provider: semaphore`;
|
||||
- configuring Semaphore CI testboxes, API auth, machine types, or OS images;
|
||||
- changing `internal/providers/semaphore`.
|
||||
|
||||
Semaphore is an SSH lease provider that creates Semaphore CI jobs as testbox
|
||||
environments via the Semaphore REST API. Crabbox handles sync and command
|
||||
execution over SSH.
|
||||
|
||||
## When To Use
|
||||
|
||||
Use Semaphore when a repo already depends on Semaphore CI environments,
|
||||
project secrets, caches, or machine images and you want a Crabbox lease that
|
||||
matches that CI context. Use AWS, Azure, Hetzner, or Static SSH when the box
|
||||
should be independent managed cloud capacity, or when VNC/desktop/code
|
||||
workflows are required.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider semaphore --semaphore-host myorg.semaphoreci.com --semaphore-project my-app
|
||||
crabbox run --provider semaphore -- pnpm test
|
||||
crabbox ssh --provider semaphore --id blue-lobster
|
||||
crabbox status --provider semaphore --id blue-lobster
|
||||
crabbox stop --provider semaphore blue-lobster
|
||||
```
|
||||
|
||||
## Backend kind
|
||||
|
||||
SSH lease. Provisions a standalone Semaphore job, retrieves SSH credentials via
|
||||
the debug SSH key API, returns a standard `LeaseTarget`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
provider: semaphore
|
||||
semaphore:
|
||||
host: myorg.semaphoreci.com # required
|
||||
token: ... # required
|
||||
project: my-app # required
|
||||
machine: f1-standard-2 # optional, default: f1-standard-2
|
||||
osImage: ubuntu2204 # optional, default: ubuntu2204
|
||||
idleTimeout: 30m # optional, default: 30m
|
||||
```
|
||||
|
||||
Flags: `--semaphore-host`, `--semaphore-token`, `--semaphore-project`,
|
||||
`--semaphore-machine`, `--semaphore-os-image`, `--semaphore-idle-timeout`.
|
||||
|
||||
Environment variables:
|
||||
|
||||
```text
|
||||
CRABBOX_SEMAPHORE_HOST
|
||||
CRABBOX_SEMAPHORE_TOKEN
|
||||
CRABBOX_SEMAPHORE_PROJECT
|
||||
CRABBOX_SEMAPHORE_MACHINE
|
||||
CRABBOX_SEMAPHORE_OS_IMAGE
|
||||
CRABBOX_SEMAPHORE_IDLE_TIMEOUT
|
||||
SEMAPHORE_HOST
|
||||
SEMAPHORE_API_TOKEN
|
||||
SEMAPHORE_PROJECT
|
||||
```
|
||||
|
||||
Token: `https://<host>/me/api-tokens`
|
||||
|
||||
Machine types: see [Semaphore docs](https://docs.semaphore.io/reference/machine-types).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. `POST /api/v1alpha/jobs` — create job with keepalive script
|
||||
2. Poll `GET /api/v1alpha/jobs/:id` until `RUNNING`
|
||||
3. `GET /api/v1alpha/jobs/:id/debug_ssh_key` — retrieve SSH key
|
||||
4. Crabbox syncs + runs over SSH
|
||||
5. `POST /api/v1alpha/jobs/:id/stop` — release
|
||||
|
||||
## Limitations
|
||||
|
||||
- Linux only.
|
||||
- No coordinator integration.
|
||||
- No VNC/desktop.
|
||||
@ -47,20 +47,18 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
`internal/providers/azure`,
|
||||
`internal/providers/ssh`, `internal/providers/blacksmith`,
|
||||
`internal/providers/daytona`, `internal/providers/islo`,
|
||||
`internal/providers/semaphore`,
|
||||
`internal/providers/all`
|
||||
- Built-in provider backend implementations:
|
||||
`internal/providers/aws`, `internal/providers/azure`,
|
||||
`internal/providers/hetzner`,
|
||||
`internal/providers/ssh`, `internal/providers/blacksmith`,
|
||||
`internal/providers/daytona`, `internal/providers/islo`,
|
||||
`internal/providers/semaphore`,
|
||||
plus shared helpers in `internal/providers/shared`
|
||||
- Worker Hetzner provider: `worker/src/hetzner.ts`
|
||||
- Worker AWS EC2 provider: `worker/src/aws.ts`
|
||||
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
|
||||
- Provider feature docs: `docs/features/aws.md`, `docs/features/azure.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
|
||||
- Provider reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/azure.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/islo.md`, `docs/providers/semaphore.md`
|
||||
- Provider reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/azure.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/islo.md`
|
||||
- Provider/backend authoring guide: `docs/provider-backends.md`
|
||||
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
|
||||
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
|
||||
|
||||
@ -63,7 +63,6 @@ type Config struct {
|
||||
Blacksmith BlacksmithConfig
|
||||
Daytona DaytonaConfig
|
||||
Islo IsloConfig
|
||||
Semaphore SemaphoreConfig
|
||||
Tailscale TailscaleConfig
|
||||
Static StaticConfig
|
||||
Results ResultsConfig
|
||||
@ -139,17 +138,6 @@ type IsloConfig struct {
|
||||
DiskGB int
|
||||
}
|
||||
|
||||
type SemaphoreConfig struct {
|
||||
Host string
|
||||
Token string
|
||||
Project string
|
||||
Machine string
|
||||
OSImage string
|
||||
Duration string
|
||||
IdleTimeout string
|
||||
Binary string
|
||||
}
|
||||
|
||||
type StaticConfig struct {
|
||||
ID string
|
||||
Name string
|
||||
@ -320,7 +308,6 @@ type fileConfig struct {
|
||||
Blacksmith *fileBlacksmithConfig `yaml:"blacksmith,omitempty"`
|
||||
Daytona *fileDaytonaConfig `yaml:"daytona,omitempty"`
|
||||
Islo *fileIsloConfig `yaml:"islo,omitempty"`
|
||||
Semaphore *fileSemaphoreConfig `yaml:"semaphore,omitempty"`
|
||||
Tailscale *fileTailscaleConfig `yaml:"tailscale,omitempty"`
|
||||
Static *fileStaticConfig `yaml:"static,omitempty"`
|
||||
Results *fileResultsConfig `yaml:"results,omitempty"`
|
||||
@ -456,17 +443,6 @@ type fileIsloConfig struct {
|
||||
DiskGB int `yaml:"diskGB,omitempty"`
|
||||
}
|
||||
|
||||
type fileSemaphoreConfig struct {
|
||||
Host string `yaml:"host,omitempty"`
|
||||
Token string `yaml:"token,omitempty"`
|
||||
Project string `yaml:"project,omitempty"`
|
||||
Machine string `yaml:"machine,omitempty"`
|
||||
OSImage string `yaml:"osImage,omitempty"`
|
||||
Duration string `yaml:"duration,omitempty"`
|
||||
IdleTimeout string `yaml:"idleTimeout,omitempty"`
|
||||
Binary string `yaml:"binary,omitempty"`
|
||||
}
|
||||
|
||||
type fileTailscaleConfig struct {
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
@ -912,32 +888,6 @@ func applyFileConfig(cfg *Config, file fileConfig) {
|
||||
cfg.Islo.DiskGB = file.Islo.DiskGB
|
||||
}
|
||||
}
|
||||
if file.Semaphore != nil {
|
||||
if file.Semaphore.Host != "" {
|
||||
cfg.Semaphore.Host = file.Semaphore.Host
|
||||
}
|
||||
if file.Semaphore.Token != "" {
|
||||
cfg.Semaphore.Token = file.Semaphore.Token
|
||||
}
|
||||
if file.Semaphore.Project != "" {
|
||||
cfg.Semaphore.Project = file.Semaphore.Project
|
||||
}
|
||||
if file.Semaphore.Machine != "" {
|
||||
cfg.Semaphore.Machine = file.Semaphore.Machine
|
||||
}
|
||||
if file.Semaphore.OSImage != "" {
|
||||
cfg.Semaphore.OSImage = file.Semaphore.OSImage
|
||||
}
|
||||
if file.Semaphore.Duration != "" {
|
||||
cfg.Semaphore.Duration = file.Semaphore.Duration
|
||||
}
|
||||
if file.Semaphore.IdleTimeout != "" {
|
||||
cfg.Semaphore.IdleTimeout = file.Semaphore.IdleTimeout
|
||||
}
|
||||
if file.Semaphore.Binary != "" {
|
||||
cfg.Semaphore.Binary = file.Semaphore.Binary
|
||||
}
|
||||
}
|
||||
if file.Tailscale != nil {
|
||||
if file.Tailscale.Enabled != nil {
|
||||
cfg.Tailscale.Enabled = *file.Tailscale.Enabled
|
||||
@ -1115,12 +1065,6 @@ func applyEnv(cfg *Config) {
|
||||
cfg.Islo.VCPUs = getenvInt("CRABBOX_ISLO_VCPUS", cfg.Islo.VCPUs)
|
||||
cfg.Islo.MemoryMB = getenvInt("CRABBOX_ISLO_MEMORY_MB", cfg.Islo.MemoryMB)
|
||||
cfg.Islo.DiskGB = getenvInt("CRABBOX_ISLO_DISK_GB", cfg.Islo.DiskGB)
|
||||
cfg.Semaphore.Host = getenv("CRABBOX_SEMAPHORE_HOST", getenv("SEMAPHORE_HOST", cfg.Semaphore.Host))
|
||||
cfg.Semaphore.Token = getenv("CRABBOX_SEMAPHORE_TOKEN", getenv("SEMAPHORE_API_TOKEN", cfg.Semaphore.Token))
|
||||
cfg.Semaphore.Project = getenv("CRABBOX_SEMAPHORE_PROJECT", getenv("SEMAPHORE_PROJECT", cfg.Semaphore.Project))
|
||||
cfg.Semaphore.Machine = getenv("CRABBOX_SEMAPHORE_MACHINE", cfg.Semaphore.Machine)
|
||||
cfg.Semaphore.OSImage = getenv("CRABBOX_SEMAPHORE_OS_IMAGE", cfg.Semaphore.OSImage)
|
||||
cfg.Semaphore.IdleTimeout = getenv("CRABBOX_SEMAPHORE_IDLE_TIMEOUT", cfg.Semaphore.IdleTimeout)
|
||||
if value, ok := getenvBool("CRABBOX_TAILSCALE"); ok {
|
||||
cfg.Tailscale.Enabled = value
|
||||
}
|
||||
|
||||
@ -55,15 +55,6 @@ func clearConfigEnv(t *testing.T) {
|
||||
"CRABBOX_ISLO_VCPUS",
|
||||
"CRABBOX_ISLO_MEMORY_MB",
|
||||
"CRABBOX_ISLO_DISK_GB",
|
||||
"CRABBOX_SEMAPHORE_HOST",
|
||||
"SEMAPHORE_HOST",
|
||||
"CRABBOX_SEMAPHORE_TOKEN",
|
||||
"SEMAPHORE_API_TOKEN",
|
||||
"CRABBOX_SEMAPHORE_PROJECT",
|
||||
"SEMAPHORE_PROJECT",
|
||||
"CRABBOX_SEMAPHORE_MACHINE",
|
||||
"CRABBOX_SEMAPHORE_OS_IMAGE",
|
||||
"CRABBOX_SEMAPHORE_IDLE_TIMEOUT",
|
||||
} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
@ -164,13 +155,6 @@ islo:
|
||||
vcpus: 4
|
||||
memoryMB: 8192
|
||||
diskGB: 40
|
||||
semaphore:
|
||||
host: semaphore.example.test
|
||||
token: semaphore-token
|
||||
project: crabbox
|
||||
machine: f1-standard-4
|
||||
osImage: ubuntu2404
|
||||
idleTimeout: 15m
|
||||
static:
|
||||
id: win-dev
|
||||
name: windows-dev
|
||||
@ -267,9 +251,6 @@ ssh:
|
||||
if cfg.Islo.BaseURL != "https://islo.example.test" || cfg.Islo.Image != "docker.io/library/ubuntu:24.04" || cfg.Islo.Workdir != "crabbox" || cfg.Islo.GatewayProfile != "default" || cfg.Islo.SnapshotName != "snap-ready" || cfg.Islo.VCPUs != 4 || cfg.Islo.MemoryMB != 8192 || cfg.Islo.DiskGB != 40 {
|
||||
t.Fatalf("islo config not loaded: %#v", cfg.Islo)
|
||||
}
|
||||
if cfg.Semaphore.Host != "semaphore.example.test" || cfg.Semaphore.Token != "semaphore-token" || cfg.Semaphore.Project != "crabbox" || cfg.Semaphore.Machine != "f1-standard-4" || cfg.Semaphore.OSImage != "ubuntu2404" || cfg.Semaphore.IdleTimeout != "15m" {
|
||||
t.Fatalf("semaphore config not loaded: %#v", cfg.Semaphore)
|
||||
}
|
||||
if cfg.Static.Host != "win-dev.local" || cfg.Static.User != "peter" || cfg.Static.Port != "22" || cfg.WorkRoot != "/home/peter/crabbox" {
|
||||
t.Fatalf("static config not loaded: static=%#v workRoot=%s", cfg.Static, cfg.WorkRoot)
|
||||
}
|
||||
@ -366,15 +347,6 @@ func TestEnvOverridesConfig(t *testing.T) {
|
||||
t.Setenv("CRABBOX_ISLO_VCPUS", "8")
|
||||
t.Setenv("CRABBOX_ISLO_MEMORY_MB", "16384")
|
||||
t.Setenv("CRABBOX_ISLO_DISK_GB", "80")
|
||||
t.Setenv("SEMAPHORE_HOST", "semaphore-file.example.test")
|
||||
t.Setenv("CRABBOX_SEMAPHORE_HOST", "semaphore-env.example.test")
|
||||
t.Setenv("SEMAPHORE_API_TOKEN", "semaphore-token-file")
|
||||
t.Setenv("CRABBOX_SEMAPHORE_TOKEN", "semaphore-token-env")
|
||||
t.Setenv("SEMAPHORE_PROJECT", "semaphore-project-file")
|
||||
t.Setenv("CRABBOX_SEMAPHORE_PROJECT", "semaphore-project-env")
|
||||
t.Setenv("CRABBOX_SEMAPHORE_MACHINE", "f1-standard-env")
|
||||
t.Setenv("CRABBOX_SEMAPHORE_OS_IMAGE", "ubuntu-env")
|
||||
t.Setenv("CRABBOX_SEMAPHORE_IDLE_TIMEOUT", "22m")
|
||||
path := userConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -417,9 +389,6 @@ func TestEnvOverridesConfig(t *testing.T) {
|
||||
if cfg.Islo.APIKey != "islo-api-env" || cfg.Islo.BaseURL != "https://islo-env.example" || cfg.Islo.Image != "ubuntu:env" || cfg.Islo.Workdir != "env-workdir" || cfg.Islo.GatewayProfile != "env-gateway" || cfg.Islo.SnapshotName != "env-snapshot" || cfg.Islo.VCPUs != 8 || cfg.Islo.MemoryMB != 16384 || cfg.Islo.DiskGB != 80 {
|
||||
t.Fatalf("unexpected islo env: %#v", cfg.Islo)
|
||||
}
|
||||
if cfg.Semaphore.Host != "semaphore-env.example.test" || cfg.Semaphore.Token != "semaphore-token-env" || cfg.Semaphore.Project != "semaphore-project-env" || cfg.Semaphore.Machine != "f1-standard-env" || cfg.Semaphore.OSImage != "ubuntu-env" || cfg.Semaphore.IdleTimeout != "22m" {
|
||||
t.Fatalf("unexpected semaphore env: %#v", cfg.Semaphore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailscaleEnvOverrides(t *testing.T) {
|
||||
|
||||
@ -311,11 +311,11 @@ func normalizeProviderName(name string) string {
|
||||
}
|
||||
|
||||
func providerHelpAll() string {
|
||||
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, semaphore, daytona, or islo"
|
||||
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo"
|
||||
}
|
||||
|
||||
func providerHelpSSH() string {
|
||||
return "provider: hetzner, aws, azure, ssh, semaphore, or daytona"
|
||||
return "provider: hetzner, aws, azure, ssh, or daytona"
|
||||
}
|
||||
|
||||
func isBlacksmithProvider(provider string) bool {
|
||||
|
||||
@ -7,6 +7,5 @@ import (
|
||||
_ "github.com/openclaw/crabbox/internal/providers/daytona"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/hetzner"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/islo"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/semaphore"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/ssh"
|
||||
)
|
||||
|
||||
@ -1,314 +0,0 @@
|
||||
package semaphore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
type apiClient struct {
|
||||
host string
|
||||
token string
|
||||
http *http.Client
|
||||
rt core.Runtime
|
||||
}
|
||||
|
||||
type jobInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
State string
|
||||
}
|
||||
|
||||
const userAgent = "SemaphoreCI v2.0 Client"
|
||||
|
||||
func newAPIClient(host, token string, rt core.Runtime) *apiClient {
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if rt.HTTP != nil {
|
||||
httpClient = rt.HTTP
|
||||
}
|
||||
return &apiClient{host: host, token: token, http: httpClient, rt: rt}
|
||||
}
|
||||
|
||||
// CreateJob creates a standalone Semaphore job with a keepalive script.
|
||||
// Returns the job ID.
|
||||
func (c *apiClient) CreateJob(ctx context.Context, project, machine, osImage string, idleTimeout time.Duration) (string, error) {
|
||||
// Resolve project name to ID
|
||||
projectID, err := c.resolveProjectID(ctx, project)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve project %q: %w", project, err)
|
||||
}
|
||||
|
||||
durationSecs := int(idleTimeout.Seconds())
|
||||
|
||||
keepalive := fmt.Sprintf("sudo mkdir -p /work/crabbox && sudo chown $(whoami) /work/crabbox && echo crabbox-testbox-ready && sleep %d", durationSecs)
|
||||
|
||||
body := map[string]any{
|
||||
"apiVersion": "v1alpha",
|
||||
"kind": "Job",
|
||||
"metadata": map[string]string{"name": "crabbox testbox"},
|
||||
"spec": map[string]any{
|
||||
"project_id": projectID,
|
||||
"agent": map[string]any{
|
||||
"machine": map[string]string{
|
||||
"type": machine,
|
||||
"os_image": osImage,
|
||||
},
|
||||
},
|
||||
"commands": []string{keepalive},
|
||||
},
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Metadata struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if err := c.post(ctx, "/api/v1alpha/jobs", body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Metadata.ID == "" {
|
||||
return "", fmt.Errorf("job creation returned no ID")
|
||||
}
|
||||
return result.Metadata.ID, nil
|
||||
}
|
||||
|
||||
// WaitForRunning polls until the job reaches RUNNING state.
|
||||
// Returns the SSH IP and port.
|
||||
func (c *apiClient) WaitForRunning(ctx context.Context, jobID string, tick func()) (string, int, error) {
|
||||
for i := 0; i < 120; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", 0, ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
tick()
|
||||
|
||||
state, ip, port, err := c.GetJobStatus(ctx, jobID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if state == "FINISHED" {
|
||||
return "", 0, core.Exit(5, "job %s finished before reaching RUNNING state", jobID)
|
||||
}
|
||||
if state == "RUNNING" {
|
||||
return ip, port, nil
|
||||
}
|
||||
}
|
||||
return "", 0, core.Exit(5, "job %s did not reach RUNNING state within timeout", jobID)
|
||||
}
|
||||
|
||||
// GetJobStatus returns the job state, IP, and SSH port.
|
||||
func (c *apiClient) GetJobStatus(ctx context.Context, jobID string) (state, ip string, sshPort int, err error) {
|
||||
var result struct {
|
||||
Status struct {
|
||||
State string `json:"state"`
|
||||
Agent struct {
|
||||
IP string `json:"ip"`
|
||||
Ports []struct {
|
||||
Name string `json:"name"`
|
||||
Number int `json:"number"`
|
||||
} `json:"ports"`
|
||||
} `json:"agent"`
|
||||
} `json:"status"`
|
||||
}
|
||||
if err := c.get(ctx, "/api/v1alpha/jobs/"+jobID, &result); err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
port := 0
|
||||
for _, p := range result.Status.Agent.Ports {
|
||||
if p.Name == "ssh" {
|
||||
port = p.Number
|
||||
}
|
||||
}
|
||||
return result.Status.State, result.Status.Agent.IP, port, nil
|
||||
}
|
||||
|
||||
// GetSSHKey returns the SSH private key for a job.
|
||||
func (c *apiClient) GetSSHKey(ctx context.Context, jobID string) (string, error) {
|
||||
var result struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := c.get(ctx, "/api/v1alpha/jobs/"+jobID+"/debug_ssh_key", &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Key == "" {
|
||||
return "", fmt.Errorf("no SSH key returned for job %s", jobID)
|
||||
}
|
||||
return result.Key, nil
|
||||
}
|
||||
|
||||
// StopJob stops a running job.
|
||||
func (c *apiClient) StopJob(ctx context.Context, jobID string) error {
|
||||
return c.post(ctx, "/api/v1alpha/jobs/"+jobID+"/stop", nil, nil)
|
||||
}
|
||||
|
||||
// ListRunningJobs returns currently running jobs.
|
||||
func (c *apiClient) ListRunningJobs(ctx context.Context) ([]jobInfo, error) {
|
||||
var result struct {
|
||||
Jobs []struct {
|
||||
Metadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"metadata"`
|
||||
Status struct {
|
||||
State string `json:"state"`
|
||||
} `json:"status"`
|
||||
} `json:"jobs"`
|
||||
}
|
||||
if err := c.get(ctx, "/api/v1alpha/jobs?states=RUNNING", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jobs []jobInfo
|
||||
for _, j := range result.Jobs {
|
||||
jobs = append(jobs, jobInfo{
|
||||
ID: j.Metadata.ID,
|
||||
Name: j.Metadata.Name,
|
||||
State: j.Status.State,
|
||||
})
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) resolveProjectID(ctx context.Context, name string) (string, error) {
|
||||
// Try direct GET by name
|
||||
var project struct {
|
||||
Metadata struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
err := c.get(ctx, "/api/v1alpha/projects/"+name, &project)
|
||||
if err == nil && project.Metadata.ID != "" {
|
||||
return project.Metadata.ID, nil
|
||||
}
|
||||
|
||||
// Fallback: paginate through all projects and match by name
|
||||
type projectEntry struct {
|
||||
Metadata struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
path := "/api/v1alpha/projects"
|
||||
for path != "" {
|
||||
var projects []projectEntry
|
||||
resp, headers, err := c.getWithHeaders(ctx, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(resp, &projects); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, p := range projects {
|
||||
if p.Metadata.Name == name {
|
||||
return p.Metadata.ID, nil
|
||||
}
|
||||
}
|
||||
path = nextLinkPath(headers)
|
||||
}
|
||||
return "", fmt.Errorf("project %q not found", name)
|
||||
}
|
||||
|
||||
func nextLinkPath(headers http.Header) string {
|
||||
for _, part := range strings.Split(headers.Get("Link"), ",") {
|
||||
sections := strings.Split(part, ";")
|
||||
if len(sections) < 2 || !strings.Contains(part, `rel="next"`) {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimSpace(sections[0])
|
||||
raw = strings.TrimPrefix(raw, "<")
|
||||
raw = strings.TrimSuffix(raw, ">")
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if u.IsAbs() {
|
||||
return u.RequestURI()
|
||||
}
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *apiClient) getWithHeaders(ctx context.Context, path string) ([]byte, http.Header, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+c.host+path, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Token "+c.token)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("semaphore API %s returned %d: %s", path, resp.StatusCode, string(body))
|
||||
}
|
||||
return body, resp.Header, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) get(ctx context.Context, path string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+c.host+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Token "+c.token)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("semaphore API %s returned %d: %s", path, resp.StatusCode, string(body))
|
||||
}
|
||||
if target != nil {
|
||||
return json.Unmarshal(body, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *apiClient) post(ctx context.Context, path string, payload any, target any) error {
|
||||
var bodyReader io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+c.host+path, bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Token "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("semaphore API %s returned %d: %s", path, resp.StatusCode, string(body))
|
||||
}
|
||||
if target != nil {
|
||||
return json.Unmarshal(body, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -1,239 +0,0 @@
|
||||
package semaphore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
type semaphoreBackend struct {
|
||||
spec core.ProviderSpec
|
||||
cfg core.Config
|
||||
rt core.Runtime
|
||||
client *apiClient
|
||||
}
|
||||
|
||||
func newBackend(spec core.ProviderSpec, cfg core.Config, rt core.Runtime) (core.Backend, error) {
|
||||
if cfg.Semaphore.Host == "" || cfg.Semaphore.Token == "" {
|
||||
return nil, core.Exit(2, "semaphore provider requires semaphore.host and semaphore.token in config or --semaphore-host/--semaphore-token flags")
|
||||
}
|
||||
cfg.Provider = providerName
|
||||
client := newAPIClient(cfg.Semaphore.Host, cfg.Semaphore.Token, rt)
|
||||
return &semaphoreBackend{spec: spec, cfg: cfg, rt: rt, client: client}, nil
|
||||
}
|
||||
|
||||
func (b *semaphoreBackend) Spec() core.ProviderSpec { return b.spec }
|
||||
|
||||
// Acquire creates a Semaphore job and returns SSH connection info.
|
||||
// Crabbox handles all sync and command execution from here.
|
||||
func (b *semaphoreBackend) Acquire(ctx context.Context, req core.AcquireRequest) (core.LeaseTarget, error) {
|
||||
project := b.cfg.Semaphore.Project
|
||||
if project == "" {
|
||||
return core.LeaseTarget{}, core.Exit(2, "semaphore.project is required")
|
||||
}
|
||||
|
||||
machine := withDefault(b.cfg.Semaphore.Machine, "f1-standard-2")
|
||||
osImage := withDefault(b.cfg.Semaphore.OSImage, "ubuntu2204")
|
||||
timeout := idleTimeout(b.cfg)
|
||||
|
||||
fmt.Fprintf(b.rt.Stderr, "provisioning provider=semaphore project=%s machine=%s os=%s\n", project, machine, osImage)
|
||||
|
||||
// 1. Create standalone job
|
||||
jobID, err := b.client.CreateJob(ctx, project, machine, osImage, timeout)
|
||||
if err != nil {
|
||||
return core.LeaseTarget{}, err
|
||||
}
|
||||
|
||||
// Best-effort cleanup if anything fails after job creation
|
||||
cleanup := func() {
|
||||
fmt.Fprintf(b.rt.Stderr, "cleaning up job %s after failed acquisition\n", jobID)
|
||||
_ = b.client.StopJob(context.Background(), jobID)
|
||||
}
|
||||
|
||||
leaseID := "sem_" + jobID
|
||||
slug := core.NewLeaseSlug(leaseID)
|
||||
fmt.Fprintf(b.rt.Stderr, "created job=%s lease=%s slug=%s\n", jobID, leaseID, slug)
|
||||
|
||||
// 2. Poll until RUNNING
|
||||
fmt.Fprintf(b.rt.Stderr, "waiting for job to start ")
|
||||
ip, sshPort, err := b.client.WaitForRunning(ctx, jobID, func() {
|
||||
fmt.Fprintf(b.rt.Stderr, ".")
|
||||
})
|
||||
fmt.Fprintln(b.rt.Stderr)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return core.LeaseTarget{}, err
|
||||
}
|
||||
|
||||
// 3. Get SSH key and write to file (crabbox expects a file path)
|
||||
sshKey, err := b.client.GetSSHKey(ctx, jobID)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return core.LeaseTarget{}, err
|
||||
}
|
||||
|
||||
keyPath, err := storeSSHKey(leaseID, sshKey)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return core.LeaseTarget{}, fmt.Errorf("store SSH key: %w", err)
|
||||
}
|
||||
|
||||
target := core.SSHTarget{
|
||||
User: "semaphore",
|
||||
Host: ip,
|
||||
Key: keyPath,
|
||||
Port: fmt.Sprintf("%d", sshPort),
|
||||
TargetOS: core.TargetLinux,
|
||||
ReadyCheck: "true", // Semaphore job is ready once SSH is reachable
|
||||
}
|
||||
|
||||
server := core.Server{
|
||||
CloudID: jobID,
|
||||
Provider: providerName,
|
||||
Name: "sem-testbox-" + slug,
|
||||
Status: "running",
|
||||
Labels: map[string]string{
|
||||
"lease": leaseID,
|
||||
"slug": slug,
|
||||
"provider": providerName,
|
||||
"project": project,
|
||||
"machine": machine,
|
||||
"os_image": osImage,
|
||||
},
|
||||
}
|
||||
server.ServerType.Name = machine
|
||||
server.PublicNet.IPv4.IP = ip
|
||||
|
||||
return core.LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
|
||||
// Resolve looks up an existing Semaphore job by ID or slug.
|
||||
func (b *semaphoreBackend) Resolve(ctx context.Context, req core.ResolveRequest) (core.LeaseTarget, error) {
|
||||
id := req.ID
|
||||
|
||||
// Try direct lease ID (sem_UUID or UUID)
|
||||
if isLeaseID(id) {
|
||||
return b.resolveByJobID(ctx, stripLeasePrefix(id))
|
||||
}
|
||||
|
||||
// Resolve slug → lease ID via claim file
|
||||
if claim, found, err := core.ResolveLeaseClaim(id); err == nil && found {
|
||||
return b.resolveByJobID(ctx, stripLeasePrefix(claim.LeaseID))
|
||||
}
|
||||
|
||||
return core.LeaseTarget{}, core.Exit(4, "semaphore lease not found for %q — use the full lease ID (sem_UUID) or a slug from a recent warmup", id)
|
||||
}
|
||||
|
||||
func isLeaseID(id string) bool {
|
||||
if len(id) > 4 && id[:4] == "sem_" {
|
||||
return true
|
||||
}
|
||||
// UUID format: 8-4-4-4-12 hex
|
||||
stripped := stripLeasePrefix(id)
|
||||
return len(stripped) == 36 && stripped[8] == '-' && stripped[13] == '-'
|
||||
}
|
||||
|
||||
func (b *semaphoreBackend) resolveByJobID(ctx context.Context, jobID string) (core.LeaseTarget, error) {
|
||||
state, ip, sshPort, err := b.client.GetJobStatus(ctx, jobID)
|
||||
if err != nil {
|
||||
return core.LeaseTarget{}, err
|
||||
}
|
||||
if state != "RUNNING" {
|
||||
return core.LeaseTarget{}, core.Exit(4, "semaphore job %s is not running (state: %s)", jobID, state)
|
||||
}
|
||||
|
||||
sshKey, err := b.client.GetSSHKey(ctx, jobID)
|
||||
if err != nil {
|
||||
return core.LeaseTarget{}, err
|
||||
}
|
||||
|
||||
leaseID := "sem_" + jobID
|
||||
keyPath, err := storeSSHKey(leaseID, sshKey)
|
||||
if err != nil {
|
||||
return core.LeaseTarget{}, fmt.Errorf("store SSH key: %w", err)
|
||||
}
|
||||
|
||||
// Read slug from claim if available
|
||||
slug := ""
|
||||
if claim, err := core.ReadLeaseClaim(leaseID); err == nil && claim.Slug != "" {
|
||||
slug = claim.Slug
|
||||
}
|
||||
|
||||
target := core.SSHTarget{
|
||||
User: "semaphore",
|
||||
Host: ip,
|
||||
Key: keyPath,
|
||||
Port: fmt.Sprintf("%d", sshPort),
|
||||
TargetOS: core.TargetLinux,
|
||||
ReadyCheck: "true",
|
||||
}
|
||||
server := core.Server{
|
||||
CloudID: jobID,
|
||||
Provider: providerName,
|
||||
Name: "sem-testbox",
|
||||
Status: "running",
|
||||
Labels: map[string]string{
|
||||
"lease": leaseID,
|
||||
"slug": slug,
|
||||
"provider": providerName,
|
||||
},
|
||||
}
|
||||
server.PublicNet.IPv4.IP = ip
|
||||
server.ServerType.Name = withDefault(b.cfg.Semaphore.Machine, "f1-standard-2")
|
||||
|
||||
return core.LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
|
||||
// List returns running Semaphore testbox jobs.
|
||||
func (b *semaphoreBackend) List(ctx context.Context, req core.ListRequest) ([]core.Server, error) {
|
||||
jobs, err := b.client.ListRunningJobs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var servers []core.Server
|
||||
for _, j := range jobs {
|
||||
s := core.Server{
|
||||
CloudID: j.ID,
|
||||
Provider: providerName,
|
||||
Name: j.Name,
|
||||
Status: j.State,
|
||||
Labels: map[string]string{
|
||||
"lease": "sem_" + j.ID,
|
||||
"provider": providerName,
|
||||
},
|
||||
}
|
||||
servers = append(servers, s)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// ReleaseLease stops the Semaphore job.
|
||||
func (b *semaphoreBackend) ReleaseLease(ctx context.Context, req core.ReleaseLeaseRequest) error {
|
||||
jobID := stripLeasePrefix(req.Lease.LeaseID)
|
||||
return b.client.StopJob(ctx, jobID)
|
||||
}
|
||||
|
||||
// Touch is a no-op for Semaphore — the keepalive script handles idle timeout.
|
||||
func (b *semaphoreBackend) Touch(ctx context.Context, req core.TouchRequest) (core.Server, error) {
|
||||
return req.Lease.Server, nil
|
||||
}
|
||||
|
||||
func storeSSHKey(leaseID, keyContent string) (string, error) {
|
||||
dir := os.TempDir()
|
||||
path := filepath.Join(dir, ".crabbox-sem-"+leaseID+".key")
|
||||
if err := os.WriteFile(path, []byte(keyContent), 0600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func stripLeasePrefix(leaseID string) string {
|
||||
if len(leaseID) > 4 && leaseID[:4] == "sem_" {
|
||||
return leaseID[4:]
|
||||
}
|
||||
return leaseID
|
||||
}
|
||||
@ -1,356 +0,0 @@
|
||||
package semaphore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
type testClock struct{}
|
||||
|
||||
func (testClock) Now() time.Time { return time.Now() }
|
||||
|
||||
func testConfig(host string) core.Config {
|
||||
cfg := core.BaseConfig()
|
||||
cfg.Provider = providerName
|
||||
cfg.Semaphore.Host = host
|
||||
cfg.Semaphore.Token = "test-token"
|
||||
cfg.Semaphore.Project = "my-project"
|
||||
cfg.Semaphore.Machine = "f1-standard-2"
|
||||
cfg.Semaphore.OSImage = "ubuntu2204"
|
||||
cfg.Semaphore.IdleTimeout = "10m"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func testRuntime(httpClient *http.Client) core.Runtime {
|
||||
return core.Runtime{
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
Clock: testClock{},
|
||||
HTTP: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider registration ---
|
||||
|
||||
func TestProviderName(t *testing.T) {
|
||||
p := Provider{}
|
||||
if p.Name() != "semaphore" {
|
||||
t.Errorf("name = %q, want semaphore", p.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderAliases(t *testing.T) {
|
||||
p := Provider{}
|
||||
aliases := p.Aliases()
|
||||
if len(aliases) != 1 || aliases[0] != "sem" {
|
||||
t.Errorf("aliases = %v, want [sem]", aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSpecIsSSHLease(t *testing.T) {
|
||||
p := Provider{}
|
||||
spec := p.Spec()
|
||||
if spec.Kind != core.ProviderKindSSHLease {
|
||||
t.Errorf("kind = %q, want ssh-lease", spec.Kind)
|
||||
}
|
||||
if spec.Coordinator != core.CoordinatorNever {
|
||||
t.Errorf("coordinator = %q, want never", spec.Coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flag registration ---
|
||||
|
||||
func TestRegisterAndApplyFlags(t *testing.T) {
|
||||
cfg := core.BaseConfig()
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
values := registerFlags(fs, cfg)
|
||||
err := fs.Parse([]string{
|
||||
"--semaphore-host", "myorg.semaphoreci.com",
|
||||
"--semaphore-token", "my-token",
|
||||
"--semaphore-project", "my-app",
|
||||
"--semaphore-machine", "f1-standard-4",
|
||||
"--semaphore-os-image", "ubuntu2404",
|
||||
"--semaphore-idle-timeout", "15m",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
applyFlagOverrides(&cfg, fs, values)
|
||||
|
||||
if cfg.Semaphore.Host != "myorg.semaphoreci.com" {
|
||||
t.Errorf("host = %q", cfg.Semaphore.Host)
|
||||
}
|
||||
if cfg.Semaphore.Token != "my-token" {
|
||||
t.Errorf("token = %q", cfg.Semaphore.Token)
|
||||
}
|
||||
if cfg.Semaphore.Project != "my-app" {
|
||||
t.Errorf("project = %q", cfg.Semaphore.Project)
|
||||
}
|
||||
if cfg.Semaphore.Machine != "f1-standard-4" {
|
||||
t.Errorf("machine = %q", cfg.Semaphore.Machine)
|
||||
}
|
||||
if cfg.Semaphore.OSImage != "ubuntu2404" {
|
||||
t.Errorf("os_image = %q", cfg.Semaphore.OSImage)
|
||||
}
|
||||
if cfg.Semaphore.IdleTimeout != "15m" {
|
||||
t.Errorf("idle_timeout = %q", cfg.Semaphore.IdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsNotSetLeavesDefaults(t *testing.T) {
|
||||
cfg := core.BaseConfig()
|
||||
cfg.Semaphore.Machine = "original"
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
values := registerFlags(fs, cfg)
|
||||
_ = fs.Parse([]string{}) // no flags
|
||||
|
||||
applyFlagOverrides(&cfg, fs, values)
|
||||
|
||||
if cfg.Semaphore.Machine != "original" {
|
||||
t.Errorf("machine changed to %q, should stay original", cfg.Semaphore.Machine)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config helpers ---
|
||||
|
||||
func TestIdleTimeoutDefault(t *testing.T) {
|
||||
cfg := core.BaseConfig()
|
||||
if d := idleTimeout(cfg); d != 30*time.Minute {
|
||||
t.Errorf("default idle timeout = %v, want 30m", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdleTimeoutFromConfig(t *testing.T) {
|
||||
cfg := core.BaseConfig()
|
||||
cfg.Semaphore.IdleTimeout = "15m"
|
||||
if d := idleTimeout(cfg); d != 15*time.Minute {
|
||||
t.Errorf("idle timeout = %v, want 15m", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefault(t *testing.T) {
|
||||
if withDefault("", "fallback") != "fallback" {
|
||||
t.Error("empty should use fallback")
|
||||
}
|
||||
if withDefault("value", "fallback") != "value" {
|
||||
t.Error("non-empty should use value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripLeasePrefix(t *testing.T) {
|
||||
if stripLeasePrefix("sem_abc123") != "abc123" {
|
||||
t.Errorf("got %q", stripLeasePrefix("sem_abc123"))
|
||||
}
|
||||
if stripLeasePrefix("abc123") != "abc123" {
|
||||
t.Errorf("got %q", stripLeasePrefix("abc123"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- API client tests with httptest ---
|
||||
|
||||
func TestCreateJob(t *testing.T) {
|
||||
var receivedBody map[string]any
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1alpha/projects" {
|
||||
json.NewEncoder(w).Encode([]map[string]any{
|
||||
{"metadata": map[string]string{"name": "my-project", "id": "proj-123"}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v1alpha/jobs" && r.Method == "POST" {
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
// Check auth header
|
||||
if auth := r.Header.Get("Authorization"); auth != "Token test-token" {
|
||||
t.Errorf("auth = %q", auth)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]string{"id": "job-456"},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "https://")
|
||||
client := &apiClient{host: host, token: "test-token", http: server.Client()}
|
||||
|
||||
jobID, err := client.CreateJob(context.Background(), "my-project", "f1-standard-2", "ubuntu2204", 30*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jobID != "job-456" {
|
||||
t.Errorf("jobID = %q, want job-456", jobID)
|
||||
}
|
||||
|
||||
// Verify the job spec
|
||||
spec := receivedBody["spec"].(map[string]any)
|
||||
if spec["project_id"] != "proj-123" {
|
||||
t.Errorf("project_id = %v", spec["project_id"])
|
||||
}
|
||||
agent := spec["agent"].(map[string]any)
|
||||
machine := agent["machine"].(map[string]any)
|
||||
if machine["type"] != "f1-standard-2" {
|
||||
t.Errorf("machine type = %v", machine["type"])
|
||||
}
|
||||
if machine["os_image"] != "ubuntu2204" {
|
||||
t.Errorf("os_image = %v", machine["os_image"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobStatus(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": map[string]any{
|
||||
"state": "RUNNING",
|
||||
"agent": map[string]any{
|
||||
"ip": "1.2.3.4",
|
||||
"ports": []map[string]any{
|
||||
{"name": "ssh", "number": 40010},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "https://")
|
||||
client := &apiClient{host: host, token: "tok", http: server.Client()}
|
||||
|
||||
state, ip, port, err := client.GetJobStatus(context.Background(), "job-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "RUNNING" {
|
||||
t.Errorf("state = %q", state)
|
||||
}
|
||||
if ip != "1.2.3.4" {
|
||||
t.Errorf("ip = %q", ip)
|
||||
}
|
||||
if port != 40010 {
|
||||
t.Errorf("port = %d", port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSSHKey(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"key": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "https://")
|
||||
client := &apiClient{host: host, token: "tok", http: server.Client()}
|
||||
|
||||
key, err := client.GetSSHKey(context.Background(), "job-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(key, "RSA PRIVATE KEY") {
|
||||
t.Errorf("key doesn't look like an SSH key: %q", key[:30])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectID(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("User-Agent"); got != "SemaphoreCI v2.0 Client" {
|
||||
t.Errorf("user-agent = %q", got)
|
||||
}
|
||||
if r.URL.Path == "/api/v1alpha/projects/my-project" {
|
||||
w.WriteHeader(400) // some hosts don't support name lookup
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v1alpha/projects" && r.URL.Query().Get("page") == "" {
|
||||
w.Header().Set("Link", `</api/v1alpha/projects?page=2>; rel="next"`)
|
||||
json.NewEncoder(w).Encode([]map[string]any{
|
||||
{"metadata": map[string]string{"name": "other", "id": "other-id"}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v1alpha/projects" && r.URL.Query().Get("page") == "2" {
|
||||
json.NewEncoder(w).Encode([]map[string]any{
|
||||
{"metadata": map[string]string{"name": "my-project", "id": "proj-abc"}},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "https://")
|
||||
client := &apiClient{host: host, token: "tok", http: server.Client()}
|
||||
|
||||
id, err := client.resolveProjectID(context.Background(), "my-project")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "proj-abc" {
|
||||
t.Errorf("project id = %q, want proj-abc", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectIDNotFound(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1alpha/projects/missing" {
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode([]map[string]any{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "https://")
|
||||
client := &apiClient{host: host, token: "tok", http: server.Client()}
|
||||
|
||||
_, err := client.resolveProjectID(context.Background(), "missing")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRequiresHostAndToken(t *testing.T) {
|
||||
cfg := core.BaseConfig()
|
||||
cfg.Provider = providerName
|
||||
// No host or token
|
||||
_, err := newBackend(Provider{}.Spec(), cfg, testRuntime(nil))
|
||||
if err == nil {
|
||||
t.Error("expected error when host/token missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopJob(t *testing.T) {
|
||||
stopped := false
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/stop") {
|
||||
stopped = true
|
||||
w.Write([]byte("{}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "https://")
|
||||
client := &apiClient{host: host, token: "tok", http: server.Client()}
|
||||
|
||||
err := client.StopJob(context.Background(), "job-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stopped {
|
||||
t.Error("stop endpoint was not called")
|
||||
}
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
package semaphore
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"time"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
const providerName = "semaphore"
|
||||
|
||||
type flagValues struct {
|
||||
Host *string
|
||||
Token *string
|
||||
Project *string
|
||||
Machine *string
|
||||
OSImage *string
|
||||
IdleTimeout *string
|
||||
}
|
||||
|
||||
func registerFlags(fs *flag.FlagSet, defaults core.Config) flagValues {
|
||||
sem := defaults.Semaphore
|
||||
return flagValues{
|
||||
Host: fs.String("semaphore-host", sem.Host, "Semaphore host (e.g. myorg.semaphoreci.com)"),
|
||||
Token: fs.String("semaphore-token", sem.Token, "Semaphore API token"),
|
||||
Project: fs.String("semaphore-project", sem.Project, "Semaphore project name"),
|
||||
Machine: fs.String("semaphore-machine", withDefault(sem.Machine, "f1-standard-2"), "Machine type"),
|
||||
OSImage: fs.String("semaphore-os-image", withDefault(sem.OSImage, "ubuntu2204"), "OS image"),
|
||||
IdleTimeout: fs.String("semaphore-idle-timeout", withDefault(sem.IdleTimeout, "30m"), "Idle timeout"),
|
||||
}
|
||||
}
|
||||
|
||||
func applyFlagOverrides(cfg *core.Config, fs *flag.FlagSet, v flagValues) {
|
||||
if wasSet(fs, "semaphore-host") {
|
||||
cfg.Semaphore.Host = *v.Host
|
||||
}
|
||||
if wasSet(fs, "semaphore-token") {
|
||||
cfg.Semaphore.Token = *v.Token
|
||||
}
|
||||
if wasSet(fs, "semaphore-project") {
|
||||
cfg.Semaphore.Project = *v.Project
|
||||
}
|
||||
if wasSet(fs, "semaphore-machine") {
|
||||
cfg.Semaphore.Machine = *v.Machine
|
||||
}
|
||||
if wasSet(fs, "semaphore-os-image") {
|
||||
cfg.Semaphore.OSImage = *v.OSImage
|
||||
}
|
||||
if wasSet(fs, "semaphore-idle-timeout") {
|
||||
cfg.Semaphore.IdleTimeout = *v.IdleTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func wasSet(fs *flag.FlagSet, name string) bool {
|
||||
found := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func withDefault(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func idleTimeout(cfg core.Config) time.Duration {
|
||||
if cfg.Semaphore.IdleTimeout != "" {
|
||||
if d, err := time.ParseDuration(cfg.Semaphore.IdleTimeout); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 30 * time.Minute
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
// Package semaphore implements a Crabbox provider that creates Semaphore CI
|
||||
// jobs as warm testbox environments. Pure REST API; no sem-agent binary needed.
|
||||
package semaphore
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterProvider(Provider{})
|
||||
}
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func (Provider) Name() string { return "semaphore" }
|
||||
func (Provider) Aliases() []string { return []string{"sem"} }
|
||||
func (Provider) Spec() core.ProviderSpec {
|
||||
return core.ProviderSpec{
|
||||
Name: "semaphore",
|
||||
Kind: core.ProviderKindSSHLease,
|
||||
Targets: []core.TargetSpec{{OS: core.TargetLinux}},
|
||||
Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync},
|
||||
Coordinator: core.CoordinatorNever,
|
||||
}
|
||||
}
|
||||
|
||||
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults core.Config) any {
|
||||
return registerFlags(fs, defaults)
|
||||
}
|
||||
|
||||
func (Provider) ApplyFlags(cfg *core.Config, fs *flag.FlagSet, values any) error {
|
||||
if v, ok := values.(flagValues); ok {
|
||||
applyFlagOverrides(cfg, fs, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
|
||||
return newBackend(p.Spec(), cfg, rt)
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
package semaphore
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
)
|
||||
|
||||
func newFlagSet(name string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
return fs
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user