Introduction

Comtrya is a permissively licensed Open Source tool that is built 100% in Rust. It allows you, as the user, to provision and configure your systems through the use of simple configuration files using the YAML or TOML formats.
The goals of comtrya are as follows:
- Run on any operating system
- Provide a simple YAML/TOML interface to, potentially, complex tasks
Comtrya's source code is available on GitHub.
Comparison to alternatives
Ansible
Ansible is a great tool task runner, but comes with a lot of modules that aren't really necessary for localhost provisioning and can be cumbersome to run individual tasks within a playbook.
SaltStack
SaltStack has been a favourite of mine (@rawkode) for many years, and while it's event system is a game changer for working with many devices - it's inability to display progress of large state runs makes it cumbersome to use for localhost provisioning.
Supported Systems
The following operating systems are officially supported by Comtrya:
- FreeBSD
- Linux
- macOS
- NetBSD
- Windows
If your operating system is not listed, file an issue on the projects GitHub and the team will see what needs to be done to add support.
Installation
There are several ways to install Comtrya on a system: cargo, shell script, provided binaries, or package managers. Comtrya can also be built from source.
Cargo installation
If your system has the Rust programming language tools such as cargo installed, cargo can be used to fetch the source and build a binary on your machine, placing it in location accessible by your path environment variable. First, ensure that the Rust programming language tool is installed by running the following:
cargo --version
You should see the version of cargo installed on your system printed out. If you get an error or nothing, please get the Rust tooling from the Rust Website if you want to build from source.
Once you have cargo and rustc on your system, you can fetch the sources and build with the following command:
cargo install comtrya
Shell Script
A script downloads a pre-compiled comtrya binary from the releases and installs it. It verifies the binary's SHA-256 checksum where the release has one (releases since mid-2025).
On Linux (x86_64, aarch64) and macOS:
curl -fsSL https://comtrya.dev/install.sh | sh
It installs to /usr/local/bin and asks for sudo if that isn't writable. To pin a version or install somewhere else:
curl -fsSL https://comtrya.dev/install.sh | VERSION=v0.11.0 INSTALL_DIR="$HOME/.local/bin" sh
On Windows, in PowerShell:
irm https://comtrya.dev/install.ps1 | iex
It installs comtrya.exe to %USERPROFILE%\.comtrya and adds that folder to your user PATH. $env:VERSION and $env:INSTALL_DIR work as above.
The scripts live in the repository as docs/src/install.sh and docs/src/install.ps1, so you can read them before running them.
Precompiled-binaries
Pre-compiled binaries are also included on our GitHub repository under our releases.
Package managers
Some package managers may provide comtrya. Check with yours. At the time of writing this, comtrya is available in the Arch User Repository (AUR), Homebrew and Ravenports. If you are interested in packaging comtrya for another ports or packaging system, feel free to reach out to the team by opening an issue on our GitHub for support.
Building from source
Building from source should be a straight forward task for anyone familiar with the Rust toolchian. It is recommended that you read through the cargo book and get familiar with it. Once you are, building is a matter of simply cloning our repository and compiling it. However, it is important to note that you may need to ensure you have the development libraries for openssl installed on your system. Check with your operating system and package manager what these packages are as they can often vary in naming between different systems.
CLI
Basic usage on local manifests
Comtrya works by running a manifest or set of manifests. Following are examples of running comtrya against manifests that are on the local machine:
# Run all manifests within your current directory
comtrya apply
# Run all manifests within your current directory and a specified configuration file
comtrya -d ./ -c /path/to/Comtrya.yaml apply
# --manifests, or -m, will run a subset of your manifests
comtrya -d ./ apply -m one,two,three
# Run all manifests within a specified directory
comtrya -d ./manifests apply
The config file is Comtrya.yaml, Comtrya.yml or Comtrya.toml, looked for in that order in the current directory, then in your config directory (~/.config on Linux, ~/Library/Application Support on macOS). With more than one in the same directory, comtrya uses the first and warns about the others. -c takes any of them; a .toml file is read as TOML, anything else as YAML.
# Comtrya.toml
manifest_paths = ["manifests"]
[variables]
ship = "Daedalus"
Without -d, the manifests are at manifest_paths from Comtrya.yaml, or next to Comtrya.yaml when it has none. Relative manifest_paths are relative to the directory of Comtrya.yaml, not to the current directory. A path given with -d is relative to the current directory.
Please refer to the commands section for more information about the usage of apply.
Basic usage on remote manifests
Comtrya also has the ability to run remote manifests, normally hosted in a git repository on github.
# Manifests in a Git repository
comtrya -d https://github.com/rawkode/rawkode apply
# Manifests in a Git repository with a branch and path
comtrya -d https://github.com/rawkode/rawkode#main:dotfiles apply
# Manifests in a Git repository with a branch and path and a subset selector
comtrya -d https://github.com/rawkode/rawkode#main:dotfiles apply -m dev.git
The part after # is <branch>:<path>, and either can be left out, e.g. #main or #:dotfiles. Comtrya clones each repository and branch into its cache, and updates the clone on every run so that you always get the current manifests of the branch (or of the remote's default branch without #<branch>). Local changes in the clone are discarded. If the update fails, for example when you are offline, Comtrya warns and continues with the clone it already has. comtrya contexts shows where the cache is.
Help menu
Comtrya provides a help menu that can be shown by running the following command in your terminal:
comtrya --help
A tool to simplify reprovisioning a fresh OS. Installs packages and manages dotfiles.
Usage: comtrya [OPTIONS] <COMMAND>
Commands:
apply Apply manifests
status List manifests status (ALPHA)
version Print version information
contexts List available contexts
gen-completions Auto generate completions
help Print this message or the help of the given subcommand(s)
Options:
-d, --manifest-directory <MANIFEST_DIRECTORY>
Directory or git repository of the manifests, instead of `manifest_paths` from the config
-c, --config-path <CONFIG_PATH>
Specify a configuration path (if invalid Comtrya will exit)
--no-color
Disable color printing
-D, --defines <DEFINES>
Set a variable, as `key=value`. Wins over `variables` from the config
-v...
Debug & tracing mode (-v, -vv)
--log-file <PATH>
Also write the log to this file, without colors and with times. It gets what `-v` shows, or more with `-vv`, and is appended to
-h, --help
Print help
-V, --version
Print version
The options can come before or after the command: comtrya -v apply and comtrya apply -v are the same.
Auto generate completions
Shell completions for comtrya can be generated by the desired shell via gen-completions subcommand.
comtrya gen-completions [SHELL]
for bash:
source <(comtrya gen-completions bash)
for fish:
comtrya gen-completions fish | source
Define variables via CLI
Comtrya offers the ability to set variables to use throughout manifests. Variables have normally been defined via the Comtrya config file named Comtrya.yaml at the root directory of the manifests. However, variables can also be defined in the command line interface by using the defines options.
comtrya -v -d ./ --defines foo=bar apply -m sample
In the sample, a variable is created in comtrya with the name foo and is set to the value bar. An action, like the sample below, can utilize this variable.
- action: command.run
command: echo
args:
- "{{ variables.foo }}"
Commands
Comtrya offers several commands for use. They can be listed in the terminal by running comtrya with the help command.
comtrya help
The most frequently used command s the apply command, which will apply the actions of provided manifests to the system.
Available Commands
| Command | Description |
|---|---|
| apply | Apply manifests |
| status | List manifest status |
| version | Print version information |
| contexts | List available contexts |
| gen-completions | Auto generate completions |
| help | Print out help information for using comtrya |
Apply
The apply command executes and runs the manifests. There are a few ways to do this.
The first option is to point comtrya to a directory of manifests and have it execute them all:
comtrya -d ./manifests apply
As shown, this is done with the -d option, which tells comtrya the directory that contains the manifests to be applied.
The second option is to specify specific manifest(s) to be executed:
comtrya -d ./ apply -m one,two,three
The -m option is used to let comtrya know which manifests to apply. Note that the name of the manifest (i.e. one.yaml) is only the name and must not contain any path information or file extension (.yaml). So, /manifests/one is not a valid input. Any manifests are expected to be located in the directory of the manifests you specified.
Without -m, apply runs the manifests listed as default_manifests in Comtrya.yaml, with their dependencies. When there are none, it runs all manifests:
default_manifests:
- dev.tools
- shell
-m still runs the manifests it names instead.
Suppose you have a directory manifests/ that contains the manifests one.yaml and two.yaml. You want to only execute one.yaml. There are three ways to achieve this. You can simply specify the manifest if it's in the current working directory:
cd manifests/
comtrya -d ./ apply -m one
Or you can specify the directory:
comtrya -d ./manifests/one.yaml apply
Alternatively a combination of the two is possible as well:
comtrya -d ./manifests/ apply -m one
Dry run
--dry-run plans every action without changing the system. For file.copy, it shows a diff of what
would change in each file; see Files and Directories.
An action that can't be planned, like a file.copy whose source is missing, fails the dry run
like it fails a real one: its manifest and the manifests that depend on it count as failed, and
comtrya exits with a non-zero status. Unlike a real run, the dry run reports every action of a
manifest that can't be planned, not only the first one.
comtrya -d ./manifests apply --dry-run
Several manifests at once
--jobs N (or -j N) runs up to N manifests at the same time, each as soon as the manifests it
depends on are done. The actions and steps of one manifest still run in order. Steps of
different manifests that change the same file or directory, use the same package manager, or
may ask for a password take turns; a command.run takes turns with every other step, because
comtrya can't know what it changes. The default is 1, one manifest after the other. A dry run
always runs one manifest after the other.
Log lines of manifests that run at once can come between each other; each line names its manifest and action.
comtrya -d ./manifests apply --jobs 4
More output
With -v, apply also shows what each step changes, and the output of programs that command.run and package managers run, line by line while they run. -vv shows even more.
Keeping a log
--log-file <path> writes the log to a file as well, for reading later. The file gets times and no colors, and always as much as -v shows, or more with -vv; the terminal shows what it always does. A run adds to the end of the file.
comtrya --log-file ~/comtrya.log -d ./manifests apply
Stopping a run
Ctrl-C lets the step that is running finish, then stops: nothing starts after it, and comtrya exits with an error. Press Ctrl-C again to quit at once. A program that is running gets the Ctrl-C from the terminal too, and usually stops, which fails its step.
Contexts
The contexts command is useful to see what comtrya knows about your system. This can be environment variables, included variables, information about the OS, user information and other variables. Below is an exmaple of the output.
env
COLORTERM DBUS_SESSION DESKTOP_SES DISPLAY DOTNET_BUND GDMSESSION
_BUS_ADDRESS SION LE_EXTRACT_
BASE_DIR
GNOME_DESKTO GNOME_SHELL_ GNOME_TERMI GNOME_TERMI GPG_AGENT_I GTK_IM_MODU
P_SESSION_ID SESSION_MODE NAL_SCREEN NAL_SERVICE NFO LE
GTK_MODULES HOME LANG LESSCLOSE LESSOPEN LOGNAME
LS_COLORS NVM_BIN NVM_CD_FLAG NVM_DIR NVM_INC OLDPWD
S
PATH PWD QT_ACCESSIB QT_IM_MODUL SESSION_MAN SHELL
ILITY E AGER
SHLVL SSH_AGENT_LA SSH_AUTH_SO SWIFTLY_BIN SWIFTLY_HOM SYSTEMD_EXE
UNCHER CK _DIR E_DIR C_PID
TERM TEXTDOMAIN USER USERNAME VTE_VERSION WINDOWPATH
XAUTHORITY XDG_CONFIG_D XDG_CURRENT XDG_DATA_DI XDG_MENU_PR XDG_RUNTIME
IRS _DESKTOP RS EFIX _DIR
XDG_SESSION_ XDG_SESSION_ XDG_SESSION XMODIFIERS _
CLASS DESKTOP _TYPE
include_variables
<empty>
os
arch
bitness
codename
distribution
edition
family
hostname
name
version
user
config_dir
data_dir
data_local_dir
document_dir
home_dir
id
name
username
variables
<empty>
You can also view the values that these contexts have by passing in a show-values option as demonstrated below:
comtrya contexts --show-values
At the end, comtrya contexts also prints the directory where manifests fetched from git repositories are cached, e.g. ~/.cache/comtrya/manifests/git on Linux. Comtrya updates these clones on every run, see remote manifests.
Status
The status command provides an overview of manifests.
| Manifest | Count of Actions |
|---|---|
| kubectl.krew | 3 |
| kubectl.kubesess | 3 |
| kdash.kdash | 1 |
Manifests
Comtrya provisions systems and performs configuration using a single or set of manifest files. These files are defined in either YAML or TOML syntax. Each manifest files is composed of actions and dependencies. To break it down even further, each action is defined as an atom or a set of atoms. An action can be something as simple as echoing text out on a terminal.
A dependency defines a relationship between manifests and actions. For instance, in order to configure neovim on a new system that is being provisioned, we might first want comtrya to ensure that neovim is installed. So we may define a dependency on an action to use the system's native package manager to install neovim before placing any configuration files we need for neovim.
Example of a YAML manifest
actions:
- action: command.run
command: echo
args:
- hi
Example of TOML manifest
[[actions]]
action = "command.run"
command = "echo"
args = [ "hi" ]
Manifest version
A manifest can state the format it is written for with a top-level version key. Only version 1 exists so far, and leaving the key out means the same. Comtrya refuses manifests with a newer version than it supports instead of misreading them. They fail like any other manifest that can't be loaded, see When something fails.
version: 1
actions:
- action: command.run
command: echo
args:
- hi
Unknown keys
Comtrya warns about keys it doesn't read, like a misspelled field, and names the key it probably means:
WARN dev.yaml: `ags` in action 1 (command.run) is ignored, did you mean `args`?
Keys inside a field that holds an object, like key of package.repository, and keys in variants are checked too:
WARN repo.yaml: `ulr` in `key` of action 1 (package.repository) is ignored, did you mean `url`?
The manifest still runs without them. When the misspelled key is a required one, the manifest can't be loaded, and the error names the likely typo. A misspelled action name fails loading the same way, naming the closest action. Load errors give the action's position (counted from 0) and line:
actions[0]: unknown action `file.cpy`, did you mean `file.copy`? at line 2 column 5
A Comtrya.yaml (or Comtrya.yml, Comtrya.toml) in the manifest directory is the config, not a manifest.
Editor support
A JSON schema for manifests is published at https://comtrya.dev/schema.json. Editors with a YAML or TOML language server can use it for completion and validation.
For YAML (yaml-language-server, e.g. the VS Code YAML extension), add this comment at the top of a manifest:
# yaml-language-server: $schema=https://comtrya.dev/schema.json
actions:
- action: command.run
command: echo
args:
- hi
For TOML (Taplo, e.g. the VS Code Even Better TOML extension):
#:schema https://comtrya.dev/schema.json
[[actions]]
action = "command.run"
command = "echo"
args = [ "hi" ]
To generate the schema for your installed version locally, run cargo run -p jsonschemagen > schema.json in a checkout of the repository.
Actions
As mentioned under manifests, actions are used to dictate an action that needs to be done to configuration a system. The following is an example of a simple action:
actions:
# Action ID can be command.run or cmd.run
- action: command.run
command: echo
args:
- hi
The above does the following: It defines a list of actions (with only one element). The action defined is the for command.run, which in this case runs the echo command, providing the argument of 'hi.' The result is something equivalent to running the following command line on a Unix-based system:
echo hi
When an action fails
An action that fails stops its manifest: the actions after it don't run, the manifest fails, and the manifests that depends on it are skipped. Other manifests still run.
With continue_on_error: true, any action may fail without that. Its failure is shown as a warning, the manifest goes on with its next action, and it still counts as done, so the manifests that depend on it run too. Ctrl-C still stops the run.
actions:
# Fails on systems without a running Docker daemon, which is fine here
- action: command.run
command: docker
args: [pull, alpine]
continue_on_error: true
- action: directory.create
path: ~/.config/myapp
continue_on_error belongs to the action, not to one of its variants.
Groups of actions provided
Comtrya provides multiple actions which are broken down into groups with the actions being apart of a larger group:
Git actions are not avaible for 0.8.8 through 0.9.0, see action documentation for me information
Binary
- binary.github
binary.github
This action will grab a binary from GitHub and place it in the target directory, unless a file with that name is there already.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | binary.github |
| name | string | no | name of binary locally after download |
| directory | string | no | directory to save the binary locally |
| repository | string | no | GitHub repository, as owner/repo |
| version | string | yes | tag of the release; the latest release without it, or with latest |
Of the release's files, comtrya picks the one whose name matches this OS and CPU best, like tool-aarch64-apple-darwin on an Apple silicon Mac. Files for another OS are never picked. The action fails when no file fits.
When GITHUB_TOKEN is set, it's used for the GitHub API, which allows more requests per hour.
Example
- action: binary.github
name: comtrya
directory: /usr/local/bin
repository: comtrya/comtrya
version: v0.8.7
Commands
- command.run
command.run
This is the most basic, but yet powerful action as it can be used to run anything needed that is not directly supported in comtrya by an action. An alias exists, allowing you to abbreviate command as cmd.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | command.run |
| command | string | no | command to run |
| args | string | yes | argument passed |
| dir | string | yes | actual working directory |
| privileged | bool | yes | elevate privileges when executing |
| env | HashMap<string, string> | yes | key and values for scoped environment variables |
| stdout | string | yes | file to write the command's output to |
The command's output is logged line by line while it runs, visible with -v. Its stdin is empty, so a command that asks a question gets an end of file instead of waiting. A command that fails stops its action with its exit code and what it wrote to stderr, e.g. Command failed with exit code: 1: …; one ended by a signal says so, e.g. Command was killed by signal 9.
Scoped environment variables
Sometimes, environment variables are needed to run a command or set of commands. env sets environment variables for the command of a single command.run action only; comtrya's own environment and later actions don't see them. A PATH in env is also where comtrya looks for the command. Multiple environment variables are supported.
Commands with privileged: true run through sudo (or the configured privilege provider), which usually resets the environment, so env may not reach them.
Writing the output to a file
With stdout, the command's output is written to a file instead of the log, like > in a shell. This is handy for shell completions:
- action: command.run
command: just
args:
- --completions
- bash
stdout: "{{ user.home_dir }}/.local/share/bash-completion/completions/just"
- A relative path is relative to
dir. - Missing parent directories are created, and an existing file is overwritten.
- If the command fails, the file is left untouched.
- The file is written by comtrya itself, so with
privileged: trueit still belongs to the user running comtrya and its directory must be writable by them.
Example
- action: command.run
dir: .
command: echo
args:
- Hello world
# we should see the GOBIN set in the go env output now
- action: command.run
command: go
args:
- env
env:
GOBIN: /Users/test
# GOBIN isn't set here, env only applied to the command above
- action: command.run
command: go
args:
- env
Files and Directories
- file.copy
- file.download
- file.link
- file.remove
- file.unarchive
- directory.copy
- directory.create
- directory.remove
All directory.* actions can also be written as dir.*, e.g. dir.create.
Every file.* and directory.* action takes privileged: true (or sudo: true) to make its change as root, e.g. for a file below /etc. See Privilege Escalation.
Note
The following commands expect the from/source to point to files/directories which are themselves under a "files" directory.
This is a restriction so that comtrya knows not to parse any .yaml file e.g. a config for a different tool as a manifest.
To see how it works check the examples
- file.link
- file.copy
- directory.copy
file.copy
Action used to copy a file from one location to another.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | file.copy |
| from | string | no | source file |
| to | string | no | destination file |
| template | boolean | yes | renders files using context providers |
default: false | |||
| chmod | integer | yes | octal permissions, e.g. 644 or "0644" |
default: 644 | |||
| owned_by_user | string | yes | user for chown |
| owned_by_group | string | yes | group for chown |
| passphrase | string | yes | decrypts an age file encrypted with a passphrase |
| process_with | list | yes | command that gets the file on stdin; its output is written instead, see below |
Examples
# Simple file copy
- action: file.copy
from: procs-config.toml
to: "{{ user.config_dir }}/procs/config.toml"
# With template and permissions
- action: file.copy
from: managed_file
to: /root/file
template: true
chmod: 644
# With encrypted file
- action: file.copy
from: encrypted-file
to: /tmp/some-decrypted-file
passphrase: "1KZ2EXDHSQKZFQP43JK2LPXUFZ8D365CM5WQXRSH97U7N9WKRVFKS0TCS30"
# file copy with chown on unix systems
- action: file.copy
from: procs-config.toml
to: "{{ user.config_dir }}/procs/config.toml"
owned_by_user: test
owned_by_group: test
Note: utilizing chown functionality will require running comtrya as root. Also, both a user and a group need to be specified.
Seeing what changes
comtrya apply --dry-run shows what file.copy would change, without changing anything:
INFO execute:{manifest="git"}:{action=file.copy}: Contents of /home/me/.gitconfig change:
--- current
+++ new
@@ -1,3 +1,3 @@
[user]
- name = Old Name
+ name = New Name
email = me@example.com
- For a file that doesn't exist yet or is empty, comtrya only says that it is created or written, without its contents.
- Permission changes from
chmodget their own line. - Binary files aren't diffed, comtrya only says that they change.
- Files written with
process_withorpassphraseare never shown, as they usually hold decrypted secrets. Rendered templates are shown, so a template that includes secrets from variables shows them too.
During a normal apply, the same diff is logged with -v.
Processing files with a command
process_with runs a command, sends the source file to its stdin and writes its stdout to to.
Use it to decrypt secrets with the tool you already use: the encrypted file is the only copy in
your repository, and no encrypted copy is left behind next to the decrypted one.
- The first entry is the program, the rest are its arguments. No shell is involved, so
~and$VARare not expanded: use contexts like{{ user.home_dir }}, or[sh, -c, "..."]. - The command runs while comtrya plans the action, also with
--dry-run, so it must not change the system. It runs as your user, withoutprivileged. - If the output equals the current file, nothing is written.
- Its output is never shown in a diff, see Seeing what changes.
- With
template: true, the output is rendered as a template afterwards. - If the command fails, the action fails and its stderr is shown.
passphraseandprocess_withcan't be combined.
# GnuPG: uses your keyring and gpg-agent, so smartcards and YubiKeys work too
- action: file.copy
from: credentials.fish.asc
to: "{{ user.config_dir }}/fish/conf.d/credentials.fish"
chmod: 600
process_with: [gpg, --quiet, --decrypt]
# age with a key file (age1... or post-quantum age1pq1... keys)
- action: file.copy
from: credentials.toml.age
to: "{{ user.home_dir }}/.cargo/credentials.toml"
chmod: 600
process_with: [age, --decrypt, --identity, "{{ user.home_dir }}/.config/age/key.txt"]
# 1Password: the file contains secret references like {{ op://Private/GitHub/token }}.
# With more than one account signed in, pick one with --account
- action: file.copy
from: gh-hosts.yml.tpl
to: "{{ user.config_dir }}/gh/hosts.yml"
chmod: 600
process_with: [op, inject, --account, my.1password.com]
# sops
- action: file.copy
from: secrets.yaml
to: "{{ user.config_dir }}/app/secrets.yaml"
chmod: 600
process_with: [sops, --decrypt, --input-type, yaml, --output-type, yaml, /dev/stdin]
When gpg or age need a passphrase, they ask for it themselves: gpg through its pinentry, age on the terminal.
file.chown
This action will change the user and group owner of a file.
Note: In order to utilize this, must run comtrya as root. Also both a user and group need to be specified.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | file.chown |
| path | string | no | file to change ownership on |
| user | string | no | user to specify as file owner |
| group | string | no | group to specify as file owner |
Examples
actions:
- action: file.chown
path: ./files/some-file
user: test
group: test
file.download
This action will download a file.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | file.download |
| from | source | string | no |
| to | target | string | no |
| chmod | integer | yes | octal permissions, e.g. 644 or "0644", default: 644 |
| owned_by_user | string | yes | user for chown |
| owned_by_group | string | yes | group for chown |
| sha256 | string | yes | expected SHA-256 checksum of the file, as hex |
Without sha256, the file is downloaded once and kept as long as it exists. With sha256:
- the download fails if its checksum doesn't match, and an existing file is left untouched
- an existing file with a different checksum is downloaded again, so updating
fromandsha256together updates the file
GitHub shows the checksum of every release file on the release page, and many projects publish a
SHA256SUMS file. Otherwise, download the file once and run shasum -a 256 <file> (or
Get-FileHash <file> in PowerShell).
An alias also exists such that source can be used in lieu of from and target can be used in lieu of to.
Example
actions:
# This will be rendered with contexts
- action: file.download
from: https://google.com/robots.txt
to: /tmp/google-robots.txt
# This whill also run a chown step on downloaded file
- action: file.download
from: https://google.com/robots.txt
to: /tmp/google-robots.txt
owned_by_user: nobody
owned_by_group: nobody
# Verified download, replaced when the checksum changes
- action: file.download
from: https://github.com/jtroo/kanata/releases/download/v1.12.0/linux-binaries-x64.zip
to: "{{ user.cache_dir }}/kanata/linux-binaries-x64.zip"
sha256: 0bedd91567c5d7c54679061baadc37e4f83fb71750003999bc1d11f2c9754f36
Note: utilizing chown functionality will require running comtrya as root. Also, both a user and a group need to be specified.
file.link
Create a symlink for files. This action can be used to symlink a single file or files in a directory.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | file.link |
| from | string | no | symlink location |
| to | string | no | symlink points to |
| walk dir | boolean | yes | Walk diles in directory |
default: false | |||
| source | string | yes | Used in conjunction with walk dir |
in liue of from |
Example
# Symlink for a single file
- action: file.link
from: /root/symlink
to: managed_file
# Symlink for all files in a directory
- action: file.link
source: walker
target: /tml/walker-123
walk_dir: true
file.remove
Removes a file.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | file.remove |
| target | string | no | file to be removed |
Example
- action: file.remove
target: /tmp/some-file-rendered
file.unarchive
This action provides the ability to unarchive a tar.gz file.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | file.unarchive |
| from | string | no | full path to archive |
| to | string | no | destination of unarchived contents |
| force | bool | yes | force the unarchiving (defaults true) |
Example
actions:
- action: file.download
from: https://github.com/comtrya/comtrya/archive/refs/tags/v0.9.0.tar.gz
to: /tmp/comtrya
- action: file.unarchive
from: /tmp/comtrya
to: /tmp/
directory.copy
Copies the contents of a directory into another directory, the same on all platforms. After the action, to holds everything from holds:
to and its subdirectories are created if missing, files with the same path are overwritten and other files in to are left alone.
A trailing / on to makes no difference.
On unix, symlinks inside from are copied as symlinks; on Windows the file or directory they point to is copied, and a link or junction back to a directory above it fails the action instead of copying forever.
The action fails if to is from itself or a directory inside it, like cp does.
Before 0.11.0, unix copied the directory itself to <to>/<name> unless to ended with /.
To keep that, put the name into to.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | directory.copy |
| from | string | no | source directory |
| to | string | no | directory the contents of from are copied into |
Example
- action: directory.copy
from: managed_directory
to: /root/location
directory.create
Creates a directory, including all missing parent directories. Does nothing if the directory already exists.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | directory.create |
| path | string | no | directory to create |
Example
- action: directory.create
path: "{{ user.home_dir }}/.config/nvim"
directory.remove
Removes a directory. Only empty directories are removed: if the directory isn't empty or doesn't exist, comtrya logs an error and skips the action.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | directory.remove |
| target | string | no | directory to remove |
Example
- action: directory.remove
target: /tmp/old-directory
Git
- git.clone
git.clone
Clones a git repository. Nothing happens if directory already exists.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | git.clone |
| repo_url | string | no | URL of the repository to clone. repository works as well |
| directory | string | no | directory to clone to |
| reference | string | yes | branch or tag to check out, instead of the repository's default branch |
Example
actions:
- action: git.clone
repo_url: https://github.com/comtrya/comtrya
directory: "{{ user.home_dir }}/Code/comtrya"
- action: git.clone
repo_url: https://github.com/comtrya/comtrya
reference: v0.10.0
directory: "{{ user.home_dir }}/Code/comtrya-0.10.0"
History
comtrya 0.8.7 and earlier named the URL repository. git.clone was removed in 0.8.8 and came back in 0.9.1 with repo_url and without reference. Since 0.11.0, both names and reference work again, so manifests written for 0.8.7 run unchanged.
Group
- group.add
Support
Not all systems are currently supported. The following is a list of group providers:
- FreeBSD
- Linux
- macOS
If your OS of choice is not listed, feel free to either contribute support by opening a pull request or requesting that support be added in the repository's issue tracker.
group.add
Adds a group to the system.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | group.add |
| group_name | string | no | name of group to add |
The action fails when group_name is empty or the tool (groupadd, dscl or pw) isn't installed.
Example
actions:
- action: group.add
group_name: testgroup
macOS
- macos.default
macos.default
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | macos.default |
| domain | string | no | Domain: defaults or domains or https://macos-defaults.com |
| key | string | no | which key to change |
| kind | string | no | value type |
| value | string | no | value |
Example
- action: macos.default
domain: com.apple.dock
key: orientation
kind: string
value: left
- action: macos.default
domain: com.apple.screencapture
key: include-date
kind: bool
value: "false"
- action: macos.default
domain: NSGlobalDomain
key: "NSTableViewDefaultSizeMode"
kind: int
value: "1"
Packages
- package.install
- package.repository
Package Providers
Packages is a group of actions that utilize the local system's package manager, however it is a bit special. Unlike some other actions, packages can also contain providers. Some operating systems may have multiple package managers available and providers allow the user to choose which package manager to use. For example, macOS will automatically utilize homebrew as the default package manager, but this default can be overridden for another package manager on the system such as pkgin, which also supports macOS, or macports.
Supported package providers
Not all package managers are supported. This is a list of currently supported package providers:
| Provider | OS |
|---|---|
| yay | Arch (default, see below) |
| paru | Arch |
| pacman | Arch |
| apt | Debian/Ubuntu |
| pkg | FreeBSD |
| pkgin | NetBSD (Multiple) |
| brew | macOS |
| winget | Windows |
| xbps | Void Linux |
| zypper | OpenSUSE |
| macports | macOS |
| dnf | Fedora |
| snapcraft | Linux |
| luarocks | Lua modules, any OS (see below) |
If you would like support to be added for a package provider, feel free to contribute the support or request support in the repository issue tracker.
Default package provider
Without provider, package.install and package.repository use the usual package manager of the OS, e.g. apt on Debian. To use another one for all of them, set package_provider in Comtrya.yaml:
# Use pkgin instead of apt, unless an action sets its own provider
package_provider: pkgin
The order is:
provideron the action, or on its variant for this OSpackage_providerinComtrya.yaml- the default for this OS
The names and aliases are the same as for provider. An unknown name stops comtrya with an error listing the valid ones. The provider in use is available as packages.provider in templates and where conditions:
where: packages.provider == "pkgin"
On an OS without a default, like Alpine, actions without a provider fail with an error until provider or package_provider is set.
Arch Linux: pacman, yay and paru
On Arch and its derivatives, the default provider depends on how comtrya runs:
- As root:
pacman, because AUR helpers refuse to run as root. Packages from the AUR fail with pacman's "target not found". - Otherwise,
paruif it is installed. - Otherwise
yay. If yay isn't installed, comtrya installsbase-develandgitwith pacman and builds yay from the AUR.
paru and yay install packages from the official repositories and the AUR. They run as your user and ask for sudo themselves, so run comtrya as a normal user to install AUR packages. With provider: yay or provider: paru as root, package.install fails with an error saying so.
pacman installs packages from the official repositories only, with pacman -S --needed, and works as root too:
- action: package.install
provider: pacman
list:
- git
- ripgrep
Before 0.10.0, pacman was another name for yay. Manifests that use provider: pacman for AUR packages need provider: yay now.
LuaRocks
provider: luarocks installs Lua modules with luarocks install, one rock at a time, and only the ones luarocks list doesn't show yet. Rocks go into the system tree, so comtrya runs luarocks through the privilege provider (like sudo). With --local (or --tree) in extra_args, they go into your own tree instead and need no root:
- action: package.install
provider: luarocks
extra_args: [--local]
list:
- lgi
- lyaml
Important note on homebrew and macOS
Some package manager providers can implement a bootstrap method that will automatically configure the package manager on the system if it is not part of the default installation. This is the case with macOS. Comtrya can automatically install homebrew to a macOS system and will do so if a manifest specifies a package.install action and does not override the macOS default of homebrew. On NetBSD, comtrya installs pkgin with pkg_add pkgin as root when it is missing; pkg_add needs to know where to download from, e.g. through PKG_PATH or /etc/pkg_install.conf.
package.install
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | package.install |
| name | string | no | name of target package |
| list | list | yes | list of multiple packages |
| provider | string | yes | Specify package provider |
| repository | string | yes | Ignored with a warning, add repositories with package.repository |
| file | bool | yes | Specify that package is a local package on the file system. |
Default value is false |
Example
# Install package using default provider
- action: package.install
name: curl
# Install a list of packages using default provider
- action: package.install
list:
- curl
- wget
# Install a package using a specific package provider
- action: package install
name: curl
provider: pkgin
# Install a package specifying a repository
- action: package.repository
provider: homebrew
name: foo-repository/formulae
- action: package.install
name: foo
provider: homebrew
Local package install support
Some package providers allow for installing a package from the local file system. An example of this would be .pkg files that can be installed using FreeBSD's package manager pkg. As of this time, it requires that the file property be set in the action's definition.
List of supported package providers:
- pkg (FreeBSD)
- aptitude (debian/ubuntu)
If you would like to have this feature supported on another package provider, please open an issue at the comtrya GitHub repository.
Example
- action: package.install
name: /some/path/to/file/nano-8.1.pkg
file: true
package.repository
| Key | Type | Optional | Description |
|---|---|---|---|
| name | string | no | Alias of url |
| key | RepositoryKey | yes | See table below |
| provider | string | yes | Default value provided, specify package manager |
RepositoryKey
| Key | Type | Optional | Description |
|---|---|---|---|
| url | string | no | URL of the signing key |
| name | string | yes | File name of the key, defaults to a hash of the URL |
| key | string | yes | Ignored with a warning |
| fingerprint | string | yes | Ignored with a warning, the key isn't checked against it |
apt (Debian, Ubuntu)
For apt, name is the repository in the one-line format <uri> <suite> [<component>...]. Comtrya writes it as a deb822 file to /etc/apt/sources.list.d/comtrya-<uri>.sources and runs apt update. If a key is given, it's downloaded to /usr/share/keyrings/<name>.asc and used as Signed-By.
- action: package.repository
name: "https://download.docker.com/linux/debian {{ os.codename }} stable"
key:
name: docker
url: https://download.docker.com/linux/debian/gpg
- action: package.install
name: docker-ce
On Ubuntu, name can also be a PPA such as ppa:neovim-ppa/stable, which is added with add-apt-repository.
User
- user.add
- user.group
user.add
Adds a user to the system.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | user.add |
| username | string | no | username of user |
| fullname | string | yes | full name of user |
| home_dir | string | yes | home directory of user, the tool's default without it |
| shell | string | yes | shell for user, the tool's default without it |
| group | list | yes | groups to add new user to |
On Linux, macOS and FreeBSD, a user that exists already is left alone. The action fails when username is empty or the tool to create users (useradd, dscl or pw) isn't installed.
Example
- action: user.add
fullname: testuser
home_dir: /home/test
username: test
shell: sh
# Add user while also adding to groups
- action: user.add
fullname: testuser
home_dir: /home/test
username: test
shell: sh
group:
- testgroup
user.group
Adds an already created user to a group.
| Key | Type | Optional | Description |
|---|---|---|---|
| action | string | no | user.group |
| username | string | no | username of user to add to group |
| group | list | no | groups to add user to |
The action fails when username or group is empty, or the tool (usermod, dscl or pw) isn't installed.
Example
- action: user.group
username: test
group:
- wheel
Templating
Comtrya renders every manifest with Tera, a template engine similar to Jinja2. Comtrya uses Tera 1.x.
Templates are rendered before the manifest is parsed, so you can use them anywhere in a manifest:
actions:
- action: file.link
from: nvim
to: "{{ user.config_dir }}/nvim"
Quote values that start with {{, otherwise YAML reads them as a mapping.
Files copied with file.copy and template: true are rendered the same way. See Files and Directories.
Contexts
The values available in templates come from contexts. Run comtrya contexts to list them on your system.
| Context | Contains |
|---|---|
user | username, name, id, home_dir, config_dir, data_dir, data_local_dir, document_dir |
os | name, family, distribution, version, codename, edition, bitness, arch, hostname |
env | All environment variables, e.g. env.HOME |
variables | variables from Comtrya.yaml, and values passed with -D key=value |
include_variables | Variables loaded from the files listed under include_variables in Comtrya.yaml |
privilege | privilege: the configured privilege escalation tool (sudo, doas or run0) |
os.arch is the CPU, like x86_64 or aarch64. Release downloads often name it differently,
e.g. arm64; Tera's replace filter maps it:
- action: file.download
where: os.name == "linux"
from: "https://github.com/neovim/neovim/releases/download/stable/nvim-linux-{{ os.arch | replace(from='aarch64', to='arm64') }}.tar.gz"
to: /opt/nvim-linux.tar.gz
privileged: true
Including variables
include_variables in Comtrya.yaml loads more variables into the include_variables context:
include_variables:
- "file+yaml:///home/me/dotfiles/variables.yaml"
- "file+toml:///home/me/dotfiles/variables.toml"
- "dns+txt://example.com" # TXT records of the form key=value
- "op://Private/Dotfiles" # fields of a 1Password item
op://<vault>/<item> reads a 1Password item with the 1Password CLI, which must be installed and signed in (op signin, or the 1Password app integration). Every field with a value becomes a variable named after the field's label, e.g. {{ include_variables.username }}. Vault and item names may contain spaces. With several 1Password accounts, pick one with ?account=, e.g. op://Private/Dotfiles?account=my.1password.com.
The values become plain template variables: Comtrya logs what an action does, so a secret used in a path or command can show up in its output. comtrya contexts lists their names, and prints the values only with --show-values. To put secrets into files without that, see process_with in file.copy.
Comtrya reads the includes at the same time. A variable from a later include overrides the same variable from an earlier one.
If one of the includes fails, for example because op isn't signed in, or hasn't answered after 30 seconds, Comtrya logs a warning and leaves out only that include's variables.
On Windows, the user directories use forward slashes, e.g. C:/Users/me. Windows accepts them, and they are safe inside double-quoted YAML strings. Other values, like env.USERPROFILE, still contain backslashes: put those in single quotes, e.g. '{{ env.USERPROFILE }}', because YAML reads \U in double quotes as an escape.
Filters and functions
All of Tera's built-in filters, tests and functions are available, as well as control structures like if and for.
For example, to use only the major version of the OS:
actions:
- action: package.install
where: os.distribution == "Fedora"
list:
- "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-{{ os.version | split(pat='.') | first }}.noarch.rpm"
Comtrya adds one function:
| Function | Description |
|---|---|
read_file_contents | Returns the contents of the file at path, with surrounding whitespace trimmed |
actions:
- action: command.run
command: echo
args:
- "{{ read_file_contents(path='/etc/hostname') }}"
where is not Tera
The where conditions on manifests, actions and variants are not Tera templates. They are Rhai expressions, evaluated with the same contexts:
where: os.name == "linux" && user.username == "rawkode"
See Variants for more examples.
Errors
If a template can't be rendered, for example because it uses an unknown variable, Comtrya logs an error and skips the whole manifest.
That error is left out when the manifest's own where condition is false. A Windows-only manifest can use get_env(name="LOCALAPPDATA") without breaking on Linux or macOS. For this, the manifest must still be valid YAML or TOML before rendering, so quote values that contain {{.
To use a literal {{ in a manifest, wrap it in Tera's {% raw %} block:
actions:
- action: command.run
command: echo
args:
- "{% raw %}{{ not rendered }}{% endraw %}"
Privilege Escalation
Escalating actions
Some actions may be run either privileged or unprivileged. For those unfamiliar, this means for example utilizing sudo or running something as admin. A common action this may be required for is command.run. Perhaps you wish to run a command that comtrya does not directly supply an action for, but that command requires elevated privileges to make changes to the system. Some actions allow that to be specified. Here is an example making use of command.run:
- action: command.run
command: whoami
sudo: true
Comtrya knows two keywords for escalating privilege. In older versions, it had to be done using sudo, however sudo is a term typically associated with a specific application and has little meaning to a Windows user for example. Even on Unix-like systems, there are alternatives available. Comtrya's architecture allows for using other providers for privilege escalation. A more generic way to write the above action would be to use privileged in lieu of sudo.
- action: command.run
command: whoami
sudo: true
privileged: true
Files and directories
Every file.* and directory.* action takes privileged: true (or sudo: true) too. Its change is then made as root:
- action: file.copy
from: hosts
to: /etc/hosts
privileged: true
Comtrya itself keeps running as you, so contexts like user.home_dir stay yours. For these actions it starts sudo comtrya __helper (or doas, run0) once per run, on the first one that needs it, so you are asked for your password once. Checking whether something changes still runs as you, so a file only root can read can't be compared. When comtrya runs as root already, the actions run directly.
Privilege escalation providers
Comtrya, as of version 0.9.0 and later, supports different providers for privilege escalation. Not all operating systems may support sudo and some users prefer different programs, such as OpenBSD's doas which is available on multiple platforms or run0 which is part of systemd in versions 256 and newer.
In order to utilize different privilege providers, you must have a Comtrya.yaml file which contains configuration for Comtrya. Here is an example:
privilege: doas
variables:
test: "one"
The privilege specifies which provider. Below are the providers and relevant values to be set for privilege:
| Provider | Value |
|---|---|
| Sudo | sudo |
| Doas | doas |
| Run0 | run0 |
As a note, Comtrya will always fall back to utilizing sudo. An example is when an action is being executed, but no privilege escalation provider is specified.
Dependencies
In comtrya, manifests can depend on other manifests. This is helpful when you need to ensure a set of actions run before another because they depend on them in some way. This is done by specifying what manifests a manifest depends on.
Suppose we have a manifest for creating users and another manifest for creating groups. We may want to ensure that the groups are created before adding users to a group. In this examples, we have two files; users.yaml and groups.yaml. To ensure that groups.yaml runs before users.yaml, we write our groups.yaml as we normally would.
groups.yaml
actions:
- action: group.add
group_name: testgroup
users.yaml
depends:
- groups
actions:
- action: user.add
fullname: testuser
home_dir: /home/test
username: test
shell: sh
group:
- testgroup
As shown, at the top of the users.yaml file, depends takes a lists of manifests that this manifest depends on.
Manifest names
depends and -m use a manifest's name: its path below the manifest directory, with . between the parts and without .yaml, .yml or .toml. tools/git.toml is tools.git, and tools/main.yaml is tools. In depends, ./ stands for the manifest's own directory: ./git in a manifest in tools/ is tools.git, and at the top level just git.
When something fails
If an action fails, comtrya skips the remaining actions of that manifest and every manifest that depends on it, directly or indirectly. All other manifests still run, so one run shows every problem at once. At the end, comtrya exits with a non-zero status, so scripts and CI notice the failure.
In the example above, if creating the group fails, users.yaml is skipped, while unrelated manifests are applied as usual.
A manifest that can't be loaded counts as failed in the same way: invalid YAML or TOML, an unknown action or field value, a template that can't be rendered, or a newer manifest version. So does a manifest whose depends names a manifest that doesn't exist. This also applies to --dry-run and comtrya status, so they can check manifests in CI.
A manifest whose where condition is false is not an error, even if its templates only work where it applies.
Variants
All comtrya actions support the concept of variants. Variants allow you to modify how the action will be executed backed on contexts.
A command example of a variant is a variant based on the operating system. Suppose you have an action that is going to differ slightly between a linux, windows, and macOS system. The following is an example of that.
actions:
# This action has a "default" execution for when the variants don't overlay,
# as it does not provide its own "where"
- action: command.run
command: echo
args:
- hello, world!
variants:
- where: os.name == "linux"
command: reboot
- where: os.name == "macos"
command: echo
args: ["Hello", "macOS"]
- where: user.username == "rawkode"
command: echo
args: ["Hello", "rawkode!"]
Variants can can allow targeting an OS family or a specific distributions.
actions:
- action: command.run
command: echo
args:
- hello, vanilla Linux!
variants:
- where: os.family == "unix"
command: echo
args: ["Hi,", "Unix"]
- where: os.distribution == "Ubuntu"
command: echo
args: ["Hi,", "Ubuntu"]
- where: os.bitness == "64-bit"
command: echo
args: ["Hi,", "64 bit!"]
Lastly, the where clause can be used to selectively skip or run tasks:
actions:
- action: command.run
where: os.name == "linux"
command: echo
args:
- Hello Linux
Recipes
Setting up a new machine with cloud-init
cloud-init only needs to do the bare minimum: install comtrya and run it. Your manifests do the rest.
#cloud-config
users:
- name: ada
shell: /bin/bash
# comtrya runs without a terminal here, so steps with `privileged: true` need sudo without a
# password
sudo: ALL=(ALL) NOPASSWD:ALL
packages:
- curl
- ca-certificates
runcmd:
# Installs the latest release to /usr/local/bin. Set VERSION=v0.11.0 to pin one
- curl -fsSL https://comtrya.dev/install.sh | sh
# Runs the manifests as ada, not as root, so `user.home_dir` and friends are hers
- su - ada -c 'comtrya -d https://github.com/ada/dotfiles apply'
runcmdruns as root, soinstall.shwrites to/usr/local/binwithout sudo.INSTALL_DIRputs the binary elsewhere.-dtakes a git repository, also with a branch and a directory in it, likehttps://github.com/ada/dotfiles#main:manifests. See CLI. comtrya clones it itself, so the image doesn't need git, and updates it on every run.- To run only some manifests, add
-m, e.g.apply -m server.base.
Building software from a git clone
Some software has no package and has to be built from source. Clone it with git.clone, then run the build in the clone with command.run and its dir:
actions:
- action: git.clone
repo_url: https://github.com/example/tool
reference: v1.2.3
directory: "{{ user.home_dir }}/.cache/comtrya/src/tool"
- action: command.run
command: make
args: [build]
dir: "{{ user.home_dir }}/.cache/comtrya/src/tool"
- action: command.run
command: make
args: [install]
dir: "{{ user.home_dir }}/.cache/comtrya/src/tool"
privileged: true
git.cloneskips a directory that exists already, and doesn't pull later changes. To build another version, changereferenceanddirectory, e.g..../src/tool-v1.2.4.command.runruns on every apply. Keep the clone, somakeonly rebuilds what changed.directory.removeonly removes empty directories. To delete a clone after the build, usecommand.runwithrm -rfand the same path.