diff --git a/docs/design.md b/docs/design.md index 96902fe..dd07e1f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -16,8 +16,12 @@ never come here. So: environment or a file) and the tool also produces the matching `.env` template. - A custom build is offered only when it is needed. If the config uses only the default plugins the helper says "no build needed, use the official image"; if it - needs `ping` or notifiers, it points to the `-full` image; only a wish for a - minimal binary leads to a custom build. + needs a plugin that the ready-made image lacks (a notifier or a heavy provider such + as rfc2136, yandexcloud or namecheap, which the schema marks with + `in_default_build: false`), it points to the `-full` image; only a wish for a + minimal binary leads to a custom build. Releases before the `full` tag, whose + schema has no `in_default_build`, are read the old way: every retriever and + provider is in the ready-made image, and `ping_url` and the notifiers are not. ## The two tools @@ -56,7 +60,8 @@ dnspatch publishes `schema.json` as an asset of every release since `v0.4.4-rc.1 `docs/development/writing-a-plugin.md`; the types are in `src/core/schema.ts`: - plugin: `kind` (retriever, provider, notifier), `name` (the `type` in a config), - `build_tags` (any one compiles the plugin in), `fields`; + `build_tags` (any one compiles the plugin in), `in_default_build` (a build without + tags has it; absent in releases before the `full` tag), `fields`; - field: `name` (`table.key` for a nested table), `type`, `required`, `required_if`, `default`, `example`, `description`, `secret`. @@ -150,15 +155,18 @@ GET {api}/builds/{id} -> {status: queued|running|done|failed, url?, sha256?, e for people without a broker: with `ping_url` dnspatch calls a link after every cycle and a service such as Healthchecks.io or Uptime Kuma writes to the user when the calls stop. The URL holds a secret key, so the config says `ping_url = "${PING_URL}"` and the value - goes to `.env`. It is written in the `[[instance]]` table itself. The `ping` tag lives - only in the full flavour, so `compose.yml` uses `latest-full`. + goes to `.env`. It is written in the `[[instance]]` table itself. Releases before the + `full` tag have the ping only in the full flavour, so for them `compose.yml` uses + `latest-full`; since then it is in every build and the note is not shown. - **Notifications** are an optional folded block that says plainly what they are: dnspatch publishes events to a message broker (MQTT, RabbitMQ, Redis) and the user's own program reads them; there is no Telegram or email in dnspatch. One notifier per type, declared in place in the instance. The broker address carries credentials, so it is a secret and goes to the environment. Events are checkboxes; `status` alone is the default and is not written. Choosing any notifier switches `compose.yml` to the - `latest-full` image, because notifiers are only in the full flavour. + `latest-full` image, because notifiers are only in the full flavour; so does a + provider that the schema marks as outside the default build, with a note under the + provider cards. ## Repository layout diff --git a/src/core/golden.test.ts b/src/core/golden.test.ts index 3d10cf1..4f18349 100644 --- a/src/core/golden.test.ts +++ b/src/core/golden.test.ts @@ -21,8 +21,9 @@ import { } from "./toml"; // Checks the generated configs against the real thing: set DNSPATCH_BIN to a -// dnspatch binary of the same release as the schema in public/schema, built with -// the tags "ping,notify_all" so that notifiers are accepted. +// dnspatch binary of the same release as the schema in public/schema, with every +// plugin compiled in so that notifiers are accepted: -tags full, or +// -tags "ping,notify_all" for a release that comes before the full tag. const bin = process.env.DNSPATCH_BIN; const tag = process.env.DNSPATCH_TAG ?? "v0.4.4-rc.1"; const schemaPath = join( diff --git a/src/core/schema.test.ts b/src/core/schema.test.ts index 5187e6e..9329f33 100644 --- a/src/core/schema.test.ts +++ b/src/core/schema.test.ts @@ -1,5 +1,11 @@ import { expect, test } from "vitest"; -import { findPlugin, isSchema, type Schema } from "./schema"; +import { + findPlugin, + inDefaultBuild, + isSchema, + pingNeedsFull, + type Schema, +} from "./schema"; const sample: Schema = { schema_version: 1, @@ -30,3 +36,33 @@ test("findPlugin matches kind and name", () => { expect(findPlugin(sample, "provider", "cloudflare")?.name).toBe("cloudflare"); expect(findPlugin(sample, "retriever", "cloudflare")).toBeUndefined(); }); + +test("an older schema has every retriever and provider in the default build and ping only in the full one", () => { + expect(sample.plugins.map(inDefaultBuild)).toEqual([true, true]); + expect( + inDefaultBuild({ + kind: "notifier", + name: "mqtt", + build_tags: ["mqtt"], + fields: [], + }), + ).toBe(false); + expect(pingNeedsFull(sample)).toBe(true); +}); + +test("a schema that says what the default build has puts ping in every build", () => { + const current: Schema = { + schema_version: 1, + plugins: [ + { + kind: "provider", + name: "rfc2136", + build_tags: ["rfc2136", "full"], + in_default_build: false, + fields: [], + }, + ], + }; + expect(current.plugins.map(inDefaultBuild)).toEqual([false]); + expect(pingNeedsFull(current)).toBe(false); +}); diff --git a/src/core/schema.ts b/src/core/schema.ts index eacb347..ad485bf 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -30,6 +30,11 @@ export interface Plugin { name: string; /** Any one of them compiles the plugin in. */ build_tags: string[]; + /** + * Whether a build made without tags has the plugin. Releases that come before + * the `full` tag do not say; see `inDefaultBuild`. + */ + in_default_build?: boolean; fields: Field[]; } @@ -43,6 +48,24 @@ export interface SchemaIndex { versions: { tag: string; prerelease: boolean }[]; } +/** + * Says whether the ready-made image has the plugin. Older releases leave + * `in_default_build` out and had every retriever and provider in the ready-made + * image and every notifier in the -full one. + */ +export function inDefaultBuild(plugin: Plugin): boolean { + return plugin.in_default_build ?? plugin.kind !== "notifier"; +} + +/** + * Says whether `ping_url` needs the -full image. It did in the releases that + * come before the `full` tag, which are the ones whose schema has no + * `in_default_build`; since then the hook is in every build. + */ +export function pingNeedsFull(schema: Schema): boolean { + return !schema.plugins.some((p) => p.in_default_build !== undefined); +} + export function isSchema(value: unknown): value is Schema { if (typeof value !== "object" || value === null) return false; const v = value as { schema_version?: unknown; plugins?: unknown }; diff --git a/src/core/tags.test.ts b/src/core/tags.test.ts index 7bc3ded..58399ea 100644 --- a/src/core/tags.test.ts +++ b/src/core/tags.test.ts @@ -76,3 +76,76 @@ test("ping adds its tag", () => { "ping", ]); }); + +// A release since the `full` tag: the schema says what a build without tags has. +const current: Schema = { + schema_version: 1, + plugins: [ + { + kind: "retriever", + name: "ipify", + build_tags: ["ipify", "full"], + in_default_build: true, + fields: [], + }, + { + kind: "provider", + name: "cloudflare", + build_tags: ["cloudflare", "full"], + in_default_build: true, + fields: [], + }, + { + kind: "provider", + name: "rfc2136", + build_tags: ["rfc2136", "full"], + in_default_build: false, + fields: [], + }, + { + kind: "notifier", + name: "mqtt", + build_tags: ["mqtt", "full"], + in_default_build: false, + fields: [], + }, + ], +}; + +test("a plugin that the default build leaves out points to the full flavour", () => { + for (const [kind, name] of [ + ["provider", "rfc2136"], + ["notifier", "mqtt"], + ] as const) { + expect( + advise(current, { plugins: [{ kind, name }], ping: false }).build, + ).toBe("full"); + } + expect( + advise(current, { + plugins: [{ kind: "provider", name: "cloudflare" }], + ping: false, + }).build, + ).toBe("official"); +}); + +test("ping needs neither the full flavour nor a tag since the release that has it everywhere", () => { + const advice = advise(current, { plugins: [], ping: true }); + expect(advice.build).toBe("official"); + expect(advice.tags).toEqual(["dnspatch_none"]); +}); + +test("the tag of a plugin outside the default build is named like the plugin", () => { + const usage = { + plugins: [ + { kind: "provider", name: "rfc2136" }, + { kind: "retriever", name: "ipify" }, + ], + ping: false, + } as const; + expect(minimalTags(current, usage).tags).toEqual([ + "dnspatch_none", + "ipify", + "rfc2136", + ]); +}); diff --git a/src/core/tags.ts b/src/core/tags.ts index b32cc11..e99205f 100644 --- a/src/core/tags.ts +++ b/src/core/tags.ts @@ -1,11 +1,12 @@ import type { Schema } from "./schema"; -import { findPlugin } from "./schema"; +import { findPlugin, inDefaultBuild, pingNeedsFull } from "./schema"; import type { Usage } from "./toml"; export interface Advice { /** * official: the ready-made image or binary has everything. - * full: monitoring pings or notifiers are needed, which the -full flavour has. + * full: a plugin is needed that only the -full flavour has: a notifier or one + * of the heavy providers, or, in older releases, the monitoring ping. */ build: "official" | "full"; /** Tags of the smallest build that holds the plugins, for those who want a small binary. */ @@ -30,14 +31,14 @@ export function minimalTags( unknown.push(`${used.kind}/${used.name}`); continue; } - // The tag named like the plugin; the others (…_all) bring in whole groups. + // The tag named like the plugin; the others bring in more than that one. named.add( plugin.build_tags.find((t) => t === plugin.name) ?? plugin.build_tags[0] ?? plugin.name, ); } - if (usage.ping) named.add("ping"); + if (usage.ping && pingNeedsFull(schema)) named.add("ping"); return { tags: ["dnspatch_none", ...[...named].sort()], unknown }; } @@ -45,6 +46,10 @@ export function minimalTags( export function advise(schema: Schema, usage: Usage): Advice { const { tags, unknown } = minimalTags(schema, usage); const needsFull = - usage.ping || usage.plugins.some((p) => p.kind === "notifier"); + (usage.ping && pingNeedsFull(schema)) || + usage.plugins.some((used) => { + const plugin = findPlugin(schema, used.kind, used.name); + return plugin !== undefined && !inDefaultBuild(plugin); + }); return { build: needsFull ? "full" : "official", tags, unknown }; } diff --git a/src/core/toml.test.ts b/src/core/toml.test.ts index a9991a9..6faa52e 100644 --- a/src/core/toml.test.ts +++ b/src/core/toml.test.ts @@ -242,3 +242,27 @@ ref = "main" test("usageOf throws on invalid TOML", () => { expect(() => usageOf("not = = toml")).toThrow(); }); + +// A release since the `full` tag: the schema says what a build without tags has. +const current: Schema = { + ...schema, + plugins: [ + ...schema.plugins.map((p) => ({ ...p, in_default_build: true })), + { + kind: "provider", + name: "rfc2136", + build_tags: ["rfc2136", "full"], + in_default_build: false, + fields: [], + }, + ], +}; + +test("a provider that the default build leaves out needs the full image, ping does not", () => { + expect(generate(draft, current).full).toBe(false); + expect(generate({ ...draft, ping: true }, current).full).toBe(false); + expect( + generate({ ...draft, provider: { type: "rfc2136", values: {} } }, current) + .full, + ).toBe(true); +}); diff --git a/src/core/toml.ts b/src/core/toml.ts index 0fe1fad..79f4ef8 100644 --- a/src/core/toml.ts +++ b/src/core/toml.ts @@ -1,6 +1,6 @@ import { parse } from "smol-toml"; import type { Field, Kind, Plugin, Schema } from "./schema"; -import { findPlugin } from "./schema"; +import { findPlugin, inDefaultBuild, pingNeedsFull } from "./schema"; /** One plugin chosen in the constructor with the values typed for its fields. */ export interface PluginChoice { @@ -39,7 +39,7 @@ export interface ConfigDraft { export const pingEnv = "PING_URL"; export interface Generated { - /** Notifiers and pings exist only in the -full flavour, so the config needs that image. */ + /** Some of its plugins exist only in the -full flavour, so the config needs that image. */ full: boolean; toml: string; /** Template of the environment file that holds the secrets. */ @@ -211,8 +211,19 @@ export function generate( const env = out.env.length ? `${["# Secrets for dnspatch. Fill in the values and keep this file private.", ...out.env].join("\n")}\n` : ""; + const chosen: [Kind, PluginChoice][] = [ + ...draft.retrievers.map((r): [Kind, PluginChoice] => ["retriever", r]), + ["provider", draft.provider], + ...draft.notifiers.map((n): [Kind, PluginChoice] => ["notifier", n]), + ]; + const full = + (draft.ping && pingNeedsFull(schema)) || + chosen.some(([kind, choice]) => { + const plugin = findPlugin(schema, kind, choice.type); + return plugin !== undefined && !inDefaultBuild(plugin); + }); return { - full: draft.notifiers.length > 0 || draft.ping, + full, toml, env, missing: out.missing, @@ -227,7 +238,7 @@ export interface UsedPlugin { export interface Usage { plugins: readonly UsedPlugin[]; - /** An instance sets ping_url, which needs the "ping" build tag. */ + /** An instance sets ping_url, which older releases have only in the -full flavour. */ ping: boolean; } diff --git a/src/i18n.ts b/src/i18n.ts index 127a3fa..b901906 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -40,6 +40,8 @@ const ru = { "Создайте проверку в Healthchecks.io (или Push-монитор в Uptime Kuma) и скопируйте её адрес для пинга. Впишите его в файл .env: ссылка содержит секретный ключ, поэтому в файл настроек она не попадает.", monitorFull: "Для мониторинга нужен образ latest-full: он уже подставлен в compose.yml.", + providerFull: + "Этого провайдера нет в обычном образе, нужен образ latest-full: он уже подставлен в compose.yml.", stepNotify: "4. Уведомления (необязательно)", notifyIntro: "dnspatch сам ничего не отправляет в Telegram или на почту. Он публикует события в брокер сообщений: MQTT (он есть, например, в Home Assistant), RabbitMQ или Redis, а читает их ваша программа или бот. Если у вас такого брокера нет, пропустите этот шаг.", @@ -185,6 +187,8 @@ const en: Record = { "Create a check in Healthchecks.io (or a Push monitor in Uptime Kuma) and copy its ping address. Put it in the .env file: the link holds a secret key, so it stays out of the settings file.", monitorFull: "Monitoring needs the latest-full image: it is already set in compose.yml.", + providerFull: + "This provider is not in the regular image, it needs the latest-full image: it is already set in compose.yml.", stepNotify: "4. Notifications (optional)", notifyIntro: "dnspatch does not send anything to Telegram or email by itself. It publishes events to a message broker: MQTT (Home Assistant has one, for example), RabbitMQ or Redis, and your own program or bot reads them. If you have no such broker, skip this step.", diff --git a/src/tools/config/ConfigTool.tsx b/src/tools/config/ConfigTool.tsx index 5f8adc2..6c42634 100644 --- a/src/tools/config/ConfigTool.tsx +++ b/src/tools/config/ConfigTool.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "preact/hooks"; import { featured, fileSecrets, pluginInfo } from "../../content/plugins"; import type { Schema } from "../../core/schema"; -import { findPlugin } from "../../core/schema"; +import { findPlugin, inDefaultBuild, pingNeedsFull } from "../../core/schema"; import { type ConfigDraft, type Generated, @@ -139,6 +139,9 @@ export function ConfigTool({ schema }: { schema: Schema }) { ); })} + {provider && !inDefaultBuild(provider) && ( +

{t("providerFull")}

+ )}

{t("stepFields")}

@@ -179,6 +182,7 @@ export function ConfigTool({ schema }: { schema: Schema }) { > setDraft((d) => ({ ...d, ping }))} /> diff --git a/src/tools/config/MonitorSection.tsx b/src/tools/config/MonitorSection.tsx index 04f9118..aec72bf 100644 --- a/src/tools/config/MonitorSection.tsx +++ b/src/tools/config/MonitorSection.tsx @@ -3,11 +3,13 @@ import { t } from "../../i18n"; interface Props { ping: boolean; + /** The release has the ping only in the -full image. */ + needsFull: boolean; onChange: (next: boolean) => void; } /** Optional block: a monitoring service that raises the alarm when dnspatch stops reporting. */ -export function MonitorSection({ ping, onChange }: Props) { +export function MonitorSection({ ping, needsFull, onChange }: Props) { return ( <>

{t("monitorIntro")}

@@ -23,7 +25,7 @@ export function MonitorSection({ ping, onChange }: Props) {

{t("monitorWhere")}

{t("secretField", { name: pingEnv })}

-

{t("monitorFull")}

+ {needsFull &&

{t("monitorFull")}

}
)}