/
rebuild.py
executable file
·251 lines (217 loc) · 7.64 KB
/
rebuild.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
#! /usr/bin/env nix-shell
#! nix-shell -i python -p python38
"""Script for rebuilding the NixOS configuration.
Run `./rebuild.py --help` for options.
"""
# Unported part of the fish function used for non-NixOS rebuilds:
# if is_darwin
# set __nix_pkg_expr "$HOME/.config/nix-config/macos.nix"
# echo -s (set_color --bold --underline) "On MacOS; building environment from $__nix_pkg_expr" (set_color normal)
# echo "Installing:"
# nix-instantiate --eval --strict \
# --expr "builtins.map (p: p.name) (import $__nix_pkg_expr {})"
# nix-env --install --remove-all --file "$__nix_pkg_expr"
from __future__ import annotations
import argparse
import os
import shlex
import subprocess
from dataclasses import dataclass, replace
from pathlib import Path
from typing import List, Optional
from tempfile import TemporaryDirectory
from util import cmd, dbg, error, fatal, info, p, run_or_fatal, show_dbg, warn
# Subcommands for `nixos-rebuild`.
# See: `man 8 nixos-rebuild`.
REBUILD_SUBCOMMANDS = [
"switch",
"build",
"boot",
"test",
"dry-build",
"dry-activate",
"edit",
"build-vm",
"build-vm-with-bootloader",
]
def main(args: Optional[Args] = None) -> None:
"""Entry point."""
if args is None:
args = Args.parse_args()
# Okay, so we don't actually use `os.chdir` here. Why? If we split a panel
# while rebuilding (or open a new window), tmux starts the new shell in the
# current process' cwd. Therefore, so we don't end up accidentally mucking
# around in `/etc/nixos`, we don't change the cwd and instead use
# `cwd=args.repo` for `subprocess.run` invocations.
cmd(f"cd {p(args.repo)}")
if args.fix_full_boot:
fix_full_boot(args)
pull(args)
rebuild(args.rebuild_args, args.sudo_prefix, args.repo)
def pull(args: Args) -> None:
"""Update ``args.repo`` with ``git pull``."""
if args.pull:
cmd("git pull --no-edit")
proc = subprocess.run(
args.sudo_prefix + ["git", "pull", "--no-edit"], check=False, cwd=args.repo
)
if proc.returncode != 0:
# git pull failed, maybe reset?
if args.reset:
run_or_fatal(
args.sudo_prefix + ["git", "reset", "--hard", args.remote_branch],
log=True,
cwd=args.repo,
)
else:
fatal("`git pull` failed. Pass `--reset` to reset the repository.")
info(f"{args.repo} is now at commit:")
subprocess.run(
["git", "log", "HEAD^1..HEAD", "--oneline"], check=False, cwd=args.repo
)
def rebuild(
rebuild_args: List[str],
sudo_prefix: List[str],
repo: Path,
rebuild_cwd: Optional[Path] = None,
) -> None:
"""Run ``nixos-rebuild``."""
if rebuild_cwd is None:
rebuild_cwd = repo
run_or_fatal(sudo_prefix + ["./init.py"], log=True, cwd=repo)
run_or_fatal(
rebuild_args,
log=True,
cwd=rebuild_cwd,
)
def fix_full_boot(args: Args) -> None:
"""Fixes 'no space left on device' error by deleting old generations."""
info(
"Cleaning up full /boot partition; removing old generations and garbage-collecting the Nix store."
)
# Before we try `./rebuild.py --fix-full-boot`, we probably built the whole
# profile but couldn't switch to it. We're going to run the Nix garbage
# collector, so we're going to do a plain `nixos-rebuild build` to prevent
# the newly-built profile from being deleted, forcing us to recompile all
# our work and wasting a lot of time.
with TemporaryDirectory() as tmpdir:
# We need to use the `nixos-rebuild build` command, so we create a new
# `Args` object with `rebuild_subcommand` set to `build`.
args = replace(args, rebuild_subcommand='build')
rebuild(
rebuild_args=args.rebuild_args,
sudo_prefix=args.sudo_prefix,
repo=args.repo,
rebuild_cwd=Path(tmpdir),
)
profile_path = os.readlink(os.path.join(tmpdir, "result"))
info(f"Newly-built profile: {p(profile_path)}")
# Finally, collect garbage.
run_or_fatal(
args.sudo_prefix + ["nix-collect-garbage", "--delete-old"], log=True
)
@dataclass
class Args:
"""Parsed command-line arguments."""
repo: Path
remote_branch: str
reset: bool
pull: bool
sudo: bool
rebuild_subcommand: str
extra_rebuild_args: List[str]
extra_sudo_args: List[str]
fix_full_boot: bool
@property
def sudo_prefix(self) -> List[str]:
"""Arguments to prepend to ``subprocess.run``.
The arguments call ``sudo`` if ``self.sudo`` is ``True``.
"""
if self.sudo:
return ["sudo"] + self.extra_sudo_args
else:
return []
@property
def rebuild_args(self) -> List[str]:
"""Arguments to run ``nixos-rebuild``."""
return (
self.sudo_prefix
+ ["nixos-rebuild", self.rebuild_subcommand]
+ self.extra_rebuild_args
)
@classmethod
def _argparser(cls) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="""Rebuilds the current NixOS configuration.
Unrecognized arguments are passed to `nixos-rebuild`.
""",
)
parser.add_argument(
"--repo",
type=Path,
default="/etc/nixos",
help="""The path to the authoritative repository to build the
configuration from. Default: "/etc/nixos".""",
)
parser.add_argument(
"-r",
"--reset",
action="store_true",
help="""If given and `git pull` fails, reset to the origin branch
instead of failing.""",
)
parser.add_argument(
"--no-pull",
action="store_false",
dest="pull",
help="If given, don't `git pull` in the repo.",
)
parser.add_argument(
"--no-sudo",
action="store_false",
dest="sudo",
help="If given, don't run commands through `sudo`",
)
parser.add_argument(
"--sudo-args",
type=shlex.split,
default=[],
help="""Extra arguments to add to `sudo`; parsed with `shlex.split`.""",
)
parser.add_argument(
"--remote-branch",
default="origin/main",
help="""Remote branch to reset to, if needed. Default: "origin/main".""",
)
parser.add_argument(
"-f",
"--fix-full-boot",
action="store_true",
help="""Fix the 'no space left on device' error when switching to a
new configuration; deletes old profiles before rebuilding.""",
)
return parser
@classmethod
def parse_args(cls) -> Args:
"""Type-safe wrapper around ``argparse.ArgumentParser.parse_args``."""
args, rest = cls._argparser().parse_known_args()
return cls(
repo=args.repo,
remote_branch=args.remote_branch,
reset=args.reset,
pull=args.pull,
sudo=args.sudo,
rebuild_subcommand=cls._rebuild_subcommand(rest),
extra_sudo_args=args.sudo_args,
extra_rebuild_args=rest,
fix_full_boot=args.fix_full_boot,
)
@classmethod
def _rebuild_subcommand(cls, extra_rebuild_args: List[str]) -> str:
for arg in extra_rebuild_args:
if arg in REBUILD_SUBCOMMANDS:
return arg
# Default: switch to the new configuration.
return "switch"
if __name__ == "__main__":
main()