Compare commits

..

7 Commits

Author SHA1 Message Date
Peter Steinberger
886afaf5d6
style: format semaphore provider 2026-05-08 09:12:26 +01:00
Peter Steinberger
338729b2a0
fix: harden semaphore provider 2026-05-08 09:10:11 +01:00
Marcos Filipe
0a1b840de9
fix: cleanup jobs and fix pagination and user agent 2026-05-08 09:06:36 +01:00
Marcos Filipe
47c49c0a35
fix: fix slug being lost on multiple runs 2026-05-08 09:06:36 +01:00
Marcos Filipe
45bb219331
fix: fix connectivity issues 2026-05-08 09:06:35 +01:00
Marcos Filipe
359befe946
feat: increment documentation 2026-05-08 09:06:35 +01:00
Marcos Filipe
7f740e06d3
feat: add semaphore provider 2026-05-08 09:06:22 +01:00
25 changed files with 1244 additions and 14 deletions

View File

@ -5,6 +5,7 @@
### 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

View File

@ -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, 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, use `provider: semaphore` for Semaphore CI environments, or use `provider: ssh` for existing macOS and Windows targets.
---
@ -77,6 +77,7 @@ 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.
@ -198,6 +199,16 @@ 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

View File

@ -73,7 +73,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|daytona
--provider hetzner|aws|azure|ssh|semaphore|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -37,7 +37,7 @@ included.
```text
--id <lease-id-or-slug> lease to inspect; required for managed providers
--provider hetzner|aws|azure|ssh|daytona override the configured provider
--provider hetzner|aws|azure|ssh|semaphore|daytona override the configured provider
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host> static SSH host for provider=ssh

View File

@ -35,7 +35,7 @@ use the normalized Crabbox lease view.
Flags:
```text
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -89,7 +89,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -38,7 +38,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|daytona
--provider hetzner|aws|azure|ssh|semaphore|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -15,7 +15,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|daytona
--provider hetzner|aws|azure|ssh|semaphore|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -26,7 +26,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -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|daytona|islo
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -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|daytona
--provider hetzner|aws|azure|ssh|semaphore|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -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|daytona|islo
--provider hetzner|aws|azure|ssh|blacksmith-testbox|semaphore|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -16,6 +16,7 @@ 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 |
@ -53,6 +54,9 @@ 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 |
@ -62,6 +66,7 @@ Delegated providers do 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 |

View File

@ -0,0 +1,82 @@
# 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.

View File

@ -47,18 +47,20 @@ 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`
- 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/backend authoring guide: `docs/provider-backends.md`
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`

View File

@ -63,6 +63,7 @@ type Config struct {
Blacksmith BlacksmithConfig
Daytona DaytonaConfig
Islo IsloConfig
Semaphore SemaphoreConfig
Tailscale TailscaleConfig
Static StaticConfig
Results ResultsConfig
@ -138,6 +139,17 @@ 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
@ -308,6 +320,7 @@ 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"`
@ -443,6 +456,17 @@ 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"`
@ -888,6 +912,32 @@ 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
@ -1065,6 +1115,12 @@ 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
}

View File

@ -55,6 +55,15 @@ 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, "")
}
@ -155,6 +164,13 @@ 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
@ -251,6 +267,9 @@ 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)
}
@ -347,6 +366,15 @@ 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)
@ -389,6 +417,9 @@ 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) {

View File

@ -311,11 +311,11 @@ func normalizeProviderName(name string) string {
}
func providerHelpAll() string {
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo"
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, semaphore, daytona, or islo"
}
func providerHelpSSH() string {
return "provider: hetzner, aws, azure, ssh, or daytona"
return "provider: hetzner, aws, azure, ssh, semaphore, or daytona"
}
func isBlacksmithProvider(provider string) bool {

View File

@ -7,5 +7,6 @@ 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"
)

View File

@ -0,0 +1,314 @@
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
}

View File

@ -0,0 +1,239 @@
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
}

View File

@ -0,0 +1,356 @@
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")
}
}

View File

@ -0,0 +1,78 @@
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
}

View File

@ -0,0 +1,42 @@
// 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)
}

View File

@ -0,0 +1,12 @@
package semaphore
import (
"flag"
"io"
)
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
return fs
}