❯ apps · Running

Le vrai Obsidian dans le navigateur, synchronisé par un sidecar git sans jamais perdre une frappe Real Obsidian in the Browser, Synced by a Git Sidecar That Never Drops a Keystroke

Un pod Kubernetes fait tourner l'application Obsidian de bureau, streamée dans le navigateur, pendant qu'un petit sidecar shell committe, rebase et pousse le coffre vers Gitea, le PC continuant de pousser sur le même dépôt. Deux écrivains, un dépôt : la boucle de synchro, la branche de conflit, et les quatre façons de perdre une frappe qu'une relecture finale a trouvées. A Kubernetes pod runs the Obsidian desktop app, streamed to the browser, while a small shell sidecar commits, rebases and pushes the vault to Gitea, with the PC still pushing to the same repository. Two writers, one repo: the sync loop, the conflict branch, and the four ways to lose a keystroke that a final review caught.

· 11 min de lecture 11 min read ·
k3s-clustergitopsgitcloudflarealertmanagertdd
🇬🇧 Read this article in English

Le besoin : le vrai Obsidian, depuis n’importe quel navigateur

Le 1er octobre 2026, je voulais pouvoir ouvrir et éditer mon coffre Obsidian depuis un téléphone ou un PC qui n’est pas le mien, sans rien installer. Pas une visionneuse Markdown : le vrai Obsidian, avec Dataview exécuté, l’Outliner, les callouts rendus à l’identique. Et sans toucher au flux existant : sur le PC, le plugin obsidian-git tire le dépôt au démarrage puis toutes les 15 minutes, et pousse vers Gitea, qui reste la source de vérité. Le coffre compte 188 notes, environ 4 Mo.

Trois options sur la table. SilverBullet est web-natif, mais n’exécute pas Dataview. Le code-server qui tourne déjà au cluster éditerait des fichiers, pas des vues. Restait la plus brutale : faire tourner l’application de bureau elle-même dans un pod et en streamer l’écran. L’image linuxserver/obsidian fait exactement ça, avec Selkies pour le bureau distant dans le navigateur.

Le streaming est la partie facile. Le vrai sujet, c’est ce qui se passe dès qu’il y a deux écrivains sur un même dépôt git : le PC d’un côté, le pod de l’autre, et aucun des deux ne sait ce que l’autre est en train de taper.

L’architecture : un pod, deux conteneurs, un volume jetable

Le pod tient en trois pièces qui partagent un PVC Longhorn de 5 Gi monté sur /config :

  • un initContainer vault-init clone le coffre au premier démarrage et pré-enregistre le dossier dans la configuration d’Obsidian, pour éviter l’écran « choisir un coffre » ; si le clone existe, il ne touche à rien ;
  • le conteneur obsidian : image épinglée par digest, limite 2 CPU / 2 Gi, /dev/shm en mémoire de 1 Gi comme l’exige Electron, pas de GPU ;
  • le sidecar git-sync : une image alpine/git qui exécute un script POSIX en boucle toutes les 120 secondes, avec une clé de déploiement Gitea en écriture limitée à ce seul dépôt.

Le Deployment est en strategy: Recreate : le volume est RWO, il ne doit jamais y avoir deux pods sur le même clone. Il est épinglé sur les deux Raspberry Pi 5 à 16 Go du cluster ; le worker à 2 Go est exclu. Les sondes sont des tcpSocket et non des httpGet : le HTTP répond 401 tant que la basic auth n’est pas passée, une sonde HTTP serait en échec permanent. Le HTTPS qu’exige Selkies (WebCodecs veut un contexte sécurisé) est fourni côté navigateur par Cloudflare.

Le PVC est jetable. La vérité vit dans Gitea, déjà sauvegardé ; perdre le volume coûte les réglages d’interface du pod, pas une note. À une condition : que rien ne reste coincé dans ce volume. Tout le reste de l’article tourne autour de cette condition.

La boucle : add, commit, fetch, rebase, push

Le principe tient en quatre lignes. Toutes les 120 secondes :

  1. git add -A, puis un commit web: <horodatage ISO> s’il y a quelque chose d’indexé ;
  2. git fetch origin main ;
  3. git rebase origin/main ;
  4. si HEAD est en avance sur origin/main, git push origin HEAD:main.

Un tour sans changement n’écrit rien, pas même une ligne de log. Le critère de réussite fixé dans la spec était simple : une note modifiée d’un côté apparaît de l’autre en 3 minutes au plus. Côté PC, rien ne change ; il voit simplement arriver des commits web: … dans l’historique.

Les quatre lignes, c’est le cas nominal. Le script en compte un peu moins de 120, et presque tout le reste sert à gérer ce qui sort du nominal.

Le conflit : une branche plutôt qu’une perte

Quand la même note a été modifiée sur le web et sur le PC dans la même fenêtre, le rebase s’arrête. Le script ne tente aucune résolution automatique ; il met la version web à l’abri et rend la main :

git rebase --abort 2>/dev/null
ref="conflit-web/$(date -u +%Y%m%d-%H%M%S)"
git push -q origin "HEAD:refs/heads/$ref" || return 1
log "CONFLIT : version web sauvegardee sur $ref"
if ! git reset -q --keep "origin/$BRANCH" 2>/dev/null; then
  log "edition pendant le conflit, realignement reporte"
  return 0
fi

La version web part sur une branche conflit-web/<AAAAMMJJ-HHMMSS>, le pod se réaligne sur main, et main n’est jamais réécrite. La résolution se fait sur le PC, avec les outils habituels : git fetch, git merge origin/conflit-web/…, résoudre, pousser main, supprimer la branche.

Reste à savoir qu’il y a quelque chose à fusionner. À chaque tour, le script fait un git ls-remote sur conflit-web/* et, tant qu’il en trouve, POSTe une alerte ObsidianSyncConflit directement sur l’API v2 de l’Alertmanager du cluster, qui la relaie sur Telegram. L’alerte porte un endsAt à +10 minutes et elle est ré-émise à chaque tour : une fois la branche supprimée, plus personne ne la renouvelle, et elle passe en RESOLVED toute seule dans la douzaine de minutes. Pas de règle Prometheus, pas d’exporter : un wget et un JSON de trois clés.

Le sidecar qui n’a pas le droit de mourir

La règle la plus importante du script tient dans son commentaire d’en-tête : il ne sort jamais en erreur. Pas de set -e, seulement set -u. Chaque étape qui échoue fait return 1 dans la fonction du tour, la boucle compte, et la dernière ligne du fichier est un exit 0.

La raison est mécanique. Un sidecar qui sort en erreur redémarre, puis part en CrashLoop ; le pod devient NotReady, le Service retire son endpoint, et l’accès web est coupé. Précisément au moment où Gitea est injoignable ou une clé révoquée, c’est-à-dire au moment où on aimerait au moins pouvoir lire ses notes.

Les problèmes remontent donc par alertes plutôt que par redémarrages :

if sync_once; then
  fails=0
else
  fails=$((fails + 1))
  log "echec ($fails tour(s) consecutif(s))"
fi
check_conflicts
if [ "$fails" -ge "$MAX_FAILS" ]; then
  send_alert ObsidianSyncEnErreur "git-sync obsidian echoue depuis $fails tours"
fi

Avec MAX_FAILS=15 et des tours de 120 secondes, ObsidianSyncEnErreur part après environ 30 minutes d’échecs consécutifs. En dessous, un échec est considéré comme transitoire. Le cas typique : le PC a poussé entre le fetch et le push du pod, le push est rejeté, et le tour suivant rattrape tout sans que personne ne soit dérangé.

Le script se teste hors cluster : un dépôt nu local joue Gitea, un clone joue le PC, un autre le pod, et un faux Alertmanager d’une vingtaine de lignes de Python consigne chaque POST dans un fichier. Les intervalles sont paramétrables par variables d’environnement, un test fait tourner un ou deux tours avec INTERVAL=0 et vérifie l’état du faux Gitea.

Quatre façons de perdre une frappe

La première version du script, celle du plan, passait ses tests. Une relecture finale du code a pourtant trouvé de quoi perdre des données ou bloquer la synchro. Le point commun de tous ces cas : Obsidian écrit quand il veut. L’autosave ne se synchronise pas sur la boucle.

1. La note en cours d’écriture prise pour un conflit. Celui-là était prévu dès le plan. Obsidian sauvegarde une note entre le commit et le rebase : l’arbre est sale, et git rebase refuse de démarrer. Ce n’est pas un conflit, et créer une branche conflit-web/* pour ça réveillerait Telegram pour rien. Le script vérifie git status --porcelain après le fetch et, si l’arbre est sale, reporte au tour suivant. La frappe sera dans le prochain commit.

2. La frappe pendant le push de la branche de conflit. C’est le défaut classé critique. La première version réalignait le pod avec git reset --hard origin/main. Or le push de la branche de conflit prend du temps, et une note sauvegardée par Obsidian pendant ce push n’est dans aucun commit : --hard l’écrasait sans bruit. Le correctif tient en un mot, --keep, qui refuse de toucher à un fichier modifié localement. Le reset échoue, le tour s’arrête, et le tour suivant committe la frappe et la pousse sur une nouvelle branche de conflit. Le test correspondant installe un hook pre-push qui écrit dans la note pendant le push, puis vérifie qu’une branche du faux Gitea contient bien le texte.

3. Le faux conflit causé par un verrou. Un index.lock posé par un autre processus git fait échouer le rebase sans qu’il y ait le moindre conflit. La première version voyait un échec de rebase et ouvrait une branche de conflit. Désormais, le script distingue : si le rebase échoue et qu’un rebase est resté en cours (.git/rebase-merge ou .git/rebase-apply), c’est un vrai conflit ; sinon, c’est un échec transitoire et on réessaie au tour suivant.

4. Les verrous orphelins. Un processus git tué en pleine opération (conteneur redémarré, enfant tué par l’OOM killer) laisse un index.lock derrière lui, et toutes les opérations suivantes échouent. Au démarrage, git-sync est seul dans le pod : tout verrou est orphelin et supprimé. En cours de route, c’est plus délicat, un verrou frais peut appartenir à un processus vivant ; seuls ceux de plus de 10 minutes sont retirés. Comme git-sync est le seul à rebaser et que ses tours ne se chevauchent pas, un rebase en cours au début d’un tour est forcément orphelin : il est annulé, et la version web finit sur une branche de conflit au lieu de traîner dans le reflog du PVC.

La suite de tests est passée de 18 vérifications dans le plan à 28, chaque correctif arrivant avec un test qui reproduit le scénario à coups de hooks git.

Les pièges d’infrastructure

Le ConfigMap qui change sans que rien ne change

Le script vit dans un ConfigMap, généré par un petit script Python à partir du fichier source. Problème : quand le ConfigMap change, ArgoCD l’affiche Synced, kubelet met bien à jour le fichier monté sur disque, mais le shell de git-sync a lu son script au démarrage et continue d’exécuter l’ancien. Pire, le /etc/passwd monté en subPath (voir plus bas) n’est jamais mis à jour.

La parade est le classique checksum dans le template du pod :

template:
  metadata:
    annotations:
      # GENERE par gen_configmap.py : change avec le ConfigMap -> le pod redemarre
      checksum/git-sync: "7453…4f1c"

Le générateur calcule le SHA-256 du ConfigMap et réécrit l’annotation dans le Deployment. Un nouveau script change le hash, donc le template, donc le pod redémarre. Deux tests gardent le tout honnête : l’un vérifie que le ConfigMap embarque exactement le script source, l’autre que l’annotation correspond au ConfigMap. Oublier de régénérer fait échouer la suite.

OpenSSH et l’uid 1000

Premier démarrage, premier clone, premier échec : No user exists for uid 1000. Le conteneur tourne en uid 1000 pour écrire avec le même propriétaire qu’Obsidian, mais l’image alpine/git n’a pas d’utilisateur 1000, et OpenSSH refuse de tourner sans entrée passwd. La correction ajoute une clé passwd au ConfigMap, montée sur /etc/passwd dans l’initContainer et dans le sidecar :

root:x:0:0:root:/root:/bin/sh
obsidian:x:1000:1000:obsidian:/tmp:/bin/sh

La NetworkPolicy et le pod de test trop pressé

Seul le tunnel Cloudflare doit atteindre Obsidian. La NetworkPolicy n’autorise l’ingress que depuis les pods app=cloudflared du namespace cloudflared, et pas depuis tout le namespace : celui-ci héberge aussi un DaemonSet en hostNetwork, dont les pods portent l’IP des nœuds.

Le piège est arrivé au moment de la tester. Un pod éphémère lancé dans cloudflared pour un wget était refusé, alors que la policy aurait dû le laisser passer. L’explication : k3s applique les NetworkPolicy avec kube-router, qui n’ajoute un nouveau pod à ses ipsets qu’à la synchro suivante. Un pod qui vit quelques secondes est refusé avant d’avoir existé aux yeux du pare-feu. Pour tester, il faut un pod qui vit au moins 45 secondes, et qui porte le label app=cloudflared.

Pas d’Ingress, volontairement

Aucun Ingress Traefik pour ce service, et c’est délibéré. Le LAN dispose d’un wildcard DNS qui envoie tous les sous-domaines du homelab vers Traefik ; un Ingress rendrait Obsidian joignable sur le réseau local sans passer par Cloudflare Access. Le tunnel cible donc directement le Service ClusterIP.

L’application Access est dédiée, autorise une seule identité, avec une session de 24 heures, et elle a été créée avant le hostname public, pour que l’URL n’ait jamais existé sans protection. La basic auth de Selkies reste en seconde couche, au cas où la NetworkPolicy ou Access seraient un jour mal configurés.

Le plugin obsidian-git, figé dans le pod

Le plan espérait que l’image Obsidian ne contienne pas git, ce qui aurait rendu inerte le plugin obsidian-git, activé dans la configuration versionnée du coffre. Raté : l’image contient git. Le plugin tourne donc aussi dans le pod, et sans précaution il deviendrait un troisième écrivain, avec ses propres commits et ses propres verrous.

Deux gestes. Ses réglages propres au pod (le data.json du plugin, gitignoré) sont figés à zéro : pas d’auto-commit, pas d’auto-push, pas d’auto-pull. Et le conteneur Obsidian reçoit GIT_OPTIONAL_LOCKS=0, pour que les lectures du plugin ne posent pas de verrou opportuniste pendant un tour de git-sync. Le PC, lui, garde son plugin tel quel : c’est le seul écrivain légitime de l’autre côté.

Ce que j’en retiens

  1. Deux écrivains sur un dépôt, c’est de la concurrence, pas de la synchro. La boucle nominale tient en quatre commandes ; tout le travail est dans les cas où quelqu’un écrit au mauvais moment.
  2. Distinguer conflit, arbre sale et verrou. Un git rebase qui échoue ne veut pas dire conflit. Seul un rebase resté en cours en est un ; le reste se reporte au tour suivant.
  3. reset --keep plutôt que --hard dès qu’un humain peut écrire en parallèle. Un reset qui refuse vaut mieux qu’un reset qui écrase.
  4. Un sidecar ne sort pas en erreur. Si sa mort coupe l’application principale, il compte ses échecs et alerte. L’API v2 de l’Alertmanager avec un endsAt court donne la résolution automatique gratuitement.
  5. Checksum du ConfigMap dans le template. Sans lui, un script monté change sur disque mais pas dans le processus qui l’a lu.
  6. Tester une NetworkPolicy avec un pod qui vit. Sous kube-router, un pod éphémère est refusé avant la synchro des ipsets ; un test négatif peut mentir.
  7. Pas d’Ingress pour ce qui doit rester derrière Access, tant qu’un wildcard LAN existe.

Limite honnête : le système suppose que les éditions simultanées d’une même note des deux côtés sont rares. Elles ne perdent rien, mais chacune coûte une fusion manuelle sur le PC. Si ça devenait fréquent, la réponse ne serait pas un meilleur script de synchro, mais d’arrêter d’avoir deux écrivains.

k3s-clustergitopsgitcloudflarealertmanagertdd