From e4ead076e363da61f9bcc230acd882562f466dbb Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 17 Mar 2026 03:26:42 +0600 Subject: [PATCH] Add admin users, BYOC teams, hosts schema, and Redis for host registration Introduce three migrations: admin permissions (is_admin + permissions table), BYOC team tracking, and multi-host support (hosts, host_tokens, host_tags). Add Redis to dev infra and wire up client in control plane for ephemeral host registration tokens. Add go-redis dependency. --- .env.example | 3 + cmd/control-plane/main.go | 18 + db/migrations/20260316203135_admin_users.sql | 21 + db/migrations/20260316203138_byoc_teams.sql | 9 + db/migrations/20260316203142_hosts.sql | 47 ++ db/queries/hosts.sql | 66 +++ db/queries/teams.sql | 6 + db/queries/users.sql | 21 + deploy/docker-compose.dev.yml | 5 + go.mod | 4 + go.sum | 16 + internal/config/config.go | 2 + internal/db/hosts.sql.go | 484 +++++++++++++++++++ internal/db/models.go | 42 ++ internal/db/teams.sql.go | 70 ++- internal/db/users.sql.go | 134 ++++- 16 files changed, 938 insertions(+), 10 deletions(-) create mode 100644 db/migrations/20260316203135_admin_users.sql create mode 100644 db/migrations/20260316203138_byoc_teams.sql create mode 100644 db/migrations/20260316203142_hosts.sql create mode 100644 internal/db/hosts.sql.go diff --git a/.env.example b/.env.example index d76cd77..0b40c9a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Database DATABASE_URL=postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable +# Redis +REDIS_URL=redis://localhost:6379/0 + # Control Plane CP_LISTEN_ADDR=:8000 CP_HOST_AGENT_ADDR=localhost:50051 diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index 23a488e..95e600f 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" "git.omukk.dev/wrenn/sandbox/internal/api" "git.omukk.dev/wrenn/sandbox/internal/auth/oauth" @@ -50,6 +51,23 @@ func main() { queries := db.New(pool) + // Redis client. + redisOpts, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + slog.Error("failed to parse REDIS_URL", "error", err) + os.Exit(1) + } + rdb := redis.NewClient(redisOpts) + defer rdb.Close() + + if err := rdb.Ping(ctx).Err(); err != nil { + slog.Error("failed to ping redis", "error", err) + os.Exit(1) + } + slog.Info("connected to redis") + + _ = rdb // TODO: pass to services that need it (host registration) + // Connect RPC client for the host agent. agentHTTP := &http.Client{Timeout: 10 * time.Minute} agentClient := hostagentv1connect.NewHostAgentServiceClient( diff --git a/db/migrations/20260316203135_admin_users.sql b/db/migrations/20260316203135_admin_users.sql new file mode 100644 index 0000000..eff669b --- /dev/null +++ b/db/migrations/20260316203135_admin_users.sql @@ -0,0 +1,21 @@ +-- +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 new file mode 100644 index 0000000..bb2c8ec --- /dev/null +++ b/db/migrations/20260316203138_byoc_teams.sql @@ -0,0 +1,9 @@ +-- +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 new file mode 100644 index 0000000..372b380 --- /dev/null +++ b/db/migrations/20260316203142_hosts.sql @@ -0,0 +1,47 @@ +-- +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/queries/hosts.sql b/db/queries/hosts.sql index e69de29..b610cbd 100644 --- a/db/queries/hosts.sql +++ b/db/queries/hosts.sql @@ -0,0 +1,66 @@ +-- name: InsertHost :one +INSERT INTO hosts (id, type, team_id, provider, availability_zone, created_by) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetHost :one +SELECT * FROM hosts WHERE id = $1; + +-- name: ListHosts :many +SELECT * FROM hosts ORDER BY created_at DESC; + +-- name: ListHostsByType :many +SELECT * FROM hosts WHERE type = $1 ORDER BY created_at DESC; + +-- name: ListHostsByTeam :many +SELECT * FROM hosts WHERE team_id = $1 ORDER BY created_at DESC; + +-- name: ListHostsByStatus :many +SELECT * FROM hosts WHERE status = $1 ORDER BY created_at DESC; + +-- name: RegisterHost :exec +UPDATE hosts +SET arch = $2, + cpu_cores = $3, + memory_mb = $4, + disk_gb = $5, + address = $6, + status = 'online', + last_heartbeat_at = NOW(), + updated_at = NOW() +WHERE id = $1; + +-- name: UpdateHostStatus :exec +UPDATE hosts SET status = $2, updated_at = NOW() WHERE id = $1; + +-- name: UpdateHostHeartbeat :exec +UPDATE hosts SET last_heartbeat_at = NOW(), updated_at = NOW() WHERE id = $1; + +-- name: DeleteHost :exec +DELETE FROM hosts WHERE id = $1; + +-- name: AddHostTag :exec +INSERT INTO host_tags (host_id, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING; + +-- name: RemoveHostTag :exec +DELETE FROM host_tags WHERE host_id = $1 AND tag = $2; + +-- name: GetHostTags :many +SELECT tag FROM host_tags WHERE host_id = $1 ORDER BY tag; + +-- name: ListHostsByTag :many +SELECT h.* FROM hosts h +JOIN host_tags ht ON ht.host_id = h.id +WHERE ht.tag = $1 +ORDER BY h.created_at DESC; + +-- name: InsertHostToken :one +INSERT INTO host_tokens (id, host_id, created_by, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: MarkHostTokenUsed :exec +UPDATE host_tokens SET used_at = NOW() WHERE id = $1; + +-- name: GetHostTokensByHost :many +SELECT * FROM host_tokens WHERE host_id = $1 ORDER BY created_at DESC; diff --git a/db/queries/teams.sql b/db/queries/teams.sql index f4c4633..5442c6d 100644 --- a/db/queries/teams.sql +++ b/db/queries/teams.sql @@ -15,3 +15,9 @@ SELECT t.* FROM teams t JOIN users_teams ut ON ut.team_id = t.id WHERE ut.user_id = $1 AND ut.is_default = TRUE LIMIT 1; + +-- name: SetTeamBYOC :exec +UPDATE teams SET is_byoc = $2 WHERE id = $1; + +-- name: GetBYOCTeams :many +SELECT * FROM teams WHERE is_byoc = TRUE ORDER BY created_at; diff --git a/db/queries/users.sql b/db/queries/users.sql index fe2be57..3c2f4f0 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -13,3 +13,24 @@ SELECT * FROM users WHERE id = $1; INSERT INTO users (id, email) VALUES ($1, $2) RETURNING *; + +-- name: SetUserAdmin :exec +UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1; + +-- name: GetAdminUsers :many +SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at; + +-- name: InsertAdminPermission :exec +INSERT INTO admin_permissions (id, user_id, permission) +VALUES ($1, $2, $3); + +-- name: DeleteAdminPermission :exec +DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2; + +-- name: GetAdminPermissions :many +SELECT * FROM admin_permissions WHERE user_id = $1 ORDER BY permission; + +-- name: HasAdminPermission :one +SELECT EXISTS( + SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 +) AS has_permission; diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 5a32c91..ebcd308 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -10,6 +10,11 @@ services: volumes: - pgdata:/var/lib/postgresql/data + redis: + image: redis:7-alpine + ports: + - "6379:6379" + prometheus: image: prom/prometheus:latest ports: diff --git a/go.mod b/go.mod index 8a9a07c..aaa473d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.18.0 github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f golang.org/x/crypto v0.49.0 @@ -19,9 +20,12 @@ require ( ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 84f9d91..752cbd6 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,16 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -23,8 +31,12 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -35,6 +47,10 @@ github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5/go.mod h1:tw github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/internal/config/config.go b/internal/config/config.go index 2c55e38..b881afb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( // Config holds the control plane configuration. type Config struct { DatabaseURL string + RedisURL string ListenAddr string HostAgentAddr string JWTSecret string @@ -28,6 +29,7 @@ func Load() Config { cfg := Config{ DatabaseURL: envOrDefault("DATABASE_URL", "postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable"), + RedisURL: envOrDefault("REDIS_URL", "redis://localhost:6379/0"), ListenAddr: envOrDefault("CP_LISTEN_ADDR", ":8080"), HostAgentAddr: envOrDefault("CP_HOST_AGENT_ADDR", "http://localhost:50051"), JWTSecret: os.Getenv("JWT_SECRET"), diff --git a/internal/db/hosts.sql.go b/internal/db/hosts.sql.go new file mode 100644 index 0000000..032c6f1 --- /dev/null +++ b/internal/db/hosts.sql.go @@ -0,0 +1,484 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: hosts.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addHostTag = `-- name: AddHostTag :exec +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"` +} + +func (q *Queries) AddHostTag(ctx context.Context, arg AddHostTagParams) error { + _, err := q.db.Exec(ctx, addHostTag, arg.HostID, arg.Tag) + return err +} + +const deleteHost = `-- name: DeleteHost :exec +DELETE FROM hosts WHERE id = $1 +` + +func (q *Queries) DeleteHost(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteHost, id) + return err +} + +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 FROM hosts WHERE id = $1 +` + +func (q *Queries) GetHost(ctx context.Context, id string) (Host, error) { + row := q.db.QueryRow(ctx, getHost, id) + var i Host + err := row.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +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) { + rows, err := q.db.Query(ctx, getHostTags, hostID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, err + } + items = append(items, tag) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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) { + rows, err := q.db.Query(ctx, getHostTokensByHost, hostID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []HostToken + for rows.Next() { + var i HostToken + if err := rows.Scan( + &i.ID, + &i.HostID, + &i.CreatedBy, + &i.CreatedAt, + &i.ExpiresAt, + &i.UsedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertHost = `-- name: InsertHost :one +INSERT INTO hosts (id, type, team_id, provider, availability_zone, created_by) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING 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 +` + +type InsertHostParams struct { + ID string `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"` +} + +func (q *Queries) InsertHost(ctx context.Context, arg InsertHostParams) (Host, error) { + row := q.db.QueryRow(ctx, insertHost, + arg.ID, + arg.Type, + arg.TeamID, + arg.Provider, + arg.AvailabilityZone, + arg.CreatedBy, + ) + var i Host + err := row.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertHostToken = `-- name: InsertHostToken :one +INSERT INTO host_tokens (id, host_id, created_by, expires_at) +VALUES ($1, $2, $3, $4) +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"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) InsertHostToken(ctx context.Context, arg InsertHostTokenParams) (HostToken, error) { + row := q.db.QueryRow(ctx, insertHostToken, + arg.ID, + arg.HostID, + arg.CreatedBy, + arg.ExpiresAt, + ) + var i HostToken + err := row.Scan( + &i.ID, + &i.HostID, + &i.CreatedBy, + &i.CreatedAt, + &i.ExpiresAt, + &i.UsedAt, + ) + return i, err +} + +const listHosts = `-- name: ListHosts :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 FROM hosts ORDER BY created_at DESC +` + +func (q *Queries) ListHosts(ctx context.Context) ([]Host, error) { + rows, err := q.db.Query(ctx, listHosts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHostsByStatus = `-- name: ListHostsByStatus :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 FROM hosts WHERE status = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListHostsByStatus(ctx context.Context, status string) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHostsByTag = `-- name: ListHostsByTag :many +SELECT h.id, h.type, h.team_id, h.provider, h.availability_zone, h.arch, h.cpu_cores, h.memory_mb, h.disk_gb, h.address, h.status, h.last_heartbeat_at, h.metadata, h.created_by, h.created_at, h.updated_at FROM hosts h +JOIN host_tags ht ON ht.host_id = h.id +WHERE ht.tag = $1 +ORDER BY h.created_at DESC +` + +func (q *Queries) ListHostsByTag(ctx context.Context, tag string) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByTag, tag) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 FROM hosts WHERE team_id = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListHostsByTeam(ctx context.Context, teamID pgtype.Text) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHostsByType = `-- name: ListHostsByType :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 FROM hosts WHERE type = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListHostsByType(ctx context.Context, type_ string) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByType, type_) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 { + _, err := q.db.Exec(ctx, markHostTokenUsed, id) + return err +} + +const registerHost = `-- name: RegisterHost :exec +UPDATE hosts +SET arch = $2, + cpu_cores = $3, + memory_mb = $4, + disk_gb = $5, + address = $6, + status = 'online', + last_heartbeat_at = NOW(), + updated_at = NOW() +WHERE id = $1 +` + +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"` +} + +func (q *Queries) RegisterHost(ctx context.Context, arg RegisterHostParams) error { + _, err := q.db.Exec(ctx, registerHost, + arg.ID, + arg.Arch, + arg.CpuCores, + arg.MemoryMb, + arg.DiskGb, + arg.Address, + ) + return err +} + +const removeHostTag = `-- name: RemoveHostTag :exec +DELETE FROM host_tags WHERE host_id = $1 AND tag = $2 +` + +type RemoveHostTagParams struct { + HostID string `json:"host_id"` + Tag string `json:"tag"` +} + +func (q *Queries) RemoveHostTag(ctx context.Context, arg RemoveHostTagParams) error { + _, err := q.db.Exec(ctx, removeHostTag, arg.HostID, arg.Tag) + return err +} + +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 { + _, err := q.db.Exec(ctx, updateHostHeartbeat, id) + return err +} + +const updateHostStatus = `-- name: UpdateHostStatus :exec +UPDATE hosts SET status = $2, updated_at = NOW() WHERE id = $1 +` + +type UpdateHostStatusParams struct { + ID string `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) UpdateHostStatus(ctx context.Context, arg UpdateHostStatusParams) error { + _, err := q.db.Exec(ctx, updateHostStatus, arg.ID, arg.Status) + return err +} diff --git a/internal/db/models.go b/internal/db/models.go index 3140907..d6faddb 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -8,6 +8,46 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type AdminPermission struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Permission string `json:"permission"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Host struct { + ID string `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"` + Status string `json:"status"` + LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"` + Metadata []byte `json:"metadata"` + CreatedBy string `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type HostTag struct { + HostID string `json:"host_id"` + Tag string `json:"tag"` +} + +type HostToken struct { + ID string `json:"id"` + HostID string `json:"host_id"` + CreatedBy string `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + UsedAt pgtype.Timestamptz `json:"used_at"` +} + type OauthProvider struct { Provider string `json:"provider"` ProviderID string `json:"provider_id"` @@ -37,6 +77,7 @@ type Team struct { ID string `json:"id"` Name string `json:"name"` CreatedAt pgtype.Timestamptz `json:"created_at"` + IsByoc bool `json:"is_byoc"` } type TeamApiKey struct { @@ -66,6 +107,7 @@ type User struct { PasswordHash pgtype.Text `json:"password_hash"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + IsAdmin bool `json:"is_admin"` } type UsersTeam struct { diff --git a/internal/db/teams.sql.go b/internal/db/teams.sql.go index 61d03bb..814ae21 100644 --- a/internal/db/teams.sql.go +++ b/internal/db/teams.sql.go @@ -9,8 +9,37 @@ import ( "context" ) +const getBYOCTeams = `-- name: GetBYOCTeams :many +SELECT id, name, created_at, is_byoc FROM teams WHERE is_byoc = TRUE ORDER BY created_at +` + +func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { + rows, err := q.db.Query(ctx, getBYOCTeams) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Team + for rows.Next() { + var i Team + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one -SELECT t.id, t.name, t.created_at FROM teams t +SELECT t.id, t.name, t.created_at, t.is_byoc FROM teams t JOIN users_teams ut ON ut.team_id = t.id WHERE ut.user_id = $1 AND ut.is_default = TRUE LIMIT 1 @@ -19,25 +48,35 @@ LIMIT 1 func (q *Queries) GetDefaultTeamForUser(ctx context.Context, userID string) (Team, error) { row := q.db.QueryRow(ctx, getDefaultTeamForUser, userID) var i Team - err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ) return i, err } const getTeam = `-- name: GetTeam :one -SELECT id, name, created_at FROM teams WHERE id = $1 +SELECT id, name, created_at, is_byoc FROM teams WHERE id = $1 ` func (q *Queries) GetTeam(ctx context.Context, id string) (Team, error) { row := q.db.QueryRow(ctx, getTeam, id) var i Team - err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ) return i, err } const insertTeam = `-- name: InsertTeam :one INSERT INTO teams (id, name) VALUES ($1, $2) -RETURNING id, name, created_at +RETURNING id, name, created_at, is_byoc ` type InsertTeamParams struct { @@ -48,7 +87,12 @@ type InsertTeamParams struct { func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, error) { row := q.db.QueryRow(ctx, insertTeam, arg.ID, arg.Name) var i Team - err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ) return i, err } @@ -73,3 +117,17 @@ func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberPara ) return err } + +const setTeamBYOC = `-- name: SetTeamBYOC :exec +UPDATE teams SET is_byoc = $2 WHERE id = $1 +` + +type SetTeamBYOCParams struct { + ID string `json:"id"` + IsByoc bool `json:"is_byoc"` +} + +func (q *Queries) SetTeamBYOC(ctx context.Context, arg SetTeamBYOCParams) error { + _, err := q.db.Exec(ctx, setTeamBYOC, arg.ID, arg.IsByoc) + return err +} diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go index 0ecbe5f..dd975e7 100644 --- a/internal/db/users.sql.go +++ b/internal/db/users.sql.go @@ -11,8 +11,82 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const deleteAdminPermission = `-- name: DeleteAdminPermission :exec +DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2 +` + +type DeleteAdminPermissionParams struct { + UserID string `json:"user_id"` + Permission string `json:"permission"` +} + +func (q *Queries) DeleteAdminPermission(ctx context.Context, arg DeleteAdminPermissionParams) error { + _, err := q.db.Exec(ctx, deleteAdminPermission, arg.UserID, arg.Permission) + return err +} + +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) { + rows, err := q.db.Query(ctx, getAdminPermissions, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AdminPermission + for rows.Next() { + var i AdminPermission + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Permission, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAdminUsers = `-- name: GetAdminUsers :many +SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE is_admin = TRUE ORDER BY created_at +` + +func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.Query(ctx, getAdminUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsAdmin, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = $1 +SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE email = $1 ` func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { @@ -24,12 +98,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.PasswordHash, &i.CreatedAt, &i.UpdatedAt, + &i.IsAdmin, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, password_hash, created_at, updated_at FROM users WHERE id = $1 +SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { @@ -41,14 +116,49 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { &i.PasswordHash, &i.CreatedAt, &i.UpdatedAt, + &i.IsAdmin, ) return i, err } +const hasAdminPermission = `-- name: HasAdminPermission :one +SELECT EXISTS( + SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 +) AS has_permission +` + +type HasAdminPermissionParams struct { + UserID string `json:"user_id"` + Permission string `json:"permission"` +} + +func (q *Queries) HasAdminPermission(ctx context.Context, arg HasAdminPermissionParams) (bool, error) { + row := q.db.QueryRow(ctx, hasAdminPermission, arg.UserID, arg.Permission) + var has_permission bool + err := row.Scan(&has_permission) + return has_permission, err +} + +const insertAdminPermission = `-- name: InsertAdminPermission :exec +INSERT INTO admin_permissions (id, user_id, permission) +VALUES ($1, $2, $3) +` + +type InsertAdminPermissionParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Permission string `json:"permission"` +} + +func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPermissionParams) error { + _, err := q.db.Exec(ctx, insertAdminPermission, arg.ID, arg.UserID, arg.Permission) + return err +} + const insertUser = `-- name: InsertUser :one INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3) -RETURNING id, email, password_hash, created_at, updated_at +RETURNING id, email, password_hash, created_at, updated_at, is_admin ` type InsertUserParams struct { @@ -66,6 +176,7 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e &i.PasswordHash, &i.CreatedAt, &i.UpdatedAt, + &i.IsAdmin, ) return i, err } @@ -73,7 +184,7 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e const insertUserOAuth = `-- name: InsertUserOAuth :one INSERT INTO users (id, email) VALUES ($1, $2) -RETURNING id, email, password_hash, created_at, updated_at +RETURNING id, email, password_hash, created_at, updated_at, is_admin ` type InsertUserOAuthParams struct { @@ -90,6 +201,21 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams &i.PasswordHash, &i.CreatedAt, &i.UpdatedAt, + &i.IsAdmin, ) return i, err } + +const setUserAdmin = `-- name: SetUserAdmin :exec +UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1 +` + +type SetUserAdminParams struct { + ID string `json:"id"` + IsAdmin bool `json:"is_admin"` +} + +func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) error { + _, err := q.db.Exec(ctx, setUserAdmin, arg.ID, arg.IsAdmin) + return err +}