Repository navigation
fix: recover arr logins after an aborted install (v1.2.1) #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,29 @@ export function adminTxtGuardMessage(installDir: string): string | null { | |
| ); | ||
| } | ||
|
|
||
| export interface AdminCredentials { | ||
| username: string; | ||
| password: string; | ||
| } | ||
|
|
||
| /** | ||
| * Read the credentials the installer wrote at install time. Returns null when | ||
| * admin.txt is absent, unreadable, or incomplete, so callers can skip recovery | ||
| * work instead of crashing or rotating the password. | ||
| */ | ||
| export function readAdminCredentials(installDir: string): AdminCredentials | null { | ||
| const p = join(installDir, "admin.txt"); | ||
| try { | ||
| const text = readFileSync(p, "utf-8"); | ||
| const username = text.match(/^username:\s*(.+)$/m)?.[1]?.trim(); | ||
| const password = text.match(/^password:\s*(.+)$/m)?.[1]?.trim(); | ||
|
Comment on lines
+65
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| if (!username || !password) return null; | ||
| return { username, password }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export function writeState(installDir: string, state: State): void { | ||
| const statePath = join(installDir, STATE_FILE); | ||
| const tmpPath = `${statePath}.tmp`; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { existsSync, openSync, writeSync, closeSync, fsyncSync, writeFileSync, readFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { readState, writeState } from "../state/store.js"; | ||
| import { readState, writeState, readAdminCredentials } from "../state/store.js"; | ||
| import { exec } from "../lib/exec.js"; | ||
| import { getServicesByIds } from "../catalog/index.js"; | ||
| import type { Service } from "../catalog/schema.js"; | ||
|
|
@@ -9,12 +9,19 @@ import { renderCaddyfile } from "../renderer/caddy.js"; | |
| import { renderCompose } from "../renderer/compose.js"; | ||
| import { resolveVpnWireguardKey } from "../wiring/nordvpn.js"; | ||
| import { configureBazarrProviderHub } from "../wiring/bazarr-providerhub.js"; | ||
| import { | ||
| seedArrAdmin, | ||
| ARR_AUTH_TARGETS, | ||
| type ArrAuthOptions, | ||
| type SeedArrAdminResult, | ||
| } from "../wiring/arr-auth.js"; | ||
|
|
||
| export interface UpdateDeps { | ||
| runStreaming: (argv: string[], onLine: (line: string) => void) => Promise<{ ok: boolean; code: number | null }>; | ||
| captureImages: (composeFile: string) => Promise<Map<string, string>>; | ||
| pruneImages: () => Promise<{ ok: boolean; message: string }>; | ||
| checkHealth: (url: string, timeoutMs: number) => Promise<boolean>; | ||
| seedArrAdmin: (opts: ArrAuthOptions) => Promise<SeedArrAdminResult>; | ||
| now: () => number; | ||
| } | ||
|
|
||
|
|
@@ -41,6 +48,7 @@ export async function runUpdate(installDir: string, deps?: Partial<UpdateDeps>): | |
| captureImages: deps?.captureImages ?? defaultCaptureImages, | ||
| pruneImages: deps?.pruneImages ?? defaultPruneImages, | ||
| checkHealth: deps?.checkHealth ?? defaultCheckHealth, | ||
| seedArrAdmin: deps?.seedArrAdmin ?? seedArrAdmin, | ||
| now: deps?.now ?? Date.now, | ||
| }; | ||
|
|
||
|
|
@@ -126,6 +134,14 @@ export async function runUpdate(installDir: string, deps?: Partial<UpdateDeps>): | |
| await runHealthChecks(services, d.checkHealth, logAndEcho); | ||
| phaseTimes["health"] = d.now() - healthStart; | ||
|
|
||
| // Recovery: an install that failed before the wiring steps (image pull | ||
| // error, health gate timeout) leaves the arr containers running but with no | ||
| // admin user, so the password in admin.txt cannot log in. Seed any arr that | ||
| // has no user yet. Best-effort: never fail an update over this. | ||
| const seedStart = d.now(); | ||
| await reseedArrAdmins(installDir, state, d.seedArrAdmin, logAndEcho); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On every update of a completed installation, this unconditionally runs the recovery path, and Useful? React with 👍 / 👎. |
||
| phaseTimes["seed-arr-admins"] = d.now() - seedStart; | ||
|
|
||
| // Provider Hub: keep the default subtitle providers installed + enabled on | ||
| // existing installs too (the built-in providers are being retired). | ||
| // Best-effort: a failure here logs a warning and never fails the update. | ||
|
|
@@ -289,6 +305,43 @@ async function runHealthChecks( | |
| ); | ||
| } | ||
|
|
||
| async function reseedArrAdmins( | ||
| installDir: string, | ||
| state: State, | ||
| seed: UpdateDeps["seedArrAdmin"], | ||
| log: (line: string) => void, | ||
| ): Promise<void> { | ||
| const targets = ARR_AUTH_TARGETS.filter((t) => state.services_enabled.includes(t.id)); | ||
| if (targets.length === 0) return; | ||
|
|
||
| const creds = readAdminCredentials(installDir); | ||
| if (!creds) { | ||
| log("[update] admin.txt missing or unreadable; skipping arr admin recovery"); | ||
| return; | ||
| } | ||
|
|
||
| for (const target of targets) { | ||
| try { | ||
| const result = await seed({ | ||
| apiKey: state.api_keys[target.id] ?? "", | ||
| username: state.admin.username, | ||
| password: creds.password, | ||
| baseUrl: `http://localhost:${target.port}`, | ||
| apiVersion: target.apiVersion, | ||
| }); | ||
| log( | ||
| `[update] ${target.id}: ${ | ||
| result === "seeded" ? "seeded admin credentials" : "admin login already configured" | ||
| }`, | ||
| ); | ||
| } catch (err) { | ||
| log( | ||
| `[update] warning: could not verify ${target.id} admin login: ${(err as Error).message}`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function diffImages(before: Map<string, string>, after: Map<string, string>): UpdateSummary[] { | ||
| const ids = new Set<string>([...before.keys(), ...after.keys()]); | ||
| const out: UpdateSummary[] = []; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| export const VERSION = "1.2.0"; | ||
| export const VERSION = "1.2.1"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This now promises that update health-checks every enabled service, but
runHealthChecksexplicitly filters to services whose catalog health type ishttp. Enabled services such as Caddy and Recyclarr are therefore not checked, so an update may report completion even when one of them is unavailable while users are told it was verified. Either add checks for the remaining service health types or document the HTTP-only scope.Useful? React with 👍 / 👎.