diff --git a/internal/host/static/options.go b/internal/host/static/options.go index 938a2d08a..f8822118e 100644 --- a/internal/host/static/options.go +++ b/internal/host/static/options.go @@ -16,6 +16,7 @@ type Option func(*options) type options struct { withName string withDescription string + withLimit int } func getDefaultOptions() options { @@ -38,3 +39,12 @@ func WithName(name string) Option { o.withName = name } } + +// WithLimit provides an option to provide a limit. Intentionally allowing +// negative integers. If WithLimit < 0, then unlimited results are +// returned. If WithLimit == 0, then default limits are used for results. +func WithLimit(l int) Option { + return func(o *options) { + o.withLimit = l + } +} diff --git a/internal/host/static/options_test.go b/internal/host/static/options_test.go index 6cf73ac69..1c3d2e0f6 100644 --- a/internal/host/static/options_test.go +++ b/internal/host/static/options_test.go @@ -8,17 +8,22 @@ import ( func Test_GetOpts(t *testing.T) { t.Parallel() - assert := assert.New(t) t.Run("WithName", func(t *testing.T) { opts := getOpts(WithName("test")) testOpts := getDefaultOptions() testOpts.withName = "test" - assert.Equal(opts, testOpts) + assert.Equal(t, opts, testOpts) }) t.Run("WithDescription", func(t *testing.T) { opts := getOpts(WithDescription("test desc")) testOpts := getDefaultOptions() testOpts.withDescription = "test desc" - assert.Equal(opts, testOpts) + assert.Equal(t, opts, testOpts) + }) + t.Run("WithLimit", func(t *testing.T) { + opts := getOpts(WithLimit(5)) + testOpts := getDefaultOptions() + testOpts.withLimit = 5 + assert.Equal(t, opts, testOpts) }) } diff --git a/internal/host/static/repository.go b/internal/host/static/repository.go index 0c54ab87b..47695c16f 100644 --- a/internal/host/static/repository.go +++ b/internal/host/static/repository.go @@ -14,12 +14,16 @@ type Repository struct { reader db.Reader writer db.Writer wrapper wrapping.Wrapper + // defaultLimit provides a default for limiting the number of results + // returned from the repo + defaultLimit int } // NewRepository creates a new Repository. The returned repository should // only be used for one transaction and it is not safe for concurrent go -// routines to access it. -func NewRepository(r db.Reader, w db.Writer, wrapper wrapping.Wrapper) (*Repository, error) { +// routines to access it. WithLimit option is used as a repo wide default +// limit applied to all ListX methods. +func NewRepository(r db.Reader, w db.Writer, wrapper wrapping.Wrapper, opt ...Option) (*Repository, error) { switch { case r == nil: return nil, fmt.Errorf("db.Reader: %w", db.ErrNilParameter) @@ -29,9 +33,16 @@ func NewRepository(r db.Reader, w db.Writer, wrapper wrapping.Wrapper) (*Reposit return nil, fmt.Errorf("wrapping.Wrapper: %w", db.ErrNilParameter) } + opts := getOpts(opt...) + if opts.withLimit == 0 { + // zero signals the boundary defaults should be used. + opts.withLimit = db.DefaultLimit + } + return &Repository{ - reader: r, - writer: w, - wrapper: wrapper, + reader: r, + writer: w, + wrapper: wrapper, + defaultLimit: opts.withLimit, }, nil } diff --git a/internal/host/static/repository_host.go b/internal/host/static/repository_host.go index e002d1f1e..79584dedb 100644 --- a/internal/host/static/repository_host.go +++ b/internal/host/static/repository_host.go @@ -2,6 +2,7 @@ package static import ( "context" + "errors" "fmt" "strings" @@ -139,3 +140,70 @@ func (r *Repository) UpdateHost(ctx context.Context, h *Host, version uint32, fi return returnedHost, rowsUpdated, nil } + +// LookupHost will look up a host in the repository. If the host is not +// found, it will return nil, nil. All options are ignored. +func (r *Repository) LookupHost(ctx context.Context, publicId string, opt ...Option) (*Host, error) { + if publicId == "" { + return nil, fmt.Errorf("lookup: static host: missing public id %w", db.ErrInvalidParameter) + } + h := allocHost() + h.PublicId = publicId + if err := r.reader.LookupByPublicId(ctx, h); err != nil { + if errors.Is(err, db.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("lookup: static host: failed %w for %s", err, publicId) + } + return h, nil +} + +// ListHosts returns a slice of Hosts for the catalogId. +// WithLimit is the only option supported. +func (r *Repository) ListHosts(ctx context.Context, catalogId string, opt ...Option) ([]*Host, error) { + if catalogId == "" { + return nil, fmt.Errorf("list: static host: missing catalog id: %w", db.ErrInvalidParameter) + } + opts := getOpts(opt...) + limit := r.defaultLimit + if opts.withLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + } + var hosts []*Host + err := r.reader.SearchWhere(ctx, &hosts, "catalog_id = ?", []interface{}{catalogId}, db.WithLimit(limit)) + if err != nil { + return nil, fmt.Errorf("list: static host: %w", err) + } + return hosts, nil +} + +// DeleteHost deletes the host for the provided id from the repository +// returning a count of the number of records deleted. All options are +// ignored. +func (r *Repository) DeleteHost(ctx context.Context, publicId string, opt ...Option) (int, error) { + if publicId == "" { + return db.NoRowsAffected, fmt.Errorf("delete: static host: missing public id: %w", db.ErrInvalidParameter) + } + h := allocHost() + h.PublicId = publicId + + var rowsDeleted int + _, err := r.writer.DoTx( + ctx, db.StdRetryCnt, db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) (err error) { + dh := h.clone() + rowsDeleted, err = w.Delete(ctx, dh, db.WithOplog(r.wrapper, h.oplog(oplog.OpType_OP_TYPE_DELETE))) + if err == nil && rowsDeleted > 1 { + return db.ErrMultipleRecords + } + return err + }, + ) + + if err != nil { + return db.NoRowsAffected, fmt.Errorf("delete: static host: %s: %w", publicId, err) + } + + return rowsDeleted, nil +} diff --git a/internal/host/static/repository_host_test.go b/internal/host/static/repository_host_test.go index f61765a79..08aabd277 100644 --- a/internal/host/static/repository_host_test.go +++ b/internal/host/static/repository_host_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/boundary/internal/db" dbassert "github.com/hashicorp/boundary/internal/db/assert" "github.com/hashicorp/boundary/internal/host/static/store" @@ -13,6 +15,7 @@ import ( "github.com/hashicorp/boundary/internal/oplog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" ) func TestRepository_CreateHost(t *testing.T) { @@ -723,3 +726,231 @@ func TestRepository_UpdateHost(t *testing.T) { assert.NoError(db.TestVerifyOplog(t, rw, hA.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_UPDATE), db.WithCreateNotBefore(10*time.Second))) }) } + +func TestRepository_LookupHost(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + _, prj := iam.TestScopes(t, conn) + catalog := testCatalogs(t, conn, prj.PublicId, 1)[0] + host := testHosts(t, conn, catalog.PublicId, 1)[0] + + hostId, err := newHostId() + require.NoError(t, err) + var tests = []struct { + name string + in string + want *Host + wantIsErr error + }{ + { + name: "with-no-public-id", + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "with-non-existing-host-id", + in: hostId, + }, + { + name: "with-existing-host-id", + in: host.PublicId, + want: host, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.LookupHost(context.Background(), tt.in) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + require.NoError(err) + assert.EqualValues(tt.want, got) + }) + } +} + +func TestRepository_ListHosts(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + _, prj := iam.TestScopes(t, conn) + catalogs := testCatalogs(t, conn, prj.PublicId, 2) + catalogA, catalogB := catalogs[0], catalogs[1] + + hosts := testHosts(t, conn, catalogA.PublicId, 3) + + var tests = []struct { + name string + in string + opts []Option + want []*Host + wantIsErr error + }{ + { + name: "with-no-catalog-id", + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "Catalog-with-no-hosts", + in: catalogB.PublicId, + want: []*Host{}, + }, + { + name: "Catalog-with-hosts", + in: catalogA.PublicId, + want: hosts, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.ListHosts(context.Background(), tt.in, tt.opts...) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + require.NoError(err) + opts := []cmp.Option{ + cmpopts.SortSlices(func(x, y *Host) bool { return x.PublicId < y.PublicId }), + protocmp.Transform(), + } + assert.Empty(cmp.Diff(tt.want, got, opts...)) + }) + } +} + +func TestRepository_ListHosts_Limits(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + _, prj := iam.TestScopes(t, conn) + catalog := testCatalogs(t, conn, prj.PublicId, 1)[0] + count := 10 + hosts := testHosts(t, conn, catalog.PublicId, count) + + var tests = []struct { + name string + repoOpts []Option + listOpts []Option + wantLen int + }{ + { + name: "With no limits", + wantLen: count, + }, + { + name: "With repo limit", + repoOpts: []Option{WithLimit(3)}, + wantLen: 3, + }, + { + name: "With negative repo limit", + repoOpts: []Option{WithLimit(-1)}, + wantLen: count, + }, + { + name: "With List limit", + listOpts: []Option{WithLimit(3)}, + wantLen: 3, + }, + { + name: "With negative List limit", + listOpts: []Option{WithLimit(-1)}, + wantLen: count, + }, + { + name: "With repo smaller than list limit", + repoOpts: []Option{WithLimit(2)}, + listOpts: []Option{WithLimit(6)}, + wantLen: 6, + }, + { + name: "With repo larger than list limit", + repoOpts: []Option{WithLimit(6)}, + listOpts: []Option{WithLimit(2)}, + wantLen: 2, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper, tt.repoOpts...) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.ListHosts(context.Background(), hosts[0].CatalogId, tt.listOpts...) + require.NoError(err) + assert.Len(got, tt.wantLen) + }) + } +} + +func TestRepository_DeleteHost(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + _, prj := iam.TestScopes(t, conn) + catalog := testCatalogs(t, conn, prj.PublicId, 1)[0] + host := testHosts(t, conn, catalog.PublicId, 1)[0] + + newHostId, err := newHostId() + require.NoError(t, err) + var tests = []struct { + name string + in string + want int + wantIsErr error + }{ + { + name: "With no public id", + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "With non existing host id", + in: newHostId, + want: 0, + }, + { + name: "With existing host id", + in: host.PublicId, + want: 1, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(err) + require.NotNil(repo) + got, err := repo.DeleteHost(context.Background(), tt.in) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Zero(got) + return + } + require.NoError(err) + assert.EqualValues(tt.want, got) + }) + } +} diff --git a/internal/host/static/repository_test.go b/internal/host/static/repository_test.go index fa31da458..987542bc8 100644 --- a/internal/host/static/repository_test.go +++ b/internal/host/static/repository_test.go @@ -6,6 +6,7 @@ import ( wrapping "github.com/hashicorp/go-kms-wrapping" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/hashicorp/boundary/internal/db" ) @@ -19,6 +20,7 @@ func TestRepository_New(t *testing.T) { r db.Reader w db.Writer wrapper wrapping.Wrapper + opts []Option } var tests = []struct { @@ -35,9 +37,25 @@ func TestRepository_New(t *testing.T) { wrapper: wrapper, }, want: &Repository{ - reader: rw, - writer: rw, + reader: rw, + writer: rw, + wrapper: wrapper, + defaultLimit: db.DefaultLimit, + }, + }, + { + name: "valid-with-limit", + args: args{ + r: rw, + w: rw, wrapper: wrapper, + opts: []Option{WithLimit(5)}, + }, + want: &Repository{ + reader: rw, + writer: rw, + wrapper: wrapper, + defaultLimit: 5, }, }, { @@ -84,15 +102,15 @@ func TestRepository_New(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - assert := assert.New(t) - got, err := NewRepository(tt.args.r, tt.args.w, tt.args.wrapper) + assert, require := assert.New(t), require.New(t) + got, err := NewRepository(tt.args.r, tt.args.w, tt.args.wrapper, tt.args.opts...) if tt.wantIsErr != nil { assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) assert.Nil(got) return } assert.NoError(err) - assert.NotNil(got) + require.NotNil(got) assert.Equal(tt.want, got) }) }