Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInstanceLimit = errors.New("instance limit reached")
|
||||
var ErrIntentState = errors.New("intent is no longer waiting")
|
||||
|
||||
type Intent struct {
|
||||
ID string `json:"id"`
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
BuildID string `json:"build_id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Host string `json:"-"`
|
||||
Created int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *Store) EnsureIntents() error {
|
||||
_, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS creation_intents(id TEXT PRIMARY KEY,uid TEXT NOT NULL,name TEXT NOT NULL,version TEXT NOT NULL,build_id TEXT NOT NULL DEFAULT '',instance_id TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,error TEXT NOT NULL DEFAULT '',host TEXT NOT NULL,created INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS intents_owner ON creation_intents(uid,status);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func reserveSlot(tx *sql.Tx, uid string) error {
|
||||
var count int
|
||||
if err := tx.QueryRow(`SELECT (SELECT count(*) FROM instances WHERE uid=?) + (SELECT count(*) FROM creation_intents WHERE uid=? AND status='waiting')`, uid, uid).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= 2 {
|
||||
return ErrInstanceLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ReserveIntent(i Intent) error {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = reserveSlot(tx, i.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO creation_intents(id,uid,name,version,status,host,created) VALUES(?,?,?,?,'waiting',?,?)`, i.ID, i.UID, i.Name, i.Version, i.Host, time.Now().Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ReserveInstance(i Instance) error {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = reserveSlot(tx, i.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = insertInstance(tx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func insertInstance(tx *sql.Tx, i Instance) error {
|
||||
_, err := tx.Exec(`INSERT INTO instances VALUES(?,?,?,?,?,?,?,?)`, i.ID, i.UID, i.Name, i.Version, i.ImageID, i.Status, i.ContainerID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
func (s *Store) ClaimIntent(id string, i Instance) error {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.Exec(`UPDATE creation_intents SET status='creating',instance_id=? WHERE id=? AND uid=? AND version=? AND status='waiting'`, i.ID, id, i.UID, i.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return ErrIntentState
|
||||
}
|
||||
if err = insertInstance(tx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *Store) CancelIntent(id, uid string) error {
|
||||
r, err := s.DB.Exec(`UPDATE creation_intents SET status='cancelled' WHERE id=? AND uid=? AND status='waiting'`, id, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := r.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return ErrIntentState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) Intents(uid string) ([]Intent, error) {
|
||||
query := `SELECT id,uid,name,version,build_id,instance_id,status,error,host,created FROM creation_intents`
|
||||
var args []any
|
||||
if uid != "" {
|
||||
query += ` WHERE uid=?`
|
||||
args = append(args, uid)
|
||||
}
|
||||
query += ` ORDER BY created,id`
|
||||
rows, err := s.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Intent{}
|
||||
for rows.Next() {
|
||||
var i Intent
|
||||
if err = rows.Scan(&i.ID, &i.UID, &i.Name, &i.Version, &i.BuildID, &i.InstanceID, &i.Status, &i.Error, &i.Host, &i.Created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, i)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntentReservationConcurrencyAndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "store.db")
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.EnsureIntents(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
result := make(chan error, 30)
|
||||
for n := 0; n < 30; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
if n%2 == 0 {
|
||||
result <- s.ReserveIntent(Intent{ID: ID(), UID: "u", Name: "test", Version: "1.2.3"})
|
||||
} else {
|
||||
result <- s.ReserveInstance(Instance{ID: ID(), UID: "u"})
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
close(result)
|
||||
ok := 0
|
||||
for err := range result {
|
||||
if err == nil {
|
||||
ok++
|
||||
} else if !errors.Is(err, ErrInstanceLimit) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ok != 2 {
|
||||
t.Fatalf("reserved %d", ok)
|
||||
}
|
||||
s.Close()
|
||||
s, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err = s.ReserveIntent(Intent{ID: ID(), UID: "u"}); !errors.Is(err, ErrInstanceLimit) {
|
||||
t.Fatalf("restart lost reservation: %v", err)
|
||||
}
|
||||
}
|
||||
func TestIntentClaimCancelAndVersion(t *testing.T) {
|
||||
s, err := Open(filepath.Join(t.TempDir(), "store.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err = s.EnsureIntents(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := Intent{ID: ID(), UID: "u", Version: "1.2.3"}
|
||||
if err = s.ReserveIntent(in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
i := Instance{ID: ID(), UID: "u", Version: "1.2.4"}
|
||||
if err = s.ClaimIntent(in.ID, i); !errors.Is(err, ErrIntentState) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.CancelIntent(in.ID, "other"); !errors.Is(err, ErrIntentState) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
i.Version = in.Version
|
||||
if err = s.ClaimIntent(in.ID, i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.CancelIntent(in.ID, in.UID); !errors.Is(err, ErrIntentState) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.ReserveIntent(Intent{ID: "second", UID: "u"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.ReserveIntent(Intent{ID: "third", UID: "u"}); !errors.Is(err, ErrInstanceLimit) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.CancelIntent("second", "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.ReserveIntent(Intent{ID: "third", UID: "u"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
_ "modernc.org/sqlite"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Admin bool `json:"admin"`
|
||||
Quota int64 `json:"quota_bytes"`
|
||||
Created time.Time `json:"created_at"`
|
||||
}
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
ImageID string `json:"image_id"`
|
||||
Status string `json:"status"`
|
||||
ContainerID string `json:"container_id"`
|
||||
Created time.Time `json:"created_at"`
|
||||
}
|
||||
type Store struct{ DB *sql.DB }
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, e := sql.Open("sqlite", path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, e = db.Exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;"); e != nil {
|
||||
db.Close()
|
||||
return nil, e
|
||||
}
|
||||
_, e = db.Exec("PRAGMA foreign_keys=ON; CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,username TEXT UNIQUE NOT NULL,pass_hash TEXT NOT NULL,admin INTEGER NOT NULL,quota INTEGER NOT NULL,created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY,uid TEXT NOT NULL,csrf TEXT NOT NULL,expires INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS instances(id TEXT PRIMARY KEY,uid TEXT NOT NULL,name TEXT NOT NULL,version TEXT,image_id TEXT,status TEXT,container_id TEXT,created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY,uid TEXT,action TEXT,target TEXT,detail TEXT,at INTEGER NOT NULL);")
|
||||
if e != nil {
|
||||
db.Close()
|
||||
return nil, e
|
||||
}
|
||||
return &Store{db}, nil
|
||||
}
|
||||
func (s *Store) Close() { s.DB.Close() }
|
||||
func ID() string { b := make([]byte, 16); _, _ = rand.Read(b); return hex.EncodeToString(b) }
|
||||
func (s *Store) UserByName(n string) (User, error) {
|
||||
var u User
|
||||
var a, t int64
|
||||
e := s.DB.QueryRow("SELECT id,username,admin,quota,created FROM users WHERE username=?", n).Scan(&u.ID, &u.Username, &a, &u.Quota, &t)
|
||||
u.Admin = a != 0
|
||||
u.Created = time.Unix(t, 0)
|
||||
return u, e
|
||||
}
|
||||
func (s *Store) UserByID(id string) (User, error) {
|
||||
var u User
|
||||
var a, t int64
|
||||
e := s.DB.QueryRow("SELECT id,username,admin,quota,created FROM users WHERE id=?", id).Scan(&u.ID, &u.Username, &a, &u.Quota, &t)
|
||||
u.Admin = a != 0
|
||||
u.Created = time.Unix(t, 0)
|
||||
return u, e
|
||||
}
|
||||
func (s *Store) CreateUser(u User, hash string) error {
|
||||
_, e := s.DB.Exec("INSERT INTO users(id,username,pass_hash,admin,quota,created) VALUES(?,?,?,?,?,?)", u.ID, u.Username, hash, boolInt(u.Admin), u.Quota, time.Now().Unix())
|
||||
return e
|
||||
}
|
||||
func boolInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (s *Store) Password(name string) (string, error) {
|
||||
var p string
|
||||
e := s.DB.QueryRow("SELECT pass_hash FROM users WHERE username=?", name).Scan(&p)
|
||||
return p, e
|
||||
}
|
||||
func (s *Store) Session(uid string) (string, string, error) {
|
||||
id, csrf := ID(), ID()
|
||||
_, e := s.DB.Exec("INSERT INTO sessions VALUES(?,?,?,?)", id, uid, csrf, time.Now().Add(24*time.Hour).Unix())
|
||||
return id, csrf, e
|
||||
}
|
||||
func (s *Store) SessionUser(id string) (User, string, error) {
|
||||
var uid, csrf string
|
||||
var exp int64
|
||||
e := s.DB.QueryRow("SELECT uid,csrf,expires FROM sessions WHERE id=?", id).Scan(&uid, &csrf, &exp)
|
||||
if e != nil || exp < time.Now().Unix() {
|
||||
return User{}, "", sql.ErrNoRows
|
||||
}
|
||||
u, e := s.UserByID(uid)
|
||||
return u, csrf, e
|
||||
}
|
||||
func (s *Store) DeleteSession(id string) { _, _ = s.DB.Exec("DELETE FROM sessions WHERE id=?", id) }
|
||||
func (s *Store) AddAudit(uid, a, t, d string) {
|
||||
_, _ = s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", uid, a, t, d, time.Now().Unix())
|
||||
}
|
||||
func (s *Store) Instances(uid string) ([]Instance, error) {
|
||||
rows, e := s.DB.Query("SELECT id,uid,name,version,image_id,status,container_id,created FROM instances WHERE uid=? ORDER BY created", uid)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Instance
|
||||
for rows.Next() {
|
||||
var i Instance
|
||||
var t int64
|
||||
if e = rows.Scan(&i.ID, &i.UID, &i.Name, &i.Version, &i.ImageID, &i.Status, &i.ContainerID, &t); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
i.Created = time.Unix(t, 0)
|
||||
out = append(out, i)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Store) AddInstance(i Instance) error {
|
||||
_, e := s.DB.Exec("INSERT INTO instances VALUES(?,?,?,?,?,?,?,?)", i.ID, i.UID, i.Name, i.Version, i.ImageID, i.Status, i.ContainerID, time.Now().Unix())
|
||||
return e
|
||||
}
|
||||
func (s *Store) GetInstance(id string) (Instance, error) {
|
||||
var i Instance
|
||||
var t int64
|
||||
e := s.DB.QueryRow("SELECT id,uid,name,version,image_id,status,container_id,created FROM instances WHERE id=?", id).Scan(&i.ID, &i.UID, &i.Name, &i.Version, &i.ImageID, &i.Status, &i.ContainerID, &t)
|
||||
i.Created = time.Unix(t, 0)
|
||||
return i, e
|
||||
}
|
||||
func (s *Store) SetInstance(id, status string) {
|
||||
_, _ = s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, id)
|
||||
}
|
||||
func (s *Store) DeleteInstance(id string) { _, _ = s.DB.Exec("DELETE FROM instances WHERE id=?", id) }
|
||||
Reference in New Issue
Block a user