Skip to content

Commit

Permalink
Add Provisioning context
Browse files Browse the repository at this point in the history
Coordinate the provisioning of a Tenant with an associated realm and cluster.
Add relevant tests.

Signed-off-by: Riccardo Binetti <[email protected]>
  • Loading branch information
rbino committed Oct 16, 2023
1 parent 73dde54 commit 5fbd5e6
Show file tree
Hide file tree
Showing 4 changed files with 372 additions and 0 deletions.
110 changes: 110 additions & 0 deletions backend/lib/edgehog/provisioning.ex
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions backend/lib/edgehog/provisioning/astarte_config.ex
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions backend/lib/edgehog/provisioning/tenant_config.ex
Original file line number Diff line number Diff line change
@@ -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
175 changes: 175 additions & 0 deletions backend/test/edgehog/provisioning_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 5fbd5e6

Please sign in to comment.