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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:04:46 -05:00
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
65 changed files with 3856 additions and 492 deletions

No files matched your search

+426 -417
View File
@@ -1,418 +1,427 @@
$ANSIBLE_VAULT;1.1;AES256
39633233363564393465633065323730343130653362373436356334323930393036656638303033
6333636536643835303936633039633138336434663565320a643961663732373630333238633466
62633236666331326165653637353038313163643162323763313635306230333338613164316337
6133396638336465350a306163643138653639646133346561323333313332336639393764343364
30666535323264363661653236333839643631376337303563383739656238343732396364366437
33333437643930656564376236333736336465356134653239393036313830333462363633653366
62623863613261323135633262633066383363313731646361326232366661356261316633323164
34653661383366643531353939303333363832323731623830393262373538613631613631616463
66396163613965343833356432643534393539643961373263323533316664386665383330326665
35393537363734333961393931386161386234323264316466303361663139323431303034393035
33353432363162353261646231313136643533363563343537666565306638353564646235623835
33653663393838303364363762613663646139326135313438663838323064326265303733346164
62386130613134653739353930623330663135633632346531306535643332303939356263636232
34313939333263313133373635396138643838396235626662343664316632663930313038643863
65653630373437373631633032333663353866636630306235363035623837656661323735356361
33343961373235613262613363636565396234613865303937323432656433343836373831343162
37356234363662333764656530333139653434336137326631386366306162646433333239316133
35303136633664336433386331316662313330643734383139633938636462346539623835653738
63373439623866363863633963633265373339613130343966656637376632396139646266306666
62316430343338626331326433366266396131666264633234323233646639643236323937396431
62323738376664303237653563323164396166313266613937383431653766616234653762383163
65666138353262663536623036623439363463306136363930343633383436333565363232376162
35343035306230666432666131643634383263663838626466633133653830623330656164313465
32363031313266396532313563343963613133396232353832636464663462636138623637646135
64356364363862653465303837663261333933623266333736343938643065393031343535633762
62363436323931373563323863643066653237393339653363656133316365346262306463336364
66303732366437633461373065363730336235313732623131633366633834376563663734653063
31656638623933376666393232613238323239623836313430356639613131303761346535336338
65613363366331613865343163623863636666303336646636663465373065393731306634663239
32653666343631666235383863336338326438646137656562646431623436323037313438343839
34663062396564306138356465623032396239396562643933306365613033333065323630393665
66346262303965353539663463626261633332373833346430346235376636323633623864353133
34666265373936376339646363383834393166376434653233306632373966336232316339336465
63313866653732386234666531663030313364626534343235333463376334663061316461613331
64303962393930373036643561393762666334666538626233333937653038633734626430623837
33303463386363386562333061353963303538366362313131656433383263666631666566633765
65613238393736646637346333323933326664663862326563363566613361343539646264626561
37323034323136376433343433316266386135333765643036373565326539643066336533343431
34316264393261373130333930383932343864386534376633336239303066356666363438356632
63313637616465386363346162653064613236383166353432636530336437376665643830356134
35366239326637373366383962376165643932336632373330326533366535666434343161373264
30323130626236356666663230613837373666666334646366616339313661613238666138353832
37633066303139313961643935383632646662613766303366663335306263646439383534373639
33643733626531376565623431666637323736393361373566626666633232383832656333333566
65616662383561626161363061646334633033386534633533393032386532643939373736356532
31633430613338343762656630303139316634323635653763386366633366366336383064613333
32373164633066643064316535333364366461666332663165346462386563373864663565613736
64636131346133623166656235306230353733373263313166383761303364366434656666303732
34313234663139616466633562383663316630666261336463653236393832303363316438326635
32343135396333613936383239633938393261393435653239643763373065623531336633313130
64323761646666636464623734656236653439646433303934633536333862653930303133336264
66333361323735386338643933336636303263613739666432626137393061343833636537396539
62626534663636323764376437393237663839356534306438306431663937323364316431323264
38353234303364653833393733383736636139336237386137313937656137636562303036666135
62626530383037613439646261346130353430303131303936393230333662386533356631326533
65643931313036623766316137353130363535383834323363623334376234393265326362653838
34353665666537343232626137383166383761643232313539346461393937353737373935323462
38303631383465623865323230333330383730333536346366616332343539313237333538333431
65636235343938316635633732393430323932626339323236323663663932306463373039343363
66363235366539386430333365613338646166313133346239346537313666396435343064643537
30363236396563346165353032373463386630646134356634646465353136643635373164643263
31396333353232323937346530303334623163316538353937396337636165356163396639373731
64363036346434623366376261316632343238306564646234383433343137396462303466383635
38353161383933336264393332386233643666656132633038323936353630313635313365353663
30353734363435373265616333653737383838376463646466633732333537346565666433656137
62333234653264626532323064386639643136336264346232663537393065386666623463336433
38613966376535303961343361363930313664633030353538306633656335326430653534346161
31613166366231386239366631623839316361303630663063336637303832373463613735356364
39323238656232383632643630386261323764643861353063616130323939383732343863356633
33303965363565313535386463613030373634653763313430643335393032663839636164303630
38653930383661386331613763316365653538386266396430633638633638653134633365343937
30313565663139366139623065623730356363313731663034663362313335346561323130396333
37343165333434613235363964323564306330393738333635303763633466366664616138613862
61623464656630343361353433313836316230653435356661326366366131386637363334326336
34313334386464363261343831356163613834353639323562333930363563343963633833623832
37323964336565383062373739346261306139376637306461336265316336666439353536313063
31653432373235666235396239363261316333373332333234376635323261306234383836383161
33303136363335353539663833306166643737333238643066313633323031613237666264666365
63333766616631366639396235633466383062623462363638363866346462306535623839626231
63666633393137316637616634653139316439626432366236663738316166643335333534626164
62623861356134663637663665313934313833636165643732376438316563383539663037373432
30326233373936346433323934663633363166376463623135386238393334396466316265613965
33623362653436613835386535393038363064373233623266643466633732366632346330343438
65383938363063666263616330356661343732313435383137313435656337353063613230656462
63383465343932303832313335346230313164333362303935343836363764313933623831306130
36333339373230386162323337386661303663616530383863333332396439313833303461643662
37336231343065623635376564393934316333626566623765353265613737646131653934343631
32613463636130333561653066343839303332396239656534303733643639343337363437656662
63303732326235643830303038663131613330303863353231313561376231666135353464303864
66633332613139386136303264366464356434633434656536393664313130646234393634326432
63663938373932386564363834373134356464633662383130386337633564643930613734383631
38643239663733393336643834643438303737636635396630333565656264653866366166623332
35653430323365313834643862393065323531633230613630343738333934383161613264356265
66626561386138396639353934653465373162323838303233636565306534633236343066623638
38653132376663666136383235663337343762646137643935626365343162363439303061393563
32633366623834613739303139333038303762316663663136656636663663386362613264663238
35323038326239643062666336333932383065383461373933626339383434363736323536313533
65363565306239363935666234653438393039303663623636376262666164393334363839393665
62326533613639643938613838396565376334333035333962626135356265353639653633373438
37316463303838633335306333346465386461373766356533313863333534326231353864303531
32663461623731653138646336663266303065306562643961393936373937653664323032626339
39373539363664316432333931633866646333346363336466393963656637366562336162613465
32663230613335393839633633633063636635373363653234663564663264353036393434663963
66653631663062666338326133636236303862356662666533326236613965323462303839623331
35626238323837343738356264313266383135626239363731353639633837323132623635633435
61386663366533326130626332613562633965633837346237633334303564363136633733323361
32353361396166396334386630353738643438643537303034623830303939326436363239343664
35633731396161623934633864313863323336326535313637653935376436363162363565666634
33613435326539343865653862613263383236313130383234623766333637646464613365346532
34653035393862643039623763323138333334623866316437613435666530396134306665316334
31373364653865323765646535303166373638636134326538343563363732636337653931373234
35303433656137333635383334376237616261393334623835353866323333393630373765613835
33363636323664383136353863666635646563393764633531616135313935333539333664343664
62633465356563353433633564303531633236656631326331323638643666643636396162316532
36313839363164663336326639313066316331303365386130666662366638353834306662303336
34393538346338376330333332383231333630353334646635313037313736613139383832663566
30653137386339623136356663613431303564633966383633643830393636383734306238333831
64363933386532346264666638316161393435653661356162306262323131643535636134396439
64656538313938623938323638393734343464653362636532386663303762326234373930363866
32306235363837396339383461396430373964396533616434373937386266396439303964356534
65663262343461343864633934636536366335326162393830633232633832383166366430313934
64643262313163323531326163323431386532386530326332326130353230653263616139303566
64386535326262326463663734303061613435653964303764386537636132646430626530313331
38323563303738353630653631333535376239393730633535353934316131333039353931616439
35343531353332303932353636643763643035376161643536333365666466616234326237633430
62666464613334633537306431396534306237313032306632323266613134363231313331313139
63316661303830653035376261633539386533373235653839333562656135636433333263303030
65333136653939616231363835616134373038633634656366363266386534343233393730386436
61366536333730646562343333313234663037616461643864613334313464326661366133613966
32343831376266376636646439323566373033643939663261356432623865313833316238363363
34353765396233336263373161323433396437363463626464386463346330653861656162346639
32653738353738396630383431346535616165306638666137616664336235623834616239613131
33643866396530643062633537376565306538313437623462363963363364303738376662323237
37313438633138666434646633333262643333633930396439333565663930613263363638366265
61376165353939633165646330656631323765313430323630303335643362393763663134333333
64383338343939616362363930626235303532666538653561656162396566383865363463373233
62363363623938383234343639656631343831616564633136363332393131356531343137396534
65626639613663323139393061353636313036396163646434613431323864303130663531343964
35363339336436336532303763333564363962313637653438626235353339333438376433396565
32646536383036353864356134623564653436326232643330613736386162656161336165363463
37646138323937343839373736656332643430386630383463393637643463396231303730656530
61633563633338663635353235663931303964333839663333323062646266646466356434343432
38303762336132333132373532623637353731306161316564373935303366666530376639613166
66383533663634333162373436313031643533336436373563356364323236333632303663393761
32343631643566626433306532343764373263666537346538303561323465646237396639363065
64646334633033653962303437396430646332306562623763323733303930623763393537646532
38336439636134666264663339346230326435383365616433363535613235626439623037666234
33393434306662333966316364303261643137356136326637346539363538353465313662393534
34306132613636633936616339636632623733616432323564393565336633366464306661306130
32373465656131346138343537373463663937636337303765366337376666346136643139356234
37376436313834366538636136303965383330663437343363633861396634373764373863613734
39363837613363663337323137616139633935643437353838383735383561346365333565316336
37373231343665343838663235656132613331663638323331363763373539323239306232656338
33643632333863333064666332663437613961393530616330643139663736313733306339346535
31386639363338656462663566313836313066623661653139306662636331373038396636386362
63623431336139316233396432396166633762363662616432616634363639616530663931366230
37396434343039303232363633626532383235376538626438356639386661373330663838663134
65666437363139663932383035303036343633303766343836363435313739363839373938643562
33393733303165373364643961326634396265323962313433653634326436336564313338323631
63666636646638626639323631323335653961363964616461393632316363623162653665333532
34346532313436356235343539343330656235376261326262646364393866343562323663363232
32636539343063323734663732303430306261366361383461363963346633316632623531363530
34343633666136323238336530653335643730326439666637663266363563636466306531393431
33306662653537643835656637636466383965356663373530386431383837373438373436383461
66653735376535656637626231613437336663333132386135666432656638636138383939386665
35346636313439613335653635653861303231663239613563333438303738356439343933326332
35653764613061356465666361323134323331326137353863666364333332326133343039353164
35663264326432366330353230643762623933346363623039656137356135613063366265626334
64666539373230376335623365373866613761373637393066343536386134633131393339343439
39353938643233636562353131336262616439623461363531383064616564616462383833363338
31396436303330336532333661333263346233626532653437633766306230643032633035636132
65343938643630303063336664393532363131333532323566306338343837323431373062393265
31653964376339626632313734366665613563383266376533353664363764366639393563313164
63646661373461323039636338313165306435323530633535353331343166636565373630333530
63343262656538616232313633616166623534643834313138326634356334636635363935343030
61373533373863633630616135626335376136336261646465666135383130633032656465623938
32306433303937363133623537313535633164346536626465366663623430383639386662396665
65393038383136306331323035373432666565373934653763336336323131353732356164366262
64396137353838303934363033393363666632323137663762626163303963386461373166323734
30336135396262323736313037653133623237336532663765323034383766363932666562343335
36623731326232653363663937626136386139663630376564333230313632646334353664303137
62363261316161356164313433666562303965646362626331323538663865386530393864373932
63326161656561393665396461313938316430353131643035353933326430653439643634316435
61626437376565363365303061346536386134356335646137626162663739656631343334653866
31323564663165376266656233316266363232333336356137353665613961303065373735353735
30663339343631633931376562316262356462656132666661383064333838323061616361383264
64666664616231363661636337323839613830363366373530313963613132643836353062646561
32396565313631623639666433663037336338313033373938653336366539653166376237326536
35323134313962613962663630376663333334373431383731383038323965333736356462303566
32633164663734366262383264376539313438343530303364623566383564376361343231386564
39316366373964333837656536393961613466353436333839396331326463356636623738613563
66333030376364383535633637383439383237636539623930623966356461366436323933646266
63363264643238333432346432363339323461646533333266666630663161653031626361366364
62613433333235356438326439306132376234326437316437373537346363303537626632373537
33346534306363323133353163663932633632626561313566383865393461316163616532666463
36333966626638336437666338356437643862303332353663303537613933643338396432663336
65613565353536396132373663663730386664323732616533303037363362663335656236383838
32666463323463363736316534393538373938303062396564333435383834646664346261383535
65653931623934363330366361303030306132616637636366313333303664656363623061346638
38626261313765303832333562383763363935656632343735613935356333346237313436326562
65353230353537373737326239636465656566346635303134363739333139666362333964393465
35663837633132663461383036353065656334303730636661363236646462353238343737633635
38663762656234376233623264343762303434353366363262353232386131303231373832336431
35636434346531376461393330363837633564613363373235623837373130336465383933353039
31363732656164333037316438393532336435663035346139373439326565653363343336306638
30383635656336643934623132383433333962323438303733396131333761643730343464633539
32306664393738353334333465363966613261333532393130366437353433643439316135613861
63303863313133333339323665343539343930356637356262316138613733613465343031376538
30633537396262636262663033656430313831616635363239646334356334323731626530303034
61343362383935343835316561333231323261643838616435313032313636613933363837343336
34613131316239653861626434366338343232326531396530373165663931396136373536366339
64306363393061333333663435666130366630633838333533306163313237653462306461303030
61353830326337333563356165663062316165373939333938373637636164646536616639366634
65353035353836356264346234653431383565373637383035323234316637356530346332643635
37326161333838643166313664616331623532356235323564356261663734613464666133313230
35393763353639323666313430666164323836633138373961666162316433396364393563626265
64373565393965313134613365653062323764346432653630643662663930353738353038643661
34376363633338353132363061363964346162663438626638373032326536363733396634303461
39306432646362666336393437396330643864353437376235313966393837393435373437313065
33303439343736626436613663353739656264313639626633323265643966643566623765653739
31336564393138356265623636306266363861323961636131336331653632626439613461383735
61616265646365636133393031333733363537366262326632363962333433656366313965303164
66663335636363343739313838633235323561343833346332353532663065363439303936303266
34356331633961383264393765316236376130323233346163306536386436366636636139363035
34623938366266343164633532646161633264316334313930383565623766396165663664393537
32373266656134333136393965366538316366313735653535626635663462623036323832373964
61343236616264373563386265656364333230396462653833373338356233393962663965346636
39313261353263373364373034653338383632643430323335306632353331633939383137396164
65313166633632366465663466633733663939303039316265313539303564613038313136623861
37383837653262633365333063646331663562356162333937383538623662633134613364653033
32613537633639656361336236323039383533303661366432376665323432386464393534633066
32616666613938373066313866306330393061396263386563306661643831303266633465396364
30613961636333373462386531373264386332303063663264393861363263666638643836396234
66613235643365316233353262663739313831373561633339396165386530313538653538633039
36353364656136306161636135663464343033306161653365653166643762343035336166316164
66346363646238636438353036353631663135356538663762323030353965386635306630643131
36666330663063626334386431663635646561643464616636633735626132383961663265626631
61653365373863313832366538363864333537643336623633343334633333323561373638613961
34396433366665333531373031336362306364633537646565656630323334643038383535383036
36633431633930313065663337366533323463333162326435626264393162333162656236306536
62323032616162313666303265636636323163303234656230313061363862343736373630626435
37616662393737656132326537336534623633613133343833313135336330303536393364336363
34383136376363306264306237653236326166323032383939616563353432323565666263646164
38346663373335636131366236313134623435633766653266383661363532353931323831363065
34663732643435623436323236316365313861346162613636323138303831353062616263626436
37356230643833323062336465306366613637633834303762323838656132333130646462653766
32393532636463363634636665643439366531303162306133626138653839316433343532646530
36383066303539386136643436393764613937313933396335376262323064663363396133373739
66643332333135333437623266626563356337366665336632333035323537343762376132373331
66303630353430313961356234616438363436323938656466636264636364313431396165623530
33306334656338623237313333386135313961633666363031376334653433366330303262363832
32333139643434623031653137393134343961353261366464383537643237623166396431356430
64356435356566336461663766393537303737393139653132363737343238366361316463306136
38313365333863303734336162666130393531356531623865356431653237343432613238633566
35323166623935336361653530373466653063383339396466356334663066393539356432616362
65643536316137326533363930333966666632393334333536326431633962353362313532383033
63376566643166353136353733353862643033363035353534643062303133633064366332623938
33363862306263663737616364633462363736666530613562643865393466656131663663663166
33313839623739386565323133323263613939623838396365313265363466666235316263313535
64393936396332376635343362396564346634336634336663383566376365353836383739333665
30653038346161623536633361363961623931326265666566316565636662633032663639373361
62653665366338656532373734663138353238356535353036336331383635306236656130613633
34323962316636333664616664313163643061623836316432366664623164303365613161653466
39333538343435383066343336313564386136363363326231353838373437643136616336393561
62626430393735326366353839393166313030376435303039643739373337653635316466643236
64646237303064306663396439316431393931626431616630396538626639636432326561653562
61363338623863363731356566306231366336363739623735313134613933373238303765386234
61363034313139626636643533643738663266343235333465663632343533646131393130666632
31366562643166616430613030333832303636616231646239646435616530343032376663653031
65636561353734656562626463643361633362313861373233346633636438653335323761373165
31613130306437346630313466393735643434376133336565656566323132653962636630313431
30386136643462306261383135336362356161666435323132626637346365396436653835363733
33393732633464626437366439303931623962313433626533363136653939316633326432356565
62393332656238303763363732623163653663373536393265343763373365316337323564353064
31616634653837396438303938343562613761363766616561643032643065346238383734376433
34613931366331366138346636376566646539303430353665626263643465313133323939613361
30613063656665666439646362656136656439643934623730383736353031363633653036643161
39366163663035663135343462336564383862336531656138313431316263653064306334633062
39386538616361623265663536636130383362633934313764373538643636353237323133303266
65613765313838363236656334663635636264356331666134643063636334633131353734623632
38316637306163363839366536333365663337383632376230373137386535303666343362316663
62663333313634313361373463313566363063363665323565633935303738316135393264663935
63643662333430633535663130343930373763366430306337656162383537376564653439343430
35656430366365636165386333663566313034646564636261333361373030663633373861323065
33363463303135336436613563633135353531623339616530353538356130656263383265353735
66383964313665623133303130323335393339663331323431363438613637636665623634313862
66336533626266343831363732613331653431306462313830343830336339663636396566343134
34396137396461376433653962613361323334393766303330633237623963653033363532613761
37316139336234366633343064313965393965353461383433366630633935336233383962616662
30356234663661343232633435346532313737376234623336366161303937623964656331633935
62333537303536333062613132646237373031663766623730666330666364313634366362363437
34643634303366633236363665643934323864303732613461326238323637393733393964663562
30386664636139313335333238373632353936646432326436666665326365646565383766316362
62376463626338643937663163336166663465353936626332303739393762313235323535366636
63666438636230383031333838613333633734623134363833383035623564613637633562323263
64356662376532333261316537656234636338393738613730656530613232373662363431393630
32323230346332613038373530643563353162366366636338623566373065373637623638333533
34633333663832393438616266653563623338626431636634303234333636613033333831316264
36333664366262343261373163373763373036653866663539343337323761633431313435323465
35386539636338613666393064303363303263323565613837623866633031303834363837626531
31363838326235643163353731333938386264323730323036333066326463326537613530373832
65303663613361336565643366316238336135396334303432393936336635306434653930643631
65393438643630313330383061643031343663393265646664356636313035313961383634316466
36633139373564623466386264303664373763386534326234323337633732633232346136366133
61363939336531633265396136623031396534363031393164646661383339653232393463636439
63306438613837313136646232333434323430643130313937376230613531383461306262366636
61633137643164653739343663393335373065386339393531343930373161376434356666306132
35383763613966353662333362396265326532303238656133316332363335346437306164653230
38333263323064653533373365336166613431303231373062376663343261663135623937373163
63613239343034303263306138376430613335643862303633343637373430336131623037373537
64323838306233396435656339306132623266363961643166303733373537333135303665336239
62623439623865363736343631613432633264363733353834386335333330313933383433346137
32386432393765356133353736306430376665626665663964313037323136326164653435383332
61346361313063333137356662383533623734633435646638363537303037393638316439316561
62376361363532623266383635323330633033333536636262386239353732633237663139393839
33633537353136376163613334393430663366636533616265633133373563653962393238373862
61376639663631323231376631353964643162616538353835363937663661313632333333373933
62323539643630303062333063386163656530623061613664376436623732386666336162396533
36343563643032356261363234663264303762663439363664353966313561623761336138313165
37633630663131313565346539396234363565646530386266666232306262663961663636326536
64653638643935636365313561313964653764363766623433353365376161623333356164346232
36646630666333383234343931343161646463633134316466616436336634336661356161303764
62646362333139373761333035386666663561643837623665656630393432303839666666303339
31646461613838353033343665396239616538356164333436353133653134313738343530313366
33646530373561633834383865393961353032656339633732646533363535343630613937323461
65363262643937373638633838343266323966623039616333656538363362333962353066323363
36326138323132333837343130363735623861616334343130363064303030306361336161303832
32343534623662393566653131363866343637373930643434326338333362646233383231636235
33636433346464363766376634636533366163333064313534313831636238376639393533616566
66333633613261336361366234643966663536653736323235663030626234386166663033376234
32623435383837336330616235313165636537623462626230346535663662386337366532363733
39306631626262623630393137653763326234366139626461643931613736653932316466396163
66643038343336326533366438356562333561616138383330643235663633336262303266613630
61346237313730303666346639366439313865663430643536336338313130656438613831383633
30613862363961373732333863636134623431626361396562393437613533383738613761363835
30363831363963623133653038383031323364346237326164313232363936656132353732373463
39383737303034346163623737353537646433366539663963313566363032343836383134636131
66363664336435376563343765623737366632383431656166303639366661633565623331613530
39396131613161663436333736666536656364663361626633333637316130646266346165323632
35623062353732616535373166336564383166663861613239373836646637616333613639613862
66396434333039393135623733653938336233613235363134636362666363303337383335303136
66313061323938643432623833363930303236633462363663323362626332363565656135363464
63376266653132333565616363383564353135656431393062393333626336663738366331383737
30626239366666366263353662333166303063386364366530653731353135313761343461646238
39353531656232616530633330393130663862623964363565326233643431343432313039333234
63336232336535373032383933393763623538353061396534373632323165666134333465303066
39333366303231363863333133643062333230316361623635656663323737643965663563376564
34343134306333386663356339616461646639303835333333623363383364666436643436343533
38373364363765343265663138616439316633623865343939353465353466613065653366336230
39636664303335316538326136356361633366373761333635613139633665616433386162623965
32636266303031336162626132363931363163313235363865353334616137643934353637643633
33626137376132353735303737343433323462343830633731396565383037313232313862396330
65383564386564353634636430393562623833373533626632333530336266633038663333663063
32313534666162666134373533313730663730333933613762303238323065363236363166326361
39633938373861366133316261303665356661393830366131343138623138616233623961386233
65356634333833313061313134333364306238623637333733666366373762383732663037643338
33333263356262633366346130386337396263646333636134333935343835386366306663663962
62306232653735656365643533653635366232326638386563393365376464653532656136656266
62393830656339393033343638643334313765383766336338363331386135373864383939643166
66316437666434643266326235663632363161663338336434313134643533383634633936336665
62653638346633336331333364623363376538373562663437373933323932663533333733633036
39386333653636646133366461633237656166396664623136633863653339633463346135393566
39316161613532386432306364333662313261346631636430363061376332633431356132663066
65643137303961383539663733653863346131396232346263393934326239383735353565376533
33356438336463323531326561326264396635623734663835363039613162613762393662363562
32646634643934393263616335633933386564373162303766376661323864306230376533633539
38363032353639633735356266616330396333373665373035643334336566636534383831323862
33623866386539376131393531653562643430663765316539613438663733366566366533393761
31303836636533656535383165323232353065663338646337323530363133393263653930666334
62323836353563366637383138383864323438353339653434306532336164306238633437646235
64366536393039323339616130663939393562363935633435373932396462323533363838356663
34343138353130383734363234366661386638653834643661353031353031343562613461393966
31386463366635353965386462653461653866393864613063636564313638623734663864333334
36386239336431316338643761363566636137333161663833623633376262623830383961613931
33666136653631663131626631643266656430356364343138353663326435656663373035333934
33636339386639666532333763393735386231376666323733333030353239373333383264323130
35303437616433393231653739653230326263616233373166316237346138336364353339653339
33353132636262323030643465313231393763613861343734363031386563646337336562336463
37303232663235313335336435333032626432633531306638313561646236616230306135313639
63633938326266656138383635653339393238303964386663366638643734633866333432373737
34636433613566366132613864643135356136336162643066633035663533666237376437333762
64376262633064333635623438353732376466366338323636643662323234326139316534646533
33666432303636373963666334376263636634323532643837316361376338616430346464656465
33373336653539303163336239613038386230363839353532336335303564306363666665333530
63393863363232653138356234323161323931353932326137653430363836323863393035356230
61633161623761303335646435383934663135633734346433643639383932643662393264626430
38303735343033303765316365353834633461386636653662393538643339346230643335656438
66386164326535646335356661383735366134613939636130383034633734313739333635376233
35623130353239323237386337623962616536643463376632613631646436353630366137343838
30376136616261303235643437613864613031346564363465646533363033303566613061363330
63306530323938383737313438333139363862366331336665623139626135353236316664643661
38343165383537643136623661663730383635376263346131303332646464376263303464373734
62366331316166633631663163306237343862306365326339653363336638633830646233363665
61393461326535653736363834346163343736666665623838643937306539376266393761633263
39316163356262633137313261353463373166633735663035663135326437383534616239343661
64633762613833653365353234363539643533353239316238636537323064353337316361353630
39343463643236656431623861333734313838346430356630656131383763386534373635666331
34333137346266353265376431623935336366623461666237666334616634613162623761346161
35366334313837333939306563316236313839623236373234623635656138656165313662656632
31343961363864346438333932656264363932646132366136663339363930386435666639663135
61313037303762626537393330366131616532646666613338393032353837643835353436383539
30323064656161636534653664336232613565626162383035636131393539386633643436653633
37343339663531396533323432643464393431623338343537646138653236366331366261663631
65386666313531323031353762363437653561653534316463666539636439383361373861646331
66626434326566653534393964643938393463303930613734336161356431363230363261383139
38356663323539636533663237306566623062333036666636663637306332626139333738386365
38363635386439646363626631366430393636613763646539303939643764643966353461343466
39363331343334653637656561383638626637646230346665393366313366633366653839663839
39333263313566626135326633653238626566633536353461376462613462303830353638393161
36366433656261313436663466366433663138613561386336383262646663313836633564353266
61623139323961346564623939303339366233643231373031626165646364376138383935626166
38373833613033633630313965336438626363353031346630376532393932353961363732623631
35613038366664663337653230653134373861666430343666623165306130306137383163396235
66633035353534386262663336646361303236623365613736383938333261653462386636376531
37616539376637386462373063316535636365626634613462623232333435313733326261373230
66373061336231663230386133613665396263646563376534313830316439623363653165613666
62373063633863656566643333636461353339396635396331633734623266306430
31356235343830393937633039386366623238373864353438353332653038616330623365343135
3961393463613335663138383233323964393832626466620a393763616364363030323833386565
30666333366332313262313836613962363537353065646361316230386434366534666338336636
3930363231393333610a613039646430663233613163376131333637383866656564333765633565
32333338316539363034316132313631643632316337356164373765373736366639623362343738
63326164623535326461323234333933656132313836643139316339333831323331393030323233
34643932626464353861616534326130373362646437613131326464313737623965366361616266
37393934333761303163346465366464393964313831393266303434313061356333373430346339
33393331393461303163306639386239656139613833386235363335333837316666323637346462
35346634383936333631303338356433323865623030663336323663373962623531313562626666
33393063613137646631666363386161636462343262313935386535386361346464396133613736
31383464613130353034396264643766646161343664363435383639656234623035343833653832
63356130646136396463636533616334643638333239636437663030393064326362393736373233
34396166653932326632376535373762663633303261303536396334303937316431346133353830
37626337313861306164366130643131383133323337643637666535316132373238373066306339
38343865613937663366376630333365633039333430653762313864303737633661356232373133
64303934623937313863323431396638383932383836613830336235656530613234316238353364
32306561343362643431623239333638316239323164386634623736313261313936616239396431
64373934313663313139316331623436663832393131373562393436326265656562363137636461
33613864356464366239306639653264363565306331636137396466336163336631353163383430
30303034383136356465643238663034623036363562353037343832616136363539656432346639
34636536383533333538346237653731633331643137303966316138333633373564383034383438
34666465323032356333613935363161306431666533353061626536633839396234613662623062
63313338346138653765393537356639373035643935373735323166376336626166396562316535
39343236356665363038326132313437303236646161333638393939653033623664306563613730
34383538656139646436333765623861303739613939633962316139386463643666366464303935
32366239656366393832376436623534653539376661363737343865663633316138663061643866
65393930386162393663303135396237623633656230303036646431633236376465633835336364
63643332616433303766396561386433363836343836663430636530616135653966336131383533
64613933306165303564303835616132613839636432653137356131343732343736313237356436
35663033353163346363366635333231366432316434306362616265653832313963303338343538
61363966306162323932393037626335613133363936393166626433653463363661343732306465
32373839653865326333613165353461613531383666323938653034623936303562363331643363
37326164323935626239396239646465616464613332636662353539363233343032616636613237
62316663643134663038626535663262336266646565336236643234663561313264626238313136
65383364643537393436666465653437653334663934333765626233303835643732373334663639
62643064383535366630306666383365353963306563323533653664643132383832346435323465
30326466623265326437383063353833383131393166313231633032333137326563633734636263
61623466613436393237656435623335623133343032333535373430633836346566353039646238
34363338343065323765313466306130643462303061336265623833343139353336383234366362
30383465376635356462313533643961613437333930396134626463383464323661396431643234
30353737396262653336376137646137353362353066366634613034313938613234323931333365
39323733613864346132633461323563396161643738333634316564353934376537353663386636
36373335343439643432396266386665396266643063616331666438363066643162356265373732
31616537656266363866656133316663383634343735616266653161613139636164653566303666
35396566653136343038636239626561303966346636333635363836366538616233386164316633
30626135303434636335623366383566366532313937313133313664313462316438323964376130
61363166306232363737616432316666623166313962366462656464626537323538316337626635
64366533383264356430343066373664646534396538306335333861356237346164373235643239
31633264396338303061376633376564356231336338376135386639393031656437363662613861
33373934313862396238353863663530633337383633623565623937623734616664626534383061
34353862393537633932393338653265353633643166383564363766636539636334643534333736
62666332313730666661303462656162303338326433393934313536326365663235346263313330
34623539383031653538333762396361336235643936386366323235346162326635393062646263
36343638613134353966363261613632313963363236326364366133353838616636323138316561
63356237663864333334343461396364656231333939636264343966343266346430363965633764
38636562626366636438343538666632643964393463353836323536333961613961613166373963
62356336396536613238353531616237616334616231363836616561333866643033666336336439
65646362393933306534326137396561346338336263383134383263373836323034653932646463
62326364336438623137623862386438396164643432333135346462363761333437653532373361
32306161313238363365633131663334376135613336363937313263663537313733313762656134
33613838326137343061306431363237373661333163373739326339303732623131393633646565
38663963663338623362653364343337383636323730306431313830316261666264326464303235
64313662326434646366386166396464653666323735323931343436653763363535396636613938
37386435303139663965623963383861353030626530653061636132326233373165613231316232
38616539303630316565366161383664316337333665323631663430363764313736643934623638
35346264666461623533623963306238613530633034333662653561353030646437346537623065
37376263366630666430383833373966353730396239626133333330306232643037643231336433
62653332623461653565376531386464353237383665323538363061633831353534656366333933
37373235376564613665303462393634623836663835383738356138323136316432663637353434
36336338623364383334363565643334613631386330633466396336336539383762383162363631
32393230623938623433333632663365313165306165643433643131366237366131616263333465
38333739636234326139393361386131383232373362343636653862356535613230343232333365
30316261623036373531383238373164313961386532393663643736363131623533373366613565
37653133383930663037363232353032616133653763306564383434366233613066373331363162
61366530303731333235313537366436363237303561336232663363396562313735613461363331
30336561643035336430643666643565653538616536646263303265663739653132353233343834
36613332313430313138636464393139316466373738313330383762623963376363663839386366
36636265383436643661653465383561306663303461633335373331323733663936373334353164
35353138306233626363616636633236643130373432636161646563333937623334636163653637
31613431363963396266303637363833303635376239356337613935636165326265303166383062
32326165636331356466363766643766363266666530363739383535623863393135663331623737
61303261313563643665626435343362656632363263656235316230646135616163346630363335
36633564383761346163383166326562313464316362613531633565646365326538356565386630
35396334353834343733643931376261336435373362386339353138386435663961623064343438
34633639383037653437653461346563373733663536313862386262393933623434313163313136
64333531656462353966353362646636353634646433333361633166653338373938326639343732
30313163613532633163663965393434393735383730653430663762336262393564303263643935
32313564393835353762363662373631643430393938643265393462643864633465643636306664
32363936303737363934373536383136613636323538363565306563653438343961373566363463
34323338636161316537383036643638366430303964376435356164333666313833303264666464
64383766356630313130626235663364653730666638363336353938323735356434336539383132
61633334383661613039373136613735306662633734346538663838653132343066313166333834
33386533306466613037383636636432363435616266373664636631623264396538626366616164
63363831616562653034666231386365666639343330303964313165616462623865663266326531
38306337376133613932656631393532613431323964643532386234303335643731373265643536
39336233363530313533663163616462333466656364666262313132636633613863343766656465
38393339386335623234646437393061366230643432636163306432633365356330306234396530
37346466333961383538356236303630353131376330613437656536393766383062323235643465
38663034393633383661363632386431343364333664396530323066306365366539366336333265
34343865633834333564343237336661613437363333373032393661633034643161623461306362
33613663613966636235333664313731333064613033613639656232626365366461373535376532
37613365393237333037653730323432386162616165663335653565666565656438373337376232
35663933646636336535653431613337313562386137363137616561346435636562656337653265
62396430663033373234366535633838306335306263616561323438343736326664653436663432
61396237383030316436306130663039623963343362326465333933373061643165353465383761
66336433356538313761616663363438316165653763306431353139656361346631336538343936
37643363363662626362643333613431363032323938396365373337643235353333393232373862
38316664383838623836343938663231623237306566353036633265316361636565366134646562
61383161313664633736613463393035363637633836636634363231326230613437663438343362
64313362653131316433303561366536373436383865333138376663353035343731363934633537
37663832316265356535643561623435336563663362663766306662326564383865373565333134
37343035363763373236656239376665663536366131306439643331663731656234386432653665
30393632653939393564363634373334623865626231653639356633653136363630306539356361
34396238393339616333393734633835626463353932616262383263336439366262303966333666
39666536363537633030393730343037623265643438313166333064303635663430323435383438
31313364336662363365303262653566303830633930666639656234373037366238643431626562
31373137663434366564373833656233636165636636653738383035393936343063303761353265
66393938643238393338343930363233326463643237633137393566316438393266613731346162
39393630316164613139393436316533333632366638663437366266323837633937613539643434
38316132306333633666633433623565323463636664336536616266306262653064363931316165
34383233343964326363363235343165653339653162326161323932373631353665353164653731
34333665313065306439393431336632356536646334376562646232353938313836313735616238
63623765313564353463653133333536343937663165396463303531616365616363383938313939
37373530363830386338393661306261616432363737303236613232616232643666653464613937
35313631616166386330653730653062646234333432336135316235356166343139316538353337
38663265633166383563623561656632386364663365326662323036316436386639663761323861
33313937343632626561663233323862636335373930366233326163623831363765643236666463
65386234646363313039646266636435366462383266396337383439623437306166663536363966
34333762366161373531633036356139646338363031366561363863393830353336343337363664
32366263343464663338306562636664333035616165363538333764616139663063326232326664
63623264303965353632303230653636313162373061396436643064353632323731633435623232
63313263616339613065313138613032626635646236353462306263376638643966313332393939
36643637653261663830313564316364643832383330353165613666333733646138633432633763
61633431396432313863343466306266666133646266336536646635666631613633346430633539
63313964353937386636626532303532353133366237383566393061326265316163613665326131
38613232366132313762393436646265326361323736643338623035323336326539616432386630
65346333643936343835376364626539303265313239376338653835343232646362656366646264
61626463363665363737323331356361616364626565333561303836313966323461663438333933
65643533346362626137363762363663383761353364363164393062383830383931653262383564
35333532646661623731663938656339323739663964666238653963323232666664313237363137
38626438313265336263626365643763393532613831343763393130343766663335343431613038
31393938303162346236303635306239613335656338643362376539346335373130323935393133
62356533393363626139323632663865373837306337393433353963396231643937373762653837
38306331656262343832333136333536383035323532383766383538653831386531353936363936
66623762663266663135373165303233646262653465633930373832383364386263383831366461
32643533386438613533326566336538633632313331396535326562336263646334336362346634
30366562623663313638356233633433306564303065316337323862663533366366353230373434
64656437626135633932616230333339373934613666383565626535663939313930373230313130
39333434643834356634383133383533396264663831383736326162323431393464613736616539
38326533623734353937373135386664613635636264383130346263326462303531316232386131
30663833633738363533333662376461313331636230346538313764633135303331613039316363
34623937386336646339373366663563626430343866303239363661313634636333616232336438
66616132363337393334646333623737336633303331393030326561613835613164303965393932
65313665303932373331656434353634343561353236643932623464393566333037386334363066
39373163313838663163323666636337663161343664646661616438393333363365363065353732
61666666666563626432383639393038633233663561303561353238613832373633653436316366
65333030623565336266646563333336373339616231366334316336666463633964323630373634
34346131626466316163366362326265613363333933336463623961306637353639346537316336
63383561353339393963396339663738333765663934383038313631363733356562373935333765
38663735636562336564636235306135666530303762623331663931366163363734316539333734
33303638643166303165653062396231613862313165373564626432313961643835383038316437
35653639656362633631336635363035633635623035653439353561343739626637656566333065
61626536366535376237366664646534383534323331633035326632656132646364363133636133
65643065383337356136633534383033396563353034383462386230623261636337303537646463
38356538356434393864303563336563343566306638353138656561663930316230353433386362
61643561383739366362666633616365343235393363623035316263313738633662323961613931
35393234373730616133316462363661663866323466333839656462636133613936643137653833
62333030343861356365616339363134353463656162616636303430356136363638313861313637
37613731663639633865353332653835316631663364646139623631613138633162366432333532
30306432653133333637623631626165633537623265313237323532303566383334633337306564
31643163633934633161316663336334316237353735313063376436393166313033313665366265
35363864386265386139353735663238366663396366653164613833383336653230383763316134
37386633376538366633343963303339323338303263343862396631613733336261356536396264
37623236383565323331306531316563356238316237636631613736316437643965613634666437
32366661346466353261336130363233613036633863303161303931346462633763353038363136
36363762353231373933616265643039666237333334326134396531643463336238303862313465
62346431336663633661303034376165643539323061653363613635343761326363616139313933
64613430353539376131653439346337353733663830306333373966393162366235393364336532
38613936303965646231323138643464636133393037363538373562396230363434386230363934
30656361323137326335316536663231376536646565643363323462363165633139346535313733
61666434643537363135613862656462653732306665623037363638613832323530653836643137
37323233316131343039643137333532653165316137303032373635346539656635363265396565
34663263396265383532306264376430313237616438383639313863663664393066643565623636
30376136373833653462646561396238356638336432376630306465333133623538636333623961
33316263373862643166623030316239383433313365313439386534336532653436633034643938
34613737333933303934653635636239363332376436303063643566663536353266303363626539
64393737383037383336373663383163303538343438306361656164356563653236383563633735
61376362303866646130666234616362323830643238353433613831353635373636306363623262
65393266313430653133316639326330383936303566613534316366613637623465643739323234
36646138353135666265646137653138656262303864653839333839633336643466343066653865
31323538353566663638316136653438353835613137653364393032383935396134333030623831
66333431326362303038343032316436356237373932316531313437613762636238663364356535
30393931613231303936366436316438396535303561393538633465623064623039386430393230
31336630663839336364336332623165613763303331383830663161613966623530323266396531
39393931363365366334643231363732646631626533653636373364393463333661653833653964
35656361623463396664306134313365333664616436313761313837363038343632373032356163
66653030393665626264663761313266383362393565633864636436326238343836383066363438
66656164373936663732333565313466366631336464316335663361663563363765333938653437
63346563356234333936666437396462613262633834333938343635613761396632633462633837
64303563376431643930343366626336656238616162366566613166623637656533653263383832
66643465626636303136383235383739393265313964373238393563613438346132383566353061
39393334643839613538343966383736646664373363373234376639636637326639383734313562
32306163326563366534303862386638643534636162353235626438373237343135376339663164
37313836313863646539396231353864623733396163636136313864613030326534663430633062
62373238313435346637623334623631343339323131643363383863323530333236313632653364
38346165396336623838623630336264313565396436646536393237646163333338346338653438
34396537373535616339626164356635333837623234316630373333646333343034376336383739
62346366383134626162376134643738623965653963303334353936613665313061653661616438
37643032393033313265396536353433323735613766326164323461623238316335623131343932
31313763303235366436613939366634646434326233303637653235323539626264306238353733
63383666636464313064623736316466323539376365376134663763343530333839313165643432
36663238333231306134313866303332623134323631646330373563656431363537323861353632
30306334366131633865633366353666306238326537633461323965303437656538643136386262
61386162626633343266666338363563663965643761636462663333373539396634313766323139
65653564333531373561393239343036616238363162633462353962393336396530383430353265
38373539613339313964356166666630376130313830356137326465653166633135346262613839
34623038396635373936333535343330623338663235653137393335346536343331383034666139
61333965386132666439616639623165663034383633303136393730396232336139306664663233
61613731646538633265323439666535346636333531326230366238653331303830646131343430
61383735386361356262326632663666383162316232393338363633323463613732623064623232
39623231643734363937623134336632623562356531333635633031636536383362343634373461
35633665383130636336363364616466346462663962376164336331663165316661393934396136
36623836636438373761376338303534353832653433396266376638636231616631356262323736
36383466643661363062373062663039333566633763316334626334373663633062376335663461
61316564303432333730333638303566313132646636643639626131343165663063333838333635
36663139643730333630633766383132396636666632366164346537363732353830613939663262
66623433633434646432313138643639366265396333626564623562323865616261663064363630
66313134356536646633366262356331336134366265316166633831653434646232333730356661
66663431653363666639333632366261613764393665393535373934616433653834646361373438
36363164363932663463623161373736353233393934633537613932333732376436623031303962
30643830353665616636663066306536336236373064376530306135646234326131613130306433
62653463306332346530336130623633393636393762633230323063646237626130353335336563
65646538313634333535333564663337623233613238303638616263383764653230343661343065
30626533643465333733366430316162316134323431643166316631623965323334316263396131
64303932663464383131386239336166353363333664353031646462363862663165376565356562
65346539633036343031396662623233613739656633653430663737613662303331396230353038
62333333383462653138653162626333326466653631643634636135663232393437343332333964
38623062656633313762353438313839373935373964393938396536633233613161666166626139
66323363323039323666303934373631343737313866343665643131386431373336333630346261
65646666303633623166643635326165303838646532393663666266303536343634633533346664
31386264656165376264653266663332316536303665323239613630386233373565353763323739
31373232623735366563303731626163356537393034313962653263333831363632343561316337
33663430333836633061643566303638316133323361343931303331353234613065366133363931
33316564313939383137653330353935396532386563623939363336383537386639376262376561
31323837346166396332373735316230653531343166393539386364646461376265373564636637
65663430333564393861343261316562386564393836303834633638346638383633353164303438
34343463353538663431306332636166623066376135323261613735393837363666666465323866
63356530346532656131383835363133353938636630333231353964373765636162613062363937
32393461356530383531363131326164356562383762643832353037393931316666353331396437
37616230383338613931646135306539636335636238373065346236333966393631346266343737
37383135613133383763306531326436323233393231376165633538653036393833373538653030
61336331313430326638373138376661633731306165653661313766663134383163653731393934
63643438353663646539623935356635303236616231646431356262383233643333393531376534
65663437633761646564333366626335336534626362353964316236643066306439366565373735
30373965366137643363653533366430303965636631383664303239623938616135303030373962
65346635663562306566303236663563623036366465353739626564393839643530353033323737
66643665303132346566333866663736376637393966366263636462396637663461646434376566
61353464313337333938303866663036646566623032393036646431343739646436356632353361
39366262376235663139666631643734376461613862613838653062313466663266646139323831
37313961383638353139353635633133636561306361323065323733633532333235323031653930
39306534343635646135646337353161303737396266343135363938383664346338373730366531
32396533323264666433616330373232623330383762323263333432383066313838663931333838
30636230333235653238356633353030663136343462353530643531376234336161656361633438
64336361636437636236373532346131393431373537633663396534363764653563393064313665
36616266333938643061613361653532356133663431333435343634373564656531383035353632
38383863616133363937633937643062623831356232346238326236313536393738346263313530
35303865666530393330343936356131326164653765623031323535363266393265333232333464
36356536613264353962323665333238666638373637306431343566656532383735363863313366
61666465316437383934653534363766356635666364383036366664356534333465366530633365
62353134313939373666623966666335663031616563373931386564383039613833323434326238
66373132323762653031363763353831353336396137376364383138366264396134343262306133
34393865653666663035633637373661366463303335353031373431386161616437353161326638
32633339356434633363313030636464376535383534346436663634396636376630613634653635
61383734313234396666303136393633363536326265643863656562356239326536353632363764
32383663306139373532643037303365346336643331336331663630333136323437663930333366
34366162356435316436313663633936323931383832373465323537653436623237323666333737
35303962396266336633623563663064613934613837356563636330323431346334326234663433
31313665653836616631653661626162613032313737373939323232386635393533363962373966
61396639366131323131623436633439363234333032636137646230656463306265333334393736
32323262643066306631316336356461633764646664653339626162366366353566373565653635
63363438396264666565383164363639303637646330333064363762393731613133633934666131
65323932373235373863623434373439343661396137656564396663653336396633393331663032
65363432373338313137356666623338303064393232636535356262383038323166393235653338
63613536626365666437376638633665626531366330313230663135353538303232356366326161
64303863643030316564643262303664653463653463393461313538343464313638653461643966
61653733343339613864666665373033353062363165376530323362663763306132336230396533
64373762626263626638643763336635616364323561633536326165326466393736333836376462
37303365396539376538663866633761313136326563623365373739373439393630376262373138
35663064636561626434313035346131633530326431653664373036356464313435333333386438
34396166643435663734363434316436386637643836313438333731323261303737653932666664
62313761383832373235353239313830326635633430313762656265313838623163366637633736
30316230353338626561393763663932663666613331653834346131343434663232623434353838
66396262383937353530366163313532343731353338633065393632353264343136386538616334
31383365666534303564616364343464313233643932326539313738386131646535316638396133
35636339396339323765336137626438643631653963333362356438663531626634633336303733
34626138336238323063623536333837336666613630626462636661353962303330323134326231
66396665383033386634373766383461616334383130356337323763353130653732333136303166
32303364616239616439336639633233666230396334666531643464653433333532313737363835
63623737383830646334643039643739636235333963643163336464373133333731633765393764
66616236346430323464393739326364333735663937643737356631393766623734636262336266
31376535613266613362313363666262383837663533316636323262316364633461656262363762
39613635343165326164646636343431623230353561363566666636656237373363646230363435
64343435633039336337313562353638363664373137396366303839303766316230323861396331
36346230353163336538303961626261383031366330613636323263346139356339616537393236
30306537663861323931323536373833643330313338363435383461383330656266613439333262
39643930313537386538323436636134643639656265336137666663666637363135643137333063
63346133313066643566643432356537383532326163356437386363353965303737656261656132
63386338643565383033353832663634633736323432336561383763356239303936333038396639
33616364346165323962313033666335323233343462353434663832386561376339666366393161
62656532636238613264643833336264616634623236643534363565303531656131646263613830
30323161623864386130316431313435383637316337333032633035303564636534326634646565
32333465303061626536303231386434363033636665366339376432376432383134353938663331
31323633386631383038376438313761323839646139616564383734366534376134366330336166
32323430663335653335376637313065383731383936663765303664356661636366616136656535
65346637313766326233376232646137313839616339636161613066653133613230623838326662
62313037393231643966353638316164333563343264616563633365333437356133373336386561
65323665356463326537326239396339613839306131656538323066656134333833656539623462
61386263646434636236643534636464363731656566383831656635336437363962306263633233
36646263636638396461313138306563656565613336356132393532626161343863633239636438
30393663323562623764663638646431336265303764356233323566613934306132333735623962
36626462343039313765333535633835303664393664366635393264373139346639636537313038
30383133376361303336363239653931653436663630666665383161333138346239616338313338
33343138666263623534396239633462383936646564313833373435346432323532336466373937
64386537653332646439643764643563393738323565346630616432316164663161313337366531
38396564386635323466386339613765326638333532623639303136656331633038336539393062
66613035306632373562666466306336353333333039346134383263313834666434313237376530
64373539383166363864366531323333323565366465383937333732613437366264396532616433
37623438653438656162626666343033346638636564653061396562343564626535616233353334
63373565383039383737333534663637666231323132313162373436666362383963303162373835
62656434313463336462643764333735653166306265636638326131613736393230326634333135
36666633626235343630306637386164313562323439663037386166303430393065386238383434
35396664336331336561633232333934343832626230343935393433326430653632393562366532
37346235343762336430653733353235643831633230646135333761613832336234646262623865
34386230623338613764303536343533623432336332396665626131363364643465666639636664
32376461393532313839393766623463386537643733616161313330623739626533643562303262
37356339353836303235343762393838343363623331646464646163363363663638626433636230
38323038363931353830633737383865353934373238313634613961373566336639326131653335
32623238313339396261333531373437336266363964386237366663336164323566313864396337
35636566616462353565383731386236356230303333336431666633613264373331663430346434
36663166363430373534346236363332346562653463373261346331653032613436616637623936
63393663666230663835623835633936656538323739356231353563623939383463353330326464
38373531653537376637633436616335633866656163303937636438643162653231393963356537
63306630313635363637656665373337363232336135346230643765376631343734303766633764
66356236643338356631373832323863353866336333316333626232396463303466643434646232
65623838353539353962346661383865626532613930363330326334316565343330343538636434
33626433623361313430313361323966353732303035626366323835383631343634316634623562
39313166376365373638303338326263616566643566636361633332373862623037336163636366
31626365663862303563626130393266316237643266383139643864613134373734663238633639
33613761383431383463383037313139393833633761633635356538313635383166386639326362
36346662626139353837376565633633363964343665356632646232393433326437353532613365
34323666633131653233393062366637336630303662306634363262666439353964653436363534
64656238363036343164656434343262393936306230333639393932663233363062666264656361
64363233303636646138323662623532333063646339306635633039653931333865356535396665
38346362366165336339646162623739653433353966613762373534306634643530316538356433
65393034366562663466313432396431636462383239326438643437316361343561393234306338
37333664666132343263343763333137316564363963306663363265376263393964386630646636
66316334323439663630373131643330636266633032376236383564643138356337353566363966
31323662613461353336396637366636366336353763666264626431653935663332363930303633
64643862393963613936323838653661646464353662613430336535363833663161323764303036
34396130633438323566366566303932363937653536653638303931646535353239393738323337
31316237356531373034646565653138396536326339376135613935366130333061636135613164
62386134626234623734333339393763393536623964343032316666366632633863643839353530
63366631646535323133353235613338383864613265613562653861653138333066396134653833
30313237646466333139323037376564646161613131393232333233633863396536363038396165
66643163386337333666626261336537353461666331353162346330616332616330653936616439
62356635323036323363653566313764396566346533646437323932366134303830356331303730
63363933396337653239343163366236386162333032356465326237623563386234656339643536
38393636376536616638303637393537393465656563353232623435323730353561303830363434
64346532303834363338643838346163313963303638346239373936303335373039323636343534
62623563343361613166633465373966343534633864303564393766656465393964396237353464
30666532393332323162313464663931356335333263643332373431343036636536316435303765
66633161653136653038366162353538623563653237666131383163333636373035643062626236
36303837376161363762353964363239656565396666356462633864343833373237303362316161
35323931616535633166646163616535363863616237363238316264363733636230323864363530
32353533666339656464303234366562616332633434393736663631656434373730313165313062
63373939313332346235386566613537343339363365623836313835343165386133643837336161
31666139393664363765646233323965653831333435376565653132313861613565393833613463
34623338313765386331643235303361363033393438353432383963663133306465613530323434
64306364326261303433623062313261613532303333656136363536353066313630363633353938
63623566613262646531616332376333633934666337376638616231653939646661316264376531
37346436323339633839393665383735653465313538366262366538623462306439363730386666
66383736623636333139346434663933303766333130323333383536316333363165633762336235
34613638626161326165346662613433316164323533376265353261303535336431323065623964
32636265653736363763316430636133376539376361616631643965353732656336333835356536
38373933623063316135353762663735376533663065336233363133613833373464633365346339
36613530663735663735353264373966643134306464343431633963366337616239623135663361
61303532323564306266656537636264663739356235393535633139646131346662353437306138
63313239623832376362353632326563633562663265623534643032306562343535613035656162
31353862366233663535396134643433306338333638613737623161376334653631316236383532
66316364313637633732363935636266393364636635373061353132383866306330333734616562
64633132336634643034633834623536383631363933653463663364643262623565393461613735
31343962333461393431643233396561376365666337313961616338313736373439323433393061
34636665323331653731346566656164316163666131396431333063656634373433306262666437
62363939326131663035633264613861376131623332666533653666373262613035636437346461
66336362393935633862323631393163373633306334366434346434333664313732636337313165
31356132646265346537363761393462393630653239396535383164323739636632306531626531
63643536366464383831333663376136643435636361666266333035616530333832336163636436
32613437333336323332376238623336373465323937353130643232656238643334313332303465
32306539626632323062343230666636653566376530633263373964623364363234316264346462
31656665336165643831333039376163623332633634643961316635396533356531323134356162
36363731373337376265303261313737663235306164666532303539656565343462333034366530
66643165646461303235653435613266383034333664323165646238616236663339633361383565
31616438623866633664323037343238383663343936643636333361633666336638333262316531
36663139313466303736336331326666666638333938613031353439313765343530626537393933
38383863643337363038386135326463363736373434646434343035613063343738383965366434
63613038353065353538323130323163383763383030666335383066373536336166393666333763
37663861353636633464396532666434313363323838346235643364393039366566313264333538
30353962613263623838656336353134373564383864383130356664376531626137353064643562
33393261346662346538666130303138623765383166626537636363313138646536623436643666
39626461383038633461306431363364636364623333663766396365653732333433346664316139
39333961366433633238386335343934383134663133633030386133386433633261383332306163
34373536346335616265666666386364646461653433386239653730656635663665643233323630
39616336386238333830363465636437323762633833633938323731653538613636653239373333
30636464343262356165313932356666663765376136663065666433616639613539323237303238
61386637373130626265373336326531653631343339613938376136346262626362653237633164
61616636626563613738613964376561376262343837396430653438623066663937653032326530
33396334653131356337376638356664313333343366373139623766393736326438376130646564
63333230306139303238323235353061616666643764333864646365356135376638643038313037
66316636643165333739356530663962346135323431386535356161393239616231326432393439
38326163363938373330386262386664313761613631356430633861346631623765663062643865
61353937383533633638313138316466313863656136393666353730303434613031313731333534
31306361613234626332386339653162393734623536613638343164663963326532623133663732
37343336326133626132383465306266623132643861376564636461383132306561613635373135
65386432316133393134386334653633666330623039336335396433623530633765383732333635
6431363665353131633562626461643631353938376163613361
+17
View File
@@ -63,6 +63,14 @@ astro_orbiter:
hosts:
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:
hosts:
carousel-of-progress:
@@ -85,6 +93,15 @@ paperclip_server:
ansible_user: wed
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:
hosts:
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,77 @@
---
# 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') }}"
# 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
gitea_compose_dir: /opt/gitea
# Image versions
gitea_version: "28.2.0"
gitea_postgres_version: "17.11"
# Gitea application settings
gitea_domain: gitea.mk-labs.cloud
gitea_root_url: "https://{{ gitea_domain }}"
+3 -3
View File
@@ -15,9 +15,9 @@
# ── Compose & env ──────────────────────────────────────────────────────────────
- name: Deploy Gitea compose file from boilerplate
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../boilerplates/gitea/compose.yaml"
- name: Deploy Gitea compose file
ansible.builtin.template:
src: compose.yaml.j2
dest: "{{ gitea_compose_dir }}/compose.yaml"
owner: root
group: root
@@ -0,0 +1,64 @@
# ------------------------------------------------------------------------------
# FILE: ansible/roles/gitea/templates/compose.yaml.j2
# DESCRIPTION: Gitea + PostgreSQL compose stack. Values come from role defaults.
# Postgres stays on 17.x; a major bump needs dump + restore.
# ------------------------------------------------------------------------------
services:
gitea:
image: docker.io/gitea/gitea:{{ gitea_version }}
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea_db
- GITEA__database__NAME={{ gitea_db_name }}
- GITEA__database__USER={{ gitea_db_user }}
- GITEA__database__PASSWD=${DATABASE_PASSWORD}
- GITEA__server__SSH_PORT={{ gitea_ssh_port }}
- GITEA__server__ROOT_URL={{ gitea_root_url }}
- GITEA__server__DOMAIN={{ gitea_domain }}
- GITEA__server__HTTP_PORT={{ gitea_http_port }}
- GITEA__service__TRUSTED_PROXIES={{ gitea_trusted_proxies }}
networks:
- gitea_backend
ports:
- "{{ gitea_http_port }}:{{ gitea_http_port }}"
- "{{ gitea_ssh_port }}:22"
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
gitea_db:
condition: service_healthy
gitea_db:
image: docker.io/library/postgres:{{ gitea_postgres_version }}
restart: unless-stopped
environment:
- TZ={{ gitea_timezone }}
- POSTGRES_USER={{ gitea_db_user }}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB={{ gitea_db_name }}
networks:
- gitea_backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ gitea_db_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- gitea_db:/var/lib/postgresql/data
volumes:
gitea_data:
driver: local
gitea_db:
driver: local
networks:
gitea_backend:
driver: bridge
@@ -117,4 +117,4 @@ baseline_journald_max_retention: "30day"
# ---------------------------------------------------------------------------
# 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]
+410
View File
@@ -0,0 +1,410 @@
# 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 |
| `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. |
### 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.
## 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` | 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,157 @@
---
# =============================================================================
# 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: {}
# -----------------------------------------------------------------------------
# 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,52 @@
---
- 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: 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,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_base_path: /opt/paperclip
paperclip_data_dir: "{{ paperclip_base_path }}/data"
+10 -10
View File
@@ -77,16 +77,16 @@
become: true
tags: [paperclip, files]
- name: Template OpenCode config
ansible.builtin.template:
src: opencode.jsonc.j2
dest: "{{ paperclip_base_path }}/.config/opencode/opencode.jsonc"
mode: '0644'
owner: 1000
group: 1000
become: true
notify: Restart paperclip stack
tags: [paperclip, files]
# - name: Template OpenCode config
# ansible.builtin.template:
# src: opencode.jsonc.j2
# dest: "{{ paperclip_base_path }}/.config/opencode/opencode.jsonc"
# mode: '0644'
# owner: 1000
# group: 1000
# become: true
# notify: Restart paperclip stack
# tags: [paperclip, files]
# Idempotent: only changed services are recreated. remove_orphans also cleans
# up the old paperclip-nginx / paperclip-certbot containers if they still exist.
@@ -19,7 +19,7 @@ services:
retries: 5
paperclip:
image: paperclip-local:latest
image: {{ paperclip_image }}:{{ paperclip_version }}
container_name: paperclip
network_mode: host
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:
routers:
gitea:
rule: "Host(`gitea.mk-labs.cloud`)"
rule: "Host(`gitea.mk-labs.cloud`) || Host(`mad-tea-party.wdw-labs.cloud`)"
entryPoints:
- websecure
tls:
certResolver: cloudflare
domains:
- main: "gitea.mk-labs.cloud"
sans:
- "mad-tea-party.wdw-labs.cloud"
service: gitea
services:
+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" {
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" {