1package catalog23import (4 "context"5 "database/sql"6 "errors"7 "fmt"8 "os"9 "path/filepath"10 "sort"11 "strings"12 "sync"13 "time"1415 "github.com/google/uuid"16 _ "modernc.org/sqlite"17)1819type Status string2021const (22 // StatusCreating identifies a tenant whose directories or services are23 // still being initialized.24 StatusCreating Status = "creating"25 // StatusReady identifies a tenant available for normal service.26 StatusReady Status = "ready"27 // StatusDisabled identifies a tenant stopped by the operator and eligible28 // for reactivation or deletion.29 StatusDisabled Status = "disabled"30 // StatusDeleting identifies a tenant whose deletion intent is recorded.31 StatusDeleting Status = "deleting"32)3334var (35 ErrNotFound = errors.New("catalog item not found")36 ErrNameTaken = errors.New("tenant name is already in use")37 ErrHostTaken = errors.New("hostname is already in use")38 ErrOwnerMismatch = errors.New("current tenant owner does not match")39 ErrInvalidTransition = errors.New("invalid tenant status transition")40 ErrClosed = errors.New("catalog is closed")41)4243type CreateOptions struct {44 // Name is the operator-visible unique tenant name.45 Name string46 // Owner is the tenant owner's public key.47 Owner string48 // Template identifies the configuration template used during setup.49 Template string50 // Source records where the tenant configuration came from.51 Source string52}5354// Paths contains the private filesystem locations assigned to a tenant.55type Paths struct {56 Root string57 Database string58 Blobs string59 Git string60}6162type Tenant struct {63 ID string64 Name string65 Owner string66 Template string67 Source string68 Status Status69 Host string70 CreatedAt time.Time71 UpdatedAt time.Time72 Paths Paths73}7475type Catalog struct {76 db *sql.DB77 dataRoot string78 mu sync.RWMutex79 closed bool80}8182func Open(ctx context.Context, dataRoot string) (*Catalog, error) {83 if err := contextError(ctx); err != nil {84 return nil, err85 }86 if dataRoot == "" {87 return nil, errors.New("catalog data root is empty")88 }89 if err := os.MkdirAll(dataRoot, 0o700); err != nil {90 return nil, fmt.Errorf("create catalog data root: %w", err)91 }92 db, err := sql.Open("sqlite", filepath.Join(dataRoot, "catalog.db"))93 if err != nil {94 return nil, fmt.Errorf("open catalog database: %w", err)95 }96 db.SetMaxOpenConns(1)97 db.SetMaxIdleConns(1)98 cat := &Catalog{db: db, dataRoot: dataRoot}99 if err := cat.configure(ctx); err != nil {100 _ = db.Close()101 return nil, err102 }103 return cat, nil104}105106func (c *Catalog) configure(ctx context.Context) error {107 statements := []string{108 "PRAGMA busy_timeout = 5000",109 "PRAGMA foreign_keys = ON",110 "PRAGMA synchronous = FULL",111 "PRAGMA journal_mode = WAL",112 `CREATE TABLE IF NOT EXISTS tenants (113 id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE,114 owner TEXT NOT NULL, template TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', status TEXT NOT NULL,115 host TEXT COLLATE NOCASE UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,116 CHECK(status IN ('creating','ready','disabled','deleting'))117 )`,118 `CREATE TABLE IF NOT EXISTS tenant_hosts (119 tenant_id TEXT NOT NULL, host TEXT PRIMARY KEY COLLATE NOCASE,120 site TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'configured',121 created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,122 FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE123 )`,124 `CREATE TABLE IF NOT EXISTS tenant_deletions (125 id TEXT PRIMARY KEY, root TEXT NOT NULL, staging TEXT NOT NULL,126 started_at INTEGER NOT NULL127 )`,128 }129 for _, statement := range statements {130 if _, err := c.db.ExecContext(ctx, statement); err != nil {131 return fmt.Errorf("configure catalog: %w", err)132 }133 }134 if err := c.ensureSourceColumn(ctx); err != nil {135 return err136 }137 if err := c.migrateTenantStatus(ctx); err != nil {138 return err139 }140 if err := c.recoverDeletions(ctx); err != nil {141 return err142 }143 return nil144}145146func (c *Catalog) migrateTenantStatus(ctx context.Context) error {147 var definition string148 if err := c.db.QueryRowContext(ctx, "SELECT sql FROM sqlite_schema WHERE type='table' AND name='tenants'").Scan(&definition); err != nil {149 return fmt.Errorf("inspect tenant status schema: %w", err)150 }151 if strings.Contains(strings.ToLower(definition), "'deleting'") {152 return nil153 }154 tx, err := c.db.BeginTx(ctx, nil)155 if err != nil {156 return fmt.Errorf("begin tenant status migration: %w", err)157 }158 defer tx.Rollback()159 statements := []string{160 "ALTER TABLE tenants RENAME TO tenants_legacy",161 `CREATE TABLE tenants (162 id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE,163 owner TEXT NOT NULL, template TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', status TEXT NOT NULL,164 host TEXT COLLATE NOCASE UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,165 CHECK(status IN ('creating','ready','disabled','deleting'))166 )`,167 "INSERT INTO tenants(id,name,owner,template,source,status,host,created_at,updated_at) SELECT id,name,owner,template,source,status,host,created_at,updated_at FROM tenants_legacy",168 "DROP TABLE tenants_legacy",169 }170 for _, statement := range statements {171 if _, err := tx.ExecContext(ctx, statement); err != nil {172 return fmt.Errorf("migrate tenant status schema: %w", err)173 }174 }175 if err := tx.Commit(); err != nil {176 return fmt.Errorf("commit tenant status migration: %w", err)177 }178 return nil179}180181func (c *Catalog) ensureSourceColumn(ctx context.Context) error {182 rows, err := c.db.QueryContext(ctx, "PRAGMA table_info(tenants)")183 if err != nil {184 return fmt.Errorf("inspect tenant schema: %w", err)185 }186 defer rows.Close()187 for rows.Next() {188 var cid, notNull, primary int189 var name, typ string190 var defaultValue sql.NullString191 if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &primary); err != nil {192 return err193 }194 if name == "source" {195 return nil196 }197 }198 if err := rows.Err(); err != nil {199 return err200 }201 if _, err := c.db.ExecContext(ctx, "ALTER TABLE tenants ADD COLUMN source TEXT NOT NULL DEFAULT ''"); err != nil {202 return fmt.Errorf("migrate tenant source: %w", err)203 }204 return nil205}206207func (c *Catalog) Close() error {208 c.mu.Lock()209 defer c.mu.Unlock()210 if c.closed {211 return nil212 }213 c.closed = true214 return c.db.Close()215}216217func (c *Catalog) Create(ctx context.Context, options CreateOptions) (Tenant, error) {218 if err := contextError(ctx); err != nil {219 return Tenant{}, err220 }221 name, err := normalizeName(options.Name)222 if err != nil {223 return Tenant{}, err224 }225 owner, err := normalizeOwner(options.Owner)226 if err != nil {227 return Tenant{}, err228 }229 template, err := normalizeTemplate(options.Template)230 if err != nil {231 return Tenant{}, err232 }233 source, err := normalizeSource(options.Source)234 if err != nil {235 return Tenant{}, err236 }237 id := uuid.NewString()238 now := time.Now().UTC()239 _, err = c.db.ExecContext(ctx, `INSERT INTO tenants240 (id,name,owner,template,source,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`,241 id, name, owner, template, source, StatusCreating, now.UnixNano(), now.UnixNano())242 if err != nil {243 if isConstraint(err) {244 return Tenant{}, ErrNameTaken245 }246 return Tenant{}, fmt.Errorf("insert tenant: %w", err)247 }248 paths := c.tenantPaths(id)249 if err := makeTenantDirs(paths); err != nil {250 return Tenant{}, fmt.Errorf("prepare tenant %q: %w", id, err)251 }252 return Tenant{ID: id, Name: name, Owner: owner, Template: template, Source: source,253 Status: StatusCreating, CreatedAt: now, UpdatedAt: now, Paths: paths}, nil254}255256func (c *Catalog) MarkReady(ctx context.Context, id string) error {257 return c.transition(ctx, id, StatusCreating, StatusReady)258}259260func (c *Catalog) Disable(ctx context.Context, id string) error {261 return c.transition(ctx, id, StatusReady, StatusDisabled)262}263264func (c *Catalog) Enable(ctx context.Context, id string) error {265 return c.transition(ctx, id, StatusDisabled, StatusReady)266}267268func (c *Catalog) UpdateOwner(ctx context.Context, id, owner string) error {269 if err := contextError(ctx); err != nil {270 return err271 }272 owner, err := normalizeOwner(owner)273 if err != nil {274 return err275 }276 result, err := c.db.ExecContext(ctx, "UPDATE tenants SET owner=?,updated_at=? WHERE id=?", owner, time.Now().UTC().UnixNano(), id)277 if err != nil {278 return fmt.Errorf("update tenant owner: %w", err)279 }280 return changedOrNotFound(result)281}282283// TransferOwner changes ownership only when the expected current owner still holds it.284func (c *Catalog) TransferOwner(ctx context.Context, id, currentOwner, nextOwner string) error {285 if err := contextError(ctx); err != nil {286 return err287 }288 currentOwner, err := normalizeOwner(currentOwner)289 if err != nil {290 return err291 }292 nextOwner, err = normalizeOwner(nextOwner)293 if err != nil {294 return err295 }296 result, err := c.db.ExecContext(ctx, "UPDATE tenants SET owner=?,updated_at=? WHERE id=? AND owner=?", nextOwner, time.Now().UTC().UnixNano(), id, currentOwner)297 if err != nil {298 return fmt.Errorf("transfer tenant owner: %w", err)299 }300 if count, err := result.RowsAffected(); err != nil {301 return fmt.Errorf("check tenant owner transfer: %w", err)302 } else if count == 1 {303 return nil304 }305 var owner string306 if err := c.db.QueryRowContext(ctx, "SELECT owner FROM tenants WHERE id=?", id).Scan(&owner); errors.Is(err, sql.ErrNoRows) {307 return ErrNotFound308 } else if err != nil {309 return fmt.Errorf("check tenant owner: %w", err)310 }311 return ErrOwnerMismatch312}313314func (c *Catalog) SetHost(ctx context.Context, id, host string) error {315 if err := contextError(ctx); err != nil {316 return err317 }318 host, err := normalizeHost(host)319 if err != nil {320 return err321 }322 var mappingTenant string323 err = c.db.QueryRowContext(ctx, "SELECT tenant_id FROM tenant_hosts WHERE host=?", host).Scan(&mappingTenant)324 if err == nil {325 return ErrHostTaken326 }327 if err != nil && !errors.Is(err, sql.ErrNoRows) {328 return fmt.Errorf("check host mapping: %w", err)329 }330 result, err := c.db.ExecContext(ctx, "UPDATE tenants SET host=?,updated_at=? WHERE id=?", host, time.Now().UTC().UnixNano(), id)331 if err != nil {332 if isConstraint(err) {333 return ErrHostTaken334 }335 return fmt.Errorf("set tenant host: %w", err)336 }337 return changedOrNotFound(result)338}339340func (c *Catalog) ClearHost(ctx context.Context, id string) error {341 if err := contextError(ctx); err != nil {342 return err343 }344 result, err := c.db.ExecContext(ctx, "UPDATE tenants SET host=NULL,updated_at=? WHERE id=?", time.Now().UTC().UnixNano(), id)345 if err != nil {346 return fmt.Errorf("clear tenant host: %w", err)347 }348 return changedOrNotFound(result)349}350351func (c *Catalog) GetByID(ctx context.Context, id string) (Tenant, error) {352 return c.get(ctx, "WHERE id=?", id)353}354355func (c *Catalog) GetByName(ctx context.Context, name string) (Tenant, error) {356 name, err := normalizeName(name)357 if err != nil {358 return Tenant{}, err359 }360 return c.get(ctx, "WHERE name=?", name)361}362363func (c *Catalog) ResolveHost(ctx context.Context, host string) (Tenant, error) {364 host, err := normalizeHost(host)365 if err != nil {366 return Tenant{}, err367 }368 ten, err := c.get(ctx, "WHERE host=?", host)369 if err == nil {370 return ten, nil371 }372 if !errors.Is(err, ErrNotFound) {373 return Tenant{}, err374 }375 var tenantID string376 if err := c.db.QueryRowContext(ctx, "SELECT tenant_id FROM tenant_hosts WHERE host=?", host).Scan(&tenantID); errors.Is(err, sql.ErrNoRows) {377 return Tenant{}, ErrNotFound378 } else if err != nil {379 return Tenant{}, fmt.Errorf("resolve host mapping: %w", err)380 }381 return c.GetByID(ctx, tenantID)382}383384func (c *Catalog) List(ctx context.Context) ([]Tenant, error) {385 if err := contextError(ctx); err != nil {386 return nil, err387 }388 rows, err := c.db.QueryContext(ctx, `SELECT id,name,owner,template,source,status,host,created_at,updated_at389 FROM tenants ORDER BY created_at,id`)390 if err != nil {391 return nil, fmt.Errorf("list tenants: %w", err)392 }393 defer rows.Close()394 var tenants []Tenant395 for rows.Next() {396 tenant, err := c.scanTenant(rows)397 if err != nil {398 return nil, err399 }400 tenants = append(tenants, tenant)401 }402 if err := rows.Err(); err != nil {403 return nil, fmt.Errorf("list tenants: %w", err)404 }405 sort.SliceStable(tenants, func(i, j int) bool { return tenants[i].CreatedAt.Before(tenants[j].CreatedAt) })406 return tenants, nil407}408409func (c *Catalog) transition(ctx context.Context, id string, from, to Status) error {410 if err := contextError(ctx); err != nil {411 return err412 }413 result, err := c.db.ExecContext(ctx, "UPDATE tenants SET status=?,updated_at=? WHERE id=? AND status=?", to, time.Now().UTC().UnixNano(), id, from)414 if err != nil {415 return fmt.Errorf("transition tenant status: %w", err)416 }417 if count, err := result.RowsAffected(); err != nil {418 return fmt.Errorf("check tenant transition: %w", err)419 } else if count == 1 {420 return nil421 }422 var status Status423 if err := c.db.QueryRowContext(ctx, "SELECT status FROM tenants WHERE id=?", id).Scan(&status); errors.Is(err, sql.ErrNoRows) {424 return ErrNotFound425 } else if err != nil {426 return fmt.Errorf("check tenant status: %w", err)427 }428 return fmt.Errorf("%w: %s to %s", ErrInvalidTransition, status, to)429}430431func (c *Catalog) get(ctx context.Context, clause, value string) (Tenant, error) {432 if err := contextError(ctx); err != nil {433 return Tenant{}, err434 }435 row := c.db.QueryRowContext(ctx, "SELECT id,name,owner,template,source,status,host,created_at,updated_at FROM tenants "+clause, value)436 ten, err := c.scanTenant(row)437 if errors.Is(err, sql.ErrNoRows) {438 return Tenant{}, ErrNotFound439 }440 if err != nil {441 return Tenant{}, fmt.Errorf("get tenant: %w", err)442 }443 return ten, nil444}445446type scanner interface{ Scan(...any) error }447448func (c *Catalog) scanTenant(row scanner) (Tenant, error) {449 var tenant Tenant450 var host sql.NullString451 var created, updated int64452 if err := row.Scan(&tenant.ID, &tenant.Name, &tenant.Owner, &tenant.Template, &tenant.Source, &tenant.Status, &host, &created, &updated); err != nil {453 return Tenant{}, err454 }455 tenant.Host = host.String456 tenant.CreatedAt = time.Unix(0, created).UTC()457 tenant.UpdatedAt = time.Unix(0, updated).UTC()458 tenant.Paths = c.tenantPaths(tenant.ID)459 return tenant, nil460}461462func (c *Catalog) tenantPaths(id string) Paths {463 root := filepath.Join(c.dataRoot, "tenants", id)464 return Paths{Root: root, Database: filepath.Join(root, "relay.db"), Blobs: filepath.Join(root, "blobs"), Git: filepath.Join(root, "git")}465}466467func makeTenantDirs(paths Paths) error {468 for _, path := range []string{paths.Root, paths.Blobs, paths.Git} {469 if err := os.MkdirAll(path, 0o700); err != nil {470 return err471 }472 }473 return nil474}475476func changedOrNotFound(result sql.Result) error {477 count, err := result.RowsAffected()478 if err != nil {479 return fmt.Errorf("check catalog update: %w", err)480 }481 if count == 0 {482 return ErrNotFound483 }484 return nil485}486487func contextError(ctx context.Context) error {488 select {489 case <-ctx.Done():490 return ctx.Err()491 default:492 return nil493 }494}