IAC in Makefile
In both a work and home server context, I have found using a Makefile a great way to keep a set of repeatable management tasks.
Challenges
These are some challenges I typically face in an environment.
- when returning to an environment after several months, struggling to remember how common tasks are managed
- when a certain software environment is required (ansible, envs, python venv, and so on) to carry out just one maintenance task like backup
- when certain environment variables are required for a task to run (like Ansible) and it isn’t clear from the error output what is needed to be done prior to running the task you came for
- when a proper readthrough of the code is required just to add a new task or tweak something
Makefile
Some of the above can be conveniently addressed with use of a Makefile.
In a Makefile for the scenario of “task = do step1, step2”, the syntax is clean and easy to read, and logical in order to review or modify.
Commands are printed in the output just before being carried out. This makes the execution process quite transparent. If you experienced a failure at one point, you would have the option to copy-paste it from your terminal and run it separately.
You can have task sets for specific directories of a large infra repo. Then, locate your shell at the root of that repo and just use the directory arg -C
to specify where you want to work:
make -C platform/node1 backup
At any point when dealing with (your own) memory issues, configure autocomplete/expansion in your shell and you can see all available make tasks when typing make [TAB]
:
user@host:~/infra$ make [TAB]
-- make target --
backup pull-config provision
-- file --
...
You can use an .mk
file to declare the environment of your Makefile a bit better, too.
This really shines for the use of Ansible, where I have needed many settings at the point of execution that I perhaps could not readily define elsewhere (like ansible.cfg
) because of relative paths or something else specific to the execution context.
.ansible/ansible.mk:
export ANSIBLE_CONFIG = $(shell realpath $(HOME_SERVER_ROOT_DIR)/.ansible/ansible.cfg)
export ANSIBLE_ROLES_PATH = $(shell realpath $(HOME_SERVER_ROOT_DIR)/.ansible/roles):$(shell realpath $(HOME_SERVER_ROOT_DIR)/.ansible/roles_external)
export ANSIBLE_ENV = ANSIBLE_CONFIG=$(ANSIBLE_CONFIG) ANSIBLE_ROLES_PATH=$(ANSIBLE_ROLES_PATH)
HOSTS :=
VARS :=
OPTS :=
TAGS :=
ifeq ($(CHECK),true)
CHECK_FLAG := --check
else
CHECK_FLAG :=
endif
metal/node-subdir/Makefile:
HOME_SERVER_ROOT_DIR=$(shell realpath ../../) # Up 2 directories to the root of the repo
include $(HOME_SERVER_ROOT_DIR)/.ansible/ansible.mk
provision:
$(ANSIBLE_ENV) ansible-playbook $(HOME_SERVER_ROOT_DIR)/metal/node-subdir/provision.yml -K -l $(HOSTS) $(if $(VARS),-e "$(VARS)",) $(if $(TAGS),--tags "$(TAGS)",) $(CHECK_FLAG) $(OPTS)
After typing this out now, I am sure I could eliminate the static roles paths from this, but I will leave it up as an example of how to predefine Ansible execution.
Ansible was supposed to be as easy to use as ansible-playbook playbook.yml
and in some ways it still can be.
However, in my own practice I have still found there are many commands and envs required to make it work, and you will be going several rounds back and forth if you are missing anything was needed.
Caveats
-
make
may not always be present at your place of execution (lol ok easily taken care of) -
handling words or arguments has a slightly unintuitive syntax. The below is required to use a ‘2nd’ argument to the make command. However it’s clear this is a consequence of ‘circumventing’ the inputs and treating it as if it were bash. So it is preferable in my mind to just use vars.
Makefile
%@: # Helper function to get the host from the command line arguments # Usage: $(call get_host,$(MAKECMDGOALS)) define get_host $(word 2,$(1)) endef # Make will pass targets as goals, intercept them %: @: provision: $(eval HOST := $(call get_host,$(MAKECMDGOALS))) ssh $(HOST)
shell
make provision hostname.lan
Better to:
Makefile
provision: ssh $(HOST)
shell
make provision HOST=hostname.lan
Principle
Use and think about Makefile as a means to interact with existing IAC CLI-based utilities in a predetermined way.
When the need to execute work is required and it becomes more complex, the work should be done by some other CLI utility, and then that utility can be executed by the Makefile.
Ergo, if conditional logic is required in response to make
user input, that might be the sign of going too far.
Reflections
Commonly when I embark upon a task, reflecting on it reveals quite a lot about the bigger picture that I missed when I was deep at work on the task.
Could provisioning be managed by a simpler tool? (pyinfra)
What is being missed by managing in this way, perhaps state management and conformity?
More worked example
netbox/backup:
@if [ -z "$(APP)" ]; then \
echo "Usage: make netbox/backup APP=<app>"; \
exit 1; \
fi
$(eval OUT_DIR := $(shell pwd))
$(eval DATE := $(shell date +%Y%m%d_%H%M%S))
@echo "Dumping database..."
docker exec -t $(APP)-postgres-1 pg_dump -U netbox -d netbox | gzip > $(OUT_DIR)/backup-$(APP)-$(DATE)-postgres.sql.gz
@echo "Filesize: $$(du -sh $(OUT_DIR)/backup-$(APP)-$(DATE)-postgres.sql.gz | cut -f1)"
@echo "Collecting media files..."
docker exec $(APP)-netbox-1 tar -czf - /opt/netbox/netbox/media > $(OUT_DIR)/backup-$(APP)-$(DATE)-media.tar.gz
@echo "Filesize: $$(du -sh $(OUT_DIR)/backup-$(APP)-$(DATE)-media.tar.gz | cut -f1)"
netbox/restore/postgres:
@if [ -z "$(APP)" ]; then \
echo "Usage: make netbox/restore/postgres APP=<app>"; \
exit 1; \
fi
@if [ -z "$(SQL_GZ)" ]; then \
echo "Usage: make netbox/restore/postgres APP=<app> SQL_GZ=<sql_gz_file>"; \
exit 1; \
fi
@echo "Restoring database..."
@echo "Are you sure you want to restore the database?\nThis will overwrite the current database.\nInput file: $(SQL_GZ).\nProceed? [y/N] " && read ans && [ $${ans:-N} = y ]
gunzip -c $(SQL_GZ) | docker exec -i $(APP)-postgres-1 psql -U netbox -d netbox
netbox/restore/media:
@if [ -z "$(APP)" ]; then \
echo "Usage: make netbox/restore/media APP=<app>"; \
exit 1; \
fi
@if [ -z "$(MEDIA_TAR_GZ)" ]; then \
echo "Usage: make netbox/restore/media APP=<app> MEDIA_TAR_GZ=<media_tar_gz_file>"; \
exit 1; \
fi
@echo "Restoring media files..."
gzip -dc $(MEDIA_TAR_GZ) | docker exec -i $(APP)-netbox-1 tar -x -f - -C /
forgejo/check:
docker exec -u git -it forgejo-server-1 /bin/bash -c "forgejo doctor check"
forgejo/backup:
@echo "To backup forgejo, service will be stopped first, backup taken, then service restarted."
@echo "Do you want to proceed? [y/N] " && read ans && [ $${ans:-N} = y ]
$(eval OUT_DIR := $(shell pwd))
$(eval DATE := $(shell date +%Y%m%d_%H%M%S))
@echo "Stopping service..."
docker exec -u root -it forgejo-server-1 /bin/bash -c "s6-svc -d /etc/s6/gitea"
@echo "Dumping version information..."
docker exec -u git -it forgejo-server-1 /bin/bash -c "forgejo --version > /tmp/version.txt"
@echo "Backing up data..."
docker exec -u root forgejo-server-1 /bin/bash -c "tar -czf /tmp/backup.tar.gz /data /custom /tmp/version.txt"
docker cp forgejo-server-1:/tmp/backup.tar.gz $(OUT_DIR)/backup-forgejo-$(DATE).tar.gz
@echo "Backup complete."
@echo "Filesize: $$(du -sh $(OUT_DIR)/backup-forgejo-$(DATE).tar.gz | cut -f1)"
@echo "Clean up files..."
docker exec -u root forgejo-server-1 /bin/bash -c "rm /tmp/version.txt /tmp/backup.tar.gz"
@echo "Restarting service..."
docker exec -u root -it forgejo-server-1 /bin/bash -c "s6-svc -u /etc/s6/gitea"
@echo "Forgejo status:"
docker exec -u root -it forgejo-server-1 /bin/bash -c "s6-svstat /etc/s6/gitea"
immich/backup:
$(eval OUT_DIR := $(shell pwd))
$(eval DATE := $(shell date +%Y%m%d_%H%M%S))
@echo "Backing up database...(may take a minute or so...)"
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | gzip > $(OUT_DIR)/backup-immich-$(DATE)-postgres.sql.gz
@echo "Database backup complete."
@echo "Filesize: $$(du -sh $(OUT_DIR)/backup-immich-$(DATE)-postgres.sql.gz | cut -f1)"
secrets/encrypt: check-app
@echo "Encrypting $(APP)/$(SECRETS_FILENAME) to $(APP)/$(ENCRYPTED_FILENAME)..."
@sops encrypt "$(APP)/$(SECRETS_FILENAME)" --output "$(APP)/$(ENCRYPTED_FILENAME)"
secrets/edit: check-app
@echo "Editing $(APP)/$(ENCRYPTED_FILENAME)..."
@sops edit "$(APP)/$(ENCRYPTED_FILENAME)"