Packages

Forgejo/Gitea multi-step check — login, issue an API token, call an authenticated endpoint — with its dashboard panel bundled in the same package as a separate module (Integrations.Forgejo.Display) — one install, both halves; a release without raven_web simply runs the monitor headless.

Current section

Files

Jump to
raven_integration_forgejo lib integrations forgejo.ex
Raw

lib/integrations/forgejo.ex

defmodule Integrations.Forgejo do
@moduledoc """
Forgejo / Gitea multi-step authenticated check.
Proves the *whole* authenticated path, not just "the server responds":
logs in with Basic Auth to mint a fresh, short-lived API token, uses that
token to list the account's repositories, then deletes the token again.
A plain liveness ping can't see an auth path that's broken behind a
healthy-looking TCP/HTTP front door — this can.
Collection only — see `Integrations.Forgejo.Display` (same package) for the
dashboard panel. `Display.BundledDefault` auto-hooks it whenever this
monitor starts, same as a single-module package would; a release without
`raven_web` simply never compiles the display half and runs this monitor
headless.
## Params
* `:base_url` — Forgejo base URL, e.g.
`http://forgejo-gitea-http.forgejo.svc.cluster.local:3000`.
Required.
* `:username` — Login username for the check account. Required.
* `:password` — Login password for the check account. Required. Use a
dedicated, low-privilege account — this integration
mints and deletes a real API token on every check.
* `:verify_repo` — `"owner/name"` of a repo the account can see. If set,
the check is `:degraded` unless it appears in the
authenticated repo list.
* `:timeout_ms` — Per-request timeout in milliseconds. Defaults to `5000`.
* `:verify_tls` — Verify the server's TLS certificate chain (only
relevant for `https://` base URLs). Defaults to `true`.
## Health signal
* `:up` — Login, token mint, and authenticated repo list all
succeeded (and `:verify_repo`, if set, was found).
* `:degraded` — The authenticated call succeeded but `:verify_repo`
wasn't found in the list.
* `:down` — Login failed (bad credentials, unreachable, timeout) or
the authenticated call itself failed.
"""
use CodeNameRaven.Monitor
require Logger
@default_timeout_ms 5_000
@impl true
def params_template do
%{
base_url: "",
username: "",
password: "",
verify_repo: "",
timeout_ms: "5000"
}
end
@impl true
def params_schema do
[
base_url: [type: :string, required: true, doc: "Forgejo base URL"],
username: [type: :string, required: true, doc: "Login username for the check account"],
password: [type: :string, required: true, doc: "Login password for the check account"],
verify_repo: [type: :string, required: false, doc: "\"owner/name\" the account must be able to see"],
timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "Per-request timeout in milliseconds"],
verify_tls: [type: :boolean, default: true, doc: "Verify TLS certificate chain"]
]
end
@impl true
def target_uri(params) do
case get_param(params, :base_url) do
nil -> :none
url -> {:ok, url}
end
end
@impl true
def identity_params(params) do
%{base_url: get_param(params, :base_url), username: get_param(params, :username)}
end
# ---------------------------------------------------------------------------
# Collect
# ---------------------------------------------------------------------------
@impl true
def collect(params, state) do
base_url = get_param(params, :base_url)
username = get_param(params, :username)
password = get_param(params, :password)
cond do
is_nil(base_url) -> {:error, "missing required param :base_url", state}
is_nil(username) -> {:error, "missing required param :username", state}
is_nil(password) -> {:error, "missing required param :password", state}
true -> do_collect(base_url, username, password, params, state)
end
end
defp do_collect(base_url, username, password, params, state) do
timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms)
verify_tls = truthy?(get_param(params, :verify_tls), true)
verify_repo = get_param(params, :verify_repo)
base_url = String.trim_trailing(base_url, "/")
total_start = System.monotonic_time(:millisecond)
case login(base_url, username, password, timeout_ms, verify_tls) do
{:ok, token_id, token, login_ms} ->
try do
case list_repos(base_url, token, timeout_ms, verify_tls) do
{:ok, repos, call_ms} ->
repo_count = length(repos)
repo_found =
if verify_repo do
Enum.any?(repos, &(&1["full_name"] == verify_repo))
else
nil
end
total_ms = System.monotonic_time(:millisecond) - total_start
{:ok,
%{
login_ms: login_ms,
call_ms: call_ms,
total_ms: total_ms,
repo_count: repo_count,
verify_repo: verify_repo,
repo_found: repo_found
}, state}
{:error, reason} ->
{:error, "authenticated call failed: #{reason}", state}
end
after
cleanup_token(base_url, username, password, token_id, timeout_ms, verify_tls)
end
{:error, reason} ->
{:error, "login failed: #{reason}", state}
end
end
# ---------------------------------------------------------------------------
# Healthy?
# ---------------------------------------------------------------------------
@impl true
def healthy?(%{repo_found: false}), do: :degraded
def healthy?(_result), do: :up
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
@impl true
def metrics(result) do
%{
login_ms: result.login_ms,
call_ms: result.call_ms,
total_ms: result.total_ms,
repo_count: result.repo_count
}
end
# ---------------------------------------------------------------------------
# Private — Forgejo API calls
# ---------------------------------------------------------------------------
defp login(base_url, username, password, timeout_ms, verify_tls) do
started_at = System.monotonic_time(:millisecond)
token_name = "raven-check-#{System.unique_integer([:positive])}"
url = "#{base_url}/api/v1/users/#{URI.encode(username)}/tokens"
req_opts =
[
auth: {:basic, "#{username}:#{password}"},
json: %{name: token_name, scopes: ["read:user", "read:repository"]},
receive_timeout: timeout_ms,
retry: false
] ++ tls_opts(base_url, verify_tls)
case Req.post(url, req_opts) do
{:ok, %{status: 201, body: %{"id" => id, "sha1" => sha1}}} ->
{:ok, id, sha1, System.monotonic_time(:millisecond) - started_at}
{:ok, %{status: 401}} ->
{:error, "authentication failed — check username/password"}
{:ok, %{status: status, body: body}} ->
{:error, "HTTP #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, format_error(reason)}
end
end
defp list_repos(base_url, token, timeout_ms, verify_tls) do
started_at = System.monotonic_time(:millisecond)
url = "#{base_url}/api/v1/user/repos"
# Forgejo expects the literal header `Authorization: token <sha1>`, not
# a bearer scheme — set it directly rather than via Req's :auth option.
req_opts =
[
headers: [{"authorization", "token #{token}"}],
receive_timeout: timeout_ms,
retry: false
] ++ tls_opts(base_url, verify_tls)
case Req.get(url, req_opts) do
{:ok, %{status: 200, body: body}} when is_list(body) ->
{:ok, body, System.monotonic_time(:millisecond) - started_at}
{:ok, %{status: status, body: body}} ->
{:error, "HTTP #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, format_error(reason)}
end
end
defp cleanup_token(base_url, username, password, token_id, timeout_ms, verify_tls) do
url = "#{base_url}/api/v1/users/#{URI.encode(username)}/tokens/#{token_id}"
req_opts =
[
auth: {:basic, "#{username}:#{password}"},
receive_timeout: timeout_ms,
retry: false
] ++ tls_opts(base_url, verify_tls)
case Req.delete(url, req_opts) do
{:ok, %{status: status}} when status in [200, 204, 404] -> :ok
{:ok, %{status: status}} -> Logger.warning("Integrations.Forgejo: token cleanup returned HTTP #{status}")
{:error, reason} -> Logger.warning("Integrations.Forgejo: token cleanup failed: #{format_error(reason)}")
end
end
defp tls_opts(base_url, verify_tls) do
if String.starts_with?(base_url, "https") and not verify_tls do
[connect_options: [transport_opts: [verify: :verify_none]]]
else
[]
end
end
defp format_error(%{reason: reason}), do: inspect(reason)
defp format_error(reason), do: inspect(reason)
defp truthy?(nil, default), do: default
defp truthy?("true", _), do: true
defp truthy?(true, _), do: true
defp truthy?(_, _), do: false
defp get_param(params, key) when is_atom(key) do
v =
case Map.fetch(params, key) do
{:ok, val} -> val
:error -> params[to_string(key)]
end
if is_binary(v) and String.trim(v) == "", do: nil, else: v
end
defp parse_int(nil, default), do: default
defp parse_int(v, _) when is_integer(v), do: v
defp parse_int(v, default) when is_binary(v) do
case Integer.parse(v) do
{n, _} -> n
:error -> default
end
end
defp parse_int(_, default), do: default
end