Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# CHANGELOG (0.9.X)

## 0.9.4 ()

### Backwards incompatible changes from 0.9.3
* None

### Bug fixes
* None

### Enhancements
* [`PULL-230`](https://github.com/thiagoesteves/deployex/pull/230) Add external alerting via Webhook, Slack, and PagerDuty notification adapters
* [`PULL-232`](https://github.com/thiagoesteves/deployex/pull/232) Remove Certificate GenServer, call initializer directly from Application
* [`PULL-233`](https://github.com/thiagoesteves/deployex/pull/233) Add on-the-fly notification config and change events to strings

## 0.9.3 🚀 (2026-06-17)

### Backwards incompatible changes from 0.9.2
Expand All @@ -11,7 +24,6 @@
### Enhancements
* [`PULL-228`](https://github.com/thiagoesteves/deployex/pull/228) Add OTP/Elixir/Phoenix version info to monitored app status


## 0.9.2 🚀 (2026-06-15)

### Backwards incompatible changes from 0.9.1
Expand Down
Binary file modified apps/deployex_web/priv/static/docs/docs.tar.gz
Binary file not shown.
29 changes: 22 additions & 7 deletions apps/foundation/lib/foundation/certificates/manager/manager.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,26 @@ defmodule Foundation.Certificates.Manager do
@impl true
def handle_continue(:check_and_renew, state) do
case request_and_import_certificate(state) do
{:ok, certificate} ->
{:ok, :renewed, certificate} ->
Logger.info(
"Certificate check completed successfully for app: #{state.app_name}, domains: #{inspect(certificate.domains)}"
"Certificate renewed successfully for app: #{state.app_name}, domains: #{inspect(certificate.domains)}"
)

Foundation.Notifications.notify("certificate_renewed", %{
app_name: state.app_name,
domains: state.domains
})

{:ok, :valid, certificate} ->
Logger.info(
"Certificate check completed successfully for app: #{state.app_name}, domains: #{inspect(certificate.domains)}"
)

Foundation.Notifications.notify("certificate_valid", %{
app_name: state.app_name,
domains: state.domains
})

{:retry_after, seconds} when seconds >= 0 ->
Logger.warning("Acme for #{state.app_name} requested retry after #{seconds} s")

Expand Down Expand Up @@ -117,7 +127,7 @@ defmodule Foundation.Certificates.Manager do
Generate and deploy a certificate for the given domains.
"""
@spec request_and_import_certificate(__MODULE__.t(), boolean()) ::
{:ok, map()} | {:retry_after, non_neg_integer()} | {:error, any()}
{:ok, :renewed | :valid, map()} | {:retry_after, non_neg_integer()} | {:error, any()}
def request_and_import_certificate(state, force_renewal \\ false) do
Logger.info(
"Generating certificate for app: #{state.app_name} - #{inspect(state.domains)} using strategy: #{state.acme_provider}"
Expand All @@ -129,25 +139,30 @@ defmodule Foundation.Certificates.Manager do
{%{certificate_pem: nil}, _} ->
Logger.info("No domain certificate exists, creating new one for #{state.app_name}")
new_certificate = Catalog.Certificate.new(state)
generate_and_store_acme_certificate(state, new_certificate)

with {:ok, cert} <- generate_and_store_acme_certificate(state, new_certificate),
do: {:ok, :renewed, cert}

{cert, true} ->
Logger.info(
"Certificate exists but force renewal requested, updating existing certificate"
)

generate_and_store_acme_certificate(state, cert)
with {:ok, cert} <- generate_and_store_acme_certificate(state, cert),
do: {:ok, :renewed, cert}

{cert, false} ->
if Catalog.Certificate.valid?(cert, state.renew_before_days) do
Logger.info(
"Using existing valid certificate for app: #{state.app_name} - #{inspect(state.domains)}"
)

{:ok, cert}
{:ok, :valid, cert}
else
Logger.info("Certificate exists but is invalid, updating existing certificate")
generate_and_store_acme_certificate(state, cert)

with {:ok, cert} <- generate_and_store_acme_certificate(state, cert),
do: {:ok, :renewed, cert}
end
end
end
Expand Down
27 changes: 14 additions & 13 deletions apps/foundation/lib/foundation/notifications.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Foundation.Notifications do
| `"watchdog_threshold_exceeded"` | Watchdog exceeded a resource threshold and restarted an app| `node`, `type`, `current_percentage`, `restart_threshold_percent` |
| `"watchdog_threshold_warning"` | Resource crossed the warning threshold (or returned below) | `node`, `type`, `current_percentage`, `warning_threshold_percent`, `action` (`:warning`/`:normalized`) |
| `"certificate_renewed"` | TLS certificate was successfully renewed | `app_name`, `domains` |
| `"certificate_valid"` | Periodic check found a still-valid certificate (no renewal)| `app_name`, `domains` |
| `"certificate_failed"` | TLS certificate renewal failed | `app_name`, `domains`, `reason` |
| `"deployment_shutdown"` | DeployEx was force-terminated (kill -9 path) | `node`, `sname` |
| `"config_changed"` | Upgradable config change detected in the YAML file | `node`, `changes_count`, `fields` |
Expand All @@ -39,7 +40,8 @@ defmodule Foundation.Notifications do
## Configuration (deployex.yaml)

Multiple adapters can run in parallel. Each entry is independent and subscribes
to its own subset of events.
to its own subset of events. Use `"all"` as the sole event to subscribe to
every supported event automatically.

notifications:
- adapter: "slack"
Expand All @@ -66,17 +68,12 @@ defmodule Foundation.Notifications do
url: "https://internal.example.com/hooks/deployex"
enabled: true
events:
- "crash_restart"
- "deployment_started"
- "deployment_complete"
- "watchdog_threshold_exceeded"
- "certificate_renewed"
- "certificate_failed"
- "all" # subscribe to every supported event

## Adding a new adapter

Implement `Foundation.Notifications.Adapter` and add a new clause to
`Foundation.Yaml.notification_adapter/1`. See `Foundation.Notifications.Adapter`
`Foundation.Yaml` (private `notification_adapter/1`). See `Foundation.Notifications.Adapter`
for a step-by-step guide and a skeleton implementation.
"""

Expand All @@ -91,9 +88,7 @@ defmodule Foundation.Notifications do
def initialize_notification_manager do
:foundation
|> Application.fetch_env!(:notifications)
|> Enum.each(&start_notification_manager/1)

:ok
|> start_notification_manager()
end

@spec stop_notification_manager() :: :ok
Expand Down Expand Up @@ -139,8 +134,14 @@ defmodule Foundation.Notifications do
### ==========================================================================
### Private Functions
### ==========================================================================
defp to_notification_struct(%Foundation.Yaml.Notification{} = config) do
struct!(Worker, Map.from_struct(config))
defp to_notification_struct(config) when is_struct(config) do
config
|> Map.from_struct()
|> Map.update(:options, %{}, fn
opts when is_struct(opts) -> Map.from_struct(opts)
opts -> opts
end)
|> then(&struct!(Worker, &1))
end

defp to_notification_struct(config) do
Expand Down
3 changes: 2 additions & 1 deletion apps/foundation/lib/foundation/notifications/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Foundation.Notifications.Adapter do
1. Create a module that `@behaviour Foundation.Notifications.Adapter`.
2. Implement `notify/3`. Return `:ok` on success or `{:error, reason}` on
failure — `Foundation.Notifications.Worker` will log the error automatically.
3. Add a new clause to `Foundation.Yaml.notification_adapter/1` so the adapter
3. Add a new clause to the private `notification_adapter/1` in `Foundation.Yaml` so the adapter
can be selected from `deployex.yaml`.

### Example skeleton
Expand Down Expand Up @@ -46,6 +46,7 @@ defmodule Foundation.Notifications.Adapter do
| `"watchdog_threshold_exceeded"`| `node`, `type`, `current_percentage`, `restart_threshold_percent` |
| `"watchdog_threshold_warning"` | `node`, `type`, `current_percentage`, `warning_threshold_percent`, `action` (`:warning`/`:normalized`) |
| `"certificate_renewed"` | `app_name`, `domains` |
| `"certificate_valid"` | `app_name`, `domains` |
| `"certificate_failed"` | `app_name`, `domains`, `reason` |
| `"config_changed"` | `node`, `changes_count`, `fields` |
| `"config_change_applied"` | `node`, `changes_count`, `fields` |
Expand Down
5 changes: 5 additions & 0 deletions apps/foundation/lib/foundation/notifications/pagerduty.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ defmodule Foundation.Notifications.PagerDuty do
| `watchdog_threshold_exceeded` | `critical` |
| `watchdog_threshold_warning` | `warning` / `info` |
| `certificate_renewed` | `info` |
| `certificate_valid` | `info` |
| `certificate_failed` | `error` |
| `deployment_shutdown` | `warning` |
| `config_changed` | `warning` |
Expand Down Expand Up @@ -146,6 +147,9 @@ defmodule Foundation.Notifications.PagerDuty do
defp format_summary("certificate_renewed", payload),
do: "certificate_renewed — #{payload.app_name} (#{Enum.join(payload.domains, ", ")})"

defp format_summary("certificate_valid", payload),
do: "certificate_valid — #{payload.app_name} (#{Enum.join(payload.domains, ", ")})"

defp format_summary("certificate_failed", payload),
do: "certificate_failed — #{payload.app_name}: #{payload.reason}"

Expand All @@ -171,6 +175,7 @@ defmodule Foundation.Notifications.PagerDuty do
defp event_severity("watchdog_threshold_warning", %{action: :warning}), do: "warning"
defp event_severity("watchdog_threshold_warning", %{action: :normalized}), do: "info"
defp event_severity("certificate_renewed", _payload), do: "info"
defp event_severity("certificate_valid", _payload), do: "info"
defp event_severity("certificate_failed", _payload), do: "error"
defp event_severity("deployment_shutdown", _payload), do: "warning"
defp event_severity("config_changed", _payload), do: "warning"
Expand Down
1 change: 1 addition & 0 deletions apps/foundation/lib/foundation/notifications/slack.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule Foundation.Notifications.Slack do
- "watchdog_threshold_exceeded"
- "watchdog_threshold_warning"
- "certificate_renewed"
- "certificate_valid"
- "certificate_failed"
- "config_changed"
- "config_change_applied"
Expand Down
1 change: 1 addition & 0 deletions apps/foundation/lib/foundation/notifications/webhook.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule Foundation.Notifications.Webhook do
- "watchdog_threshold_exceeded"
- "watchdog_threshold_warning"
- "certificate_renewed"
- "certificate_valid"
- "certificate_failed"
- "config_changed"
- "config_change_applied"
Expand Down
25 changes: 19 additions & 6 deletions apps/foundation/lib/foundation/yaml.ex
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ defmodule Foundation.Yaml do
- `:url` — destination URL; required for `webhook` and `slack`, optional for `pagerduty`
(which defaults to the standard Events API endpoint)
- `:enabled` — set to `false` to silence a channel without removing it; defaults to `true`
- `:events` — list of event atoms this channel subscribes to (empty list = no deliveries)
- `:events` — list of events this channel subscribes to; use `"all"` as the sole entry to
subscribe to every supported event (empty list = no deliveries)
- `:options` — adapter-specific key/value pairs parsed directly from the YAML `options:` map

## Adapter-specific options
Expand Down Expand Up @@ -528,7 +529,17 @@ defmodule Foundation.Yaml do
url: data["url"],
enabled: data["enabled"] != false,
events: parse_notification_events(data["events"] || []),
options: atomize_keys(data["options"])
options: parse_notification_options(data["options"])
}
end

defp parse_notification_options(nil), do: %Foundation.Yaml.Notification.Options{}

defp parse_notification_options(opts) do
%Foundation.Yaml.Notification.Options{
routing_key: opts["routing_key"],
username: opts["username"],
icon_emoji: opts["icon_emoji"]
}
end

Expand All @@ -544,19 +555,21 @@ defmodule Foundation.Yaml do
watchdog_threshold_exceeded
watchdog_threshold_warning
certificate_renewed
certificate_valid
certificate_failed
deployment_shutdown
config_changed
config_change_applied
)

defp parse_notification_events(events) do
Enum.map(events, &parse_notification_event/1)
if "all" in events do
@valid_notification_events
else
Enum.map(events, &parse_notification_event/1)
end
end

defp parse_notification_event(event) when event in @valid_notification_events, do: event
defp parse_notification_event(event), do: raise("Unknown notification event: #{event}")

defp atomize_keys(nil), do: %{}
defp atomize_keys(map), do: Map.new(map, fn {k, v} -> {String.to_atom(k), v} end)
end
74 changes: 70 additions & 4 deletions apps/foundation/test/certificates/manager/manager_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ defmodule Foundation.Certificates.ManagerTest do
valid?: fn ^existing, _threshold_days -> true end
]}
]) do
assert {:ok, ^existing} = Manager.request_and_import_certificate(base_config())
assert {:ok, :valid, ^existing} = Manager.request_and_import_certificate(base_config())
end
end

Expand Down Expand Up @@ -95,7 +95,7 @@ defmodule Foundation.Certificates.ManagerTest do
end
]}
]) do
assert {:ok, _cert} = Manager.request_and_import_certificate(base_config())
assert {:ok, :renewed, _cert} = Manager.request_and_import_certificate(base_config())
end
end

Expand Down Expand Up @@ -128,7 +128,8 @@ defmodule Foundation.Certificates.ManagerTest do
end
]}
]) do
assert {:ok, _cert} = Manager.request_and_import_certificate(base_config(), true)
assert {:ok, :renewed, _cert} =
Manager.request_and_import_certificate(base_config(), true)
end
end

Expand Down Expand Up @@ -162,7 +163,7 @@ defmodule Foundation.Certificates.ManagerTest do
end
]}
]) do
assert {:ok, _cert} = Manager.request_and_import_certificate(base_config())
assert {:ok, :renewed, _cert} = Manager.request_and_import_certificate(base_config())
end
end

Expand Down Expand Up @@ -328,6 +329,71 @@ defmodule Foundation.Certificates.ManagerTest do
end
end

@tag :capture_log
test "fires certificate_valid notification when existing cert is still valid" do
existing = valid_existing_cert()
test_pid = self()

with_mocks([
{Catalog, [],
[
certificate: fn _app -> existing end,
certificate_update: fn _app, c -> {:ok, c} end
]},
{Catalog.Certificate, [],
[
valid?: fn _cert, _threshold_days -> true end
]},
{Foundation.Notifications, [:passthrough],
[notify: fn event, _payload -> send(test_pid, {:notified, event}) end]}
]) do
{:ok, pid} = Manager.start_link(base_config(certificate_check_interval_ms: 60_000))
assert_receive {:notified, "certificate_valid"}, 500
refute_received {:notified, "certificate_renewed"}
GenServer.stop(pid)
end
end

@tag :capture_log
test "fires certificate_renewed notification when a new certificate is generated" do
no_cert = %Certificate{certificate_pem: nil}
test_pid = self()

with_mocks([
{Catalog, [],
[
certificate: fn _app -> no_cert end,
certificate_update: fn _app, c -> {:ok, c} end
]},
{Catalog.Certificate, [],
[
new: fn _state -> no_cert end,
metadata_from_cert_pem: fn _pem ->
{:ok,
%{
issuer: "TestCA",
valid_from: ~U[2024-01-01 00:00:00Z],
valid_until: ~U[2025-01-01 00:00:00Z]
}}
end,
split_certificate_chain: fn _pem -> {"cert_pem", "chain_pem"} end
]},
{Network, [],
[
lookup: fn _domain_charlist, _class, _type, _options ->
["_acme.example.com.", "token123"]
end
]},
{Foundation.Notifications, [:passthrough],
[notify: fn event, _payload -> send(test_pid, {:notified, event}) end]}
]) do
{:ok, pid} = Manager.start_link(base_config(certificate_check_interval_ms: 60_000))
assert_receive {:notified, "certificate_renewed"}, 500
refute_received {:notified, "certificate_valid"}
GenServer.stop(pid)
end
end

@tag :capture_log
test "logs failure when certificate check errors during init" do
with_mocks([
Expand Down
Loading
Loading