fix: fall back from coordinator pool list

This commit is contained in:
Peter Steinberger 2026-05-07 14:52:15 +01:00
parent 93a9e64998
commit 7884b1d71f
No known key found for this signature in database
5 changed files with 196 additions and 9 deletions

View File

@ -18,6 +18,7 @@
- Fixed Islo provider sync so `crabbox run --provider islo` uploads the local workspace, uses the correct `/workspace/<workdir>`, and falls back to chunked exec upload while the archive API returns server errors.
- Fixed Code and WebVNC bridge websocket auth so upgraded brokers receive short-lived bridge tickets in the `Authorization` header instead of logging them in URL query strings, while preserving query fallback for older brokers.
- Fixed managed AWS macOS desktop leases so readiness and WebVNC use a writable `ec2-user` work root, call `crabbox-ready` by absolute path, and read the generated Screen Sharing password via sudo.
- Fixed coordinator-backed `crabbox list` so a stale admin token no longer blocks normal logged-in users; the CLI now falls back to active user-visible leases instead of failing with `401 unauthorized`.
## 0.6.0 - 2026-05-07

View File

@ -575,6 +575,25 @@ func (c *CoordinatorClient) Pool(ctx context.Context, cfg Config) ([]Coordinator
return res.Machines, err
}
func (c *CoordinatorClient) Leases(ctx context.Context, state string, limit int) ([]CoordinatorLease, error) {
var res struct {
Leases []CoordinatorLease `json:"leases"`
}
values := url.Values{}
if state != "" {
values.Set("state", state)
}
if limit > 0 {
values.Set("limit", strconv.Itoa(limit))
}
path := "/v1/leases"
if encoded := values.Encode(); encoded != "" {
path += "?" + encoded
}
err := c.do(ctx, http.MethodGet, path, nil, &res)
return res.Leases, err
}
func (c *CoordinatorClient) Usage(ctx context.Context, scope, owner, org, month string) (CoordinatorUsageResponse, error) {
var res CoordinatorUsageResponse
values := url.Values{}

View File

@ -39,6 +39,16 @@ func (a App) list(ctx context.Context, args []string) error {
if err != nil {
return err
}
if *jsonOut {
if jsonBackend, ok := backend.(JSONListBackend); ok {
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
if err != nil {
return err
}
a.syncExternalRunnersBestEffort(ctx, cfg, backend)
return json.NewEncoder(a.Stdout).Encode(view)
}
}
var servers []Server
switch b := backend.(type) {
case SSHLeaseBackend:
@ -53,13 +63,6 @@ func (a App) list(ctx context.Context, args []string) error {
}
a.syncExternalRunnersBestEffort(ctx, cfg, backend)
if *jsonOut {
if jsonBackend, ok := backend.(JSONListBackend); ok {
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
if err != nil {
return err
}
return json.NewEncoder(a.Stdout).Encode(view)
}
return json.NewEncoder(a.Stdout).Encode(servers)
}
renderServerList(a.Stdout, servers)

View File

@ -132,7 +132,11 @@ func (b *coordinatorLeaseBackend) Status(ctx context.Context, req StatusRequest)
func (b *coordinatorLeaseBackend) List(ctx context.Context, req ListRequest) ([]Server, error) {
machines, activeLeaseIDs, err := b.listMachines(ctx)
if err != nil {
return nil, err
leases, fallbackErr := b.listLeasesFallback(ctx, err)
if fallbackErr != nil {
return nil, fallbackErr
}
return coordinatorLeasesToServers(leases, b.cfg), nil
}
return coordinatorMachinesToServers(machines, activeLeaseIDs), nil
}
@ -141,7 +145,7 @@ func (b *coordinatorLeaseBackend) ListJSON(ctx context.Context, req ListRequest)
_ = req
machines, _, err := b.listMachines(ctx)
if err != nil {
return nil, err
return b.listLeasesFallback(ctx, err)
}
return machines, nil
}
@ -168,6 +172,51 @@ func (b *coordinatorLeaseBackend) listMachines(ctx context.Context) ([]Coordinat
return machines, activeCoordinatorLeaseIDs(activeLeases), nil
}
func (b *coordinatorLeaseBackend) listLeasesFallback(ctx context.Context, adminErr error) ([]CoordinatorLease, error) {
if b.cfg.CoordToken == "" {
return nil, adminErr
}
if adminErr != nil && isCoordinatorUnauthorized(adminErr) {
fmt.Fprintf(b.rt.Stderr, "warning: coordinator admin pool list unauthorized; falling back to user-visible leases\n")
} else if adminErr != nil && b.cfg.CoordAdminToken == "" {
fmt.Fprintf(b.rt.Stderr, "warning: coordinator admin pool list unavailable; falling back to user-visible leases\n")
} else if adminErr != nil {
return nil, adminErr
}
leases, err := b.coord.Leases(ctx, "active", 1000)
if err != nil {
return nil, err
}
return filterCoordinatorLeasesForProvider(leases, b.cfg.Provider), nil
}
func coordinatorLeasesToServers(leases []CoordinatorLease, cfg Config) []Server {
servers := make([]Server, 0, len(leases))
for _, lease := range leases {
server, _, _ := leaseToServerTarget(lease, cfg)
servers = append(servers, server)
}
return servers
}
func filterCoordinatorLeasesForProvider(leases []CoordinatorLease, provider string) []CoordinatorLease {
provider = strings.TrimSpace(provider)
if provider == "" {
return leases
}
out := make([]CoordinatorLease, 0, len(leases))
for _, lease := range leases {
if strings.EqualFold(strings.TrimSpace(lease.Provider), provider) {
out = append(out, lease)
}
}
return out
}
func isCoordinatorUnauthorized(err error) bool {
return err != nil && strings.Contains(err.Error(), "http 401")
}
func (b *coordinatorLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
if req.Lease.LeaseID == "" {
return exit(2, "missing coordinator lease id")

View File

@ -0,0 +1,115 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCoordinatorListFallsBackToUserLeasesWhenAdminTokenUnauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/pool":
if got := r.Header.Get("Authorization"); got != "Bearer stale-admin-token" {
t.Fatalf("pool auth=%q", got)
}
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
case "/v1/leases":
if got := r.URL.Query().Get("state"); got != "active" {
t.Fatalf("leases state=%q", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer user-token" {
t.Fatalf("leases auth=%q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"leases": []CoordinatorLease{
{
ID: "cbx_123",
Slug: "blue-lobster",
Provider: "aws",
TargetOS: targetLinux,
ServerID: 42,
CloudID: "i-123",
ServerName: "crabbox-blue-lobster",
Host: "203.0.113.10",
SSHUser: "crabbox",
SSHPort: "2222",
ServerType: "c7a.48xlarge",
State: "active",
Keep: true,
ExpiresAt: "2026-05-07T15:00:00Z",
IdleTimeoutSeconds: 1800,
},
{ID: "cbx_other", Provider: "hetzner", State: "active"},
}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
var stderr bytes.Buffer
cfg := Config{
Provider: "aws",
TargetOS: targetLinux,
Coordinator: server.URL,
CoordToken: "user-token",
CoordAdminToken: "stale-admin-token",
}
coord, _, err := newCoordinatorClient(cfg)
if err != nil {
t.Fatal(err)
}
backend := &coordinatorLeaseBackend{cfg: cfg, coord: coord, rt: Runtime{Stderr: &stderr}}
servers, err := backend.List(context.Background(), ListRequest{})
if err != nil {
t.Fatal(err)
}
if len(servers) != 1 {
t.Fatalf("servers=%d, want 1: %#v", len(servers), servers)
}
if servers[0].Labels["lease"] != "cbx_123" || servers[0].Labels["slug"] != "blue-lobster" {
t.Fatalf("server labels=%#v", servers[0].Labels)
}
if !strings.Contains(stderr.String(), "falling back to user-visible leases") {
t.Fatalf("missing fallback warning: %q", stderr.String())
}
}
func TestCoordinatorListJSONFallsBackWhenAdminTokenMissing(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/leases" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("state"); got != "active" {
t.Fatalf("leases state=%q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"leases": []CoordinatorLease{
{ID: "cbx_123", Provider: "aws", State: "active"},
}})
}))
defer server.Close()
cfg := Config{Provider: "aws", TargetOS: targetLinux, Coordinator: server.URL, CoordToken: "user-token"}
coord, _, err := newCoordinatorClient(cfg)
if err != nil {
t.Fatal(err)
}
backend := &coordinatorLeaseBackend{cfg: cfg, coord: coord, rt: Runtime{Stderr: &bytes.Buffer{}}}
view, err := backend.ListJSON(context.Background(), ListRequest{})
if err != nil {
t.Fatal(err)
}
leases, ok := view.([]CoordinatorLease)
if !ok {
t.Fatalf("view=%T, want []CoordinatorLease", view)
}
if len(leases) != 1 || leases[0].ID != "cbx_123" {
t.Fatalf("leases=%#v", leases)
}
}