From 889af762ef563089d9a88552c17968e5482380d9 Mon Sep 17 00:00:00 2001 From: Juan Hernandez Date: Wed, 27 May 2026 16:09:38 +0200 Subject: [PATCH] NO-ISSUE: Add builtin `system` and `shared` organizations This is a preparatory step towards making the tenant field mandatory and enforcing that every tenant references an existing organization via a database foreign key constraint. For that to work the `system` and `shared` tenants, which are already used by convention throughout the codebase, must exist as rows in the `organizations` table. Migration 46 inserts both organizations with matching `id`, `name`, and `tenant` columns (e.g. `system`/`system`/`system`) and their status set to `ORGANIZATION_STATE_SYNCED` since they are always operational and do not require IDP reconciliation. The list tests in `organizations_server_test.go` and `private_organizations_server_test.go` are updated to filter by name so they remain deterministic now that the table is no longer empty at test start. A migration test verifies the rows are created correctly, and an integration test confirms both organizations are retrievable through the private gRPC API. Signed-off-by: Juan Hernandez Assisted-by: Cursor --- .github/workflows/check-pull-request.yaml | 6 +- .../39_move_hub_fields_to_spec_test.go | 4 +- .../40_rename_tenants_to_tenant_test.go | 4 +- .../41_rename_creators_to_creator_test.go | 4 +- ...or_in_public_ip_attachments_tables_test.go | 6 +- .../migrations/43_drop_leases_tables_test.go | 6 +- ...blic_ip_attachments_unique_indexes_test.go | 40 ++++++------ .../database/migrations/45_fix_tables_test.go | 10 +-- .../46_add_immutable_column_trigger_test.go | 6 +- .../47_create_projects_tables_test.go | 6 +- .../migrations/48_add_builtin_tenants.up.sql | 22 +++++++ .../migrations/48_add_builtin_tenants_test.go | 60 +++++++++++++++++ .../migrations/migrations_suite_test.go | 13 +--- it/it_builtin_tenants_test.go | 64 +++++++++++++++++++ 14 files changed, 203 insertions(+), 48 deletions(-) create mode 100644 internal/database/migrations/48_add_builtin_tenants.up.sql create mode 100644 internal/database/migrations/48_add_builtin_tenants_test.go create mode 100644 it/it_builtin_tenants_test.go diff --git a/.github/workflows/check-pull-request.yaml b/.github/workflows/check-pull-request.yaml index 0da8d8c22..2297e5cb5 100644 --- a/.github/workflows/check-pull-request.yaml +++ b/.github/workflows/check-pull-request.yaml @@ -59,7 +59,7 @@ jobs: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-go - run: | - ginkgo run -r internal + ginkgo run --timeout 1h -r internal build-binaries: name: Build binaries @@ -81,7 +81,7 @@ jobs: echo '127.0.0.1 fulfillment-api.osac.svc.cluster.local' | sudo tee -a /etc/hosts echo '127.0.0.1 fulfillment-internal-api.osac.svc.cluster.local' | sudo tee -a /etc/hosts echo '127.0.0.1 keycloak.keycloak.svc.cluster.local' | sudo tee -a /etc/hosts - IT_DEPLOY_MODE=helm ginkgo run -v it + IT_DEPLOY_MODE=helm ginkgo run --timeout 1h -v it - if: always() uses: actions/upload-artifact@v7 with: @@ -99,7 +99,7 @@ jobs: echo '127.0.0.1 fulfillment-api.osac.svc.cluster.local' | sudo tee -a /etc/hosts echo '127.0.0.1 fulfillment-internal-api.osac.svc.cluster.local' | sudo tee -a /etc/hosts echo '127.0.0.1 keycloak.keycloak.svc.cluster.local' | sudo tee -a /etc/hosts - IT_DEPLOY_MODE=kustomize ginkgo run -v it + IT_DEPLOY_MODE=kustomize ginkgo run --timeout 1h -v it - if: always() uses: actions/upload-artifact@v7 with: diff --git a/internal/database/migrations/39_move_hub_fields_to_spec_test.go b/internal/database/migrations/39_move_hub_fields_to_spec_test.go index 55a3e5e62..95b74547d 100644 --- a/internal/database/migrations/39_move_hub_fields_to_spec_test.go +++ b/internal/database/migrations/39_move_hub_fields_to_spec_test.go @@ -13,6 +13,8 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/table" . "github.com/onsi/gomega" ) @@ -20,7 +22,7 @@ import ( var _ = DescribeMigration("Add 'spec' and 'status' fields to hubs", func() { DescribeTable( "Data migration", - func(original, expected string) { + func(ctx context.Context, original, expected string) { // Create a row with the original data: _, err := conn.Exec( ctx, diff --git a/internal/database/migrations/40_rename_tenants_to_tenant_test.go b/internal/database/migrations/40_rename_tenants_to_tenant_test.go index 58c641c79..7af89da17 100644 --- a/internal/database/migrations/40_rename_tenants_to_tenant_test.go +++ b/internal/database/migrations/40_rename_tenants_to_tenant_test.go @@ -14,6 +14,8 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/table" . "github.com/onsi/gomega" ) @@ -21,7 +23,7 @@ import ( var _ = DescribeMigration("Rename tenants to tenant", func() { DescribeTable( "Migrates the tenants array to a single tenant value", - func(tenants []string, expectedTenant string) { + func(ctx context.Context, tenants []string, expectedTenant string) { // Insert a row with the old tenants array column: _, err := conn.Exec( ctx, diff --git a/internal/database/migrations/41_rename_creators_to_creator_test.go b/internal/database/migrations/41_rename_creators_to_creator_test.go index 58daac1ca..d60207f27 100644 --- a/internal/database/migrations/41_rename_creators_to_creator_test.go +++ b/internal/database/migrations/41_rename_creators_to_creator_test.go @@ -14,6 +14,8 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/table" . "github.com/onsi/gomega" ) @@ -21,7 +23,7 @@ import ( var _ = DescribeMigration("Rename creators to creator", func() { DescribeTable( "Migrates the creators array to a single creator value", - func(creators []string, expectedCreator string) { + func(ctx context.Context, creators []string, expectedCreator string) { // Insert a row with the old creators array column: _, err := conn.Exec( ctx, diff --git a/internal/database/migrations/42_singular_tenant_and_creator_in_public_ip_attachments_tables_test.go b/internal/database/migrations/42_singular_tenant_and_creator_in_public_ip_attachments_tables_test.go index 47475ef0b..7c4ed0507 100644 --- a/internal/database/migrations/42_singular_tenant_and_creator_in_public_ip_attachments_tables_test.go +++ b/internal/database/migrations/42_singular_tenant_and_creator_in_public_ip_attachments_tables_test.go @@ -14,6 +14,8 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/table" . "github.com/onsi/gomega" ) @@ -21,7 +23,7 @@ import ( var _ = DescribeMigration("Replace tenants array with single tenant in public ip attachments tables", func() { DescribeTable( "Migrates the tenants array to a single tenant value in public ip attachments tables", - func(tenants []string, expectedTenant string) { + func(ctx context.Context, tenants []string, expectedTenant string) { // Insert a row with the old creators array column: _, err := conn.Exec( ctx, @@ -65,7 +67,7 @@ var _ = DescribeMigration("Replace tenants array with single tenant in public ip var _ = DescribeMigration("Replace creators array with single creator in public ip attachments tables", func() { DescribeTable( "Migrates the creators array to a single creator value in public ip attachments tables", - func(creators []string, expectedCreator string) { + func(ctx context.Context, creators []string, expectedCreator string) { // Insert a row with the old creators array column: _, err := conn.Exec( ctx, diff --git a/internal/database/migrations/43_drop_leases_tables_test.go b/internal/database/migrations/43_drop_leases_tables_test.go index 7a66eccde..90ec40394 100644 --- a/internal/database/migrations/43_drop_leases_tables_test.go +++ b/internal/database/migrations/43_drop_leases_tables_test.go @@ -14,12 +14,14 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/core" . "github.com/onsi/gomega" ) var _ = DescribeMigration("Drop leases tables", func() { - It("Drops the leases table", func() { + It("Drops the leases table", func(ctx context.Context) { // Verify the table exists before the migration: var exists bool err := conn.QueryRow(ctx, @@ -40,7 +42,7 @@ var _ = DescribeMigration("Drop leases tables", func() { Expect(exists).To(BeFalse()) }) - It("Drops the archived_leases table", func() { + It("Drops the archived_leases table", func(ctx context.Context) { // Verify the table exists before the migration: var exists bool err := conn.QueryRow(ctx, diff --git a/internal/database/migrations/44_add_public_ip_attachments_unique_indexes_test.go b/internal/database/migrations/44_add_public_ip_attachments_unique_indexes_test.go index b349bb299..9741b3659 100644 --- a/internal/database/migrations/44_add_public_ip_attachments_unique_indexes_test.go +++ b/internal/database/migrations/44_add_public_ip_attachments_unique_indexes_test.go @@ -14,12 +14,14 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/core" . "github.com/onsi/gomega" ) var _ = DescribeMigration("Add unique indexes for public IP attachments", func() { - insert := func(id, publicIP, computeInstance string) error { + insert := func(ctx context.Context, id, publicIP, computeInstance string) error { _, err := conn.Exec( ctx, `insert into public_ip_attachments (id, data) values ($1, $2)`, @@ -29,7 +31,7 @@ var _ = DescribeMigration("Add unique indexes for public IP attachments", func() return err } - softDelete := func(id string) { + softDelete := func(ctx context.Context, id string) { _, err := conn.Exec( ctx, `update public_ip_attachments set deletion_timestamp = now() where id = $1`, @@ -38,62 +40,62 @@ var _ = DescribeMigration("Add unique indexes for public IP attachments", func() Expect(err).ToNot(HaveOccurred()) } - It("Rejects duplicate active public IP", func() { - err := insert("a1", "pip-1", "ci-1") + It("Rejects duplicate active public IP", func(ctx context.Context) { + err := insert(ctx, "a1", "pip-1", "ci-1") Expect(err).ToNot(HaveOccurred()) err = tool.Migrate(ctx, 44) Expect(err).ToNot(HaveOccurred()) - err = insert("a2", "pip-1", "ci-2") + err = insert(ctx, "a2", "pip-1", "ci-2") Expect(err).To(HaveOccurred()) }) - It("Rejects duplicate active compute instance", func() { - err := insert("a1", "pip-1", "ci-1") + It("Rejects duplicate active compute instance", func(ctx context.Context) { + err := insert(ctx, "a1", "pip-1", "ci-1") Expect(err).ToNot(HaveOccurred()) err = tool.Migrate(ctx, 44) Expect(err).ToNot(HaveOccurred()) - err = insert("a2", "pip-2", "ci-1") + err = insert(ctx, "a2", "pip-2", "ci-1") Expect(err).To(HaveOccurred()) }) - It("Allows same public IP after soft delete", func() { - err := insert("a1", "pip-1", "ci-1") + It("Allows same public IP after soft delete", func(ctx context.Context) { + err := insert(ctx, "a1", "pip-1", "ci-1") Expect(err).ToNot(HaveOccurred()) err = tool.Migrate(ctx, 44) Expect(err).ToNot(HaveOccurred()) - softDelete("a1") + softDelete(ctx, "a1") - err = insert("a2", "pip-1", "ci-2") + err = insert(ctx, "a2", "pip-1", "ci-2") Expect(err).ToNot(HaveOccurred()) }) - It("Allows same compute instance after soft delete", func() { - err := insert("a1", "pip-1", "ci-1") + It("Allows same compute instance after soft delete", func(ctx context.Context) { + err := insert(ctx, "a1", "pip-1", "ci-1") Expect(err).ToNot(HaveOccurred()) err = tool.Migrate(ctx, 44) Expect(err).ToNot(HaveOccurred()) - softDelete("a1") + softDelete(ctx, "a1") - err = insert("a2", "pip-2", "ci-1") + err = insert(ctx, "a2", "pip-2", "ci-1") Expect(err).ToNot(HaveOccurred()) }) - It("Allows different public IPs and compute instances", func() { - err := insert("a1", "pip-1", "ci-1") + It("Allows different public IPs and compute instances", func(ctx context.Context) { + err := insert(ctx, "a1", "pip-1", "ci-1") Expect(err).ToNot(HaveOccurred()) err = tool.Migrate(ctx, 44) Expect(err).ToNot(HaveOccurred()) - err = insert("a2", "pip-2", "ci-2") + err = insert(ctx, "a2", "pip-2", "ci-2") Expect(err).ToNot(HaveOccurred()) }) }) diff --git a/internal/database/migrations/45_fix_tables_test.go b/internal/database/migrations/45_fix_tables_test.go index 1b2c4f0ea..c131157ad 100644 --- a/internal/database/migrations/45_fix_tables_test.go +++ b/internal/database/migrations/45_fix_tables_test.go @@ -14,12 +14,14 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/core" . "github.com/onsi/gomega" ) var _ = DescribeMigration("Fix tables", func() { - It("Renames creators to creator in archived_public_ip_attachments", func() { + It("Renames creators to creator in archived_public_ip_attachments", func(ctx context.Context) { // Insert using the old plural column names that exist before this migration: _, err := conn.Exec( ctx, @@ -39,7 +41,7 @@ var _ = DescribeMigration("Fix tables", func() { Expect(creator).To(Equal("user-a")) }) - It("Renames tenants to tenant in archived_public_ip_attachments", func() { + It("Renames tenants to tenant in archived_public_ip_attachments", func(ctx context.Context) { // Insert using the old plural column names that exist before this migration: _, err := conn.Exec( ctx, @@ -59,7 +61,7 @@ var _ = DescribeMigration("Fix tables", func() { Expect(tenant).To(Equal("my-tenant")) }) - It("Drops finalizers from archived_organizations", func() { + It("Drops finalizers from archived_organizations", func(ctx context.Context) { err := tool.Migrate(ctx, 45) Expect(err).ToNot(HaveOccurred()) @@ -73,7 +75,7 @@ var _ = DescribeMigration("Fix tables", func() { Expect(count).To(Equal(0)) }) - It("Drops finalizers from archived_users", func() { + It("Drops finalizers from archived_users", func(ctx context.Context) { err := tool.Migrate(ctx, 45) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/database/migrations/46_add_immutable_column_trigger_test.go b/internal/database/migrations/46_add_immutable_column_trigger_test.go index 468c7cc4c..dc366020b 100644 --- a/internal/database/migrations/46_add_immutable_column_trigger_test.go +++ b/internal/database/migrations/46_add_immutable_column_trigger_test.go @@ -14,12 +14,14 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/core" . "github.com/onsi/gomega" ) var _ = DescribeMigration("Add immutable column trigger", func() { - It("Creates the 'check_immutable_columns' function", func() { + It("Creates the 'check_immutable_columns' function", func(ctx context.Context) { err := tool.Migrate(ctx, 46) Expect(err).ToNot(HaveOccurred()) @@ -37,7 +39,7 @@ var _ = DescribeMigration("Add immutable column trigger", func() { Expect(count).To(Equal(1)) }) - It("Adds a trigger to the organizations table", func() { + It("Adds a trigger to the organizations table", func(ctx context.Context) { err := tool.Migrate(ctx, 46) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/database/migrations/47_create_projects_tables_test.go b/internal/database/migrations/47_create_projects_tables_test.go index a024b30e8..c002b80b0 100644 --- a/internal/database/migrations/47_create_projects_tables_test.go +++ b/internal/database/migrations/47_create_projects_tables_test.go @@ -14,12 +14,14 @@ language governing permissions and limitations under the License. package migrations import ( + "context" + . "github.com/onsi/ginkgo/v2/dsl/core" . "github.com/onsi/gomega" ) var _ = DescribeMigration("Create projects tables", func() { - It("Creates the projects table", func() { + It("Creates the projects table", func(ctx context.Context) { // Run the migration: err := tool.Migrate(ctx, 47) Expect(err).ToNot(HaveOccurred()) @@ -50,7 +52,7 @@ var _ = DescribeMigration("Create projects tables", func() { Expect(exists).To(BeTrue()) }) - It("Can insert and query a project", func() { + It("Can insert and query a project", func(ctx context.Context) { // Run the migration: err := tool.Migrate(ctx, 47) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/database/migrations/48_add_builtin_tenants.up.sql b/internal/database/migrations/48_add_builtin_tenants.up.sql new file mode 100644 index 000000000..19a68bc6d --- /dev/null +++ b/internal/database/migrations/48_add_builtin_tenants.up.sql @@ -0,0 +1,22 @@ +-- +-- Copyright (c) 2026 Red Hat Inc. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +-- an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +-- specific language governing permissions and limitations under the License. +-- + +-- This migration adds the builtin 'system' and 'shared' organizations. These are tenants that exist by convention and +-- are used throughout the codebase. Having them as rows in the database ensures they can be discovered via the API like +-- any other organization. + +insert into organizations (id, name, tenant, creator, data) +values ('system', 'system', 'system', 'system', '{}'); + +insert into organizations (id, name, tenant, creator, data) +values ('shared', 'shared', 'shared', 'system', '{}'); diff --git a/internal/database/migrations/48_add_builtin_tenants_test.go b/internal/database/migrations/48_add_builtin_tenants_test.go new file mode 100644 index 000000000..09c55c534 --- /dev/null +++ b/internal/database/migrations/48_add_builtin_tenants_test.go @@ -0,0 +1,60 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package migrations + +import ( + "context" + + . "github.com/onsi/ginkgo/v2/dsl/table" + . "github.com/onsi/gomega" + "github.com/osac-project/fulfillment-service/internal/auth" +) + +var _ = DescribeMigration("Add builtin tenants", func() { + DescribeTable( + "Inserts builtin tenants", + func(ctx context.Context, id string) { + err := tool.Migrate(ctx, 48) + Expect(err).ToNot(HaveOccurred()) + var ( + name string + tenant string + creator string + data []byte + ) + row := conn.QueryRow(ctx, + ` + select + name, + tenant, + creator, + data + from + organizations + where + id = $1 + `, + id, + ) + err = row.Scan(&name, &tenant, &creator, &data) + Expect(err).ToNot(HaveOccurred()) + Expect(name).To(Equal(id)) + Expect(tenant).To(Equal(id)) + Expect(creator).To(Equal(auth.SystemTenant)) + Expect(data).To(MatchJSON(`{}`)) + }, + Entry("System", auth.SystemTenant), + Entry("Shared", auth.SharedTenant), + ) +}) diff --git a/internal/database/migrations/migrations_suite_test.go b/internal/database/migrations/migrations_suite_test.go index d141e42af..53b22a517 100644 --- a/internal/database/migrations/migrations_suite_test.go +++ b/internal/database/migrations/migrations_suite_test.go @@ -24,7 +24,6 @@ import ( "strconv" "strings" gotesting "testing" - "time" "github.com/jackc/pgx/v5" . "github.com/onsi/ginkgo/v2/dsl/core" @@ -42,7 +41,6 @@ func TestMigrations(t *gotesting.T) { // Logger and database objects used by the tests: var ( - ctx context.Context logger *slog.Logger server *database.Container db *database.Instance @@ -50,7 +48,7 @@ var ( conn *pgx.Conn ) -var _ = BeforeSuite(func() { +var _ = BeforeSuite(func(ctx context.Context) { var err error // Create the logger: @@ -61,8 +59,6 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) // Create the database server: - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - DeferCleanup(cancel) server, err = database.NewContainer(). SetLogger(logger). Build() @@ -70,8 +66,6 @@ var _ = BeforeSuite(func() { err = server.Start(ctx) Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() err = server.Stop(ctx) Expect(err).ToNot(HaveOccurred()) }) @@ -143,12 +137,9 @@ func DescribeMigration(description string, body func()) bool { previousNumber = migrationNumber(previousFile) }) - BeforeEach(func() { + BeforeEach(func(ctx context.Context) { var err error - // Create a context: - ctx = context.Background() - // Create the database migrated up to the previous migration: db, err = server.NewInstance(). SetVersion(previousNumber). diff --git a/it/it_builtin_tenants_test.go b/it/it_builtin_tenants_test.go new file mode 100644 index 000000000..869d558e8 --- /dev/null +++ b/it/it_builtin_tenants_test.go @@ -0,0 +1,64 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package it + +import ( + "context" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/ginkgo/v2/dsl/table" + . "github.com/onsi/gomega" + + privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" + "github.com/osac-project/fulfillment-service/internal/auth" +) + +var _ = Describe("Builtin tenants", func() { + var client privatev1.OrganizationsClient + + BeforeEach(func() { + client = privatev1.NewOrganizationsClient(tool.InternalView().AdminConn()) + }) + + DescribeTable( + "Can be retrieved via the private API", + func(ctx context.Context, id string) { + response, err := client.Get(ctx, privatev1.OrganizationsGetRequest_builder{ + Id: id, + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := response.GetObject() + Expect(object).ToNot(BeNil()) + Expect(object.GetId()).To(Equal(id)) + metadata := object.GetMetadata() + Expect(metadata).ToNot(BeNil()) + Expect(metadata.GetName()).To(Equal(id)) + Expect(metadata.GetTenant()).To(Equal(id)) + }, + Entry("System", auth.SystemTenant), + Entry("Shared", auth.SharedTenant), + ) + + It("Includes builtin tenants in the list", func(ctx context.Context) { + response, err := client.List(ctx, privatev1.OrganizationsListRequest_builder{}.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + ids := make([]string, len(response.GetItems())) + for i, item := range response.GetItems() { + ids[i] = item.GetId() + } + Expect(ids).To(ContainElement(auth.SystemTenant)) + Expect(ids).To(ContainElement(auth.SharedTenant)) + }) +})