├── .gitignore ├── LICENSE ├── Readme.md ├── default.nix ├── flake.lock ├── flake.nix └── systemd2nix.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*/ 2 | **/__pycache__ 3 | *.service 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 DavHau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # systemd2nix 2 | Convert systemd service files to nix syntax for nixpkgs 3 | 4 | 5 | #### Run without installing 6 | ```bash 7 | nix run github:DavHau/systemd2nix 8 | ``` 9 | 10 | #### Install 11 | ```bash 12 | nix-env -if https://github.com/DavHau/systemd2nix/tarball/master 13 | ``` 14 | 15 | #### Usage 16 | ```bash 17 | systemd2nix < example.service 18 | ``` 19 | 20 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | # very lazy packaging 3 | with pkgs; 4 | writeScriptBin "systemd2nix" '' 5 | #!/usr/bin/env bash 6 | ${python3}/bin/python ${./systemd2nix.py} "$@" 7 | '' 8 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1694529238, 9 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1698611440, 24 | "narHash": "sha256-jPjHjrerhYDy3q9+s5EAsuhyhuknNfowY6yt6pjn9pc=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "0cbe9f69c234a7700596e943bfae7ef27a31b735", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 3 | inputs.flake-utils.url = "github:numtide/flake-utils"; 4 | outputs = { nixpkgs, ... }@inputs: 5 | with inputs; 6 | flake-utils.lib.eachDefaultSystem (system: 7 | let pkgs = nixpkgs.legacyPackages.${system}; 8 | in rec { 9 | packages.systemd2nix = pkgs.callPackage ./default.nix { }; 10 | defaultPackage = packages.systemd2nix; 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /systemd2nix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import re 5 | import sys 6 | from argparse import ArgumentParser 7 | 8 | 9 | class Keys: 10 | # list of options that move to the top level of the nix attrset 11 | # The 'Environment' option is not listed because it is processed by `parse_environment()` 12 | 13 | list_of_strings = [ 14 | 'after', 15 | 'before', 16 | 'bindsTo', 17 | 'conflicts', 18 | 'documentation', 19 | 'partOf', 20 | 'requiredBy', 21 | 'requires', 22 | 'requisite', 23 | 'wantedBy', 24 | 'wants', 25 | ] 26 | 27 | rest = [ 28 | 'description', 29 | 'onFailure', 30 | 'postStart', 31 | 'postStop', 32 | 'preStart', 33 | 'preStop', 34 | 'reload', 35 | 'reloadIfChanged', 36 | 'restartIfChanged', 37 | 'restartTriggers', 38 | 'startAt', 39 | 'startLimitIntervalSec', 40 | 'stopIfChanged', 41 | ] 42 | 43 | all = list_of_strings + rest 44 | 45 | 46 | def key2nix(key: str): 47 | # convert to camel case 48 | return key[0].lower() + key[1:] 49 | 50 | 51 | def parse_environment(env: str) -> dict: 52 | separated = env.strip().split(' ') 53 | return dict(map(lambda s: s.split('='), separated)) 54 | 55 | 56 | def format_config(conf: dict) -> dict: 57 | new_conf = dict(environment={}) 58 | if 'Install' in conf: 59 | for key, val in conf["Install"].items(): 60 | key = key2nix(key) 61 | new_conf[key] = val 62 | if "Service" in conf: 63 | if "Environment" in conf['Service']: 64 | new_conf['environment'] = parse_environment(conf['Service']['Environment']) 65 | del conf['Service']['Environment'] 66 | new_conf['serviceConfig'] = conf['Service'] 67 | if "Unit" in conf: 68 | for key, val in conf['Unit'].items(): 69 | key = key2nix(key) 70 | if key in Keys.all: 71 | new_conf[key] = val 72 | else: 73 | if 'unitConfig' not in conf: 74 | new_conf['unitConfig'] = {} 75 | new_conf['unitConfig'][key] = val 76 | 77 | # convert some values to list of strings 78 | for key in Keys.list_of_strings: 79 | if key not in new_conf: 80 | continue 81 | new_conf[key] = new_conf[key].strip().split(' ') 82 | return new_conf 83 | 84 | 85 | def sort_dict(nix_dict: dict) -> dict: 86 | new_dict = {} 87 | for key in list(sorted(Keys.all)) + ["environment", "unitConfig", "serviceConfig"]: 88 | if key in nix_dict: 89 | new_dict[key] = nix_dict[key] 90 | return new_dict 91 | 92 | 93 | def dict2nix(d: dict) -> str: 94 | s = json.dumps(d, indent=2) 95 | splitter = ' "environment": {' 96 | head, rest = s.split(splitter) 97 | rest = splitter + rest 98 | 99 | head = head.\ 100 | replace('":', ' =').\ 101 | replace('],', '];'). \ 102 | replace('",\n ]', '"\n ]'). \ 103 | replace('",\n "', '"\n "'). \ 104 | replace('",\n', '";\n'). \ 105 | replace('\n "', '\n ') 106 | 107 | rest = rest.\ 108 | replace(' "', ' ').\ 109 | replace('":', ' =').\ 110 | replace('",\n', '";\n').\ 111 | replace('"\n', '";\n').\ 112 | replace(' {},', ' {};').\ 113 | replace(' },', ' };').\ 114 | replace(' }\n', ' };\n') 115 | 116 | return head + rest 117 | 118 | 119 | def parse_unit_file(file_content: str) -> dict: 120 | config = {} 121 | section = None 122 | for line in file_content.splitlines(): 123 | # match section headers like '[Unit]' 124 | match = re.fullmatch(r"^\[(\w*)\]$", line) 125 | if match: 126 | section = match.groups()[0] 127 | if section not in config: 128 | config[section] = {} 129 | continue 130 | # match key-value pairs with quotes 131 | match = re.fullmatch(r'^(\w*)="(.*)"$', line) 132 | # match key-value pairs without quotes 133 | if not match: 134 | match = re.fullmatch(r"^(\w*)=(.*)$", line) 135 | if not match: 136 | continue 137 | if not section: 138 | print("ERROR: Entry without section", file=sys.stderr) 139 | exit(1) 140 | key, val = match.groups() 141 | # option assignment with empty value resets option 142 | if key not in config[section] or val == '': 143 | config[section][key] = [] 144 | config[section][key].append(val) 145 | for section in config.values(): 146 | for key, val in section.items(): 147 | section[key] = ' '.join(val) 148 | return config 149 | 150 | 151 | def parse_args(): 152 | parser = ArgumentParser( 153 | description="Convert systemd service files to nix syntax for nixpkgs", 154 | usage='systemd2nix < example.service') 155 | return parser.parse_args() 156 | 157 | 158 | def main(): 159 | parse_args() # just to display usage 160 | _input = sys.stdin.read() 161 | config = parse_unit_file(_input) 162 | formatted = format_config(config) 163 | print(dict2nix(sort_dict(formatted))) 164 | 165 | 166 | if __name__ == '__main__': 167 | main() 168 | --------------------------------------------------------------------------------