Compare commits

...

10 commits

Author SHA1 Message Date
June 3cdd4ba8b5
update!: switch from one comma-sep. argument to variadic args. for hosts
Since the hosts are what nixos-rebuild acts on it makes to have them
represented by unlimited variadic arguments similar to how "git add",
"cat", "nix build", etc. work.
2024-06-06 21:08:38 +02:00
June 11a2b74aee
docs: redefine commit message "update" tag to be for tagging non-feat.
Redefine the commit message "update" tag to be for tagging an
enhancement or update, which doesn't qualify as a feature, and also have
it not be restricted from tagging a breaking change.
2024-06-06 21:04:48 +02:00
June 89cb3586c2
build: set version statically in pyproject.toml 2024-06-06 20:23:48 +02:00
June 5c55bd3e97
build: Add Nix flake for building x86_64- and aarch64-linux Nix packages 2024-06-06 20:18:52 +02:00
June 3410f647f5
other: Put ruff linter settings in lint section as recommended by ruff 2024-06-06 01:07:43 +02:00
June 7cfa2726d3
other: Use more recent min. vers. for the lint, fmt and typing tooling 2024-06-06 01:07:43 +02:00
June c7f7771e26
other: Use "ruff check" instead of just "ruff" as recommended by ruff
This is as of ruff 0.3.0. See here:
https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md#030
2024-06-06 01:07:39 +02:00
June cb9f2e3a11
docs: specify a commit message format to use 2024-06-06 00:37:51 +02:00
June 05c45fe5e3
Port infra-rebuild to Python
Do this for a nicer developer experience in a safer language, which has
nice libraries available to e.g. build command line interfaces (e.g.
click).

Set minimum Python version to 3.10 to support match statements.
2024-06-06 00:05:55 +02:00
June 500438636a
Create a skeleton python project and modify it to fit this project
Create a skeleton python project using "hatch new -i --cli
infra-rebuild" with Hatch version 1.7.0 and modify it to fit this
project. This is the first step of porting infra-rebuild to Python.

Also provide a first .gitignore ignoring relevant build directories.
2024-06-05 15:24:47 +02:00
17 changed files with 478 additions and 208 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__
/dist/
/.coverage*
/result

29
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,29 @@
# Contributing
## Commit Message Format
The commit message format is as follows:
```
tag(!): short description
Longer description here if necessary
```
The `tag` should be one of the following:
- `fix` - for a bug fix
- `update` - for an enhancement or update not qualifying as a feature
- `feature` - for a new feature
- `perf` - for a code change that improves performance
- `refactor` - for a code change that isn't one of `fix`, `update`, `feature` or `perf`
- `build` - for changes that affect the build system or external dependencies
- `test` - for adding or correcting tests
- `style` - for changes to the linter or formatter configuration and its results
- `docs` - for changes to documentation only
- `other` - for anything that isn't covered by the tags above
If a change is a breaking change then that should be indicated by adding a `!` after the tag.
These tags are an adapted version of the tags [eslint](https://eslint.org/docs/developer-guide/contributing/pull-requests#step-2-make-your-changes) and of the tags [Angular](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#type) use. \
This commit message format is also inspired by [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).

27
flake.lock Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1717428950,
"narHash": "sha256-Pr3lhu2No1GHJarhjt+Jsfxye1wNLoY12E44p0b3VO0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "4fad892a8f64635a55423e4acfefeefb6caf4d0d",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-23.11-small",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

45
flake.nix Normal file
View file

@ -0,0 +1,45 @@
{
description = "A simple NixOS deployment tool using nixos-rebuild internally, but trying to make infrastructure deployment more convenient.";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11-small";
};
outputs = { nixpkgs, ... }:
let
version = "0.0.1";
in
{
packages = nixpkgs.lib.attrsets.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system:
let
pkgs = nixpkgs.legacyPackages."${system}";
in
rec {
infra-rebuild = pkgs.python3Packages.buildPythonApplication {
pname = "infra-rebuild";
inherit version;
pyproject = true;
src = ./.;
nativeBuildInputs = with pkgs.python3Packages; [
hatchling
];
propagatedBuildInputs = [
pkgs.python3Packages.click
pkgs.nixos-rebuild
];
meta = with nixpkgs.lib; {
description = "A simple NixOS deployment tool using nixos-rebuild internally";
longDescription = "A simple NixOS deployment tool using nixos-rebuild internally, but trying to make infrastructure deployment more convenient.";
homepage = "https://git.hamburg.ccc.de/CCCHH/infra-rebuild";
license = licenses.gpl3Plus;
};
};
default = infra-rebuild;
}
);
};
}

View file

@ -1,3 +0,0 @@
#!/usr/bin/bash
echo "$(tput bold)$(tput setaf 9)$@$(tput sgr0)"

View file

@ -1,3 +0,0 @@
#!/usr/bin/bash
echo "$(tput bold)$(tput setaf 12)$@$(tput sgr0)"

View file

@ -1,3 +0,0 @@
#!/usr/bin/bash
echo "$(tput bold)$(tput setaf 11)$@$(tput sgr0)"

View file

@ -1,39 +0,0 @@
#!/usr/bin/env bash
REAL_BASE_DIR=$( dirname $( realpath "$0" ) )
if [ $# -lt 2 ] || [ $# -gt 2 ]; then
$REAL_BASE_DIR/helper/msg_error.sh "\
Error: Incorrect amount of arguments given.
You need to provide exactly two arguments. The first one being the operation you want to run and the second one being the host or hosts (comma separated) you want to run the operation for."
exit 1
fi
OPERATION="$1"
HOSTS="$2"
case $OPERATION in
build|build-vm|build-vm-with-bootloader|switch|boot|test|reboot)
;;
*)
$REAL_BASE_DIR/helper/msg_error.sh "\
Error: No valid operation given.
The operation must be one of:
build, build-vm, build-vm-with-bootloader, switch, boot, test, reboot."
exit 1
;;
esac
set -e
for HOST in ${HOSTS//,/ }; do
case $OPERATION in
build|build-vm|build-vm-with-bootloader)
env OPERATION="$OPERATION" HOST="$HOST" $REAL_BASE_DIR/operations/local.sh
;;
switch|boot|test|reboot)
env OPERATION="$OPERATION" HOST="$HOST" $REAL_BASE_DIR/operations/remote.sh
;;
esac
done

View file

@ -1,45 +0,0 @@
#!/usr/bin/env bash
REAL_BASE_DIR=$( dirname $( realpath "$0" ) )
# Wrapper for nixos-rebuild operations, which act locally.
# Takes the following arguments supplied as environment variables.
# HOST: The host as defined in nixosConfigurations.
# OPERATION: The nixos-rebuild operation to execute. Can be one of:
# build, build-vm, build-vm-with-bootloader
# All operations are as defined in the nixos-rebuild man page.
if [ -z $HOST ]; then
$REAL_BASE_DIR/../helper/msg_error.sh "\
Internal Error: No host given.
A host needs to be provided via the HOST environment variable."
exit 1
fi
if [ -z $OPERATION ]; then
$REAL_BASE_DIR/../helper/msg_error.sh "\
Internal Error: No operation given.
An operation needs to be provided via the OPERATION environment variable."
exit 1
fi
case $OPERATION in
build|build-vm|build-vm-with-bootloader)
;;
*)
$REAL_BASE_DIR/../helper/msg_error.sh "\
Internal Error: No valid operation given.
The operation provided via the OPERATION environment variable needs to be one of:
build, build-vm, build-vm-with-bootloader."
exit 1
;;
esac
set -e
$REAL_BASE_DIR/../helper/msg_info.sh "\
Running nixos-rebuild $OPERATION for $HOST..."
nixos-rebuild "$OPERATION" --flake ".#$HOST"

View file

@ -1,115 +0,0 @@
#!/usr/bin/env bash
REAL_BASE_DIR=$( dirname $( realpath "$0" ) )
# Wrapper for nixos-rebuild operations, which act remotely.
# Gets the necessary configuration to do so.
# Takes the following arguments supplied as environment variables.
# HOST: The host as defined in nixosConfigurations and the
# deployment_configuration.json.
# OPERATION: The nixos-rebuild operation to execute. Can be one of:
# switch, boot, test, reboot
# All operations are as defined in the nixos-rebuild man page except
# for reboot, which runs boot, but then also reboots the host.
if [ -z $HOST ]; then
$REAL_BASE_DIR/../helper/msg_error.sh "\
Internal Error: No host given.
A host needs to be provided via the HOST environment variable."
exit 1
fi
if [ -z $OPERATION ]; then
$REAL_BASE_DIR/../helper/msg_error.sh "\
Internal Error: No operation given.
An operation needs to be provided via the OPERATION environment variable."
exit 1
fi
ACTUAL_OPERATION=""
case $OPERATION in
switch|boot|test)
ACTUAL_OPERATION="$OPERATION"
;;
reboot)
ACTUAL_OPERATION="boot"
;;
*)
$REAL_BASE_DIR/../helper/msg_error.sh "\
Internal Error: No valid operation given.
The operation provided via the OPERTION environment variable needs to be one of:
switch, boot, test, reboot."
exit 1
;;
esac
TARGET_HOSTNAME=""
TARGET_USER=""
TARGET_PORT=""
DEPLOYMENT_CONFIGURATION_EXISTS=true
if ! [ -f deployment_configuration.json ]; then
$REAL_BASE_DIR/../helper/msg_warning.sh "\
Warning: No deployment_configuration.json exists and therefore it can't be used to retrieve configuration values."
DEPLOYMENT_CONFIGURATION_EXISTS=false
fi
if $DEPLOYMENT_CONFIGURATION_EXISTS && ! cat deployment_configuration.json | jq . >/dev/null 2>&1; then
$REAL_BASE_DIR/../helper/msg_warning.sh "\
Warning: jq can't parse the deployment_configuration.json and therefore it can't be used to retrieve configuration values."
fi
if $DEPLOYMENT_CONFIGURATION_EXISTS && CONFIG_TARGET_HOSTNAME="$(cat deployment_configuration.json | jq -re .hosts.\"$HOST\".targetHostname 2>/dev/null)"; then
TARGET_HOSTNAME=$CONFIG_TARGET_HOSTNAME
elif NIX_CONFIG_FQDN="$(nix eval --raw .\#nixosConfigurations.$HOST.config.networking.fqdn 2>/dev/null)"; then
TARGET_HOSTNAME=$NIX_CONFIG_FQDN
else
$REAL_BASE_DIR/../helper/msg_error.sh "\
Error: Couldn't determine target hostname for $HOST.
You either need to set targetHostname for this host in the deployment_configuration.json or have an FQDN available in the NixOS configuration of this host, which then gets used for the target hostname."
exit 1
fi
if $DEPLOYMENT_CONFIGURATION_EXISTS && CONFIG_TARGET_USER="$(cat deployment_configuration.json | jq -re .hosts.\"$HOST\".targetUser 2>/dev/null)"; then
TARGET_USER=$CONFIG_TARGET_USER
elif $DEPLOYMENT_CONFIGURATION_EXISTS && CONFIG_DEFAULT_TARGET_USER="$(cat deployment_configuration.json | jq -re '.default.targetUser' 2>/dev/null)"; then
TARGET_USER=$CONFIG_DEFAULT_TARGET_USER
fi
if $DEPLOYMENT_CONFIGURATION_EXISTS && CONFIG_TARGET_PORT="$(cat deployment_configuration.json | jq -re .hosts.\"$HOST\".targetPort 2>/dev/null)"; then
TARGET_PORT=$CONFIG_TARGET_PORT
elif $DEPLOYMENT_CONFIGURATION_EXISTS && CONFIG_DEFAULT_TARGET_PORT="$(cat deployment_configuration.json | jq -re '.default.targetPort' 2>/dev/null)"; then
TARGET_PORT=$CONFIG_DEFAULT_TARGET_PORT
fi
TARGET_HOST="$TARGET_HOSTNAME"
if [ -n "$TARGET_USER" ]; then
TARGET_HOST="$TARGET_USER@$TARGET_HOST"
fi
SSHOPTS=""
if [ -n "$TARGET_PORT" ]; then
SSHOPTS="-o Port=$TARGET_PORT"
fi
set -e
if [ -n "$TARGET_PORT" ]; then
$REAL_BASE_DIR/../helper/msg_info.sh "\
Running nixos-rebuild $ACTUAL_OPERATION for $HOST on $TARGET_HOST:$TARGET_PORT..."
else
$REAL_BASE_DIR/../helper/msg_info.sh "\
Running nixos-rebuild $ACTUAL_OPERATION for $HOST on $TARGET_HOST..."
fi
env NIX_SSHOPTS="$SSHOPTS" nixos-rebuild "$ACTUAL_OPERATION" --flake ".#$HOST" --target-host "$TARGET_HOST" --use-substitutes --use-remote-sudo
if [ "$OPERATION" = "reboot" ]; then
$REAL_BASE_DIR/../helper/msg_info.sh "\
Rebooting $TARGET_HOSTNAME..."
ssh $SSH_OPTS "$TARGET_HOST" sudo systemctl reboot
fi

158
pyproject.toml Normal file
View file

@ -0,0 +1,158 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "infra-rebuild"
version = "0.0.1"
description = "A simple NixOS deployment tool using nixos-rebuild internally, but trying to make infrastructure deployment more convenient."
readme = "README.md"
requires-python = ">=3.10"
keywords = ["nix", "NixOS"]
authors = [
{ name = "June", email = "june@jsts.xyz" },
]
classifiers = [
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
"click",
]
[project.urls]
Documentation = "https://git.hamburg.ccc.de/CCCHH/infra-rebuild#readme"
Issues = "https://git.hamburg.ccc.de/CCCHH/infra-rebuild/issues"
Source = "https://git.hamburg.ccc.de/CCCHH/infra-rebuild"
[project.scripts]
infra-rebuild = "infra_rebuild.cli:infra_rebuild"
[tool.hatch.envs.default]
dependencies = [
"coverage[toml]>=6.5",
"pytest",
]
[tool.hatch.envs.default.scripts]
test = "pytest {args:tests}"
test-cov = "coverage run -m pytest {args:tests}"
cov-report = [
"- coverage combine",
"coverage report",
]
cov = [
"test-cov",
"cov-report",
]
[[tool.hatch.envs.all.matrix]]
python = ["3.10", "3.11"]
[tool.hatch.envs.lint]
detached = true
dependencies = [
"black>=24.4.0",
"mypy>=1.10.0",
"ruff>=0.4.0",
]
[tool.hatch.envs.lint.scripts]
typing = "mypy --install-types --non-interactive {args:src/infra_rebuild tests}"
style = [
"ruff check {args:.}",
"black --check --diff {args:.}",
]
fmt = [
"black {args:.}",
"ruff check --fix {args:.}",
"style",
]
all = [
"style",
"typing",
]
[tool.black]
target-version = ["py310"]
line-length = 120
skip-string-normalization = true
[tool.ruff]
target-version = "py310"
line-length = 120
[tool.ruff.lint]
select = [
"A",
"ARG",
"B",
"C",
"DTZ",
"E",
"EM",
"F",
"FBT",
"I",
"ICN",
"ISC",
"N",
"PLC",
"PLE",
"PLR",
"PLW",
"Q",
"RUF",
"S",
"T",
"TID",
"UP",
"W",
"YTT",
]
ignore = [
# Allow non-abstract empty methods in abstract base classes
"B027",
# Allow boolean positional values in function calls, like `dict.get(... True)`
"FBT003",
# Ignore checks for possible passwords
"S105", "S106", "S107",
# Ignore complexity
"C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915",
]
unfixable = [
# Don't touch unused imports
"F401",
]
[tool.ruff.lint.isort]
known-first-party = ["infra_rebuild"]
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.lint.per-file-ignores]
# Tests can use magic values, assertions, and relative imports
"tests/**/*" = ["PLR2004", "S101", "TID252"]
[tool.coverage.run]
source_pkgs = ["infra_rebuild", "tests"]
branch = true
parallel = true
omit = [
"src/infra_rebuild/__about__.py",
]
[tool.coverage.paths]
infra_rebuild = ["src/infra_rebuild", "*/infra-rebuild/src/infra_rebuild"]
tests = ["tests", "*/infra-rebuild/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]

View file

View file

@ -0,0 +1,6 @@
import sys
if __name__ == "__main__":
from infra_rebuild.cli import infra_rebuild
sys.exit(infra_rebuild())

View file

@ -0,0 +1,51 @@
import click
from infra_rebuild import operations
@click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True)
@click.version_option(prog_name="infra-rebuild")
def infra_rebuild():
pass
@infra_rebuild.command()
@click.argument("hosts", nargs=-1)
def build(hosts):
operations.run("build", hosts)
@infra_rebuild.command()
@click.argument("hosts", nargs=-1)
def build_vm(hosts):
operations.run("build-vm", hosts)
@infra_rebuild.command()
@click.argument("hosts", nargs=-1)
def build_vm_with_bootloader(hosts):
operations.run("build-vm-with-bootloader", hosts)
@infra_rebuild.command()
@click.argument("hosts", nargs=-1)
def switch(hosts):
operations.run("switch", hosts)
@infra_rebuild.command()
@click.argument("hosts", nargs=-1)
def boot(hosts):
operations.run("boot", hosts)
@infra_rebuild.command()
@click.argument("hosts", nargs=-1)
def test(hosts):
operations.run("test", hosts)
@infra_rebuild.command()
@click.argument("hosts", nargs=-1)
def reboot(hosts):
operations.run("reboot", hosts)

View file

@ -0,0 +1,13 @@
import click
def info(message):
click.echo(click.style(message, bold=True, fg="bright_blue"))
def warning(message):
click.echo(click.style(message, bold=True, fg="bright_yellow"), err=True)
def error(message):
click.echo(click.style(message, bold=True, fg="bright_red"), err=True)

View file

@ -0,0 +1,145 @@
import json
import os
import subprocess
import sys
from infra_rebuild import msg
def run(operation, hosts):
act_remotely = False
reboot = False
match operation:
case "build" | "build-vm" | "build-vm-with-bootloader":
actual_operation = operation
case "switch" | "boot" | "test":
act_remotely = True
actual_operation = operation
case "reboot":
act_remotely = True
actual_operation = "boot"
reboot = True
case _:
msg.error("Internal Error: The given operation isn't valid.")
sys.exit(1)
for host in hosts:
if not host:
msg.warning("Skipping empty string provided for host.")
continue
if act_remotely:
remote(actual_operation, host, reboot)
else:
local(actual_operation, host)
def local(operation, host):
msg.info(f"Running nixos-rebuild {operation} for {host}...")
subprocess.run(
["nixos-rebuild", operation, "--flake", f".#{host}"], # noqa: S607, S603 - can't know what the users system looks like, maybe do some kind of validation for the given host in the future?
check=False
) # fmt: skip
def remote(operation, host, reboot=False): # noqa: FBT002 - having reboot as a Boolean positional argument is fine I think # fmt: skip
config = None
try:
with open("deployment_configuration.json") as config_file:
try:
config = json.load(config_file)
except json.JSONDecodeError:
msg.warning(
"The deployment_configuration.json is not a valid JSON document and therefore it can't be used to retrieve configuration values." # noqa: E501
)
except FileNotFoundError:
msg.warning(
"No deployment_configuration.json exists and therefore it can't be used to retrieve configuration values."
)
except OSError:
msg.warning(
"Couldn't open deployment_configuration.json and therefore it can't be used to retrieve configuration values." # noqa: E501
)
target_hostname = None
if config:
try:
target_hostname = config["hosts"][host]["targetHostname"]
except KeyError:
pass
if not target_hostname:
nix_config_fqdn = subprocess.run(
["nix", "eval", "--raw", f".#nixosConfigurations.{host}.config.networking.fqdn"], # noqa: S607, S603 - can't know what the users system looks like, maybe do some kind of validation for host in the future?
capture_output=True,
text=True,
check=False,
) # fmt: skip
if nix_config_fqdn.returncode == 0 and nix_config_fqdn.stdout:
target_hostname = nix_config_fqdn.stdout
if not target_hostname:
msg.error(f"Couldn't determine target hostname for {host}.")
msg.error(
"You either need to set targetHostname for this host in the deployment_configuration.json or have an FQDN available in the NixOS configuration of this host, which then gets used for the target hostname." # noqa: E501
)
msg.error(f"Skipping {host}.")
return
target_user = None
if config:
try:
target_user = config["hosts"][host]["targetUser"]
except KeyError:
pass
if config and not target_user:
try:
target_user = config["default"]["targetUser"]
except KeyError:
pass
target_port = None
if config:
try:
target_port = config["hosts"][host]["targetPort"]
except KeyError:
pass
if config and not target_port:
try:
target_port = config["default"]["targetPort"]
except KeyError:
pass
target_host = target_hostname
if target_user:
target_host = f"{target_user}@{target_host}"
ssh_opts = None
if target_port:
ssh_opts = f"-o Port={target_port}"
nixos_rebuild_env = os.environ.copy()
if ssh_opts:
nixos_rebuild_env["NIX_SSHOPTS"] = ssh_opts
if target_port:
msg.info(f"Running nixos-rebuild {operation} for {host} on {target_host}:{target_port}...")
else:
msg.info(f"Running nixos-rebuild {operation} for {host} on {target_host}...")
nixos_rebuild = subprocess.run(
["nixos-rebuild", operation, "--flake", f".#{host}", "--target-host", target_host, "--use-substitutes", "--use-remote-sudo"], # noqa: S607, S603, E501 - can't know what the users system looks like, maybe do some kind of validation for the given host in the future?, not breaking up the command makes it more readable
env=nixos_rebuild_env,
check=False,
) # fmt: skip
if reboot and nixos_rebuild.returncode == 0:
msg.info(f"Rebooting {target_hostname}...")
if ssh_opts:
subprocess.run(
["ssh", ssh_opts, target_host, "sudo", "systemctl", "reboot"], # noqa: S607, S603 - can't know what the users system looks like, maybe do some kind of validation for the given host in the future?
check=False
) # fmt: skip
else:
subprocess.run(
["ssh", target_host, "sudo", "systemctl", "reboot"], # noqa: S607, S603 - can't know what the users system looks like, maybe do some kind of validation for the given host in the future?
check=False,
) # fmt: skip

0
tests/__init__.py Normal file
View file