diff --git a/internal/servers/repository_worker.go b/internal/servers/repository_worker.go index f32fa5a65..18b70c94a 100644 --- a/internal/servers/repository_worker.go +++ b/internal/servers/repository_worker.go @@ -3,8 +3,10 @@ package servers import ( "context" "fmt" + "strings" "github.com/hashicorp/boundary/internal/db" + dbcommon "github.com/hashicorp/boundary/internal/db/common" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/servers/store" "github.com/hashicorp/boundary/internal/types/scope" @@ -284,6 +286,90 @@ func setWorkerTags(ctx context.Context, w db.Writer, id string, ts TagSource, ta return nil } +// UpdateWorker will update a worker in the repository and return the resulting +// worker. fieldMaskPaths provides field_mask.proto paths for fields that should +// be updated. Fields will be set to NULL if the field is a zero value and +// included in fieldMask. Name, Description, and Address are the only updatable +// fields, if no updatable fields are included in the fieldMaskPaths, then an +// error is returned. If any paths besides those listed above are included in +// the path then an error is returned. +func (r *Repository) UpdateWorker(ctx context.Context, worker *Worker, version uint32, fieldMaskPaths []string, opt ...Option) (*Worker, int, error) { + const ( + nameField = "name" + descField = "description" + addressField = "address" + ) + const op = "servers.(Repository).UpdateWorker" + switch { + case worker == nil: + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "worker is nil") + case worker.PublicId == "": + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "missing public id") + case version == 0: + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "version is zero") + } + + for _, f := range fieldMaskPaths { + switch { + case strings.EqualFold(nameField, f): + case strings.EqualFold(descField, f): + case strings.EqualFold(addressField, f): + default: + return nil, db.NoRowsAffected, errors.New(ctx, errors.InvalidFieldMask, op, fmt.Sprintf("invalid field mask: %s", f)) + } + } + + var dbMask, nullFields []string + dbMask, nullFields = dbcommon.BuildUpdatePaths( + map[string]interface{}{ + nameField: worker.Name, + descField: worker.Description, + addressField: worker.Address, + }, + fieldMaskPaths, + nil, + ) + if len(dbMask) == 0 && len(nullFields) == 0 { + return nil, db.NoRowsAffected, errors.New(ctx, errors.EmptyFieldMask, op, "no fields to update") + } + + var rowsUpdated int + var ret *Worker + var err error + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(reader db.Reader, w db.Writer) error { + worker := worker.clone() + rowsUpdated, err = w.Update(ctx, worker, dbMask, nullFields, db.WithVersion(&version)) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if rowsUpdated > 1 { + // return err, which will result in a rollback of the update + return errors.New(ctx, errors.MultipleRecords, op, "more than 1 resource would have been updated") + } + + wAgg := &workerAggregate{PublicId: worker.GetPublicId()} + if err := reader.LookupById(ctx, wAgg); err != nil { + return errors.Wrap(ctx, err, op) + } + if ret, err = wAgg.toWorker(ctx); err != nil { + return err + } + return nil + }, + ) + if err != nil { + if errors.IsUniqueError(err) { + return nil, db.NoRowsAffected, errors.New(ctx, errors.NotUnique, op, fmt.Sprintf("worker with name %q already exists", worker.Name)) + } + return nil, db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("for %s", worker.GetPublicId()))) + } + return ret, rowsUpdated, nil +} + // CreateWorker will create a worker in the repository and return the written // worker. Creating a worker is not intentionally oplogged. A worker's // ReportedStatus and Tags are intentionally ignored when creating a worker (not diff --git a/internal/servers/repository_worker_test.go b/internal/servers/repository_worker_test.go index 04e8f4f3e..351f28b80 100644 --- a/internal/servers/repository_worker_test.go +++ b/internal/servers/repository_worker_test.go @@ -614,3 +614,268 @@ func TestRepository_CreateWorker(t *testing.T) { }) } } + +func TestRepository_UpdateWorker(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, wrapper) + + repo, err := servers.NewRepository(rw, rw, kmsCache) + require.NoError(t, err) + + setCases := []struct { + name string + modifyWorker func(*testing.T, *servers.Worker) + path []string + assertGot func(*testing.T, *servers.Worker) + wantErr bool + }{ + { + name: "update address", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.Address = "foo" + }, + path: []string{"address"}, + assertGot: func(t *testing.T, w *servers.Worker) { + t.Helper() + assert.Equal(t, "foo", w.GetAddress()) + assert.Equal(t, uint32(2), w.GetVersion()) + assert.Equal(t, w.GetLastStatusTime().AsTime(), w.GetCreateTime().AsTime()) + assert.Greater(t, w.GetUpdateTime().AsTime(), w.GetCreateTime().AsTime()) + }, + }, + { + name: "update name", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.Name = "foo" + }, + path: []string{"Name"}, + assertGot: func(t *testing.T, w *servers.Worker) { + t.Helper() + assert.Equal(t, "foo", w.GetName()) + assert.Equal(t, uint32(2), w.GetVersion()) + assert.Equal(t, w.GetLastStatusTime().AsTime(), w.GetCreateTime().AsTime()) + assert.Greater(t, w.GetUpdateTime().AsTime(), w.GetCreateTime().AsTime()) + }, + }, + { + name: "update description", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.Description = "foo" + }, + path: []string{"Description"}, + assertGot: func(t *testing.T, w *servers.Worker) { + t.Helper() + assert.Equal(t, "foo", w.GetDescription()) + assert.Equal(t, uint32(2), w.GetVersion()) + assert.Equal(t, w.GetLastStatusTime().AsTime(), w.GetCreateTime().AsTime()) + assert.Greater(t, w.GetUpdateTime().AsTime(), w.GetCreateTime().AsTime()) + }, + }, + { + name: "update worker reported name", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.WorkerReportedName = "foo" + }, + path: []string{"WorkerReportedName"}, + wantErr: true, + }, + { + name: "update worker reported name and name", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.WorkerReportedName = "foo" + w.Name = "another" + }, + path: []string{"WorkerReportedName", "Name"}, + wantErr: true, + }, + { + name: "Clear worker reported name", + modifyWorker: func(t *testing.T, w *servers.Worker) {}, + path: []string{"WorkerReportedName"}, + wantErr: true, + }, + } + for _, tt := range setCases { + t.Run(tt.name, func(t *testing.T) { + wkr := servers.TestWorker(t, conn, wrapper) + tt.modifyWorker(t, wkr) + got, _, err := repo.UpdateWorker(ctx, wkr, 1, tt.path) + if tt.wantErr { + assert.Error(t, err) + return + } else { + require.NoError(t, err) + } + tt.assertGot(t, got) + }) + } + + // Clear cases + clearCases := []struct { + name string + modifyWorker func(*testing.T, *servers.Worker) + path []string + assertGot func(*testing.T, *servers.Worker) + wantErr bool + }{ + { + name: "clear address", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.Address = "" + }, + path: []string{"address"}, + assertGot: func(t *testing.T, w *servers.Worker) { + t.Helper() + assert.Empty(t, w.GetAddress()) + assert.Equal(t, w.GetLastStatusTime().AsTime(), w.GetCreateTime().AsTime()) + assert.Greater(t, w.GetUpdateTime().AsTime(), w.GetCreateTime().AsTime()) + }, + }, + { + name: "clear name", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.Name = "" + }, + path: []string{"Name"}, + assertGot: func(t *testing.T, w *servers.Worker) { + t.Helper() + assert.Empty(t, w.GetName()) + assert.Equal(t, w.GetLastStatusTime().AsTime(), w.GetCreateTime().AsTime()) + assert.Greater(t, w.GetUpdateTime().AsTime(), w.GetCreateTime().AsTime()) + }, + }, + { + name: "clear description", + modifyWorker: func(t *testing.T, w *servers.Worker) { + t.Helper() + w.Description = "" + }, + path: []string{"Description"}, + assertGot: func(t *testing.T, w *servers.Worker) { + t.Helper() + assert.Empty(t, w.GetDescription()) + assert.Equal(t, w.GetLastStatusTime().AsTime(), w.GetCreateTime().AsTime()) + assert.Greater(t, w.GetUpdateTime().AsTime(), w.GetCreateTime().AsTime()) + }, + }, + } + for _, tt := range clearCases { + t.Run(tt.name, func(t *testing.T) { + wkr := servers.TestWorker(t, conn, wrapper) + wkr.Name = tt.name + wkr.Description = tt.name + wkr.Address = tt.name + wkr, _, err := repo.UpdateWorker(ctx, wkr, 1, []string{"name", "description", "address"}) + require.NoError(t, err) + + tt.modifyWorker(t, wkr) + got, _, err := repo.UpdateWorker(ctx, wkr, 2, tt.path) + if tt.wantErr { + assert.Error(t, err) + return + } else { + require.NoError(t, err) + } + tt.assertGot(t, got) + }) + } + + t.Run("version is wrong", func(t *testing.T) { + wkr := servers.TestWorker(t, conn, wrapper) + wkr.Address = "version is wrong" + result, numUpdated, err := repo.UpdateWorker(ctx, wkr, 2, []string{"address"}) + require.NoError(t, err) + assert.Zero(t, numUpdated) + assert.Equal(t, wkr.GetUpdateTime().AsTime(), result.GetUpdateTime().AsTime()) + }) + + errorCases := []struct { + name string + input *servers.Worker + path []string + version uint32 + wantErr *errors.Template + }{ + { + name: "nil worker", + path: []string{"name"}, + version: 1, + wantErr: errors.T(errors.InvalidParameter), + }, + { + name: "empty path", + input: servers.TestWorker(t, conn, wrapper), + version: 1, + wantErr: errors.T(errors.EmptyFieldMask), + }, + { + name: "0 version", + input: servers.TestWorker(t, conn, wrapper), + path: []string{"name"}, + version: 0, + wantErr: errors.T(errors.InvalidParameter), + }, + { + name: "no public id", + input: func() *servers.Worker { + w := servers.TestWorker(t, conn, wrapper) + w.PublicId = "" + return w + }(), + path: []string{"name"}, + version: 0, + wantErr: errors.T(errors.InvalidParameter), + }, + { + name: "unrecognized path", + input: servers.TestWorker(t, conn, wrapper), + path: []string{"UnrecognizedField"}, + version: 1, + wantErr: errors.T(errors.InvalidFieldMask), + }, + { + name: "not found worker", + input: func() *servers.Worker { + w := servers.TestWorker(t, conn, wrapper) + w.PublicId = "w_notfoundworker" + return w + }(), + path: []string{"name"}, + version: 1, + wantErr: errors.T(errors.RecordNotFound), + }, + { + name: "duplicate name", + input: func() *servers.Worker { + w1 := servers.TestWorker(t, conn, wrapper) + w1.Name = "somenamethatijustmadeup" + w1, _, err := repo.UpdateWorker(ctx, w1, w1.Version, []string{"name"}, nil) + require.NoError(t, err) + w2 := servers.TestWorker(t, conn, wrapper) + w2.Name = w1.Name + return w2 + }(), + path: []string{"name"}, + version: 1, + wantErr: errors.T("worker with name \"somenamethatijustmadeup\" already exists"), + }, + } + for _, tt := range errorCases { + t.Run(tt.name, func(t *testing.T) { + _, updated, err := repo.UpdateWorker(ctx, tt.input, tt.version, tt.path) + assert.Equal(t, 0, updated) + assert.Truef(t, errors.Match(tt.wantErr, err), "Didn't match error %v", err) + }) + } +}