5 Commits
Author SHA1 Message Date
JARVISandClaude Opus 5.5 01e6ad90f0 traefik: serve gitea on mad-tea-party.wdw-labs.cloud too, add SAN (WDWL-27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:51:28 -05:00
JARVISandClaude Opus 5.5 008fd5bf7b gitea: template compose from role vars; bump to 28.2.0 / postgres 17.11 (WDWL-27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:51:28 -05:00
JARVISandClaude Opus 5.5 fc2ad85e19 obsidian_headless: grant the service user on shared folders too (WDWL-22)
Content multica creates in inbox/ is owned multica:multica, so obsync fell
into "other" (---) and Sync could neither read nor upload Wong's
submissions. Found after the real Sync sign-in on spaceship-earth; the
earlier verify only tested the obsync -> multica direction.

Shared folders now carry user:obsync:rwx as an access and a default entry
alongside the shared user's, and verify tests the multica -> obsync
direction (read + rewrite, as merge needs). Second run changed=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:28:52 -05:00
JARVISandClaude Opus 5.5 2d039ae32f multica_agent_host: merge declared keys into the daemon user's Claude settings (WDWL-22)
The npm-installed Claude Code on spaceship-earth updated itself from the
pinned 2.1.304 to 2.1.308 right after its first login, so every play would
downgrade it and it would update again. New multica_claude_settings is
merged (recursively, not templated, since Claude writes the file too) into
~multica/.claude/settings.json.

provision-agent-host.yml sets DISABLE_AUTOUPDATER=1 and mirrors the model
mix already live on haunted-mansion: model sonnet, subagents haiku,
MAX_THINKING_TOKENS 4000, BASH_MAX_OUTPUT_LENGTH 20000, thinking off.

spaceship-earth: back on 2.1.304 and staying there; second run changed=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:38:00 -05:00
JARVISandClaude Opus 5.5 94c77daf13 multica_agent_host: daemon lifecycle via systemd; fix login guard (WDWL-22)
Found provisioning spaceship-earth:

- Login was not idempotent: `multica auth status` prints to stderr (0.6.5),
  and the guard read stdout only, so every run re-logged in and restarted
  the daemon. The guard now reads both streams; the status read is no_log
  because it echoes a token prefix.
- `multica daemon start` / `daemon restart` fork the daemon inside the
  caller's session (Ansible's SSH session scope), outside the systemd user
  unit, which stays inactive: the same orphan seen on haunted-mansion on
  2026-10-21. Start/restart now go through `systemctl --user`, after a
  daemon-reload when autostart was (re)registered. A daemon found running
  outside its unit is stopped first. The unit name is read from
  `autostart status`, so --profile hosts resolve their own unit.

spaceship-earth: second run changed=0, daemon in multica-daemon.service.
Not yet run against haunted-mansion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:04:46 -05:00
16 changed files with 303 additions and 76 deletions

No files matched your search

@@ -20,6 +20,17 @@
# multica_workspaces_root_mount: "/mnt/utilidor"
multica_agent_clis:
- claude
# Merged into the daemon user's ~/.claude/settings.json. Mirrors the model
# mix already live on haunted-mansion (unpinned runs on sonnet, subagents
# on haiku), and stops the npm install self-updating past the pin.
multica_claude_settings:
model: sonnet
alwaysThinkingEnabled: false
env:
DISABLE_AUTOUPDATER: "1"
CLAUDE_CODE_SUBAGENT_MODEL: haiku
MAX_THINKING_TOKENS: "4000"
BASH_MAX_OUTPUT_LENGTH: "20000"
# --- Secret source ----------------------------------------------------
# This homelab keeps Ansible secrets in ansible-vault encrypted files, so
+4
View File
@@ -6,6 +6,10 @@
# Directory layout on mad-tea-party
gitea_compose_dir: /opt/gitea
# Image versions
gitea_version: "28.2.0"
gitea_postgres_version: "17.11"
# Gitea application settings
gitea_domain: gitea.mk-labs.cloud
gitea_root_url: "https://{{ gitea_domain }}"
+3 -3
View File
@@ -15,9 +15,9 @@
# ── Compose & env ──────────────────────────────────────────────────────────────
- name: Deploy Gitea compose file from boilerplate
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../boilerplates/gitea/compose.yaml"
- name: Deploy Gitea compose file
ansible.builtin.template:
src: compose.yaml.j2
dest: "{{ gitea_compose_dir }}/compose.yaml"
owner: root
group: root
@@ -0,0 +1,64 @@
# ------------------------------------------------------------------------------
# FILE: ansible/roles/gitea/templates/compose.yaml.j2
# DESCRIPTION: Gitea + PostgreSQL compose stack. Values come from role defaults.
# Postgres stays on 17.x; a major bump needs dump + restore.
# ------------------------------------------------------------------------------
services:
gitea:
image: docker.io/gitea/gitea:{{ gitea_version }}
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea_db
- GITEA__database__NAME={{ gitea_db_name }}
- GITEA__database__USER={{ gitea_db_user }}
- GITEA__database__PASSWD=${DATABASE_PASSWORD}
- GITEA__server__SSH_PORT={{ gitea_ssh_port }}
- GITEA__server__ROOT_URL={{ gitea_root_url }}
- GITEA__server__DOMAIN={{ gitea_domain }}
- GITEA__server__HTTP_PORT={{ gitea_http_port }}
- GITEA__service__TRUSTED_PROXIES={{ gitea_trusted_proxies }}
networks:
- gitea_backend
ports:
- "{{ gitea_http_port }}:{{ gitea_http_port }}"
- "{{ gitea_ssh_port }}:22"
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
gitea_db:
condition: service_healthy
gitea_db:
image: docker.io/library/postgres:{{ gitea_postgres_version }}
restart: unless-stopped
environment:
- TZ={{ gitea_timezone }}
- POSTGRES_USER={{ gitea_db_user }}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB={{ gitea_db_name }}
networks:
- gitea_backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ gitea_db_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- gitea_db:/var/lib/postgresql/data
volumes:
gitea_data:
driver: local
gitea_db:
driver: local
networks:
gitea_backend:
driver: bridge
+9 -3
View File
@@ -307,13 +307,19 @@ Every step reads before it writes:
| CLI install | `multica --version` equals the pin |
| Agent CLI install | `command -v <binary>` and its version command |
| Config | `multica config show` parsed and compared key by key |
| Login | `multica auth status` already names `multica_server_url` |
| Login | `multica auth status` already names `multica_server_url` (it prints to **stderr**; the guard reads both streams) |
| Autostart | `multica daemon autostart status` |
| Daemon start | `multica daemon status --output json` reports `running` |
| Daemon start | `systemctl --user is-active <unit>`; a daemon running *outside* the unit is stopped first, then the unit is started |
A binary, config or credential change notifies a `multica daemon restart`
A binary, config or credential change notifies a `systemctl --user restart`
handler, which is flushed *before* verification reads the daemon's state back.
The role never calls `multica daemon start` or `multica daemon restart`. Both
fork the daemon inside the caller's session (Ansible's SSH session scope),
outside the unit's cgroup. The unit then shows `inactive`, nothing supervises
the process, and a later `systemctl --user start` collides with it on
`daemon.lock`.
A converged host reports zero changed tasks.
## Verification
@@ -129,6 +129,15 @@ multica_agent_cli_catalog:
manual_login: true
manual_login_hint: "configure Hermes credentials as {{ multica_user }} (Hermes reads 1Password natively; confirm against its docs)"
# Keys MERGED (recursively) into the daemon user's ~/.claude/settings.json.
# Merged, not templated: Claude Code writes that file itself. Keys not named
# here are left alone. Empty = don't touch the file.
#
# An npm-installed Claude Code updates itself in place, which silently breaks
# the package_version pin above (and the next play downgrades it again).
# Setting env.DISABLE_AUTOUPDATER: "1" here is what makes the pin hold.
multica_claude_settings: {}
# -----------------------------------------------------------------------------
# Node.js (installed only when a selected agent CLI needs npm)
# -----------------------------------------------------------------------------
@@ -1,7 +1,9 @@
---
# Through systemd, not `multica daemon restart`: that respawns the daemon in
# the caller's session, outside the unit (see tasks/daemon.yml).
- name: Restart multica daemon
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'restart'] }}"
argv: [systemctl, --user, restart, "{{ _multica_unit | default('multica-daemon.service') }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
@@ -1,6 +1,10 @@
---
# `multica auth status` prints the server and user when authenticated and exits
# non-zero otherwise, so it is the idempotency guard for login.
#
# It prints to STDERR, not stdout (0.6.5). A stdout-only guard never matches,
# so every run re-logs in and restarts the daemon. It also echoes a token
# prefix, hence no_log.
- name: Auth | Read the current authentication state
ansible.builtin.command:
argv: "{{ _multica_cmd + ['auth', 'status'] }}"
@@ -11,13 +15,15 @@
changed_when: false
failed_when: false
check_mode: false
no_log: true
- name: Auth | Decide whether a login is needed
ansible.builtin.set_fact:
_multica_needs_login: >-
{{ multica_force_relogin | bool
or _multica_auth_status.rc != 0
or multica_server_url.rstrip('/') not in (_multica_auth_status.stdout | default('')) }}
or multica_server_url.rstrip('/') not in
((_multica_auth_status.stdout | default('')) ~ (_multica_auth_status.stderr | default(''))) }}
# no_log is mandatory: without it Ansible records the full argv — including the
# PAT — in play output, CI logs, and every callback plugin.
@@ -0,0 +1,34 @@
---
- name: Claude settings | Ensure ~/.claude exists
ansible.builtin.file:
path: "{{ multica_user_home }}/.claude"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
- name: Claude settings | Read the current settings
ansible.builtin.slurp:
src: "{{ multica_user_home }}/.claude/settings.json"
register: _multica_claude_settings_raw
failed_when: false
become: true
- name: Claude settings | Merge the declared keys over them
ansible.builtin.set_fact:
_multica_claude_settings_current: >-
{{ (_multica_claude_settings_raw.content | b64decode | from_json)
if _multica_claude_settings_raw.content is defined else {} }}
- name: Claude settings | Write them back if anything differs
ansible.builtin.copy:
dest: "{{ multica_user_home }}/.claude/settings.json"
content: "{{ _multica_claude_settings_current | combine(multica_claude_settings, recursive=true) | to_nice_json }}\n"
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0600"
become: true
when: >-
_multica_claude_settings_current | combine(multica_claude_settings, recursive=true)
!= _multica_claude_settings_current
@@ -43,8 +43,61 @@
- multica_autostart | bool
- _multica_autostart_status.rc != 0
or 'enabled' not in (_multica_autostart_status.stdout | default('') | lower)
register: _multica_autostart_enable
changed_when: true
# Lifecycle goes through systemd, never `multica daemon start|restart`. Those
# fork the daemon inside the CALLER's session (here, Ansible's SSH session
# scope), outside the unit's cgroup: the unit stays inactive, nothing
# supervises the process, and the next `systemctl --user start` collides with
# it on daemon.lock. Seen on haunted-mansion (2026-10-21) and spaceship-earth
# (2026-10-22).
#
# The unit name comes from `autostart status` ("Location: .../<name>.service")
# so a --profile host resolves its own unit.
- name: Daemon | Re-read the autostart registration
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'status'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_autostart_reread
changed_when: false
failed_when: false
check_mode: false
when: _multica_autostart_enable is changed
- name: Daemon | Resolve the systemd user unit
ansible.builtin.set_fact:
_multica_unit: >-
{{ ((_s.stdout | default('')) ~ '\n' ~ (_s.stderr | default('')))
| regex_search('Location:\s*\S*/([^/\s]+\.service)', '\1')
| default(['multica-daemon.service'], true) | first }}
vars:
_s: "{{ _multica_autostart_reread if _multica_autostart_enable is changed else _multica_autostart_status }}"
# `autostart enable` may rewrite the unit file while the user manager still
# holds the old definition.
- name: Daemon | Reload the user manager after a (re)registration
ansible.builtin.command:
argv: [systemctl, --user, daemon-reload]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: _multica_autostart_enable is changed
changed_when: true
- name: Daemon | Read the unit state
ansible.builtin.command:
argv: [systemctl, --user, is-active, "{{ _multica_unit }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_unit_state
changed_when: false
failed_when: false
check_mode: false
- name: Daemon | Read daemon status
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}"
@@ -56,15 +109,27 @@
failed_when: false
check_mode: false
- name: Daemon | Start it if it is not already running
# Running, but not under the unit: an orphan. Stop it so the unit can own the
# lock and the process.
- name: Daemon | Stop a daemon running outside its unit
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'start'] }}"
argv: "{{ _multica_cmd + ['daemon', 'stop'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: >-
_multica_daemon_status.rc != 0
or ((_multica_daemon_status.stdout | from_json).status | default('')) != 'running'
when:
- (_multica_unit_state.stdout | trim) != 'active'
- _multica_daemon_status.rc == 0
- ((_multica_daemon_status.stdout | from_json).status | default('')) == 'running'
changed_when: true
- name: Daemon | Start the unit if it is not active
ansible.builtin.command:
argv: [systemctl, --user, start, "{{ _multica_unit }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: (_multica_unit_state.stdout | trim) != 'active'
changed_when: true
# Anything that changed the binary, the config or the credentials restarts the
@@ -27,6 +27,13 @@
ansible.builtin.import_tasks: agent_clis.yml
tags: [multica, multica_agents]
- name: Claude Code settings
ansible.builtin.import_tasks: claude_settings.yml
when:
- "'claude' in multica_agent_clis"
- multica_claude_settings | length > 0
tags: [multica, multica_agents, multica_claude_settings]
- name: CLI configuration
ansible.builtin.import_tasks: config.yml
tags: [multica, multica_config]
+4 -1
View File
@@ -25,7 +25,7 @@ only. Entry point: `playbooks/provision-wong-host.yml`.
| `/srv/obsidian/` | `0710 obsync` | `user:multica:--x` |
| `/srv/obsidian/vault/` | `0710 obsync` | `user:multica:--x`, so `multica` can traverse but not list |
| `vault/everpure/`, `vault/wip/` | `0700 obsync` | none |
| `vault/inbox/` | `0770 obsync` | `user:multica:rwx`, plus the same as a **default** entry |
| `vault/inbox/` | `0770 obsync` | `user:multica:rwx` and `user:obsync:rwx`, each also as a **default** entry |
On a directory with an ACL, the mode's group bits are the ACL mask. A
`chmod 0700` would therefore void every named entry, which is why the
@@ -38,6 +38,9 @@ only. Entry point: `playbooks/provision-wong-host.yml`.
- `multica` writes, reads and deletes in `inbox/`.
- A folder and file created by `obsync` under umask `0077` (the way Sync
creates them) stay readable and writable by `multica`.
- What `multica` creates stays readable and writable by `obsync`. Those
files are owned `multica:multica`, so without the named `user:obsync`
default entry Sync could not read them and would never upload them.
- `multica` is refused both listing the vault root and listing or writing
`everpure/` and `wip/`.
@@ -4,8 +4,13 @@
# <parent>/ obsync 0710 + user:<shared>:--x traverse, no listing
# <vault>/ obsync 0710 + user:<shared>:--x traverse, no listing
# <vault>/<private>/ obsync 0700 nobody else
# <vault>/<shared>/ obsync 0770 + user:<u>:rwx, and the same as a
# DEFAULT entry, so new children inherit
# <vault>/<shared>/ obsync 0770 + user:<u>:rwx and user:obsync:rwx,
# each also as a DEFAULT entry, so new
# children inherit both
#
# The user:obsync entry matters for content the SHARED user creates: that is
# owned <u>:<u>, so without a named entry obsync falls into "other" (---)
# and Sync can neither read nor upload it.
#
# The owning group is obsync's private group, so its bits grant nobody. On a
# directory that carries an ACL, the group bits of the mode ARE the ACL mask,
@@ -62,6 +67,18 @@
loop_control:
label: "{{ item.path }}"
# Each shared folder gets its user AND the service user, both as access and
# default entries.
- name: Layout | Build the shared-folder grants
ansible.builtin.set_fact:
_obh_grants: >-
{{ (obsidian_headless_shared_folders
+ (obsidian_headless_shared_folders
| map(attribute='path')
| map('community.general.dict_kv', 'path') | list
| map('combine', {'user': obsidian_headless_user}) | list))
| unique | list }}
- name: Layout | Grant shared folders (access and default entries)
ansible.posix.acl:
path: "{{ obsidian_headless_vault_path }}/{{ item.0.path }}"
@@ -70,6 +87,6 @@
permissions: rwx
default: "{{ item.1 }}"
state: present
loop: "{{ obsidian_headless_shared_folders | product([false, true]) | list }}"
loop: "{{ _obh_grants | product([false, true]) | list }}"
loop_control:
label: "{{ item.0.path }} -> {{ item.0.user }}{{ ' (default)' if item.1 else '' }}"
@@ -18,6 +18,61 @@
changed_when: false
check_mode: false
# The reverse direction: what the shared user writes, Sync must be able to
# read (to upload it) and rewrite (to merge remote edits into it).
- name: Verify | Content a shared user creates stays usable by the service (create)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1/.obh-verify-rev"; mkdir -p "$d" && echo ok > "$d/note.md"'
- sh
- "{{ obsidian_headless_vault_path }}/{{ item.path }}"
become: true
become_user: "{{ item.user }}"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.user }} -> {{ item.path }}"
changed_when: false
check_mode: false
- name: Verify | Content a shared user creates stays usable by the service (use)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1/.obh-verify-rev"; grep -q ok "$d/note.md" && echo more >> "$d/note.md" && echo ok > "$d/reply.md"'
- sh
- "{{ obsidian_headless_vault_path }}/{{ item.path }}"
become: true
become_user: "{{ obsidian_headless_user }}"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
register: _obh_reverse
changed_when: false
failed_when: false
check_mode: false
- name: Verify | Remove the reverse probes
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item.path }}/.obh-verify-rev"
state: absent
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
changed_when: false
- name: Verify | The service can read and rewrite shared users' content
ansible.builtin.assert:
that:
- _obh_reverse.results | rejectattr('rc', 'equalto', 0) | list | length == 0
fail_msg: >-
{{ obsidian_headless_user }} cannot read or write content a shared user
created; Sync would never upload it. Check the user:{{ obsidian_headless_user }}
default ACL entry on the shared folder.
quiet: true
# Simulates the service: obsync, umask 0077, creating a folder and a file the
# way Sync does. The shared user must still reach both, via the default ACL.
- name: Verify | Content the service creates in a shared folder inherits the grant (create)
-58
View File
@@ -1,58 +0,0 @@
services:
gitea:
image: docker.io/gitea/gitea:1.25.4
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea_db
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${DATABASE_PASSWORD}
- GITEA__server__SSH_PORT=2221
- GITEA__server__ROOT_URL=https://gitea.mk-labs.cloud
- GITEA__server__DOMAIN=gitea.mk-labs.cloud
- GITEA__server__HTTP_PORT=3000
- GITEA__service__TRUSTED_PROXIES=10.1.71.35
networks:
- gitea_backend
ports:
- "3000:3000"
- "2221:22"
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
gitea_db:
condition: service_healthy
gitea_db:
image: docker.io/library/postgres:17.8
restart: unless-stopped
environment:
- TZ=America/Chicago
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=gitea
networks:
- gitea_backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- gitea_db:/var/lib/postgresql/data
volumes:
gitea_data:
driver: local
gitea_db:
driver: local
networks:
gitea_backend:
driver: bridge
+3 -1
View File
@@ -1,13 +1,15 @@
http:
routers:
gitea:
rule: "Host(`gitea.mk-labs.cloud`)"
rule: "Host(`gitea.mk-labs.cloud`) || Host(`mad-tea-party.wdw-labs.cloud`)"
entryPoints:
- websecure
tls:
certResolver: cloudflare
domains:
- main: "gitea.mk-labs.cloud"
sans:
- "mad-tea-party.wdw-labs.cloud"
service: gitea
services: