Skip to content
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

Replace gorm.DB with an interface #3008

Merged
merged 15 commits into from
Aug 25, 2022
Merged

Replace gorm.DB with an interface #3008

merged 15 commits into from
Aug 25, 2022

Conversation

dnephin
Copy link
Contributor

@dnephin dnephin commented Aug 22, 2022

Summary

Branched from #3006, that will need to merge first.

This is a rather large PR because it was hard to split this change into smaller pieces. Introducing the interface should be the largest part of the change. After this everything should be easily broken up into much smaller changes.

This PR does the following:

  • introduces a GormTxn interface that is accepted by all the data query functions
  • adds methods to that interface to pass the Organization ID
  • updates the middleware to set the orgID on this transaction, and all functions to read from this value.

This approach makes our code much safer, because we are no longer defaulting to the default orgID. I'll call out some places where I had to explicitly do this in tests to prevent breaking many tests. I'll go through in a follow up to fix these places.

There are many commits, and a lot of them are very mechanism find/replace. I'll try to call out the most interesting bits with inline comments.

Related to #2415

@dnephin dnephin changed the title Dnephin/data interface take2 Replace gorm.DB with an interface Aug 22, 2022
Comment on lines +20 to +29
s := []data.SelectorFunc{
data.Preload("IssuedForIdentity"),
data.ByOptionalIssuedFor(identityID),
data.ByOptionalName(name),
}
if !showExpired {
s = append(s, data.ByNotExpiredOrExtended())
}

return data.ListAccessKeys(db.Preload("IssuedForIdentity"), p, s...)
return data.ListAccessKeys(db, p, s...)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a few places I had to convert Preload to a selector so that I could accept a data.GormTxn as the first argument to the query function, and only selectors would continue to use gorm.DB. This is one of the places, there should be a few others.

Comment on lines +34 to +38
db = data.NewTransaction(db.GormDB(), details.Org.ID)
c.Set("db", db)
rCtx := GetRequestContext(c)
rCtx.DBTxn = db
c.Set(RequestContextKey, rCtx)
Copy link
Contributor Author

@dnephin dnephin Aug 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we're passing orgID as part of the transaction the data.CreateOrganization function can't set the org ID in the context. I think this is a good thing, as previously it was surprising that creating an org mutated the DB to make all queries use that org.

As a consequence of that change we have to set the tx here for the rest of the request, but this is the only place where we need to do this.

I've also restored the original name for CreateOrganization, now that it's not doing the SetContext part.

Comment on lines +96 to +98
// FIXME: this is a hack to keep our tests passing. The db should not
// be scoped to an org ID.
return d.DefaultOrg.ID
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling this out. This is not a regression, because it works this way on main, but it is a problem we should fix.

All of our test fixtures should be more explicit about which org they are using when creating things.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this one. How does this do the right thing in production?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly. This is why I prioritized this part of the data package work, because this is the behaviour we have now by setting the context when we create a database.

It "works" in production because requests use the transaction reference where we override the organization value.

The only code that doesn't do that right now is:

  • the config loading, where you fixed it to set this same default value
  • tests in the server package where we do the same thing by setting the default org.

I plan on removing this in the first follow up PR.

@@ -76,66 +75,8 @@ func TestSnowflakeIDSerialization(t *testing.T) {
})
}

func TestDatabaseSelectors(t *testing.T) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed because this test is just a simulation of the selector behaviour, and we should be removing selector functions soon. Porting this test to the new interface would be a lot more work than other tests, and is not worth it.

Comment on lines +115 to +119
selectID := func(db *gorm.DB) *gorm.DB {
return db.Select("id")
}
selectors = append([]SelectorFunc{selectID}, selectors...)
toDelete, err := ListIdentities(tx, nil, selectors...)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another place where I had to move a thing to the selectors. I think this is the only case that was not a Preload

assert.NilError(t, m.Migrate())

initialSchema = dumpSchema(t, os.Getenv("POSTGRESQL_CONNECTION"))

assert.NilError(t, db.Exec("DROP SCHEMA IF EXISTS testing CASCADE").Error)
_, err = db.Exec("DROP SCHEMA IF EXISTS testing CASCADE")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I converted the few remaining Exec calls to the new interface, which matches the stdlib. So you'll notice the .Error removed, and that it now returns two args.

return org
}

func MustGetOrgFromContext(ctx context.Context) *models.Organization {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we have a strongly typed interface we don't need to store the org in the context. This wasn't necessary to do all at once, but it was a very small change at the end (see the last commit).

We no longer panic, but an unset orgID will also be 0, which will never match a real org (not even the default), which will still error and should be caught by a test.

pgsql := postgresDriver(t)
db := setupDB(t, pgsql)
func TestCreateOrganization(t *testing.T) {
runDBTests(t, func(t *testing.T, db *DB) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated this test to use runDBTests like all the others. We only really need to test against postgres right now, but I expect runDBTests to be useful in the future despite that.

One use case that comes to mind is testing postgres upgrades. We can run all of our data tests against multiple versions of postgres to catch problems.

withDBTxn(c.Request.Context(), srv.DB(), func(tx *gorm.DB) {
withDBTxn(c.Request.Context(), srv.DB().GormDB(), func(db *gorm.DB) {

tx := data.NewTransaction(db, 0)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it was before, we now set the orgID in the transaction as part of these middleware. But until we have an org identified we query with orgID =0, which should ensure we never match an org.

@dnephin dnephin force-pushed the dnephin/data-interface-take2 branch from ccc3f42 to c898a37 Compare August 22, 2022 22:54
@@ -42,6 +43,10 @@ func TestSignup(t *testing.T) {
assert.Equal(t, identity.Name, user)
assert.Equal(t, identity.OrganizationID, org.ID)

// simulate a request
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch

return list[models.AccessKey](db, p, selectors...)
}

func GetAccessKey(db *gorm.DB, selectors ...SelectorFunc) (*models.AccessKey, error) {
func GetAccessKey(tx GormTxn, selectors ...SelectorFunc) (*models.AccessKey, error) {
db := tx.GormDB()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do some queries need tx.GormDB?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anything that builds a query itself using gorm methods will need to call tx.GormDB. Mostly that is our helpers like add, save, get, but also a few others that do more interesting queries.

This will give us an easy way of finding all the queries that need to be replaced with sql.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found this a little confusing too when reading through the PR. Maybe a note around GormDB() which explains when to use it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be removing GormDB very soon, but I'll add a comment for the interim.

}
if err := SaveAccessKey(db, t); err != nil {

tx = NewTransaction(tx.GormDB(), t.OrganizationID)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a nested transaction?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming is maybe a bit misleading. NewTransaction doesn't create a new DB transaction, it uses an existing reference to an sql.Tx, but sets a new orgID for the transaction.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also took me a moment to realize what was going on, and how Exec() works inside the transaction.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed this is done in the wrong place. I'll add a comment and move it to outside of this block.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This NewTransaction wouldn't be necessary if we didn't have the panic thta was requested in these comments. It's kind of unfortunate that we're forcing unnecessary code because of safeguards that aren't correct in all cases.

Copy link
Contributor Author

@dnephin dnephin Aug 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to remove this by moving where we panic:

https://github.com/infrahq/infra/compare/dnephin/data-interface-take2...dnephin/data-interface-org-id-panic?expand=1

I'll open that as a follow up

internal/server/data/data.go Show resolved Hide resolved
@@ -157,6 +158,7 @@ func unauthenticatedMiddleware(srv *Server) gin.HandlerFunc {
sendAPIError(c, internal.ErrBadRequest)
return
}
authned.Organization = org
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be set directly on the authned access key already, re-assigning feels dangerous. Or is this just loading the org for the access key's org ID?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the unauthenticated route, so it may not be set yet.

I wasn't sure where to store it. Maybe we should split up the Authenticated struct into something like this:

type RequestDetails struct {
    Authenticated Authenticated
    Organization *models.Organization
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like a good idea

internal/server/passwordreset.go Show resolved Hide resolved
Copy link
Contributor

@ssoroka ssoroka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would like to see the return of MustGetOrg type semantics, where things can't quietly fail when missing. Even in cases where the calling code panics anyway, the panic message is more clear when explicit.

internal/server/data/data.go Show resolved Hide resolved
internal/server/data/data.go Show resolved Hide resolved
Comment on lines +96 to +98
// FIXME: this is a hack to keep our tests passing. The db should not
// be scoped to an org ID.
return d.DefaultOrg.ID
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this one. How does this do the right thing in production?

internal/server/middleware.go Show resolved Hide resolved
internal/server/data/organization.go Show resolved Hide resolved
internal/access/organization.go Show resolved Hide resolved
Copy link
Contributor

@pdevine pdevine left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks fine. We still need to somehow split this for privileged vs unprivileged requests, and I'm not 100% sure how to carve that up. We could hold two references inside of the transaction, although it would be preferable only to store the unprivileged connection just in case we mess something up and call something in the data layer which references the wrong connection.

Right now we need the privileged connection to:

  • Use the telemetry/metrics
  • Load the config (which needs to look up the default org, but we could drop this if we didn't have a default org at all)
  • DeleteIdentities() and DeleteGrants() also needs the work somehow in the config which gets rid of any users/grants which were set up previously. This gets really weird if the org changes.
  • Encryption keys (since they're not tied to an org -- the unprivileged connection can still look these up right now which is maybe not what we want)
  • Migrations
  • Sign-up (although this could also work if we don't turn on RLS for the organizations table)

@@ -15,7 +14,7 @@ import (
"github.com/infrahq/infra/internal/testing/patch"
)

func setupDB(t *testing.T) *gorm.DB {
func setupDB(t *testing.T) *data.DB {
t.Helper()
driver := database.PostgresDriver(t, "_authn")
if driver == nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be pulling out the SQLite driver now?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No reason to yet. That should be a separate change either way.

internal/server/data/data.go Show resolved Hide resolved
return list[models.AccessKey](db, p, selectors...)
}

func GetAccessKey(db *gorm.DB, selectors ...SelectorFunc) (*models.AccessKey, error) {
func GetAccessKey(tx GormTxn, selectors ...SelectorFunc) (*models.AccessKey, error) {
db := tx.GormDB()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found this a little confusing too when reading through the PR. Maybe a note around GormDB() which explains when to use it?

}
if err := SaveAccessKey(db, t); err != nil {

tx = NewTransaction(tx.GormDB(), t.OrganizationID)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also took me a moment to realize what was going on, and how Exec() works inside the transaction.

@dnephin
Copy link
Contributor Author

dnephin commented Aug 23, 2022

We still need to somehow split this for privileged vs unprivileged requests, and I'm not 100% sure how to carve that up.

The data.DB struct should return two connection pools, one privileged, and one unprivileged.

When we choose which one to use when we create the transaction.

I don't think the changes were should impact that much. The biggest change might be in tests.

@dnephin dnephin force-pushed the dnephin/data-interface-take2 branch from 9c59375 to ee783cd Compare August 23, 2022 17:39
@dnephin
Copy link
Contributor Author

dnephin commented Aug 23, 2022

I've restored the panic and added more godoc.

I don't like the panic, I think it's assuming too much about the caller, which maybe true for regular requests, but is not true for middleware and other callers of the data package. But I'm fine to add it for now , and we can revisit it in a smaller more focused PR.

There are some places where we have to create a transaction just to work around the panic. The data.Transaction wouldn't be necessary otherwise, because the org ID is already set on the object.

I've also added some godoc in a few places as requested.

Those changes are in the latest 2 commits. This should be ready for another review.

Base automatically changed from dnephin/data-remove-gorm-from-migrator to main August 23, 2022 21:23
Copy link
Collaborator

@BruceMacD BruceMacD left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me pending any further comments from Steven and Patrick

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants