16 Commits
Author SHA1 Message Date
rblundon 0d674e0c4b update vault 2026-10-23 17:34:02 -05:00
rblundon de9e340151 added 1password token to vault 2026-10-23 17:15:03 -05:00
JARVISandClaude Opus 5.5 be7d4a4378 multica_agent_host: install the 1Password CLI from 1Password's signed repo (WDWL-37)
Agents get `op` so op:// references resolve at command time and secret
values stay out of prompts and transcripts. Installed per 1Password's
apt instructions (signed-by keyring, debsig policy + keyring,
1password-cli), with the key fingerprint-checked before it is trusted;
EL via their yum repo. multica_op_version pins, "" tracks the repo.
Tag: multica_op.

Optional, default-off hook writes OP_SERVICE_ACCOUNT_TOKEN from the
playbook (ansible-vault) to a 0600 file owned by the daemon user. It is
deliberately not wired into the daemon environment: the user unit
belongs to the multica CLI, and the delivery design is pending.

Rolled out with --tags multica_op to haunted-mansion and spaceship-earth;
second run changed=0, op 2.41.1 as multica, debsig verifies the .deb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:11:27 -05:00
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
rblundon 61dba61025 Merge branch 'main' of gitea.mk-labs.cloud:rblundon/homelab 2026-10-22 16:00:51 -05:00
rblundon b73107be79 update vault 2026-10-22 15:59:59 -05:00
JARVISandClaude Opus 5.5 15aeb8e222 spaceship-earth: per-host Multica PAT, root LV growth, headless Obsidian (WDWL-22)
- provision-agent-host.yml: multica_pat now comes from
  vault_multica_pats[inventory_hostname_short], one daemon-<host> token per
  host. A pre_task asserts the key exists (never prints the value) so a
  missing entry fails by host name instead of as a no_log "censored".
- provision-wong-host.yml: spaceship-earth only. Grows the root partition,
  PV and LV/filesystem with growpart + community.general.lvg/lvol;
  idempotent (second run changed=0). Then applies obsidian_headless.
- roles/obsidian_headless: Obsidian's official headless Sync client
  (obsidian-headless 0.0.14) as system user obsync under a sandboxed
  systemd unit with UMask=0077. Vault at /srv/obsidian/vault: everpure/
  and wip/ obsync-only, inbox/ granted to multica via access + default
  ACLs, vault root traverse-only. Verifies the isolation as multica.
  Sign-in to Obsidian Sync stays manual (see the role README).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:41:54 -05:00
rblundon 04a0937910 inventory update 2026-10-22 12:32:18 -05:00
rblundon 01cd7b8d1e added multica agent hosts 2026-10-22 12:30:35 -05:00
rblundon 5559cd425d deploy claude with ansible 2026-10-21 11:50:53 -05:00
rblundon c396c89efb Merge branch 'main' of gitea.mk-labs.cloud:rblundon/homelab 2026-10-21 11:17:17 -05:00
rblundon 7450892a68 added claude role 2026-10-21 11:17:14 -05:00
67 changed files with 4227 additions and 492 deletions

No files matched your search

+471 -417
View File
@@ -1,418 +1,472 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
39633233363564393465633065323730343130653362373436356334323930393036656638303033 31303035626165353032623535326564616362663265363634346230393632623233333132636230
6333636536643835303936633039633138336434663565320a643961663732373630333238633466 3263666137666332353136626438663063633165663565360a333934333364653766626263383464
62633236666331326165653637353038313163643162323763313635306230333338613164316337 33356463653166666236623638303561376439373261306334373134386262636534623966633436
6133396638336465350a306163643138653639646133346561323333313332336639393764343364 3330613861353861350a393035363566383862643563376436643665626135373830616231656365
30666535323264363661653236333839643631376337303563383739656238343732396364366437 61623530363761616533376434326230363132666433356238646139393730343264636362333463
33333437643930656564376236333736336465356134653239393036313830333462363633653366 63396239313137376262356664323562306634633064616166316331333862326436393861346632
62623863613261323135633262633066383363313731646361326232366661356261316633323164 33393031646532376462313837343762663266363366353665366266623434326538646239623234
34653661383366643531353939303333363832323731623830393262373538613631613631616463 65623362333166326566303739623466376537353561643034616563356664363266643462323435
66396163613965343833356432643534393539643961373263323533316664386665383330326665 63623436353162376366343836626439623963313764346562363061653832646130396463396530
35393537363734333961393931386161386234323264316466303361663139323431303034393035 35346631666633383435656563366164306432663131326633343563313134323937313961613437
33353432363162353261646231313136643533363563343537666565306638353564646235623835 32383163613439636561383064346238303535613339366234633234633430353232613761636562
33653663393838303364363762613663646139326135313438663838323064326265303733346164 36323461623465653765303533303230303064363762393865326533613336346366386630353339
62386130613134653739353930623330663135633632346531306535643332303939356263636232 61343164333262646239636161313231386133363037666539653666656461653666356239663234
34313939333263313133373635396138643838396235626662343664316632663930313038643863 66373736616232336466623030373366393366393032656138306465636431383937646461383136
65653630373437373631633032333663353866636630306235363035623837656661323735356361 66303733626633386335623835383135363265383062343937306564613234366435383236356537
33343961373235613262613363636565396234613865303937323432656433343836373831343162 66303638626365343066343537306636623935393637616237306337363538616262323735363230
37356234363662333764656530333139653434336137326631386366306162646433333239316133 34343561336634393334323763393836633736383739323661303531626133383834333138656362
35303136633664336433386331316662313330643734383139633938636462346539623835653738 35656463316136373861613636366165383530383465326666616139333130663230356166373633
63373439623866363863633963633265373339613130343966656637376632396139646266306666 62613535656161633530393031663063363031656431303338613864653739323932646333633236
62316430343338626331326433366266396131666264633234323233646639643236323937396431 36346464303564343661653138323433306563306566623966313664643030653934343230626333
62323738376664303237653563323164396166313266613937383431653766616234653762383163 38343732653264376530343933326365363864666539306562366434363161633132373165393234
65666138353262663536623036623439363463306136363930343633383436333565363232376162 65333339356330323562343032343334616439343963623066626538633238663132383965656231
35343035306230666432666131643634383263663838626466633133653830623330656164313465 38346230666230396434613036333466633334386337373437383063336364366130383064633831
32363031313266396532313563343963613133396232353832636464663462636138623637646135 38303630656162376331623335323565356530326539333536623835383132353462353161663061
64356364363862653465303837663261333933623266333736343938643065393031343535633762 37353263646537633561656133393562623034363830663961623331623263303861383465383865
62363436323931373563323863643066653237393339653363656133316365346262306463336364 66303765313830383431636236313937616666303833383738353438643461373166343130353661
66303732366437633461373065363730336235313732623131633366633834376563663734653063 31623166356134343833313736623738303837303332346361316532633434666266386661326337
31656638623933376666393232613238323239623836313430356639613131303761346535336338 64396633373537656135373938663433333230353632366538356135666139363232613664356436
65613363366331613865343163623863636666303336646636663465373065393731306634663239 36346363343638303764623931373263613461303266323766646434626336313933303237353538
32653666343631666235383863336338326438646137656562646431623436323037313438343839 35643165666232653831356530313330633831323461303165623436343861656431313266333662
34663062396564306138356465623032396239396562643933306365613033333065323630393665 31313461663438643062376262343365633534323837336434616431656131626530666637666432
66346262303965353539663463626261633332373833346430346235376636323633623864353133 63383461303562633063343863633832313530373334303234363366363966303866303138316131
34666265373936376339646363383834393166376434653233306632373966336232316339336465 32363034653836633636393864643966333032323830376465663937386238666539373733333838
63313866653732386234666531663030313364626534343235333463376334663061316461613331 38363964363135343764353432383736656561653164396361393337616335633635316536326333
64303962393930373036643561393762666334666538626233333937653038633734626430623837 36376164653565646138356433656639343938643039326165366461643230393364396439666266
33303463386363386562333061353963303538366362313131656433383263666631666566633765 62346163653531383934666562613563633163613239646265623236356132653230303032376364
65613238393736646637346333323933326664663862326563363566613361343539646264626561 37323532303666333964346132613262653236313239623531613933383362623233366632313736
37323034323136376433343433316266386135333765643036373565326539643066336533343431 39313162303930383637663237636237633539663763623661326530656132633233346361663136
34316264393261373130333930383932343864386534376633336239303066356666363438356632 34316462666661343839333634666130346662623136656536366661306433306134323163323561
63313637616465386363346162653064613236383166353432636530336437376665643830356134 31626264623939313033623738313265626637353837613735343166306233333837613336353639
35366239326637373366383962376165643932336632373330326533366535666434343161373264 61356461656561383565623965353738643033333637613039353838623337336138343937613663
30323130626236356666663230613837373666666334646366616339313661613238666138353832 64376163623562633135366563623533383466656234333535393030323631343163643363623463
37633066303139313961643935383632646662613766303366663335306263646439383534373639 35303131383937643632656434366339616266653134656163633661623330666637656335666335
33643733626531376565623431666637323736393361373566626666633232383832656333333566 61373362626463323131666231326636343866336337626637306465613463666335366431373339
65616662383561626161363061646334633033386534633533393032386532643939373736356532 32343136353463336164363664333861326465363334616530643234353165353235653665633135
31633430613338343762656630303139316634323635653763386366633366366336383064613333 64656163333462366437616337343438623835313139623839626237323433303861323963333937
32373164633066643064316535333364366461666332663165346462386563373864663565613736 64323437623630613136646161303363313033316634666531323534383363366232313166343433
64636131346133623166656235306230353733373263313166383761303364366434656666303732 61373832623464653865323364623837303637303234646462633336316630303766633733653131
34313234663139616466633562383663316630666261336463653236393832303363316438326635 35393834336166323334663033613938616232656134313735373434353263306130363130613966
32343135396333613936383239633938393261393435653239643763373065623531336633313130 34626130646534623864316630383235303263626462343732646633303764366234303135383638
64323761646666636464623734656236653439646433303934633536333862653930303133336264 38363362623137623532663365333563363138306336336537623236613766383861313865376131
66333361323735386338643933336636303263613739666432626137393061343833636537396539 61626231663930303162653334666231366435356231303730666664613737303961316163633339
62626534663636323764376437393237663839356534306438306431663937323364316431323264 34393730633966323565343039626231303736313836346237386662653537613132383962643035
38353234303364653833393733383736636139336237386137313937656137636562303036666135 62363233663366383365616632316161386531646230346331343162663537393033646330376538
62626530383037613439646261346130353430303131303936393230333662386533356631326533 37636234633136633363626634303662663765643664623432636530386364663961353330336164
65643931313036623766316137353130363535383834323363623334376234393265326362653838 63306339386364623331663930373335623766306536353663643130613731323261616339616661
34353665666537343232626137383166383761643232313539346461393937353737373935323462 33373138623333633962386161333166393466643565306564326234653165353362313038326538
38303631383465623865323230333330383730333536346366616332343539313237333538333431 62353136663533336361323966633737376133366338396431333435323261363035393561316136
65636235343938316635633732393430323932626339323236323663663932306463373039343363 36323738646138363861636439316531646235396466316138366266316361356664353533346630
66363235366539386430333365613338646166313133346239346537313666396435343064643537 61633538353661646437633433313463326332613261643536386666656637376163393266376530
30363236396563346165353032373463386630646134356634646465353136643635373164643263 65323834393131643731333863623134613638633235333064653238383363653432363736396462
31396333353232323937346530303334623163316538353937396337636165356163396639373731 38393939383139326362623937643364303833663861383130323235313865653831656133613439
64363036346434623366376261316632343238306564646234383433343137396462303466383635 63303533613065626434663066643531306139626133636532633932633037323536373439613461
38353161383933336264393332386233643666656132633038323936353630313635313365353663 61353234356162363936313065636136653636663437663536613563656333353039323863623861
30353734363435373265616333653737383838376463646466633732333537346565666433656137 61393630363237653963323534316130333363663561376133323039326137323766636161653831
62333234653264626532323064386639643136336264346232663537393065386666623463336433 65663937663434333337393863323134386638613435656130396366636139333130353334313762
38613966376535303961343361363930313664633030353538306633656335326430653534346161 63326661373932653561373635633332366332306133626133303066376137633531656562313835
31613166366231386239366631623839316361303630663063336637303832373463613735356364 30393266393161643836326664343731633935356264356136313362386535376466343333656264
39323238656232383632643630386261323764643861353063616130323939383732343863356633 64383663346365373830393333313735623536383732363037616231653164306337323832633661
33303965363565313535386463613030373634653763313430643335393032663839636164303630 37663437653763373563396465333836613964343536636564343665313534616230613162303338
38653930383661386331613763316365653538386266396430633638633638653134633365343937 66303337663361643635663639633832386263663539303361343934613864396534653135353766
30313565663139366139623065623730356363313731663034663362313335346561323130396333 33373862643332356530303964623839663732663536656134316262346334623361653266323263
37343165333434613235363964323564306330393738333635303763633466366664616138613862 31636133356635623166306463306637383333383235323334373736656262393661323564656166
61623464656630343361353433313836316230653435356661326366366131386637363334326336 36666161326436396133336662633465636532633262616132366162346365653433623639323830
34313334386464363261343831356163613834353639323562333930363563343963633833623832 61366466636165303563323738333831373931346563366561333235343539313930396637616461
37323964336565383062373739346261306139376637306461336265316336666439353536313063 61633561636136613665386162646130663864316238356130313931376238353234633137383734
31653432373235666235396239363261316333373332333234376635323261306234383836383161 36343231333266393030333630663863373663386333353039643661353861316366666339366634
33303136363335353539663833306166643737333238643066313633323031613237666264666365 66306334653339303762666135353032643034633739373330333830366436666138343738343534
63333766616631366639396235633466383062623462363638363866346462306535623839626231 64333137303963373438336361663136623064363665343835316661623831396532376362366134
63666633393137316637616634653139316439626432366236663738316166643335333534626164 34386463666532616131643331643436613630353561346430383736616437336466663232616165
62623861356134663637663665313934313833636165643732376438316563383539663037373432 32303434373035323235666564343136333161323839363361326162626465653736306332396530
30326233373936346433323934663633363166376463623135386238393334396466316265613965 33303637616637663566313665356335326266616534613234343839326138393664393062336430
33623362653436613835386535393038363064373233623266643466633732366632346330343438 36626533666137336366613761356438616136643436326437633936663961613434646263336439
65383938363063666263616330356661343732313435383137313435656337353063613230656462 34383538353063616464303931633561323834623761373763343962343663653265613030663036
63383465343932303832313335346230313164333362303935343836363764313933623831306130 32393538626530373838653934626333336161383235333265636665336638353839623061336133
36333339373230386162323337386661303663616530383863333332396439313833303461643662 30643439396638343261613831326131363837366462333031626666623031346236643437386532
37336231343065623635376564393934316333626566623765353265613737646131653934343631 35376133323462336262613563653830396461626362343861333139353038313966636363323836
32613463636130333561653066343839303332396239656534303733643639343337363437656662 65326466653365666338613566323461376333393162366638313733663631353933343962623737
63303732326235643830303038663131613330303863353231313561376231666135353464303864 38323365373263643138393339323937313864356131313032613463306135336134366230346339
66633332613139386136303264366464356434633434656536393664313130646234393634326432 31363966306339383264346464646361306463333031626534346662396336336338616563616338
63663938373932386564363834373134356464633662383130386337633564643930613734383631 37626434366161656238646433323133653430626564616336613461363661633138663932653038
38643239663733393336643834643438303737636635396630333565656264653866366166623332 30646435313436303937643730336434373666386163383439623338393862623838356537393836
35653430323365313834643862393065323531633230613630343738333934383161613264356265 65626165373363653638633164653761353265366461316239383732633837306636343137623962
66626561386138396639353934653465373162323838303233636565306534633236343066623638 33396435343837326338336538646534613131346463393862623234643363323030373164623533
38653132376663666136383235663337343762646137643935626365343162363439303061393563 61316466336132343433346538623761616535306564306566396339393830353235323735343463
32633366623834613739303139333038303762316663663136656636663663386362613264663238 64336462313630303631393839383833323337326337333166383431306638626166313064336336
35323038326239643062666336333932383065383461373933626339383434363736323536313533 39366163613634343933343462393932643666326434336666636262656633393762653536623838
65363565306239363935666234653438393039303663623636376262666164393334363839393665 61353438343165633038363135613665663531646261393534353938653262303763663166643737
62326533613639643938613838396565376334333035333962626135356265353639653633373438 62653935393338663065396462616534326237396434333566396466643238393734313036613532
37316463303838633335306333346465386461373766356533313863333534326231353864303531 61336663393537303031316161363165626661366166616333333761376532306462396463393932
32663461623731653138646336663266303065306562643961393936373937653664323032626339 35633266303665343836626335653238313065336435383634383566643162653337613531636165
39373539363664316432333931633866646333346363336466393963656637366562336162613465 64616635373533616230656335366131623262396163393561346432636363356132636339393861
32663230613335393839633633633063636635373363653234663564663264353036393434663963 65303735333935663232393162653739353964666164343434643737666538316633383930663232
66653631663062666338326133636236303862356662666533326236613965323462303839623331 63303230373233383261633863393638343562343831323834383163616333393762613265333961
35626238323837343738356264313266383135626239363731353639633837323132623635633435 61333032366433613032373263643662653066316162663438666666633430373135653738303763
61386663366533326130626332613562633965633837346237633334303564363136633733323361 62363139316638363463646631656266363333613966333365353666393233623336376338393931
32353361396166396334386630353738643438643537303034623830303939326436363239343664 66353438633435313061336435326336313734656230363061303530383937373965356563326462
35633731396161623934633864313863323336326535313637653935376436363162363565666634 31386661656533303861366333313939303337636264373432343466396633623533323662346536
33613435326539343865653862613263383236313130383234623766333637646464613365346532 39373865613639336265663862653735666239616331363531393138623763326363656437313963
34653035393862643039623763323138333334623866316437613435666530396134306665316334 65393934366430626464633632323738336636376533333963343536616135366131383934376138
31373364653865323765646535303166373638636134326538343563363732636337653931373234 36303562326463373261333538313135366435376138313539316236643334356636373063663930
35303433656137333635383334376237616261393334623835353866323333393630373765613835 62366534643333326362613163356532616266313436626162643132303266353962396365666162
33363636323664383136353863666635646563393764633531616135313935333539333664343664 66386231393161343561393864633566656361333439336663333763363339613439383231346236
62633465356563353433633564303531633236656631326331323638643666643636396162316532 63323635656635353135363535613134383465363331343730646239343936376333353636616639
36313839363164663336326639313066316331303365386130666662366638353834306662303336 63313439346262306634316236383138346166336365393531393930306139306637313831316666
34393538346338376330333332383231333630353334646635313037313736613139383832663566 37636133643839356366363532333466336438643739636136656338653533616231626431613439
30653137386339623136356663613431303564633966383633643830393636383734306238333831 37643565333863383633656233326432636662386437663731373564616135663661313265373430
64363933386532346264666638316161393435653661356162306262323131643535636134396439 65303830313161366437633233326136303330373163343736333835333736643035623062656136
64656538313938623938323638393734343464653362636532386663303762326234373930363866 66383436313165626234326438656665303831386563656330336332653130633964626633623764
32306235363837396339383461396430373964396533616434373937386266396439303964356534 30393933643362653039386332366463633363353834363866343061343236663738613061613739
65663262343461343864633934636536366335326162393830633232633832383166366430313934 39623736393364383635373964323163353338613466663366333631383531333736313265656566
64643262313163323531326163323431386532386530326332326130353230653263616139303566 31313537343039333164326232333166613238626533363832323732333261323864333532383737
64386535326262326463663734303061613435653964303764386537636132646430626530313331 63393664303362636662643130373565356364386362386261626431306631326633373230636531
38323563303738353630653631333535376239393730633535353934316131333039353931616439 35386134326435326539663138343532646533623130363163343164356461366662303833353732
35343531353332303932353636643763643035376161643536333365666466616234326237633430 65653638613437653835653139636563313263656464373166373163633861333761326562313436
62666464613334633537306431396534306237313032306632323266613134363231313331313139 32656130313938663433646265656566646434656337376136383837313263396537346130343536
63316661303830653035376261633539386533373235653839333562656135636433333263303030 31343466356463316338393862643063653331363434646563653238316532343764636538393461
65333136653939616231363835616134373038633634656366363266386534343233393730386436 38666365306532643437333164383661313365363831376233646165656633633236313962643233
61366536333730646562343333313234663037616461643864613334313464326661366133613966 33313763623430336132363639663734363762643732383866373762393533336162666361326638
32343831376266376636646439323566373033643939663261356432623865313833316238363363 64633231623338383633386636343338643138343362636636393239373231643031663865613331
34353765396233336263373161323433396437363463626464386463346330653861656162346639 62646366353439653465616337346363373736616137306339663433303234626536323633633233
32653738353738396630383431346535616165306638666137616664336235623834616239613131 65623131343039613366346665393235626364333063663830393436333633626464326465353832
33643866396530643062633537376565306538313437623462363963363364303738376662323237 30663561306138333831313033396165313063613063626563326562316636393036666638656134
37313438633138666434646633333262643333633930396439333565663930613263363638366265 30306665393137373865663161323435366365393731303764353539656266653035643964373462
61376165353939633165646330656631323765313430323630303335643362393763663134333333 37326639613161373436663335356666323964383737623133623962666138373465346462623635
64383338343939616362363930626235303532666538653561656162396566383865363463373233 30353664336664353539346264363563383330363964316332663734613566363035383735633333
62363363623938383234343639656631343831616564633136363332393131356531343137396534 33613830343633343631313362393037646564636333316231346166393935343739323962633636
65626639613663323139393061353636313036396163646434613431323864303130663531343964 39623063613631383561363030666131636437326537346333653739363434656334383638343635
35363339336436336532303763333564363962313637653438626235353339333438376433396565 33343763363234323137396239653239386533613263366565343865616131666331376465666562
32646536383036353864356134623564653436326232643330613736386162656161336165363463 32383130636462363861326264376362626264393666393366346165633131643734366130313334
37646138323937343839373736656332643430386630383463393637643463396231303730656530 37666336376433313833336131623234306631363832343039613333666134396461636565353432
61633563633338663635353235663931303964333839663333323062646266646466356434343432 37633633616464373639343064613635643466306661306535373935643563623163306336376637
38303762336132333132373532623637353731306161316564373935303366666530376639613166 33313638303436336537663463373535663133356362643763643430313462383538383939393133
66383533663634333162373436313031643533336436373563356364323236333632303663393761 33336632633835346139373731626430626561316366363665353039353134386265323634623861
32343631643566626433306532343764373263666537346538303561323465646237396639363065 65653837393465666230346437336538623765393131656638323238616537326432326531323166
64646334633033653962303437396430646332306562623763323733303930623763393537646532 38363530343335643637666262363763326466376265656534636135376135336563343735306162
38336439636134666264663339346230326435383365616433363535613235626439623037666234 31646231303932616439306666396537316161633032636638653238616637353738373539616664
33393434306662333966316364303261643137356136326637346539363538353465313662393534 66666661633137306331366230616531336461353963623934313635356338633030376461636261
34306132613636633936616339636632623733616432323564393565336633366464306661306130 61316561633366386661383666393961646264396537383439626262373833343036663365393339
32373465656131346138343537373463663937636337303765366337376666346136643139356234 36383862656331643035303635303935346366383534663563653237386435306439653931353566
37376436313834366538636136303965383330663437343363633861396634373764373863613734 63383964353666616433353833626132626362383065306561393539666637653834646633663865
39363837613363663337323137616139633935643437353838383735383561346365333565316336 33346365333262633362326537646633383031313536626462666363373236313663313264636335
37373231343665343838663235656132613331663638323331363763373539323239306232656338 39396330376366636339313133666437393833316161663264373265633962636338306534626335
33643632333863333064666332663437613961393530616330643139663736313733306339346535 38663962303130646239653439626366326661646239333235336635346131376264303562336130
31386639363338656462663566313836313066623661653139306662636331373038396636386362 31663165313665356639613937323165616631656535353564366265316637373135353366623332
63623431336139316233396432396166633762363662616432616634363639616530663931366230 39383333356636323763623630616562633364623638666665663831336665323031343037623930
37396434343039303232363633626532383235376538626438356639386661373330663838663134 36643762646139356661386262373134386431643536663038636236623636633364666333383135
65666437363139663932383035303036343633303766343836363435313739363839373938643562 33613638613261316333333033386461386332303363303138313031666264623039393935633731
33393733303165373364643961326634396265323962313433653634326436336564313338323631 32323937356639343834656465303938623338356633393233353630653039346534643366346535
63666636646638626639323631323335653961363964616461393632316363623162653665333532 62353134643563323966326434353764366436653036623161356239326162343966356164613533
34346532313436356235343539343330656235376261326262646364393866343562323663363232 35626138633537376637376334396535643132336631636137653666363264306166333733393831
32636539343063323734663732303430306261366361383461363963346633316632623531363530 32373563366135336463613734623565666365383266613931656238333631303530363965643938
34343633666136323238336530653335643730326439666637663266363563636466306531393431 39396330326339336336313236336432396262643066396133633036653938613133616531393036
33306662653537643835656637636466383965356663373530386431383837373438373436383461 35323265323966386465663331613062313763663131643663346465623031626662646133333734
66653735376535656637626231613437336663333132386135666432656638636138383939386665 64616131633665653135666562373666333362306233323539393234303866396333646536363238
35346636313439613335653635653861303231663239613563333438303738356439343933326332 31363633363431616263376339376238313162363437376530653366626439333364623764373864
35653764613061356465666361323134323331326137353863666364333332326133343039353164 30333832383532613836376361663934313561373733343166383266616236643034616662313933
35663264326432366330353230643762623933346363623039656137356135613063366265626334 65366637656338653437616166396637323633386334613461363062386630316135366433323935
64666539373230376335623365373866613761373637393066343536386134633131393339343439 31326232323361366563343039663837366337373862353833613336336633636635616565653164
39353938643233636562353131336262616439623461363531383064616564616462383833363338 30316531663432353238663037323866393766373866353766663434633937303032653362323664
31396436303330336532333661333263346233626532653437633766306230643032633035636132 30653334393439363638623539363337666161393532363933653961323662343761313430616538
65343938643630303063336664393532363131333532323566306338343837323431373062393265 35646563373563363761663635646338383566363731363264653330663635386232613565613264
31653964376339626632313734366665613563383266376533353664363764366639393563313164 33306266346361356632326332306534643865323032346330663966333964313535383261316161
63646661373461323039636338313165306435323530633535353331343166636565373630333530 61633535303739343763333031353736643664323731363839353934326132656364366566343462
63343262656538616232313633616166623534643834313138326634356334636635363935343030 30356631313636616165623662356162643261646139373366333433623661313164316664653539
61373533373863633630616135626335376136336261646465666135383130633032656465623938 66373137666130666535356435353738663133353864323537396433346261373131383764373863
32306433303937363133623537313535633164346536626465366663623430383639386662396665 62373836333962333665376539356632383361353561616334653139666363643937633837353839
65393038383136306331323035373432666565373934653763336336323131353732356164366262 36653034333930326135363565363662313731383135636231343666666536363934623034396131
64396137353838303934363033393363666632323137663762626163303963386461373166323734 31323166323763326231333063656139333730373030653631323365343637343536303365643965
30336135396262323736313037653133623237336532663765323034383766363932666562343335 66623733656165333030643139646439343030666633366534396337663236623763376232346465
36623731326232653363663937626136386139663630376564333230313632646334353664303137 39333839336538646262383034646335323066613231353731666132313734663637396563373332
62363261316161356164313433666562303965646362626331323538663865386530393864373932 34663462306565393934383436363865343932613336396238313231323636303765646164356565
63326161656561393665396461313938316430353131643035353933326430653439643634316435 65663463636331633336626438656334393366343338383564383231353364316361386265666566
61626437376565363365303061346536386134356335646137626162663739656631343334653866 63376464656263336461333661383333306566663130363939323131316663633662653738306366
31323564663165376266656233316266363232333336356137353665613961303065373735353735 35626361376163393864653332326463653564373563396531643532343766366261653332313131
30663339343631633931376562316262356462656132666661383064333838323061616361383264 33613463313136336434303039356662313233366137633062636232346365366361636633316565
64666664616231363661636337323839613830363366373530313963613132643836353062646561 34363562613265643437383033393062663165313536366634303661303533663862363234313839
32396565313631623639666433663037336338313033373938653336366539653166376237326536 30613032666164313231633837343532306331366437376530633364656531633534643335616364
35323134313962613962663630376663333334373431383731383038323965333736356462303566 31653764646236656637373036656532366631363164346262353034336564373233626263383333
32633164663734366262383264376539313438343530303364623566383564376361343231386564 37343166653865306565383036333032653335353161626630376436626363663065343336653936
39316366373964333837656536393961613466353436333839396331326463356636623738613563 30613339393064393532643862333530656432343631353766353338363464626535366164666661
66333030376364383535633637383439383237636539623930623966356461366436323933646266 33366634383339666135613430333832316262376162336530343363663666386431653232616664
63363264643238333432346432363339323461646533333266666630663161653031626361366364 30653637353432376132316561373031373330373266333731393338613635653466343464313334
62613433333235356438326439306132376234326437316437373537346363303537626632373537 63636435646466393766653135333037623638356630636239303564353337323135613233336461
33346534306363323133353163663932633632626561313566383865393461316163616532666463 30383537326139343538643633326331366531353732643730326465363566623265303030376534
36333966626638336437666338356437643862303332353663303537613933643338396432663336 34313635346134636337623737633838336239643938316264616435303935363036323339316466
65613565353536396132373663663730386664323732616533303037363362663335656236383838 66653238613463353964383231366563316666376237333631393435666133343636666435643535
32666463323463363736316534393538373938303062396564333435383834646664346261383535 38373635363134346636333563653063656537646331316264626139323631383039303564376461
65653931623934363330366361303030306132616637636366313333303664656363623061346638 32663633623538636232306532623261633563376562366133366665633862313431323032643666
38626261313765303832333562383763363935656632343735613935356333346237313436326562 65363362363065626536653430653562316539396264633865346233343430613333303865636139
65353230353537373737326239636465656566346635303134363739333139666362333964393465 66656632363564363739306666316666346239336432363762373730353637333465363337653466
35663837633132663461383036353065656334303730636661363236646462353238343737633635 62313765336130356532636261366232666462616464383439366334646364383861616166383836
38663762656234376233623264343762303434353366363262353232386131303231373832336431 33346162613132656262663062383962323637333332373062323936313365323864326439373565
35636434346531376461393330363837633564613363373235623837373130336465383933353039 64386235633531663366376332353866366333643736663833396361633836353061323333656132
31363732656164333037316438393532336435663035346139373439326565653363343336306638 36363761353566643432363634323861356534393664373761383565306436303239613532623337
30383635656336643934623132383433333962323438303733396131333761643730343464633539 66366666613932356138636230306434396365356338343437643534343634313432643238353234
32306664393738353334333465363966613261333532393130366437353433643439316135613861 63393338303961353264613666346339396439313838633431366134613931666565333263316632
63303863313133333339323665343539343930356637356262316138613733613465343031376538 37636365616532656339303963633135613830356130376463336335633331333065336538643963
30633537396262636262663033656430313831616635363239646334356334323731626530303034 34616565353630393932393231646433666363356134623363323462386136353139363931326237
61343362383935343835316561333231323261643838616435313032313636613933363837343336 66336637643530616630656231316662356138336266363434363463306236623462303764643162
34613131316239653861626434366338343232326531396530373165663931396136373536366339 31306634313630663530666464303337386664666361633539383531346533626335333939353432
64306363393061333333663435666130366630633838333533306163313237653462306461303030 35316664303938656436363631666465336561626431616663646239313938623961626230366533
61353830326337333563356165663062316165373939333938373637636164646536616639366634 31373961373332313264616433316366626464323462326462613736643137633031646536353766
65353035353836356264346234653431383565373637383035323234316637356530346332643635 66383465376262393133663931663835656236313334656563386266396237363963663430356137
37326161333838643166313664616331623532356235323564356261663734613464666133313230 31383563616165356539356530353566383431656163663130343865623639306165306531363966
35393763353639323666313430666164323836633138373961666162316433396364393563626265 33626638353532643063656132326239363166336334356236313334646538306162386530666131
64373565393965313134613365653062323764346432653630643662663930353738353038643661 33333935653933643331363636623738366263643162396131616364333963323534333263373439
34376363633338353132363061363964346162663438626638373032326536363733396634303461 38396231636532663934326465376161626565333732353037306131343763343162636539373763
39306432646362666336393437396330643864353437376235313966393837393435373437313065 30656135383263653238623062373231636436613733373131646433616336396531316230373937
33303439343736626436613663353739656264313639626633323265643966643566623765653739 39383231386438316432373035626639343266633335653835656262643433393434373333643863
31336564393138356265623636306266363861323961636131336331653632626439613461383735 36303833396363663430393164666439316465313662343162323435653632663737326435356239
61616265646365636133393031333733363537366262326632363962333433656366313965303164 38646531613439633737326539383361356237336561373264663838313535396236373033343535
66663335636363343739313838633235323561343833346332353532663065363439303936303266 35303234656666663766323261613739623930396239636639393936653537383432613165636137
34356331633961383264393765316236376130323233346163306536386436366636636139363035 34613964313136323430383564326265386135316339323962633830373931613037303436633361
34623938366266343164633532646161633264316334313930383565623766396165663664393537 62366438313738633263343833303237383239333534656538393432666332376461326631303637
32373266656134333136393965366538316366313735653535626635663462623036323832373964 30613037666237366666616163303662346230633131393938643636383636313436616539633337
61343236616264373563386265656364333230396462653833373338356233393962663965346636 63363332333662303761396339313766383132373831346266323234313338343430656136643638
39313261353263373364373034653338383632643430323335306632353331633939383137396164 66633964636337353736373464376631306139663539333464303438616334306666623061346537
65313166633632366465663466633733663939303039316265313539303564613038313136623861 33356665666661356363383066376665386266363434636262643430626130323163306634306633
37383837653262633365333063646331663562356162333937383538623662633134613364653033 37343735626361393036343566346230363137326563643666316630636139343865346430356131
32613537633639656361336236323039383533303661366432376665323432386464393534633066 61643535396264663336663866653762336330396135666162303665643733653630346565623236
32616666613938373066313866306330393061396263386563306661643831303266633465396364 35336630616531383931386430336238663963316237643765666534636264623533643930376637
30613961636333373462386531373264386332303063663264393861363263666638643836396234 37633031393532646362333733356165373431363330323863343362643231636361343862633666
66613235643365316233353262663739313831373561633339396165386530313538653538633039 35633834323137626137346230313662373166373039653532636163613231313737396262643364
36353364656136306161636135663464343033306161653365653166643762343035336166316164 38656465346336333965663463393963303136636131383432376465303463616564616631346139
66346363646238636438353036353631663135356538663762323030353965386635306630643131 33336535666133653563303039353737333162616239643261323333316334336335306230303037
36666330663063626334386431663635646561643464616636633735626132383961663265626631 32396339343137386632636636353730336464386537353530353563656464376532326133613933
61653365373863313832366538363864333537643336623633343334633333323561373638613961 33313734346132313066303538303761633333303564343262313636346238323236626566356535
34396433366665333531373031336362306364633537646565656630323334643038383535383036 34376232616335336539333662373563306637613232643732376635323036303666393737316139
36633431633930313065663337366533323463333162326435626264393162333162656236306536 34303663323064623863393262316533376263636332653964343235613338616137366461386132
62323032616162313666303265636636323163303234656230313061363862343736373630626435 39383837663538653533343663613963613066376432626464373061653139323139313937353031
37616662393737656132326537336534623633613133343833313135336330303536393364336363 34663961313435653263306263366366366632356236366162623239363336376166353137623136
34383136376363306264306237653236326166323032383939616563353432323565666263646164 37366434326539333535646339323738336238383835303163363334663737333135643564333763
38346663373335636131366236313134623435633766653266383661363532353931323831363065 61343236616530376335323537636666363537303130383064663666623461303339633564333836
34663732643435623436323236316365313861346162613636323138303831353062616263626436 33373031306639653063313063613763343332303861666533383239366463306538346566356466
37356230643833323062336465306366613637633834303762323838656132333130646462653766 65323161646264326330663164633239336562373564616131306330343765636339353237363134
32393532636463363634636665643439366531303162306133626138653839316433343532646530 36626462313962303132636463326534633035666335376133376131623863666563633066336537
36383066303539386136643436393764613937313933396335376262323064663363396133373739 65623264653066623566626534633662396163393438306239353864616565333531623939663961
66643332333135333437623266626563356337366665336632333035323537343762376132373331 63653461633565326463363165356664373164383631656131633663343336663137636561343363
66303630353430313961356234616438363436323938656466636264636364313431396165623530 61353339613033336462366131366565386338663435346462346136326634646134613234626666
33306334656338623237313333386135313961633666363031376334653433366330303262363832 64386465353962656532656631613735383664653932376334663262306230306130613936353732
32333139643434623031653137393134343961353261366464383537643237623166396431356430 36663538336634346531396438386437633038313830633164353664306533343837353332636530
64356435356566336461663766393537303737393139653132363737343238366361316463306136 30666231666532303437393264623030373461306266316335613465313531396436376563653932
38313365333863303734336162666130393531356531623865356431653237343432613238633566 63383764623635383634646163363435366430383864666632313437356135303934633662343366
35323166623935336361653530373466653063383339396466356334663066393539356432616362 32643834363937393162393666373361306332613233663564653633373935613661633131666432
65643536316137326533363930333966666632393334333536326431633962353362313532383033 66623931643436633964353433383264616462366235336266643630376532336566636632303038
63376566643166353136353733353862643033363035353534643062303133633064366332623938 65363838313539623132636464336632386164306632316230363136643038353135373635626164
33363862306263663737616364633462363736666530613562643865393466656131663663663166 33613239623438663832376137616530643762393666373063623734633164663961386666613137
33313839623739386565323133323263613939623838396365313265363466666235316263313535 36616638653038353235376138323666653866393035393937353631653532353734646663623932
64393936396332376635343362396564346634336634336663383566376365353836383739333665 39653162623833316262633033336363633030336561663231363730396535623738613834376636
30653038346161623536633361363961623931326265666566316565636662633032663639373361 35633434306438643937396532663364343661623134646239306362623432313930643333643834
62653665366338656532373734663138353238356535353036336331383635306236656130613633 35626231393431653238646635616161343733363662376638666166613835356236393135623439
34323962316636333664616664313163643061623836316432366664623164303365613161653466 31623465653532386438376235303036383434616135653438343431333832396563353066643161
39333538343435383066343336313564386136363363326231353838373437643136616336393561 65303361383437323032653033393663386338353164613137643662663231336332643639613530
62626430393735326366353839393166313030376435303039643739373337653635316466643236 34306535643161363064646432663961666537346464393530326665353237356134633162343839
64646237303064306663396439316431393931626431616630396538626639636432326561653562 31396466386335656236343639316136323536363231376632383364353236376339376535326164
61363338623863363731356566306231366336363739623735313134613933373238303765386234 36343334643433636238373363663435383966666133636164623863653232383064326237333234
61363034313139626636643533643738663266343235333465663632343533646131393130666632 38376435373066306462613935313735316232383364333733636261323835646237363262323862
31366562643166616430613030333832303636616231646239646435616530343032376663653031 39356465363132336235613465663762323230623133373237383961363936623763643430643235
65636561353734656562626463643361633362313861373233346633636438653335323761373165 31363931333964656539383739646234666433346663653965373833353633366566303864623863
31613130306437346630313466393735643434376133336565656566323132653962636630313431 63336330343666386135343730623432393934663532656565386531393762633734653438633461
30386136643462306261383135336362356161666435323132626637346365396436653835363733 65323366613334626134636631353631323238643138356164656461643866363061303062626433
33393732633464626437366439303931623962313433626533363136653939316633326432356565 64373662643532303266346333633366383133333339373865363137356632666232363039646538
62393332656238303763363732623163653663373536393265343763373365316337323564353064 61613130363763303563393735373761336164626432356664393063386162646233306166336563
31616634653837396438303938343562613761363766616561643032643065346238383734376433 31633739316662356630353938383861613964333661316237616533363530376230613464333265
34613931366331366138346636376566646539303430353665626263643465313133323939613361 38636133646235643438303163396132616537663630356635346332316364343966393131383931
30613063656665666439646362656136656439643934623730383736353031363633653036643161 31646536303761383866366366303131363634383062623731373434646430323237313730383531
39366163663035663135343462336564383862336531656138313431316263653064306334633062 61366533666162373962326132396166326564623464646136653730326430623631363762396231
39386538616361623265663536636130383362633934313764373538643636353237323133303266 61343333613162373865353165663331336232656139633766356239343162326664313639363135
65613765313838363236656334663635636264356331666134643063636334633131353734623632 39613639373736346531656636646136323237646232343139323164323665363664363665316630
38316637306163363839366536333365663337383632376230373137386535303666343362316663 30373164333166346437323661646463323032336265656339656336306431636537363433363533
62663333313634313361373463313566363063363665323565633935303738316135393264663935 34636464353332663138313132363739353065336564333532366634646238303435646237303533
63643662333430633535663130343930373763366430306337656162383537376564653439343430 61616231333832623036326133316534343566323134376461623338653836346435366433346438
35656430366365636165386333663566313034646564636261333361373030663633373861323065 39366563336436333466636433396230663136306666393334313039656365316264313561613863
33363463303135336436613563633135353531623339616530353538356130656263383265353735 32656238373536343132333135653131643936666231393165643662616434623532393638363666
66383964313665623133303130323335393339663331323431363438613637636665623634313862 34346634326631373739313463356632396366653161616461333432303333363265373561613266
66336533626266343831363732613331653431306462313830343830336339663636396566343134 30316264366465643633323237393562373963396135616465356533653464333536383935663738
34396137396461376433653962613361323334393766303330633237623963653033363532613761 62353537663663363862303038306461363364353133303533663732356366323631363138383632
37316139336234366633343064313965393965353461383433366630633935336233383962616662 62613662616538326435353534373865373231393833633566333534306231363734356435633835
30356234663661343232633435346532313737376234623336366161303937623964656331633935 36343962363739343235373834656135386662326136396331333964306633623931646539313232
62333537303536333062613132646237373031663766623730666330666364313634366362363437 33663162306462333562613632666431316330396630393165633530333737356635323633623430
34643634303366633236363665643934323864303732613461326238323637393733393964663562 64653231663665646436656263323032353530613731336331383131643937623333656138646664
30386664636139313335333238373632353936646432326436666665326365646565383766316362 35366638333562373431663038643331346634316431646130663666643464343037373266353966
62376463626338643937663163336166663465353936626332303739393762313235323535366636 34663736333839333835383437363562326436363962313066613864663665373438353932316330
63666438636230383031333838613333633734623134363833383035623564613637633562323263 62373066373634346434616234376535306133386331663438613061623965383333323364663664
64356662376532333261316537656234636338393738613730656530613232373662363431393630 32336438376262376331663565643466396237323362386261643162633636316138306331343764
32323230346332613038373530643563353162366366636338623566373065373637623638333533 39356361396535373961613930623030353766666133633738366263643830343463633563626462
34633333663832393438616266653563623338626431636634303234333636613033333831316264 30373839373862323164313265316139336364633836356534623835653633646439346436663966
36333664366262343261373163373763373036653866663539343337323761633431313435323465 30343137353765653239626265356338653031633166313432353865373530373437643531333339
35386539636338613666393064303363303263323565613837623866633031303834363837626531 32613636336333373239336230326639663466383466393930336161376663386534393866396161
31363838326235643163353731333938386264323730323036333066326463326537613530373832 61306563373637653962393233663130326535373737396361633236363239633333393838316633
65303663613361336565643366316238336135396334303432393936336635306434653930643631 34656435366662366666313632333739353962343132343265363962343236636439663866623961
65393438643630313330383061643031343663393265646664356636313035313961383634316466 32323935363538313531376637356636623666346535313939626333386564323765303961343961
36633139373564623466386264303664373763386534326234323337633732633232346136366133 35306234333738386266343333373733326634666362396432323561613437306533333536323337
61363939336531633265396136623031396534363031393164646661383339653232393463636439 38626439626338376536343439343431356338353264633661613035393636633863303133616464
63306438613837313136646232333434323430643130313937376230613531383461306262366636 35353861653463386634313437323639633965323432636164343862353030323033386566623963
61633137643164653739343663393335373065386339393531343930373161376434356666306132 32303962353632326239613537623834373534356238633433633131313763353662353036333132
35383763613966353662333362396265326532303238656133316332363335346437306164653230 34613236316331373937666631386462653539623135656533386164613064383666346239356635
38333263323064653533373365336166613431303231373062376663343261663135623937373163 65666164386635346166383963366135623934336636613963393862656131396466386335616161
63613239343034303263306138376430613335643862303633343637373430336131623037373537 63633334386665666165643664363065396665353061323063613635643864313330643433313661
64323838306233396435656339306132623266363961643166303733373537333135303665336239 36616632633037323338636630643037643334613236653137343530653032326335306238323766
62623439623865363736343631613432633264363733353834386335333330313933383433346137 62356266656235333434396435646332363639356163363365623136306432643131663131386139
32386432393765356133353736306430376665626665663964313037323136326164653435383332 39666137313563373462653638646230643762393962373963623161366233393462343036373738
61346361313063333137356662383533623734633435646638363537303037393638316439316561 63636464393263356331663030646433663737323233646461323963383335363962616337373934
62376361363532623266383635323330633033333536636262386239353732633237663139393839 39623865623835623163323164626162393038303931343036383738306463643431383364663564
33633537353136376163613334393430663366636533616265633133373563653962393238373862 36623563363363326665303731643531666434323338373935643736306138666665313035306136
61376639663631323231376631353964643162616538353835363937663661313632333333373933 37666230373034333261643431333262646238396436346366383063616531653961623834346336
62323539643630303062333063386163656530623061613664376436623732386666336162396533 30306336323665366131323462626330343136346334343435313630653135333637383731613963
36343563643032356261363234663264303762663439363664353966313561623761336138313165 66636537386336313235386234356238306361623962363263313039393362353665346664363637
37633630663131313565346539396234363565646530386266666232306262663961663636326536 63646433393464623663653431323133393235313834646266396265343633663732396638363532
64653638643935636365313561313964653764363766623433353365376161623333356164346232 63663336653638643638336566383239303563393764303065636138376336623166356634396432
36646630666333383234343931343161646463633134316466616436336634336661356161303764 64353763386230626566646634316437383963653462346435336536363462333038303966663931
62646362333139373761333035386666663561643837623665656630393432303839666666303339 38613732306431323937393336386133396362323939356330363132653736373433373834663433
31646461613838353033343665396239616538356164333436353133653134313738343530313366 66366333613263396131396434386565333065623335353661363336373530393934646630306337
33646530373561633834383865393961353032656339633732646533363535343630613937323461 62623062633038316530303865373662323332353065386430643530383335316565356135373666
65363262643937373638633838343266323966623039616333656538363362333962353066323363 32323731663461303138613064346138613333353534376232303538323132313039386264323732
36326138323132333837343130363735623861616334343130363064303030306361336161303832 61653033356337653735323330303462303439666464393262623062663736626661306139303837
32343534623662393566653131363866343637373930643434326338333362646233383231636235 34663330313539336663363830363963613561336664633330326265343333653839663462353230
33636433346464363766376634636533366163333064313534313831636238376639393533616566 39373938626266616239643939353937303635343661346535663564616136386337633533376137
66333633613261336361366234643966663536653736323235663030626234386166663033376234 61636135393836643764633838633361393230303730383932333564363739633137343930336331
32623435383837336330616235313165636537623462626230346535663662386337366532363733 63343937383763363436366335663763343534383963363266346637363437336635376566366230
39306631626262623630393137653763326234366139626461643931613736653932316466396163 36633535356239386238646136376431383061633939656663373735383235326663623365306438
66643038343336326533366438356562333561616138383330643235663633336262303266613630 64366433336638333235663437353463633937396136663764623166353864613162303832383566
61346237313730303666346639366439313865663430643536336338313130656438613831383633 38303664303962396362643631336536353264616335643332353137366431396464326635396634
30613862363961373732333863636134623431626361396562393437613533383738613761363835 38333038373933663236623632363431386337616566313765663633323163663933303337663938
30363831363963623133653038383031323364346237326164313232363936656132353732373463 32613133343730396666653461373161313139303063393639613932393864376337323363376332
39383737303034346163623737353537646433366539663963313566363032343836383134636131 66623266613962663066353335326230616438616562626337363965626564663666353064353530
66363664336435376563343765623737366632383431656166303639366661633565623331613530 61393934643933343132306265653235383430653837316365383265353565343762616135633866
39396131613161663436333736666536656364663361626633333637316130646266346165323632 64633766653133653335333061303233393038633665363566633238323530373239656661666161
35623062353732616535373166336564383166663861613239373836646637616333613639613862 64393262656162356665646661383861303765383232363639303065363061303332336562333732
66396434333039393135623733653938336233613235363134636362666363303337383335303136 31303363653136313732646362303838343838373533373338313838653633643136666631363638
66313061323938643432623833363930303236633462363663323362626332363565656135363464 31613738396463663931393064643132373564373837333337343832633837336663336262616665
63376266653132333565616363383564353135656431393062393333626336663738366331383737 63326134376538313466353333623633383861613534373534336464396261643233656130643765
30626239366666366263353662333166303063386364366530653731353135313761343461646238 31393631653630333233623339666535323031613939613835303764646161646132306635643161
39353531656232616530633330393130663862623964363565326233643431343432313039333234 37313361323165393034366365396139643233386637323734633463613931656238663537373562
63336232336535373032383933393763623538353061396534373632323165666134333465303066 37396265373364316561393433363635326365356338623038656439623539623139326235336632
39333366303231363863333133643062333230316361623635656663323737643965663563376564 32396264366634646330346232376636313038656164323134653866383864363864383965356666
34343134306333386663356339616461646639303835333333623363383364666436643436343533 65336363333035383637303934383533313663326431323637626133363338363538633634333035
38373364363765343265663138616439316633623865343939353465353466613065653366336230 34653961623933376534613239663037396432316237313137396339623239393635383966663164
39636664303335316538326136356361633366373761333635613139633665616433386162623965 65636266633439376135613832383634343633323366663833323533386239643762303638623738
32636266303031336162626132363931363163313235363865353334616137643934353637643633 35626335373135636261616138363739633134623738633966366139643365323264326239646636
33626137376132353735303737343433323462343830633731396565383037313232313862396330 37373231656436343431613830303662336138376437613938343439633534666563323930663938
65383564386564353634636430393562623833373533626632333530336266633038663333663063 65643036653265396237343164336365666337633361616433393561356537316438336331666365
32313534666162666134373533313730663730333933613762303238323065363236363166326361 63313964373235346534326639643264613539326366653938646663333365666538373530363064
39633938373861366133316261303665356661393830366131343138623138616233623961386233 63326431393130336165613632393662663563393066346562383534613833346661363933386135
65356634333833313061313134333364306238623637333733666366373762383732663037643338 63366636346539383636356662306162353161646132336630303739313866366434383464396136
33333263356262633366346130386337396263646333636134333935343835386366306663663962 39643163623433636164626563306138653265623563353262333831303438363739393762333464
62306232653735656365643533653635366232326638386563393365376464653532656136656266 32313664353563663364366636313366313639313134356332336562363864663862666632623832
62393830656339393033343638643334313765383766336338363331386135373864383939643166 66333236343136653439353066326434633032353139323563616666336239343730336530613761
66316437666434643266326235663632363161663338336434313134643533383634633936336665 62376231633237313732393835346665396634316263356630396463343137363936623064306461
62653638346633336331333364623363376538373562663437373933323932663533333733633036 39663935353463323565386333613464363438653237633666613261343833386336333261323561
39386333653636646133366461633237656166396664623136633863653339633463346135393566 33303662613239333362383661636235643861393734333463343464633930396434303562613034
39316161613532386432306364333662313261346631636430363061376332633431356132663066 36383130356635623665306362623235373364666137636336313066323933613836373431623635
65643137303961383539663733653863346131396232346263393934326239383735353565376533 30326465323366306566636637313631363963316566323039396430396139336234306330316465
33356438336463323531326561326264396635623734663835363039613162613762393662363562 34623339396136643938353066633036643837363562646539653533303035333562373436373031
32646634643934393263616335633933386564373162303766376661323864306230376533633539 30353438636430376265613038303633656230353934636163383465303363613936613236663366
38363032353639633735356266616330396333373665373035643334336566636534383831323862 63626637633432303463306666356562363065313335356530346539323961313035393865666633
33623866386539376131393531653562643430663765316539613438663733366566366533393761 36646332376238663166666264376132336637316561616532623534633430646139373532663138
31303836636533656535383165323232353065663338646337323530363133393263653930666334 66313066363435303864623866393462346137633730323066623337663335366461393632626138
62323836353563366637383138383864323438353339653434306532336164306238633437646235 38636635626365613561313334616230363936383231356465383439313138613136356462316634
64366536393039323339616130663939393562363935633435373932396462323533363838356663 63666131386339666464356533386333663562373733353434616666666639386365633732303737
34343138353130383734363234366661386638653834643661353031353031343562613461393966 30663364636336343362316135323236346639663939373365396561663766313362393838633737
31386463366635353965386462653461653866393864613063636564313638623734663864333334 36323835343731663235363234306332616336313035636235393038316438366538616162626634
36386239336431316338643761363566636137333161663833623633376262623830383961613931 36343836373966383538353034313264313732653635396233613835636461646231353365616163
33666136653631663131626631643266656430356364343138353663326435656663373035333934 35383835333466343934333065303538366361616436626338333137343332613965323964323836
33636339386639666532333763393735386231376666323733333030353239373333383264323130 66323665333461323864346635393636333163396334663264393337313863646139396664613163
35303437616433393231653739653230326263616233373166316237346138336364353339653339 30653637613864646631666364373162313939353339613732643637353635653665326164383261
33353132636262323030643465313231393763613861343734363031386563646337336562336463 30336135313766323933383232323630356139343934643863356434333663653830636561613731
37303232663235313335336435333032626432633531306638313561646236616230306135313639 65613861333639393434646436646131353563663864376564626439316261303161336336383739
63633938326266656138383635653339393238303964386663366638643734633866333432373737 32356635656637633066303638376436353261616232313065303431653036656235323337353933
34636433613566366132613864643135356136336162643066633035663533666237376437333762 39623930396239323766346362336131653137663533343133333937626535656236343639613363
64376262633064333635623438353732376466366338323636643662323234326139316534646533 62396338636336323531653564366133643866366432353566366661393263666164653630363738
33666432303636373963666334376263636634323532643837316361376338616430346464656465 65316562333131643966376634396335383131366233326231386432623364323235613635373635
33373336653539303163336239613038386230363839353532336335303564306363666665333530 35303931636466303130303233326531343539366238376363333138666234636532363965383239
63393863363232653138356234323161323931353932326137653430363836323863393035356230 62383734363734643165373734343963626538396237323530643333666131366365353538666662
61633161623761303335646435383934663135633734346433643639383932643662393264626430 37643939643661383138383638363431343330363931306162303963366230636364633066373537
38303735343033303765316365353834633461386636653662393538643339346230643335656438 39353462613265363439633961346331323864326536656536376534666534623936653231313263
66386164326535646335356661383735366134613939636130383034633734313739333635376233 30336232636434646666393566653164653834336563313236323965306663663431656239656163
35623130353239323237386337623962616536643463376632613631646436353630366137343838 66393136333937393863396531376561656538316164356265343532663262336539363662643962
30376136616261303235643437613864613031346564363465646533363033303566613061363330 61643031383037653964383661386238636633646232323565363162643239393330373263326432
63306530323938383737313438333139363862366331336665623139626135353236316664643661 30643561623733336234373962376239346663363537656462366134623563363762613462393333
38343165383537643136623661663730383635376263346131303332646464376263303464373734 34346431393136613866386338303066343463316233633933363636656630363638363264376263
62366331316166633631663163306237343862306365326339653363336638633830646233363665 38343564376564616665666432376336383833633765383263383137306235303934373230653862
61393461326535653736363834346163343736666665623838643937306539376266393761633263 34333233303037326432396365393339613261663266373465306635616435616363316563326436
39316163356262633137313261353463373166633735663035663135326437383534616239343661 38626532363862373832386335323335646462633138396162663134393036666166633337373638
64633762613833653365353234363539643533353239316238636537323064353337316361353630 36376562656434363835303336343638306538373733353062313530316363353161663732303836
39343463643236656431623861333734313838346430356630656131383763386534373635666331 63393363306336376564633462373265313539313130373434383539363462623131613862333936
34333137346266353265376431623935336366623461666237666334616634613162623761346161 62336630346133366264663665663431313437633762663530636266623935313766346233633438
35366334313837333939306563316236313839623236373234623635656138656165313662656632 36326164333839323562313930653038363262326539393932303237663563346664363032336630
31343961363864346438333932656264363932646132366136663339363930386435666639663135 38353530346166356231323062663666623961623835656265306561373332613937303739663965
61313037303762626537393330366131616532646666613338393032353837643835353436383539 61303765353437333465323165396438623838333635303164653764336633363633303935336264
30323064656161636534653664336232613565626162383035636131393539386633643436653633 33383934396630663564313163633466393861326330646461386536373336633135623030646266
37343339663531396533323432643464393431623338343537646138653236366331366261663631 37333134616531636135373063396330666538313736623563363432323936633163643162333466
65386666313531323031353762363437653561653534316463666539636439383361373861646331 31323739663931653235346235663832396437303932383066613633316362613563363530396463
66626434326566653534393964643938393463303930613734336161356431363230363261383139 64623363366237616632326531633932343231366430366633333633336462336634613033363830
38356663323539636533663237306566623062333036666636663637306332626139333738386365 36333661343765303336393263656231313739323038363038303436396130396335343539336561
38363635386439646363626631366430393636613763646539303939643764643966353461343466 65396532663637663234373431616536653661626665363634303265393931353232316236386433
39363331343334653637656561383638626637646230346665393366313366633366653839663839 63656266613965366439326630336533363166323935393230333930353166393465623438393830
39333263313566626135326633653238626566633536353461376462613462303830353638393161 39343332613462323437653865343333353338326636363735366462303766343433623136363739
36366433656261313436663466366433663138613561386336383262646663313836633564353266 35393630646537646333363931393764653036306261306633353463333939613837316265643961
61623139323961346564623939303339366233643231373031626165646364376138383935626166 62346234373039343066336534363065636339313031353264313537393063346566366263373339
38373833613033633630313965336438626363353031346630376532393932353961363732623631 65623539316635386532346435303631303030656262396535663433346138333963353732616562
35613038366664663337653230653134373861666430343666623165306130306137383163396235 32643334663139616439303639313333303064396563376361303933393437356664636331343631
66633035353534386262663336646361303236623365613736383938333261653462386636376531 33326565656632656638656331653435643433646533383036653264663232313730346331333139
37616539376637386462373063316535636365626634613462623232333435313733326261373230 33393238363165656137383130373137616132393366373930353161363232656532363936303038
66373061336231663230386133613665396263646563376534313830316439623363653165613666 34353463303666363730353235323034386230373333653263373238343863333233663261616431
62373063633863656566643333636461353339396635396331633734623266306430 34316135363537613762383036366333386165346361386165663939623639323535313237353365
63373135633538303232643065323232353434356365393464373963623066316138636661616632
34333332386432633933303465353564636361316435356163633765383833303437656535356130
62363863633433323231353932663534313166373734353837303861336162633438656237653137
31356330306236353432373431306631306561633264656663383266653266633661633237643461
34383566636162396234303233303534613461366466626135643336633635363037376638346338
65356334303633306536643966336561646531623633663664653461326237383331303232666666
33363765393334326130633634396163616639383339306137393364333233633539353263306663
38313866363737653932616339653230613830336132643361373933323466303038613831353066
30653963343637326364363537343437376661303937316464656234636161313235613839346464
38386636336262356561623566343931626237653663353137633935653830383033393035666565
62626239333731623337616636386361376565663966613562383639303338643737333063333161
61613937393865393037313932363639343562303561653633343266353237356666663966633331
33333430636439313464373632396163326531636231623066396462373738653366646238636535
37343461323565376339376362356364663239653463343362613966653337343733613366306431
64353862653365323336393330333863323564613664613838653036336466323239343161663430
63373039333433396238376134353666356632396434303166346134333562323862653361626134
33353138623863623739376134643561303565373261326133626630313035313563663763313131
31613832643063356261363639616265316332353864393764383532333538393938336433353333
62366334343139656139623165323935646439383437353636326666393935663333653533666132
39396539376263623834373137323430643538383336313932376166386465303361383166366463
30373466333663343835343730383331316536383261333934323166333765633339383161303630
65336631393638333638383431663035336162333062613630323130643663383135643161373736
31643465366539626136316565373666623631356434313633653633323533373531666265306235
62366237653136626238393462633165336532633734336662373130336538343162383366633166
63333333643038613561633137366233316432363836316333353839313432613837323364393031
32653165616432386565656666383737356430666239313363396263306335316435356232356238
32613032376536323130646439613832343364306431616337343730616239383730613139373365
37373638393264633935383166313130633864646635643734653832313037613132663666353666
33376466313838633530666136343132316530613662346431633063396165386133666230363333
66346630633366396137356430636330373562336638393936303438333531656661616161373939
35663037373332366262323731633166383336393236396461363535643535356138303266323862
39643332373534376234636133353735346662366435663930643464636135636633306366383835
38333137656264623765323239306365306661346466633638373735613735633063383139373464
34653934376565656438633235386433636434316565646634303332313636613662363661306138
31313562616364633832623934306461313739376236323334623761326335343334353561303762
66633937363234313039343534353435623035636234363462306363343035636536363262353165
33623930343234613964663332383833383433383537636163616137373134373032333439633937
38623033653437316336663931306235623264363934633139396132366430363133616163663033
63613631663963303635316161306131326137613439353564353438396564313635623030643935
39333866633130393062303332346566653461353036313134626131363766323632323465656363
32336131643466623139356639613035623531653061393666353431323638373735623066333564
63303664373765633830346534333439663963393163333764343162303265663465346130376164
61376631393438386430626663653936383330643131376431303136333030346466336439326466
64663962373731306462643662373166383333313164626135633136373930393734373932663663
34386235653037326332653432653435636132636263363938666561333436643734653264636431
31653439663264623837326561383464623163666237653433623433373336383931303066663265
34313663663966383133666534323366323739653336643437303564386363386363373033356464
35633364623062353561613331306463396162333037343037363966323330636131386530376338
33376234636462613239386134396335376563623039323033323064306537653136653239636161
38353766633562623135343461653433663338353939633330353462323737353536366564306134
39663034653039633463386230393032616638353737656438653662653833643861393235636331
30383039393130623362303533613535313365663434313662323633373039393762633731666232
37643635313865336637376666363432336563613735646433653337346566613764366466346365
646230383034313737303435323762633061
+17
View File
@@ -63,6 +63,14 @@ astro_orbiter:
hosts: hosts:
astro-orbiter: astro-orbiter:
claude_servers:
hosts:
haunted-mansion:
ansible_host: 10.1.71.137
ansible_user: wed
ansible_become: true
ansible_ssh_private_key_file: ~/.ssh/ansible
hermes_server: hermes_server:
hosts: hosts:
carousel-of-progress: carousel-of-progress:
@@ -85,6 +93,15 @@ paperclip_server:
ansible_user: wed ansible_user: wed
ansible_become: true ansible_become: true
multica_agent_hosts:
hosts:
haunted-mansion:
ansible_host: haunted-mansion.wdw-labs.cloud
multica_device_name: haunted-mansion
spaceship-earth:
ansible_host: spaceship-earth.wdw-labs.cloud
multica_device_name: spaceship-earth
multica: multica:
hosts: hosts:
muppet-vision: muppet-vision:
+11
View File
@@ -0,0 +1,11 @@
# install-claude.yml
---
# Usage: ansible-playbook -i inventory.ini install-claude.yml
- name: Install Claude Code on Ubuntu servers
hosts: claude_servers
become: false # the role escalates only where it needs to
roles:
- role: claude-code
vars:
claude_code_install_method: apt # or: native
claude_code_apt_channel: stable # or: latest
@@ -0,0 +1,83 @@
---
# Example playbook. The role takes multica_pat as a VALUE — pick ONE of the
# lookups below (or any other) and delete the rest. Changing secret provider
# later is a change to this file only; the role is untouched.
- name: Provision Multica agent hosts
hosts: multica_agent_hosts
become: true
vars:
multica_server_url: "https://multica.wdw-labs.cloud"
multica_app_url: "https://multica.wdw-labs.cloud"
multica_version: "0.6.5"
multica_node_version: "24.22.1"
multica_workspaces_root: "/opt/multica/workspaces"
# No NAS mount under the workspaces root yet, so the mount guard is off.
# When utilidor is mounted, switch both lines together — never set the
# root onto a mount without also setting the guard, or a play that runs
# while the mount is down fills the root filesystem instead of failing.
# multica_workspaces_root: "/mnt/utilidor/multica/workspaces"
# 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
# that is the live form. One PAT per host (named daemon-<host>), held in
# the global vault (group_vars/all/vault) as a dictionary keyed by short
# hostname:
# vault_multica_pats: { haunted-mansion: mul_…, spaceship-earth: mul_… }
# A host with no entry fails the pre_task below, by name, before anything
# runs. The indirection keeps the name greppable and the file swappable.
multica_pat: "{{ vault_multica_pats[inventory_hostname_short] }}"
#
# Alternatives, if the backend ever consolidates. Each is a one-line
# change here; the role is untouched by any of them.
#
# HashiCorp Vault (community.hashi_vault):
# multica_pat: "{{ lookup('community.hashi_vault.vault_kv2_get',
# 'multica/agent-host').secret.pat }}"
#
# 1Password (community.general):
# multica_pat: "{{ lookup('community.general.onepassword',
# 'Multica Agent Host PAT', field='credential') }}"
#
# Environment variable on the control node:
# multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}"
# 1Password service account token for `op`. Off until the service
# account exists and the delivery design is agreed (WDWL-37). Same shape as
# the PAT: a per-host dictionary in group_vars/all/vault.
# multica_op_deliver_token: true
# multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}"
# The role's own PAT assert is no_log, so a missing dictionary entry would
# surface there only as "censored". This checks key presence, never the
# value, so its failure can name the host.
pre_tasks:
- name: Require a per-host Multica PAT in the vault
ansible.builtin.assert:
that:
- vault_multica_pats is defined
- vault_multica_pats is mapping
- inventory_hostname_short in vault_multica_pats
fail_msg: >-
No Multica PAT for {{ inventory_hostname_short }}: add
vault_multica_pats['{{ inventory_hostname_short }}'] to
group_vars/all/vault.
quiet: true
tags: [always, multica_pat_check]
roles:
- role: multica_agent_host
+84
View File
@@ -0,0 +1,84 @@
---
# =============================================================================
# provision-wong-host.yml — spaceship-earth (WDWL-22)
#
# spaceship-earth runs exactly ONE agent: wong, the personal vault's librarian
# and its only writer. No other agent may be pinned to its runtime. The
# isolation is enforced by the OS, not by agent instructions: Obsidian Sync
# runs as `obsync`, and Wong's daemon user (multica) can reach inbox/ only.
#
# Run order on a fresh host:
# 1. ansible-playbook playbooks/provision-agent-host.yml --limit spaceship-earth
# (multica daemon user, Node.js, multica CLI, Claude Code)
# 2. ansible-playbook playbooks/provision-wong-host.yml
# (root LV growth, headless Obsidian, vault folder isolation)
#
# Step 2 needs Node.js and the multica user from step 1, and asserts both.
# The Obsidian account sign-in is manual; see roles/obsidian_headless/README.md.
# =============================================================================
- name: Grow the root filesystem to fill the disk
hosts: spaceship-earth
become: true
tags: [wong_disk]
vars:
root_vg: ubuntu-vg
root_lv: ubuntu-lv
tasks:
- name: Disk | Find the PV backing the root VG
ansible.builtin.set_fact:
_root_pv: >-
{{ ansible_facts['lvm']['pvs'] | dict2items
| selectattr('value.vg', 'equalto', root_vg)
| map(attribute='key') | first }}
- name: Disk | Split the PV into disk and partition number
ansible.builtin.set_fact:
_root_disk: "{{ _root_pv | regex_replace('p?[0-9]+$', '') }}"
_root_partno: "{{ _root_pv | regex_search('[0-9]+$') }}"
- name: Disk | Install growpart
ansible.builtin.package:
name: cloud-guest-utils
state: present
# growpart exits 1 with "NOCHANGE" when the partition already fills the disk.
- name: Disk | Grow the partition
ansible.builtin.command:
argv: [growpart, "{{ _root_disk }}", "{{ _root_partno }}"]
register: _growpart
changed_when: _growpart.rc == 0 and 'CHANGED' in _growpart.stdout
failed_when: _growpart.rc != 0 and 'NOCHANGE' not in _growpart.stdout
- name: Disk | Grow the PV and VG
community.general.lvg:
vg: "{{ root_vg }}"
pvs: "{{ _root_pv }}"
pvresize: true
- name: Disk | Extend the root LV and its filesystem into the free space
community.general.lvol:
vg: "{{ root_vg }}"
lv: "{{ root_lv }}"
size: +100%FREE
resizefs: true
- name: Headless Obsidian Sync with vault folder isolation
hosts: spaceship-earth
become: true
tags: [obsidian]
vars:
obsidian_headless_vault_path: /srv/obsidian/vault
obsidian_headless_device_name: spaceship-earth
obsidian_headless_private_folders:
- everpure
- wip
obsidian_headless_shared_folders:
- path: inbox
user: multica
roles:
- role: obsidian_headless
+35
View File
@@ -0,0 +1,35 @@
# claude_code role
Installs [Claude Code](https://code.claude.com/docs/en/setup) on Ubuntu 20.04+ / Debian 10+ (x64 or ARM64, 4 GB+ RAM).
## Methods
| `claude_code_install_method` | What it does | Updates |
|---|---|---|
| `apt` (default) | Adds Anthropic's signed apt repo (stable or latest channel) and installs the `claude-code` package system-wide. Verifies the signing-key fingerprint before trusting it. | `apt upgrade` / set `claude_code_package_state: latest` |
| `native` | Runs Anthropic's installer as `claude_code_user`; binary lands in `~/.local/bin/claude`. | Self-updates in the background |
## Key variables (see `defaults/main.yml`)
- `claude_code_apt_channel`: `stable` (about a week behind, skips bad releases) or `latest`
- `claude_code_package_state`: `present` or `latest`
- `claude_code_native_version`: `latest`, `stable`, or a pinned version like `2.1.89`
- `claude_code_settings`: optional dict written to `~/.claude/settings.json` (overwrites the file)
## Use
```bash
cp inventory.ini.example inventory.ini # edit hosts
ansible-playbook -i inventory.ini install-claude.yml --check # dry run
ansible-playbook -i inventory.ini install-claude.yml
```
The remote user needs sudo for the `apt` method.
## After install: authenticate (not automated)
Run `claude` once on the server and follow the login prompt, or export `ANTHROPIC_API_KEY`
for the user (store it in Ansible Vault, not in the repo). A Pro, Max, Team, Enterprise or
Console account is required; the free claude.ai plan does not include Claude Code.
Verify with `claude --version` and `claude doctor`.
@@ -0,0 +1,39 @@
# roles/claude_code/defaults/main.yml
---
# How to install Claude Code:
# apt - signed Anthropic apt repo, system-wide, upgraded via apt (recommended for servers)
# native - Anthropic's installer script, per-user in ~/.local/bin, self-updating
claude_code_install_method: apt
# ---- apt method -------------------------------------------------------------
# Release channel: "stable" (~1 week behind, skips bad releases) or "latest"
claude_code_apt_channel: stable
# "present" installs once; "latest" upgrades on every run
claude_code_package_state: present
claude_code_apt_key_url: https://downloads.claude.ai/keys/claude-code.asc
claude_code_apt_key_path: /etc/apt/keyrings/claude-code.asc
# Fingerprint published in Anthropic's docs; the role refuses to continue if it differs
claude_code_apt_key_fingerprint: "31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE"
claude_code_apt_repo_base: https://downloads.claude.ai/claude-code/apt
# ---- native method ----------------------------------------------------------
# User the native installer runs as
claude_code_user: "{{ ansible_user_id }}"
# "latest", "stable", or a specific version such as "2.1.89"
claude_code_native_version: latest
# ---- common -----------------------------------------------------------------
claude_code_prereq_packages:
- curl
- ca-certificates
- gnupg
# Optional: content for ~/.claude/settings.json of claude_code_settings_user.
# Empty dict = don't touch the file. NOTE: when set, the file is overwritten.
# Example: { "autoUpdatesChannel": "stable", "env": { "DISABLE_AUTOUPDATER": "1" } }
claude_code_settings: {}
claude_code_settings_user: "{{ claude_code_user }}"
# Run `claude --version` at the end and fail if it doesn't work
claude_code_verify: true
+15
View File
@@ -0,0 +1,15 @@
# roles/claude_code/meta/main.yml
---
galaxy_info:
role_name: claude_code
author: ryan
description: Install Claude Code on Ubuntu/Debian servers (signed apt repo or native installer)
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions: [focal, jammy, noble]
- name: Debian
versions: [buster, bullseye, bookworm]
galaxy_tags: [claude, ai, cli]
dependencies: []
+70
View File
@@ -0,0 +1,70 @@
# roles/claude_code/tasks/apt.yml
---
- name: Create apt keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
owner: root
group: root
become: true
- name: Download Anthropic release signing key
ansible.builtin.get_url:
url: "{{ claude_code_apt_key_url }}"
dest: "{{ claude_code_apt_key_path }}"
mode: "0644"
owner: root
group: root
become: true
- name: Read key fingerprint
ansible.builtin.shell:
cmd: |
set -o pipefail
gpg --show-keys --with-colons {{ claude_code_apt_key_path | quote }} | awk -F':' '/^fpr/ {print $10; exit}'
executable: /bin/bash
register: claude_code_key_fpr
changed_when: false
check_mode: false
become: true
- name: Refuse to continue if the key fingerprint does not match
ansible.builtin.assert:
that:
- claude_code_key_fpr.stdout | trim | upper == claude_code_apt_key_fingerprint | upper
fail_msg: >-
Signing key fingerprint mismatch: got '{{ claude_code_key_fpr.stdout | trim }}',
expected '{{ claude_code_apt_key_fingerprint }}'. Removing the key file.
quiet: true
register: claude_code_key_check
ignore_errors: true
- name: Remove untrusted key file
ansible.builtin.file:
path: "{{ claude_code_apt_key_path }}"
state: absent
become: true
when: claude_code_key_check is failed
- name: Stop if key check failed
ansible.builtin.fail:
msg: "{{ claude_code_key_check.msg }}"
when: claude_code_key_check is failed
- name: Configure Claude Code apt repository
ansible.builtin.apt_repository:
repo: >-
deb [signed-by={{ claude_code_apt_key_path }}]
{{ claude_code_apt_repo_base }}/{{ claude_code_apt_channel }}
{{ claude_code_apt_channel }} main
filename: claude-code
state: present
update_cache: true
become: true
- name: Install claude-code package
ansible.builtin.apt:
name: claude-code
state: "{{ claude_code_package_state }}"
become: true
+44
View File
@@ -0,0 +1,44 @@
# roles/claude_code/tasks/main.yml
---
- name: Assert a supported install method
ansible.builtin.assert:
that:
- claude_code_install_method in ['apt', 'native']
- claude_code_apt_channel in ['stable', 'latest']
fail_msg: "claude_code_install_method must be apt|native and claude_code_apt_channel stable|latest"
quiet: true
- name: Assert a supported OS (Ubuntu 20.04+ / Debian 10+)
ansible.builtin.assert:
that:
- ansible_facts['os_family'] == 'Debian'
- >-
(ansible_facts['distribution'] == 'Ubuntu' and ansible_facts['distribution_version'] is version('20.04', '>='))
or (ansible_facts['distribution'] == 'Debian' and ansible_facts['distribution_version'] is version('10', '>='))
- ansible_facts['architecture'] in ['x86_64', 'aarch64']
fail_msg: "Claude Code needs Ubuntu 20.04+/Debian 10+ on x64 or ARM64"
quiet: true
- name: Install prerequisite packages
ansible.builtin.apt:
name: "{{ claude_code_prereq_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
become: true
- name: Install via apt repository
ansible.builtin.include_tasks: apt.yml
when: claude_code_install_method == 'apt'
- name: Install via native installer
ansible.builtin.include_tasks: native.yml
when: claude_code_install_method == 'native'
- name: Write Claude Code settings
ansible.builtin.include_tasks: settings.yml
when: claude_code_settings | length > 0
- name: Verify installation
ansible.builtin.include_tasks: verify.yml
when: claude_code_verify | bool
@@ -0,0 +1,20 @@
# roles/claude_code/tasks/native.yml
---
- name: Install Claude Code with the native installer
ansible.builtin.shell:
cmd: set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash -s {{ claude_code_native_version | quote }}
executable: /bin/bash
creates: "~{{ claude_code_user }}/.local/bin/claude"
become: true
become_user: "{{ claude_code_user }}"
- name: Put ~/.local/bin on PATH for login shells
ansible.builtin.copy:
dest: /etc/profile.d/claude-code.sh
content: |
# Managed by Ansible (claude_code role)
case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$HOME/.local/bin:$PATH" ;; esac
mode: "0644"
owner: root
group: root
become: true
@@ -0,0 +1,22 @@
# roles/claude_code/tasks/settings.yml
---
- name: Look up settings user
ansible.builtin.getent:
database: passwd
key: "{{ claude_code_settings_user }}"
- name: Create ~/.claude directory
ansible.builtin.file:
path: "{{ ansible_facts['getent_passwd'][claude_code_settings_user][4] }}/.claude"
state: directory
mode: "0700"
owner: "{{ claude_code_settings_user }}"
become: true
- name: Write ~/.claude/settings.json
ansible.builtin.copy:
dest: "{{ ansible_facts['getent_passwd'][claude_code_settings_user][4] }}/.claude/settings.json"
content: "{{ claude_code_settings | to_nice_json }}\n"
mode: "0600"
owner: "{{ claude_code_settings_user }}"
become: true
@@ -0,0 +1,17 @@
# roles/claude_code/tasks/verify.yml
---
- name: Check claude --version
ansible.builtin.shell:
cmd: |
if command -v claude >/dev/null 2>&1; then claude --version
else "$HOME/.local/bin/claude" --version; fi
executable: /bin/bash
become: "{{ claude_code_install_method == 'native' }}"
become_user: "{{ claude_code_user if claude_code_install_method == 'native' else omit }}"
register: claude_code_version_out
changed_when: false
check_mode: false
- name: Show installed version
ansible.builtin.debug:
msg: "{{ claude_code_version_out.stdout }}"
+4
View File
@@ -6,6 +6,10 @@
# Directory layout on mad-tea-party # Directory layout on mad-tea-party
gitea_compose_dir: /opt/gitea gitea_compose_dir: /opt/gitea
# Image versions
gitea_version: "28.2.0"
gitea_postgres_version: "17.11"
# Gitea application settings # Gitea application settings
gitea_domain: gitea.mk-labs.cloud gitea_domain: gitea.mk-labs.cloud
gitea_root_url: "https://{{ gitea_domain }}" gitea_root_url: "https://{{ gitea_domain }}"
+3 -3
View File
@@ -15,9 +15,9 @@
# ── Compose & env ────────────────────────────────────────────────────────────── # ── Compose & env ──────────────────────────────────────────────────────────────
- name: Deploy Gitea compose file from boilerplate - name: Deploy Gitea compose file
ansible.builtin.copy: ansible.builtin.template:
src: "{{ playbook_dir }}/../../boilerplates/gitea/compose.yaml" src: compose.yaml.j2
dest: "{{ gitea_compose_dir }}/compose.yaml" dest: "{{ gitea_compose_dir }}/compose.yaml"
owner: root owner: root
group: 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
@@ -117,4 +117,4 @@ baseline_journald_max_retention: "30day"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MOTD branding # MOTD branding
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
baseline_motd_banner: "mk-labs" baseline_motd_banner: "wdw-labs"
@@ -0,0 +1,9 @@
---
# The role's public variables are named in the issue's spec (multica_version,
# multica_server_url, multica_workspaces_root, multica_agent_clis, ...) and
# match the product's own namespace, including its MULTICA_* environment
# variables. Prefixing them with the role name instead would make every call
# site read multica_agent_host_multica_version. Internal facts use a leading
# underscore (_multica_*) to stay clearly private.
skip_list:
- var-naming[no-role-prefix]
+468
View File
@@ -0,0 +1,468 @@
# multica_agent_host
Provisions a Multica agent host from bare Linux to a **registered, running
daemon with its provider runtimes online**, and re-runs cleanly on a host that
is already provisioned.
Reaches "daemon running, runtimes registered" unattended. The one step it
cannot do is the provider CLI's own subscription login, which needs a browser
once per host.
## If your repo runs ansible-lint
This role keeps the variable names from its spec (`multica_version`,
`multica_server_url`, `multica_workspaces_root`, ...), which match the
product's own `MULTICA_*` namespace. ansible-lint's `var-naming[no-role-prefix]`
wants them prefixed with the role name instead — `multica_agent_host_multica_version`
at every call site.
The role ships the exemption at `roles/multica_agent_host/.ansible-lint`, but
**ansible-lint only reads that file from the directory you invoke it in**. If
you lint from your repo root, copy these two lines into your repo's own
`.ansible-lint`:
```yaml
skip_list:
- var-naming[no-role-prefix]
```
Without it a repo-root lint run reports ~78 violations against a role that is
otherwise clean at the `production` profile.
## What it does
| Step | Handled |
|---|---|
| Dedicated `multica` service account (real home, linger) | yes |
| Node.js, pinned, from nodejs.org with SHASUMS256 verification | yes, when an npm-installed agent CLI is selected |
| `multica` CLI, pinned, from the GitHub release with `checksums.txt` verification | yes |
| Agent CLIs (`claude`, `hermes`, …) | yes |
| 1Password CLI (`op`), from 1Password's signed apt/yum repository | yes (`multica_op_install`) |
| 1Password service account token, as a 0600 file for the daemon user | opt-in (`multica_op_deliver_token`); not exported to the daemon |
| `workspaces_root`, on the intended mount, writable by the daemon user | yes |
| Multica authentication (`multica login --token`) | yes |
| CLI config (`server_url`, `app_url`, `workspaces_root`, `device_name`, …) | yes |
| Boot autostart (systemd user unit + `loginctl enable-linger`) | yes |
| Daemon start | yes |
| Runtime registration | follows automatically |
| **Provider CLI subscription login (e.g. `claude`)** | **no — browser, once per host** |
| Provider CLI credentials from a secret manager | out of scope; the role reports it as a remaining step |
## Requirements
- Linux target, `x86_64` or `aarch64`.
- systemd, for boot autostart. Without it the CLI falls back to an XDG autostart
entry, which does not help a headless host — set `multica_autostart: false`
and supervise the daemon yourself.
- Privilege escalation on the target (`become: true`).
- Ansible 2.14+.
## Secrets
**The role does not know where your secrets live.** It consumes one plain
variable:
```yaml
multica_pat: "{{ ... }}" # mul_… user PAT, or mcn_… Cloud Node PAT
```
The playbook supplies it however your environment already does. In this
homelab that means an **`ansible-vault` encrypted var**, which is what the
example playbook uses; a `community.hashi_vault` lookup, a 1Password lookup and
a plain environment variable are written out beside it as commented
alternatives.
See [Getting and storing the token](#getting-and-storing-the-token) below for
the end-to-end procedure.
Three consequences worth stating:
1. The role is reusable across environments that do not share a secrets backend.
2. Consolidating providers later is a one-line playbook change; this role is
untouched.
3. It sidesteps the HashiCorp Vault / `ansible-vault` ambiguity entirely — a
role that takes a value rather than fetching one works with either today.
`no_log: true` is set on the login task regardless of where the token comes
from. Ansible logs command arguments by default; without it the PAT lands in
play output, in any CI log, and in every callback plugin.
This matters just as much with `ansible-vault` as with a remote lookup: an
encrypted var is decrypted in memory for the duration of the play, so
encryption at rest does nothing for the runtime path. `no_log` is what covers
it.
**Prefer a Cloud Node PAT (`mcn_…`) over a user PAT** where its scope is
narrower — a daemon host does not need everything a person can do. Confirm the
scope difference against your Multica deployment before deciding.
## Getting and storing the token
### Minting it
**There is no CLI command for this.** `multica user` only manages your profile
and `multica auth` only reports status and logs out; nothing in the command
tree mints a token of either kind — the CLI only ever consumes one. A PAT is
created in the Multica web app under account settings, in a Tokens section.
(The exact menu path could not be confirmed from a headless runtime, so treat
that as "where to look", not a click path.)
Two prefixes exist, and `multica login --token` accepts either:
| Prefix | Kind |
|---|---|
| `mul_` | User PAT — acts as you, with everything your account can reach |
| `mcn_` | "Cloud Node" PAT |
**On a self-hosted server, check whether `mcn_` is offered at all before
planning around it.** The string `mcn_` appears in exactly one place in the
shipped CLI — the `login --token` help text — and the only other place the
product uses "Cloud" as a noun is `multica setup cloud`, which points at
Multica Cloud (multica.ai) as opposed to `multica setup self-host`. That is
suggestive, not conclusive: Cloud Node PATs may well be a Multica Cloud
concept with no equivalent on a self-hosted deployment. The web UI's token
screen is the authority — whatever token types it offers you is the real
answer.
Either prefix satisfies the role; preflight validates both.
### Scoping a token on a self-hosted deployment
If `mul_` is the only kind available, a user PAT on a long-lived daemon host
means every run that host claims carries your full authority, and revoking it
later cuts off your own access too.
The lever that does exist is a **dedicated service member**. Workspace roles
are `owner`, `admin` and `member`:
```bash
multica workspace member list --output json # see who exists and their roles
multica workspace member invite agents@example.com --role member
```
Have that account accept the invitation, log in as it, mint *its* PAT, and give
that to the host. `member` is coarser than a purpose-built node credential, but
it is strictly narrower than `owner`, and revoking it touches nothing you use.
Do **not** reuse the session token your desktop CLI already holds. It is not a
PAT, the server renews it on its own schedule, and it ties the host's identity
to your desktop login — revoking one breaks the other.
### Storing it with ansible-vault
One-time, if you do not already have a vault password file:
```bash
mkdir -p ~/.ansible
openssl rand -base64 48 > ~/.ansible/vault-pass-homelab
chmod 600 ~/.ansible/vault-pass-homelab
```
Point `ansible.cfg` at it so no play needs `--ask-vault-pass`:
```ini
[defaults]
vault_password_file = ~/.ansible/vault-pass-homelab
```
Then create the encrypted var file:
```bash
mkdir -p group_vars/multica_agent_hosts
ansible-vault create group_vars/multica_agent_hosts/vault.yml
```
with a single line in it:
```yaml
vault_multica_pat: "mul_...."
```
The playbook already reads `multica_pat: "{{ vault_multica_pat }}"`. The
indirection is deliberate: the `vault_` prefix makes it obvious at a glance
which variables are encrypted, and it keeps the name greppable without
decrypting anything.
**Alternative, if you would rather not keep a whole encrypted file:** encrypt
just the string and paste the block into an ordinary `group_vars` file.
```bash
ansible-vault encrypt_string --stdin-name 'vault_multica_pat'
```
Paste the token, press Ctrl-D. Because it reads stdin, the token never enters
your shell history — which `ansible-vault encrypt_string 'mul_...'` as an
argument would.
### Checks worth running once
```bash
# the file is actually encrypted, not plaintext you forgot to encrypt
head -1 group_vars/multica_agent_hosts/vault.yml # => $ANSIBLE_VAULT;1.1;AES256
# the var resolves and the role's prefix check will pass, without printing it
ansible -i inventory/hosts.yml multica_agent_hosts -m debug \
-a 'msg="pat resolves: {{ (vault_multica_pat | default("")) is match("^(mul_|mcn_)") }}"'
```
That second command prints only `true` or `false`, never the token.
Add the vault password file to `.gitignore` if it lives anywhere near the repo.
The encrypted `vault.yml` is safe to commit; the password file never is.
## Ansible owns the version
`multica_version` is pinned and `disable_auto_update` is set to `true` in CLI
config. Re-running the play **is** the upgrade path. Without this the CLI
self-updates out from under the role's declared state and the host silently
stops matching what the repo says it is.
The same reasoning applies to `multica_node_version` (full semver, not a major
track) and to `package_version` on each npm-installed agent CLI — left empty by
default, which tracks `latest`; set it to own that version too.
## Variables
See `defaults/main.yml` for the annotated list. The ones you will actually set:
| Variable | Default | Notes |
|---|---|---|
| `multica_server_url` | — | **Required.** |
| `multica_pat` | — | **Required.** See above. |
| `multica_version` | `0.6.5` | Pinned CLI release. |
| `multica_node_version` | `24.22.1` | Pinned, full semver. |
| `multica_workspaces_root` | `/opt/multica/workspaces` | Local disk. Repoint at a mount when one exists. |
| `multica_workspaces_root_mount` | `""` | Set it and the play aborts if the mount is missing. |
| `multica_agent_clis` | `[claude]` | Keys of `multica_agent_cli_catalog`. |
| `multica_device_name` | short inventory hostname | Shows up in `runtime list` → `device_info`. |
| `multica_user` | `multica` | Daemon service account. |
| `multica_profile` | `""` | `--profile`, to isolate config/daemon state/workspaces. |
| `multica_allow_remote_install_scripts` | `false` | Required for `hermes`; see below. |
| `multica_op_install` | `true` | 1Password CLI from 1Password's apt/yum repo. |
| `multica_op_version` | `""` | `""` tracks the repo (upgrades on every run); set e.g. `2.41.1` to pin. |
| `multica_op_deliver_token` | `false` | Write `multica_op_service_account_token` to `multica_op_token_file`. |
### Adding an agent CLI
Extend `multica_agent_cli_catalog`. Two install methods are implemented:
```yaml
multica_agent_cli_catalog:
codex:
provider: codex # must match `provider` in `multica runtime list`
binary: codex
method: npm
package: "@openai/codex"
package_version: ""
version_command: "codex --version"
manual_login: true
manual_login_hint: "run `codex login` once as the daemon user"
```
`method: script` fetches `install_url` and runs it as the daemon user. That is a
supply-chain decision, so the role **refuses** unless
`multica_allow_remote_install_scripts: true`. `hermes` uses this method
(`https://hermes-agent.nousresearch.com/install.sh`). Vendoring the installer
into your repo and switching to a local `script` source is the stricter option.
Agent CLI binaries are symlinked into `/usr/local/bin`. This matters: the daemon
runs from a systemd **user unit**, which gets systemd's default `PATH` and does
*not* source `/etc/profile.d`. A CLI reachable only from an interactive login
shell is invisible to the daemon. `MULTICA_<PROVIDER>_PATH` is the escape hatch
if you need the binary somewhere else.
Both install methods resolve the binary with `command -v` as the daemon user
and link *that* path, rather than assuming where they put it. This matters when
a CLI was already installed by other means — the install step is skipped, so
assuming the npm prefix would link to nothing. The role asserts the binary is
resolvable before linking, and linking the resolved path repairs a dangling
link left behind by an earlier run.
## Moving the workspaces root onto a mount later
`workspaces_root` holds per-run working directories, not durable state — the
daemon recreates what it needs. Moving it is therefore a drain-and-repoint, not
a migration:
```bash
multica daemon disk-usage --output json # see what is actually there first
sudo -iu multica multica daemon stop # let in-flight runs finish first
```
Then set `multica_workspaces_root` and `multica_workspaces_root_mount` together
in the playbook and re-run. The role creates the new directory, proves the
daemon user can write to it, updates `config set workspaces_root`, and restarts
the daemon.
**Set both variables in the same change.** Pointing the root at a mount path
without setting the guard means a play that runs while the mount is down
silently writes run workspaces onto the root filesystem — which nobody notices
until it fills. The guard turns that into a failed assertion instead.
The old directory is left behind deliberately; remove it by hand once the new
one has taken runs.
## Idempotency
Every step reads before it writes:
| Step | Guard |
|---|---|
| Node install | `node --version` equals the pin |
| 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` (it prints to **stderr**; the guard reads both streams) |
| Autostart | `multica daemon autostart status` |
| 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 `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
The role ends in assertions, not hope:
- `multica daemon status --output json` reports `status: running`, and its
`cli_version` and `server_url` match the declared values.
- `multica runtime list --output json` contains an **online** runtime for every
entry in `multica_agent_clis`, filtered to this host's `daemon_id`, with
`device_info` naming `multica_device_name`. Retried, because registration is
asynchronous after the daemon starts.
- `workspaces_root` exists, is a directory, is owned by the daemon user, is
actually writable by it (ownership is not writability on a root-squashed
network mount), and sits on `multica_workspaces_root_mount` when that is set.
It finishes by printing the manual steps that remain — the per-host provider CLI
logins.
## Testing against a throwaway VM
Molecule is fine but a scratch VM is proportionate:
```bash
# 1. clean host
ansible-playbook -i inventory/hosts.yml playbooks/provision-agent-host.yml -l scratch-vm
# 2. re-run: must report changed=0
ansible-playbook -i inventory/hosts.yml playbooks/provision-agent-host.yml -l scratch-vm
# 3. reboot, then confirm the daemon came back on its own
ssh scratch-vm 'sudo -iu multica multica daemon status --output json'
# 4. confirm from the workspace side
multica runtime list --output json | jq '.[] | select(.device_info | startswith("scratch-vm"))'
```
Then complete the browser login for each provider CLI once:
```bash
ssh -t scratch-vm 'sudo -iu multica claude'
```
That step only becomes automatable if the host moves from a subscription to an
API key.
## 1Password CLI
`op` lets an agent resolve `op://vault/item/field` references at the moment
it runs a command (`op run -- cmd`, `op inject -i tpl -o out`, `op read`), so
the secret value never has to appear in a prompt, an issue or a transcript.
The 1Password desktop-app integration (and the Claude Code plugin built on it)
needs a GUI session, so headless agent hosts use a **service account** instead.
Install, Debian/Ubuntu, exactly as 1Password documents it, with one addition:
- the key at `multica_op_key_url` is fingerprint-checked against
`multica_op_key_fingerprint` before it is dearmored, so apt never trusts a
substituted key;
- dearmored to `/usr/share/keyrings/1password-archive-keyring.gpg` (apt,
`signed-by=`) and to `/usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg`
with the policy in `/etc/debsig/policies/AC2D62742012EA22/` (debsig-verify);
- `1password-cli` from `downloads.1password.com/linux/debian/<arch> stable`.
EL uses 1Password's yum repository with `gpgcheck` and `repo_gpgcheck`.
The play then runs `op --version` as the daemon user, in the daemon user's
environment.
### The service account token
The role can deliver the token, but by default does not:
```yaml
multica_op_deliver_token: true
multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}"
```
writes it to `multica_op_token_file`
(`~multica/.config/op-service-account/token`, directory 0700, file 0600,
owned by the daemon user). That is all it does. **The role does not put the
token into the daemon's environment**, for two reasons:
1. The daemon's systemd user unit is written and owned by the multica CLI
(`multica daemon autostart`). The role must not template or edit it, or it
silently freezes the CLI's `ExecStart`, environment and restart semantics.
2. Anything in the daemon's environment is inherited by every agent process
it spawns, where a casual `env` or `printenv` puts it in a transcript.
How the token reaches `op` is a separate decision, not yet made. Until then
an agent can use it explicitly:
```bash
OP_SERVICE_ACCOUNT_TOKEN="$(cat ~/.config/op-service-account/token)" op run -- <cmd>
```
`op` reads the token only from `OP_SERVICE_ACCOUNT_TOKEN`; it has no
token-file option.
## Known gaps
- **Provider CLI login.** Unavoidable for subscription auth.
- **Hermes credentials.** Hermes reads 1Password natively; wiring that is not
implemented here because the right item layout is deployment-specific. The
role installs Hermes and reports the credential step as outstanding.
- **Non-systemd hosts.** Autostart degrades to an XDG entry that a headless host
never triggers.
## Tags
| Tag | Covers |
|---|---|
| `multica_workspaces` | workspaces root |
| `multica_node` | Node.js |
| `multica_cli` | multica CLI |
| `multica_agents` | agent CLIs |
| `multica_config` | `multica config set` |
| `multica_auth` | `multica login` |
| `multica_daemon` | autostart + start |
| `multica_verify` | the assertions |
| `multica_op` | 1Password CLI, and the token file when enabled |
| `multica` | all of the above |
Preflight and the service-account step are tagged `always`: they build
`_multica_uid` and `_multica_user_env`, which every `become_user` task below
depends on, so a `--tags` subset still runs them. Both are idempotent.
## Offline checks
From the repo root:
```bash
ansible-lint playbooks/provision-agent-host.yml roles/multica_agent_host
ansible-playbook --syntax-check playbooks/provision-agent-host.yml
ansible-playbook -i localhost, tests/expressions.yml
```
`tests/expressions.yml` runs the role's filter chains — `config show` parsing,
version extraction, the online-runtime difference, the mount containment check
— against fixtures captured from a real `multica` CLI, with no target host.
It catches the class of bug where a filter chain silently returns the wrong
type or matches nothing; it does not replace the scratch-VM run above.
@@ -0,0 +1,185 @@
---
# =============================================================================
# multica_agent_host — defaults
#
# Everything the role needs is a plain variable. The role never reaches into a
# secrets backend itself; see `multica_pat` below.
# =============================================================================
# -----------------------------------------------------------------------------
# Service account
# -----------------------------------------------------------------------------
# The daemon runs as a dedicated unprivileged user with a real home directory.
# It needs a real home (CLI config, agent CLI credentials) and systemd linger,
# so this is NOT a `system: true` account.
multica_manage_user: true
multica_user: multica
multica_group: multica
multica_user_home: "/home/{{ multica_user }}"
multica_user_shell: /bin/bash
# Extra groups for the daemon user (e.g. docker). Left empty by default.
multica_user_extra_groups: []
# -----------------------------------------------------------------------------
# multica CLI — Ansible owns the version
# -----------------------------------------------------------------------------
# Pin it. Re-running the play IS the upgrade path; see multica_disable_auto_update.
multica_version: "0.6.5"
multica_install_dir: /usr/local/bin
multica_release_download_base_url: "https://github.com/multica-ai/multica/releases/download"
# Verify the release tarball against the release's own checksums.txt.
multica_verify_checksum: true
# -----------------------------------------------------------------------------
# Server / workspace
# -----------------------------------------------------------------------------
multica_server_url: "" # REQUIRED, e.g. https://multica.wdw-labs.cloud
multica_app_url: "" # optional frontend URL
multica_workspace_id: "" # optional: pin this profile's default workspace
multica_profile: "" # optional: --profile <name> (isolates config + daemon state)
# -----------------------------------------------------------------------------
# Daemon configuration (mirrors `multica config set` keys)
# -----------------------------------------------------------------------------
# Local disk by default. Repoint this at a mount when one exists, and set
# multica_workspaces_root_mount at the same time so the play fails loudly if
# the mount is missing rather than silently filling the root filesystem.
multica_workspaces_root: "/opt/multica/workspaces"
# Optional guard: assert workspaces_root actually sits on this mount point.
# e.g. "/mnt/utilidor". Empty disables the check.
multica_workspaces_root_mount: ""
multica_workspaces_root_mode: "0750"
multica_device_name: "{{ inventory_hostname.split('.')[0] }}"
multica_runtime_name: "" # "" = leave unset, CLI derives it
multica_max_concurrent_tasks: "" # "" = leave unset
multica_poll_interval: "" # "" = leave unset
multica_heartbeat_interval: "" # "" = leave unset
multica_agent_timeout: "" # "" = leave unset ("0s" disables the cap)
# Ansible owns the version: stop the CLI self-updating out from under the
# role's declared state. Set false only if you want the daemon to self-update.
multica_disable_auto_update: true
# Boot autostart. On Linux `multica daemon autostart enable` writes a systemd
# *user* unit, which only starts at boot when the user has lingering enabled.
multica_autostart: true
multica_enable_linger: true
# -----------------------------------------------------------------------------
# Authentication — backend-agnostic by design
# -----------------------------------------------------------------------------
# REQUIRED. A Multica personal access token: `mul_...` (user PAT) or
# `mcn_...` (Cloud Node PAT). The role takes a VALUE; it does not know or care
# where it came from. Supply it from the playbook with whatever your
# environment already uses:
#
# HashiCorp Vault:
# multica_pat: "{{ lookup('community.hashi_vault.vault_kv2_get',
# 'multica/agent-host').secret.pat }}"
# ansible-vault encrypted var:
# multica_pat: "{{ vaulted_multica_pat }}"
# 1Password:
# multica_pat: "{{ lookup('community.general.onepassword',
# 'Multica Agent Host PAT', field='credential') }}"
# Environment:
# multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}"
#
# Consolidating secret providers later is a one-line playbook change; this role
# is untouched by it.
multica_pat: ""
# Force `multica login --token` even when the host already reports an
# authenticated session against multica_server_url.
multica_force_relogin: false
# -----------------------------------------------------------------------------
# Agent CLIs
# -----------------------------------------------------------------------------
# Which provider CLIs this host should offer. Each must be a key in
# multica_agent_cli_catalog. The daemon registers one runtime per CLI it finds.
multica_agent_clis:
- claude
# Some agent CLIs are installed by piping a vendor script into a shell. That is
# a real supply-chain decision, so it is opt-in and off by default.
multica_allow_remote_install_scripts: false
multica_agent_cli_catalog:
claude:
provider: claude # matches `provider` in `multica runtime list`
binary: claude
method: npm
package: "@anthropic-ai/claude-code"
# Pinned: npm "latest" is 2.1.306 while "stable" is 2.1.304, so an empty
# value silently tracks whichever npm serves that day. 2.1.304 is the
# version running on haunted-mansion.
package_version: "2.1.304"
version_command: "claude --version"
# Subscription auth needs a browser once per host. Not automatable here.
manual_login: true
manual_login_hint: "run `claude` once as {{ multica_user }} and complete the login"
hermes:
provider: hermes
binary: hermes
method: script
install_url: "https://hermes-agent.nousresearch.com/install.sh"
install_dir: "{{ multica_user_home }}/.hermes/hermes-agent"
version_command: "hermes --version"
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: {}
# -----------------------------------------------------------------------------
# 1Password CLI (op)
# -----------------------------------------------------------------------------
# Lets agents resolve op:// references with `op run` / `op inject` / `op read`,
# so secret values never pass through a prompt or a transcript. Installed from
# 1Password's signed apt/yum repository.
multica_op_install: true
# "" = whatever the repository serves, upgraded on every run. Set e.g. "2.41.1"
# to pin. 1Password's apt index carries only the newest build, so a pin older
# than that cannot be installed on a fresh host.
multica_op_version: ""
multica_op_key_url: "https://downloads.1password.com/linux/keys/1password.asc"
# Code signing for 1Password <codesign@1password.com>. The play refuses a key
# at multica_op_key_url with any other fingerprint.
multica_op_key_fingerprint: "3FEF9748469ADBE15DA7CA80AC2D62742012EA22"
multica_op_debsig_policy_url: "https://downloads.1password.com/linux/debian/debsig/1password.pol"
multica_op_apt_base_url: "https://downloads.1password.com/linux/debian"
multica_op_rpm_base_url: "https://downloads.1password.com/linux/rpm/stable"
# Service account token delivery. Off by default. When on, the token is written
# to multica_op_token_file (0600, owned by the daemon user) and nowhere else.
# The role does NOT export it into the daemon environment; see the README.
# Supply the VALUE from the playbook, like multica_pat, e.g.
# multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}"
multica_op_deliver_token: false
multica_op_service_account_token: ""
multica_op_token_file: "{{ multica_user_home }}/.config/op-service-account/token"
# -----------------------------------------------------------------------------
# Node.js (installed only when a selected agent CLI needs npm)
# -----------------------------------------------------------------------------
# Full semver — this is a pin, not a major-version track.
multica_node_version: "24.22.1"
multica_node_dist_base_url: "https://nodejs.org/dist"
multica_node_prefix: /usr/local/lib/nodejs
# npm global prefix for the daemon user, so agent CLI installs need no root.
multica_npm_prefix: "{{ multica_user_home }}/.npm-global"
# -----------------------------------------------------------------------------
# Verification
# -----------------------------------------------------------------------------
multica_verify: true
# How long to wait for runtimes to appear online after the daemon starts.
multica_verify_retries: 24
multica_verify_delay: 5
@@ -0,0 +1,11 @@
---
# 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: [systemctl, --user, restart, "{{ _multica_unit | default('multica-daemon.service') }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
changed_when: true
listen: restart multica daemon
@@ -0,0 +1,22 @@
---
galaxy_info:
role_name: multica_agent_host
namespace: wdwlabs
author: WDW Labs
description: >-
Provision a Multica agent host from bare Linux to a registered, running
daemon with its provider runtimes online.
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Debian
versions: [bullseye, bookworm, trixie]
- name: Ubuntu
versions: [jammy, noble]
- name: EL
versions: ["9"]
galaxy_tags:
- multica
- agents
- daemon
dependencies: []
@@ -0,0 +1,42 @@
---
# Resolve where the binary ACTUALLY is before linking to it.
#
# The npm path used to link unconditionally from multica_npm_prefix. When the
# CLI was already installed by some other means — a distro package, a vendor
# installer, a previous hand-rollout — nothing was installed at that prefix and
# `file: state=link force=true` happily created a DANGLING symlink in
# /usr/local/bin. PATH resolution skips a dangling link, so discovery still
# worked by luck wherever the real binary was already on systemd's PATH, and
# the role's actual guarantee silently did not hold.
- name: "Agents | Resolve the installed binary | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _agent_which
changed_when: false
failed_when: false
check_mode: false
- name: "Agents | Require the binary to be resolvable | {{ _agent_key }}"
ansible.builtin.assert:
that:
- _agent_which.rc == 0
- (_agent_which.stdout | trim) | length > 0
fail_msg: >-
{{ _agent.binary }} is not on {{ multica_user }}'s PATH after the install
step. The daemon discovers provider CLIs by PATH, so it would register no
{{ _agent_key }} runtime.
quiet: true
# Linking the resolved path also repairs a dangling link left by an older run:
# `command -v` skips the broken link, so this overwrites it with a working one.
- name: "Agents | Link onto the default PATH | {{ _agent_key }}"
ansible.builtin.file:
src: "{{ _agent_which.stdout | trim }}"
dest: "{{ multica_install_dir }}/{{ _agent.binary }}"
state: link
force: true
become: true
when: (_agent_which.stdout | trim) != (multica_install_dir ~ '/' ~ _agent.binary)
@@ -0,0 +1,46 @@
---
- name: "Agents | Resolve catalog entry | {{ _agent_key }}"
ansible.builtin.set_fact:
_agent: "{{ multica_agent_cli_catalog[_agent_key] }}"
- name: "Agents | Read the installed version | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }} >/dev/null 2>&1 && {{ _agent.version_command }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _agent_installed
changed_when: false
failed_when: false
check_mode: false
# Version formats differ per CLI ("2.1.304 (Claude Code)", "Hermes Agent
# v0.21.5+..."), so this matches the pinned version anywhere in the output —
# but on a version-component boundary. A plain substring test would accept
# 2.1.3045 for a pin of 2.1.304, and silently never upgrade.
- name: "Agents | Decide whether an install is needed | {{ _agent_key }}"
ansible.builtin.set_fact:
_agent_needs_install: >-
{{ _agent_installed.rc != 0
or (_agent.package_version | default('') | length > 0
and not ((_agent_installed.stdout | default('')) is search(
'(^|[^0-9.])' ~ (_agent.package_version | regex_escape) ~ '([^0-9.]|$)'))) }}
- name: "Agents | Install via npm as the daemon user | {{ _agent_key }}"
ansible.builtin.command:
argv:
- "{{ multica_install_dir }}/npm"
- install
- --global
- --no-fund
- --no-audit
- "{{ _agent.package }}{{ ('@' ~ _agent.package_version) if _agent.package_version | default('') | length > 0 else '' }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: _agent_needs_install | bool
changed_when: true
notify: restart multica daemon
- name: "Agents | Resolve and link onto the default PATH | {{ _agent_key }}"
ansible.builtin.include_tasks: _link.yml
@@ -0,0 +1,53 @@
---
- name: "Agents | Resolve catalog entry | {{ _agent_key }}"
ansible.builtin.set_fact:
_agent: "{{ multica_agent_cli_catalog[_agent_key] }}"
- name: "Agents | Read the installed version | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }} >/dev/null 2>&1 && {{ _agent.version_command }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _agent_installed
changed_when: false
failed_when: false
check_mode: false
# Piping a vendor script into a shell is a supply-chain decision, so the role
# refuses rather than making it quietly on the operator's behalf.
- name: "Agents | Refuse to run a remote installer unless allowed | {{ _agent_key }}"
ansible.builtin.fail:
msg: >-
{{ _agent_key }} installs by executing {{ _agent.install_url }}.
Set multica_allow_remote_install_scripts: true to permit that, vendor the
installer yourself, or drop {{ _agent_key }} from multica_agent_clis.
when:
- _agent_installed.rc != 0
- not (multica_allow_remote_install_scripts | bool)
- name: "Agents | Install via the vendor script | {{ _agent_key }}"
when:
- _agent_installed.rc != 0
- multica_allow_remote_install_scripts | bool
block:
- name: "Agents | Fetch the installer | {{ _agent_key }}"
ansible.builtin.get_url:
url: "{{ _agent.install_url }}"
dest: "{{ multica_user_home }}/.cache/{{ _agent_key }}-install.sh"
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
- name: "Agents | Run it as the daemon user | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/bash", "{{ multica_user_home }}/.cache/{{ _agent_key }}-install.sh"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
changed_when: true
notify: restart multica daemon
- name: "Agents | Resolve and link onto the default PATH | {{ _agent_key }}"
ansible.builtin.include_tasks: _link.yml
@@ -0,0 +1,7 @@
---
- name: Agents | Install each selected agent CLI
ansible.builtin.include_tasks: "agent_cli/{{ multica_agent_cli_catalog[_agent_key].method }}.yml"
loop: "{{ multica_agent_clis }}"
loop_control:
loop_var: _agent_key
label: "{{ _agent_key }}"
@@ -0,0 +1,50 @@
---
# `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'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_auth_status
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('')) ~ (_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.
- name: Auth | Log in with the personal access token
ansible.builtin.command:
argv: "{{ _multica_cmd + ['login', '--token', multica_pat] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: _multica_needs_login | bool
changed_when: true
no_log: true
notify: restart multica daemon
- name: Auth | Pin the default workspace
ansible.builtin.command:
argv: "{{ _multica_cmd + ['workspace', 'switch', multica_workspace_id] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: multica_workspace_id | length > 0
register: _multica_ws_switch
changed_when: _multica_needs_login | bool
notify: restart multica daemon
@@ -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
@@ -0,0 +1,56 @@
---
# The CLI owns its config file format, so the role drives `multica config set`
# rather than templating config.json. Each key is read back first, so a
# converged host reports zero changes.
- name: Config | Build the desired configuration
ansible.builtin.set_fact:
_multica_desired_config: >-
{{ {
'server_url': multica_server_url,
'app_url': multica_app_url,
'workspaces_root': multica_workspaces_root,
'device_name': multica_device_name,
'runtime_name': multica_runtime_name,
'max_concurrent_tasks': multica_max_concurrent_tasks,
'poll_interval': multica_poll_interval,
'heartbeat_interval': multica_heartbeat_interval,
'agent_timeout': multica_agent_timeout,
'disable_auto_update': ('true' if multica_disable_auto_update | bool else 'false'),
} | dict2items
| rejectattr('value', 'equalto', '')
| list | items2dict }}
- name: Config | Read the current configuration
ansible.builtin.command:
argv: "{{ _multica_cmd + ['config', 'show'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_config_show
changed_when: false
check_mode: false
# `config show` prints "key:<spaces>value", with "(not set)" for unset keys.
- name: Config | Parse it
ansible.builtin.set_fact:
_multica_current_config: >-
{{ dict(
_multica_config_show.stdout_lines
| select('match', '^[a-z_]+:')
| map('regex_replace', '^([a-z_]+):\s*(.*?)\s*$', '\1|\2')
| map('split', '|')
) }}
- name: Config | Apply the keys that differ
ansible.builtin.command:
argv: "{{ _multica_cmd + ['config', 'set', item.key, item.value] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
loop: "{{ _multica_desired_config | dict2items }}"
loop_control:
label: "{{ item.key }}={{ item.value }}"
when: (_multica_current_config[item.key] | default('(not set)')) != item.value
changed_when: true
notify: restart multica daemon
@@ -0,0 +1,138 @@
---
# On Linux `multica daemon autostart enable` writes a systemd *user* unit.
# A user unit does not start at boot unless the user lingers — without this the
# daemon only comes back when someone logs in, which on a headless agent host
# is never.
- name: Daemon | Enable systemd lingering for the daemon user
ansible.builtin.command:
argv: ["loginctl", "enable-linger", "{{ multica_user }}"]
creates: "/var/lib/systemd/linger/{{ multica_user }}"
become: true
when:
- multica_enable_linger | bool
- multica_autostart | bool
- name: Daemon | Wait for the user's runtime directory
ansible.builtin.wait_for:
path: "/run/user/{{ _multica_uid }}"
state: present
timeout: 30
become: true
when:
- multica_enable_linger | bool
- multica_autostart | bool
- name: Daemon | Read the current autostart registration
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'status'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_autostart_status
changed_when: false
failed_when: false
check_mode: false
- name: Daemon | Register boot autostart
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'enable'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when:
- 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'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_daemon_status
changed_when: false
failed_when: false
check_mode: false
# 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', 'stop'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
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
# daemon here, before verification reads its state back.
- name: Daemon | Flush handlers
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,62 @@
---
- name: Preflight
ansible.builtin.import_tasks: preflight.yml
# Prerequisite for every other block: always runs, even under a --tags subset.
tags: [always]
- name: Service account
ansible.builtin.import_tasks: user.yml
# Builds _multica_uid / _multica_user_env, which every later become_user task
# needs; creating an existing user is a no-op.
tags: [always]
- name: Workspaces root
ansible.builtin.import_tasks: workspaces_root.yml
tags: [multica, multica_workspaces]
- name: Node.js
ansible.builtin.import_tasks: node.yml
when: _multica_node_required | bool
tags: [multica, multica_node]
- name: Multica CLI
ansible.builtin.import_tasks: multica_cli.yml
tags: [multica, multica_cli]
- name: Agent CLIs
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: 1Password CLI
ansible.builtin.import_tasks: op_cli.yml
when: multica_op_install | bool
tags: [multica, multica_op]
- name: 1Password service account token
ansible.builtin.import_tasks: op_token.yml
when: multica_op_deliver_token | bool
tags: [multica, multica_op]
- name: CLI configuration
ansible.builtin.import_tasks: config.yml
tags: [multica, multica_config]
- name: Authentication
ansible.builtin.import_tasks: auth.yml
tags: [multica, multica_auth]
- name: Daemon
ansible.builtin.import_tasks: daemon.yml
tags: [multica, multica_daemon]
- name: Verify
ansible.builtin.import_tasks: verify.yml
when: multica_verify | bool
tags: [multica, multica_verify]
@@ -0,0 +1,72 @@
---
- name: CLI | Check whether the binary is already present
ansible.builtin.stat:
path: "{{ _multica_bin }}"
register: _multica_cli_stat
check_mode: false
- name: CLI | Read the installed version
ansible.builtin.command:
argv: ["{{ _multica_bin }}", "--version"]
register: _multica_cli_installed
when: _multica_cli_stat.stat.exists
changed_when: false
failed_when: false
check_mode: false
# `multica --version` prints e.g. "multica 0.6.5 (commit: ..., built: ...)".
- name: CLI | Extract the installed version string
ansible.builtin.set_fact:
_multica_cli_version_found: >-
{{ ((_multica_cli_installed.stdout_lines | default([]) | first | default('')).split() + ['', ''])[1]
if (_multica_cli_installed.rc | default(1)) == 0 else '' }}
- name: CLI | Decide whether an install is needed
ansible.builtin.set_fact:
_multica_cli_needs_install: "{{ (_multica_cli_version_found | trim) != multica_version }}"
- name: CLI | Install the pinned release
when: _multica_cli_needs_install | bool
block:
- name: CLI | Create a staging directory
ansible.builtin.tempfile:
state: directory
suffix: multica-cli
register: _multica_stage
become: true
- name: CLI | Download the release tarball
ansible.builtin.get_url:
url: "{{ _multica_release_url }}"
dest: "{{ _multica_stage.path }}/{{ _multica_tarball }}"
checksum: "{{ ('sha256:' ~ _multica_checksums_url) if multica_verify_checksum | bool else omit }}"
mode: "0644"
become: true
- name: CLI | Unpack the binary
ansible.builtin.unarchive:
src: "{{ _multica_stage.path }}/{{ _multica_tarball }}"
dest: "{{ _multica_stage.path }}"
remote_src: true
become: true
# Replacing the binary while the daemon runs is fine — the handler restarts
# it, and the daemon also watches its own binary for version changes.
- name: CLI | Install it onto the PATH
ansible.builtin.copy:
src: "{{ _multica_stage.path }}/multica"
dest: "{{ _multica_bin }}"
remote_src: true
owner: root
group: root
mode: "0755"
become: true
notify: restart multica daemon
always:
- name: CLI | Remove the staging directory
ansible.builtin.file:
path: "{{ _multica_stage.path }}"
state: absent
become: true
when: _multica_stage.path is defined
@@ -0,0 +1,93 @@
---
# Installed from the official nodejs.org tarball rather than a distro repo so
# that multica_node_version is a real pin on every distribution.
- name: Node | Check whether the pinned runtime is already present
ansible.builtin.stat:
path: "{{ multica_install_dir }}/node"
register: _multica_node_stat
check_mode: false
- name: Node | Read the installed version
ansible.builtin.command:
argv: ["{{ multica_install_dir }}/node", "--version"]
register: _multica_node_installed
when: _multica_node_stat.stat.exists
changed_when: false
failed_when: false
check_mode: false
- name: Node | Decide whether an install is needed
ansible.builtin.set_fact:
_multica_node_needs_install: >-
{{ (not _multica_node_stat.stat.exists)
or (_multica_node_installed.rc | default(1)) != 0
or (_multica_node_installed.stdout | default('') | trim) != ('v' ~ multica_node_version) }}
- name: Node | Install the pinned runtime
when: _multica_node_needs_install | bool
block:
- name: Node | Create the install prefix
ansible.builtin.file:
path: "{{ multica_node_prefix }}"
state: directory
owner: root
group: root
mode: "0755"
become: true
# get_url resolves `sha256:<url>` against the file's basename in SHASUMS256.txt.
- name: Node | Download the tarball with checksum verification
ansible.builtin.get_url:
url: "{{ multica_node_dist_base_url }}/v{{ multica_node_version }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
dest: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
checksum: "sha256:{{ multica_node_dist_base_url }}/v{{ multica_node_version }}/SHASUMS256.txt"
mode: "0644"
become: true
- name: Node | Unpack it
ansible.builtin.unarchive:
src: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
dest: "{{ multica_node_prefix }}"
remote_src: true
owner: root
group: root
creates: "{{ multica_node_prefix }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}/bin/node"
become: true
- name: Node | Remove the downloaded tarball
ansible.builtin.file:
path: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
state: absent
become: true
# Symlinked into /usr/local/bin, which is on systemd's default PATH — the
# daemon's autostart unit does not source /etc/profile.d.
- name: Node | Link node, npm and npx onto the default PATH
ansible.builtin.file:
src: "{{ multica_node_prefix }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}/bin/{{ item }}"
dest: "{{ multica_install_dir }}/{{ item }}"
state: link
force: true
become: true
loop: [node, npm, npx]
- name: Node | Create the daemon user's npm global prefix
ansible.builtin.file:
path: "{{ multica_npm_prefix }}"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0755"
become: true
- name: Node | Point the daemon user's npm at that prefix
ansible.builtin.lineinfile:
path: "{{ multica_user_home }}/.npmrc"
regexp: '^prefix='
line: "prefix={{ multica_npm_prefix }}"
create: true
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0644"
become: true
@@ -0,0 +1,187 @@
---
# 1Password CLI from 1Password's own signed package repository, never a
# curl-piped binary. Mirrors https://developer.1password.com/docs/cli/get-started
# (Linux, APT / YUM), with the signing key's fingerprint checked before apt or
# dnf is allowed to trust it.
- name: 1Password CLI | Require a supported OS family
ansible.builtin.assert:
that:
- ansible_facts['os_family'] in ['Debian', 'RedHat']
fail_msg: >-
The 1Password CLI install supports Debian- and RedHat-family hosts only
(found {{ ansible_facts['os_family'] }}). Set multica_op_install: false.
quiet: true
# --- Debian / Ubuntu ---------------------------------------------------------
- name: 1Password CLI | Install gnupg and debsig-verify
ansible.builtin.apt:
name: [gnupg, debsig-verify]
state: present
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Fetch the signing key
ansible.builtin.get_url:
url: "{{ multica_op_key_url }}"
dest: /usr/share/keyrings/1password-archive-keyring.asc
owner: root
group: root
mode: "0644"
register: _multica_op_key_asc
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Read the signing key's fingerprint
ansible.builtin.command:
argv: [gpg, --show-keys, --with-colons, /usr/share/keyrings/1password-archive-keyring.asc]
register: _multica_op_key_info
changed_when: false
check_mode: false
become: true
when:
- ansible_facts['os_family'] == 'Debian'
- not (ansible_check_mode and _multica_op_key_asc is changed)
# Under --check on a fresh host the key was only pretend-downloaded, so there is
# nothing to read; the real run checks it.
# A key that does not match is never dearmored, so apt never trusts it.
- name: 1Password CLI | Require the expected signing key
ansible.builtin.assert:
that:
- multica_op_key_fingerprint in (_multica_op_key_info.stdout_lines
| select('match', '^fpr:') | map('split', ':') | map(attribute=9) | list)
fail_msg: >-
{{ multica_op_key_url }} is not the key with fingerprint
{{ multica_op_key_fingerprint }}. Refusing to trust it.
quiet: true
when:
- ansible_facts['os_family'] == 'Debian'
- _multica_op_key_info is not skipped
- name: 1Password CLI | Look for the dearmored keyrings
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /usr/share/keyrings/1password-archive-keyring.gpg
- /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg
register: _multica_op_keyrings
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Create the debsig policy and keyring directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- /etc/debsig/policies/AC2D62742012EA22
- /usr/share/debsig/keyrings/AC2D62742012EA22
register: _multica_op_debsig_dirs
become: true
when: ansible_facts['os_family'] == 'Debian'
# Same key, two consumers: apt (repository metadata) and debsig-verify (the
# signature on each .deb).
- name: 1Password CLI | Dearmor the key for apt and debsig
ansible.builtin.command:
argv: [gpg, --batch, --yes, --dearmor, --output, "{{ item.item }}", /usr/share/keyrings/1password-archive-keyring.asc]
loop: "{{ _multica_op_keyrings.results }}"
loop_control:
label: "{{ item.item }}"
when:
- ansible_facts['os_family'] == 'Debian'
- _multica_op_key_asc is changed or not item.stat.exists
changed_when: true
become: true
- name: 1Password CLI | Install the debsig policy
ansible.builtin.get_url:
url: "{{ multica_op_debsig_policy_url }}"
dest: /etc/debsig/policies/AC2D62742012EA22/1password.pol
owner: root
group: root
mode: "0644"
become: true
when:
- ansible_facts['os_family'] == 'Debian'
- not (ansible_check_mode and _multica_op_debsig_dirs is changed)
- name: 1Password CLI | Add the apt repository
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ _multica_arch }} signed-by=/usr/share/keyrings/1password-archive-keyring.gpg]
{{ multica_op_apt_base_url }}/{{ _multica_arch }} stable main
filename: 1password
state: present
update_cache: true
register: _multica_op_repo_deb
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Install the package
ansible.builtin.apt:
name: >-
{{ '1password-cli=' ~ multica_op_version ~ '*'
if multica_op_version | length > 0 else '1password-cli' }}
state: "{{ 'present' if multica_op_version | length > 0 else 'latest' }}"
allow_downgrade: "{{ multica_op_version | length > 0 }}"
register: _multica_op_pkg_deb
become: true
when:
- ansible_facts['os_family'] == 'Debian'
- not (ansible_check_mode and _multica_op_repo_deb is changed)
# --- EL ----------------------------------------------------------------------
- name: 1Password CLI | Add the yum repository
ansible.builtin.yum_repository:
name: 1password
description: 1Password Stable Channel
baseurl: "{{ multica_op_rpm_base_url }}/$basearch"
gpgkey: "{{ multica_op_key_url }}"
gpgcheck: true
repo_gpgcheck: true
enabled: true
register: _multica_op_repo_rpm
become: true
when: ansible_facts['os_family'] == 'RedHat'
- name: 1Password CLI | Install the package
ansible.builtin.dnf:
name: >-
{{ '1password-cli-' ~ multica_op_version
if multica_op_version | length > 0 else '1password-cli' }}
state: "{{ 'present' if multica_op_version | length > 0 else 'latest' }}"
allow_downgrade: "{{ multica_op_version | length > 0 }}"
register: _multica_op_pkg_rpm
become: true
when:
- ansible_facts['os_family'] == 'RedHat'
- not (ansible_check_mode and _multica_op_repo_rpm is changed)
# --- Verify ------------------------------------------------------------------
# As the daemon user, through its own environment: that is the context agents
# call `op` from.
- name: 1Password CLI | Check the installed version as the daemon user
ansible.builtin.command:
argv: [op, --version]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_op_version_out
changed_when: false
check_mode: false
failed_when: >-
_multica_op_version_out.rc != 0
or (multica_op_version | length > 0
and _multica_op_version_out.stdout | trim != multica_op_version)
# Under --check, only when the binary is already there to ask.
when: >-
not ansible_check_mode
or (_multica_op_pkg_deb is not skipped and _multica_op_pkg_deb is not changed)
or (_multica_op_pkg_rpm is not skipped and _multica_op_pkg_rpm is not changed)
@@ -0,0 +1,37 @@
---
# Delivers the 1Password service account token to a file only the daemon user
# can read. It deliberately stops there: nothing here puts the token into the
# daemon's environment. The daemon's systemd user unit belongs to the multica
# CLI, so the role must not edit it, and exporting the token to the daemon
# would hand it to every agent process too. See the README.
# no_log: a failed assert would otherwise render the token it inspected.
- name: 1Password token | Require a service account token
ansible.builtin.assert:
that:
- multica_op_service_account_token | length > 0
- multica_op_service_account_token is match('^ops_')
fail_msg: >-
multica_op_deliver_token is true but multica_op_service_account_token is
not a 1Password service account token (ops_...).
quiet: true
no_log: true
- name: 1Password token | Create the token directory
ansible.builtin.file:
path: "{{ multica_op_token_file | dirname }}"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
- name: 1Password token | Write the token file
ansible.builtin.copy:
dest: "{{ multica_op_token_file }}"
content: "{{ multica_op_service_account_token }}\n"
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0600"
no_log: true
become: true
@@ -0,0 +1,86 @@
---
- name: Preflight | Require a Linux target
ansible.builtin.assert:
that:
- ansible_facts['system'] == 'Linux'
fail_msg: >-
multica_agent_host supports Linux hosts only
(found {{ ansible_facts['system'] }}).
quiet: true
- name: Preflight | Require a supported CPU architecture
ansible.builtin.assert:
that:
- ansible_facts['architecture'] in _multica_arch_map
fail_msg: >-
No multica CLI release asset for architecture
{{ ansible_facts['architecture'] }}.
Supported: {{ _multica_arch_map.keys() | list | join(', ') }}.
quiet: true
- name: Preflight | Require the server URL
ansible.builtin.assert:
that:
- multica_server_url | length > 0
- multica_server_url is match('^https?://')
fail_msg: "multica_server_url must be set to an http(s) URL."
quiet: true
# no_log: a failed assert would otherwise render the variables it inspected.
- name: Preflight | Require a Multica personal access token
ansible.builtin.assert:
that:
- multica_pat | length > 0
- multica_pat is match('^(mul_|mcn_)')
fail_msg: >-
multica_pat must be set to a Multica personal access token
(mul_... user PAT, or mcn_... Cloud Node PAT). Supply it from the
playbook; see this role's README for Vault / 1Password / env examples.
quiet: true
no_log: true
- name: Preflight | Require every selected agent CLI to be in the catalog
ansible.builtin.assert:
that:
- multica_agent_clis | difference(multica_agent_cli_catalog.keys() | list) | length == 0
fail_msg: >-
Unknown agent CLI(s):
{{ multica_agent_clis | difference(multica_agent_cli_catalog.keys() | list) | join(', ') }}.
Add them to multica_agent_cli_catalog or drop them from multica_agent_clis.
quiet: true
- name: Preflight | Require at least one agent CLI
ansible.builtin.assert:
that:
- multica_agent_clis | length > 0
fail_msg: >-
multica_agent_clis is empty; the daemon would register no runtimes and
the host could never claim a run.
quiet: true
- name: Preflight | Resolve release artefact names and the invocation prefix
ansible.builtin.set_fact:
_multica_arch: "{{ _multica_arch_map[ansible_facts['architecture']] }}"
_multica_node_arch: "{{ _multica_node_arch_map[ansible_facts['architecture']] }}"
_multica_bin: "{{ multica_install_dir }}/multica"
_multica_cmd: >-
{{ [multica_install_dir ~ '/multica']
+ (['--profile', multica_profile] if multica_profile | length > 0 else []) }}
_multica_node_required: >-
{{ multica_agent_clis
| map('extract', multica_agent_cli_catalog, 'method')
| select('equalto', 'npm') | list | length > 0 }}
- name: Preflight | Name the release tarball
ansible.builtin.set_fact:
_multica_tarball: "multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz"
_multica_release_url: >-
{{ multica_release_download_base_url }}/v{{ multica_version }}/multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz
_multica_checksums_url: >-
{{ multica_release_download_base_url }}/v{{ multica_version }}/checksums.txt
- name: Preflight | Require base tooling on the target
ansible.builtin.package:
name: "{{ ['tar', 'xz-utils' if ansible_facts['os_family'] == 'Debian' else 'xz', 'ca-certificates', 'git'] }}"
state: present
become: true
@@ -0,0 +1,63 @@
---
- name: User | Create the daemon group
ansible.builtin.group:
name: "{{ multica_group }}"
state: present
become: true
when: multica_manage_user | bool
# Deliberately not a system account: the daemon needs a real home for CLI
# config and agent CLI credentials, and systemd linger needs a real user.
- name: User | Create the daemon user
ansible.builtin.user:
name: "{{ multica_user }}"
group: "{{ multica_group }}"
groups: "{{ multica_user_extra_groups }}"
append: true
home: "{{ multica_user_home }}"
shell: "{{ multica_user_shell }}"
create_home: true
state: present
become: true
when: multica_manage_user | bool
- name: User | Look up the daemon user
ansible.builtin.getent:
database: passwd
key: "{{ multica_user }}"
become: true
- name: User | Record uid and the per-user runtime environment
ansible.builtin.set_fact:
_multica_uid: "{{ ansible_facts.getent_passwd[multica_user][1] }}"
# systemd user units — which is what `multica daemon autostart enable` writes on
# Linux — are unreachable over a plain `sudo -u` shell without these. Without
# XDG_RUNTIME_DIR, `systemctl --user` fails with "Failed to connect to bus".
- name: User | Build the daemon user's command environment
ansible.builtin.set_fact:
_multica_user_env:
HOME: "{{ multica_user_home }}"
USER: "{{ multica_user }}"
LOGNAME: "{{ multica_user }}"
XDG_RUNTIME_DIR: "/run/user/{{ _multica_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ _multica_uid }}/bus"
PATH: "{{ multica_npm_prefix }}/bin:{{ multica_user_home }}/.local/bin:{{ multica_install_dir }}:/usr/local/bin:/usr/bin:/bin"
- name: User | Ensure the user's local bin directory exists
ansible.builtin.file:
path: "{{ multica_user_home }}/.local/bin"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0755"
become: true
- name: User | Ensure the user's cache directory exists
ansible.builtin.file:
path: "{{ multica_user_home }}/.cache"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
@@ -0,0 +1,120 @@
---
# Assertions, not hope. Each of these has failed in practice for a different
# reason, so each gets its own check and its own error message.
- name: Verify | Daemon reports running
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_verify_daemon
until:
- _multica_verify_daemon.rc == 0
- (_multica_verify_daemon.stdout | from_json).status | default('') == 'running'
retries: "{{ multica_verify_retries }}"
delay: "{{ multica_verify_delay }}"
changed_when: false
check_mode: false
- name: Verify | Daemon is the pinned version and sees the configured workspaces root
ansible.builtin.assert:
that:
- (_multica_verify_daemon.stdout | from_json).cli_version == multica_version
- (_multica_verify_daemon.stdout | from_json).server_url | regex_replace('/$', '')
== multica_server_url | regex_replace('/$', '')
fail_msg: >-
Daemon is running but does not match declared state:
{{ _multica_verify_daemon.stdout }}
quiet: true
- name: Verify | Every selected agent CLI is registered as an online runtime
ansible.builtin.command:
argv: "{{ _multica_cmd + ['runtime', 'list', '--output', 'json'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_verify_runtimes
until:
- _multica_verify_runtimes.rc == 0
- _multica_expected_providers
| difference(_multica_verify_runtimes.stdout | from_json
| selectattr('daemon_id', 'equalto', (_multica_verify_daemon.stdout | from_json).daemon_id)
| selectattr('status', 'equalto', 'online')
| map(attribute='provider') | list)
| length == 0
retries: "{{ multica_verify_retries }}"
delay: "{{ multica_verify_delay }}"
changed_when: false
check_mode: false
vars:
_multica_expected_providers: >-
{{ multica_agent_clis | map('extract', multica_agent_cli_catalog, 'provider') | list }}
- name: Verify | Runtimes name this host
ansible.builtin.assert:
that:
- _multica_verify_runtimes.stdout | from_json
| selectattr('daemon_id', 'equalto', (_multica_verify_daemon.stdout | from_json).daemon_id)
| rejectattr('device_info', 'search', '^' ~ (multica_device_name | regex_escape))
| list | length == 0
fail_msg: >-
Runtimes registered under this daemon do not report device_name
"{{ multica_device_name }}".
quiet: true
- name: Verify | Stat the workspaces root
ansible.builtin.stat:
path: "{{ multica_workspaces_root }}"
register: _multica_verify_ws
become: true
- name: Verify | Re-prove the daemon user can write to the workspaces root
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1"; f="$d/.multica-write-test.$$"; : > "$f" && rm -f "$f"'
- sh
- "{{ multica_workspaces_root }}"
become: true
become_user: "{{ multica_user }}"
register: _multica_verify_ws_write
changed_when: false
failed_when: false
check_mode: false
- name: Verify | Workspaces root assertions
ansible.builtin.assert:
that:
- _multica_verify_ws.stat.exists
- _multica_verify_ws.stat.isdir
- _multica_verify_ws.stat.pw_name == multica_user
- _multica_verify_ws_write.rc == 0
fail_msg: >-
{{ multica_workspaces_root }} is missing, not a directory, or not
owned/writable by {{ multica_user }}.
quiet: true
- name: Verify | Workspaces root is on the expected mount
ansible.builtin.assert:
that:
- multica_workspaces_root_mount in (ansible_facts['mounts'] | map(attribute='mount') | list)
fail_msg: "{{ multica_workspaces_root_mount }} is not mounted."
quiet: true
when: multica_workspaces_root_mount | length > 0
- name: Verify | Report what still needs a human
ansible.builtin.debug:
msg: >-
{{ multica_device_name }} is provisioned: multica {{ multica_version }},
daemon running, runtimes online for
{{ multica_agent_clis | join(', ') }}.
Remaining manual step(s):
{{ multica_agent_clis
| select('in', multica_agent_cli_catalog)
| map('extract', multica_agent_cli_catalog)
| selectattr('manual_login', 'defined')
| selectattr('manual_login')
| map(attribute='manual_login_hint')
| list | join('; ') | default('none', true) }}
@@ -0,0 +1,41 @@
---
# Guard against the mount not being present: without it the daemon happily
# writes run workspaces onto the root filesystem and nobody notices until it
# fills up.
- name: Workspaces root | Assert it lands on the expected mount
ansible.builtin.assert:
that:
- multica_workspaces_root_mount in (ansible_facts['mounts'] | map(attribute='mount') | list)
- multica_workspaces_root == multica_workspaces_root_mount
or multica_workspaces_root.startswith(multica_workspaces_root_mount.rstrip('/') ~ '/')
fail_msg: >-
multica_workspaces_root ({{ multica_workspaces_root }}) is not on
{{ multica_workspaces_root_mount }}, which is not currently mounted or
does not contain it. Mount it before provisioning.
quiet: true
when: multica_workspaces_root_mount | length > 0
- name: Workspaces root | Create the directory
ansible.builtin.file:
path: "{{ multica_workspaces_root }}"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "{{ multica_workspaces_root_mode }}"
become: true
# Ownership is not writability: a read-only or root-squashed network mount
# passes the file module and fails the first run.
- name: Workspaces root | Prove the daemon user can write to it
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1"; f="$d/.multica-write-test.$$"; : > "$f" && rm -f "$f"'
- sh
- "{{ multica_workspaces_root }}"
become: true
become_user: "{{ multica_user }}"
changed_when: false
register: _multica_ws_write
failed_when: _multica_ws_write.rc != 0
@@ -0,0 +1,16 @@
---
# Internal, not meant to be overridden.
# ansible_architecture -> release asset arch
_multica_arch_map: # noqa: var-naming[no-role-prefix]
x86_64: amd64
amd64: amd64
aarch64: arm64
arm64: arm64
# ansible_architecture -> nodejs.org dist arch
_multica_node_arch_map: # noqa: var-naming[no-role-prefix]
x86_64: x64
amd64: x64
aarch64: arm64
arm64: arm64
+81
View File
@@ -0,0 +1,81 @@
# obsidian_headless
Runs Obsidian's official headless Sync client
([`obsidian-headless`](https://obsidian.md/help/sync/headless), command `ob`,
open beta) as a dedicated unprivileged user under a systemd unit. It lays out
the vault folder so that other local users reach only the folders granted to
them.
Written for `spaceship-earth` (WDWL-22). Wong's daemon user (`multica`) may
read and write `inbox/` and nothing else. `everpure/` and `wip/` are `obsync`
only. Entry point: `playbooks/provision-wong-host.yml`.
## What it does
1. Creates the system user `obsync`, with a `0700` home in `/var/lib/obsync`.
That home holds the Obsidian account token and per-vault sync state.
2. Installs the pinned `obsidian-headless` into that user's npm prefix and
links `ob` into `/usr/local/bin`. Node.js is **not** installed here. The
role asserts that the runtime `multica_agent_host` installs is present and
is at least version 22.
3. Lays out the vault folder and its permissions:
| Path | Mode | ACL |
|---|---|---|
| `/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` 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
shared paths use `0710` and `0770`. The owning group is `obsync`'s private
group, so those bits grant nobody anything.
4. Installs `obsidian-sync.service` (`User=obsync`, `UMask=0077`, sandboxed)
and enables it. It is started only once a vault has been set up.
`Condition*=` lines keep it inert until then, including across reboots.
5. Verifies the isolation as the users themselves:
- `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/`.
## Do modes and ACLs survive Sync?
Obsidian Sync carries no Unix modes or ACLs. Files are created locally by
`obsync`, so their permissions come from this host, not from the remote:
- In `inbox/`, the default ACL applies and the umask is ignored, so new
content is granted to `multica`.
- Everywhere else, `UMask=0077` applies, so new content is private to
`obsync`. That covers any new top-level folder and `.obsidian/`.
- If Sync deletes and recreates `inbox/` itself, the recreated folder is
private and `multica` loses access. The failure is closed, not open, and
re-running the play restores the grant.
## Manual step: sign in and set up the vault (once per host)
The role never handles the Obsidian account. Run these as `obsync`; `-H`
makes `HOME` point to `/var/lib/obsync`:
```bash
sudo -u obsync -H ob login
sudo -u obsync -H ob sync-list-remote
sudo -u obsync -H ob sync-setup --vault "<vault name>" \
--path /srv/obsidian/vault --device-name spaceship-earth
sudo systemctl start obsidian-sync
journalctl -u obsidian-sync -f
```
Re-running the play after this starts the unit as well. Do not also run
desktop Obsidian Sync on this host: Obsidian warns that two Sync clients on
one device conflict.
## Tags
`obsidian_user`, `obsidian_install`, `obsidian_layout`, `obsidian_service`,
`obsidian_verify`. The play itself is tagged `obsidian`.
@@ -0,0 +1,64 @@
---
# =============================================================================
# obsidian_headless — defaults
#
# Runs Obsidian's official headless Sync client (`ob`, npm obsidian-headless)
# as a dedicated unprivileged user under a systemd unit, and lays out the vault
# folder so that other local users reach only the folders granted to them.
# =============================================================================
# -----------------------------------------------------------------------------
# Service account
# -----------------------------------------------------------------------------
# Holds the Obsidian account token (~/.config/obsidian-headless/auth_token) and
# the per-vault sync state, so its home is private.
obsidian_headless_user: obsync
obsidian_headless_group: obsync
obsidian_headless_home: "/var/lib/{{ obsidian_headless_user }}"
# -----------------------------------------------------------------------------
# Client
# -----------------------------------------------------------------------------
# Pinned. The package is in open beta and versions move quickly.
obsidian_headless_version: "0.0.14"
obsidian_headless_npm_prefix: "{{ obsidian_headless_home }}/.npm-global"
# Node.js is NOT installed by this role. It expects the runtime that
# multica_agent_host already puts on this host. obsidian-headless needs >= 22.
obsidian_headless_node_bin_dir: /usr/local/bin
obsidian_headless_node_min_version: "22.0.0"
# Where `ob` is linked, so `sudo -u obsync -H ob ...` works without a PATH.
obsidian_headless_link_dir: /usr/local/bin
# -----------------------------------------------------------------------------
# Vault
# -----------------------------------------------------------------------------
obsidian_headless_vault_path: /srv/obsidian/vault
# Shown in Sync version history. Applied by the manual `ob sync-setup`.
obsidian_headless_device_name: "{{ inventory_hostname_short }}"
# Top-level folders only obsidian_headless_user may enter (0700, no ACL).
obsidian_headless_private_folders: []
# Top-level folders another local user may read and write. Granted with named
# ACL entries plus matching DEFAULT entries, so files and folders Sync creates
# later inherit the grant. Everything not listed here is private: the unit runs
# with UMask=0077, and the vault root grants listed users traverse (--x) only,
# never list.
# - path: inbox
# user: multica
obsidian_headless_shared_folders: []
# -----------------------------------------------------------------------------
# Service
# -----------------------------------------------------------------------------
obsidian_headless_service_name: obsidian-sync
# Enabled either way; started only once `ob login` and `ob sync-setup` have
# been done by hand. Its Condition*= lines keep it inert until then.
obsidian_headless_service_enabled: true
# -----------------------------------------------------------------------------
# Verification
# -----------------------------------------------------------------------------
# Prove the isolation as the shared users themselves: read/write in their
# folders, denied in every private folder and on listing the vault root.
obsidian_headless_verify: true
@@ -0,0 +1,8 @@
---
# Restarts only a unit that is already running: before the manual sign-in the
# unit is inert, and starting it here would just trip its conditions.
- name: Restart obsidian sync
ansible.builtin.command:
argv: [systemctl, try-restart, "{{ obsidian_headless_service_name }}.service"]
changed_when: true
listen: restart obsidian sync
@@ -0,0 +1,15 @@
---
galaxy_info:
role_name: obsidian_headless
author: JARVIS
description: >-
Obsidian's official headless Sync client as a dedicated user under a
systemd unit, with per-folder isolation for other local users.
license: MIT
min_ansible_version: "2.16"
platforms:
- name: Ubuntu
versions:
- noble
dependencies: []
@@ -0,0 +1,39 @@
---
- name: Install | Read the installed client version
ansible.builtin.slurp:
src: "{{ obsidian_headless_npm_prefix }}/lib/node_modules/obsidian-headless/package.json"
register: _obh_pkg
failed_when: false
- name: Install | Decide whether an install is needed
ansible.builtin.set_fact:
_obh_needs_install: >-
{{ _obh_pkg.content is not defined
or (_obh_pkg.content | b64decode | from_json).version != obsidian_headless_version }}
- name: Install | Install the pinned client as the service user
ansible.builtin.command:
argv:
- "{{ obsidian_headless_node_bin_dir }}/npm"
- install
- --global
- --prefix
- "{{ obsidian_headless_npm_prefix }}"
- --no-fund
- --no-audit
- "obsidian-headless@{{ obsidian_headless_version }}"
become: true
become_user: "{{ obsidian_headless_user }}"
environment:
HOME: "{{ obsidian_headless_home }}"
PATH: "{{ obsidian_headless_node_bin_dir }}:/usr/bin:/bin"
when: _obh_needs_install | bool
changed_when: true
notify: restart obsidian sync
- name: Install | Link ob onto the default PATH
ansible.builtin.file:
src: "{{ obsidian_headless_npm_prefix }}/bin/ob"
dest: "{{ obsidian_headless_link_dir }}/ob"
state: link
force: true
@@ -0,0 +1,92 @@
---
# Permission model
#
# <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 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,
# which is why the modes here are 0710/0770 rather than 0700: a 0700 chmod
# would zero the mask and silently void every named entry.
#
# Anything else in the vault (folders Sync creates later, .obsidian/) is born
# private: the service runs with UMask=0077, and the umask is ignored only
# under a directory that has a default ACL — i.e. only in the shared folders.
- name: Layout | Collect the users granted shared folders
ansible.builtin.set_fact:
_obh_shared_users: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}"
- name: Layout | Create the vault and its parent
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "{{ '0710' if _obh_shared_users | length > 0 else '0700' }}"
loop:
- "{{ obsidian_headless_vault_path | dirname }}"
- "{{ obsidian_headless_vault_path }}"
- name: Layout | Let shared users traverse (not list) the vault and its parent
ansible.posix.acl:
path: "{{ item.0 }}"
entity: "{{ item.1 }}"
etype: user
permissions: x
state: present
loop: >-
{{ [obsidian_headless_vault_path | dirname, obsidian_headless_vault_path]
| product(_obh_shared_users) | list }}
- name: Layout | Create the private folders
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "0700"
loop: "{{ obsidian_headless_private_folders }}"
- name: Layout | Create the shared folders
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item.path }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "0770"
loop: "{{ obsidian_headless_shared_folders }}"
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 }}"
entity: "{{ item.0.user }}"
etype: user
permissions: rwx
default: "{{ item.1 }}"
state: present
loop: "{{ _obh_grants | product([false, true]) | list }}"
loop_control:
label: "{{ item.0.path }} -> {{ item.0.user }}{{ ' (default)' if item.1 else '' }}"
@@ -0,0 +1,27 @@
---
# Not `always`: that would also fire under unrelated tags of the calling
# playbook (e.g. --tags wong_disk). Every tag of this role carries it instead.
- name: Preflight
ansible.builtin.import_tasks: preflight.yml
tags: [obsidian, obsidian_user, obsidian_install, obsidian_layout, obsidian_service, obsidian_verify]
- name: Service account
ansible.builtin.import_tasks: user.yml
tags: [obsidian_user]
- name: Client
ansible.builtin.import_tasks: install.yml
tags: [obsidian_install]
- name: Vault layout
ansible.builtin.import_tasks: layout.yml
tags: [obsidian_layout]
- name: Service
ansible.builtin.import_tasks: service.yml
tags: [obsidian_service]
- name: Verify
ansible.builtin.import_tasks: verify.yml
when: obsidian_headless_verify | bool
tags: [obsidian_verify]
@@ -0,0 +1,33 @@
---
- name: Preflight | Read the Node.js version
ansible.builtin.command:
argv: ["{{ obsidian_headless_node_bin_dir }}/node", "--version"]
register: _obh_node
changed_when: false
failed_when: false
check_mode: false
- name: Preflight | Require Node.js new enough for obsidian-headless
ansible.builtin.assert:
that:
- _obh_node.rc == 0
- (_obh_node.stdout | trim | regex_replace('^v', '')) is version(obsidian_headless_node_min_version, '>=')
fail_msg: >-
{{ obsidian_headless_node_bin_dir }}/node is missing or older than
{{ obsidian_headless_node_min_version }} (found:
{{ _obh_node.stdout | default('nothing') | trim }}). This role uses the
runtime multica_agent_host installs; run provision-agent-host.yml first.
quiet: true
- name: Preflight | Look up the users granted shared folders
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
loop: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}"
- name: Preflight | Require shared folders to stay disjoint from private ones
ansible.builtin.assert:
that:
- obsidian_headless_shared_folders | map(attribute='path') | intersect(obsidian_headless_private_folders) | length == 0
fail_msg: A folder cannot be both private and shared.
quiet: true
@@ -0,0 +1,31 @@
---
- name: Service | Install the unit
ansible.builtin.template:
src: obsidian-sync.service.j2
dest: "/etc/systemd/system/{{ obsidian_headless_service_name }}.service"
owner: root
group: root
mode: "0644"
notify: restart obsidian sync
- name: Service | Enable it at boot
ansible.builtin.systemd_service:
name: "{{ obsidian_headless_service_name }}"
enabled: "{{ obsidian_headless_service_enabled | bool }}"
daemon_reload: true
# Starting it before the manual sign-in would be a no-op (its conditions fail)
# that reports "changed" on every run. Start it only once it can actually sync.
- name: Service | Check whether the manual sign-in and vault setup are done
ansible.builtin.find:
paths: "{{ obsidian_headless_home }}/.config/obsidian-headless/sync"
file_type: directory
register: _obh_sync_configs
- name: Service | Start it once a vault is set up
ansible.builtin.systemd_service:
name: "{{ obsidian_headless_service_name }}"
state: started
when:
- obsidian_headless_service_enabled | bool
- _obh_sync_configs.matched | default(0) > 0
@@ -0,0 +1,26 @@
---
- name: User | Create the group
ansible.builtin.group:
name: "{{ obsidian_headless_group }}"
system: true
state: present
# nologin: nobody needs a shell as this user. `sudo -u obsync -H ob ...` runs
# the command directly and does not consult the login shell.
- name: User | Create the user
ansible.builtin.user:
name: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
home: "{{ obsidian_headless_home }}"
shell: /usr/sbin/nologin
system: true
create_home: true
state: present
- name: User | Keep the home private (holds the Obsidian account token)
ansible.builtin.file:
path: "{{ obsidian_headless_home }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "0700"
@@ -0,0 +1,151 @@
---
# Each check runs AS the user it is about, because file modes and ACLs only
# mean something when exercised. Probes are cleaned up; nothing is left behind.
- name: Verify | Shared users can write, read and delete in their folders
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'f="$1/.obh-verify.$$"; echo ok > "$f" && grep -q ok "$f" && rm -f "$f"'
- 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
# 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)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'umask 0077; d="$1/.obh-verify-dir"; mkdir -p "$d" && echo ok > "$d/note.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 }}"
changed_when: false
check_mode: false
- name: Verify | Content the service creates in a shared folder inherits the grant (use)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1/.obh-verify-dir"; grep -q ok "$d/note.md" && echo ok > "$d/reply.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 }}"
register: _obh_inherit
changed_when: false
failed_when: false
check_mode: false
- name: Verify | Remove the inheritance probes
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item.path }}/.obh-verify-dir"
state: absent
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
changed_when: false
- name: Verify | Inheritance held
ansible.builtin.assert:
that:
- _obh_inherit.results | rejectattr('rc', 'equalto', 0) | list | length == 0
fail_msg: >-
A shared user could not read or write content created by
{{ obsidian_headless_user }} under umask 0077; the default ACL is not
being inherited.
quiet: true
# Every shared user must be refused: listing the vault root, and listing,
# reading into or writing into each private folder.
- name: Verify | Shared users are denied the vault root listing and every private folder
ansible.builtin.command:
argv: >-
{{ ['/bin/sh', '-c', _obh_deny_script, 'sh', obsidian_headless_vault_path]
+ obsidian_headless_private_folders }}
vars:
_obh_deny_script: |
v="$1"; shift
ls "$v" >/dev/null 2>&1 && { echo "can list $v"; exit 1; }
for p in "$@"; do
ls "$v/$p" >/dev/null 2>&1 && { echo "can list $p"; exit 1; }
touch "$v/$p/.obh-verify" 2>/dev/null && { rm -f "$v/$p/.obh-verify"; echo "can write $p"; exit 1; }
done
exit 0
become: true
become_user: "{{ item }}"
loop: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}"
changed_when: false
check_mode: false
@@ -0,0 +1,34 @@
# {{ ansible_managed }}
[Unit]
Description=Obsidian Headless Sync ({{ obsidian_headless_vault_path }})
Documentation=https://obsidian.md/help/sync/headless
Wants=network-online.target
After=network-online.target
# Inert until `ob login` and `ob sync-setup` have been run by hand.
ConditionPathExists={{ obsidian_headless_home }}/.config/obsidian-headless/auth_token
ConditionDirectoryNotEmpty={{ obsidian_headless_home }}/.config/obsidian-headless/sync
[Service]
Type=simple
User={{ obsidian_headless_user }}
Group={{ obsidian_headless_group }}
# Everything Sync creates is private unless it lands under a folder with a
# default ACL (the shared folders), where the umask does not apply.
UMask=0077
Environment=HOME={{ obsidian_headless_home }}
Environment=PATH={{ obsidian_headless_node_bin_dir }}:/usr/bin:/bin
WorkingDirectory={{ obsidian_headless_vault_path }}
ExecStart={{ obsidian_headless_link_dir }}/ob sync --continuous --path {{ obsidian_headless_vault_path }}
Restart=on-failure
RestartSec=30
# 2: missing encryption key, 3: no sync configuration. Both need a human.
RestartPreventExitStatus=2 3
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ obsidian_headless_vault_path }} {{ obsidian_headless_home }}
[Install]
WantedBy=multi-user.target
@@ -1,4 +1,8 @@
--- ---
# Paperclip Official image
paperclip_image: ghcr.io/paperclipai/paperclip
paperclip_version: "2026.1001.0" # no "v" prefix; also accepts stable, beta, nightly, canary, sha-<short>
# Paperclip deployment paths and configuration # Paperclip deployment paths and configuration
paperclip_base_path: /opt/paperclip paperclip_base_path: /opt/paperclip
paperclip_data_dir: "{{ paperclip_base_path }}/data" paperclip_data_dir: "{{ paperclip_base_path }}/data"
+10 -10
View File
@@ -77,16 +77,16 @@
become: true become: true
tags: [paperclip, files] tags: [paperclip, files]
- name: Template OpenCode config # - name: Template OpenCode config
ansible.builtin.template: # ansible.builtin.template:
src: opencode.jsonc.j2 # src: opencode.jsonc.j2
dest: "{{ paperclip_base_path }}/.config/opencode/opencode.jsonc" # dest: "{{ paperclip_base_path }}/.config/opencode/opencode.jsonc"
mode: '0644' # mode: '0644'
owner: 1000 # owner: 1000
group: 1000 # group: 1000
become: true # become: true
notify: Restart paperclip stack # notify: Restart paperclip stack
tags: [paperclip, files] # tags: [paperclip, files]
# Idempotent: only changed services are recreated. remove_orphans also cleans # Idempotent: only changed services are recreated. remove_orphans also cleans
# up the old paperclip-nginx / paperclip-certbot containers if they still exist. # up the old paperclip-nginx / paperclip-certbot containers if they still exist.
@@ -19,7 +19,7 @@ services:
retries: 5 retries: 5
paperclip: paperclip:
image: paperclip-local:latest image: {{ paperclip_image }}:{{ paperclip_version }}
container_name: paperclip container_name: paperclip
network_mode: host network_mode: host
env_file: env_file:
+275
View File
@@ -0,0 +1,275 @@
---
# Offline regression tests for the role's Jinja expressions, run against
# fixtures captured from a real `multica` CLI. No target host required:
#
# ansible-playbook -i localhost, tests/expressions.yml
#
# This does NOT replace the scratch-VM run described in the role README; it
# catches the class of bug where a filter chain silently returns the wrong
# type or quietly matches nothing.
- name: Expressions | parsing real multica CLI output
hosts: localhost
gather_facts: false
vars:
multica_server_url: "https://multica.wdw-labs.cloud"
multica_app_url: ""
multica_workspaces_root: "/srv/multica/workspaces"
multica_device_name: "rblundon--Mac16"
multica_runtime_name: ""
multica_max_concurrent_tasks: ""
multica_poll_interval: ""
multica_heartbeat_interval: ""
multica_agent_timeout: ""
multica_disable_auto_update: true
multica_version: "0.6.5"
multica_agent_clis: [claude, hermes]
multica_agent_cli_catalog:
claude: {provider: claude, method: npm}
hermes: {provider: hermes, method: script}
cursor: {provider: cursor, method: npm}
_multica_arch_map: {x86_64: amd64, aarch64: arm64}
tasks:
- name: Load fixtures
ansible.builtin.set_fact:
rt: "{{ lookup('file', '{{ playbook_dir }}/fixtures/runtime-list.json') }}"
ds: "{{ lookup('file', '{{ playbook_dir }}/fixtures/daemon-status.json') }}"
cs_lines: "{{ lookup('file', '{{ playbook_dir }}/fixtures/config-show.txt').splitlines() }}"
ver_out: "multica 0.6.5 (commit: 654b8ca95, built: 2026-10-21T09:41:38Z)"
# --- CLI version extraction (multica_cli.yml) ---------------------------
- name: Extract installed CLI version
ansible.builtin.set_fact:
found: >-
{{ ((([ver_out] | first | default('')).split() + ['', ''])[1]) }}
- name: Assert version extraction
ansible.builtin.assert:
that: [(found | trim) == multica_version]
success_msg: "CLI version extraction OK -> {{ found }}"
# --- config show parsing (config.yml) -----------------------------------
- name: Parse config show
ansible.builtin.set_fact:
current: >-
{{ dict(
cs_lines
| select('match', '^[a-z_]+:')
| map('regex_replace', '^([a-z_]+):\s*(.*?)\s*$', '\1|\2')
| map('split', '|')
) }}
- name: Build desired config
ansible.builtin.set_fact:
desired: >-
{{ {
'server_url': multica_server_url,
'app_url': multica_app_url,
'workspaces_root': multica_workspaces_root,
'device_name': multica_device_name,
'runtime_name': multica_runtime_name,
'max_concurrent_tasks': multica_max_concurrent_tasks,
'poll_interval': multica_poll_interval,
'heartbeat_interval': multica_heartbeat_interval,
'agent_timeout': multica_agent_timeout,
'disable_auto_update': ('true' if multica_disable_auto_update | bool else 'false'),
} | dict2items
| rejectattr('value', 'equalto', '')
| list | items2dict }}
- name: Show parse results
ansible.builtin.debug:
msg:
parsed_keys: "{{ current.keys() | list }}"
disable_auto_update_now: "{{ current['disable_auto_update'] | default('MISSING') }}"
server_url_now: "{{ current['server_url'] | default('MISSING') }}"
desired: "{{ desired }}"
would_change: >-
{{ desired | dict2items
| selectattr('key', 'in', current.keys() | list)
| rejectattr('value', 'equalto', 'IMPOSSIBLE')
| map(attribute='key') | list }}
- name: Assert config parsing found the expected keys
ansible.builtin.assert:
that:
- "'server_url' in current"
- "'disable_auto_update' in current"
- current['server_url'] == '(not set)'
- current['disable_auto_update'] == 'false'
- desired | length == 4
- "'app_url' not in desired"
success_msg: "config show parsing + desired-config filtering OK"
# --- runtime verification (verify.yml) ----------------------------------
- name: Compute online providers for this daemon
ansible.builtin.set_fact:
expected: "{{ multica_agent_clis | map('extract', multica_agent_cli_catalog, 'provider') | list }}"
online: >-
{{ rt | from_json
| selectattr('daemon_id', 'equalto', (ds | from_json).daemon_id)
| selectattr('status', 'equalto', 'online')
| map(attribute='provider') | list }}
- name: Assert every expected provider is online
ansible.builtin.assert:
that: [(expected | difference(online)) | length == 0]
success_msg: "runtime check OK: expected={{ expected }} online={{ online }}"
- name: Assert device_info naming check
ansible.builtin.assert:
that:
- rt | from_json
| selectattr('daemon_id', 'equalto', (ds | from_json).daemon_id)
| rejectattr('device_info', 'search', '^' ~ (multica_device_name | regex_escape))
| list | length == 0
success_msg: "device_info check OK for {{ multica_device_name }}"
- name: Assert daemon status fields
ansible.builtin.assert:
that:
- (ds | from_json).status == 'running'
- (ds | from_json).cli_version == multica_version
- ((ds | from_json).server_url | regex_replace('/$','')) == (multica_server_url | regex_replace('/$',''))
success_msg: "daemon status assertions OK"
# --- negative case: a provider that is NOT online ------------------------
- name: Assert a missing provider is detected
ansible.builtin.assert:
that:
- (['codex'] | difference(online)) | length == 1
success_msg: "missing-provider detection OK"
- name: Expressions | preflight fact-building
hosts: localhost
gather_facts: false
vars:
multica_install_dir: /usr/local/bin
multica_agent_cli_catalog:
claude: {provider: claude, method: npm}
hermes: {provider: hermes, method: script}
multica_release_download_base_url: "https://github.com/multica-ai/multica/releases/download"
multica_version: "0.6.5"
_multica_arch_map: {x86_64: amd64, aarch64: arm64}
tasks:
- name: Case - profile unset, npm CLI selected
ansible.builtin.set_fact:
multica_profile: ""
multica_agent_clis: [claude, hermes]
_multica_arch: "{{ _multica_arch_map['x86_64'] }}"
- name: Build facts
ansible.builtin.set_fact:
_multica_cmd: >-
{{ [multica_install_dir ~ '/multica']
+ (['--profile', multica_profile] if multica_profile | length > 0 else []) }}
_multica_node_required: >-
{{ multica_agent_clis
| map('extract', multica_agent_cli_catalog, 'method')
| select('equalto', 'npm') | list | length > 0 }}
_multica_release_url: >-
{{ multica_release_download_base_url }}/v{{ multica_version }}/multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz
- name: Assert types and values
ansible.builtin.assert:
that:
- _multica_cmd is sequence
- _multica_cmd is not string
- _multica_cmd == ['/usr/local/bin/multica']
- (_multica_cmd + ['daemon', 'status']) == ['/usr/local/bin/multica', 'daemon', 'status']
- _multica_node_required | bool
- _multica_release_url == 'https://github.com/multica-ai/multica/releases/download/v0.6.5/multica-cli-0.6.5-linux-amd64.tar.gz'
success_msg: "argv prefix + node detection + URL OK: {{ _multica_cmd }}"
- name: Case - profile set, script-only CLI selected
ansible.builtin.set_fact:
multica_profile: "haunted"
multica_agent_clis: [hermes]
- name: Rebuild facts
ansible.builtin.set_fact:
_multica_cmd: >-
{{ [multica_install_dir ~ '/multica']
+ (['--profile', multica_profile] if multica_profile | length > 0 else []) }}
_multica_node_required: >-
{{ multica_agent_clis
| map('extract', multica_agent_cli_catalog, 'method')
| select('equalto', 'npm') | list | length > 0 }}
- name: Assert profile + no-node case
ansible.builtin.assert:
that:
- _multica_cmd == ['/usr/local/bin/multica', '--profile', 'haunted']
- not (_multica_node_required | bool)
success_msg: "profile prefix + node-not-required OK: {{ _multica_cmd }}"
- name: Assert PAT prefix validation accepts both token kinds and rejects junk
ansible.builtin.assert:
that:
- "'mul_abc123' is match('^(mul_|mcn_)')"
- "'mcn_abc123' is match('^(mul_|mcn_)')"
- "not ('ghp_abc123' is match('^(mul_|mcn_)'))"
- "not ('' is match('^(mul_|mcn_)'))"
success_msg: "PAT validation OK"
- name: Assert unknown-agent detection
ansible.builtin.assert:
that:
- "['claude', 'bogus'] | difference(multica_agent_cli_catalog.keys() | list) == ['bogus']"
- "['claude', 'hermes'] | difference(multica_agent_cli_catalog.keys() | list) | length == 0"
success_msg: "catalog validation OK"
- name: Assert workspaces-root-under-mount logic
vars:
root: "/mnt/utilidor/multica/workspaces"
mnt: "/mnt/utilidor"
bad_root: "/mnt/utilidor-other/workspaces"
ansible.builtin.assert:
that:
- root == mnt or root.startswith(mnt.rstrip('/') ~ '/')
- not (bad_root == mnt or bad_root.startswith(mnt.rstrip('/') ~ '/'))
success_msg: "mount containment check OK (rejects /mnt/utilidor-other)"
- name: Expressions | pinned agent-CLI version guard
hosts: localhost
gather_facts: false
vars:
# Mirrors the expression in tasks/agent_cli/npm.yml.
guard: >-
{{ rc != 0
or (pin | default('') | length > 0
and not ((out | default('')) is search(
'(^|[^0-9.])' ~ (pin | regex_escape) ~ '([^0-9.]|$)'))) }}
tasks:
- name: Pinned version already installed - must NOT reinstall
ansible.builtin.assert:
that: [not (guard | bool)]
success_msg: "2.1.304 pin vs '2.1.304 (Claude Code)' -> no install"
vars: {rc: 0, pin: "2.1.304", out: "2.1.304 (Claude Code)"}
- name: Different version installed - must reinstall
ansible.builtin.assert:
that: [guard | bool]
success_msg: "2.1.304 pin vs 2.1.306 installed -> install"
vars: {rc: 0, pin: "2.1.304", out: "2.1.306 (Claude Code)"}
- name: Longer version must not satisfy a shorter pin
ansible.builtin.assert:
that: [guard | bool]
success_msg: "2.1.304 pin vs 2.1.3045 installed -> install (no substring false-match)"
vars: {rc: 0, pin: "2.1.304", out: "2.1.3045 (Claude Code)"}
- name: Prefix of installed version must not satisfy the pin
ansible.builtin.assert:
that: [guard | bool]
success_msg: "2.1.30 pin vs 2.1.304 installed -> install"
vars: {rc: 0, pin: "2.1.30", out: "2.1.304 (Claude Code)"}
- name: Version embedded mid-string still matches (hermes-style output)
ansible.builtin.assert:
that: [not (guard | bool)]
success_msg: "0.21.5 pin vs 'Hermes Agent v0.21.5+2024.gb609c74' -> no install"
vars: {rc: 0, pin: "0.21.5", out: "Hermes Agent v0.21.5+2024.gb609c74 (2026.9.24)"}
- name: Binary absent - must install regardless of pin
ansible.builtin.assert:
that: [guard | bool]
success_msg: "rc!=0 -> install"
vars: {rc: 1, pin: "2.1.304", out: ""}
- name: Unpinned tracks latest - never reinstalls once present
ansible.builtin.assert:
that: [not (guard | bool)]
success_msg: "empty pin + present -> no install"
vars: {rc: 0, pin: "", out: "2.1.306 (Claude Code)"}
+17
View File
@@ -0,0 +1,17 @@
Config file: /home/multica/.config/multica/config.json
server_url: (not set)
app_url: (not set)
workspace_id: (not set)
device_name: (not set)
runtime_name: (not set)
workspaces_root: (not set)
max_concurrent_tasks: (not set)
poll_interval: (not set)
ws_claim_poll_interval: (not set)
heartbeat_interval: (not set)
agent_timeout: (not set)
codex_semantic_inactivity_timeout: (not set)
codex_handshake_timeout: (not set)
disable_auto_update: false
auto_update_check_interval: (not set)
disable_auto_reload: false
+37
View File
@@ -0,0 +1,37 @@
{
"active_task_count": 1,
"agents": [
"claude",
"opencode",
"hermes",
"cursor"
],
"cli_commit": "654b8ca954034f8181eb6bbb4fc298dede760156",
"cli_version": "0.6.5",
"daemon_id": "01a15ad9-f27e-72da-a1a5-53ec15b53f9d",
"device_name": "rblundon--Mac16",
"failed_terminal_report_bytes": 0,
"failed_terminal_report_count": 0,
"launched_by": "desktop",
"os": "darwin",
"pending_terminal_report_bytes": 0,
"pending_terminal_report_count": 0,
"pid": 34832,
"profile": "desktop-multica.wdw-labs.cloud",
"resource_wait_task_count": 0,
"running_task_count": 1,
"server_url": "https://multica.wdw-labs.cloud",
"status": "running",
"uptime": "4h12m4s",
"workspaces": [
{
"id": "00000000-0000-0000-0000-000000000001",
"runtimes": [
"ea210ad4-b0e8-4663-a212-7d6878668ec7",
"eb68ea3f-78d6-4214-974d-9022aff55216",
"245f271d-339b-47d5-8ea6-1e7eb6576ece",
"c8316de7-5668-4276-8b31-3e7f873b8499"
]
}
]
}
+146
View File
@@ -0,0 +1,146 @@
[
{
"created_at": "2026-10-20T19:56:21Z",
"custom_name": null,
"daemon_id": "01a15ad9-f27e-72da-a1a5-53ec15b53f9d",
"device_info": "rblundon--Mac16 \u00b7 2.1.274 (Claude Code)",
"id": "ea210ad4-b0e8-4663-a212-7d6878668ec7",
"last_seen_at": "2026-10-21T19:47:35Z",
"launch_header": "claude (stream-json)",
"metadata": {
"capabilities": [
"skill-bundles-v1",
"coalesced-comments-v1",
"execution-manifest-v1",
"agent-skill-v1",
"remote-mcp-v1",
"local-worktree-v1",
"source_context_quick_create_v1",
"rpc-v1",
"platform-skill-v1",
"checkout-keeps-work-v1",
"joined-wakeups-v1"
],
"cli_version": "0.6.5",
"launched_by": "desktop",
"version": "2.1.274 (Claude Code)"
},
"name": "Claude (rblundon--Mac16)",
"owner_id": "00000000-0000-0000-0000-000000000000",
"profile_id": null,
"provider": "claude",
"runtime_mode": "local",
"status": "online",
"updated_at": "2026-10-21T17:09:10Z",
"visibility": "private",
"workspace_id": "00000000-0000-0000-0000-000000000001"
},
{
"created_at": "2026-10-20T19:56:22Z",
"custom_name": null,
"daemon_id": "01a15ad9-f27e-72da-a1a5-53ec15b53f9d",
"device_info": "rblundon--Mac16 \u00b7 2025.09.18-7ae6800",
"id": "eb68ea3f-78d6-4214-974d-9022aff55216",
"last_seen_at": "2026-10-21T19:47:35Z",
"launch_header": "cursor-agent (stream-json)",
"metadata": {
"capabilities": [
"skill-bundles-v1",
"coalesced-comments-v1",
"execution-manifest-v1",
"agent-skill-v1",
"remote-mcp-v1",
"local-worktree-v1",
"source_context_quick_create_v1",
"rpc-v1",
"platform-skill-v1",
"checkout-keeps-work-v1",
"joined-wakeups-v1"
],
"cli_version": "0.6.5",
"launched_by": "desktop",
"version": "2025.09.18-7ae6800"
},
"name": "Cursor (rblundon--Mac16)",
"owner_id": "00000000-0000-0000-0000-000000000000",
"profile_id": null,
"provider": "cursor",
"runtime_mode": "local",
"status": "online",
"updated_at": "2026-10-21T17:09:10Z",
"visibility": "private",
"workspace_id": "00000000-0000-0000-0000-000000000001"
},
{
"created_at": "2026-10-20T19:56:22Z",
"custom_name": null,
"daemon_id": "01a15ad9-f27e-72da-a1a5-53ec15b53f9d",
"device_info": "rblundon--Mac16 \u00b7 Hermes Agent v0.21.5+2024.gb609c74 (2026.9.24) \u00b7 upstream f1247d2e",
"id": "245f271d-339b-47d5-8ea6-1e7eb6576ece",
"last_seen_at": "2026-10-21T19:47:35Z",
"launch_header": "hermes acp",
"metadata": {
"capabilities": [
"skill-bundles-v1",
"coalesced-comments-v1",
"execution-manifest-v1",
"agent-skill-v1",
"remote-mcp-v1",
"local-worktree-v1",
"source_context_quick_create_v1",
"rpc-v1",
"platform-skill-v1",
"checkout-keeps-work-v1",
"joined-wakeups-v1"
],
"cli_version": "0.6.5",
"launched_by": "desktop",
"version": "Hermes Agent v0.21.5+2024.gb609c74 (2026.9.24) \u00b7 upstream f1247d2e"
},
"name": "Hermes (rblundon--Mac16)",
"owner_id": "00000000-0000-0000-0000-000000000000",
"profile_id": null,
"provider": "hermes",
"runtime_mode": "local",
"status": "online",
"updated_at": "2026-10-21T17:09:10Z",
"visibility": "private",
"workspace_id": "00000000-0000-0000-0000-000000000001"
},
{
"created_at": "2026-10-20T19:56:22Z",
"custom_name": null,
"daemon_id": "01a15ad9-f27e-72da-a1a5-53ec15b53f9d",
"device_info": "rblundon--Mac16 \u00b7 1.18.20",
"id": "c8316de7-5668-4276-8b31-3e7f873b8499",
"last_seen_at": "2026-10-21T19:47:35Z",
"launch_header": "opencode run (json)",
"metadata": {
"capabilities": [
"skill-bundles-v1",
"coalesced-comments-v1",
"execution-manifest-v1",
"agent-skill-v1",
"remote-mcp-v1",
"local-worktree-v1",
"source_context_quick_create_v1",
"rpc-v1",
"platform-skill-v1",
"checkout-keeps-work-v1",
"joined-wakeups-v1"
],
"cli_version": "0.6.5",
"launched_by": "desktop",
"version": "1.18.20"
},
"name": "Opencode (rblundon--Mac16)",
"owner_id": "00000000-0000-0000-0000-000000000000",
"profile_id": null,
"provider": "opencode",
"runtime_mode": "local",
"status": "online",
"updated_at": "2026-10-21T17:09:10Z",
"visibility": "private",
"workspace_id": "00000000-0000-0000-0000-000000000001"
}
]
-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: http:
routers: routers:
gitea: gitea:
rule: "Host(`gitea.mk-labs.cloud`)" rule: "Host(`gitea.mk-labs.cloud`) || Host(`mad-tea-party.wdw-labs.cloud`)"
entryPoints: entryPoints:
- websecure - websecure
tls: tls:
certResolver: cloudflare certResolver: cloudflare
domains: domains:
- main: "gitea.mk-labs.cloud" - main: "gitea.mk-labs.cloud"
sans:
- "mad-tea-party.wdw-labs.cloud"
service: gitea service: gitea
services: services:
+45
View File
@@ -0,0 +1,45 @@
# ------------------------------------------------------------------------------
# App of Apps — Root
#
# This is applied ONCE after ArgoCD is bootstrapped. It is the second and
# final manual kubectl apply:
#
# kubectl apply -n argocd -f cluster/argocd/apps-of-apps.yaml
#
# From this point forward, all changes are made via git. ArgoCD self-manages.
#
# This Application watches cluster/platform/ and cluster/applications/ and
# creates child Applications for everything it finds there.
# ------------------------------------------------------------------------------
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: apps-of-apps
namespace: argocd
annotations:
# Wave 0 — this is the root, everything else comes after
argocd.argoproj.io/sync-wave: "0"
spec:
project: default
source:
repoURL: https://mad-tea-party.wdw-labs.cloud/rblundon/homelab.git
targetRevision: main
path: fastpass
directory:
recurse: true
include: "*/application.yaml"
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
ignoreDifferences:
- group: argoproj.io
kind: Application
jsonPointers:
- /spec/syncPolicy/automated
+29
View File
@@ -0,0 +1,29 @@
# ------------------------------------------------------------------------------
# ExternalSecret — Authentik OIDC credentials for ArgoCD
#
# Targets argocd-secret directly — ArgoCD reads oidc.authentik.* keys
# from its own secret. ESO merges these fields into the existing secret
# without overwriting admin.password etc. (creationPolicy: Merge)
# ------------------------------------------------------------------------------
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: argocd-oidc-secret
namespace: argocd
spec:
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-connect
refreshInterval: "1h"
target:
name: argocd-secret
creationPolicy: Merge
data:
- secretKey: oidc.authentik.clientID
remoteRef:
key: argocd-oidc
property: client-id
- secretKey: oidc.authentik.clientSecret
remoteRef:
key: argocd-oidc
property: client-secret
+21
View File
@@ -0,0 +1,21 @@
# ------------------------------------------------------------------------------
# ArgoCD Install — v3.4.2
#
# This is NOT applied by ArgoCD itself (chicken-and-egg).
# It is the ONE manual bootstrap step:
#
# kubectl create namespace argocd
# kubectl apply -n argocd --server-side --force-conflicts -f install.yaml
#
# After this, everything else is driven by apps-of-apps.yaml.
#
# To upgrade ArgoCD: update the version below and re-apply.
# --server-side --force-conflicts is required due to CRD size limits.
#
# Source: https://github.com/argoproj/argo-cd/releases/tag/v3.4.2
# ------------------------------------------------------------------------------
# kubectl apply -n argocd --server-side --force-conflicts -f \
# https://raw.githubusercontent.com/argoproj/argo-cd/v3.4.2/manifests/install.yaml
#
# We reference the upstream manifest directly rather than vendoring it.
# Pin upgrades by changing the version tag above and re-running the command.
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: argocd
+135
View File
@@ -0,0 +1,135 @@
# ─── ArgoCD Helm values — fastpass cluster ─────────────────────────────────────
# Chart: argo/argo-cd
# Version: 7.8.23
#
# Install:
# helm repo add argo https://argoproj.github.io/argo-helm
# helm repo update
# helm upgrade --install argocd argo/argo-cd \
# --namespace argocd \
# --create-namespace \
# --version 7.8.23 \
# --values cluster/argocd/values.yaml \
# --wait
#
# Then apply the root app-of-apps (second and final manual step):
# kubectl apply -n argocd -f cluster/argocd/apps-of-apps.yaml
# ───────────────────────────────────────────────────────────────────────────────
# ─── Global ─────────────────────────────────────────────────────────────────────
global:
domain: carousel-of-progress.wdw-labs.cloud
# ─── ArgoCD server ──────────────────────────────────────────────────────────────
server:
# Run insecure — TLS is terminated at ingress-nginx, not at ArgoCD itself.
# Without this, ArgoCD redirects to HTTPS internally and breaks behind ingress.
extraArgs:
- --insecure
metrics:
enabled: true
serviceMonitor:
enabled: true
ingress:
enabled: true
ingressClassName: nginx
hostname: carousel-of-progress.wdw-labs.cloud
tls: true
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-passthrough: "false"
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
# ─── ConfigMap — core ArgoCD settings ───────────────────────────────────────────
configs:
params:
server.insecure: "true"
server.session.duration: "720h"
cm:
url: https://carousel-of-progress.wdw-labs.cloud
# Authentik native OIDC SSO
# Client secret pulled from 1Password via ESO → argocd-oidc-secret
oidc.config: |
name: Authentik
issuer: https://authentik.local.mk-labs.cloud/application/o/argocd/
clientID: $oidc.authentik.clientID
clientSecret: $oidc.authentik.clientSecret
requestedScopes:
- openid
- profile
- email
- groups
groupsClaim: groups
# Resource health checks — ignore progressing Cilium CRDs
resource.customizations.health.cilium.io_CiliumLoadBalancerIPPool: |
hs = {}
hs.status = "Healthy"
hs.message = "IP pool ready"
return hs
# Ignore ESO-injected default fields on ExternalSecret resources.
resource.customizations.ignoreDifferences.external-secrets.io_ExternalSecret: |
jqPathExpressions:
- '.spec.data[]?.remoteRef?.conversionStrategy'
- '.spec.data[]?.remoteRef?.decodingStrategy'
- '.spec.data[]?.remoteRef?.metadataPolicy'
- '.spec.dataFrom[]?.extract?.conversionStrategy'
- '.spec.dataFrom[]?.extract?.decodingStrategy'
- '.spec.dataFrom[]?.extract?.metadataPolicy'
# RBAC — argocd-admins group from Authentik gets full access
rbac:
policy.default: role:readonly
policy.csv: |
g, argocd-admins, role:admin
g, argocd-viewers, role:readonly
# Repository credentials — Gitea SSH
repositories:
homelab-repo:
url: git@gitea.mk-labs.cloud:rblundon/homelab.git
name: homelab
type: git
insecure: "false"
# ─── Redis ──────────────────────────────────────────────────────────────────────
redis:
enabled: true
# ─── Notifications ──────────────────────────────────────────────────────────────
# Disabled for now — wire into n8n via webhook later
notifications:
enabled: false
# ─── ApplicationSet controller ──────────────────────────────────────────────────
applicationSet:
enabled: true
metrics:
enabled: true
serviceMonitor:
enabled: true
# ─── Dex ────────────────────────────────────────────────────────────────────────
# Disabled — using Authentik as the native OIDC provider
dex:
enabled: false
# Disable default admin user
admin:
enabled: false
# Enable Prometheus metrics for ArgoCD
controller:
metrics:
enabled: true
serviceMonitor:
enabled: true
repoServer:
metrics:
enabled: true
serviceMonitor:
enabled: true
+1 -1
View File
@@ -2,7 +2,7 @@
variable "proxmox_api_url" { variable "proxmox_api_url" {
type = string type = string
description = "Proxmox API endpoint URL (e.g., https://fantasyland.mk-labs.net:8006)" description = "Proxmox API endpoint URL (e.g., https://fantasyland.local.mk-labs.cloud:8006)"
} }
variable "proxmox_api_token" { variable "proxmox_api_token" {