Coverage for r11k/puppetmodule/forge.py: 84%
79 statements
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-13 23:29 +0100
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-13 23:29 +0100
1"""
2Modules form the [Puppet Forge][forge].
4The Forge notes a few installation methods, where we (currently)
5aren't listed. The manual install method
7```sh
8puppet module install puppetlabs-stdlib --version 8.4.0
9```
10maps unto us as
11>>> ForgePuppetModule(name='puppetlabs-stdlib', version='8.4.0')
13[forge]: https://forge.puppet.com/
14"""
16import logging
17import os.path
18import hashlib
19import threading
20import shutil
22from semver import VersionInfo
23import tarfile
25from r11k.puppetmodule.base import PuppetModule
26from r11k.puppet import PuppetMetadata
27from r11k.forge import FullForgeModule, CurrentRelease
28from r11k import util
29from r11k.util import (
30 unfix_name,
31)
34logger = logging.getLogger(__name__)
37class ForgePuppetModule(PuppetModule):
38 """
39 Puppet module backed by the Puppet Forge.
41 :param name: Module name, as noted on the Forge for installation.
42 :param version: Semver formatted string, as per the Puppet Forge
43 """
45 def _fetch_metadata(self) -> PuppetMetadata:
46 """Return metadata for this module."""
47 try:
48 version = self.version or self.latest()
49 return self.get_versioned_forge_module_metadata(self.name, version).metadata
50 except Exception as e:
51 logger.error(self)
52 raise e
54 def _fetch_latest_metadata(self) -> FullForgeModule:
55 """
56 Get complete metadata for module.
58 Possibly download the module from puppet forge, or simple look it
59 up in the module cache.
61 :param module_name: Name of the module to look up.
62 """
63 unfixed_module_name = unfix_name(self.name)
65 url = f'{self.config.api_base}/v3/modules/{unfixed_module_name}'
66 data = self.config.httpcache.get(url)
67 if not data: 67 ↛ 68line 67 didn't jump to line 68, because the condition on line 67 was never true
68 raise ValueError('No data')
70 return FullForgeModule(**data)
72 def fetch(self) -> str:
73 """
74 Download module from puppet forge.
76 Return path to a tarball.
77 """
78 name = self.name
79 version = self.version
81 # TODO this should use the forges file_uri instead (when available)
82 url = f"{self.config.api_base}/v3/files/{name}-{version}.tar.gz"
84 # TODO this should use the HTTP cache, but currently HTTPcache
85 # is hardcoded to expect JSON responses
86 filename = os.path.join(self.config.tar_cache, f'{name}-{version}.tar.gz')
88 if not os.path.exists(filename):
89 util.download_file(url, filename)
91 return filename
93 def publish(self, path: str) -> None:
94 """
95 Extract this module to path.
97 Does some checks if the source and target differs, and does
98 nothing if they are the same.
99 """
100 tar_filepath = self.fetch()
101 with tarfile.open(tar_filepath) as archive:
102 archive_root = archive.next()
103 if not archive_root: 103 ↛ 104line 103 didn't jump to line 104, because the condition on line 103 was never true
104 raise ValueError(f"Empty tarfile: {tar_filepath}")
105 if not archive_root.isdir(): 105 ↛ 106line 105 didn't jump to line 106, because the condition on line 105 was never true
106 raise ValueError(f"Tar root not a directory: {tar_filepath}, {archive_root.name}")
108 unlink_old: bool = False
109 if os.path.exists(path):
110 if os.path.isdir(path): 110 ↛ 124line 110 didn't jump to line 124, because the condition on line 110 was never false
111 with open(os.path.join(path, 'metadata.json'), 'rb') as f:
112 file_meta_chk = hashlib.sha256(f.read())
114 member = archive.getmember(archive_root.name + '/metadata.json')
115 if not archive.fileobj: 115 ↛ 116line 115 didn't jump to line 116, because the condition on line 115 was never true
116 raise ValueError('Underlying tar-file non-existant')
117 archive.fileobj.seek(member.offset_data)
118 tar_meta_chk = hashlib.sha256(archive.fileobj.read(member.size))
120 if tar_meta_chk == file_meta_chk: 120 ↛ 121line 120 didn't jump to line 121, because the condition on line 120 was never true
121 return
122 unlink_old = True
123 else:
124 os.unlink(path)
126 archive.extractall(path=os.path.dirname(path))
127 extract_path = os.path.join(os.path.dirname(path),
128 archive_root.name)
129 tmp_path = os.path.join(os.path.dirname(path), '.old-' + os.path.basename(path))
130 # This will hopefully cause the update to be
131 # unnoticable to any observers
132 with threading.Lock():
133 if unlink_old:
134 os.rename(path, tmp_path)
135 os.rename(extract_path, path)
136 if os.path.exists(tmp_path):
137 shutil.rmtree(tmp_path)
139 def versions(self) -> list[VersionInfo]:
140 """Return available versions."""
141 j = self._fetch_latest_metadata()
142 # return [o['version'] for o in j['releases']]
143 return [o.version for o in j.releases]
145 def __repr__(self) -> str:
146 return f"ForgePuppetModule('{self.name}', '{self.version}')"
148 def get_versioned_forge_module_metadata(self, module_name: str, version: str) -> CurrentRelease:
149 """Retrieve release data from puppet forge."""
150 unfixed_module_name = unfix_name(module_name)
151 url = f'{self.config.api_base}/v3/releases/{unfixed_module_name}-{version}'
152 data = self.config.httpcache.get(url)
153 if not data: 153 ↛ 154line 153 didn't jump to line 154, because the condition on line 153 was never true
154 raise ValueError('No Data')
155 return CurrentRelease(**data)