diff --git a/.env b/.env index 6bebf41d9..6a0b8d907 100644 --- a/.env +++ b/.env @@ -22,6 +22,27 @@ IPBASE_API_KEY= GOOGLE_GEOLOCATION_API_KEY= GOOGLE_GEOCODING_API_KEY= +# Wether to use SSL/TLS connection to the database, defaults to `false`. +DATABASE_ENABLE_SSL=false + +# Whether to use the host machine certificates or not to verify the connection +# with the database. +# DATABASE_USE_OS_CERTS=true + +# The CA certificate file to use to verify the TLS connection to the database. +# DATABASE_SSL_CACERTFILE=./example_cacert.pem + +# Whether verify the SSL connection with the database or not. +# DATABASE_SSL_VERIFY=true + +# The top level domain of your edgehog instance. +# In case you want to make Edgehog visible in your LAN, consider setting the variable +# to .nip.io +DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN=edgehog.localhost +# The top level domain of your astarte instance. It must be set according to your +# configuration for Astarte. +DOCKER_COMPOSE_ASTARTE_BASE_DOMAIN=astarte.localhost + S3_ACCESS_KEY_ID=minioadmin S3_SECRET_ACCESS_KEY=minioadmin S3_REGION=local @@ -29,10 +50,10 @@ S3_SCHEME=http:// S3_HOST=minio S3_PORT=9000 S3_BUCKET=edgehog -S3_ASSET_HOST=http://minio-storage.edgehog.localhost/edgehog +S3_ASSET_HOST=http://minio-storage.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}/edgehog S3_GCP_CREDENTIALS= SEEDS_REALM=test SEEDS_REALM_PRIVATE_KEY_FILE=./backend/priv/repo/seeds/keys/realm_private.pem SEEDS_TENANT_PRIVATE_KEY_FILE=./backend/priv/repo/seeds/keys/tenant_private.pem -SEEDS_ASTARTE_BASE_API_URL=http://api.astarte.localhost +SEEDS_ASTARTE_BASE_API_URL=http://api.${DOCKER_COMPOSE_ASTARTE_BASE_DOMAIN} diff --git a/.github/workflows/backend-test.yaml b/.github/workflows/backend-test.yaml index fe4b03c18..845740af8 100644 --- a/.github/workflows/backend-test.yaml +++ b/.github/workflows/backend-test.yaml @@ -185,6 +185,163 @@ jobs: if: matrix.postgres != env.postgres_version_uploading_to_coveralls run: mix test + integration-minio: + name: Integration tests using MinIO + runs-on: ubuntu-22.04 + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + minio: + image: bitnami/minio:latest + env: + MINIO_ROOT_USER: "minioadmin" + MINIO_ROOT_PASSWORD: "minioadmin" + ports: + - 9000:9000 + options: >- + --name minio + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --health-cmd "curl http://localhost:9000/minio/health/live" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + show-progress: false + + - name: Bucket setup + run: | + wget https://dl.min.io/client/mc/release/linux-amd64/mc + chmod +x ./mc + ./mc alias set minio http://localhost:9000 minioadmin minioadmin; + ./mc mb minio/edgehog; + ./mc anonymous set download minio/edgehog; + + - name: Install OTP and Elixir + uses: erlef/setup-beam@v1 + id: beam + with: + version-file: .tool-versions + version-type: strict + + - name: Cache dependencies + id: cache-deps + uses: actions/cache@v3 + with: + path: | + backend/deps + backend/_build + key: "${{ runner.os }}-\ + otp-${{ steps.beam.outputs.otp-version }}-\ + elixir-${{ steps.beam.outputs.elixir-version }}-\ + ${{ hashFiles('backend/mix.lock') }}" + + - name: Install and compile dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' + run: | + mix deps.get --only test + mix deps.compile + + - name: Test + env: + STORAGE_TYPE: s3 + S3_ACCESS_KEY_ID: minioadmin + S3_SECRET_ACCESS_KEY: minioadmin + S3_REGION: local + S3_SCHEME: http:// + S3_HOST: localhost + S3_PORT: 9000 + S3_BUCKET: edgehog + S3_ASSET_HOST: http://localhost:9000/edgehog + run: mix test --only integration_storage + + integration-azurite: + name: Integration tests using Azurite + runs-on: ubuntu-22.04 + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + azurite: + image: mcr.microsoft.com/azure-storage/azurite + ports: + - "10000:10000" + - "10001:10001" + - "10002:10002" + options: >- + --health-cmd "nc -z 127.0.0.1 10000" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + show-progress: false + + - name: Storage setup + env: + AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;" + run: | + # install az-cli + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + + # setup container + az storage container create --name edgehog --connection-string $AZURE_STORAGE_CONNECTION_STRING + az storage container set-permission --name edgehog --public-access blob --connection-string $AZURE_STORAGE_CONNECTION_STRING + + - name: Install OTP and Elixir + uses: erlef/setup-beam@v1 + id: beam + with: + version-file: .tool-versions + version-type: strict + + - name: Cache dependencies + id: cache-deps + uses: actions/cache@v3 + with: + path: | + backend/deps + backend/_build + key: "${{ runner.os }}-\ + otp-${{ steps.beam.outputs.otp-version }}-\ + elixir-${{ steps.beam.outputs.elixir-version }}-\ + ${{ hashFiles('backend/mix.lock') }}" + + - name: Install and compile dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' + run: | + mix deps.get --only test + mix deps.compile + + - name: Test + env: + STORAGE_TYPE: azure + AZURE_BLOB_ENDPOINT: "http://localhost:10000/devstoreaccount1" + AZURE_CONTAINER: "edgehog" + AZURE_STORAGE_ACCOUNT_KEY: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + AZURE_STORAGE_ACCOUNT_NAME: "devstoreaccount1" + run: mix test --only integration_storage + build-docker-image: name: Build Docker image runs-on: ubuntu-latest diff --git a/.github/workflows/docs-workflow.yaml b/.github/workflows/docs-workflow.yaml index 19c072593..907d0c6ab 100644 --- a/.github/workflows/docs-workflow.yaml +++ b/.github/workflows/docs-workflow.yaml @@ -115,11 +115,9 @@ jobs: # Copy docs over to the docs repository - name: Copy Docs, preserving Device SDK docs run: | - export DOCS_DIRNAME="$(echo ${{ github.ref }} | sed 's,refs/heads/,,' | sed 's/main/snapshot/g' | sed 's/release-//g')" + export DOCS_DIRNAME="$(echo ${{ github.ref }} | sed 's,refs/heads/,,' | sed 's/main/snapshot/g' | sed 's/release-//g')" rm -rf docs/$DOCS_DIRNAME mkdir docs/$DOCS_DIRNAME - # Restore Device SDK docs. Don't fail if they're not there. - cd docs && git restore $DOCS_DIRNAME/device-sdks || true && cd .. # Copy doc pages cp -r edgehog/doc/doc/* docs/$DOCS_DIRNAME/ # Copy version dropdown config @@ -128,6 +126,15 @@ jobs: cp -r ./edgehog/backend/tenant-graphql-api docs/$DOCS_DIRNAME/ # Copy Admin REST API docs cp -r ./edgehog/backend/admin-rest-api docs/$DOCS_DIRNAME/ + + # Upload as an artifact the generated HTML + - name: Upload HTML documentation + uses: actions/upload-artifact@v4 + with: + name: docs_html + path: ./docs + + # Commit and push changes to the docs repository - name: Commit files working-directory: ./docs run: | diff --git a/.reuse/templates/apache.jinja2 b/.reuse/templates/apache.jinja2 new file mode 100644 index 000000000..c832b6cd0 --- /dev/null +++ b/.reuse/templates/apache.jinja2 @@ -0,0 +1,21 @@ +This file is part of Edgehog. + +{% for copyright_line in copyright_lines %} +{{ copyright_line }} +{% endfor %} + +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. + +{% for expression in spdx_expressions %} +SPDX-License-Identifier: {{ expression }} +{% endfor %} diff --git a/CHANGELOG.md b/CHANGELOG.md index cc31e10d7..6515952a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.10] - Unreleased ### Added +- Managed OTA operations expose the update target that created them in graphql ([#356](https://github.com/edgehog-device-manager/edgehog/issues/356). +- Support for using Azure Storage as the persistence layer for asset uploads ([#233](https://github.com/edgehog-device-manager/edgehog/issues/233)). +- Ecto SSL configuration is exposed trough `DATABASE_*` environment variables (see [.env](./.env)) - Added Applications tab to Device page ([#662](https://github.com/edgehog-device-manager/edgehog/issues/662)) - Implemented a application management feature, enabling users to view and navigate through applications and their release details ([#704](https://github.com/edgehog-device-manager/edgehog/issues/704)) - **Applications page**: Displays a list of all existing applications, with navigation to individual Application pages. @@ -15,6 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `ReleaseCreate` page to enable users to create a new release for an application with fields for release Version and a list of Containers. - Add upgrade deployment functionality with version selection ([#703](https://github.com/edgehog-device-manager/edgehog/issues/703)) +## [0.9.3] - Unreleased +### Fixed +- Base Image deletion in S3 storage + +## [0.9.2] - 2024-12-09 +### Changed +- Update the docker-compose configuration to allow both physical and virtual devices + to connect to Edgehog, provided that the devices and the host are on the same LAN. + ## [0.9.1] - 2024-10-28 ### Fixed - Allow receiving `trigger_name` key in trigger payload, which is sent by Astarte >= 1.2.0. diff --git a/backend/config/dev.exs b/backend/config/dev.exs index 0ff5b13f6..604f58fdc 100644 --- a/backend/config/dev.exs +++ b/backend/config/dev.exs @@ -20,6 +20,12 @@ import Config +config :azurex, Azurex.Blob.Config, + api_url: "http://localhost:10000/devstoreaccount1", + default_container: "edgehog", + storage_account_name: "devstoreaccount1", + storage_account_key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + # Configure your database config :edgehog, Edgehog.Repo, username: "postgres", diff --git a/backend/config/runtime.exs b/backend/config/runtime.exs index efeb95621..1c2a3638d 100644 --- a/backend/config/runtime.exs +++ b/backend/config/runtime.exs @@ -20,10 +20,158 @@ import Config +alias Azurex.Blob.Config +alias Waffle.Storage.Google.CloudStorage +alias Waffle.Storage.S3 + if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do config :edgehog, EdgehogWeb.Endpoint, server: true end +# We need s3 storage configuration both in production and in integration tests +if config_env() in [:prod, :test] do + # TODO: while you can use access key + secret key with S3-compatible storages, + # Waffle's default S3 adapter doesn't work well with Google Cloud Storage. + # To use GCP, you need to supply the JSON credentials of an authorized Service + # Account instead, which are used by the GCP adapter for Waffle. + s3 = %{ + access_key_id: System.get_env("S3_ACCESS_KEY_ID"), + secret_access_key: System.get_env("S3_SECRET_ACCESS_KEY"), + gcp_credentials: System.get_env("S3_GCP_CREDENTIALS"), + region: System.get_env("S3_REGION"), + bucket: System.get_env("S3_BUCKET"), + asset_host: System.get_env("S3_ASSET_HOST"), + scheme: System.get_env("S3_SCHEME"), + host: System.get_env("S3_HOST"), + port: System.get_env("S3_PORT") + } + + azure = + case System.fetch_env("AZURE_CONNECTION_STRING") do + {:ok, connection_string} -> + connection_string_values = Azurex.Blob.Config.parse_connection_string(connection_string) + + %{ + api_url: connection_string_values["BlobEndpoint"], + storage_account_name: connection_string_values["AccountName"], + storage_account_key: connection_string_values["AccountKey"], + region: nil, + container: System.get_env("AZURE_CONTAINER") + } + + :error -> + %{ + api_url: System.get_env("AZURE_BLOB_ENDPOINT"), + storage_account_name: System.get_env("AZURE_STORAGE_ACCOUNT_NAME"), + storage_account_key: System.get_env("AZURE_STORAGE_ACCOUNT_KEY"), + region: System.get_env("AZURE_REGION"), + container: System.get_env("AZURE_CONTAINER") + } + end + + allowed_storage_types = ["s3", "azure"] + storage_type = "STORAGE_TYPE" |> System.get_env("s3") |> String.downcase(:ascii) + + unless storage_type in allowed_storage_types do + raise "Invalid storage type provided: #{inspect(storage_type)}. Allowed values are #{inspect(allowed_storage_types)}." + end + + if storage_type == "azure" && azure.api_url == nil && azure.storage_account_name == nil do + raise """ + When using Azure Blob Storage, either the blob endpoint (AZURE_BLOB_ENDPOINT) \ + or the account name (AZURE_STORAGE_ACCOUNT_NAME) must be specified. + """ + end + + storage_module = + cond do + storage_type == "azure" -> Edgehog.AzureStorage + s3.host == "storage.googleapis.com" -> CloudStorage + true -> S3 + end + + azure_api_url = + with nil <- azure.api_url do + azure_zone_url_str = + case to_string(azure.region) do + "" -> "" + zone -> "." <> zone + end + + account_name = to_string(azure.storage_account_name) + + # https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview#standard-endpoints + "https://" <> account_name <> azure_zone_url_str <> ".blob.core.windows.net" + end + + # The maximum upload size, particularly relevant for OTA updates. Default to 4 GB. + max_upload_size_bytes = + "MAX_UPLOAD_SIZE_BYTES" + |> System.get_env(to_string(4_000_000_000)) + |> String.to_integer() + + %{ + waffle_bucket: waffle_bucket, + waffle_asset_host: waffle_asset_host, + waffle_virtual_host?: waffle_virtual_host?, + edgehog_enable_s3_storage?: edgehog_enable_s3_storage? + } = + case storage_module do + Edgehog.AzureStorage -> + %{ + waffle_bucket: azure.container, + waffle_asset_host: azure_api_url, + waffle_virtual_host?: false, + edgehog_enable_s3_storage?: azure |> Map.values() |> Enum.any?(&(!is_nil(&1))) + } + + _ -> + %{ + waffle_bucket: s3.bucket, + waffle_asset_host: s3.asset_host, + waffle_virtual_host?: true, + edgehog_enable_s3_storage?: s3 |> Map.values() |> Enum.any?(&(!is_nil(&1))) + } + end + + edgehog_enable_s3_storage? = + case config_env() do + :test -> true + :prod -> edgehog_enable_s3_storage? + end + + config :azurex, Config, + api_url: azure_api_url, + default_container: azure.container, + storage_account_name: azure.storage_account_name, + storage_account_key: azure.storage_account_key + + # Enable uploaders only when the S3 storage has been configured + config :edgehog, + enable_s3_storage?: edgehog_enable_s3_storage?, + max_upload_size_bytes: max_upload_size_bytes + + config :ex_aws, :s3, + scheme: s3.scheme, + host: s3.host, + port: s3.port + + config :ex_aws, + region: s3.region, + access_key_id: s3.access_key_id, + secret_access_key: s3.secret_access_key + + config :goth, + disabled: storage_module != CloudStorage, + json: s3.gcp_credentials + + config :waffle, + storage: storage_module, + asset_host: waffle_asset_host, + bucket: waffle_bucket, + virtual_host: waffle_virtual_host? +end + # config/runtime.exs is executed for all environments, including # during releases. It is executed after compilation and before the # system starts, so it is typically used to load production configuration @@ -31,10 +179,13 @@ end # any compile-time configuration in here, as it won't be applied. # The block below contains prod specific runtime configuration. if config_env() == :prod do - database_username = System.fetch_env!("DATABASE_USERNAME") - database_password = System.fetch_env!("DATABASE_PASSWORD") - database_hostname = System.fetch_env!("DATABASE_HOSTNAME") - database_name = System.fetch_env!("DATABASE_NAME") + database = %{ + username: System.fetch_env!("DATABASE_USERNAME"), + password: System.fetch_env!("DATABASE_PASSWORD"), + hostname: System.fetch_env!("DATABASE_HOSTNAME"), + name: System.fetch_env!("DATABASE_NAME"), + ssl: Config.database_ssl_config() + } # The secret key base is used to sign/encrypt cookies and other secrets. # A default value is used in config/dev.exs and config/test.exs but you @@ -61,54 +212,19 @@ if config_env() == :prod do url_port = System.get_env("URL_PORT", "443") url_scheme = System.get_env("URL_SCHEME", "https") - # TODO: while you can use access key + secret key with S3-compatible storages, - # Waffle's default S3 adapter doesn't work well with Google Cloud Storage. - # To use GCP, you need to supply the JSON credentials of an authorized Service - # Account instead, which are used by the GCP adapter for Waffle. - s3 = %{ - access_key_id: System.get_env("S3_ACCESS_KEY_ID"), - secret_access_key: System.get_env("S3_SECRET_ACCESS_KEY"), - gcp_credentials: System.get_env("S3_GCP_CREDENTIALS"), - region: System.get_env("S3_REGION"), - bucket: System.get_env("S3_BUCKET"), - asset_host: System.get_env("S3_ASSET_HOST"), - scheme: System.get_env("S3_SCHEME"), - host: System.get_env("S3_HOST"), - port: System.get_env("S3_PORT") - } - - # The maximum upload size, particularly relevant for OTA updates. Default to 4 GB. - max_upload_size_bytes = - "MAX_UPLOAD_SIZE_BYTES" - |> System.get_env(to_string(4_000_000_000)) - |> String.to_integer() - - use_google_cloud_storage = - case s3.host do - "storage.googleapis.com" -> true - _ -> false - end - - s3_storage_module = - if use_google_cloud_storage do - Waffle.Storage.Google.CloudStorage - else - Waffle.Storage.S3 - end - forwarder_secure_sessions? = System.get_env("EDGEHOG_FORWARDER_SECURE_SESSIONS", "true") == "true" forwarder_hostname = System.get_env("EDGEHOG_FORWARDER_HOSTNAME") config :edgehog, Edgehog.Repo, - # ssl: true, # socket_options: [:inet6], - username: database_username, - password: database_password, - hostname: database_hostname, - database: database_name, - pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + username: database.username, + password: database.password, + hostname: database.hostname, + database: database.name, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + ssl: database.ssl config :edgehog, EdgehogWeb.Endpoint, http: [ @@ -127,31 +243,6 @@ if config_env() == :prod do ], secret_key_base: secret_key_base - # Enable uploaders only when the S3 storage has been configured - config :edgehog, - enable_s3_storage?: Enum.any?(s3, fn {_, v} -> v != nil end), - max_upload_size_bytes: max_upload_size_bytes - - config :ex_aws, :s3, - scheme: s3.scheme, - host: s3.host, - port: s3.port - - config :ex_aws, - region: s3.region, - access_key_id: s3.access_key_id, - secret_access_key: s3.secret_access_key - - config :goth, - disabled: !use_google_cloud_storage, - json: s3.gcp_credentials - - config :waffle, - storage: s3_storage_module, - bucket: s3.bucket, - asset_host: s3.asset_host, - virtual_host: true - if forwarder_hostname != nil && (String.starts_with?(forwarder_hostname, "http://") || String.starts_with?(forwarder_hostname, "https://")) do diff --git a/backend/lib/edgehog/astarte/realm/realm.ex b/backend/lib/edgehog/astarte/realm/realm.ex index 89b6b605b..06b94350d 100644 --- a/backend/lib/edgehog/astarte/realm/realm.ex +++ b/backend/lib/edgehog/astarte/realm/realm.ex @@ -84,6 +84,7 @@ defmodule Edgehog.Astarte.Realm do identities do identity :name, [:name] identity :unique_name_for_cluster, [:name, :cluster_id], all_tenants?: true + identity :one_realm_per_tenant, [:tenant_id] end postgres do diff --git a/backend/lib/edgehog/azure_storage.ex b/backend/lib/edgehog/azure_storage.ex new file mode 100644 index 000000000..7b902402f --- /dev/null +++ b/backend/lib/edgehog/azure_storage.ex @@ -0,0 +1,94 @@ +# +# This file is part of Edgehog. +# +# Copyright 2024 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.AzureStorage do + @moduledoc """ + Waffle module for Azure storage compatibility. + + Inspiration taken from waffle's s3 module + https://github.com/elixir-waffle/waffle/blob/8b058e5e4aabe29481df16ab691f8d1ffce6b6fd/lib/waffle/storage/s3.ex + """ + + alias Azurex.Blob + alias Waffle.Definition.Versioning + + def put(definition, version, {file, scope}) do + destination_dir = definition.storage_dir(version, {file, scope}) + name = Path.join(destination_dir, file.file_name) + container = container(definition, {file, scope}) + + content_type = + version |> definition.s3_object_headers({file, scope}) |> Keyword.get(:content_type) + + contents = + case file do + %Waffle.File{binary: file_binary} when is_binary(file_binary) -> file_binary + %Waffle.File{path: file_path} -> {:stream, File.stream!(file_path)} + end + + with :ok <- Blob.put_blob(name, contents, content_type, container) do + {:ok, file.file_name} + end + end + + def delete(definition, version, {file, scope}) do + destination_dir = definition.storage_dir(version, {file, scope}) + filename = Path.basename(file.file_name) + name = Path.join(destination_dir, filename) + container = container(definition, {file, scope}) + + Blob.delete_blob(name, container) + + :ok + end + + def url(definition, version, file_and_scope, _options \\ []) do + host = host(definition, file_and_scope) + dir = definition.storage_dir(version, file_and_scope) + filename = Versioning.resolve_file_name(definition, version, file_and_scope) + container = container(definition, file_and_scope) + + # TODO: replace with Blob.get_url when https://github.com/jakobht/azurex/pull/47 is merged + # and we can specify a custom api_url + Path.join([host, container, dir, filename]) + end + + defp container(definition, file_and_scope) do + file_and_scope |> definition.bucket() |> parse_container() + end + + defp parse_container({:system, env_var}) when is_binary(env_var), do: System.get_env(env_var) + defp parse_container(name), do: name + + defp host(definition, file_and_scope) do + case asset_host(definition, file_and_scope) do + {:system, env_var} when is_binary(env_var) -> System.get_env(env_var) + url -> url + end + end + + defp asset_host(definition, _file_and_scope) do + case definition.asset_host() do + false -> Blob.Config.api_url() + nil -> Blob.Config.api_url() + host -> host + end + end +end diff --git a/backend/lib/edgehog/base_images/base_images.ex b/backend/lib/edgehog/base_images/base_images.ex index 3e07d0ba9..b5f8b33a1 100644 --- a/backend/lib/edgehog/base_images/base_images.ex +++ b/backend/lib/edgehog/base_images/base_images.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2022-2024 SECO Mind Srl +# Copyright 2022-2025 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. @@ -67,7 +67,10 @@ defmodule Edgehog.BaseImages do end resources do - resource BaseImage + resource BaseImage do + define :delete_base_image, action: :destroy + end + resource BaseImageCollection end end diff --git a/backend/lib/edgehog/base_images/bucket_storage.ex b/backend/lib/edgehog/base_images/bucket_storage.ex index 3ce0490d5..94a261bae 100644 --- a/backend/lib/edgehog/base_images/bucket_storage.ex +++ b/backend/lib/edgehog/base_images/bucket_storage.ex @@ -42,8 +42,20 @@ defmodule Edgehog.BaseImages.BucketStorage do end @impl Storage - def delete(%BaseImage{} = scope) do - %BaseImage{url: url} = scope + def delete(%BaseImage{} = base_image) do + %BaseImage{ + url: url, + tenant_id: tenant_id, + version: base_image_version, + base_image_collection_id: base_image_collection_id + } = base_image + + scope = %{ + tenant_id: tenant_id, + base_image_collection_id: base_image_collection_id, + base_image_version: base_image_version + } + Uploaders.BaseImage.delete({url, scope}) end end diff --git a/backend/lib/edgehog/config.ex b/backend/lib/edgehog/config.ex index c5dbcb8d6..0b873777e 100644 --- a/backend/lib/edgehog/config.ex +++ b/backend/lib/edgehog/config.ex @@ -43,6 +43,30 @@ defmodule Edgehog.Config do os_env: "ADMIN_JWT_PUBLIC_KEY_PATH", type: JWTPublicKeyPEMType + @envdoc "Whether edgehog should use a tls connection with the database or not." + app_env :database_enable_ssl, :edgehog, :database_enable_ssl, + os_env: "DATABASE_ENABLE_SSL", + type: :boolean, + default: false + + @envdoc "The certificate file to use to verify the ssl connection with the database." + app_env :database_ssl_cacertfile, :edgehog, :database_ssl_cacertfile, + os_env: "DATABASE_SSL_CACERTFILE", + type: :binary, + default: "" + + @envdoc "Whether to use the os certificates to communicate with the database over ssl." + app_env :database_use_os_certs, :edgehog, :database_use_os_certs, + os_env: "DATABASE_USE_OS_CERTS", + type: :boolean, + default: false + + @envdoc "Whether to verify the ssl connection with the database or not." + app_env :database_ssl_verify, :edgehog, :database_ssl_verify, + os_env: "DATABASE_SSL_VERIFY", + type: :boolean, + default: false + @envdoc """ Disables tenant authentication. CHANGING IT TO TRUE IS GENERALLY A REALLY BAD IDEA IN A PRODUCTION ENVIRONMENT, IF YOU DON'T KNOW WHAT YOU ARE DOING. """ @@ -94,6 +118,67 @@ defmodule Edgehog.Config do @spec admin_authentication_disabled?() :: boolean() def admin_authentication_disabled?, do: disable_admin_authentication!() + @doc """ + Returns true if edgehog should use an ssl connection with the database. + """ + @spec database_enable_ssl?() :: boolean() + def database_enable_ssl?, do: database_enable_ssl!() + + @doc """ + Reutrns whether to verify the ssl connection witht he database or not. + """ + @spec database_ssl_verify?() :: boolean() + def database_ssl_verify?, do: database_ssl_verify!() + + @doc """ + Returns true if edgehog should use the operative system certificates. + """ + @spec database_use_os_certs?() :: boolean() + def database_use_os_certs?, do: database_use_os_certs!() + + defp database_ssl_cert_config do + use_os_certs = database_use_os_certs?() + + certfile = System.get_env("DATABASE_SSL_CACERTFILE") + + case {certfile, use_os_certs} do + {nil, false} -> + raise """ + invalid database SSL configuration: + either set DATABASE_USE_OS_CERTS true to use system's certificates + or provide a CA certificate file with DATABASE_SSL_CACERTFILE. + The latter will take precedence. + """ + + {nil, true} -> + {:cacerts, :public_key.cacerts_get()} + + {file, _} -> + # Assuming `file` is a file path + {:cacertfile, file} + end + end + + @doc """ + Returns the Ecto configuration for the ssl connection to the database. + """ + @spec database_ssl_config_opts() :: list(term()) + def database_ssl_config_opts do + if database_ssl_verify?(), + do: [{:verify, :verify_peer}, database_ssl_cert_config()], + else: [verify: :verify_none] + end + + @doc """ + Returns the database configuration for the database connection. + """ + @spec database_ssl_config() :: false | list(term()) + def database_ssl_config do + if database_enable_ssl?(), + do: database_ssl_config_opts(), + else: false + end + @doc """ Returns true if tenant authentication is disabled. """ diff --git a/backend/lib/edgehog/devices/devices.ex b/backend/lib/edgehog/devices/devices.ex index fd9267006..777841abd 100644 --- a/backend/lib/edgehog/devices/devices.ex +++ b/backend/lib/edgehog/devices/devices.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2021-2024 SECO Mind Srl +# Copyright 2021-2025 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. @@ -111,7 +111,11 @@ defmodule Edgehog.Devices do resource HardwareType resource Edgehog.Devices.HardwareTypePartNumber - resource SystemModel + + resource SystemModel do + define :delete_system_model, action: :destroy + end + resource Edgehog.Devices.SystemModelPartNumber end end diff --git a/backend/lib/edgehog/groups/device_group/device_group.ex b/backend/lib/edgehog/groups/device_group/device_group.ex index 26d84996b..22118be42 100644 --- a/backend/lib/edgehog/groups/device_group/device_group.ex +++ b/backend/lib/edgehog/groups/device_group/device_group.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2022-2024 SECO Mind Srl +# Copyright 2022-2025 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. @@ -60,6 +60,14 @@ defmodule Edgehog.Groups.DeviceGroup do accept [:update_channel_id] end + update :assign_update_channel do + accept [:update_channel_id] + + require_atomic? false + + validate Validations.UpdateChannelAbsent + end + destroy :destroy do description "Deletes a device group." primary? true diff --git a/backend/lib/edgehog/groups/device_group/validations/update_channel_absent.ex b/backend/lib/edgehog/groups/device_group/validations/update_channel_absent.ex new file mode 100644 index 000000000..f6b346998 --- /dev/null +++ b/backend/lib/edgehog/groups/device_group/validations/update_channel_absent.ex @@ -0,0 +1,38 @@ +# +# This file is part of Edgehog. +# +# Copyright 2025 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.Groups.DeviceGroup.Validations.UpdateChannelAbsent do + @moduledoc false + use Ash.Resource.Validation + + @impl Ash.Resource.Validation + def validate(changeset, _opts, _context) do + device_group = changeset.data + + if device_group.update_channel_id do + {:error, + field: :update_channel_id, + message: "The update channel is already set for the device group \"#{device_group.name}\"", + short_message: "Update channel already set"} + else + :ok + end + end +end diff --git a/backend/lib/edgehog/os_management/os_management.ex b/backend/lib/edgehog/os_management/os_management.ex index 9785401fd..b599849a2 100644 --- a/backend/lib/edgehog/os_management/os_management.ex +++ b/backend/lib/edgehog/os_management/os_management.ex @@ -44,6 +44,7 @@ defmodule Edgehog.OSManagement do define :mark_ota_operation_as_timed_out, action: :mark_as_timed_out define :update_ota_operation_status, action: :update_status, args: [:status] define :send_update_request, args: [:ota_operation] + define :delete_ota_operation, action: :destroy end end end diff --git a/backend/lib/edgehog/os_management/ota_operation/ota_operation.ex b/backend/lib/edgehog/os_management/ota_operation/ota_operation.ex index 92fed74c9..c8543f577 100644 --- a/backend/lib/edgehog/os_management/ota_operation/ota_operation.ex +++ b/backend/lib/edgehog/os_management/ota_operation/ota_operation.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2022-2024 SECO Mind Srl +# Copyright 2022-2025 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. @@ -47,7 +47,7 @@ defmodule Edgehog.OSManagement.OTAOperation do end actions do - defaults [:read, :destroy] + defaults [:read] create :create_fixture do accept [ @@ -98,6 +98,14 @@ defmodule Edgehog.OSManagement.OTAOperation do change {PublishNotification, event_type: :ota_operation_created} end + destroy :destroy do + require_atomic? false + + change Changes.HandleEphemeralImageDeletion do + where attribute_equals(:manual?, true) + end + end + update :mark_as_timed_out do # Needed because PublishNotification and HandleEphemeralImageDeletion are not atomic require_atomic? false @@ -191,6 +199,15 @@ defmodule Edgehog.OSManagement.OTAOperation do attribute_public? false allow_nil? false end + + has_one :update_target, Edgehog.UpdateCampaigns.UpdateTarget do + description """ + The update target of an update campaing that created the managed + ota operation, if any. + """ + + public? true + end end calculations do diff --git a/backend/lib/edgehog/tenants/reconciler.ex b/backend/lib/edgehog/tenants/reconciler.ex index a1f5f95b1..628b87f90 100644 --- a/backend/lib/edgehog/tenants/reconciler.ex +++ b/backend/lib/edgehog/tenants/reconciler.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2023 SECO Mind Srl +# Copyright 2023-2025 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. @@ -73,7 +73,8 @@ defmodule Edgehog.Tenants.Reconciler do |> Ash.Query.load(:realm_management_client) Tenant - |> Ash.read!(load: [realm: global_realm_query]) + |> Ash.read!() + |> Enum.map(&Ash.load!(&1, [realm: global_realm_query], tenant: &1)) |> Enum.each(&start_reconciliation_task(&1, tenant_to_trigger_url_fun)) {:noreply, state} diff --git a/backend/lib/edgehog/tenants/tenant/changes/handle_cleanup.ex b/backend/lib/edgehog/tenants/tenant/changes/handle_cleanup.ex new file mode 100644 index 000000000..7c7ae8744 --- /dev/null +++ b/backend/lib/edgehog/tenants/tenant/changes/handle_cleanup.ex @@ -0,0 +1,85 @@ +# +# This file is part of Edgehog. +# +# Copyright 2025 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.Tenants.Tenant.Changes.HandleCleanup do + @moduledoc false + use Ash.Resource.Change + + alias Edgehog.BaseImages + alias Edgehog.BaseImages.BaseImage + alias Edgehog.Devices + alias Edgehog.Devices.SystemModel + alias Edgehog.OSManagement + alias Edgehog.OSManagement.OTAOperation + + require Ash.Query + require Logger + + @impl Ash.Resource.Change + def change(changeset, _opts, _context) do + tenant = changeset.data + + system_models = Ash.read!(SystemModel, tenant: tenant) + + base_images = Ash.read!(BaseImage, tenant: tenant) + + manual_otas = + OTAOperation + |> Ash.Query.filter(manual?) + |> Ash.read!(tenant: tenant) + + Ash.Changeset.after_transaction(changeset, fn _changeset, result -> + with {:ok, tenant} <- result do + try do + cleanup_system_models(system_models, tenant) + cleanup_base_images(base_images, tenant) + cleanup_ephimeral_images(manual_otas, tenant) + catch + signal, error -> + Logger.error(""" + Tenant cleanup was not completed: + recived signal #{inspect(signal)} with the following error: #{inspect({error})} + """) + end + + # Return :ok if the cleanup does not fully succeeds + {:ok, tenant} + end + end) + end + + defp cleanup_system_models(system_models, tenant) do + for system_model <- system_models do + Devices.delete_system_model!(system_model, tenant: tenant) + end + end + + defp cleanup_base_images(base_images, tenant) do + for image <- base_images do + BaseImages.delete_base_image!(image, tenant: tenant) + end + end + + defp cleanup_ephimeral_images(manual_otas, tenant) do + for ota <- manual_otas do + OSManagement.delete_ota_operation!(ota, tenant: tenant) + end + end +end diff --git a/backend/lib/edgehog/tenants/tenant/tenant.ex b/backend/lib/edgehog/tenants/tenant/tenant.ex index 47d591151..dd95ada8a 100644 --- a/backend/lib/edgehog/tenants/tenant/tenant.ex +++ b/backend/lib/edgehog/tenants/tenant/tenant.ex @@ -31,6 +31,7 @@ defmodule Edgehog.Tenants.Tenant do alias Ash.Error.Invalid.TenantRequired alias Edgehog.Tenants.AstarteConfig alias Edgehog.Tenants.Tenant + alias Edgehog.Tenants.Tenant.Changes alias Edgehog.Validations require Ash.Query @@ -60,7 +61,7 @@ defmodule Edgehog.Tenants.Tenant do end actions do - defaults [:read, :destroy] + defaults [:read] create :create do primary? true @@ -99,6 +100,13 @@ defmodule Edgehog.Tenants.Tenant do run Tenant.ManualActions.ReconcilerAction end + + destroy :destroy do + description "Destroy tenant handling resource cleanup." + + require_atomic? false + change Changes.HandleCleanup + end end validations do diff --git a/backend/lib/edgehog/update_campaigns/update_channel/changes/relate_target_groups.ex b/backend/lib/edgehog/update_campaigns/update_channel/changes/relate_target_groups.ex index bb3f11129..dff3c1a03 100644 --- a/backend/lib/edgehog/update_campaigns/update_channel/changes/relate_target_groups.ex +++ b/backend/lib/edgehog/update_campaigns/update_channel/changes/relate_target_groups.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2024 SECO Mind Srl +# Copyright 2024-2025 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. @@ -22,36 +22,13 @@ defmodule Edgehog.UpdateCampaigns.UpdateChannel.Changes.RelateTargetGroups do @moduledoc false use Ash.Resource.Change - alias Ash.Error.Changes.InvalidArgument - alias Edgehog.Groups.DeviceGroup - @impl Ash.Resource.Change - def change(changeset, _opts, context) do - %{tenant: tenant} = context - + def change(changeset, _opts, _context) do {:ok, target_group_ids} = Ash.Changeset.fetch_argument(changeset, :target_group_ids) - Ash.Changeset.after_action(changeset, fn _changeset, update_channel -> - relate_target_groups(tenant, update_channel, target_group_ids) - end) - end - - defp relate_target_groups(tenant, update_channel, target_group_ids) do - %Ash.BulkResult{status: status, records: records} = - DeviceGroup - |> Ash.Query.filter(id in ^target_group_ids) - |> Ash.Query.filter(is_nil(update_channel_id)) - |> Ash.Query.set_tenant(tenant) - |> Ash.bulk_update(:update_update_channel, %{update_channel_id: update_channel.id}, return_records?: true) - - if status == :success and length(records) == length(target_group_ids) do - {:ok, update_channel} - else - {:error, - InvalidArgument.exception( - field: :target_group_ids, - message: "some target groups were not found or are already associated with an update channel" - )} - end + Ash.Changeset.manage_relationship(changeset, :target_groups, target_group_ids, + on_lookup: {:relate, :assign_update_channel}, + on_no_match: :error + ) end end diff --git a/backend/lib/edgehog/update_campaigns/update_channel/error_handler.ex b/backend/lib/edgehog/update_campaigns/update_channel/error_handler.ex new file mode 100644 index 000000000..975c0b316 --- /dev/null +++ b/backend/lib/edgehog/update_campaigns/update_channel/error_handler.ex @@ -0,0 +1,49 @@ +# +# This file is part of Edgehog. +# +# Copyright 2025 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.UpdateCampaigns.UpdateChannel.ErrorHandler do + @moduledoc false + + def handle_error(error, context) do + %{action: action} = context + + case action do + :create -> target_ids_translation(error) + :update -> target_ids_translation(error) + _ -> error + end + end + + defp target_ids_translation(error) do + if missing_target_ids?(error) do + %{ + error + | fields: [:target_group_ids], + message: "One or more target groups could not be found" + } + else + error + end + end + + defp missing_target_ids?(error) do + error[:code] == "not_found" + end +end diff --git a/backend/lib/edgehog/update_campaigns/update_channel/update_channel.ex b/backend/lib/edgehog/update_campaigns/update_channel/update_channel.ex index f6e765eff..99daa97aa 100644 --- a/backend/lib/edgehog/update_campaigns/update_channel/update_channel.ex +++ b/backend/lib/edgehog/update_campaigns/update_channel/update_channel.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2023-2024 SECO Mind Srl +# Copyright 2023-2025 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. @@ -28,6 +28,7 @@ defmodule Edgehog.UpdateCampaigns.UpdateChannel do alias Edgehog.UpdateCampaigns.UpdateChannel.Calculations alias Edgehog.UpdateCampaigns.UpdateChannel.Changes + alias Edgehog.UpdateCampaigns.UpdateChannel.ErrorHandler resource do description """ @@ -40,6 +41,8 @@ defmodule Edgehog.UpdateCampaigns.UpdateChannel do graphql do type :update_channel + + error_handler {ErrorHandler, :handle_error, []} end actions do @@ -55,9 +58,6 @@ defmodule Edgehog.UpdateCampaigns.UpdateChannel do description """ The IDs of the target groups that are targeted by this update channel. """ - - allow_nil? false - constraints min_length: 1 end change Changes.RelateTargetGroups do @@ -75,8 +75,6 @@ defmodule Edgehog.UpdateCampaigns.UpdateChannel do description """ The IDs of the target groups that are targeted by this update channel. """ - - constraints min_length: 1 end # Needed because manage_relationship is not atomic diff --git a/backend/lib/edgehog/update_campaigns/update_target/changes/create_managed_ota_operation.ex b/backend/lib/edgehog/update_campaigns/update_target/changes/create_managed_ota_operation.ex index 0fbb83508..9c8ead6ba 100644 --- a/backend/lib/edgehog/update_campaigns/update_target/changes/create_managed_ota_operation.ex +++ b/backend/lib/edgehog/update_campaigns/update_target/changes/create_managed_ota_operation.ex @@ -22,29 +22,17 @@ defmodule Edgehog.UpdateCampaigns.UpdateTarget.Changes.CreateManagedOTAOperation @moduledoc false use Ash.Resource.Change - alias Edgehog.OSManagement - @impl Ash.Resource.Change - def change(changeset, _opts, context) do - %{tenant: tenant} = context - + def change(changeset, _opts, _context) do {:ok, base_image} = Ash.Changeset.fetch_argument(changeset, :base_image) - device_id = Ash.Changeset.get_attribute(changeset, :device_id) + ota = %{device_id: device_id, base_image_url: base_image.url} - Ash.Changeset.before_action(changeset, fn changeset -> - # TODO: this is not transactional, since if for some reason the database - # operations fail, we would still have revert the OTA Operation that was - # already sent to Astarte by create_managed_ota_operation!/2. - # So we leave this like this for now and we'll revisit this when we add - # support for canceling OTA Operations. - ota_operation = - OSManagement.create_managed_ota_operation!( - %{device_id: device_id, base_image_url: base_image.url}, - tenant: tenant - ) - - Ash.Changeset.change_attribute(changeset, :ota_operation_id, ota_operation.id) - end) + # TODO: this is not transactional, since if for some reason the database + # operations fail, we would still have revert the OTA Operation that was + # already sent to Astarte by create_managed_ota_operation!/2. + # So we leave this like this for now and we'll revisit this when we add + # support for canceling OTA Operations. + Ash.Changeset.manage_relationship(changeset, :ota_operation, ota, on_no_match: {:create, :create_managed}) end end diff --git a/backend/mix.exs b/backend/mix.exs index 465b26c24..d22406f2c 100644 --- a/backend/mix.exs +++ b/backend/mix.exs @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2021-2024 SECO Mind Srl +# Copyright 2021-2025 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. @@ -24,7 +24,7 @@ defmodule Edgehog.MixProject do def project do [ app: :edgehog, - version: "0.9.1", + version: "0.9.2", elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers(), @@ -95,6 +95,7 @@ defmodule Edgehog.MixProject do {:envar, "~> 1.1"}, {:ex_aws, "~> 2.2"}, {:ex_aws_s3, "~> 2.0"}, + {:azurex, "~> 1.1"}, {:hackney, "~> 1.9"}, {:sweet_xml, "~> 0.6"}, {:waffle_gcs, "~> 0.2"}, diff --git a/backend/mix.lock b/backend/mix.lock index adad5dc03..20ba9c1fc 100644 --- a/backend/mix.lock +++ b/backend/mix.lock @@ -2,14 +2,15 @@ "absinthe": {:hex, :absinthe, "1.7.8", "43443d12ad2b4fcce60e257ac71caf3081f3d5c4ddd5eac63a02628bcaf5b556", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4085df201892a498384f997649aedb37a4ce8a726c170d5b5617ed3bf45d40b"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, "absinthe_relay": {:hex, :absinthe_relay, "1.5.2", "cfb8aed70f4e4c7718d3f1c212332d2ea728f17c7fc0f68f1e461f0f5f0c4b9a", [:mix], [{:absinthe, "~> 1.5.0 or ~> 1.6.0 or ~> 1.7.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "0587ee913afa31512e1457a5064ee88427f8fe7bcfbeeecd41c71d9cff0b62b6"}, - "ash": {:hex, :ash, "3.4.8", "8c35c1e401044d05474c0e1a3209c74afb7ac955243085489242cade24d31906", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.11 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.22 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e8dd3eb1f3aa45a75265c784df95ce261a3fad7f0d07400d316e981e14ed10f0"}, - "ash_graphql": {:hex, :ash_graphql, "1.3.3", "5f42592a5b82f47f5d7fd8ad42f395c7655decd4ba8211e95cd1da93b15f4669", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.2.3 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.3", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f86687c80f9288cae07aee5ce687cf7ecbd8d60019bc90fe097d4b1e259620dc"}, + "ash": {:hex, :ash, "3.4.53", "85a4740636199a8aedf835bc69c400ca5bdc587a263a3439e97d9a82ac099467", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.8 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57de328520a9af92364a4dd2b4c877182d90953ec145e9e67ff76be377961699"}, + "ash_graphql": {:hex, :ash_graphql, "1.5.0", "29dce267c7bd86fa7c94feb5ce861674d46046e528299f095db659411f32cd24", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.2.3 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.34 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7657ad63d2fba2feeac3ce9b158077e7b8cdcd823573c8888619de8ba929c188"}, "ash_json_api": {:hex, :ash_json_api, "1.4.7", "ced9c146e6e7a4ab2e9891efb133bcaaf031ac7b05541a41d549262af2988ee7", [:mix], [{:ash, "~> 3.3", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.14 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a00f8657e2492d87d7e7e851302ee320af21f12fc104a49cace972c804df8f4a"}, - "ash_postgres": {:hex, :ash_postgres, "2.3.0", "85b786019f75d12f63af00a0a7cf15d885820b1279561e2a33fbb59df1aa4af5", [:mix], [{:ash, ">= 3.4.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.30 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "e7f7ada1978239bfbf60ed3d4af76c34d427278aff0aa51552ac2196c7460394"}, - "ash_sql": {:hex, :ash_sql, "0.2.32", "de99255becfb9daa7991c18c870e9f276bb372acda7eda3e05c3e2ff2ca8922e", [:mix], [{:ash, ">= 3.1.7 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "43773bcd33d21319c11804d76fe11f1a1b7c8faba7aaedeab6f55fde3d2405db"}, + "ash_postgres": {:hex, :ash_postgres, "2.4.21", "0bbe2f8603fc6709742fd666d347c9236bd1a5794d52692964d9170794a2b7d6", [:mix], [{:ash, ">= 3.4.48 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.43 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "dac98a6bc8f6836a942257674a038c682b017bafd3480d2f73c9889c808e86a9"}, + "ash_sql": {:hex, :ash_sql, "0.2.44", "d9ab30acb8bedfbaf73f1a737ab319cdb04bcff40e4f4997b95d4e0a8a0e3536", [:mix], [{:ash, ">= 3.1.7 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "a4a178da467c57b443d7525632a686712c0f561b518ad870d14779b550d9e855"}, "astarte_client": {:git, "https://github.com/astarte-platform/astarte-client-elixir.git", "e7e66963eb6d977f73309f95b0b9d04506dff358", []}, + "azurex": {:hex, :azurex, "1.1.0", "32c18dc3817338bc7ff794a93a0448696cfc407585b634af7a2f4caada0c0e62", [:mix], [{:httpoison, "~> 1.8", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm", "7abe82d2b9de836428eb608db6afc383dad200294fb9b9d9adc003681d72bf50"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, + "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, @@ -19,11 +20,11 @@ "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "dataloader": {:hex, :dataloader, "1.0.11", "49bbfc7dd8a1990423c51000b869b1fecaab9e3ccd6b29eab51616ae8ad0a2f5", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba0b0ec532ec68e9d033d03553561d693129bd7cbd5c649dc7903f07ffba08fe"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, - "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, - "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, + "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "envar": {:hex, :envar, "1.1.0", "105bcac5a03800a1eb21e2c7e229edc687359b0cc184150ec1380db5928c115c", [:mix], [], "hexpm", "97028ab4a040a5c19e613fdf46a41cf51c6e848d99077e525b338e21d2993320"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, @@ -35,15 +36,16 @@ "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, - "glob_ex": {:hex, :glob_ex, "0.1.8", "f7ef872877ca2ae7a792ab1f9ff73d9c16bf46ecb028603a8a3c5283016adc07", [:mix], [], "hexpm", "9e39d01729419a60a937c9260a43981440c43aa4cadd1fa6672fecd58241c464"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "google_api_storage": {:hex, :google_api_storage, "0.41.0", "803f2eb69ebad9d9d201b3c64dde73d2dcefebc7420b2ee27cc64d9315e4780b", [:mix], [{:google_gax, "~> 0.4", [hex: :google_gax, repo: "hexpm", optional: false]}], "hexpm", "f5d048fd83f850ab1ea7a907190d11212301f67d354734fb7a322e261cd79d6f"}, "google_gax": {:hex, :google_gax, "0.4.0", "83651f8561c02a295826cb96b4bddde030e2369747bbddc592c4569526bafe94", [:mix], [{:poison, ">= 3.0.0 and < 5.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "a95d36f1dd753ab31268dd8bb6de9911243c911cfda9080f64778f6297b9ac57"}, "goth": {:hex, :goth, "1.4.3", "80e86225ae174844e6a77b61982fafadfc715277db465e0956348d8bdd56b231", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "34e2012ed1af2fe2446083da60a988fd9829943d30e4447832646c3a6863a7e6"}, "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "igniter": {:hex, :igniter, "0.3.27", "d6e8441982be6da118578d63011e91ac9c32f8104d553fdc485570ec93b711e3", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d377e1646a0b0b9bed4eb021e10ccaa67c7d2d676c8d577c75f02136c6c10387"}, + "igniter": {:hex, :igniter, "0.5.8", "d91e90fecb99beadfa9d0d8434fbd4f0fe06ea1a1d29cae4dfd0cb058cb3a5c7", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fef198324925405ea5c3b16166002be03b2d7497c038cfc9708aa557d27ba5a2"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -64,7 +66,7 @@ "observer_cli": {:hex, :observer_cli, "1.7.5", "cf73407c40ba3933a4be8be5cdbfcd647a7ec24b49f1d75e912ae1f2e58bc5d4", [:mix, :rebar3], [{:recon, "~> 2.5.5", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "872cf8e833a3a71ebd05420692678ec8aaede8fd96c805a4687398f6b23a3014"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, "open_api_spex": {:hex, :open_api_spex, "3.20.1", "ce5b3db013cd7337ab147f39fa2d4d627ddeeb4ff3fea34792f43d7e2e654605", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "dc9c383949d0fc4b20b73103ac20af39dad638b3a15c0e6281853c2fc7cc3cc8"}, - "owl": {:hex, :owl, "0.11.0", "2cd46185d330aa2400f1c8c3cddf8d2ff6320baeff23321d1810e58127082cae", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "73f5783f0e963cc04a061be717a0dbb3e49ae0c4bfd55fb4b78ece8d33a65efe"}, + "owl": {:hex, :owl, "0.12.0", "0c4b48f90797a7f5f09ebd67ba7ebdc20761c3ec9c7928dfcafcb6d3c2d25c99", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "241d85ae62824dd72f9b2e4a5ba4e69ebb9960089a3c68ce6c1ddf2073db3c15"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, @@ -77,20 +79,20 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "plug_heartbeat": {:hex, :plug_heartbeat, "1.0.0", "11b263a4c04d45b85c16ae815dd9a29ad405d7962769313c1244959328ebc39c", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "fa17f6eac7f4d91fcef36d253bc0114ff8c4d6e91665f2b06be1beb753cbb6aa"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, - "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, + "postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"}, "pretty_log": {:hex, :pretty_log, "0.9.0", "f84aab76e20c551a624ddd4656f1e5f9ca2941625db07549e9cb6a84a346bd40", [:mix], [{:logfmt, "~> 3.3", [hex: :logfmt, repo: "hexpm", optional: false]}], "hexpm", "abf9605c50fdd9377a3ce02ea51696538f4f647b9bb63a8dac209427fc7badf4"}, "prom_ex": {:hex, :prom_ex, "1.9.0", "63e6dda6c05cdeec1f26c48443dcc38ffd2118b3665ae8d2bd0e5b79f2aea03e", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.0.2", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.5.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.4.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.3", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.14.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.12.1", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "01f3d4f69ec93068219e686cc65e58a29c42bea5429a8ff4e2121f19db178ee6"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "reactor": {:hex, :reactor, "0.9.1", "082f8e9b1fd7586c0a016c2fb533835fec7eaef5ffb0263abb4473106c20b1ca", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7191ddf95fdd2b65770a57a2e38dd502a94909e51ac8daf497330e67fc032dc3"}, + "reactor": {:hex, :reactor, "0.10.3", "41a8c34251148e36dd7c75aa8433f2c2f283f29c097f9eb84a630ab28dd75651", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b34380e22b69a35943a7bcceffd5a8b766870f1fc9052162a7ff74ef9cdb3b2"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"}, + "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, "skogsra": {:hex, :skogsra, "2.5.0", "57d57c15bb8356662177779cb10adf1272069eeb4f3c032bf7d71d522e726f06", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: true]}], "hexpm", "b7dfe23ef3f9999a96fa330b73363b3f48d68a7ca3eb98ab1f32cd888ef207ee"}, - "sourceror": {:hex, :sourceror, "1.6.0", "9907884e1449a4bd7dbaabe95088ed4d9a09c3c791fb0103964e6316bc9448a7", [:mix], [], "hexpm", "e90aef8c82dacf32c89c8ef83d1416fc343cd3e5556773eeffd2c1e3f991f699"}, - "spark": {:hex, :spark, "2.2.24", "0cbd0e224af530f8f12f0e83ac5743b21802fb821d85b58d32a4da7e2268522b", [:mix], [{:igniter, ">= 0.2.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f05fd64ef74b3f3fe7817743962956dcc8a8e84bb9dc796ac7bf7fdcf4db5b6d"}, - "spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"}, - "splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"}, + "sourceror": {:hex, :sourceror, "1.7.1", "599d78f4cc2be7d55c9c4fd0a8d772fd0478e3a50e726697c20d13d02aa056d4", [:mix], [], "hexpm", "cd6f268fe29fa00afbc535e215158680a0662b357dc784646d7dff28ac65a0fc"}, + "spark": {:hex, :spark, "2.2.36", "07c921e5efb27f184267c3431d2f82099e24cac90748a47383dd75cbfb558268", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "e5ac56b75e5ad43da6d8302b6713277488f8e9a3abdba9aae8f0d0f9cff04538"}, + "spitfire": {:hex, :spitfire, "0.1.4", "8fe0df66e735323e4f2a56e719603391b160dd68efd922cadfbb85a2cf6c68af", [:mix], [], "hexpm", "d40d850f4ede5235084876246756b90c7bcd12994111d57c55e2e1e23ac3fe61"}, + "splode": {:hex, :splode, "0.2.7", "ed042fa9bd8fe7b66dd0a0faabdb97352058420d90cd1c7c1537f609deb7ef6d", [:mix], [], "hexpm", "267f1f51d5a5ac988cda0649498294844988c5086916fed5a8aff297d69a2059"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"}, + "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, "styler": {:hex, :styler, "1.0.0", "87daf4a8e421d7678da78f9532a632974de6b8060b80d7827abec3bca5140173", [:mix], [], "hexpm", "4ba5bc40c5eaebe2bb05ec0bb7b5a889d38c6ea6865c584dffd360b3a94ec625"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "swoosh": {:hex, :swoosh, "1.16.12", "cbb24ad512f2f7f24c7a469661c188a00a8c2cd64e0ab54acd1520f132092dfd", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0e262df1ae510d59eeaaa3db42189a2aa1b3746f73771eb2616fc3f7ee63cc20"}, @@ -99,6 +101,7 @@ "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, "tesla": {:hex, :tesla, "1.12.1", "fe2bf4250868ee72e5d8b8dfa408d13a00747c41b7237b6aa3b9a24057346681", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2391efc6243d37ead43afd0327b520314c7b38232091d4a440c1212626fdd6e7"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "waffle": {:hex, :waffle, "1.1.9", "8ce5ca9e59fa5491da67a2df57b8711d93223df3c3e5c21ad2acdedc41a0f51a", [:mix], [{:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.1", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "307c63cfdfb4624e7c423868a128ccfcb0e5291ae73a9deecb3a10b7a3eb277c"}, "waffle_gcs": {:hex, :waffle_gcs, "0.2.0", "dea6bb5da8da961a2a7a000178b0912cd342c89891c73ec745e19b1841b47fb2", [:mix], [{:google_api_storage, "~> 0.14", [hex: :google_api_storage, repo: "hexpm", optional: false]}, {:goth, "~> 1.1", [hex: :goth, repo: "hexpm", optional: false]}, {:waffle, "~> 1.1", [hex: :waffle, repo: "hexpm", optional: false]}], "hexpm", "5250750957d21cbaa762b88e71ca82014e7d803f3c2759a75ec2bb93fa3a384d"}, diff --git a/backend/mix.lock.license b/backend/mix.lock.license index 14e69eae4..e0a790994 100644 --- a/backend/mix.lock.license +++ b/backend/mix.lock.license @@ -1,2 +1,2 @@ -SPDX-FileCopyrightText: 2021-2024 SECO Mind Srl +SPDX-FileCopyrightText: 2021-2025 SECO Mind Srl SPDX-License-Identifier: CC0-1.0 diff --git a/backend/priv/repo/migrations/20241209134733_realm_tenant_uniqueness.exs b/backend/priv/repo/migrations/20241209134733_realm_tenant_uniqueness.exs new file mode 100644 index 000000000..cd25beb92 --- /dev/null +++ b/backend/priv/repo/migrations/20241209134733_realm_tenant_uniqueness.exs @@ -0,0 +1,41 @@ +# +# This file is part of Edgehog. +# +# Copyright 2024 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.Repo.Migrations.RealmTenantUniqueness do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:realms, [:tenant_id, :tenant_id], + name: "realms_one_realm_per_tenant_index" + ) + end + + def down do + drop_if_exists unique_index(:realms, [:tenant_id, :tenant_id], + name: "realms_one_realm_per_tenant_index" + ) + end +end diff --git a/backend/priv/repo/seeds.exs b/backend/priv/repo/seeds.exs index afa66b274..e14b93505 100644 --- a/backend/priv/repo/seeds.exs +++ b/backend/priv/repo/seeds.exs @@ -139,6 +139,11 @@ tenant = {status, realm_pk} = read_key!.("SEEDS_REALM_PRIVATE_KEY_FILE", "SEEDS_REALM_ORIGINAL_FILE", "realm_private") +Astarte.create_realm!( + %{cluster_id: cluster.id, name: read_env_var.("SEEDS_REALM"), private_key: realm_pk}, + tenant: tenant +) + if status == :default do """ You are using the default realm private key. \ @@ -146,6 +151,8 @@ if status == :default do """ |> String.trim_trailing("\n") |> Logger.warning() +else + Edgehog.Tenants.reconcile_tenant(tenant) end realm = @@ -325,4 +332,9 @@ _deployment = tenant: tenant ) +Astarte.create_realm!( + %{cluster_id: cluster.id, name: read_env_var.("SEEDS_REALM"), private_key: realm_pk}, + tenant: tenant +) + :ok diff --git a/backend/priv/resource_snapshots/repo/realms/20241209134733.json b/backend/priv/resource_snapshots/repo/realms/20241209134733.json new file mode 100644 index 000000000..22b373870 --- /dev/null +++ b/backend/priv/resource_snapshots/repo/realms/20241209134733.json @@ -0,0 +1,246 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "nil", + "generated?": true, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "private_key", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "tenant_id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "realms_tenant_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "tenants" + }, + "size": null, + "source": "tenant_id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "realms_cluster_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "clusters" + }, + "size": null, + "source": "cluster_id", + "type": "bigint" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [ + { + "all_tenants?": true, + "concurrently": false, + "error_fields": [ + "id", + "tenant_id" + ], + "fields": [ + { + "type": "atom", + "value": "id" + }, + { + "type": "atom", + "value": "tenant_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": true, + "using": null, + "where": null + }, + { + "all_tenants?": true, + "concurrently": false, + "error_fields": [ + "tenant_id" + ], + "fields": [ + { + "type": "atom", + "value": "tenant_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + }, + { + "all_tenants?": true, + "concurrently": false, + "error_fields": [ + "cluster_id" + ], + "fields": [ + { + "type": "atom", + "value": "cluster_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "46D0CC2683A8CD9B4DBD9A59D03E844D2FDA972CC5F9382DA4341683AE9253C8", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "realms_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": true, + "base_filter": null, + "index_name": "realms_unique_name_for_cluster_index", + "keys": [ + { + "type": "atom", + "value": "name" + }, + { + "type": "atom", + "value": "cluster_id" + } + ], + "name": "unique_name_for_cluster", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "realms_one_realm_per_tenant_index", + "keys": [ + { + "type": "atom", + "value": "tenant_id" + } + ], + "name": "one_realm_per_tenant", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": "tenant_id", + "global": false, + "strategy": "attribute" + }, + "repo": "Elixir.Edgehog.Repo", + "schema": null, + "table": "realms" +} \ No newline at end of file diff --git a/backend/test/edgehog/astarte/realm_test.exs b/backend/test/edgehog/astarte/realm_test.exs index 847f09c60..6d874125f 100644 --- a/backend/test/edgehog/astarte/realm_test.exs +++ b/backend/test/edgehog/astarte/realm_test.exs @@ -55,6 +55,17 @@ defmodule Edgehog.Astarte.RealmTest do assert %{field: :private_key} = error end + test "create_realm/2 does not allow creating multiple realms for the same tenant" do + tenant = tenant_fixture() + cluster = cluster_fixture() + _realm = realm_fixture(cluster_id: cluster.id, tenant: tenant) + + assert {:error, %Invalid{errors: [error]}} = + create_realm(tenant: tenant) + + assert %{field: :tenant_id, message: "has already been taken"} = error + end + test "with a duplicate name in the same tenant returns error" do tenant = tenant_fixture() cluster = cluster_fixture() diff --git a/backend/test/edgehog/storage_test.exs b/backend/test/edgehog/storage_test.exs new file mode 100644 index 000000000..227fef721 --- /dev/null +++ b/backend/test/edgehog/storage_test.exs @@ -0,0 +1,125 @@ +# +# This file is part of Edgehog. +# +# Copyright 2024 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.StorageTest do + @moduledoc false + use Edgehog.DataCase, async: true + + alias Edgehog.BaseImages.BaseImage + + @moduletag :integration_storage + + setup do + # Do not mock the storage for integration + Mox.stub_with(Edgehog.BaseImages.StorageMock, Edgehog.BaseImages.BucketStorage) + Mox.stub_with(Edgehog.Assets.SystemModelPictureMock, Edgehog.Assets.SystemModelPicture) + Mox.stub_with(Edgehog.OSManagement.EphemeralImageMock, Edgehog.OSManagement.EphemeralImage) + + :ok + end + + test "Base Images can be uploaded, read and deleted" do + tenant = Edgehog.TenantsFixtures.tenant_fixture() + + base_image_collection = + Edgehog.BaseImagesFixtures.base_image_collection_fixture(tenant: tenant) + + file = temporary_file_fixture() + version = "0.0.1" + + base_image = + Ash.create!( + BaseImage, + %{version: version, base_image_collection_id: base_image_collection.id, file: file}, + tenant: tenant + ) + + result = + HTTPoison.request(%HTTPoison.Request{method: :get, url: base_image.url}) + + assert {:ok, %{status_code: 200, body: result_body}} = result + assert File.read!(file.path) == result_body + + Ash.destroy!(base_image) + + result = + HTTPoison.request(%HTTPoison.Request{method: :get, url: base_image.url}) + + assert {:ok, %{status_code: 404}} = result + end + + test "System Model Picture can be uploaded, read and deleted" do + tenant = Edgehog.TenantsFixtures.tenant_fixture() + filename = "example.png" + expected_content_type = "image/png" + file = temporary_file_fixture(file_name: filename) + + system_model = + Edgehog.DevicesFixtures.system_model_fixture(picture_file: file, tenant: tenant) + + result = + HTTPoison.request(%HTTPoison.Request{method: :get, url: system_model.picture_url}) + + assert {:ok, %{status_code: 200, body: result_body, headers: headers}} = result + + {_header, content_type} = + Enum.find(headers, fn {key, _value} -> String.downcase(key) == "content-type" end) + + assert content_type == expected_content_type + assert File.read!(file.path) == result_body + + Ash.destroy!(system_model) + + result = + HTTPoison.request(%HTTPoison.Request{method: :get, url: system_model.picture_url}) + + assert {:ok, %{status_code: 404}} = result + end + + test "Ephimeral Images can be uploaded and read" do + tenant = Edgehog.TenantsFixtures.tenant_fixture() + file = temporary_file_fixture() + device_id = [tenant: tenant] |> Edgehog.DevicesFixtures.device_fixture() |> Map.fetch!(:id) + + Mox.stub(Edgehog.Astarte.Device.OTARequestV1Mock, :update, fn _, _, _, _ -> :ok end) + + ota_operation = + Edgehog.OSManagement.OTAOperation + |> Ash.Changeset.for_create(:manual, [device_id: device_id, base_image_file: file], tenant: tenant) + |> Ash.create!() + + result = + HTTPoison.request(%HTTPoison.Request{method: :get, url: ota_operation.base_image_url}) + + assert {:ok, %{status_code: 200, body: result_body}} = result + assert File.read!(file.path) == result_body + end + + def temporary_file_fixture(opts \\ []) do + file_name = Keyword.get(opts, :file_name, "example.bin") + contents = Keyword.get(opts, :contents, "example") + content_type = Keyword.get(opts, :content_type) + + temp_file = Plug.Upload.random_file!(file_name) + File.write!(temp_file, contents) + + %Plug.Upload{path: temp_file, filename: file_name, content_type: content_type} + end +end diff --git a/backend/test/edgehog/tenants_test.exs b/backend/test/edgehog/tenants_test.exs index 2910e7d66..9c9b7191a 100644 --- a/backend/test/edgehog/tenants_test.exs +++ b/backend/test/edgehog/tenants_test.exs @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2021-2024 SECO Mind Srl +# Copyright 2021-2025 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. @@ -28,6 +28,8 @@ defmodule Edgehog.TenantsTest do alias Ash.Error.Invalid alias Ash.Error.Query.NotFound alias Edgehog.Astarte + alias Edgehog.BaseImages.StorageMock + alias Edgehog.OSManagement.EphemeralImageMock alias Edgehog.Tenants alias Edgehog.Tenants.ReconcilerMock alias Edgehog.Tenants.Tenant @@ -326,8 +328,21 @@ defmodule Edgehog.TenantsTest do manual_ota_operation = manual_ota_operation_fixture(device_id: device.id, tenant: tenant) update_channel = update_channel_fixture(tenant: tenant) - update_campaign = update_campaign_fixture(tenant: tenant) - update_target = target_fixture(tenant: tenant) + update_campaign = update_campaign_fixture(base_image_id: base_image.id, tenant: tenant) + update_target = target_fixture(base_image_id: base_image.id, tenant: tenant) + + expect(StorageMock, :delete, fn to_delete -> + assert to_delete.id == base_image.id + :ok + end) + + expect(EphemeralImageMock, :delete, fn tenant_id, ota_operation_id, url -> + assert tenant_id == manual_ota_operation.tenant_id + assert ota_operation_id == manual_ota_operation.id + assert url == manual_ota_operation.base_image_url + + :ok + end) assert :ok = Tenants.destroy_tenant(tenant) diff --git a/backend/test/edgehog_web/schema/mutation/create_update_channel_test.exs b/backend/test/edgehog_web/schema/mutation/create_update_channel_test.exs index 154d9e34c..1f949ccd0 100644 --- a/backend/test/edgehog_web/schema/mutation/create_update_channel_test.exs +++ b/backend/test/edgehog_web/schema/mutation/create_update_channel_test.exs @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2023-2024 SECO Mind Srl +# Copyright 2023-2025 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. @@ -27,6 +27,7 @@ defmodule EdgehogWeb.Schema.Mutation.CreateUpdateChannelTest do describe "createUpdateChannel mutation" do test "creates update_channel with valid data", %{tenant: tenant} do target_group = device_group_fixture(tenant: tenant) + assert target_group.update_channel_id == nil target_group_id = AshGraphql.Resource.encode_relay_id(target_group) @@ -48,6 +49,34 @@ defmodule EdgehogWeb.Schema.Mutation.CreateUpdateChannelTest do assert target_group_data["handle"] == target_group.handle end + test "creates update_channel with nil target_group_ids", %{tenant: tenant} do + name = unique_update_channel_name() + handle = unique_update_channel_handle() + + result = + [target_group_ids: nil, tenant: tenant, name: name, handle: handle] + |> create_update_channel_mutation() + |> extract_result!() + + assert result["name"] == name + assert result["handle"] == handle + assert result["targetGroups"] == [] + end + + test "creates update_channel with empty target_group_ids", %{tenant: tenant} do + name = unique_update_channel_name() + handle = unique_update_channel_handle() + + result = + [target_group_ids: [], tenant: tenant, name: name, handle: handle] + |> create_update_channel_mutation() + |> extract_result!() + + assert result["name"] == name + assert result["handle"] == handle + assert result["targetGroups"] == [] + end + test "fails with missing name", %{tenant: tenant} do error = [name: nil, tenant: tenant] @@ -142,30 +171,6 @@ defmodule EdgehogWeb.Schema.Mutation.CreateUpdateChannelTest do } = error end - test "fails with missing target_group_ids", %{tenant: tenant} do - error = - [target_group_ids: nil, tenant: tenant] - |> create_update_channel_mutation() - |> extract_error!() - - assert %{message: message} = error - assert message =~ ~s - end - - test "fails with empty target_group_ids", %{tenant: tenant} do - error = - [target_group_ids: [], tenant: tenant] - |> create_update_channel_mutation() - |> extract_error!() - - assert %{ - path: ["createUpdateChannel"], - fields: [:target_group_ids], - message: "must have 1 or more items", - code: "invalid_argument" - } = error - end - test "fails when trying to use a non-existing target group", %{tenant: tenant} do target_group_id = non_existing_device_group_id(tenant) @@ -177,14 +182,19 @@ defmodule EdgehogWeb.Schema.Mutation.CreateUpdateChannelTest do assert %{ path: ["createUpdateChannel"], fields: [:target_group_ids], - message: "some target groups were not found or are already associated with an update channel", - code: "invalid_argument" + message: "One or more target groups could not be found", + code: "not_found" } = error end test "fails when trying to use already assigned target groups", %{tenant: tenant} do target_group = device_group_fixture(tenant: tenant) - _ = update_channel_fixture(tenant: tenant, target_group_ids: [target_group.id]) + + _ = + update_channel_fixture( + tenant: tenant, + target_group_ids: [target_group.id] + ) target_group_id = AshGraphql.Resource.encode_relay_id(target_group) @@ -195,10 +205,12 @@ defmodule EdgehogWeb.Schema.Mutation.CreateUpdateChannelTest do assert %{ path: ["createUpdateChannel"], - fields: [:target_group_ids], - message: "some target groups were not found or are already associated with an update channel", - code: "invalid_argument" + fields: [:update_channel_id], + message: "The update channel is already set for the device group " <> name, + code: "invalid_attribute" } = error + + assert name == ~s["#{target_group.name}"] end end diff --git a/backend/test/edgehog_web/schema/mutation/update_base_image_collection_test.exs b/backend/test/edgehog_web/schema/mutation/update_base_image_collection_test.exs index b595ebe4b..933526c63 100644 --- a/backend/test/edgehog_web/schema/mutation/update_base_image_collection_test.exs +++ b/backend/test/edgehog_web/schema/mutation/update_base_image_collection_test.exs @@ -86,7 +86,7 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateBaseImageCollectionTest do handle: "" ) - assert %{fields: [:handle], message: "should start with" <> _} = + assert %{fields: [:handle], message: "is required"} = extract_error!(result) end diff --git a/backend/test/edgehog_web/schema/mutation/update_update_channel_test.exs b/backend/test/edgehog_web/schema/mutation/update_update_channel_test.exs index b4b1e3a47..522755382 100644 --- a/backend/test/edgehog_web/schema/mutation/update_update_channel_test.exs +++ b/backend/test/edgehog_web/schema/mutation/update_update_channel_test.exs @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2023-2024 SECO Mind Srl +# Copyright 2023-2025 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. @@ -130,18 +130,13 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateUpdateChannelTest do } = error end - test "fails with empty target_group_ids", %{tenant: tenant, id: id} do - error = + test "updates update_channel with empty target_group_ids", %{tenant: tenant, id: id} do + result = [id: id, target_group_ids: [], tenant: tenant] |> update_update_channel_mutation() - |> extract_error!() + |> extract_result!() - assert %{ - path: ["updateUpdateChannel"], - fields: [:target_group_ids], - code: "invalid_argument", - message: "must have 1 or more items" - } = error + assert result["id"] == id end test "fails when trying to use a non-existing target group id", %{tenant: tenant, id: id} do @@ -155,14 +150,16 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateUpdateChannelTest do assert %{ path: ["updateUpdateChannel"], fields: [:target_group_ids], - code: "invalid_argument", - message: "some target groups were not found or are already associated with an update channel" + code: "not_found", + message: "One or more target groups could not be found" } = error end test "fails when trying to use already assigned target groups", %{tenant: tenant, id: id} do target_group = device_group_fixture(tenant: tenant) - _ = update_channel_fixture(tenant: tenant, target_group_ids: [target_group.id]) + + _ = + update_channel_fixture(tenant: tenant, target_group_ids: [target_group.id]) target_group_id = AshGraphql.Resource.encode_relay_id(target_group) @@ -173,10 +170,12 @@ defmodule EdgehogWeb.Schema.Mutation.UpdateUpdateChannelTest do assert %{ path: ["updateUpdateChannel"], - fields: [:target_group_ids], - code: "invalid_argument", - message: "some target groups were not found or are already associated with an update channel" + fields: [:update_channel_id], + code: "invalid_attribute", + message: "The update channel is already set for the device group " <> name } = error + + assert name == ~s["#{target_group.name}"] end end diff --git a/backend/test/support/fixtures/devices_fixtures.ex b/backend/test/support/fixtures/devices_fixtures.ex index 83797bb76..5e7cd376e 100644 --- a/backend/test/support/fixtures/devices_fixtures.ex +++ b/backend/test/support/fixtures/devices_fixtures.ex @@ -25,6 +25,7 @@ defmodule Edgehog.DevicesFixtures do """ alias Edgehog.AstarteFixtures + alias Edgehog.Tenants.Tenant @doc """ Generate a unique hardware_type handle. @@ -64,7 +65,11 @@ defmodule Edgehog.DevicesFixtures do {realm_id, opts} = Keyword.pop_lazy(opts, :realm_id, fn -> - [tenant: tenant] |> AstarteFixtures.realm_fixture() |> Map.fetch!(:id) + tenant = Ash.get!(Tenant, tenant, tenant: tenant, load: :realm) + + if tenant.realm == nil, + do: AstarteFixtures.realm_fixture(tenant: tenant).id, + else: tenant.realm.id end) default_device_id = AstarteFixtures.random_device_id() diff --git a/backend/test/test_helper.exs b/backend/test/test_helper.exs index f4465bb5a..4470df522 100644 --- a/backend/test/test_helper.exs +++ b/backend/test/test_helper.exs @@ -18,7 +18,7 @@ # SPDX-License-Identifier: Apache-2.0 # -ExUnit.start() +ExUnit.start(exclude: [:integration_storage]) Ecto.Adapters.SQL.Sandbox.mode(Edgehog.Repo, :manual) Mox.defmock(Edgehog.Geolocation.GeolocationProviderMock, diff --git a/doc/images/logo-favicon.png b/doc/images/logo-favicon.png index 856712504..c8820f2d5 100644 Binary files a/doc/images/logo-favicon.png and b/doc/images/logo-favicon.png differ diff --git a/doc/mix.exs b/doc/mix.exs index a92612133..61b422476 100644 --- a/doc/mix.exs +++ b/doc/mix.exs @@ -24,11 +24,11 @@ defmodule Doc.MixProject do def project do [ app: :doc, - version: "0.9.1", + version: "0.9.2", elixir: "~> 1.15", start_permanent: Mix.env() == :prod, deps: deps(), - name: "Edgehog", + name: "Clea Edgehog", homepage_url: "http://edgehog.io", docs: docs() ] @@ -66,7 +66,7 @@ defmodule Doc.MixProject do "pages/user/hardware_types.md", "pages/user/system_models.md", "pages/user/devices.md", - "pages/user/device_sdks_runtime.md", + "pages/user/devices_and_runtime.md", "pages/user/attribute_value_sources.md", "pages/user/groups.md", "pages/user/batch_operations.md", diff --git a/doc/pages/admin/deploying_with_kubernetes.md b/doc/pages/admin/deploying_with_kubernetes.md index 464326e8f..61e154b3b 100644 --- a/doc/pages/admin/deploying_with_kubernetes.md +++ b/doc/pages/admin/deploying_with_kubernetes.md @@ -180,6 +180,19 @@ Values to be replaced - `ACCESS-KEY-ID`: the access key ID for your S3 storage. - `SECRET-ACCESS-KEY`: the secret access key for your S3 storage. +#### Azure Blob Credentials + +To get started, follow the +[documentation](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-portal#create-a-container) +to create a container. + +Then, you can create a secret containing your connection string: + +```bash +$ kubectl create secret generic -n edgehog edgehog-azure-credentials \ + --from-literal="connection-string=" +``` + #### Google Geolocation API Key (optional) Activate the Geolocation API for your project in GCP and @@ -353,7 +366,31 @@ spec: value: - name: EDGEHOG_FORWARDER_SECURE_SESSIONS value: - image: edgehogdevicemanager/edgehog-backend:0.9.1 + + # If you're using Azure instead, use the following configuration instead of the S3 + # configuration above + # - name: STORAGE_TYPE + # value: azure + # - name: AZURE_CONNECTION_STRING + # valueFrom: + # secretKeyRef: + # name: edgehog-azure-credentials + # key: connection-string + # - name: AZURE_CONTAINER + # value: + + # You can also use standalone values instead of a connection string + # - name: AZURE_REGION + # value: + # - name: AZURE_STORAGE_ACCOUNT_NAME + # value: + # - name: AZURE_STORAGE_ACCOUNT_KEY + # value: + # - name: AZURE_BLOB_ENDPOINT + # value: + + + image: edgehogdevicemanager/edgehog-backend:0.9.2 imagePullPolicy: Always name: edgehog-backend ports: @@ -414,7 +451,7 @@ spec: - env: - name: BACKEND_URL value: - image: edgehogdevicemanager/edgehog-frontend:0.9.1 + image: edgehogdevicemanager/edgehog-frontend:0.9.2 imagePullPolicy: Always name: edgehog-frontend ports: diff --git a/doc/pages/ota_updates/ota_updates.md b/doc/pages/ota_updates/ota_updates.md index f801f5af9..f8a0a1965 100644 --- a/doc/pages/ota_updates/ota_updates.md +++ b/doc/pages/ota_updates/ota_updates.md @@ -8,14 +8,14 @@ Edgehog provides an OTA update mechanism that allows remotely updating devices. The OTA update mechanism is not tied to a specific platform and can be used on any [Edgehog -runtime](device_sdks_runtime.html) which implements the +runtime](devices_and_runtime.html) which implements the [`io.edgehog.devicemanager.OTARequest`](astarte_interfaces.html#io-edgehog-devicemanager-otarequest-v1-0), [`io.edgehog.devicemanager.OTAEvent`](astarte_interfaces.html#io-edgehog-devicemanager-otaevent-v0-1) and [`io.edgehog.devicemanager.BaseImage`](astarte_interfaces.html#io-edgehog-devicemanager-baseimage-v0-1) interfaces. -OTA Update concepts are detailed in the [dedicated page](ota_update_concepts.html), this guide +OTA Update concepts are detailed in the [dedicated page](ota_update_concepts.html), this guide demonstrates the usage of an OTA update mechanism. ## Managed OTA Updates @@ -24,7 +24,7 @@ Edgehog provides a mechanism to roll-out OTA updates to devices automatically, b Model](core_concepts.html#system-model) and their membership to specific [Groups](core_concepts.html#group). To push updates towards Devices, an Update Campaign must be created. It's important to note that an Update -Campaign can only send updates for the same Base Image Collection. Special operations, such as +Campaign can only send updates for the same Base Image Collection. Special operations, such as converting a Device from one System Model to another, must always be done with a [Manual OTA Update](#manual-ota-updates). @@ -42,7 +42,7 @@ Before actual push to the Device corresponding [Update Target](ota_update_concep is verified for fulfillment of Base Image and Roll-out mechanism criteria. For example: - Devices having same Base Image version will be silently marked as successful. - Devices with Base Images that don't meet [Version Requirement](ota_update_concepts.html#version-requirement) - of distributed Base Image will be marked as failed, unless the `Force Downgrade` option + of distributed Base Image will be marked as failed, unless the `Force Downgrade` option of [Push Roll-out mechanism](update_campaigns.html#roll-out-mechanism) is enabled. ## Manual OTA Updates @@ -51,7 +51,7 @@ As an escape hatch, it's always possible to manually update a [Device](core_conc from its page on the Edgehog dashboard (or using the Edgehog GraphQL API). Note that Manual OTA Updates do not perform any check on the [System Model](core_concepts.html#system-model), -so they can effectively be used to change the System Model of a Device. This also means that the user +so they can effectively be used to change the System Model of a Device. This also means that the user must exercise particular attention to avoid bricking a Device, if the Device does not implement the necessary safety checks. diff --git a/doc/pages/tutorials/edgehog_in_5_minutes.md b/doc/pages/tutorials/edgehog_in_5_minutes.md index 14d287a6b..869ef9e9d 100644 --- a/doc/pages/tutorials/edgehog_in_5_minutes.md +++ b/doc/pages/tutorials/edgehog_in_5_minutes.md @@ -144,14 +144,6 @@ You can finally navigate to `http://edgehog.localhost` in your browser and login ## Test Astarte connection -Astarte connectivity may not work right away, as edgehog has not yet reconciled -its interfaces and triggers with astarte. Without waiting, we can force it to execute -the reconciler using: - -```sh -$ docker compose exec edgehog-backend bin/edgehog rpc "Edgehog.Tenants.list_tenants |> Enum.each(&Edgehog.Tenants.reconcile_tenant/1)" -``` - If you now connect a device to astarte and open or reload the edgehog web page, you should see the new device in the appropriate section. diff --git a/doc/pages/user/device_sdks_runtime.md b/doc/pages/user/device_sdks_runtime.md deleted file mode 100644 index 91e3a7d76..000000000 --- a/doc/pages/user/device_sdks_runtime.md +++ /dev/null @@ -1,17 +0,0 @@ - - -# SDKs & Runtime - -## Edgehog Device ESP32 SDK -Edgehog Device ESP32 is an [ESP-IDF component](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/index.html) -written in C, that enables remote device management using Edgehog. -* [edgehog-esp32-device](https://github.com/edgehog-device-manager/edgehog-esp32-device) -* [documentation](device-sdks/esp32/index.html) - -## Edgehog Device Runtime -Edgehog Device Runtime is a portable middleware written in Rust, that enables remote device management using Edgehog. -* [edgehog-device-runtime](https://github.com/edgehog-device-manager/edgehog-device-runtime) diff --git a/doc/pages/user/devices_and_runtime.md b/doc/pages/user/devices_and_runtime.md new file mode 100644 index 000000000..a90ea5444 --- /dev/null +++ b/doc/pages/user/devices_and_runtime.md @@ -0,0 +1,24 @@ + + +# Devices & runtime + +## Edgehog device for ESP32 +The Edgehog device for ESP32 is an +[ESP-IDF component](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/index.html) +written in C, that enables remote device management using Edgehog. +You can find: +* The source code and installation instructions in the + [GitHub page](https://github.com/edgehog-device-manager/edgehog-esp32-device). +* The APIs for each version at the following links: + * [0.8](../devices/esp32/0.8/api/index.html). + +## Edgehog device runtime +The Edgehog device runtime is a portable middleware written in Rust, that enables remote device +management using Edgehog. +You can find: +* The source code and installation instructions in the + [GitHub page](https://github.com/edgehog-device-manager/edgehog-device-runtime). diff --git a/doc/versions.js b/doc/versions.js index 8e6cc9578..ab9db560c 100644 --- a/doc/versions.js +++ b/doc/versions.js @@ -1,6 +1,6 @@ var versionNodes = [ { - version: "v0.9.1", + version: "v0.9.2", url: "https://docs.edgehog.io/0.9" }, { diff --git a/docker-compose.without-astarte.yml b/docker-compose.without-astarte.yml index 6f6d92fa1..85f09c864 100644 --- a/docker-compose.without-astarte.yml +++ b/docker-compose.without-astarte.yml @@ -18,7 +18,6 @@ # SPDX-License-Identifier: Apache-2.0 # -version: "3.8" services: traefik: image: traefik:v2.10 diff --git a/docker-compose.yml b/docker-compose.yml index 498ec2054..558a00a3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,6 @@ # SPDX-License-Identifier: Apache-2.0 # -version: "3.8" services: postgresql: image: postgres:16.3 @@ -29,7 +28,7 @@ services: - postgresql-data:/var/lib/postgresql/data edgehog-backend: - image: edgehogdevicemanager/edgehog-backend:0.9.1 + image: edgehogdevicemanager/edgehog-backend:0.9.2 build: context: backend env_file: .env @@ -46,7 +45,7 @@ services: URL_HOST: edgehog-backend URL_PORT: 4000 URL_SCHEME: http - EDGEHOG_FORWARDER_HOSTNAME: device-forwarder.edgehog.localhost + EDGEHOG_FORWARDER_HOSTNAME: device-forwarder.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN} EDGEHOG_FORWARDER_PORT: 80 EDGEHOG_FORWARDER_SECURE_SESSIONS: "false" restart: on-failure @@ -63,21 +62,21 @@ services: - minio-init labels: - "traefik.enable=true" - - "traefik.http.routers.edgehog-backend.rule=Host(`api.edgehog.localhost`)" + - "traefik.http.routers.edgehog-backend.rule=Host(`api.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}`)" - "traefik.http.routers.edgehog-backend.entrypoints=web" - "traefik.http.routers.edgehog-backend.service=edgehog-backend" - "traefik.http.services.edgehog-backend.loadbalancer.server.port=4000" edgehog-frontend: - image: edgehogdevicemanager/edgehog-frontend:0.9.1 + image: edgehogdevicemanager/edgehog-frontend:0.9.2 build: context: frontend environment: - BACKEND_URL: http://api.edgehog.localhost/ + BACKEND_URL: http://api.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}/ restart: on-failure labels: - "traefik.enable=true" - - "traefik.http.routers.edgehog-frontend.rule=Host(`edgehog.localhost`)" + - "traefik.http.routers.edgehog-frontend.rule=Host(`${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}`)" - "traefik.http.routers.edgehog-frontend.entrypoints=web" - "traefik.http.routers.edgehog-frontend.service=edgehog-frontend" - "traefik.http.services.edgehog-frontend.loadbalancer.server.port=80" @@ -88,11 +87,11 @@ services: RELEASE_NAME: edgehog-device-forwarder SECRET_KEY_BASE: fXzwqLnU1V1bhfOwMRdm3tiGHRlfSpqmrw2aONac2QU4T9iwh3vjSIaweH1n0ZWg PORT: 4001 - PHX_HOST: device-forwarder.edgehog.localhost + PHX_HOST: device-forwarder.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN} restart: on-failure labels: - "traefik.enable=true" - - "traefik.http.routers.edgehog-device-forwarder.rule=Host(`device-forwarder.edgehog.localhost`)" + - "traefik.http.routers.edgehog-device-forwarder.rule=Host(`device-forwarder.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}`)" - "traefik.http.routers.edgehog-device-forwarder.entrypoints=web" - "traefik.http.routers.edgehog-device-forwarder.service=edgehog-device-forwarder" - "traefik.http.services.edgehog-device-forwarder.loadbalancer.server.port=4001" @@ -107,11 +106,11 @@ services: command: server --console-address ":9001" /data labels: - "traefik.enable=true" - - "traefik.http.routers.edgehog-minio-storage.rule=Host(`minio-storage.edgehog.localhost`)" + - "traefik.http.routers.edgehog-minio-storage.rule=Host(`minio-storage.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}`)" - "traefik.http.routers.edgehog-minio-storage.entrypoints=web" - "traefik.http.routers.edgehog-minio-storage.service=edgehog-minio-storage" - "traefik.http.services.edgehog-minio-storage.loadbalancer.server.port=9000" - - "traefik.http.routers.edgehog-minio-console.rule=Host(`minio.edgehog.localhost`)" + - "traefik.http.routers.edgehog-minio-console.rule=Host(`minio.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}`)" - "traefik.http.routers.edgehog-minio-console.entrypoints=web" - "traefik.http.routers.edgehog-minio-console.service=edgehog-minio-console" - "traefik.http.services.edgehog-minio-console.loadbalancer.server.port=9001" diff --git a/frontend/index.html b/frontend/index.html index 74ce2ea9f..38841ee83 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,7 +1,7 @@