diff --git a/db/migrations/20260310094104_initial.sql b/db/migrations/20260310094104_initial.sql index c291815..be5d29f 100644 --- a/db/migrations/20260310094104_initial.sql +++ b/db/migrations/20260310094104_initial.sql @@ -1,25 +1,236 @@ -- +goose Up -CREATE TABLE sandboxes ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL DEFAULT '', - host_id TEXT NOT NULL DEFAULT 'default', - template TEXT NOT NULL DEFAULT 'minimal', - status TEXT NOT NULL DEFAULT 'pending', - vcpus INTEGER NOT NULL DEFAULT 1, - memory_mb INTEGER NOT NULL DEFAULT 512, - timeout_sec INTEGER NOT NULL DEFAULT 0, - guest_ip TEXT NOT NULL DEFAULT '', - host_ip TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - started_at TIMESTAMPTZ, - last_active_at TIMESTAMPTZ, - last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() +-- 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 ( + id UUID PRIMARY KEY, + team_id UUID NOT NULL REFERENCES teams(id), + host_id UUID NOT NULL, + template TEXT NOT NULL DEFAULT 'minimal', + status TEXT NOT NULL DEFAULT 'pending', + vcpus INTEGER NOT NULL DEFAULT 1, + memory_mb INTEGER NOT NULL DEFAULT 512, + timeout_sec INTEGER NOT NULL DEFAULT 300, + guest_ip TEXT NOT NULL DEFAULT '', + host_ip TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + last_active_at TIMESTAMPTZ, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() +); CREATE INDEX idx_sandboxes_status ON sandboxes(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 - -DROP TABLE sandboxes; +DROP TABLE IF EXISTS template_builds; +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; diff --git a/db/migrations/20260311224925_snapshots.sql b/db/migrations/20260311224925_snapshots.sql deleted file mode 100644 index 8a0427c..0000000 --- a/db/migrations/20260311224925_snapshots.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260313210608_auth.sql b/db/migrations/20260313210608_auth.sql deleted file mode 100644 index 03970a8..0000000 --- a/db/migrations/20260313210608_auth.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260313210611_team_ownership.sql b/db/migrations/20260313210611_team_ownership.sql deleted file mode 100644 index 849e781..0000000 --- a/db/migrations/20260313210611_team_ownership.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260315001514_oauth.sql b/db/migrations/20260315001514_oauth.sql deleted file mode 100644 index c3c33e9..0000000 --- a/db/migrations/20260315001514_oauth.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260316203135_admin_users.sql b/db/migrations/20260316203135_admin_users.sql deleted file mode 100644 index eff669b..0000000 --- a/db/migrations/20260316203135_admin_users.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260316203138_byoc_teams.sql b/db/migrations/20260316203138_byoc_teams.sql deleted file mode 100644 index bb2c8ec..0000000 --- a/db/migrations/20260316203138_byoc_teams.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260316203142_hosts.sql b/db/migrations/20260316203142_hosts.sql deleted file mode 100644 index 372b380..0000000 --- a/db/migrations/20260316203142_hosts.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260316223629_host_mtls.sql b/db/migrations/20260316223629_host_mtls.sql deleted file mode 100644 index f56b923..0000000 --- a/db/migrations/20260316223629_host_mtls.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260324071453_team_management.sql b/db/migrations/20260324071453_team_management.sql deleted file mode 100644 index 1495d6d..0000000 --- a/db/migrations/20260324071453_team_management.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260324100234_user_names.sql b/db/migrations/20260324100234_user_names.sql deleted file mode 100644 index 2775d12..0000000 --- a/db/migrations/20260324100234_user_names.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''; - --- +goose Down -ALTER TABLE users DROP COLUMN name; diff --git a/db/migrations/20260324120214_host_refresh_tokens.sql b/db/migrations/20260324120214_host_refresh_tokens.sql deleted file mode 100644 index 02a13f7..0000000 --- a/db/migrations/20260324120214_host_refresh_tokens.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260324220743_audit_logs.sql b/db/migrations/20260324220743_audit_logs.sql deleted file mode 100644 index 91b7375..0000000 --- a/db/migrations/20260324220743_audit_logs.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260325074949_metrics_snapshots.sql b/db/migrations/20260325074949_metrics_snapshots.sql deleted file mode 100644 index 7d373e8..0000000 --- a/db/migrations/20260325074949_metrics_snapshots.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260325135035_add_sandbox_metric_points.sql b/db/migrations/20260325135035_add_sandbox_metric_points.sql deleted file mode 100644 index 08e8683..0000000 --- a/db/migrations/20260325135035_add_sandbox_metric_points.sql +++ /dev/null @@ -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; diff --git a/db/migrations/20260326090649_template_builds.sql b/db/migrations/20260326090649_template_builds.sql deleted file mode 100644 index 8e5326d..0000000 --- a/db/migrations/20260326090649_template_builds.sql +++ /dev/null @@ -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; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index 131fe1e..71e61dc 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -50,7 +50,7 @@ WHERE id = $1; UPDATE sandboxes SET status = $2, last_updated = NOW() -WHERE id = ANY($1::text[]); +WHERE id = ANY($1::uuid[]); -- name: ListActiveSandboxesByTeam :many SELECT * FROM sandboxes @@ -72,4 +72,4 @@ WHERE host_id = $1 AND status IN ('running', 'starting', 'pending'); UPDATE sandboxes SET status = 'running', last_updated = NOW() -WHERE id = ANY($1::text[]) AND status = 'missing'; +WHERE id = ANY($1::uuid[]) AND status = 'missing'; diff --git a/envd/internal/api/init.go b/envd/internal/api/init.go index bd2456e..301400c 100644 --- a/envd/internal/api/init.go +++ b/envd/internal/api/init.go @@ -14,12 +14,12 @@ import ( "os/exec" "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/logs" "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" + "github.com/awnumar/memguard" + "github.com/rs/zerolog" + "github.com/txn2/txeh" ) var ( @@ -287,4 +287,3 @@ func getIPFamily(address string) (txeh.IPFamily, error) { return txeh.IPFamilyV4, fmt.Errorf("%w: %s", ErrUnknownAddressFormat, address) } } - diff --git a/internal/api/agent_helper.go b/internal/api/agent_helper.go index ac5b38e..98a881d 100644 --- a/internal/api/agent_helper.go +++ b/internal/api/agent_helper.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" "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. // 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) if err != nil { return nil, fmt.Errorf("host not found: %w", err) diff --git a/internal/api/handler_sandbox_proxy.go b/internal/api/handler_sandbox_proxy.go index 019fd7f..322a559 100644 --- a/internal/api/handler_sandbox_proxy.go +++ b/internal/api/handler_sandbox_proxy.go @@ -10,14 +10,17 @@ import ( "strconv" "strings" + "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" ) // sandboxHostPattern matches hostnames like "49999-sb-abcd1234.localhost" or // "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 // 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] - sandboxID := matches[2] + sandboxIDStr := matches[2] // Validate port. portNum, err := strconv.Atoi(port) @@ -73,6 +76,12 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) return } + sandboxID, err := id.ParseSandboxID(sandboxIDStr) + if err != nil { + http.Error(w, "invalid sandbox ID", http.StatusBadRequest) + return + } + ctx := r.Context() // Look up sandbox and verify ownership. @@ -96,13 +105,13 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - if !agentHost.Address.Valid || agentHost.Address.String == "" { + if agentHost.Address == "" { http.Error(w, "host agent has no address", http.StatusServiceUnavailable) return } - agentAddr := lifecycle.EnsureScheme(agentHost.Address.String) - upstreamPath := fmt.Sprintf("/proxy/%s/%s%s", sandboxID, port, r.URL.Path) + agentAddr := lifecycle.EnsureScheme(agentHost.Address) + upstreamPath := fmt.Sprintf("/proxy/%s/%s%s", sandboxIDStr, port, r.URL.Path) target, err := url.Parse(agentAddr) 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) { slog.Debug("sandbox proxy error", - "sandbox_id", sandboxID, + "sandbox_id", sandboxIDStr, "port", port, "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. // 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") if key == "" { - return "", fmt.Errorf("X-API-Key header required") + return pgtype.UUID{}, fmt.Errorf("X-API-Key header required") } hash := auth.HashAPIKey(key) row, err := h.db.GetAPIKeyByHash(r.Context(), hash) if err != nil { - return "", fmt.Errorf("invalid API key") + return pgtype.UUID{}, fmt.Errorf("invalid API key") } return row.TeamID, nil } diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go index 2637181..700ddc5 100644 --- a/internal/api/handlers_apikeys.go +++ b/internal/api/handlers_apikeys.go @@ -9,6 +9,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/service" ) @@ -39,11 +40,11 @@ type apiKeyResponse struct { func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse { resp := apiKeyResponse{ - ID: k.ID, - TeamID: k.TeamID, + ID: id.FormatAPIKeyID(k.ID), + TeamID: id.FormatTeamID(k.TeamID), Name: k.Name, KeyPrefix: k.KeyPrefix, - CreatedBy: k.CreatedBy, + CreatedBy: id.FormatUserID(k.CreatedBy), } if k.CreatedAt.Valid { resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339) @@ -57,11 +58,11 @@ func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse { func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyResponse { resp := apiKeyResponse{ - ID: k.ID, - TeamID: k.TeamID, + ID: id.FormatAPIKeyID(k.ID), + TeamID: id.FormatTeamID(k.TeamID), Name: k.Name, KeyPrefix: k.KeyPrefix, - CreatedBy: k.CreatedBy, + CreatedBy: id.FormatUserID(k.CreatedBy), CreatorEmail: k.CreatorEmail, } 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}. func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) { 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 { writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key") diff --git a/internal/api/handlers_audit.go b/internal/api/handlers_audit.go index 7812309..a19ab1d 100644 --- a/internal/api/handlers_audit.go +++ b/internal/api/handlers_audit.go @@ -6,7 +6,10 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/service" ) @@ -65,13 +68,24 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) { 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{ TeamID: ac.TeamID, AdminScoped: ac.Role == "owner" || ac.Role == "admin", ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]), Actions: parseMultiParam(r.URL.Query()["action"]), Before: before, - BeforeID: r.URL.Query().Get("before_id"), + BeforeID: beforeID, Limit: limit, }) if err != nil { diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index ba60d8e..b1d4915 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -20,7 +20,7 @@ import ( // 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 // 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) if err == nil { 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{ Token: token, - UserID: userID, - TeamID: teamID, + UserID: id.FormatUserID(userID), + TeamID: id.FormatTeamID(teamID), Email: req.Email, Name: req.Name, }) @@ -236,8 +236,8 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, authResponse{ Token: token, - UserID: user.ID, - TeamID: team.ID, + UserID: id.FormatUserID(user.ID), + TeamID: id.FormatTeamID(team.ID), Email: user.Email, Name: user.Name, }) @@ -260,10 +260,16 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) { return } + teamID, err := id.ParseTeamID(req.TeamID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id") + return + } + ctx := r.Context() // 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 errors.Is(err, pgx.ErrNoRows) { 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. membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{ UserID: ac.UserID, - TeamID: req.TeamID, + TeamID: teamID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -298,7 +304,7 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) { 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 { writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") return @@ -306,8 +312,8 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, authResponse{ Token: token, - UserID: ac.UserID, - TeamID: req.TeamID, + UserID: id.FormatUserID(ac.UserID), + TeamID: id.FormatTeamID(teamID), Email: ac.Email, Name: user.Name, }) diff --git a/internal/api/handlers_builds.go b/internal/api/handlers_builds.go index e62b0c6..f1b3973 100644 --- a/internal/api/handlers_builds.go +++ b/internal/api/handlers_builds.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "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/validate" ) @@ -53,7 +54,7 @@ type buildResponse struct { func buildToResponse(b db.TemplateBuild) buildResponse { resp := buildResponse{ - ID: b.ID, + ID: id.FormatBuildID(b.ID), Name: b.Name, BaseTemplate: b.BaseTemplate, Recipe: b.Recipe, @@ -64,17 +65,19 @@ func buildToResponse(b db.TemplateBuild) buildResponse { TotalSteps: b.TotalSteps, Logs: b.Logs, } - if b.Healthcheck.Valid { - resp.Healthcheck = &b.Healthcheck.String + if b.Healthcheck != "" { + resp.Healthcheck = &b.Healthcheck } - if b.Error.Valid { - resp.Error = &b.Error.String + if b.Error != "" { + resp.Error = &b.Error } if b.SandboxID.Valid { - resp.SandboxID = &b.SandboxID.String + s := id.FormatSandboxID(b.SandboxID) + resp.SandboxID = &s } if b.HostID.Valid { - resp.HostID = &b.HostID.String + s := id.FormatHostID(b.HostID) + resp.HostID = &s } if b.CreatedAt.Valid { 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}. 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) if err != nil { diff --git a/internal/api/handlers_exec.go b/internal/api/handlers_exec.go index 84b3833..596457b 100644 --- a/internal/api/handlers_exec.go +++ b/internal/api/handlers_exec.go @@ -14,6 +14,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" ) @@ -46,10 +47,16 @@ type execResponse struct { // Exec handles POST /v1/sandboxes/{id}/exec. func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { - sandboxID := chi.URLParam(r, "id") + sandboxIDStr := chi.URLParam(r, "id") ctx := r.Context() 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}) if err != nil { 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{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Cmd: req.Cmd, Args: req.Args, TimeoutSec: req.TimeoutSec, @@ -101,7 +108,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { Valid: true, }, }); 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. @@ -112,7 +119,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { if !utf8.Valid(stdout) || !utf8.Valid(stderr) { encoding = "base64" writeJSON(w, http.StatusOK, execResponse{ - SandboxID: sandboxID, + SandboxID: sandboxIDStr, Cmd: req.Cmd, Stdout: base64.StdEncoding.EncodeToString(stdout), Stderr: base64.StdEncoding.EncodeToString(stderr), @@ -124,7 +131,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, execResponse{ - SandboxID: sandboxID, + SandboxID: sandboxIDStr, Cmd: req.Cmd, Stdout: string(stdout), Stderr: string(stderr), diff --git a/internal/api/handlers_exec_stream.go b/internal/api/handlers_exec_stream.go index 3ecfdfe..52dfd17 100644 --- a/internal/api/handlers_exec_stream.go +++ b/internal/api/handlers_exec_stream.go @@ -14,6 +14,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" ) @@ -48,10 +49,16 @@ type wsOutMsg struct { // ExecStream handles WS /v1/sandboxes/{id}/exec/stream. func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) { - sandboxID := chi.URLParam(r, "id") + sandboxIDStr := chi.URLParam(r, "id") ctx := r.Context() 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}) if err != nil { 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() stream, err := agent.ExecStream(streamCtx, connect.NewRequest(&pb.ExecStreamRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Cmd: startMsg.Cmd, Args: startMsg.Args, })) @@ -157,7 +164,7 @@ func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) { Valid: true, }, }); 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) } } diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index c5fff70..a2e9936 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -11,6 +11,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" 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 // - "file" file field: binary content to write func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) { - sandboxID := chi.URLParam(r, "id") + sandboxIDStr := chi.URLParam(r, "id") ctx := r.Context() 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}) if err != nil { 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{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Path: filePath, Content: content, })); err != nil { @@ -101,10 +108,16 @@ type readFileRequest struct { // Download handles POST /v1/sandboxes/{id}/files/read. // Accepts JSON body with path, returns raw file content with Content-Disposition. func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) { - sandboxID := chi.URLParam(r, "id") + sandboxIDStr := chi.URLParam(r, "id") ctx := r.Context() 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}) if err != nil { 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{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Path: req.Path, })) if err != nil { diff --git a/internal/api/handlers_files_stream.go b/internal/api/handlers_files_stream.go index 66e89c7..e6c040f 100644 --- a/internal/api/handlers_files_stream.go +++ b/internal/api/handlers_files_stream.go @@ -12,6 +12,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" 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. // Streams file content directly from the request body to the host agent without buffering. func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) { - sandboxID := chi.URLParam(r, "id") + sandboxIDStr := chi.URLParam(r, "id") ctx := r.Context() 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}) if err != nil { 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{ Content: &pb.WriteFileStreamRequest_Meta{ Meta: &pb.WriteFileStreamMeta{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, 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. // Accepts JSON body with path, streams file content back without buffering. func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) { - sandboxID := chi.URLParam(r, "id") + sandboxIDStr := chi.URLParam(r, "id") ctx := r.Context() 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}) if err != nil { 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. stream, err := agent.ReadFileStream(ctx, connect.NewRequest(&pb.ReadFileStreamRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Path: req.Path, })) if err != nil { diff --git a/internal/api/handlers_hosts.go b/internal/api/handlers_hosts.go index f4f7917..c910c61 100644 --- a/internal/api/handlers_hosts.go +++ b/internal/api/handlers_hosts.go @@ -8,9 +8,12 @@ import ( "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/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/service" ) @@ -93,34 +96,35 @@ type hostResponse struct { func hostToResponse(h db.Host) hostResponse { resp := hostResponse{ - ID: h.ID, + ID: id.FormatHostID(h.ID), Type: h.Type, Status: h.Status, - CreatedBy: h.CreatedBy, + CreatedBy: id.FormatUserID(h.CreatedBy), } if h.TeamID.Valid { - resp.TeamID = &h.TeamID.String + s := id.FormatTeamID(h.TeamID) + resp.TeamID = &s } - if h.Provider.Valid { - resp.Provider = &h.Provider.String + if h.Provider != "" { + resp.Provider = &h.Provider } - if h.AvailabilityZone.Valid { - resp.AvailabilityZone = &h.AvailabilityZone.String + if h.AvailabilityZone != "" { + resp.AvailabilityZone = &h.AvailabilityZone } - if h.Arch.Valid { - resp.Arch = &h.Arch.String + if h.Arch != "" { + resp.Arch = &h.Arch } - if h.CpuCores.Valid { - resp.CPUCores = &h.CpuCores.Int32 + if h.CpuCores != 0 { + resp.CPUCores = &h.CpuCores } - if h.MemoryMb.Valid { - resp.MemoryMB = &h.MemoryMb.Int32 + if h.MemoryMb != 0 { + resp.MemoryMB = &h.MemoryMb } - if h.DiskGb.Valid { - resp.DiskGB = &h.DiskGb.Int32 + if h.DiskGb != 0 { + resp.DiskGB = &h.DiskGb } - if h.Address.Valid { - resp.Address = &h.Address.String + if h.Address != "" { + resp.Address = &h.Address } if h.LastHeartbeatAt.Valid { 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. -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) if err != nil { return false @@ -151,14 +155,23 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) { ac := auth.MustFromContext(r.Context()) - result, err := h.svc.Create(r.Context(), service.HostCreateParams{ - Type: req.Type, - TeamID: req.TeamID, - Provider: req.Provider, - AvailabilityZone: req.AvailabilityZone, - RequestingUserID: ac.UserID, - IsRequestorAdmin: h.isAdmin(r, ac.UserID), - }) + // Parse optional team ID from request body. + var params service.HostCreateParams + params.Type = req.Type + params.Provider = req.Provider + params.AvailabilityZone = req.AvailabilityZone + params.RequestingUserID = 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 { status, code, msg := serviceErrToHTTP(err) 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). - hostTeamID := result.Host.TeamID.String - h.audit.LogHostCreate(r.Context(), ac, result.Host.ID, hostTeamID) + h.audit.LogHostCreate(r.Context(), ac, result.Host.ID, result.Host.TeamID) writeJSON(w, http.StatusCreated, createHostResponse{ Host: hostToResponse(result.Host), @@ -192,14 +204,22 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) { seen := make(map[string]struct{}) for _, host := range hosts { if host.TeamID.Valid { - seen[host.TeamID.String] = struct{}{} + key := id.FormatTeamID(host.TeamID) + seen[key] = struct{}{} } } if len(seen) > 0 { teamNames = make(map[string]string, len(seen)) - for id := range seen { - if team, err := h.queries.GetTeam(r.Context(), id); err == nil { - teamNames[id] = team.Name + for _, host := range hosts { + if !host.TeamID.Valid { + 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 { resp[i] = hostToResponse(host) 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 } } @@ -220,9 +241,15 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) { // Get handles GET /v1/hosts/{id}. 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()) + 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)) if err != nil { 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. // Returns what would be affected without making changes, for confirmation UI. 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()) + 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)) if err != nil { 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. // With ?force=true: gracefully stops all sandboxes then deletes the host. 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()) 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. deletedHost, hostErr := h.queries.GetHost(r.Context(), hostID) 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 { - h.audit.LogHostDelete(r.Context(), ac, hostID, deletedHost.TeamID.String) + h.audit.LogHostDelete(r.Context(), ac, hostID, deletedHost.TeamID) w.WriteHeader(http.StatusNoContent) return } @@ -292,9 +331,15 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) { // RegenerateToken handles POST /v1/hosts/{id}/token. 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()) + 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)) if err != nil { 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). 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()) + 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. if hostID != hc.HostID { 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. 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) @@ -376,10 +427,16 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) { // AddTag handles POST /v1/hosts/{id}/tags. 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()) 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 if err := decodeJSON(r, &req); err != nil { 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}. 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") 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 { status, code, msg := serviceErrToHTTP(err) 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. 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()) + 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)) if err != nil { status, code, msg := serviceErrToHTTP(err) diff --git a/internal/api/handlers_metrics.go b/internal/api/handlers_metrics.go index 793349e..25f485c 100644 --- a/internal/api/handlers_metrics.go +++ b/internal/api/handlers_metrics.go @@ -7,9 +7,11 @@ import ( "connectrpc.com/connect" "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/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" 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. func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) { - sandboxID := chi.URLParam(r, "id") + sandboxIDStr := chi.URLParam(r, "id") ctx := r.Context() 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") if rangeTier == "" { rangeTier = "10m" @@ -60,15 +68,15 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques switch sb.Status { case "running": - h.getFromAgent(w, r, sandboxID, rangeTier, sb.HostID) + h.getFromAgent(w, r, sandboxIDStr, rangeTier, sb.HostID) case "paused": - h.getFromDB(ctx, w, sandboxID, rangeTier) + h.getFromDB(ctx, w, sandboxIDStr, sandboxID, rangeTier) default: 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() 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{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Range: rangeTier, })) if err != nil { @@ -98,7 +106,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ } writeJSON(w, http.StatusOK, metricsResponse{ - SandboxID: sandboxID, + SandboxID: sandboxIDStr, Range: rangeTier, Points: points, }) @@ -118,7 +126,7 @@ var rangeToDB = map[string]struct { "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] rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{ SandboxID: sandboxID, @@ -141,7 +149,7 @@ func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWr } writeJSON(w, http.StatusOK, metricsResponse{ - SandboxID: sandboxID, + SandboxID: sandboxIDStr, Range: rangeTier, Points: points, }) diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index 348dd85..a9c448a 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -162,7 +162,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { redirectWithError(w, r, redirectBase, "internal_error") 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 } if !errors.Is(err, pgx.ErrNoRows) { @@ -262,7 +262,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { 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. @@ -296,7 +296,7 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov redirectWithError(w, r, redirectBase, "internal_error") 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) { diff --git a/internal/api/handlers_sandbox.go b/internal/api/handlers_sandbox.go index b2709a5..a19a7cc 100644 --- a/internal/api/handlers_sandbox.go +++ b/internal/api/handlers_sandbox.go @@ -10,6 +10,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/service" ) @@ -46,7 +47,7 @@ type sandboxResponse struct { func sandboxToResponse(sb db.Sandbox) sandboxResponse { resp := sandboxResponse{ - ID: sb.ID, + ID: id.FormatSandboxID(sb.ID), Status: sb.Status, Template: sb.Template, VCPUs: sb.Vcpus, @@ -81,7 +82,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { } ac := auth.MustFromContext(r.Context()) - if ac.TeamID == "" { + if !ac.TeamID.Valid { writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate") return } @@ -122,9 +123,15 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) { // Get handles GET /v1/sandboxes/{id}. 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()) + 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) if err != nil { 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. 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()) + 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) if err != nil { 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. 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()) + 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) if err != nil { 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. 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()) + 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 { status, code, msg := serviceErrToHTTP(err) 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}. 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()) + 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 { status, code, msg := serviceErrToHTTP(err) writeError(w, status, code, msg) diff --git a/internal/api/handlers_snapshots.go b/internal/api/handlers_snapshots.go index fbfcdc1..f3e2907 100644 --- a/internal/api/handlers_snapshots.go +++ b/internal/api/handlers_snapshots.go @@ -10,7 +10,6 @@ import ( "connectrpc.com/connect" "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/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 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, SizeBytes: t.SizeBytes, } - if t.Vcpus.Valid { - resp.VCPUs = &t.Vcpus.Int32 + if t.Vcpus != 0 { + resp.VCPUs = &t.Vcpus } - if t.MemoryMb.Valid { - resp.MemoryMB = &t.MemoryMb.Int32 + if t.MemoryMb != 0 { + resp.MemoryMB = &t.MemoryMb } if t.CreatedAt.Valid { resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339) @@ -103,6 +102,12 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { return } + sandboxID, err := id.ParseSandboxID(req.SandboxID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox_id") + return + } + if req.Name == "" { 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. - 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 { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") 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). if sb.Status != "paused" { if _, err := h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ - ID: req.SandboxID, Status: "paused", + ID: sandboxID, Status: "paused", }); err != nil { 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{ Name: req.Name, Type: "snapshot", - Vcpus: pgtype.Int4{Int32: sb.Vcpus, Valid: true}, - MemoryMb: pgtype.Int4{Int32: sb.MemoryMb, Valid: true}, + Vcpus: sb.Vcpus, + MemoryMb: sb.MemoryMb, SizeBytes: resp.Msg.SizeBytes, TeamID: ac.TeamID, }) diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go index 3950ab7..2bf99f9 100644 --- a/internal/api/handlers_team.go +++ b/internal/api/handlers_team.go @@ -7,10 +7,12 @@ import ( "time" "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/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/service" ) @@ -48,7 +50,7 @@ type memberResponse struct { func teamToResponse(t db.Team) teamResponse { resp := teamResponse{ - ID: t.ID, + ID: id.FormatTeamID(t.ID), Name: t.Name, Slug: t.Slug, IsByoc: t.IsByoc, @@ -72,11 +74,16 @@ func memberInfoToResponse(m service.MemberInfo) memberResponse { // 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. // Returns false and writes 403 if they don't match. -func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthContext) (string, bool) { - teamID := chi.URLParam(r, "id") +func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthContext) (pgtype.UUID, bool) { + 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 { 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 } @@ -185,7 +192,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) { // Fetch old name for audit log before renaming. oldTeam, err := h.svc.GetTeam(r.Context(), teamID) 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 { @@ -267,7 +274,11 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) { 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)) } @@ -279,7 +290,13 @@ func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { if !ok { 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 { status, code, msg := serviceErrToHTTP(err) @@ -299,7 +316,13 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) { if !ok { 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 { 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). // Enables or disables the BYOC feature flag for a team. 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 { Enabled bool `json:"enabled"` diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 8269d3c..1705064 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -8,6 +8,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" ) type usersHandler struct { @@ -45,7 +46,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) { } resp := make([]userResult, len(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) } diff --git a/internal/api/host_monitor.go b/internal/api/host_monitor.go index 4bf19d8..95fde10 100644 --- a/internal/api/host_monitor.go +++ b/internal/api/host_monitor.go @@ -6,9 +6,11 @@ import ( "time" "connectrpc.com/connect" + "github.com/jackc/pgx/v5/pgtype" "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" 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 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) 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 { - 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 } @@ -110,19 +112,20 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) { if err != nil { // RPC failure is a transient condition; the passive phase will catch it // 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 } // 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)) for _, sb := range resp.Msg.Sandboxes { alive[sb.SandboxId] = struct{}{} } autoPaused := make(map[string]struct{}, len(resp.Msg.AutoPausedSandboxIds)) - for _, id := range resp.Msg.AutoPausedSandboxIds { - autoPaused[id] = struct{}{} + for _, apID := range resp.Msg.AutoPausedSandboxIds { + autoPaused[apID] = struct{}{} } // --- 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"}, }) 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 { - var toRestore []string - var toStop []string + var toRestore []pgtype.UUID + var toStop []pgtype.UUID 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) } else { toStop = append(toStop, sb.ID) } } 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 { - 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 { - 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{ Column1: toStop, Status: "stopped", }); 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"}, }) 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 } - var toPause, toStop []string - sbTeamID := make(map[string]string, len(runningSandboxes)) + var toPause, toStop []pgtype.UUID + sbTeamID := make(map[pgtype.UUID]pgtype.UUID, len(runningSandboxes)) for _, sb := range runningSandboxes { + sbIDStr := id.FormatSandboxID(sb.ID) sbTeamID[sb.ID] = sb.TeamID - if _, ok := alive[sb.ID]; ok { + if _, ok := alive[sbIDStr]; ok { continue } - if _, ok := autoPaused[sb.ID]; ok { + if _, ok := autoPaused[sbIDStr]; ok { toPause = append(toPause, sb.ID) } else { toStop = append(toStop, sb.ID) @@ -188,24 +193,24 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) { } 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{ Column1: toPause, Status: "paused", }); 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 { m.audit.LogSandboxAutoPause(ctx, sbTeamID[sbID], sbID) } } 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{ Column1: toStop, Status: "stopped", }); 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) } } } diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index dee4240..985b289 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -7,6 +7,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "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. @@ -24,7 +25,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler } 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{ @@ -45,9 +46,20 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler 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{ - TeamID: claims.TeamID, - UserID: claims.Subject, + TeamID: teamID, + UserID: userID, Email: claims.Email, Name: claims.Name, Role: claims.Role, diff --git a/internal/api/middleware_hosttoken.go b/internal/api/middleware_hosttoken.go index a5c5e6f..b926e41 100644 --- a/internal/api/middleware_hosttoken.go +++ b/internal/api/middleware_hosttoken.go @@ -4,6 +4,7 @@ import ( "net/http" "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, @@ -23,7 +24,13 @@ func requireHostToken(secret []byte) func(http.Handler) http.Handler { 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)) }) } diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go index 96b1c68..3721538 100644 --- a/internal/api/middleware_jwt.go +++ b/internal/api/middleware_jwt.go @@ -5,6 +5,7 @@ import ( "strings" "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/id" ) // requireJWT validates the Authorization: Bearer header, verifies the JWT @@ -25,9 +26,20 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler { 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{ - TeamID: claims.TeamID, - UserID: claims.Subject, + TeamID: teamID, + UserID: userID, Email: claims.Email, Name: claims.Name, Role: claims.Role, diff --git a/internal/audit/logger.go b/internal/audit/logger.go index 8f44059..e60d8a6 100644 --- a/internal/audit/logger.go +++ b/internal/audit/logger.go @@ -25,18 +25,15 @@ func New(queries *db.Queries) *AuditLogger { } // 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) { - if ac.UserID != "" { - return "user", - pgtype.Text{String: ac.UserID, Valid: true}, - pgtype.Text{String: ac.Name, Valid: ac.Name != ""} +// actor_id is stored as a prefixed string in the TEXT column. +func actorFields(ac auth.AuthContext) (actorType, actorID, actorName string) { + if ac.UserID.Valid { + return "user", id.FormatUserID(ac.UserID), ac.Name } - if ac.APIKeyID != "" { - return "api_key", - pgtype.Text{String: ac.APIKeyID, Valid: true}, - pgtype.Text{String: ac.APIKeyName, Valid: true} + if ac.APIKeyID.Valid { + return "api_key", id.FormatAPIKeyID(ac.APIKeyID), ac.APIKeyName } - return "system", pgtype.Text{}, pgtype.Text{} + return "system", "", "" } 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", "action", p.Action, "resource_type", p.ResourceType, - "team_id", p.TeamID, "error", err, ) } @@ -61,18 +57,26 @@ func marshalMeta(meta map[string]any) []byte { 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) --- -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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "sandbox", - ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + ResourceID: optText(id.FormatSandboxID(sandboxID)), Action: "create", Scope: "team", 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "sandbox", - ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + ResourceID: optText(id.FormatSandboxID(sandboxID)), Action: "pause", Scope: "team", 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). -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{ ID: id.NewAuditLogID(), TeamID: teamID, ActorType: "system", ActorID: pgtype.Text{}, - ActorName: pgtype.Text{}, + ActorName: "", ResourceType: "sandbox", - ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + ResourceID: optText(id.FormatSandboxID(sandboxID)), Action: "pause", Scope: "team", 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "sandbox", - ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + ResourceID: optText(id.FormatSandboxID(sandboxID)), Action: "resume", Scope: "team", 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "sandbox", - ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + ResourceID: optText(id.FormatSandboxID(sandboxID)), Action: "destroy", Scope: "team", Status: "warning", @@ -156,10 +160,10 @@ func (l *AuditLogger) LogSnapshotCreate(ctx context.Context, ac auth.AuthContext ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "snapshot", - ResourceID: pgtype.Text{String: name, Valid: true}, + ResourceID: optText(name), Action: "create", Scope: "team", Status: "success", @@ -173,10 +177,10 @@ func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "snapshot", - ResourceID: pgtype.Text{String: name, Valid: true}, + ResourceID: optText(name), Action: "delete", Scope: "team", Status: "warning", @@ -186,16 +190,16 @@ func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext // --- 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "team", - ResourceID: pgtype.Text{String: teamID, Valid: true}, + ResourceID: optText(id.FormatTeamID(teamID)), Action: "rename", Scope: "team", Status: "info", @@ -205,16 +209,16 @@ func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, te // --- 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "api_key", - ResourceID: pgtype.Text{String: keyID, Valid: true}, + ResourceID: optText(id.FormatAPIKeyID(keyID)), Action: "create", Scope: "team", 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "api_key", - ResourceID: pgtype.Text{String: keyID, Valid: true}, + ResourceID: optText(id.FormatAPIKeyID(keyID)), Action: "revoke", Scope: "team", Status: "warning", @@ -241,16 +245,16 @@ func (l *AuditLogger) LogAPIKeyRevoke(ctx context.Context, ac auth.AuthContext, // --- 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "member", - ResourceID: pgtype.Text{String: targetUserID, Valid: true}, + ResourceID: optText(id.FormatUserID(targetUserID)), Action: "add", Scope: "admin", 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "member", - ResourceID: pgtype.Text{String: targetUserID, Valid: true}, + ResourceID: optText(id.FormatUserID(targetUserID)), Action: "remove", Scope: "admin", 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) { actorType, actorID, actorName := actorFields(ac) + resourceID := "" + if ac.UserID.Valid { + resourceID = id.FormatUserID(ac.UserID) + } l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "member", - ResourceID: pgtype.Text{String: ac.UserID, Valid: ac.UserID != ""}, + ResourceID: optText(resourceID), Action: "leave", Scope: "admin", 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) l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: ac.TeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "member", - ResourceID: pgtype.Text{String: targetUserID, Valid: true}, + ResourceID: optText(id.FormatUserID(targetUserID)), Action: "role_update", Scope: "admin", Status: "info", @@ -311,24 +319,24 @@ func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthConte // --- 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) // For shared hosts with no owning team, use the caller's team. logTeamID := teamID - if logTeamID == "" { + if !logTeamID.Valid { logTeamID = ac.TeamID } - if logTeamID == "" { + if !logTeamID.Valid { return } l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: logTeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "host", - ResourceID: pgtype.Text{String: hostID, Valid: true}, + ResourceID: optText(id.FormatHostID(hostID)), Action: "create", Scope: "admin", 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) logTeamID := teamID - if logTeamID == "" { + if !logTeamID.Valid { logTeamID = ac.TeamID } - if logTeamID == "" { + if !logTeamID.Valid { return } l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), TeamID: logTeamID, ActorType: actorType, - ActorID: actorID, + ActorID: optText(actorID), ActorName: actorName, ResourceType: "host", - ResourceID: pgtype.Text{String: hostID, Valid: true}, + ResourceID: optText(id.FormatHostID(hostID)), Action: "delete", Scope: "admin", 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. -// teamID must be non-empty (BYOC hosts only); shared hosts are not logged. -func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID string) { - if teamID == "" { +func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID pgtype.UUID) { + if !teamID.Valid { return } l.write(ctx, db.InsertAuditLogParams{ @@ -371,9 +378,9 @@ func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID stri TeamID: teamID, ActorType: "system", ActorID: pgtype.Text{}, - ActorName: pgtype.Text{}, + ActorName: "", ResourceType: "host", - ResourceID: pgtype.Text{String: hostID, Valid: true}, + ResourceID: optText(id.FormatHostID(hostID)), Action: "marked_down", Scope: "admin", 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. -// teamID must be non-empty (BYOC hosts only); shared hosts are not logged. -func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID string) { - if teamID == "" { +func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID pgtype.UUID) { + if !teamID.Valid { return } l.write(ctx, db.InsertAuditLogParams{ @@ -392,9 +398,9 @@ func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID string TeamID: teamID, ActorType: "system", ActorID: pgtype.Text{}, - ActorName: pgtype.Text{}, + ActorName: "", ResourceType: "host", - ResourceID: pgtype.Text{String: hostID, Valid: true}, + ResourceID: optText(id.FormatHostID(hostID)), Action: "marked_up", Scope: "admin", Status: "success", diff --git a/internal/auth/context.go b/internal/auth/context.go index 22bf795..762227c 100644 --- a/internal/auth/context.go +++ b/internal/auth/context.go @@ -1,6 +1,10 @@ package auth -import "context" +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) type contextKey int @@ -8,14 +12,14 @@ const authCtxKey contextKey = 0 // AuthContext is stamped into request context by auth middleware. type AuthContext struct { - TeamID string - UserID string // empty when authenticated via API key - Email 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 - IsAdmin bool // platform-level admin; always false when authenticated via API key - APIKeyID string // populated when authenticated via API key; empty for JWT auth - APIKeyName string // display name of the key, snapshotted at auth time; empty for JWT auth + TeamID pgtype.UUID + UserID pgtype.UUID // zero value (Valid=false) when authenticated via API key + Email 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 + IsAdmin bool // platform-level admin; always false when authenticated via API key + 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 } // WithAuthContext returns a new context with the given AuthContext. @@ -43,7 +47,7 @@ const hostCtxKey contextKey = 1 // HostContext is stamped into request context by host token middleware. type HostContext struct { - HostID string + HostID pgtype.UUID } // WithHostContext returns a new context with the given HostContext. diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index fd1bc02..840cd3b 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -5,6 +5,9 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/id" ) const jwtExpiry = 6 * time.Hour @@ -23,16 +26,16 @@ type Claims struct { } // 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() claims := Claims{ - TeamID: teamID, + TeamID: id.FormatTeamID(teamID), Role: role, Email: email, Name: name, IsAdmin: isAdmin, RegisteredClaims: jwt.RegisteredClaims{ - Subject: userID, + Subject: id.FormatUserID(userID), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)), }, @@ -70,14 +73,15 @@ type HostClaims struct { jwt.RegisteredClaims } -// SignHostJWT signs a long-lived (1 year) JWT for a registered host agent. -func SignHostJWT(secret []byte, hostID string) (string, error) { +// SignHostJWT signs a long-lived (7-day) JWT for a registered host agent. +func SignHostJWT(secret []byte, hostID pgtype.UUID) (string, error) { + formatted := id.FormatHostID(hostID) now := time.Now() claims := HostClaims{ Type: "host", - HostID: hostID, + HostID: formatted, RegisteredClaims: jwt.RegisteredClaims{ - Subject: hostID, + Subject: formatted, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(hostJWTExpiry)), }, diff --git a/internal/db/api_keys.sql.go b/internal/db/api_keys.sql.go index b4f0ffc..4b8d369 100644 --- a/internal/db/api_keys.sql.go +++ b/internal/db/api_keys.sql.go @@ -16,8 +16,8 @@ DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2 ` type DeleteAPIKeyParams struct { - ID string `json:"id"` - TeamID string `json:"team_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` } 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 { - ID string `json:"id"` - TeamID string `json:"team_id"` - Name string `json:"name"` - KeyHash string `json:"key_hash"` - KeyPrefix string `json:"key_prefix"` - CreatedBy string `json:"created_by"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + KeyPrefix string `json:"key_prefix"` + CreatedBy pgtype.UUID `json:"created_by"` } 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 ` -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) if err != nil { return nil, err @@ -126,18 +126,18 @@ ORDER BY k.created_at DESC ` type ListAPIKeysByTeamWithCreatorRow struct { - ID string `json:"id"` - TeamID string `json:"team_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` Name string `json:"name"` KeyHash string `json:"key_hash"` KeyPrefix string `json:"key_prefix"` - CreatedBy string `json:"created_by"` + CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` LastUsed pgtype.Timestamptz `json:"last_used"` 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) if err != nil { return nil, err @@ -171,7 +171,7 @@ const updateAPIKeyLastUsed = `-- name: UpdateAPIKeyLastUsed :exec 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) return err } diff --git a/internal/db/audit.sql.go b/internal/db/audit.sql.go index 9370eca..69b2b8c 100644 --- a/internal/db/audit.sql.go +++ b/internal/db/audit.sql.go @@ -17,11 +17,11 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ` type InsertAuditLogParams struct { - ID string `json:"id"` - TeamID string `json:"team_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` ActorType string `json:"actor_type"` ActorID pgtype.Text `json:"actor_id"` - ActorName pgtype.Text `json:"actor_name"` + ActorName string `json:"actor_name"` ResourceType string `json:"resource_type"` ResourceID pgtype.Text `json:"resource_id"` Action string `json:"action"` @@ -60,12 +60,12 @@ LIMIT $7 ` type ListAuditLogsParams struct { - TeamID string `json:"team_id"` + TeamID pgtype.UUID `json:"team_id"` Column2 []string `json:"column_2"` Column3 []string `json:"column_3"` Column4 []string `json:"column_4"` Column5 pgtype.Timestamptz `json:"column_5"` - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Limit int32 `json:"limit"` } diff --git a/internal/db/host_refresh_tokens.sql.go b/internal/db/host_refresh_tokens.sql.go index d02a0e7..0ec162d 100644 --- a/internal/db/host_refresh_tokens.sql.go +++ b/internal/db/host_refresh_tokens.sql.go @@ -47,8 +47,8 @@ RETURNING id, host_id, token_hash, expires_at, created_at, revoked_at ` type InsertHostRefreshTokenParams struct { - ID string `json:"id"` - HostID string `json:"host_id"` + ID pgtype.UUID `json:"id"` + HostID pgtype.UUID `json:"host_id"` TokenHash string `json:"token_hash"` 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 ` -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) return err } @@ -86,7 +86,7 @@ UPDATE host_refresh_tokens SET revoked_at = NOW() 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) return err } diff --git a/internal/db/hosts.sql.go b/internal/db/hosts.sql.go index 2d7b8e0..8bfd8d3 100644 --- a/internal/db/hosts.sql.go +++ b/internal/db/hosts.sql.go @@ -16,8 +16,8 @@ INSERT INTO host_tags (host_id, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING ` type AddHostTagParams struct { - HostID string `json:"host_id"` - Tag string `json:"tag"` + HostID pgtype.UUID `json:"host_id"` + Tag string `json:"tag"` } func (q *Queries) AddHostTag(ctx context.Context, arg AddHostTagParams) error { @@ -29,7 +29,7 @@ const deleteHost = `-- name: DeleteHost :exec 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) 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 ` -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) var i Host err := row.Scan( @@ -69,8 +69,8 @@ SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_m ` type GetHostByTeamParams struct { - ID string `json:"id"` - TeamID pgtype.Text `json:"team_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` } 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 ` -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) if err != nil { 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 ` -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) if err != nil { return nil, err @@ -161,12 +161,12 @@ RETURNING id, type, team_id, provider, availability_zone, arch, cpu_cores, memor ` type InsertHostParams struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Type string `json:"type"` - TeamID pgtype.Text `json:"team_id"` - Provider pgtype.Text `json:"provider"` - AvailabilityZone pgtype.Text `json:"availability_zone"` - CreatedBy string `json:"created_by"` + TeamID pgtype.UUID `json:"team_id"` + Provider string `json:"provider"` + AvailabilityZone string `json:"availability_zone"` + CreatedBy pgtype.UUID `json:"created_by"` } 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 { - ID string `json:"id"` - HostID string `json:"host_id"` - CreatedBy string `json:"created_by"` + ID pgtype.UUID `json:"id"` + HostID pgtype.UUID `json:"host_id"` + CreatedBy pgtype.UUID `json:"created_by"` 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 ` -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) if err != nil { return nil, err @@ -500,7 +500,7 @@ const markHostTokenUsed = `-- name: MarkHostTokenUsed :exec 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) return err } @@ -509,7 +509,7 @@ const markHostUnreachable = `-- name: MarkHostUnreachable :exec 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) return err } @@ -528,12 +528,12 @@ WHERE id = $1 AND status = 'pending' ` type RegisterHostParams struct { - ID string `json:"id"` - Arch pgtype.Text `json:"arch"` - CpuCores pgtype.Int4 `json:"cpu_cores"` - MemoryMb pgtype.Int4 `json:"memory_mb"` - DiskGb pgtype.Int4 `json:"disk_gb"` - Address pgtype.Text `json:"address"` + ID pgtype.UUID `json:"id"` + Arch string `json:"arch"` + CpuCores int32 `json:"cpu_cores"` + MemoryMb int32 `json:"memory_mb"` + DiskGb int32 `json:"disk_gb"` + Address string `json:"address"` } func (q *Queries) RegisterHost(ctx context.Context, arg RegisterHostParams) (int64, error) { @@ -556,8 +556,8 @@ DELETE FROM host_tags WHERE host_id = $1 AND tag = $2 ` type RemoveHostTagParams struct { - HostID string `json:"host_id"` - Tag string `json:"tag"` + HostID pgtype.UUID `json:"host_id"` + Tag string `json:"tag"` } func (q *Queries) RemoveHostTag(ctx context.Context, arg RemoveHostTagParams) error { @@ -569,7 +569,7 @@ const updateHostHeartbeat = `-- name: UpdateHostHeartbeat :exec 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) return err } @@ -584,7 +584,7 @@ WHERE id = $1 // 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. -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) if err != nil { return 0, err @@ -597,8 +597,8 @@ UPDATE hosts SET status = $2, updated_at = NOW() WHERE id = $1 ` type UpdateHostStatusParams struct { - ID string `json:"id"` - Status string `json:"status"` + ID pgtype.UUID `json:"id"` + Status string `json:"status"` } func (q *Queries) UpdateHostStatus(ctx context.Context, arg UpdateHostStatusParams) error { diff --git a/internal/db/metrics.sql.go b/internal/db/metrics.sql.go index 8050155..f522dc2 100644 --- a/internal/db/metrics.sql.go +++ b/internal/db/metrics.sql.go @@ -7,6 +7,8 @@ package db import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec @@ -14,7 +16,7 @@ DELETE FROM sandbox_metric_points 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) return err } @@ -25,8 +27,8 @@ WHERE sandbox_id = $1 AND tier = $2 ` type DeleteSandboxMetricPointsByTierParams struct { - SandboxID string `json:"sandbox_id"` - Tier string `json:"tier"` + SandboxID pgtype.UUID `json:"sandbox_id"` + Tier string `json:"tier"` } func (q *Queries) DeleteSandboxMetricPointsByTier(ctx context.Context, arg DeleteSandboxMetricPointsByTierParams) error { @@ -53,7 +55,7 @@ type GetLiveMetricsRow struct { // Reads directly from sandboxes for accurate real-time current values. // CPU reserved = running + starting only (paused VMs release CPU). // 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) var i GetLiveMetricsRow err := row.Scan(&i.RunningCount, &i.VcpusReserved, &i.MemoryMbReserved) @@ -76,7 +78,7 @@ type GetPeakMetricsRow struct { 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) var i GetPeakMetricsRow err := row.Scan(&i.PeakRunningCount, &i.PeakVcpus, &i.PeakMemoryMb) @@ -91,9 +93,9 @@ ORDER BY ts ASC ` type GetSandboxMetricPointsParams struct { - SandboxID string `json:"sandbox_id"` - Tier string `json:"tier"` - Ts int64 `json:"ts"` + SandboxID pgtype.UUID `json:"sandbox_id"` + Tier string `json:"tier"` + Ts int64 `json:"ts"` } type GetSandboxMetricPointsRow struct { @@ -134,10 +136,10 @@ VALUES ($1, $2, $3, $4) ` type InsertMetricsSnapshotParams struct { - TeamID string `json:"team_id"` - RunningCount int32 `json:"running_count"` - VcpusReserved int32 `json:"vcpus_reserved"` - MemoryMbReserved int32 `json:"memory_mb_reserved"` + TeamID pgtype.UUID `json:"team_id"` + RunningCount int32 `json:"running_count"` + VcpusReserved int32 `json:"vcpus_reserved"` + MemoryMbReserved int32 `json:"memory_mb_reserved"` } func (q *Queries) InsertMetricsSnapshot(ctx context.Context, arg InsertMetricsSnapshotParams) error { @@ -157,12 +159,12 @@ ON CONFLICT (sandbox_id, tier, ts) DO NOTHING ` type InsertSandboxMetricPointParams struct { - SandboxID string `json:"sandbox_id"` - Tier string `json:"tier"` - Ts int64 `json:"ts"` - CpuPct float64 `json:"cpu_pct"` - MemBytes int64 `json:"mem_bytes"` - DiskBytes int64 `json:"disk_bytes"` + SandboxID pgtype.UUID `json:"sandbox_id"` + Tier string `json:"tier"` + Ts int64 `json:"ts"` + CpuPct float64 `json:"cpu_pct"` + MemBytes int64 `json:"mem_bytes"` + DiskBytes int64 `json:"disk_bytes"` } func (q *Queries) InsertSandboxMetricPoint(ctx context.Context, arg InsertSandboxMetricPointParams) error { @@ -210,10 +212,10 @@ GROUP BY team_id ` type SampleSandboxMetricsRow struct { - TeamID string `json:"team_id"` - RunningCount int32 `json:"running_count"` - VcpusReserved int32 `json:"vcpus_reserved"` - MemoryMbReserved int32 `json:"memory_mb_reserved"` + TeamID pgtype.UUID `json:"team_id"` + RunningCount int32 `json:"running_count"` + VcpusReserved int32 `json:"vcpus_reserved"` + MemoryMbReserved int32 `json:"memory_mb_reserved"` } // Aggregates per-team resource usage from the live sandboxes table. diff --git a/internal/db/models.go b/internal/db/models.go index 74596c6..3aa765c 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -9,18 +9,18 @@ import ( ) type AdminPermission struct { - ID string `json:"id"` - UserID string `json:"user_id"` + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` Permission string `json:"permission"` CreatedAt pgtype.Timestamptz `json:"created_at"` } type AuditLog struct { - ID string `json:"id"` - TeamID string `json:"team_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` ActorType string `json:"actor_type"` ActorID pgtype.Text `json:"actor_id"` - ActorName pgtype.Text `json:"actor_name"` + ActorName string `json:"actor_name"` ResourceType string `json:"resource_type"` ResourceID pgtype.Text `json:"resource_id"` Action string `json:"action"` @@ -31,29 +31,29 @@ type AuditLog struct { } type Host struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Type string `json:"type"` - TeamID pgtype.Text `json:"team_id"` - Provider pgtype.Text `json:"provider"` - AvailabilityZone pgtype.Text `json:"availability_zone"` - Arch pgtype.Text `json:"arch"` - CpuCores pgtype.Int4 `json:"cpu_cores"` - MemoryMb pgtype.Int4 `json:"memory_mb"` - DiskGb pgtype.Int4 `json:"disk_gb"` - Address pgtype.Text `json:"address"` + TeamID pgtype.UUID `json:"team_id"` + Provider string `json:"provider"` + AvailabilityZone string `json:"availability_zone"` + Arch string `json:"arch"` + CpuCores int32 `json:"cpu_cores"` + MemoryMb int32 `json:"memory_mb"` + DiskGb int32 `json:"disk_gb"` + Address string `json:"address"` Status string `json:"status"` LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"` Metadata []byte `json:"metadata"` - CreatedBy string `json:"created_by"` + CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` - CertFingerprint pgtype.Text `json:"cert_fingerprint"` + CertFingerprint string `json:"cert_fingerprint"` MtlsEnabled bool `json:"mtls_enabled"` } type HostRefreshToken struct { - ID string `json:"id"` - HostID string `json:"host_id"` + ID pgtype.UUID `json:"id"` + HostID pgtype.UUID `json:"host_id"` TokenHash string `json:"token_hash"` ExpiresAt pgtype.Timestamptz `json:"expires_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` @@ -61,14 +61,14 @@ type HostRefreshToken struct { } type HostTag struct { - HostID string `json:"host_id"` - Tag string `json:"tag"` + HostID pgtype.UUID `json:"host_id"` + Tag string `json:"tag"` } type HostToken struct { - ID string `json:"id"` - HostID string `json:"host_id"` - CreatedBy string `json:"created_by"` + ID pgtype.UUID `json:"id"` + HostID pgtype.UUID `json:"host_id"` + CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` ExpiresAt pgtype.Timestamptz `json:"expires_at"` UsedAt pgtype.Timestamptz `json:"used_at"` @@ -77,14 +77,15 @@ type HostToken struct { type OauthProvider struct { Provider string `json:"provider"` ProviderID string `json:"provider_id"` - UserID string `json:"user_id"` + UserID pgtype.UUID `json:"user_id"` Email string `json:"email"` CreatedAt pgtype.Timestamptz `json:"created_at"` } type Sandbox struct { - ID string `json:"id"` - HostID string `json:"host_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` + HostID pgtype.UUID `json:"host_id"` Template string `json:"template"` Status string `json:"status"` Vcpus int32 `json:"vcpus"` @@ -96,21 +97,20 @@ type Sandbox struct { StartedAt pgtype.Timestamptz `json:"started_at"` LastActiveAt pgtype.Timestamptz `json:"last_active_at"` LastUpdated pgtype.Timestamptz `json:"last_updated"` - TeamID string `json:"team_id"` } type SandboxMetricPoint struct { - SandboxID string `json:"sandbox_id"` - Tier string `json:"tier"` - Ts int64 `json:"ts"` - CpuPct float64 `json:"cpu_pct"` - MemBytes int64 `json:"mem_bytes"` - DiskBytes int64 `json:"disk_bytes"` + SandboxID pgtype.UUID `json:"sandbox_id"` + Tier string `json:"tier"` + Ts int64 `json:"ts"` + CpuPct float64 `json:"cpu_pct"` + MemBytes int64 `json:"mem_bytes"` + DiskBytes int64 `json:"disk_bytes"` } type SandboxMetricsSnapshot struct { ID int64 `json:"id"` - TeamID string `json:"team_id"` + TeamID pgtype.UUID `json:"team_id"` SampledAt pgtype.Timestamptz `json:"sampled_at"` RunningCount int32 `json:"running_count"` VcpusReserved int32 `json:"vcpus_reserved"` @@ -118,21 +118,21 @@ type SandboxMetricsSnapshot struct { } type Team struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Name string `json:"name"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - IsByoc bool `json:"is_byoc"` Slug string `json:"slug"` + IsByoc bool `json:"is_byoc"` + CreatedAt pgtype.Timestamptz `json:"created_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` } type TeamApiKey struct { - ID string `json:"id"` - TeamID string `json:"team_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` Name string `json:"name"` KeyHash string `json:"key_hash"` KeyPrefix string `json:"key_prefix"` - CreatedBy string `json:"created_by"` + CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` LastUsed pgtype.Timestamptz `json:"last_used"` } @@ -140,46 +140,46 @@ type TeamApiKey struct { type Template struct { Name string `json:"name"` Type string `json:"type"` - Vcpus pgtype.Int4 `json:"vcpus"` - MemoryMb pgtype.Int4 `json:"memory_mb"` + Vcpus int32 `json:"vcpus"` + MemoryMb int32 `json:"memory_mb"` SizeBytes int64 `json:"size_bytes"` CreatedAt pgtype.Timestamptz `json:"created_at"` - TeamID string `json:"team_id"` + TeamID pgtype.UUID `json:"team_id"` } type TemplateBuild struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Name string `json:"name"` BaseTemplate string `json:"base_template"` Recipe []byte `json:"recipe"` - Healthcheck pgtype.Text `json:"healthcheck"` + Healthcheck string `json:"healthcheck"` Vcpus int32 `json:"vcpus"` MemoryMb int32 `json:"memory_mb"` Status string `json:"status"` CurrentStep int32 `json:"current_step"` TotalSteps int32 `json:"total_steps"` Logs []byte `json:"logs"` - Error pgtype.Text `json:"error"` - SandboxID pgtype.Text `json:"sandbox_id"` - HostID pgtype.Text `json:"host_id"` + Error string `json:"error"` + SandboxID pgtype.UUID `json:"sandbox_id"` + HostID pgtype.UUID `json:"host_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` StartedAt pgtype.Timestamptz `json:"started_at"` CompletedAt pgtype.Timestamptz `json:"completed_at"` } type User struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Email string `json:"email"` PasswordHash pgtype.Text `json:"password_hash"` + Name string `json:"name"` + IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` - IsAdmin bool `json:"is_admin"` - Name string `json:"name"` } type UsersTeam struct { - UserID string `json:"user_id"` - TeamID string `json:"team_id"` + UserID pgtype.UUID `json:"user_id"` + TeamID pgtype.UUID `json:"team_id"` IsDefault bool `json:"is_default"` Role string `json:"role"` CreatedAt pgtype.Timestamptz `json:"created_at"` diff --git a/internal/db/oauth.sql.go b/internal/db/oauth.sql.go index ab79eec..0270def 100644 --- a/internal/db/oauth.sql.go +++ b/internal/db/oauth.sql.go @@ -7,6 +7,8 @@ package db import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const getOAuthProvider = `-- name: GetOAuthProvider :one @@ -38,10 +40,10 @@ VALUES ($1, $2, $3, $4) ` type InsertOAuthProviderParams struct { - Provider string `json:"provider"` - ProviderID string `json:"provider_id"` - UserID string `json:"user_id"` - Email string `json:"email"` + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + UserID pgtype.UUID `json:"user_id"` + Email string `json:"email"` } func (q *Queries) InsertOAuthProvider(ctx context.Context, arg InsertOAuthProviderParams) error { diff --git a/internal/db/sandboxes.sql.go b/internal/db/sandboxes.sql.go index 620f77e..07effdf 100644 --- a/internal/db/sandboxes.sql.go +++ b/internal/db/sandboxes.sql.go @@ -15,12 +15,12 @@ const bulkRestoreRunning = `-- name: BulkRestoreRunning :exec UPDATE sandboxes SET status = 'running', 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 // 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) return err } @@ -29,12 +29,12 @@ const bulkUpdateStatusByIDs = `-- name: BulkUpdateStatusByIDs :exec UPDATE sandboxes SET status = $2, last_updated = NOW() -WHERE id = ANY($1::text[]) +WHERE id = ANY($1::uuid[]) ` type BulkUpdateStatusByIDsParams struct { - Column1 []string `json:"column_1"` - Status string `json:"status"` + Column1 []pgtype.UUID `json:"column_1"` + Status string `json:"status"` } func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatusByIDsParams) error { @@ -43,14 +43,15 @@ func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatu } 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) var i Sandbox err := row.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -63,18 +64,17 @@ func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) { &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ) return i, err } 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 { - ID string `json:"id"` - TeamID string `json:"team_id"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` } 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 err := row.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -94,7 +95,6 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ) return i, err } @@ -102,18 +102,18 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara const insertSandbox = `-- name: InsertSandbox :one INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec) 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 { - ID string `json:"id"` - TeamID string `json:"team_id"` - HostID string `json:"host_id"` - Template string `json:"template"` - Status string `json:"status"` - Vcpus int32 `json:"vcpus"` - MemoryMb int32 `json:"memory_mb"` - TimeoutSec int32 `json:"timeout_sec"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` + HostID pgtype.UUID `json:"host_id"` + Template string `json:"template"` + Status string `json:"status"` + Vcpus int32 `json:"vcpus"` + MemoryMb int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` } func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) { @@ -130,6 +130,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S var i Sandbox err := row.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -142,18 +143,17 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ) return i, err } 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') 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) if err != nil { return nil, err @@ -164,6 +164,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string) var i Sandbox if err := rows.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -176,7 +177,6 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string) &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ); err != nil { return nil, err } @@ -189,7 +189,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string) } 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) { @@ -203,6 +203,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { var i Sandbox if err := rows.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -215,7 +216,6 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ); err != nil { return nil, err } @@ -228,14 +228,14 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { } 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[]) ORDER BY created_at DESC ` type ListSandboxesByHostAndStatusParams struct { - HostID string `json:"host_id"` - Column2 []string `json:"column_2"` + HostID pgtype.UUID `json:"host_id"` + Column2 []string `json:"column_2"` } func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSandboxesByHostAndStatusParams) ([]Sandbox, error) { @@ -249,6 +249,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand var i Sandbox if err := rows.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -261,7 +262,6 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ); err != nil { return nil, err } @@ -274,12 +274,12 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand } 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') 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) if err != nil { return nil, err @@ -290,6 +290,7 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]San var i Sandbox if err := rows.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -302,7 +303,6 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]San &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ); err != nil { 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. // Marks running/starting/pending sandboxes on that host as 'missing' so users see // 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) return err } @@ -337,7 +337,7 @@ WHERE id = $1 ` type UpdateLastActiveParams struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` LastActiveAt pgtype.Timestamptz `json:"last_active_at"` } @@ -355,11 +355,11 @@ SET status = 'running', last_active_at = $4, last_updated = NOW() 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 { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` HostIp string `json:"host_ip"` GuestIp string `json:"guest_ip"` StartedAt pgtype.Timestamptz `json:"started_at"` @@ -375,6 +375,7 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun var i Sandbox err := row.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -387,7 +388,6 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ) return i, err } @@ -397,12 +397,12 @@ UPDATE sandboxes SET status = $2, last_updated = NOW() 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 { - ID string `json:"id"` - Status string `json:"status"` + ID pgtype.UUID `json:"id"` + Status string `json:"status"` } func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStatusParams) (Sandbox, error) { @@ -410,6 +410,7 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat var i Sandbox err := row.Scan( &i.ID, + &i.TeamID, &i.HostID, &i.Template, &i.Status, @@ -422,7 +423,6 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, - &i.TeamID, ) return i, err } diff --git a/internal/db/teams.sql.go b/internal/db/teams.sql.go index a00f5ef..334141f 100644 --- a/internal/db/teams.sql.go +++ b/internal/db/teams.sql.go @@ -16,8 +16,8 @@ DELETE FROM users_teams WHERE team_id = $1 AND user_id = $2 ` type DeleteTeamMemberParams struct { - TeamID string `json:"team_id"` - UserID string `json:"user_id"` + TeamID pgtype.UUID `json:"team_id"` + UserID pgtype.UUID `json:"user_id"` } 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 -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) { @@ -41,9 +41,9 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { if err := rows.Scan( &i.ID, &i.Name, - &i.CreatedAt, - &i.IsByoc, &i.Slug, + &i.IsByoc, + &i.CreatedAt, &i.DeletedAt, ); err != nil { return nil, err @@ -57,46 +57,46 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { } 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 WHERE ut.user_id = $1 AND ut.is_default = TRUE AND t.deleted_at IS NULL 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) var i Team err := row.Scan( &i.ID, &i.Name, - &i.CreatedAt, - &i.IsByoc, &i.Slug, + &i.IsByoc, + &i.CreatedAt, &i.DeletedAt, ) return i, err } 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) var i Team err := row.Scan( &i.ID, &i.Name, - &i.CreatedAt, - &i.IsByoc, &i.Slug, + &i.IsByoc, + &i.CreatedAt, &i.DeletedAt, ) return i, err } 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) { @@ -105,9 +105,9 @@ func (q *Queries) GetTeamBySlug(ctx context.Context, slug string) (Team, error) err := row.Scan( &i.ID, &i.Name, - &i.CreatedAt, - &i.IsByoc, &i.Slug, + &i.IsByoc, + &i.CreatedAt, &i.DeletedAt, ) return i, err @@ -122,14 +122,14 @@ ORDER BY ut.created_at ` type GetTeamMembersRow struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Name string `json:"name"` Email string `json:"email"` Role string `json:"role"` 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) if err != nil { 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 { - UserID string `json:"user_id"` - TeamID string `json:"team_id"` + UserID pgtype.UUID `json:"user_id"` + TeamID pgtype.UUID `json:"team_id"` } func (q *Queries) GetTeamMembership(ctx context.Context, arg GetTeamMembershipParams) (UsersTeam, error) { @@ -186,7 +186,7 @@ ORDER BY ut.created_at ` type GetTeamsForUserRow struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Name string `json:"name"` Slug string `json:"slug"` IsByoc bool `json:"is_byoc"` @@ -195,7 +195,7 @@ type GetTeamsForUserRow struct { 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) if err != nil { return nil, err @@ -226,13 +226,13 @@ func (q *Queries) GetTeamsForUser(ctx context.Context, userID string) ([]GetTeam const insertTeam = `-- name: InsertTeam :one INSERT INTO teams (id, name, slug) 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 { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` } func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, error) { @@ -241,9 +241,9 @@ func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, e err := row.Scan( &i.ID, &i.Name, - &i.CreatedAt, - &i.IsByoc, &i.Slug, + &i.IsByoc, + &i.CreatedAt, &i.DeletedAt, ) return i, err @@ -255,10 +255,10 @@ VALUES ($1, $2, $3, $4) ` type InsertTeamMemberParams struct { - UserID string `json:"user_id"` - TeamID string `json:"team_id"` - IsDefault bool `json:"is_default"` - Role string `json:"role"` + UserID pgtype.UUID `json:"user_id"` + TeamID pgtype.UUID `json:"team_id"` + IsDefault bool `json:"is_default"` + Role string `json:"role"` } func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberParams) error { @@ -276,8 +276,8 @@ UPDATE teams SET is_byoc = $2 WHERE id = $1 ` type SetTeamBYOCParams struct { - ID string `json:"id"` - IsByoc bool `json:"is_byoc"` + ID pgtype.UUID `json:"id"` + IsByoc bool `json:"is_byoc"` } func (q *Queries) SetTeamBYOC(ctx context.Context, arg SetTeamBYOCParams) error { @@ -289,7 +289,7 @@ const softDeleteTeam = `-- name: SoftDeleteTeam :exec 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) return err } @@ -299,9 +299,9 @@ UPDATE users_teams SET role = $3 WHERE team_id = $1 AND user_id = $2 ` type UpdateMemberRoleParams struct { - TeamID string `json:"team_id"` - UserID string `json:"user_id"` - Role string `json:"role"` + TeamID pgtype.UUID `json:"team_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` } func (q *Queries) UpdateMemberRole(ctx context.Context, arg UpdateMemberRoleParams) error { @@ -314,8 +314,8 @@ UPDATE teams SET name = $2 WHERE id = $1 AND deleted_at IS NULL ` type UpdateTeamNameParams struct { - ID string `json:"id"` - Name string `json:"name"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` } func (q *Queries) UpdateTeamName(ctx context.Context, arg UpdateTeamNameParams) error { diff --git a/internal/db/template_builds.sql.go b/internal/db/template_builds.sql.go index 8142d29..9e770ee 100644 --- a/internal/db/template_builds.sql.go +++ b/internal/db/template_builds.sql.go @@ -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 ` -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) var i TemplateBuild err := row.Scan( @@ -47,11 +47,11 @@ RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status ` type InsertTemplateBuildParams struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Name string `json:"name"` BaseTemplate string `json:"base_template"` Recipe []byte `json:"recipe"` - Healthcheck pgtype.Text `json:"healthcheck"` + Healthcheck string `json:"healthcheck"` Vcpus int32 `json:"vcpus"` MemoryMb int32 `json:"memory_mb"` TotalSteps int32 `json:"total_steps"` @@ -140,8 +140,8 @@ WHERE id = $1 ` type UpdateBuildErrorParams struct { - ID string `json:"id"` - Error pgtype.Text `json:"error"` + ID pgtype.UUID `json:"id"` + Error string `json:"error"` } func (q *Queries) UpdateBuildError(ctx context.Context, arg UpdateBuildErrorParams) error { @@ -156,9 +156,9 @@ WHERE id = $1 ` type UpdateBuildProgressParams struct { - ID string `json:"id"` - CurrentStep int32 `json:"current_step"` - Logs []byte `json:"logs"` + ID pgtype.UUID `json:"id"` + CurrentStep int32 `json:"current_step"` + Logs []byte `json:"logs"` } func (q *Queries) UpdateBuildProgress(ctx context.Context, arg UpdateBuildProgressParams) error { @@ -173,9 +173,9 @@ WHERE id = $1 ` type UpdateBuildSandboxParams struct { - ID string `json:"id"` - SandboxID pgtype.Text `json:"sandbox_id"` - HostID pgtype.Text `json:"host_id"` + ID pgtype.UUID `json:"id"` + SandboxID pgtype.UUID `json:"sandbox_id"` + HostID pgtype.UUID `json:"host_id"` } func (q *Queries) UpdateBuildSandbox(ctx context.Context, arg UpdateBuildSandboxParams) error { @@ -193,8 +193,8 @@ RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status ` type UpdateBuildStatusParams struct { - ID string `json:"id"` - Status string `json:"status"` + ID pgtype.UUID `json:"id"` + Status string `json:"status"` } func (q *Queries) UpdateBuildStatus(ctx context.Context, arg UpdateBuildStatusParams) (TemplateBuild, error) { diff --git a/internal/db/templates.sql.go b/internal/db/templates.sql.go index cafae69..8703bc9 100644 --- a/internal/db/templates.sql.go +++ b/internal/db/templates.sql.go @@ -25,8 +25,8 @@ DELETE FROM templates WHERE name = $1 AND team_id = $2 ` type DeleteTemplateByTeamParams struct { - Name string `json:"name"` - TeamID string `json:"team_id"` + Name string `json:"name"` + TeamID pgtype.UUID `json:"team_id"` } func (q *Queries) DeleteTemplateByTeam(ctx context.Context, arg DeleteTemplateByTeamParams) error { @@ -58,8 +58,8 @@ SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templa ` type GetTemplateByTeamParams struct { - Name string `json:"name"` - TeamID string `json:"team_id"` + Name string `json:"name"` + TeamID pgtype.UUID `json:"team_id"` } 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 { Name string `json:"name"` Type string `json:"type"` - Vcpus pgtype.Int4 `json:"vcpus"` - MemoryMb pgtype.Int4 `json:"memory_mb"` + Vcpus int32 `json:"vcpus"` + MemoryMb int32 `json:"memory_mb"` 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) { @@ -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 ` -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) if err != nil { return nil, err @@ -183,8 +183,8 @@ SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templa ` type ListTemplatesByTeamAndTypeParams struct { - TeamID string `json:"team_id"` - Type string `json:"type"` + TeamID pgtype.UUID `json:"team_id"` + Type string `json:"type"` } func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTemplatesByTeamAndTypeParams) ([]Template, error) { diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go index 50ba287..9de866b 100644 --- a/internal/db/users.sql.go +++ b/internal/db/users.sql.go @@ -16,8 +16,8 @@ DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2 ` type DeleteAdminPermissionParams struct { - UserID string `json:"user_id"` - Permission string `json:"permission"` + UserID pgtype.UUID `json:"user_id"` + Permission string `json:"permission"` } func (q *Queries) DeleteAdminPermission(ctx context.Context, arg DeleteAdminPermissionParams) error { @@ -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 ` -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) if err != nil { return nil, err @@ -55,7 +55,7 @@ func (q *Queries) GetAdminPermissions(ctx context.Context, userID string) ([]Adm } 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) { @@ -71,10 +71,10 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { &i.ID, &i.Email, &i.PasswordHash, + &i.Name, + &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsAdmin, - &i.Name, ); err != nil { return nil, err } @@ -87,7 +87,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { } 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) { @@ -97,29 +97,29 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.ID, &i.Email, &i.PasswordHash, + &i.Name, + &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsAdmin, - &i.Name, ) return i, err } 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) var i User err := row.Scan( &i.ID, &i.Email, &i.PasswordHash, + &i.Name, + &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsAdmin, - &i.Name, ) return i, err } @@ -131,8 +131,8 @@ SELECT EXISTS( ` type HasAdminPermissionParams struct { - UserID string `json:"user_id"` - Permission string `json:"permission"` + UserID pgtype.UUID `json:"user_id"` + Permission string `json:"permission"` } func (q *Queries) HasAdminPermission(ctx context.Context, arg HasAdminPermissionParams) (bool, error) { @@ -148,9 +148,9 @@ VALUES ($1, $2, $3) ` type InsertAdminPermissionParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Permission string `json:"permission"` + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Permission string `json:"permission"` } func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPermissionParams) error { @@ -161,11 +161,11 @@ func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPerm const insertUser = `-- name: InsertUser :one INSERT INTO users (id, email, password_hash, name) 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 { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` Email string `json:"email"` PasswordHash pgtype.Text `json:"password_hash"` Name string `json:"name"` @@ -183,10 +183,10 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e &i.ID, &i.Email, &i.PasswordHash, + &i.Name, + &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsAdmin, - &i.Name, ) return i, err } @@ -194,13 +194,13 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e const insertUserOAuth = `-- name: InsertUserOAuth :one INSERT INTO users (id, email, name) 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 { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` } func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams) (User, error) { @@ -210,10 +210,10 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams &i.ID, &i.Email, &i.PasswordHash, + &i.Name, + &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsAdmin, - &i.Name, ) return i, err } @@ -223,8 +223,8 @@ SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10 ` type SearchUsersByEmailPrefixRow struct { - ID string `json:"id"` - Email string `json:"email"` + ID pgtype.UUID `json:"id"` + Email string `json:"email"` } func (q *Queries) SearchUsersByEmailPrefix(ctx context.Context, dollar_1 pgtype.Text) ([]SearchUsersByEmailPrefixRow, error) { @@ -252,8 +252,8 @@ UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1 ` type SetUserAdminParams struct { - ID string `json:"id"` - IsAdmin bool `json:"is_admin"` + ID pgtype.UUID `json:"id"` + IsAdmin bool `json:"is_admin"` } func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) error { @@ -266,8 +266,8 @@ UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1 ` type UpdateUserNameParams struct { - ID string `json:"id"` - Name string `json:"name"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` } func (q *Queries) UpdateUserName(ctx context.Context, arg UpdateUserNameParams) error { diff --git a/internal/id/id.go b/internal/id/id.go index 836af6d..c27869a 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -4,8 +4,114 @@ import ( "crypto/rand" "encoding/hex" "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 { b := make([]byte, 4) if _, err := rand.Read(b); err != nil { @@ -14,78 +120,8 @@ func hex8() string { return hex.EncodeToString(b) } -// NewSandboxID generates a new sandbox ID in the format "sb-" + 8 hex chars. -func NewSandboxID() string { - 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) +func hexToken(nBytes int) string { + b := make([]byte, nBytes) if _, err := rand.Read(b); err != nil { panic(fmt.Sprintf("crypto/rand failed: %v", err)) } diff --git a/internal/lifecycle/hostpool.go b/internal/lifecycle/hostpool.go index c6e724b..f134165 100644 --- a/internal/lifecycle/hostpool.go +++ b/internal/lifecycle/hostpool.go @@ -8,6 +8,7 @@ import ( "time" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" "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 // and returns an error if the host has no address recorded yet. func (p *HostClientPool) GetForHost(h db.Host) (hostagentv1connect.HostAgentServiceClient, error) { - if !h.Address.Valid || h.Address.String == "" { - return nil, fmt.Errorf("host %s has no address", h.ID) + if h.Address == "" { + 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 diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 88b058c..15453eb 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -96,7 +96,7 @@ func New(cfg Config) *Manager { // 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) { if sandboxID == "" { - sandboxID = id.NewSandboxID() + sandboxID = id.FormatSandboxID(id.NewSandboxID()) } if vcpus <= 0 { diff --git a/internal/scheduler/round_robin.go b/internal/scheduler/round_robin.go index 31433a0..c2ab0f4 100644 --- a/internal/scheduler/round_robin.go +++ b/internal/scheduler/round_robin.go @@ -5,6 +5,8 @@ import ( "fmt" "sync/atomic" + "github.com/jackc/pgx/v5/pgtype" + "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 // are considered. For non-BYOC teams, only online regular (platform) hosts // 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. @@ -32,7 +34,7 @@ func NewRoundRobinScheduler(queries *db.Queries) *RoundRobinScheduler { } // 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) if err != nil { 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 for _, h := range hosts { - if h.Status != "online" || !h.Address.Valid || h.Address.String == "" { + if h.Status != "online" || h.Address == "" { continue } if isByoc { // 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 } } else { diff --git a/internal/service/apikey.go b/internal/service/apikey.go index c49ddca..7a2b073 100644 --- a/internal/service/apikey.go +++ b/internal/service/apikey.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/id" @@ -22,7 +24,7 @@ type APIKeyCreateResult struct { } // 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 == "" { 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. -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) } // 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) } // 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}) } diff --git a/internal/service/audit.go b/internal/service/audit.go index 5306142..67faafa 100644 --- a/internal/service/audit.go +++ b/internal/service/audit.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" ) const auditMaxLimit = 200 @@ -31,13 +32,13 @@ type AuditEntry struct { // AuditListParams controls the ListAuditLogs query. type AuditListParams struct { - TeamID string - AdminScoped bool // true → include admin-scoped events; false → team-scoped only - ResourceTypes []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) - BeforeID string // tie-breaker: id of the last item at the Before timestamp; empty = no tie-break - Limit int // clamped to auditMaxLimit by the handler + TeamID pgtype.UUID + AdminScoped bool // true → include admin-scoped events; false → team-scoped only + ResourceTypes []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) + 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 } // AuditService provides the read side of the audit log. @@ -94,11 +95,11 @@ func (s *AuditService) List(ctx context.Context, p AuditListParams) ([]AuditEntr _ = json.Unmarshal(row.Metadata, &meta) } entries[i] = AuditEntry{ - ID: row.ID, - TeamID: row.TeamID, + ID: id.FormatAuditLogID(row.ID), + TeamID: id.FormatTeamID(row.TeamID), ActorType: row.ActorType, ActorID: row.ActorID.String, - ActorName: row.ActorName.String, + ActorName: row.ActorName, ResourceType: row.ResourceType, ResourceID: row.ResourceID.String, Action: row.Action, diff --git a/internal/service/build.go b/internal/service/build.go index 3c7975d..1bd82a8 100644 --- a/internal/service/build.go +++ b/internal/service/build.go @@ -19,11 +19,10 @@ import ( ) const ( - buildQueueKey = "wrenn:build_queue" - buildCommandTimeout = 30 * time.Second - healthcheckInterval = 1 * time.Second - healthcheckTimeout = 60 * time.Second - platformTeamID = "platform" + buildQueueKey = "wrenn:build_queue" + buildCommandTimeout = 30 * time.Second + healthcheckInterval = 1 * time.Second + healthcheckTimeout = 60 * time.Second ) // 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() + buildIDStr := id.FormatBuildID(buildID) build, err := s.DB.InsertTemplateBuild(ctx, db.InsertTemplateBuildParams{ ID: buildID, Name: p.Name, BaseTemplate: p.BaseTemplate, Recipe: recipeJSON, - Healthcheck: pgtype.Text{String: p.Healthcheck, Valid: p.Healthcheck != ""}, + Healthcheck: p.Healthcheck, Vcpus: p.VCPUs, MemoryMb: p.MemoryMB, 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) } - // Enqueue build ID to Redis for workers to pick up. - if err := s.Redis.RPush(ctx, buildQueueKey, buildID).Err(); err != nil { + // Enqueue build ID (as formatted string) to Redis for workers to pick up. + if err := s.Redis.RPush(ctx, buildQueueKey, buildIDStr).Err(); err != nil { 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. -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) } @@ -140,15 +140,21 @@ func (s *BuildService) worker(ctx context.Context, workerID int) { time.Sleep(time.Second) continue } - // result[0] is the key, result[1] is the build ID. - buildID := result[1] - log.Info("picked up build", "build_id", buildID) - s.executeBuild(ctx, buildID) + // result[0] is the key, result[1] is the build ID (formatted string). + buildIDStr := result[1] + log.Info("picked up build", "build_id", buildIDStr) + s.executeBuild(ctx, buildIDStr) } } -func (s *BuildService) executeBuild(ctx context.Context, buildID string) { - log := slog.With("build_id", buildID) +func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) { + 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) if err != nil { @@ -172,7 +178,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { } // 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 { s.failBuild(ctx, buildID, fmt.Sprintf("no host available: %v", err)) return @@ -185,10 +191,11 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { } 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{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Template: build.BaseTemplate, Vcpus: build.Vcpus, MemoryMb: build.MemoryMb, @@ -203,8 +210,8 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { // Record sandbox/host association. _ = s.DB.UpdateBuildSandbox(ctx, db.UpdateBuildSandboxParams{ ID: buildID, - SandboxID: pgtype.Text{String: sandboxID, Valid: true}, - HostID: pgtype.Text{String: host.ID, Valid: true}, + SandboxID: sandboxID, + HostID: host.ID, }) // Execute recipe commands. @@ -216,7 +223,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { start := time.Now() execResp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Cmd: "/bin/sh", Args: []string{"-c", cmd}, TimeoutSec: int32(buildCommandTimeout.Seconds()), @@ -234,7 +241,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { entry.Ok = false logs = append(logs, entry) 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)) return } @@ -248,7 +255,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { s.updateLogs(ctx, buildID, i+1, logs) 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)) return } @@ -256,10 +263,10 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { // Healthcheck or direct snapshot. var sizeBytes int64 - if build.Healthcheck.Valid && build.Healthcheck.String != "" { - log.Info("running healthcheck", "cmd", build.Healthcheck.String) - if err := s.waitForHealthcheck(ctx, agent, sandboxID, build.Healthcheck.String); err != nil { - s.destroySandbox(ctx, agent, sandboxID) + if build.Healthcheck != "" { + log.Info("running healthcheck", "cmd", build.Healthcheck) + if err := s.waitForHealthcheck(ctx, agent, sandboxIDStr, build.Healthcheck); err != nil { + s.destroySandbox(ctx, agent, sandboxIDStr) s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", err)) return } @@ -267,11 +274,11 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { // Healthcheck passed → full snapshot (with memory/CPU state). log.Info("healthcheck passed, creating snapshot") snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Name: build.Name, })) if err != nil { - s.destroySandbox(ctx, agent, sandboxID) + s.destroySandbox(ctx, agent, sandboxIDStr) s.failBuild(ctx, buildID, fmt.Sprintf("create snapshot failed: %v", err)) return } @@ -280,11 +287,11 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { // No healthcheck → image-only template (rootfs only). log.Info("no healthcheck, flattening rootfs") flatResp, err := agent.FlattenRootfs(ctx, connect.NewRequest(&pb.FlattenRootfsRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Name: build.Name, })) if err != nil { - s.destroySandbox(ctx, agent, sandboxID) + s.destroySandbox(ctx, agent, sandboxIDStr) s.failBuild(ctx, buildID, fmt.Sprintf("flatten rootfs failed: %v", err)) return } @@ -293,17 +300,17 @@ func (s *BuildService) executeBuild(ctx context.Context, buildID string) { // Insert into templates table as a global (platform) template. templateType := "base" - if build.Healthcheck.Valid && build.Healthcheck.String != "" { + if build.Healthcheck != "" { templateType = "snapshot" } if _, err := s.DB.InsertTemplate(ctx, db.InsertTemplateParams{ Name: build.Name, Type: templateType, - Vcpus: pgtype.Int4{Int32: build.Vcpus, Valid: true}, - MemoryMb: pgtype.Int4{Int32: build.MemoryMb, Valid: true}, + Vcpus: build.Vcpus, + MemoryMb: build.MemoryMb, SizeBytes: sizeBytes, - TeamID: platformTeamID, + TeamID: id.PlatformTeamID, }); err != nil { log.Error("failed to insert template record", "error", err) // 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) } -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) defer deadline.Stop() ticker := time.NewTicker(healthcheckInterval) @@ -338,7 +345,7 @@ func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentC case <-ticker.C: execCtx, cancel := context.WithTimeout(ctx, 10*time.Second) resp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Cmd: "/bin/sh", Args: []string{"-c", cmd}, 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) if err != nil { 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) { - slog.Error("build failed", "build_id", buildID, "error", errMsg) +func (s *BuildService) failBuild(_ context.Context, buildID pgtype.UUID, errMsg string) { + 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). ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := s.DB.UpdateBuildError(ctx, db.UpdateBuildErrorParams{ ID: buildID, - Error: pgtype.Text{String: errMsg, Valid: true}, + Error: errMsg, }); 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. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, })); 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) } } diff --git a/internal/service/host.go b/internal/service/host.go index b3538df..195b9ff 100644 --- a/internal/service/host.go +++ b/internal/service/host.go @@ -32,10 +32,10 @@ type HostService struct { // HostCreateParams holds the parameters for creating a host. type HostCreateParams struct { Type string - TeamID string // required for BYOC, empty for regular + TeamID pgtype.UUID // required for BYOC, zero value for regular Provider string AvailabilityZone string - RequestingUserID string + RequestingUserID pgtype.UUID IsRequestorAdmin bool } @@ -103,7 +103,7 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat } } else { // 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") } 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. - if p.TeamID != "" { + if p.TeamID.Valid { team, err := s.DB.GetTeam(ctx, p.TeamID) if err != nil || team.DeletedAt.Valid { 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() - 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{ ID: hostID, Type: p.Type, - TeamID: teamID, - Provider: provider, - AvailabilityZone: az, + TeamID: p.TeamID, + Provider: p.Provider, + AvailabilityZone: p.AvailabilityZone, CreatedBy: p.RequestingUserID, }) if err != nil { @@ -166,8 +153,8 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat tokenID := id.NewHostTokenID() payload, _ := json.Marshal(regTokenPayload{ - HostID: hostID, - TokenID: tokenID, + HostID: id.FormatHostID(hostID), + TokenID: id.FormatHostTokenID(tokenID), }) if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil { 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, ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true}, }); 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 @@ -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" // status. This allows retry when a previous registration attempt failed after // 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) if err != nil { 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" { 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") } 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() payload, _ := json.Marshal(regTokenPayload{ - HostID: hostID, - TokenID: tokenID, + HostID: id.FormatHostID(hostID), + TokenID: id.FormatHostTokenID(tokenID), }) if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil { 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, ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true}, }); 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 @@ -262,24 +249,33 @@ func (s *HostService) Register(ctx context.Context, p HostRegisterParams) (HostR 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) } // 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 { return HostRegisterResult{}, fmt.Errorf("sign host token: %w", err) } // Atomically update only if still pending (defense-in-depth against races). rowsAffected, err := s.DB.RegisterHost(ctx, db.RegisterHostParams{ - ID: payload.HostID, - Arch: pgtype.Text{String: p.Arch, Valid: p.Arch != ""}, - CpuCores: pgtype.Int4{Int32: p.CPUCores, Valid: p.CPUCores > 0}, - MemoryMb: pgtype.Int4{Int32: p.MemoryMB, Valid: p.MemoryMB > 0}, - DiskGb: pgtype.Int4{Int32: p.DiskGB, Valid: p.DiskGB > 0}, - Address: pgtype.Text{String: p.Address, Valid: p.Address != ""}, + ID: hostID, + Arch: p.Arch, + CpuCores: p.CPUCores, + MemoryMb: p.MemoryMB, + DiskGb: p.DiskGB, + Address: p.Address, }) if err != nil { 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. - 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) } // Issue a long-lived refresh token. - refreshToken, err := s.issueRefreshToken(ctx, payload.HostID) + refreshToken, err := s.issueRefreshToken(ctx, hostID) if err != nil { return HostRegisterResult{}, fmt.Errorf("issue refresh token: %w", err) } // 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 { 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 // 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() hash := hashToken(token) now := time.Now() @@ -375,7 +371,7 @@ func hashToken(token string) string { // Heartbeat updates the last heartbeat timestamp for a host and transitions // 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). -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) if err != nil { return err @@ -388,21 +384,21 @@ func (s *HostService) Heartbeat(ctx context.Context, hostID string) error { // List returns hosts visible to the caller. // 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 { 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. -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) if err != nil { return db.Host{}, fmt.Errorf("host not found: %w", err) } 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") } } @@ -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 // 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) { - host, err := s.checkDeletePermission(ctx, hostID, "", teamID, isAdmin) +func (s *HostService) DeletePreview(ctx context.Context, hostID, teamID pgtype.UUID, isAdmin bool) (HostDeletePreview, error) { + host, err := s.checkDeletePermission(ctx, hostID, pgtype.UUID{}, teamID, isAdmin) if err != nil { return HostDeletePreview{}, err } @@ -427,7 +423,7 @@ func (s *HostService) DeletePreview(ctx context.Context, hostID, teamID string, ids := make([]string, len(sandboxes)) for i, sb := range sandboxes { - ids[i] = sb.ID + ids[i] = id.FormatSandboxID(sb.ID) } 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 // sandboxes so the caller can present a confirmation. With force it gracefully // 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) if err != nil { return err @@ -453,35 +449,37 @@ func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string, if len(sandboxes) > 0 && !force { ids := make([]string, len(sandboxes)) for i, sb := range sandboxes { - ids[i] = sb.ID + ids[i] = id.FormatSandboxID(sb.ID) } return &HostHasSandboxesError{SandboxIDs: ids} } + hostIDStr := id.FormatHostID(hostID) + // 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) if err == nil { for _, sb := range sandboxes { if sb.Status == "running" || sb.Status == "starting" { _, rpcErr := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ - SandboxId: sb.ID, + SandboxId: id.FormatSandboxID(sb.ID), })) 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. 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. if len(sandboxes) > 0 { - sbIDs := make([]string, len(sandboxes)) + sbIDs := make([]pgtype.UUID, len(sandboxes)) for i, sb := range sandboxes { sbIDs[i] = sb.ID } @@ -489,18 +487,18 @@ func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string, Column1: sbIDs, Status: "stopped", }); 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. 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. if s.Pool != nil { - s.Pool.Evict(hostID) + s.Pool.Evict(id.FormatHostID(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 // 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) if err != nil { 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" { 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") } - if userID != "" { + if userID.Valid { membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ UserID: userID, TeamID: teamID, @@ -545,7 +543,7 @@ func (s *HostService) checkDeletePermission(ctx context.Context, hostID, userID, } // 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 { return err } @@ -553,7 +551,7 @@ func (s *HostService) AddTag(ctx context.Context, hostID, teamID string, isAdmin } // 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 { return err } @@ -561,7 +559,7 @@ func (s *HostService) RemoveTag(ctx context.Context, hostID, teamID string, isAd } // 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 { return nil, err } diff --git a/internal/service/sandbox.go b/internal/service/sandbox.go index 142b9bd..89e40c5 100644 --- a/internal/service/sandbox.go +++ b/internal/service/sandbox.go @@ -27,7 +27,7 @@ type SandboxService struct { // SandboxCreateParams holds the parameters for creating a sandbox. type SandboxCreateParams struct { - TeamID string + TeamID pgtype.UUID Template string VCPUs int32 MemoryMB int32 @@ -35,7 +35,7 @@ type SandboxCreateParams struct { } // 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) if err != nil { 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 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.Int32 - } - if tmpl.MemoryMb.Valid { - p.MemoryMB = tmpl.MemoryMb.Int32 - } + p.VCPUs = tmpl.Vcpus + p.MemoryMB = tmpl.MemoryMb } - if p.TeamID == "" { + if !p.TeamID.Valid { 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() + sandboxIDStr := id.FormatSandboxID(sandboxID) if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{ 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{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, Template: p.Template, Vcpus: p.VCPUs, 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{ ID: sandboxID, Status: "error", }); 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) } @@ -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. -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) } // 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}) } // 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}) if err != nil { 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 } + sandboxIDStr := id.FormatSandboxID(sandboxID) + // Flush all metrics tiers before pausing so data survives in DB. s.flushAndPersistMetrics(ctx, agent, sandboxID, true) if _, err := agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, })); err != nil { 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. -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}) if err != nil { 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 } + sandboxIDStr := id.FormatSandboxID(sandboxID) + resp, err := agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, TimeoutSec: sb.TimeoutSec, })) 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. -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}) if err != nil { return fmt.Errorf("sandbox not found: %w", err) @@ -251,6 +252,8 @@ func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string) return err } + sandboxIDStr := id.FormatSandboxID(sandboxID) + // If running, flush 24h tier metrics for analytics before destroying. if sb.Status == "running" { 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. if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, })); err != nil && connect.CodeOf(err) != connect.CodeNotFound { 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 // the returned data to DB. If allTiers is true, all three tiers are saved; // 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{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, })) 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 } msg := resp.Msg @@ -301,7 +305,8 @@ func (s *SandboxService) flushAndPersistMetrics(ctx context.Context, agent hosta 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 { if err := s.DB.InsertSandboxMetricPoint(ctx, db.InsertSandboxMetricPointParams{ SandboxID: sandboxID, @@ -311,13 +316,13 @@ func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID, tie MemBytes: p.MemBytes, DiskBytes: p.DiskBytes, }); 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. -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}) if err != nil { 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 } + sandboxIDStr := id.FormatSandboxID(sandboxID) + if _, err := agent.PingSandbox(ctx, connect.NewRequest(&pb.PingSandboxRequest{ - SandboxId: sandboxID, + SandboxId: sandboxIDStr, })); err != nil { return fmt.Errorf("agent ping: %w", err) } @@ -344,7 +351,7 @@ func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) err Valid: true, }, }); 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 } diff --git a/internal/service/stats.go b/internal/service/stats.go index 1a075aa..88cace7 100644 --- a/internal/service/stats.go +++ b/internal/service/stats.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "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 // 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] if !ok { return CurrentStats{}, PeakStats{}, nil, fmt.Errorf("unknown range: %s", r) @@ -132,7 +133,7 @@ GROUP BY bucket 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) if err != nil { return nil, err diff --git a/internal/service/team.go b/internal/service/team.go index d4c911c..667cd04 100644 --- a/internal/service/team.go +++ b/internal/service/team.go @@ -9,6 +9,7 @@ import ( "connectrpc.com/connect" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "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. // 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{ UserID: callerUserID, 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. -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) if err != nil { 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. -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) if err != nil { 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. -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) { 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). -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) { 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. // 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. -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) if err != nil { return err @@ -174,16 +175,16 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID strin return fmt.Errorf("list active sandboxes: %w", err) } - var stopIDs []string + var stopIDs []pgtype.UUID for _, sb := range sandboxes { host, hostErr := s.DB.GetHost(ctx, sb.HostID) if hostErr == nil { agent, agentErr := s.HostPool.GetForHost(host) if agentErr == nil { if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ - SandboxId: sb.ID, + SandboxId: id.FormatSandboxID(sb.ID), })); 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. -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) if err != nil { 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 } members[i] = MemberInfo{ - UserID: r.ID, + UserID: id.FormatUserID(r.ID), Name: r.Name, Email: r.Email, 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. // 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) if err != nil { 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{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. // 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) if err != nil { 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. // Caller must be admin or owner (verified from DB). Owner's role cannot be changed. // 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" { 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. // 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) if err != nil { 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 // be disabled — it is a one-way transition. // 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) if err != nil { return fmt.Errorf("team not found: %w", err) diff --git a/internal/service/template.go b/internal/service/template.go index d669e45..22bc4d6 100644 --- a/internal/service/template.go +++ b/internal/service/template.go @@ -3,6 +3,8 @@ package service import ( "context" + "github.com/jackc/pgx/v5/pgtype" + "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 // 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 != "" { return s.DB.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{ TeamID: teamID,