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>
This commit is contained in:
Mikael Hugo 2026-05-09 19:49:01 +02:00
commit 009d644a15
11 changed files with 305 additions and 0 deletions

49
README.md Normal file
View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

30
config/config.exs Normal file
View file

@ -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"

15
config/dev.exs Normal file
View file

@ -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

4
config/prod.exs Normal file
View file

@ -0,0 +1,4 @@
import Config
config :logger, level: :info
config :phoenix, :serve_endpoints, true

1
config/test.exs Normal file
View file

@ -0,0 +1 @@
import Config

16
mix.exs Normal file
View file

@ -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