PhoenixKit.Install.ConfigSplice (phoenix_kit v2.62.2)

Copy Markdown View Source

Reads and appends to the literal lists (crontab: [...], plugins: [...], queues: [...]) inside a host's own config :app, Oban block, by text.

Every list-append in PhoenixKit.Install.ObanConfig used to do the same thing: trim the list body, look at whether it ended in ,, and glue ",\n" <> entries on. That reads the tail of the source text as if it were the tail of the list, and a host's list is full of things that are not list elements — a # every minute after the last tuple, a block of comment lines before the closing ]. The comma landed inside the comment, the candidate did not parse, ConfigVerify.verify_or_rollback/3 rolled it back, and the host was told to add the entries by hand. A real host lost four cron entries (the job-run sweeper among them) to a five-line comment.

How it avoids reading the tail

The content is first masked: comments (and, for bracket matching, the insides of strings, sigils, heredocs and character literals) are overwritten with spaces, byte for byte, newlines kept — so every offset in the masked copy is the same offset in the original. All decisions (where the block and the list open and close, which character is the last real token) are made on the masked copy; the edit is made on the original. The new entries go after the last real token and the comma that separates them goes right behind that token — in front of any trailing comment, never inside it. Whatever the host wrote after that token (comment lines, an end-of-line comment, an unusual indent) stays where it was.

Inserting at the start of the list was the alternative: it makes the tail irrelevant too. It was not chosen because it reorders a host's list (Lifeline would land ahead of Pruner, queues ahead of default), which a host reads as PhoenixKit rewriting its config; appending keeps the diff a pure addition at the end.

Two nets

The masking is a lexer written for this purpose, not Elixir's, so it can be wrong about an unusual file. Two checks keep a wrong guess from becoming a corrupted file:

  • a file whose masked text does not balance its brackets is refused as :unreadable — the usual symptom of a construct the masker lost track of;
  • callers verify the candidate (ConfigVerify.verify_or_rollback/3: it parses and the entries are members of the intended list) and then preserves_original?/3 (nothing but the entries changed).

Refusals

A list this module cannot take returns {:error, reason} — see reason_text/2 for the wording a caller shows. Notably an empty queues: [] is refused when allow_empty: false (Oban documents an empty queue list as "run no queues", which a node should not silently be given).

Summary

Functions

Appends entries (each a string, possibly several lines) to the literal list opened by key: inside config :app_name, Oban.

config :app_name, Oban's own text with comments blanked (strings: true blanks string bodies too), or nil when there is no literal block to scope to.

The comments of content and nothing else: code and string bodies are blanked, the # markers and the comment text stay, newlines are kept. A commented-out entry that spans several lines reads as consecutive text.

Every spelling of the Cron plugin the file can use: both module names, plus each short form its alias lines give them (alias Oban.Plugins.Cron, alias Oban.Plugins.{Cron, Pruner}, alias Oban.{Plugins.Cron}, alias Oban.Plugins.Cron, as: C, alias Oban.Plugins + Plugins.Cron).

Whether a top-level Oban config schedules module, optionally with a digest cadence. Reads only worker positions in direct crontab entries, resolving aliases; a module named in a job's arguments does not schedule that worker. Other cron plugins' literal crontabs count too, to avoid scheduling twice.

The whole file's code with comments blanked and every config :x, Oban block blanked too — the app's own, other apps', and any nested in an if / case (a prod-only block says nothing about the other environments). What is left is what a presence check may read when the app's own list is not a literal one: the variable or attribute defined outside any Oban block.

True when candidate has a duplicate that original did not: the same service twice (plugins: and the top-level service options together, names as Oban renames them), the same tuple twice in one crontab (worker aliases resolved), the same key twice in queues:. Oban refuses a duplicate plugin at boot, so this is the check that catches a splice whose idea of the block was wrong, whatever the reason.

The services that ARE known, even when some option is not readable: {modules, unknown?}. A plugins: held in a variable hides its plugins, but a literal top-level cron: or lifeline: next to it still counts. :error when the file does not parse or has no config :app, Oban.

The text between the brackets of key:'s list, as written (:original) and with comments blanked (:code).

Where key:'s literal list sits inside config :app_name, Oban: the offsets of its [ and its matching ], and the masked copy they index into. {:error, reason} when there is no such list (see reason_text/2).

Replaces comments with spaces (newlines kept). With strings: true, string, sigil, heredoc and character-literal bodies are blanked too (their delimiters stay) — use that before matching brackets or module names; leave it off (the default) when the text of a string matters, e.g. cadence: "daily". With strings: :all the delimiters go as well: nothing of a string is left, so a " or """ standing in column 0 cannot be mistaken for code there.

True when module (a full name, as a string) is used in the code of the app's TOP-LEVEL Oban block, with alias resolved: alias PhoenixKit.Jobs.SweepWorker plus {"*/5 * * * *", SweepWorker} uses PhoenixKit.Jobs.SweepWorker.

True when a nested config :app, Oban comes AFTER the app's top-level block — the order Config evaluates them in, so the nested one can override it.

Whether a refusal means the list is not a literal one (crontab: some_var).

True when the app's block is config :app, Oban, false (or nil).

True when the block says key: false (or nil).

True when candidate equals original apart from the entries: with every list element equal to one of the entries removed from both, the two parse to the same tree (source positions ignored).

Whether a refusal is the host's own choice (nothing to add), not a failure.

Operator-facing explanation of a refusal.

Oban's name for a plugin module: Oban.Plugins.Cron and Oban.Cron are the same service, as are the other renamed plugins.

The services the app's Oban config sets up, as Oban sees them: the modules of plugins: (aliases resolved, renamed), the top-level cron:, pruner:, lifeline:, reindexer: options, and a legacy crontab:. :error when the file does not parse, there is no config :app, Oban, or an option holds something the tree cannot read (a variable) — see known_services/2 for what can be said anyway.

Applies fun to the original text of config :app_name, Oban and splices the result back. The content is returned untouched when there is no literal block.

Types

reason()

@type reason() ::
  :no_block
  | :nested_block
  | :oban_disabled
  | :option_disabled
  | :plugins_disabled
  | :plugins_not_literal
  | :cron_only_nested
  | :cron_disabled
  | :key_not_found
  | :not_literal_list
  | :combined_list
  | :unbalanced
  | :unreadable
  | :empty_list

Functions

append_to_list(content, app_name, key, entries, opts \\ [])

@spec append_to_list(String.t(), atom() | String.t(), atom(), [String.t()], keyword()) ::
  {:ok, String.t()} | {:error, reason()}

Appends entries (each a string, possibly several lines) to the literal list opened by key: inside config :app_name, Oban.

Continuation lines of an entry are re-indented to the list's entry indent, and the line ending (LF or CRLF) of the list's own neighbourhood is kept. Returns {:ok, candidate} — still to be verified by the caller — or {:error, reason}.

Options: allow_empty: false refuses a list with no real element.

block_code(content, app_name, opts \\ [])

@spec block_code(String.t(), atom() | String.t(), keyword()) :: String.t() | nil

config :app_name, Oban's own text with comments blanked (strings: true blanks string bodies too), or nil when there is no literal block to scope to.

comments_only(content)

@spec comments_only(String.t()) :: String.t()

The comments of content and nothing else: code and string bodies are blanked, the # markers and the comment text stay, newlines are kept. A commented-out entry that spans several lines reads as consecutive text.

cron_names(content)

@spec cron_names(String.t()) :: [String.t()]

Every spelling of the Cron plugin the file can use: both module names, plus each short form its alias lines give them (alias Oban.Plugins.Cron, alias Oban.Plugins.{Cron, Pruner}, alias Oban.{Plugins.Cron}, alias Oban.Plugins.Cron, as: C, alias Oban.Plugins + Plugins.Cron).

crontab_entry_used?(content, app_name, module, cadence \\ nil)

@spec crontab_entry_used?(
  String.t(),
  atom() | String.t(),
  String.t(),
  String.t() | nil
) :: boolean()

Whether a top-level Oban config schedules module, optionally with a digest cadence. Reads only worker positions in direct crontab entries, resolving aliases; a module named in a job's arguments does not schedule that worker. Other cron plugins' literal crontabs count too, to avoid scheduling twice.

file_code(content, opts \\ [])

@spec file_code(String.t(), keyword()) :: String.t()

The whole file's code with comments blanked and every config :x, Oban block blanked too — the app's own, other apps', and any nested in an if / case (a prod-only block says nothing about the other environments). What is left is what a presence check may read when the app's own list is not a literal one: the variable or attribute defined outside any Oban block.

introduces_duplicates?(original, candidate, app_name)

@spec introduces_duplicates?(String.t(), String.t(), atom() | String.t()) :: boolean()

True when candidate has a duplicate that original did not: the same service twice (plugins: and the top-level service options together, names as Oban renames them), the same tuple twice in one crontab (worker aliases resolved), the same key twice in queues:. Oban refuses a duplicate plugin at boot, so this is the check that catches a splice whose idea of the block was wrong, whatever the reason.

known_services(content, app_name)

@spec known_services(String.t(), atom() | String.t()) ::
  {[module()], boolean()} | :error

The services that ARE known, even when some option is not readable: {modules, unknown?}. A plugins: held in a variable hides its plugins, but a literal top-level cron: or lifeline: next to it still counts. :error when the file does not parse or has no config :app, Oban.

list_text(content, app_name, key)

@spec list_text(String.t(), atom() | String.t(), atom()) ::
  {:ok, %{original: String.t(), code: String.t()}} | {:error, reason()}

The text between the brackets of key:'s list, as written (:original) and with comments blanked (:code).

locate_list(content, app_name, key)

@spec locate_list(String.t(), atom() | String.t(), atom()) ::
  {:ok,
   %{masked: String.t(), open: non_neg_integer(), close: non_neg_integer()}}
  | {:error, reason()}

Where key:'s literal list sits inside config :app_name, Oban: the offsets of its [ and its matching ], and the masked copy they index into. {:error, reason} when there is no such list (see reason_text/2).

For :crontab only the list of an Oban.Plugins.Cron tuple counts — a crontab: of another plugin (DynamicCron) is not the one entries belong in.

mask(content, opts \\ [])

@spec mask(String.t(), keyword()) :: String.t()

Replaces comments with spaces (newlines kept). With strings: true, string, sigil, heredoc and character-literal bodies are blanked too (their delimiters stay) — use that before matching brackets or module names; leave it off (the default) when the text of a string matters, e.g. cadence: "daily". With strings: :all the delimiters go as well: nothing of a string is left, so a " or """ standing in column 0 cannot be mistaken for code there.

The result has the same byte length as the input and the same newlines.

module_used?(content, app_name, module)

@spec module_used?(String.t(), atom() | String.t(), String.t()) :: boolean()

True when module (a full name, as a string) is used in the code of the app's TOP-LEVEL Oban block, with alias resolved: alias PhoenixKit.Jobs.SweepWorker plus {"*/5 * * * *", SweepWorker} uses PhoenixKit.Jobs.SweepWorker.

nested_block_follows?(content, app_name)

@spec nested_block_follows?(String.t(), atom() | String.t()) :: boolean()

True when a nested config :app, Oban comes AFTER the app's top-level block — the order Config evaluates them in, so the nested one can override it.

not_literal?(reason)

@spec not_literal?(reason()) :: boolean()

Whether a refusal means the list is not a literal one (crontab: some_var).

oban_disabled?(content, app_name)

@spec oban_disabled?(String.t(), atom() | String.t()) :: boolean()

True when the app's block is config :app, Oban, false (or nil).

option_off?(content, app_name, key)

@spec option_off?(String.t(), atom() | String.t(), atom()) :: boolean()

True when the block says key: false (or nil).

preserves_original?(original, candidate, entries)

@spec preserves_original?(String.t(), String.t(), [String.t()]) :: boolean()

True when candidate equals original apart from the entries: with every list element equal to one of the entries removed from both, the two parse to the same tree (source positions ignored).

This is the net under the masking heuristic. A splice that put the comma inside one of the host's strings, or merged two of its elements, still parses — and still contains the new entries — but changes something the host wrote, which this catches. It does not say where the entries went; callers confirm that with their own check on the parsed candidate.

quiet?(reason)

@spec quiet?(reason()) :: boolean()

Whether a refusal is the host's own choice (nothing to add), not a failure.

reason_text(reason, key)

@spec reason_text(reason(), atom()) :: String.t()

Operator-facing explanation of a refusal.

service(module)

@spec service(module()) :: module()

Oban's name for a plugin module: Oban.Plugins.Cron and Oban.Cron are the same service, as are the other renamed plugins.

services(content, app_name)

@spec services(String.t(), atom() | String.t()) :: {:ok, [module()]} | :error

The services the app's Oban config sets up, as Oban sees them: the modules of plugins: (aliases resolved, renamed), the top-level cron:, pruner:, lifeline:, reindexer: options, and a legacy crontab:. :error when the file does not parse, there is no config :app, Oban, or an option holds something the tree cannot read (a variable) — see known_services/2 for what can be said anyway.

update_block(content, app_name, fun)

@spec update_block(String.t(), atom() | String.t(), (String.t() -> String.t())) ::
  String.t()

Applies fun to the original text of config :app_name, Oban and splices the result back. The content is returned untouched when there is no literal block.