Scribus resolutions are the browser-ctl mutant's mutations

140bc1d · gmorell · 2026-11-09 05:34

26 files +443 -98
Message
{commit_body(@commit)}

Files changed

modified app.yml
+18 −0
@@ -133,6 +133,24 @@ spec:
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,6 +122,20 @@ defmodule RoomSanctum.Accounts do
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,33 +900,6 @@ defmodule RoomSanctum.Configuration do
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,27 +0,0 @@
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
@@ -0,0 +1,191 @@
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