Coverage for r11k/puppetfile.py: 77%

127 statements  

« prev     ^ index     » next       coverage.py v7.2.1, created at 2023-03-13 23:53 +0100

1""" 

2Definition and operations on our puppetfile format. 

3 

4This file includes: 

5- The specification of our puppetfile.yaml format 

6 - The thing which publishes our environments 

7- the dependency resolver 

8 

9## Puppetfile.yaml 

10 

11A top level key `modules`, containing a list where each element is a dictionary 

12containing 

13 

14:param name: Either the forge module name, or the target name for a git repo 

15:param [version]: Which version to use. 

16:param [git]: Git repo to clone to retrieve this object. Mutually exclusive with `http` 

17:param [http]: URL where a tarball can be found. Mutually exclusive with `git` 

18 

19If neither a git nor http key is given then it's fetched from the 

20[Puppet Forge][forge] 

21 

22#### `version` 

23If it's a Forge module then the version is looked up in the Forge. If it's a Git 

24repo then the version indicates a given ref (tag, branch, commit, ...). For HTTP 

25the version field is ignored. 

26 

27An absent version field means latest compatible. 

28 

29#### `http` 

30Is currently not implemented. 

31 

32### Sample Puppetfile 

33 

34```yaml 

35modules: 

36 - name: stdlib 

37 version: 8.2.0 

38 - name: extlib 

39 - name: mymodule 

40 git: https://git.example.com/a-different-repo-name.git 

41``` 

42 

43-------------------------------------------------- 

44 

45[forge]: https://forge.puppet.com 

46""" 

47 

48import logging 

49import os.path 

50 

51from dataclasses import dataclass, field 

52from functools import reduce 

53from typing import ( 

54 Any, 

55 Optional, 

56) 

57 

58import yaml 

59 

60import r11k.config 

61from r11k import util 

62from r11k.interval import Interval 

63from r11k import interval 

64from r11k.puppetmodule import ( 

65 PuppetModule, 

66 ForgePuppetModule, 

67 parse_module_dict, 

68) 

69 

70 

71logger = logging.getLogger(__name__) 

72 

73 

74@dataclass 

75class Hiera: 

76 """ 

77 Contents of a hiera.yaml. 

78 

79 https://puppet.com/docs/puppet/7/hiera_config_yaml_5.html 

80 """ 

81 

82 version: int = 5 

83 hierarchy: list[dict[str, str]] = field(default_factory=list) 

84 defaults: Optional[dict[str, str]] = None 

85 default_hierarchy: Optional[list[dict[str, str]]] = None 

86 

87 

88@dataclass 

89class PuppetFile: 

90 """Complete contents of a puppetfile.yaml.""" 

91 

92 # TODO do I really want a dict here? Would a set be better? 

93 modules: dict[str, PuppetModule] = field(default_factory=dict) 

94 """ 

95 Module declarations from puppet file. Mapping from module name to 

96 module. 

97 """ 

98 environment_name: Optional[str] = None 

99 """Name of the environment, used when publishing""" 

100 data: dict[str, dict[str, Any]] = field(default_factory=dict) 

101 """ 

102 Hiera data which will be published as part of the environment. 

103 Each top level key is a path (with slashes for delimiters), but 

104 the .yaml suffix omitted. The dictionaries under that will be 

105 written to the output file. 

106 """ 

107 hiera: Optional[Hiera] = None 

108 """hiera.yaml for this environment.""" 

109 config: r11k.config.Config = field(default_factory=lambda: r11k.config.config) 

110 """r11k configuration""" 

111 

112 def include(self, parent: 'PuppetFile') -> None: 

113 """ 

114 Include a parent to this PuppetFile. 

115 

116 ### Merging Strategies 

117 #### modules 

118 Each module from the parent is included, when both specify the 

119 same plugin, the version specified by us gets chosen. 

120 #### hiera 

121 We keep ours if we have one, otherwise we take it from the 

122 parent. 

123 #### data 

124 The set of files from both instances will be merged, 

125 defaulting to our version. The keys inside a file aren't 

126 merged. 

127 """ 

128 self.modules = parent.modules | self.modules 

129 

130 if not self.hiera: 130 ↛ 133line 130 didn't jump to line 133, because the condition on line 130 was never false

131 self.hiera = parent.hiera 

132 

133 self.data = parent.data | self.data 

134 

135 def serialize(self) -> dict[str, Any]: 

136 """Serialize back into format of puppetfile.yaml.""" 

137 # data omitted since that gets published as data directory 

138 # hiera is ommitted since its published as hiera.yaml 

139 # environment name ommited since that becomes the target directory 

140 return {'modules': {k: v.serialize() for k, v in self.modules.items()}} 

141 

142 

143@dataclass 

144class ResolvedPuppetFile(PuppetFile): 

145 """ 

146 Identical to PuppetFile, but modules is the true set. 

147 

148 PuppetFile contains the user supplied modules from the 

149 puppetfile.yaml, this instead has the true set of modules, where 

150 all modules are present, and ALL of them will have exact versions 

151 (when needed). 

152 

153 It's constructor should be seen assumed private. These objects 

154 should only be built through find_all_modules_for_environment. 

155 """ 

156 

157 def publish(self, destination: str) -> None: 

158 """ 

159 Publish actual environments to direcotry. 

160 

161 [Parameters] 

162 destination - Where to publish to. 

163 This is most likely 

164 /etc/puppetlabs/code/environments/{env_name} 

165 """ 

166 logger.info('== Building actual environment ==') 

167 util.ensure_directory(destination) 

168 for module in self.modules.values(): 

169 logger.info(module) 

170 path = os.path.join(destination, 'modules', module.module_path) 

171 module.publish(path) 

172 

173 if self.hiera: 

174 with open(os.path.join(destination, 'hiera.yaml'), 'w') as f: 

175 yaml.dump(self.hiera, f) 

176 

177 if self.data: 

178 for path, data in self.data.items(): 

179 # Re-normalize path for systems with other path delimiters 

180 path = os.path.join(*path.split('/')) 

181 path = os.path.join(destination, 'data', f'{path}.yaml') 

182 os.makedirs(os.path.dirname(path), exist_ok=True) 

183 with open(path, 'w') as f: 

184 yaml.dump(data, f) 

185 

186 with open(os.path.join(destination, 'puppetfile.yaml'), 'w') as f: 

187 yaml.dump(self.serialize(), f) 

188 

189 

190def parse_puppetfile(data: dict[str, Any], 

191 *, 

192 config: Optional[r11k.config.Config] = None, 

193 path: Optional[str] = None) -> PuppetFile: 

194 """Parse data from puppetfile dictionary.""" 

195 pf = PuppetFile() 

196 

197 if config: 

198 pf.config = config 

199 

200 for module in data['modules']: 

201 if config: 

202 module['config'] = config 

203 m = parse_module_dict(module) 

204 m.explicit = True 

205 pf.modules[m.name] = m 

206 

207 if env := data.get('environment'): 207 ↛ 208line 207 didn't jump to line 208, because the condition on line 207 was never true

208 pf.environment_name = env 

209 elif path: 

210 pf.environment_name, _ = os.path.splitext(os.path.basename(path)) 

211 

212 if data_entry := data.get('data'): 212 ↛ 213line 212 didn't jump to line 213, because the condition on line 212 was never true

213 pf.data = data_entry 

214 

215 if hiera := data.get('hiera'): 215 ↛ 216line 215 didn't jump to line 216, because the condition on line 215 was never true

216 pf.hiera = hiera 

217 

218 if subpath := data.get('include'): 

219 if not path: 219 ↛ 220line 219 didn't jump to line 220, because the condition on line 219 was never true

220 raise ValueError('include only possible when we have a source file') 

221 path = os.path.join(os.path.dirname(path), subpath) 

222 parent = load_puppetfile(path) 

223 pf.include(parent) 

224 

225 return pf 

226 

227 

228def load_puppetfile(filepath: str) -> PuppetFile: 

229 """Load puppetfile.yaml.""" 

230 with open(filepath) as f: 

231 data = yaml.full_load(f) 

232 return parse_puppetfile(data, path=filepath) 

233 

234 

235def update_module_names(modules: list[PuppetModule]) -> None: 

236 """ 

237 Update module name from metadata. 

238 

239 Compare the currently known module name (which probably orginated 

240 in the puppetfile) with the name in the modules metadata. Update 

241 to the name from the metadata if they differ. 

242 """ 

243 logger.info('== Updating modules (and extracting dependencies) ==') 

244 # for every explicit module 

245 for module in modules: 

246 metadata = module.metadata 

247 # Here since we want the "true" name (as per the metadata) 

248 if module.name != metadata.name: 

249 logger.info('Module and metadata names differ, updating module to match metadata.') 

250 logger.info(f'{module.name} ≠ {metadata.name}') 

251 module.name = metadata.name 

252 

253 

254# TODO figure out proper way to propagate config through everything 

255# TODO shouldn't this be a method on PuppetFile? 

256def find_all_modules_for_environment(puppetfile: PuppetFile) -> ResolvedPuppetFile: 

257 """ 

258 Find all modules which would make out this puppet environment. 

259 

260 Returns a list of modules, including which version they should be. 

261 """ 

262 modules: list[PuppetModule] = list(puppetfile.modules.values()) 

263 

264 update_module_names(modules) 

265 

266 known_modules: dict[str, PuppetModule] = {} 

267 for module in modules: 

268 known_modules[module.name] = module 

269 

270 # Resolve all modules, restarting with the new set of modules 

271 # repeatedly until we find a stable point. 

272 while True: 

273 # Dict from which modules we want, to which versions we can have 

274 constraints: dict[str, list[Interval]] = {} 

275 

276 # For each module in puppetfile 

277 for module in modules: 

278 logger.debug(module) 

279 # collect its dependencies 

280 for depspec in module.metadata.dependencies: 

281 # and merge them to the dependency set 

282 constraints.setdefault(depspec.name, []) \ 

283 .append(depspec.interval(by=module.name)) 

284 

285 # resolve constraints 

286 resolved_constraints: dict[str, Interval] = {} 

287 for module_name, intervals in constraints.items(): 

288 # TODO what to do if invalid constraints 

289 resolved_constraints[module_name] = reduce(interval.intersect, intervals) 

290 

291 # build the next iteration of modules 

292 # If this turns out to be identical to modules, then 

293 # everything is resolved and we exit. Otherwise we continue 

294 # the loop 

295 next_modules: dict[str, PuppetModule] = {} 

296 for name, interval_ in resolved_constraints.items(): 

297 if module_ := known_modules.get(name): 

298 next_modules[name] = module_ 

299 else: 

300 # TODO keep interval instead of locking it in 

301 module_ = ForgePuppetModule(name, config=puppetfile.config) 

302 # TODO this crashes when the dependency is a git 

303 # module, since it tries to fetch a forge module of 

304 # that name. 

305 # TODO continue here, fix this 

306 module_.version = interval_.newest(module_.versions()) 

307 next_modules[name] = module_ 

308 known_modules[name] = module_ 

309 

310 # TODO what are we even forcing here? 

311 for name, module_ in puppetfile.modules.items(): 

312 if next_modules.get(name): 312 ↛ 313line 312 didn't jump to line 313, because the condition on line 312 was never true

313 logger.warn('Forcing %s', name) 

314 next_modules[name] = module_ 

315 

316 next_modules_list: list[PuppetModule] = list(next_modules.values()) 

317 

318 if next_modules_list == modules: 

319 break 

320 

321 modules = next_modules_list 

322 

323 return ResolvedPuppetFile(modules={m.name: m for m in modules}, 

324 environment_name=puppetfile.environment_name, 

325 data=puppetfile.data, 

326 hiera=puppetfile.hiera, 

327 config=puppetfile.config)