]> git.scottworley.com Git - pinch/blame - pinch.py
Introduce SearchPath type
[pinch] / pinch.py
CommitLineData
0e5e611d 1import argparse
f15e458d 2import configparser
2f96f32a
SW
3import filecmp
4import functools
736c25eb 5import getpass
2f96f32a
SW
6import hashlib
7import operator
8import os
9import os.path
9a78329e 10import shlex
2f96f32a 11import shutil
73bec7e8 12import subprocess
9d7844bb 13import sys
2f96f32a 14import tempfile
89e79125 15import types
2f96f32a
SW
16import urllib.parse
17import urllib.request
18import xml.dom.minidom
19
20from typing import (
2f96f32a
SW
21 Dict,
22 Iterable,
23 List,
73bec7e8 24 NewType,
2f96f32a
SW
25 Tuple,
26)
27
3603dde2
SW
28# Use xdg module when it's less painful to have as a dependency
29
30
31class XDG(types.SimpleNamespace):
32 XDG_CACHE_HOME: str
33
34
35xdg = XDG(
36 XDG_CACHE_HOME=os.getenv(
37 'XDG_CACHE_HOME',
38 os.path.expanduser('~/.cache')))
26125a28
SW
39
40
73bec7e8
SW
41Digest16 = NewType('Digest16', str)
42Digest32 = NewType('Digest32', str)
43
2f96f32a 44
72d3478a 45class ChannelTableEntry(types.SimpleNamespace):
e434d96d 46 absolute_url: str
73bec7e8 47 digest: Digest16
89e79125
SW
48 file: str
49 size: int
50 url: str
51
52
d44818a9
SW
53class SearchPath(types.SimpleNamespace):
54 release_name: str
55
56
57class Channel(SearchPath):
17906b27 58 alias_of: str
89e79125 59 channel_html: bytes
b17def3f 60 channel_url: str
89e79125 61 forwarded_url: str
dc038df0
SW
62 git_ref: str
63 git_repo: str
89e79125 64 git_revision: str
8fca6c28 65 old_git_revision: str
72d3478a 66 table: Dict[str, ChannelTableEntry]
89e79125
SW
67
68
2f96f32a
SW
69class VerificationError(Exception):
70 pass
71
72
73class Verification:
74
75 def __init__(self) -> None:
76 self.line_length = 0
77
78 def status(self, s: str) -> None:
9d7844bb 79 print(s, end=' ', file=sys.stderr, flush=True)
2f96f32a
SW
80 self.line_length += 1 + len(s) # Unicode??
81
82 @staticmethod
83 def _color(s: str, c: int) -> str:
84 return '\033[%2dm%s\033[00m' % (c, s)
85
86 def result(self, r: bool) -> None:
87 message, color = {True: ('OK ', 92), False: ('FAIL', 91)}[r]
88 length = len(message)
dd1026fe 89 cols = shutil.get_terminal_size().columns or 80
2f96f32a 90 pad = (cols - (self.line_length + length)) % cols
9d7844bb 91 print(' ' * pad + self._color(message, color), file=sys.stderr)
2f96f32a
SW
92 self.line_length = 0
93 if not r:
94 raise VerificationError()
95
96 def check(self, s: str, r: bool) -> None:
97 self.status(s)
98 self.result(r)
99
100 def ok(self) -> None:
101 self.result(True)
102
103
dc038df0 104def compare(a: str, b: str) -> Tuple[List[str], List[str], List[str]]:
2f96f32a
SW
105
106 def throw(error: OSError) -> None:
107 raise error
108
109 def join(x: str, y: str) -> str:
110 return y if x == '.' else os.path.join(x, y)
111
112 def recursive_files(d: str) -> Iterable[str]:
113 all_files: List[str] = []
114 for path, dirs, files in os.walk(d, onerror=throw):
115 rel = os.path.relpath(path, start=d)
116 all_files.extend(join(rel, f) for f in files)
117 for dir_or_link in dirs:
118 if os.path.islink(join(path, dir_or_link)):
119 all_files.append(join(rel, dir_or_link))
120 return all_files
121
122 def exclude_dot_git(files: Iterable[str]) -> Iterable[str]:
123 return (f for f in files if not f.startswith('.git/'))
124
125 files = functools.reduce(
126 operator.or_, (set(
127 exclude_dot_git(
128 recursive_files(x))) for x in [a, b]))
129 return filecmp.cmpfiles(a, b, files, shallow=False)
130
131
ca2c3edd 132def fetch(v: Verification, channel: Channel) -> None:
2f96f32a 133 v.status('Fetching channel')
b17def3f 134 request = urllib.request.urlopen(channel.channel_url, timeout=10)
72d3478a
SW
135 channel.channel_html = request.read()
136 channel.forwarded_url = request.geturl()
7c4de64c 137 v.result(request.status == 200) # type: ignore # (for old mypy)
b17def3f 138 v.check('Got forwarded', channel.channel_url != channel.forwarded_url)
2f96f32a
SW
139
140
72d3478a 141def parse_channel(v: Verification, channel: Channel) -> None:
2f96f32a 142 v.status('Parsing channel description as XML')
72d3478a 143 d = xml.dom.minidom.parseString(channel.channel_html)
2f96f32a
SW
144 v.ok()
145
3e6421c4
SW
146 v.status('Extracting release name:')
147 title_name = d.getElementsByTagName(
148 'title')[0].firstChild.nodeValue.split()[2]
149 h1_name = d.getElementsByTagName('h1')[0].firstChild.nodeValue.split()[2]
150 v.status(title_name)
151 v.result(title_name == h1_name)
72d3478a 152 channel.release_name = title_name
3e6421c4
SW
153
154 v.status('Extracting git commit:')
2f96f32a 155 git_commit_node = d.getElementsByTagName('tt')[0]
61aaf799
SW
156 channel.git_revision = git_commit_node.firstChild.nodeValue
157 v.status(channel.git_revision)
2f96f32a
SW
158 v.ok()
159 v.status('Verifying git commit label')
160 v.result(git_commit_node.previousSibling.nodeValue == 'Git commit ')
161
162 v.status('Parsing table')
72d3478a 163 channel.table = {}
2f96f32a
SW
164 for row in d.getElementsByTagName('tr')[1:]:
165 name = row.childNodes[0].firstChild.firstChild.nodeValue
166 url = row.childNodes[0].firstChild.getAttribute('href')
167 size = int(row.childNodes[1].firstChild.nodeValue)
73bec7e8 168 digest = Digest16(row.childNodes[2].firstChild.firstChild.nodeValue)
dc038df0
SW
169 channel.table[name] = ChannelTableEntry(
170 url=url, digest=digest, size=size)
2f96f32a
SW
171 v.ok()
172
173
dc038df0
SW
174def digest_string(s: bytes) -> Digest16:
175 return Digest16(hashlib.sha256(s).hexdigest())
176
177
73bec7e8
SW
178def digest_file(filename: str) -> Digest16:
179 hasher = hashlib.sha256()
180 with open(filename, 'rb') as f:
181 # pylint: disable=cell-var-from-loop
182 for block in iter(lambda: f.read(4096), b''):
183 hasher.update(block)
184 return Digest16(hasher.hexdigest())
185
186
187def to_Digest16(v: Verification, digest32: Digest32) -> Digest16:
188 v.status('Converting digest to base16')
189 process = subprocess.run(
ba596fc0 190 ['nix', 'to-base16', '--type', 'sha256', digest32], stdout=subprocess.PIPE)
73bec7e8
SW
191 v.result(process.returncode == 0)
192 return Digest16(process.stdout.decode().strip())
193
194
195def to_Digest32(v: Verification, digest16: Digest16) -> Digest32:
196 v.status('Converting digest to base32')
197 process = subprocess.run(
ba596fc0 198 ['nix', 'to-base32', '--type', 'sha256', digest16], stdout=subprocess.PIPE)
73bec7e8
SW
199 v.result(process.returncode == 0)
200 return Digest32(process.stdout.decode().strip())
201
202
203def fetch_with_nix_prefetch_url(
204 v: Verification,
205 url: str,
206 digest: Digest16) -> str:
207 v.status('Fetching %s' % url)
208 process = subprocess.run(
ba596fc0 209 ['nix-prefetch-url', '--print-path', url, digest], stdout=subprocess.PIPE)
73bec7e8
SW
210 v.result(process.returncode == 0)
211 prefetch_digest, path, empty = process.stdout.decode().split('\n')
212 assert empty == ''
213 v.check("Verifying nix-prefetch-url's digest",
214 to_Digest16(v, Digest32(prefetch_digest)) == digest)
215 v.status("Verifying file digest")
216 file_digest = digest_file(path)
217 v.result(file_digest == digest)
7c4de64c 218 return path # type: ignore # (for old mypy)
2f96f32a 219
73bec7e8 220
72d3478a 221def fetch_resources(v: Verification, channel: Channel) -> None:
2f96f32a 222 for resource in ['git-revision', 'nixexprs.tar.xz']:
72d3478a 223 fields = channel.table[resource]
e434d96d
SW
224 fields.absolute_url = urllib.parse.urljoin(
225 channel.forwarded_url, fields.url)
226 fields.file = fetch_with_nix_prefetch_url(
227 v, fields.absolute_url, fields.digest)
73bec7e8
SW
228 v.status('Verifying git commit on main page matches git commit in table')
229 v.result(
230 open(
61aaf799 231 channel.table['git-revision'].file).read(999) == channel.git_revision)
2f96f32a 232
971d3659 233
9836141c 234def git_cachedir(git_repo: str) -> str:
26125a28
SW
235 return os.path.join(
236 xdg.XDG_CACHE_HOME,
237 'pinch/git',
eb0c6f1b
SW
238 digest_string(git_repo.encode()))
239
240
241def tarball_cache_file(channel: Channel) -> str:
242 return os.path.join(
243 xdg.XDG_CACHE_HOME,
244 'pinch/git-tarball',
245 '%s-%s-%s' %
246 (digest_string(channel.git_repo.encode()),
247 channel.git_revision,
248 channel.release_name))
971d3659
SW
249
250
251def verify_git_ancestry(v: Verification, channel: Channel) -> None:
252 cachedir = git_cachedir(channel.git_repo)
253 v.status('Verifying rev is an ancestor of ref')
254 process = subprocess.run(['git',
255 '-C',
256 cachedir,
257 'merge-base',
258 '--is-ancestor',
259 channel.git_revision,
260 channel.git_ref])
261 v.result(process.returncode == 0)
262
263 if hasattr(channel, 'old_git_revision'):
264 v.status(
265 'Verifying rev is an ancestor of previous rev %s' %
266 channel.old_git_revision)
267 process = subprocess.run(['git',
268 '-C',
269 cachedir,
270 'merge-base',
271 '--is-ancestor',
272 channel.old_git_revision,
273 channel.git_revision])
274 v.result(process.returncode == 0)
9836141c 275
2f96f32a 276
dc038df0
SW
277def git_fetch(v: Verification, channel: Channel) -> None:
278 # It would be nice if we could share the nix git cache, but as of the time
279 # of writing it is transitioning from gitv2 (deprecated) to gitv3 (not ready
280 # yet), and trying to straddle them both is too far into nix implementation
281 # details for my comfort. So we re-implement here half of nix.fetchGit.
282 # :(
283
9836141c
SW
284 cachedir = git_cachedir(channel.git_repo)
285 if not os.path.exists(cachedir):
dc038df0
SW
286 v.status("Initializing git repo")
287 process = subprocess.run(
9836141c 288 ['git', 'init', '--bare', cachedir])
dc038df0
SW
289 v.result(process.returncode == 0)
290
971d3659
SW
291 v.status('Fetching ref "%s" from %s' % (channel.git_ref, channel.git_repo))
292 # We don't use --force here because we want to abort and freak out if forced
293 # updates are happening.
294 process = subprocess.run(['git',
295 '-C',
296 cachedir,
297 'fetch',
298 channel.git_repo,
299 '%s:%s' % (channel.git_ref,
300 channel.git_ref)])
301 v.result(process.returncode == 0)
302
8fca6c28 303 if hasattr(channel, 'git_revision'):
971d3659 304 v.status('Verifying that fetch retrieved this rev')
8fca6c28 305 process = subprocess.run(
9836141c 306 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
dc038df0 307 v.result(process.returncode == 0)
971d3659 308 else:
8fca6c28
SW
309 channel.git_revision = open(
310 os.path.join(
9836141c 311 cachedir,
8fca6c28
SW
312 'refs',
313 'heads',
314 channel.git_ref)).read(999).strip()
dc038df0 315
971d3659 316 verify_git_ancestry(v, channel)
dc038df0 317
971d3659
SW
318
319def ensure_git_rev_available(v: Verification, channel: Channel) -> None:
320 cachedir = git_cachedir(channel.git_repo)
321 if os.path.exists(cachedir):
322 v.status('Checking if we already have this rev:')
323 process = subprocess.run(
324 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
325 if process.returncode == 0:
326 v.status('yes')
327 if process.returncode == 1:
328 v.status('no')
329 v.result(process.returncode == 0 or process.returncode == 1)
330 if process.returncode == 0:
331 verify_git_ancestry(v, channel)
332 return
333 git_fetch(v, channel)
7d889b12 334
dc038df0 335
925c801b
SW
336def compare_tarball_and_git(
337 v: Verification,
338 channel: Channel,
339 channel_contents: str,
340 git_contents: str) -> None:
341 v.status('Comparing channel tarball with git checkout')
342 match, mismatch, errors = compare(os.path.join(
343 channel_contents, channel.release_name), git_contents)
344 v.ok()
345 v.check('%d files match' % len(match), len(match) > 0)
346 v.check('%d files differ' % len(mismatch), len(mismatch) == 0)
347 expected_errors = [
348 '.git-revision',
349 '.version-suffix',
350 'nixpkgs',
351 'programs.sqlite',
352 'svn-revision']
353 benign_errors = []
354 for ee in expected_errors:
355 if ee in errors:
356 errors.remove(ee)
357 benign_errors.append(ee)
358 v.check(
359 '%d unexpected incomparable files' %
360 len(errors),
361 len(errors) == 0)
362 v.check(
363 '(%d of %d expected incomparable files)' %
364 (len(benign_errors),
365 len(expected_errors)),
366 len(benign_errors) == len(expected_errors))
367
368
369def extract_tarball(v: Verification, channel: Channel, dest: str) -> None:
370 v.status('Extracting tarball %s' %
371 channel.table['nixexprs.tar.xz'].file)
372 shutil.unpack_archive(
373 channel.table['nixexprs.tar.xz'].file,
374 dest)
375 v.ok()
376
377
378def git_checkout(v: Verification, channel: Channel, dest: str) -> None:
379 v.status('Checking out corresponding git revision')
380 git = subprocess.Popen(['git',
381 '-C',
9836141c 382 git_cachedir(channel.git_repo),
925c801b 383 'archive',
61aaf799 384 channel.git_revision],
925c801b
SW
385 stdout=subprocess.PIPE)
386 tar = subprocess.Popen(
387 ['tar', 'x', '-C', dest, '-f', '-'], stdin=git.stdout)
de68382a
SW
388 if git.stdout:
389 git.stdout.close()
925c801b
SW
390 tar.wait()
391 git.wait()
392 v.result(git.returncode == 0 and tar.returncode == 0)
393
394
736c25eb 395def git_get_tarball(v: Verification, channel: Channel) -> str:
eb0c6f1b
SW
396 cache_file = tarball_cache_file(channel)
397 if os.path.exists(cache_file):
398 cached_tarball = open(cache_file).read(9999)
399 if os.path.exists(cached_tarball):
400 return cached_tarball
401
736c25eb
SW
402 with tempfile.TemporaryDirectory() as output_dir:
403 output_filename = os.path.join(
404 output_dir, channel.release_name + '.tar.xz')
405 with open(output_filename, 'w') as output_file:
406 v.status(
407 'Generating tarball for git revision %s' %
408 channel.git_revision)
409 git = subprocess.Popen(['git',
410 '-C',
411 git_cachedir(channel.git_repo),
412 'archive',
413 '--prefix=%s/' % channel.release_name,
414 channel.git_revision],
415 stdout=subprocess.PIPE)
416 xz = subprocess.Popen(['xz'], stdin=git.stdout, stdout=output_file)
417 xz.wait()
418 git.wait()
419 v.result(git.returncode == 0 and xz.returncode == 0)
420
421 v.status('Putting tarball in Nix store')
422 process = subprocess.run(
ba596fc0 423 ['nix-store', '--add', output_filename], stdout=subprocess.PIPE)
736c25eb 424 v.result(process.returncode == 0)
eb0c6f1b
SW
425 store_tarball = process.stdout.decode().strip()
426
427 os.makedirs(os.path.dirname(cache_file), exist_ok=True)
428 open(cache_file, 'w').write(store_tarball)
7c4de64c 429 return store_tarball # type: ignore # (for old mypy)
736c25eb
SW
430
431
f9cd7bdc
SW
432def check_channel_metadata(
433 v: Verification,
434 channel: Channel,
435 channel_contents: str) -> None:
436 v.status('Verifying git commit in channel tarball')
437 v.result(
438 open(
439 os.path.join(
440 channel_contents,
441 channel.release_name,
61aaf799 442 '.git-revision')).read(999) == channel.git_revision)
f9cd7bdc
SW
443
444 v.status(
445 'Verifying version-suffix is a suffix of release name %s:' %
446 channel.release_name)
447 version_suffix = open(
448 os.path.join(
449 channel_contents,
450 channel.release_name,
451 '.version-suffix')).read(999)
452 v.status(version_suffix)
453 v.result(channel.release_name.endswith(version_suffix))
454
455
72d3478a 456def check_channel_contents(v: Verification, channel: Channel) -> None:
dc038df0
SW
457 with tempfile.TemporaryDirectory() as channel_contents, \
458 tempfile.TemporaryDirectory() as git_contents:
925c801b
SW
459
460 extract_tarball(v, channel, channel_contents)
f9cd7bdc
SW
461 check_channel_metadata(v, channel, channel_contents)
462
925c801b
SW
463 git_checkout(v, channel, git_contents)
464
465 compare_tarball_and_git(v, channel, channel_contents, git_contents)
466
dc038df0 467 v.status('Removing temporary directories')
2f96f32a
SW
468 v.ok()
469
470
8fca6c28
SW
471def pin_channel(v: Verification, channel: Channel) -> None:
472 fetch(v, channel)
473 parse_channel(v, channel)
474 fetch_resources(v, channel)
971d3659 475 ensure_git_rev_available(v, channel)
8fca6c28
SW
476 check_channel_contents(v, channel)
477
478
e3cae769
SW
479def git_revision_name(v: Verification, channel: Channel) -> str:
480 v.status('Getting commit date')
481 process = subprocess.run(['git',
482 '-C',
9836141c 483 git_cachedir(channel.git_repo),
bed32182 484 'log',
e3cae769
SW
485 '-n1',
486 '--format=%ct-%h',
487 '--abbrev=11',
88af5903 488 '--no-show-signature',
e3cae769 489 channel.git_revision],
ba596fc0 490 stdout=subprocess.PIPE)
de68382a 491 v.result(process.returncode == 0 and process.stdout != b'')
e3cae769
SW
492 return '%s-%s' % (os.path.basename(channel.git_repo),
493 process.stdout.decode().strip())
494
495
01ba0eb2
SW
496def read_config(filename: str) -> configparser.ConfigParser:
497 config = configparser.ConfigParser()
498 config.read_file(open(filename), filename)
499 return config
500
501
0e5e611d 502def pin(args: argparse.Namespace) -> None:
2f96f32a 503 v = Verification()
01ba0eb2 504 config = read_config(args.channels_file)
5cfa8e11 505 for section in config.sections():
98853153
SW
506 if args.channels and section not in args.channels:
507 continue
736c25eb
SW
508
509 channel = Channel(**dict(config[section].items()))
17906b27
SW
510
511 if hasattr(channel, 'alias_of'):
512 assert not hasattr(channel, 'git_repo')
513 continue
514
736c25eb
SW
515 if hasattr(channel, 'git_revision'):
516 channel.old_git_revision = channel.git_revision
517 del channel.git_revision
518
8fca6c28
SW
519 if 'channel_url' in config[section]:
520 pin_channel(v, channel)
736c25eb 521 config[section]['release_name'] = channel.release_name
8fca6c28
SW
522 config[section]['tarball_url'] = channel.table['nixexprs.tar.xz'].absolute_url
523 config[section]['tarball_sha256'] = channel.table['nixexprs.tar.xz'].digest
524 else:
525 git_fetch(v, channel)
736c25eb 526 config[section]['release_name'] = git_revision_name(v, channel)
8fca6c28
SW
527 config[section]['git_revision'] = channel.git_revision
528
0e5e611d 529 with open(args.channels_file, 'w') as configfile:
e434d96d 530 config.write(configfile)
2f96f32a
SW
531
532
10ddbaff
SW
533def fetch_channel(
534 v: Verification,
535 section: str,
536 conf: configparser.SectionProxy) -> str:
537 if 'git_repo' not in conf or 'release_name' not in conf:
538 raise Exception(
539 'Cannot update unpinned channel "%s" (Run "pin" before "update")' %
540 section)
541
542 if 'channel_url' in conf:
543 return fetch_with_nix_prefetch_url(
544 v, conf['tarball_url'], Digest16(
545 conf['tarball_sha256']))
546
547 channel = Channel(**dict(conf.items()))
548 ensure_git_rev_available(v, channel)
549 return git_get_tarball(v, channel)
550
551
736c25eb
SW
552def update(args: argparse.Namespace) -> None:
553 v = Verification()
554 config = configparser.ConfigParser()
da135b07 555 exprs: Dict[str, str] = {}
01ba0eb2
SW
556 configs = [read_config(filename) for filename in args.channels_file]
557 for config in configs:
558 for section in config.sections():
01ba0eb2
SW
559 if 'alias_of' in config[section]:
560 assert 'git_repo' not in config[section]
561 continue
10ddbaff 562 tarball = fetch_channel(v, section, config[section])
ab7ebb2f
SW
563 if section in exprs:
564 raise Exception('Duplicate channel "%s"' % section)
01ba0eb2
SW
565 exprs[section] = (
566 'f: f { name = "%s"; channelName = "%%s"; src = builtins.storePath "%s"; }' %
567 (config[section]['release_name'], tarball))
568
569 for config in configs:
570 for section in config.sections():
571 if 'alias_of' in config[section]:
ab7ebb2f
SW
572 if section in exprs:
573 raise Exception('Duplicate channel "%s"' % section)
01ba0eb2 574 exprs[section] = exprs[str(config[section]['alias_of'])]
17906b27 575
9a78329e
SW
576 command = [
577 'nix-env',
578 '--profile',
579 '/nix/var/nix/profiles/per-user/%s/channels' %
580 getpass.getuser(),
581 '--show-trace',
582 '--file',
583 '<nix/unpack-channel.nix>',
584 '--install',
17906b27 585 '--from-expression'] + [exprs[name] % name for name in sorted(exprs.keys())]
9a78329e
SW
586 if args.dry_run:
587 print(' '.join(map(shlex.quote, command)))
588 else:
589 v.status('Installing channels with nix-env')
590 process = subprocess.run(command)
591 v.result(process.returncode == 0)
736c25eb
SW
592
593
0e5e611d
SW
594def main() -> None:
595 parser = argparse.ArgumentParser(prog='pinch')
596 subparsers = parser.add_subparsers(dest='mode', required=True)
597 parser_pin = subparsers.add_parser('pin')
598 parser_pin.add_argument('channels_file', type=str)
98853153 599 parser_pin.add_argument('channels', type=str, nargs='*')
0e5e611d 600 parser_pin.set_defaults(func=pin)
736c25eb 601 parser_update = subparsers.add_parser('update')
9a78329e 602 parser_update.add_argument('--dry-run', action='store_true')
01ba0eb2 603 parser_update.add_argument('channels_file', type=str, nargs='+')
736c25eb 604 parser_update.set_defaults(func=update)
0e5e611d
SW
605 args = parser.parse_args()
606 args.func(args)
607
608
b5964ec3
SW
609if __name__ == '__main__':
610 main()