Introduction

comtrya

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

CommandDescription
applyApply manifests
statusList manifest status
versionPrint version information
contextsList available contexts
gen-completionsAuto generate completions
helpPrint 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.

ManifestCount of Actions
kubectl.krew3
kubectl.kubesess3
kdash.kdash1

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:

1

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.

KeyTypeOptionalDescription
actionstringnobinary.github
namestringnoname of binary locally after download
directorystringnodirectory to save the binary locally
repositorystringnoGitHub repository, as owner/repo
versionstringyestag 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.

KeyTypeOptionalDescription
actionstringnocommand.run
commandstringnocommand to run
argsstringyesargument passed
dirstringyesactual working directory
privilegedboolyeselevate privileges when executing
envHashMap<string, string>yeskey and values for scoped environment variables
stdoutstringyesfile 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: true it 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.

KeyTypeOptionalDescription
actionstringnofile.copy
fromstringnosource file
tostringnodestination file
templatebooleanyesrenders files using context providers
default: false
chmodintegeryesoctal permissions, e.g. 644 or "0644"
default: 644
owned_by_userstringyesuser for chown
owned_by_groupstringyesgroup for chown
passphrasestringyesdecrypts an age file encrypted with a passphrase
process_withlistyescommand 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 chmod get their own line.
  • Binary files aren't diffed, comtrya only says that they change.
  • Files written with process_with or passphrase are 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 $VAR are 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, without privileged.
  • 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.
  • passphrase and process_with can'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.

KeyTypeOptionalDescription
actionstringnofile.chown
pathstringnofile to change ownership on
userstringnouser to specify as file owner
groupstringnogroup 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.

KeyTypeOptionalDescription
actionstringnofile.download
fromsourcestringno
totargetstringno
chmodintegeryesoctal permissions, e.g. 644 or "0644", default: 644
owned_by_userstringyesuser for chown
owned_by_groupstringyesgroup for chown
sha256stringyesexpected 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 from and sha256 together 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.

Create a symlink for files. This action can be used to symlink a single file or files in a directory.

KeyTypeOptionalDescription
actionstringnofile.link
fromstringnosymlink location
tostringnosymlink points to
walk dirbooleanyesWalk diles in directory
default: false
sourcestringyesUsed 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.

KeyTypeOptionalDescription
actionstringnofile.remove
targetstringnofile 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.

KeyTypeOptionalDescription
actionstringnofile.unarchive
fromstringnofull path to archive
tostringnodestination of unarchived contents
forceboolyesforce 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.

KeyTypeOptionalDescription
actionstringnodirectory.copy
fromstringnosource directory
tostringnodirectory 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.

KeyTypeOptionalDescription
actionstringnodirectory.create
pathstringnodirectory 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.

KeyTypeOptionalDescription
actionstringnodirectory.remove
targetstringnodirectory 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.

KeyTypeOptionalDescription
actionstringnogit.clone
repo_urlstringnoURL of the repository to clone. repository works as well
directorystringnodirectory to clone to
referencestringyesbranch 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.

KeyTypeOptionalDescription
actionstringnogroup.add
group_namestringnoname 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

KeyTypeOptionalDescription
actionstringnomacos.default
domainstringnoDomain: defaults or domains or https://macos-defaults.com
keystringnowhich key to change
kindstringnovalue type
valuestringnovalue

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:

ProviderOS
yayArch (default, see below)
paruArch
pacmanArch
aptDebian/Ubuntu
pkgFreeBSD
pkginNetBSD (Multiple)
brewmacOS
wingetWindows
xbpsVoid Linux
zypperOpenSUSE
macportsmacOS
dnfFedora
snapcraftLinux
luarocksLua 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:

  1. provider on the action, or on its variant for this OS
  2. package_provider in Comtrya.yaml
  3. 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:

  1. As root: pacman, because AUR helpers refuse to run as root. Packages from the AUR fail with pacman's "target not found".
  2. Otherwise, paru if it is installed.
  3. Otherwise yay. If yay isn't installed, comtrya installs base-devel and git with 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

KeyTypeOptionalDescription
actionstringnopackage.install
namestringnoname of target package
listlistyeslist of multiple packages
providerstringyesSpecify package provider
repositorystringyesIgnored with a warning, add repositories with package.repository
fileboolyesSpecify 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

KeyTypeOptionalDescription
namestringnoAlias of url
keyRepositoryKeyyesSee table below
providerstringyesDefault value provided, specify package manager

RepositoryKey

KeyTypeOptionalDescription
urlstringnoURL of the signing key
namestringyesFile name of the key, defaults to a hash of the URL
keystringyesIgnored with a warning
fingerprintstringyesIgnored 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.

KeyTypeOptionalDescription
actionstringnouser.add
usernamestringnousername of user
fullnamestringyesfull name of user
home_dirstringyeshome directory of user, the tool's default without it
shellstringyesshell for user, the tool's default without it
grouplistyesgroups 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.

KeyTypeOptionalDescription
actionstringnouser.group
usernamestringnousername of user to add to group
grouplistnogroups 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.

ContextContains
userusername, name, id, home_dir, config_dir, data_dir, data_local_dir, document_dir
osname, family, distribution, version, codename, edition, bitness, arch, hostname
envAll environment variables, e.g. env.HOME
variablesvariables from Comtrya.yaml, and values passed with -D key=value
include_variablesVariables loaded from the files listed under include_variables in Comtrya.yaml
privilegeprivilege: 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:

FunctionDescription
read_file_contentsReturns 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:

ProviderValue
Sudosudo
Doasdoas
Run0run0

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'
  • runcmd runs as root, so install.sh writes to /usr/local/bin without sudo. INSTALL_DIR puts the binary elsewhere.
  • -d takes a git repository, also with a branch and a directory in it, like https://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.clone skips a directory that exists already, and doesn't pull later changes. To build another version, change reference and directory, e.g. .../src/tool-v1.2.4.
  • command.run runs on every apply. Keep the clone, so make only rebuilds what changed.
  • directory.remove only removes empty directories. To delete a clone after the build, use command.run with rm -rf and the same path.