Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/core/golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
38 changes: 37 additions & 1 deletion src/core/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
23 changes: 23 additions & 0 deletions src/core/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand All @@ -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 };
Expand Down
73 changes: 73 additions & 0 deletions src/core/tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});
15 changes: 10 additions & 5 deletions src/core/tags.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -30,21 +31,25 @@ 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 };
}

/** Says whether a ready-made image is enough, or a custom build is needed. */
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 };
}
24 changes: 24 additions & 0 deletions src/core/toml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
19 changes: 15 additions & 4 deletions src/core/toml.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, а читает их ваша программа или бот. Если у вас такого брокера нет, пропустите этот шаг.",
Expand Down Expand Up @@ -185,6 +187,8 @@ const en: Record<Key, string> = {
"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.",
Expand Down
6 changes: 5 additions & 1 deletion src/tools/config/ConfigTool.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -139,6 +139,9 @@ export function ConfigTool({ schema }: { schema: Schema }) {
);
})}
</div>
{provider && !inDefaultBuild(provider) && (
<p class="hint">{t("providerFull")}</p>
)}
<MissingPlugin kind="provider" />

<h3>{t("stepFields")}</h3>
Expand Down Expand Up @@ -179,6 +182,7 @@ export function ConfigTool({ schema }: { schema: Schema }) {
>
<MonitorSection
ping={draft.ping}
needsFull={pingNeedsFull(schema)}
onChange={(ping) => setDraft((d) => ({ ...d, ping }))}
/>
</Step>
Expand Down
Loading
Loading