1
0
forked from wrenn/wrenn

Switch database IDs from TEXT to native UUID

Consolidate 16 migrations into one with UUID columns for all entity
IDs. TEXT is kept only for polymorphic fields (audit_logs.actor_id,
resource_id) and template names. The id package now generates UUIDs
via google/uuid, with Format*/Parse* helpers for the prefixed wire
format (sb-{uuid}, usr-{uuid}, etc.). Auth context, services, and
handlers pass pgtype.UUID internally; conversion to/from prefixed
strings happens at API and RPC boundaries. Adds PlatformTeamID
(all-zeros UUID) for shared resources.
This commit is contained in:
2026-03-26 16:16:21 +06:00
parent cdd89a7cee
commit 4ddd494160
66 changed files with 1350 additions and 1127 deletions

View File

@ -1,14 +1,149 @@
-- +goose Up -- +goose Up
-- teams
CREATE TABLE teams (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
is_byoc BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_teams_slug ON teams(slug);
-- users
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT,
name TEXT NOT NULL DEFAULT '',
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- users_teams (junction)
CREATE TABLE users_teams (
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (team_id, user_id)
);
CREATE INDEX idx_users_teams_user ON users_teams(user_id);
-- team_api_keys
CREATE TABLE team_api_keys (
id UUID PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE INDEX idx_team_api_keys_team ON team_api_keys(team_id);
-- oauth_providers
CREATE TABLE oauth_providers (
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
email TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (provider, provider_id)
);
CREATE INDEX idx_oauth_providers_user ON oauth_providers(user_id);
-- admin_permissions
CREATE TABLE admin_permissions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
permission TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, permission)
);
CREATE INDEX idx_admin_permissions_user ON admin_permissions(user_id);
-- hosts
CREATE TABLE hosts (
id UUID PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'regular',
team_id UUID REFERENCES teams(id),
provider TEXT NOT NULL DEFAULT '',
availability_zone TEXT NOT NULL DEFAULT '',
arch TEXT NOT NULL DEFAULT '',
cpu_cores INTEGER NOT NULL DEFAULT 0,
memory_mb INTEGER NOT NULL DEFAULT 0,
disk_gb INTEGER NOT NULL DEFAULT 0,
address TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
last_heartbeat_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cert_fingerprint TEXT NOT NULL DEFAULT '',
mtls_enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_hosts_type ON hosts(type);
CREATE INDEX idx_hosts_team ON hosts(team_id);
CREATE INDEX idx_hosts_status ON hosts(status);
-- host_tokens
CREATE TABLE host_tokens (
id UUID PRIMARY KEY,
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
CREATE INDEX idx_host_tokens_host ON host_tokens(host_id);
-- host_tags
CREATE TABLE host_tags (
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (host_id, tag)
);
CREATE INDEX idx_host_tags_tag ON host_tags(tag);
-- host_refresh_tokens
CREATE TABLE host_refresh_tokens (
id UUID PRIMARY KEY,
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_host_refresh_tokens_host ON host_refresh_tokens(host_id);
-- templates (TEXT primary key — not UUID)
CREATE TABLE templates (
name TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'base',
vcpus INTEGER NOT NULL DEFAULT 1,
memory_mb INTEGER NOT NULL DEFAULT 512,
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
team_id UUID NOT NULL
);
CREATE INDEX idx_templates_team ON templates(team_id);
-- sandboxes
CREATE TABLE sandboxes ( CREATE TABLE sandboxes (
id TEXT PRIMARY KEY, id UUID PRIMARY KEY,
owner_id TEXT NOT NULL DEFAULT '', team_id UUID NOT NULL REFERENCES teams(id),
host_id TEXT NOT NULL DEFAULT 'default', host_id UUID NOT NULL,
template TEXT NOT NULL DEFAULT 'minimal', template TEXT NOT NULL DEFAULT 'minimal',
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
vcpus INTEGER NOT NULL DEFAULT 1, vcpus INTEGER NOT NULL DEFAULT 1,
memory_mb INTEGER NOT NULL DEFAULT 512, memory_mb INTEGER NOT NULL DEFAULT 512,
timeout_sec INTEGER NOT NULL DEFAULT 0, timeout_sec INTEGER NOT NULL DEFAULT 300,
guest_ip TEXT NOT NULL DEFAULT '', guest_ip TEXT NOT NULL DEFAULT '',
host_ip TEXT NOT NULL DEFAULT '', host_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -16,10 +151,86 @@ CREATE TABLE sandboxes (
last_active_at TIMESTAMPTZ, last_active_at TIMESTAMPTZ,
last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX idx_sandboxes_status ON sandboxes(status); CREATE INDEX idx_sandboxes_status ON sandboxes(status);
CREATE INDEX idx_sandboxes_host_status ON sandboxes(host_id, status); CREATE INDEX idx_sandboxes_host_status ON sandboxes(host_id, status);
CREATE INDEX idx_sandboxes_team ON sandboxes(team_id);
-- audit_logs (id and team_id are UUID; actor_id and resource_id are TEXT for polymorphism)
CREATE TABLE audit_logs (
id UUID PRIMARY KEY,
team_id UUID NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT,
actor_name TEXT NOT NULL DEFAULT '',
resource_type TEXT NOT NULL,
resource_id TEXT,
action TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'team',
status TEXT NOT NULL DEFAULT 'success',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_team_time ON audit_logs(team_id, created_at DESC);
CREATE INDEX idx_audit_logs_team_resource ON audit_logs(team_id, resource_type, created_at DESC);
-- sandbox_metrics_snapshots
CREATE TABLE sandbox_metrics_snapshots (
id BIGSERIAL PRIMARY KEY,
team_id UUID NOT NULL,
sampled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
running_count INTEGER NOT NULL DEFAULT 0,
vcpus_reserved INTEGER NOT NULL DEFAULT 0,
memory_mb_reserved INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_metrics_snapshots_team_time ON sandbox_metrics_snapshots(team_id, sampled_at DESC);
-- sandbox_metric_points
CREATE TABLE sandbox_metric_points (
sandbox_id UUID NOT NULL,
tier TEXT NOT NULL CHECK (tier IN ('10m', '2h', '24h')),
ts BIGINT NOT NULL,
cpu_pct FLOAT8 NOT NULL DEFAULT 0,
mem_bytes BIGINT NOT NULL DEFAULT 0,
disk_bytes BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (sandbox_id, tier, ts)
);
CREATE INDEX idx_sandbox_metric_points_sandbox_tier ON sandbox_metric_points(sandbox_id, tier);
-- template_builds
CREATE TABLE template_builds (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
base_template TEXT NOT NULL,
recipe JSONB NOT NULL DEFAULT '[]',
healthcheck TEXT NOT NULL DEFAULT '',
vcpus INTEGER NOT NULL DEFAULT 1,
memory_mb INTEGER NOT NULL DEFAULT 512,
status TEXT NOT NULL DEFAULT 'pending',
current_step INTEGER NOT NULL DEFAULT 0,
total_steps INTEGER NOT NULL DEFAULT 0,
logs JSONB NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
sandbox_id UUID,
host_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
-- +goose Down -- +goose Down
DROP TABLE IF EXISTS template_builds;
DROP TABLE sandboxes; DROP TABLE IF EXISTS sandbox_metric_points;
DROP TABLE IF EXISTS sandbox_metrics_snapshots;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS sandboxes;
DROP TABLE IF EXISTS templates;
DROP TABLE IF EXISTS host_refresh_tokens;
DROP TABLE IF EXISTS host_tags;
DROP TABLE IF EXISTS host_tokens;
DROP TABLE IF EXISTS hosts;
DROP TABLE IF EXISTS admin_permissions;
DROP TABLE IF EXISTS oauth_providers;
DROP TABLE IF EXISTS team_api_keys;
DROP TABLE IF EXISTS users_teams;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS teams;

View File

@ -1,14 +0,0 @@
-- +goose Up
CREATE TABLE templates (
name TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'base', -- 'base' or 'snapshot'
vcpus INTEGER,
memory_mb INTEGER,
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- +goose Down
DROP TABLE templates;

View File

@ -1,46 +0,0 @@
-- +goose Up
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users_teams (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
is_default BOOLEAN NOT NULL DEFAULT TRUE,
role TEXT NOT NULL DEFAULT 'owner',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (team_id, user_id)
);
CREATE INDEX idx_users_teams_user ON users_teams(user_id);
CREATE TABLE team_api_keys (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE INDEX idx_team_api_keys_team ON team_api_keys(team_id);
-- +goose Down
DROP TABLE team_api_keys;
DROP TABLE users_teams;
DROP TABLE teams;
DROP TABLE users;

View File

@ -1,31 +0,0 @@
-- +goose Up
ALTER TABLE sandboxes
ADD COLUMN team_id TEXT NOT NULL DEFAULT '';
UPDATE sandboxes SET team_id = owner_id WHERE owner_id != '';
ALTER TABLE sandboxes
DROP COLUMN owner_id;
ALTER TABLE templates
ADD COLUMN team_id TEXT NOT NULL DEFAULT '';
CREATE INDEX idx_sandboxes_team ON sandboxes(team_id);
CREATE INDEX idx_templates_team ON templates(team_id);
-- +goose Down
ALTER TABLE sandboxes
ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';
UPDATE sandboxes SET owner_id = team_id WHERE team_id != '';
ALTER TABLE sandboxes
DROP COLUMN team_id;
ALTER TABLE templates
DROP COLUMN team_id;
DROP INDEX IF EXISTS idx_sandboxes_team;
DROP INDEX IF EXISTS idx_templates_team;

View File

@ -1,22 +0,0 @@
-- +goose Up
ALTER TABLE users
ALTER COLUMN password_hash DROP NOT NULL;
CREATE TABLE oauth_providers (
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (provider, provider_id)
);
CREATE INDEX idx_oauth_providers_user ON oauth_providers(user_id);
-- +goose Down
DROP TABLE oauth_providers;
UPDATE users SET password_hash = '' WHERE password_hash IS NULL;
ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL;

View File

@ -1,21 +0,0 @@
-- +goose Up
ALTER TABLE users
ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE admin_permissions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, permission)
);
CREATE INDEX idx_admin_permissions_user ON admin_permissions(user_id);
-- +goose Down
DROP TABLE admin_permissions;
ALTER TABLE users
DROP COLUMN is_admin;

View File

@ -1,9 +0,0 @@
-- +goose Up
ALTER TABLE teams
ADD COLUMN is_byoc BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose Down
ALTER TABLE teams
DROP COLUMN is_byoc;

View File

@ -1,47 +0,0 @@
-- +goose Up
CREATE TABLE hosts (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'regular', -- 'regular' or 'byoc'
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
provider TEXT,
availability_zone TEXT,
arch TEXT,
cpu_cores INTEGER,
memory_mb INTEGER,
disk_gb INTEGER,
address TEXT, -- ip:port of host agent
status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'online', 'offline', 'draining'
last_heartbeat_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE host_tokens (
id TEXT PRIMARY KEY,
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
CREATE TABLE host_tags (
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (host_id, tag)
);
CREATE INDEX idx_hosts_type ON hosts(type);
CREATE INDEX idx_hosts_team ON hosts(team_id);
CREATE INDEX idx_hosts_status ON hosts(status);
CREATE INDEX idx_host_tokens_host ON host_tokens(host_id);
CREATE INDEX idx_host_tags_tag ON host_tags(tag);
-- +goose Down
DROP TABLE host_tags;
DROP TABLE host_tokens;
DROP TABLE hosts;

View File

@ -1,11 +0,0 @@
-- +goose Up
ALTER TABLE hosts
ADD COLUMN cert_fingerprint TEXT,
ADD COLUMN mtls_enabled BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose Down
ALTER TABLE hosts
DROP COLUMN cert_fingerprint,
DROP COLUMN mtls_enabled;

View File

@ -1,17 +0,0 @@
-- +goose Up
ALTER TABLE teams ADD COLUMN slug TEXT;
ALTER TABLE teams ADD COLUMN deleted_at TIMESTAMPTZ;
-- Backfill slugs for existing teams using MD5 of their ID.
-- MD5 returns 32 hex chars; take chars 1-6 and 7-12 to form a 6-6 slug.
UPDATE teams SET slug = LEFT(MD5(id), 6) || '-' || SUBSTRING(MD5(id), 7, 6);
ALTER TABLE teams ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX idx_teams_slug ON teams(slug);
-- +goose Down
DROP INDEX idx_teams_slug;
ALTER TABLE teams DROP COLUMN deleted_at;
ALTER TABLE teams DROP COLUMN slug;

View File

@ -1,5 +0,0 @@
-- +goose Up
ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE users DROP COLUMN name;

View File

@ -1,19 +0,0 @@
-- +goose Up
-- Refresh tokens for host agent JWT rotation.
-- Hosts exchange a refresh token for a new short-lived JWT + new refresh token (rotation).
-- Refresh tokens expire after 60 days; hosts must re-register with a new one-time token after that.
CREATE TABLE host_refresh_tokens (
id TEXT PRIMARY KEY,
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex of the opaque token
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ -- NULL = active; set on rotation or host delete
);
CREATE INDEX idx_host_refresh_tokens_host ON host_refresh_tokens(host_id);
-- +goose Down
DROP TABLE host_refresh_tokens;

View File

@ -1,28 +0,0 @@
-- +goose Up
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
actor_type TEXT NOT NULL, -- 'user', 'api_key', 'system'
actor_id TEXT, -- user_id or api_key_id; NULL for system
actor_name TEXT, -- display name snapshotted at write time; NULL for system
resource_type TEXT NOT NULL, -- 'sandbox', 'snapshot', 'team', 'api_key', 'member', 'host'
resource_id TEXT, -- primary ID of the affected resource; NULL when not applicable
action TEXT NOT NULL, -- 'create', 'pause', 'resume', 'destroy', 'delete', 'rename',
-- 'revoke', 'add', 'remove', 'leave', 'role_update',
-- 'marked_down', 'marked_up'
scope TEXT NOT NULL, -- 'team' or 'admin'
status TEXT NOT NULL, -- 'success', 'info', 'warning', 'error'
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Primary access pattern: team feed sorted newest-first with cursor pagination.
CREATE INDEX idx_audit_logs_team_time ON audit_logs (team_id, created_at DESC);
-- Secondary index: filtered by resource_type and action within a team.
CREATE INDEX idx_audit_logs_team_resource ON audit_logs (team_id, resource_type, action, created_at DESC);
-- +goose Down
DROP TABLE audit_logs;

View File

@ -1,18 +0,0 @@
-- +goose Up
CREATE TABLE sandbox_metrics_snapshots (
id BIGSERIAL PRIMARY KEY,
team_id TEXT NOT NULL,
sampled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
running_count INTEGER NOT NULL,
vcpus_reserved INTEGER NOT NULL,
memory_mb_reserved INTEGER NOT NULL
);
-- All queries filter on team_id first then range-scan sampled_at.
CREATE INDEX idx_metrics_snapshots_team_time
ON sandbox_metrics_snapshots (team_id, sampled_at DESC);
-- +goose Down
DROP TABLE sandbox_metrics_snapshots;

View File

@ -1,16 +0,0 @@
-- +goose Up
CREATE TABLE sandbox_metric_points (
sandbox_id TEXT NOT NULL,
tier TEXT NOT NULL CHECK (tier IN ('10m', '2h', '24h')),
ts BIGINT NOT NULL,
cpu_pct FLOAT8 NOT NULL DEFAULT 0,
mem_bytes BIGINT NOT NULL DEFAULT 0,
disk_bytes BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (sandbox_id, tier, ts)
);
CREATE INDEX idx_sandbox_metric_points_sandbox_tier
ON sandbox_metric_points (sandbox_id, tier);
-- +goose Down
DROP TABLE IF EXISTS sandbox_metric_points;

View File

@ -1,25 +0,0 @@
-- +goose Up
CREATE TABLE template_builds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_template TEXT NOT NULL DEFAULT 'minimal',
recipe JSONB NOT NULL DEFAULT '[]',
healthcheck TEXT,
vcpus INTEGER NOT NULL DEFAULT 1,
memory_mb INTEGER NOT NULL DEFAULT 512,
status TEXT NOT NULL DEFAULT 'pending',
current_step INTEGER NOT NULL DEFAULT 0,
total_steps INTEGER NOT NULL DEFAULT 0,
logs JSONB NOT NULL DEFAULT '[]',
error TEXT,
sandbox_id TEXT,
host_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
-- +goose Down
DROP TABLE template_builds;

View File

@ -50,7 +50,7 @@ WHERE id = $1;
UPDATE sandboxes UPDATE sandboxes
SET status = $2, SET status = $2,
last_updated = NOW() last_updated = NOW()
WHERE id = ANY($1::text[]); WHERE id = ANY($1::uuid[]);
-- name: ListActiveSandboxesByTeam :many -- name: ListActiveSandboxesByTeam :many
SELECT * FROM sandboxes SELECT * FROM sandboxes
@ -72,4 +72,4 @@ WHERE host_id = $1 AND status IN ('running', 'starting', 'pending');
UPDATE sandboxes UPDATE sandboxes
SET status = 'running', SET status = 'running',
last_updated = NOW() last_updated = NOW()
WHERE id = ANY($1::text[]) AND status = 'missing'; WHERE id = ANY($1::uuid[]) AND status = 'missing';

View File

@ -14,12 +14,12 @@ import (
"os/exec" "os/exec"
"time" "time"
"github.com/awnumar/memguard"
"github.com/rs/zerolog"
"github.com/txn2/txeh"
"git.omukk.dev/wrenn/sandbox/envd/internal/host" "git.omukk.dev/wrenn/sandbox/envd/internal/host"
"git.omukk.dev/wrenn/sandbox/envd/internal/logs" "git.omukk.dev/wrenn/sandbox/envd/internal/logs"
"git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys"
"github.com/awnumar/memguard"
"github.com/rs/zerolog"
"github.com/txn2/txeh"
) )
var ( var (
@ -287,4 +287,3 @@ func getIPFamily(address string) (txeh.IPFamily, error) {
return txeh.IPFamilyV4, fmt.Errorf("%w: %s", ErrUnknownAddressFormat, address) return txeh.IPFamilyV4, fmt.Errorf("%w: %s", ErrUnknownAddressFormat, address)
} }
} }

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
@ -11,7 +13,7 @@ import (
// agentForHost looks up the host record and returns a Connect RPC client for it. // agentForHost looks up the host record and returns a Connect RPC client for it.
// Returns an error if the host is not found or has no address. // Returns an error if the host is not found or has no address.
func agentForHost(ctx context.Context, queries *db.Queries, pool *lifecycle.HostClientPool, hostID string) (hostagentv1connect.HostAgentServiceClient, error) { func agentForHost(ctx context.Context, queries *db.Queries, pool *lifecycle.HostClientPool, hostID pgtype.UUID) (hostagentv1connect.HostAgentServiceClient, error) {
host, err := queries.GetHost(ctx, hostID) host, err := queries.GetHost(ctx, hostID)
if err != nil { if err != nil {
return nil, fmt.Errorf("host not found: %w", err) return nil, fmt.Errorf("host not found: %w", err)

View File

@ -10,14 +10,17 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
) )
// sandboxHostPattern matches hostnames like "49999-sb-abcd1234.localhost" or // sandboxHostPattern matches hostnames like "49999-sb-abcd1234.localhost" or
// "49999-sb-abcd1234.example.com". Captures: port, sandbox ID. // "49999-sb-abcd1234.example.com". Captures: port, sandbox ID.
var sandboxHostPattern = regexp.MustCompile(`^(\d+)-(sb-[0-9a-f]+)\.`) var sandboxHostPattern = regexp.MustCompile(`^(\d+)-(sb-[0-9a-f-]+)\.`)
// SandboxProxyWrapper wraps an existing HTTP handler and intercepts requests // SandboxProxyWrapper wraps an existing HTTP handler and intercepts requests
// whose Host header matches the {port}-{sandbox_id}.{domain} pattern. Matching // whose Host header matches the {port}-{sandbox_id}.{domain} pattern. Matching
@ -57,7 +60,7 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
} }
port := matches[1] port := matches[1]
sandboxID := matches[2] sandboxIDStr := matches[2]
// Validate port. // Validate port.
portNum, err := strconv.Atoi(port) portNum, err := strconv.Atoi(port)
@ -73,6 +76,12 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
return return
} }
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
http.Error(w, "invalid sandbox ID", http.StatusBadRequest)
return
}
ctx := r.Context() ctx := r.Context()
// Look up sandbox and verify ownership. // Look up sandbox and verify ownership.
@ -96,13 +105,13 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
return return
} }
if !agentHost.Address.Valid || agentHost.Address.String == "" { if agentHost.Address == "" {
http.Error(w, "host agent has no address", http.StatusServiceUnavailable) http.Error(w, "host agent has no address", http.StatusServiceUnavailable)
return return
} }
agentAddr := lifecycle.EnsureScheme(agentHost.Address.String) agentAddr := lifecycle.EnsureScheme(agentHost.Address)
upstreamPath := fmt.Sprintf("/proxy/%s/%s%s", sandboxID, port, r.URL.Path) upstreamPath := fmt.Sprintf("/proxy/%s/%s%s", sandboxIDStr, port, r.URL.Path)
target, err := url.Parse(agentAddr) target, err := url.Parse(agentAddr)
if err != nil { if err != nil {
@ -121,7 +130,7 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
}, },
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
slog.Debug("sandbox proxy error", slog.Debug("sandbox proxy error",
"sandbox_id", sandboxID, "sandbox_id", sandboxIDStr,
"port", port, "port", port,
"error", err, "error", err,
) )
@ -134,16 +143,16 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
// authenticateRequest validates the request's API key and returns the team ID. // authenticateRequest validates the request's API key and returns the team ID.
// Only API key authentication is supported for sandbox proxy requests (not JWT). // Only API key authentication is supported for sandbox proxy requests (not JWT).
func (h *SandboxProxyWrapper) authenticateRequest(r *http.Request) (string, error) { func (h *SandboxProxyWrapper) authenticateRequest(r *http.Request) (pgtype.UUID, error) {
key := r.Header.Get("X-API-Key") key := r.Header.Get("X-API-Key")
if key == "" { if key == "" {
return "", fmt.Errorf("X-API-Key header required") return pgtype.UUID{}, fmt.Errorf("X-API-Key header required")
} }
hash := auth.HashAPIKey(key) hash := auth.HashAPIKey(key)
row, err := h.db.GetAPIKeyByHash(r.Context(), hash) row, err := h.db.GetAPIKeyByHash(r.Context(), hash)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid API key") return pgtype.UUID{}, fmt.Errorf("invalid API key")
} }
return row.TeamID, nil return row.TeamID, nil
} }

View File

@ -9,6 +9,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/service" "git.omukk.dev/wrenn/sandbox/internal/service"
) )
@ -39,11 +40,11 @@ type apiKeyResponse struct {
func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse { func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse {
resp := apiKeyResponse{ resp := apiKeyResponse{
ID: k.ID, ID: id.FormatAPIKeyID(k.ID),
TeamID: k.TeamID, TeamID: id.FormatTeamID(k.TeamID),
Name: k.Name, Name: k.Name,
KeyPrefix: k.KeyPrefix, KeyPrefix: k.KeyPrefix,
CreatedBy: k.CreatedBy, CreatedBy: id.FormatUserID(k.CreatedBy),
} }
if k.CreatedAt.Valid { if k.CreatedAt.Valid {
resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339) resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339)
@ -57,11 +58,11 @@ func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse {
func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyResponse { func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyResponse {
resp := apiKeyResponse{ resp := apiKeyResponse{
ID: k.ID, ID: id.FormatAPIKeyID(k.ID),
TeamID: k.TeamID, TeamID: id.FormatTeamID(k.TeamID),
Name: k.Name, Name: k.Name,
KeyPrefix: k.KeyPrefix, KeyPrefix: k.KeyPrefix,
CreatedBy: k.CreatedBy, CreatedBy: id.FormatUserID(k.CreatedBy),
CreatorEmail: k.CreatorEmail, CreatorEmail: k.CreatorEmail,
} }
if k.CreatedAt.Valid { if k.CreatedAt.Valid {
@ -118,7 +119,13 @@ func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) {
// Delete handles DELETE /v1/api-keys/{id}. // Delete handles DELETE /v1/api-keys/{id}.
func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) { func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
keyID := chi.URLParam(r, "id") keyIDStr := chi.URLParam(r, "id")
keyID, err := id.ParseAPIKeyID(keyIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid API key ID")
return
}
if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil { if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key") writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key")

View File

@ -6,7 +6,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/service" "git.omukk.dev/wrenn/sandbox/internal/service"
) )
@ -65,13 +68,24 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
limit = n limit = n
} }
// Parse ?before_id cursor (UUID).
var beforeID pgtype.UUID
if s := r.URL.Query().Get("before_id"); s != "" {
parsed, err := id.ParseAuditLogID(s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "before_id must be a valid audit log ID")
return
}
beforeID = parsed
}
entries, err := h.svc.List(r.Context(), service.AuditListParams{ entries, err := h.svc.List(r.Context(), service.AuditListParams{
TeamID: ac.TeamID, TeamID: ac.TeamID,
AdminScoped: ac.Role == "owner" || ac.Role == "admin", AdminScoped: ac.Role == "owner" || ac.Role == "admin",
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]), ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
Actions: parseMultiParam(r.URL.Query()["action"]), Actions: parseMultiParam(r.URL.Query()["action"]),
Before: before, Before: before,
BeforeID: r.URL.Query().Get("before_id"), BeforeID: beforeID,
Limit: limit, Limit: limit,
}) })
if err != nil { if err != nil {

View File

@ -20,7 +20,7 @@ import (
// It prefers the user's default team; if none is flagged as default it falls // It prefers the user's default team; if none is flagged as default it falls
// back to the earliest-joined team. Returns pgx.ErrNoRows when the user has // back to the earliest-joined team. Returns pgx.ErrNoRows when the user has
// no team memberships at all. // no team memberships at all.
func loginTeam(ctx context.Context, q *db.Queries, userID string) (db.Team, string, error) { func loginTeam(ctx context.Context, q *db.Queries, userID pgtype.UUID) (db.Team, string, error) {
team, err := q.GetDefaultTeamForUser(ctx, userID) team, err := q.GetDefaultTeamForUser(ctx, userID)
if err == nil { if err == nil {
membership, err := q.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: userID, TeamID: team.ID}) membership, err := q.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: userID, TeamID: team.ID})
@ -176,8 +176,8 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, authResponse{ writeJSON(w, http.StatusCreated, authResponse{
Token: token, Token: token,
UserID: userID, UserID: id.FormatUserID(userID),
TeamID: teamID, TeamID: id.FormatTeamID(teamID),
Email: req.Email, Email: req.Email,
Name: req.Name, Name: req.Name,
}) })
@ -236,8 +236,8 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, authResponse{ writeJSON(w, http.StatusOK, authResponse{
Token: token, Token: token,
UserID: user.ID, UserID: id.FormatUserID(user.ID),
TeamID: team.ID, TeamID: id.FormatTeamID(team.ID),
Email: user.Email, Email: user.Email,
Name: user.Name, Name: user.Name,
}) })
@ -260,10 +260,16 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
return return
} }
teamID, err := id.ParseTeamID(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
return
}
ctx := r.Context() ctx := r.Context()
// Verify team exists and is not deleted. // Verify team exists and is not deleted.
team, err := h.db.GetTeam(ctx, req.TeamID) team, err := h.db.GetTeam(ctx, teamID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "team not found") writeError(w, http.StatusNotFound, "not_found", "team not found")
@ -280,7 +286,7 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
// Verify membership from DB — JWT role is not trusted here. // Verify membership from DB — JWT role is not trusted here.
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{ membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{
UserID: ac.UserID, UserID: ac.UserID,
TeamID: req.TeamID, TeamID: teamID,
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@ -298,7 +304,7 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
return return
} }
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, user.Name, membership.Role, user.IsAdmin) token, err := auth.SignJWT(h.jwtSecret, ac.UserID, teamID, ac.Email, user.Name, membership.Role, user.IsAdmin)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return return
@ -306,8 +312,8 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, authResponse{ writeJSON(w, http.StatusOK, authResponse{
Token: token, Token: token,
UserID: ac.UserID, UserID: id.FormatUserID(ac.UserID),
TeamID: req.TeamID, TeamID: id.FormatTeamID(teamID),
Email: ac.Email, Email: ac.Email,
Name: user.Name, Name: user.Name,
}) })

View File

@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/service" "git.omukk.dev/wrenn/sandbox/internal/service"
"git.omukk.dev/wrenn/sandbox/internal/validate" "git.omukk.dev/wrenn/sandbox/internal/validate"
) )
@ -53,7 +54,7 @@ type buildResponse struct {
func buildToResponse(b db.TemplateBuild) buildResponse { func buildToResponse(b db.TemplateBuild) buildResponse {
resp := buildResponse{ resp := buildResponse{
ID: b.ID, ID: id.FormatBuildID(b.ID),
Name: b.Name, Name: b.Name,
BaseTemplate: b.BaseTemplate, BaseTemplate: b.BaseTemplate,
Recipe: b.Recipe, Recipe: b.Recipe,
@ -64,17 +65,19 @@ func buildToResponse(b db.TemplateBuild) buildResponse {
TotalSteps: b.TotalSteps, TotalSteps: b.TotalSteps,
Logs: b.Logs, Logs: b.Logs,
} }
if b.Healthcheck.Valid { if b.Healthcheck != "" {
resp.Healthcheck = &b.Healthcheck.String resp.Healthcheck = &b.Healthcheck
} }
if b.Error.Valid { if b.Error != "" {
resp.Error = &b.Error.String resp.Error = &b.Error
} }
if b.SandboxID.Valid { if b.SandboxID.Valid {
resp.SandboxID = &b.SandboxID.String s := id.FormatSandboxID(b.SandboxID)
resp.SandboxID = &s
} }
if b.HostID.Valid { if b.HostID.Valid {
resp.HostID = &b.HostID.String s := id.FormatHostID(b.HostID)
resp.HostID = &s
} }
if b.CreatedAt.Valid { if b.CreatedAt.Valid {
resp.CreatedAt = b.CreatedAt.Time.Format(time.RFC3339) resp.CreatedAt = b.CreatedAt.Time.Format(time.RFC3339)
@ -146,7 +149,13 @@ func (h *buildHandler) List(w http.ResponseWriter, r *http.Request) {
// Get handles GET /v1/admin/builds/{id}. // Get handles GET /v1/admin/builds/{id}.
func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id") buildIDStr := chi.URLParam(r, "id")
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
return
}
build, err := h.svc.Get(r.Context(), buildID) build, err := h.svc.Get(r.Context(), buildID)
if err != nil { if err != nil {

View File

@ -14,6 +14,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
) )
@ -46,10 +47,16 @@ type execResponse struct {
// Exec handles POST /v1/sandboxes/{id}/exec. // Exec handles POST /v1/sandboxes/{id}/exec.
func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
@ -80,7 +87,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
} }
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{ resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Cmd: req.Cmd, Cmd: req.Cmd,
Args: req.Args, Args: req.Args,
TimeoutSec: req.TimeoutSec, TimeoutSec: req.TimeoutSec,
@ -101,7 +108,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
Valid: true, Valid: true,
}, },
}); err != nil { }); err != nil {
slog.Warn("failed to update last_active_at", "id", sandboxID, "error", err) slog.Warn("failed to update last_active_at", "id", sandboxIDStr, "error", err)
} }
// Use base64 encoding if output contains non-UTF-8 bytes. // Use base64 encoding if output contains non-UTF-8 bytes.
@ -112,7 +119,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
if !utf8.Valid(stdout) || !utf8.Valid(stderr) { if !utf8.Valid(stdout) || !utf8.Valid(stderr) {
encoding = "base64" encoding = "base64"
writeJSON(w, http.StatusOK, execResponse{ writeJSON(w, http.StatusOK, execResponse{
SandboxID: sandboxID, SandboxID: sandboxIDStr,
Cmd: req.Cmd, Cmd: req.Cmd,
Stdout: base64.StdEncoding.EncodeToString(stdout), Stdout: base64.StdEncoding.EncodeToString(stdout),
Stderr: base64.StdEncoding.EncodeToString(stderr), Stderr: base64.StdEncoding.EncodeToString(stderr),
@ -124,7 +131,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, execResponse{ writeJSON(w, http.StatusOK, execResponse{
SandboxID: sandboxID, SandboxID: sandboxIDStr,
Cmd: req.Cmd, Cmd: req.Cmd,
Stdout: string(stdout), Stdout: string(stdout),
Stderr: string(stderr), Stderr: string(stderr),

View File

@ -14,6 +14,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
) )
@ -48,10 +49,16 @@ type wsOutMsg struct {
// ExecStream handles WS /v1/sandboxes/{id}/exec/stream. // ExecStream handles WS /v1/sandboxes/{id}/exec/stream.
func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) { func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
@ -91,7 +98,7 @@ func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
defer cancel() defer cancel()
stream, err := agent.ExecStream(streamCtx, connect.NewRequest(&pb.ExecStreamRequest{ stream, err := agent.ExecStream(streamCtx, connect.NewRequest(&pb.ExecStreamRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Cmd: startMsg.Cmd, Cmd: startMsg.Cmd,
Args: startMsg.Args, Args: startMsg.Args,
})) }))
@ -157,7 +164,7 @@ func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
Valid: true, Valid: true,
}, },
}); err != nil { }); err != nil {
slog.Warn("failed to update last active after stream exec", "sandbox_id", sandboxID, "error", err) slog.Warn("failed to update last active after stream exec", "sandbox_id", sandboxIDStr, "error", err)
} }
} }

View File

@ -11,6 +11,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
) )
@ -29,10 +30,16 @@ func newFilesHandler(db *db.Queries, pool *lifecycle.HostClientPool) *filesHandl
// - "path" text field: absolute destination path inside the sandbox // - "path" text field: absolute destination path inside the sandbox
// - "file" file field: binary content to write // - "file" file field: binary content to write
func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) { func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
@ -82,7 +89,7 @@ func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
} }
if _, err := agent.WriteFile(ctx, connect.NewRequest(&pb.WriteFileRequest{ if _, err := agent.WriteFile(ctx, connect.NewRequest(&pb.WriteFileRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Path: filePath, Path: filePath,
Content: content, Content: content,
})); err != nil { })); err != nil {
@ -101,10 +108,16 @@ type readFileRequest struct {
// Download handles POST /v1/sandboxes/{id}/files/read. // Download handles POST /v1/sandboxes/{id}/files/read.
// Accepts JSON body with path, returns raw file content with Content-Disposition. // Accepts JSON body with path, returns raw file content with Content-Disposition.
func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) { func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
@ -133,7 +146,7 @@ func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
} }
resp, err := agent.ReadFile(ctx, connect.NewRequest(&pb.ReadFileRequest{ resp, err := agent.ReadFile(ctx, connect.NewRequest(&pb.ReadFileRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Path: req.Path, Path: req.Path,
})) }))
if err != nil { if err != nil {

View File

@ -12,6 +12,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
) )
@ -29,10 +30,16 @@ func newFilesStreamHandler(db *db.Queries, pool *lifecycle.HostClientPool) *file
// Expects multipart/form-data with "path" text field and "file" file field. // Expects multipart/form-data with "path" text field and "file" file field.
// Streams file content directly from the request body to the host agent without buffering. // Streams file content directly from the request body to the host agent without buffering.
func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) { func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
@ -101,7 +108,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
if err := stream.Send(&pb.WriteFileStreamRequest{ if err := stream.Send(&pb.WriteFileStreamRequest{
Content: &pb.WriteFileStreamRequest_Meta{ Content: &pb.WriteFileStreamRequest_Meta{
Meta: &pb.WriteFileStreamMeta{ Meta: &pb.WriteFileStreamMeta{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Path: filePath, Path: filePath,
}, },
}, },
@ -146,10 +153,16 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
// StreamDownload handles POST /v1/sandboxes/{id}/files/stream/read. // StreamDownload handles POST /v1/sandboxes/{id}/files/stream/read.
// Accepts JSON body with path, streams file content back without buffering. // Accepts JSON body with path, streams file content back without buffering.
func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) { func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
@ -178,7 +191,7 @@ func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Reque
// Open server-streaming RPC to host agent. // Open server-streaming RPC to host agent.
stream, err := agent.ReadFileStream(ctx, connect.NewRequest(&pb.ReadFileStreamRequest{ stream, err := agent.ReadFileStream(ctx, connect.NewRequest(&pb.ReadFileStreamRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Path: req.Path, Path: req.Path,
})) }))
if err != nil { if err != nil {

View File

@ -8,9 +8,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/service" "git.omukk.dev/wrenn/sandbox/internal/service"
) )
@ -93,34 +96,35 @@ type hostResponse struct {
func hostToResponse(h db.Host) hostResponse { func hostToResponse(h db.Host) hostResponse {
resp := hostResponse{ resp := hostResponse{
ID: h.ID, ID: id.FormatHostID(h.ID),
Type: h.Type, Type: h.Type,
Status: h.Status, Status: h.Status,
CreatedBy: h.CreatedBy, CreatedBy: id.FormatUserID(h.CreatedBy),
} }
if h.TeamID.Valid { if h.TeamID.Valid {
resp.TeamID = &h.TeamID.String s := id.FormatTeamID(h.TeamID)
resp.TeamID = &s
} }
if h.Provider.Valid { if h.Provider != "" {
resp.Provider = &h.Provider.String resp.Provider = &h.Provider
} }
if h.AvailabilityZone.Valid { if h.AvailabilityZone != "" {
resp.AvailabilityZone = &h.AvailabilityZone.String resp.AvailabilityZone = &h.AvailabilityZone
} }
if h.Arch.Valid { if h.Arch != "" {
resp.Arch = &h.Arch.String resp.Arch = &h.Arch
} }
if h.CpuCores.Valid { if h.CpuCores != 0 {
resp.CPUCores = &h.CpuCores.Int32 resp.CPUCores = &h.CpuCores
} }
if h.MemoryMb.Valid { if h.MemoryMb != 0 {
resp.MemoryMB = &h.MemoryMb.Int32 resp.MemoryMB = &h.MemoryMb
} }
if h.DiskGb.Valid { if h.DiskGb != 0 {
resp.DiskGB = &h.DiskGb.Int32 resp.DiskGB = &h.DiskGb
} }
if h.Address.Valid { if h.Address != "" {
resp.Address = &h.Address.String resp.Address = &h.Address
} }
if h.LastHeartbeatAt.Valid { if h.LastHeartbeatAt.Valid {
s := h.LastHeartbeatAt.Time.Format(time.RFC3339) s := h.LastHeartbeatAt.Time.Format(time.RFC3339)
@ -133,7 +137,7 @@ func hostToResponse(h db.Host) hostResponse {
} }
// isAdmin fetches the user record and returns whether they are an admin. // isAdmin fetches the user record and returns whether they are an admin.
func (h *hostHandler) isAdmin(r *http.Request, userID string) bool { func (h *hostHandler) isAdmin(r *http.Request, userID pgtype.UUID) bool {
user, err := h.queries.GetUserByID(r.Context(), userID) user, err := h.queries.GetUserByID(r.Context(), userID)
if err != nil { if err != nil {
return false return false
@ -151,14 +155,23 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
result, err := h.svc.Create(r.Context(), service.HostCreateParams{ // Parse optional team ID from request body.
Type: req.Type, var params service.HostCreateParams
TeamID: req.TeamID, params.Type = req.Type
Provider: req.Provider, params.Provider = req.Provider
AvailabilityZone: req.AvailabilityZone, params.AvailabilityZone = req.AvailabilityZone
RequestingUserID: ac.UserID, params.RequestingUserID = ac.UserID
IsRequestorAdmin: h.isAdmin(r, ac.UserID), params.IsRequestorAdmin = h.isAdmin(r, ac.UserID)
}) if req.TeamID != "" {
teamID, err := id.ParseTeamID(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
return
}
params.TeamID = teamID
}
result, err := h.svc.Create(r.Context(), params)
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)
@ -166,8 +179,7 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
// Log audit for the owning team (BYOC hosts have a team; shared hosts use caller's team). // Log audit for the owning team (BYOC hosts have a team; shared hosts use caller's team).
hostTeamID := result.Host.TeamID.String h.audit.LogHostCreate(r.Context(), ac, result.Host.ID, result.Host.TeamID)
h.audit.LogHostCreate(r.Context(), ac, result.Host.ID, hostTeamID)
writeJSON(w, http.StatusCreated, createHostResponse{ writeJSON(w, http.StatusCreated, createHostResponse{
Host: hostToResponse(result.Host), Host: hostToResponse(result.Host),
@ -192,14 +204,22 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, host := range hosts { for _, host := range hosts {
if host.TeamID.Valid { if host.TeamID.Valid {
seen[host.TeamID.String] = struct{}{} key := id.FormatTeamID(host.TeamID)
seen[key] = struct{}{}
} }
} }
if len(seen) > 0 { if len(seen) > 0 {
teamNames = make(map[string]string, len(seen)) teamNames = make(map[string]string, len(seen))
for id := range seen { for _, host := range hosts {
if team, err := h.queries.GetTeam(r.Context(), id); err == nil { if !host.TeamID.Valid {
teamNames[id] = team.Name continue
}
key := id.FormatTeamID(host.TeamID)
if _, ok := teamNames[key]; ok {
continue
}
if team, err := h.queries.GetTeam(r.Context(), host.TeamID); err == nil {
teamNames[key] = team.Name
} }
} }
} }
@ -209,7 +229,8 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) {
for i, host := range hosts { for i, host := range hosts {
resp[i] = hostToResponse(host) resp[i] = hostToResponse(host)
if host.TeamID.Valid { if host.TeamID.Valid {
if name, ok := teamNames[host.TeamID.String]; ok { key := id.FormatTeamID(host.TeamID)
if name, ok := teamNames[key]; ok {
resp[i].TeamName = &name resp[i].TeamName = &name
} }
} }
@ -220,9 +241,15 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) {
// Get handles GET /v1/hosts/{id}. // Get handles GET /v1/hosts/{id}.
func (h *hostHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) Get(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
host, err := h.svc.Get(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID)) host, err := h.svc.Get(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
@ -236,9 +263,15 @@ func (h *hostHandler) Get(w http.ResponseWriter, r *http.Request) {
// DeletePreview handles GET /v1/hosts/{id}/delete-preview. // DeletePreview handles GET /v1/hosts/{id}/delete-preview.
// Returns what would be affected without making changes, for confirmation UI. // Returns what would be affected without making changes, for confirmation UI.
func (h *hostHandler) DeletePreview(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) DeletePreview(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
preview, err := h.svc.DeletePreview(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID)) preview, err := h.svc.DeletePreview(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
@ -256,19 +289,25 @@ func (h *hostHandler) DeletePreview(w http.ResponseWriter, r *http.Request) {
// Without ?force=true: returns 409 with affected sandbox IDs if any are active. // Without ?force=true: returns 409 with affected sandbox IDs if any are active.
// With ?force=true: gracefully stops all sandboxes then deletes the host. // With ?force=true: gracefully stops all sandboxes then deletes the host.
func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
force := r.URL.Query().Get("force") == "true" force := r.URL.Query().Get("force") == "true"
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
// Fetch host before deletion to capture team_id for audit. // Fetch host before deletion to capture team_id for audit.
deletedHost, hostErr := h.queries.GetHost(r.Context(), hostID) deletedHost, hostErr := h.queries.GetHost(r.Context(), hostID)
if hostErr != nil { if hostErr != nil {
slog.Warn("audit: could not fetch host before delete", "host_id", hostID, "error", hostErr) slog.Warn("audit: could not fetch host before delete", "host_id", hostIDStr, "error", hostErr)
} }
err := h.svc.Delete(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID), force) err = h.svc.Delete(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID), force)
if err == nil { if err == nil {
h.audit.LogHostDelete(r.Context(), ac, hostID, deletedHost.TeamID.String) h.audit.LogHostDelete(r.Context(), ac, hostID, deletedHost.TeamID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
@ -292,9 +331,15 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
// RegenerateToken handles POST /v1/hosts/{id}/token. // RegenerateToken handles POST /v1/hosts/{id}/token.
func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
result, err := h.svc.RegenerateToken(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID)) result, err := h.svc.RegenerateToken(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
@ -348,9 +393,15 @@ func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) {
// Heartbeat handles POST /v1/hosts/{id}/heartbeat (host-token-authenticated). // Heartbeat handles POST /v1/hosts/{id}/heartbeat (host-token-authenticated).
func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
hc := auth.MustHostFromContext(r.Context()) hc := auth.MustHostFromContext(r.Context())
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
// Prevent a host from heartbeating for a different host. // Prevent a host from heartbeating for a different host.
if hostID != hc.HostID { if hostID != hc.HostID {
writeError(w, http.StatusForbidden, "forbidden", "host ID mismatch") writeError(w, http.StatusForbidden, "forbidden", "host ID mismatch")
@ -368,7 +419,7 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
// Log marked_up if the host just recovered from unreachable. // Log marked_up if the host just recovered from unreachable.
if prevHost.Status == "unreachable" { if prevHost.Status == "unreachable" {
h.audit.LogHostMarkedUp(r.Context(), prevHost.TeamID.String, hc.HostID) h.audit.LogHostMarkedUp(r.Context(), prevHost.TeamID, hc.HostID)
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@ -376,10 +427,16 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
// AddTag handles POST /v1/hosts/{id}/tags. // AddTag handles POST /v1/hosts/{id}/tags.
func (h *hostHandler) AddTag(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) AddTag(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
admin := h.isAdmin(r, ac.UserID) admin := h.isAdmin(r, ac.UserID)
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
var req addTagRequest var req addTagRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
@ -401,10 +458,16 @@ func (h *hostHandler) AddTag(w http.ResponseWriter, r *http.Request) {
// RemoveTag handles DELETE /v1/hosts/{id}/tags/{tag}. // RemoveTag handles DELETE /v1/hosts/{id}/tags/{tag}.
func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
tag := chi.URLParam(r, "tag") tag := chi.URLParam(r, "tag")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
if err := h.svc.RemoveTag(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID), tag); err != nil { if err := h.svc.RemoveTag(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID), tag); err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)
@ -443,9 +506,15 @@ func (h *hostHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
// ListTags handles GET /v1/hosts/{id}/tags. // ListTags handles GET /v1/hosts/{id}/tags.
func (h *hostHandler) ListTags(w http.ResponseWriter, r *http.Request) { func (h *hostHandler) ListTags(w http.ResponseWriter, r *http.Request) {
hostID := chi.URLParam(r, "id") hostIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
return
}
tags, err := h.svc.ListTags(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID)) tags, err := h.svc.ListTags(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)

View File

@ -7,9 +7,11 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
) )
@ -38,10 +40,16 @@ type metricsResponse struct {
// GetMetrics handles GET /v1/sandboxes/{id}/metrics?range=10m|2h|24h. // GetMetrics handles GET /v1/sandboxes/{id}/metrics?range=10m|2h|24h.
func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) { func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ctx := r.Context() ctx := r.Context()
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
rangeTier := r.URL.Query().Get("range") rangeTier := r.URL.Query().Get("range")
if rangeTier == "" { if rangeTier == "" {
rangeTier = "10m" rangeTier = "10m"
@ -60,15 +68,15 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
switch sb.Status { switch sb.Status {
case "running": case "running":
h.getFromAgent(w, r, sandboxID, rangeTier, sb.HostID) h.getFromAgent(w, r, sandboxIDStr, rangeTier, sb.HostID)
case "paused": case "paused":
h.getFromDB(ctx, w, sandboxID, rangeTier) h.getFromDB(ctx, w, sandboxIDStr, sandboxID, rangeTier)
default: default:
writeError(w, http.StatusNotFound, "not_found", "metrics not available for sandbox in state: "+sb.Status) writeError(w, http.StatusNotFound, "not_found", "metrics not available for sandbox in state: "+sb.Status)
} }
} }
func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Request, sandboxID, rangeTier, hostID string) { func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Request, sandboxIDStr, rangeTier string, hostID pgtype.UUID) {
ctx := r.Context() ctx := r.Context()
agent, err := agentForHost(ctx, h.db, h.pool, hostID) agent, err := agentForHost(ctx, h.db, h.pool, hostID)
@ -78,7 +86,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
} }
resp, err := agent.GetSandboxMetrics(ctx, connect.NewRequest(&pb.GetSandboxMetricsRequest{ resp, err := agent.GetSandboxMetrics(ctx, connect.NewRequest(&pb.GetSandboxMetricsRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Range: rangeTier, Range: rangeTier,
})) }))
if err != nil { if err != nil {
@ -98,7 +106,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
} }
writeJSON(w, http.StatusOK, metricsResponse{ writeJSON(w, http.StatusOK, metricsResponse{
SandboxID: sandboxID, SandboxID: sandboxIDStr,
Range: rangeTier, Range: rangeTier,
Points: points, Points: points,
}) })
@ -118,7 +126,7 @@ var rangeToDB = map[string]struct {
"24h": {"24h", 24 * time.Hour}, "24h": {"24h", 24 * time.Hour},
} }
func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWriter, sandboxID, rangeTier string) { func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWriter, sandboxIDStr string, sandboxID pgtype.UUID, rangeTier string) {
mapping := rangeToDB[rangeTier] mapping := rangeToDB[rangeTier]
rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{ rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{
SandboxID: sandboxID, SandboxID: sandboxID,
@ -141,7 +149,7 @@ func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWr
} }
writeJSON(w, http.StatusOK, metricsResponse{ writeJSON(w, http.StatusOK, metricsResponse{
SandboxID: sandboxID, SandboxID: sandboxIDStr,
Range: rangeTier, Range: rangeTier,
Points: points, Points: points,
}) })

View File

@ -162,7 +162,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
redirectWithError(w, r, redirectBase, "internal_error") redirectWithError(w, r, redirectBase, "internal_error")
return return
} }
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email, user.Name) redirectWithToken(w, r, redirectBase, token, id.FormatUserID(user.ID), id.FormatTeamID(team.ID), user.Email, user.Name)
return return
} }
if !errors.Is(err, pgx.ErrNoRows) { if !errors.Is(err, pgx.ErrNoRows) {
@ -262,7 +262,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
return return
} }
redirectWithToken(w, r, redirectBase, token, userID, teamID, email, profile.Name) redirectWithToken(w, r, redirectBase, token, id.FormatUserID(userID), id.FormatTeamID(teamID), email, profile.Name)
} }
// retryAsLogin handles the race where a concurrent request already created the user. // retryAsLogin handles the race where a concurrent request already created the user.
@ -296,7 +296,7 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov
redirectWithError(w, r, redirectBase, "internal_error") redirectWithError(w, r, redirectBase, "internal_error")
return return
} }
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email, user.Name) redirectWithToken(w, r, redirectBase, token, id.FormatUserID(user.ID), id.FormatTeamID(team.ID), user.Email, user.Name)
} }
func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email, name string) { func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email, name string) {

View File

@ -10,6 +10,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/service" "git.omukk.dev/wrenn/sandbox/internal/service"
) )
@ -46,7 +47,7 @@ type sandboxResponse struct {
func sandboxToResponse(sb db.Sandbox) sandboxResponse { func sandboxToResponse(sb db.Sandbox) sandboxResponse {
resp := sandboxResponse{ resp := sandboxResponse{
ID: sb.ID, ID: id.FormatSandboxID(sb.ID),
Status: sb.Status, Status: sb.Status,
Template: sb.Template, Template: sb.Template,
VCPUs: sb.Vcpus, VCPUs: sb.Vcpus,
@ -81,7 +82,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
if ac.TeamID == "" { if !ac.TeamID.Valid {
writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate") writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate")
return return
} }
@ -122,9 +123,15 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
// Get handles GET /v1/sandboxes/{id}. // Get handles GET /v1/sandboxes/{id}.
func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.svc.Get(r.Context(), sandboxID, ac.TeamID) sb, err := h.svc.Get(r.Context(), sandboxID, ac.TeamID)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
@ -136,9 +143,15 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
// Pause handles POST /v1/sandboxes/{id}/pause. // Pause handles POST /v1/sandboxes/{id}/pause.
func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.svc.Pause(r.Context(), sandboxID, ac.TeamID) sb, err := h.svc.Pause(r.Context(), sandboxID, ac.TeamID)
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
@ -152,9 +165,15 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
// Resume handles POST /v1/sandboxes/{id}/resume. // Resume handles POST /v1/sandboxes/{id}/resume.
func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
sb, err := h.svc.Resume(r.Context(), sandboxID, ac.TeamID) sb, err := h.svc.Resume(r.Context(), sandboxID, ac.TeamID)
if err != nil { if err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
@ -168,9 +187,15 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
// Ping handles POST /v1/sandboxes/{id}/ping. // Ping handles POST /v1/sandboxes/{id}/ping.
func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil { if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)
@ -182,9 +207,15 @@ func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
// Destroy handles DELETE /v1/sandboxes/{id}. // Destroy handles DELETE /v1/sandboxes/{id}.
func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxIDStr := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
return
}
if err := h.svc.Destroy(r.Context(), sandboxID, ac.TeamID); err != nil { if err := h.svc.Destroy(r.Context(), sandboxID, ac.TeamID); err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)

View File

@ -10,7 +10,6 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
@ -51,7 +50,7 @@ func (h *snapshotHandler) deleteSnapshotBroadcast(ctx context.Context, name stri
} }
if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{Name: name})); err != nil { if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{Name: name})); err != nil {
if connect.CodeOf(err) != connect.CodeNotFound { if connect.CodeOf(err) != connect.CodeNotFound {
slog.Warn("snapshot: failed to delete on host", "host_id", host.ID, "name", name, "error", err) slog.Warn("snapshot: failed to delete on host", "host_id", id.FormatHostID(host.ID), "name", name, "error", err)
} }
} }
} }
@ -78,11 +77,11 @@ func templateToResponse(t db.Template) snapshotResponse {
Type: t.Type, Type: t.Type,
SizeBytes: t.SizeBytes, SizeBytes: t.SizeBytes,
} }
if t.Vcpus.Valid { if t.Vcpus != 0 {
resp.VCPUs = &t.Vcpus.Int32 resp.VCPUs = &t.Vcpus
} }
if t.MemoryMb.Valid { if t.MemoryMb != 0 {
resp.MemoryMB = &t.MemoryMb.Int32 resp.MemoryMB = &t.MemoryMb
} }
if t.CreatedAt.Valid { if t.CreatedAt.Valid {
resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339) resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339)
@ -103,6 +102,12 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
sandboxID, err := id.ParseSandboxID(req.SandboxID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox_id")
return
}
if req.Name == "" { if req.Name == "" {
req.Name = id.NewSnapshotName() req.Name = id.NewSnapshotName()
} }
@ -133,7 +138,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
// Verify sandbox exists, belongs to team, and is running or paused. // Verify sandbox exists, belongs to team, and is running or paused.
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: req.SandboxID, TeamID: ac.TeamID}) sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return return
@ -162,7 +167,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
// Mark sandbox as paused (if it was running, it got paused by the snapshot). // Mark sandbox as paused (if it was running, it got paused by the snapshot).
if sb.Status != "paused" { if sb.Status != "paused" {
if _, err := h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ if _, err := h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: req.SandboxID, Status: "paused", ID: sandboxID, Status: "paused",
}); err != nil { }); err != nil {
slog.Error("failed to update sandbox status after snapshot", "sandbox_id", req.SandboxID, "error", err) slog.Error("failed to update sandbox status after snapshot", "sandbox_id", req.SandboxID, "error", err)
} }
@ -171,8 +176,8 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
tmpl, err := h.db.InsertTemplate(ctx, db.InsertTemplateParams{ tmpl, err := h.db.InsertTemplate(ctx, db.InsertTemplateParams{
Name: req.Name, Name: req.Name,
Type: "snapshot", Type: "snapshot",
Vcpus: pgtype.Int4{Int32: sb.Vcpus, Valid: true}, Vcpus: sb.Vcpus,
MemoryMb: pgtype.Int4{Int32: sb.MemoryMb, Valid: true}, MemoryMb: sb.MemoryMb,
SizeBytes: resp.Msg.SizeBytes, SizeBytes: resp.Msg.SizeBytes,
TeamID: ac.TeamID, TeamID: ac.TeamID,
}) })

View File

@ -7,10 +7,12 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/service" "git.omukk.dev/wrenn/sandbox/internal/service"
) )
@ -48,7 +50,7 @@ type memberResponse struct {
func teamToResponse(t db.Team) teamResponse { func teamToResponse(t db.Team) teamResponse {
resp := teamResponse{ resp := teamResponse{
ID: t.ID, ID: id.FormatTeamID(t.ID),
Name: t.Name, Name: t.Name,
Slug: t.Slug, Slug: t.Slug,
IsByoc: t.IsByoc, IsByoc: t.IsByoc,
@ -72,11 +74,16 @@ func memberInfoToResponse(m service.MemberInfo) memberResponse {
// requireTeamAccess is an inline check used by every team-scoped handler: // requireTeamAccess is an inline check used by every team-scoped handler:
// the JWT team_id must match the URL {id} before any DB call is made. // the JWT team_id must match the URL {id} before any DB call is made.
// Returns false and writes 403 if they don't match. // Returns false and writes 403 if they don't match.
func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthContext) (string, bool) { func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthContext) (pgtype.UUID, bool) {
teamID := chi.URLParam(r, "id") teamIDStr := chi.URLParam(r, "id")
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
return pgtype.UUID{}, false
}
if ac.TeamID != teamID { if ac.TeamID != teamID {
writeError(w, http.StatusForbidden, "forbidden", "JWT team does not match requested team; use switch-team first") writeError(w, http.StatusForbidden, "forbidden", "JWT team does not match requested team; use switch-team first")
return "", false return pgtype.UUID{}, false
} }
return teamID, true return teamID, true
} }
@ -185,7 +192,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
// Fetch old name for audit log before renaming. // Fetch old name for audit log before renaming.
oldTeam, err := h.svc.GetTeam(r.Context(), teamID) oldTeam, err := h.svc.GetTeam(r.Context(), teamID)
if err != nil { if err != nil {
slog.Warn("audit: could not fetch old team name for rename log", "team_id", teamID, "error", err) slog.Warn("audit: could not fetch old team name for rename log", "team_id", id.FormatTeamID(teamID), "error", err)
} }
if err := h.svc.RenameTeam(r.Context(), teamID, ac.UserID, req.Name); err != nil { if err := h.svc.RenameTeam(r.Context(), teamID, ac.UserID, req.Name); err != nil {
@ -267,7 +274,11 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) {
return return
} }
h.audit.LogMemberAdd(r.Context(), ac, member.UserID, member.Email, member.Role) // member.UserID is already formatted with prefix; parse it back for the audit logger.
targetUserID, parseErr := id.ParseUserID(member.UserID)
if parseErr == nil {
h.audit.LogMemberAdd(r.Context(), ac, targetUserID, member.Email, member.Role)
}
writeJSON(w, http.StatusCreated, memberInfoToResponse(member)) writeJSON(w, http.StatusCreated, memberInfoToResponse(member))
} }
@ -279,7 +290,13 @@ func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
targetUserID := chi.URLParam(r, "uid") targetUserIDStr := chi.URLParam(r, "uid")
targetUserID, err := id.ParseUserID(targetUserIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
return
}
if err := h.svc.RemoveMember(r.Context(), teamID, ac.UserID, targetUserID); err != nil { if err := h.svc.RemoveMember(r.Context(), teamID, ac.UserID, targetUserID); err != nil {
status, code, msg := serviceErrToHTTP(err) status, code, msg := serviceErrToHTTP(err)
@ -299,7 +316,13 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
targetUserID := chi.URLParam(r, "uid") targetUserIDStr := chi.URLParam(r, "uid")
targetUserID, err := id.ParseUserID(targetUserIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
return
}
var req struct { var req struct {
Role string `json:"role"` Role string `json:"role"`
@ -341,7 +364,13 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) {
// SetBYOC handles PUT /v1/admin/teams/{id}/byoc (admin only). // SetBYOC handles PUT /v1/admin/teams/{id}/byoc (admin only).
// Enables or disables the BYOC feature flag for a team. // Enables or disables the BYOC feature flag for a team.
func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) { func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
teamID := chi.URLParam(r, "id") teamIDStr := chi.URLParam(r, "id")
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
return
}
var req struct { var req struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`

View File

@ -8,6 +8,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
) )
type usersHandler struct { type usersHandler struct {
@ -45,7 +46,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
} }
resp := make([]userResult, len(results)) resp := make([]userResult, len(results))
for i, u := range results { for i, u := range results {
resp[i] = userResult{UserID: u.ID, Email: u.Email} resp[i] = userResult{UserID: id.FormatUserID(u.ID), Email: u.Email}
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }

View File

@ -6,9 +6,11 @@ import (
"time" "time"
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle" "git.omukk.dev/wrenn/sandbox/internal/lifecycle"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
) )
@ -82,15 +84,15 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
time.Since(host.LastHeartbeatAt.Time) > unreachableThreshold time.Since(host.LastHeartbeatAt.Time) > unreachableThreshold
if stale && host.Status != "unreachable" { if stale && host.Status != "unreachable" {
slog.Info("host monitor: marking host unreachable", "host_id", host.ID, slog.Info("host monitor: marking host unreachable", "host_id", id.FormatHostID(host.ID),
"last_heartbeat", host.LastHeartbeatAt.Time) "last_heartbeat", host.LastHeartbeatAt.Time)
if err := m.db.MarkHostUnreachable(ctx, host.ID); err != nil { if err := m.db.MarkHostUnreachable(ctx, host.ID); err != nil {
slog.Warn("host monitor: failed to mark host unreachable", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to mark host unreachable", "host_id", id.FormatHostID(host.ID), "error", err)
} }
if err := m.db.MarkSandboxesMissingByHost(ctx, host.ID); err != nil { if err := m.db.MarkSandboxesMissingByHost(ctx, host.ID); err != nil {
slog.Warn("host monitor: failed to mark sandboxes missing", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to mark sandboxes missing", "host_id", id.FormatHostID(host.ID), "error", err)
} }
m.audit.LogHostMarkedDown(ctx, host.TeamID.String, host.ID) m.audit.LogHostMarkedDown(ctx, host.TeamID, host.ID)
return return
} }
@ -110,19 +112,20 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
if err != nil { if err != nil {
// RPC failure is a transient condition; the passive phase will catch it // RPC failure is a transient condition; the passive phase will catch it
// if heartbeats stop arriving. // if heartbeats stop arriving.
slog.Debug("host monitor: ListSandboxes failed (transient)", "host_id", host.ID, "error", err) slog.Debug("host monitor: ListSandboxes failed (transient)", "host_id", id.FormatHostID(host.ID), "error", err)
return return
} }
// Build set of sandbox IDs alive on the host. // Build set of sandbox IDs alive on the host.
// The host agent returns sandbox IDs as strings (formatted with prefix).
alive := make(map[string]struct{}, len(resp.Msg.Sandboxes)) alive := make(map[string]struct{}, len(resp.Msg.Sandboxes))
for _, sb := range resp.Msg.Sandboxes { for _, sb := range resp.Msg.Sandboxes {
alive[sb.SandboxId] = struct{}{} alive[sb.SandboxId] = struct{}{}
} }
autoPaused := make(map[string]struct{}, len(resp.Msg.AutoPausedSandboxIds)) autoPaused := make(map[string]struct{}, len(resp.Msg.AutoPausedSandboxIds))
for _, id := range resp.Msg.AutoPausedSandboxIds { for _, apID := range resp.Msg.AutoPausedSandboxIds {
autoPaused[id] = struct{}{} autoPaused[apID] = struct{}{}
} }
// --- Restore sandboxes that are "missing" in DB but alive on host --- // --- Restore sandboxes that are "missing" in DB but alive on host ---
@ -134,30 +137,31 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
Column2: []string{"missing"}, Column2: []string{"missing"},
}) })
if err != nil { if err != nil {
slog.Warn("host monitor: failed to list missing sandboxes", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to list missing sandboxes", "host_id", id.FormatHostID(host.ID), "error", err)
} else { } else {
var toRestore []string var toRestore []pgtype.UUID
var toStop []string var toStop []pgtype.UUID
for _, sb := range missingSandboxes { for _, sb := range missingSandboxes {
if _, ok := alive[sb.ID]; ok { sbIDStr := id.FormatSandboxID(sb.ID)
if _, ok := alive[sbIDStr]; ok {
toRestore = append(toRestore, sb.ID) toRestore = append(toRestore, sb.ID)
} else { } else {
toStop = append(toStop, sb.ID) toStop = append(toStop, sb.ID)
} }
} }
if len(toRestore) > 0 { if len(toRestore) > 0 {
slog.Info("host monitor: restoring missing sandboxes", "host_id", host.ID, "count", len(toRestore)) slog.Info("host monitor: restoring missing sandboxes", "host_id", id.FormatHostID(host.ID), "count", len(toRestore))
if err := m.db.BulkRestoreRunning(ctx, toRestore); err != nil { if err := m.db.BulkRestoreRunning(ctx, toRestore); err != nil {
slog.Warn("host monitor: failed to restore missing sandboxes", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to restore missing sandboxes", "host_id", id.FormatHostID(host.ID), "error", err)
} }
} }
if len(toStop) > 0 { if len(toStop) > 0 {
slog.Info("host monitor: stopping confirmed-dead missing sandboxes", "host_id", host.ID, "count", len(toStop)) slog.Info("host monitor: stopping confirmed-dead missing sandboxes", "host_id", id.FormatHostID(host.ID), "count", len(toStop))
if err := m.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{ if err := m.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{
Column1: toStop, Column1: toStop,
Status: "stopped", Status: "stopped",
}); err != nil { }); err != nil {
slog.Warn("host monitor: failed to stop missing sandboxes", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to stop missing sandboxes", "host_id", id.FormatHostID(host.ID), "error", err)
} }
} }
} }
@ -169,18 +173,19 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
Column2: []string{"running"}, Column2: []string{"running"},
}) })
if err != nil { if err != nil {
slog.Warn("host monitor: failed to list running sandboxes", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to list running sandboxes", "host_id", id.FormatHostID(host.ID), "error", err)
return return
} }
var toPause, toStop []string var toPause, toStop []pgtype.UUID
sbTeamID := make(map[string]string, len(runningSandboxes)) sbTeamID := make(map[pgtype.UUID]pgtype.UUID, len(runningSandboxes))
for _, sb := range runningSandboxes { for _, sb := range runningSandboxes {
sbIDStr := id.FormatSandboxID(sb.ID)
sbTeamID[sb.ID] = sb.TeamID sbTeamID[sb.ID] = sb.TeamID
if _, ok := alive[sb.ID]; ok { if _, ok := alive[sbIDStr]; ok {
continue continue
} }
if _, ok := autoPaused[sb.ID]; ok { if _, ok := autoPaused[sbIDStr]; ok {
toPause = append(toPause, sb.ID) toPause = append(toPause, sb.ID)
} else { } else {
toStop = append(toStop, sb.ID) toStop = append(toStop, sb.ID)
@ -188,24 +193,24 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
} }
if len(toPause) > 0 { if len(toPause) > 0 {
slog.Info("host monitor: marking auto-paused sandboxes", "host_id", host.ID, "count", len(toPause)) slog.Info("host monitor: marking auto-paused sandboxes", "host_id", id.FormatHostID(host.ID), "count", len(toPause))
if err := m.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{ if err := m.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{
Column1: toPause, Column1: toPause,
Status: "paused", Status: "paused",
}); err != nil { }); err != nil {
slog.Warn("host monitor: failed to mark paused", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to mark paused", "host_id", id.FormatHostID(host.ID), "error", err)
} }
for _, sbID := range toPause { for _, sbID := range toPause {
m.audit.LogSandboxAutoPause(ctx, sbTeamID[sbID], sbID) m.audit.LogSandboxAutoPause(ctx, sbTeamID[sbID], sbID)
} }
} }
if len(toStop) > 0 { if len(toStop) > 0 {
slog.Info("host monitor: marking orphaned sandboxes stopped", "host_id", host.ID, "count", len(toStop)) slog.Info("host monitor: marking orphaned sandboxes stopped", "host_id", id.FormatHostID(host.ID), "count", len(toStop))
if err := m.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{ if err := m.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{
Column1: toStop, Column1: toStop,
Status: "stopped", Status: "stopped",
}); err != nil { }); err != nil {
slog.Warn("host monitor: failed to mark stopped", "host_id", host.ID, "error", err) slog.Warn("host monitor: failed to mark stopped", "host_id", id.FormatHostID(host.ID), "error", err)
} }
} }
} }

View File

@ -7,6 +7,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
) )
// requireAPIKeyOrJWT accepts either X-API-Key header or Authorization: Bearer JWT. // requireAPIKeyOrJWT accepts either X-API-Key header or Authorization: Bearer JWT.
@ -24,7 +25,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
} }
if err := queries.UpdateAPIKeyLastUsed(r.Context(), row.ID); err != nil { if err := queries.UpdateAPIKeyLastUsed(r.Context(), row.ID); err != nil {
slog.Warn("failed to update api key last_used", "key_id", row.ID, "error", err) slog.Warn("failed to update api key last_used", "key_id", id.FormatAPIKeyID(row.ID), "error", err)
} }
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
@ -45,9 +46,20 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
return return
} }
teamID, err := id.ParseTeamID(claims.TeamID)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid team ID in token")
return
}
userID, err := id.ParseUserID(claims.Subject)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid user ID in token")
return
}
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
TeamID: claims.TeamID, TeamID: teamID,
UserID: claims.Subject, UserID: userID,
Email: claims.Email, Email: claims.Email,
Name: claims.Name, Name: claims.Name,
Role: claims.Role, Role: claims.Role,

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/id"
) )
// requireHostToken validates the X-Host-Token header containing a host JWT, // requireHostToken validates the X-Host-Token header containing a host JWT,
@ -23,7 +24,13 @@ func requireHostToken(secret []byte) func(http.Handler) http.Handler {
return return
} }
ctx := auth.WithHostContext(r.Context(), auth.HostContext{HostID: claims.HostID}) hostID, err := id.ParseHostID(claims.HostID)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid host ID in token")
return
}
ctx := auth.WithHostContext(r.Context(), auth.HostContext{HostID: hostID})
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/id"
) )
// requireJWT validates the Authorization: Bearer <token> header, verifies the JWT // requireJWT validates the Authorization: Bearer <token> header, verifies the JWT
@ -25,9 +26,20 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler {
return return
} }
teamID, err := id.ParseTeamID(claims.TeamID)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid team ID in token")
return
}
userID, err := id.ParseUserID(claims.Subject)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid user ID in token")
return
}
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
TeamID: claims.TeamID, TeamID: teamID,
UserID: claims.Subject, UserID: userID,
Email: claims.Email, Email: claims.Email,
Name: claims.Name, Name: claims.Name,
Role: claims.Role, Role: claims.Role,

View File

@ -25,18 +25,15 @@ func New(queries *db.Queries) *AuditLogger {
} }
// actorFields extracts actor_type, actor_id, and actor_name from an AuthContext. // actorFields extracts actor_type, actor_id, and actor_name from an AuthContext.
func actorFields(ac auth.AuthContext) (actorType string, actorID pgtype.Text, actorName pgtype.Text) { // actor_id is stored as a prefixed string in the TEXT column.
if ac.UserID != "" { func actorFields(ac auth.AuthContext) (actorType, actorID, actorName string) {
return "user", if ac.UserID.Valid {
pgtype.Text{String: ac.UserID, Valid: true}, return "user", id.FormatUserID(ac.UserID), ac.Name
pgtype.Text{String: ac.Name, Valid: ac.Name != ""}
} }
if ac.APIKeyID != "" { if ac.APIKeyID.Valid {
return "api_key", return "api_key", id.FormatAPIKeyID(ac.APIKeyID), ac.APIKeyName
pgtype.Text{String: ac.APIKeyID, Valid: true},
pgtype.Text{String: ac.APIKeyName, Valid: true}
} }
return "system", pgtype.Text{}, pgtype.Text{} return "system", "", ""
} }
func (l *AuditLogger) write(ctx context.Context, p db.InsertAuditLogParams) { func (l *AuditLogger) write(ctx context.Context, p db.InsertAuditLogParams) {
@ -44,7 +41,6 @@ func (l *AuditLogger) write(ctx context.Context, p db.InsertAuditLogParams) {
slog.Warn("audit: failed to write log entry", slog.Warn("audit: failed to write log entry",
"action", p.Action, "action", p.Action,
"resource_type", p.ResourceType, "resource_type", p.ResourceType,
"team_id", p.TeamID,
"error", err, "error", err,
) )
} }
@ -61,18 +57,26 @@ func marshalMeta(meta map[string]any) []byte {
return b return b
} }
// optText returns a valid pgtype.Text if s is non-empty, otherwise an invalid (NULL) one.
func optText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}
// --- Sandbox events (scope: team) --- // --- Sandbox events (scope: team) ---
func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext, sandboxID, template string) { func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID, template string) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "sandbox", ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true}, ResourceID: optText(id.FormatSandboxID(sandboxID)),
Action: "create", Action: "create",
Scope: "team", Scope: "team",
Status: "success", Status: "success",
@ -80,16 +84,16 @@ func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext,
}) })
} }
func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext, sandboxID string) { func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "sandbox", ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true}, ResourceID: optText(id.FormatSandboxID(sandboxID)),
Action: "pause", Action: "pause",
Scope: "team", Scope: "team",
Status: "success", Status: "success",
@ -98,15 +102,15 @@ func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext,
} }
// LogSandboxAutoPause records a system-initiated auto-pause (TTL or host reconciler). // LogSandboxAutoPause records a system-initiated auto-pause (TTL or host reconciler).
func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID string) { func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID pgtype.UUID) {
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: teamID, TeamID: teamID,
ActorType: "system", ActorType: "system",
ActorID: pgtype.Text{}, ActorID: pgtype.Text{},
ActorName: pgtype.Text{}, ActorName: "",
ResourceType: "sandbox", ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true}, ResourceID: optText(id.FormatSandboxID(sandboxID)),
Action: "pause", Action: "pause",
Scope: "team", Scope: "team",
Status: "info", Status: "info",
@ -114,16 +118,16 @@ func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID
}) })
} }
func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext, sandboxID string) { func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "sandbox", ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true}, ResourceID: optText(id.FormatSandboxID(sandboxID)),
Action: "resume", Action: "resume",
Scope: "team", Scope: "team",
Status: "success", Status: "success",
@ -131,16 +135,16 @@ func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext,
}) })
} }
func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext, sandboxID string) { func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "sandbox", ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true}, ResourceID: optText(id.FormatSandboxID(sandboxID)),
Action: "destroy", Action: "destroy",
Scope: "team", Scope: "team",
Status: "warning", Status: "warning",
@ -156,10 +160,10 @@ func (l *AuditLogger) LogSnapshotCreate(ctx context.Context, ac auth.AuthContext
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "snapshot", ResourceType: "snapshot",
ResourceID: pgtype.Text{String: name, Valid: true}, ResourceID: optText(name),
Action: "create", Action: "create",
Scope: "team", Scope: "team",
Status: "success", Status: "success",
@ -173,10 +177,10 @@ func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "snapshot", ResourceType: "snapshot",
ResourceID: pgtype.Text{String: name, Valid: true}, ResourceID: optText(name),
Action: "delete", Action: "delete",
Scope: "team", Scope: "team",
Status: "warning", Status: "warning",
@ -186,16 +190,16 @@ func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext
// --- Team events (scope: team) --- // --- Team events (scope: team) ---
func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, teamID, oldName, newName string) { func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID, oldName, newName string) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "team", ResourceType: "team",
ResourceID: pgtype.Text{String: teamID, Valid: true}, ResourceID: optText(id.FormatTeamID(teamID)),
Action: "rename", Action: "rename",
Scope: "team", Scope: "team",
Status: "info", Status: "info",
@ -205,16 +209,16 @@ func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, te
// --- API key events (scope: team) --- // --- API key events (scope: team) ---
func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID, keyName string) { func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID, keyName string) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "api_key", ResourceType: "api_key",
ResourceID: pgtype.Text{String: keyID, Valid: true}, ResourceID: optText(id.FormatAPIKeyID(keyID)),
Action: "create", Action: "create",
Scope: "team", Scope: "team",
Status: "success", Status: "success",
@ -222,16 +226,16 @@ func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext,
}) })
} }
func (l *AuditLogger) LogAPIKeyRevoke(ctx context.Context, ac auth.AuthContext, keyID string) { func (l *AuditLogger) LogAPIKeyRevoke(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "api_key", ResourceType: "api_key",
ResourceID: pgtype.Text{String: keyID, Valid: true}, ResourceID: optText(id.FormatAPIKeyID(keyID)),
Action: "revoke", Action: "revoke",
Scope: "team", Scope: "team",
Status: "warning", Status: "warning",
@ -241,16 +245,16 @@ func (l *AuditLogger) LogAPIKeyRevoke(ctx context.Context, ac auth.AuthContext,
// --- Member events (scope: admin) --- // --- Member events (scope: admin) ---
func (l *AuditLogger) LogMemberAdd(ctx context.Context, ac auth.AuthContext, targetUserID, targetEmail, role string) { func (l *AuditLogger) LogMemberAdd(ctx context.Context, ac auth.AuthContext, targetUserID pgtype.UUID, targetEmail, role string) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "member", ResourceType: "member",
ResourceID: pgtype.Text{String: targetUserID, Valid: true}, ResourceID: optText(id.FormatUserID(targetUserID)),
Action: "add", Action: "add",
Scope: "admin", Scope: "admin",
Status: "success", Status: "success",
@ -258,16 +262,16 @@ func (l *AuditLogger) LogMemberAdd(ctx context.Context, ac auth.AuthContext, tar
}) })
} }
func (l *AuditLogger) LogMemberRemove(ctx context.Context, ac auth.AuthContext, targetUserID string) { func (l *AuditLogger) LogMemberRemove(ctx context.Context, ac auth.AuthContext, targetUserID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "member", ResourceType: "member",
ResourceID: pgtype.Text{String: targetUserID, Valid: true}, ResourceID: optText(id.FormatUserID(targetUserID)),
Action: "remove", Action: "remove",
Scope: "admin", Scope: "admin",
Status: "warning", Status: "warning",
@ -277,14 +281,18 @@ func (l *AuditLogger) LogMemberRemove(ctx context.Context, ac auth.AuthContext,
func (l *AuditLogger) LogMemberLeave(ctx context.Context, ac auth.AuthContext) { func (l *AuditLogger) LogMemberLeave(ctx context.Context, ac auth.AuthContext) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
resourceID := ""
if ac.UserID.Valid {
resourceID = id.FormatUserID(ac.UserID)
}
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "member", ResourceType: "member",
ResourceID: pgtype.Text{String: ac.UserID, Valid: ac.UserID != ""}, ResourceID: optText(resourceID),
Action: "leave", Action: "leave",
Scope: "admin", Scope: "admin",
Status: "info", Status: "info",
@ -292,16 +300,16 @@ func (l *AuditLogger) LogMemberLeave(ctx context.Context, ac auth.AuthContext) {
}) })
} }
func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthContext, targetUserID, newRole string) { func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthContext, targetUserID pgtype.UUID, newRole string) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: ac.TeamID, TeamID: ac.TeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "member", ResourceType: "member",
ResourceID: pgtype.Text{String: targetUserID, Valid: true}, ResourceID: optText(id.FormatUserID(targetUserID)),
Action: "role_update", Action: "role_update",
Scope: "admin", Scope: "admin",
Status: "info", Status: "info",
@ -311,24 +319,24 @@ func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthConte
// --- Host events (scope: admin) --- // --- Host events (scope: admin) ---
func (l *AuditLogger) LogHostCreate(ctx context.Context, ac auth.AuthContext, hostID, teamID string) { func (l *AuditLogger) LogHostCreate(ctx context.Context, ac auth.AuthContext, hostID, teamID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
// For shared hosts with no owning team, use the caller's team. // For shared hosts with no owning team, use the caller's team.
logTeamID := teamID logTeamID := teamID
if logTeamID == "" { if !logTeamID.Valid {
logTeamID = ac.TeamID logTeamID = ac.TeamID
} }
if logTeamID == "" { if !logTeamID.Valid {
return return
} }
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: logTeamID, TeamID: logTeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "host", ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true}, ResourceID: optText(id.FormatHostID(hostID)),
Action: "create", Action: "create",
Scope: "admin", Scope: "admin",
Status: "success", Status: "success",
@ -336,23 +344,23 @@ func (l *AuditLogger) LogHostCreate(ctx context.Context, ac auth.AuthContext, ho
}) })
} }
func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, hostID, teamID string) { func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, hostID, teamID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac) actorType, actorID, actorName := actorFields(ac)
logTeamID := teamID logTeamID := teamID
if logTeamID == "" { if !logTeamID.Valid {
logTeamID = ac.TeamID logTeamID = ac.TeamID
} }
if logTeamID == "" { if !logTeamID.Valid {
return return
} }
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(), ID: id.NewAuditLogID(),
TeamID: logTeamID, TeamID: logTeamID,
ActorType: actorType, ActorType: actorType,
ActorID: actorID, ActorID: optText(actorID),
ActorName: actorName, ActorName: actorName,
ResourceType: "host", ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true}, ResourceID: optText(id.FormatHostID(hostID)),
Action: "delete", Action: "delete",
Scope: "admin", Scope: "admin",
Status: "warning", Status: "warning",
@ -361,9 +369,8 @@ func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, ho
} }
// LogHostMarkedDown records a system-initiated host status transition to unreachable. // LogHostMarkedDown records a system-initiated host status transition to unreachable.
// teamID must be non-empty (BYOC hosts only); shared hosts are not logged. func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID pgtype.UUID) {
func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID string) { if !teamID.Valid {
if teamID == "" {
return return
} }
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
@ -371,9 +378,9 @@ func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID stri
TeamID: teamID, TeamID: teamID,
ActorType: "system", ActorType: "system",
ActorID: pgtype.Text{}, ActorID: pgtype.Text{},
ActorName: pgtype.Text{}, ActorName: "",
ResourceType: "host", ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true}, ResourceID: optText(id.FormatHostID(hostID)),
Action: "marked_down", Action: "marked_down",
Scope: "admin", Scope: "admin",
Status: "error", Status: "error",
@ -382,9 +389,8 @@ func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID stri
} }
// LogHostMarkedUp records a system-initiated host status transition back to online. // LogHostMarkedUp records a system-initiated host status transition back to online.
// teamID must be non-empty (BYOC hosts only); shared hosts are not logged. func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID pgtype.UUID) {
func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID string) { if !teamID.Valid {
if teamID == "" {
return return
} }
l.write(ctx, db.InsertAuditLogParams{ l.write(ctx, db.InsertAuditLogParams{
@ -392,9 +398,9 @@ func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID string
TeamID: teamID, TeamID: teamID,
ActorType: "system", ActorType: "system",
ActorID: pgtype.Text{}, ActorID: pgtype.Text{},
ActorName: pgtype.Text{}, ActorName: "",
ResourceType: "host", ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true}, ResourceID: optText(id.FormatHostID(hostID)),
Action: "marked_up", Action: "marked_up",
Scope: "admin", Scope: "admin",
Status: "success", Status: "success",

View File

@ -1,6 +1,10 @@
package auth package auth
import "context" import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type contextKey int type contextKey int
@ -8,13 +12,13 @@ const authCtxKey contextKey = 0
// AuthContext is stamped into request context by auth middleware. // AuthContext is stamped into request context by auth middleware.
type AuthContext struct { type AuthContext struct {
TeamID string TeamID pgtype.UUID
UserID string // empty when authenticated via API key UserID pgtype.UUID // zero value (Valid=false) when authenticated via API key
Email string // empty when authenticated via API key Email string // empty when authenticated via API key
Name string // empty when authenticated via API key Name string // empty when authenticated via API key
Role string // owner, admin, or member; empty when authenticated via API key Role string // owner, admin, or member; empty when authenticated via API key
IsAdmin bool // platform-level admin; always false when authenticated via API key IsAdmin bool // platform-level admin; always false when authenticated via API key
APIKeyID string // populated when authenticated via API key; empty for JWT auth APIKeyID pgtype.UUID // populated when authenticated via API key; zero value for JWT auth
APIKeyName string // display name of the key, snapshotted at auth time; empty for JWT auth APIKeyName string // display name of the key, snapshotted at auth time; empty for JWT auth
} }
@ -43,7 +47,7 @@ const hostCtxKey contextKey = 1
// HostContext is stamped into request context by host token middleware. // HostContext is stamped into request context by host token middleware.
type HostContext struct { type HostContext struct {
HostID string HostID pgtype.UUID
} }
// WithHostContext returns a new context with the given HostContext. // WithHostContext returns a new context with the given HostContext.

View File

@ -5,6 +5,9 @@ import (
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/id"
) )
const jwtExpiry = 6 * time.Hour const jwtExpiry = 6 * time.Hour
@ -23,16 +26,16 @@ type Claims struct {
} }
// SignJWT signs a new 6-hour JWT for the given user. // SignJWT signs a new 6-hour JWT for the given user.
func SignJWT(secret []byte, userID, teamID, email, name, role string, isAdmin bool) (string, error) { func SignJWT(secret []byte, userID, teamID pgtype.UUID, email, name, role string, isAdmin bool) (string, error) {
now := time.Now() now := time.Now()
claims := Claims{ claims := Claims{
TeamID: teamID, TeamID: id.FormatTeamID(teamID),
Role: role, Role: role,
Email: email, Email: email,
Name: name, Name: name,
IsAdmin: isAdmin, IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Subject: userID, Subject: id.FormatUserID(userID),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)), ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)),
}, },
@ -70,14 +73,15 @@ type HostClaims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// SignHostJWT signs a long-lived (1 year) JWT for a registered host agent. // SignHostJWT signs a long-lived (7-day) JWT for a registered host agent.
func SignHostJWT(secret []byte, hostID string) (string, error) { func SignHostJWT(secret []byte, hostID pgtype.UUID) (string, error) {
formatted := id.FormatHostID(hostID)
now := time.Now() now := time.Now()
claims := HostClaims{ claims := HostClaims{
Type: "host", Type: "host",
HostID: hostID, HostID: formatted,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Subject: hostID, Subject: formatted,
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(hostJWTExpiry)), ExpiresAt: jwt.NewNumericDate(now.Add(hostJWTExpiry)),
}, },

View File

@ -16,8 +16,8 @@ DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2
` `
type DeleteAPIKeyParams struct { type DeleteAPIKeyParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) error { func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) error {
@ -52,12 +52,12 @@ RETURNING id, team_id, name, key_hash, key_prefix, created_by, created_at, last_
` `
type InsertAPIKeyParams struct { type InsertAPIKeyParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
Name string `json:"name"` Name string `json:"name"`
KeyHash string `json:"key_hash"` KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"` KeyPrefix string `json:"key_prefix"`
CreatedBy string `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
} }
func (q *Queries) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (TeamApiKey, error) { func (q *Queries) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (TeamApiKey, error) {
@ -87,7 +87,7 @@ const listAPIKeysByTeam = `-- name: ListAPIKeysByTeam :many
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC
` `
func (q *Queries) ListAPIKeysByTeam(ctx context.Context, teamID string) ([]TeamApiKey, error) { func (q *Queries) ListAPIKeysByTeam(ctx context.Context, teamID pgtype.UUID) ([]TeamApiKey, error) {
rows, err := q.db.Query(ctx, listAPIKeysByTeam, teamID) rows, err := q.db.Query(ctx, listAPIKeysByTeam, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -126,18 +126,18 @@ ORDER BY k.created_at DESC
` `
type ListAPIKeysByTeamWithCreatorRow struct { type ListAPIKeysByTeamWithCreatorRow struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
Name string `json:"name"` Name string `json:"name"`
KeyHash string `json:"key_hash"` KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"` KeyPrefix string `json:"key_prefix"`
CreatedBy string `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
LastUsed pgtype.Timestamptz `json:"last_used"` LastUsed pgtype.Timestamptz `json:"last_used"`
CreatorEmail string `json:"creator_email"` CreatorEmail string `json:"creator_email"`
} }
func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID string) ([]ListAPIKeysByTeamWithCreatorRow, error) { func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID pgtype.UUID) ([]ListAPIKeysByTeamWithCreatorRow, error) {
rows, err := q.db.Query(ctx, listAPIKeysByTeamWithCreator, teamID) rows, err := q.db.Query(ctx, listAPIKeysByTeamWithCreator, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -171,7 +171,7 @@ const updateAPIKeyLastUsed = `-- name: UpdateAPIKeyLastUsed :exec
UPDATE team_api_keys SET last_used = NOW() WHERE id = $1 UPDATE team_api_keys SET last_used = NOW() WHERE id = $1
` `
func (q *Queries) UpdateAPIKeyLastUsed(ctx context.Context, id string) error { func (q *Queries) UpdateAPIKeyLastUsed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updateAPIKeyLastUsed, id) _, err := q.db.Exec(ctx, updateAPIKeyLastUsed, id)
return err return err
} }

View File

@ -17,11 +17,11 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
` `
type InsertAuditLogParams struct { type InsertAuditLogParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
ActorType string `json:"actor_type"` ActorType string `json:"actor_type"`
ActorID pgtype.Text `json:"actor_id"` ActorID pgtype.Text `json:"actor_id"`
ActorName pgtype.Text `json:"actor_name"` ActorName string `json:"actor_name"`
ResourceType string `json:"resource_type"` ResourceType string `json:"resource_type"`
ResourceID pgtype.Text `json:"resource_id"` ResourceID pgtype.Text `json:"resource_id"`
Action string `json:"action"` Action string `json:"action"`
@ -60,12 +60,12 @@ LIMIT $7
` `
type ListAuditLogsParams struct { type ListAuditLogsParams struct {
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
Column2 []string `json:"column_2"` Column2 []string `json:"column_2"`
Column3 []string `json:"column_3"` Column3 []string `json:"column_3"`
Column4 []string `json:"column_4"` Column4 []string `json:"column_4"`
Column5 pgtype.Timestamptz `json:"column_5"` Column5 pgtype.Timestamptz `json:"column_5"`
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
} }

View File

@ -47,8 +47,8 @@ RETURNING id, host_id, token_hash, expires_at, created_at, revoked_at
` `
type InsertHostRefreshTokenParams struct { type InsertHostRefreshTokenParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
TokenHash string `json:"token_hash"` TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"` ExpiresAt pgtype.Timestamptz `json:"expires_at"`
} }
@ -76,7 +76,7 @@ const revokeHostRefreshToken = `-- name: RevokeHostRefreshToken :exec
UPDATE host_refresh_tokens SET revoked_at = NOW() WHERE id = $1 UPDATE host_refresh_tokens SET revoked_at = NOW() WHERE id = $1
` `
func (q *Queries) RevokeHostRefreshToken(ctx context.Context, id string) error { func (q *Queries) RevokeHostRefreshToken(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, revokeHostRefreshToken, id) _, err := q.db.Exec(ctx, revokeHostRefreshToken, id)
return err return err
} }
@ -86,7 +86,7 @@ UPDATE host_refresh_tokens SET revoked_at = NOW()
WHERE host_id = $1 AND revoked_at IS NULL WHERE host_id = $1 AND revoked_at IS NULL
` `
func (q *Queries) RevokeHostRefreshTokensByHost(ctx context.Context, hostID string) error { func (q *Queries) RevokeHostRefreshTokensByHost(ctx context.Context, hostID pgtype.UUID) error {
_, err := q.db.Exec(ctx, revokeHostRefreshTokensByHost, hostID) _, err := q.db.Exec(ctx, revokeHostRefreshTokensByHost, hostID)
return err return err
} }

View File

@ -16,7 +16,7 @@ INSERT INTO host_tags (host_id, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING
` `
type AddHostTagParams struct { type AddHostTagParams struct {
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
Tag string `json:"tag"` Tag string `json:"tag"`
} }
@ -29,7 +29,7 @@ const deleteHost = `-- name: DeleteHost :exec
DELETE FROM hosts WHERE id = $1 DELETE FROM hosts WHERE id = $1
` `
func (q *Queries) DeleteHost(ctx context.Context, id string) error { func (q *Queries) DeleteHost(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteHost, id) _, err := q.db.Exec(ctx, deleteHost, id)
return err return err
} }
@ -38,7 +38,7 @@ const getHost = `-- name: GetHost :one
SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE id = $1 SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE id = $1
` `
func (q *Queries) GetHost(ctx context.Context, id string) (Host, error) { func (q *Queries) GetHost(ctx context.Context, id pgtype.UUID) (Host, error) {
row := q.db.QueryRow(ctx, getHost, id) row := q.db.QueryRow(ctx, getHost, id)
var i Host var i Host
err := row.Scan( err := row.Scan(
@ -69,8 +69,8 @@ SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_m
` `
type GetHostByTeamParams struct { type GetHostByTeamParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID pgtype.Text `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) GetHostByTeam(ctx context.Context, arg GetHostByTeamParams) (Host, error) { func (q *Queries) GetHostByTeam(ctx context.Context, arg GetHostByTeamParams) (Host, error) {
@ -103,7 +103,7 @@ const getHostTags = `-- name: GetHostTags :many
SELECT tag FROM host_tags WHERE host_id = $1 ORDER BY tag SELECT tag FROM host_tags WHERE host_id = $1 ORDER BY tag
` `
func (q *Queries) GetHostTags(ctx context.Context, hostID string) ([]string, error) { func (q *Queries) GetHostTags(ctx context.Context, hostID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, getHostTags, hostID) rows, err := q.db.Query(ctx, getHostTags, hostID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -127,7 +127,7 @@ const getHostTokensByHost = `-- name: GetHostTokensByHost :many
SELECT id, host_id, created_by, created_at, expires_at, used_at FROM host_tokens WHERE host_id = $1 ORDER BY created_at DESC SELECT id, host_id, created_by, created_at, expires_at, used_at FROM host_tokens WHERE host_id = $1 ORDER BY created_at DESC
` `
func (q *Queries) GetHostTokensByHost(ctx context.Context, hostID string) ([]HostToken, error) { func (q *Queries) GetHostTokensByHost(ctx context.Context, hostID pgtype.UUID) ([]HostToken, error) {
rows, err := q.db.Query(ctx, getHostTokensByHost, hostID) rows, err := q.db.Query(ctx, getHostTokensByHost, hostID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -161,12 +161,12 @@ RETURNING id, type, team_id, provider, availability_zone, arch, cpu_cores, memor
` `
type InsertHostParams struct { type InsertHostParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Type string `json:"type"` Type string `json:"type"`
TeamID pgtype.Text `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
Provider pgtype.Text `json:"provider"` Provider string `json:"provider"`
AvailabilityZone pgtype.Text `json:"availability_zone"` AvailabilityZone string `json:"availability_zone"`
CreatedBy string `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
} }
func (q *Queries) InsertHost(ctx context.Context, arg InsertHostParams) (Host, error) { func (q *Queries) InsertHost(ctx context.Context, arg InsertHostParams) (Host, error) {
@ -209,9 +209,9 @@ RETURNING id, host_id, created_by, created_at, expires_at, used_at
` `
type InsertHostTokenParams struct { type InsertHostTokenParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
CreatedBy string `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"` ExpiresAt pgtype.Timestamptz `json:"expires_at"`
} }
@ -414,7 +414,7 @@ const listHostsByTeam = `-- name: ListHostsByTeam :many
SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE team_id = $1 AND type = 'byoc' ORDER BY created_at DESC SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE team_id = $1 AND type = 'byoc' ORDER BY created_at DESC
` `
func (q *Queries) ListHostsByTeam(ctx context.Context, teamID pgtype.Text) ([]Host, error) { func (q *Queries) ListHostsByTeam(ctx context.Context, teamID pgtype.UUID) ([]Host, error) {
rows, err := q.db.Query(ctx, listHostsByTeam, teamID) rows, err := q.db.Query(ctx, listHostsByTeam, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -500,7 +500,7 @@ const markHostTokenUsed = `-- name: MarkHostTokenUsed :exec
UPDATE host_tokens SET used_at = NOW() WHERE id = $1 UPDATE host_tokens SET used_at = NOW() WHERE id = $1
` `
func (q *Queries) MarkHostTokenUsed(ctx context.Context, id string) error { func (q *Queries) MarkHostTokenUsed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markHostTokenUsed, id) _, err := q.db.Exec(ctx, markHostTokenUsed, id)
return err return err
} }
@ -509,7 +509,7 @@ const markHostUnreachable = `-- name: MarkHostUnreachable :exec
UPDATE hosts SET status = 'unreachable', updated_at = NOW() WHERE id = $1 UPDATE hosts SET status = 'unreachable', updated_at = NOW() WHERE id = $1
` `
func (q *Queries) MarkHostUnreachable(ctx context.Context, id string) error { func (q *Queries) MarkHostUnreachable(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markHostUnreachable, id) _, err := q.db.Exec(ctx, markHostUnreachable, id)
return err return err
} }
@ -528,12 +528,12 @@ WHERE id = $1 AND status = 'pending'
` `
type RegisterHostParams struct { type RegisterHostParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Arch pgtype.Text `json:"arch"` Arch string `json:"arch"`
CpuCores pgtype.Int4 `json:"cpu_cores"` CpuCores int32 `json:"cpu_cores"`
MemoryMb pgtype.Int4 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
DiskGb pgtype.Int4 `json:"disk_gb"` DiskGb int32 `json:"disk_gb"`
Address pgtype.Text `json:"address"` Address string `json:"address"`
} }
func (q *Queries) RegisterHost(ctx context.Context, arg RegisterHostParams) (int64, error) { func (q *Queries) RegisterHost(ctx context.Context, arg RegisterHostParams) (int64, error) {
@ -556,7 +556,7 @@ DELETE FROM host_tags WHERE host_id = $1 AND tag = $2
` `
type RemoveHostTagParams struct { type RemoveHostTagParams struct {
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
Tag string `json:"tag"` Tag string `json:"tag"`
} }
@ -569,7 +569,7 @@ const updateHostHeartbeat = `-- name: UpdateHostHeartbeat :exec
UPDATE hosts SET last_heartbeat_at = NOW(), updated_at = NOW() WHERE id = $1 UPDATE hosts SET last_heartbeat_at = NOW(), updated_at = NOW() WHERE id = $1
` `
func (q *Queries) UpdateHostHeartbeat(ctx context.Context, id string) error { func (q *Queries) UpdateHostHeartbeat(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updateHostHeartbeat, id) _, err := q.db.Exec(ctx, updateHostHeartbeat, id)
return err return err
} }
@ -584,7 +584,7 @@ WHERE id = $1
// Updates last_heartbeat_at and transitions unreachable hosts back to online. // Updates last_heartbeat_at and transitions unreachable hosts back to online.
// Returns 0 if no host was found (deleted), which the caller treats as 404. // Returns 0 if no host was found (deleted), which the caller treats as 404.
func (q *Queries) UpdateHostHeartbeatAndStatus(ctx context.Context, id string) (int64, error) { func (q *Queries) UpdateHostHeartbeatAndStatus(ctx context.Context, id pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, updateHostHeartbeatAndStatus, id) result, err := q.db.Exec(ctx, updateHostHeartbeatAndStatus, id)
if err != nil { if err != nil {
return 0, err return 0, err
@ -597,7 +597,7 @@ UPDATE hosts SET status = $2, updated_at = NOW() WHERE id = $1
` `
type UpdateHostStatusParams struct { type UpdateHostStatusParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Status string `json:"status"` Status string `json:"status"`
} }

View File

@ -7,6 +7,8 @@ package db
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
) )
const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec
@ -14,7 +16,7 @@ DELETE FROM sandbox_metric_points
WHERE sandbox_id = $1 WHERE sandbox_id = $1
` `
func (q *Queries) DeleteSandboxMetricPoints(ctx context.Context, sandboxID string) error { func (q *Queries) DeleteSandboxMetricPoints(ctx context.Context, sandboxID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteSandboxMetricPoints, sandboxID) _, err := q.db.Exec(ctx, deleteSandboxMetricPoints, sandboxID)
return err return err
} }
@ -25,7 +27,7 @@ WHERE sandbox_id = $1 AND tier = $2
` `
type DeleteSandboxMetricPointsByTierParams struct { type DeleteSandboxMetricPointsByTierParams struct {
SandboxID string `json:"sandbox_id"` SandboxID pgtype.UUID `json:"sandbox_id"`
Tier string `json:"tier"` Tier string `json:"tier"`
} }
@ -53,7 +55,7 @@ type GetLiveMetricsRow struct {
// Reads directly from sandboxes for accurate real-time current values. // Reads directly from sandboxes for accurate real-time current values.
// CPU reserved = running + starting only (paused VMs release CPU). // CPU reserved = running + starting only (paused VMs release CPU).
// RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling). // RAM reserved = running + starting + sum(ceil(each_paused/2)) (per-VM ceiling).
func (q *Queries) GetLiveMetrics(ctx context.Context, teamID string) (GetLiveMetricsRow, error) { func (q *Queries) GetLiveMetrics(ctx context.Context, teamID pgtype.UUID) (GetLiveMetricsRow, error) {
row := q.db.QueryRow(ctx, getLiveMetrics, teamID) row := q.db.QueryRow(ctx, getLiveMetrics, teamID)
var i GetLiveMetricsRow var i GetLiveMetricsRow
err := row.Scan(&i.RunningCount, &i.VcpusReserved, &i.MemoryMbReserved) err := row.Scan(&i.RunningCount, &i.VcpusReserved, &i.MemoryMbReserved)
@ -76,7 +78,7 @@ type GetPeakMetricsRow struct {
PeakMemoryMb int32 `json:"peak_memory_mb"` PeakMemoryMb int32 `json:"peak_memory_mb"`
} }
func (q *Queries) GetPeakMetrics(ctx context.Context, teamID string) (GetPeakMetricsRow, error) { func (q *Queries) GetPeakMetrics(ctx context.Context, teamID pgtype.UUID) (GetPeakMetricsRow, error) {
row := q.db.QueryRow(ctx, getPeakMetrics, teamID) row := q.db.QueryRow(ctx, getPeakMetrics, teamID)
var i GetPeakMetricsRow var i GetPeakMetricsRow
err := row.Scan(&i.PeakRunningCount, &i.PeakVcpus, &i.PeakMemoryMb) err := row.Scan(&i.PeakRunningCount, &i.PeakVcpus, &i.PeakMemoryMb)
@ -91,7 +93,7 @@ ORDER BY ts ASC
` `
type GetSandboxMetricPointsParams struct { type GetSandboxMetricPointsParams struct {
SandboxID string `json:"sandbox_id"` SandboxID pgtype.UUID `json:"sandbox_id"`
Tier string `json:"tier"` Tier string `json:"tier"`
Ts int64 `json:"ts"` Ts int64 `json:"ts"`
} }
@ -134,7 +136,7 @@ VALUES ($1, $2, $3, $4)
` `
type InsertMetricsSnapshotParams struct { type InsertMetricsSnapshotParams struct {
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
RunningCount int32 `json:"running_count"` RunningCount int32 `json:"running_count"`
VcpusReserved int32 `json:"vcpus_reserved"` VcpusReserved int32 `json:"vcpus_reserved"`
MemoryMbReserved int32 `json:"memory_mb_reserved"` MemoryMbReserved int32 `json:"memory_mb_reserved"`
@ -157,7 +159,7 @@ ON CONFLICT (sandbox_id, tier, ts) DO NOTHING
` `
type InsertSandboxMetricPointParams struct { type InsertSandboxMetricPointParams struct {
SandboxID string `json:"sandbox_id"` SandboxID pgtype.UUID `json:"sandbox_id"`
Tier string `json:"tier"` Tier string `json:"tier"`
Ts int64 `json:"ts"` Ts int64 `json:"ts"`
CpuPct float64 `json:"cpu_pct"` CpuPct float64 `json:"cpu_pct"`
@ -210,7 +212,7 @@ GROUP BY team_id
` `
type SampleSandboxMetricsRow struct { type SampleSandboxMetricsRow struct {
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
RunningCount int32 `json:"running_count"` RunningCount int32 `json:"running_count"`
VcpusReserved int32 `json:"vcpus_reserved"` VcpusReserved int32 `json:"vcpus_reserved"`
MemoryMbReserved int32 `json:"memory_mb_reserved"` MemoryMbReserved int32 `json:"memory_mb_reserved"`

View File

@ -9,18 +9,18 @@ import (
) )
type AdminPermission struct { type AdminPermission struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Permission string `json:"permission"` Permission string `json:"permission"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type AuditLog struct { type AuditLog struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
ActorType string `json:"actor_type"` ActorType string `json:"actor_type"`
ActorID pgtype.Text `json:"actor_id"` ActorID pgtype.Text `json:"actor_id"`
ActorName pgtype.Text `json:"actor_name"` ActorName string `json:"actor_name"`
ResourceType string `json:"resource_type"` ResourceType string `json:"resource_type"`
ResourceID pgtype.Text `json:"resource_id"` ResourceID pgtype.Text `json:"resource_id"`
Action string `json:"action"` Action string `json:"action"`
@ -31,29 +31,29 @@ type AuditLog struct {
} }
type Host struct { type Host struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Type string `json:"type"` Type string `json:"type"`
TeamID pgtype.Text `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
Provider pgtype.Text `json:"provider"` Provider string `json:"provider"`
AvailabilityZone pgtype.Text `json:"availability_zone"` AvailabilityZone string `json:"availability_zone"`
Arch pgtype.Text `json:"arch"` Arch string `json:"arch"`
CpuCores pgtype.Int4 `json:"cpu_cores"` CpuCores int32 `json:"cpu_cores"`
MemoryMb pgtype.Int4 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
DiskGb pgtype.Int4 `json:"disk_gb"` DiskGb int32 `json:"disk_gb"`
Address pgtype.Text `json:"address"` Address string `json:"address"`
Status string `json:"status"` Status string `json:"status"`
LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"` LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"`
Metadata []byte `json:"metadata"` Metadata []byte `json:"metadata"`
CreatedBy string `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
CertFingerprint pgtype.Text `json:"cert_fingerprint"` CertFingerprint string `json:"cert_fingerprint"`
MtlsEnabled bool `json:"mtls_enabled"` MtlsEnabled bool `json:"mtls_enabled"`
} }
type HostRefreshToken struct { type HostRefreshToken struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
TokenHash string `json:"token_hash"` TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"` ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
@ -61,14 +61,14 @@ type HostRefreshToken struct {
} }
type HostTag struct { type HostTag struct {
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
Tag string `json:"tag"` Tag string `json:"tag"`
} }
type HostToken struct { type HostToken struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
CreatedBy string `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"` ExpiresAt pgtype.Timestamptz `json:"expires_at"`
UsedAt pgtype.Timestamptz `json:"used_at"` UsedAt pgtype.Timestamptz `json:"used_at"`
@ -77,14 +77,15 @@ type HostToken struct {
type OauthProvider struct { type OauthProvider struct {
Provider string `json:"provider"` Provider string `json:"provider"`
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type Sandbox struct { type Sandbox struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
HostID string `json:"host_id"` TeamID pgtype.UUID `json:"team_id"`
HostID pgtype.UUID `json:"host_id"`
Template string `json:"template"` Template string `json:"template"`
Status string `json:"status"` Status string `json:"status"`
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
@ -96,11 +97,10 @@ type Sandbox struct {
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
LastActiveAt pgtype.Timestamptz `json:"last_active_at"` LastActiveAt pgtype.Timestamptz `json:"last_active_at"`
LastUpdated pgtype.Timestamptz `json:"last_updated"` LastUpdated pgtype.Timestamptz `json:"last_updated"`
TeamID string `json:"team_id"`
} }
type SandboxMetricPoint struct { type SandboxMetricPoint struct {
SandboxID string `json:"sandbox_id"` SandboxID pgtype.UUID `json:"sandbox_id"`
Tier string `json:"tier"` Tier string `json:"tier"`
Ts int64 `json:"ts"` Ts int64 `json:"ts"`
CpuPct float64 `json:"cpu_pct"` CpuPct float64 `json:"cpu_pct"`
@ -110,7 +110,7 @@ type SandboxMetricPoint struct {
type SandboxMetricsSnapshot struct { type SandboxMetricsSnapshot struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
SampledAt pgtype.Timestamptz `json:"sampled_at"` SampledAt pgtype.Timestamptz `json:"sampled_at"`
RunningCount int32 `json:"running_count"` RunningCount int32 `json:"running_count"`
VcpusReserved int32 `json:"vcpus_reserved"` VcpusReserved int32 `json:"vcpus_reserved"`
@ -118,21 +118,21 @@ type SandboxMetricsSnapshot struct {
} }
type Team struct { type Team struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
IsByoc bool `json:"is_byoc"`
Slug string `json:"slug"` Slug string `json:"slug"`
IsByoc bool `json:"is_byoc"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
} }
type TeamApiKey struct { type TeamApiKey struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
Name string `json:"name"` Name string `json:"name"`
KeyHash string `json:"key_hash"` KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"` KeyPrefix string `json:"key_prefix"`
CreatedBy string `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
LastUsed pgtype.Timestamptz `json:"last_used"` LastUsed pgtype.Timestamptz `json:"last_used"`
} }
@ -140,46 +140,46 @@ type TeamApiKey struct {
type Template struct { type Template struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Vcpus pgtype.Int4 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb pgtype.Int4 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
type TemplateBuild struct { type TemplateBuild struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
BaseTemplate string `json:"base_template"` BaseTemplate string `json:"base_template"`
Recipe []byte `json:"recipe"` Recipe []byte `json:"recipe"`
Healthcheck pgtype.Text `json:"healthcheck"` Healthcheck string `json:"healthcheck"`
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb int32 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
Status string `json:"status"` Status string `json:"status"`
CurrentStep int32 `json:"current_step"` CurrentStep int32 `json:"current_step"`
TotalSteps int32 `json:"total_steps"` TotalSteps int32 `json:"total_steps"`
Logs []byte `json:"logs"` Logs []byte `json:"logs"`
Error pgtype.Text `json:"error"` Error string `json:"error"`
SandboxID pgtype.Text `json:"sandbox_id"` SandboxID pgtype.UUID `json:"sandbox_id"`
HostID pgtype.Text `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"` CompletedAt pgtype.Timestamptz `json:"completed_at"`
} }
type User struct { type User struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
IsAdmin bool `json:"is_admin"`
Name string `json:"name"`
} }
type UsersTeam struct { type UsersTeam struct {
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
Role string `json:"role"` Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`

View File

@ -7,6 +7,8 @@ package db
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
) )
const getOAuthProvider = `-- name: GetOAuthProvider :one const getOAuthProvider = `-- name: GetOAuthProvider :one
@ -40,7 +42,7 @@ VALUES ($1, $2, $3, $4)
type InsertOAuthProviderParams struct { type InsertOAuthProviderParams struct {
Provider string `json:"provider"` Provider string `json:"provider"`
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
} }

View File

@ -15,12 +15,12 @@ const bulkRestoreRunning = `-- name: BulkRestoreRunning :exec
UPDATE sandboxes UPDATE sandboxes
SET status = 'running', SET status = 'running',
last_updated = NOW() last_updated = NOW()
WHERE id = ANY($1::text[]) AND status = 'missing' WHERE id = ANY($1::uuid[]) AND status = 'missing'
` `
// Called by the reconciler when a host comes back online and its sandboxes are // Called by the reconciler when a host comes back online and its sandboxes are
// confirmed alive. Restores only sandboxes that are in 'missing' state. // confirmed alive. Restores only sandboxes that are in 'missing' state.
func (q *Queries) BulkRestoreRunning(ctx context.Context, dollar_1 []string) error { func (q *Queries) BulkRestoreRunning(ctx context.Context, dollar_1 []pgtype.UUID) error {
_, err := q.db.Exec(ctx, bulkRestoreRunning, dollar_1) _, err := q.db.Exec(ctx, bulkRestoreRunning, dollar_1)
return err return err
} }
@ -29,11 +29,11 @@ const bulkUpdateStatusByIDs = `-- name: BulkUpdateStatusByIDs :exec
UPDATE sandboxes UPDATE sandboxes
SET status = $2, SET status = $2,
last_updated = NOW() last_updated = NOW()
WHERE id = ANY($1::text[]) WHERE id = ANY($1::uuid[])
` `
type BulkUpdateStatusByIDsParams struct { type BulkUpdateStatusByIDsParams struct {
Column1 []string `json:"column_1"` Column1 []pgtype.UUID `json:"column_1"`
Status string `json:"status"` Status string `json:"status"`
} }
@ -43,14 +43,15 @@ func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatu
} }
const getSandbox = `-- name: GetSandbox :one const getSandbox = `-- name: GetSandbox :one
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE id = $1 SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes WHERE id = $1
` `
func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) { func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, error) {
row := q.db.QueryRow(ctx, getSandbox, id) row := q.db.QueryRow(ctx, getSandbox, id)
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -63,18 +64,17 @@ func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) {
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }
const getSandboxByTeam = `-- name: GetSandboxByTeam :one const getSandboxByTeam = `-- name: GetSandboxByTeam :one
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE id = $1 AND team_id = $2 SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes WHERE id = $1 AND team_id = $2
` `
type GetSandboxByTeamParams struct { type GetSandboxByTeamParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamParams) (Sandbox, error) { func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamParams) (Sandbox, error) {
@ -82,6 +82,7 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -94,7 +95,6 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }
@ -102,13 +102,13 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara
const insertSandbox = `-- name: InsertSandbox :one const insertSandbox = `-- name: InsertSandbox :one
INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated
` `
type InsertSandboxParams struct { type InsertSandboxParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
Template string `json:"template"` Template string `json:"template"`
Status string `json:"status"` Status string `json:"status"`
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
@ -130,6 +130,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -142,18 +143,17 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting') WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
ORDER BY created_at DESC ORDER BY created_at DESC
` `
func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string) ([]Sandbox, error) { func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.UUID) ([]Sandbox, error) {
rows, err := q.db.Query(ctx, listActiveSandboxesByTeam, teamID) rows, err := q.db.Query(ctx, listActiveSandboxesByTeam, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -164,6 +164,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string)
var i Sandbox var i Sandbox
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -176,7 +177,6 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string)
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -189,7 +189,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string)
} }
const listSandboxes = `-- name: ListSandboxes :many const listSandboxes = `-- name: ListSandboxes :many
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes ORDER BY created_at DESC SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes ORDER BY created_at DESC
` `
func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
@ -203,6 +203,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
var i Sandbox var i Sandbox
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -215,7 +216,6 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -228,13 +228,13 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
} }
const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes
WHERE host_id = $1 AND status = ANY($2::text[]) WHERE host_id = $1 AND status = ANY($2::text[])
ORDER BY created_at DESC ORDER BY created_at DESC
` `
type ListSandboxesByHostAndStatusParams struct { type ListSandboxesByHostAndStatusParams struct {
HostID string `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
Column2 []string `json:"column_2"` Column2 []string `json:"column_2"`
} }
@ -249,6 +249,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
var i Sandbox var i Sandbox
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -261,7 +262,6 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -274,12 +274,12 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
} }
const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes
WHERE team_id = $1 AND status NOT IN ('stopped', 'error') WHERE team_id = $1 AND status NOT IN ('stopped', 'error')
ORDER BY created_at DESC ORDER BY created_at DESC
` `
func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]Sandbox, error) { func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID pgtype.UUID) ([]Sandbox, error) {
rows, err := q.db.Query(ctx, listSandboxesByTeam, teamID) rows, err := q.db.Query(ctx, listSandboxesByTeam, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -290,6 +290,7 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]San
var i Sandbox var i Sandbox
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -302,7 +303,6 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]San
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -324,7 +324,7 @@ WHERE host_id = $1 AND status IN ('running', 'starting', 'pending')
// Called when the host monitor marks a host unreachable. // Called when the host monitor marks a host unreachable.
// Marks running/starting/pending sandboxes on that host as 'missing' so users see // Marks running/starting/pending sandboxes on that host as 'missing' so users see
// the sandbox is not currently reachable, without permanently losing the record. // the sandbox is not currently reachable, without permanently losing the record.
func (q *Queries) MarkSandboxesMissingByHost(ctx context.Context, hostID string) error { func (q *Queries) MarkSandboxesMissingByHost(ctx context.Context, hostID pgtype.UUID) error {
_, err := q.db.Exec(ctx, markSandboxesMissingByHost, hostID) _, err := q.db.Exec(ctx, markSandboxesMissingByHost, hostID)
return err return err
} }
@ -337,7 +337,7 @@ WHERE id = $1
` `
type UpdateLastActiveParams struct { type UpdateLastActiveParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
LastActiveAt pgtype.Timestamptz `json:"last_active_at"` LastActiveAt pgtype.Timestamptz `json:"last_active_at"`
} }
@ -355,11 +355,11 @@ SET status = 'running',
last_active_at = $4, last_active_at = $4,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated
` `
type UpdateSandboxRunningParams struct { type UpdateSandboxRunningParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
HostIp string `json:"host_ip"` HostIp string `json:"host_ip"`
GuestIp string `json:"guest_ip"` GuestIp string `json:"guest_ip"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
@ -375,6 +375,7 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -387,7 +388,6 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }
@ -397,11 +397,11 @@ UPDATE sandboxes
SET status = $2, SET status = $2,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated
` `
type UpdateSandboxStatusParams struct { type UpdateSandboxStatusParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Status string `json:"status"` Status string `json:"status"`
} }
@ -410,6 +410,7 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.TeamID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -422,7 +423,6 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }

View File

@ -16,8 +16,8 @@ DELETE FROM users_teams WHERE team_id = $1 AND user_id = $2
` `
type DeleteTeamMemberParams struct { type DeleteTeamMemberParams struct {
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
} }
func (q *Queries) DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error { func (q *Queries) DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error {
@ -26,7 +26,7 @@ func (q *Queries) DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberPara
} }
const getBYOCTeams = `-- name: GetBYOCTeams :many const getBYOCTeams = `-- name: GetBYOCTeams :many
SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE is_byoc = TRUE AND deleted_at IS NULL ORDER BY created_at SELECT id, name, slug, is_byoc, created_at, deleted_at FROM teams WHERE is_byoc = TRUE AND deleted_at IS NULL ORDER BY created_at
` `
func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
@ -41,9 +41,9 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.CreatedAt,
&i.IsByoc,
&i.Slug, &i.Slug,
&i.IsByoc,
&i.CreatedAt,
&i.DeletedAt, &i.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@ -57,46 +57,46 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
} }
const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one
SELECT t.id, t.name, t.created_at, t.is_byoc, t.slug, t.deleted_at FROM teams t SELECT t.id, t.name, t.slug, t.is_byoc, t.created_at, t.deleted_at FROM teams t
JOIN users_teams ut ON ut.team_id = t.id JOIN users_teams ut ON ut.team_id = t.id
WHERE ut.user_id = $1 AND ut.is_default = TRUE AND t.deleted_at IS NULL WHERE ut.user_id = $1 AND ut.is_default = TRUE AND t.deleted_at IS NULL
LIMIT 1 LIMIT 1
` `
func (q *Queries) GetDefaultTeamForUser(ctx context.Context, userID string) (Team, error) { func (q *Queries) GetDefaultTeamForUser(ctx context.Context, userID pgtype.UUID) (Team, error) {
row := q.db.QueryRow(ctx, getDefaultTeamForUser, userID) row := q.db.QueryRow(ctx, getDefaultTeamForUser, userID)
var i Team var i Team
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.CreatedAt,
&i.IsByoc,
&i.Slug, &i.Slug,
&i.IsByoc,
&i.CreatedAt,
&i.DeletedAt, &i.DeletedAt,
) )
return i, err return i, err
} }
const getTeam = `-- name: GetTeam :one const getTeam = `-- name: GetTeam :one
SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE id = $1 SELECT id, name, slug, is_byoc, created_at, deleted_at FROM teams WHERE id = $1
` `
func (q *Queries) GetTeam(ctx context.Context, id string) (Team, error) { func (q *Queries) GetTeam(ctx context.Context, id pgtype.UUID) (Team, error) {
row := q.db.QueryRow(ctx, getTeam, id) row := q.db.QueryRow(ctx, getTeam, id)
var i Team var i Team
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.CreatedAt,
&i.IsByoc,
&i.Slug, &i.Slug,
&i.IsByoc,
&i.CreatedAt,
&i.DeletedAt, &i.DeletedAt,
) )
return i, err return i, err
} }
const getTeamBySlug = `-- name: GetTeamBySlug :one const getTeamBySlug = `-- name: GetTeamBySlug :one
SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE slug = $1 AND deleted_at IS NULL SELECT id, name, slug, is_byoc, created_at, deleted_at FROM teams WHERE slug = $1 AND deleted_at IS NULL
` `
func (q *Queries) GetTeamBySlug(ctx context.Context, slug string) (Team, error) { func (q *Queries) GetTeamBySlug(ctx context.Context, slug string) (Team, error) {
@ -105,9 +105,9 @@ func (q *Queries) GetTeamBySlug(ctx context.Context, slug string) (Team, error)
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.CreatedAt,
&i.IsByoc,
&i.Slug, &i.Slug,
&i.IsByoc,
&i.CreatedAt,
&i.DeletedAt, &i.DeletedAt,
) )
return i, err return i, err
@ -122,14 +122,14 @@ ORDER BY ut.created_at
` `
type GetTeamMembersRow struct { type GetTeamMembersRow struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` Role string `json:"role"`
JoinedAt pgtype.Timestamptz `json:"joined_at"` JoinedAt pgtype.Timestamptz `json:"joined_at"`
} }
func (q *Queries) GetTeamMembers(ctx context.Context, teamID string) ([]GetTeamMembersRow, error) { func (q *Queries) GetTeamMembers(ctx context.Context, teamID pgtype.UUID) ([]GetTeamMembersRow, error) {
rows, err := q.db.Query(ctx, getTeamMembers, teamID) rows, err := q.db.Query(ctx, getTeamMembers, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -160,8 +160,8 @@ SELECT user_id, team_id, is_default, role, created_at FROM users_teams WHERE use
` `
type GetTeamMembershipParams struct { type GetTeamMembershipParams struct {
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) GetTeamMembership(ctx context.Context, arg GetTeamMembershipParams) (UsersTeam, error) { func (q *Queries) GetTeamMembership(ctx context.Context, arg GetTeamMembershipParams) (UsersTeam, error) {
@ -186,7 +186,7 @@ ORDER BY ut.created_at
` `
type GetTeamsForUserRow struct { type GetTeamsForUserRow struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
IsByoc bool `json:"is_byoc"` IsByoc bool `json:"is_byoc"`
@ -195,7 +195,7 @@ type GetTeamsForUserRow struct {
Role string `json:"role"` Role string `json:"role"`
} }
func (q *Queries) GetTeamsForUser(ctx context.Context, userID string) ([]GetTeamsForUserRow, error) { func (q *Queries) GetTeamsForUser(ctx context.Context, userID pgtype.UUID) ([]GetTeamsForUserRow, error) {
rows, err := q.db.Query(ctx, getTeamsForUser, userID) rows, err := q.db.Query(ctx, getTeamsForUser, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -226,11 +226,11 @@ func (q *Queries) GetTeamsForUser(ctx context.Context, userID string) ([]GetTeam
const insertTeam = `-- name: InsertTeam :one const insertTeam = `-- name: InsertTeam :one
INSERT INTO teams (id, name, slug) INSERT INTO teams (id, name, slug)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, name, created_at, is_byoc, slug, deleted_at RETURNING id, name, slug, is_byoc, created_at, deleted_at
` `
type InsertTeamParams struct { type InsertTeamParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
} }
@ -241,9 +241,9 @@ func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, e
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.CreatedAt,
&i.IsByoc,
&i.Slug, &i.Slug,
&i.IsByoc,
&i.CreatedAt,
&i.DeletedAt, &i.DeletedAt,
) )
return i, err return i, err
@ -255,8 +255,8 @@ VALUES ($1, $2, $3, $4)
` `
type InsertTeamMemberParams struct { type InsertTeamMemberParams struct {
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
Role string `json:"role"` Role string `json:"role"`
} }
@ -276,7 +276,7 @@ UPDATE teams SET is_byoc = $2 WHERE id = $1
` `
type SetTeamBYOCParams struct { type SetTeamBYOCParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
IsByoc bool `json:"is_byoc"` IsByoc bool `json:"is_byoc"`
} }
@ -289,7 +289,7 @@ const softDeleteTeam = `-- name: SoftDeleteTeam :exec
UPDATE teams SET deleted_at = NOW() WHERE id = $1 UPDATE teams SET deleted_at = NOW() WHERE id = $1
` `
func (q *Queries) SoftDeleteTeam(ctx context.Context, id string) error { func (q *Queries) SoftDeleteTeam(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteTeam, id) _, err := q.db.Exec(ctx, softDeleteTeam, id)
return err return err
} }
@ -299,8 +299,8 @@ UPDATE users_teams SET role = $3 WHERE team_id = $1 AND user_id = $2
` `
type UpdateMemberRoleParams struct { type UpdateMemberRoleParams struct {
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"` Role string `json:"role"`
} }
@ -314,7 +314,7 @@ UPDATE teams SET name = $2 WHERE id = $1 AND deleted_at IS NULL
` `
type UpdateTeamNameParams struct { type UpdateTeamNameParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }

View File

@ -15,7 +15,7 @@ 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 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 FROM template_builds WHERE id = $1
` `
func (q *Queries) GetTemplateBuild(ctx context.Context, id string) (TemplateBuild, error) { func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (TemplateBuild, error) {
row := q.db.QueryRow(ctx, getTemplateBuild, id) row := q.db.QueryRow(ctx, getTemplateBuild, id)
var i TemplateBuild var i TemplateBuild
err := row.Scan( err := row.Scan(
@ -47,11 +47,11 @@ RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status
` `
type InsertTemplateBuildParams struct { type InsertTemplateBuildParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
BaseTemplate string `json:"base_template"` BaseTemplate string `json:"base_template"`
Recipe []byte `json:"recipe"` Recipe []byte `json:"recipe"`
Healthcheck pgtype.Text `json:"healthcheck"` Healthcheck string `json:"healthcheck"`
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb int32 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
TotalSteps int32 `json:"total_steps"` TotalSteps int32 `json:"total_steps"`
@ -140,8 +140,8 @@ WHERE id = $1
` `
type UpdateBuildErrorParams struct { type UpdateBuildErrorParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Error pgtype.Text `json:"error"` Error string `json:"error"`
} }
func (q *Queries) UpdateBuildError(ctx context.Context, arg UpdateBuildErrorParams) error { func (q *Queries) UpdateBuildError(ctx context.Context, arg UpdateBuildErrorParams) error {
@ -156,7 +156,7 @@ WHERE id = $1
` `
type UpdateBuildProgressParams struct { type UpdateBuildProgressParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
CurrentStep int32 `json:"current_step"` CurrentStep int32 `json:"current_step"`
Logs []byte `json:"logs"` Logs []byte `json:"logs"`
} }
@ -173,9 +173,9 @@ WHERE id = $1
` `
type UpdateBuildSandboxParams struct { type UpdateBuildSandboxParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
SandboxID pgtype.Text `json:"sandbox_id"` SandboxID pgtype.UUID `json:"sandbox_id"`
HostID pgtype.Text `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
} }
func (q *Queries) UpdateBuildSandbox(ctx context.Context, arg UpdateBuildSandboxParams) error { func (q *Queries) UpdateBuildSandbox(ctx context.Context, arg UpdateBuildSandboxParams) error {
@ -193,7 +193,7 @@ RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status
` `
type UpdateBuildStatusParams struct { type UpdateBuildStatusParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Status string `json:"status"` Status string `json:"status"`
} }

View File

@ -26,7 +26,7 @@ DELETE FROM templates WHERE name = $1 AND team_id = $2
type DeleteTemplateByTeamParams struct { type DeleteTemplateByTeamParams struct {
Name string `json:"name"` Name string `json:"name"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) DeleteTemplateByTeam(ctx context.Context, arg DeleteTemplateByTeamParams) error { func (q *Queries) DeleteTemplateByTeam(ctx context.Context, arg DeleteTemplateByTeamParams) error {
@ -59,7 +59,7 @@ SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templa
type GetTemplateByTeamParams struct { type GetTemplateByTeamParams struct {
Name string `json:"name"` Name string `json:"name"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) { func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) {
@ -86,10 +86,10 @@ RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id
type InsertTemplateParams struct { type InsertTemplateParams struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Vcpus pgtype.Int4 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb pgtype.Int4 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) {
@ -150,7 +150,7 @@ const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 ORDER BY created_at DESC
` `
func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID string) ([]Template, error) { func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) ([]Template, error) {
rows, err := q.db.Query(ctx, listTemplatesByTeam, teamID) rows, err := q.db.Query(ctx, listTemplatesByTeam, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -183,7 +183,7 @@ SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templa
` `
type ListTemplatesByTeamAndTypeParams struct { type ListTemplatesByTeamAndTypeParams struct {
TeamID string `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
Type string `json:"type"` Type string `json:"type"`
} }

View File

@ -16,7 +16,7 @@ DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2
` `
type DeleteAdminPermissionParams struct { type DeleteAdminPermissionParams struct {
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Permission string `json:"permission"` Permission string `json:"permission"`
} }
@ -29,7 +29,7 @@ const getAdminPermissions = `-- name: GetAdminPermissions :many
SELECT id, user_id, permission, created_at FROM admin_permissions WHERE user_id = $1 ORDER BY permission SELECT id, user_id, permission, created_at FROM admin_permissions WHERE user_id = $1 ORDER BY permission
` `
func (q *Queries) GetAdminPermissions(ctx context.Context, userID string) ([]AdminPermission, error) { func (q *Queries) GetAdminPermissions(ctx context.Context, userID pgtype.UUID) ([]AdminPermission, error) {
rows, err := q.db.Query(ctx, getAdminPermissions, userID) rows, err := q.db.Query(ctx, getAdminPermissions, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -55,7 +55,7 @@ func (q *Queries) GetAdminPermissions(ctx context.Context, userID string) ([]Adm
} }
const getAdminUsers = `-- name: GetAdminUsers :many const getAdminUsers = `-- name: GetAdminUsers :many
SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE is_admin = TRUE ORDER BY created_at SELECT id, email, password_hash, name, is_admin, created_at, updated_at FROM users WHERE is_admin = TRUE ORDER BY created_at
` `
func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
@ -71,10 +71,10 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.Name,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsAdmin,
&i.Name,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -87,7 +87,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
} }
const getUserByEmail = `-- name: GetUserByEmail :one const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE email = $1 SELECT id, email, password_hash, name, is_admin, created_at, updated_at FROM users WHERE email = $1
` `
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
@ -97,29 +97,29 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.Name,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsAdmin,
&i.Name,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE id = $1 SELECT id, email, password_hash, name, is_admin, created_at, updated_at FROM users WHERE id = $1
` `
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id) row := q.db.QueryRow(ctx, getUserByID, id)
var i User var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.Name,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsAdmin,
&i.Name,
) )
return i, err return i, err
} }
@ -131,7 +131,7 @@ SELECT EXISTS(
` `
type HasAdminPermissionParams struct { type HasAdminPermissionParams struct {
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Permission string `json:"permission"` Permission string `json:"permission"`
} }
@ -148,8 +148,8 @@ VALUES ($1, $2, $3)
` `
type InsertAdminPermissionParams struct { type InsertAdminPermissionParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
UserID string `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Permission string `json:"permission"` Permission string `json:"permission"`
} }
@ -161,11 +161,11 @@ func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPerm
const insertUser = `-- name: InsertUser :one const insertUser = `-- name: InsertUser :one
INSERT INTO users (id, email, password_hash, name) INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, created_at, updated_at, is_admin, name RETURNING id, email, password_hash, name, is_admin, created_at, updated_at
` `
type InsertUserParams struct { type InsertUserParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
Name string `json:"name"` Name string `json:"name"`
@ -183,10 +183,10 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.Name,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsAdmin,
&i.Name,
) )
return i, err return i, err
} }
@ -194,11 +194,11 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
const insertUserOAuth = `-- name: InsertUserOAuth :one const insertUserOAuth = `-- name: InsertUserOAuth :one
INSERT INTO users (id, email, name) INSERT INTO users (id, email, name)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, email, password_hash, created_at, updated_at, is_admin, name RETURNING id, email, password_hash, name, is_admin, created_at, updated_at
` `
type InsertUserOAuthParams struct { type InsertUserOAuthParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
} }
@ -210,10 +210,10 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.Name,
&i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsAdmin,
&i.Name,
) )
return i, err return i, err
} }
@ -223,7 +223,7 @@ SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10
` `
type SearchUsersByEmailPrefixRow struct { type SearchUsersByEmailPrefixRow struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
} }
@ -252,7 +252,7 @@ UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1
` `
type SetUserAdminParams struct { type SetUserAdminParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
} }
@ -266,7 +266,7 @@ UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1
` `
type UpdateUserNameParams struct { type UpdateUserNameParams struct {
ID string `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }

View File

@ -4,8 +4,114 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
// --- Generation ---
// newUUID returns a new random (v4) UUID wrapped in pgtype.UUID for direct DB use.
func newUUID() pgtype.UUID {
return pgtype.UUID{Bytes: uuid.New(), Valid: true}
}
func NewSandboxID() pgtype.UUID { return newUUID() }
func NewUserID() pgtype.UUID { return newUUID() }
func NewTeamID() pgtype.UUID { return newUUID() }
func NewAPIKeyID() pgtype.UUID { return newUUID() }
func NewHostID() pgtype.UUID { return newUUID() }
func NewHostTokenID() pgtype.UUID { return newUUID() }
func NewRefreshTokenID() pgtype.UUID { return newUUID() }
func NewAuditLogID() pgtype.UUID { return newUUID() }
func NewBuildID() pgtype.UUID { return newUUID() }
func NewAdminPermissionID() pgtype.UUID { return newUUID() }
// NewSnapshotName generates a snapshot name: "template-" + 8 hex chars.
// Templates use TEXT primary keys (not UUID), so this stays as a string.
func NewSnapshotName() string {
return "template-" + hex8()
}
// NewTeamSlug generates a unique team slug in the format "xxxxxx-yyyyyy".
func NewTeamSlug() string {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err))
}
return hex.EncodeToString(b[:3]) + "-" + hex.EncodeToString(b[3:])
}
// NewRegistrationToken generates a 64-char hex token (32 bytes of entropy).
func NewRegistrationToken() string {
return hexToken(32)
}
// NewRefreshToken generates a 64-char hex token (32 bytes of entropy).
func NewRefreshToken() string {
return hexToken(32)
}
// --- Formatting (pgtype.UUID → prefixed string for API/RPC output) ---
const (
PrefixSandbox = "sb-"
PrefixUser = "usr-"
PrefixTeam = "team-"
PrefixAPIKey = "key-"
PrefixHost = "host-"
PrefixHostToken = "htok-"
PrefixRefreshToken = "hrt-"
PrefixAuditLog = "log-"
PrefixBuild = "bld-"
PrefixAdminPermission = "perm-"
)
func formatUUID(prefix string, id pgtype.UUID) string {
return prefix + uuid.UUID(id.Bytes).String()
}
func FormatSandboxID(id pgtype.UUID) string { return formatUUID(PrefixSandbox, id) }
func FormatUserID(id pgtype.UUID) string { return formatUUID(PrefixUser, id) }
func FormatTeamID(id pgtype.UUID) string { return formatUUID(PrefixTeam, id) }
func FormatAPIKeyID(id pgtype.UUID) string { return formatUUID(PrefixAPIKey, id) }
func FormatHostID(id pgtype.UUID) string { return formatUUID(PrefixHost, id) }
func FormatHostTokenID(id pgtype.UUID) string { return formatUUID(PrefixHostToken, id) }
func FormatRefreshTokenID(id pgtype.UUID) string { return formatUUID(PrefixRefreshToken, id) }
func FormatAuditLogID(id pgtype.UUID) string { return formatUUID(PrefixAuditLog, id) }
func FormatBuildID(id pgtype.UUID) string { return formatUUID(PrefixBuild, id) }
// --- Parsing (prefixed string from API/RPC input → pgtype.UUID) ---
func parseUUID(prefix, s string) (pgtype.UUID, error) {
if !strings.HasPrefix(s, prefix) {
return pgtype.UUID{}, fmt.Errorf("invalid ID: expected %q prefix, got %q", prefix, s)
}
u, err := uuid.Parse(strings.TrimPrefix(s, prefix))
if err != nil {
return pgtype.UUID{}, fmt.Errorf("invalid ID %q: %w", s, err)
}
return pgtype.UUID{Bytes: u, Valid: true}, nil
}
func ParseSandboxID(s string) (pgtype.UUID, error) { return parseUUID(PrefixSandbox, s) }
func ParseUserID(s string) (pgtype.UUID, error) { return parseUUID(PrefixUser, s) }
func ParseTeamID(s string) (pgtype.UUID, error) { return parseUUID(PrefixTeam, s) }
func ParseAPIKeyID(s string) (pgtype.UUID, error) { return parseUUID(PrefixAPIKey, s) }
func ParseHostID(s string) (pgtype.UUID, error) { return parseUUID(PrefixHost, s) }
func ParseHostTokenID(s string) (pgtype.UUID, error) { return parseUUID(PrefixHostToken, s) }
func ParseAuditLogID(s string) (pgtype.UUID, error) { return parseUUID(PrefixAuditLog, s) }
func ParseBuildID(s string) (pgtype.UUID, error) { return parseUUID(PrefixBuild, s) }
// --- Well-known IDs ---
// PlatformTeamID is the all-zeros UUID reserved for platform-owned resources
// (e.g. base templates, shared infrastructure).
var PlatformTeamID = pgtype.UUID{Bytes: [16]byte{}, Valid: true}
// --- Helpers ---
func hex8() string { func hex8() string {
b := make([]byte, 4) b := make([]byte, 4)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
@ -14,78 +120,8 @@ func hex8() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// NewSandboxID generates a new sandbox ID in the format "sb-" + 8 hex chars. func hexToken(nBytes int) string {
func NewSandboxID() string { b := make([]byte, nBytes)
return "sb-" + hex8()
}
// NewSnapshotName generates a snapshot name in the format "template-" + 8 hex chars.
func NewSnapshotName() string {
return "template-" + hex8()
}
// NewUserID generates a new user ID in the format "usr-" + 8 hex chars.
func NewUserID() string {
return "usr-" + hex8()
}
// NewTeamID generates a new team ID in the format "team-" + 8 hex chars.
func NewTeamID() string {
return "team-" + hex8()
}
// NewTeamSlug generates a unique team slug in the format "xxxxxx-yyyyyy"
// where each part is 3 random bytes encoded as hex (6 hex chars each).
func NewTeamSlug() string {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err))
}
return hex.EncodeToString(b[:3]) + "-" + hex.EncodeToString(b[3:])
}
// NewAPIKeyID generates a new API key ID in the format "key-" + 8 hex chars.
func NewAPIKeyID() string {
return "key-" + hex8()
}
// NewHostID generates a new host ID in the format "host-" + 8 hex chars.
func NewHostID() string {
return "host-" + hex8()
}
// NewHostTokenID generates a new host token audit ID in the format "htok-" + 8 hex chars.
func NewHostTokenID() string {
return "htok-" + hex8()
}
// NewRegistrationToken generates a 64-char hex token (32 bytes of entropy).
func NewRegistrationToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err))
}
return hex.EncodeToString(b)
}
// NewRefreshTokenID generates a new refresh token record ID in the format "hrt-" + 8 hex chars.
func NewRefreshTokenID() string {
return "hrt-" + hex8()
}
// NewAuditLogID generates a new audit log ID in the format "log-" + 8 hex chars.
func NewAuditLogID() string {
return "log-" + hex8()
}
// NewBuildID generates a new template build ID in the format "bld-" + 8 hex chars.
func NewBuildID() string {
return "bld-" + hex8()
}
// NewRefreshToken generates a 64-char hex token (32 bytes of entropy) for use as a host refresh token.
func NewRefreshToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err)) panic(fmt.Sprintf("crypto/rand failed: %v", err))
} }

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
) )
@ -53,10 +54,10 @@ func (p *HostClientPool) Get(hostID, address string) hostagentv1connect.HostAgen
// GetForHost is a convenience wrapper that extracts the address from a db.Host // GetForHost is a convenience wrapper that extracts the address from a db.Host
// and returns an error if the host has no address recorded yet. // and returns an error if the host has no address recorded yet.
func (p *HostClientPool) GetForHost(h db.Host) (hostagentv1connect.HostAgentServiceClient, error) { func (p *HostClientPool) GetForHost(h db.Host) (hostagentv1connect.HostAgentServiceClient, error) {
if !h.Address.Valid || h.Address.String == "" { if h.Address == "" {
return nil, fmt.Errorf("host %s has no address", h.ID) return nil, fmt.Errorf("host %s has no address", id.FormatHostID(h.ID))
} }
return p.Get(h.ID, h.Address.String), nil return p.Get(id.FormatHostID(h.ID), h.Address), nil
} }
// Evict removes the cached client for the given host, forcing a new client to be // Evict removes the cached client for the given host, forcing a new client to be

View File

@ -96,7 +96,7 @@ func New(cfg Config) *Manager {
// If sandboxID is empty, a new ID is generated. // If sandboxID is empty, a new ID is generated.
func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus, memoryMB, timeoutSec int) (*models.Sandbox, error) { func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus, memoryMB, timeoutSec int) (*models.Sandbox, error) {
if sandboxID == "" { if sandboxID == "" {
sandboxID = id.NewSandboxID() sandboxID = id.FormatSandboxID(id.NewSandboxID())
} }
if vcpus <= 0 { if vcpus <= 0 {

View File

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"sync/atomic" "sync/atomic"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
) )
@ -15,7 +17,7 @@ type HostScheduler interface {
// For BYOC teams (isByoc=true), only online BYOC hosts belonging to teamID // For BYOC teams (isByoc=true), only online BYOC hosts belonging to teamID
// are considered. For non-BYOC teams, only online regular (platform) hosts // are considered. For non-BYOC teams, only online regular (platform) hosts
// are considered. Returns an error if no suitable host is available. // are considered. Returns an error if no suitable host is available.
SelectHost(ctx context.Context, teamID string, isByoc bool) (db.Host, error) SelectHost(ctx context.Context, teamID pgtype.UUID, isByoc bool) (db.Host, error)
} }
// RoundRobinScheduler cycles through eligible online hosts in round-robin order. // RoundRobinScheduler cycles through eligible online hosts in round-robin order.
@ -32,7 +34,7 @@ func NewRoundRobinScheduler(queries *db.Queries) *RoundRobinScheduler {
} }
// SelectHost returns the next eligible online host in round-robin order. // SelectHost returns the next eligible online host in round-robin order.
func (s *RoundRobinScheduler) SelectHost(ctx context.Context, teamID string, isByoc bool) (db.Host, error) { func (s *RoundRobinScheduler) SelectHost(ctx context.Context, teamID pgtype.UUID, isByoc bool) (db.Host, error) {
hosts, err := s.db.ListActiveHosts(ctx) hosts, err := s.db.ListActiveHosts(ctx)
if err != nil { if err != nil {
return db.Host{}, fmt.Errorf("list hosts: %w", err) return db.Host{}, fmt.Errorf("list hosts: %w", err)
@ -40,12 +42,12 @@ func (s *RoundRobinScheduler) SelectHost(ctx context.Context, teamID string, isB
var eligible []db.Host var eligible []db.Host
for _, h := range hosts { for _, h := range hosts {
if h.Status != "online" || !h.Address.Valid || h.Address.String == "" { if h.Status != "online" || h.Address == "" {
continue continue
} }
if isByoc { if isByoc {
// BYOC team: only use hosts belonging to this team. // BYOC team: only use hosts belonging to this team.
if h.Type != "byoc" || !h.TeamID.Valid || h.TeamID.String != teamID { if h.Type != "byoc" || !h.TeamID.Valid || h.TeamID != teamID {
continue continue
} }
} else { } else {

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/id"
@ -22,7 +24,7 @@ type APIKeyCreateResult struct {
} }
// Create generates a new API key for the given team. // Create generates a new API key for the given team.
func (s *APIKeyService) Create(ctx context.Context, teamID, userID, name string) (APIKeyCreateResult, error) { func (s *APIKeyService) Create(ctx context.Context, teamID, userID pgtype.UUID, name string) (APIKeyCreateResult, error) {
if name == "" { if name == "" {
name = "Unnamed API Key" name = "Unnamed API Key"
} }
@ -48,16 +50,16 @@ func (s *APIKeyService) Create(ctx context.Context, teamID, userID, name string)
} }
// List returns all API keys belonging to the given team. // List returns all API keys belonging to the given team.
func (s *APIKeyService) List(ctx context.Context, teamID string) ([]db.TeamApiKey, error) { func (s *APIKeyService) List(ctx context.Context, teamID pgtype.UUID) ([]db.TeamApiKey, error) {
return s.DB.ListAPIKeysByTeam(ctx, teamID) return s.DB.ListAPIKeysByTeam(ctx, teamID)
} }
// ListWithCreator returns all API keys for the team, joined with the creator's email. // ListWithCreator returns all API keys for the team, joined with the creator's email.
func (s *APIKeyService) ListWithCreator(ctx context.Context, teamID string) ([]db.ListAPIKeysByTeamWithCreatorRow, error) { func (s *APIKeyService) ListWithCreator(ctx context.Context, teamID pgtype.UUID) ([]db.ListAPIKeysByTeamWithCreatorRow, error) {
return s.DB.ListAPIKeysByTeamWithCreator(ctx, teamID) return s.DB.ListAPIKeysByTeamWithCreator(ctx, teamID)
} }
// Delete removes an API key by ID, scoped to the given team. // Delete removes an API key by ID, scoped to the given team.
func (s *APIKeyService) Delete(ctx context.Context, keyID, teamID string) error { func (s *APIKeyService) Delete(ctx context.Context, keyID, teamID pgtype.UUID) error {
return s.DB.DeleteAPIKey(ctx, db.DeleteAPIKeyParams{ID: keyID, TeamID: teamID}) return s.DB.DeleteAPIKey(ctx, db.DeleteAPIKeyParams{ID: keyID, TeamID: teamID})
} }

View File

@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
) )
const auditMaxLimit = 200 const auditMaxLimit = 200
@ -31,12 +32,12 @@ type AuditEntry struct {
// AuditListParams controls the ListAuditLogs query. // AuditListParams controls the ListAuditLogs query.
type AuditListParams struct { type AuditListParams struct {
TeamID string TeamID pgtype.UUID
AdminScoped bool // true → include admin-scoped events; false → team-scoped only AdminScoped bool // true → include admin-scoped events; false → team-scoped only
ResourceTypes []string // empty = no filter; multiple values = OR match ResourceTypes []string // empty = no filter; multiple values = OR match
Actions []string // empty = no filter; multiple values = OR match Actions []string // empty = no filter; multiple values = OR match
Before time.Time // zero = no cursor (start from latest) Before time.Time // zero = no cursor (start from latest)
BeforeID string // tie-breaker: id of the last item at the Before timestamp; empty = no tie-break BeforeID pgtype.UUID // tie-breaker: id of the last item at the Before timestamp; zero = no tie-break
Limit int // clamped to auditMaxLimit by the handler Limit int // clamped to auditMaxLimit by the handler
} }
@ -94,11 +95,11 @@ func (s *AuditService) List(ctx context.Context, p AuditListParams) ([]AuditEntr
_ = json.Unmarshal(row.Metadata, &meta) _ = json.Unmarshal(row.Metadata, &meta)
} }
entries[i] = AuditEntry{ entries[i] = AuditEntry{
ID: row.ID, ID: id.FormatAuditLogID(row.ID),
TeamID: row.TeamID, TeamID: id.FormatTeamID(row.TeamID),
ActorType: row.ActorType, ActorType: row.ActorType,
ActorID: row.ActorID.String, ActorID: row.ActorID.String,
ActorName: row.ActorName.String, ActorName: row.ActorName,
ResourceType: row.ResourceType, ResourceType: row.ResourceType,
ResourceID: row.ResourceID.String, ResourceID: row.ResourceID.String,
Action: row.Action, Action: row.Action,

View File

@ -23,7 +23,6 @@ const (
buildCommandTimeout = 30 * time.Second buildCommandTimeout = 30 * time.Second
healthcheckInterval = 1 * time.Second healthcheckInterval = 1 * time.Second
healthcheckTimeout = 60 * time.Second healthcheckTimeout = 60 * time.Second
platformTeamID = "platform"
) )
// buildAgentClient is the subset of the host agent client used by the build worker. // buildAgentClient is the subset of the host agent client used by the build worker.
@ -82,13 +81,14 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp
} }
buildID := id.NewBuildID() buildID := id.NewBuildID()
buildIDStr := id.FormatBuildID(buildID)
build, err := s.DB.InsertTemplateBuild(ctx, db.InsertTemplateBuildParams{ build, err := s.DB.InsertTemplateBuild(ctx, db.InsertTemplateBuildParams{
ID: buildID, ID: buildID,
Name: p.Name, Name: p.Name,
BaseTemplate: p.BaseTemplate, BaseTemplate: p.BaseTemplate,
Recipe: recipeJSON, Recipe: recipeJSON,
Healthcheck: pgtype.Text{String: p.Healthcheck, Valid: p.Healthcheck != ""}, Healthcheck: p.Healthcheck,
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
TotalSteps: int32(len(p.Recipe)), TotalSteps: int32(len(p.Recipe)),
@ -97,8 +97,8 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp
return db.TemplateBuild{}, fmt.Errorf("insert build: %w", err) return db.TemplateBuild{}, fmt.Errorf("insert build: %w", err)
} }
// Enqueue build ID to Redis for workers to pick up. // Enqueue build ID (as formatted string) to Redis for workers to pick up.
if err := s.Redis.RPush(ctx, buildQueueKey, buildID).Err(); err != nil { if err := s.Redis.RPush(ctx, buildQueueKey, buildIDStr).Err(); err != nil {
return db.TemplateBuild{}, fmt.Errorf("enqueue build: %w", err) return db.TemplateBuild{}, fmt.Errorf("enqueue build: %w", err)
} }
@ -106,7 +106,7 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp
} }
// Get returns a single build by ID. // Get returns a single build by ID.
func (s *BuildService) Get(ctx context.Context, buildID string) (db.TemplateBuild, error) { func (s *BuildService) Get(ctx context.Context, buildID pgtype.UUID) (db.TemplateBuild, error) {
return s.DB.GetTemplateBuild(ctx, buildID) return s.DB.GetTemplateBuild(ctx, buildID)
} }
@ -140,15 +140,21 @@ func (s *BuildService) worker(ctx context.Context, workerID int) {
time.Sleep(time.Second) time.Sleep(time.Second)
continue continue
} }
// result[0] is the key, result[1] is the build ID. // result[0] is the key, result[1] is the build ID (formatted string).
buildID := result[1] buildIDStr := result[1]
log.Info("picked up build", "build_id", buildID) log.Info("picked up build", "build_id", buildIDStr)
s.executeBuild(ctx, buildID) s.executeBuild(ctx, buildIDStr)
} }
} }
func (s *BuildService) executeBuild(ctx context.Context, buildID string) { func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
log := slog.With("build_id", buildID) log := slog.With("build_id", buildIDStr)
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
log.Error("invalid build ID from queue", "error", err)
return
}
build, err := s.DB.GetTemplateBuild(ctx, buildID) build, err := s.DB.GetTemplateBuild(ctx, buildID)
if err != nil { if err != nil {
@ -172,7 +178,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
} }
// Pick a platform host and create a sandbox. // Pick a platform host and create a sandbox.
host, err := s.Scheduler.SelectHost(ctx, platformTeamID, false) host, err := s.Scheduler.SelectHost(ctx, id.PlatformTeamID, false)
if err != nil { if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("no host available: %v", err)) s.failBuild(ctx, buildID, fmt.Sprintf("no host available: %v", err))
return return
@ -185,10 +191,11 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
} }
sandboxID := id.NewSandboxID() sandboxID := id.NewSandboxID()
log = log.With("sandbox_id", sandboxID, "host_id", host.ID) sandboxIDStr := id.FormatSandboxID(sandboxID)
log = log.With("sandbox_id", sandboxIDStr, "host_id", id.FormatHostID(host.ID))
resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{ resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Template: build.BaseTemplate, Template: build.BaseTemplate,
Vcpus: build.Vcpus, Vcpus: build.Vcpus,
MemoryMb: build.MemoryMb, MemoryMb: build.MemoryMb,
@ -203,8 +210,8 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
// Record sandbox/host association. // Record sandbox/host association.
_ = s.DB.UpdateBuildSandbox(ctx, db.UpdateBuildSandboxParams{ _ = s.DB.UpdateBuildSandbox(ctx, db.UpdateBuildSandboxParams{
ID: buildID, ID: buildID,
SandboxID: pgtype.Text{String: sandboxID, Valid: true}, SandboxID: sandboxID,
HostID: pgtype.Text{String: host.ID, Valid: true}, HostID: host.ID,
}) })
// Execute recipe commands. // Execute recipe commands.
@ -216,7 +223,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
start := time.Now() start := time.Now()
execResp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{ execResp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Cmd: "/bin/sh", Cmd: "/bin/sh",
Args: []string{"-c", cmd}, Args: []string{"-c", cmd},
TimeoutSec: int32(buildCommandTimeout.Seconds()), TimeoutSec: int32(buildCommandTimeout.Seconds()),
@ -234,7 +241,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
entry.Ok = false entry.Ok = false
logs = append(logs, entry) logs = append(logs, entry)
s.updateLogs(ctx, buildID, i+1, logs) s.updateLogs(ctx, buildID, i+1, logs)
s.destroySandbox(ctx, agent, sandboxID) s.destroySandbox(ctx, agent, sandboxIDStr)
s.failBuild(ctx, buildID, fmt.Sprintf("step %d exec error: %v", i+1, err)) s.failBuild(ctx, buildID, fmt.Sprintf("step %d exec error: %v", i+1, err))
return return
} }
@ -248,7 +255,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
s.updateLogs(ctx, buildID, i+1, logs) s.updateLogs(ctx, buildID, i+1, logs)
if execResp.Msg.ExitCode != 0 { if execResp.Msg.ExitCode != 0 {
s.destroySandbox(ctx, agent, sandboxID) s.destroySandbox(ctx, agent, sandboxIDStr)
s.failBuild(ctx, buildID, fmt.Sprintf("step %d failed with exit code %d", i+1, execResp.Msg.ExitCode)) s.failBuild(ctx, buildID, fmt.Sprintf("step %d failed with exit code %d", i+1, execResp.Msg.ExitCode))
return return
} }
@ -256,10 +263,10 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
// Healthcheck or direct snapshot. // Healthcheck or direct snapshot.
var sizeBytes int64 var sizeBytes int64
if build.Healthcheck.Valid && build.Healthcheck.String != "" { if build.Healthcheck != "" {
log.Info("running healthcheck", "cmd", build.Healthcheck.String) log.Info("running healthcheck", "cmd", build.Healthcheck)
if err := s.waitForHealthcheck(ctx, agent, sandboxID, build.Healthcheck.String); err != nil { if err := s.waitForHealthcheck(ctx, agent, sandboxIDStr, build.Healthcheck); err != nil {
s.destroySandbox(ctx, agent, sandboxID) s.destroySandbox(ctx, agent, sandboxIDStr)
s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", err)) s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", err))
return return
} }
@ -267,11 +274,11 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
// Healthcheck passed → full snapshot (with memory/CPU state). // Healthcheck passed → full snapshot (with memory/CPU state).
log.Info("healthcheck passed, creating snapshot") log.Info("healthcheck passed, creating snapshot")
snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{ snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Name: build.Name, Name: build.Name,
})) }))
if err != nil { if err != nil {
s.destroySandbox(ctx, agent, sandboxID) s.destroySandbox(ctx, agent, sandboxIDStr)
s.failBuild(ctx, buildID, fmt.Sprintf("create snapshot failed: %v", err)) s.failBuild(ctx, buildID, fmt.Sprintf("create snapshot failed: %v", err))
return return
} }
@ -280,11 +287,11 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
// No healthcheck → image-only template (rootfs only). // No healthcheck → image-only template (rootfs only).
log.Info("no healthcheck, flattening rootfs") log.Info("no healthcheck, flattening rootfs")
flatResp, err := agent.FlattenRootfs(ctx, connect.NewRequest(&pb.FlattenRootfsRequest{ flatResp, err := agent.FlattenRootfs(ctx, connect.NewRequest(&pb.FlattenRootfsRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Name: build.Name, Name: build.Name,
})) }))
if err != nil { if err != nil {
s.destroySandbox(ctx, agent, sandboxID) s.destroySandbox(ctx, agent, sandboxIDStr)
s.failBuild(ctx, buildID, fmt.Sprintf("flatten rootfs failed: %v", err)) s.failBuild(ctx, buildID, fmt.Sprintf("flatten rootfs failed: %v", err))
return return
} }
@ -293,17 +300,17 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
// Insert into templates table as a global (platform) template. // Insert into templates table as a global (platform) template.
templateType := "base" templateType := "base"
if build.Healthcheck.Valid && build.Healthcheck.String != "" { if build.Healthcheck != "" {
templateType = "snapshot" templateType = "snapshot"
} }
if _, err := s.DB.InsertTemplate(ctx, db.InsertTemplateParams{ if _, err := s.DB.InsertTemplate(ctx, db.InsertTemplateParams{
Name: build.Name, Name: build.Name,
Type: templateType, Type: templateType,
Vcpus: pgtype.Int4{Int32: build.Vcpus, Valid: true}, Vcpus: build.Vcpus,
MemoryMb: pgtype.Int4{Int32: build.MemoryMb, Valid: true}, MemoryMb: build.MemoryMb,
SizeBytes: sizeBytes, SizeBytes: sizeBytes,
TeamID: platformTeamID, TeamID: id.PlatformTeamID,
}); err != nil { }); err != nil {
log.Error("failed to insert template record", "error", err) log.Error("failed to insert template record", "error", err)
// Build succeeded on disk, just DB record failed — don't mark as failed. // Build succeeded on disk, just DB record failed — don't mark as failed.
@ -323,7 +330,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) {
log.Info("template build completed successfully", "name", build.Name) log.Info("template build completed successfully", "name", build.Name)
} }
func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentClient, sandboxID, cmd string) error { func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentClient, sandboxIDStr, cmd string) error {
deadline := time.NewTimer(healthcheckTimeout) deadline := time.NewTimer(healthcheckTimeout)
defer deadline.Stop() defer deadline.Stop()
ticker := time.NewTicker(healthcheckInterval) ticker := time.NewTicker(healthcheckInterval)
@ -338,7 +345,7 @@ func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentC
case <-ticker.C: case <-ticker.C:
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second) execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
resp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{ resp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Cmd: "/bin/sh", Cmd: "/bin/sh",
Args: []string{"-c", cmd}, Args: []string{"-c", cmd},
TimeoutSec: 10, TimeoutSec: 10,
@ -357,7 +364,7 @@ func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentC
} }
} }
func (s *BuildService) updateLogs(ctx context.Context, buildID string, step int, logs []BuildLogEntry) { func (s *BuildService) updateLogs(ctx context.Context, buildID pgtype.UUID, step int, logs []BuildLogEntry) {
logsJSON, err := json.Marshal(logs) logsJSON, err := json.Marshal(logs)
if err != nil { if err != nil {
slog.Warn("failed to marshal build logs", "error", err) slog.Warn("failed to marshal build logs", "error", err)
@ -372,26 +379,26 @@ func (s *BuildService) updateLogs(ctx context.Context, buildID string, step int,
} }
} }
func (s *BuildService) failBuild(_ context.Context, buildID, errMsg string) { func (s *BuildService) failBuild(_ context.Context, buildID pgtype.UUID, errMsg string) {
slog.Error("build failed", "build_id", buildID, "error", errMsg) slog.Error("build failed", "build_id", id.FormatBuildID(buildID), "error", errMsg)
// Use a detached context so DB writes survive parent context cancellation (e.g. shutdown). // Use a detached context so DB writes survive parent context cancellation (e.g. shutdown).
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := s.DB.UpdateBuildError(ctx, db.UpdateBuildErrorParams{ if err := s.DB.UpdateBuildError(ctx, db.UpdateBuildErrorParams{
ID: buildID, ID: buildID,
Error: pgtype.Text{String: errMsg, Valid: true}, Error: errMsg,
}); err != nil { }); err != nil {
slog.Error("failed to update build error", "build_id", buildID, "error", err) slog.Error("failed to update build error", "build_id", id.FormatBuildID(buildID), "error", err)
} }
} }
func (s *BuildService) destroySandbox(_ context.Context, agent buildAgentClient, sandboxID string) { func (s *BuildService) destroySandbox(_ context.Context, agent buildAgentClient, sandboxIDStr string) {
// Use a detached context so cleanup succeeds even during shutdown. // Use a detached context so cleanup succeeds even during shutdown.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
})); err != nil { })); err != nil {
slog.Warn("failed to destroy build sandbox", "sandbox_id", sandboxID, "error", err) slog.Warn("failed to destroy build sandbox", "sandbox_id", sandboxIDStr, "error", err)
} }
} }

View File

@ -32,10 +32,10 @@ type HostService struct {
// HostCreateParams holds the parameters for creating a host. // HostCreateParams holds the parameters for creating a host.
type HostCreateParams struct { type HostCreateParams struct {
Type string Type string
TeamID string // required for BYOC, empty for regular TeamID pgtype.UUID // required for BYOC, zero value for regular
Provider string Provider string
AvailabilityZone string AvailabilityZone string
RequestingUserID string RequestingUserID pgtype.UUID
IsRequestorAdmin bool IsRequestorAdmin bool
} }
@ -103,7 +103,7 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
} }
} else { } else {
// BYOC: platform admin, or team owner/admin. // BYOC: platform admin, or team owner/admin.
if p.TeamID == "" { if !p.TeamID.Valid {
return HostCreateResult{}, fmt.Errorf("invalid request: team_id is required for BYOC hosts") return HostCreateResult{}, fmt.Errorf("invalid request: team_id is required for BYOC hosts")
} }
if !p.IsRequestorAdmin { if !p.IsRequestorAdmin {
@ -124,7 +124,7 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
} }
// Validate team exists, is not deleted, and has BYOC enabled. // Validate team exists, is not deleted, and has BYOC enabled.
if p.TeamID != "" { if p.TeamID.Valid {
team, err := s.DB.GetTeam(ctx, p.TeamID) team, err := s.DB.GetTeam(ctx, p.TeamID)
if err != nil || team.DeletedAt.Valid { if err != nil || team.DeletedAt.Valid {
return HostCreateResult{}, fmt.Errorf("invalid request: team not found") return HostCreateResult{}, fmt.Errorf("invalid request: team not found")
@ -136,25 +136,12 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
hostID := id.NewHostID() hostID := id.NewHostID()
var teamID pgtype.Text
if p.TeamID != "" {
teamID = pgtype.Text{String: p.TeamID, Valid: true}
}
var provider pgtype.Text
if p.Provider != "" {
provider = pgtype.Text{String: p.Provider, Valid: true}
}
var az pgtype.Text
if p.AvailabilityZone != "" {
az = pgtype.Text{String: p.AvailabilityZone, Valid: true}
}
host, err := s.DB.InsertHost(ctx, db.InsertHostParams{ host, err := s.DB.InsertHost(ctx, db.InsertHostParams{
ID: hostID, ID: hostID,
Type: p.Type, Type: p.Type,
TeamID: teamID, TeamID: p.TeamID,
Provider: provider, Provider: p.Provider,
AvailabilityZone: az, AvailabilityZone: p.AvailabilityZone,
CreatedBy: p.RequestingUserID, CreatedBy: p.RequestingUserID,
}) })
if err != nil { if err != nil {
@ -166,8 +153,8 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
tokenID := id.NewHostTokenID() tokenID := id.NewHostTokenID()
payload, _ := json.Marshal(regTokenPayload{ payload, _ := json.Marshal(regTokenPayload{
HostID: hostID, HostID: id.FormatHostID(hostID),
TokenID: tokenID, TokenID: id.FormatHostTokenID(tokenID),
}) })
if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil { if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil {
return HostCreateResult{}, fmt.Errorf("store registration token: %w", err) return HostCreateResult{}, fmt.Errorf("store registration token: %w", err)
@ -180,7 +167,7 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
CreatedBy: p.RequestingUserID, CreatedBy: p.RequestingUserID,
ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true}, ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true},
}); err != nil { }); err != nil {
slog.Warn("failed to insert host token audit record", "host_id", hostID, "error", err) slog.Warn("failed to insert host token audit record", "host_id", id.FormatHostID(hostID), "error", err)
} }
return HostCreateResult{Host: host, RegistrationToken: token}, nil return HostCreateResult{Host: host, RegistrationToken: token}, nil
@ -189,7 +176,7 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
// RegenerateToken issues a new registration token for a host still in "pending" // RegenerateToken issues a new registration token for a host still in "pending"
// status. This allows retry when a previous registration attempt failed after // status. This allows retry when a previous registration attempt failed after
// the original token was consumed. // the original token was consumed.
func (s *HostService) RegenerateToken(ctx context.Context, hostID, userID, teamID string, isAdmin bool) (HostCreateResult, error) { func (s *HostService) RegenerateToken(ctx context.Context, hostID, userID, teamID pgtype.UUID, isAdmin bool) (HostCreateResult, error) {
host, err := s.DB.GetHost(ctx, hostID) host, err := s.DB.GetHost(ctx, hostID)
if err != nil { if err != nil {
return HostCreateResult{}, fmt.Errorf("host not found: %w", err) return HostCreateResult{}, fmt.Errorf("host not found: %w", err)
@ -202,7 +189,7 @@ func (s *HostService) RegenerateToken(ctx context.Context, hostID, userID, teamI
if host.Type != "byoc" { if host.Type != "byoc" {
return HostCreateResult{}, fmt.Errorf("forbidden: only admins can manage regular hosts") return HostCreateResult{}, fmt.Errorf("forbidden: only admins can manage regular hosts")
} }
if !host.TeamID.Valid || host.TeamID.String != teamID { if !host.TeamID.Valid || host.TeamID != teamID {
return HostCreateResult{}, fmt.Errorf("forbidden: host does not belong to your team") return HostCreateResult{}, fmt.Errorf("forbidden: host does not belong to your team")
} }
membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{
@ -224,8 +211,8 @@ func (s *HostService) RegenerateToken(ctx context.Context, hostID, userID, teamI
tokenID := id.NewHostTokenID() tokenID := id.NewHostTokenID()
payload, _ := json.Marshal(regTokenPayload{ payload, _ := json.Marshal(regTokenPayload{
HostID: hostID, HostID: id.FormatHostID(hostID),
TokenID: tokenID, TokenID: id.FormatHostTokenID(tokenID),
}) })
if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil { if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil {
return HostCreateResult{}, fmt.Errorf("store registration token: %w", err) return HostCreateResult{}, fmt.Errorf("store registration token: %w", err)
@ -238,7 +225,7 @@ func (s *HostService) RegenerateToken(ctx context.Context, hostID, userID, teamI
CreatedBy: userID, CreatedBy: userID,
ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true}, ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true},
}); err != nil { }); err != nil {
slog.Warn("failed to insert host token audit record", "host_id", hostID, "error", err) slog.Warn("failed to insert host token audit record", "host_id", id.FormatHostID(hostID), "error", err)
} }
return HostCreateResult{Host: host, RegistrationToken: token}, nil return HostCreateResult{Host: host, RegistrationToken: token}, nil
@ -262,24 +249,33 @@ func (s *HostService) Register(ctx context.Context, p HostRegisterParams) (HostR
return HostRegisterResult{}, fmt.Errorf("corrupted registration token") return HostRegisterResult{}, fmt.Errorf("corrupted registration token")
} }
if _, err := s.DB.GetHost(ctx, payload.HostID); err != nil { hostID, err := id.ParseHostID(payload.HostID)
if err != nil {
return HostRegisterResult{}, fmt.Errorf("corrupted registration token: %w", err)
}
tokenID, err := id.ParseHostTokenID(payload.TokenID)
if err != nil {
return HostRegisterResult{}, fmt.Errorf("corrupted registration token: %w", err)
}
if _, err := s.DB.GetHost(ctx, hostID); err != nil {
return HostRegisterResult{}, fmt.Errorf("host not found: %w", err) return HostRegisterResult{}, fmt.Errorf("host not found: %w", err)
} }
// Sign JWT before mutating DB — if signing fails, the host stays pending. // Sign JWT before mutating DB — if signing fails, the host stays pending.
hostJWT, err := auth.SignHostJWT(s.JWT, payload.HostID) hostJWT, err := auth.SignHostJWT(s.JWT, hostID)
if err != nil { if err != nil {
return HostRegisterResult{}, fmt.Errorf("sign host token: %w", err) return HostRegisterResult{}, fmt.Errorf("sign host token: %w", err)
} }
// Atomically update only if still pending (defense-in-depth against races). // Atomically update only if still pending (defense-in-depth against races).
rowsAffected, err := s.DB.RegisterHost(ctx, db.RegisterHostParams{ rowsAffected, err := s.DB.RegisterHost(ctx, db.RegisterHostParams{
ID: payload.HostID, ID: hostID,
Arch: pgtype.Text{String: p.Arch, Valid: p.Arch != ""}, Arch: p.Arch,
CpuCores: pgtype.Int4{Int32: p.CPUCores, Valid: p.CPUCores > 0}, CpuCores: p.CPUCores,
MemoryMb: pgtype.Int4{Int32: p.MemoryMB, Valid: p.MemoryMB > 0}, MemoryMb: p.MemoryMB,
DiskGb: pgtype.Int4{Int32: p.DiskGB, Valid: p.DiskGB > 0}, DiskGb: p.DiskGB,
Address: pgtype.Text{String: p.Address, Valid: p.Address != ""}, Address: p.Address,
}) })
if err != nil { if err != nil {
return HostRegisterResult{}, fmt.Errorf("register host: %w", err) return HostRegisterResult{}, fmt.Errorf("register host: %w", err)
@ -289,18 +285,18 @@ func (s *HostService) Register(ctx context.Context, p HostRegisterParams) (HostR
} }
// Mark audit trail. // Mark audit trail.
if err := s.DB.MarkHostTokenUsed(ctx, payload.TokenID); err != nil { if err := s.DB.MarkHostTokenUsed(ctx, tokenID); err != nil {
slog.Warn("failed to mark host token used", "token_id", payload.TokenID, "error", err) slog.Warn("failed to mark host token used", "token_id", payload.TokenID, "error", err)
} }
// Issue a long-lived refresh token. // Issue a long-lived refresh token.
refreshToken, err := s.issueRefreshToken(ctx, payload.HostID) refreshToken, err := s.issueRefreshToken(ctx, hostID)
if err != nil { if err != nil {
return HostRegisterResult{}, fmt.Errorf("issue refresh token: %w", err) return HostRegisterResult{}, fmt.Errorf("issue refresh token: %w", err)
} }
// Re-fetch the host to get the updated state. // Re-fetch the host to get the updated state.
host, err := s.DB.GetHost(ctx, payload.HostID) host, err := s.DB.GetHost(ctx, hostID)
if err != nil { if err != nil {
return HostRegisterResult{}, fmt.Errorf("fetch updated host: %w", err) return HostRegisterResult{}, fmt.Errorf("fetch updated host: %w", err)
} }
@ -349,7 +345,7 @@ func (s *HostService) Refresh(ctx context.Context, refreshToken string) (HostRef
// issueRefreshToken creates a new refresh token record in the DB and returns // issueRefreshToken creates a new refresh token record in the DB and returns
// the opaque token string. // the opaque token string.
func (s *HostService) issueRefreshToken(ctx context.Context, hostID string) (string, error) { func (s *HostService) issueRefreshToken(ctx context.Context, hostID pgtype.UUID) (string, error) {
token := id.NewRefreshToken() token := id.NewRefreshToken()
hash := hashToken(token) hash := hashToken(token)
now := time.Now() now := time.Now()
@ -375,7 +371,7 @@ func hashToken(token string) string {
// Heartbeat updates the last heartbeat timestamp for a host and transitions // Heartbeat updates the last heartbeat timestamp for a host and transitions
// any 'unreachable' host back to 'online'. Returns a "host not found" error // any 'unreachable' host back to 'online'. Returns a "host not found" error
// (which becomes 404) if the host record no longer exists (e.g., was deleted). // (which becomes 404) if the host record no longer exists (e.g., was deleted).
func (s *HostService) Heartbeat(ctx context.Context, hostID string) error { func (s *HostService) Heartbeat(ctx context.Context, hostID pgtype.UUID) error {
n, err := s.DB.UpdateHostHeartbeatAndStatus(ctx, hostID) n, err := s.DB.UpdateHostHeartbeatAndStatus(ctx, hostID)
if err != nil { if err != nil {
return err return err
@ -388,21 +384,21 @@ func (s *HostService) Heartbeat(ctx context.Context, hostID string) error {
// List returns hosts visible to the caller. // List returns hosts visible to the caller.
// Admins see all hosts; non-admins see only BYOC hosts belonging to their team. // Admins see all hosts; non-admins see only BYOC hosts belonging to their team.
func (s *HostService) List(ctx context.Context, teamID string, isAdmin bool) ([]db.Host, error) { func (s *HostService) List(ctx context.Context, teamID pgtype.UUID, isAdmin bool) ([]db.Host, error) {
if isAdmin { if isAdmin {
return s.DB.ListHosts(ctx) return s.DB.ListHosts(ctx)
} }
return s.DB.ListHostsByTeam(ctx, pgtype.Text{String: teamID, Valid: true}) return s.DB.ListHostsByTeam(ctx, teamID)
} }
// Get returns a single host, enforcing access control. // Get returns a single host, enforcing access control.
func (s *HostService) Get(ctx context.Context, hostID, teamID string, isAdmin bool) (db.Host, error) { func (s *HostService) Get(ctx context.Context, hostID, teamID pgtype.UUID, isAdmin bool) (db.Host, error) {
host, err := s.DB.GetHost(ctx, hostID) host, err := s.DB.GetHost(ctx, hostID)
if err != nil { if err != nil {
return db.Host{}, fmt.Errorf("host not found: %w", err) return db.Host{}, fmt.Errorf("host not found: %w", err)
} }
if !isAdmin { if !isAdmin {
if !host.TeamID.Valid || host.TeamID.String != teamID { if !host.TeamID.Valid || host.TeamID != teamID {
return db.Host{}, fmt.Errorf("host not found") return db.Host{}, fmt.Errorf("host not found")
} }
} }
@ -411,8 +407,8 @@ func (s *HostService) Get(ctx context.Context, hostID, teamID string, isAdmin bo
// DeletePreview returns what would be affected by deleting the host, without // DeletePreview returns what would be affected by deleting the host, without
// making any changes. Use this to show the user a confirmation prompt. // making any changes. Use this to show the user a confirmation prompt.
func (s *HostService) DeletePreview(ctx context.Context, hostID, teamID string, isAdmin bool) (HostDeletePreview, error) { func (s *HostService) DeletePreview(ctx context.Context, hostID, teamID pgtype.UUID, isAdmin bool) (HostDeletePreview, error) {
host, err := s.checkDeletePermission(ctx, hostID, "", teamID, isAdmin) host, err := s.checkDeletePermission(ctx, hostID, pgtype.UUID{}, teamID, isAdmin)
if err != nil { if err != nil {
return HostDeletePreview{}, err return HostDeletePreview{}, err
} }
@ -427,7 +423,7 @@ func (s *HostService) DeletePreview(ctx context.Context, hostID, teamID string,
ids := make([]string, len(sandboxes)) ids := make([]string, len(sandboxes))
for i, sb := range sandboxes { for i, sb := range sandboxes {
ids[i] = sb.ID ids[i] = id.FormatSandboxID(sb.ID)
} }
return HostDeletePreview{Host: host, SandboxIDs: ids}, nil return HostDeletePreview{Host: host, SandboxIDs: ids}, nil
@ -436,7 +432,7 @@ func (s *HostService) DeletePreview(ctx context.Context, hostID, teamID string,
// Delete removes a host. Without force it returns an error listing active // Delete removes a host. Without force it returns an error listing active
// sandboxes so the caller can present a confirmation. With force it gracefully // sandboxes so the caller can present a confirmation. With force it gracefully
// destroys all running sandboxes before deleting the host record. // destroys all running sandboxes before deleting the host record.
func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string, isAdmin, force bool) error { func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID pgtype.UUID, isAdmin, force bool) error {
host, err := s.checkDeletePermission(ctx, hostID, userID, teamID, isAdmin) host, err := s.checkDeletePermission(ctx, hostID, userID, teamID, isAdmin)
if err != nil { if err != nil {
return err return err
@ -453,35 +449,37 @@ func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string,
if len(sandboxes) > 0 && !force { if len(sandboxes) > 0 && !force {
ids := make([]string, len(sandboxes)) ids := make([]string, len(sandboxes))
for i, sb := range sandboxes { for i, sb := range sandboxes {
ids[i] = sb.ID ids[i] = id.FormatSandboxID(sb.ID)
} }
return &HostHasSandboxesError{SandboxIDs: ids} return &HostHasSandboxesError{SandboxIDs: ids}
} }
hostIDStr := id.FormatHostID(hostID)
// Gracefully destroy running sandboxes and terminate the agent (best-effort). // Gracefully destroy running sandboxes and terminate the agent (best-effort).
if host.Address.Valid && host.Address.String != "" { if host.Address != "" {
agent, err := s.Pool.GetForHost(host) agent, err := s.Pool.GetForHost(host)
if err == nil { if err == nil {
for _, sb := range sandboxes { for _, sb := range sandboxes {
if sb.Status == "running" || sb.Status == "starting" { if sb.Status == "running" || sb.Status == "starting" {
_, rpcErr := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ _, rpcErr := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
SandboxId: sb.ID, SandboxId: id.FormatSandboxID(sb.ID),
})) }))
if rpcErr != nil && connect.CodeOf(rpcErr) != connect.CodeNotFound { if rpcErr != nil && connect.CodeOf(rpcErr) != connect.CodeNotFound {
slog.Warn("delete host: failed to destroy sandbox on agent", "sandbox_id", sb.ID, "error", rpcErr) slog.Warn("delete host: failed to destroy sandbox on agent", "sandbox_id", id.FormatSandboxID(sb.ID), "error", rpcErr)
} }
} }
} }
// Tell the agent to shut itself down immediately. // Tell the agent to shut itself down immediately.
if _, rpcErr := agent.Terminate(ctx, connect.NewRequest(&pb.TerminateRequest{})); rpcErr != nil { if _, rpcErr := agent.Terminate(ctx, connect.NewRequest(&pb.TerminateRequest{})); rpcErr != nil {
slog.Warn("delete host: failed to send Terminate to agent", "host_id", hostID, "error", rpcErr) slog.Warn("delete host: failed to send Terminate to agent", "host_id", hostIDStr, "error", rpcErr)
} }
} }
} }
// Mark all affected sandboxes as stopped in DB. // Mark all affected sandboxes as stopped in DB.
if len(sandboxes) > 0 { if len(sandboxes) > 0 {
sbIDs := make([]string, len(sandboxes)) sbIDs := make([]pgtype.UUID, len(sandboxes))
for i, sb := range sandboxes { for i, sb := range sandboxes {
sbIDs[i] = sb.ID sbIDs[i] = sb.ID
} }
@ -489,18 +487,18 @@ func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string,
Column1: sbIDs, Column1: sbIDs,
Status: "stopped", Status: "stopped",
}); err != nil { }); err != nil {
slog.Warn("delete host: failed to mark sandboxes stopped", "host_id", hostID, "error", err) slog.Warn("delete host: failed to mark sandboxes stopped", "host_id", hostIDStr, "error", err)
} }
} }
// Revoke all refresh tokens for this host. // Revoke all refresh tokens for this host.
if err := s.DB.RevokeHostRefreshTokensByHost(ctx, hostID); err != nil { if err := s.DB.RevokeHostRefreshTokensByHost(ctx, hostID); err != nil {
slog.Warn("delete host: failed to revoke refresh tokens", "host_id", hostID, "error", err) slog.Warn("delete host: failed to revoke refresh tokens", "host_id", hostIDStr, "error", err)
} }
// Evict the client from the pool so no further RPCs are sent. // Evict the client from the pool so no further RPCs are sent.
if s.Pool != nil { if s.Pool != nil {
s.Pool.Evict(hostID) s.Pool.Evict(id.FormatHostID(hostID))
} }
return s.DB.DeleteHost(ctx, hostID) return s.DB.DeleteHost(ctx, hostID)
@ -508,7 +506,7 @@ func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string,
// checkDeletePermission verifies the caller has permission to delete the given // checkDeletePermission verifies the caller has permission to delete the given
// host and returns the host record on success. // host and returns the host record on success.
func (s *HostService) checkDeletePermission(ctx context.Context, hostID, userID, teamID string, isAdmin bool) (db.Host, error) { func (s *HostService) checkDeletePermission(ctx context.Context, hostID, userID, teamID pgtype.UUID, isAdmin bool) (db.Host, error) {
host, err := s.DB.GetHost(ctx, hostID) host, err := s.DB.GetHost(ctx, hostID)
if err != nil { if err != nil {
return db.Host{}, fmt.Errorf("host not found: %w", err) return db.Host{}, fmt.Errorf("host not found: %w", err)
@ -521,11 +519,11 @@ func (s *HostService) checkDeletePermission(ctx context.Context, hostID, userID,
if host.Type != "byoc" { if host.Type != "byoc" {
return db.Host{}, fmt.Errorf("forbidden: only admins can delete regular hosts") return db.Host{}, fmt.Errorf("forbidden: only admins can delete regular hosts")
} }
if !host.TeamID.Valid || host.TeamID.String != teamID { if !host.TeamID.Valid || host.TeamID != teamID {
return db.Host{}, fmt.Errorf("forbidden: host does not belong to your team") return db.Host{}, fmt.Errorf("forbidden: host does not belong to your team")
} }
if userID != "" { if userID.Valid {
membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{
UserID: userID, UserID: userID,
TeamID: teamID, TeamID: teamID,
@ -545,7 +543,7 @@ func (s *HostService) checkDeletePermission(ctx context.Context, hostID, userID,
} }
// AddTag adds a tag to a host. // AddTag adds a tag to a host.
func (s *HostService) AddTag(ctx context.Context, hostID, teamID string, isAdmin bool, tag string) error { func (s *HostService) AddTag(ctx context.Context, hostID, teamID pgtype.UUID, isAdmin bool, tag string) error {
if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil { if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil {
return err return err
} }
@ -553,7 +551,7 @@ func (s *HostService) AddTag(ctx context.Context, hostID, teamID string, isAdmin
} }
// RemoveTag removes a tag from a host. // RemoveTag removes a tag from a host.
func (s *HostService) RemoveTag(ctx context.Context, hostID, teamID string, isAdmin bool, tag string) error { func (s *HostService) RemoveTag(ctx context.Context, hostID, teamID pgtype.UUID, isAdmin bool, tag string) error {
if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil { if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil {
return err return err
} }
@ -561,7 +559,7 @@ func (s *HostService) RemoveTag(ctx context.Context, hostID, teamID string, isAd
} }
// ListTags returns all tags for a host. // ListTags returns all tags for a host.
func (s *HostService) ListTags(ctx context.Context, hostID, teamID string, isAdmin bool) ([]string, error) { func (s *HostService) ListTags(ctx context.Context, hostID, teamID pgtype.UUID, isAdmin bool) ([]string, error) {
if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil { if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil {
return nil, err return nil, err
} }

View File

@ -27,7 +27,7 @@ type SandboxService struct {
// SandboxCreateParams holds the parameters for creating a sandbox. // SandboxCreateParams holds the parameters for creating a sandbox.
type SandboxCreateParams struct { type SandboxCreateParams struct {
TeamID string TeamID pgtype.UUID
Template string Template string
VCPUs int32 VCPUs int32
MemoryMB int32 MemoryMB int32
@ -35,7 +35,7 @@ type SandboxCreateParams struct {
} }
// agentForSandbox looks up the host for the given sandbox and returns a client. // agentForSandbox looks up the host for the given sandbox and returns a client.
func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID string) (hostagentClient, db.Sandbox, error) { func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID pgtype.UUID) (hostagentClient, db.Sandbox, error) {
sb, err := s.DB.GetSandbox(ctx, sandboxID) sb, err := s.DB.GetSandbox(ctx, sandboxID)
if err != nil { if err != nil {
return nil, db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err) return nil, db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
@ -80,15 +80,11 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
// If the template is a snapshot, use its baked-in vcpus/memory. // If the template is a snapshot, use its baked-in vcpus/memory.
if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" { if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" {
if tmpl.Vcpus.Valid { p.VCPUs = tmpl.Vcpus
p.VCPUs = tmpl.Vcpus.Int32 p.MemoryMB = tmpl.MemoryMb
}
if tmpl.MemoryMb.Valid {
p.MemoryMB = tmpl.MemoryMb.Int32
}
} }
if p.TeamID == "" { if !p.TeamID.Valid {
return db.Sandbox{}, fmt.Errorf("invalid request: team_id is required") return db.Sandbox{}, fmt.Errorf("invalid request: team_id is required")
} }
@ -110,6 +106,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
} }
sandboxID := id.NewSandboxID() sandboxID := id.NewSandboxID()
sandboxIDStr := id.FormatSandboxID(sandboxID)
if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{ if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{
ID: sandboxID, ID: sandboxID,
@ -125,7 +122,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
} }
resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{ resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
Template: p.Template, Template: p.Template,
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
@ -135,7 +132,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: sandboxID, Status: "error", ID: sandboxID, Status: "error",
}); dbErr != nil { }); dbErr != nil {
slog.Warn("failed to update sandbox status to error", "id", sandboxID, "error", dbErr) slog.Warn("failed to update sandbox status to error", "id", sandboxIDStr, "error", dbErr)
} }
return db.Sandbox{}, fmt.Errorf("agent create: %w", err) return db.Sandbox{}, fmt.Errorf("agent create: %w", err)
} }
@ -158,17 +155,17 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
} }
// List returns active sandboxes (excludes stopped/error) belonging to the given team. // List returns active sandboxes (excludes stopped/error) belonging to the given team.
func (s *SandboxService) List(ctx context.Context, teamID string) ([]db.Sandbox, error) { func (s *SandboxService) List(ctx context.Context, teamID pgtype.UUID) ([]db.Sandbox, error) {
return s.DB.ListSandboxesByTeam(ctx, teamID) return s.DB.ListSandboxesByTeam(ctx, teamID)
} }
// Get returns a single sandbox by ID, scoped to the given team. // Get returns a single sandbox by ID, scoped to the given team.
func (s *SandboxService) Get(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) { func (s *SandboxService) Get(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
return s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) return s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
} }
// Pause snapshots and freezes a running sandbox to disk. // Pause snapshots and freezes a running sandbox to disk.
func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) { func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil { if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err) return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
@ -182,11 +179,13 @@ func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID string) (d
return db.Sandbox{}, err return db.Sandbox{}, err
} }
sandboxIDStr := id.FormatSandboxID(sandboxID)
// Flush all metrics tiers before pausing so data survives in DB. // Flush all metrics tiers before pausing so data survives in DB.
s.flushAndPersistMetrics(ctx, agent, sandboxID, true) s.flushAndPersistMetrics(ctx, agent, sandboxID, true)
if _, err := agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{ if _, err := agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
})); err != nil { })); err != nil {
return db.Sandbox{}, fmt.Errorf("agent pause: %w", err) return db.Sandbox{}, fmt.Errorf("agent pause: %w", err)
} }
@ -201,7 +200,7 @@ func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID string) (d
} }
// Resume restores a paused sandbox from snapshot. // Resume restores a paused sandbox from snapshot.
func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) { func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil { if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err) return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
@ -215,8 +214,10 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID string) (
return db.Sandbox{}, err return db.Sandbox{}, err
} }
sandboxIDStr := id.FormatSandboxID(sandboxID)
resp, err := agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{ resp, err := agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
TimeoutSec: sb.TimeoutSec, TimeoutSec: sb.TimeoutSec,
})) }))
if err != nil { if err != nil {
@ -240,7 +241,7 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID string) (
} }
// Destroy stops a sandbox and marks it as stopped. // Destroy stops a sandbox and marks it as stopped.
func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string) error { func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil { if err != nil {
return fmt.Errorf("sandbox not found: %w", err) return fmt.Errorf("sandbox not found: %w", err)
@ -251,6 +252,8 @@ func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string)
return err return err
} }
sandboxIDStr := id.FormatSandboxID(sandboxID)
// If running, flush 24h tier metrics for analytics before destroying. // If running, flush 24h tier metrics for analytics before destroying.
if sb.Status == "running" { if sb.Status == "running" {
s.flushAndPersistMetrics(ctx, agent, sandboxID, false) s.flushAndPersistMetrics(ctx, agent, sandboxID, false)
@ -258,7 +261,7 @@ func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string)
// Destroy on host agent. A not-found response is fine — sandbox is already gone. // Destroy on host agent. A not-found response is fine — sandbox is already gone.
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
})); err != nil && connect.CodeOf(err) != connect.CodeNotFound { })); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
return fmt.Errorf("agent destroy: %w", err) return fmt.Errorf("agent destroy: %w", err)
} }
@ -284,12 +287,13 @@ func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string)
// flushAndPersistMetrics calls FlushSandboxMetrics on the agent and stores // flushAndPersistMetrics calls FlushSandboxMetrics on the agent and stores
// the returned data to DB. If allTiers is true, all three tiers are saved; // the returned data to DB. If allTiers is true, all three tiers are saved;
// otherwise only the 24h tier (for post-destroy analytics). // otherwise only the 24h tier (for post-destroy analytics).
func (s *SandboxService) flushAndPersistMetrics(ctx context.Context, agent hostagentClient, sandboxID string, allTiers bool) { func (s *SandboxService) flushAndPersistMetrics(ctx context.Context, agent hostagentClient, sandboxID pgtype.UUID, allTiers bool) {
sandboxIDStr := id.FormatSandboxID(sandboxID)
resp, err := agent.FlushSandboxMetrics(ctx, connect.NewRequest(&pb.FlushSandboxMetricsRequest{ resp, err := agent.FlushSandboxMetrics(ctx, connect.NewRequest(&pb.FlushSandboxMetricsRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
})) }))
if err != nil { if err != nil {
slog.Warn("flush metrics failed (best-effort)", "sandbox_id", sandboxID, "error", err) slog.Warn("flush metrics failed (best-effort)", "sandbox_id", sandboxIDStr, "error", err)
return return
} }
msg := resp.Msg msg := resp.Msg
@ -301,7 +305,8 @@ func (s *SandboxService) flushAndPersistMetrics(ctx context.Context, agent hosta
s.persistMetricPoints(ctx, sandboxID, "24h", msg.Points_24H) s.persistMetricPoints(ctx, sandboxID, "24h", msg.Points_24H)
} }
func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID, tier string, points []*pb.MetricPoint) { func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID pgtype.UUID, tier string, points []*pb.MetricPoint) {
sandboxIDStr := id.FormatSandboxID(sandboxID)
for _, p := range points { for _, p := range points {
if err := s.DB.InsertSandboxMetricPoint(ctx, db.InsertSandboxMetricPointParams{ if err := s.DB.InsertSandboxMetricPoint(ctx, db.InsertSandboxMetricPointParams{
SandboxID: sandboxID, SandboxID: sandboxID,
@ -311,13 +316,13 @@ func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID, tie
MemBytes: p.MemBytes, MemBytes: p.MemBytes,
DiskBytes: p.DiskBytes, DiskBytes: p.DiskBytes,
}); err != nil { }); err != nil {
slog.Warn("persist metric point failed", "sandbox_id", sandboxID, "tier", tier, "error", err) slog.Warn("persist metric point failed", "sandbox_id", sandboxIDStr, "tier", tier, "error", err)
} }
} }
} }
// Ping resets the inactivity timer for a running sandbox. // Ping resets the inactivity timer for a running sandbox.
func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) error { func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil { if err != nil {
return fmt.Errorf("sandbox not found: %w", err) return fmt.Errorf("sandbox not found: %w", err)
@ -331,8 +336,10 @@ func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) err
return err return err
} }
sandboxIDStr := id.FormatSandboxID(sandboxID)
if _, err := agent.PingSandbox(ctx, connect.NewRequest(&pb.PingSandboxRequest{ if _, err := agent.PingSandbox(ctx, connect.NewRequest(&pb.PingSandboxRequest{
SandboxId: sandboxID, SandboxId: sandboxIDStr,
})); err != nil { })); err != nil {
return fmt.Errorf("agent ping: %w", err) return fmt.Errorf("agent ping: %w", err)
} }
@ -344,7 +351,7 @@ func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) err
Valid: true, Valid: true,
}, },
}); err != nil { }); err != nil {
slog.Warn("ping: failed to update last_active_at", "sandbox_id", sandboxID, "error", err) slog.Warn("ping: failed to update last_active_at", "sandbox_id", sandboxIDStr, "error", err)
} }
return nil return nil
} }

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
@ -72,7 +73,7 @@ type StatsService struct {
// GetStats returns current stats, 30-day peaks, and a time-series for the // GetStats returns current stats, 30-day peaks, and a time-series for the
// given team and time range. If no snapshots exist yet, zeros are returned. // given team and time range. If no snapshots exist yet, zeros are returned.
func (s *StatsService) GetStats(ctx context.Context, teamID string, r TimeRange) (CurrentStats, PeakStats, []StatPoint, error) { func (s *StatsService) GetStats(ctx context.Context, teamID pgtype.UUID, r TimeRange) (CurrentStats, PeakStats, []StatPoint, error) {
cfg, ok := rangeConfigs[r] cfg, ok := rangeConfigs[r]
if !ok { if !ok {
return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("unknown range: %s", r) return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("unknown range: %s", r)
@ -132,7 +133,7 @@ GROUP BY bucket
ORDER BY bucket ASC ORDER BY bucket ASC
` `
func (s *StatsService) queryTimeSeries(ctx context.Context, teamID string, cfg rangeConfig) ([]StatPoint, error) { func (s *StatsService) queryTimeSeries(ctx context.Context, teamID pgtype.UUID, cfg rangeConfig) ([]StatPoint, error) {
rows, err := s.Pool.Query(ctx, timeSeriesSQL, cfg.bucketSec, teamID, cfg.intervalLiteral) rows, err := s.Pool.Query(ctx, timeSeriesSQL, cfg.bucketSec, teamID, cfg.intervalLiteral)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -9,6 +9,7 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
@ -43,7 +44,7 @@ type MemberInfo struct {
// callerRole fetches the calling user's role in the given team from DB. // callerRole fetches the calling user's role in the given team from DB.
// Returns an error wrapping "forbidden" if the caller is not a member. // Returns an error wrapping "forbidden" if the caller is not a member.
func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID string) (string, error) { func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID pgtype.UUID) (string, error) {
m, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ m, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{
UserID: callerUserID, UserID: callerUserID,
TeamID: teamID, TeamID: teamID,
@ -66,7 +67,7 @@ func requireAdmin(role string) error {
} }
// GetTeam returns the team by ID. Returns an error if the team is deleted or not found. // GetTeam returns the team by ID. Returns an error if the team is deleted or not found.
func (s *TeamService) GetTeam(ctx context.Context, teamID string) (db.Team, error) { func (s *TeamService) GetTeam(ctx context.Context, teamID pgtype.UUID) (db.Team, error) {
team, err := s.DB.GetTeam(ctx, teamID) team, err := s.DB.GetTeam(ctx, teamID)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
@ -81,7 +82,7 @@ func (s *TeamService) GetTeam(ctx context.Context, teamID string) (db.Team, erro
} }
// ListTeamsForUser returns all active teams the user belongs to, with their role in each. // ListTeamsForUser returns all active teams the user belongs to, with their role in each.
func (s *TeamService) ListTeamsForUser(ctx context.Context, userID string) ([]TeamWithRole, error) { func (s *TeamService) ListTeamsForUser(ctx context.Context, userID pgtype.UUID) ([]TeamWithRole, error) {
rows, err := s.DB.GetTeamsForUser(ctx, userID) rows, err := s.DB.GetTeamsForUser(ctx, userID)
if err != nil { if err != nil {
return nil, fmt.Errorf("list teams: %w", err) return nil, fmt.Errorf("list teams: %w", err)
@ -97,7 +98,7 @@ func (s *TeamService) ListTeamsForUser(ctx context.Context, userID string) ([]Te
} }
// CreateTeam creates a new team owned by the given user. // CreateTeam creates a new team owned by the given user.
func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID, name string) (TeamWithRole, error) { func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID pgtype.UUID, name string) (TeamWithRole, error) {
if !teamNameRE.MatchString(name) { if !teamNameRE.MatchString(name) {
return TeamWithRole{}, fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _") return TeamWithRole{}, fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
} }
@ -137,7 +138,7 @@ func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID, name string)
} }
// RenameTeam updates the team name. Caller must be admin or owner (verified from DB). // RenameTeam updates the team name. Caller must be admin or owner (verified from DB).
func (s *TeamService) RenameTeam(ctx context.Context, teamID, callerUserID, newName string) error { func (s *TeamService) RenameTeam(ctx context.Context, teamID, callerUserID pgtype.UUID, newName string) error {
if !teamNameRE.MatchString(newName) { if !teamNameRE.MatchString(newName) {
return fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _") return fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
} }
@ -159,7 +160,7 @@ func (s *TeamService) RenameTeam(ctx context.Context, teamID, callerUserID, newN
// DeleteTeam soft-deletes the team and destroys all running/paused/starting sandboxes. // DeleteTeam soft-deletes the team and destroys all running/paused/starting sandboxes.
// Caller must be owner (verified from DB). All DB records (sandboxes, keys, templates) // Caller must be owner (verified from DB). All DB records (sandboxes, keys, templates)
// are preserved; only the team's deleted_at is set and active VMs are stopped. // are preserved; only the team's deleted_at is set and active VMs are stopped.
func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID string) error { func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtype.UUID) error {
role, err := s.callerRole(ctx, teamID, callerUserID) role, err := s.callerRole(ctx, teamID, callerUserID)
if err != nil { if err != nil {
return err return err
@ -174,16 +175,16 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID strin
return fmt.Errorf("list active sandboxes: %w", err) return fmt.Errorf("list active sandboxes: %w", err)
} }
var stopIDs []string var stopIDs []pgtype.UUID
for _, sb := range sandboxes { for _, sb := range sandboxes {
host, hostErr := s.DB.GetHost(ctx, sb.HostID) host, hostErr := s.DB.GetHost(ctx, sb.HostID)
if hostErr == nil { if hostErr == nil {
agent, agentErr := s.HostPool.GetForHost(host) agent, agentErr := s.HostPool.GetForHost(host)
if agentErr == nil { if agentErr == nil {
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
SandboxId: sb.ID, SandboxId: id.FormatSandboxID(sb.ID),
})); err != nil && connect.CodeOf(err) != connect.CodeNotFound { })); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
slog.Warn("team delete: failed to destroy sandbox", "sandbox_id", sb.ID, "error", err) slog.Warn("team delete: failed to destroy sandbox", "sandbox_id", id.FormatSandboxID(sb.ID), "error", err)
} }
} }
} }
@ -208,7 +209,7 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID strin
} }
// GetMembers returns all members of the team with their emails and roles. // GetMembers returns all members of the team with their emails and roles.
func (s *TeamService) GetMembers(ctx context.Context, teamID string) ([]MemberInfo, error) { func (s *TeamService) GetMembers(ctx context.Context, teamID pgtype.UUID) ([]MemberInfo, error) {
rows, err := s.DB.GetTeamMembers(ctx, teamID) rows, err := s.DB.GetTeamMembers(ctx, teamID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get members: %w", err) return nil, fmt.Errorf("get members: %w", err)
@ -220,7 +221,7 @@ func (s *TeamService) GetMembers(ctx context.Context, teamID string) ([]MemberIn
joinedAt = r.JoinedAt.Time joinedAt = r.JoinedAt.Time
} }
members[i] = MemberInfo{ members[i] = MemberInfo{
UserID: r.ID, UserID: id.FormatUserID(r.ID),
Name: r.Name, Name: r.Name,
Email: r.Email, Email: r.Email,
Role: r.Role, Role: r.Role,
@ -232,7 +233,7 @@ func (s *TeamService) GetMembers(ctx context.Context, teamID string) ([]MemberIn
// AddMember adds an existing user (looked up by email) to the team as a member. // AddMember adds an existing user (looked up by email) to the team as a member.
// Caller must be admin or owner (verified from DB). // Caller must be admin or owner (verified from DB).
func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID, email string) (MemberInfo, error) { func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID pgtype.UUID, email string) (MemberInfo, error) {
role, err := s.callerRole(ctx, teamID, callerUserID) role, err := s.callerRole(ctx, teamID, callerUserID)
if err != nil { if err != nil {
return MemberInfo{}, err return MemberInfo{}, err
@ -269,12 +270,12 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID, email
return MemberInfo{}, fmt.Errorf("insert member: %w", err) return MemberInfo{}, fmt.Errorf("insert member: %w", err)
} }
return MemberInfo{UserID: target.ID, Name: target.Name, Email: target.Email, Role: "member"}, nil return MemberInfo{UserID: id.FormatUserID(target.ID), Name: target.Name, Email: target.Email, Role: "member"}, nil
} }
// RemoveMember removes a user from the team. // RemoveMember removes a user from the team.
// Caller must be admin or owner (verified from DB). Owner cannot be removed. // Caller must be admin or owner (verified from DB). Owner cannot be removed.
func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, targetUserID string) error { func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, targetUserID pgtype.UUID) error {
callerRole, err := s.callerRole(ctx, teamID, callerUserID) callerRole, err := s.callerRole(ctx, teamID, callerUserID)
if err != nil { if err != nil {
return err return err
@ -310,7 +311,7 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
// UpdateMemberRole changes a member's role to admin or member. // UpdateMemberRole changes a member's role to admin or member.
// Caller must be admin or owner (verified from DB). Owner's role cannot be changed. // Caller must be admin or owner (verified from DB). Owner's role cannot be changed.
// Valid target roles: "admin", "member". // Valid target roles: "admin", "member".
func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID, targetUserID, newRole string) error { func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID, targetUserID pgtype.UUID, newRole string) error {
if newRole != "admin" && newRole != "member" { if newRole != "admin" && newRole != "member" {
return fmt.Errorf("invalid: role must be admin or member") return fmt.Errorf("invalid: role must be admin or member")
} }
@ -350,7 +351,7 @@ func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID
// LeaveTeam removes the calling user from the team. // LeaveTeam removes the calling user from the team.
// The owner cannot leave; they must delete the team instead. // The owner cannot leave; they must delete the team instead.
func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID string) error { func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype.UUID) error {
role, err := s.callerRole(ctx, teamID, callerUserID) role, err := s.callerRole(ctx, teamID, callerUserID)
if err != nil { if err != nil {
return err return err
@ -371,7 +372,7 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID string
// SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot // SetBYOC enables the BYOC feature flag for a team. Once enabled, BYOC cannot
// be disabled — it is a one-way transition. // be disabled — it is a one-way transition.
// Admin-only — the caller must verify admin status before invoking this. // Admin-only — the caller must verify admin status before invoking this.
func (s *TeamService) SetBYOC(ctx context.Context, teamID string, enabled bool) error { func (s *TeamService) SetBYOC(ctx context.Context, teamID pgtype.UUID, enabled bool) error {
team, err := s.DB.GetTeam(ctx, teamID) team, err := s.DB.GetTeam(ctx, teamID)
if err != nil { if err != nil {
return fmt.Errorf("team not found: %w", err) return fmt.Errorf("team not found: %w", err)

View File

@ -3,6 +3,8 @@ package service
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
) )
@ -14,7 +16,7 @@ type TemplateService struct {
// List returns all templates belonging to the given team. If typeFilter is // List returns all templates belonging to the given team. If typeFilter is
// non-empty, only templates of that type ("base" or "snapshot") are returned. // non-empty, only templates of that type ("base" or "snapshot") are returned.
func (s *TemplateService) List(ctx context.Context, teamID, typeFilter string) ([]db.Template, error) { func (s *TemplateService) List(ctx context.Context, teamID pgtype.UUID, typeFilter string) ([]db.Template, error) {
if typeFilter != "" { if typeFilter != "" {
return s.DB.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{ return s.DB.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{
TeamID: teamID, TeamID: teamID,