← Publications

Note terrain · Platform engineering

Pourquoi j’ai fini par écrire une petite app macOS pour provisionner mes postes#

Le premier login mélange réseau, identité, privilèges root, Nix et reprise sur erreur. À un moment, continuer en shell était plus compliqué qu’écrire une petite app SwiftUI avec une vraie machine à états.

Série Nix, Entra et Apple Business · 3/5 · ← Précédent

Au début, je pensais vraiment pouvoir gérer le premier login avec quelques scripts.

Sur le papier, il fallait vérifier que Nix était là, récupérer l’utilisateur, faire une authentification, construire la config, lancer nix-darwin et terminé.

Évidemment, la vraie liste était un peu moins jolie.

Il fallait attendre une vraie session graphique, savoir si Determinate était réellement prêt, gérer Entra, éventuellement GitHub, construire Nix dans le bon contexte utilisateur, activer en root, reprendre après un crash, expliquer ce qui se passe à l’écran et surtout éviter qu’un retry relance n’importe quoi dans n’importe quel état.

À ce moment-là, continuer en shell aurait juste produit une application… sans interface et sans vraie machine à états.

J’ai donc arrêté de lutter contre le problème et j’ai écrit une petite app macOS.

SwiftUI était le choix le plus banal#

Le projet est volontairement classique : Swift 6, SwiftUI, Xcode et XcodeGen.

Je n’avais aucune envie de rajouter Electron ou une webview pour une application qui doit discuter avec macOS, Keychain, MSAL, launchd et un helper privilégié.

L’app est petite et très liée au système. SwiftUI est largement suffisant.

Je garde aussi le build utilisable en ligne de commande avec xcodebuild. Xcode sert quand j’en ai besoin pour les previews, le debug ou la signature, mais je ne veux pas que la release dépende d’une série de clics dans l’IDE.

Le vrai intérêt n’est pas l’interface#

L’interface pourrait être moche et le modèle resterait intéressant.

Ce qui m’intéresse surtout, c’est que le parcours soit enfin explicite.

Il y a un état de preflight, un état d’authentification Entra, un profil résolu, éventuellement une étape GitHub, puis le provisioning, la validation et la fin.

Un échec est lui aussi un état.

Ça paraît trivial, mais ça change beaucoup de choses. Quand le Mac redémarre au milieu, quand l’app est fermée de force ou quand une activation Nix échoue, je peux décider précisément ce qui est rejouable et ce qui ne l’est pas.

Avant, ce genre de logique finit vite dans des fichiers marqueurs, quelques if, un sleep 10 et beaucoup d’espoir.

Là, les transitions sont testées et le run garde des checkpoints.

Je ne voulais pas faire tourner l’UI en root#

Autre point assez vite évident : l’application graphique ne doit pas avoir les privilèges nécessaires pour modifier le système.

J’ai donc séparé l’app et un helper privilégié.

XPC

Omnivya Setup.app

Workstation Helper

Nix build

nix-darwin activate

rollback

Je ne voulais surtout pas d’un endpoint du genre run(command).

Le helper expose un petit protocole avec des opérations connues : preflight, préparation du device, build, activation, validation, rollback, status.

Ça rend la frontière beaucoup plus claire. L’app demande une opération. Le helper décide comment la réaliser avec les privilèges nécessaires.

C’est beaucoup plus sain qu’un shell root télécommandé depuis l’UI.

Le contexte utilisateur compte vraiment avec Nix#

Le build et l’activation ne vivent pas exactement au même endroit.

Le build doit connaître le bon utilisateur, son home, Home Manager et éventuellement son checkout Git. L’activation du système, elle, a besoin de root.

Au début, je faisais donc systématiquement construire dans le contexte utilisateur puis activer côté helper.

Depuis, le moteur sait aussi prendre un autre chemin : si le registry Entra fournit une closure Nix prébuildée et vérifiée, Setup la matérialise dans son cache, puis le helper l’importe directement dans le store avant activation. Si l’artefact n’existe pas encore, je garde le chemin de build local comme fallback.

registry artefact disponible
   -> download
   -> SHA-256
   -> nix-store --import
   -> activate

sinon
   -> nix build local
   -> activate

Ça évite aussi de dépendre d’un prompt sudo qui apparaîtrait plus ou moins bien au milieu du premier login. Et surtout, la state machine ne change pas : seule l’implémentation du step de provisioning évolue.

Ce genre de détail n’est pas spectaculaire, mais c’est précisément ce qui rend le provisioning reproductible au lieu de marcher seulement sur mon Mac.

GitHub n’arrive que quand il sert#

Pour un profil Standard ou Direction, je n’ai aucune raison d’imposer GitHub.

Pour un profil Tech, mon implémentation actuelle peut lancer un Device Flow puis cloner mdm-setup dans le workspace utilisateur.

Ce point a commencé à bouger depuis les premiers runs.

Le registry privé est maintenant câblé, et le helper sait importer une closure prébuildée lorsqu’elle est disponible. GitHub n’a donc plus besoin d’être le mécanisme de distribution de la workstation.

Je le garde encore pour le profil Tech parce qu’un développeur va de toute façon travailler avec les repositories. Le Device Flow reste alors une vraie étape d’onboarding développeur, pas une dépendance artificielle imposée à tous les postes.

Le premier login est beaucoup moins stable qu’il en a l’air#

Le pilote m’a rappelé un truc assez simple : voir le desktop ne veut pas dire que la machine est prête.

Le Setup Assistant peut se terminer alors que Determinate n’est pas encore complètement opérationnel. Le Wi-Fi peut être connecté mais bloqué par un captive portal. Le helper peut ne pas être chargé. Et sur un Mac propre, les Command Line Tools ou Homebrew peuvent tout simplement ne pas exister.

J’ai donc fini par rendre le preflight visible au lieu de cacher tout ça derrière un spinner.

Aujourd’hui l’app déroule une petite checklist : Determinate, Apple Command Line Tools, Homebrew, enrollment ADE ou adoption locale, réseau, puis présence de la configuration. Si Homebrew manque, Setup sait l’installer dans le contexte utilisateur. Si les CLT manquent, il ouvre d’abord le mécanisme Apple prévu pour les installer.

La partie réseau est devenue un vrai gate également. Je vérifie le path, un éventuel captive portal et la joignabilité de l’endpoint Microsoft avant de lancer MSAL, avec un retry borné. Ça évite de transformer un problème Wi-Fi en faux incident Entra.

Ce n’est pas très spectaculaire techniquement, mais c’est nettement plus agréable à exploiter : l’utilisateur voit ce que la machine attend réellement au lieu de regarder une roue tourner.

J’ai aussi arrêté de faire bouger toute la fenêtre#

Les premiers essais avaient un autre défaut très banal : à mesure que les écrans changeaient, les boutons et la progression pouvaient se retrouver trop bas ou faire varier la taille de la fenêtre.

J’ai fini par traiter l’app comme un vrai assistant : header fixe, progression Welcome → Sign-in → Profile → Provision → Done, contenu central scrollable quand il le faut, actions toujours visibles dans le footer.

Ce n’est pas le morceau le plus « architecture » du projet, mais ça compte énormément pendant un onboarding. Un bouton Retry qu’il faut retrouver en redimensionnant la fenêtre n’est pas vraiment un mécanisme de recovery.

Et quand une étape casse, l’écran d’erreur affiche maintenant les checkpoints déjà validés et la prochaine étape que Retry va reprendre. Là aussi, la state machine devient visible pour l’utilisateur au lieu de rester un détail interne.

J’ai fini par afficher les logs Nix dans l’app#

Je n’aime pas trop les barres de progression qui disent « préparation en cours » pendant dix minutes.

Le build Nix fournit déjà beaucoup d’informations, donc autant les exploiter.

L’app affiche une progression, les étapes Build / Install / Activate / Validate et un bout du log. C’est aussi ce qui m’intéressait avec une vraie UI : voir où la machine en est sans aller tailer trois fichiers depuis un autre terminal.

Omnivya Setup téléchargeant une image workstation Nix préconstruite avec progression

Le provisioning reste visible : Setup télécharge la closure préconstruite, affiche le débit et la progression avant import et activation.

En cas d’échec, on peut retry, exporter les diagnostics et, quand ça a du sens, rollback.

Pour moi, ça fait complètement partie du sujet Platform Engineering.

Un golden path qui marche uniquement quand tout va bien n’est pas vraiment un golden path. Ce qui compte aussi, c’est ce qu’il raconte quand quelque chose casse.

La même app doit aussi savoir reprendre un Mac déjà utilisé#

Ça m’évite également de créer un deuxième outil pour les machines qui existent déjà.

Sur un Mac neuf, l’app sait qu’elle arrive juste après le bootstrap et peut dérouler son onboarding.

Sur un Mac qui a déjà vécu, elle doit être un peu moins naïve.

Avant de toucher quoi que ce soit, elle peut regarder ce qui existe déjà : Nix, Homebrew, une éventuelle génération nix-darwin, les versions du bootstrap, l’espace disque, les fichiers que Home Manager risque de reprendre et l’état de management du Mac.

Le rôle Entra ne change pas. Le profil cible non plus.

Ce qui change, c’est la manière d’y arriver.

Je veux que ce premier apply « brownfield » montre ce qu’il va faire avant de le faire. Pas besoin de produire un diff de 4 000 lignes Nix, mais au moins dire clairement que la machine va rejoindre le profil Tech, quels composants importants vont être ajoutés, si un conflit a été détecté et si une dépendance existante demande une intervention.

Ensuite seulement, elle converge vers le même état que celui qu’aurait produit une installation neuve.

Ça donne finalement deux points d’entrée vers la même workstation :

Mac neuf
  -> ADE
  -> Bootstrap
  -> Setup

Mac existant
  -> Bootstrap manuel
  -> audit local
  -> Setup

puis dans les deux cas
  -> Entra
  -> profil
  -> Nix
  -> même état versionné

Je préfère largement ça à maintenir un « vieux parc » à côté du nouveau pendant des mois.

J’ai fini par instrumenter le parcours lui-même#

Comme la state machine était déjà explicite, l’instrumentation OpenTelemetry est devenue assez naturelle.

Le Setup interactif émet maintenant une trace end-to-end pour un run complet, avec les phases principales comme spans : preflight, Entra, résolution du rôle, GitHub, provisioning et validation. À l’intérieur du provisioning, je distingue aussi le build local, le download registry, l’import de closure et l’activation.

Je ne voulais pas transformer chaque tick de progression en span. Les checkpoints et la progression de téléchargement sont donc des events sur les spans ouverts, et les logs partent comme des wide events JSON vers Loki.

Ça donne quelque chose de beaucoup plus lisible qu’un déluge de spans minuscules.

omnivya.setup.run
  -> preflight
  -> entra
  -> role
  -> github
  -> provisioning
       -> registry.download
       -> registry.import
       -> nix.activate
  -> validate

Le même run_id est réutilisé si Setup est relancé après un crash, donc la reprise du workflow reste corrélable dans Tempo au lieu de créer une deuxième histoire sans lien avec la première.

Trace Tempo du parcours Omnivya Setup avec preflight, Entra, rôle, GitHub et préparation

Le parcours interactif est tracé de bout en bout : preflight, Entra, rôle, GitHub puis provisioning restent corrélés dans le même run.

L’app ne disparaît plus après le premier boot#

Au début je voyais surtout Omnivya Setup comme l’assistant du premier login.

Ce n’est déjà plus vraiment le cas.

Le package installe maintenant un LaunchAgent persistant. Tant que le provisioning n’est pas terminé, il garde le chemin interactif. Une fois la machine configurée, le même composant peut tourner en mode --agent, sans fenêtre et sans déclencher de login Entra ou GitHub interactif.

Sur le pilote, je l’ai volontairement réglé à 15 minutes pour voir rapidement les changements dans Grafana. La cible normale reviendra plutôt vers quelques heures une fois la chaîne validée.

Une fois le poste construit, j’ai encore besoin de répondre à des questions très simples : quelle version du Bootstrap.pkg est installée, quelle version de l’app tourne, quel Nix est réellement disponible, quelle configuration workstation est active, quel commit l’a produite et vers quoi je peux rollback.

Le package embarque sa provenance, le helper expose son état et l’agent écrit maintenant un snapshot périodique. L’écran final rassemble déjà une bonne partie de ces informations :

Omnivya Workstation

Bootstrap      <version>
Setup.app      <version>
Helper         connected / protocol 2
Nix            <système actif>

Profile        tech / stable
Configuration  2.7.0
Source         <commit>
System         /nix/store/...
Previous       2.6.1

Last check     il y a 2 h
Status         up to date

Le dashboard de fin n’est plus seulement une idée. Il collecte déjà la provenance du bootstrap, macOS, le système Nix actif, le nombre de paths de la closure, les binaires exposés, les formulae/casks Homebrew, l’état live du MDM ainsi que la santé Entra, GitHub et du helper. Le tout peut aussi être exporté en JSON pour les diagnostics.

Ce n’est pas encore un SBOM complet, et je ne veux pas le vendre comme tel. C’est plutôt un « neofetch de workstation » suffisamment précis pour savoir ce qu’on regarde.

Je ne mettrais pas pour autant la vérification distante dans le daemon root.

Le helper privilégié doit rester bête : lire l’état système, activer une génération, valider, rollback. La boucle périodique appartient au LaunchAgent utilisateur. Il collecte l’état, vérifie les sessions de manière non interactive, écrit son snapshot et émet la télémétrie du pilote, puis sort.

Ça évite surtout de donner des tokens utilisateur ou du trafic réseau à un process root qui n’en a pas besoin.

L’app devient alors moins un « wizard qu’on utilise une fois » qu’un petit panneau de contrôle de la workstation.

Finalement, l’app reste assez petite#

Je ne suis pas en train de construire un produit MDM maison.

L’application sert surtout de colle entre des composants qui ont chacun leur rôle.

SwiftUI gère l’interaction. Entra l’identité. Le helper les privilèges. Nix l’état du poste. Apple Business le bootstrap.

Une fois ce découpage posé, la suite est devenue assez logique.

Je versionne déjà mes services, mes images et mes artefacts. Pourquoi mes postes resteraient-ils sur « la dernière config Git qui a réussi à passer » ?

Sources officielles#

Étienne Deneuve

CTO expert

Je dirige des équipes, je reste dans le code et les plateformes, et je vérifie ce qui change vraiment le delivery. J’anime aussi ateliers, interventions et formations.

À propos · Accompagnement

Omnivya est l’entreprise que j’ai cofondée avec Taous, et dont je suis CTO. Les missions structurées y passent.

Pour une question ou un échange sans brief commercial : M’écrire · LinkedIn

Continuer la série

Précédent · 2/5Apple Business pour enrôler, Entra pour décider quel poste construire

← Toutes les publications