Skip to content

Commit 62d7637

Browse files
Support different versions per instance of a component (#559)
This PR changes Commodore to * Create separate worktrees for each component alias * Create class symlinks for each component alias * Create each alias target with only the defaults and component class symlinked from the alias worktree * Read instance versions from `parameters.components.<instance-name>` Note that per-instance versions only work correctly only for components which use `${_base_directory}` in their config when specifying Jsonnet files or Helm chart/YAML locations in `kapitan.compile` and `kapitan.dependencies`. Note, that components should use `${_base_directory}` anyway, and new components created from the template use `${_base_directory}` out of the box. Components must signal that they support per-instance versions by setting component parameter `_metadata.multi_version=true`. Resolves #563 Co-authored-by: Aline Abler <[email protected]> Co-authored-by: Aline Abler <[email protected]>
1 parent 147f4c3 commit 62d7637

19 files changed

+636
-94
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ Commodore also supports additional processing on the output of Kapitan, such as
115115

116116
1. Run linting and tests
117117

118-
Auto format with autopep8
118+
Automatically apply Black formatting
119119
```console
120-
poetry run autopep
120+
poetry run black .
121121
```
122122

123123
List all Tox targets
@@ -132,7 +132,7 @@ Commodore also supports additional processing on the output of Kapitan, such as
132132

133133
Run just a specific target
134134
```console
135-
poetry run tox -e py38
135+
poetry run tox -e py312
136136
```
137137

138138

commodore/cluster.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ def generate_target(
169169
"_instance": target,
170170
}
171171
if not bootstrap:
172-
parameters["_base_directory"] = str(components[component].target_directory)
172+
parameters["_base_directory"] = str(
173+
components[component].alias_directory(target)
174+
)
173175
parameters["_kustomize_wrapper"] = str(__kustomize_wrapper__)
174176
parameters["kapitan"] = {
175177
"vars": {
@@ -206,24 +208,28 @@ def render_target(
206208
classes = [f"params.{inv.bootstrap_target}"]
207209

208210
for c in sorted(components):
209-
if inv.defaults_file(c).is_file():
210-
classes.append(f"defaults.{c}")
211+
defaults_file = inv.defaults_file(c)
212+
if c == component and target != component:
213+
# Special case alias defaults symlink
214+
defaults_file = inv.defaults_file(target)
215+
216+
if defaults_file.is_file():
217+
classes.append(f"defaults.{defaults_file.stem}")
211218
else:
212219
click.secho(f" > Default file for class {c} missing", fg="yellow")
213220

214221
classes.append("global.commodore")
215222

216223
if not bootstrap:
217-
if not inv.component_file(component).is_file():
224+
if not inv.component_file(target).is_file():
218225
raise click.ClickException(
219226
f"Target rendering failed for {target}: component class is missing"
220227
)
221-
classes.append(f"components.{component}")
228+
classes.append(f"components.{target}")
222229

223230
return generate_target(inv, target, components, classes, component)
224231

225232

226-
# pylint: disable=unsubscriptable-object
227233
def update_target(cfg: Config, target: str, component: Optional[str] = None):
228234
click.secho(f"Updating Kapitan target for {target}...", bold=True)
229235
file = cfg.inventory.target_file(target)

commodore/compile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def setup_compile_environment(config: Config) -> tuple[dict[str, Any], Iterable[
240240
config.register_component_deprecations(cluster_parameters)
241241
# Raise exception if component version override without URL is present in the
242242
# hierarchy.
243-
verify_version_overrides(cluster_parameters)
243+
verify_version_overrides(cluster_parameters, config.get_component_aliases())
244244

245245
for component in config.get_components().values():
246246
ckey = component.parameters_key

commodore/component/__init__.py

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class Component:
1919
_version: Optional[str] = None
2020
_dir: P
2121
_sub_path: str
22+
_aliases: dict[str, tuple[str, str]]
23+
_work_dir: Optional[P]
2224

2325
@classmethod
2426
def clone(cls, cfg, clone_url: str, name: str, version: str = "master"):
@@ -57,6 +59,8 @@ def __init__(
5759
self.version = version
5860
self._sub_path = sub_path
5961
self._repo = None
62+
self._aliases = {self.name: (self.version or "", self.sub_path or "")}
63+
self._work_dir = work_dir
6064

6165
@property
6266
def name(self) -> str:
@@ -67,8 +71,12 @@ def repo(self) -> GitRepo:
6771
if not self._repo:
6872
if self._dependency:
6973
dep_repo = self._dependency.bare_repo
70-
author_name = dep_repo.author.name
71-
author_email = dep_repo.author.email
74+
author_name = (
75+
dep_repo.author.name if hasattr(dep_repo, "author") else None
76+
)
77+
author_email = (
78+
dep_repo.author.email if hasattr(dep_repo, "author") else None
79+
)
7280
else:
7381
# Fall back to author detection if we don't have a dependency
7482
author_name = None
@@ -126,21 +134,46 @@ def sub_path(self) -> str:
126134
def repo_directory(self) -> P:
127135
return self._dir
128136

137+
@property
138+
def work_directory(self) -> Optional[P]:
139+
return self._work_dir
140+
129141
@property
130142
def target_directory(self) -> P:
131-
return self._dir / self._sub_path
143+
return self.alias_directory(self.name)
132144

133145
@property
134146
def target_dir(self) -> P:
135147
return self.target_directory
136148

137149
@property
138150
def class_file(self) -> P:
139-
return self.target_directory / "class" / f"{self.name}.yml"
151+
return self.alias_class_file(self.name)
140152

141153
@property
142154
def defaults_file(self) -> P:
143-
return self.target_directory / "class" / "defaults.yml"
155+
return self.alias_defaults_file(self.name)
156+
157+
def alias_directory(self, alias: str) -> P:
158+
if not self._dependency:
159+
return self._dir / self._sub_path
160+
apath = self._dependency.get_component(alias)
161+
if not apath:
162+
raise ValueError(f"unknown alias {alias} for component {self.name}")
163+
if alias not in self._aliases:
164+
raise ValueError(
165+
f"alias {alias} for component {self.name} has not been registered"
166+
)
167+
return apath / self._aliases[alias][1]
168+
169+
def alias_class_file(self, alias: str) -> P:
170+
return self.alias_directory(alias) / "class" / f"{self.name}.yml"
171+
172+
def alias_defaults_file(self, alias: str) -> P:
173+
return self.alias_directory(alias) / "class" / "defaults.yml"
174+
175+
def has_alias(self, alias: str):
176+
return alias in self._aliases
144177

145178
@property
146179
def lib_files(self) -> Iterable[P]:
@@ -177,6 +210,35 @@ def checkout(self):
177210
)
178211
self._dependency.checkout_component(self.name, self.version)
179212

213+
def register_alias(self, alias: str, version: str, sub_path: str = ""):
214+
if not self._work_dir:
215+
raise ValueError(
216+
f"Can't register alias on component {self.name} "
217+
+ "which isn't configured with a working directory"
218+
)
219+
if alias in self._aliases:
220+
raise ValueError(
221+
f"alias {alias} already registered on component {self.name}"
222+
)
223+
self._aliases[alias] = (version, sub_path)
224+
if self._dependency:
225+
self._dependency.register_component(
226+
alias, component_dir(self._work_dir, alias)
227+
)
228+
229+
def checkout_alias(
230+
self, alias: str, alias_dependency: Optional[MultiDependency] = None
231+
):
232+
if alias not in self._aliases:
233+
raise ValueError(
234+
f"alias {alias} is not registered on component {self.name}"
235+
)
236+
237+
if alias_dependency:
238+
alias_dependency.checkout_component(alias, self._aliases[alias][0])
239+
elif self._dependency:
240+
self._dependency.checkout_component(alias, self._aliases[alias][0])
241+
180242
def is_checked_out(self) -> bool:
181243
return self.target_dir is not None and self.target_dir.is_dir()
182244

commodore/config.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,14 +377,26 @@ def get_component_aliases(self):
377377
return self._component_aliases
378378

379379
def register_component_aliases(self, aliases: dict[str, str]):
380-
self._component_aliases = aliases
380+
self._component_aliases.update(aliases)
381381

382382
def verify_component_aliases(self, cluster_parameters: dict):
383383
for alias, cn in self._component_aliases.items():
384-
if alias != cn and not _component_is_aliasable(cluster_parameters, cn):
385-
raise click.ClickException(
386-
f"Component {cn} with alias {alias} does not support instantiation."
384+
if alias != cn:
385+
if not _component_is_aliasable(cluster_parameters, cn):
386+
raise click.ClickException(
387+
f"Component {cn} with alias {alias} does not support instantiation."
388+
)
389+
390+
cv = cluster_parameters.get("components", {}).get(alias, {})
391+
alias_has_version = (
392+
cv.get("url") is not None or cv.get("version") is not None
387393
)
394+
if alias_has_version and not _component_supports_alias_version(
395+
cluster_parameters, cn, alias
396+
):
397+
raise click.ClickException(
398+
f"Component {cn} with alias {alias} does not support overriding instance version."
399+
)
388400

389401
def get_component_alias_versioninfos(self) -> dict[str, InstanceVersionInfo]:
390402
return {
@@ -453,6 +465,18 @@ def _component_is_aliasable(cluster_parameters: dict, component_name: str):
453465
return cmeta.get("multi_instance", False)
454466

455467

468+
def _component_supports_alias_version(
469+
cluster_parameters: dict,
470+
component_name: str,
471+
alias: str,
472+
):
473+
ckey = component_parameters_key(component_name)
474+
cmeta = cluster_parameters[ckey].get("_metadata", {})
475+
akey = component_parameters_key(alias)
476+
ameta = cluster_parameters.get(akey, {}).get("_metadata", {})
477+
return cmeta.get("multi_version", False) and ameta.get("multi_version", False)
478+
479+
456480
def set_fact_value(facts: dict[str, Any], raw_key: str, value: Any) -> None:
457481
"""Set value for nested fact at `raw_key` (expected form `path.to.key`) to `value`.
458482

commodore/dependency_mgmt/__init__.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ def create_component_symlinks(cfg, component: Component):
4040
)
4141

4242

43+
def create_alias_symlinks(cfg, component: Component, alias: str):
44+
if not component.has_alias(alias):
45+
raise ValueError(
46+
f"component {component.name} doesn't have alias {alias} registered"
47+
)
48+
relsymlink(
49+
component.alias_class_file(alias),
50+
cfg.inventory.components_dir,
51+
dest_name=f"{alias}.yml",
52+
)
53+
inventory_default = cfg.inventory.defaults_file(alias)
54+
relsymlink(
55+
component.alias_defaults_file(alias),
56+
inventory_default.parent,
57+
dest_name=inventory_default.name,
58+
)
59+
60+
4361
def create_package_symlink(cfg, pname: str, package: Package):
4462
"""
4563
Create package symlink in the inventory.
@@ -69,7 +87,7 @@ def fetch_components(cfg: Config):
6987
component_names, component_aliases = _discover_components(cfg)
7088
click.secho("Registering component aliases...", bold=True)
7189
cfg.register_component_aliases(component_aliases)
72-
cspecs = _read_components(cfg, component_names)
90+
cspecs = _read_components(cfg, component_aliases)
7391
click.secho("Fetching components...", bold=True)
7492

7593
deps: dict[str, list] = {}
@@ -93,6 +111,25 @@ def fetch_components(cfg: Config):
93111
deps.setdefault(cdep.url, []).append(c)
94112
fetch_parallel(fetch_component, cfg, deps.values())
95113

114+
components = cfg.get_components()
115+
116+
for alias, component in component_aliases.items():
117+
if alias == component:
118+
# Nothing to setup for identity alias
119+
continue
120+
121+
c = components[component]
122+
aspec = cspecs[alias]
123+
adep = None
124+
if aspec.url != c.repo_url:
125+
adep = cfg.register_dependency_repo(aspec.url)
126+
adep.register_component(alias, component_dir(cfg.work_dir, alias))
127+
128+
c.register_alias(alias, aspec.version, aspec.path)
129+
c.checkout_alias(alias, adep)
130+
131+
create_alias_symlinks(cfg, c, alias)
132+
96133

97134
def fetch_component(cfg, dependencies):
98135
"""
@@ -126,7 +163,7 @@ def register_components(cfg: Config):
126163
click.secho("Discovering included components...", bold=True)
127164
try:
128165
components, component_aliases = _discover_components(cfg)
129-
cspecs = _read_components(cfg, components)
166+
cspecs = _read_components(cfg, component_aliases)
130167
except KeyError as e:
131168
raise click.ClickException(f"While discovering components: {e}")
132169
click.secho("Registering components and aliases...", bold=True)
@@ -152,9 +189,9 @@ def register_components(cfg: Config):
152189
cfg.register_component(component)
153190
create_component_symlinks(cfg, component)
154191

155-
registered_components = cfg.get_components().keys()
192+
registered_components = cfg.get_components()
156193
pruned_aliases = {
157-
a: c for a, c in component_aliases.items() if c in registered_components
194+
a: c for a, c in component_aliases.items() if c in registered_components.keys()
158195
}
159196
pruned = sorted(set(component_aliases.keys()) - set(pruned_aliases.keys()))
160197
if len(pruned) > 0:
@@ -163,6 +200,24 @@ def register_components(cfg: Config):
163200
)
164201
cfg.register_component_aliases(pruned_aliases)
165202

203+
for alias, cn in pruned_aliases.items():
204+
if alias == cn:
205+
# Nothing to setup for identity alias
206+
continue
207+
208+
c = registered_components[cn]
209+
aspec = cspecs[alias]
210+
211+
if aspec.url != c.repo_url:
212+
adep = cfg.register_dependency_repo(aspec.url)
213+
adep.register_component(alias, component_dir(cfg.work_dir, alias))
214+
c.register_alias(alias, aspec.version, aspec.path)
215+
216+
if not component_dir(cfg.work_dir, alias).is_dir():
217+
raise click.ClickException(f"Missing alias checkout for '{alias} as {cn}'")
218+
219+
create_alias_symlinks(cfg, c, alias)
220+
166221

167222
def fetch_packages(cfg: Config):
168223
"""
@@ -235,10 +290,18 @@ def register_packages(cfg: Config):
235290
create_package_symlink(cfg, p, pkg)
236291

237292

238-
def verify_version_overrides(cluster_parameters):
293+
def verify_version_overrides(cluster_parameters, component_aliases: dict[str, str]):
239294
errors = []
295+
aliases = set(component_aliases.keys()) - set(component_aliases.values())
240296
for cname, cspec in cluster_parameters["components"].items():
241-
if "url" not in cspec:
297+
if cname in aliases:
298+
# We don't require an url in component alias version configs
299+
# but we do require the base component to have one
300+
if component_aliases[cname] not in cluster_parameters["components"]:
301+
errors.append(
302+
f"component '{component_aliases[cname]}' (imported as {cname})"
303+
)
304+
elif "url" not in cspec:
242305
errors.append(f"component '{cname}'")
243306

244307
for pname, pspec in cluster_parameters.get("packages", {}).items():

0 commit comments

Comments
 (0)