1
0
forked from wrenn/wrenn

Merge pull request 'Added metadata tracking for binaries and refactored to maintain a separate cloud version' (#28) from feat/meta-versioning into dev

Reviewed-on: wrenn/wrenn#28
This commit is contained in:
2026-04-15 15:44:20 +00:00
113 changed files with 1137 additions and 543 deletions

View File

@ -13,6 +13,7 @@ WRENN_DIR=/var/lib/wrenn
WRENN_HOST_INTERFACE=eth0 WRENN_HOST_INTERFACE=eth0
WRENN_CP_URL=http://localhost:9725 WRENN_CP_URL=http://localhost:9725
WRENN_DEFAULT_ROOTFS_SIZE=5Gi WRENN_DEFAULT_ROOTFS_SIZE=5Gi
WRENN_FIRECRACKER_BIN=/usr/local/bin/firecracker
# Auth # Auth
JWT_SECRET= JWT_SECRET=

View File

@ -4,6 +4,10 @@
DATABASE_URL ?= postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable DATABASE_URL ?= postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable
GOBIN := $(shell pwd)/builds GOBIN := $(shell pwd)/builds
ENVD_DIR := envd ENVD_DIR := envd
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
VERSION_CP := $(shell cat VERSION_CP 2>/dev/null | tr -d '[:space:]' || echo "0.0.0-dev")
VERSION_AGENT := $(shell cat VERSION_AGENT 2>/dev/null | tr -d '[:space:]' || echo "0.0.0-dev")
VERSION_ENVD := $(shell cat envd/VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.0-dev")
LDFLAGS := -s -w LDFLAGS := -s -w
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
@ -17,14 +21,14 @@ build-frontend:
cd frontend && pnpm install --frozen-lockfile && pnpm build cd frontend && pnpm install --frozen-lockfile && pnpm build
build-cp: build-cp:
go build -v -ldflags="$(LDFLAGS)" -o $(GOBIN)/wrenn-cp ./cmd/control-plane go build -v -ldflags="$(LDFLAGS) -X main.version=$(VERSION_CP) -X main.commit=$(COMMIT)" -o $(GOBIN)/wrenn-cp ./cmd/control-plane
build-agent: build-agent:
go build -v -ldflags="$(LDFLAGS)" -o $(GOBIN)/wrenn-agent ./cmd/host-agent go build -v -ldflags="$(LDFLAGS) -X main.version=$(VERSION_AGENT) -X main.commit=$(COMMIT)" -o $(GOBIN)/wrenn-agent ./cmd/host-agent
build-envd: build-envd:
cd $(ENVD_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ cd $(ENVD_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="$(LDFLAGS)" -o $(GOBIN)/envd . go build -ldflags="$(LDFLAGS) -X main.Version=$(VERSION_ENVD) -X main.commitSHA=$(COMMIT)" -o $(GOBIN)/envd .
@file $(GOBIN)/envd | grep -q "statically linked" || \ @file $(GOBIN)/envd | grep -q "statically linked" || \
(echo "ERROR: envd is not statically linked!" && exit 1) (echo "ERROR: envd is not statically linked!" && exit 1)

1
VERSION_AGENT Normal file
View File

@ -0,0 +1 @@
0.1.0

1
VERSION_CP Normal file
View File

@ -0,0 +1 @@
0.1.0

View File

@ -1,191 +1,15 @@
package main package main
import ( import "git.omukk.dev/wrenn/wrenn/pkg/cpserver"
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool" // Set via -ldflags at build time.
"github.com/redis/go-redis/v9" var (
version = "dev"
"git.omukk.dev/wrenn/wrenn/internal/api" commit = "unknown"
"git.omukk.dev/wrenn/wrenn/internal/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth"
"git.omukk.dev/wrenn/wrenn/internal/auth/oauth"
"git.omukk.dev/wrenn/wrenn/internal/channels"
"git.omukk.dev/wrenn/wrenn/internal/config"
"git.omukk.dev/wrenn/wrenn/internal/db"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
"git.omukk.dev/wrenn/wrenn/internal/scheduler"
) )
func main() { func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ cpserver.Run(
Level: slog.LevelDebug, cpserver.WithVersion(version, commit),
}))) )
cfg := config.Load()
if len(cfg.JWTSecret) < 32 {
slog.Error("JWT_SECRET must be at least 32 characters")
os.Exit(1)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Database connection pool.
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
slog.Error("failed to ping database", "error", err)
os.Exit(1)
}
slog.Info("connected to database")
queries := db.New(pool)
// Redis client.
redisOpts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
slog.Error("failed to parse REDIS_URL", "error", err)
os.Exit(1)
}
rdb := redis.NewClient(redisOpts)
defer rdb.Close()
if err := rdb.Ping(ctx).Err(); err != nil {
slog.Error("failed to ping redis", "error", err)
os.Exit(1)
}
slog.Info("connected to redis")
// mTLS is mandatory — parse internal CA for CP↔agent communication.
if cfg.CACert == "" || cfg.CAKey == "" {
slog.Error("WRENN_CA_CERT and WRENN_CA_KEY are required — mTLS is mandatory for CP↔agent communication")
os.Exit(1)
}
ca, err := auth.ParseCA(cfg.CACert, cfg.CAKey)
if err != nil {
slog.Error("failed to parse mTLS CA from environment", "error", err)
os.Exit(1)
}
slog.Info("mTLS enabled: CA loaded")
// Host client pool — manages Connect RPC clients to host agents.
cpCertStore, err := auth.NewCPCertStore(ca)
if err != nil {
slog.Error("failed to issue CP client certificate", "error", err)
os.Exit(1)
}
// Renew the CP client certificate periodically so it never expires
// while the control plane is running (TTL = 24h, renewal = every 12h).
go func() {
ticker := time.NewTicker(auth.CPCertRenewInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := cpCertStore.Refresh(); err != nil {
slog.Error("failed to renew CP client certificate", "error", err)
} else {
slog.Info("CP client certificate renewed")
}
}
}
}()
hostPool := lifecycle.NewHostClientPoolTLS(auth.CPClientTLSConfig(ca, cpCertStore))
slog.Info("host client pool: mTLS enabled")
// Scheduler — picks a host for each new sandbox (least-loaded, bottleneck-first).
hostScheduler := scheduler.NewLeastLoadedScheduler(queries)
// OAuth provider registry.
oauthRegistry := oauth.NewRegistry()
if cfg.OAuthGitHubClientID != "" && cfg.OAuthGitHubClientSecret != "" {
if cfg.CPPublicURL == "" {
slog.Error("CP_PUBLIC_URL must be set when OAuth providers are configured")
os.Exit(1)
}
callbackURL := strings.TrimRight(cfg.CPPublicURL, "/") + "/auth/oauth/github/callback"
ghProvider := oauth.NewGitHubProvider(cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret, callbackURL)
oauthRegistry.Register(ghProvider)
slog.Info("registered OAuth provider", "provider", "github")
}
// Channels: publisher, service, dispatcher.
if len(cfg.EncryptionKeyHex) != 64 {
slog.Error("WRENN_ENCRYPTION_KEY must be a hex-encoded 32-byte key (64 hex chars)")
os.Exit(1)
}
channelPub := channels.NewPublisher(rdb)
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey}
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey)
// Shared audit logger with event publishing.
al := audit.NewWithPublisher(queries, channelPub)
// API server.
srv := api.New(queries, hostPool, hostScheduler, pool, rdb, []byte(cfg.JWTSecret), oauthRegistry, cfg.OAuthRedirectURL, ca, al, channelSvc)
// Start template build workers (2 concurrent).
stopBuildWorkers := srv.BuildSvc.StartWorkers(ctx, 2)
defer stopBuildWorkers()
// Start channel event dispatcher.
channelDispatcher.Start(ctx)
// Start host monitor (passive + active reconciliation every 30s).
monitor := api.NewHostMonitor(queries, hostPool, al, 30*time.Second)
monitor.Start(ctx)
// Start metrics sampler (records per-team sandbox stats every 10s).
sampler := api.NewMetricsSampler(queries, 10*time.Second)
sampler.Start(ctx)
// Wrap the API handler with the sandbox proxy so that requests with
// {port}-{sandbox_id}.{domain} Host headers are routed to the sandbox's
// host agent. All other requests pass through to the normal API router.
proxyWrapper := api.NewSandboxProxyWrapper(srv.Handler(), queries, hostPool)
httpServer := &http.Server{
Addr: cfg.ListenAddr,
Handler: proxyWrapper,
}
// Graceful shutdown on signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
slog.Info("received signal, shutting down", "signal", sig)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("http server shutdown error", "error", err)
}
}()
slog.Info("control plane starting", "addr", cfg.ListenAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("http server error", "error", err)
os.Exit(1)
}
slog.Info("control plane stopped")
} }

View File

@ -15,14 +15,21 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
"git.omukk.dev/wrenn/wrenn/internal/auth"
"git.omukk.dev/wrenn/wrenn/internal/devicemapper" "git.omukk.dev/wrenn/wrenn/internal/devicemapper"
"git.omukk.dev/wrenn/wrenn/internal/hostagent" "git.omukk.dev/wrenn/wrenn/internal/hostagent"
"git.omukk.dev/wrenn/wrenn/internal/layout"
"git.omukk.dev/wrenn/wrenn/internal/network" "git.omukk.dev/wrenn/wrenn/internal/network"
"git.omukk.dev/wrenn/wrenn/internal/sandbox" "git.omukk.dev/wrenn/wrenn/internal/sandbox"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
) )
// Set via -ldflags at build time.
var (
version = "dev"
commit = "unknown"
)
func main() { func main() {
// Best-effort load — missing .env file is fine. // Best-effort load — missing .env file is fine.
_ = godotenv.Load() _ = godotenv.Load()
@ -82,9 +89,31 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Resolve latest kernel version.
kernelPath, kernelVersion, err := layout.LatestKernel(rootDir)
if err != nil {
slog.Error("failed to find kernel", "error", err)
os.Exit(1)
}
slog.Info("resolved kernel", "version", kernelVersion, "path", kernelPath)
// Detect firecracker version.
fcBin := envOrDefault("WRENN_FIRECRACKER_BIN", "/usr/local/bin/firecracker")
fcVersion, err := sandbox.DetectFirecrackerVersion(fcBin)
if err != nil {
slog.Error("failed to detect firecracker version", "error", err)
os.Exit(1)
}
slog.Info("resolved firecracker", "version", fcVersion, "path", fcBin)
cfg := sandbox.Config{ cfg := sandbox.Config{
WrennDir: rootDir, WrennDir: rootDir,
DefaultRootfsSizeMB: defaultRootfsSizeMB, DefaultRootfsSizeMB: defaultRootfsSizeMB,
KernelPath: kernelPath,
KernelVersion: kernelVersion,
FirecrackerBin: fcBin,
FirecrackerVersion: fcVersion,
AgentVersion: version,
} }
mgr := sandbox.New(cfg) mgr := sandbox.New(cfg)
@ -193,7 +222,7 @@ func main() {
doShutdown("signal: " + sig.String()) doShutdown("signal: " + sig.String())
}() }()
slog.Info("host agent starting", "addr", listenAddr, "host_id", creds.HostID) slog.Info("host agent starting", "addr", listenAddr, "host_id", creds.HostID, "version", version, "commit", commit)
// TLSConfig is always set (mTLS is mandatory). Create the TLS listener // TLSConfig is always set (mTLS is mandatory). Create the TLS listener
// manually because ListenAndServeTLS requires on-disk cert/key paths // manually because ListenAndServeTLS requires on-disk cert/key paths
// but we use GetCertificate callback for hot-swap support. // but we use GetCertificate callback for hot-swap support.

View File

@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE sandboxes ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}';
ALTER TABLE templates ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}';
ALTER TABLE template_builds ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE sandboxes DROP COLUMN metadata;
ALTER TABLE templates DROP COLUMN metadata;
ALTER TABLE template_builds DROP COLUMN metadata;

10
db/migrations/embed.go Normal file
View File

@ -0,0 +1,10 @@
// Package migrations embeds the SQL migration files so that external modules
// (such as the enterprise edition) can access them programmatically.
package migrations
import "embed"
// FS contains all SQL migration files.
//
//go:embed *.sql
var FS embed.FS

View File

@ -1,6 +1,6 @@
-- name: InsertSandbox :one -- name: InsertSandbox :one
INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, template_id, template_team_id) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, template_id, template_team_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *; RETURNING *;
-- name: GetSandbox :one -- name: GetSandbox :one
@ -74,6 +74,12 @@ SET status = 'missing',
last_updated = NOW() last_updated = NOW()
WHERE host_id = $1 AND status IN ('running', 'starting', 'pending'); WHERE host_id = $1 AND status IN ('running', 'starting', 'pending');
-- name: UpdateSandboxMetadata :exec
UPDATE sandboxes
SET metadata = $2,
last_updated = NOW()
WHERE id = $1;
-- name: BulkRestoreRunning :exec -- name: BulkRestoreRunning :exec
-- Called by the reconciler when a host comes back online and its sandboxes are -- Called by the reconciler when a host comes back online and its sandboxes are
-- confirmed alive. Restores only sandboxes that are in 'missing' state. -- confirmed alive. Restores only sandboxes that are in 'missing' state.

View File

@ -34,5 +34,5 @@ WHERE id = $1;
-- name: UpdateBuildDefaults :exec -- name: UpdateBuildDefaults :exec
UPDATE template_builds UPDATE template_builds
SET default_user = $2, default_env = $3 SET default_user = $2, default_env = $3, metadata = $4
WHERE id = $1; WHERE id = $1;

View File

@ -1,6 +1,6 @@
-- name: InsertTemplate :one -- name: InsertTemplate :one
INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env) INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *; RETURNING *;
-- name: GetTemplate :one -- name: GetTemplate :one

1
envd/VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

View File

@ -99,7 +99,7 @@ func TestGetFilesContentDisposition(t *testing.T) {
EnvVars: utils.NewMap[string, string](), EnvVars: utils.NewMap[string, string](),
User: currentUser.Username, User: currentUser.Username,
} }
api := New(&logger, defaults, nil, false, context.Background(), nil) api := New(&logger, defaults, nil, false, context.Background(), nil, "test")
// Create request and response recorder // Create request and response recorder
req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil)
@ -148,7 +148,7 @@ func TestGetFilesContentDispositionWithNestedPath(t *testing.T) {
EnvVars: utils.NewMap[string, string](), EnvVars: utils.NewMap[string, string](),
User: currentUser.Username, User: currentUser.Username,
} }
api := New(&logger, defaults, nil, false, context.Background(), nil) api := New(&logger, defaults, nil, false, context.Background(), nil, "test")
// Create request and response recorder // Create request and response recorder
req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil)
@ -191,7 +191,7 @@ func TestGetFiles_GzipEncoding_ExplicitIdentityOffWithRange(t *testing.T) {
EnvVars: utils.NewMap[string, string](), EnvVars: utils.NewMap[string, string](),
User: currentUser.Username, User: currentUser.Username,
} }
api := New(&logger, defaults, nil, false, context.Background(), nil) api := New(&logger, defaults, nil, false, context.Background(), nil, "test")
// Create request and response recorder // Create request and response recorder
req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil)
@ -232,7 +232,7 @@ func TestGetFiles_GzipDownload(t *testing.T) {
EnvVars: utils.NewMap[string, string](), EnvVars: utils.NewMap[string, string](),
User: currentUser.Username, User: currentUser.Username,
} }
api := New(&logger, defaults, nil, false, context.Background(), nil) api := New(&logger, defaults, nil, false, context.Background(), nil, "test")
req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil)
req.Header.Set("Accept-Encoding", "gzip") req.Header.Set("Accept-Encoding", "gzip")
@ -297,7 +297,7 @@ func TestPostFiles_GzipUpload(t *testing.T) {
EnvVars: utils.NewMap[string, string](), EnvVars: utils.NewMap[string, string](),
User: currentUser.Username, User: currentUser.Username,
} }
api := New(&logger, defaults, nil, false, context.Background(), nil) api := New(&logger, defaults, nil, false, context.Background(), nil, "test")
req := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf) req := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf)
req.Header.Set("Content-Type", mpWriter.FormDataContentType()) req.Header.Set("Content-Type", mpWriter.FormDataContentType())
@ -357,7 +357,7 @@ func TestGzipUploadThenGzipDownload(t *testing.T) {
EnvVars: utils.NewMap[string, string](), EnvVars: utils.NewMap[string, string](),
User: currentUser.Username, User: currentUser.Username,
} }
api := New(&logger, defaults, nil, false, context.Background(), nil) api := New(&logger, defaults, nil, false, context.Background(), nil, "test")
uploadReq := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf) uploadReq := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf)
uploadReq.Header.Set("Content-Type", mpWriter.FormDataContentType()) uploadReq.Header.Set("Content-Type", mpWriter.FormDataContentType())

View File

@ -79,7 +79,7 @@ func newTestAPI(accessToken *SecureToken, mmdsClient MMDSClient) *API {
defaults := &execcontext.Defaults{ defaults := &execcontext.Defaults{
EnvVars: utils.NewMap[string, string](), EnvVars: utils.NewMap[string, string](),
} }
api := New(&logger, defaults, nil, false, context.Background(), nil) api := New(&logger, defaults, nil, false, context.Background(), nil, "test")
if accessToken != nil { if accessToken != nil {
api.accessToken.TakeFrom(accessToken) api.accessToken.TakeFrom(accessToken)
} }

View File

@ -34,6 +34,7 @@ type API struct {
logger *zerolog.Logger logger *zerolog.Logger
accessToken *SecureToken accessToken *SecureToken
defaults *execcontext.Defaults defaults *execcontext.Defaults
version string
mmdsChan chan *host.MMDSOpts mmdsChan chan *host.MMDSOpts
hyperloopLock sync.Mutex hyperloopLock sync.Mutex
@ -48,7 +49,7 @@ type API struct {
portSubsystem *publicport.PortSubsystem portSubsystem *publicport.PortSubsystem
} }
func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host.MMDSOpts, isNotFC bool, rootCtx context.Context, portSubsystem *publicport.PortSubsystem) *API { func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host.MMDSOpts, isNotFC bool, rootCtx context.Context, portSubsystem *publicport.PortSubsystem, version string) *API {
return &API{ return &API{
logger: l, logger: l,
defaults: defaults, defaults: defaults,
@ -59,6 +60,7 @@ func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host.
accessToken: &SecureToken{}, accessToken: &SecureToken{},
rootCtx: rootCtx, rootCtx: rootCtx,
portSubsystem: portSubsystem, portSubsystem: portSubsystem,
version: version,
} }
} }
@ -68,9 +70,11 @@ func (a *API) GetHealth(w http.ResponseWriter, r *http.Request) {
a.logger.Trace().Msg("Health check") a.logger.Trace().Msg("Health check")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNoContent) _ = json.NewEncoder(w).Encode(map[string]string{
"version": a.version,
})
} }
func (a *API) GetMetrics(w http.ResponseWriter, r *http.Request) { func (a *API) GetMetrics(w http.ResponseWriter, r *http.Request) {

View File

@ -50,7 +50,7 @@ const (
) )
var ( var (
Version = "0.5.4" Version = "0.1.0"
commitSHA string commitSHA string
@ -197,7 +197,7 @@ func main() {
portSubsystem.Start(ctx) portSubsystem.Start(ctx)
defer portSubsystem.Stop() defer portSubsystem.Stop()
service := api.New(&envLogger, defaults, mmdsChan, isNotFC, ctx, portSubsystem) service := api.New(&envLogger, defaults, mmdsChan, isNotFC, ctx, portSubsystem, Version)
handler := api.HandlerFromMux(service, m) handler := api.HandlerFromMux(service, m)
middleware := authn.NewMiddleware(permissions.AuthenticateUsername) middleware := authn.NewMiddleware(permissions.AuthenticateUsername)

View File

@ -6,8 +6,8 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
) )

View File

@ -17,9 +17,9 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
) )
// Sentinel errors returned by proxyTarget, used to map to HTTP status codes // Sentinel errors returned by proxyTarget, used to map to HTTP status codes

View File

@ -11,13 +11,13 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
"git.omukk.dev/wrenn/wrenn/internal/validate" "git.omukk.dev/wrenn/wrenn/pkg/validate"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )
@ -224,6 +224,7 @@ func (h *adminCapsuleHandler) Snapshot(w http.ResponseWriter, r *http.Request) {
TeamID: id.PlatformTeamID, TeamID: id.PlatformTeamID,
DefaultUser: "root", DefaultUser: "root",
DefaultEnv: []byte("{}"), DefaultEnv: []byte("{}"),
Metadata: sb.Metadata,
}) })
if err != nil { if err != nil {
slog.Error("failed to insert template record", "name", req.Name, "error", err) slog.Error("failed to insert template record", "name", req.Name, "error", err)

View File

@ -6,11 +6,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
) )
type apiKeyHandler struct { type apiKeyHandler struct {

View File

@ -8,9 +8,9 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
) )
type auditHandler struct { type auditHandler struct {

View File

@ -12,9 +12,9 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// loginTeam returns the team and role to stamp into a login JWT. // loginTeam returns the team and role to stamp into a login JWT.

View File

@ -12,12 +12,12 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/db"
"git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/layout" "git.omukk.dev/wrenn/wrenn/internal/layout"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/validate" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
"git.omukk.dev/wrenn/wrenn/pkg/service"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -8,11 +8,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/channels" "git.omukk.dev/wrenn/wrenn/pkg/channels"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
type channelHandler struct { type channelHandler struct {

View File

@ -12,10 +12,10 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -12,10 +12,10 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -9,10 +9,10 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -10,10 +10,10 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -6,10 +6,10 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -10,11 +10,11 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
) )
type hostHandler struct { type hostHandler struct {

View File

@ -9,10 +9,10 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -16,10 +16,10 @@ import (
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/auth/oauth" "git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
type oauthHandler struct { type oauthHandler struct {

View File

@ -12,10 +12,10 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -14,10 +14,10 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
) )

View File

@ -7,11 +7,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
) )
type sandboxHandler struct { type sandboxHandler struct {
@ -31,18 +31,19 @@ type createSandboxRequest struct {
} }
type sandboxResponse struct { type sandboxResponse struct {
ID string `json:"id"` ID string `json:"id"`
Status string `json:"status"` Status string `json:"status"`
Template string `json:"template"` Template string `json:"template"`
VCPUs int32 `json:"vcpus"` VCPUs int32 `json:"vcpus"`
MemoryMB int32 `json:"memory_mb"` MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"` TimeoutSec int32 `json:"timeout_sec"`
GuestIP string `json:"guest_ip,omitempty"` GuestIP string `json:"guest_ip,omitempty"`
HostIP string `json:"host_ip,omitempty"` HostIP string `json:"host_ip,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
StartedAt *string `json:"started_at,omitempty"` StartedAt *string `json:"started_at,omitempty"`
LastActiveAt *string `json:"last_active_at,omitempty"` LastActiveAt *string `json:"last_active_at,omitempty"`
LastUpdated string `json:"last_updated"` LastUpdated string `json:"last_updated"`
Metadata map[string]string `json:"metadata,omitempty"`
} }
func sandboxToResponse(sb db.Sandbox) sandboxResponse { func sandboxToResponse(sb db.Sandbox) sandboxResponse {
@ -56,6 +57,12 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse {
GuestIP: sb.GuestIp, GuestIP: sb.GuestIp,
HostIP: sb.HostIp, HostIP: sb.HostIp,
} }
if len(sb.Metadata) > 0 {
var meta map[string]string
if err := json.Unmarshal(sb.Metadata, &meta); err == nil && len(meta) > 0 {
resp.Metadata = meta
}
}
if sb.CreatedAt.Valid { if sb.CreatedAt.Valid {
resp.CreatedAt = sb.CreatedAt.Time.Format(time.RFC3339) resp.CreatedAt = sb.CreatedAt.Time.Format(time.RFC3339)
} }

View File

@ -13,14 +13,14 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth"
"git.omukk.dev/wrenn/wrenn/internal/db"
"git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/layout" "git.omukk.dev/wrenn/wrenn/internal/layout"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/validate" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
"git.omukk.dev/wrenn/wrenn/pkg/service"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )
@ -69,13 +69,14 @@ type createSnapshotRequest struct {
} }
type snapshotResponse struct { type snapshotResponse struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
VCPUs *int32 `json:"vcpus,omitempty"` VCPUs *int32 `json:"vcpus,omitempty"`
MemoryMB *int32 `json:"memory_mb,omitempty"` MemoryMB *int32 `json:"memory_mb,omitempty"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
Platform bool `json:"platform"` Platform bool `json:"platform"`
Metadata map[string]string `json:"metadata,omitempty"`
} }
func templateToResponse(t db.Template) snapshotResponse { func templateToResponse(t db.Template) snapshotResponse {
@ -94,6 +95,12 @@ func templateToResponse(t db.Template) snapshotResponse {
if t.CreatedAt.Valid { if t.CreatedAt.Valid {
resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339) resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339)
} }
if len(t.Metadata) > 0 {
var meta map[string]string
if err := json.Unmarshal(t.Metadata, &meta); err == nil && len(meta) > 0 {
resp.Metadata = meta
}
}
return resp return resp
} }
@ -219,6 +226,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
TeamID: ac.TeamID, TeamID: ac.TeamID,
DefaultUser: "root", DefaultUser: "root",
DefaultEnv: []byte("{}"), DefaultEnv: []byte("{}"),
Metadata: sb.Metadata,
}) })
if err != nil { if err != nil {
slog.Error("failed to insert template record", "name", req.Name, "error", err) slog.Error("failed to insert template record", "name", req.Name, "error", err)

View File

@ -5,8 +5,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
) )
type statsHandler struct { type statsHandler struct {

View File

@ -10,11 +10,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
) )
type teamHandler struct { type teamHandler struct {

View File

@ -9,10 +9,10 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/service"
) )
type usersHandler struct { type usersHandler struct {

View File

@ -8,10 +8,10 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
) )

View File

@ -5,7 +5,7 @@ import (
"log/slog" "log/slog"
"time" "time"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
) )
// MetricsSampler records per-team sandbox resource usage to // MetricsSampler records per-team sandbox resource usage to

View File

@ -14,7 +14,7 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
type errorResponse struct { type errorResponse struct {

View File

@ -3,9 +3,9 @@ package api
import ( import (
"net/http" "net/http"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// injectPlatformTeam overwrites the AuthContext's TeamID with the platform // injectPlatformTeam overwrites the AuthContext's TeamID with the platform

View File

@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// requireAPIKeyOrJWT accepts either X-API-Key header or Authorization: Bearer JWT. // requireAPIKeyOrJWT accepts either X-API-Key header or Authorization: Bearer JWT.

View File

@ -3,8 +3,8 @@ package api
import ( import (
"net/http" "net/http"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// requireHostToken validates the X-Host-Token header containing a host JWT, // requireHostToken validates the X-Host-Token header containing a host JWT,

View File

@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// requireJWT validates a JWT from the Authorization: Bearer header or the // requireJWT validates a JWT from the Authorization: Bearer header or the

View File

@ -9,14 +9,15 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/audit" "git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/auth/oauth" "git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/internal/channels" "git.omukk.dev/wrenn/wrenn/pkg/channels"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/cpextension"
"git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/scheduler" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
"git.omukk.dev/wrenn/wrenn/internal/service" "git.omukk.dev/wrenn/wrenn/pkg/scheduler"
"git.omukk.dev/wrenn/wrenn/pkg/service"
) )
//go:embed openapi.yaml //go:embed openapi.yaml
@ -29,6 +30,8 @@ type Server struct {
} }
// New constructs the chi router and registers all routes. // New constructs the chi router and registers all routes.
// Extensions are called after core routes are registered, allowing enterprise
// or third-party code to add routes and middleware.
func New( func New(
queries *db.Queries, queries *db.Queries,
pool *lifecycle.HostClientPool, pool *lifecycle.HostClientPool,
@ -41,6 +44,8 @@ func New(
ca *auth.CA, ca *auth.CA,
al *audit.AuditLogger, al *audit.AuditLogger,
channelSvc *channels.Service, channelSvc *channels.Service,
extensions []cpextension.Extension,
sctx cpextension.ServerContext,
) *Server { ) *Server {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(requestLogger()) r.Use(requestLogger())
@ -239,6 +244,11 @@ func New(
}) })
}) })
// Let extensions register their routes after all core routes.
for _, ext := range extensions {
ext.RegisterRoutes(r, sctx)
}
return &Server{router: r, BuildSvc: buildSvc} return &Server{router: r, BuildSvc: buildSvc}
} }
@ -247,6 +257,11 @@ func (s *Server) Handler() http.Handler {
return s.router return s.router
} }
// Router returns the underlying chi.Router for direct access.
func (s *Server) Router() chi.Router {
return s.router
}
func serveOpenAPI(w http.ResponseWriter, r *http.Request) { func serveOpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml") w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write(openapiYAML) _, _ = w.Write(openapiYAML)

View File

@ -2,7 +2,9 @@ package envdclient
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@ -31,6 +33,38 @@ func (c *Client) WaitUntilReady(ctx context.Context) error {
} }
} }
// FetchVersion queries envd's health endpoint and returns the reported version.
func (c *Client) FetchVersion(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.healthURL, nil)
if err != nil {
return "", fmt.Errorf("build health request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch envd version: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return "", fmt.Errorf("health check returned %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil || len(body) == 0 {
return "", nil // envd may not support version reporting yet
}
var data struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &data); err != nil {
return "", nil // non-JSON response, old envd
}
return data.Version, nil
}
// healthCheck sends a single GET /health request to envd. // healthCheck sends a single GET /health request to envd.
func (c *Client) healthCheck(ctx context.Context) error { func (c *Client) healthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.healthURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.healthURL, nil)

View File

@ -80,6 +80,7 @@ func (s *Server) CreateSandbox(
SandboxId: sb.ID, SandboxId: sb.ID,
Status: string(sb.Status), Status: string(sb.Status),
HostIp: sb.HostIP.String(), HostIp: sb.HostIP.String(),
Metadata: sb.Metadata,
}), nil }), nil
} }
@ -108,7 +109,7 @@ func (s *Server) ResumeSandbox(
req *connect.Request[pb.ResumeSandboxRequest], req *connect.Request[pb.ResumeSandboxRequest],
) (*connect.Response[pb.ResumeSandboxResponse], error) { ) (*connect.Response[pb.ResumeSandboxResponse], error) {
msg := req.Msg msg := req.Msg
sb, err := s.mgr.Resume(ctx, msg.SandboxId, int(msg.TimeoutSec)) sb, err := s.mgr.Resume(ctx, msg.SandboxId, int(msg.TimeoutSec), msg.KernelVersion)
if err != nil { if err != nil {
return nil, connect.NewError(connect.CodeInternal, err) return nil, connect.NewError(connect.CodeInternal, err)
} }
@ -124,6 +125,7 @@ func (s *Server) ResumeSandbox(
SandboxId: sb.ID, SandboxId: sb.ID,
Status: string(sb.Status), Status: string(sb.Status),
HostIp: sb.HostIP.String(), HostIp: sb.HostIP.String(),
Metadata: sb.Metadata,
}), nil }), nil
} }
@ -564,6 +566,7 @@ func (s *Server) ListSandboxes(
CreatedAtUnix: sb.CreatedAt.Unix(), CreatedAtUnix: sb.CreatedAt.Unix(),
LastActiveAtUnix: sb.LastActiveAt.Unix(), LastActiveAtUnix: sb.LastActiveAt.Unix(),
TimeoutSec: int32(sb.TimeoutSec), TimeoutSec: int32(sb.TimeoutSec),
Metadata: sb.Metadata,
} }
} }

View File

@ -1,11 +1,15 @@
package layout package layout
import ( import (
"fmt"
"os"
"path/filepath" "path/filepath"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// IsMinimal reports whether the given team and template IDs represent the // IsMinimal reports whether the given team and template IDs represent the
@ -47,6 +51,75 @@ func KernelPath(wrennDir string) string {
return filepath.Join(wrennDir, "kernels", "vmlinux") return filepath.Join(wrennDir, "kernels", "vmlinux")
} }
// KernelPathVersioned returns the path to a specific kernel version.
func KernelPathVersioned(wrennDir, version string) string {
return filepath.Join(wrennDir, "kernels", "vmlinux-"+version)
}
// LatestKernel scans the kernels directory for files matching vmlinux-{semver}
// and returns the path and version of the latest one (by semver sort).
func LatestKernel(wrennDir string) (path, version string, err error) {
dir := filepath.Join(wrennDir, "kernels")
return latestVersionedFile(dir, "vmlinux-")
}
// latestVersionedFile scans dir for files with the given prefix, extracts the
// version suffix, sorts by semver, and returns the path and version of the latest.
func latestVersionedFile(dir, prefix string) (path, version string, err error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", "", fmt.Errorf("read directory %s: %w", dir, err)
}
var versions []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if v, ok := strings.CutPrefix(name, prefix); ok && v != "" {
versions = append(versions, v)
}
}
if len(versions) == 0 {
return "", "", fmt.Errorf("no %s* files found in %s", prefix, dir)
}
sort.Slice(versions, func(i, j int) bool {
return compareSemver(versions[i], versions[j]) < 0
})
latest := versions[len(versions)-1]
return filepath.Join(dir, prefix+latest), latest, nil
}
// compareSemver compares two dotted-numeric version strings.
// Returns -1 if a < b, 0 if equal, 1 if a > b.
func compareSemver(a, b string) int {
aParts := strings.Split(a, ".")
bParts := strings.Split(b, ".")
maxLen := max(len(aParts), len(bParts))
for i := 0; i < maxLen; i++ {
var av, bv int
if i < len(aParts) {
_, _ = fmt.Sscanf(aParts[i], "%d", &av)
}
if i < len(bParts) {
_, _ = fmt.Sscanf(bParts[i], "%d", &bv)
}
if av < bv {
return -1
}
if av > bv {
return 1
}
}
return 0
}
// ImagesRoot returns the root images directory. // ImagesRoot returns the root images directory.
func ImagesRoot(wrennDir string) string { func ImagesRoot(wrennDir string) string {
return filepath.Join(wrennDir, "images") return filepath.Join(wrennDir, "images")

View File

@ -6,7 +6,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
func TestIsMinimal(t *testing.T) { func TestIsMinimal(t *testing.T) {

View File

@ -30,4 +30,5 @@ type Sandbox struct {
RootfsPath string RootfsPath string
CreatedAt time.Time CreatedAt time.Time
LastActiveAt time.Time LastActiveAt time.Time
Metadata map[string]string
} }

View File

@ -0,0 +1,30 @@
package sandbox
import (
"fmt"
"os/exec"
"strings"
)
// DetectFirecrackerVersion runs the firecracker binary with --version and
// parses the semver from the output (e.g. "Firecracker v1.14.1" → "1.14.1").
func DetectFirecrackerVersion(binaryPath string) (string, error) {
out, err := exec.Command(binaryPath, "--version").Output()
if err != nil {
return "", fmt.Errorf("run %s --version: %w", binaryPath, err)
}
// Output is typically "Firecracker v1.14.1\n" or similar.
line := strings.TrimSpace(string(out))
for _, field := range strings.Fields(line) {
v := strings.TrimPrefix(field, "v")
if v != field || strings.Contains(field, ".") {
// Either had a "v" prefix or contains a dot — likely the version.
if strings.Count(v, ".") >= 1 {
return v, nil
}
}
}
return "", fmt.Errorf("could not parse version from firecracker output: %q", line)
}

View File

@ -9,8 +9,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/layout" "git.omukk.dev/wrenn/wrenn/internal/layout"
"git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// DefaultDiskSizeMB is the standard disk size for base images. Images smaller // DefaultDiskSizeMB is the standard disk size for base images. Images smaller

View File

@ -17,13 +17,13 @@ import (
"git.omukk.dev/wrenn/wrenn/internal/devicemapper" "git.omukk.dev/wrenn/wrenn/internal/devicemapper"
"git.omukk.dev/wrenn/wrenn/internal/envdclient" "git.omukk.dev/wrenn/wrenn/internal/envdclient"
"git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/layout" "git.omukk.dev/wrenn/wrenn/internal/layout"
"git.omukk.dev/wrenn/wrenn/internal/models" "git.omukk.dev/wrenn/wrenn/internal/models"
"git.omukk.dev/wrenn/wrenn/internal/network" "git.omukk.dev/wrenn/wrenn/internal/network"
"git.omukk.dev/wrenn/wrenn/internal/snapshot" "git.omukk.dev/wrenn/wrenn/internal/snapshot"
"git.omukk.dev/wrenn/wrenn/internal/uffd" "git.omukk.dev/wrenn/wrenn/internal/uffd"
"git.omukk.dev/wrenn/wrenn/internal/vm" "git.omukk.dev/wrenn/wrenn/internal/vm"
"git.omukk.dev/wrenn/wrenn/pkg/id"
envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen" envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen"
) )
@ -32,6 +32,13 @@ type Config struct {
WrennDir string // root directory (e.g. /var/lib/wrenn); all sub-paths derived via layout package WrennDir string // root directory (e.g. /var/lib/wrenn); all sub-paths derived via layout package
EnvdTimeout time.Duration EnvdTimeout time.Duration
DefaultRootfsSizeMB int // target size for template rootfs images; 0 → DefaultDiskSizeMB DefaultRootfsSizeMB int // target size for template rootfs images; 0 → DefaultDiskSizeMB
// Resolved at startup by the host agent.
KernelPath string // path to the latest vmlinux-x.y.z
KernelVersion string // semver extracted from filename
FirecrackerBin string // path to the firecracker binary
FirecrackerVersion string // semver from firecracker --version
AgentVersion string // host agent version (injected via ldflags)
} }
// Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd. // Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd.
@ -86,6 +93,35 @@ type snapshotParent struct {
// preventing the crash. // preventing the crash.
const maxDiffGenerations = 8 const maxDiffGenerations = 8
// buildMetadata constructs the metadata map with version information.
func (m *Manager) buildMetadata(envdVersion string) map[string]string {
meta := map[string]string{
"kernel_version": m.cfg.KernelVersion,
"firecracker_version": m.cfg.FirecrackerVersion,
"agent_version": m.cfg.AgentVersion,
}
if envdVersion != "" {
meta["envd_version"] = envdVersion
}
return meta
}
// resolveKernelPath returns the kernel path for the given version hint.
// If the exact version exists on disk, it is used. Otherwise, falls back to
// the latest kernel (m.cfg.KernelPath).
func (m *Manager) resolveKernelPath(versionHint string) string {
if versionHint == "" {
return m.cfg.KernelPath
}
exact := layout.KernelPathVersioned(m.cfg.WrennDir, versionHint)
if _, err := os.Stat(exact); err == nil {
return exact
}
slog.Warn("requested kernel version not found, using latest",
"requested", versionHint, "latest", m.cfg.KernelVersion)
return m.cfg.KernelPath
}
// New creates a new sandbox manager. // New creates a new sandbox manager.
func New(cfg Config) *Manager { func New(cfg Config) *Manager {
if cfg.EnvdTimeout == 0 { if cfg.EnvdTimeout == 0 {
@ -175,7 +211,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, template
vmCfg := vm.VMConfig{ vmCfg := vm.VMConfig{
SandboxID: sandboxID, SandboxID: sandboxID,
TemplateID: id.UUIDString(templateID), TemplateID: id.UUIDString(templateID),
KernelPath: layout.KernelPath(m.cfg.WrennDir), KernelPath: m.cfg.KernelPath,
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
VCPUs: vcpus, VCPUs: vcpus,
MemoryMB: memoryMB, MemoryMB: memoryMB,
@ -185,6 +221,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, template
GuestIP: slot.GuestIP, GuestIP: slot.GuestIP,
GatewayIP: slot.TapIP, GatewayIP: slot.TapIP,
NetMask: slot.GuestNetMask, NetMask: slot.GuestNetMask,
FirecrackerBin: m.cfg.FirecrackerBin,
} }
if _, err := m.vm.Create(ctx, vmCfg); err != nil { if _, err := m.vm.Create(ctx, vmCfg); err != nil {
@ -211,6 +248,9 @@ func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, template
return nil, fmt.Errorf("wait for envd: %w", err) return nil, fmt.Errorf("wait for envd: %w", err)
} }
// Fetch envd version (best-effort).
envdVersion, _ := client.FetchVersion(ctx)
now := time.Now() now := time.Now()
sb := &sandboxState{ sb := &sandboxState{
Sandbox: models.Sandbox{ Sandbox: models.Sandbox{
@ -226,6 +266,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, template
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
CreatedAt: now, CreatedAt: now,
LastActiveAt: now, LastActiveAt: now,
Metadata: m.buildMetadata(envdVersion),
}, },
slot: slot, slot: slot,
client: client, client: client,
@ -558,7 +599,7 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
// Resume restores a paused sandbox from its snapshot using UFFD for // Resume restores a paused sandbox from its snapshot using UFFD for
// lazy memory loading. The sandbox gets a new network slot. // lazy memory loading. The sandbox gets a new network slot.
func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int) (*models.Sandbox, error) { func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int, kernelVersion string) (*models.Sandbox, error) {
pauseDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID) pauseDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID)
if _, err := os.Stat(pauseDir); err != nil { if _, err := os.Stat(pauseDir); err != nil {
return nil, fmt.Errorf("no snapshot found for sandbox %s", sandboxID) return nil, fmt.Errorf("no snapshot found for sandbox %s", sandboxID)
@ -672,7 +713,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
// Restore VM from snapshot. // Restore VM from snapshot.
vmCfg := vm.VMConfig{ vmCfg := vm.VMConfig{
SandboxID: sandboxID, SandboxID: sandboxID,
KernelPath: layout.KernelPath(m.cfg.WrennDir), KernelPath: m.resolveKernelPath(kernelVersion),
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
VCPUs: 1, // Placeholder; overridden by snapshot. VCPUs: 1, // Placeholder; overridden by snapshot.
MemoryMB: int(header.Metadata.Size / (1024 * 1024)), // Placeholder; overridden by snapshot. MemoryMB: int(header.Metadata.Size / (1024 * 1024)), // Placeholder; overridden by snapshot.
@ -682,6 +723,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
GuestIP: slot.GuestIP, GuestIP: slot.GuestIP,
GatewayIP: slot.TapIP, GatewayIP: slot.TapIP,
NetMask: slot.GuestNetMask, NetMask: slot.GuestNetMask,
FirecrackerBin: m.cfg.FirecrackerBin,
} }
resumeSnapPath := filepath.Join(pauseDir, snapshot.SnapFileName) resumeSnapPath := filepath.Join(pauseDir, snapshot.SnapFileName)
@ -718,6 +760,9 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
slog.Warn("post-init failed after resume, metadata files may be stale", "sandbox", sandboxID, "error", err) slog.Warn("post-init failed after resume, metadata files may be stale", "sandbox", sandboxID, "error", err)
} }
// Fetch envd version (best-effort).
envdVersion, _ := client.FetchVersion(ctx)
now := time.Now() now := time.Now()
sb := &sandboxState{ sb := &sandboxState{
Sandbox: models.Sandbox{ Sandbox: models.Sandbox{
@ -731,6 +776,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
CreatedAt: now, CreatedAt: now,
LastActiveAt: now, LastActiveAt: now,
Metadata: m.buildMetadata(envdVersion),
}, },
slot: slot, slot: slot,
client: client, client: client,
@ -1099,7 +1145,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team
vmCfg := vm.VMConfig{ vmCfg := vm.VMConfig{
SandboxID: sandboxID, SandboxID: sandboxID,
TemplateID: id.UUIDString(templateID), TemplateID: id.UUIDString(templateID),
KernelPath: layout.KernelPath(m.cfg.WrennDir), KernelPath: m.cfg.KernelPath,
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
VCPUs: vcpus, VCPUs: vcpus,
MemoryMB: memoryMB, MemoryMB: memoryMB,
@ -1109,6 +1155,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team
GuestIP: slot.GuestIP, GuestIP: slot.GuestIP,
GatewayIP: slot.TapIP, GatewayIP: slot.TapIP,
NetMask: slot.GuestNetMask, NetMask: slot.GuestNetMask,
FirecrackerBin: m.cfg.FirecrackerBin,
} }
snapPath := filepath.Join(tmplDir, snapshot.SnapFileName) snapPath := filepath.Join(tmplDir, snapshot.SnapFileName)
@ -1145,6 +1192,9 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team
slog.Warn("post-init failed after template restore, metadata files may be stale", "sandbox", sandboxID, "error", err) slog.Warn("post-init failed after template restore, metadata files may be stale", "sandbox", sandboxID, "error", err)
} }
// Fetch envd version (best-effort).
envdVersion, _ := client.FetchVersion(ctx)
now := time.Now() now := time.Now()
sb := &sandboxState{ sb := &sandboxState{
Sandbox: models.Sandbox{ Sandbox: models.Sandbox{
@ -1160,6 +1210,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
CreatedAt: now, CreatedAt: now,
LastActiveAt: now, LastActiveAt: now,
Metadata: m.buildMetadata(envdVersion),
}, },
slot: slot, slot: slot,
client: client, client: client,

View File

@ -7,10 +7,10 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/events" "git.omukk.dev/wrenn/wrenn/pkg/events"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// AuditLogger writes audit log entries for user-initiated and system events. // AuditLogger writes audit log entries for user-initiated and system events.

View File

@ -7,7 +7,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
const jwtExpiry = 6 * time.Hour const jwtExpiry = 6 * time.Hour

View File

@ -7,7 +7,7 @@ import (
"github.com/containrrr/shoutrrr" "github.com/containrrr/shoutrrr"
"git.omukk.dev/wrenn/wrenn/internal/events" "git.omukk.dev/wrenn/wrenn/pkg/events"
) )
// Deliver sends a notification to a single provider with the given config. // Deliver sends a notification to a single provider with the given config.

View File

@ -8,9 +8,9 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/events" "git.omukk.dev/wrenn/wrenn/pkg/events"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
const ( const (

View File

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"git.omukk.dev/wrenn/wrenn/internal/events" "git.omukk.dev/wrenn/wrenn/pkg/events"
) )
// FormatMessage produces a human-readable notification string containing // FormatMessage produces a human-readable notification string containing

View File

@ -7,7 +7,7 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/events" "git.omukk.dev/wrenn/wrenn/pkg/events"
) )
const streamKey = "wrenn:events" const streamKey = "wrenn:events"

View File

@ -13,10 +13,10 @@ import (
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/events" "git.omukk.dev/wrenn/wrenn/pkg/events"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/internal/validate" "git.omukk.dev/wrenn/wrenn/pkg/validate"
) )
// Valid providers. // Valid providers.

View File

@ -0,0 +1,48 @@
// Package cpextension defines the types for extending the control plane server.
// This package is intentionally minimal and dependency-free (relative to internal/)
// to avoid import cycles between pkg/cpserver and internal/api.
package cpextension
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/config"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
"git.omukk.dev/wrenn/wrenn/pkg/scheduler"
)
// ServerContext exposes the initialized dependencies that extensions can use
// to register routes and start background workers. All fields are read-only
// from the extension's perspective.
type ServerContext struct {
Queries *db.Queries
PgPool *pgxpool.Pool
Redis *redis.Client
HostPool *lifecycle.HostClientPool
Scheduler scheduler.HostScheduler
CA *auth.CA
Audit *audit.AuditLogger
JWTSecret []byte
Config config.Config
}
// Extension allows enterprise (or any external) code to plug additional
// routes and background workers into the control plane without modifying
// the core server.
type Extension interface {
// RegisterRoutes is called after all core routes are registered.
// The chi.Router supports sub-routing, middleware, etc.
RegisterRoutes(r chi.Router, ctx ServerContext)
// BackgroundWorkers returns functions that will be called once with
// the application context after the server is fully initialized.
// Each function should start its own goroutine(s) and return.
BackgroundWorkers(ctx ServerContext) []func(context.Context)
}

11
pkg/cpserver/extension.go Normal file
View File

@ -0,0 +1,11 @@
package cpserver
import "git.omukk.dev/wrenn/wrenn/pkg/cpextension"
// ServerContext is an alias for cpextension.ServerContext.
// Enterprise code should use this package (pkg/cpserver) as the main entry point.
type ServerContext = cpextension.ServerContext
// Extension is an alias for cpextension.Extension.
// Enterprise code should use this package (pkg/cpserver) as the main entry point.
type Extension = cpextension.Extension

27
pkg/cpserver/options.go Normal file
View File

@ -0,0 +1,27 @@
package cpserver
// options holds the configuration for Run.
type options struct {
version string
commit string
extensions []Extension
}
// Option configures the control plane server.
type Option func(*options)
// WithVersion sets the version and commit strings for logging.
func WithVersion(version, commit string) Option {
return func(o *options) {
o.version = version
o.commit = commit
}
}
// WithExtensions registers one or more extensions that add routes and
// background workers to the control plane.
func WithExtensions(exts ...Extension) Option {
return func(o *options) {
o.extensions = append(o.extensions, exts...)
}
}

224
pkg/cpserver/run.go Normal file
View File

@ -0,0 +1,224 @@
package cpserver
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/api"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/pkg/channels"
"git.omukk.dev/wrenn/wrenn/pkg/config"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
"git.omukk.dev/wrenn/wrenn/pkg/scheduler"
)
// Run initializes and starts the control plane server. It blocks until a
// SIGINT or SIGTERM signal is received, then shuts down gracefully.
//
// Extensions registered via WithExtensions get to add routes and start
// background workers after the core server is fully initialized.
func Run(opts ...Option) {
o := &options{
version: "dev",
commit: "unknown",
}
for _, opt := range opts {
opt(o)
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
cfg := config.Load()
if len(cfg.JWTSecret) < 32 {
slog.Error("JWT_SECRET must be at least 32 characters")
os.Exit(1)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Database connection pool.
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
slog.Error("failed to ping database", "error", err)
os.Exit(1)
}
slog.Info("connected to database")
queries := db.New(pool)
// Redis client.
redisOpts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
slog.Error("failed to parse REDIS_URL", "error", err)
os.Exit(1)
}
rdb := redis.NewClient(redisOpts)
defer rdb.Close()
if err := rdb.Ping(ctx).Err(); err != nil {
slog.Error("failed to ping redis", "error", err)
os.Exit(1)
}
slog.Info("connected to redis")
// mTLS is mandatory — parse internal CA for CP↔agent communication.
if cfg.CACert == "" || cfg.CAKey == "" {
slog.Error("WRENN_CA_CERT and WRENN_CA_KEY are required — mTLS is mandatory for CP↔agent communication")
os.Exit(1)
}
ca, err := auth.ParseCA(cfg.CACert, cfg.CAKey)
if err != nil {
slog.Error("failed to parse mTLS CA from environment", "error", err)
os.Exit(1)
}
slog.Info("mTLS enabled: CA loaded")
// Host client pool — manages Connect RPC clients to host agents.
cpCertStore, err := auth.NewCPCertStore(ca)
if err != nil {
slog.Error("failed to issue CP client certificate", "error", err)
os.Exit(1)
}
// Renew the CP client certificate periodically so it never expires
// while the control plane is running (TTL = 24h, renewal = every 12h).
go func() {
ticker := time.NewTicker(auth.CPCertRenewInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := cpCertStore.Refresh(); err != nil {
slog.Error("failed to renew CP client certificate", "error", err)
} else {
slog.Info("CP client certificate renewed")
}
}
}
}()
hostPool := lifecycle.NewHostClientPoolTLS(auth.CPClientTLSConfig(ca, cpCertStore))
slog.Info("host client pool: mTLS enabled")
// Scheduler — picks a host for each new sandbox (least-loaded, bottleneck-first).
hostScheduler := scheduler.NewLeastLoadedScheduler(queries)
// OAuth provider registry.
oauthRegistry := oauth.NewRegistry()
if cfg.OAuthGitHubClientID != "" && cfg.OAuthGitHubClientSecret != "" {
if cfg.CPPublicURL == "" {
slog.Error("CP_PUBLIC_URL must be set when OAuth providers are configured")
os.Exit(1)
}
callbackURL := strings.TrimRight(cfg.CPPublicURL, "/") + "/auth/oauth/github/callback"
ghProvider := oauth.NewGitHubProvider(cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret, callbackURL)
oauthRegistry.Register(ghProvider)
slog.Info("registered OAuth provider", "provider", "github")
}
// Channels: publisher, service, dispatcher.
if len(cfg.EncryptionKeyHex) != 64 {
slog.Error("WRENN_ENCRYPTION_KEY must be a hex-encoded 32-byte key (64 hex chars)")
os.Exit(1)
}
channelPub := channels.NewPublisher(rdb)
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey}
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey)
// Shared audit logger with event publishing.
al := audit.NewWithPublisher(queries, channelPub)
// Build the server context that extensions receive.
sctx := ServerContext{
Queries: queries,
PgPool: pool,
Redis: rdb,
HostPool: hostPool,
Scheduler: hostScheduler,
CA: ca,
Audit: al,
JWTSecret: []byte(cfg.JWTSecret),
Config: cfg,
}
// API server.
srv := api.New(queries, hostPool, hostScheduler, pool, rdb, []byte(cfg.JWTSecret), oauthRegistry, cfg.OAuthRedirectURL, ca, al, channelSvc, o.extensions, sctx)
// Start template build workers (2 concurrent).
stopBuildWorkers := srv.BuildSvc.StartWorkers(ctx, 2)
defer stopBuildWorkers()
// Start channel event dispatcher.
channelDispatcher.Start(ctx)
// Start host monitor (passive + active reconciliation every 30s).
monitor := api.NewHostMonitor(queries, hostPool, al, 30*time.Second)
monitor.Start(ctx)
// Start metrics sampler (records per-team sandbox stats every 10s).
sampler := api.NewMetricsSampler(queries, 10*time.Second)
sampler.Start(ctx)
// Start extension background workers.
for _, ext := range o.extensions {
for _, worker := range ext.BackgroundWorkers(sctx) {
worker(ctx)
}
}
// Wrap the API handler with the sandbox proxy so that requests with
// {port}-{sandbox_id}.{domain} Host headers are routed to the sandbox's
// host agent. All other requests pass through to the normal API router.
proxyWrapper := api.NewSandboxProxyWrapper(srv.Handler(), queries, hostPool)
httpServer := &http.Server{
Addr: cfg.ListenAddr,
Handler: proxyWrapper,
}
// Graceful shutdown on signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
slog.Info("received signal, shutting down", "signal", sig)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("http server shutdown error", "error", err)
}
}()
slog.Info("control plane starting", "addr", cfg.ListenAddr, "version", o.version, "commit", o.commit)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("http server error", "error", err)
os.Exit(1)
}
slog.Info("control plane stopped")
}

View File

@ -111,6 +111,7 @@ type Sandbox struct {
LastUpdated pgtype.Timestamptz `json:"last_updated"` LastUpdated pgtype.Timestamptz `json:"last_updated"`
TemplateID pgtype.UUID `json:"template_id"` TemplateID pgtype.UUID `json:"template_id"`
TemplateTeamID pgtype.UUID `json:"template_team_id"` TemplateTeamID pgtype.UUID `json:"template_team_id"`
Metadata []byte `json:"metadata"`
} }
type SandboxMetricPoint struct { type SandboxMetricPoint struct {
@ -162,6 +163,7 @@ type Template struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
DefaultUser string `json:"default_user"` DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"` DefaultEnv []byte `json:"default_env"`
Metadata []byte `json:"metadata"`
} }
type TemplateBuild struct { type TemplateBuild struct {
@ -187,6 +189,7 @@ type TemplateBuild struct {
SkipPrePost bool `json:"skip_pre_post"` SkipPrePost bool `json:"skip_pre_post"`
DefaultUser string `json:"default_user"` DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"` DefaultEnv []byte `json:"default_env"`
Metadata []byte `json:"metadata"`
} }
type User struct { type User struct {

View File

@ -43,7 +43,7 @@ func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatu
} }
const getSandbox = `-- name: GetSandbox :one const getSandbox = `-- name: GetSandbox :one
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id FROM sandboxes WHERE id = $1 SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes WHERE id = $1
` `
func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, error) { func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, error) {
@ -67,12 +67,13 @@ func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, erro
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
) )
return i, err return i, err
} }
const getSandboxByTeam = `-- name: GetSandboxByTeam :one const getSandboxByTeam = `-- name: GetSandboxByTeam :one
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id FROM sandboxes WHERE id = $1 AND team_id = $2 SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes WHERE id = $1 AND team_id = $2
` `
type GetSandboxByTeamParams struct { type GetSandboxByTeamParams struct {
@ -101,6 +102,7 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
) )
return i, err return i, err
} }
@ -127,9 +129,9 @@ func (q *Queries) GetSandboxProxyTarget(ctx context.Context, id pgtype.UUID) (Ge
} }
const insertSandbox = `-- name: InsertSandbox :one const insertSandbox = `-- name: InsertSandbox :one
INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, template_id, template_team_id) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, template_id, template_team_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata
` `
type InsertSandboxParams struct { type InsertSandboxParams struct {
@ -144,6 +146,7 @@ type InsertSandboxParams struct {
DiskSizeMb int32 `json:"disk_size_mb"` DiskSizeMb int32 `json:"disk_size_mb"`
TemplateID pgtype.UUID `json:"template_id"` TemplateID pgtype.UUID `json:"template_id"`
TemplateTeamID pgtype.UUID `json:"template_team_id"` TemplateTeamID pgtype.UUID `json:"template_team_id"`
Metadata []byte `json:"metadata"`
} }
func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) { func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) {
@ -159,6 +162,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
arg.DiskSizeMb, arg.DiskSizeMb,
arg.TemplateID, arg.TemplateID,
arg.TemplateTeamID, arg.TemplateTeamID,
arg.Metadata,
) )
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
@ -179,12 +183,13 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
) )
return i, err return i, err
} }
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting') WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -216,6 +221,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -228,7 +234,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U
} }
const listSandboxes = `-- name: ListSandboxes :many const listSandboxes = `-- name: ListSandboxes :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id FROM sandboxes ORDER BY created_at DESC SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes ORDER BY created_at DESC
` `
func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
@ -258,6 +264,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -270,7 +277,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
} }
const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes
WHERE host_id = $1 AND status = ANY($2::text[]) WHERE host_id = $1 AND status = ANY($2::text[])
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -307,6 +314,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -319,7 +327,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
} }
const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes
WHERE team_id = $1 AND status NOT IN ('stopped', 'error') WHERE team_id = $1 AND status NOT IN ('stopped', 'error')
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -351,6 +359,7 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID pgtype.UUID) (
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -394,6 +403,23 @@ func (q *Queries) UpdateLastActive(ctx context.Context, arg UpdateLastActivePara
return err return err
} }
const updateSandboxMetadata = `-- name: UpdateSandboxMetadata :exec
UPDATE sandboxes
SET metadata = $2,
last_updated = NOW()
WHERE id = $1
`
type UpdateSandboxMetadataParams struct {
ID pgtype.UUID `json:"id"`
Metadata []byte `json:"metadata"`
}
func (q *Queries) UpdateSandboxMetadata(ctx context.Context, arg UpdateSandboxMetadataParams) error {
_, err := q.db.Exec(ctx, updateSandboxMetadata, arg.ID, arg.Metadata)
return err
}
const updateSandboxRunning = `-- name: UpdateSandboxRunning :one const updateSandboxRunning = `-- name: UpdateSandboxRunning :one
UPDATE sandboxes UPDATE sandboxes
SET status = 'running', SET status = 'running',
@ -403,7 +429,7 @@ SET status = 'running',
last_active_at = $4, last_active_at = $4,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata
` `
type UpdateSandboxRunningParams struct { type UpdateSandboxRunningParams struct {
@ -439,6 +465,7 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
) )
return i, err return i, err
} }
@ -448,7 +475,7 @@ UPDATE sandboxes
SET status = $2, SET status = $2,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata
` `
type UpdateSandboxStatusParams struct { type UpdateSandboxStatusParams struct {
@ -477,6 +504,7 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID, &i.TemplateID,
&i.TemplateTeamID, &i.TemplateTeamID,
&i.Metadata,
) )
return i, err return i, err
} }

View File

@ -12,7 +12,7 @@ import (
) )
const getTemplateBuild = `-- name: GetTemplateBuild :one const getTemplateBuild = `-- name: GetTemplateBuild :one
SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env FROM template_builds WHERE id = $1 SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env, metadata FROM template_builds WHERE id = $1
` `
func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (TemplateBuild, error) { func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (TemplateBuild, error) {
@ -41,6 +41,7 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }
@ -48,7 +49,7 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat
const insertTemplateBuild = `-- name: InsertTemplateBuild :one const insertTemplateBuild = `-- name: InsertTemplateBuild :one
INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps, template_id, team_id, skip_pre_post) INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps, template_id, team_id, skip_pre_post)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10, $11)
RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env, metadata
` `
type InsertTemplateBuildParams struct { type InsertTemplateBuildParams struct {
@ -103,12 +104,13 @@ func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBui
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }
const listTemplateBuilds = `-- name: ListTemplateBuilds :many const listTemplateBuilds = `-- name: ListTemplateBuilds :many
SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env FROM template_builds ORDER BY created_at DESC SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env, metadata FROM template_builds ORDER BY created_at DESC
` `
func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, error) { func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, error) {
@ -143,6 +145,7 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -156,7 +159,7 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro
const updateBuildDefaults = `-- name: UpdateBuildDefaults :exec const updateBuildDefaults = `-- name: UpdateBuildDefaults :exec
UPDATE template_builds UPDATE template_builds
SET default_user = $2, default_env = $3 SET default_user = $2, default_env = $3, metadata = $4
WHERE id = $1 WHERE id = $1
` `
@ -164,10 +167,16 @@ type UpdateBuildDefaultsParams struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
DefaultUser string `json:"default_user"` DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"` DefaultEnv []byte `json:"default_env"`
Metadata []byte `json:"metadata"`
} }
func (q *Queries) UpdateBuildDefaults(ctx context.Context, arg UpdateBuildDefaultsParams) error { func (q *Queries) UpdateBuildDefaults(ctx context.Context, arg UpdateBuildDefaultsParams) error {
_, err := q.db.Exec(ctx, updateBuildDefaults, arg.ID, arg.DefaultUser, arg.DefaultEnv) _, err := q.db.Exec(ctx, updateBuildDefaults,
arg.ID,
arg.DefaultUser,
arg.DefaultEnv,
arg.Metadata,
)
return err return err
} }
@ -227,7 +236,7 @@ SET status = $2,
started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END, started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END,
completed_at = CASE WHEN $2 IN ('success', 'failed', 'cancelled') THEN NOW() ELSE completed_at END completed_at = CASE WHEN $2 IN ('success', 'failed', 'cancelled') THEN NOW() ELSE completed_at END
WHERE id = $1 WHERE id = $1
RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id, skip_pre_post, default_user, default_env, metadata
` `
type UpdateBuildStatusParams struct { type UpdateBuildStatusParams struct {
@ -261,6 +270,7 @@ func (q *Queries) UpdateBuildStatus(ctx context.Context, arg UpdateBuildStatusPa
&i.SkipPrePost, &i.SkipPrePost,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }

View File

@ -45,7 +45,7 @@ func (q *Queries) DeleteTemplatesByTeam(ctx context.Context, teamID pgtype.UUID)
} }
const getPlatformTemplateByName = `-- name: GetPlatformTemplateByName :one const getPlatformTemplateByName = `-- name: GetPlatformTemplateByName :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1
` `
// Check if a global (platform) template exists with the given name. // Check if a global (platform) template exists with the given name.
@ -63,12 +63,13 @@ func (q *Queries) GetPlatformTemplateByName(ctx context.Context, name string) (T
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }
const getTemplate = `-- name: GetTemplate :one const getTemplate = `-- name: GetTemplate :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE id = $1 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE id = $1
` `
func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, error) { func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, error) {
@ -85,12 +86,13 @@ func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, er
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }
const getTemplateByName = `-- name: GetTemplateByName :one const getTemplateByName = `-- name: GetTemplateByName :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE team_id = $1 AND name = $2 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE team_id = $1 AND name = $2
` `
type GetTemplateByNameParams struct { type GetTemplateByNameParams struct {
@ -113,12 +115,13 @@ func (q *Queries) GetTemplateByName(ctx context.Context, arg GetTemplateByNamePa
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }
const getTemplateByTeam = `-- name: GetTemplateByTeam :one const getTemplateByTeam = `-- name: GetTemplateByTeam :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000') SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
` `
type GetTemplateByTeamParams struct { type GetTemplateByTeamParams struct {
@ -141,14 +144,15 @@ func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamPa
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }
const insertTemplate = `-- name: InsertTemplate :one const insertTemplate = `-- name: InsertTemplate :one
INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env) INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata
` `
type InsertTemplateParams struct { type InsertTemplateParams struct {
@ -161,6 +165,7 @@ type InsertTemplateParams struct {
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
DefaultUser string `json:"default_user"` DefaultUser string `json:"default_user"`
DefaultEnv []byte `json:"default_env"` DefaultEnv []byte `json:"default_env"`
Metadata []byte `json:"metadata"`
} }
func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) {
@ -174,6 +179,7 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams)
arg.TeamID, arg.TeamID,
arg.DefaultUser, arg.DefaultUser,
arg.DefaultEnv, arg.DefaultEnv,
arg.Metadata,
) )
var i Template var i Template
err := row.Scan( err := row.Scan(
@ -187,12 +193,13 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams)
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
) )
return i, err return i, err
} }
const listTemplates = `-- name: ListTemplates :many const listTemplates = `-- name: ListTemplates :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates ORDER BY created_at DESC
` `
func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
@ -215,6 +222,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -227,7 +235,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
} }
const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') ORDER BY created_at DESC
` `
// Platform templates are visible to all teams. // Platform templates are visible to all teams.
@ -251,6 +259,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) (
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -263,7 +272,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) (
} }
const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC
` `
type ListTemplatesByTeamAndTypeParams struct { type ListTemplatesByTeamAndTypeParams struct {
@ -292,6 +301,7 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -304,7 +314,7 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla
} }
const listTemplatesByTeamOnly = `-- name: ListTemplatesByTeamOnly :many const listTemplatesByTeamOnly = `-- name: ListTemplatesByTeamOnly :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE team_id = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE team_id = $1 ORDER BY created_at DESC
` `
// List templates owned by a specific team (NOT including platform templates). // List templates owned by a specific team (NOT including platform templates).
@ -328,6 +338,7 @@ func (q *Queries) ListTemplatesByTeamOnly(ctx context.Context, teamID pgtype.UUI
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -340,7 +351,7 @@ func (q *Queries) ListTemplatesByTeamOnly(ctx context.Context, teamID pgtype.UUI
} }
const listTemplatesByType = `-- name: ListTemplatesByType :many const listTemplatesByType = `-- name: ListTemplatesByType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env FROM templates WHERE type = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata FROM templates WHERE type = $1 ORDER BY created_at DESC
` `
func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) { func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) {
@ -363,6 +374,7 @@ func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Temp
&i.ID, &i.ID,
&i.DefaultUser, &i.DefaultUser,
&i.DefaultEnv, &i.DefaultEnv,
&i.Metadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@ -8,8 +8,8 @@ import (
"sync" "sync"
"time" "time"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
) )

View File

@ -6,7 +6,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
) )
// Resource overhead reserved for the host OS. // Resource overhead reserved for the host OS.

View File

@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
) )
// HostScheduler selects a host for a new sandbox. Implementations may use // HostScheduler selects a host for a new sandbox. Implementations may use

View File

@ -6,9 +6,9 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/pkg/id"
) )
// APIKeyService provides API key operations shared between the REST API and the dashboard. // APIKeyService provides API key operations shared between the REST API and the dashboard.

Some files were not shown because too many files have changed in this diff Show More