docs(slice1): tasks.mosaicstack.dev on Vikunja 2.7.0, runbook sections 2-3 by Sage (row 35, #1517, lead decision 75)
Jason asked for tasks.mosaicstack.dev operational with agents configured in it. Sage backed it up, tested the restore, pinned 2.7.0 (infra PR #325) and ran sections 2 and 3 through the API: owner id 4, svc-mosaic-stack id 5, project 32, bots 6-10, scoped tokens to 2027-01-07. Probes pass. Scope is Mosaic Stack only (Mos relaying Jason, his Q9 open). OIDC has been broken since a 2026-04-27 NetworkPolicy; infra PR #326 is with ops-01. BUILD-LOG also records the cert renewal fix and the last-applied annotation slip. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"at": "2026-10-09T22:45:09.920Z",
|
||||
"origin": "https://tasks.mosaicstack.dev",
|
||||
"owner": {
|
||||
"username": "mosaic-stack-owner",
|
||||
"id": 4
|
||||
},
|
||||
"svc": {
|
||||
"username": "svc-mosaic-stack",
|
||||
"id": 5
|
||||
},
|
||||
"project": {
|
||||
"id": 32,
|
||||
"kanbanView": 152,
|
||||
"buckets": {
|
||||
"in-progress": 238,
|
||||
"todo": 237,
|
||||
"done": 239,
|
||||
"in-review": 240,
|
||||
"blocked": 241
|
||||
},
|
||||
"doneBucket": 239,
|
||||
"defaultBucket": 237,
|
||||
"bucketConfigMode": "manual"
|
||||
},
|
||||
"bots": {
|
||||
"pm": {
|
||||
"id": 6,
|
||||
"username": "bot-mosaic-stack-pm",
|
||||
"permission": 1,
|
||||
"tokenId": 2,
|
||||
"expires": "2027-01-07T00:00:00Z",
|
||||
"startsTk": true
|
||||
},
|
||||
"cto": {
|
||||
"id": 7,
|
||||
"username": "bot-mosaic-stack-cto",
|
||||
"permission": 1,
|
||||
"tokenId": 3,
|
||||
"expires": "2027-01-07T00:00:00Z",
|
||||
"startsTk": true
|
||||
},
|
||||
"coder": {
|
||||
"id": 8,
|
||||
"username": "bot-mosaic-stack-coder",
|
||||
"permission": 1,
|
||||
"tokenId": 4,
|
||||
"expires": "2027-01-07T00:00:00Z",
|
||||
"startsTk": true
|
||||
},
|
||||
"reviewer": {
|
||||
"id": 9,
|
||||
"username": "bot-mosaic-stack-reviewer",
|
||||
"permission": 1,
|
||||
"tokenId": 5,
|
||||
"expires": "2027-01-07T00:00:00Z",
|
||||
"startsTk": true
|
||||
},
|
||||
"sync": {
|
||||
"id": 10,
|
||||
"username": "bot-mosaic-stack-sync",
|
||||
"permission": 0,
|
||||
"tokenId": 6,
|
||||
"expires": "2027-01-07T00:00:00Z",
|
||||
"startsTk": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
Sage, 2026-10-09 (UTC). tasks.mosaicstack.dev: upgrade to Vikunja 2.7.0, then runbook sections 2 and 3 (lead decision 75).
|
||||
Statuses and ids only. No password or token appears here.
|
||||
|
||||
Backup, 22:34Z, workstation: /mnt/storage/backups/tasks-mosaicstack-dev/20261009T223450Z-pre-2.7.0/ (dirs 0700, files 0600)
|
||||
a2173459fca535202b2851dd6bf32dde9e9485aa897406f405b02483de5b2c5f vikunja.pgdump (pg_dump -Fc, 154770 bytes, 34 TABLE DATA entries)
|
||||
c9a2f6d850a29a4154a8302ed5cb6287275cee9b575a1dcdaaf90c5ec9c62bc4 files.tar (vikunja-files PVC through a read-only busybox pod, 2 entries)
|
||||
Restore test: pg_restore into scratch DB sage_restoretest in postgres-0, exit 0.
|
||||
tasks live=142 restored=142; projects live=29 restored=29; users live=3 restored=3. Scratch DB dropped.
|
||||
|
||||
Upgrade: infra PR #325 (mosaicstack/infrastructure), one line in k8s/applications/vikunja/base/vikunja.yaml,
|
||||
vikunja/vikunja:latest@sha256:f13103b0... (2.1.0) -> vikunja/vikunja:2.7.0@sha256:e2204a1c1c6a81e833c2b3a5442be182ca2335b54c2e7e37578cc3fe12a27cfc
|
||||
Merged by Sage as author; ArgoCD (selfHeal) rolled it out. Mos accepted it after the fact and set the rule that infra PRs get independent review.
|
||||
22:40:06Z "Running migrations"; 22:40:12Z "Ran all migrations successfully."
|
||||
/api/v1/info: version v2.7.0, local auth on, registration on, OIDC on with providers [].
|
||||
Counts after: tasks 142, projects 29, users 3. TLS verify 0. Rollback: revert #325, then pg_restore vikunja.pgdump.
|
||||
|
||||
OIDC finding (predates the upgrade): NetworkPolicy vikunja (created 2026-04-27) allows 443 only outside RFC 1918;
|
||||
auth.diversecanvas.com resolves to 10.1.1.222; discovery times out. Last users.updated 2026-03-05.
|
||||
Infra PR #326 adds 10.1.1.222/32:443. Not merged by Sage; Mos routed it to ops-01 for review.
|
||||
|
||||
Sections 2 and 3, 22:45Z, setup.mjs (output in 2026-10-09_ids.json):
|
||||
mosaic-stack-owner id 4 (owns project 32); svc-mosaic-stack id 5 (owns bots 6-10, no labels, no projects)
|
||||
project mosaic-stack id 32, kanban view 152, buckets todo 237, in-progress 238, done 239, in-review 240, blocked 241
|
||||
done bucket 239, default bucket 237, bucket configuration manual
|
||||
shared with jason.woltje, permission 2 (admin)
|
||||
bots pm 6, cto 7, coder 8, reviewer 9 at permission 1; sync 10 at permission 0
|
||||
tokens ids 2-6, expires 2027-01-07T00:00:00Z, all start tk_, 43 bytes, 0600
|
||||
Passwords moved afterwards to ~/.config/mosaic-dev/secrets/vikunja-admin/ (0700 dir, 0600 files, 32 bytes each).
|
||||
Instance label count: 0, so the label leg of decision 68's probe has nothing to leak yet.
|
||||
|
||||
probe.mjs (32 vs project 1):
|
||||
pm own=401 other=401 projects=401 labels=200:[] otherViews=401
|
||||
cto/coder/reviewer own=401 other=401 projects=401 labels=401 otherViews=401
|
||||
sync own=200 other=403 projects=401 labels=401 otherViews=403
|
||||
401 is a route outside the token's scopes; 403 is a project the bot isn't shared into.
|
||||
|
||||
roundtrip.mjs (project 32, foreign task 1):
|
||||
pm create 201 (task 143)
|
||||
cto/coder/reviewer read=200 comment=201 foreignTask=403 delete=401
|
||||
sync read=200 comment=401 foreignTask=403
|
||||
pm foreignTask=403
|
||||
owner cleanup delete 204
|
||||
@@ -0,0 +1,21 @@
|
||||
// Sage, 2026-10-09: isolation probe for the mosaic-stack bots on tasks.mosaicstack.dev (decisions 66 and 68).
|
||||
// Reads each token file in-process and prints statuses and ids only.
|
||||
// Usage: node probe.mjs SECRETS_DIR PROJECT_ID OTHER_PROJECT_ID
|
||||
import { readFileSync } from "node:fs";
|
||||
const [S, P, OTHER] = process.argv.slice(2);
|
||||
const BASE = "https://tasks.mosaicstack.dev/api/v2";
|
||||
async function get(path, tok) {
|
||||
const r = await fetch(BASE + path, { headers: { Authorization: `Bearer ${tok}` } });
|
||||
let j = null; try { j = await r.json(); } catch {}
|
||||
return { s: r.status, j };
|
||||
}
|
||||
const ids = (j) => (j?.items ?? (Array.isArray(j) ? j : [])).map((x) => x.id);
|
||||
for (const r of ["pm", "cto", "coder", "reviewer", "sync"]) {
|
||||
const tok = readFileSync(`${S}/${r}-vikunja.token`, "utf8").trim();
|
||||
const own = await get(`/projects/${P}`, tok);
|
||||
const other = await get(`/projects/${OTHER}`, tok);
|
||||
const list = await get(`/projects`, tok);
|
||||
const labels = await get(`/labels`, tok);
|
||||
const tasks = await get(`/projects/${OTHER}/views`, tok);
|
||||
console.log(r, `own=${own.s}`, `other=${other.s}`, `projects=${list.s}:${JSON.stringify(ids(list.j))}`, `labels=${labels.s}:${JSON.stringify(ids(labels.j))}`, `otherViews=${tasks.s}`);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Sage, 2026-10-09: task round trip for the mosaic-stack bots on tasks.mosaicstack.dev, then cleanup as the owner.
|
||||
// Prints statuses and ids only. Usage: node roundtrip.mjs SECRETS_DIR PROJECT_ID FOREIGN_TASK_ID
|
||||
import { readFileSync } from "node:fs";
|
||||
const [S, P, FOREIGN] = process.argv.slice(2);
|
||||
const BASE = "https://tasks.mosaicstack.dev/api/v2";
|
||||
const rd = (f) => readFileSync(`${S}/${f}`, "utf8").trim();
|
||||
async function call(method, path, tok, body) {
|
||||
const headers = { Authorization: `Bearer ${tok}`, "Content-Type": "application/json" };
|
||||
const r = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
let j = null; try { j = await r.json(); } catch {}
|
||||
return { s: r.status, j };
|
||||
}
|
||||
const login = await fetch(BASE + "/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "mosaic-stack-owner", password: rd("vikunja-owner.password") }) });
|
||||
const owner = (await login.json()).token;
|
||||
const pm = rd("pm-vikunja.token");
|
||||
const made = await call("POST", `/projects/${P}/tasks`, pm, { title: "probe: delete me (sage 2026-10-09)" });
|
||||
console.log("pm create", made.s, made.j?.id, "bucket", made.j?.bucket_id);
|
||||
const T = made.j?.id;
|
||||
for (const r of ["cto", "coder", "reviewer"]) {
|
||||
const tok = rd(`${r}-vikunja.token`);
|
||||
const read = await call("GET", `/tasks/${T}`, tok);
|
||||
const cmt = await call("POST", `/tasks/${T}/comments`, tok, { comment: `probe comment from ${r}` });
|
||||
const foreign = await call("GET", `/tasks/${FOREIGN}`, tok);
|
||||
const del = await call("DELETE", `/tasks/${T}`, tok);
|
||||
console.log(r, `read=${read.s}`, `comment=${cmt.s}`, `foreignTask=${foreign.s}`, `delete=${del.s}`);
|
||||
}
|
||||
const sync = rd("sync-vikunja.token");
|
||||
const sread = await call("GET", `/tasks/${T}`, sync);
|
||||
const swrite = await call("POST", `/tasks/${T}/comments`, sync, { comment: "sync should not write" });
|
||||
const sforeign = await call("GET", `/tasks/${FOREIGN}`, sync);
|
||||
console.log("sync", `read=${sread.s}`, `comment=${swrite.s}`, `foreignTask=${sforeign.s}`);
|
||||
const pforeign = await call("GET", `/tasks/${FOREIGN}`, pm);
|
||||
console.log("pm", `foreignTask=${pforeign.s}`);
|
||||
const gone = await call("DELETE", `/tasks/${T}`, owner);
|
||||
console.log("owner cleanup delete", gone.s);
|
||||
@@ -0,0 +1,64 @@
|
||||
// Sage, 2026-10-09: runbook sections 2 and 3 on tasks.mosaicstack.dev through the v2 API (lead decision 75).
|
||||
// Accounts: mosaic-stack-owner owns the project, svc-mosaic-stack owns the five bots and nothing else.
|
||||
// Passwords and tokens go straight to 0600 files (flag wx, never overwritten) and never reach stdout.
|
||||
// Usage: node setup.mjs https://tasks.mosaicstack.dev SECRETS_DIR OUT_JSON
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, S, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.mosaicstack\.dev$/.test(ORIGIN)) throw new Error("tasks.mosaicstack.dev only");
|
||||
const BASE = ORIGIN + "/api/v2";
|
||||
const BIZ = "mosaic-stack", EXP = "2027-01-07T00:00:00Z", TODAY = new Date().toISOString().slice(0, 10);
|
||||
const secrets = [];
|
||||
async function api(method, path, body, auth) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (auth) headers.Authorization = `Bearer ${auth}`;
|
||||
const r = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); return r.json; };
|
||||
const keep = (file, value) => { writeFileSync(`${S}/${file}`, value, { flag: "wx", mode: 0o600 }); secrets.push(value); };
|
||||
for (const f of ["vikunja-owner.password", "svc-vikunja.password"]) if (existsSync(`${S}/${f}`)) throw new Error(`${f} exists; refusing to rerun`);
|
||||
|
||||
async function account(username, file) {
|
||||
const password = randomBytes(24).toString("base64url");
|
||||
keep(file, password);
|
||||
const u = must(await api("POST", "/register", { username, email: `${username}@noreply.mosaicstack.dev`, password }), `register ${username}`);
|
||||
const tok = must(await api("POST", "/login", { username, password }), `login ${username}`).token;
|
||||
if (!tok) throw new Error(`login ${username}: no token field`);
|
||||
secrets.push(tok);
|
||||
return { id: u.id, tok };
|
||||
}
|
||||
const owner = await account(`${BIZ}-owner`, "vikunja-owner.password");
|
||||
const svc = await account(`svc-${BIZ}`, "svc-vikunja.password");
|
||||
const rec = { at: new Date().toISOString(), origin: ORIGIN, owner: { username: `${BIZ}-owner`, id: owner.id }, svc: { username: `svc-${BIZ}`, id: svc.id } };
|
||||
|
||||
const P = must(await api("POST", "/projects", { title: BIZ }, owner.tok), "project").id;
|
||||
const K = must(await api("GET", `/projects/${P}/views`, undefined, owner.tok), "views").items.find((v) => v.view_kind === "kanban").id;
|
||||
const rename = { "To-Do": "todo", Doing: "in-progress", Done: "done" };
|
||||
for (const b of must(await api("GET", `/projects/${P}/views/${K}/buckets`, undefined, owner.tok), "buckets").items)
|
||||
must(await api("PUT", `/projects/${P}/views/${K}/buckets/${b.id}`, { title: rename[b.title] ?? b.title }, owner.tok), `rename ${b.title}`);
|
||||
for (const title of ["in-review", "blocked"]) must(await api("POST", `/projects/${P}/views/${K}/buckets`, { title }, owner.tok), title);
|
||||
const buckets = Object.fromEntries(must(await api("GET", `/projects/${P}/views/${K}/buckets`, undefined, owner.tok), "buckets").items.map((b) => [b.title, b.id]));
|
||||
const view = must(await api("GET", `/projects/${P}/views/${K}`, undefined, owner.tok), "view");
|
||||
rec.project = { id: P, kanbanView: K, buckets, doneBucket: view.done_bucket_id, defaultBucket: view.default_bucket_id, bucketConfigMode: view.bucket_configuration_mode };
|
||||
must(await api("POST", `/projects/${P}/users`, { username: "jason.woltje", permission: 2 }, owner.tok), "share jason.woltje");
|
||||
|
||||
const SCOPES = {
|
||||
sync: { projects: ["read_one", "views_buckets", "views_buckets_tasks_get"], projects_views: ["read_all"], tasks: ["read_all", "read_one"], tasks_comments: ["read_all"] },
|
||||
pm: { tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"], tasks_relations: ["create", "delete"], tasks_labels: ["create", "delete"], tasks_comments: ["create"], labels: ["read_all"], projects: ["views_buckets_tasks"] },
|
||||
worker: { tasks: ["read_one", "update"], tasks_comments: ["create"], projects: ["views_buckets_tasks"] },
|
||||
};
|
||||
rec.bots = {};
|
||||
for (const r of ["pm", "cto", "coder", "reviewer", "sync"]) {
|
||||
const b = must(await api("POST", "/user/bots", { username: `bot-${BIZ}-${r}`, name: `${BIZ} ${r}` }, svc.tok), `bot ${r}`);
|
||||
const sh = must(await api("POST", `/projects/${P}/users`, { username: b.username, permission: r === "sync" ? 0 : 1 }, owner.tok), `share ${r}`);
|
||||
const t = await api("POST", "/tokens", { title: `${BIZ}-${r}-${TODAY}`, owner_id: b.id, expires_at: EXP, permissions: SCOPES[r] ?? SCOPES.worker }, svc.tok);
|
||||
if (t.status !== 201 || typeof t.json?.token !== "string") throw new Error(`mint ${r}: ${t.status} ${t.json?.code ?? ""}`);
|
||||
keep(`${r}-vikunja.token`, t.json.token);
|
||||
rec.bots[r] = { id: b.id, username: b.username, permission: sh.permission, tokenId: t.json.id, expires: t.json.expires_at, startsTk: t.json.token.startsWith("tk_") };
|
||||
}
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (secrets.some((s) => out.includes(s))) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out, { flag: "wx" });
|
||||
process.stdout.write(out);
|
||||
Reference in New Issue
Block a user