feat(cli): add code lease bridge command
This commit is contained in:
parent
22ea8e0a2c
commit
30e81c6f17
@ -87,6 +87,8 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
|
||||
return a.vnc(ctx, helpArgs), true
|
||||
case "webvnc":
|
||||
return a.webvnc(ctx, helpArgs), true
|
||||
case "code":
|
||||
return a.webCode(ctx, helpArgs), true
|
||||
case "screenshot":
|
||||
return a.screenshot(ctx, helpArgs), true
|
||||
case "inspect":
|
||||
@ -122,8 +124,8 @@ Start Here:
|
||||
Lease a reusable box and print a cbx_... id plus friendly slug.
|
||||
crabbox run --id blue-lobster -- pnpm test:changed
|
||||
Sync this checkout to the box and run a command.
|
||||
crabbox warmup --desktop --browser
|
||||
Lease a UI-capable box with a browser.
|
||||
crabbox warmup --desktop --browser --code
|
||||
Lease a UI-capable box with a browser and web code editor.
|
||||
|
||||
Commands:
|
||||
init Onboard the current repo for Crabbox
|
||||
@ -151,6 +153,7 @@ Commands:
|
||||
ssh Print the SSH command for a lease
|
||||
vnc Print or open VNC connection details for a desktop lease
|
||||
webvnc Bridge a desktop lease into the authenticated web portal
|
||||
code Bridge a code lease into the authenticated web portal
|
||||
screenshot Capture a PNG from a desktop lease
|
||||
inspect Print lease/provider details; add --json for scripts
|
||||
stop Release a lease or delete a direct-provider machine
|
||||
@ -167,6 +170,7 @@ Common Flows:
|
||||
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
|
||||
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
|
||||
crabbox webvnc --id blue-lobster --open
|
||||
crabbox code --id blue-lobster --open
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
crabbox inspect --id blue-lobster --json
|
||||
crabbox history --lease cbx_abcdef123456
|
||||
@ -208,6 +212,7 @@ Environment:
|
||||
CRABBOX_WINDOWS_MODE normal or wsl2
|
||||
CRABBOX_DESKTOP Provision or require desktop/VNC capability
|
||||
CRABBOX_BROWSER Provision or require browser capability
|
||||
CRABBOX_CODE Provision or require web code capability
|
||||
CRABBOX_STATIC_HOST Static SSH host for provider=ssh
|
||||
CRABBOX_OWNER Usage owner override
|
||||
CRABBOX_ORG Usage org override
|
||||
|
||||
@ -438,6 +438,10 @@ func cloudInitOptionalReadyChecks(cfg Config) string {
|
||||
b.WriteString(" test -x \"$BROWSER\"\n")
|
||||
b.WriteString(" \"$BROWSER\" --version >/dev/null\n")
|
||||
}
|
||||
if cfg.Code {
|
||||
b.WriteString(" test -x /usr/local/bin/code-server\n")
|
||||
b.WriteString(" /usr/local/bin/code-server --version >/dev/null\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
@ -577,6 +581,11 @@ EOF
|
||||
chown crabbox:crabbox /var/lib/crabbox/browser.env
|
||||
chmod 0644 /var/lib/crabbox/browser.env
|
||||
fi`)
|
||||
}
|
||||
if cfg.Code {
|
||||
parts = append(parts, ` retry apt-get install -y --no-install-recommends libatomic1
|
||||
retry sh -c 'curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/usr/local'
|
||||
/usr/local/bin/code-server --version >/dev/null`)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@ import (
|
||||
const (
|
||||
desktopDisplay = ":99"
|
||||
managedVNCPort = "5900"
|
||||
managedCodePort = "8080"
|
||||
codeServerBinary = "/usr/local/bin/code-server"
|
||||
vncPasswordPath = "/var/lib/crabbox/vnc.password"
|
||||
windowsVNCPasswordPath = `C:\ProgramData\crabbox\vnc.password`
|
||||
macOSVNCPasswordPath = "/var/db/crabbox/vnc.password"
|
||||
@ -24,9 +26,10 @@ type vncEndpoint struct {
|
||||
Managed bool
|
||||
}
|
||||
|
||||
func applyCapabilityFlags(cfg *Config, desktop, browser bool) {
|
||||
func applyCapabilityFlags(cfg *Config, desktop, browser, code bool) {
|
||||
cfg.Desktop = desktop
|
||||
cfg.Browser = browser
|
||||
cfg.Code = code
|
||||
}
|
||||
|
||||
func validateRequestedCapabilities(cfg Config) error {
|
||||
@ -36,6 +39,12 @@ func validateRequestedCapabilities(cfg Config) error {
|
||||
if cfg.Browser && isBlacksmithProvider(cfg.Provider) {
|
||||
return exit(2, "browser provisioning is not supported for provider=%s; use Blacksmith workflow setup for headless browser automation", cfg.Provider)
|
||||
}
|
||||
if cfg.Code && isBlacksmithProvider(cfg.Provider) {
|
||||
return exit(2, "web code is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
|
||||
}
|
||||
if cfg.Code && cfg.TargetOS != targetLinux {
|
||||
return exit(2, "web code currently supports managed Linux leases only")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -49,6 +58,9 @@ func enforceManagedLeaseCapabilities(cfg Config, server Server, leaseID string)
|
||||
if cfg.Browser && !labelBool(server.Labels["browser"]) {
|
||||
return exit(2, "lease %s was not created with browser=true; warm a new lease with --browser", leaseID)
|
||||
}
|
||||
if cfg.Code && !labelBool(server.Labels["code"]) {
|
||||
return exit(2, "lease %s was not created with code=true; warm a new lease with --code", leaseID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ type crabboxKongCLI struct {
|
||||
Ssh sshKongCmd `cmd:"" name:"ssh" passthrough:"" help:"Print the SSH command for a lease."`
|
||||
Vnc vncKongCmd `cmd:"" name:"vnc" passthrough:"" help:"Print or open VNC connection details for a desktop lease."`
|
||||
Webvnc webvncKongCmd `cmd:"" name:"webvnc" passthrough:"" help:"Bridge a desktop lease into the authenticated web portal."`
|
||||
Code codeKongCmd `cmd:"" passthrough:"" help:"Bridge a code lease into the authenticated web portal."`
|
||||
Screenshot screenshotKongCmd `cmd:"" passthrough:"" help:"Capture a PNG from a desktop lease."`
|
||||
Inspect inspectKongCmd `cmd:"" passthrough:"" help:"Print lease/provider details; add --json for scripts."`
|
||||
Stop stopKongCmd `cmd:"" passthrough:"" help:"Release a lease or delete a direct-provider machine."`
|
||||
@ -171,6 +172,9 @@ type vncKongCmd struct {
|
||||
type webvncKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type codeKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type screenshotKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
@ -309,6 +313,7 @@ func (c *usageKongCmd) Run(ctx context.Context, app App) error { return app.u
|
||||
func (c *sshKongCmd) Run(ctx context.Context, app App) error { return app.ssh(ctx, c.Args) }
|
||||
func (c *vncKongCmd) Run(ctx context.Context, app App) error { return app.vnc(ctx, c.Args) }
|
||||
func (c *webvncKongCmd) Run(ctx context.Context, app App) error { return app.webvnc(ctx, c.Args) }
|
||||
func (c *codeKongCmd) Run(ctx context.Context, app App) error { return app.webCode(ctx, c.Args) }
|
||||
func (c *screenshotKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.screenshot(ctx, c.Args)
|
||||
}
|
||||
|
||||
428
internal/cli/code.go
Normal file
428
internal/cli/code.go
Normal file
@ -0,0 +1,428 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
type coordinatorCodeTicket struct {
|
||||
Ticket string `json:"ticket"`
|
||||
LeaseID string `json:"leaseID"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type codeProxyMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (a App) webCode(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("code", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
localPort := fs.String("local-port", "", "local code-server tunnel port")
|
||||
openPortal := fs.Bool("open", false, "open the web portal code page")
|
||||
networkFlags := registerNetworkModeFlag(fs, defaults)
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *id == "" && fs.NArg() > 0 {
|
||||
*id = fs.Arg(0)
|
||||
}
|
||||
if *id == "" {
|
||||
return exit(2, "usage: crabbox code --id <lease-id-or-slug>")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Provider = *provider
|
||||
cfg.Code = true
|
||||
if err := applyNetworkModeFlagOverride(&cfg, fs, networkFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequestedCapabilities(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
|
||||
return exit(2, "code currently supports coordinator-backed hetzner/aws Linux leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !useCoordinator || coord == nil || coord.Token == "" {
|
||||
return exit(2, "code requires a configured coordinator login; run crabbox login first")
|
||||
}
|
||||
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
|
||||
return err
|
||||
} else {
|
||||
target = resolved.Target
|
||||
}
|
||||
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
repo, err := findRepo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
|
||||
return err
|
||||
}
|
||||
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
|
||||
workdir := remoteJoin(cfg, leaseID, repo.Name)
|
||||
if err := ensureRemoteCodeServer(ctx, target, workdir); err != nil {
|
||||
return err
|
||||
}
|
||||
if *localPort == "" {
|
||||
*localPort = availableLocalCodePort()
|
||||
}
|
||||
tunnel, err := startVNCForegroundTunnel(ctx, target, *localPort, "127.0.0.1", managedCodePort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stopProcess(tunnel)
|
||||
portal := webCodePortalURL(coord.BaseURL, leaseID)
|
||||
|
||||
opened := false
|
||||
for {
|
||||
bridge, err := connectCodeBridge(ctx, coord, leaseID, "127.0.0.1", *localPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(a.Stdout, "bridge: connected; keep this process running while using Code")
|
||||
fmt.Fprintf(a.Stdout, "code: %s\n", portal)
|
||||
if *openPortal && !opened {
|
||||
if err := openLocalURL(portal); err != nil {
|
||||
bridge.Close(websocket.StatusNormalClosure, "bridge stopped")
|
||||
return err
|
||||
}
|
||||
opened = true
|
||||
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
|
||||
}
|
||||
err = bridge.Serve(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
if !isRetryableCodeBridgeError(err) {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(a.Stdout, "bridge: disconnected; reconnecting")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureRemoteCodeServer(ctx context.Context, target SSHTarget, workdir string) error {
|
||||
if err := runSSHQuiet(ctx, target, codeServerReadyCommand()); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := runSSHQuiet(ctx, target, startCodeServerCommand(workdir)); err != nil {
|
||||
return exit(5, "start code-server: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
if err := runSSHQuiet(ctx, target, codeServerReadyCommand()); err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return exit(5, "timed out waiting for code-server on 127.0.0.1:%s", managedCodePort)
|
||||
}
|
||||
|
||||
func codeServerReadyCommand() string {
|
||||
return "curl -fsS http://127.0.0.1:" + managedCodePort + "/healthz >/dev/null || curl -fsS http://127.0.0.1:" + managedCodePort + "/ >/dev/null"
|
||||
}
|
||||
|
||||
func startCodeServerCommand(workdir string) string {
|
||||
return strings.Join([]string{
|
||||
"mkdir -p " + shellQuote(workdir),
|
||||
"pkill -f '" + codeServerBinary + ".*127.0.0.1:" + managedCodePort + "' >/dev/null 2>&1 || true",
|
||||
"nohup env VSCODE_PROXY_URI='./proxy/{{port}}' " + codeServerBinary +
|
||||
" --auth none --bind-addr 127.0.0.1:" + managedCodePort +
|
||||
" --disable-telemetry --disable-update-check " + shellQuote(workdir) +
|
||||
" >/tmp/crabbox-code-server.log 2>&1 &",
|
||||
}, " && ")
|
||||
}
|
||||
|
||||
type codeBridge struct {
|
||||
ws *websocket.Conn
|
||||
baseURL string
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
upstream map[string]*websocket.Conn
|
||||
}
|
||||
|
||||
func connectCodeBridge(ctx context.Context, coord *CoordinatorClient, leaseID, host, port string) (*codeBridge, error) {
|
||||
ticket, err := coord.CreateCodeTicket(ctx, leaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws, _, err := websocket.Dial(ctx, webCodeAgentURL(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
|
||||
HTTPHeader: coord.webVNCAccessHeaders(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &codeBridge{
|
||||
ws: ws,
|
||||
baseURL: "http://" + host + ":" + port,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
upstream: map[string]*websocket.Conn{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *codeBridge) Serve(ctx context.Context) error {
|
||||
defer b.Close(websocket.StatusNormalClosure, "bridge stopped")
|
||||
for {
|
||||
_, data, err := b.ws.Read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var msg codeProxyMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "http":
|
||||
go b.handleHTTP(ctx, msg)
|
||||
case "ws_open":
|
||||
go b.openUpstreamWebSocket(ctx, msg)
|
||||
case "ws_data":
|
||||
b.writeUpstreamWebSocket(ctx, msg)
|
||||
case "ws_close":
|
||||
b.closeUpstreamWebSocket(msg.ID, websocket.StatusCode(msg.Code), msg.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *codeBridge) Close(code websocket.StatusCode, reason string) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if b.ws != nil {
|
||||
_ = b.ws.Close(code, reason)
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for id, conn := range b.upstream {
|
||||
_ = conn.Close(websocket.StatusNormalClosure, "bridge stopped")
|
||||
delete(b.upstream, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *codeBridge) handleHTTP(ctx context.Context, msg codeProxyMessage) {
|
||||
body, _ := base64.StdEncoding.DecodeString(msg.Body)
|
||||
upstream := b.baseURL + codeUpstreamPath(msg.Path)
|
||||
req, err := http.NewRequestWithContext(ctx, msg.Method, upstream, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
_ = b.writeJSON(ctx, codeProxyMessage{Type: "http", ID: msg.ID, Status: 502, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
for key, value := range msg.Headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
_ = b.writeJSON(ctx, codeProxyMessage{Type: "http", ID: msg.ID, Status: 502, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 25*1024*1024))
|
||||
if err != nil {
|
||||
_ = b.writeJSON(ctx, codeProxyMessage{Type: "http", ID: msg.ID, Status: 502, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
headers := map[string]string{}
|
||||
for key, values := range resp.Header {
|
||||
if len(values) > 0 {
|
||||
headers[key] = values[0]
|
||||
}
|
||||
}
|
||||
_ = b.writeJSON(ctx, codeProxyMessage{
|
||||
Type: "http",
|
||||
ID: msg.ID,
|
||||
Status: resp.StatusCode,
|
||||
Headers: headers,
|
||||
Body: base64.StdEncoding.EncodeToString(respBody),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *codeBridge) openUpstreamWebSocket(ctx context.Context, msg codeProxyMessage) {
|
||||
upstream := "ws" + strings.TrimPrefix(b.baseURL, "http") + codeUpstreamPath(msg.Path)
|
||||
header := http.Header{}
|
||||
for key, value := range msg.Headers {
|
||||
header.Set(key, value)
|
||||
}
|
||||
conn, _, err := websocket.Dial(ctx, upstream, &websocket.DialOptions{HTTPHeader: header})
|
||||
if err != nil {
|
||||
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_close", ID: msg.ID, Code: int(websocket.StatusInternalError), Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.upstream[msg.ID] = conn
|
||||
b.mu.Unlock()
|
||||
go b.readUpstreamWebSocket(ctx, msg.ID, conn)
|
||||
}
|
||||
|
||||
func (b *codeBridge) readUpstreamWebSocket(ctx context.Context, id string, conn *websocket.Conn) {
|
||||
for {
|
||||
_, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
reason := err.Error()
|
||||
var closeErr websocket.CloseError
|
||||
code := int(websocket.StatusNormalClosure)
|
||||
if errors.As(err, &closeErr) {
|
||||
code = int(closeErr.Code)
|
||||
reason = closeErr.Reason
|
||||
}
|
||||
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_close", ID: id, Code: code, Reason: reason})
|
||||
b.closeUpstreamWebSocket(id, websocket.StatusNormalClosure, "closed")
|
||||
return
|
||||
}
|
||||
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_data", ID: id, Body: base64.StdEncoding.EncodeToString(data)})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *codeBridge) writeUpstreamWebSocket(ctx context.Context, msg codeProxyMessage) {
|
||||
data, _ := base64.StdEncoding.DecodeString(msg.Body)
|
||||
b.mu.Lock()
|
||||
conn := b.upstream[msg.ID]
|
||||
b.mu.Unlock()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Write(ctx, websocket.MessageBinary, data)
|
||||
}
|
||||
|
||||
func (b *codeBridge) closeUpstreamWebSocket(id string, code websocket.StatusCode, reason string) {
|
||||
if code == 0 {
|
||||
code = websocket.StatusNormalClosure
|
||||
}
|
||||
b.mu.Lock()
|
||||
conn := b.upstream[id]
|
||||
delete(b.upstream, id)
|
||||
b.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close(code, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *codeBridge) writeJSON(ctx context.Context, msg codeProxyMessage) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.ws.Write(ctx, websocket.MessageText, data)
|
||||
}
|
||||
|
||||
func isRetryableCodeBridgeError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var closeErr websocket.CloseError
|
||||
if errors.As(err, &closeErr) {
|
||||
return closeErr.Code == websocket.StatusInternalError || closeErr.Code == websocket.StatusServiceRestart
|
||||
}
|
||||
return strings.Contains(err.Error(), "failed to read frame header: EOF")
|
||||
}
|
||||
|
||||
func codeUpstreamPath(path string) string {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||
if len(parts) >= 4 && parts[0] == "portal" && parts[1] == "leases" && parts[3] == "code" {
|
||||
tail := strings.Join(parts[4:], "/")
|
||||
if tail == "" {
|
||||
u.Path = "/"
|
||||
} else {
|
||||
u.Path = "/" + tail
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
func availableLocalCodePort() string {
|
||||
for port := 8081; port <= 8180; port++ {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = ln.Close()
|
||||
return fmt.Sprint(port)
|
||||
}
|
||||
return "8081"
|
||||
}
|
||||
|
||||
func webCodeAgentURL(base, leaseID, ticket string) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/code/agent"
|
||||
values := url.Values{}
|
||||
values.Set("ticket", ticket)
|
||||
u.RawQuery = values.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func webCodePortalURL(base, leaseID string) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/portal/leases/" + url.PathEscape(leaseID) + "/code/"
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) CreateCodeTicket(ctx context.Context, leaseID string) (coordinatorCodeTicket, error) {
|
||||
var res coordinatorCodeTicket
|
||||
err := c.do(ctx, http.MethodPost, "/v1/leases/"+url.PathEscape(leaseID)+"/code/ticket", map[string]any{}, &res)
|
||||
return res, err
|
||||
}
|
||||
@ -19,6 +19,7 @@ type Config struct {
|
||||
WindowsMode string
|
||||
Desktop bool
|
||||
Browser bool
|
||||
Code bool
|
||||
Network NetworkMode
|
||||
Class string
|
||||
ServerType string
|
||||
@ -229,6 +230,7 @@ type fileConfig struct {
|
||||
Windows *fileWindowsConfig `yaml:"windows,omitempty"`
|
||||
Desktop *bool `yaml:"desktop,omitempty"`
|
||||
Browser *bool `yaml:"browser,omitempty"`
|
||||
Code *bool `yaml:"code,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
Class string `yaml:"class,omitempty"`
|
||||
ServerType string `yaml:"serverType,omitempty"`
|
||||
@ -496,6 +498,9 @@ func applyFileConfig(cfg *Config, file fileConfig) {
|
||||
if file.Browser != nil {
|
||||
cfg.Browser = *file.Browser
|
||||
}
|
||||
if file.Code != nil {
|
||||
cfg.Code = *file.Code
|
||||
}
|
||||
if file.Network != "" {
|
||||
cfg.Network = NetworkMode(strings.ToLower(strings.TrimSpace(file.Network)))
|
||||
}
|
||||
@ -781,6 +786,9 @@ func applyEnv(cfg *Config) {
|
||||
if value, ok := getenvBool("CRABBOX_BROWSER"); ok {
|
||||
cfg.Browser = value
|
||||
}
|
||||
if value, ok := getenvBool("CRABBOX_CODE"); ok {
|
||||
cfg.Code = value
|
||||
}
|
||||
if network := os.Getenv("CRABBOX_NETWORK"); network != "" {
|
||||
cfg.Network = NetworkMode(strings.ToLower(strings.TrimSpace(network)))
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ type CoordinatorLease struct {
|
||||
WindowsMode string `json:"windowsMode,omitempty"`
|
||||
Desktop bool `json:"desktop,omitempty"`
|
||||
Browser bool `json:"browser,omitempty"`
|
||||
Code bool `json:"code,omitempty"`
|
||||
Tailscale *TailscaleMetadata `json:"tailscale,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Org string `json:"org"`
|
||||
@ -315,6 +316,7 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
|
||||
"windowsMode": cfg.WindowsMode,
|
||||
"desktop": cfg.Desktop,
|
||||
"browser": cfg.Browser,
|
||||
"code": cfg.Code,
|
||||
"tailscale": cfg.Tailscale.Enabled,
|
||||
"tailscaleTags": cfg.Tailscale.Tags,
|
||||
"tailscaleHostname": cfg.Tailscale.Hostname,
|
||||
@ -846,6 +848,7 @@ func leaseToServerTarget(lease CoordinatorLease, cfg Config) (Server, SSHTarget,
|
||||
"windows_mode": blank(lease.WindowsMode, cfg.WindowsMode),
|
||||
"desktop": fmt.Sprint(lease.Desktop),
|
||||
"browser": fmt.Sprint(lease.Browser),
|
||||
"code": fmt.Sprint(lease.Code),
|
||||
"expires_at": lease.ExpiresAt,
|
||||
"last_touched_at": lease.LastTouchedAt,
|
||||
"idle_timeout_secs": fmt.Sprint(lease.IdleTimeoutSeconds),
|
||||
|
||||
@ -16,11 +16,19 @@ func TestValidateCoordinatorLeaseCapabilitiesRequiresBrowserEcho(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCoordinatorLeaseCapabilitiesRequiresCodeEcho(t *testing.T) {
|
||||
err := validateCoordinatorLeaseCapabilities(Config{Code: true}, CoordinatorLease{ID: "cbx_test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected code capability mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCoordinatorLeaseCapabilitiesAcceptsRequestedCapabilities(t *testing.T) {
|
||||
err := validateCoordinatorLeaseCapabilities(Config{Desktop: true, Browser: true}, CoordinatorLease{
|
||||
err := validateCoordinatorLeaseCapabilities(Config{Desktop: true, Browser: true, Code: true}, CoordinatorLease{
|
||||
ID: "cbx_test",
|
||||
Desktop: true,
|
||||
Browser: true,
|
||||
Code: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validateCoordinatorLeaseCapabilities error: %v", err)
|
||||
|
||||
@ -17,6 +17,7 @@ type leaseCreateFlagValues struct {
|
||||
Idle *time.Duration
|
||||
Desktop *bool
|
||||
Browser *bool
|
||||
Code *bool
|
||||
Blacksmith blacksmithFlagValues
|
||||
Target targetFlagValues
|
||||
Network networkFlagValues
|
||||
@ -33,6 +34,7 @@ func registerLeaseCreateFlags(fs *flag.FlagSet, defaults Config) leaseCreateFlag
|
||||
Idle: fs.Duration("idle-timeout", defaults.IdleTimeout, "idle timeout"),
|
||||
Desktop: fs.Bool("desktop", defaults.Desktop, "provision or require a visible desktop/VNC session"),
|
||||
Browser: fs.Bool("browser", defaults.Browser, "provision or require a browser binary"),
|
||||
Code: fs.Bool("code", defaults.Code, "provision or require web code-server capability"),
|
||||
Blacksmith: registerBlacksmithFlags(fs, defaults),
|
||||
Target: registerTargetFlags(fs, defaults),
|
||||
Network: registerNetworkFlags(fs, defaults),
|
||||
@ -43,7 +45,7 @@ func applyLeaseCreateFlags(cfg *Config, fs *flag.FlagSet, values leaseCreateFlag
|
||||
cfg.Provider = *values.Provider
|
||||
cfg.Profile = *values.Profile
|
||||
cfg.Class = *values.Class
|
||||
applyCapabilityFlags(cfg, *values.Desktop, *values.Browser)
|
||||
applyCapabilityFlags(cfg, *values.Desktop, *values.Browser, *values.Code)
|
||||
if err := applyTargetFlagOverrides(cfg, fs, values.Target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -41,6 +41,9 @@ func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep
|
||||
if cfg.Browser {
|
||||
labels["browser"] = "true"
|
||||
}
|
||||
if cfg.Code {
|
||||
labels["code"] = "true"
|
||||
}
|
||||
if cfg.Tailscale.Enabled {
|
||||
labels["tailscale"] = "true"
|
||||
labels["tailscale_state"] = "requested"
|
||||
|
||||
@ -16,6 +16,7 @@ func TestDirectLeaseLabelsAreProviderSafe(t *testing.T) {
|
||||
ServerType: "cpx62",
|
||||
Desktop: true,
|
||||
Browser: true,
|
||||
Code: true,
|
||||
TTL: 15 * time.Minute,
|
||||
IdleTimeout: 4 * time.Minute,
|
||||
}
|
||||
@ -35,7 +36,7 @@ func TestDirectLeaseLabelsAreProviderSafe(t *testing.T) {
|
||||
if labels["ttl_secs"] != "900" {
|
||||
t.Fatalf("ttl_secs=%q want 900", labels["ttl_secs"])
|
||||
}
|
||||
if labels["desktop"] != "true" || labels["browser"] != "true" {
|
||||
if labels["desktop"] != "true" || labels["browser"] != "true" || labels["code"] != "true" {
|
||||
t.Fatalf("capability labels missing: %#v", labels)
|
||||
}
|
||||
if labels["expires_at"] != "1777637040" {
|
||||
|
||||
@ -732,6 +732,9 @@ func validateCoordinatorLeaseCapabilities(cfg Config, lease CoordinatorLease) er
|
||||
if cfg.Browser && !lease.Browser {
|
||||
return exit(5, "coordinator did not provision browser=true for lease %s; deploy the coordinator with browser support", blank(lease.ID, "-"))
|
||||
}
|
||||
if cfg.Code && !lease.Code {
|
||||
return exit(5, "coordinator did not provision code=true for lease %s; deploy the coordinator with web code support", blank(lease.ID, "-"))
|
||||
}
|
||||
if cfg.Tailscale.Enabled && (lease.Tailscale == nil || !lease.Tailscale.Enabled) {
|
||||
return exit(5, "coordinator did not provision tailscale=true for lease %s; deploy the coordinator with Tailscale support", blank(lease.ID, "-"))
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user