1
0
mirror of https://github.com/rancher/steve.git synced 2025-09-03 08:25:13 +00:00

Hard-wire external associations: 5/7: update A=>B links when instances of A change (#646)

* Continue rebasing.

* Wrote unit tests for external associations.

* Fix the generated SQL.

Some syntactic sugar (capitalizing the keywords), but use the 'ON' syntax on JOINs.

* We want "management.cattle.io.projects:spec.displayName" not "...spec.clusterName"

* Implement hard-wired external associations:

* The table is in sqlproxy.proxy_store
  - externalGVKDependencies - a map of GVKs to dependencies.
    When the key GVK is updated, it triggers the updates in the database for the dependent GVKs,
    replacing fields as specified in the table.

* This is done in an afterUpsert handler, but it's done after the transaction for the core
  GVK update is finished, because most likely the dependent GVK updates will depend on the
  final database values for the GVK being updated, and if we do it as part of the transaction
  the new values won't be committed to the database.

* When an object is modified/created, check for external deps that need updating.

* Stop emitting errors when joining tables if one of the tables doesn't exist.

* Update unit test syntax for SQL queries.

* And an override check

This ensures we don't overwrite good data when
pulling data from one table to another.

* Drop labels, and use mgmt.cattle.io/spec.displayName

There's no need to hardwire labels in proxy_store:typeSpecificIndexedFields
because all labels are indexed in the shadow labels table.

* Keep clusterName, add displayName for mgmt.cattle.io

* Fix rebase/merge breakage.

* Finish the merge: add the 'selfUpdateInfo' param where it didn't get inserted during merge.

* Patch up rebase failures.

* Now gomock generates named args. I give up.
This commit is contained in:
Eric Promislow
2025-07-03 14:35:09 -07:00
committed by GitHub
parent 61173104af
commit 3be82a28d1
11 changed files with 288 additions and 113 deletions

View File

@@ -8,6 +8,7 @@ import (
"database/sql"
"fmt"
"reflect"
"strings"
"github.com/rancher/lasso/pkg/log"
"github.com/rancher/steve/pkg/sqlcache/db"
@@ -44,6 +45,7 @@ type Store struct {
gvk schema.GroupVersionKind
name string
externalUpdateInfo *sqltypes.ExternalGVKUpdates
selfUpdateInfo *sqltypes.ExternalGVKUpdates
typ reflect.Type
keyFunc cache.KeyFunc
shouldEncrypt bool
@@ -72,12 +74,13 @@ type Store struct {
var _ cache.Store = (*Store)(nil)
// NewStore creates a SQLite-backed cache.Store for objects of the given example type
func NewStore(ctx context.Context, example any, keyFunc cache.KeyFunc, c db.Client, shouldEncrypt bool, gvk schema.GroupVersionKind, name string, externalUpdateInfo *sqltypes.ExternalGVKUpdates) (*Store, error) {
func NewStore(ctx context.Context, example any, keyFunc cache.KeyFunc, c db.Client, shouldEncrypt bool, gvk schema.GroupVersionKind, name string, externalUpdateInfo *sqltypes.ExternalGVKUpdates, selfUpdateInfo *sqltypes.ExternalGVKUpdates) (*Store, error) {
s := &Store{
ctx: ctx,
name: name,
gvk: gvk,
externalUpdateInfo: externalUpdateInfo,
selfUpdateInfo: selfUpdateInfo,
typ: reflect.TypeOf(example),
Client: c,
keyFunc: keyFunc,
@@ -121,19 +124,37 @@ func NewStore(ctx context.Context, example any, keyFunc cache.KeyFunc, c db.Clie
return s, nil
}
func (s *Store) checkUpdateExternalInfo(key string) error {
if s.externalUpdateInfo == nil {
return nil
}
return s.WithTransaction(s.ctx, true, func(tx transaction.Client) error {
if err := s.updateExternalInfo(tx, key, s.externalUpdateInfo); err != nil {
// Just report and ignore errors
logrus.Errorf("Error updating external info %v: %s", s.externalUpdateInfo, err)
}
return nil
})
func isDBError(e error) bool {
return strings.Contains(e.Error(), "SQL logic error: no such table:")
}
func (s *Store) checkUpdateExternalInfo(key string) {
for _, updateBlock := range []*sqltypes.ExternalGVKUpdates{s.externalUpdateInfo, s.selfUpdateInfo} {
if updateBlock != nil {
s.WithTransaction(s.ctx, true, func(tx transaction.Client) error {
err := s.updateExternalInfo(tx, key, updateBlock)
if err != nil && !isDBError(err) {
// Just report and ignore errors
logrus.Errorf("Error updating external info %v: %s", s.externalUpdateInfo, err)
}
return nil
})
}
}
}
// This function is called in two different conditions:
// Let's say resource B has a field X that we want to copy into resource A
// When a B is upserted, we update any A's that depend on it
// When an A is upserted, we check to see if any B's have that info
// The `key` argument here can belong to either an A or a B, depending on which resource is being updated.
// So it's only used in debug messages.
// The SELECT queries are more generic -- find *all* the instances of A that have a connection to B,
// ignoring any cases where A.X == B.X, as there's no need to update those.
//
// Some code later on in the function verifies that we aren't overwriting a non-empty value
// with the empty string. I assume this is never desired.
func (s *Store) updateExternalInfo(tx transaction.Client, key string, externalUpdateInfo *sqltypes.ExternalGVKUpdates) error {
for _, labelDep := range externalUpdateInfo.ExternalLabelDependencies {
rawGetStmt := fmt.Sprintf(`SELECT DISTINCT f.key, ex2."%s" FROM "%s_fields" f
@@ -151,7 +172,9 @@ func (s *Store) updateExternalInfo(tx transaction.Client, key string, externalUp
getStmt := s.Prepare(rawGetStmt)
rows, err := s.QueryForRows(s.ctx, getStmt, labelDep.SourceLabelName)
if err != nil {
logrus.Infof("Error getting external info for table %s, key %s: %v", labelDep.TargetGVK, key, &db.QueryError{QueryString: rawGetStmt, Err: err})
if !isDBError(err) {
logrus.Infof("Error getting external info for table %s, key %s: %v", labelDep.TargetGVK, key, &db.QueryError{QueryString: rawGetStmt, Err: err})
}
continue
}
result, err := s.ReadStrings2(rows)
@@ -165,6 +188,10 @@ func (s *Store) updateExternalInfo(tx transaction.Client, key string, externalUp
for _, innerResult := range result {
sourceKey := innerResult[0]
finalTargetValue := innerResult[1]
ignoreUpdate, err := s.overrideCheck(labelDep.TargetFinalFieldName, labelDep.SourceGVK, sourceKey, finalTargetValue)
if ignoreUpdate || err != nil {
continue
}
rawStmt := fmt.Sprintf(`UPDATE "%s_fields" SET "%s" = ? WHERE key = ?`,
labelDep.SourceGVK, labelDep.TargetFinalFieldName)
preparedStmt := s.Prepare(rawStmt)
@@ -191,7 +218,9 @@ func (s *Store) updateExternalInfo(tx transaction.Client, key string, externalUp
getStmt := s.Prepare(rawGetStmt)
rows, err := s.QueryForRows(s.ctx, getStmt, nonLabelDep.SourceFieldName)
if err != nil {
logrus.Infof("Error getting external info for table %s, key %s: %v", nonLabelDep.TargetGVK, key, &db.QueryError{QueryString: rawGetStmt, Err: err})
if !isDBError(err) {
logrus.Infof("Error getting external info for table %s, key %s: %v", nonLabelDep.TargetGVK, key, &db.QueryError{QueryString: rawGetStmt, Err: err})
}
continue
}
result, err := s.ReadStrings2(rows)
@@ -205,6 +234,10 @@ func (s *Store) updateExternalInfo(tx transaction.Client, key string, externalUp
for _, innerResult := range result {
sourceKey := innerResult[0]
finalTargetValue := innerResult[1]
ignoreUpdate, err := s.overrideCheck(nonLabelDep.TargetFinalFieldName, nonLabelDep.SourceGVK, sourceKey, finalTargetValue)
if ignoreUpdate || err != nil {
continue
}
rawStmt := fmt.Sprintf(`UPDATE "%s_fields" SET "%s" = ? WHERE key = ?`,
nonLabelDep.SourceGVK, nonLabelDep.TargetFinalFieldName)
preparedStmt := s.Prepare(rawStmt)
@@ -213,12 +246,46 @@ func (s *Store) updateExternalInfo(tx transaction.Client, key string, externalUp
logrus.Infof("Error running %s(%s, %s): %s", rawStmt, finalTargetValue, sourceKey, err)
continue
}
logrus.Debugf("QQQ: non-label-Updated %s[%s].%s to %s",
nonLabelDep.SourceGVK,
sourceKey,
nonLabelDep.TargetFinalFieldName,
finalTargetValue)
}
}
return nil
}
// If the new value will change a non-empty current value, return [true, error:nil]
func (s *Store) overrideCheck(finalFieldName, sourceGVK, sourceKey, finalTargetValue string) (bool, error) {
rawGetValueStmt := fmt.Sprintf(`SELECT f."%s" FROM "%s_fields" f WHERE f.key = ?`,
finalFieldName, sourceGVK)
getValueStmt := s.Prepare(rawGetValueStmt)
rows, err := s.QueryForRows(s.ctx, getValueStmt, sourceKey)
if err != nil {
logrus.Debugf("Checking the field, got error %s", err)
return false, err
}
results, err := s.ReadStrings(rows)
if err != nil {
logrus.Infof("Checking the field for table %s, key %s, got error %s", sourceGVK, sourceKey, err)
return false, err
}
if len(results) == 1 {
currentValue := results[0]
if len(currentValue) > 0 && len(finalTargetValue) == 0 {
logrus.Debugf("Don't override %s key %s, field %s=%s with an empty string",
sourceGVK,
sourceKey,
finalFieldName,
currentValue)
return true, nil
}
}
return false, nil
}
// deleteByKey deletes the object associated with key, if it exists in this Store
func (s *Store) deleteByKey(key string, obj any) error {
return s.WithTransaction(s.ctx, true, func(tx transaction.Client) error {
@@ -282,7 +349,8 @@ func (s *Store) Add(obj any) error {
log.Errorf("Error in Store.Add for type %v: %v", s.name, err)
return err
}
return s.checkUpdateExternalInfo(key)
s.checkUpdateExternalInfo(key)
return nil
}
// Update saves an obj, or updates it if it exists in this Store
@@ -309,7 +377,8 @@ func (s *Store) Update(obj any) error {
log.Errorf("Error in Store.Update for type %v: %v", s.name, err)
return err
}
return s.checkUpdateExternalInfo(key)
s.checkUpdateExternalInfo(key)
return nil
}
// Delete deletes the given object, if it exists in this Store

View File

@@ -14,7 +14,6 @@ import (
"context"
"database/sql"
"fmt"
"github.com/rancher/steve/pkg/sqlcache/sqltypes"
"reflect"
"regexp"
"strings"
@@ -22,6 +21,7 @@ import (
"github.com/rancher/steve/pkg/sqlcache/db"
"github.com/rancher/steve/pkg/sqlcache/db/transaction"
"github.com/rancher/steve/pkg/sqlcache/sqltypes"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
@@ -705,17 +705,102 @@ func WSIgnoringMatcher(expected string) gomock.Matcher {
}
}
func TestAddWithExternalUpdates(t *testing.T) {
func TestAddWithOneUpdate(t *testing.T) {
type testCase struct {
description string
updateExternal bool
updateSelf bool
}
testObject := testStoreObject{Id: "testStoreObject", Val: "a"}
var tests []testCase
tests = append(tests,
testCase{description: "Add external update",
updateExternal: true,
updateSelf: false,
})
tests = append(tests,
testCase{description: "Add self update",
updateExternal: false,
updateSelf: true,
})
t.Parallel()
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
c, txC := SetupMockDB(t)
stmts := NewMockStmt(gomock.NewController(t))
store := SetupStoreWithExternalDependencies(t, c, test.updateExternal, test.updateSelf)
c.EXPECT().Upsert(txC, store.upsertStmt, "testStoreObject", testObject, store.shouldEncrypt).Return(nil)
c.EXPECT().WithTransaction(gomock.Any(), true, gomock.Any()).Return(nil).Do(
func(ctx context.Context, shouldEncrypt bool, f db.WithTransactionFunction) {
err := f(txC)
if err != nil {
t.Fail()
}
}).Times(2)
rawStmt := `SELECT DISTINCT f.key, ex2."spec.displayName" FROM "_v1_Namespace_fields" f
LEFT OUTER JOIN "_v1_Namespace_labels" lt1 ON f.key = lt1.key
JOIN "management.cattle.io_v3_Project_fields" ex2 ON lt1.value = ex2."metadata.name"
WHERE lt1.label = ? AND f."spec.displayName" != ex2."spec.displayName"`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt))
results1 := "field.cattle.io/projectId"
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), results1)
c.EXPECT().ReadStrings2(gomock.Any()).Return([][]string{{"lego.cattle.io/fields1", "moose1"}}, nil)
// Override check:
rawStmt2 := `SELECT f."spec.displayName" FROM "_v1_Namespace_fields" f WHERE f.key = ?`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt2))
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), gomock.Any())
c.EXPECT().ReadStrings(gomock.Any())
rawStmt2a := `UPDATE "_v1_Namespace_fields" SET "spec.displayName" = ? WHERE key = ?`
c.EXPECT().Prepare(rawStmt2a)
txC.EXPECT().Stmt(gomock.Any()).Return(stmts)
stmts.EXPECT().Exec("moose1", "lego.cattle.io/fields1")
rawStmt3 := `SELECT f.key, ex2."spec.projectName" FROM "_v1_Pods_fields" f
JOIN "provisioner.cattle.io_v3_Cluster_fields" ex2 ON f."field.cattle.io/fixer" = ex2."metadata.name"
WHERE f."spec.projectName" != ex2."spec.projectName"`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt3))
results2 := []any{"lego.cattle.io/fields2"}
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), results2)
c.EXPECT().ReadStrings2(gomock.Any()).Return([][]string{{"lego.cattle.io/fields2", "moose2"}}, nil)
// Override check:
rawStmt2 = `SELECT f."spec.projectName" FROM "_v1_Pods_fields" f WHERE f.key = ?`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt2))
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), gomock.Any())
c.EXPECT().ReadStrings(gomock.Any())
rawStmt4 := `UPDATE "_v1_Pods_fields" SET "spec.projectName" = ? WHERE key = ?`
c.EXPECT().Prepare(rawStmt4)
txC.EXPECT().Stmt(gomock.Any()).Return(stmts)
stmts.EXPECT().Exec("moose2", "lego.cattle.io/fields2")
err := store.Add(testObject)
assert.Nil(t, err)
})
}
}
func TestAddWithBothUpdates(t *testing.T) {
type testCase struct {
description string
test func(t *testing.T)
}
testObject := testStoreObject{Id: "testStoreObject", Val: "a"}
var tests []testCase
tests = append(tests, testCase{description: "Add with no DB client errors", test: func(t *testing.T) {
tests = append(tests, testCase{description: "Update both external and self", test: func(t *testing.T) {
c, txC := SetupMockDB(t)
stmts := NewMockStmt(gomock.NewController(t))
store := SetupStoreWithExternalDependencies(t, c)
store := SetupStoreWithExternalDependencies(t, c, true, true)
rawStmt := `SELECT DISTINCT f.key, ex2."spec.displayName" FROM "_v1_Namespace_fields" f
LEFT OUTER JOIN "_v1_Namespace_labels" lt1 ON f.key = lt1.key
JOIN "management.cattle.io_v3_Project_fields" ex2 ON lt1.value = ex2."metadata.name"
WHERE lt1.label = ? AND f."spec.displayName" != ex2."spec.displayName"`
rawStmt3 := `SELECT f.key, ex2."spec.projectName" FROM "_v1_Pods_fields" f
JOIN "provisioner.cattle.io_v3_Cluster_fields" ex2 ON f."field.cattle.io/fixer" = ex2."metadata.name"
WHERE f."spec.projectName" != ex2."spec.projectName"`
c.EXPECT().Upsert(txC, store.upsertStmt, "testStoreObject", testObject, store.shouldEncrypt).Return(nil)
c.EXPECT().WithTransaction(gomock.Any(), true, gomock.Any()).Return(nil).Do(
@@ -724,32 +809,47 @@ func TestAddWithExternalUpdates(t *testing.T) {
if err != nil {
t.Fail()
}
}).Times(2)
rawStmt := `SELECT DISTINCT f.key, ex2."spec.displayName" FROM "_v1_Namespace_fields" f
LEFT OUTER JOIN "_v1_Namespace_labels" lt1 ON f.key = lt1.key
JOIN "management.cattle.io_v3_Project_fields" ex2 ON lt1.value = ex2."metadata.name"
WHERE lt1.label = ? AND f."spec.displayName" != ex2."spec.displayName"`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt))
results1 := []any{"field.cattle.io/projectId"}
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), results1)
c.EXPECT().ReadStrings2(gomock.Any()).Return([][]string{{"lego.cattle.io/fields1", "moose1"}}, nil)
rawStmt2 := `UPDATE "_v1_Namespace_fields" SET "spec.displayName" = ? WHERE key = ?`
c.EXPECT().Prepare(rawStmt2)
txC.EXPECT().Stmt(gomock.Any()).Return(stmts)
stmts.EXPECT().Exec("moose1", "lego.cattle.io/fields1")
})
for _ = range 2 {
c.EXPECT().WithTransaction(gomock.Any(), true, gomock.Any()).Return(nil).Do(
func(ctx context.Context, shouldEncrypt bool, f db.WithTransactionFunction) {
err := f(txC)
if err != nil {
t.Fail()
}
})
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt))
results1 := "field.cattle.io/projectId"
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), results1)
c.EXPECT().ReadStrings2(gomock.Any()).Return([][]string{{"lego.cattle.io/fields1", "moose1"}}, nil)
// Override check:
rawStmt2 := `SELECT f."spec.displayName" FROM "_v1_Namespace_fields" f WHERE f.key = ?`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt2))
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), gomock.Any())
c.EXPECT().ReadStrings(gomock.Any())
rawStmt3 := `SELECT f.key, ex2."spec.projectName" FROM "_v1_Pods_fields" f
JOIN "provisioner.cattle.io_v3_Cluster_fields" ex2 ON f."field.cattle.io/fixer" = ex2."metadata.name"
WHERE f."spec.projectName" != ex2."spec.projectName"`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt3))
results2 := []any{"field.cattle.io/fixer"}
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), results2)
rawStmt2a := `UPDATE "_v1_Namespace_fields" SET "spec.displayName" = ? WHERE key = ?`
c.EXPECT().Prepare(rawStmt2a)
txC.EXPECT().Stmt(gomock.Any()).Return(stmts)
stmts.EXPECT().Exec("moose1", "lego.cattle.io/fields1")
c.EXPECT().ReadStrings2(gomock.Any()).Return([][]string{{"lego.cattle.io/fields2", "moose2"}}, nil)
rawStmt4 := `UPDATE "_v1_Pods_fields" SET "spec.projectName" = ? WHERE key = ?`
c.EXPECT().Prepare(rawStmt4)
txC.EXPECT().Stmt(gomock.Any()).Return(stmts)
stmts.EXPECT().Exec("moose2", "lego.cattle.io/fields2")
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt3))
results2 := []any{"field.cattle.io/fixer"}
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), results2)
c.EXPECT().ReadStrings2(gomock.Any()).Return([][]string{{"lego.cattle.io/fields2", "moose2"}}, nil)
// Override check:
rawStmt2 = `SELECT f."spec.projectName" FROM "_v1_Pods_fields" f WHERE f.key = ?`
c.EXPECT().Prepare(WSIgnoringMatcher(rawStmt2))
c.EXPECT().QueryForRows(gomock.Any(), gomock.Any(), gomock.Any())
c.EXPECT().ReadStrings(gomock.Any())
rawStmt4 := `UPDATE "_v1_Pods_fields" SET "spec.projectName" = ? WHERE key = ?`
c.EXPECT().Prepare(rawStmt4)
txC.EXPECT().Stmt(gomock.Any()).Return(stmts)
stmts.EXPECT().Exec("moose2", "lego.cattle.io/fields2")
// And again for the other object
}
err := store.Add(testObject)
assert.Nil(t, err)
@@ -789,7 +889,7 @@ func SetupMockDB(t *testing.T) (*MockClient, *MockTXClient) {
func SetupStore(t *testing.T, client *MockClient, shouldEncrypt bool) *Store {
name := "testStoreObject"
gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: name}
store, err := NewStore(context.Background(), testStoreObject{}, testStoreKeyFunc, client, shouldEncrypt, gvk, name, nil)
store, err := NewStore(context.Background(), testStoreObject{}, testStoreKeyFunc, client, shouldEncrypt, gvk, name, nil, nil)
if err != nil {
t.Error(err)
}
@@ -800,7 +900,7 @@ func gvkKey(group, version, kind string) string {
return group + "_" + version + "_" + kind
}
func SetupStoreWithExternalDependencies(t *testing.T, client *MockClient) *Store {
func SetupStoreWithExternalDependencies(t *testing.T, client *MockClient, updateExternal bool, updateSelf bool) *Store {
name := "testStoreObject"
gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: name}
namespaceProjectLabelDep := sqltypes.ExternalLabelDependency{
@@ -822,7 +922,15 @@ func SetupStoreWithExternalDependencies(t *testing.T, client *MockClient) *Store
ExternalDependencies: []sqltypes.ExternalDependency{namespaceNonLabelDep},
ExternalLabelDependencies: []sqltypes.ExternalLabelDependency{namespaceProjectLabelDep},
}
store, err := NewStore(context.Background(), testStoreObject{}, testStoreKeyFunc, client, false, gvk, name, &updateInfo)
externalUpdateInfo := &updateInfo
selfUpdateInfo := &updateInfo
if !updateExternal {
externalUpdateInfo = nil
}
if !updateSelf {
selfUpdateInfo = nil
}
store, err := NewStore(context.Background(), testStoreObject{}, testStoreKeyFunc, client, false, gvk, name, externalUpdateInfo, selfUpdateInfo)
if err != nil {
t.Error(err)
}