Merge ~jugmac00/lpci:add-more-hooks into lpci:main

Proposed by Jürgen Gmach
Status: Merged
Merged at revision: f16ec90b5a97b9e324992884824b5b2edf00d0cf
Proposed branch: ~jugmac00/lpci:add-more-hooks
Merge into: lpci:main
Diff against target: 398 lines (+151/-37)
8 files modified
docs/plugins.rst (+5/-0)
lpcraft/commands/run.py (+39/-16)
lpcraft/commands/tests/test_run.py (+14/-14)
lpcraft/config.py (+4/-0)
lpcraft/plugin/hookspecs.py (+21/-0)
lpcraft/plugin/lib.py (+6/-0)
lpcraft/plugin/tests/test_plugins.py (+51/-7)
lpcraft/plugins/tox.py (+11/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+413393@code.launchpad.net

Commit message

Add more hooks

Description of the change

-

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

Non-blocking, but I think this would be clearer and easier to unit-test if you pushed down the "combine plugin and configuration" logic for each of the various job properties to methods on `Job` (e.g. `Job.get_run`, or however you need to spell that to get it past pydantic), rather than open-coding it in `_run_job`.

review: Approve
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Thanks for the review.

I certainly noticed that inferring configuration options within the run method may not the best option.

Do you recall my MP review on https://code.launchpad.net/~cjwatson/lpcraft/+git/lpcraft/+merge/413081 where I mentioned that we need to build a configuration object?

`_run_job(job_name, job, provider, getattr(args, "output", None))`

I think this is related to your comment here.

I am not (yet) convinced that Job is the right place to put the logic to load additional data from the plugins. I especially do not like that after executing `Config.load(<config-file>)` we have a half finished config object, and then we must not forget to trigger some method(s) on Job to finish it.

Currently, `class Config` is more like a representation of a YAML file rather than a full blown configuration object.

Basically we have several sources of configuration options, which we need to combine.

We need some kind of configuration builder, which encapsulates all that logic:

def generate_configuration(...):
   a = load_yaml(...)
   b = load_config_from_plugins()
   c = load_config_from_cli()
   d = load_config_from_environment()
   # mix
   return configuration

And I totally agree - this would be way easier to unit test.

But I think that is something left for 2022 :-)

Revision history for this message
Colin Watson (cjwatson) wrote :

I'm not sure about every detail of this (e.g. to me `args.output` feels separate because it can't be set anywhere other than on the command line), but in broad terms I agree - we should certainly do some refactoring next year as time permits.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/docs/plugins.rst b/docs/plugins.rst
2index 4c644d6..ca26449 100644
3--- a/docs/plugins.rst
4+++ b/docs/plugins.rst
5@@ -20,3 +20,8 @@ Plugin discovery
6
7 As currently only builtin plugins are supported,
8 you need to define a plugin in ``lpcraft/plugins/<plugin>``.
9+
10+
11+.. comments
12+
13+ XXX jugmac00 2021-12-17: render all available hooks via plugin
14diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
15index 3c53eef..1805b05 100644
16--- a/lpcraft/commands/run.py
17+++ b/lpcraft/commands/run.py
18@@ -168,12 +168,36 @@ def _run_job(
19 host_architecture = get_host_architecture()
20 if host_architecture not in job.architectures:
21 return
22- if job.run is None:
23+ pm = get_plugin_manager(job)
24+ # XXX jugmac00 2021-12-17: extract infering run_command
25+ run_command = None
26+
27+ run_from_configuration = job.run
28+ if run_from_configuration is not None:
29+ run_command = run_from_configuration
30+ else:
31+ rv = pm.hook.lpcraft_execute_run()
32+ run_command = rv and rv[0] or None
33+
34+ if not run_command:
35 raise CommandError(
36 f"Job {job_name!r} for {job.series}/{host_architecture} "
37 f"does not set 'run'"
38 )
39
40+ # XXX jugmac00 2021-12-17: extract infering environment variables
41+ rv = pm.hook.lpcraft_set_environment()
42+ if rv:
43+ # XXX jugmac00 2021-12-17: check for length or reduce?
44+ env_from_plugin = rv[0]
45+ else:
46+ env_from_plugin = {}
47+
48+ env_from_configuration = job.environment
49+ if env_from_configuration is not None:
50+ env_from_plugin.update(env_from_configuration)
51+ environment = env_from_plugin
52+
53 cwd = Path.cwd()
54 remote_cwd = env.get_managed_environment_project_path()
55
56@@ -186,16 +210,15 @@ def _run_job(
57 series=job.series,
58 architecture=host_architecture,
59 ) as instance:
60- if job.snaps:
61- for snap in job.snaps:
62- emit.progress(f"Running `snap install {snap}`")
63- install_from_store(
64- executor=instance,
65- snap_name=snap,
66- channel="stable",
67- classic=True,
68- )
69- pm = get_plugin_manager(job)
70+ snaps = list(itertools.chain(*pm.hook.lpcraft_install_snaps()))
71+ for snap in snaps:
72+ emit.progress(f"Running `snap install {snap}`")
73+ install_from_store(
74+ executor=instance,
75+ snap_name=snap,
76+ channel="stable",
77+ classic=True,
78+ )
79 packages = list(itertools.chain(*pm.hook.lpcraft_install_packages()))
80 if packages:
81 packages_cmd = ["apt", "install", "-y"] + packages
82@@ -204,17 +227,17 @@ def _run_job(
83 proc = instance.execute_run(
84 packages_cmd,
85 cwd=remote_cwd,
86- env=job.environment,
87+ env=environment,
88 stdout=stream,
89 stderr=stream,
90 )
91- run_cmd = ["bash", "--noprofile", "--norc", "-ec", job.run]
92+ full_run_cmd = ["bash", "--noprofile", "--norc", "-ec", run_command]
93 emit.progress("Running the job")
94- with emit.open_stream(f"Running {run_cmd}") as stream:
95+ with emit.open_stream(f"Running {full_run_cmd}") as stream:
96 proc = instance.execute_run(
97- run_cmd,
98+ full_run_cmd,
99 cwd=remote_cwd,
100- env=job.environment,
101+ env=environment,
102 stdout=stream,
103 stderr=stream,
104 )
105diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
106index e682273..e9a93ea 100644
107--- a/lpcraft/commands/tests/test_run.py
108+++ b/lpcraft/commands/tests/test_run.py
109@@ -193,7 +193,7 @@ class TestRun(RunBaseTestCase):
110 call(
111 ["bash", "--noprofile", "--norc", "-ec", "tox"],
112 cwd=Path("/root/project"),
113- env=None,
114+ env={},
115 stdout=ANY,
116 stderr=ANY,
117 )
118@@ -281,7 +281,7 @@ class TestRun(RunBaseTestCase):
119 execute_run.assert_called_once_with(
120 ["bash", "--noprofile", "--norc", "-ec", "tox"],
121 cwd=Path("/root/project"),
122- env=None,
123+ env={},
124 stdout=ANY,
125 stderr=ANY,
126 )
127@@ -327,7 +327,7 @@ class TestRun(RunBaseTestCase):
128 call(
129 ["bash", "--noprofile", "--norc", "-ec", "tox"],
130 cwd=Path("/root/project"),
131- env=None,
132+ env={},
133 stdout=ANY,
134 stderr=ANY,
135 ),
136@@ -340,7 +340,7 @@ class TestRun(RunBaseTestCase):
137 "pyproject-build",
138 ],
139 cwd=Path("/root/project"),
140- env=None,
141+ env={},
142 stdout=ANY,
143 stderr=ANY,
144 ),
145@@ -392,14 +392,14 @@ class TestRun(RunBaseTestCase):
146 call(
147 ["bash", "--noprofile", "--norc", "-ec", "tox"],
148 cwd=Path("/root/project"),
149- env=None,
150+ env={},
151 stdout=ANY,
152 stderr=ANY,
153 ),
154 call(
155 ["bash", "--noprofile", "--norc", "-ec", "tox"],
156 cwd=Path("/root/project"),
157- env=None,
158+ env={},
159 stdout=ANY,
160 stderr=ANY,
161 ),
162@@ -412,7 +412,7 @@ class TestRun(RunBaseTestCase):
163 "pyproject-build",
164 ],
165 cwd=Path("/root/project"),
166- env=None,
167+ env={},
168 stdout=ANY,
169 stderr=ANY,
170 ),
171@@ -956,9 +956,9 @@ class TestRun(RunBaseTestCase):
172 call(
173 ["bash", "--noprofile", "--norc", "-ec", "tox"],
174 cwd=Path("/root/project"),
175- env=None,
176- stdout=27,
177- stderr=27,
178+ env={},
179+ stdout=ANY,
180+ stderr=ANY,
181 ),
182 ],
183 execute_run.call_args_list,
184@@ -1003,14 +1003,14 @@ class TestRun(RunBaseTestCase):
185 "apache2",
186 ],
187 cwd=Path("/root/project"),
188- env=None,
189+ env={},
190 stdout=ANY,
191 stderr=ANY,
192 ),
193 call(
194 ["bash", "--noprofile", "--norc", "-ec", "tox"],
195 cwd=Path("/root/project"),
196- env=None,
197+ env={},
198 stdout=ANY,
199 stderr=ANY,
200 ),
201@@ -1136,7 +1136,7 @@ class TestRunOne(RunBaseTestCase):
202 execute_run.assert_called_once_with(
203 ["bash", "--noprofile", "--norc", "-ec", "pyproject-build"],
204 cwd=Path("/root/project"),
205- env=None,
206+ env={},
207 stdout=ANY,
208 stderr=ANY,
209 )
210@@ -1184,7 +1184,7 @@ class TestRunOne(RunBaseTestCase):
211 execute_run.assert_called_once_with(
212 ["bash", "--noprofile", "--norc", "-ec", "tox"],
213 cwd=Path("/root/project"),
214- env=None,
215+ env={},
216 stdout=ANY,
217 stderr=ANY,
218 )
219diff --git a/lpcraft/config.py b/lpcraft/config.py
220index ed6ae7f..98931f6 100644
221--- a/lpcraft/config.py
222+++ b/lpcraft/config.py
223@@ -60,6 +60,10 @@ class Output(ModelConfigDefaults):
224 class Job(ModelConfigDefaults):
225 """A job definition."""
226
227+ # XXX jugmac00 2012-12-17: working with Job's attributes could be
228+ # simplified if they would not be optional, but rather return e.g.
229+ # an empty list
230+
231 series: _Identifier
232 architectures: List[_Identifier]
233 run: Optional[StrictStr]
234diff --git a/lpcraft/plugin/hookspecs.py b/lpcraft/plugin/hookspecs.py
235index f16f909..e1232f4 100644
236--- a/lpcraft/plugin/hookspecs.py
237+++ b/lpcraft/plugin/hookspecs.py
238@@ -11,3 +11,24 @@ hookspec = pluggy.HookspecMarker("lpcraft")
239 @hookspec # type: ignore
240 def lpcraft_install_packages() -> list[str]:
241 """system packages to be installed"""
242+
243+
244+@hookspec # type: ignore
245+def lpcraft_install_snaps() -> list[str]:
246+ """snaps to be installed"""
247+
248+
249+@hookspec # type: ignore
250+def lpcraft_execute_run() -> str:
251+ """command to be executed"""
252+ # Please note: when both a plugin and the configuration file are
253+ # providing a `run` command, the one from the configuration file will be
254+ # used
255+
256+
257+@hookspec # type: ignore
258+def lpcraft_set_environment() -> dict[str, str | None]:
259+ """environment variables to be set"""
260+ # Please note: when there is the same environment variable provided by
261+ # the plugin and the configuration file, the one in the configuration
262+ # file will be taken into account
263diff --git a/lpcraft/plugin/lib.py b/lpcraft/plugin/lib.py
264index b22be5a..ced3cdd 100644
265--- a/lpcraft/plugin/lib.py
266+++ b/lpcraft/plugin/lib.py
267@@ -23,3 +23,9 @@ class InternalPlugins:
268 if self.config.packages:
269 return self.config.packages
270 return []
271+
272+ @hookimpl # type: ignore
273+ def lpcraft_install_snaps(self) -> list[str]:
274+ if self.config.snaps:
275+ return self.config.snaps
276+ return []
277diff --git a/lpcraft/plugin/tests/test_builtin_plugins.py b/lpcraft/plugin/tests/test_plugins.py
278similarity index 77%
279rename from lpcraft/plugin/tests/test_builtin_plugins.py
280rename to lpcraft/plugin/tests/test_plugins.py
281index 79b0a1d..270ad7a 100644
282--- a/lpcraft/plugin/tests/test_builtin_plugins.py
283+++ b/lpcraft/plugin/tests/test_plugins.py
284@@ -14,7 +14,7 @@ from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
285 from lpcraft.providers.tests import FakeLXDInstaller
286
287
288-class TestBuiltinPlugins(CommandBaseTestCase):
289+class TestPlugins(CommandBaseTestCase):
290 def makeLXDProvider(
291 self,
292 is_ready=True,
293@@ -39,9 +39,6 @@ class TestBuiltinPlugins(CommandBaseTestCase):
294 mock_get_provider.return_value = provider
295 execute_run = launcher.return_value.execute_run
296 execute_run.return_value = subprocess.CompletedProcess([], 0)
297- # XXX jugmac00 2021-12-16: the current implementation of the `tox`
298- # plugin does not provide a run command, so we need to specify it
299- # here in the configuration file
300 config = dedent(
301 """
302 pipeline:
303@@ -52,7 +49,6 @@ class TestBuiltinPlugins(CommandBaseTestCase):
304 series: focal
305 architectures: amd64
306 packages: [nginx, apache2]
307- run: tox
308 plugin: tox
309 """
310 )
311@@ -65,14 +61,14 @@ class TestBuiltinPlugins(CommandBaseTestCase):
312 call(
313 ["apt", "install", "-y", "tox", "nginx", "apache2"],
314 cwd=PosixPath("/root/project"),
315- env=None,
316+ env={"PLUGIN": "tox"},
317 stdout=ANY,
318 stderr=ANY,
319 ),
320 call(
321 ["bash", "--noprofile", "--norc", "-ec", "tox"],
322 cwd=PosixPath("/root/project"),
323- env=None,
324+ env={"PLUGIN": "tox"},
325 stdout=ANY,
326 stderr=ANY,
327 ),
328@@ -110,3 +106,51 @@ class TestBuiltinPlugins(CommandBaseTestCase):
329
330 self.assertEqual(1, result.exit_code)
331 self.assertEqual([YAMLError("Unknown plugin")], result.errors)
332+
333+ @patch("lpcraft.commands.run.get_provider")
334+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
335+ def test_run_command_from_configuration_takes_precedence(
336+ self, mock_get_host_architecture, mock_get_provider
337+ ):
338+ launcher = Mock(spec=launch)
339+ provider = self.makeLXDProvider(lxd_launcher=launcher)
340+ mock_get_provider.return_value = provider
341+ execute_run = launcher.return_value.execute_run
342+ execute_run.return_value = subprocess.CompletedProcess([], 0)
343+ config = dedent(
344+ """
345+ pipeline:
346+ - test
347+
348+ jobs:
349+ test:
350+ series: focal
351+ architectures: amd64
352+ packages: [nginx, apache2]
353+ run: ls
354+ plugin: tox
355+ """
356+ )
357+ Path(".launchpad.yaml").write_text(config)
358+
359+ self.run_command("run")
360+
361+ self.assertEqual(
362+ [
363+ call(
364+ ["apt", "install", "-y", "tox", "nginx", "apache2"],
365+ cwd=PosixPath("/root/project"),
366+ env={"PLUGIN": "tox"},
367+ stdout=ANY,
368+ stderr=ANY,
369+ ),
370+ call(
371+ ["bash", "--noprofile", "--norc", "-ec", "ls"],
372+ cwd=PosixPath("/root/project"),
373+ env={"PLUGIN": "tox"},
374+ stdout=ANY,
375+ stderr=ANY,
376+ ),
377+ ],
378+ execute_run.call_args_list,
379+ )
380diff --git a/lpcraft/plugins/tox.py b/lpcraft/plugins/tox.py
381index 2952371..00ba66c 100644
382--- a/lpcraft/plugins/tox.py
383+++ b/lpcraft/plugins/tox.py
384@@ -15,3 +15,14 @@ class ToxPlugin:
385 @hookimpl # type: ignore
386 def lpcraft_install_packages(self) -> list[str]:
387 return ["tox"]
388+
389+ @hookimpl # type: ignore
390+ def lpcraft_execute_run(self) -> str:
391+ return "tox"
392+
393+ @hookimpl # type: ignore
394+ def lpcraft_set_environment(self) -> dict[str, str | None]:
395+ # XXX jugmac00 2021-12-17: this was added to raise coverage and is not
396+ # necessary. Let's remove this once we have a plugin which actually
397+ # needs to set environment variables.
398+ return {"PLUGIN": "tox"}

Subscribers

People subscribed via source and target branches