54d507b8d7
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
sprites "github.com/superfly/sprites-go"
|
|
)
|
|
|
|
const (
|
|
spriteUploadTimeout = 10 * time.Minute // bundles can be large
|
|
spriteStepTimeout = 2 * time.Minute
|
|
spriteControlRetries = 3
|
|
spriteUploadRetries = 3
|
|
spriteBackoffInit = 700 * time.Millisecond
|
|
)
|
|
|
|
func newSpriteClient(token string) *sprites.Client {
|
|
return sprites.New(token)
|
|
}
|
|
|
|
// getOrCreateSprite returns an existing sprite or creates a new one.
|
|
// Cold/sleeping sprites wake automatically when commands are issued.
|
|
func getOrCreateSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) {
|
|
var lastErr error
|
|
for attempt := 1; attempt <= spriteControlRetries; attempt++ {
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
sprite, err := client.GetSprite(stepCtx, name)
|
|
cancel()
|
|
if err == nil {
|
|
return sprite, nil
|
|
}
|
|
|
|
if isSpriteNotFound(err) {
|
|
createCtx, createCancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
sprite, err = client.CreateSprite(createCtx, name, nil)
|
|
createCancel()
|
|
if err == nil {
|
|
return sprite, nil
|
|
}
|
|
lastErr = fmt.Errorf("create sprite: %w", err)
|
|
} else {
|
|
lastErr = fmt.Errorf("get sprite: %w", err)
|
|
}
|
|
|
|
if !isTransientSpriteError(lastErr) || attempt == spriteControlRetries {
|
|
return nil, lastErr
|
|
}
|
|
if err := waitForSpriteRetry(ctx, "get/create sprite", attempt, lastErr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
func isSpriteNotFound(err error) bool {
|
|
// The SDK returns a plain fmt.Errorf("sprite not found: %s") for 404s —
|
|
// no typed error is available, so we check the message string.
|
|
return strings.Contains(err.Error(), "not found")
|
|
}
|
|
|
|
func isTransientSpriteError(err error) bool {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return true
|
|
}
|
|
|
|
var networkErr net.Error
|
|
if errors.As(err, &networkErr) && networkErr.Timeout() {
|
|
return true
|
|
}
|
|
// Temporary is deprecated on net.Error, but older transports still expose
|
|
// it. Keep compatibility through a local interface without calling the
|
|
// deprecated method on net.Error directly.
|
|
var temporaryErr interface{ Temporary() bool }
|
|
if errors.As(err, &temporaryErr) && temporaryErr.Temporary() {
|
|
return true
|
|
}
|
|
|
|
var apiErr *sprites.APIError
|
|
if errors.As(err, &apiErr) {
|
|
return apiErr.StatusCode == 429 || apiErr.StatusCode >= 500
|
|
}
|
|
return false
|
|
}
|
|
|
|
func waitForSpriteRetry(ctx context.Context, operation string, attempt int, err error) error {
|
|
delay := spriteBackoffInit * time.Duration(1<<(attempt-1))
|
|
var apiErr *sprites.APIError
|
|
if errors.As(err, &apiErr) && apiErr.GetRetryAfterSeconds() > 0 {
|
|
delay = time.Duration(apiErr.GetRetryAfterSeconds()) * time.Second
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, " %s attempt %d failed (%v), retrying in %v...\n", operation, attempt, err, delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(delay):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// uploadBundle uploads the bundle tarball to the sprite via the Filesystem API.
|
|
// Retries up to spriteUploadRetries times on transient errors with context-aware backoff.
|
|
func uploadBundle(ctx context.Context, sprite *sprites.Sprite, tarPath string) error {
|
|
data, err := os.ReadFile(tarPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read bundle: %w", err)
|
|
}
|
|
|
|
uploadCtx, cancel := context.WithTimeout(ctx, spriteUploadTimeout)
|
|
defer cancel()
|
|
|
|
backoff := spriteBackoffInit
|
|
var lastErr error
|
|
for attempt := 1; attempt <= spriteUploadRetries; attempt++ {
|
|
err := sprite.Filesystem().WriteFileContext(uploadCtx, "/tmp/kandev-preview.tar.gz", data, 0o644)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
if attempt == spriteUploadRetries || uploadCtx.Err() != nil {
|
|
break
|
|
}
|
|
fmt.Fprintf(os.Stderr, " upload attempt %d failed (%v), retrying in %v...\n", attempt, err, backoff)
|
|
select {
|
|
case <-uploadCtx.Done():
|
|
return uploadCtx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
backoff *= 2
|
|
}
|
|
return fmt.Errorf("upload bundle after %d attempts: %w", spriteUploadRetries, lastErr)
|
|
}
|
|
|
|
// extractBundle extracts the bundle tarball and writes the startup script.
|
|
func extractBundle(ctx context.Context, sprite *sprites.Sprite, port int) error {
|
|
script := buildExtractScript(port)
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
out, err := sprite.CommandContext(stepCtx, "bash", "-c", script).CombinedOutput()
|
|
if len(out) > 0 {
|
|
fmt.Fprintf(os.Stderr, "%s\n", string(out))
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("extract bundle: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildExtractScript(backendPort int) string {
|
|
return fmt.Sprintf(`set -e
|
|
tar -xzf /tmp/kandev-preview.tar.gz -C /
|
|
chmod +x /app/apps/backend/bin/kandev \
|
|
/app/apps/backend/bin/agentctl \
|
|
/app/apps/backend/bin/mock-agent
|
|
ln -sf /app/apps/backend/bin/agentctl /usr/local/bin/agentctl
|
|
ln -sf /app/apps/backend/bin/mock-agent /usr/local/bin/mock-agent
|
|
ln -sf /app/apps/backend/bin/kandev /usr/local/bin/kandev
|
|
# Reset data directory on each deploy so the DB starts fresh (preview env only).
|
|
rm -rf /data
|
|
mkdir -p /data /var/log
|
|
echo "=== kandev binary info ==="
|
|
ldd /app/apps/backend/bin/kandev 2>&1 || true
|
|
echo "==========================="
|
|
cat > /app/start-kandev.sh << 'STARTSCRIPT'
|
|
#!/bin/bash
|
|
set -e
|
|
mkdir -p /data
|
|
|
|
# Kill any agentctl orphans from previous runs.
|
|
pkill -f agentctl || true
|
|
sleep 1
|
|
cd /app
|
|
|
|
export KANDEV_HOME_DIR=/data
|
|
export KANDEV_DOCKER_ENABLED=false
|
|
export KANDEV_LOG_LEVEL=info
|
|
export KANDEV_WEB_TITLE_PREFIX=Preview
|
|
export KANDEV_WEB_DIST_DIR=/app/apps/web/dist
|
|
# Preview mode: keep the mock agent while loading the built-in agent catalogue
|
|
# so Settings can show agents that are available to install.
|
|
export KANDEV_MOCK_AGENT=true
|
|
# The preview service runs from /app, so the backend's relative dist probes do
|
|
# not reach the packaged Vite build under /app/apps/web/dist.
|
|
export KANDEV_WEB_DIST_DIR=/app/apps/web/dist
|
|
|
|
# Launch through the CLI so the backend runs under the restart supervisor.
|
|
exec kandev start \
|
|
--backend-port %d \
|
|
--verbose \
|
|
--headless \
|
|
> /var/log/kandev.log 2>&1
|
|
STARTSCRIPT
|
|
chmod +x /app/start-kandev.sh`, backendPort)
|
|
}
|
|
|
|
// deployService stops any running kandev service, registers (or updates) its
|
|
// config, then explicitly starts it. Three-step stop → create → start ensures
|
|
// the process is restarted on re-deploy and started on first deploy:
|
|
// - StopService terminates any running instance (no-op if none exists).
|
|
// - CreateService is idempotent: creates or updates the service config.
|
|
// - StartService explicitly starts the process regardless of prior state.
|
|
func deployService(ctx context.Context, sprite *sprites.Sprite, port int) error {
|
|
// Stop any running instance so StartService always spawns a fresh process.
|
|
// Errors here are best-effort — the service may not exist yet on first deploy.
|
|
stopCtx, stopCancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer stopCancel()
|
|
if stream, err := sprite.StopService(stopCtx, "kandev"); err == nil {
|
|
drainStream(stream)
|
|
}
|
|
|
|
createCtx, createCancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer createCancel()
|
|
|
|
createStream, err := sprite.CreateService(createCtx, "kandev", &sprites.ServiceRequest{
|
|
Cmd: "/app/start-kandev.sh",
|
|
HTTPPort: &port,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create service: %w", err)
|
|
}
|
|
// Drain create stream (informational only).
|
|
for {
|
|
ev, err := createStream.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
_ = createStream.Close()
|
|
return fmt.Errorf("create service stream: %w", err)
|
|
}
|
|
if ev.Type == "error" {
|
|
_ = createStream.Close()
|
|
return fmt.Errorf("create service error: %s", ev.Data)
|
|
}
|
|
}
|
|
_ = createStream.Close()
|
|
|
|
// Explicitly start the fresh service.
|
|
startCtx, startCancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer startCancel()
|
|
|
|
startStream, err := sprite.StartService(startCtx, "kandev")
|
|
if err != nil {
|
|
return fmt.Errorf("start service: %w", err)
|
|
}
|
|
defer func() { _ = startStream.Close() }()
|
|
|
|
return waitForServiceStarted(startStream)
|
|
}
|
|
|
|
// drainStream reads all events from a ServiceStream and closes it.
|
|
// Used for informational streams where we don't need to act on the content.
|
|
func drainStream(stream *sprites.ServiceStream) {
|
|
defer func() { _ = stream.Close() }()
|
|
for {
|
|
_, err := stream.Next()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitForServiceStarted(stream *sprites.ServiceStream) error {
|
|
// CreateService returns HTTP 200 when the service is registered; the stream
|
|
// carries optional progress events. EOF means the server finished streaming —
|
|
// treat it as success unless we saw an explicit failure event first.
|
|
// On existing sprites the service can start fast enough that no events arrive.
|
|
for {
|
|
event, err := stream.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("service stream: %w", err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, " [service] type=%s data=%q\n", event.Type, event.Data)
|
|
switch event.Type {
|
|
case "started", "complete":
|
|
return nil
|
|
case "error":
|
|
return fmt.Errorf("service error: %s", event.Data)
|
|
case "exit":
|
|
code := -1
|
|
if event.ExitCode != nil {
|
|
code = *event.ExitCode
|
|
}
|
|
return fmt.Errorf("service exited (code %d) before 'started'", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// kandevReadyURL returns the readiness endpoint waitForKandev polls, broken
|
|
// out so tests can pin the endpoint without going through the sprite exec/
|
|
// websocket machinery. /health only proves the bootstrap listener has bound
|
|
// the socket (it returns 200 immediately, before the real router is wired
|
|
// in); /ready is what flips once the real router is swapped in and requests
|
|
// stop getting the bootstrap handler's 503 "starting" response.
|
|
func kandevReadyURL(port int) string {
|
|
return fmt.Sprintf("http://localhost:%d/ready", port)
|
|
}
|
|
|
|
// waitForKandev polls the kandev /ready endpoint inside the sprite via
|
|
// CommandContext until kandev is ready to serve real traffic or the deadline
|
|
// is exceeded. Using the internal address (localhost) avoids Sprites routing
|
|
// state that may lag during a service restart triggered by enablePublicURL.
|
|
func waitForKandev(ctx context.Context, sprite *sprites.Sprite, port int) error {
|
|
const (
|
|
timeout = 90 * time.Second
|
|
retryWait = 3 * time.Second
|
|
)
|
|
readyURL := kandevReadyURL(port)
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
for time.Now().Before(deadline) {
|
|
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
out, err := sprite.CommandContext(checkCtx, "curl", "-sf", readyURL).Output()
|
|
cancel()
|
|
|
|
if err == nil && len(out) > 0 {
|
|
fmt.Fprintf(os.Stderr, " kandev is ready\n")
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(retryWait):
|
|
}
|
|
}
|
|
|
|
// Readiness check timed out — fetch logs to help diagnose.
|
|
diag := fetchSpriteLogs(ctx, sprite)
|
|
return fmt.Errorf("kandev did not become ready within %v\n%s", timeout, diag)
|
|
}
|
|
|
|
// fetchSpriteLogs reads log files from the sprite for failure diagnostics.
|
|
func fetchSpriteLogs(ctx context.Context, sprite *sprites.Sprite) string {
|
|
logCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
script := `echo "=== /var/log/kandev.log ==="; tail -50 /var/log/kandev.log 2>/dev/null || echo "(empty)"`
|
|
out, err := sprite.CommandContext(logCtx, "bash", "-c", script).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Sprintf("[log fetch error: %v]\n%s", err, string(out))
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// destroySprite destroys the named sprite and returns its creation time for
|
|
// runtime calculation. Returns zero time if the sprite was not found.
|
|
func destroySprite(ctx context.Context, client *sprites.Client, name string) (time.Time, error) {
|
|
getCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
sprite, err := client.GetSprite(getCtx, name)
|
|
if err != nil {
|
|
// The SDK returns a plain fmt.Errorf("sprite not found: %s") for 404s.
|
|
if strings.Contains(err.Error(), "not found") {
|
|
fmt.Fprintf(os.Stderr, "sprite %s not found, skipping destroy\n", name)
|
|
return time.Time{}, nil
|
|
}
|
|
return time.Time{}, fmt.Errorf("get sprite: %w", err)
|
|
}
|
|
createdAt := sprite.CreatedAt
|
|
|
|
destroyCtx, destroyCancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer destroyCancel()
|
|
|
|
if err := sprite.Delete(destroyCtx); err != nil {
|
|
return createdAt, fmt.Errorf("delete sprite: %w", err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "sprite %s destroyed\n", name)
|
|
return createdAt, nil
|
|
}
|