tinyrelayfile
tinyrelay/internal/catalog/catalog.go/

internal/catalog/catalog.go raw
1package catalog
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/google/uuid"
16 _ "modernc.org/sqlite"
17)
18
19type Status string
20
21const (
22 // StatusCreating identifies a tenant whose directories or services are
23 // 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 eligible
28 // for reactivation or deletion.
29 StatusDisabled Status = "disabled"
30 // StatusDeleting identifies a tenant whose deletion intent is recorded.
31 StatusDeleting Status = "deleting"
32)
33
34var (
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)
42
43type CreateOptions struct {
44 // Name is the operator-visible unique tenant name.
45 Name string
46 // Owner is the tenant owner's public key.
47 Owner string
48 // Template identifies the configuration template used during setup.
49 Template string
50 // Source records where the tenant configuration came from.
51 Source string
52}
53
54// Paths contains the private filesystem locations assigned to a tenant.
55type Paths struct {
56 Root string
57 Database string
58 Blobs string
59 Git string
60}
61
62type Tenant struct {
63 ID string
64 Name string
65 Owner string
66 Template string
67 Source string
68 Status Status
69 Host string
70 CreatedAt time.Time
71 UpdatedAt time.Time
72 Paths Paths
73}
74
75type Catalog struct {
76 db *sql.DB
77 dataRoot string
78 mu sync.RWMutex
79 closed bool
80}
81
82func Open(ctx context.Context, dataRoot string) (*Catalog, error) {
83 if err := contextError(ctx); err != nil {
84 return nil, err
85 }
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, err
102 }
103 return cat, nil
104}
105
106func (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 CASCADE
123 )`,
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 NULL
127 )`,
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 err
136 }
137 if err := c.migrateTenantStatus(ctx); err != nil {
138 return err
139 }
140 if err := c.recoverDeletions(ctx); err != nil {
141 return err
142 }
143 return nil
144}
145
146func (c *Catalog) migrateTenantStatus(ctx context.Context) error {
147 var definition string
148 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 nil
153 }
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 nil
179}
180
181func (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 int
189 var name, typ string
190 var defaultValue sql.NullString
191 if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &primary); err != nil {
192 return err
193 }
194 if name == "source" {
195 return nil
196 }
197 }
198 if err := rows.Err(); err != nil {
199 return err
200 }
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 nil
205}
206
207func (c *Catalog) Close() error {
208 c.mu.Lock()
209 defer c.mu.Unlock()
210 if c.closed {
211 return nil
212 }
213 c.closed = true
214 return c.db.Close()
215}
216
217func (c *Catalog) Create(ctx context.Context, options CreateOptions) (Tenant, error) {
218 if err := contextError(ctx); err != nil {
219 return Tenant{}, err
220 }
221 name, err := normalizeName(options.Name)
222 if err != nil {
223 return Tenant{}, err
224 }
225 owner, err := normalizeOwner(options.Owner)
226 if err != nil {
227 return Tenant{}, err
228 }
229 template, err := normalizeTemplate(options.Template)
230 if err != nil {
231 return Tenant{}, err
232 }
233 source, err := normalizeSource(options.Source)
234 if err != nil {
235 return Tenant{}, err
236 }
237 id := uuid.NewString()
238 now := time.Now().UTC()
239 _, err = c.db.ExecContext(ctx, `INSERT INTO tenants
240 (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{}, ErrNameTaken
245 }
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}, nil
254}
255
256func (c *Catalog) MarkReady(ctx context.Context, id string) error {
257 return c.transition(ctx, id, StatusCreating, StatusReady)
258}
259
260func (c *Catalog) Disable(ctx context.Context, id string) error {
261 return c.transition(ctx, id, StatusReady, StatusDisabled)
262}
263
264func (c *Catalog) Enable(ctx context.Context, id string) error {
265 return c.transition(ctx, id, StatusDisabled, StatusReady)
266}
267
268func (c *Catalog) UpdateOwner(ctx context.Context, id, owner string) error {
269 if err := contextError(ctx); err != nil {
270 return err
271 }
272 owner, err := normalizeOwner(owner)
273 if err != nil {
274 return err
275 }
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}
282
283// 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 err
287 }
288 currentOwner, err := normalizeOwner(currentOwner)
289 if err != nil {
290 return err
291 }
292 nextOwner, err = normalizeOwner(nextOwner)
293 if err != nil {
294 return err
295 }
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 nil
304 }
305 var owner string
306 if err := c.db.QueryRowContext(ctx, "SELECT owner FROM tenants WHERE id=?", id).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
307 return ErrNotFound
308 } else if err != nil {
309 return fmt.Errorf("check tenant owner: %w", err)
310 }
311 return ErrOwnerMismatch
312}
313
314func (c *Catalog) SetHost(ctx context.Context, id, host string) error {
315 if err := contextError(ctx); err != nil {
316 return err
317 }
318 host, err := normalizeHost(host)
319 if err != nil {
320 return err
321 }
322 var mappingTenant string
323 err = c.db.QueryRowContext(ctx, "SELECT tenant_id FROM tenant_hosts WHERE host=?", host).Scan(&mappingTenant)
324 if err == nil {
325 return ErrHostTaken
326 }
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 ErrHostTaken
334 }
335 return fmt.Errorf("set tenant host: %w", err)
336 }
337 return changedOrNotFound(result)
338}
339
340func (c *Catalog) ClearHost(ctx context.Context, id string) error {
341 if err := contextError(ctx); err != nil {
342 return err
343 }
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}
350
351func (c *Catalog) GetByID(ctx context.Context, id string) (Tenant, error) {
352 return c.get(ctx, "WHERE id=?", id)
353}
354
355func (c *Catalog) GetByName(ctx context.Context, name string) (Tenant, error) {
356 name, err := normalizeName(name)
357 if err != nil {
358 return Tenant{}, err
359 }
360 return c.get(ctx, "WHERE name=?", name)
361}
362
363func (c *Catalog) ResolveHost(ctx context.Context, host string) (Tenant, error) {
364 host, err := normalizeHost(host)
365 if err != nil {
366 return Tenant{}, err
367 }
368 ten, err := c.get(ctx, "WHERE host=?", host)
369 if err == nil {
370 return ten, nil
371 }
372 if !errors.Is(err, ErrNotFound) {
373 return Tenant{}, err
374 }
375 var tenantID string
376 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{}, ErrNotFound
378 } else if err != nil {
379 return Tenant{}, fmt.Errorf("resolve host mapping: %w", err)
380 }
381 return c.GetByID(ctx, tenantID)
382}
383
384func (c *Catalog) List(ctx context.Context) ([]Tenant, error) {
385 if err := contextError(ctx); err != nil {
386 return nil, err
387 }
388 rows, err := c.db.QueryContext(ctx, `SELECT id,name,owner,template,source,status,host,created_at,updated_at
389 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 []Tenant
395 for rows.Next() {
396 tenant, err := c.scanTenant(rows)
397 if err != nil {
398 return nil, err
399 }
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, nil
407}
408
409func (c *Catalog) transition(ctx context.Context, id string, from, to Status) error {
410 if err := contextError(ctx); err != nil {
411 return err
412 }
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 nil
421 }
422 var status Status
423 if err := c.db.QueryRowContext(ctx, "SELECT status FROM tenants WHERE id=?", id).Scan(&status); errors.Is(err, sql.ErrNoRows) {
424 return ErrNotFound
425 } 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}
430
431func (c *Catalog) get(ctx context.Context, clause, value string) (Tenant, error) {
432 if err := contextError(ctx); err != nil {
433 return Tenant{}, err
434 }
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{}, ErrNotFound
439 }
440 if err != nil {
441 return Tenant{}, fmt.Errorf("get tenant: %w", err)
442 }
443 return ten, nil
444}
445
446type scanner interface{ Scan(...any) error }
447
448func (c *Catalog) scanTenant(row scanner) (Tenant, error) {
449 var tenant Tenant
450 var host sql.NullString
451 var created, updated int64
452 if err := row.Scan(&tenant.ID, &tenant.Name, &tenant.Owner, &tenant.Template, &tenant.Source, &tenant.Status, &host, &created, &updated); err != nil {
453 return Tenant{}, err
454 }
455 tenant.Host = host.String
456 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, nil
460}
461
462func (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}
466
467func 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 err
471 }
472 }
473 return nil
474}
475
476func 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 ErrNotFound
483 }
484 return nil
485}
486
487func contextError(ctx context.Context) error {
488 select {
489 case <-ctx.Done():
490 return ctx.Err()
491 default:
492 return nil
493 }
494}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs