Use Commonlib for self-hosted setup tools

This commit is contained in:
vorotamoroz
2026-07-20 15:47:12 +00:00
parent c49536acc9
commit 44784d4865
15 changed files with 1474 additions and 409 deletions
+20 -27
View File
@@ -1,33 +1,26 @@
#!/bin/bash
if [[ -z "$hostname" ]]; then
echo "ERROR: Hostname missing"
exit 1
fi
if [[ -z "$username" ]]; then
echo "ERROR: Username missing"
set -euo pipefail
if ! command -v deno >/dev/null 2>&1; then
echo "ERROR: Deno is required to run the Commonlib-backed CouchDB provisioning tool." >&2
exit 1
fi
if [[ -z "$password" ]]; then
echo "ERROR: Password missing"
exit 1
fi
if [[ -z "$node" ]]; then
echo "INFO: defaulting to _local"
node=_local
script_url="${provision_script_url:-https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/provision.ts}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"
deno_dependency_options=()
if [[ -n "$script_dir" && -f "$script_dir/provision.ts" ]]; then
script_url="$script_dir/provision.ts"
lockfile="$script_dir/../flyio/deno.lock"
deno_config="$script_dir/../flyio/deno.jsonc"
if [[ -f "$lockfile" && -f "$deno_config" ]]; then
deno_dependency_options+=("--config=$deno_config" --frozen "--lock=$lockfile")
fi
fi
echo "-- Configuring CouchDB by REST APIs... -->"
until (curl -X POST "${hostname}/_cluster_setup" -H "Content-Type: application/json" -d "{\"action\":\"enable_single_node\",\"username\":\"${username}\",\"password\":\"${password}\",\"bind_address\":\"0.0.0.0\",\"port\":5984,\"singlenode\":true}" --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd_auth/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/WWW-Authenticate" -H "Content-Type: application/json" -d '"Basic realm=\"couchdb\""' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/max_http_request_size" -H "Content-Type: application/json" -d '"4294967296"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/couchdb/max_document_size" -H "Content-Type: application/json" -d '"50000000"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/cors/credentials" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/cors/origins" -H "Content-Type: application/json" -d '"app://obsidian.md,capacitor://localhost,http://localhost"' --user "${username}:${password}"); do sleep 5; done
echo "<-- Configuring CouchDB by REST APIs Done!"
exec deno run \
--minimum-dependency-age=0 \
"${deno_dependency_options[@]}" \
--allow-env \
--allow-net \
"$script_url"
+83
View File
@@ -0,0 +1,83 @@
import { provisionCouchDB } from "./provision.ts";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("configures CouchDB and delegates database-version initialisation", async () => {
const requests: Array<{ url: string; method: string; body: string }> = [];
const initialisations: Array<[string, string, string]> = [];
await provisionCouchDB(
{
hostname: "https://couch.example.test/",
username: "alice",
password: "secret",
database: "notes",
retryCount: 1,
retryDelayMs: 0,
},
{
fetch: async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: String(init?.body ?? ""),
});
return new Response("{}", { status: 201 });
},
sleep: async () => {},
initialiseDatabaseVersion: async (...args) => {
initialisations.push(args);
},
},
);
assert(
requests[0].url.endsWith("/_cluster_setup"),
"cluster setup was not first",
);
assert(
requests.some((request) =>
request.url.endsWith("/_config/cors/origins") &&
request.body.includes("app://obsidian.md")
),
"the Obsidian CORS origins were not configured",
);
assert(
requests.at(-1)?.url === "https://couch.example.test/notes",
"the requested database was not created last",
);
assert(
initialisations.length === 1,
"database-version initialisation was not delegated once",
);
assert(
initialisations[0][0] === "https://couch.example.test/notes",
"database-version initialisation used the wrong URL",
);
});
Deno.test("leaves database creation to the client when no database is supplied", async () => {
let initialised = false;
await provisionCouchDB(
{
hostname: "http://127.0.0.1:5984",
username: "admin",
password: "secret",
retryCount: 1,
retryDelayMs: 0,
},
{
fetch: async () => new Response("{}", { status: 200 }),
sleep: async () => {},
initialiseDatabaseVersion: async () => {
initialised = true;
},
},
);
assert(
!initialised,
"database-version initialisation ran without a database",
);
});
+250
View File
@@ -0,0 +1,250 @@
import { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/pouchdb-browser";
import { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/negotiation";
export interface CouchDBProvisioningOptions {
hostname: string;
username: string;
password: string;
node?: string;
database?: string;
origins?: string;
retryCount?: number;
retryDelayMs?: number;
}
interface ProvisioningDependencies {
fetch: typeof fetch;
sleep: (milliseconds: number) => Promise<void>;
initialiseDatabaseVersion: (
databaseURL: string,
username: string,
password: string,
) => Promise<void>;
}
const DEFAULT_ORIGINS =
"app://obsidian.md,capacitor://localhost,http://localhost";
function requireValue(value: string, name: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error(`${name} is required`);
return trimmed;
}
function normaliseHostname(hostname: string): string {
const parsed = new URL(requireValue(hostname, "hostname"));
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
parsed.search = "";
parsed.hash = "";
return parsed.href.replace(/\/$/, "");
}
function validateDatabaseName(database: string): string {
const trimmed = database.trim();
if (!/^[a-z][a-z0-9_$()+-]*$/.test(trimmed)) {
throw new Error(
"database must begin with a lower-case letter and contain only lower-case letters, digits, _, $, (, ), +, or -",
);
}
return trimmed;
}
function basicAuthorisation(username: string, password: string): string {
return `Basic ${btoa(`${username}:${password}`)}`;
}
async function requestWithRetry(
dependencies: ProvisioningDependencies,
label: string,
url: string,
init: RequestInit,
accept: (response: Response, body: string) => boolean,
retryCount: number,
retryDelayMs: number,
): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= retryCount; attempt++) {
try {
const response = await dependencies.fetch(url, init);
const body = await response.text();
if (accept(response, body)) return;
const error = new Error(
`${label} failed with HTTP ${response.status}: ${body}`,
);
if (response.status < 500) throw error;
lastError = error;
} catch (error) {
lastError = error;
if (
error instanceof Error &&
error.message.startsWith(`${label} failed with HTTP 4`)
) {
throw error;
}
}
if (attempt < retryCount) await dependencies.sleep(retryDelayMs);
}
throw lastError instanceof Error
? lastError
: new Error(`${label} failed after ${retryCount} attempts`);
}
export async function initialiseLiveSyncDatabaseVersion(
databaseURL: string,
username: string,
password: string,
): Promise<void> {
const database = new PouchDB(databaseURL, {
adapter: "http",
auth: { username, password },
skip_setup: true,
});
try {
const compatible = await checkRemoteVersion(
database,
async () => false,
);
if (!compatible) {
throw new Error(
"the remote database uses an incompatible LiveSync database version",
);
}
} finally {
await database.close();
}
}
export async function provisionCouchDB(
options: CouchDBProvisioningOptions,
overrides: Partial<ProvisioningDependencies> = {},
): Promise<void> {
const hostname = normaliseHostname(options.hostname);
const username = requireValue(options.username, "username");
const password = requireValue(options.password, "password");
const node = encodeURIComponent(options.node?.trim() || "_local");
const origins = options.origins?.trim() || DEFAULT_ORIGINS;
const retryCount = options.retryCount ?? 12;
const retryDelayMs = options.retryDelayMs ?? 5_000;
if (!Number.isInteger(retryCount) || retryCount < 1) {
throw new Error("retryCount must be a positive integer");
}
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
throw new Error("retryDelayMs must be zero or greater");
}
const dependencies: ProvisioningDependencies = {
fetch,
sleep: (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds)),
initialiseDatabaseVersion: initialiseLiveSyncDatabaseVersion,
...overrides,
};
const headers = {
"Content-Type": "application/json",
Authorization: basicAuthorisation(username, password),
};
const configure = async (
label: string,
path: string,
body: string,
method = "PUT",
accept: (response: Response, body: string) => boolean = (response) =>
response.ok,
) =>
await requestWithRetry(
dependencies,
label,
`${hostname}${path}`,
{ method, headers, body },
accept,
retryCount,
retryDelayMs,
);
await configure(
"single-node cluster setup",
"/_cluster_setup",
JSON.stringify({
action: "enable_single_node",
username,
password,
bind_address: "0.0.0.0",
port: 5984,
singlenode: true,
}),
"POST",
(response, body) =>
response.ok ||
((response.status === 400 || response.status === 409) &&
/already|finished/i.test(body)),
);
const settings: Array<[string, string, string]> = [
["require authenticated HTTP users", "chttpd/require_valid_user", '"true"'],
[
"require authenticated HTTP users for authentication",
"chttpd_auth/require_valid_user",
'"true"',
],
[
"set the HTTP authentication challenge",
"httpd/WWW-Authenticate",
'"Basic realm=\\"couchdb\\""',
],
["enable HTTP CORS", "httpd/enable_cors", '"true"'],
["enable clustered HTTP CORS", "chttpd/enable_cors", '"true"'],
[
"set the maximum HTTP request size",
"chttpd/max_http_request_size",
'"4294967296"',
],
[
"set the maximum document size",
"couchdb/max_document_size",
'"50000000"',
],
["enable CORS credentials", "cors/credentials", '"true"'],
["set allowed CORS origins", "cors/origins", JSON.stringify(origins)],
];
for (const [label, key, body] of settings) {
await configure(label, `/_node/${node}/_config/${key}`, body);
}
if (options.database?.trim()) {
const database = validateDatabaseName(options.database);
const databaseURL = `${hostname}/${encodeURIComponent(database)}`;
await requestWithRetry(
dependencies,
"create database",
databaseURL,
{ method: "PUT", headers },
(response) => response.ok || response.status === 412,
retryCount,
retryDelayMs,
);
await dependencies.initialiseDatabaseVersion(
databaseURL,
username,
password,
);
}
}
function optionalNumber(name: string): number | undefined {
const value = Deno.env.get(name)?.trim();
return value ? Number(value) : undefined;
}
if (import.meta.main) {
await provisionCouchDB({
hostname: Deno.env.get("hostname") ?? "",
username: Deno.env.get("username") ?? "",
password: Deno.env.get("password") ?? "",
node: Deno.env.get("node"),
database: Deno.env.get("database"),
origins: Deno.env.get("origins"),
retryCount: optionalNumber("retry_count"),
retryDelayMs: optionalNumber("retry_delay_ms"),
});
console.log("CouchDB provisioning completed.");
}