diff --git a/.env.example b/.env.example index 2b9e073..80ddf0d 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ WRENN_DIR=/var/lib/wrenn WRENN_HOST_INTERFACE=eth0 WRENN_CP_URL=http://localhost:9725 WRENN_DEFAULT_ROOTFS_SIZE=5Gi +WRENN_FIRECRACKER_BIN=/usr/local/bin/firecracker # Auth JWT_SECRET= diff --git a/Makefile b/Makefile index 2dbcc76..e80869c 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ DATABASE_URL ?= postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable GOBIN := $(shell pwd)/builds 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 # ═══════════════════════════════════════════════════ @@ -17,14 +21,14 @@ build-frontend: cd frontend && pnpm install --frozen-lockfile && pnpm build 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: - 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: 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" || \ (echo "ERROR: envd is not statically linked!" && exit 1) diff --git a/VERSION_AGENT b/VERSION_AGENT new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION_AGENT @@ -0,0 +1 @@ +0.1.0 diff --git a/VERSION_CP b/VERSION_CP new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION_CP @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index a7235a7..26c57be 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -1,191 +1,15 @@ package main -import ( - "context" - "log/slog" - "net/http" - "os" - "os/signal" - "strings" - "syscall" - "time" +import "git.omukk.dev/wrenn/wrenn/pkg/cpserver" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/redis/go-redis/v9" - - "git.omukk.dev/wrenn/wrenn/internal/api" - "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" +// Set via -ldflags at build time. +var ( + version = "dev" + commit = "unknown" ) func main() { - 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) - - // 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") + cpserver.Run( + cpserver.WithVersion(version, commit), + ) } diff --git a/cmd/host-agent/main.go b/cmd/host-agent/main.go index 287122b..047d726 100644 --- a/cmd/host-agent/main.go +++ b/cmd/host-agent/main.go @@ -15,14 +15,21 @@ import ( "github.com/joho/godotenv" - "git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/internal/devicemapper" "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/sandbox" + "git.omukk.dev/wrenn/wrenn/pkg/auth" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" ) +// Set via -ldflags at build time. +var ( + version = "dev" + commit = "unknown" +) + func main() { // Best-effort load — missing .env file is fine. _ = godotenv.Load() @@ -82,9 +89,31 @@ func main() { 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{ WrennDir: rootDir, DefaultRootfsSizeMB: defaultRootfsSizeMB, + KernelPath: kernelPath, + KernelVersion: kernelVersion, + FirecrackerBin: fcBin, + FirecrackerVersion: fcVersion, + AgentVersion: version, } mgr := sandbox.New(cfg) @@ -193,7 +222,7 @@ func main() { 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 // manually because ListenAndServeTLS requires on-disk cert/key paths // but we use GetCertificate callback for hot-swap support. diff --git a/db/migrations/20260415134310_add_metadata.sql b/db/migrations/20260415134310_add_metadata.sql new file mode 100644 index 0000000..ada4e35 --- /dev/null +++ b/db/migrations/20260415134310_add_metadata.sql @@ -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; diff --git a/db/migrations/embed.go b/db/migrations/embed.go new file mode 100644 index 0000000..d5bbfcb --- /dev/null +++ b/db/migrations/embed.go @@ -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 diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index b8871ac..73843f8 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -1,6 +1,6 @@ -- 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) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +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, $12) RETURNING *; -- name: GetSandbox :one @@ -74,6 +74,12 @@ SET status = 'missing', last_updated = NOW() 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 -- Called by the reconciler when a host comes back online and its sandboxes are -- confirmed alive. Restores only sandboxes that are in 'missing' state. diff --git a/db/queries/template_builds.sql b/db/queries/template_builds.sql index 1a0e3b0..69eebc5 100644 --- a/db/queries/template_builds.sql +++ b/db/queries/template_builds.sql @@ -34,5 +34,5 @@ WHERE id = $1; -- name: UpdateBuildDefaults :exec UPDATE template_builds -SET default_user = $2, default_env = $3 +SET default_user = $2, default_env = $3, metadata = $4 WHERE id = $1; diff --git a/db/queries/templates.sql b/db/queries/templates.sql index fbea228..7c50ea6 100644 --- a/db/queries/templates.sql +++ b/db/queries/templates.sql @@ -1,6 +1,6 @@ -- name: InsertTemplate :one -INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +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, $10) RETURNING *; -- name: GetTemplate :one diff --git a/envd/VERSION b/envd/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/envd/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/envd/internal/api/download_test.go b/envd/internal/api/download_test.go index d9ab79a..a4379cc 100644 --- a/envd/internal/api/download_test.go +++ b/envd/internal/api/download_test.go @@ -99,7 +99,7 @@ func TestGetFilesContentDisposition(t *testing.T) { EnvVars: utils.NewMap[string, string](), 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 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](), 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 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](), 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 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](), 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.Header.Set("Accept-Encoding", "gzip") @@ -297,7 +297,7 @@ func TestPostFiles_GzipUpload(t *testing.T) { EnvVars: utils.NewMap[string, string](), 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.Header.Set("Content-Type", mpWriter.FormDataContentType()) @@ -357,7 +357,7 @@ func TestGzipUploadThenGzipDownload(t *testing.T) { EnvVars: utils.NewMap[string, string](), 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.Header.Set("Content-Type", mpWriter.FormDataContentType()) diff --git a/envd/internal/api/init_test.go b/envd/internal/api/init_test.go index 979adad..18ee203 100644 --- a/envd/internal/api/init_test.go +++ b/envd/internal/api/init_test.go @@ -79,7 +79,7 @@ func newTestAPI(accessToken *SecureToken, mmdsClient MMDSClient) *API { defaults := &execcontext.Defaults{ 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 { api.accessToken.TakeFrom(accessToken) } diff --git a/envd/internal/api/store.go b/envd/internal/api/store.go index ddb726a..ca97957 100644 --- a/envd/internal/api/store.go +++ b/envd/internal/api/store.go @@ -34,6 +34,7 @@ type API struct { logger *zerolog.Logger accessToken *SecureToken defaults *execcontext.Defaults + version string mmdsChan chan *host.MMDSOpts hyperloopLock sync.Mutex @@ -48,7 +49,7 @@ type API struct { 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{ logger: l, defaults: defaults, @@ -59,6 +60,7 @@ func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host. accessToken: &SecureToken{}, rootCtx: rootCtx, portSubsystem: portSubsystem, + version: version, } } @@ -68,9 +70,11 @@ func (a *API) GetHealth(w http.ResponseWriter, r *http.Request) { a.logger.Trace().Msg("Health check") 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) { diff --git a/envd/main.go b/envd/main.go index 5fb1813..1cd9403 100644 --- a/envd/main.go +++ b/envd/main.go @@ -50,7 +50,7 @@ const ( ) var ( - Version = "0.5.4" + Version = "0.1.0" commitSHA string @@ -197,7 +197,7 @@ func main() { portSubsystem.Start(ctx) 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) middleware := authn.NewMiddleware(permissions.AuthenticateUsername) diff --git a/internal/api/agent_helper.go b/internal/api/agent_helper.go index e4a3545..6a7acf5 100644 --- a/internal/api/agent_helper.go +++ b/internal/api/agent_helper.go @@ -6,8 +6,8 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" ) diff --git a/internal/api/handler_sandbox_proxy.go b/internal/api/handler_sandbox_proxy.go index 1e4e195..5e3754d 100644 --- a/internal/api/handler_sandbox_proxy.go +++ b/internal/api/handler_sandbox_proxy.go @@ -17,9 +17,9 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" - "git.omukk.dev/wrenn/wrenn/internal/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" ) // Sentinel errors returned by proxyTarget, used to map to HTTP status codes diff --git a/internal/api/handlers_admin_capsules.go b/internal/api/handlers_admin_capsules.go index e3a964f..13250e5 100644 --- a/internal/api/handlers_admin_capsules.go +++ b/internal/api/handlers_admin_capsules.go @@ -11,13 +11,13 @@ import ( "connectrpc.com/connect" "github.com/go-chi/chi/v5" - "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/lifecycle" - "git.omukk.dev/wrenn/wrenn/internal/service" - "git.omukk.dev/wrenn/wrenn/internal/validate" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "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" ) @@ -224,6 +224,7 @@ func (h *adminCapsuleHandler) Snapshot(w http.ResponseWriter, r *http.Request) { TeamID: id.PlatformTeamID, DefaultUser: "root", DefaultEnv: []byte("{}"), + Metadata: sb.Metadata, }) if err != nil { slog.Error("failed to insert template record", "name", req.Name, "error", err) diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go index 440cb59..a4c077a 100644 --- a/internal/api/handlers_apikeys.go +++ b/internal/api/handlers_apikeys.go @@ -6,11 +6,11 @@ import ( "github.com/go-chi/chi/v5" - "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/service" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) type apiKeyHandler struct { diff --git a/internal/api/handlers_audit.go b/internal/api/handlers_audit.go index 66768ec..feaebb7 100644 --- a/internal/api/handlers_audit.go +++ b/internal/api/handlers_audit.go @@ -8,9 +8,9 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/auth" - "git.omukk.dev/wrenn/wrenn/internal/id" - "git.omukk.dev/wrenn/wrenn/internal/service" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) type auditHandler struct { diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 8502554..56793ef 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -12,9 +12,9 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - "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/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) // loginTeam returns the team and role to stamp into a login JWT. diff --git a/internal/api/handlers_builds.go b/internal/api/handlers_builds.go index b0b0e40..5228420 100644 --- a/internal/api/handlers_builds.go +++ b/internal/api/handlers_builds.go @@ -12,12 +12,12 @@ import ( "connectrpc.com/connect" "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/lifecycle" - "git.omukk.dev/wrenn/wrenn/internal/service" - "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" ) diff --git a/internal/api/handlers_channels.go b/internal/api/handlers_channels.go index 9da20e0..a221e31 100644 --- a/internal/api/handlers_channels.go +++ b/internal/api/handlers_channels.go @@ -8,11 +8,11 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" - "git.omukk.dev/wrenn/wrenn/internal/audit" - "git.omukk.dev/wrenn/wrenn/internal/auth" - "git.omukk.dev/wrenn/wrenn/internal/channels" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/channels" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) type channelHandler struct { diff --git a/internal/api/handlers_exec.go b/internal/api/handlers_exec.go index b7d2f94..8e94da7 100644 --- a/internal/api/handlers_exec.go +++ b/internal/api/handlers_exec.go @@ -12,10 +12,10 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/handlers_exec_stream.go b/internal/api/handlers_exec_stream.go index ca7fc03..a47c228 100644 --- a/internal/api/handlers_exec_stream.go +++ b/internal/api/handlers_exec_stream.go @@ -12,10 +12,10 @@ import ( "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgtype" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index e8cf42d..f69c8f1 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -9,10 +9,10 @@ import ( "connectrpc.com/connect" "github.com/go-chi/chi/v5" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/handlers_files_stream.go b/internal/api/handlers_files_stream.go index 3fbfa9c..88377ae 100644 --- a/internal/api/handlers_files_stream.go +++ b/internal/api/handlers_files_stream.go @@ -10,10 +10,10 @@ import ( "connectrpc.com/connect" "github.com/go-chi/chi/v5" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/handlers_fs.go b/internal/api/handlers_fs.go index ff50013..cfdd6a7 100644 --- a/internal/api/handlers_fs.go +++ b/internal/api/handlers_fs.go @@ -6,10 +6,10 @@ import ( "connectrpc.com/connect" "github.com/go-chi/chi/v5" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/handlers_hosts.go b/internal/api/handlers_hosts.go index 51aa833..9536197 100644 --- a/internal/api/handlers_hosts.go +++ b/internal/api/handlers_hosts.go @@ -10,11 +10,11 @@ import ( "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/service" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) type hostHandler struct { diff --git a/internal/api/handlers_metrics.go b/internal/api/handlers_metrics.go index 6b49b57..28a2157 100644 --- a/internal/api/handlers_metrics.go +++ b/internal/api/handlers_metrics.go @@ -9,10 +9,10 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index 4bf8ca7..eef0977 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -16,10 +16,10 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" - "git.omukk.dev/wrenn/wrenn/internal/auth" - "git.omukk.dev/wrenn/wrenn/internal/auth/oauth" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/auth/oauth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) type oauthHandler struct { diff --git a/internal/api/handlers_process.go b/internal/api/handlers_process.go index 7c99221..9d62729 100644 --- a/internal/api/handlers_process.go +++ b/internal/api/handlers_process.go @@ -12,10 +12,10 @@ import ( "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgtype" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/handlers_pty.go b/internal/api/handlers_pty.go index cd5dcae..8d571ae 100644 --- a/internal/api/handlers_pty.go +++ b/internal/api/handlers_pty.go @@ -14,10 +14,10 @@ import ( "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgtype" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" ) diff --git a/internal/api/handlers_sandbox.go b/internal/api/handlers_sandbox.go index 1650720..badb3d0 100644 --- a/internal/api/handlers_sandbox.go +++ b/internal/api/handlers_sandbox.go @@ -7,11 +7,11 @@ import ( "github.com/go-chi/chi/v5" - "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/service" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) type sandboxHandler struct { @@ -31,18 +31,19 @@ type createSandboxRequest struct { } type sandboxResponse struct { - ID string `json:"id"` - Status string `json:"status"` - Template string `json:"template"` - VCPUs int32 `json:"vcpus"` - MemoryMB int32 `json:"memory_mb"` - TimeoutSec int32 `json:"timeout_sec"` - GuestIP string `json:"guest_ip,omitempty"` - HostIP string `json:"host_ip,omitempty"` - CreatedAt string `json:"created_at"` - StartedAt *string `json:"started_at,omitempty"` - LastActiveAt *string `json:"last_active_at,omitempty"` - LastUpdated string `json:"last_updated"` + ID string `json:"id"` + Status string `json:"status"` + Template string `json:"template"` + VCPUs int32 `json:"vcpus"` + MemoryMB int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` + GuestIP string `json:"guest_ip,omitempty"` + HostIP string `json:"host_ip,omitempty"` + CreatedAt string `json:"created_at"` + StartedAt *string `json:"started_at,omitempty"` + LastActiveAt *string `json:"last_active_at,omitempty"` + LastUpdated string `json:"last_updated"` + Metadata map[string]string `json:"metadata,omitempty"` } func sandboxToResponse(sb db.Sandbox) sandboxResponse { @@ -56,6 +57,12 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse { GuestIP: sb.GuestIp, 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 { resp.CreatedAt = sb.CreatedAt.Time.Format(time.RFC3339) } diff --git a/internal/api/handlers_snapshots.go b/internal/api/handlers_snapshots.go index c70c912..e141b7a 100644 --- a/internal/api/handlers_snapshots.go +++ b/internal/api/handlers_snapshots.go @@ -13,14 +13,14 @@ import ( "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/lifecycle" - "git.omukk.dev/wrenn/wrenn/internal/service" - "git.omukk.dev/wrenn/wrenn/internal/validate" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "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" ) @@ -69,13 +69,14 @@ type createSnapshotRequest struct { } type snapshotResponse struct { - Name string `json:"name"` - Type string `json:"type"` - VCPUs *int32 `json:"vcpus,omitempty"` - MemoryMB *int32 `json:"memory_mb,omitempty"` - SizeBytes int64 `json:"size_bytes"` - CreatedAt string `json:"created_at"` - Platform bool `json:"platform"` + Name string `json:"name"` + Type string `json:"type"` + VCPUs *int32 `json:"vcpus,omitempty"` + MemoryMB *int32 `json:"memory_mb,omitempty"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` + Platform bool `json:"platform"` + Metadata map[string]string `json:"metadata,omitempty"` } func templateToResponse(t db.Template) snapshotResponse { @@ -94,6 +95,12 @@ func templateToResponse(t db.Template) snapshotResponse { if t.CreatedAt.Valid { 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 } @@ -219,6 +226,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { TeamID: ac.TeamID, DefaultUser: "root", DefaultEnv: []byte("{}"), + Metadata: sb.Metadata, }) if err != nil { slog.Error("failed to insert template record", "name", req.Name, "error", err) diff --git a/internal/api/handlers_stats.go b/internal/api/handlers_stats.go index ad2b5f9..1289d68 100644 --- a/internal/api/handlers_stats.go +++ b/internal/api/handlers_stats.go @@ -5,8 +5,8 @@ import ( "net/http" "time" - "git.omukk.dev/wrenn/wrenn/internal/auth" - "git.omukk.dev/wrenn/wrenn/internal/service" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) type statsHandler struct { diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go index 1c26681..bb24e7d 100644 --- a/internal/api/handlers_team.go +++ b/internal/api/handlers_team.go @@ -10,11 +10,11 @@ import ( "github.com/go-chi/chi/v5" "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/service" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) type teamHandler struct { diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index ab3098c..23b4b53 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -9,10 +9,10 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" - "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/service" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) type usersHandler struct { diff --git a/internal/api/host_monitor.go b/internal/api/host_monitor.go index 0779de8..763555e 100644 --- a/internal/api/host_monitor.go +++ b/internal/api/host_monitor.go @@ -8,10 +8,10 @@ import ( "connectrpc.com/connect" "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/audit" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" - "git.omukk.dev/wrenn/wrenn/internal/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/api/metrics_sampler.go b/internal/api/metrics_sampler.go index 096c2a2..864a789 100644 --- a/internal/api/metrics_sampler.go +++ b/internal/api/metrics_sampler.go @@ -5,7 +5,7 @@ import ( "log/slog" "time" - "git.omukk.dev/wrenn/wrenn/internal/db" + "git.omukk.dev/wrenn/wrenn/pkg/db" ) // MetricsSampler records per-team sandbox resource usage to diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 95d2175..5053b78 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -14,7 +14,7 @@ import ( "connectrpc.com/connect" "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) type errorResponse struct { diff --git a/internal/api/middleware_admin.go b/internal/api/middleware_admin.go index f55fb9d..cf23dce 100644 --- a/internal/api/middleware_admin.go +++ b/internal/api/middleware_admin.go @@ -3,9 +3,9 @@ package api import ( "net/http" - "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/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) // injectPlatformTeam overwrites the AuthContext's TeamID with the platform diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index 2c5e192..b328e40 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -5,9 +5,9 @@ import ( "net/http" "strings" - "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/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) // requireAPIKeyOrJWT accepts either X-API-Key header or Authorization: Bearer JWT. diff --git a/internal/api/middleware_hosttoken.go b/internal/api/middleware_hosttoken.go index 9f8cfc0..39ebdd9 100644 --- a/internal/api/middleware_hosttoken.go +++ b/internal/api/middleware_hosttoken.go @@ -3,8 +3,8 @@ package api import ( "net/http" - "git.omukk.dev/wrenn/wrenn/internal/auth" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) // requireHostToken validates the X-Host-Token header containing a host JWT, diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go index a4f311a..c0f4260 100644 --- a/internal/api/middleware_jwt.go +++ b/internal/api/middleware_jwt.go @@ -5,9 +5,9 @@ import ( "net/http" "strings" - "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/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) // requireJWT validates a JWT from the Authorization: Bearer header or the diff --git a/internal/api/server.go b/internal/api/server.go index b33b145..f4f561d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -9,14 +9,15 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" - "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/db" - "git.omukk.dev/wrenn/wrenn/internal/lifecycle" - "git.omukk.dev/wrenn/wrenn/internal/scheduler" - "git.omukk.dev/wrenn/wrenn/internal/service" + "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/cpextension" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/scheduler" + "git.omukk.dev/wrenn/wrenn/pkg/service" ) //go:embed openapi.yaml @@ -29,6 +30,8 @@ type Server struct { } // 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( queries *db.Queries, pool *lifecycle.HostClientPool, @@ -41,6 +44,8 @@ func New( ca *auth.CA, al *audit.AuditLogger, channelSvc *channels.Service, + extensions []cpextension.Extension, + sctx cpextension.ServerContext, ) *Server { r := chi.NewRouter() 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} } @@ -247,6 +257,11 @@ func (s *Server) Handler() http.Handler { 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) { w.Header().Set("Content-Type", "application/yaml") _, _ = w.Write(openapiYAML) diff --git a/internal/envdclient/health.go b/internal/envdclient/health.go index dfb7df8..4837051 100644 --- a/internal/envdclient/health.go +++ b/internal/envdclient/health.go @@ -2,7 +2,9 @@ package envdclient import ( "context" + "encoding/json" "fmt" + "io" "log/slog" "net/http" "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. func (c *Client) healthCheck(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.healthURL, nil) diff --git a/internal/hostagent/server.go b/internal/hostagent/server.go index 7b88965..663d2cb 100644 --- a/internal/hostagent/server.go +++ b/internal/hostagent/server.go @@ -80,6 +80,7 @@ func (s *Server) CreateSandbox( SandboxId: sb.ID, Status: string(sb.Status), HostIp: sb.HostIP.String(), + Metadata: sb.Metadata, }), nil } @@ -108,7 +109,7 @@ func (s *Server) ResumeSandbox( req *connect.Request[pb.ResumeSandboxRequest], ) (*connect.Response[pb.ResumeSandboxResponse], error) { 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 { return nil, connect.NewError(connect.CodeInternal, err) } @@ -124,6 +125,7 @@ func (s *Server) ResumeSandbox( SandboxId: sb.ID, Status: string(sb.Status), HostIp: sb.HostIP.String(), + Metadata: sb.Metadata, }), nil } @@ -564,6 +566,7 @@ func (s *Server) ListSandboxes( CreatedAtUnix: sb.CreatedAt.Unix(), LastActiveAtUnix: sb.LastActiveAt.Unix(), TimeoutSec: int32(sb.TimeoutSec), + Metadata: sb.Metadata, } } diff --git a/internal/layout/layout.go b/internal/layout/layout.go index dfe2f9b..fcb11ad 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -1,11 +1,15 @@ package layout import ( + "fmt" + "os" "path/filepath" + "sort" + "strings" "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 @@ -47,6 +51,75 @@ func KernelPath(wrennDir string) string { 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. func ImagesRoot(wrennDir string) string { return filepath.Join(wrennDir, "images") diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index 3501ee4..f3e3532 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -6,7 +6,7 @@ import ( "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) { diff --git a/internal/models/sandbox.go b/internal/models/sandbox.go index ab72cd3..8228679 100644 --- a/internal/models/sandbox.go +++ b/internal/models/sandbox.go @@ -30,4 +30,5 @@ type Sandbox struct { RootfsPath string CreatedAt time.Time LastActiveAt time.Time + Metadata map[string]string } diff --git a/internal/sandbox/fcversion.go b/internal/sandbox/fcversion.go new file mode 100644 index 0000000..092fbe0 --- /dev/null +++ b/internal/sandbox/fcversion.go @@ -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) +} diff --git a/internal/sandbox/images.go b/internal/sandbox/images.go index eabb2e3..26634d3 100644 --- a/internal/sandbox/images.go +++ b/internal/sandbox/images.go @@ -9,8 +9,8 @@ import ( "strconv" "strings" - "git.omukk.dev/wrenn/wrenn/internal/id" "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 diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 576a3e9..524631d 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -17,13 +17,13 @@ import ( "git.omukk.dev/wrenn/wrenn/internal/devicemapper" "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/models" "git.omukk.dev/wrenn/wrenn/internal/network" "git.omukk.dev/wrenn/wrenn/internal/snapshot" "git.omukk.dev/wrenn/wrenn/internal/uffd" "git.omukk.dev/wrenn/wrenn/internal/vm" + "git.omukk.dev/wrenn/wrenn/pkg/id" 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 EnvdTimeout time.Duration 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. @@ -86,6 +93,35 @@ type snapshotParent struct { // preventing the crash. 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. func New(cfg Config) *Manager { if cfg.EnvdTimeout == 0 { @@ -175,7 +211,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, template vmCfg := vm.VMConfig{ SandboxID: sandboxID, TemplateID: id.UUIDString(templateID), - KernelPath: layout.KernelPath(m.cfg.WrennDir), + KernelPath: m.cfg.KernelPath, RootfsPath: dmDev.DevicePath, VCPUs: vcpus, MemoryMB: memoryMB, @@ -185,6 +221,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, template GuestIP: slot.GuestIP, GatewayIP: slot.TapIP, NetMask: slot.GuestNetMask, + FirecrackerBin: m.cfg.FirecrackerBin, } 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) } + // Fetch envd version (best-effort). + envdVersion, _ := client.FetchVersion(ctx) + now := time.Now() sb := &sandboxState{ Sandbox: models.Sandbox{ @@ -226,6 +266,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, template RootfsPath: dmDev.DevicePath, CreatedAt: now, LastActiveAt: now, + Metadata: m.buildMetadata(envdVersion), }, slot: slot, 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 // 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) if _, err := os.Stat(pauseDir); err != nil { 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. vmCfg := vm.VMConfig{ SandboxID: sandboxID, - KernelPath: layout.KernelPath(m.cfg.WrennDir), + KernelPath: m.resolveKernelPath(kernelVersion), RootfsPath: dmDev.DevicePath, VCPUs: 1, // 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, GatewayIP: slot.TapIP, NetMask: slot.GuestNetMask, + FirecrackerBin: m.cfg.FirecrackerBin, } 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) } + // Fetch envd version (best-effort). + envdVersion, _ := client.FetchVersion(ctx) + now := time.Now() sb := &sandboxState{ Sandbox: models.Sandbox{ @@ -731,6 +776,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int) RootfsPath: dmDev.DevicePath, CreatedAt: now, LastActiveAt: now, + Metadata: m.buildMetadata(envdVersion), }, slot: slot, client: client, @@ -1099,7 +1145,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team vmCfg := vm.VMConfig{ SandboxID: sandboxID, TemplateID: id.UUIDString(templateID), - KernelPath: layout.KernelPath(m.cfg.WrennDir), + KernelPath: m.cfg.KernelPath, RootfsPath: dmDev.DevicePath, VCPUs: vcpus, MemoryMB: memoryMB, @@ -1109,6 +1155,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team GuestIP: slot.GuestIP, GatewayIP: slot.TapIP, NetMask: slot.GuestNetMask, + FirecrackerBin: m.cfg.FirecrackerBin, } 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) } + // Fetch envd version (best-effort). + envdVersion, _ := client.FetchVersion(ctx) + now := time.Now() sb := &sandboxState{ Sandbox: models.Sandbox{ @@ -1160,6 +1210,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team RootfsPath: dmDev.DevicePath, CreatedAt: now, LastActiveAt: now, + Metadata: m.buildMetadata(envdVersion), }, slot: slot, client: client, diff --git a/internal/audit/logger.go b/pkg/audit/logger.go similarity index 99% rename from internal/audit/logger.go rename to pkg/audit/logger.go index 2210594..e101b3c 100644 --- a/internal/audit/logger.go +++ b/pkg/audit/logger.go @@ -7,10 +7,10 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/auth" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/events" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/events" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) // AuditLogger writes audit log entries for user-initiated and system events. diff --git a/internal/auth/apikey.go b/pkg/auth/apikey.go similarity index 100% rename from internal/auth/apikey.go rename to pkg/auth/apikey.go diff --git a/internal/auth/cert.go b/pkg/auth/cert.go similarity index 100% rename from internal/auth/cert.go rename to pkg/auth/cert.go diff --git a/internal/auth/context.go b/pkg/auth/context.go similarity index 100% rename from internal/auth/context.go rename to pkg/auth/context.go diff --git a/internal/auth/jwt.go b/pkg/auth/jwt.go similarity index 98% rename from internal/auth/jwt.go rename to pkg/auth/jwt.go index 21cd589..70dd947 100644 --- a/internal/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -7,7 +7,7 @@ import ( "github.com/golang-jwt/jwt/v5" "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 diff --git a/internal/auth/oauth/github.go b/pkg/auth/oauth/github.go similarity index 100% rename from internal/auth/oauth/github.go rename to pkg/auth/oauth/github.go diff --git a/internal/auth/oauth/provider.go b/pkg/auth/oauth/provider.go similarity index 100% rename from internal/auth/oauth/provider.go rename to pkg/auth/oauth/provider.go diff --git a/internal/auth/password.go b/pkg/auth/password.go similarity index 100% rename from internal/auth/password.go rename to pkg/auth/password.go diff --git a/internal/channels/crypto.go b/pkg/channels/crypto.go similarity index 100% rename from internal/channels/crypto.go rename to pkg/channels/crypto.go diff --git a/internal/channels/deliver.go b/pkg/channels/deliver.go similarity index 94% rename from internal/channels/deliver.go rename to pkg/channels/deliver.go index 1f5e333..51e01db 100644 --- a/internal/channels/deliver.go +++ b/pkg/channels/deliver.go @@ -7,7 +7,7 @@ import ( "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. diff --git a/internal/channels/dispatcher.go b/pkg/channels/dispatcher.go similarity index 97% rename from internal/channels/dispatcher.go rename to pkg/channels/dispatcher.go index b28b05d..4a24d5a 100644 --- a/internal/channels/dispatcher.go +++ b/pkg/channels/dispatcher.go @@ -8,9 +8,9 @@ import ( "github.com/redis/go-redis/v9" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/events" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/events" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) const ( diff --git a/internal/channels/message.go b/pkg/channels/message.go similarity index 97% rename from internal/channels/message.go rename to pkg/channels/message.go index 2900c40..fe512de 100644 --- a/internal/channels/message.go +++ b/pkg/channels/message.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "git.omukk.dev/wrenn/wrenn/internal/events" + "git.omukk.dev/wrenn/wrenn/pkg/events" ) // FormatMessage produces a human-readable notification string containing diff --git a/internal/channels/publisher.go b/pkg/channels/publisher.go similarity index 95% rename from internal/channels/publisher.go rename to pkg/channels/publisher.go index da632c5..3f2c36f 100644 --- a/internal/channels/publisher.go +++ b/pkg/channels/publisher.go @@ -7,7 +7,7 @@ import ( "github.com/redis/go-redis/v9" - "git.omukk.dev/wrenn/wrenn/internal/events" + "git.omukk.dev/wrenn/wrenn/pkg/events" ) const streamKey = "wrenn:events" diff --git a/internal/channels/service.go b/pkg/channels/service.go similarity index 97% rename from internal/channels/service.go rename to pkg/channels/service.go index 5f53742..248abfc 100644 --- a/internal/channels/service.go +++ b/pkg/channels/service.go @@ -13,10 +13,10 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/events" - "git.omukk.dev/wrenn/wrenn/internal/id" - "git.omukk.dev/wrenn/wrenn/internal/validate" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/events" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/validate" ) // Valid providers. diff --git a/internal/channels/shoutrrr.go b/pkg/channels/shoutrrr.go similarity index 100% rename from internal/channels/shoutrrr.go rename to pkg/channels/shoutrrr.go diff --git a/internal/channels/webhook.go b/pkg/channels/webhook.go similarity index 100% rename from internal/channels/webhook.go rename to pkg/channels/webhook.go diff --git a/internal/config/config.go b/pkg/config/config.go similarity index 100% rename from internal/config/config.go rename to pkg/config/config.go diff --git a/pkg/cpextension/extension.go b/pkg/cpextension/extension.go new file mode 100644 index 0000000..7dcc98b --- /dev/null +++ b/pkg/cpextension/extension.go @@ -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) +} diff --git a/pkg/cpserver/extension.go b/pkg/cpserver/extension.go new file mode 100644 index 0000000..26a0dc6 --- /dev/null +++ b/pkg/cpserver/extension.go @@ -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 diff --git a/pkg/cpserver/options.go b/pkg/cpserver/options.go new file mode 100644 index 0000000..dc2ce0a --- /dev/null +++ b/pkg/cpserver/options.go @@ -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...) + } +} diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go new file mode 100644 index 0000000..ee6be00 --- /dev/null +++ b/pkg/cpserver/run.go @@ -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") +} diff --git a/internal/db/api_keys.sql.go b/pkg/db/api_keys.sql.go similarity index 100% rename from internal/db/api_keys.sql.go rename to pkg/db/api_keys.sql.go diff --git a/internal/db/audit.sql.go b/pkg/db/audit.sql.go similarity index 100% rename from internal/db/audit.sql.go rename to pkg/db/audit.sql.go diff --git a/internal/db/channels.sql.go b/pkg/db/channels.sql.go similarity index 100% rename from internal/db/channels.sql.go rename to pkg/db/channels.sql.go diff --git a/internal/db/db.go b/pkg/db/db.go similarity index 100% rename from internal/db/db.go rename to pkg/db/db.go diff --git a/internal/db/host_refresh_tokens.sql.go b/pkg/db/host_refresh_tokens.sql.go similarity index 100% rename from internal/db/host_refresh_tokens.sql.go rename to pkg/db/host_refresh_tokens.sql.go diff --git a/internal/db/hosts.sql.go b/pkg/db/hosts.sql.go similarity index 100% rename from internal/db/hosts.sql.go rename to pkg/db/hosts.sql.go diff --git a/internal/db/metrics.sql.go b/pkg/db/metrics.sql.go similarity index 100% rename from internal/db/metrics.sql.go rename to pkg/db/metrics.sql.go diff --git a/internal/db/models.go b/pkg/db/models.go similarity index 98% rename from internal/db/models.go rename to pkg/db/models.go index 82829b7..5e1128a 100644 --- a/internal/db/models.go +++ b/pkg/db/models.go @@ -111,6 +111,7 @@ type Sandbox struct { LastUpdated pgtype.Timestamptz `json:"last_updated"` TemplateID pgtype.UUID `json:"template_id"` TemplateTeamID pgtype.UUID `json:"template_team_id"` + Metadata []byte `json:"metadata"` } type SandboxMetricPoint struct { @@ -162,6 +163,7 @@ type Template struct { ID pgtype.UUID `json:"id"` DefaultUser string `json:"default_user"` DefaultEnv []byte `json:"default_env"` + Metadata []byte `json:"metadata"` } type TemplateBuild struct { @@ -187,6 +189,7 @@ type TemplateBuild struct { SkipPrePost bool `json:"skip_pre_post"` DefaultUser string `json:"default_user"` DefaultEnv []byte `json:"default_env"` + Metadata []byte `json:"metadata"` } type User struct { diff --git a/internal/db/oauth.sql.go b/pkg/db/oauth.sql.go similarity index 100% rename from internal/db/oauth.sql.go rename to pkg/db/oauth.sql.go diff --git a/internal/db/sandboxes.sql.go b/pkg/db/sandboxes.sql.go similarity index 92% rename from internal/db/sandboxes.sql.go rename to pkg/db/sandboxes.sql.go index 520dcf6..e33818d 100644 --- a/internal/db/sandboxes.sql.go +++ b/pkg/db/sandboxes.sql.go @@ -43,7 +43,7 @@ func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatu } 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) { @@ -67,12 +67,13 @@ func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, erro &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ) return i, err } 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 { @@ -101,6 +102,7 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ) return i, err } @@ -127,9 +129,9 @@ func (q *Queries) GetSandboxProxyTarget(ctx context.Context, id pgtype.UUID) (Ge } 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) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) -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 +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, $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, metadata ` type InsertSandboxParams struct { @@ -144,6 +146,7 @@ type InsertSandboxParams struct { DiskSizeMb int32 `json:"disk_size_mb"` TemplateID pgtype.UUID `json:"template_id"` TemplateTeamID pgtype.UUID `json:"template_team_id"` + Metadata []byte `json:"metadata"` } 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.TemplateID, arg.TemplateTeamID, + arg.Metadata, ) var i Sandbox err := row.Scan( @@ -179,12 +183,13 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ) return i, err } 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') ORDER BY created_at DESC ` @@ -216,6 +221,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ); err != nil { return nil, err } @@ -228,7 +234,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U } 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) { @@ -258,6 +264,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ); err != nil { return nil, err } @@ -270,7 +277,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { } 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[]) ORDER BY created_at DESC ` @@ -307,6 +314,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ); err != nil { return nil, err } @@ -319,7 +327,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand } 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') ORDER BY created_at DESC ` @@ -351,6 +359,7 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID pgtype.UUID) ( &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ); err != nil { return nil, err } @@ -394,6 +403,23 @@ func (q *Queries) UpdateLastActive(ctx context.Context, arg UpdateLastActivePara 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 UPDATE sandboxes SET status = 'running', @@ -403,7 +429,7 @@ SET status = 'running', last_active_at = $4, last_updated = NOW() 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 { @@ -439,6 +465,7 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ) return i, err } @@ -448,7 +475,7 @@ UPDATE sandboxes SET status = $2, last_updated = NOW() 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 { @@ -477,6 +504,7 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat &i.LastUpdated, &i.TemplateID, &i.TemplateTeamID, + &i.Metadata, ) return i, err } diff --git a/internal/db/teams.sql.go b/pkg/db/teams.sql.go similarity index 100% rename from internal/db/teams.sql.go rename to pkg/db/teams.sql.go diff --git a/internal/db/template_builds.sql.go b/pkg/db/template_builds.sql.go similarity index 91% rename from internal/db/template_builds.sql.go rename to pkg/db/template_builds.sql.go index ff15634..051547d 100644 --- a/internal/db/template_builds.sql.go +++ b/pkg/db/template_builds.sql.go @@ -12,7 +12,7 @@ import ( ) 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) { @@ -41,6 +41,7 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat &i.SkipPrePost, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } @@ -48,7 +49,7 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat 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) 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 { @@ -103,12 +104,13 @@ func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBui &i.SkipPrePost, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } 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) { @@ -143,6 +145,7 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro &i.SkipPrePost, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ); err != nil { return nil, err } @@ -156,7 +159,7 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro const updateBuildDefaults = `-- name: UpdateBuildDefaults :exec UPDATE template_builds -SET default_user = $2, default_env = $3 +SET default_user = $2, default_env = $3, metadata = $4 WHERE id = $1 ` @@ -164,10 +167,16 @@ type UpdateBuildDefaultsParams struct { ID pgtype.UUID `json:"id"` DefaultUser string `json:"default_user"` DefaultEnv []byte `json:"default_env"` + Metadata []byte `json:"metadata"` } 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 } @@ -227,7 +236,7 @@ SET status = $2, 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 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 { @@ -261,6 +270,7 @@ func (q *Queries) UpdateBuildStatus(ctx context.Context, arg UpdateBuildStatusPa &i.SkipPrePost, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } diff --git a/internal/db/templates.sql.go b/pkg/db/templates.sql.go similarity index 85% rename from internal/db/templates.sql.go rename to pkg/db/templates.sql.go index 1606d6f..97e528b 100644 --- a/internal/db/templates.sql.go +++ b/pkg/db/templates.sql.go @@ -45,7 +45,7 @@ func (q *Queries) DeleteTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) } 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. @@ -63,12 +63,13 @@ func (q *Queries) GetPlatformTemplateByName(ctx context.Context, name string) (T &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } 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) { @@ -85,12 +86,13 @@ func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, er &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } 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 { @@ -113,12 +115,13 @@ func (q *Queries) GetTemplateByName(ctx context.Context, arg GetTemplateByNamePa &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } 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 { @@ -141,14 +144,15 @@ func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamPa &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } const insertTemplate = `-- name: InsertTemplate :one -INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user, default_env) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) -RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, 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, $10) +RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata ` type InsertTemplateParams struct { @@ -161,6 +165,7 @@ type InsertTemplateParams struct { TeamID pgtype.UUID `json:"team_id"` DefaultUser string `json:"default_user"` DefaultEnv []byte `json:"default_env"` + Metadata []byte `json:"metadata"` } 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.DefaultUser, arg.DefaultEnv, + arg.Metadata, ) var i Template err := row.Scan( @@ -187,12 +193,13 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ) return i, err } 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) { @@ -215,6 +222,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ); err != nil { return nil, err } @@ -227,7 +235,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { } 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. @@ -251,6 +259,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) ( &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ); err != nil { return nil, err } @@ -263,7 +272,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) ( } 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 { @@ -292,6 +301,7 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ); err != nil { return nil, err } @@ -304,7 +314,7 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla } 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). @@ -328,6 +338,7 @@ func (q *Queries) ListTemplatesByTeamOnly(ctx context.Context, teamID pgtype.UUI &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ); err != nil { return nil, err } @@ -340,7 +351,7 @@ func (q *Queries) ListTemplatesByTeamOnly(ctx context.Context, teamID pgtype.UUI } 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) { @@ -363,6 +374,7 @@ func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Temp &i.ID, &i.DefaultUser, &i.DefaultEnv, + &i.Metadata, ); err != nil { return nil, err } diff --git a/internal/db/users.sql.go b/pkg/db/users.sql.go similarity index 100% rename from internal/db/users.sql.go rename to pkg/db/users.sql.go diff --git a/internal/events/event.go b/pkg/events/event.go similarity index 100% rename from internal/events/event.go rename to pkg/events/event.go diff --git a/internal/id/id.go b/pkg/id/id.go similarity index 100% rename from internal/id/id.go rename to pkg/id/id.go diff --git a/internal/id/id_test.go b/pkg/id/id_test.go similarity index 100% rename from internal/id/id_test.go rename to pkg/id/id_test.go diff --git a/internal/lifecycle/hostpool.go b/pkg/lifecycle/hostpool.go similarity index 98% rename from internal/lifecycle/hostpool.go rename to pkg/lifecycle/hostpool.go index ca9e8bb..3931d7b 100644 --- a/internal/lifecycle/hostpool.go +++ b/pkg/lifecycle/hostpool.go @@ -8,8 +8,8 @@ import ( "sync" "time" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" ) diff --git a/internal/lifecycle/manager.go b/pkg/lifecycle/manager.go similarity index 100% rename from internal/lifecycle/manager.go rename to pkg/lifecycle/manager.go diff --git a/internal/scheduler/least_loaded.go b/pkg/scheduler/least_loaded.go similarity index 99% rename from internal/scheduler/least_loaded.go rename to pkg/scheduler/least_loaded.go index 6bc2838..57c4a18 100644 --- a/internal/scheduler/least_loaded.go +++ b/pkg/scheduler/least_loaded.go @@ -6,7 +6,7 @@ import ( "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. diff --git a/internal/scheduler/round_robin.go b/pkg/scheduler/round_robin.go similarity index 98% rename from internal/scheduler/round_robin.go rename to pkg/scheduler/round_robin.go index f2f47ad..693de30 100644 --- a/internal/scheduler/round_robin.go +++ b/pkg/scheduler/round_robin.go @@ -7,7 +7,7 @@ import ( "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 diff --git a/internal/scheduler/scheduler.go b/pkg/scheduler/scheduler.go similarity index 100% rename from internal/scheduler/scheduler.go rename to pkg/scheduler/scheduler.go diff --git a/internal/scheduler/single_host.go b/pkg/scheduler/single_host.go similarity index 100% rename from internal/scheduler/single_host.go rename to pkg/scheduler/single_host.go diff --git a/internal/service/apikey.go b/pkg/service/apikey.go similarity index 93% rename from internal/service/apikey.go rename to pkg/service/apikey.go index 90bdfb6..8eea896 100644 --- a/internal/service/apikey.go +++ b/pkg/service/apikey.go @@ -6,9 +6,9 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "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/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) // APIKeyService provides API key operations shared between the REST API and the dashboard. diff --git a/internal/service/audit.go b/pkg/service/audit.go similarity index 97% rename from internal/service/audit.go rename to pkg/service/audit.go index e028625..cee95d6 100644 --- a/internal/service/audit.go +++ b/pkg/service/audit.go @@ -8,8 +8,8 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" ) const auditMaxLimit = 200 diff --git a/internal/service/build.go b/pkg/service/build.go similarity index 97% rename from internal/service/build.go rename to pkg/service/build.go index 92d826d..70e67f7 100644 --- a/internal/service/build.go +++ b/pkg/service/build.go @@ -13,11 +13,11 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/redis/go-redis/v9" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" - "git.omukk.dev/wrenn/wrenn/internal/lifecycle" "git.omukk.dev/wrenn/wrenn/internal/recipe" - "git.omukk.dev/wrenn/wrenn/internal/scheduler" + "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/scheduler" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) @@ -326,7 +326,8 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) { s.failBuild(buildCtx, buildID, fmt.Sprintf("create sandbox failed: %v", err)) return } - _ = resp + // Capture sandbox metadata (envd/kernel/firecracker/agent versions). + sandboxMetadata := resp.Msg.Metadata // Record sandbox/host association. _ = s.DB.UpdateBuildSandbox(buildCtx, db.UpdateBuildSandboxParams{ @@ -502,6 +503,12 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) { defaultEnvJSON = []byte("{}") } + // Serialize sandbox metadata for DB storage. + metadataJSON, err := json.Marshal(sandboxMetadata) + if err != nil || len(sandboxMetadata) == 0 { + metadataJSON = []byte("{}") + } + if _, err := s.DB.InsertTemplate(buildCtx, db.InsertTemplateParams{ ID: build.TemplateID, Name: build.Name, @@ -512,16 +519,18 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) { TeamID: id.PlatformTeamID, DefaultUser: templateDefaultUser, DefaultEnv: defaultEnvJSON, + Metadata: metadataJSON, }); err != nil { log.Error("failed to insert template record", "error", err) // Build succeeded on disk, just DB record failed — don't mark as failed. } - // Record defaults on the build record for inspection. + // Record defaults and metadata on the build record for inspection. _ = s.DB.UpdateBuildDefaults(buildCtx, db.UpdateBuildDefaultsParams{ ID: buildID, DefaultUser: templateDefaultUser, DefaultEnv: defaultEnvJSON, + Metadata: metadataJSON, }) // For CreateSnapshot, the sandbox is already destroyed by the snapshot process. diff --git a/internal/service/host.go b/pkg/service/host.go similarity index 99% rename from internal/service/host.go rename to pkg/service/host.go index 1ddffca..9f5b5c8 100644 --- a/internal/service/host.go +++ b/pkg/service/host.go @@ -14,10 +14,10 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/redis/go-redis/v9" - "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/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/service/sandbox.go b/pkg/service/sandbox.go similarity index 90% rename from internal/service/sandbox.go rename to pkg/service/sandbox.go index 9af480b..d50520b 100644 --- a/internal/service/sandbox.go +++ b/pkg/service/sandbox.go @@ -10,11 +10,11 @@ import ( "connectrpc.com/connect" "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" - "git.omukk.dev/wrenn/wrenn/internal/lifecycle" - "git.omukk.dev/wrenn/wrenn/internal/scheduler" - "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/scheduler" + "git.omukk.dev/wrenn/wrenn/pkg/validate" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) @@ -143,6 +143,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db. DiskSizeMb: p.DiskSizeMB, TemplateID: templateID, TemplateTeamID: templateTeamID, + Metadata: []byte("{}"), }); err != nil { return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err) } @@ -182,6 +183,18 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db. return db.Sandbox{}, fmt.Errorf("update sandbox running: %w", err) } + // Store runtime metadata from the agent (envd/kernel/firecracker/agent versions). + if meta := resp.Msg.Metadata; len(meta) > 0 { + metaJSON, _ := json.Marshal(meta) + if err := s.DB.UpdateSandboxMetadata(ctx, db.UpdateSandboxMetadataParams{ + ID: sandboxID, + Metadata: metaJSON, + }); err != nil { + slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err) + } + sb.Metadata = metaJSON + } + return sb, nil } @@ -272,11 +285,21 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UU } } + // Extract kernel version hint from existing sandbox metadata. + var kernelVersion string + if len(sb.Metadata) > 0 { + var meta map[string]string + if err := json.Unmarshal(sb.Metadata, &meta); err == nil { + kernelVersion = meta["kernel_version"] + } + } + resp, err := agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{ - SandboxId: sandboxIDStr, - TimeoutSec: sb.TimeoutSec, - DefaultUser: resumeDefaultUser, - DefaultEnv: resumeDefaultEnv, + SandboxId: sandboxIDStr, + TimeoutSec: sb.TimeoutSec, + DefaultUser: resumeDefaultUser, + DefaultEnv: resumeDefaultEnv, + KernelVersion: kernelVersion, })) if err != nil { return db.Sandbox{}, fmt.Errorf("agent resume: %w", err) @@ -295,6 +318,19 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UU if err != nil { return db.Sandbox{}, fmt.Errorf("update status: %w", err) } + + // Update metadata with actual versions used after resume. + if meta := resp.Msg.Metadata; len(meta) > 0 { + metaJSON, _ := json.Marshal(meta) + if err := s.DB.UpdateSandboxMetadata(ctx, db.UpdateSandboxMetadataParams{ + ID: sandboxID, + Metadata: metaJSON, + }); err != nil { + slog.Warn("failed to update sandbox metadata after resume", "id", sandboxIDStr, "error", err) + } + sb.Metadata = metaJSON + } + return sb, nil } diff --git a/internal/service/stats.go b/pkg/service/stats.go similarity index 99% rename from internal/service/stats.go rename to pkg/service/stats.go index 88abde2..d756a74 100644 --- a/internal/service/stats.go +++ b/pkg/service/stats.go @@ -10,7 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - "git.omukk.dev/wrenn/wrenn/internal/db" + "git.omukk.dev/wrenn/wrenn/pkg/db" ) // TimeRange identifies a chart time window. diff --git a/internal/service/team.go b/pkg/service/team.go similarity index 99% rename from internal/service/team.go rename to pkg/service/team.go index ece4078..858c7e2 100644 --- a/internal/service/team.go +++ b/pkg/service/team.go @@ -12,9 +12,9 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - "git.omukk.dev/wrenn/wrenn/internal/db" - "git.omukk.dev/wrenn/wrenn/internal/id" - "git.omukk.dev/wrenn/wrenn/internal/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" + "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" ) diff --git a/internal/service/template.go b/pkg/service/template.go similarity index 94% rename from internal/service/template.go rename to pkg/service/template.go index af18076..269af10 100644 --- a/internal/service/template.go +++ b/pkg/service/template.go @@ -5,7 +5,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/db" + "git.omukk.dev/wrenn/wrenn/pkg/db" ) // TemplateService provides template/snapshot operations shared between the diff --git a/internal/service/user.go b/pkg/service/user.go similarity index 97% rename from internal/service/user.go rename to pkg/service/user.go index 2b90c61..b585e23 100644 --- a/internal/service/user.go +++ b/pkg/service/user.go @@ -7,7 +7,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" - "git.omukk.dev/wrenn/wrenn/internal/db" + "git.omukk.dev/wrenn/wrenn/pkg/db" ) // UserService provides user management operations. diff --git a/internal/validate/name.go b/pkg/validate/name.go similarity index 100% rename from internal/validate/name.go rename to pkg/validate/name.go diff --git a/internal/validate/name_test.go b/pkg/validate/name_test.go similarity index 100% rename from internal/validate/name_test.go rename to pkg/validate/name_test.go diff --git a/proto/hostagent/gen/hostagent.pb.go b/proto/hostagent/gen/hostagent.pb.go index fc6b2e0..96a0512 100644 --- a/proto/hostagent/gen/hostagent.pb.go +++ b/proto/hostagent/gen/hostagent.pb.go @@ -150,10 +150,13 @@ func (x *CreateSandboxRequest) GetDefaultEnv() map[string]string { } type CreateSandboxResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` - Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` - HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + // Runtime metadata collected during sandbox creation (e.g. envd_version, + // kernel_version, firecracker_version, agent_version). + Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -209,6 +212,13 @@ func (x *CreateSandboxResponse) GetHostIp() string { return "" } +func (x *CreateSandboxResponse) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + type DestroySandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` @@ -378,7 +388,10 @@ type ResumeSandboxRequest struct { // Default unix user for the sandbox (set in envd via PostInit on resume). DefaultUser string `protobuf:"bytes,3,opt,name=default_user,json=defaultUser,proto3" json:"default_user,omitempty"` // Default environment variables (set in envd via PostInit on resume). - DefaultEnv map[string]string `protobuf:"bytes,4,rep,name=default_env,json=defaultEnv,proto3" json:"default_env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + DefaultEnv map[string]string `protobuf:"bytes,4,rep,name=default_env,json=defaultEnv,proto3" json:"default_env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Kernel version hint from the DB — the agent tries to use the exact version, + // falling back to latest if not found on disk. + KernelVersion string `protobuf:"bytes,5,opt,name=kernel_version,json=kernelVersion,proto3" json:"kernel_version,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -441,11 +454,21 @@ func (x *ResumeSandboxRequest) GetDefaultEnv() map[string]string { return nil } +func (x *ResumeSandboxRequest) GetKernelVersion() string { + if x != nil { + return x.KernelVersion + } + return "" +} + type ResumeSandboxResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` - Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` - HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + // Actual runtime metadata after resume (versions may differ from hint if + // the exact kernel was not available). + Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -501,6 +524,13 @@ func (x *ResumeSandboxResponse) GetHostIp() string { return "" } +func (x *ResumeSandboxResponse) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + type CreateSnapshotRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` @@ -956,8 +986,10 @@ type SandboxInfo struct { TimeoutSec int32 `protobuf:"varint,9,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` TeamId string `protobuf:"bytes,10,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` TemplateId string `protobuf:"bytes,11,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Runtime metadata (envd_version, kernel_version, etc.). + Metadata map[string]string `protobuf:"bytes,12,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxInfo) Reset() { @@ -1067,6 +1099,13 @@ func (x *SandboxInfo) GetTemplateId() string { return "" } +func (x *SandboxInfo) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + type WriteFileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` @@ -4102,12 +4141,16 @@ const file_hostagent_proto_rawDesc = "" + "defaultEnv\x1a=\n" + "\x0fDefaultEnvEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"g\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf3\x01\n" + "\x15CreateSandboxResponse\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" + - "\ahost_ip\x18\x03 \x01(\tR\x06hostIp\"6\n" + + "\ahost_ip\x18\x03 \x01(\tR\x06hostIp\x12M\n" + + "\bmetadata\x18\x04 \x03(\v21.hostagent.v1.CreateSandboxResponse.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"6\n" + "\x15DestroySandboxRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x18\n" + @@ -4115,7 +4158,7 @@ const file_hostagent_proto_rawDesc = "" + "\x13PauseSandboxRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x16\n" + - "\x14PauseSandboxResponse\"\x8d\x02\n" + + "\x14PauseSandboxResponse\"\xb4\x02\n" + "\x14ResumeSandboxRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + @@ -4123,15 +4166,20 @@ const file_hostagent_proto_rawDesc = "" + "timeoutSec\x12!\n" + "\fdefault_user\x18\x03 \x01(\tR\vdefaultUser\x12S\n" + "\vdefault_env\x18\x04 \x03(\v22.hostagent.v1.ResumeSandboxRequest.DefaultEnvEntryR\n" + - "defaultEnv\x1a=\n" + + "defaultEnv\x12%\n" + + "\x0ekernel_version\x18\x05 \x01(\tR\rkernelVersion\x1a=\n" + "\x0fDefaultEnvEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"g\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf3\x01\n" + "\x15ResumeSandboxResponse\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" + - "\ahost_ip\x18\x03 \x01(\tR\x06hostIp\"\x84\x01\n" + + "\ahost_ip\x18\x03 \x01(\tR\x06hostIp\x12M\n" + + "\bmetadata\x18\x04 \x03(\v21.hostagent.v1.ResumeSandboxResponse.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x84\x01\n" + "\x15CreateSnapshotRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + @@ -4163,7 +4211,7 @@ const file_hostagent_proto_rawDesc = "" + "\x14ListSandboxesRequest\"\x87\x01\n" + "\x15ListSandboxesResponse\x127\n" + "\tsandboxes\x18\x01 \x03(\v2\x19.hostagent.v1.SandboxInfoR\tsandboxes\x125\n" + - "\x17auto_paused_sandbox_ids\x18\x02 \x03(\tR\x14autoPausedSandboxIds\"\xde\x02\n" + + "\x17auto_paused_sandbox_ids\x18\x02 \x03(\tR\x14autoPausedSandboxIds\"\xe0\x03\n" + "\vSandboxInfo\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + @@ -4179,7 +4227,11 @@ const file_hostagent_proto_rawDesc = "" + "\ateam_id\x18\n" + " \x01(\tR\x06teamId\x12\x1f\n" + "\vtemplate_id\x18\v \x01(\tR\n" + - "templateId\"_\n" + + "templateId\x12C\n" + + "\bmetadata\x18\f \x03(\v2'.hostagent.v1.SandboxInfo.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"_\n" + "\x10WriteFileRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + @@ -4434,7 +4486,7 @@ func file_hostagent_proto_rawDescGZIP() []byte { return file_hostagent_proto_rawDescData } -var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 73) +var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 76) var file_hostagent_proto_goTypes = []any{ (*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest (*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse @@ -4506,96 +4558,102 @@ var file_hostagent_proto_goTypes = []any{ (*ConnectProcessRequest)(nil), // 67: hostagent.v1.ConnectProcessRequest (*ConnectProcessResponse)(nil), // 68: hostagent.v1.ConnectProcessResponse nil, // 69: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry - nil, // 70: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry - nil, // 71: hostagent.v1.PtyAttachRequest.EnvsEntry - nil, // 72: hostagent.v1.StartBackgroundRequest.EnvsEntry + nil, // 70: hostagent.v1.CreateSandboxResponse.MetadataEntry + nil, // 71: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry + nil, // 72: hostagent.v1.ResumeSandboxResponse.MetadataEntry + nil, // 73: hostagent.v1.SandboxInfo.MetadataEntry + nil, // 74: hostagent.v1.PtyAttachRequest.EnvsEntry + nil, // 75: hostagent.v1.StartBackgroundRequest.EnvsEntry } var file_hostagent_proto_depIdxs = []int32{ 69, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry - 70, // 1: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry - 16, // 2: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo - 23, // 3: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart - 24, // 4: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData - 25, // 5: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd - 27, // 6: hostagent.v1.WriteFileStreamRequest.meta:type_name -> hostagent.v1.WriteFileStreamMeta - 33, // 7: hostagent.v1.ListDirResponse.entries:type_name -> hostagent.v1.FileEntry - 33, // 8: hostagent.v1.MakeDirResponse.entry:type_name -> hostagent.v1.FileEntry - 42, // 9: hostagent.v1.GetSandboxMetricsResponse.points:type_name -> hostagent.v1.MetricPoint - 42, // 10: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint - 42, // 11: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint - 42, // 12: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint - 71, // 13: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry - 51, // 14: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted - 52, // 15: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput - 53, // 16: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited - 72, // 17: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry - 63, // 18: hostagent.v1.ListProcessesResponse.processes:type_name -> hostagent.v1.ProcessEntry - 23, // 19: hostagent.v1.ConnectProcessResponse.start:type_name -> hostagent.v1.ExecStreamStart - 24, // 20: hostagent.v1.ConnectProcessResponse.data:type_name -> hostagent.v1.ExecStreamData - 25, // 21: hostagent.v1.ConnectProcessResponse.end:type_name -> hostagent.v1.ExecStreamEnd - 0, // 22: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest - 2, // 23: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest - 4, // 24: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest - 6, // 25: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest - 12, // 26: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest - 14, // 27: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest - 17, // 28: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest - 19, // 29: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest - 31, // 30: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest - 34, // 31: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest - 36, // 32: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest - 8, // 33: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest - 10, // 34: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest - 21, // 35: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest - 26, // 36: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest - 29, // 37: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest - 38, // 38: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest - 40, // 39: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest - 43, // 40: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest - 45, // 41: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest - 47, // 42: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest - 49, // 43: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest - 54, // 44: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest - 56, // 45: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest - 58, // 46: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest - 60, // 47: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest - 62, // 48: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest - 65, // 49: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest - 67, // 50: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest - 1, // 51: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse - 3, // 52: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse - 5, // 53: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse - 7, // 54: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse - 13, // 55: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse - 15, // 56: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse - 18, // 57: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse - 20, // 58: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse - 32, // 59: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse - 35, // 60: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse - 37, // 61: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse - 9, // 62: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse - 11, // 63: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse - 22, // 64: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse - 28, // 65: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse - 30, // 66: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse - 39, // 67: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse - 41, // 68: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse - 44, // 69: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse - 46, // 70: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse - 48, // 71: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse - 50, // 72: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse - 55, // 73: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse - 57, // 74: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse - 59, // 75: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse - 61, // 76: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse - 64, // 77: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse - 66, // 78: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse - 68, // 79: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse - 51, // [51:80] is the sub-list for method output_type - 22, // [22:51] is the sub-list for method input_type - 22, // [22:22] is the sub-list for extension type_name - 22, // [22:22] is the sub-list for extension extendee - 0, // [0:22] is the sub-list for field type_name + 70, // 1: hostagent.v1.CreateSandboxResponse.metadata:type_name -> hostagent.v1.CreateSandboxResponse.MetadataEntry + 71, // 2: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry + 72, // 3: hostagent.v1.ResumeSandboxResponse.metadata:type_name -> hostagent.v1.ResumeSandboxResponse.MetadataEntry + 16, // 4: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo + 73, // 5: hostagent.v1.SandboxInfo.metadata:type_name -> hostagent.v1.SandboxInfo.MetadataEntry + 23, // 6: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart + 24, // 7: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData + 25, // 8: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd + 27, // 9: hostagent.v1.WriteFileStreamRequest.meta:type_name -> hostagent.v1.WriteFileStreamMeta + 33, // 10: hostagent.v1.ListDirResponse.entries:type_name -> hostagent.v1.FileEntry + 33, // 11: hostagent.v1.MakeDirResponse.entry:type_name -> hostagent.v1.FileEntry + 42, // 12: hostagent.v1.GetSandboxMetricsResponse.points:type_name -> hostagent.v1.MetricPoint + 42, // 13: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint + 42, // 14: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint + 42, // 15: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint + 74, // 16: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry + 51, // 17: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted + 52, // 18: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput + 53, // 19: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited + 75, // 20: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry + 63, // 21: hostagent.v1.ListProcessesResponse.processes:type_name -> hostagent.v1.ProcessEntry + 23, // 22: hostagent.v1.ConnectProcessResponse.start:type_name -> hostagent.v1.ExecStreamStart + 24, // 23: hostagent.v1.ConnectProcessResponse.data:type_name -> hostagent.v1.ExecStreamData + 25, // 24: hostagent.v1.ConnectProcessResponse.end:type_name -> hostagent.v1.ExecStreamEnd + 0, // 25: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest + 2, // 26: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest + 4, // 27: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest + 6, // 28: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest + 12, // 29: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest + 14, // 30: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest + 17, // 31: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest + 19, // 32: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest + 31, // 33: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest + 34, // 34: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest + 36, // 35: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest + 8, // 36: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest + 10, // 37: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest + 21, // 38: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest + 26, // 39: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest + 29, // 40: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest + 38, // 41: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest + 40, // 42: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest + 43, // 43: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest + 45, // 44: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest + 47, // 45: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest + 49, // 46: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest + 54, // 47: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest + 56, // 48: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest + 58, // 49: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest + 60, // 50: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest + 62, // 51: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest + 65, // 52: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest + 67, // 53: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest + 1, // 54: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse + 3, // 55: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse + 5, // 56: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse + 7, // 57: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse + 13, // 58: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse + 15, // 59: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse + 18, // 60: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse + 20, // 61: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse + 32, // 62: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse + 35, // 63: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse + 37, // 64: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse + 9, // 65: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse + 11, // 66: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse + 22, // 67: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse + 28, // 68: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse + 30, // 69: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse + 39, // 70: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse + 41, // 71: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse + 44, // 72: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse + 46, // 73: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse + 48, // 74: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse + 50, // 75: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse + 55, // 76: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse + 57, // 77: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse + 59, // 78: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse + 61, // 79: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse + 64, // 80: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse + 66, // 81: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse + 68, // 82: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse + 54, // [54:83] is the sub-list for method output_type + 25, // [25:54] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name } func init() { file_hostagent_proto_init() } @@ -4641,7 +4699,7 @@ func file_hostagent_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)), NumEnums: 0, - NumMessages: 73, + NumMessages: 76, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/hostagent/hostagent.proto b/proto/hostagent/hostagent.proto index 48b9ded..5dbf222 100644 --- a/proto/hostagent/hostagent.proto +++ b/proto/hostagent/hostagent.proto @@ -144,6 +144,10 @@ message CreateSandboxResponse { string sandbox_id = 1; string status = 2; string host_ip = 3; + + // Runtime metadata collected during sandbox creation (e.g. envd_version, + // kernel_version, firecracker_version, agent_version). + map metadata = 4; } message DestroySandboxRequest { @@ -170,12 +174,20 @@ message ResumeSandboxRequest { // Default environment variables (set in envd via PostInit on resume). map default_env = 4; + + // Kernel version hint from the DB — the agent tries to use the exact version, + // falling back to latest if not found on disk. + string kernel_version = 5; } message ResumeSandboxResponse { string sandbox_id = 1; string status = 2; string host_ip = 3; + + // Actual runtime metadata after resume (versions may differ from hint if + // the exact kernel was not available). + map metadata = 4; } message CreateSnapshotRequest { @@ -241,6 +253,9 @@ message SandboxInfo { int32 timeout_sec = 9; string team_id = 10; string template_id = 11; + + // Runtime metadata (envd_version, kernel_version, etc.). + map metadata = 12; } message WriteFileRequest { diff --git a/sqlc.yaml b/sqlc.yaml index eb9298f..1840f4e 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -6,6 +6,6 @@ sql: gen: go: package: "db" - out: "internal/db" + out: "pkg/db" sql_package: "pgx/v5" emit_json_tags: true