From 5fbd5e6e5f32d226795c7db2810c6320a0ca19a8 Mon Sep 17 00:00:00 2001 From: Riccardo Binetti Date: Mon, 16 Oct 2023 18:41:53 +0200 Subject: [PATCH] Add Provisioning context Coordinate the provisioning of a Tenant with an associated realm and cluster. Add relevant tests. Signed-off-by: Riccardo Binetti --- backend/lib/edgehog/provisioning.ex | 110 +++++++++++ .../edgehog/provisioning/astarte_config.ex | 42 +++++ .../lib/edgehog/provisioning/tenant_config.ex | 45 +++++ backend/test/edgehog/provisioning_test.exs | 175 ++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 backend/lib/edgehog/provisioning.ex create mode 100644 backend/lib/edgehog/provisioning/astarte_config.ex create mode 100644 backend/lib/edgehog/provisioning/tenant_config.ex create mode 100644 backend/test/edgehog/provisioning_test.exs diff --git a/backend/lib/edgehog/provisioning.ex b/backend/lib/edgehog/provisioning.ex new file mode 100644 index 000000000..a430190cc --- /dev/null +++ b/backend/lib/edgehog/provisioning.ex @@ -0,0 +1,110 @@ +# +# This file is part of Edgehog. +# +# Copyright 2023 SECO Mind Srl +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.Provisioning do + alias Edgehog.Astarte + alias Edgehog.Provisioning.AstarteConfig + alias Edgehog.Provisioning.TenantConfig + alias Edgehog.Repo + alias Edgehog.Tenants + + def provision_tenant(attrs) do + config_changeset = TenantConfig.changeset(%TenantConfig{}, attrs) + + with {:ok, tenant_config} <- Ecto.Changeset.apply_action(config_changeset, :create), + {:error, error_changeset} <- provision_tenant_from_config(tenant_config) do + {:error, remap_error_changeset(config_changeset, error_changeset)} + end + end + + defp provision_tenant_from_config(tenant_config) do + %TenantConfig{astarte_config: astarte_config} = tenant_config + + Repo.transact(fn -> + with {:ok, tenant} <- create_tenant(tenant_config), + Repo.put_tenant_id(tenant.tenant_id), + {:ok, cluster} <- create_cluster(astarte_config), + {:ok, realm} <- create_realm(cluster, astarte_config) do + # Build back the tenant config, to reflect what has actually been + # saved in the database + tenant_config = %TenantConfig{ + name: tenant.name, + slug: tenant.slug, + public_key: tenant.public_key, + astarte_config: %AstarteConfig{ + base_api_url: cluster.base_api_url, + realm_name: realm.name, + realm_private_key: realm.private_key + } + } + + {:ok, tenant_config} + end + end) + end + + defp create_tenant(tenant_config) do + tenant_params = Map.take(tenant_config, [:name, :slug, :public_key]) + Tenants.create_tenant(tenant_params) + end + + defp create_cluster(astarte_config) do + cluster_params = %{base_api_url: astarte_config.base_api_url} + Astarte.upsert_cluster(cluster_params) + end + + defp create_realm(cluster, astarte_config) do + realm_params = %{ + name: astarte_config.realm_name, + private_key: astarte_config.realm_private_key + } + + Astarte.create_realm(cluster, realm_params) + end + + # Utils to remap error changesets from Cluster or Realm creation to a + # TenantConfig error changest + defp remap_error_changeset(config_changeset, error_changeset) do + case error_changeset.data do + %Tenants.Tenant{} -> + # This has the same fields as TenantConfig, no need for remapping + error_changeset + + %Astarte.Cluster{} -> + field_mappings = %{base_api_url: :base_api_url} + remap_astarte_config_errors(config_changeset, error_changeset, field_mappings) + + %Astarte.Realm{} -> + field_mappings = %{name: :realm_name, private_key: :realm_private_key} + remap_astarte_config_errors(config_changeset, error_changeset, field_mappings) + end + end + + defp remap_astarte_config_errors(config_changeset, error_changeset, field_mappings) do + Enum.reduce(field_mappings, config_changeset, fn {source_field, dest_field}, acc -> + if errors = error_changeset.errors[source_field] do + put_in(acc.changes.astarte_config.errors[dest_field], errors) + |> Map.put(:valid?, false) + else + acc + end + end) + end +end diff --git a/backend/lib/edgehog/provisioning/astarte_config.ex b/backend/lib/edgehog/provisioning/astarte_config.ex new file mode 100644 index 000000000..0ac391215 --- /dev/null +++ b/backend/lib/edgehog/provisioning/astarte_config.ex @@ -0,0 +1,42 @@ +# +# This file is part of Edgehog. +# +# Copyright 2023 SECO Mind Srl +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.Provisioning.AstarteConfig do + use Ecto.Schema + import Ecto.Changeset + import Edgehog.ChangesetValidation + + @primary_key false + embedded_schema do + field :base_api_url, :string + field :realm_name, :string + field :realm_private_key, :string + end + + @doc false + def changeset(astarte_config, attrs) do + astarte_config + |> cast(attrs, [:base_api_url, :realm_name, :realm_private_key]) + |> validate_required([:base_api_url, :realm_name, :realm_private_key]) + |> validate_realm_name(:realm_name) + |> validate_pem_private_key(:realm_private_key) + |> validate_url(:base_api_url) + end +end diff --git a/backend/lib/edgehog/provisioning/tenant_config.ex b/backend/lib/edgehog/provisioning/tenant_config.ex new file mode 100644 index 000000000..7bd22937d --- /dev/null +++ b/backend/lib/edgehog/provisioning/tenant_config.ex @@ -0,0 +1,45 @@ +# +# This file is part of Edgehog. +# +# Copyright 2023 SECO Mind Srl +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.Provisioning.TenantConfig do + use Ecto.Schema + import Ecto.Changeset + import Edgehog.ChangesetValidation + + alias Edgehog.Provisioning.AstarteConfig + + @primary_key false + embedded_schema do + field :name, :string + field :slug, :string + field :public_key, :string + embeds_one :astarte_config, AstarteConfig + end + + @doc false + def changeset(tenant, attrs) do + tenant + |> cast(attrs, [:name, :slug, :public_key]) + |> cast_embed(:astarte_config, required: true) + |> validate_required([:name, :slug, :public_key]) + |> validate_tenant_slug(:slug) + |> validate_pem_public_key(:public_key) + end +end diff --git a/backend/test/edgehog/provisioning_test.exs b/backend/test/edgehog/provisioning_test.exs new file mode 100644 index 000000000..3ea62d441 --- /dev/null +++ b/backend/test/edgehog/provisioning_test.exs @@ -0,0 +1,175 @@ +# +# This file is part of Edgehog. +# +# Copyright 2023 SECO Mind Srl +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.ProvisioningTest do + use Edgehog.DataCase, async: true + use Edgehog.AstarteMockCase + + import Edgehog.AstarteFixtures + import Edgehog.TenantsFixtures + + alias Edgehog.Astarte + alias Edgehog.Provisioning + alias Edgehog.Provisioning.AstarteConfig + alias Edgehog.Provisioning.TenantConfig + alias Edgehog.Tenants + + @valid_pem_public_key X509.PrivateKey.new_ec(:secp256r1) + |> X509.PublicKey.derive() + |> X509.PublicKey.to_pem() + + @valid_pem_private_key X509.PrivateKey.new_ec(:secp256r1) |> X509.PrivateKey.to_pem() + + describe "provision_tenant/1" do + test "with valid attrs creates the tenant, cluster and realm" do + attrs = %{ + name: "Test", + slug: "test", + public_key: @valid_pem_public_key, + astarte_config: %{ + base_api_url: "https://astarte.api.example", + realm_name: "testrealm", + realm_private_key: @valid_pem_private_key + } + } + + assert {:ok, %TenantConfig{} = tenant_config} = Provisioning.provision_tenant(attrs) + + %TenantConfig{ + name: name, + slug: slug, + public_key: public_key + } = tenant_config + + assert name == attrs.name + assert slug == attrs.slug + assert public_key == attrs.public_key + + assert {:ok, tenant} = Tenants.fetch_tenant_by_slug(tenant_config.slug) + + assert %Tenants.Tenant{ + name: ^name, + slug: ^slug, + public_key: ^public_key + } = tenant + + tenant_id = tenant.tenant_id + + %AstarteConfig{ + base_api_url: base_api_url, + realm_name: realm_name, + realm_private_key: realm_private_key + } = tenant_config.astarte_config + + assert base_api_url == attrs.astarte_config.base_api_url + assert realm_name == attrs.astarte_config.realm_name + assert realm_private_key == attrs.astarte_config.realm_private_key + + Astarte.Cluster + |> Ecto.Query.where(base_api_url: ^base_api_url) + |> Repo.exists?(skip_tenant_id: true) + + assert {:ok, %Astarte.Realm{tenant_id: ^tenant_id, private_key: ^realm_private_key}} = + Astarte.fetch_realm_by_name(realm_name) + end + + test "succeeds when providing the URL of an already existing cluster" do + cluster = cluster_fixture() + + assert {:ok, _tenant_config} = + provision_tenant(astarte_config: [base_api_url: cluster.base_api_url]) + end + + test "fails with invalid tenant slug" do + assert {:error, changeset} = provision_tenant(slug: "1-INVALID") + assert errors_on(changeset)[:slug] != nil + end + + test "fails with invalid tenant public key" do + assert {:error, changeset} = provision_tenant(public_key: "invalid") + assert errors_on(changeset)[:public_key] != nil + end + + test "fails with invalid Astarte base API url" do + assert {:error, changeset} = provision_tenant(astarte_config: [base_api_url: "invalid"]) + assert errors_on(changeset)[:astarte_config][:base_api_url] != nil + end + + test "fails with invalid Astarte realm name" do + assert {:error, changeset} = provision_tenant(astarte_config: [realm_name: "INVALID"]) + assert errors_on(changeset)[:astarte_config][:realm_name] != nil + end + + test "fails with invalid Astarte realm private key" do + assert {:error, changeset} = + provision_tenant(astarte_config: [realm_private_key: "invalid"]) + + assert errors_on(changeset)[:astarte_config][:realm_private_key] != nil + end + + test "fails when providing an already existing tenant slug" do + tenant = tenant_fixture() + + assert {:error, changeset} = provision_tenant(slug: tenant.slug) + assert "has already been taken" in errors_on(changeset)[:slug] + end + + test "fails when providing an already existing tenant name" do + tenant = tenant_fixture() + + assert {:error, changeset} = provision_tenant(name: tenant.name) + assert "has already been taken" in errors_on(changeset)[:name] + end + + test "fails when providing an already existing realm name" do + cluster = cluster_fixture() + realm = realm_fixture(cluster) + + assert {:error, changeset} = + [astarte_config: [base_api_url: cluster.base_api_url, realm_name: realm.name]] + |> provision_tenant() + + assert "has already been taken" in errors_on(changeset)[:astarte_config][:realm_name] + end + end + + defp provision_tenant(opts) do + {astarte_config, opts} = Keyword.pop(opts, :astarte_config, []) + + astarte_config = + astarte_config + |> Enum.into(%{ + base_api_url: unique_cluster_base_api_url(), + realm_name: unique_realm_name(), + realm_private_key: @valid_pem_private_key + }) + + attrs = + opts + |> Enum.into(%{ + name: unique_tenant_name(), + slug: unique_tenant_slug(), + public_key: @valid_pem_public_key, + astarte_config: astarte_config + }) + + Provisioning.provision_tenant(attrs) + end +end