commit 009d644a1540d0d1ff0b695739c4efdf2f883d72 Author: Mikael Hugo Date: Sat May 9 19:49:01 2026 +0200 feat: scaffold centralcloud Elixir umbrella (my. + ops. + core) - Umbrella root with apps/centralcloud_{my,ops,core} - centralcloud_core: HostBill Admin API client, DR Portal API client - centralcloud_my: Phoenix LiveView app for my.centralcloud.com - centralcloud_ops: Phoenix app for ops.centralcloud.com - Shared config: Authentik OIDC, HostBill, DR Portal endpoints - README with quick start and required env vars Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> diff --git a/README.md b/README.md new file mode 100644 index 0000000..2280b25 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# centralcloud + +Elixir umbrella app for CentralCloud customer and staff portals. + +## Apps + +| App | URL | Audience | +|-----|-----|----------| +| `centralcloud_my` | `my.centralcloud.com` | Customers — unified dashboard | +| `centralcloud_ops` | `ops.centralcloud.com` | Staff — ops and on-call | +| `centralcloud_core` | (library) | Shared: HostBill client, DR API client, OIDC | + +`my.centralcloud.com` will also serve `www.centralcloud.com` (marketing pages) when DNS is repointed. + +## Quick start + +```bash +# Install Elixir 1.17+ and Phoenix +mix local.hex --force +mix archive.install hex phx_new --force + +# Install deps +mix deps.get + +# Run my. portal (port 4001) +cd apps/centralcloud_my +mix phx.server + +# Run ops portal (port 4000) +cd apps/centralcloud_ops +mix phx.server +``` + +## Required env vars + +```bash +MY_SECRET_KEY_BASE=... # mix phx.gen.secret +OPS_SECRET_KEY_BASE=... +HOSTBILL_API_ID=... # from HostBill admin API keys +HOSTBILL_API_KEY=... +DR_PORTAL_URL=https://dr.centralcloud.com +DR_PORTAL_API_KEY=... +OIDC_CLIENT_ID=... # Authentik application client ID +OIDC_CLIENT_SECRET=... +``` + +## Architecture + +See `../dr-repo/docs/adr/001-unified-portal-architecture.md` diff --git a/apps/centralcloud_core/lib/centralcloud_core/dr_portal.ex b/apps/centralcloud_core/lib/centralcloud_core/dr_portal.ex new file mode 100644 index 0000000..034e5de --- /dev/null +++ b/apps/centralcloud_core/lib/centralcloud_core/dr_portal.ex @@ -0,0 +1,30 @@ +defmodule CentralcloudCore.DrPortal do + @moduledoc """ + DR Portal API client (Go/Gin backend at dr.centralcloud.com). + Used by my.centralcloud.com to fetch VM/replication status for a customer. + """ + + @spec get_customer_vms(String.t()) :: {:ok, list(map())} | {:error, term()} + def get_customer_vms(customer_id) do + get("/api/v1/customers/#{customer_id}/vms") + end + + @spec get_replication_status(String.t()) :: {:ok, map()} | {:error, term()} + def get_replication_status(customer_id) do + get("/api/v1/customers/#{customer_id}/replication") + end + + # --------------------------------------------------------------------------- + # Private + + defp get(path) do + cfg = Application.fetch_env!(:centralcloud_core, :dr_portal) + url = cfg[:base_url] <> path + + case Req.get(url, headers: [{"x-api-key", cfg[:api_key]}]) do + {:ok, %{status: 200, body: body}} -> {:ok, body} + {:ok, %{status: status}} -> {:error, {:http, status}} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/apps/centralcloud_core/lib/centralcloud_core/hostbill.ex b/apps/centralcloud_core/lib/centralcloud_core/hostbill.ex new file mode 100644 index 0000000..22ee133 --- /dev/null +++ b/apps/centralcloud_core/lib/centralcloud_core/hostbill.ex @@ -0,0 +1,50 @@ +defmodule CentralcloudCore.HostBill do + @moduledoc """ + HostBill Admin API client. + + Uses the Admin API at /admin/api.php (version 2026-04-21). + All requests are authenticated with api_id + api_key query params. + """ + + @spec get_client(integer()) :: {:ok, map()} | {:error, term()} + def get_client(client_id) do + call("getClientsDetails", %{clientid: client_id}) + end + + @spec get_clients() :: {:ok, list(map())} | {:error, term()} + def get_clients do + call("getClients", %{}) + end + + @spec get_client_services(integer()) :: {:ok, list(map())} | {:error, term()} + def get_client_services(client_id) do + call("getClientsProducts", %{clientid: client_id}) + end + + @spec get_invoices(integer()) :: {:ok, list(map())} | {:error, term()} + def get_invoices(client_id) do + call("getInvoices", %{clientid: client_id}) + end + + # --------------------------------------------------------------------------- + # Private + + defp call(action, params) do + cfg = Application.fetch_env!(:centralcloud_core, :hostbill) + + query = + Map.merge(params, %{ + api_id: cfg[:api_id], + api_key: cfg[:api_key], + call: action + }) + + url = cfg[:base_url] <> "/admin/api.php" + + case Req.post(url, form: query) do + {:ok, %{status: 200, body: body}} -> {:ok, body} + {:ok, %{status: status}} -> {:error, {:http, status}} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/apps/centralcloud_core/mix.exs b/apps/centralcloud_core/mix.exs new file mode 100644 index 0000000..986e7e6 --- /dev/null +++ b/apps/centralcloud_core/mix.exs @@ -0,0 +1,29 @@ +defmodule CentralcloudCore.MixProject do + use Mix.Project + + def project do + [ + app: :centralcloud_core, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [extra_applications: [:logger]] + end + + defp deps do + [ + {:req, "~> 0.5"}, # HTTP client for HostBill, DR API, OnCall + {:jason, "~> 1.4"}, # JSON + {:jose, "~> 1.11"} # JWT / OIDC token verification + ] + end +end diff --git a/apps/centralcloud_my/mix.exs b/apps/centralcloud_my/mix.exs new file mode 100644 index 0000000..4dcc196 --- /dev/null +++ b/apps/centralcloud_my/mix.exs @@ -0,0 +1,44 @@ +defmodule CentralcloudMy.MixProject do + use Mix.Project + + def project do + [ + app: :centralcloud_my, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + mod: {CentralcloudMy.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp deps do + [ + {:centralcloud_core, in_umbrella: true}, + {:phoenix, "~> 1.7"}, + {:phoenix_live_view, "~> 1.0"}, + {:phoenix_html, "~> 4.0"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:bandit, "~> 1.2"}, + {:ecto_sql, "~> 3.10"}, + {:postgrex, "~> 0.17"}, + {:swoosh, "~> 1.3"}, # email + {:finch, "~> 0.13"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.4"}, + {:dns_cluster, "~> 0.1.1"}, + {:oidcc, "~> 3.2"} # OIDC client for Authentik SSO + ] + end +end diff --git a/apps/centralcloud_ops/mix.exs b/apps/centralcloud_ops/mix.exs new file mode 100644 index 0000000..79749e0 --- /dev/null +++ b/apps/centralcloud_ops/mix.exs @@ -0,0 +1,37 @@ +defmodule CentralcloudOps.MixProject do + use Mix.Project + + def project do + [ + app: :centralcloud_ops, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + mod: {CentralcloudOps.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp deps do + [ + {:centralcloud_core, in_umbrella: true}, + {:phoenix, "~> 1.7"}, + {:phoenix_live_view, "~> 1.0"}, + {:phoenix_html, "~> 4.0"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:bandit, "~> 1.2"}, + {:jason, "~> 1.4"}, + {:oidcc, "~> 3.2"} + ] + end +end diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..b84ec96 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,30 @@ +import Config + +config :centralcloud_my, CentralcloudMy.Endpoint, + url: [host: "my.centralcloud.com"], + http: [port: 4001], + secret_key_base: System.fetch_env!("MY_SECRET_KEY_BASE") + +config :centralcloud_ops, CentralcloudOps.Endpoint, + url: [host: "ops.centralcloud.com"], + http: [port: 4000], + secret_key_base: System.fetch_env!("OPS_SECRET_KEY_BASE") + +# HostBill Admin API +config :centralcloud_core, :hostbill, + base_url: System.get_env("HOSTBILL_URL", "https://portal.centralcloud.com"), + api_id: System.fetch_env!("HOSTBILL_API_ID"), + api_key: System.fetch_env!("HOSTBILL_API_KEY") + +# DR Portal API +config :centralcloud_core, :dr_portal, + base_url: System.get_env("DR_PORTAL_URL", "https://dr.centralcloud.com"), + api_key: System.fetch_env!("DR_PORTAL_API_KEY") + +# Authentik OIDC +config :centralcloud_core, :oidc, + issuer: System.get_env("AUTHENTIK_ISSUER", "https://sso.centralcloud.com/application/o/centralcloud/"), + client_id: System.fetch_env!("OIDC_CLIENT_ID"), + client_secret: System.fetch_env!("OIDC_CLIENT_SECRET") + +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..efd368c --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,15 @@ +import Config + +config :centralcloud_my, CentralcloudMy.Endpoint, + code_reloader: true, + debug_errors: true, + check_origin: false, + watchers: [] + +config :centralcloud_ops, CentralcloudOps.Endpoint, + code_reloader: true, + debug_errors: true, + check_origin: false + +config :logger, :console, format: "[$level] $message\n" +config :phoenix, :stacktrace_depth, 20 diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..1fe0a5c --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,4 @@ +import Config + +config :logger, level: :info +config :phoenix, :serve_endpoints, true diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/test.exs @@ -0,0 +1 @@ +import Config diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..0f80122 --- /dev/null +++ b/mix.exs @@ -0,0 +1,16 @@ +defmodule Centralcloud.MixProject do + use Mix.Project + + def project do + [ + apps_path: "apps", + version: "0.1.0", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + defp deps do + [] + end +end