From 8f17a3661ec4135b214e85813f2c422792c0c5b7 Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Fri, 2 May 2025 00:26:32 +0200 Subject: [PATCH] chore(webui): prepare lock code for webui force unlock #13 --- pkg/database/locks.go | 59 +++++++++++++++++++++++++---------- pkg/database/sql/000_init.sql | 4 +-- pkg/database/states.go | 55 +++++++++++++++++++++----------- pkg/database/versions.go | 28 +++++++++++++---- pkg/model/lock.go | 13 ++++++++ pkg/model/state.go | 2 +- pkg/model/version.go | 2 +- pkg/webui/html/states.html | 10 ++++-- pkg/webui/html/statesId.html | 8 ++++- pkg/webui/static/main.css | 3 +- 10 files changed, 135 insertions(+), 49 deletions(-) create mode 100644 pkg/model/lock.go diff --git a/pkg/database/locks.go b/pkg/database/locks.go index 6c0bdfb..1d7496c 100644 --- a/pkg/database/locks.go +++ b/pkg/database/locks.go @@ -16,44 +16,71 @@ func (db *DB) SetLockOrGetExistingLock(path string, lock any) (bool, error) { ret := false return ret, db.WithTransaction(func(tx *sql.Tx) error { var lockData []byte - if err := tx.QueryRowContext(db.ctx, `SELECT lock FROM states WHERE path = ?;`, path).Scan(&lockData); err != nil { + err := tx.QueryRowContext(db.ctx, + `SELECT json_extract(lock, '$') + FROM states + WHERE path = ?;`, + path).Scan(&lockData) + if err != nil { if errors.Is(err, sql.ErrNoRows) { if lockData, err = json.Marshal(lock); err != nil { - return err + return fmt.Errorf("failed to marshall lock data: %w", err) } var stateId uuid.UUID if err := stateId.Generate(uuid.V7); err != nil { return fmt.Errorf("failed to generate state id: %w", err) } - _, err = tx.ExecContext(db.ctx, `INSERT INTO states(id, path, lock) VALUES (?, ?, json(?))`, stateId, path, lockData) + _, err := tx.ExecContext(db.ctx, + `INSERT INTO states(id, path, lock) + VALUES (?, ?, jsonb(?))`, + stateId, path, lockData) + if err != nil { + return fmt.Errorf("failed to create new state: %w", err) + } ret = true - return err - } else { - return err + return nil } + return fmt.Errorf("failed to select lock data from state: %w", err) } if lockData != nil { - return json.Unmarshal(lockData, lock) + if err := json.Unmarshal(lockData, lock); err != nil { + return fmt.Errorf("failed to unmarshal lock data: %w", err) + } + return nil } - var err error if lockData, err = json.Marshal(lock); err != nil { - return err + return fmt.Errorf("failed to marshal lock data: %w", err) + } + _, err = tx.ExecContext(db.ctx, + `UPDATE states + SET lock = jsonb(?) + WHERE path = ?;`, + lockData, path) + if err != nil { + return fmt.Errorf("failed to set lock data: %w", err) } - _, err = tx.ExecContext(db.ctx, `UPDATE states SET lock = json(?) WHERE path = ?;`, lockData, path) ret = true - return err + return nil }) } -func (db *DB) Unlock(path, lock any) (bool, error) { +func (db *DB) Unlock(path string, lock any) (bool, error) { data, err := json.Marshal(lock) if err != nil { - return false, err + return false, fmt.Errorf("failed to marshal lock data: %w", err) } - result, err := db.Exec(`UPDATE states SET lock = NULL WHERE path = ? and lock = json(?);`, path, data) + result, err := db.Exec( + `UPDATE states + SET lock = NULL + WHERE path = ? and lock = jsonb(?);`, + path, data) if err != nil { - return false, err + return false, fmt.Errorf("failed to update state: %w", err) } n, err := result.RowsAffected() - return n == 1, err + if err != nil { + return false, fmt.Errorf("failed to get affected rows: %w", err) + } + return n == 1, nil +} } diff --git a/pkg/database/sql/000_init.sql b/pkg/database/sql/000_init.sql index 0441c89..30fff33 100644 --- a/pkg/database/sql/000_init.sql +++ b/pkg/database/sql/000_init.sql @@ -26,7 +26,7 @@ CREATE INDEX sessions_data_account_id ON sessions(data->'account'->>'id'); CREATE TABLE states ( id TEXT PRIMARY KEY, path TEXT NOT NULL, - lock TEXT, + lock BLOB, created INTEGER DEFAULT (unixepoch()), updated INTEGER DEFAULT (unixepoch()) ) STRICT; @@ -37,7 +37,7 @@ CREATE TABLE versions ( account_id TEXT NOT NULL, state_id TEXT, data BLOB, - lock TEXT, + lock BLOB, created INTEGER DEFAULT (unixepoch()), FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE FOREIGN KEY(state_id) REFERENCES states(id) ON DELETE CASCADE diff --git a/pkg/database/states.go b/pkg/database/states.go index 3429418..3f20f43 100644 --- a/pkg/database/states.go +++ b/pkg/database/states.go @@ -2,9 +2,9 @@ package database import ( "database/sql" + "encoding/json" "errors" "fmt" - "slices" "time" "git.adyxax.org/adyxax/tfstated/pkg/model" @@ -98,16 +98,24 @@ func (db *DB) LoadStateById(stateId uuid.UUID) (*model.State, error) { var ( created int64 updated int64 + lock []byte ) err := db.QueryRow( - `SELECT created, lock, path, updated FROM states WHERE id = ?;`, - stateId).Scan(&created, &state.Lock, &state.Path, &updated) + `SELECT created, json_extract(lock, '$'), path, updated + FROM states + WHERE id = ?;`, + stateId).Scan(&created, &lock, &state.Path, &updated) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("failed to load state id %s from database: %w", stateId, err) } + if lock != nil { + if err := json.Unmarshal(lock, &state.Lock); err != nil { + return nil, fmt.Errorf("failed to unmarshal lock data: %w", err) + } + } state.Created = time.Unix(created, 0) state.Updated = time.Unix(updated, 0) return &state, nil @@ -140,7 +148,7 @@ func (db *DB) LoadStatePaths() (map[string]string, error) { func (db *DB) LoadStates() ([]model.State, error) { rows, err := db.Query( - `SELECT created, id, lock, path, updated FROM states;`) + `SELECT created, id, json_extract(lock, '$'), path, updated FROM states;`) if err != nil { return nil, fmt.Errorf("failed to load states from database: %w", err) } @@ -151,11 +159,17 @@ func (db *DB) LoadStates() ([]model.State, error) { state model.State created int64 updated int64 + lock []byte ) - err = rows.Scan(&created, &state.Id, &state.Lock, &state.Path, &updated) + err = rows.Scan(&created, &state.Id, &lock, &state.Path, &updated) if err != nil { return nil, fmt.Errorf("failed to load state from row: %w", err) } + if lock != nil { + if err := json.Unmarshal(lock, &state.Lock); err != nil { + return nil, fmt.Errorf("failed to unmarshal lock data: %w", err) + } + } state.Created = time.Unix(created, 0) state.Updated = time.Unix(updated, 0) states = append(states, state) @@ -168,12 +182,16 @@ func (db *DB) LoadStates() ([]model.State, error) { // Returns (true, nil) on successful save func (db *DB) SaveState(state *model.State) (bool, error) { - _, err := db.Exec( + lock, err := json.Marshal(state.Lock) + if err != nil { + return false, fmt.Errorf("failed to marshal lock data: %w", err) + } + _, err = db.Exec( `UPDATE states - SET lock = ?, + SET lock = jsonb(?), path = ? WHERE id = ?`, - state.Lock, + lock, state.Path, state.Id) if err != nil { @@ -189,7 +207,7 @@ func (db *DB) SaveState(state *model.State) (bool, error) { } // returns true in case of lock mismatch -func (db *DB) SetState(path string, accountId uuid.UUID, data []byte, lock string) (bool, error) { +func (db *DB) SetState(path string, accountId uuid.UUID, data []byte, lockId string) (bool, error) { encryptedData, err := db.dataEncryptionKey.EncryptAES256(data) if err != nil { return false, fmt.Errorf("failed to encrypt state data: %w", err) @@ -198,28 +216,27 @@ func (db *DB) SetState(path string, accountId uuid.UUID, data []byte, lock strin return ret, db.WithTransaction(func(tx *sql.Tx) error { var ( stateId string - lockData []byte + lockData *string ) - if err = tx.QueryRowContext(db.ctx, `SELECT id, lock->>'ID' FROM states WHERE path = ?;`, path).Scan(&stateId, &lockData); err != nil { + if err := tx.QueryRowContext(db.ctx, `SELECT id, lock->>'ID' FROM states WHERE path = ?;`, path).Scan(&stateId, &lockData); err != nil { if errors.Is(err, sql.ErrNoRows) { var stateUUID uuid.UUID if err := stateUUID.Generate(uuid.V7); err != nil { return fmt.Errorf("failed to generate state id: %w", err) } - _, err = tx.ExecContext(db.ctx, `INSERT INTO states(id, path) VALUES (?, ?)`, stateUUID, path) + _, err := tx.ExecContext(db.ctx, `INSERT INTO states(id, path) VALUES (?, ?)`, stateUUID, path) if err != nil { return fmt.Errorf("failed to insert new state: %w", err) } stateId = stateUUID.String() } else { - return err + return fmt.Errorf("failed to select lock data from state: %w", err) } } - if lock != "" && slices.Compare([]byte(lock), lockData) != 0 { - err = fmt.Errorf("failed to update state: lock ID mismatch") + if lockId != "" && (lockData == nil || lockId != *lockData) { ret = true - return err + return fmt.Errorf("failed to update state: lock ID mismatch") } var versionId uuid.UUID if err := versionId.Generate(uuid.V7); err != nil { @@ -227,9 +244,9 @@ func (db *DB) SetState(path string, accountId uuid.UUID, data []byte, lock strin } _, err = tx.ExecContext(db.ctx, `INSERT INTO versions(id, account_id, state_id, data, lock) - SELECT :versionId, :accountId, :stateId, :data, lock - FROM states - WHERE states.id = :stateId;`, + SELECT :versionId, :accountId, :stateId, :data, lock + FROM states + WHERE states.id = :stateId;`, sql.Named("accountId", accountId), sql.Named("data", encryptedData), sql.Named("stateId", stateId), diff --git a/pkg/database/versions.go b/pkg/database/versions.go index e6626a8..10bb6dc 100644 --- a/pkg/database/versions.go +++ b/pkg/database/versions.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "encoding/json" "errors" "fmt" "time" @@ -17,14 +18,16 @@ func (db *DB) LoadVersionById(id uuid.UUID) (*model.Version, error) { var ( created int64 encryptedData []byte + lock []byte ) err := db.QueryRow( - `SELECT account_id, state_id, data, lock, created FROM versions WHERE id = ?;`, + `SELECT account_id, state_id, data, json_extract(lock, '$'), created + FROM versions WHERE id = ?;`, id).Scan( &version.AccountId, &version.StateId, &encryptedData, - &version.Lock, + &lock, &created) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -32,6 +35,9 @@ func (db *DB) LoadVersionById(id uuid.UUID) (*model.Version, error) { } return nil, fmt.Errorf("failed to load version id %s from database: %w", id, err) } + if err := json.Unmarshal(lock, &version.Lock); err != nil { + return nil, fmt.Errorf("failed to unmarshal lock data: %w", err) + } version.Created = time.Unix(created, 0) version.Data, err = db.dataEncryptionKey.DecryptAES256(encryptedData) if err != nil { @@ -42,7 +48,7 @@ func (db *DB) LoadVersionById(id uuid.UUID) (*model.Version, error) { func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) { rows, err := db.Query( - `SELECT account_id, created, data, id, lock + `SELECT account_id, created, data, id, json_extract(lock, '$') FROM versions WHERE state_id = ? ORDER BY id DESC;`, state.Id) @@ -54,10 +60,14 @@ func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) { for rows.Next() { version := model.Version{StateId: state.Id} var created int64 - err = rows.Scan(&version.AccountId, &created, &version.Data, &version.Id, &version.Lock) + var lock []byte + err = rows.Scan(&version.AccountId, &created, &version.Data, &version.Id, &lock) if err != nil { return nil, fmt.Errorf("failed to load version from row: %w", err) } + if err := json.Unmarshal(lock, &version.Lock); err != nil { + return nil, fmt.Errorf("failed to unmarshal lock data: %w", err) + } version.Created = time.Unix(created, 0) versions = append(versions, version) } @@ -69,7 +79,7 @@ func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) { func (db *DB) LoadVersionsByAccount(account *model.Account) ([]model.Version, error) { rows, err := db.Query( - `SELECT created, data, id, lock, state_id + `SELECT created, data, id, json_extract(lock, '$'), state_id FROM versions WHERE account_id = ? ORDER BY id DESC;`, account.Id) @@ -81,10 +91,16 @@ func (db *DB) LoadVersionsByAccount(account *model.Account) ([]model.Version, er for rows.Next() { version := model.Version{AccountId: account.Id} var created int64 - err = rows.Scan(&created, &version.Data, &version.Id, &version.Lock, &version.StateId) + var lock []byte + err = rows.Scan(&created, &version.Data, &version.Id, &lock, &version.StateId) if err != nil { return nil, fmt.Errorf("failed to load version from row: %w", err) } + if lock != nil { + if err := json.Unmarshal(lock, &version.Lock); err != nil { + return nil, fmt.Errorf("failed to unmarshal lock data: %w", err) + } + } version.Created = time.Unix(created, 0) versions = append(versions, version) } diff --git a/pkg/model/lock.go b/pkg/model/lock.go new file mode 100644 index 0000000..d4b730f --- /dev/null +++ b/pkg/model/lock.go @@ -0,0 +1,13 @@ +package model + +import "time" + +type Lock struct { + Created time.Time `json:"Created"` + Id string `json:"ID"` + Info string `json:"Info"` + Operation string `json:"Operation"` + Path string `json:"Path"` + Version string `json:"Version"` + Who string `json:"Who"` +} diff --git a/pkg/model/state.go b/pkg/model/state.go index 12f9e83..75509c7 100644 --- a/pkg/model/state.go +++ b/pkg/model/state.go @@ -9,7 +9,7 @@ import ( type State struct { Created time.Time Id uuid.UUID - Lock *string + Lock *Lock Path string Updated time.Time } diff --git a/pkg/model/version.go b/pkg/model/version.go index 2a18d14..edfdfcf 100644 --- a/pkg/model/version.go +++ b/pkg/model/version.go @@ -12,6 +12,6 @@ type Version struct { Created time.Time Data json.RawMessage Id uuid.UUID - Lock *string + Lock *Lock StateId uuid.UUID } diff --git a/pkg/webui/html/states.html b/pkg/webui/html/states.html index 6deeb61..73f16b7 100644 --- a/pkg/webui/html/states.html +++ b/pkg/webui/html/states.html @@ -70,9 +70,15 @@ unlocked {{ else }} - locked + locked - {{ .State.Lock }} + Created: {{ .Lock.Created }}
+ Id: {{ .Lock.Id }}
+ Info: {{ .Lock.Info }}
+ Operation: {{ .Lock.Operation }}
+ Path: {{ .Lock.Path }}
+ Version: {{ .Lock.Version }}
+ Who: {{ .Lock.Who }}
{{ end }} diff --git a/pkg/webui/html/statesId.html b/pkg/webui/html/statesId.html index d8bb9ea..412ec5f 100644 --- a/pkg/webui/html/statesId.html +++ b/pkg/webui/html/statesId.html @@ -14,7 +14,13 @@ locked. - {{ .State.Lock }} + Created: {{ .State.Lock.Created }}
+ Id: {{ .State.Lock.Id }}
+ Info: {{ .State.Lock.Info }}
+ Operation: {{ .State.Lock.Operation }}
+ Path: {{ .State.Lock.Path }}
+ Version: {{ .State.Lock.Version }}
+ Who: {{ .State.Lock.Who }}
{{ end }} diff --git a/pkg/webui/static/main.css b/pkg/webui/static/main.css index ed24230..a33716e 100644 --- a/pkg/webui/static/main.css +++ b/pkg/webui/static/main.css @@ -413,12 +413,13 @@ th { color: var(--fg-0); left: 50%; margin-left: -60px; + min-width: 320px; padding: 5px 0; position: absolute; text-align: center; top: 100%; visibility: hidden; - width: 120px; + width: fit-content; z-index: 1; } .tooltip .tooltip-text::after {