From b077bc63aade200d909c065d0433538aa1892d2c Mon Sep 17 00:00:00 2001 From: Paul Kobow Date: Wed, 12 Aug 2026 15:28:14 +0200 Subject: [PATCH] Add Nix compatability as package and home-manager module --- .github/workflows/nix.yml | 61 ++++++++++ .gitignore | 3 +- README.md | 68 +++++++++++ flake.lock | 236 ++++++++++++++++++++++++++++++++++++ flake.nix | 64 ++++++++++ nix/home-manager-module.nix | 165 +++++++++++++++++++++++++ nix/package.nix | 34 ++++++ nix/python-env.nix | 82 +++++++++++++ uv.lock | 2 +- 9 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/nix.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/home-manager-module.nix create mode 100644 nix/package.nix create mode 100644 nix/python-env.nix diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..78dc027 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,61 @@ +name: Nix + +on: + workflow_dispatch: + push: + paths: + - "flake.nix" + - "nix/**" + - "flake.lock" + - "uv.lock" + - "**/*.py" + - "pyproject.toml" + - ".github/workflows/nix.yml" + pull_request: + paths: + - "flake.nix" + - "nix/**" + - "flake.lock" + - "uv.lock" + - "**/*.py" + - "pyproject.toml" + - ".github/workflows/nix.yml" + +jobs: + check: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Check flake + run: nix flake check --print-build-logs + + - name: Build package + run: nix build .#default --print-build-logs + + - name: Evaluate home-manager module + run: | + nix eval --impure --json --expr ' + let + flake = builtins.getFlake (toString ./.); + pkgs = import flake.inputs.nixpkgs { system = builtins.currentSystem; }; + module = flake.homeManagerModules.logsmith { + config.programs.logsmith = { + enable = true; + autostart = true; + }; + lib = pkgs.lib; + inherit pkgs; + }; + in + builtins.attrNames module.options.programs.logsmith + ' diff --git a/.gitignore b/.gitignore index 07cb0e1..755ecce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +result *.pyc venv .venv @@ -14,4 +15,4 @@ legacy .lsp tmp __pycache__ -.pytest_cache \ No newline at end of file +.pytest_cache diff --git a/README.md b/README.md index ed12c96..90f9160 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,74 @@ brew install logsmith-beta This will copy the binary into your Application folder. Start the application from there an follow the steps above from step 4. +### Nix / home-manager + +The flake exposes `packages.default` and a home-manager module as `homeManagerModules.logsmith`. + +```nix +{ + inputs.logsmith.url = "github:redvox/logsmith"; + + # in your home-manager configuration + imports = [ inputs.logsmith.homeManagerModules.logsmith ]; + + programs.logsmith = { + enable = true; + autostart = true; # run as a systemd user service in the graphical session + }; +} +``` + +The aws cli and the google cloud sdk are installed along with logsmith. The service is +started by systemd and not by an interactive shell, so tools that are only on the PATH +via `~/.zshrc` or `~/.bashrc` are neither found by logsmith nor by the post login +scripts of a profile group. Use `extraPackages` and `extraPaths` for those: + +```nix +programs.logsmith = { + enable = true; + extraPackages = [ pkgs.kubectl ]; + extraPaths = [ "${config.home.homeDirectory}/.asdf/shims" ]; +}; +``` + +`programs.logsmith.settings` and `programs.logsmith.accounts` can be used to declare +`~/.logsmith/config.yaml` and `~/.logsmith/accounts.yaml`. Both take a nix attribute +set (not a yaml string), which is rendered to the config file: + +```nix +programs.logsmith = { + enable = true; + + settings = { + mfa_shell_command = "ykman oath accounts code AWS=AWS -s "; + default_access_key = "access-key"; + default_sso_session = "sso"; + default_sso_interval = "8"; + }; + + accounts = { + productive = { + team = "team1"; + region = "eu-central-1"; + color = "#388E3C"; + auth_mode = "sso"; + profiles = [ + { + profile = "nonlive"; + account = "123456789123"; + role = "developer"; + default = true; + } + ]; + }; + }; +}; +``` + +Note that these files then become read-only symlinks into the nix store, so the in-app +config dialog can no longer write to them. + ## Configuration The configuration is a YAML file that contains any number of profile groups. Each profile group can contain any number of profiles which will be assumed when the profile group is selected. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3e8eed8 --- /dev/null +++ b/flake.lock @@ -0,0 +1,236 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786247143, + "narHash": "sha256-8S3Kcxs7D4UtxJxSJZz0m14CGhuW0MxfrIwJxeGWGnQ=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "279b4a8275f032c566576b3f181fa0f27197f588", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1785571196, + "narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1783224372, + "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": "nixpkgs_2", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + }, + "locked": { + "lastModified": 1785730568, + "narHash": "sha256-NjSPsgjJ7MSpBtTkUcmNhRe6AFZ96+zsca2M8YuQi8Y=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "90fde00db3687922d39d95fc591475fd0bbbcd72", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "pyproject-build-systems", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784591072, + "narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=", + "owner": "nix-community", + "repo": "pyproject.nix", + "rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "pyproject.nix", + "type": "github" + } + }, + "pyproject-nix_2": { + "inputs": { + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1786031528, + "narHash": "sha256-cROiHKO3UbIKqF5FG5NikvydzlfIj4EcR1Cty9qOVt4=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "1b1485546d85f6f6c7aadb10c4923dbc09633263", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "pyproject-nix_3": { + "inputs": { + "nixpkgs": [ + "uv2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786025587, + "narHash": "sha256-n1E1kC090MobXdYkOZLud/RLuinsNTdouRlIClLq2RA=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "0f4fb6ca53143ec38f933f05d2fad03513567fab", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix_2", + "uv2nix": "uv2nix_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "pyproject-build-systems", + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-build-systems", + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1785277507, + "narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + }, + "uv2nix_2": { + "inputs": { + "nixpkgs": "nixpkgs_4", + "pyproject-nix": "pyproject-nix_3" + }, + "locked": { + "lastModified": 1786026603, + "narHash": "sha256-payw8w/aqR/Vr7LvDp14jxa5egFrZN7g8INsPzn7rN0=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "0dfa8388dc855b1774f509725d8ea6806291571d", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..cd2fd61 --- /dev/null +++ b/flake.nix @@ -0,0 +1,64 @@ +{ + description = "Logsmith AWS account management utility"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + uv2nix.url = "github:pyproject-nix/uv2nix"; + pyproject-nix.url = "github:pyproject-nix/pyproject.nix"; + pyproject-build-systems.url = "github:pyproject-nix/build-system-pkgs"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + uv2nix, + pyproject-nix, + pyproject-build-systems, + }: + let + # The external CLIs logsmith shells out to at runtime. Used by the wrapper + # of the package, by `home.packages` and by the PATH of the systemd user + # service, so they are listed once here. + runtimeTools = pkgs: [ + pkgs.awscli2 + pkgs.google-cloud-sdk + ]; + + homeManagerModules.logsmith = import ./nix/home-manager-module.nix { inherit self runtimeTools; }; + + systemIndependentOutputs = { + inherit homeManagerModules; + # Aliases for the naming variants home-manager users expect. + homeManagerModule = homeManagerModules.logsmith; + homeModules = homeManagerModules; + }; + + perSystemOutputs = + system: + let + pkgs = import nixpkgs { inherit system; }; + + pythonEnv = import ./nix/python-env.nix { + inherit + pkgs + uv2nix + pyproject-nix + pyproject-build-systems + ; + python = pkgs.python313; + workspaceRoot = ./.; + }; + in + { + packages.default = import ./nix/package.nix { + inherit pkgs pythonEnv; + src = ./.; + runtimeTools = runtimeTools pkgs; + }; + }; + in + systemIndependentOutputs // flake-utils.lib.eachDefaultSystem perSystemOutputs; +} diff --git a/nix/home-manager-module.nix b/nix/home-manager-module.nix new file mode 100644 index 0000000..9f0faf7 --- /dev/null +++ b/nix/home-manager-module.nix @@ -0,0 +1,165 @@ +# home-manager module: installs logsmith, optionally writes its configuration +# files and autostarts it as a systemd user service. +# +# System independent on purpose - `pkgs` is the one of the importing +# home-manager configuration, `self` is only used to look up the default +# package for that system. +{ self, runtimeTools }: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.programs.logsmith; + + tools = runtimeTools pkgs; + + yamlType = (pkgs.formats.yaml { }).type; + + # Written as json, which is a subset of yaml, so no converter has to be built + # just to render a config file. + toYamlFile = (pkgs.formats.json { }).generate; + + # PATH for the systemd service: the user supplied directories first, then the + # store paths of the packages logsmith and the post login scripts call. + servicePath = lib.concatStringsSep ":" ( + cfg.extraPaths ++ [ (lib.makeBinPath (tools ++ cfg.extraPackages)) ] + ); + + # `~/.logsmith/` is only managed by nix when the option is set. + # An empty attribute set means "leave the file alone", so that logsmith itself + # can keep writing it. + declarativeFile = + fileName: value: + lib.mkIf (value != { }) { + ".logsmith/${fileName}".source = toYamlFile "logsmith-${fileName}" value; + }; +in +{ + options.programs.logsmith = { + enable = lib.mkEnableOption "logsmith aws login helper"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.system}.default; + defaultText = lib.literalExpression "logsmith.packages.\${pkgs.system}.default"; + description = "The logsmith package to use."; + }; + + autostart = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Start logsmith as a systemd user service with the graphical session."; + }; + + extraPackages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + example = lib.literalExpression "[ pkgs.kubectl pkgs.kubelogin ]"; + description = '' + Additional packages that are installed and put on the PATH of the + service, for tools used by the post login scripts of a profile group. + ''; + }; + + extraPaths = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = lib.literalExpression '' + [ + "''${config.home.homeDirectory}/.asdf/shims" + "''${config.home.homeDirectory}/.krew/bin" + ] + ''; + description = '' + Additional directories that are prepended to the PATH of the service. + + The service is started by systemd and not by an interactive shell, so + directories that are added in `~/.zshrc` or `~/.bashrc` are neither + visible to logsmith nor to the post login scripts. + ''; + }; + + settings = lib.mkOption { + type = lib.types.attrsOf yamlType; + default = { }; + example = lib.literalExpression '' + { + default_access_key = "access-key"; + default_sso_session = "sso"; + mfa_shell_command = "pass otp aws"; + } + ''; + description = '' + Declarative content of `~/.logsmith/config.yaml`, written as a nix + attribute set (not as a yaml string). + + When set, the file becomes a read-only symlink into the nix store and + logsmith's "Edit Config" dialog can no longer write to it. + ''; + }; + + accounts = lib.mkOption { + type = lib.types.attrsOf yamlType; + default = { }; + example = lib.literalExpression '' + { + my-team = { + color = "#388E3C"; + region = "eu-central-1"; + profiles = [ + { + profile = "developer"; + account = "123456789012"; + role = "developer"; + } + ]; + }; + } + ''; + description = '' + Declarative content of `~/.logsmith/accounts.yaml` (the profile groups), + written as a nix attribute set (not as a yaml string). + + When set, the file becomes a read-only symlink into the nix store and + logsmith's config dialog can no longer write to it. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + home.packages = [ cfg.package ] ++ tools ++ cfg.extraPackages; + + home.file = lib.mkMerge [ + (declarativeFile "config.yaml" cfg.settings) + (declarativeFile "accounts.yaml" cfg.accounts) + ]; + + systemd.user.services.logsmith = lib.mkIf cfg.autostart { + Unit = { + Description = "logsmith aws login helper"; + # Needs both a tray to dock into and network access to reach AWS. + After = [ + "graphical-session.target" + "network-online.target" + ]; + Wants = [ "network-online.target" ]; + PartOf = [ "graphical-session.target" ]; + }; + Service = { + Type = "simple"; + # Started via a shell to extend the PATH of the systemd user manager + # instead of replacing it. logsmith collects its environment from a + # non-interactive login shell, which only inherits this PATH, so + # everything the post login scripts need has to be on it. + # `$$PATH` escapes the `$` for systemd, the shell sees `$PATH`. + ExecStart = "${pkgs.runtimeShell} -c 'PATH=${servicePath}:$$PATH exec ${cfg.package}/bin/logsmith'"; + Restart = "on-failure"; + RestartSec = "5s"; + }; + Install.WantedBy = [ "graphical-session.target" ]; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..380526c --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,34 @@ +# The `logsmith` executable: the app sources plus a wrapper that runs them with +# the prebuilt python environment and the runtime CLIs on the PATH. +{ + pkgs, + pythonEnv, + runtimeTools, + src, +}: +let + inherit (pkgs) lib; + + # Single source of truth for the version is app/version.py. + version = lib.head ( + builtins.match ''.*version[[:space:]]*=[[:space:]]*"([^"]+)".*'' ( + builtins.readFile "${src}/app/version.py" + ) + ); +in +pkgs.stdenv.mkDerivation { + pname = "logsmith"; + inherit version src; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + buildInputs = [ pythonEnv ]; + + installPhase = '' + mkdir -p $out/bin $out/share/logsmith + cp -r app $out/share/logsmith/ + makeWrapper ${pythonEnv}/bin/python $out/bin/logsmith \ + --add-flags $out/share/logsmith/app/run.py \ + --prefix PYTHONPATH : $out/share/logsmith \ + --prefix PATH : ${lib.makeBinPath runtimeTools} + ''; +} diff --git a/nix/python-env.nix b/nix/python-env.nix new file mode 100644 index 0000000..bb4890d --- /dev/null +++ b/nix/python-env.nix @@ -0,0 +1,82 @@ +# Builds the python environment logsmith runs in. +# +# The dependencies come from `uv.lock` via uv2nix, so nix and `uv sync` install +# exactly the same versions. The result is a python interpreter that already +# knows about all runtime dependencies and about the Qt shipped inside the PyQt6 +# wheels. +{ + pkgs, + python, + workspaceRoot, + uv2nix, + pyproject-nix, + pyproject-build-systems, +}: +let + inherit (pkgs) lib; + + workspace = uv2nix.lib.workspace.loadWorkspace { inherit workspaceRoot; }; + + # The PyQt6 wheels bundle their own Qt build, so nixpkgs' Qt must never end up + # on the library path - mixing the two causes symbol lookup errors. Only the + # plain system libraries the bundled Qt links against are needed. + qtSystemLibs = with pkgs; [ + dbus + expat + fontconfig + freetype + glib + libglvnd + libx11 + libxcb + libxext + libxkbcommon + xcbutil + xcbutilcursor + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + xcbutilwm + zlib + zstd + ]; + + # The prebuilt PyQt6 binaries link against Qt libraries shipped in the + # separate pyqt6-qt6 wheel, which autoPatchelf cannot see at build time. + withBundledQt = + drv: + drv.overrideAttrs (old: { + autoPatchelfIgnoreMissingDeps = true; + buildInputs = (old.buildInputs or [ ]) ++ qtSystemLibs; + }); + + pyqtOverlay = _final: prev: { + pyqt6 = withBundledQt prev.pyqt6; + pyqt6-qt6 = withBundledQt prev.pyqt6-qt6; + }; + + pythonSet = (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + (workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }) + pyqtOverlay + ] + ); + + pythonEnv = pythonSet.mkVirtualEnv "logsmith" workspace.deps.default; + + bundledQt = "${pythonEnv}/${python.sitePackages}/PyQt6/Qt6"; +in +# Point the interpreter at the Qt that lives inside the PyQt6 wheels, otherwise +# it finds no platform plugin and refuses to start. +pkgs.symlinkJoin { + name = "logsmith-python-env"; + paths = [ pythonEnv ]; + buildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + wrapProgram $out/bin/python \ + --set QT_PLUGIN_PATH "${bundledQt}/plugins" \ + --set QML_IMPORT_PATH "${bundledQt}/qml" \ + --prefix LD_LIBRARY_PATH : "${bundledQt}/lib:${lib.makeLibraryPath qtSystemLibs}" + ''; +} diff --git a/uv.lock b/uv.lock index 35feb2c..d31ec2f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.13.7" +requires-python = "==3.13" [[package]] name = "altgraph"