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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Add a drive later by mounting it on the host, re-running `arrstack`, and enterin
| --- | --- |
| `arrstack install` | Runs the wizard and installs (default when no subcommand is given) |
| `arrstack doctor` | Three passes: preflight (Docker installed + running, Compose v2 present, 10 GB free on `/` and on `storage_root`, ports 80 and 443 free), `docker compose ps` container state, then an HTTP probe on every service that declares an HTTP healthcheck |
| `arrstack update` | `docker compose pull` then `docker compose up -d`. Configs and media untouched |
| `arrstack update` | Pulls images, recreates containers, health-checks them, and seeds any missing Sonarr/Radarr/Prowlarr admin login. Configs and media untouched |
| `arrstack show-password` | Prints `<install-dir>/admin.txt` (username, password, timestamp) to stdout |
| `arrstack logs <svc>` | Follows `docker compose logs -f <svc>` for one service (`jellyfin`, `sonarr`, etc) |
| `arrstack uninstall` | `docker compose down` only. Preserves `config/`, `state.json`, `admin.txt`, and media. Add `--purge` to also delete `<install-dir>/config/`; `state.json`, `admin.txt`, and media still survive |
Expand Down
12 changes: 8 additions & 4 deletions docs/guide/09-updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ arrstack doctor
## What `arrstack update` does

1. Reads `state.json` to find the install directory and compose file.
2. Runs `docker compose pull` against the generated `docker-compose.yml` so every enabled service fetches its latest image.
3. Runs `docker compose up -d`, which recreates only the containers whose images changed.
4. Prints a hint to run `arrstack doctor` next, which re-probes every service HTTP endpoint.
2. Re-renders `docker-compose.yml` and `Caddyfile` from the current binary's templates, so installer fixes reach an existing install.
3. Runs `docker compose pull` against the generated `docker-compose.yml` so every enabled service fetches its latest image.
4. Runs `docker compose up -d`, which recreates only the containers whose images changed.
5. Health-checks every enabled service and seeds a missing Sonarr, Radarr, or Prowlarr admin user from `admin.txt`. This repairs arr logins left behind by an install that aborted before its wiring steps.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit the documented health checks to HTTP services

This now promises that update health-checks every enabled service, but runHealthChecks explicitly filters to services whose catalog health type is http. 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 👍 / 👎.

6. Prunes dangling images and prints a hint to run `arrstack doctor` next.

Duration: 30 seconds to 5 minutes depending on image sizes and download speed. Health checks, image pruning, and single-service updates are not built in yet, run them with `docker compose` directly if you need them.
Duration: 30 seconds to 5 minutes depending on image sizes and download speed. To update a single service, use `docker compose` directly.

`arrstack update` does not re-run the Jellyfin or Jellyseerr bootstrap. If those were never configured because an install failed early, use `arrstack install --resume`.

## Image tag policy

Expand Down
18 changes: 18 additions & 0 deletions docs/troubleshooting/common-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ sudo -E arrstack install --install-dir /opt/arrstack # or run elevated

---

### admin.txt credentials are rejected after a failed install

**Symptom.** The install failed at `docker compose pull` or the health gate, you started the containers by hand with `docker compose up -d`, and now the username/password in `admin.txt` does not log in to Sonarr, Radarr, Prowlarr, or Jellyfin.

**Cause.** The installer writes `admin.txt` before it touches Docker, but it creates the arr admin users and the Jellyfin admin user during the wiring steps that run *after* the containers are healthy. An install that aborts early never reaches them. The containers still answer health checks, so everything looks up, but no login exists yet. Running `docker compose up -d` by hand does not run the wiring.

**Fix.** Finish the install so the wiring steps run:
```bash
arrstack install --resume
```
On arrstack 1.2.1 and later, `arrstack update` also seeds a missing Sonarr/Radarr/Prowlarr admin user, but it does not run the Jellyfin or Jellyseerr setup. Use `--resume` when Jellyfin login is also broken.

`arrstack doctor` prints an "Install incomplete" warning when `state.json` has no `install_completed_at` timestamp, which is the signature of this state.

**Prevention.** Let the installer finish instead of starting containers manually after a failed step. Re-run `arrstack install --resume` once the underlying image or config problem is fixed.

---

### Jellyseerr EACCES on /app/config/logs

**Symptom.** `jellyseerr | Error: EACCES: permission denied, mkdir '/app/config/logs'` followed by exit 1.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arrstack-installer",
"version": "1.2.0",
"version": "1.2.1",
"type": "module",
"bin": {
"arrstack": "src/cli.ts"
Expand Down
23 changes: 23 additions & 0 deletions src/state/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep credential parsing on the current line

When admin.txt contains an empty password field, which is one of the incomplete-file cases this helper promises to reject, \s* consumes the newline and (.+) captures the following generated: line as the password. The update recovery path can then seed the arr account with that unintended value while the file still shows no usable password. Match only horizontal whitespace after the colon so an empty field returns null.

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`;
Expand Down
14 changes: 14 additions & 0 deletions src/usecase/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ export async function runDoctor(installDir: string): Promise<void> {

let allOk = true;

// A failed install (most often an image pull failure) writes state.json
// before the docker steps but never reaches the wiring steps. Containers can
// still be started by hand and answer health checks while no arr admin user
// exists, so admin.txt credentials fail to log in. Surface that here.
if (!state.install_completed_at) {
section("Install state");
allOk = false;
fail(
"Install incomplete",
"state.json has no install_completed_at timestamp",
"Run: arrstack install --resume to finish service wiring (arr logins, Jellyfin setup)",
);
}

// --- Preflight checks ---
section("System checks");
// Post-install: skip 80/443-free checks. Our own Caddy is supposed to be
Expand Down
10 changes: 3 additions & 7 deletions src/usecase/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
repushProwlarrIndexersToApps,
} from "../wiring/prowlarr-flaresolverr.js";
import { linkJellyseerrToArrs } from "../wiring/jellyseerr-arr.js";
import { seedArrAdmin } from "../wiring/arr-auth.js";
import { seedArrAdmin, ARR_AUTH_TARGETS } from "../wiring/arr-auth.js";
import { configureTrailarr } from "../wiring/trailarr.js";
import { configureArr } from "../wiring/sonarr-radarr.js";
import { configureQbit } from "../wiring/qbittorrent.js";
Expand Down Expand Up @@ -569,19 +569,15 @@ export async function runInstall(
// Step 12c2: Seed admin credentials on each arr app. Sonarr/Radarr/Prowlarr
// store users in SQLite, so the config.xml preset alone doesn't pre-create
// an account. PUT /api/v{N}/config/host with username/password does.
for (const { id, port, version } of [
{ id: "prowlarr", port: 9696, version: "v1" as const },
{ id: "sonarr", port: 8989, version: "v3" as const },
{ id: "radarr", port: 7878, version: "v3" as const },
]) {
for (const { id, port, apiVersion } of ARR_AUTH_TARGETS) {
if (!has(id)) continue;
await runStep(`Seeding ${id} admin credentials`, onStep, log, async () => {
await seedArrAdmin({
apiKey: apiKeys[id] ?? "",
username: state.admin.username,
password: adminPassword,
baseUrl: `http://localhost:${port}`,
apiVersion: version,
apiVersion,
});
});
}
Expand Down
55 changes: 54 additions & 1 deletion src/usecase/update.ts
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";
Expand All @@ -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;
}

Expand All @@ -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,
};

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict login recovery to interrupted installs

On every update of a completed installation, this unconditionally runs the recovery path, and seedArrAdmin interprets blank username/password fields as an aborted install. A user who intentionally configured an arr service for external or disabled authentication and cleared those fields will therefore have the next update overwrite that configuration with forms authentication and the credentials from admin.txt. Gate this recovery on the existing !state.install_completed_at signal (or otherwise distinguish an interrupted install) so updates preserve intentional authentication settings.

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.
Expand Down Expand Up @@ -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[] = [];
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = "1.2.0";
export const VERSION = "1.2.1";
25 changes: 20 additions & 5 deletions src/wiring/arr-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@ export interface ArrAuthOptions {
apiVersion: "v1" | "v3";
}

// Shared by install and update so both know every arr app whose login is
// seeded through the API. Ports match the catalog's adminPort values.
export const ARR_AUTH_TARGETS = [
{ id: "prowlarr", port: 9696, apiVersion: "v1" },
{ id: "sonarr", port: 8989, apiVersion: "v3" },
{ id: "radarr", port: 7878, apiVersion: "v3" },
] as const;

export type ArrAuthTarget = (typeof ARR_AUTH_TARGETS)[number];

async function readBody(res: Response): Promise<string> {
try { return (await res.text()).slice(0, 400); } catch { return ""; }
}

export async function seedArrAdmin(opts: ArrAuthOptions): Promise<void> {
export type SeedArrAdminResult = "seeded" | "exists";

export async function seedArrAdmin(opts: ArrAuthOptions): Promise<SeedArrAdminResult> {
const hostUrl = `${opts.baseUrl}/api/${opts.apiVersion}/config/host`;

const getRes = await fetchWithRetry(hostUrl, {
Expand All @@ -31,10 +43,12 @@ export async function seedArrAdmin(opts: ArrAuthOptions): Promise<void> {
}
const current = (await getRes.json()) as Record<string, unknown>;

// If the admin user is already set, don't re-PUT — some arr versions reject
// identical password updates as "Must match" validation noise on reconfigure.
if (current.username === opts.username && current.password) {
return;
// Any existing user wins. Do not overwrite a username/password the user
// changed in the UI, even when it differs from the installer default. Only a
// genuinely unseeded instance (the state left behind when an install aborts
// before the wiring steps) gets credentials written.
if (current.username && current.password) {
return "exists";
}

const updated = {
Expand All @@ -58,4 +72,5 @@ export async function seedArrAdmin(opts: ArrAuthOptions): Promise<void> {
`PUT ${hostUrl} failed: HTTP ${putRes.status}\n${await readBody(putRes)}`,
);
}
return "seeded";
}
20 changes: 20 additions & 0 deletions tests/state/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
writeState,
adminTxtUnreadable,
adminTxtGuardMessage,
readAdminCredentials,
} from "../../src/state/store.js";
import type { State } from "../../src/state/schema.js";

Expand Down Expand Up @@ -102,6 +103,25 @@ test("adminTxtUnreadable: true when admin.txt exists but can't be read", () => {
chmodSync(p, 0o600); // let afterEach clean up
});

test("readAdminCredentials parses username and password from admin.txt", () => {
mkdirSync(TMP, { recursive: true });
writeFileSync(
join(TMP, "admin.txt"),
"username: admin\npassword: UXsr-v3H8-JpMZ-gqCm\ngenerated: 2026-06-24T23:31:10.664Z\n",
);
expect(readAdminCredentials(TMP)).toEqual({
username: "admin",
password: "UXsr-v3H8-JpMZ-gqCm",
});
});

test("readAdminCredentials returns null when admin.txt is absent or incomplete", () => {
mkdirSync(TMP, { recursive: true });
expect(readAdminCredentials(TMP)).toBeNull();
writeFileSync(join(TMP, "admin.txt"), "username: admin\n");
expect(readAdminCredentials(TMP)).toBeNull();
});

test("adminTxtGuardMessage: null when admin.txt is absent or readable", () => {
mkdirSync(TMP, { recursive: true });
expect(adminTxtGuardMessage(TMP)).toBeNull();
Expand Down
59 changes: 59 additions & 0 deletions tests/usecase/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function makeDeps(overrides: Partial<UpdateDeps> = {}): { deps: UpdateDeps; call
return { ok: true, message: "Total reclaimed space: 0B" };
},
checkHealth: async () => true,
seedArrAdmin: async () => "exists",
now: Date.now,
...overrides,
};
Expand Down Expand Up @@ -212,6 +213,64 @@ test("runUpdate attempts the Bazarr Provider Hub wiring when bazarr is enabled",
expect(logTxt.toLowerCase()).toContain("provider hub");
});

test("runUpdate seeds arr admin credentials from admin.txt after a failed install", async () => {
// Regression for #6: an install that aborts at docker compose pull leaves the
// arr containers running with no user in SQLite, so the admin.txt password is
// rejected. update must seed it on the next run.
const dir = makeInstallDir();
writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: s3cret-pw\ngenerated: now\n");
const calls: any[] = [];
const { deps } = makeDeps({
seedArrAdmin: async (opts) => {
calls.push(opts);
return "seeded";
},
});

await silently(() => runUpdate(dir, deps));

expect(calls).toHaveLength(1);
expect(calls[0].username).toBe("admin");
expect(calls[0].password).toBe("s3cret-pw");
expect(calls[0].baseUrl).toBe("http://localhost:8989");
expect(calls[0].apiVersion).toBe("v3");
const logTxt = readFileSync(join(dir, "update.log"), "utf-8");
expect(logTxt).toContain("sonarr: seeded admin credentials");
});

test("runUpdate skips arr admin seeding when admin.txt is missing", async () => {
const dir = makeInstallDir();
let called = false;
const { deps } = makeDeps({
seedArrAdmin: async () => {
called = true;
return "seeded";
},
});

await silently(() => runUpdate(dir, deps));

expect(called).toBe(false);
const logTxt = readFileSync(join(dir, "update.log"), "utf-8");
expect(logTxt).toContain("admin.txt missing or unreadable");
});

test("runUpdate logs a warning and continues when arr admin seeding fails", async () => {
const dir = makeInstallDir();
writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: pw\ngenerated: now\n");
const { deps } = makeDeps({
seedArrAdmin: async () => {
throw new Error("HTTP 500");
},
});

await silently(() => runUpdate(dir, deps));

const logTxt = readFileSync(join(dir, "update.log"), "utf-8");
expect(logTxt).toContain("warning: could not verify sonarr admin login");
expect(logTxt).toContain("done.");
});

test("runUpdate summary reports changed service images", async () => {
const dir = makeInstallDir();
let captureCount = 0;
Expand Down
Loading
Loading