Files
kandev/apps/backend/cmd/preview/sprite_ops.go
T
2026-06-20 19:40:15 +01:00

315 lines
10 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
sprites "github.com/superfly/sprites-go"
)
const (
spriteUploadTimeout = 10 * time.Minute // bundles can be large
spriteStepTimeout = 2 * time.Minute
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) {
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
defer cancel()
sprite, err := client.GetSprite(stepCtx, name)
if err == nil {
return sprite, nil
}
// The SDK returns a plain fmt.Errorf("sprite not found: %s") for 404s —
// no typed error is available, so we check the message string.
if !strings.Contains(err.Error(), "not found") {
return nil, fmt.Errorf("get sprite: %w", err)
}
createCtx, createCancel := context.WithTimeout(ctx, spriteStepTimeout)
defer createCancel()
sprite, err = client.CreateSprite(createCtx, name, nil)
if err != nil {
return nil, fmt.Errorf("create sprite: %w", err)
}
return sprite, 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_DIST_DIR=/app/apps/web/dist
# Preview mode: only register the mock agent, suppress real agent discovery.
export KANDEV_MOCK_AGENT=only
# 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)
}
}
}
// waitForKandev polls the kandev /health endpoint inside the sprite via
// CommandContext until kandev responds 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
)
healthURL := fmt.Sprintf("http://localhost:%d/health", 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", healthURL).Output()
cancel()
if err == nil && len(out) > 0 {
fmt.Fprintf(os.Stderr, " kandev is healthy\n")
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(retryWait):
}
}
// Health check timed out — fetch logs to help diagnose.
diag := fetchSpriteLogs(ctx, sprite)
return fmt.Errorf("kandev did not become healthy 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
}