-
Notifications
You must be signed in to change notification settings - Fork 56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use SQL for Destination{Create,Update,Delete} #3356
Changes from all commits
fc07e83
f6c774e
3b5f89c
20fa80a
7d900b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,30 +4,67 @@ import ( | |
"fmt" | ||
"time" | ||
|
||
"github.com/infrahq/infra/internal" | ||
"github.com/infrahq/infra/internal/server/models" | ||
"github.com/infrahq/infra/uid" | ||
) | ||
|
||
type destinationsTable models.Destination | ||
|
||
func (d destinationsTable) Table() string { | ||
return "destinations" | ||
} | ||
|
||
func (d destinationsTable) Columns() []string { | ||
return []string{"connection_ca", "connection_url", "created_at", "deleted_at", "id", "last_seen_at", "name", "organization_id", "resources", "roles", "unique_id", "updated_at", "version"} | ||
} | ||
|
||
func (d destinationsTable) Values() []any { | ||
return []any{d.ConnectionCA, d.ConnectionURL, d.CreatedAt, d.DeletedAt, d.ID, d.LastSeenAt, d.Name, d.OrganizationID, d.Resources, d.Roles, d.UniqueID, d.UpdatedAt, d.Version} | ||
} | ||
|
||
func (d *destinationsTable) ScanFields() []any { | ||
return []any{&d.ConnectionCA, &d.ConnectionURL, &d.CreatedAt, &d.DeletedAt, &d.ID, &d.LastSeenAt, &d.Name, &d.OrganizationID, &d.Resources, &d.Roles, &d.UniqueID, &d.UpdatedAt, &d.Version} | ||
} | ||
|
||
// destinationsUpdateTable is used to update the destination. It excludes | ||
// the CreatedAt field, because that field is not part of the input to | ||
// UpdateDestination. | ||
type destinationsUpdateTable models.Destination | ||
|
||
func (d destinationsUpdateTable) Table() string { | ||
return "destinations" | ||
} | ||
|
||
func (d destinationsUpdateTable) Columns() []string { | ||
return []string{"connection_ca", "connection_url", "deleted_at", "id", "last_seen_at", "name", "organization_id", "resources", "roles", "unique_id", "updated_at", "version"} | ||
} | ||
|
||
func (d destinationsUpdateTable) Values() []any { | ||
return []any{d.ConnectionCA, d.ConnectionURL, d.DeletedAt, d.ID, d.LastSeenAt, d.Name, d.OrganizationID, d.Resources, d.Roles, d.UniqueID, d.UpdatedAt, d.Version} | ||
} | ||
|
||
func validateDestination(dest *models.Destination) error { | ||
if dest.Name == "" { | ||
return fmt.Errorf("name is required") | ||
return fmt.Errorf("Destination.Name is required") | ||
} | ||
if dest.UniqueID == "" { | ||
return fmt.Errorf("Destination.UniqueID is required") | ||
} | ||
return nil | ||
} | ||
|
||
func CreateDestination(db GormTxn, destination *models.Destination) error { | ||
func CreateDestination(tx WriteTxn, destination *models.Destination) error { | ||
if err := validateDestination(destination); err != nil { | ||
return err | ||
} | ||
return add(db, destination) | ||
return insert(tx, (*destinationsTable)(destination)) | ||
} | ||
|
||
func SaveDestination(db GormTxn, destination *models.Destination) error { | ||
func UpdateDestination(tx WriteTxn, destination *models.Destination) error { | ||
if err := validateDestination(destination); err != nil { | ||
return err | ||
} | ||
return save(db, destination) | ||
return update(tx, (*destinationsUpdateTable)(destination)) | ||
} | ||
|
||
func GetDestination(db GormTxn, selectors ...SelectorFunc) (*models.Destination, error) { | ||
|
@@ -38,51 +75,42 @@ func ListDestinations(db GormTxn, p *Pagination, selectors ...SelectorFunc) ([]m | |
return list[models.Destination](db, p, selectors...) | ||
} | ||
|
||
func DeleteDestinations(db GormTxn, selector SelectorFunc) error { | ||
toDelete, err := ListDestinations(db, nil, selector) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
if len(toDelete) > 0 { | ||
ids := make([]uid.ID, 0) | ||
for _, g := range toDelete { | ||
ids = append(ids, g.ID) | ||
} | ||
|
||
return deleteAll[models.Destination](db, ByIDs(ids)) | ||
} | ||
|
||
return internal.ErrNotFound | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously we were returning If we want to return a not found on a missing delete then we can check |
||
func DeleteDestination(tx WriteTxn, id uid.ID) error { | ||
stmt := ` | ||
UPDATE destinations SET deleted_at = ? | ||
WHERE id = ? AND organization_id = ? AND deleted_at is null | ||
` | ||
_, err := tx.Exec(stmt, time.Now(), id, tx.OrganizationID()) | ||
return handleError(err) | ||
} | ||
|
||
type destinationsCount struct { | ||
type DestinationsCount struct { | ||
Connected bool | ||
Version string | ||
Count float64 | ||
} | ||
|
||
func CountDestinationsByConnectedVersion(tx ReadTxn) ([]destinationsCount, error) { | ||
func CountDestinationsByConnectedVersion(tx ReadTxn) ([]DestinationsCount, error) { | ||
timeout := time.Now().Add(-5 * time.Minute) | ||
|
||
stmt := ` | ||
SELECT *, COUNT(*) AS count | ||
FROM ( | ||
SELECT COALESCE(version, '') AS version, last_seen_at >= ? AS connected | ||
FROM destinations | ||
WHERE deleted_at IS NULL | ||
) AS d | ||
GROUP BY version, connected` | ||
SELECT COALESCE(version, '') as version, | ||
last_seen_at >= ? as connected, | ||
count(*) | ||
FROM destinations | ||
WHERE deleted_at IS NULL | ||
GROUP BY connected, version | ||
` | ||
rows, err := tx.Query(stmt, timeout) | ||
if err != nil { | ||
return nil, err | ||
|
||
} | ||
defer rows.Close() | ||
|
||
var result []destinationsCount | ||
var result []DestinationsCount | ||
for rows.Next() { | ||
var item destinationsCount | ||
var item DestinationsCount | ||
if err := rows.Scan(&item.Version, &item.Connected, &item.Count); err != nil { | ||
return nil, err | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,54 +1,171 @@ | ||
package data | ||
|
||
import ( | ||
"errors" | ||
"testing" | ||
"time" | ||
|
||
"gotest.tools/v3/assert" | ||
|
||
"github.com/infrahq/infra/internal" | ||
"github.com/infrahq/infra/internal/server/models" | ||
) | ||
|
||
func TestDestinationSaveCreatedPersists(t *testing.T) { | ||
func TestCreateDestination(t *testing.T) { | ||
runDBTests(t, func(t *testing.T, db *DB) { | ||
destination := &models.Destination{ | ||
Name: "example-cluster-1", | ||
} | ||
t.Run("success", func(t *testing.T) { | ||
tx := txnForTestCase(t, db, db.DefaultOrg.ID) | ||
|
||
err := CreateDestination(db, destination) | ||
assert.NilError(t, err) | ||
assert.Assert(t, !destination.CreatedAt.IsZero()) | ||
destination := &models.Destination{ | ||
Name: "kubernetes", | ||
UniqueID: "unique-id", | ||
ConnectionURL: "10.0.0.1:1001", | ||
ConnectionCA: "the-pem-encoded-cert", | ||
LastSeenAt: time.Date(2022, 1, 2, 3, 4, 5, 600, time.UTC), | ||
Version: "0.100.1", | ||
Resources: []string{"res1", "res2"}, | ||
Roles: []string{"role1", "role2"}, | ||
} | ||
|
||
destination.Name = "example-cluster-2" | ||
destination.CreatedAt = time.Time{} | ||
err := CreateDestination(tx, destination) | ||
assert.NilError(t, err) | ||
assert.Assert(t, destination.ID != 0) | ||
|
||
err = SaveDestination(db, destination) | ||
assert.NilError(t, err) | ||
expected := &models.Destination{ | ||
Model: models.Model{ | ||
ID: destination.ID, | ||
CreatedAt: time.Now(), | ||
UpdatedAt: time.Now(), | ||
}, | ||
OrganizationMember: models.OrganizationMember{OrganizationID: defaultOrganizationID}, | ||
Name: "kubernetes", | ||
UniqueID: "unique-id", | ||
ConnectionURL: "10.0.0.1:1001", | ||
ConnectionCA: "the-pem-encoded-cert", | ||
LastSeenAt: time.Date(2022, 1, 2, 3, 4, 5, 600, time.UTC), | ||
Version: "0.100.1", | ||
Resources: []string{"res1", "res2"}, | ||
Roles: []string{"role1", "role2"}, | ||
} | ||
assert.DeepEqual(t, destination, expected, cmpModel) | ||
}) | ||
t.Run("conflict on uniqueID", func(t *testing.T) { | ||
tx := txnForTestCase(t, db, db.DefaultOrg.ID) | ||
|
||
destination := &models.Destination{ | ||
Name: "kubernetes", | ||
UniqueID: "unique-id", | ||
} | ||
err := CreateDestination(tx, destination) | ||
assert.NilError(t, err) | ||
assert.Assert(t, destination.ID != 0) | ||
|
||
next := &models.Destination{ | ||
Name: "other", | ||
UniqueID: "unique-id", | ||
} | ||
err = CreateDestination(tx, next) | ||
var ucErr UniqueConstraintError | ||
assert.Assert(t, errors.As(err, &ucErr)) | ||
expected := UniqueConstraintError{Table: "destinations", Column: "uniqueID"} | ||
assert.DeepEqual(t, ucErr, expected) | ||
}) | ||
}) | ||
} | ||
|
||
destination, err = GetDestination(db, ByID(destination.ID)) | ||
func TestUpdateDestination(t *testing.T) { | ||
runDBTests(t, func(t *testing.T, db *DB) { | ||
t.Run("success", func(t *testing.T) { | ||
tx := txnForTestCase(t, db, db.DefaultOrg.ID) | ||
|
||
created := time.Date(2022, 1, 2, 3, 4, 5, 600, time.UTC) | ||
orig := &models.Destination{ | ||
Model: models.Model{CreatedAt: created, UpdatedAt: created}, | ||
Name: "example-cluster-1", | ||
UniqueID: "11111", | ||
} | ||
createDestinations(t, tx, orig) | ||
|
||
// Unlike other update operations, the passed in destination | ||
// may be constructed entirely by the caller and may not have the | ||
// created, or updated time set. | ||
destination := &models.Destination{ | ||
Model: models.Model{ID: orig.ID}, | ||
Name: "example-cluster-2", | ||
UniqueID: "22222", | ||
ConnectionURL: "dest.internal:10001", | ||
ConnectionCA: "the-pem-encoded-cert", | ||
Resources: []string{"res1", "res3"}, | ||
Roles: []string{"role1"}, | ||
Version: "0.100.2", | ||
} | ||
err := UpdateDestination(tx, destination) | ||
assert.NilError(t, err) | ||
|
||
actual, err := GetDestination(tx, ByID(destination.ID)) | ||
assert.NilError(t, err) | ||
|
||
expected := &models.Destination{ | ||
Model: models.Model{ | ||
ID: destination.ID, | ||
CreatedAt: created, | ||
UpdatedAt: time.Now(), | ||
}, | ||
OrganizationMember: models.OrganizationMember{OrganizationID: defaultOrganizationID}, | ||
Name: "example-cluster-2", | ||
UniqueID: "22222", | ||
ConnectionURL: "dest.internal:10001", | ||
ConnectionCA: "the-pem-encoded-cert", | ||
Resources: []string{"res1", "res3"}, | ||
Roles: []string{"role1"}, | ||
Version: "0.100.2", | ||
} | ||
assert.DeepEqual(t, actual, expected, cmpModel) | ||
}) | ||
}) | ||
} | ||
|
||
func TestDeleteDestination(t *testing.T) { | ||
runDBTests(t, func(t *testing.T, db *DB) { | ||
tx := txnForTestCase(t, db, db.DefaultOrg.ID) | ||
|
||
dest := &models.Destination{Name: "kube", UniqueID: "1111"} | ||
createDestinations(t, tx, dest) | ||
|
||
err := DeleteDestination(tx, dest.ID) | ||
assert.NilError(t, err) | ||
assert.Assert(t, !destination.CreatedAt.IsZero()) | ||
|
||
_, err = GetDestination(tx, ByID(dest.ID)) | ||
assert.ErrorIs(t, err, internal.ErrNotFound) | ||
}) | ||
} | ||
|
||
func TestCountDestinationsByConnectedVersion(t *testing.T) { | ||
runDBTests(t, func(t *testing.T, db *DB) { | ||
assert.NilError(t, CreateDestination(db, &models.Destination{Name: "1", UniqueID: "1", LastSeenAt: time.Now()})) | ||
assert.NilError(t, CreateDestination(db, &models.Destination{Name: "2", UniqueID: "2", Version: "", LastSeenAt: time.Now().Add(-10 * time.Minute)})) | ||
assert.NilError(t, CreateDestination(db, &models.Destination{Name: "3", UniqueID: "3", Version: "0.1.0", LastSeenAt: time.Now()})) | ||
assert.NilError(t, CreateDestination(db, &models.Destination{Name: "4", UniqueID: "4", Version: "0.1.0"})) | ||
assert.NilError(t, CreateDestination(db, &models.Destination{Name: "5", UniqueID: "5", Version: "0.1.0"})) | ||
|
||
createDestinations(t, db, | ||
&models.Destination{Name: "1", UniqueID: "1", LastSeenAt: time.Now()}, | ||
&models.Destination{Name: "2", UniqueID: "2", Version: "", LastSeenAt: time.Now().Add(-10 * time.Minute)}, | ||
&models.Destination{Name: "3", UniqueID: "3", Version: "0.1.0", LastSeenAt: time.Now()}, | ||
&models.Destination{Name: "4", UniqueID: "4", Version: "0.1.0"}, | ||
&models.Destination{Name: "5", UniqueID: "5", Version: "0.1.0"}, | ||
) | ||
actual, err := CountDestinationsByConnectedVersion(db) | ||
assert.NilError(t, err) | ||
|
||
expected := []destinationsCount{ | ||
expected := []DestinationsCount{ | ||
{Connected: false, Version: "", Count: 1}, | ||
{Connected: true, Version: "", Count: 1}, | ||
{Connected: false, Version: "0.1.0", Count: 2}, | ||
{Connected: true, Version: "", Count: 1}, | ||
{Connected: true, Version: "0.1.0", Count: 1}, | ||
} | ||
|
||
assert.DeepEqual(t, actual, expected) | ||
}) | ||
} | ||
|
||
func createDestinations(t *testing.T, tx GormTxn, destinations ...*models.Destination) { | ||
t.Helper() | ||
for i := range destinations { | ||
err := CreateDestination(tx, destinations[i]) | ||
assert.NilError(t, err, destinations[i].Name) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels a bit off, but I can't come up with a better way to do updates either in our current model
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is unfortunate, there are some other options:
select created_at
and set the value on the struct, before theUPDATE
insert()
and copy out the implementation intoCreateDestination
.Maybe option 2 is better, I'll see what it looks like
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess the problem with 2 is that we have to add
CreatedAt
back in for bothGet
andList
, which means we end up having to deal with the special case in 3 places, instead of onlyUpdate
being a special case.Let's try it with the current implementation, and see if we run into this problem with any other models.