A simple configuration management tool written in JavaScript for fun and practice
Akinizer is an configuration management tool I wrote for managing my preferred programs and configs across different operating systems and machines.
I created Akinizer for fun, practice, and to learn more about operating system configuration management. Why use high-quality robust software when I could write my own janky tool in JavaScript? 😉
Akinizer supports the following operating systems (but it would probably work on other versions of macOS and Debian-based Linux distros):
- Linux - Ubuntu 18.04, 20.04
- Mac - macOS 10.15, 11.0
OS support is verified via end-to-end tests. See the CI/CD section for details.
Here's a simple example of what an Akinizer config looks like. See Using Akinizer for more details.
const {
ACTIONS,
createTaskTree,
definePhase,
defineRoot,
} = require('akinizer');
createTaskTree(
defineRoot([
// Make sure `cowsay`, `htop`, and `vim` are installed
definePhase('installUtilsPhase', ACTIONS.INSTALL_PACKAGES, [
'cowsay',
'htop',
'vim',
]),
]),
exports,
);Here's a sample output for when it's applied:
[15:20:41] Starting 'default'...
[15:20:41] Starting 'installUtilsPhase:cowsay'...
info: Checking if target package 'cowsay' is installed...
info: Verifying target 'cowsay' exists with `brew list --versions 'cowsay'`...'
cowsay 3.04
info: Target package 'cowsay' is already installed. Moving on...
[15:20:44] Finished 'installUtilsPhase:cowsay' after 2.83 s
...
[15:20:47] Finished 'default' after 5.85 s
To install or update Akinizer, you should run the bootstrap.sh script which assures required programs are installed (e.g., git, node.js), downloads or updates Akinizer, and installs its dependencies. Review the script, then either download and run the script manually, or use the following cURL or Wget commands:
curl -o- https://raw.githubusercontent.com/robatron/akinizer/master/bootstrap.sh | bashwget -qO- https://raw.githubusercontent.com/robatron/akinizer/master/bootstrap.sh | bashThe bootstrap script's behavior can be modified with the following environment variables:
AK_GIT_REF- The Akinizer repo ref to checkout (default:master)AK_INSTALL_ROOT- Where to clone the Akinizer repo to (default:$HOME/opt/akinizer)AK_SKIP_CLONE- Skip the Akinizer clone step (default:no)
For example, the following would change the Akinizer installation directory to /opt with the AK_INSTALL_ROOT option:
curl -o- https://raw.githubusercontent.com/robatron/akinizer/master/bootstrap.sh | AK_INSTALL_ROOT=/opt bashBy default, Akinizer uses the following package management tools to verify and install programs:
Apt and dpkg must be pre-installed on the Linux system, but Homebrew and Cask can be installed via the bootstrap script on Mac.
Akinizer's system configuration is declared as a tree of phases, each of which contains a list of targets and an action to apply to them. Akinizer converts the phase tree into a hierarchy of runnable gulp tasks.
ℹ️ For a full annotated working example, see examples/gulpfile.js
The following is a simple example that assures a list of utilities are installed on the system.
// ./examples/simple/gulpfile.js
const {
ACTIONS,
createTaskTree,
definePhase,
defineRoot,
} = require('akinizer');
// Create the phase tree and export a hierarchy of runnable gulp tasks, one for
// each package and phase.
createTaskTree(
defineRoot([
definePhase('installUtilsPhase', ACTIONS.INSTALL_PACKAGES, [
'cowsay',
'gpg',
'htop',
'jq',
'vim',
]),
]),
exports,
);Run gulp to execute the default task which refers to Akinizer's root phase:
[I] ➜ gulp
[15:20:41] Using gulpfile ~/code/akinizer/examples/simple/gulpfile.js
[15:20:41] Starting 'default'...
[15:20:41] Starting 'installUtilsPhase:cowsay'...
info: Checking if target package 'cowsay' is installed...
info: Verifying target 'cowsay' exists with `brew list --versions 'cowsay'`...'
cowsay 3.04
info: Target package 'cowsay' is already installed. Moving on...
[15:20:44] Finished 'installUtilsPhase:cowsay' after 2.83 s
...
[15:20:46] Starting 'installUtilsPhase:vim'...
info: Checking if target package 'vim' is installed...
info: Verifying target 'vim' exists with `brew list --versions 'vim'`...'
vim 8.2.1500
info: Target package 'vim' is already installed. Moving on...
[15:20:47] Finished 'installUtilsPhase:vim' after 753 ms
[15:20:47] Finished 'default' after 5.85 s
You can also run each phase and task individually:
[I] ➜ gulp installUtilsPhase:vim
[15:26:56] Using gulpfile ~/code/akinizer/examples/simple/gulpfile.js
[15:26:56] Starting 'installUtilsPhase:vim'...
info: Checking if target package 'vim' is installed...
info: Verifying target 'vim' exists with `brew list --versions 'vim'`...'
vim 8.2.1500
info: Target package 'vim' is already installed. Moving on...
[15:26:57] Finished 'installUtilsPhase:vim' after 835 ms
You can list all available tasks with gulp --tasks:
[I] ➜ gulp --tasks
[15:27:34] Tasks for ~/code/akinizer/examples/simple/gulpfile.js
[15:27:34] ├── installUtilsPhase:cowsay
[15:27:34] ├── installUtilsPhase:gpg
[15:27:34] ├── installUtilsPhase:htop
[15:27:34] ├── installUtilsPhase:jq
[15:27:34] ├── installUtilsPhase:vim
[15:27:34] ├─┬ installUtilsPhase
[15:27:34] │ └─┬ <series>
[15:27:34] │ ├── installUtilsPhase:cowsay
[15:27:34] │ ├── installUtilsPhase:gpg
[15:27:34] │ ├── installUtilsPhase:htop
[15:27:34] │ ├── installUtilsPhase:jq
[15:27:34] │ └── installUtilsPhase:vim
[15:27:34] └─┬ default
[15:27:34] └─┬ <series>
[15:27:34] └─┬ <series>
[15:27:34] ├── installUtilsPhase:cowsay
[15:27:34] ├── installUtilsPhase:gpg
[15:27:34] ├── installUtilsPhase:htop
[15:27:34] ├── installUtilsPhase:jq
[15:27:34] └── installUtilsPhase:vim
Top-level function to create the entire phase task tree. This should be the final function call of your gulpfile.js file.
Parameters:
rootPhase- The output ofdefineRoot(), the root of the phase treeexp- The module'sexportsobject, onto which the gulp tasks are attached so they can be runnable
Example:
createTaskTree(
defineRoot([
/* ... phases ... */
]),
exports,
);Define a phase in which targets have an action applied to them, e.g., to assure a set of packages are installed.
name- Name of the phaseaction- Action to apply to the list of targets. See Phase actions for details.targets- A list of targets which can be strings or the outputs ofdefineTarget()phaseOpts- Phase optionsphaseOpts.parallel- Process targets in parallelphaseOpts.targetOpts- Options to apply to all targets
Example:
definePhase(
'installUtilsPhase',
ACTIONS.INSTALL_PACKAGES,
[
// Simple targets without arguments
'cowsay',
'gpg',
'htop',
// Targets defined with `defineTarget()`
defineTarget('python3-distutils', {
skipAction: () => !isLinux(),
}),
defineTarget('pip', {
actionCommands: [
'sudo curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py',
'sudo -H python3 /tmp/get-pip.py',
],
}),
],
// phaseOpts
{
targetOpts: {
forceAction: true,
},
parallel: true,
},
);Defines the root phase. It takes only one argument, a list of phases defined by definePhase().
Example:
defineRoot([
definePhase('phase1' /* ... */),
definePhase('phase2' /* ... */),
// ... more phases ...
]);Define a target and its action arguments. See "Phase actions" section below for details about how actions work.
name- Name or identifier of target, depending on its phase's actionactionArgs- Arguments for this target's phase's action
Examples:
defineTarget('python3');
defineTarget('python3-distutils', {
skipAction: () => !isLinux(),
});
defineTarget('pip', {
actionCommands: [
'sudo curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py',
'sudo -H python3 /tmp/get-pip.py',
],
});
defineTarget('pyenv', {
actionCommands: ['curl https://pyenv.run | bash'],
skipAction: () => fileExists(pyenvDir),
skipActionMessage: () => `File exists: ${pyenvDir}`,
});Actions, defined in definePhase(), are verbs that will be applied to all targets of a phase. Actions treat targets differently, e.g. as jobs, packages, or phases, and take arguments defined in defineTarget() or phaseOpts. Supported actions and their arguments are listed below.
All actions support the following function arguments, all of which will be provided with the target when they're evaluated.
forceAction: function(target: Target): string- (Optional) If this function is provided, always run the action if this evaluates totrueskipAction: function(target: Target): string- (Optional) If this function is provided, always skip the action if this evaluates totrueskipActionMessage: function(target: Target): string- (Optional) A function that return a message to explain why the action was skipped
Executes arbitrary shell code. Required arguments:
actionCommands: string[]- Shell commands to execute
Installs a target package using the system package manager by default. Supported arguments:
actionCommands: string[]- Shell commands to executegitPackage: object- Marks this target as a "git package"gitPackage.repoUrl: string- URL (HTTPS) to the git repo of the target packagegitPackage.symlink: string- (Optional) File to symlink from the repo after its cloned. Default: target namegitPackage.binDir: string- (Optional) Symlink target directory. Default:$HOME/bingitPackage.cloneDir: string- (Optional) Clone target directory. Default:$HOME/opt
postInstall: function(target: Target): void- (Optional) Function that's called with thetargetafter installation is complete.verifyCommandExists- Verify the target name exists as a command as oppose to verifying the target is installed via the system target manager
Example:
definePhase('installTerm', ACTIONS.INSTALL_PACKAGES, [
defineTarget('zsh'),
defineTarget('oh-my-zsh', {
actionCommands: [
`curl https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o /tmp/omzshinstall.sh`,
`RUNZSH=no sh /tmp/omzshinstall.sh`,
],
skipAction: () => fileExists(OMZDir),
skipActionMessage: () => `File exists: ${OMZDir}`,
}),
defineTarget('spaceship-prompt', {
gitPackage: {
binDir: `${OMZDir}/themes/spaceship.zsh-theme`,
binSymlink: 'spaceship.zsh-theme',
cloneDir: SpaceshipThemeDir,
ref: 'c38183d654c978220ddf123c9bdb8e0d3ff7e455',
repoUrl: 'https://github.com/denysdovhan/spaceship-prompt.git',
},
skipAction: () => fileExists(SpaceshipThemeDir),
skipActionMessage: () => `File exists: ${SpaceshipThemeDir}`,
}),
]);Runs nested phases. Example:
// Targets are other phases
definePhase('installUtils', ACTIONS.RUN_PHASES, [
// Common phase (install on all systems)
definePhase('common', ACTIONS.INSTALL_PACKAGES, ['cowsay', 'gpg', 'htop']),
// Linux phase (install only on Linux)
isLinux() &&
definePhase('linux', ACTIONS.INSTALL_PACKAGES, ['fortune-mod']),
// Mac phase (install only on Mac)
isMac() && definePhase('mac', ACTIONS.INSTALL_PACKAGES, ['fortune']),
]);Verifies packages are installed. Supported arguments:
verifyCommandExists- Verify the target name exists as a command as oppose to verifying the target is installed via the system target manager
Example:
definePhase(
'verifyPrereqs',
ACTIONS.VERIFY_PACKAGES,
['curl', 'git', 'node', 'npm'],
{
// Apply these options to all of this phase's packages
targetOpts: {
// This option verifies the command exists instead of verifying
// its target exists with the system target manager
verifyCommandExists: true,
},
// We can run the phase in parallel b/c target verifications are
// independent from each other
parallel: true,
},
);Here are some notes about how to develop Akinizer.
Akinizer was mostly developed against unit tests, which are run with jest. To run the full suite of tests:
npm testOr run the tests and watch for changes:
npm run watchSometimes it's necessary to run the entire system end-to-end. To protect your machine from inadvertent system-wide changes during e2e development, Akinizer provides a Docker container to create and run a repeatable, isolated development sandbox. To use it, first build the image from the ./Dockerfile:
npm run buildThen run it:
npm startThe repo will be mounted inside of the container. Play around as much as you want. All changes will be reverted when the container is restarted.
End-to-end and unit tests are run automatically via GitHub Actions when updates are pushed to the repo. These tests are configured in the .github/workflows/*.yml files.
Here are a few noteable technologies and concepts I learned, and/or practiced to create this project.
- GitHub Actions is used as the CI/CD pipeline technology to run end-to-end and unit tests against a matrix of operating systems and scenarios.
- It supports Ubuntu and macOS, Akinizer's target operating systems
- It's free within generous limits!
- See the
*.ymlfiles in .github/workflows/
- Docker is used as a local development sandbox, ideal for testing configuration management stuff!
- See Dockerfile for details
- Jest snapshot testing is used to quickly test complex task trees and other objects outside of a React/UI testing context.
- Inline snapshots are used to test smaller objects alongside
expectstatements .toThrowErrorMatchingInlineSnapshotsis used to easily test error messages
- Inline snapshots are used to test smaller objects alongside
- Semi-declarative programming pattern is used to define task and phase trees.
- See examples/gulpfile.js for an example.
- The simple-git library is used for interacting with git repos
- The nodegit library is powerful, but turned out to be too low-level and complex for this project
- Node-config is used to enable Akinizer configuration via config files. See examples/.akinizerrc.js for an example.
- The Connonical way to combine Prettier and Eslint is used to enable seamless linting and formatting
- DocToc is used to maintain the README table of contents.