chore(webui): prepare lock code for webui force unlock
Some checks failed
main / main (push) Failing after 20s
main / deploy (push) Has been skipped
main / publish (push) Has been skipped

#13
This commit is contained in:
Julien Dessaux 2025-05-02 00:26:32 +02:00
parent 0a63e1f593
commit 8f17a3661e
Signed by: adyxax
GPG key ID: F92E51B86E07177E
10 changed files with 135 additions and 49 deletions

View file

@ -16,44 +16,71 @@ func (db *DB) SetLockOrGetExistingLock(path string, lock any) (bool, error) {
ret := false ret := false
return ret, db.WithTransaction(func(tx *sql.Tx) error { return ret, db.WithTransaction(func(tx *sql.Tx) error {
var lockData []byte 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 errors.Is(err, sql.ErrNoRows) {
if lockData, err = json.Marshal(lock); err != nil { if lockData, err = json.Marshal(lock); err != nil {
return err return fmt.Errorf("failed to marshall lock data: %w", err)
} }
var stateId uuid.UUID var stateId uuid.UUID
if err := stateId.Generate(uuid.V7); err != nil { if err := stateId.Generate(uuid.V7); err != nil {
return fmt.Errorf("failed to generate state id: %w", err) 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,
ret = true `INSERT INTO states(id, path, lock)
return err VALUES (?, ?, jsonb(?))`,
} else { stateId, path, lockData)
return err if err != nil {
return fmt.Errorf("failed to create new state: %w", err)
} }
ret = true
return nil
}
return fmt.Errorf("failed to select lock data from state: %w", err)
} }
if lockData != nil { 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 { 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 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) data, err := json.Marshal(lock)
if err != nil { 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 { if err != nil {
return false, err return false, fmt.Errorf("failed to update state: %w", err)
} }
n, err := result.RowsAffected() 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
}
} }

View file

@ -26,7 +26,7 @@ CREATE INDEX sessions_data_account_id ON sessions(data->'account'->>'id');
CREATE TABLE states ( CREATE TABLE states (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
path TEXT NOT NULL, path TEXT NOT NULL,
lock TEXT, lock BLOB,
created INTEGER DEFAULT (unixepoch()), created INTEGER DEFAULT (unixepoch()),
updated INTEGER DEFAULT (unixepoch()) updated INTEGER DEFAULT (unixepoch())
) STRICT; ) STRICT;
@ -37,7 +37,7 @@ CREATE TABLE versions (
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
state_id TEXT, state_id TEXT,
data BLOB, data BLOB,
lock TEXT, lock BLOB,
created INTEGER DEFAULT (unixepoch()), created INTEGER DEFAULT (unixepoch()),
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
FOREIGN KEY(state_id) REFERENCES states(id) ON DELETE CASCADE FOREIGN KEY(state_id) REFERENCES states(id) ON DELETE CASCADE

View file

@ -2,9 +2,9 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"slices"
"time" "time"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
@ -98,16 +98,24 @@ func (db *DB) LoadStateById(stateId uuid.UUID) (*model.State, error) {
var ( var (
created int64 created int64
updated int64 updated int64
lock []byte
) )
err := db.QueryRow( err := db.QueryRow(
`SELECT created, lock, path, updated FROM states WHERE id = ?;`, `SELECT created, json_extract(lock, '$'), path, updated
stateId).Scan(&created, &state.Lock, &state.Path, &updated) FROM states
WHERE id = ?;`,
stateId).Scan(&created, &lock, &state.Path, &updated)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("failed to load state id %s from database: %w", stateId, err) 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.Created = time.Unix(created, 0)
state.Updated = time.Unix(updated, 0) state.Updated = time.Unix(updated, 0)
return &state, nil return &state, nil
@ -140,7 +148,7 @@ func (db *DB) LoadStatePaths() (map[string]string, error) {
func (db *DB) LoadStates() ([]model.State, error) { func (db *DB) LoadStates() ([]model.State, error) {
rows, err := db.Query( 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 { if err != nil {
return nil, fmt.Errorf("failed to load states from database: %w", err) 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 state model.State
created int64 created int64
updated 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 { if err != nil {
return nil, fmt.Errorf("failed to load state from row: %w", err) 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.Created = time.Unix(created, 0)
state.Updated = time.Unix(updated, 0) state.Updated = time.Unix(updated, 0)
states = append(states, state) states = append(states, state)
@ -168,12 +182,16 @@ func (db *DB) LoadStates() ([]model.State, error) {
// Returns (true, nil) on successful save // Returns (true, nil) on successful save
func (db *DB) SaveState(state *model.State) (bool, error) { 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 `UPDATE states
SET lock = ?, SET lock = jsonb(?),
path = ? path = ?
WHERE id = ?`, WHERE id = ?`,
state.Lock, lock,
state.Path, state.Path,
state.Id) state.Id)
if err != nil { if err != nil {
@ -189,7 +207,7 @@ func (db *DB) SaveState(state *model.State) (bool, error) {
} }
// returns true in case of lock mismatch // 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) encryptedData, err := db.dataEncryptionKey.EncryptAES256(data)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to encrypt state data: %w", err) 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 { return ret, db.WithTransaction(func(tx *sql.Tx) error {
var ( var (
stateId string 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) { if errors.Is(err, sql.ErrNoRows) {
var stateUUID uuid.UUID var stateUUID uuid.UUID
if err := stateUUID.Generate(uuid.V7); err != nil { if err := stateUUID.Generate(uuid.V7); err != nil {
return fmt.Errorf("failed to generate state id: %w", err) 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 { if err != nil {
return fmt.Errorf("failed to insert new state: %w", err) return fmt.Errorf("failed to insert new state: %w", err)
} }
stateId = stateUUID.String() stateId = stateUUID.String()
} else { } else {
return err return fmt.Errorf("failed to select lock data from state: %w", err)
} }
} }
if lock != "" && slices.Compare([]byte(lock), lockData) != 0 { if lockId != "" && (lockData == nil || lockId != *lockData) {
err = fmt.Errorf("failed to update state: lock ID mismatch")
ret = true ret = true
return err return fmt.Errorf("failed to update state: lock ID mismatch")
} }
var versionId uuid.UUID var versionId uuid.UUID
if err := versionId.Generate(uuid.V7); err != nil { if err := versionId.Generate(uuid.V7); err != nil {

View file

@ -2,6 +2,7 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@ -17,14 +18,16 @@ func (db *DB) LoadVersionById(id uuid.UUID) (*model.Version, error) {
var ( var (
created int64 created int64
encryptedData []byte encryptedData []byte
lock []byte
) )
err := db.QueryRow( 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( id).Scan(
&version.AccountId, &version.AccountId,
&version.StateId, &version.StateId,
&encryptedData, &encryptedData,
&version.Lock, &lock,
&created) &created)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { 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) 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.Created = time.Unix(created, 0)
version.Data, err = db.dataEncryptionKey.DecryptAES256(encryptedData) version.Data, err = db.dataEncryptionKey.DecryptAES256(encryptedData)
if err != nil { 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) { func (db *DB) LoadVersionsByState(state *model.State) ([]model.Version, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT account_id, created, data, id, lock `SELECT account_id, created, data, id, json_extract(lock, '$')
FROM versions FROM versions
WHERE state_id = ? WHERE state_id = ?
ORDER BY id DESC;`, 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() { for rows.Next() {
version := model.Version{StateId: state.Id} version := model.Version{StateId: state.Id}
var created int64 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 { if err != nil {
return nil, fmt.Errorf("failed to load version from row: %w", err) 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) version.Created = time.Unix(created, 0)
versions = append(versions, version) 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) { func (db *DB) LoadVersionsByAccount(account *model.Account) ([]model.Version, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT created, data, id, lock, state_id `SELECT created, data, id, json_extract(lock, '$'), state_id
FROM versions FROM versions
WHERE account_id = ? WHERE account_id = ?
ORDER BY id DESC;`, 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() { for rows.Next() {
version := model.Version{AccountId: account.Id} version := model.Version{AccountId: account.Id}
var created int64 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 { if err != nil {
return nil, fmt.Errorf("failed to load version from row: %w", err) 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) version.Created = time.Unix(created, 0)
versions = append(versions, version) versions = append(versions, version)
} }

13
pkg/model/lock.go Normal file
View file

@ -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"`
}

View file

@ -9,7 +9,7 @@ import (
type State struct { type State struct {
Created time.Time Created time.Time
Id uuid.UUID Id uuid.UUID
Lock *string Lock *Lock
Path string Path string
Updated time.Time Updated time.Time
} }

View file

@ -12,6 +12,6 @@ type Version struct {
Created time.Time Created time.Time
Data json.RawMessage Data json.RawMessage
Id uuid.UUID Id uuid.UUID
Lock *string Lock *Lock
StateId uuid.UUID StateId uuid.UUID
} }

View file

@ -70,9 +70,15 @@
unlocked unlocked
{{ else }} {{ else }}
<span class="tooltip"> <span class="tooltip">
locked <strong>locked</strong>
<span class="tooltip-text"> <span class="tooltip-text">
{{ .State.Lock }} <strong>Created: </strong>{{ .Lock.Created }}<br>
<strong>Id: </strong>{{ .Lock.Id }}<br>
<strong>Info: </strong>{{ .Lock.Info }}<br>
<strong>Operation: </strong>{{ .Lock.Operation }}<br>
<strong>Path: </strong>{{ .Lock.Path }}<br>
<strong>Version: </strong>{{ .Lock.Version }}<br>
<strong>Who: </strong>{{ .Lock.Who }}
</span> </span>
</span> </span>
{{ end }} {{ end }}

View file

@ -14,7 +14,13 @@
<span class="tooltip"> <span class="tooltip">
locked. locked.
<span class="tooltip-text"> <span class="tooltip-text">
{{ .State.Lock }} <strong>Created: </strong>{{ .State.Lock.Created }}<br>
<strong>Id: </strong>{{ .State.Lock.Id }}<br>
<strong>Info: </strong>{{ .State.Lock.Info }}<br>
<strong>Operation: </strong>{{ .State.Lock.Operation }}<br>
<strong>Path: </strong>{{ .State.Lock.Path }}<br>
<strong>Version: </strong>{{ .State.Lock.Version }}<br>
<strong>Who: </strong>{{ .State.Lock.Who }}
</span> </span>
</span> </span>
{{ end }} {{ end }}

View file

@ -413,12 +413,13 @@ th {
color: var(--fg-0); color: var(--fg-0);
left: 50%; left: 50%;
margin-left: -60px; margin-left: -60px;
min-width: 320px;
padding: 5px 0; padding: 5px 0;
position: absolute; position: absolute;
text-align: center; text-align: center;
top: 100%; top: 100%;
visibility: hidden; visibility: hidden;
width: 120px; width: fit-content;
z-index: 1; z-index: 1;
} }
.tooltip .tooltip-text::after { .tooltip .tooltip-text::after {