-
Notifications
You must be signed in to change notification settings - Fork 2
Simple Saltstack-like deployment system in 2k lines of Python
License
mattbillenstein/salty
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Salty - devops inspired by Saltstack, but simpler.
This is an experiment to see with how little code I could build a useful
SaltStack-like deployment system. As of February 2025, that is about 2000 LOC,
and I'm using it on a couple projects in production/staging/dev environments.
It supports Linux (primary tested on Ubuntu LTS) and MacOS using Python3.
It currently implements server/client over msgpack-rpc using gevent TLS/TCP
sockets, a simple request/response mechanism for triggering deployments across
a fleet of hosts, and an async file-server.
It uses simple Python (vs yaml) to define hosts and roles, clusters (groups of
hosts - ie prod, staging, etc), and environments (collections of vars that
apply to a host). The templating language is also Python (via Mako); so this is
probably the most complete Python all-in deployment system you can use. There
is very little to learn other than Python itself.
And, it can be deployed as a single binary packaged using PyInstaller - see
releases for those packages.
You interact with the system via the CLI by instantiating a client to send a
request to the server, typically via a shell script:
$ cat bin/apply
#!/bin/bash
VERSION="$(git rev-parse HEAD)"
sudo /opt/salty/salty cli 127.0.0.1:11111 --keyroot=/opt/salty/keys type=apply version=$VERSION $*
By default it reports elapsed time, errors, and roles changed per host:
$ bin/apply
host:local.foo.dev elapsed:0.661 errors:0 changed:0
elapsed:0.677
And we can get more verbose showing each role for each host as well:
$ bin/apply -v
host:local.foo.dev elapsed:0.651 errors:0 changed:0
role:users elapsed:0.001 errors:0 changed:0
role:system elapsed:0.003 errors:0 changed:0
role:ve elapsed:0.000 errors:0 changed:0
role:dotfiles elapsed:0.013 errors:0 changed:0
role:src elapsed:0.003 errors:0 changed:0
role:postgres elapsed:0.608 errors:0 changed:0
role:redis elapsed:0.003 errors:0 changed:0
role:nginx elapsed:0.011 errors:0 changed:0
role:nsq elapsed:0.000 errors:0 changed:0
role:supervisord elapsed:0.009 errors:0 changed:0
role:cleanup elapsed:0.000 errors:0 changed:0
elapsed:0.668
Roles are designed to be idempotent, so if a role is up to date, nothing is
changed. If we're more verbose, we can list all the steps for a role - here I'm
limiting to a single role for brevity:
$ bin/apply -vv roles=nginx
host:local.foo.dev elapsed:0.009 errors:0 changed:0
role:users elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:system elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:ve elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:dotfiles elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:src elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:postgres elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:redis elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:nginx elapsed:0.009 errors:0 changed:0
{'cmd': 'useradd(nginx, system=True)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 3.2e-05}
{'cmd': 'makedirs(/opt/w/log/nginx, nginx, 0o755)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 2.5e-05}
{'cmd': 'makedirs(/opt/w/run/nginx, nginx, 0o755)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 1.5e-05}
{'cmd': 'makedirs(/opt/w/etc/ssl, nginx, 0o700)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 1.4e-05}
{'cmd': 'copy(nginx/mime.types, /opt/w/etc/mime.types)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 0.000489}
{'cmd': 'copy(nginx/ssl/dhparam.pem, /opt/w/etc/ssl/dhparam.pem)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 0.000441}
{'cmd': 'render(nginx/nginx.conf, /opt/w/etc/nginx.conf)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 0.004856}
{'cmd': 'render(nginx/event.lua, /opt/w/etc/event.lua)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 0.001625}
{'cmd': 'copy(nginx/nginx.logrotate, /etc/logrotate.d/nginx)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 0.000417}
{'cmd': 'copy(nginx/ssl/foo.dev.key, /opt/w/etc/ssl/foo.dev.key)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 0.000444}
{'cmd': 'copy(nginx/ssl/foo.dev.cer, /opt/w/etc/ssl/foo.dev.cer)', 'rc': 0, 'changed': False, 'created': False, 'elapsed': 0.00045}
role:nsq elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:supervisord elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
role:cleanup elapsed:0.000 errors:0 changed:0
{'rc': 0, 'cmd': '...role skipped...', 'elapsed': 0.0, 'changed': False}
elapsed:0.052
Every role except nginx was skipped, and now we can see all the steps for that
role, their runtime, and whether they were changed and created. rc is just a
'result code' in unix form where 0 is success and non-zero is failure.
One of the design goals is to be very fast - doing in seconds what takes
minutes in other systems by changing as little as is possible, and doing as
little actual work and I/O as possible.
See the example directory for a simple functioning example you can run locally.
Below is some simple documentation for what is currently available in writing
roles and I encourage you to consult the source in the "run" method in
operators.py:
https://github.com/mattbillenstein/salty/blob/master/lib/operators.py
Common Imports available in roles/templates:
os, os.path, json
Functions available in roles:
File management:
copy(src, dst, user=DEFAULT_USER, mode=0o644)
copy src file from server to dst path on client
remove(path):
recursively remove a path (see shutil.rmtree)
line_in_file(line, path, user=DEFAULT_USER, mode=0o644)
ensure given line is in file at path, create the file if it doesn't exist
makedirs(path, user=DEFAULT_USER, mode=0o755)
make all directories up to final directory denoted by path
render(src, dst, user=DEFAULT_USER, mode=0o644, **kw)
render src template from server to dst path on client
symlink(src, dst)
symlink src to dest on client
syncdir(src, dst, user=DEFAULT_USER, mode=0o755, exclude=None)
Synchronize a src dir on the server to the dst dir on the client - ala
rsync
Shell commands:
shell(cmds, **kw)
Run shell commands on client, **kw are optional Popen kwargs
server_shell(cmds, **kw)
Run shell commands on server, **kw are optional Popen kwargs
User management:
useradd(username, system=False)
Add a user and group of same name
usergroups(username, groups)
Add/remove user's groups
Misc
print(s)
Captures output for the role run response
is_changed()
True if command in the current role has changed
Context available in templates:
id:
current host id
me:
host metadata of current host - ie, the contents of hosts[id]
role:
current role being executed
hosts:
other hosts in the cluster keyed by host id, metadata includes:
env: host's environment 'dev', 'prod', etc
cluster: name of host's cluster
facts: collected facts for host, kernel, arch, ip addresses, etc
vars: collected env/cluster variables for host
bootstrap:
True/False if we're in bootstrap mode - ie, init is not running yet, skip
service restarts, etc
version:
Current git HEAD
my_ip():
get my (private) ip address
get_ips(role):
get a list of ip addresses by role
Crypto:
The PASSWORD var here would be the contents of your crypto.pass as read by
salty, so you could use the shell:
PASSWORD=$(sudo cat /path/to/salty/crypto.pass) ./crypto.py ...
Encrypt string for env/cluster vars:
PASSWORD=... ./crypto.py es '<string>'
Decrypt string for env/cluster vars:
PASSWORD=... ./crypto.py ds '<string>'
Encrypt file for files/<role>/<filename.ext>.enc
PASSWORD=... ./crypto.py e filename [more filenames]
Decrypt file for files/<role>/<filename.ext>.enc
PASSWORD=... ./crypto.py d filename [more filenames]
About
Simple Saltstack-like deployment system in 2k lines of Python
Resources
License
Stars
Watchers
Forks
Packages 0
No packages published