neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Scribus resolutions are the browser-ctl mutant's mutations
140bc1d · gmorell · 2026-11-09 05:34
Message
{commit_body(@commit)}
Files changed
modified
app.yml
+18
−0
| 133 | 133 | href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> |
| 134 | 134 | contributors |
| 135 | 135 | |
| 136 | + # Scribus resolutions come from the browser-snap-mutant | |
| 137 | + # MutantDeployment via the mutant operator's API. The token is | |
| 138 | + # a copy, in this namespace, of the operator's mutantops-api | |
| 139 | + # secret (mutants namespace). | |
| 140 | + - name: MUTANT_API_URL | |
| 141 | + value: http://mutantops-api.mutants.svc | |
| 142 | + - name: MUTANT_API_TOKEN | |
| 143 | + valueFrom: | |
| 144 | + secretKeyRef: | |
| 145 | + name: mutantops-api | |
| 146 | + key: token | |
| 147 | + optional: true | |
| 148 | + | |
| 149 | + # Comma-separated emails of users who may start and stop | |
| 150 | + # Scribus resolutions. | |
| 151 | + - name: CRIB_ADMIN_EMAILS | |
| 152 | + value: "" | |
| 153 | + | |
| 136 | 154 | name: apollos-crib |
| 137 | 155 | ports: |
| 138 | 156 | - containerPort: 4000 |
modified
apps/room_sanctum/lib/room_sanctum/accounts.ex
+14
−0
| 122 | 122 | |
| 123 | 123 | ## Database getters |
| 124 | 124 | |
| 125 | + @doc """ | |
| 126 | + Whether `user` is an admin: their email is in `CRIB_ADMIN_EMAILS` | |
| 127 | + (comma-separated, case-insensitive). Admins can do what touches shared | |
| 128 | + infrastructure, such as starting or stopping a Scribus resolution. | |
| 129 | + """ | |
| 130 | + def admin?(%User{email: email}) when is_binary(email) do | |
| 131 | + email = String.downcase(email) | |
| 132 | + | |
| 133 | + Application.get_env(:room_sanctum, :admin_emails, []) | |
| 134 | + |> Enum.any?(&(String.downcase(&1) == email)) | |
| 135 | + end | |
| 136 | + | |
| 137 | + def admin?(_), do: false | |
| 138 | + | |
| 125 | 139 | @doc """ |
| 126 | 140 | Gets a user by email. |
| 127 | 141 |
modified
apps/room_sanctum/lib/room_sanctum/configuration.ex
+0
−27
| 900 | 900 | Scribus.changeset(scribus, attrs) |
| 901 | 901 | end |
| 902 | 902 | |
| 903 | − alias RoomSanctum.Configuration.ScribusResolution | |
| 904 | − | |
| 905 | − def list_scribus_resolutions({:user, uid}) do | |
| 906 | − from(r in ScribusResolution, where: r.user_id == ^uid, order_by: [asc: r.name]) | |
| 907 | − |> Repo.all() | |
| 908 | − end | |
| 909 | − | |
| 910 | − def get_scribus_resolution!(id), do: Repo.get!(ScribusResolution, id) | |
| 911 | − | |
| 912 | − def create_scribus_resolution(attrs \\ %{}) do | |
| 913 | − %ScribusResolution{} | |
| 914 | − |> ScribusResolution.changeset(attrs) | |
| 915 | − |> Repo.insert() | |
| 916 | − end | |
| 917 | − | |
| 918 | − def update_scribus_resolution(%ScribusResolution{} = res, attrs) do | |
| 919 | − res | |
| 920 | − |> ScribusResolution.changeset(attrs) | |
| 921 | − |> Repo.update() | |
| 922 | − end | |
| 923 | − | |
| 924 | − def delete_scribus_resolution(%ScribusResolution{} = res), do: Repo.delete(res) | |
| 925 | − | |
| 926 | − def change_scribus_resolution(%ScribusResolution{} = res, attrs \\ %{}) do | |
| 927 | − ScribusResolution.changeset(res, attrs) | |
| 928 | − end | |
| 929 | − | |
| 930 | 903 | alias RoomSanctum.Configuration.Agyr |
| 931 | 904 | |
| 932 | 905 | @doc """ |
deleted
apps/room_sanctum/lib/room_sanctum/configuration/scribus_resolution.ex
+0
−27
| 1 | −defmodule RoomSanctum.Configuration.ScribusResolution do | |
| 2 | − use Ecto.Schema | |
| 3 | − import Ecto.Changeset | |
| 4 | − | |
| 5 | − schema "cfg_scribus_resolutions" do | |
| 6 | − field :name, :string | |
| 7 | − field :width, :integer | |
| 8 | − field :height, :integer | |
| 9 | − belongs_to :user, RoomSanctum.Accounts.User | |
| 10 | − | |
| 11 | − timestamps() | |
| 12 | − end | |
| 13 | − | |
| 14 | − def changeset(resolution, attrs) do | |
| 15 | − resolution | |
| 16 | − |> cast(attrs, [:name, :width, :height, :user_id]) | |
| 17 | − |> validate_required([:name, :width, :height, :user_id]) | |
| 18 | − |> validate_number(:width, greater_than: 0) | |
| 19 | − |> validate_number(:height, greater_than: 0) | |
| 20 | − |> unique_constraint([:user_id, :name]) | |
| 21 | − end | |
| 22 | − | |
| 23 | − def display(%__MODULE__{width: w, height: h, name: name}), | |
| 24 | − do: "#{name} (#{w}x#{h})" | |
| 25 | − | |
| 26 | − def to_resolution_string(%__MODULE__{width: w, height: h}), do: "#{w}x#{h}" | |
| 27 | −end |
added
apps/room_sanctum/lib/room_sanctum/mutants.ex
+191
−0
| 1 | +defmodule RoomSanctum.Mutants do | |
| 2 | + @moduledoc """ | |
| 3 | + Scribus resolutions, backed by the browser-ctl MutantDeployment. | |
| 4 | + | |
| 5 | + Each browser-ctl mutation runs snappers bound to the routing key in its | |
| 6 | + `SNAP_RES`, and the scribe worker publishes to exactly that key -- so the | |
| 7 | + mutations *are* the resolutions a Scribus can use. They are read from, and | |
| 8 | + created and deleted through, the mutant operator's HTTP API rather than | |
| 9 | + kept in a table that could drift from what is actually running. | |
| 10 | + | |
| 11 | + Mutations declared inline in the MutantDeployment (its manifest, in git) | |
| 12 | + are listed but read-only: the operator refuses to delete them. | |
| 13 | + """ | |
| 14 | + | |
| 15 | + require Logger | |
| 16 | + | |
| 17 | + @env "SNAP_RES" | |
| 18 | + | |
| 19 | + defmodule Resolution do | |
| 20 | + @moduledoc "One running (or starting) browser-ctl mutation." | |
| 21 | + defstruct [:label, :resolution, :width, :height, :instances, :ready, :state, :source] | |
| 22 | + | |
| 23 | + @type t :: %__MODULE__{ | |
| 24 | + label: String.t(), | |
| 25 | + resolution: String.t(), | |
| 26 | + width: integer() | nil, | |
| 27 | + height: integer() | nil, | |
| 28 | + instances: integer(), | |
| 29 | + ready: integer(), | |
| 30 | + state: String.t(), | |
| 31 | + source: :inline | :resource | |
| 32 | + } | |
| 33 | + | |
| 34 | + def display(%__MODULE__{label: label, resolution: res}), do: "#{label} (#{res})" | |
| 35 | + end | |
| 36 | + | |
| 37 | + @doc "Every resolution, in label order." | |
| 38 | + @spec list_resolutions() :: {:ok, [Resolution.t()]} | {:error, String.t()} | |
| 39 | + def list_resolutions do | |
| 40 | + with {:ok, body} <- request(:get, "/mutations") do | |
| 41 | + {:ok, | |
| 42 | + body | |
| 43 | + |> Enum.flat_map(&to_resolution/1) | |
| 44 | + |> Enum.sort_by(& &1.label)} | |
| 45 | + end | |
| 46 | + end | |
| 47 | + | |
| 48 | + @doc """ | |
| 49 | + `{label, value}` options for a Scribus' resolution select: every Ready | |
| 50 | + resolution, plus `current` if given and not among them, so editing a | |
| 51 | + Scribus never silently drops the resolution it has. | |
| 52 | + """ | |
| 53 | + @spec resolution_options(String.t() | nil) :: [{String.t(), String.t()}] | |
| 54 | + def resolution_options(current \\ nil) do | |
| 55 | + ready = | |
| 56 | + case list_resolutions() do | |
| 57 | + {:ok, list} -> Enum.filter(list, &(&1.state == "Ready")) | |
| 58 | + {:error, _} -> [] | |
| 59 | + end | |
| 60 | + | |
| 61 | + options = Enum.map(ready, &{Resolution.display(&1), &1.resolution}) | |
| 62 | + | |
| 63 | + if current in [nil, ""] or Enum.any?(options, fn {_, v} -> v == current end), | |
| 64 | + do: options, | |
| 65 | + else: options ++ [{"#{current} (not running)", current}] | |
| 66 | + end | |
| 67 | + | |
| 68 | + @doc """ | |
| 69 | + Starts a resolution: a new mutation running `instances` snappers at | |
| 70 | + `width`x`height`, labelled from `name`. | |
| 71 | + """ | |
| 72 | + @spec create_resolution(String.t(), integer(), integer(), integer()) :: | |
| 73 | + {:ok, String.t()} | {:error, String.t()} | |
| 74 | + def create_resolution(name, width, height, instances \\ 1) do | |
| 75 | + label = label_for(name) | |
| 76 | + | |
| 77 | + cond do | |
| 78 | + label == "" -> | |
| 79 | + {:error, "name needs at least one letter or digit"} | |
| 80 | + | |
| 81 | + not (is_integer(width) and width > 0 and is_integer(height) and height > 0) -> | |
| 82 | + {:error, "width and height must be positive"} | |
| 83 | + | |
| 84 | + true -> | |
| 85 | + body = %{label: label, env: %{@env => "#{width}x#{height}"}, instances: instances} | |
| 86 | + | |
| 87 | + with {:ok, _} <- request(:post, "/mutations", body), do: {:ok, label} | |
| 88 | + end | |
| 89 | + end | |
| 90 | + | |
| 91 | + @doc "Stops a resolution started through `create_resolution/4`." | |
| 92 | + @spec delete_resolution(String.t()) :: :ok | {:error, String.t()} | |
| 93 | + def delete_resolution(label) do | |
| 94 | + with {:ok, _} <- request(:delete, "/mutations/" <> URI.encode_www_form(label)), do: :ok | |
| 95 | + end | |
| 96 | + | |
| 97 | + @doc """ | |
| 98 | + The mutation label for a display name: lowercase alphanumerics and '-', | |
| 99 | + as the operator requires (it becomes part of a Deployment name). | |
| 100 | + | |
| 101 | + iex> RoomSanctum.Mutants.label_for("InkPad 5 (landscape)") | |
| 102 | + "inkpad-5-landscape" | |
| 103 | + """ | |
| 104 | + def label_for(name) do | |
| 105 | + name | |
| 106 | + |> String.downcase() | |
| 107 | + |> String.replace(~r/[^a-z0-9]+/, "-") | |
| 108 | + |> String.trim("-") | |
| 109 | + |> String.slice(0, 63) | |
| 110 | + |> String.trim_trailing("-") | |
| 111 | + end | |
| 112 | + | |
| 113 | + # A mutation without SNAP_RES is not a resolution a Scribus can target. | |
| 114 | + defp to_resolution(%{"label" => label, "env" => %{@env => res}} = m) do | |
| 115 | + {w, h} = parse(res) | |
| 116 | + status = m["status"] || %{} | |
| 117 | + | |
| 118 | + [ | |
| 119 | + %Resolution{ | |
| 120 | + label: label, | |
| 121 | + resolution: res, | |
| 122 | + width: w, | |
| 123 | + height: h, | |
| 124 | + instances: m["instances"], | |
| 125 | + ready: status["readyReplicas"] || 0, | |
| 126 | + state: status["state"] || "Unknown", | |
| 127 | + source: if(get_in(m, ["source", "kind"]) == "inline", do: :inline, else: :resource) | |
| 128 | + } | |
| 129 | + ] | |
| 130 | + end | |
| 131 | + | |
| 132 | + defp to_resolution(_), do: [] | |
| 133 | + | |
| 134 | + defp parse(res) do | |
| 135 | + with [w, h] <- String.split(res, "x"), | |
| 136 | + {w, ""} <- Integer.parse(w), | |
| 137 | + {h, ""} <- Integer.parse(h) do | |
| 138 | + {w, h} | |
| 139 | + else | |
| 140 | + _ -> {nil, nil} | |
| 141 | + end | |
| 142 | + end | |
| 143 | + | |
| 144 | + defp request(method, path, body \\ nil) do | |
| 145 | + cfg = Application.get_env(:room_sanctum, __MODULE__, []) | |
| 146 | + | |
| 147 | + case cfg[:url] do | |
| 148 | + url when url in [nil, ""] -> | |
| 149 | + {:error, "mutant operator API is not configured (MUTANT_API_URL)"} | |
| 150 | + | |
| 151 | + url -> | |
| 152 | + full = | |
| 153 | + String.trim_trailing(url, "/") <> | |
| 154 | + "/api/v1/namespaces/#{cfg[:namespace]}/mutantdeployments/#{cfg[:mutant_deployment]}" <> | |
| 155 | + path | |
| 156 | + | |
| 157 | + headers = | |
| 158 | + [{"accept", "application/json"}, {"content-type", "application/json"}] ++ | |
| 159 | + if(cfg[:token] in [nil, ""], do: [], else: [{"authorization", "Bearer #{cfg[:token]}"}]) | |
| 160 | + | |
| 161 | + payload = if body, do: Jason.encode!(body), else: "" | |
| 162 | + send_request(method, full, payload, headers) | |
| 163 | + end | |
| 164 | + end | |
| 165 | + | |
| 166 | + defp send_request(method, url, payload, headers) do | |
| 167 | + case HTTPoison.request(method, url, payload, headers, recv_timeout: 5_000) do | |
| 168 | + {:ok, %HTTPoison.Response{status_code: code, body: body}} when code in 200..299 -> | |
| 169 | + {:ok, if(body == "", do: nil, else: Jason.decode!(body))} | |
| 170 | + | |
| 171 | + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> | |
| 172 | + {:error, error_message(code, body)} | |
| 173 | + | |
| 174 | + {:error, %HTTPoison.Error{reason: reason}} -> | |
| 175 | + Logger.warning("mutant operator unreachable: #{inspect(reason)}") | |
| 176 | + {:error, "mutant operator unreachable"} | |
| 177 | + end | |
| 178 | + rescue | |
| 179 | + # hackney can throw rather than return for an unreachable host. | |
| 180 | + e -> | |
| 181 | + Logger.warning("mutant operator request failed: #{Exception.message(e)}") | |
| 182 | + {:error, "mutant operator unreachable"} | |
| 183 | + end | |
| 184 | + | |
| 185 | + defp error_message(code, body) do | |
| 186 | + case Jason.decode(body) do | |
| 187 | + {:ok, %{"error" => msg}} -> msg | |
| 188 | + _ -> "mutant operator answered #{code}" | |
| 189 | + end | |
| 190 | + end | |
| 191 | +end |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_scribus_live/form_component.ex
+3
−5
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_scribus_live/index.ex
+61
−23
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_scribus_live/index.html.heex
+44
−16
Click to load diff…
added
apps/room_sanctum/priv/repo/migrations/20261109120000_drop_cfg_scribus_resolutions.exs
+24
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum/mutants_test.exs
+18
−0
Click to load diff…
modified
config/runtime.exs
+15
−0
Click to load diff…
added
mutant.yml
+55
−0
Click to load diff…
Parents: ead3597