diff --git a/README.md b/README.md index 93d5f3a..03a2be9 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,18 @@ # Decman -> 🎉 Early support for Flatpaks was just added! 🎉 -> By default flatpak management is disabled. Support is in early stages so expect bugs. +> There are going to be breaking changes! +> Decman has undergone an architecture rewrite! The new architecture makes decman more expandable and maintainable. +> +> The AUR package will receive this update after I have tested it enough. +> See this [tag](https://github.com/kiviktnm/decman/tree/0.4.2) for the current version of decman available in the AUR. +> +> Migration guide is [here](/docs/migrate-to-v1.md). -> ```py -> import decman -> import decman.config -> decman.config.enable_flatpak = True -> # You can add system wide packages as well as user packages -> decman.flatpak_packages += ["org.signal.Signal"] -> decman.flatpak_user_packages["user"] = decman.flatpak_user_packages.get("user", []) -> decman.flatpak_user_packages["user"].append("dev.zed.Zed") -> ``` - -Decman is a declarative package & configuration manager for Arch Linux. It allows you to manage installed packages, your dotfiles, enabled systemd units, and run commands automatically. Your system is configured using python so your configuration can be very adaptive. - -If you want, you can also use decman with other configuration languages. See the [example with TOML later in this README](#decman-with-other-configuration-languages). +Decman is a declarative package & configuration manager for Arch Linux. It allows you to manage installed packages, your dotfiles, enabled systemd units, and run commands automatically. Your system is configured using Python so your configuration can be very adaptive. ## Overview -A complete example is available in the `example`-directory of this repository. It also serves as documentation so reading it is recommended. +[See the complete documentation for using decman.](/docs/README.md) To use decman, you need a source file that declares your system installation. I recommend you put this file in source control, for example in a git repository. @@ -27,26 +20,28 @@ To use decman, you need a source file that declares your system installation. I ```py import decman + from decman import File, Directory -# Declare installed packages -decman.packages += ["python", "git", "networkmanager", "ufw", "neovim"] +# Declare installed pacman packages +decman.pacman.packages |= {"base", "linux", "linux-firmware", "networkmanager", "ufw", "neovim"} # Declare installed aur packages -decman.aur_packages += ["protonvpn"] +decman.aur.packages |= {"decman"} # Declare configuration files # Inline decman.files["/etc/vconsole.conf"] = File(content="KEYMAP=us") -# From files within your repository + +# From files within your source repository +# (full path here would be /home/user/config/dotfiles/pacman.conf) decman.files["/etc/pacman.conf"] = File(source_file="./dotfiles/pacman.conf") # Declare a whole directory decman.directories["/home/user/.config/nvim"] = Directory(source_directory="./dotfiles/nvim", owner="user") - # Ensure that a systemd unit is enabled. -decman.enabled_systemd_units += ["NetworkManager.service"] +decman.systemd.enabled_units |= {"NetworkManager.service"} ``` To better organize your system configuration, you can create modules. @@ -54,17 +49,20 @@ To better organize your system configuration, you can create modules. `/home/user/config/syncthing.py`: ```py -from decman import Module, prg +from decman import Module, Store, prg +from decman.plugins import pacman, systemd # Your custom modules are child classes of the module class. # They can override methods of the Module-class. class Syncthing(Module): def __init__(self): - super().__init__(name="syncthing", enabled=True, version="1") + super().__init__(name="syncthing") - def on_enable(self): - # Run code when a module is first enabled + # Run code when a module is first enabled + def on_enable(self, store: Store): + # Note: store is a key-value store that will persist between decman runs. + # You can use it to store your own data as well. Here it is not needed. # Call a program prg(["ufw", "allow", "syncthing"]) @@ -72,17 +70,26 @@ class Syncthing(Module): # Run any python code print("Remember to setup syncthing with the browser UI!") - def on_disable(self): + # On disable is a special method, it will get executed when this module no longer exists. + # Therefore it must be static, take no parameters, and inline all imports. + # Imported modules should be available everywhere. + @staticmethod + def on_disable(): # Run code when a module is disabled - prg(["ufw", "deny", "syncthing"]) + import decman + decman.prg(["ufw", "deny", "syncthing"]) - def pacman_packages(self) -> list[str]: - # Packages part of this module - return ["syncthing"] + # Decorate a function with @pacman.packages to indicate it returns a set of pacman packages + # to be installed + @pacman.packages + def pacman_packages(self) -> set[str]: + return {"syncthing"} - def systemd_user_units(self) -> dict[str, list[str]]: + # Systemd units are declared in a similiar fashion + @systemd.user_units + def systemd_user_units(self) -> dict[str, set[str]]: # Systemd user units part of this module - return {"user": ["syncthing.service"]} + return {"user": {"syncthing.service"}} ``` Then import your module in your main source file. @@ -93,12 +100,10 @@ Then import your module in your main source file. import decman from syncthing import Syncthing -# NOTE! Removing a enabled module from decman.module means that on_disable will not run. -# Instead disable the module. -decman.modules += [Syncthing()] +decman.modules |= {Syncthing()} ``` -Then run decman. Note that terminal colors cannot be disabled for decman. +Then run decman. > [!WARNING] > Decman runs as root. This means that your `source.py` will be executed as root as well. @@ -138,52 +143,62 @@ Remember to add decman to its own configuration. ```py import decman -decman.aur_packages += ["decman"] +decman.aur.packages |= {"decman"} ``` ## What decman manages? -### Packages +Decman has built-in functionality for managing files and directories. Additionally decman manages system state using plugins. By default decman ships with the following plugins: -Decman can be used to install pacman packages. Decman will install all packages defined in the source and **remove** all explicitly installed packages not defined in the source. You don't need to list dependencies as those will be handeled by pacman. You can set packages to be ignored by decman, so that it won't install them nor remove them. +- [pacman](/docs/pacman.md) +- [systemd](/docs/systemd.md) +- [aur](/docs/aur.md) +- [flatpak](/docs/flatpak.md) -Please keep in mind that decman doesn't play well with package groups, since all packages part of that group will be installed explicitly. After the initial run decman will now try to remove those packages since it only knows that the group itself should be explicitly installed. Instead of package groups, use meta packages. +Plugins can be disabled if desired and flatpaks are disabled by default. + +Please read the documentation to understand the functionality of those plugins in detail. Here are quick examples to show what the default plugins are capable of. + +### Pacman + +Pacman plugins manages native packages. Native packages can be installed from the pacman repositories. This plugin will never touch AUR packages. ```py -# Include both foreign and pacman packages here. -decman.ignored_packages += ["yay", "opendoas"] +import decman + +# Packages that decman ensures are installed to the system +decman.pacman.packages |= {"firefox", "reflector"} + +# These packages will never get installed or removed by decman. +decman.pacman.ignored_packages |= {"opendoas"} ``` -### Foreign packages +### AUR > [!NOTE] -> Building of foreign packages is not the primary function of decman. There are some issues that I may or may not fix. -> If you can't build a package using decman, consider adding it to `ignored_packages` and building it yourself. +> Building of AUR or custom packages is not the primary function of decman. There are some issues that I may or may not fix. +> If you can't build a package using decman, consider adding it to `decman.aur.ignored_packages` and building it yourself. -Decman can install AUR packages as well as user defined packages. Foreign packages are AUR and user packages combined. - -Here is an example of a user package. Managing user packages is somewhat cumbersome as you have to declare their versions, dependencies and make dependencies manually. However, you probably won't install many user packages anyway. +AUR plugins manages foreign packages. Foreign packages are installed from the AUR or other sources. This plugin will never touch native packages. ```py -# Note, decman now has a aur package, I recommend using that instead. -# Also, this example may be out of date -decman.user_packages.append( - decman.UserPackage( - pkgname="decman-git", - provides=["decman"], - version="0.4.1", - dependencies=["python", "python-requests", "devtools", "pacman", "systemd", "git"], - make_dependencies=[ - "python-setuptools", "python-build", "python-installer", "python-wheel" - ], - git_url="https://github.com/kiviktnm/decman-pkgbuild.git", - )) +import decman +from decman.plugins.aur import CustomPackage + +# AUR Packages that decman ensures are installed to the system +decman.aur.packages |= {"android-studio", "fnm-bin"} + +# These foreign packages will never get installed or removed by decman. +decman.aur.ignored_packages |= {"yay"} + +# You can add packages from custom sources. +# Just add a package name and repository / directory containing a PKGBUILD +decman.aur.custom_packages |= { + CustomPackage("decman", git_url="https://github.com/kiviktnm/decman-pkgbuild.git"), + CustomPackage("my-own-package", pkgbuild_directory="/path/to/directory/"), +} ``` -Building of foreign packages happens in a chroot. This creates some overhead, but ensures clean builds. By default the chroot is created to `/tmp/decman/build`. I recommend to use a tmpfs for the `/tmp/` directory to speed up builds. Also make sure that the tmpfs-partition is large enough. I recommend at least 6 GB. - -Build packages are stored in a cache `/var/cache/decman`. By default decman keeps 3 most recent versions of all packages. - ### Systemd units > [!NOTE] @@ -191,142 +206,72 @@ Build packages are stored in a cache `/var/cache/decman`. By default decman keep Decman can enable systemd services, system wide or for a specific user. Decman will enable all units defined in the source, and disable them when they are removed from the source. If a unit is not defined in the source, decman will not touch it. -### Files - -Decman functions as a dotfile manager. It will install the defined files and directories to their destinations. You can set file permissions, owners as well as define variables that will be substituted in the installed files. Decman keeps track of all files it creates and when a file is no longer present in your source, it will be also removed from its destination. This helps with keeping your system clean. However, decman won't ever remove directories as they might contain files that weren't created by decman. - -### Commands - -Modules have 4 methods: `on_enable`, `on_disable`, `after_update` and `after_version_change`. These will be executed if the module is enabled, the module is disabled, after every update and after the version of the module has changed. You can use the helper functions `prg` and `sh` to run programs. These programs could for example be used to update packages managed by another package manager. - -## Order of operations - -When decman runs, it does the following things in this order. - -1. Disable systemd units that are no longer in the source. -1. Create and update files. -1. Remove files no longer in the source. -1. Remove packages not defined in the source. -1. Upgrade packages. - - To upgrade foreign devel packages (eg. `*-git`) use the `--upgrade-devel` CLI option. -1. Install new packages. -1. Enable new systemd units. -1. Run commands: - 1. `on_enable` - 1. `after_version_change` - 1. `on_disable` - 1. `after_update` - -Operations may be skipped with command line options. - -## Decman with other configuration languages - -Since decman uses Python files to declare your system, you can easily parse another configuration language in your Python source instead of declaring things directly in Python. - -
-Here is a basic example using TOML. - -To use TOML with Python, you need the `toml`-package. To install it in Arch Linux, install the `python-toml`-package. - -This example doesn't allow you to use decman's modules or set decman's settings using TOML. You'll have to set them using Python. With this example you can use both TOML and Python if you want. -It would be possible to add support for TOML decman modules, but I don't think it would be worth the effort. - -Write this in your decman source. - ```py -import toml import decman -TOML_CONFIG_FILE="/your/file/here.toml" +# System-wide units +decman.systemd.enabled_units |= {"NetworkManager.service"} -# These functions convert TOML tables to decman Files/Directories/UserPackages. - -def toml_to_decman_file(toml_dict) -> decman.File: - return decman.File(content=toml_dict.get("content"), - source_file=toml_dict.get("source_file"), - bin_file=toml_dict.get("bin_file", False), - encoding=toml_dict.get("encoding", "utf-8"), - owner=toml_dict.get("owner"), - group=toml_dict.get("group"), - permissions=toml_dict.get("permissions", 0o644)) - - -def toml_to_decman_directory(toml_dict) -> decman.Directory: - return decman.Directory(source_directory=toml_dict["source_directory"], - bin_files=toml_dict.get("bin_files", False), - encoding=toml_dict.get("encoding", "utf-8"), - owner=toml_dict.get("owner"), - group=toml_dict.get("group"), - permissions=toml_dict.get("permissions", 0o644)) - -def toml_to_decman_user_package(toml_dict) -> decman.UserPackage: - return decman.UserPackage(pkgname=toml_dict["pkgname"], - version=toml_dict["version"], - dependencies=toml_dict["dependencies"], - git_url=toml_dict["git_url"], - pkgbase=toml_dict.get("pkgbase"), - provides=toml_dict.get("provides"), - make_dependencies=toml_dict.get("make_dependencies"), - check_dependencies=toml_dict.get("check_dependencies")) - - -# Parse TOML into a Python dictionary -toml_source = toml.load(TOML_CONFIG_FILE) - -# Set decman variables using the parsed dictionary. - -decman.packages = toml_source.get("packages", []) -decman.aur_packages = toml_source.get("aur_packages", []) -decman.ignored_packages = toml_source.get("ignored_packages", []) -decman.enabled_systemd_units = toml_source.get("enabled_systemd_units", []) -decman.enabled_systemd_user_units = toml_source.get("enabled_systemd_user_units", {}) - -for filename, toml_file_dec in toml_source.get("files", {}).items(): - decman.files[filename] = toml_to_decman_file(toml_file_dec) - -for dirname, toml_dir_dec in toml_source.get("directories", {}).items(): - decman.directories[dirname] = toml_to_decman_directory(toml_dir_dec) - -for toml_user_package_dec in toml_source.get("user_packages", []): - decman.user_packages.append(toml_to_decman_user_package(toml_user_package_dec)) +# User specific units +decman.systemd.enabled_user_units.setdefault("user", {}).update({"syncthing.service"}) ``` -Then you can use TOML configuration like this: +### Flatpak -```toml -packages = ["python", "git", "networkmanager", "ufw", "neovim", "python-toml"] -aur_packages = ["protonvpn"] -enabled_systemd_units = ["NetworkManager.service"] -ignored_packages = ["yay"] -user_packages = [{ - # Note, decman now has a aur package, I recommend using that instead. - # Also, this example may be out of date - pkgname="decman-git", - provides=["decman"], - version="0.4.1", - dependencies=["python", "python-requests", "devtools", "pacman", "systemd", "git"], - make_dependencies=[ - "python-setuptools", "python-build", "python-installer", "python-wheel" - ], - git_url="https://github.com/kiviktnm/decman-pkgbuild.git" -}] +```py +import decman -[files] -'/etc/vconsole.conf' = { content="KEYMAP=us" } -'/etc/pacman.conf' = { source_file="./dotfiles/pacman.conf" } +# Flatpaks that decman ensures are installed to the system +decman.flatpak.packages |= {"org.mozilla.firefox", "org.signal.Signal"} -[directories] -'/home/user/.config/nvim' = { source_directory="./dotfiles/nvim", owner="user" } +# Flatpaks can be installed to specific users only +decman.flatpak.user_packages.setdefault("user", {}).update({"com.valvesoftware.Steam"}) -# To set other file/directory attributes, just add them like this. Note that here I am not using octal notation for permissions. -'/home/user/.config/example' = { source_directory="./dotfiles/example", owner="user", group="user", bin_files=true, encoding="utf-8", permissions=448 } - -[enabled_systemd_user_units] -user = ["syncthing.service"] -user2 = ["example.service", "another.service"] +# These flatpaks will never get installed or removed by decman. +decman.flatpak.ignored_packages |= {"dev.zed.Zed"} ``` -
+## Managing plugins and the order of operations + +The order of operations is managed by setting `decman.execution_order`. This is also the default. + +```py +import decman +decman.execution_order = [ + "files", + "pacman", + "aur", + "systemd", +] +``` + +This variable also manages which plugins are enabled. To enable flatpaks, simply add the plugin to the execution order. + +```py +import decman +decman.execution_order = [ + "files", + "pacman", + "aur", + "flatpak", + "systemd", +] +``` + +Note that `files` is not a plugin, but is defined here anyways. + +Before the core execution order, decman will run hook methods from `Module`s. + +1. `before_update` +1. `on_disable` + +After the plugin execution, decman will run the following hook methods. + +1. `on_enable` +1. `on_change` +1. `atfer_update` + +Operations and hooks may be skipped with command line options. ## Why use decman? @@ -341,14 +286,16 @@ You can consult your config to see what packages are installed and what config f In a modular config, you can also change parts of your system eg. switch shells without it affecting your other setups at all. If you create a module called `Shell` that exposes a function `add_alias`, you can call that function from other modules. Then later if you decide to switch from bash to fish, you can change the internals of your `Shell`-module without modifying your other modules at all. ```py +from decman import Module + +# Look below for an example of a theme module import theme + class Shell(Module): def __init__(self): - super().__init__("shell", enabled=True, version="1") + super().__init__("shell") self._aliases_text = "" - # -- - def add_alias(self, alias: str, cmd: str): self._aliases_text += f"alias {alias}='{cmd}'\n" @@ -358,7 +305,6 @@ class Shell(Module): File(source_file="./files/shell/config.fish", owner="user") } - def file_variables(self) -> dict[str, str]: fvars = { "%aliases%": self._aliases_text, @@ -394,24 +340,34 @@ Using python you can use the same config for different computers and only change ```py import socket +import decman + if socket.gethostname() == "laptop": # add brightness controls to your laptop - decman.packages += ["brightnessctl"] + decman.pacman.packages += ["brightnessctl"] ``` +## Alternatives + +There are some alternatives you may want to consider instead of using decman. + +- [Ansible](https://docs.ansible.com/) +- [aconfmgr](https://github.com/CyberShadow/aconfmgr) +- [NixOS](https://nixos.org/) + ### Why not use NixOS? NixOS is a Linux disto built around the idea of declarative system management, so why create a more limited alternative? -I tried NixOS in the past, but it had some issues that caused me to create decman for Arch Linux instead. In my personal opinion: +I tried NixOS in the past, but it had some issues that caused me to create decman for Arch Linux instead. In my opinion: -- NixOS forces you to do everything the Nix way. Sometimes I just want to develop software without having to use nix tools. -- NixOS is hard, and the documentation (when I last tried it) wasn't that good. Doing more complex stuff was sometimes just very annoying. -- NixOS has unnecessary abstraction with NixOS options. They are great until you have to configure something specific and there is not an option for it. Then you'll have to inline other configuration language within your Nix config. And if some software doesn't have any premade options you'll have to do write the config manually. Then you'll have some software managed with just options and others with normal config files. I prefer to keep everything consistent. +- NixOS forces you to do everything the Nix way. +- NixOS requires learning a new domain specific language. +- NixOS is extreme when it comes to declaration. Sometimes you don't want _everything_ to be managed declaratively. ## License -Copyright (C) 2024 Kivi Kaitaniemi +Copyright (C) 2024-2025 Kivi Kaitaniemi Decman is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8b4416c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,485 @@ +# Decman documentation + +This contains the core documentation for decman. Each plugin has its own documentation. + +- [pacman](/docs/pacman.md) +- [systemd](/docs/systemd.md) +- [aur](/docs/aur.md) +- [flatpak](/docs/flatpak.md) + +## Decman Store + +Decman stores data in the file `/var/lib/decman/store.json`. This file should not be modified manually. However, if encountering bugs with decman, manual modification may be desirable. The file is JSON so editing it should be easy enough. + +Using the store: + +```py +# The store is always given as a parameter to a method call. +# You don't need to create new instances. +store["key"] = value + +# To ensure that a key exists (with a default value if it doesn't) +store.ensure("my_dict", {}) +store["my_dict"]["dict_key"] = 3 +``` + +This store is also available to plugins and modules. The following keys are used by decman: + +- `allow_running_source_without_prompt` +- `source_file` +- `enabled_modules` +- `module_on_disable_scripts` +- `all_files` + +Details about the keys used by each plugin are provided in the plugin’s documentation. + +## Configuring decman + +Decman has a small number of configuration options. They are set in your source file with python. These values are prioritized over command line options. + +Import the config to modify it. + +```py +import decman.config +``` + +Enable debug messages + +```py +decman.config.debug_output = False +``` + +Disable info messages + +```py +decman.config.quiet_output = False +``` + +Set colored output. This setting should not be used. It should be passed as a command line argument or an environment variable instead. + +- Command line argument: `--no-color` +- Environment variables: + - `NO_COLOR`: disables color + - `FORCE_COLOR`: enables color + +```py + decman.config.color_output = True +``` + +Directory for scripts containing Modules' on_disable code + +```py +decman.config.module_on_disable_scripts_dir = "/var/lib/decman/scripts/" +``` + +Cache directory. Plugins like the AUR plugin use this directory as their own cache. + +```py +decman.config.cache_dir = "/var/cache/decman" +``` + +The architecture of the computer's CPU. Currently, this is only used by the AUR plugin, but it may be useful for some other plugins. + +```py +decman.config.arch = "x86_64" +``` + +## Files and directories + +Decman functions as a dotfile manager. It will install the defined files and directories to their destinations. You can set file permissions, owners as well as define variables that will be substituted in the installed files. Decman keeps track of all files it creates and when a file is no longer present in your source, it will be also removed from its destination. This helps with keeping your system clean. However, decman won't remove directories as they might contain files that weren't created by decman. + +Variables can only be defined for files within modules. See the module example for using file variables. + +Files and directories are updated during the `files` execution order step. + +### File + +Declarative file specification describing how a file should be materialized at a target path. + +```py +from decman import File +import decman + +# To declare a file, add it's target path and create a File object +decman.files["/home/me/.config/nvim/init.lua"] = File( + source_file="./dotfiles/nvim/init.lua", + bin_file=False, + encoding="utf-8", + owner="me", + group="users", + permissions=0o700, +) +``` + +Exactly one of `source_file` or `content` must be provided. + +The file can be created by copying an existing source file or by writing provided content. For text files, optional variable substitution is applied at copy time. Binary files are copied or written verbatim and never undergo substitution. + +Ownership, permissions, and parent directories are enforced on creation. Missing parent directories are created recursively and assigned the same ownership as the file when specified. + +#### Parameters: + +- `source_file: str`: Path to an existing file to copy from. Mutually exclusive with `content`. +- `content: str`: In-memory file contents to write. Mutually exclusive with `source_file`. +- `bin_file: bool`: If `True`, treat the file as binary. Disables variable substitution and writes bytes verbatim. +- `encoding: str`: Text encoding used when reading or writing non-binary files. +- `owner: str`: System user name to own the file and created parent directories. +- `group: str`: System group name to own the file and created parent directories. By default the `owner`'s group is used. +- `permissions: int`: File mode applied to the target file (e.g. `0o644`). + +Note: Variable substitution is a simple string replacement where each key in variables is replaced by its corresponding value. No escaping or templating semantics are applied. + +### Directory + +Declarative specification for copying the contents of a source directory into a target directory. + +```py +from decman import Directory +import decman + +# To declare a directory, add it's target path and create a Directory object +decman.directories["/home/me/.config/nvim"] = File( + source_directory="./dotfiles/nvim", + bin_files=False, + encoding="utf-8", + owner="me", + group="users", + permissions=0o600, +) +``` + +For text files in the directory, optional variable substitution is applied at copy time. Binary files are copied or written verbatim and never undergo substitution. + +Ownership, permissions, and parent directories are enforced on creation. Missing parent directories are created recursively and assigned the same ownership as the target directory when specified. + +#### Parameters: + +- `source_directory: str`: Path to the directory whose contents will be mirrored into the target. +- `bin_files: bool`: If `True`, treat all files as binary. Disables variable substitution and copies bytes verbatim. +- `encoding: str`: Text encoding used when reading or writing non-binary files. +- `owner: str`: System user name to own the files and directories. +- `group: str`: System group name to own the files and directories. By default the `owner`'s group is used. +- `permissions: int`: File mode applied to the created or updated files (e.g. `0o644`). + +## Modules + +Modules allow grouping related functionality together. + +A **Module** is the primary unit for grouping related files, directories, packages, and executable logic in decman. Create your own modules by subclassing `Module`. Then override the methods documented below. + +Each module is uniquely identified by its `name`. + +### Basic Structure + +```python +from decman import Module + +class MyModule(Module): + def __init__(self) -> None: + super().__init__("my-module") +``` + +### Lifecycle Hooks + +Modules can hook into specific phases of a decman run by overriding methods. + +#### Before update + +Executed **before** any updates are applied. + +```python +def before_update(self, store): + ... +``` + +#### After update + +Executed **after** all updates are applied. + +```python +def after_update(self, store): + ... +``` + +#### On enable + +Executed **once**, when the module transitions from disabled to enabled. + +```python +def on_enable(self, store): + ... +``` + +#### On change + +Executed when the module’s **content changes** between runs. Module's content is deemed changed if: + +- If files or directories defined within the module have their content updated +- A plugin marks the module as changed + - For example, the pacman plugin marks a module as changed if the packages defined within that module change + +```python +def on_change(self, store): + ... +``` + +#### On disable + +Executed when the module is disabled. A module is disabled when it's removed from the modules set. + +**Must be declared as `@staticmethod`.** +Validated at class creation time. + +```python +@staticmethod +def on_disable(): + import os + os.remove("/some/file") +``` + +**Important constraints:** + +- Code is copied verbatim into a temporary file +- No external variables +- Imports must be inside the function +- Signature must be exactly `on_disable()` + +### Filesystem Declarations + +Modules can declaratively define files and directories to be installed. + +#### Files + +Returns a mapping of target paths to `File` objects. + +```python +def files(self) -> dict[str, File]: + return { + "/etc/myapp/config.conf": File(source_file="./dotfiles/config.conf"), + } +``` + +#### Directories + +Returns a mapping of target paths to `Directory` objects. + +```python +def directories(self) -> dict[str, Directory]: + return { + "/var/lib/myapp": Directory(source_directory="./dotfiles/myapp"), + } +``` + +#### File Variable Substitution + +Defines variables that are substituted inside **text files** belonging to the module. + +```python +def file_variables(self) -> dict[str, str]: + return { + "HOSTNAME": "example.com", + "PORT": "8080", + } +``` + +### Extending with plugins + +To include plugin functionality inside a module, create a new method and mark it with the plugin's decorator. During the execution of decman, the plugin will call the marked method and use its result. Here is an example with the pacman plugin. + +```py +from decman.plugins import pacman + +@pacman.packages +def pacman_packages(self) -> set[str]: + return {"wget", "zip"} +``` + +## Plugins + +Plugins are used to manage a single aspect of a system declaratively. Decman ships with some default plugins useful with Arch Linux but it is possible to add custom plugins. + +To manage the execution order of plugins set `decman.execution_order`. + +```py +import decman +decman.execution_order = [ + "files", # not a plugin but included here + "pacman", + "aur", + "flatpak", + "systemd", +] +``` + +Available plugins are found in `decman.plugins`. You can add your own plugins to that dictionary. + +```py +import decman +my_plugin = MyPlugin() +decman.plugins["my-plugin"] = my_plugin + +# Remember to include your plugin in the execution order +decman.execution_order += ["my-plugin"] +``` + +For conveniance, decman provides some plugins with quick access. + +```py +import decman + +decman.pacman = decman.plugins.get("pacman") +decman.aur = decman.plugins.get("aur") +decman.systemd = decman.plugins.get("systemd") +decman.flatpak = decman.plugins.get("flatpak") +``` + +### Creating custom plugins + +Create your own modules by subclassing `Plugin`. Then override the methods documented below. + +#### Basic Structure + +```python +from decman.plugins import Plugin + +class MyPlugin(Plugin): + # Plugins should be singletons. + # This name should be the same as the key used in decman.plugins dict. + NAME = "my-plugin" +``` + +#### Availability check + +Checks if this plugin can be enabled. For example, this could check if a required command is available. Returns `True` if this plugin can be enabled. + +This is not useful if the plugin is directly added to `decman.plugins`. However, if using the Python package method for installing plugins, this check is used before adding the plugin automatically to `decman.plugins`. + +```py +def available(self) -> bool: + return True +``` + +#### Process modules + +This method gathers state information from modules. If the module's state has changed since the last time running this plugin, set the module to changed. For example, the pacman plugin uses this method to find which modules have methods marked with `@pacman.packages` and calls them. + +This method only gathers information. It doesn't apply it. + +```py +from decman import Store, Module + +def process_modules(self, store: Store, modules: set[Module]): + ... + + # Toy example for setting modules as changed + for module in modules: + module._changed = True +``` + +#### Apply + +Ensures that the state managed by this plugin is present on the system. + +`dry_run` indicates that changes should only be printed, not yet applied. + +`params` is a list of strings passed as command line arguments. For example running `decman --params abc def` would cause `params = ["abc", "def"]`. + +This method must not raise exceptions. Instead it should return `False` to indicate a +failure. The method should handle it's exceptions and print them to the user. + +```py +from decman import Store + +def apply( + self, store: Store, dry_run: bool = False, params: list[str] | None = None +) -> bool: + return True +``` + +### Installing plugins as Python packages + +You can have decman automatically detect plugins by creating a Python package with entry points in `decman.plugins`. Decman also does this with its own plugins. + +In `pyproject.toml` set: + +```toml +[project.entry-points."decman.plugins"] +systemd = "decman.plugins.systemd:Systemd" +pacman = "decman.plugins.pacman:Pacman" +aur = "decman.plugins.aur:AUR" +``` + +## Useful utilities + +Decman ships with some useful utilites that can help with modules and plugins. + +### Run commands + +Runs a command and returns its output. + +```py +import decman +decman.prg( + ["nvim", "--headless", "+Lazy! sync", "+qa"], + user = "user", + env_overrides = {"EXAMPLE": "value"}, + mimic_login = True, + pty = True, + check = True, +) +``` + +#### Parameters + +- `cmd: list[str]`: Command to execute. +- `user: str`: User name to run the command as. If set, the command is executed after dropping privileges to this user. +- `env_overrides dict[str, str]`: Environment variables to override or add for the command execution. These values are merged on top of the current process environment. +- `mimic_login: bool`: If mimic_login is True, will set the following environment variables according to the given user's passwd file details. This only happens when user is set. + - `HOME` + - `USER` + - `LOGNAME` + - `SHELL` +- `pty: bool`: If `True`, run the command inside a pseudo-terminal (PTY). This enables interactive behavior and terminal-dependent programs. If `False`, run the command without a PTY using standard subprocess execution. +- `check`: If `True`, raise `decman.core.error.CommandFailedError` when the command exits with a non-zero status. If `False`, print a warning when encountering a non-zero exit code. + +### Run a command in a shell + +Runs a command in a shell and returns its output. Almost same as `decman.prg` but takes a string argument instead of a list and for example shell redirects are allowed. + +```py +import decman +decman.sh( + "echo $EXAMPLE | less", + user = "user", + env_overrides = {"EXAMPLE": "value"}, + mimic_login = True, + pty = True, + check = True, +) +``` + +#### Parameters + +- `sh_cmd: str`: Shell command to execute. +- `user: str`: User name to run the command as. If set, the command is executed after dropping privileges to this user. +- `env_overrides dict[str, str]`: Environment variables to override or add for the command execution. These values are merged on top of the current process environment. +- `mimic_login: bool`: If mimic_login is True, will set the following environment variables according to the given user's passwd file details. This only happens when user is set. + - `HOME` + - `USER` + - `LOGNAME` + - `SHELL` +- `pty: bool`: If `True`, run the command inside a pseudo-terminal (PTY). This enables interactive behavior and terminal-dependent programs. If `False`, run the command without a PTY using standard subprocess execution. +- `check`: If `True`, raise `decman.core.error.CommandFailedError` when the command exits with a non-zero status. If `False`, print a warning when encountering a non-zero exit code. + +### Errors + +When your source needs to raise an error, decman provides `SourceError`s. These are the errors that should be raised when decman runs your `source.py` file. + +```py +import decman +raise decman.SourceError("boom") +``` + +#### Decman Core + +Additionally, you can import the modules used by decman. They should be relatively stable and not change too much between decman versions. The module `decman.core.output` is probably the most relevant one, as it provides methods for printing output that decman uses. diff --git a/docs/aur.md b/docs/aur.md new file mode 100644 index 0000000..d622184 --- /dev/null +++ b/docs/aur.md @@ -0,0 +1,29 @@ +# AUR + +> [!NOTE] +> Building of foreign packages is not the primary function of decman. There are some issues that I may or may not fix. +> If you can't build a package using decman, consider adding it to `ignored_packages` and building it yourself. + +Decman can install AUR packages as well as user defined packages. Foreign packages are AUR and user packages combined. + +Here is an example of a user package. Managing user packages is somewhat cumbersome as you have to declare their versions, dependencies and make dependencies manually. However, you probably won't install many user packages anyway. + +```py +# Note, decman now has a aur package, I recommend using that instead. +# Also, this example may be out of date +decman.user_packages.append( + decman.UserPackage( + pkgname="decman-git", + provides=["decman"], + version="0.4.1", + dependencies=["python", "python-requests", "devtools", "pacman", "systemd", "git"], + make_dependencies=[ + "python-setuptools", "python-build", "python-installer", "python-wheel" + ], + git_url="https://github.com/kiviktnm/decman-pkgbuild.git", + )) +``` + +Building of foreign packages happens in a chroot. This creates some overhead, but ensures clean builds. By default the chroot is created to `/tmp/decman/build`. I recommend to use a tmpfs for the `/tmp/` directory to speed up builds. Also make sure that the tmpfs-partition is large enough. I recommend at least 6 GB. + +Build packages are stored in a cache `/var/cache/decman`. By default decman keeps 3 most recent versions of all packages. diff --git a/docs/flatpak.md b/docs/flatpak.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/migrate-to-v1.md b/docs/migrate-to-v1.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/pacman.md b/docs/pacman.md new file mode 100644 index 0000000..25b3b28 --- /dev/null +++ b/docs/pacman.md @@ -0,0 +1,15 @@ +# Pacman + +Decman can be used to install pacman packages. Decman will install all packages defined in the source and **remove** all explicitly installed packages not defined in the source. You don't need to list dependencies as those will be handeled by pacman. You can set packages to be ignored by decman, so that it won't install them nor remove them. + +Please keep in mind that decman doesn't play well with package groups, since all packages part of that group will be installed explicitly. After the initial run decman will now try to remove those packages since it only knows that the group itself should be explicitly installed. Instead of package groups, use meta packages. + +```py +# Include only pacman packages found in the pacman repositories in here. +decman.pacman.ignored_packages |= {"opendoas"} + +# Decman will highlight text from pacman commands according to these keywords, +# if the feauture is enabled. These are the defaults. +decman.pacman.print_highlights = True +decman.pacman.keywords = {"pacsave", "pacnew" } +``` diff --git a/docs/systemd.md b/docs/systemd.md new file mode 100644 index 0000000..e69de29 diff --git a/src/decman/__init__.py b/src/decman/__init__.py index ccc1385..98ad204 100644 --- a/src/decman/__init__.py +++ b/src/decman/__init__.py @@ -8,6 +8,7 @@ import decman.core.output as output from decman.core.error import SourceError from decman.core.fs import Directory, File from decman.core.module import Module +from decman.core.store import Store from decman.plugins import Plugin, available_plugins # Plugin types @@ -21,6 +22,7 @@ __all__ = [ "File", "Directory", "Module", + "Store", "Plugin", "prg", "sh", @@ -37,7 +39,6 @@ execution_order: list[str] = [ "files", "pacman", "aur", - "flatpak", "systemd", ] diff --git a/src/decman/app.py b/src/decman/app.py index ff9479a..94be6f1 100644 --- a/src/decman/app.py +++ b/src/decman/app.py @@ -45,12 +45,25 @@ def main(): default=False, help="don't run hook methods for modules", ) + parser.add_argument( + "--no-color", + action="store_true", + default=False, + help="don't print messages with color", + ) parser.add_argument( "--params", nargs="*", type=str, help="additional parameters passed to plugins" ) args = parser.parse_args() + conf.debug_output = args.debug + + if args.no_color: + conf.color_output = False + else: + conf.color_output = output.has_ansi_support() + if os.getuid() != 0: output.print_error("Not running as root. Please run decman as root.") sys.exit(1) diff --git a/src/decman/core/error.py b/src/decman/core/error.py index 4246786..9b77dca 100644 --- a/src/decman/core/error.py +++ b/src/decman/core/error.py @@ -3,9 +3,6 @@ class SourceError(Exception): Error raised manually from the user's source. """ - def __init__(self, message): - super().__init__(message) - class FSInstallationFailedError(Exception): """ diff --git a/src/decman/plugins/aur/__init__.py b/src/decman/plugins/aur/__init__.py index d528011..a682da6 100644 --- a/src/decman/plugins/aur/__init__.py +++ b/src/decman/plugins/aur/__init__.py @@ -98,18 +98,19 @@ class AUR(plugins.Plugin): custom_packages = ( plugins.run_method_with_attribute(mod, "__custom__packages__") or set() ) + custom_package_strs = set(map(str, custom_packages)) if store["aur_packages_for_module"][mod.name] != aur_packages: mod._changed = True - if store["custom_packages_for_module"][mod.name] != custom_packages: + if store["custom_packages_for_module"][mod.name] != custom_package_strs: mod._changed = True self.packages |= aur_packages self.custom_packages |= custom_packages store["aur_packages_for_module"][mod.name] = aur_packages - store["custom_packages_for_module"][mod.name] = custom_packages + store["custom_packages_for_module"][mod.name] = custom_package_strs def apply( self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None diff --git a/src/decman/plugins/aur/package.py b/src/decman/plugins/aur/package.py index dae9634..79c4176 100644 --- a/src/decman/plugins/aur/package.py +++ b/src/decman/plugins/aur/package.py @@ -213,7 +213,9 @@ class CustomPackage: Path to the directory containing the PKGBUILD. """ - def __init__(self, pkgname: str, git_url: str | None, pkgbuild_directory: str | None) -> None: + def __init__( + self, pkgname: str, git_url: str | None = None, pkgbuild_directory: str | None = None + ) -> None: if git_url is None and pkgbuild_directory is None: raise ValueError("Both git_url and pkgbuild_directory cannot be None.") diff --git a/src/decman/plugins/systemd.py b/src/decman/plugins/systemd.py index 9ab0f95..15c6fa5 100644 --- a/src/decman/plugins/systemd.py +++ b/src/decman/plugins/systemd.py @@ -74,8 +74,8 @@ class Systemd(plugins.Plugin): NAME = "systemd" def __init__(self) -> None: - self.enabled_systemd_units: set[str] = set() - self.enabled_systemd_user_units: dict[str, set[str]] = {} + self.enabled_units: set[str] = set() + self.enabled_user_units: dict[str, set[str]] = {} self.commands = SystemdCommands() def available(self) -> bool: @@ -100,9 +100,9 @@ class Systemd(plugins.Plugin): if store["systemd_user_units_for_module"][mod.name] != user_units: mod._changed = True - self.enabled_systemd_units |= units + self.enabled_units |= units for user, u_units in user_units.items(): - self.enabled_systemd_user_units.setdefault(user, set()).update(u_units) + self.enabled_user_units.setdefault(user, set()).update(u_units) store["systemd_units_for_module"][mod.name] = units store["systemd_user_units_for_module"][mod.name] = user_units @@ -118,15 +118,15 @@ class Systemd(plugins.Plugin): user_units_to_enable: dict[str, set[str]] = {} user_units_to_disable: dict[str, set[str]] = {} - for unit in self.enabled_systemd_units: + for unit in self.enabled_units: if unit not in store["systemd_units"]: units_to_enable.add(unit) for unit in store["systemd_units"]: - if unit not in self.enabled_systemd_units: + if unit not in self.enabled_units: units_to_disable.add(unit) - for user, units in self.enabled_systemd_user_units.items(): + for user, units in self.enabled_user_units.items(): store["systemd_user_units"].setdefault(user, set()) user_units_to_enable.setdefault(user, set()) @@ -135,11 +135,11 @@ class Systemd(plugins.Plugin): user_units_to_enable[user].add(unit) for user, units in store["systemd_user_units"].items(): - self.enabled_systemd_user_units.setdefault(user, set()) + self.enabled_user_units.setdefault(user, set()) user_units_to_disable.setdefault(user, set()) for unit in units: - if unit not in self.enabled_systemd_user_units[user]: + if unit not in self.enabled_user_units[user]: user_units_to_disable[user].add(unit) output.print_info("Reloading systemd daemon.") diff --git a/tests/test_decman_plugins_aur.py b/tests/test_decman_plugins_aur.py index fc2e6a9..117693b 100644 --- a/tests/test_decman_plugins_aur.py +++ b/tests/test_decman_plugins_aur.py @@ -66,8 +66,8 @@ def test_process_modules_collects_aur_and_custom_packages_and_marks_changed( # stored per-module assert store["aur_packages_for_module"]["mod1"] == {"aur1", "aur2"} assert store["aur_packages_for_module"]["mod2"] == {"aur3"} - assert store["custom_packages_for_module"]["mod1"] == {cp1} - assert store["custom_packages_for_module"]["mod2"] == {cp2} + assert store["custom_packages_for_module"]["mod1"] == {str(cp1)} + assert store["custom_packages_for_module"]["mod2"] == {str(cp2)} # first run: modules marked changed assert mod1._changed is True diff --git a/tests/test_decman_plugins_systemd.py b/tests/test_decman_plugins_systemd.py index 285f010..8464e24 100644 --- a/tests/test_decman_plugins_systemd.py +++ b/tests/test_decman_plugins_systemd.py @@ -81,8 +81,8 @@ def test_process_modules_marks_changed_and_updates_store(monkeypatch, store, sys assert m2._changed is False # enabled units aggregated - assert systemd.enabled_systemd_units == {"a.service"} - assert systemd.enabled_systemd_user_units == {"alice": {"u1.service"}} + assert systemd.enabled_units == {"a.service"} + assert systemd.enabled_user_units == {"alice": {"u1.service"}} # store updated per module assert store["systemd_units_for_module"]["mod1"] == {"a.service"} @@ -121,8 +121,8 @@ def test_apply_enables_and_disables_units_and_user_units(store): s = systemd_mod.Systemd() # Current enabled according to modules - s.enabled_systemd_units = {"new.service"} - s.enabled_systemd_user_units = {"alice": {"newuser.service"}} + s.enabled_units = {"new.service"} + s.enabled_user_units = {"alice": {"newuser.service"}} # Store says we had an old unit enabled before store["systemd_units"] = {"old.service"} @@ -186,8 +186,8 @@ def test_apply_enables_and_disables_units_and_user_units(store): def test_apply_dry_run_does_not_mutate_store_or_call_commands(store): s = systemd_mod.Systemd() - s.enabled_systemd_units = {"new.service"} - s.enabled_systemd_user_units = {"alice": {"newuser.service"}} + s.enabled_units = {"new.service"} + s.enabled_user_units = {"alice": {"newuser.service"}} store["systemd_units"] = {"old.service"} store["systemd_user_units"] = {"alice": {"olduser.service"}}