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 | bash
wget -qO- https://raw.githubusercontent.com/robatron/akinizer/master/bootstrap.sh | bash
The 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 bash
By 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'sexports
object, 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 totrue
skipAction: function(target: Target): string
- (Optional) If this function is provided, always skip the action if this evaluates totrue
skipActionMessage: 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/bin
gitPackage.cloneDir: string
- (Optional) Clone target directory. Default:$HOME/opt
postInstall: function(target: Target): void
- (Optional) Function that's called with thetarget
after 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 test
Or run the tests and watch for changes:
npm run watch
Sometimes 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 build
Then run it:
npm start
The 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
*.yml
files 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
expect
statements .toThrowErrorMatchingInlineSnapshots
is 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.