Skip to content

Commit e667f2d

Browse files
committed
tasks: Add the ability to include files
Signed-off-by: Stephen Brennan <[email protected]>
1 parent b807cf3 commit e667f2d

File tree

4 files changed

+580
-24
lines changed

4 files changed

+580
-24
lines changed

doc/guide/tasks.rst

+88-10
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,32 @@ functionality.
108108
- However, if task ``A`` and ``B`` are both specified to run, then Yo will
109109
ensure that A runs to completion before task ``B`` begins.
110110

111-
These functions can be used anywhere in your script, however bash syntax such as
112-
quoting or variable expansion is not respected where Yo interprets them. So,
113-
while the following is valid bash, it won't work with Yo:
111+
- ``INCLUDE_FILE <path> [destination]`` - this declares that the given path from
112+
the client system should be copied to the instance. If the path is a
113+
directory, it will be included recursively. Paths must be either absolute
114+
(i.e. starting with ``/``) or relative to the home directory (i.e. starting
115+
with ``~/``). By default, files are copied into the corresponding location on
116+
the instance, but a different ``destination`` may be specified if necessary.
117+
The path may also be a glob -- in which case, the destination argument must be
118+
provided, and it will be interpreted as a directory into which each matching
119+
file or directory is placed.
120+
121+
This command works by building a tarball of all required files for a task,
122+
copying it to the instance, and extracting it into place. For more details on
123+
file management, see the section below.
124+
125+
- The variant ``MAYBE_INCLUDE_FILE <filename> [destination]`` can be used to
126+
include a file if it exists on the host system. No error will be raised if
127+
the file does not exist.
128+
129+
- ``SENDFILE <filename>`` - this declares that the given filename should
130+
be directly copied into ``$TASK_DATA_DIR/$FILENAME``. This is a somewhat
131+
low-level command -- no tarball is involved. See the section below for more
132+
details on file management.
133+
134+
These functions can be used anywhere in your script, however bash variable
135+
expansion is not respected when Yo reads and pre-processes the script. So, while
136+
the following is valid bash, it won't work with Yo:
114137

115138
.. code:: bash
116139
@@ -215,20 +238,75 @@ and self-configures.
215238
Please note that tasks specified in an instance profile cannot be removed from
216239
the profile on the command line. You can only specify _additional_ tasks to run.
217240

241+
Specifying Files for Tasks
242+
--------------------------
243+
244+
Tasks can be included onto an instance with two mechanisms, ``INCLUDE_FILE`` and
245+
``SENDFILE``, described above. The implementation of these commands is described
246+
here in a bit more detail, so you can understand what's happening under the hood
247+
and make use of them well.
248+
249+
Files copied by ``INCLUDE_FILE`` are split into two groups: user files (those
250+
whose destination is prefixed by ``~/``, and thus are destined for the home
251+
directory), and system files (those whose destination starts with ``/``). The
252+
user files are placed into a tarball called ``user.tar.gz``, and the system
253+
files go into ``system.tar.gz``. Yo caches these files in
254+
``~/.cache/yo-tasks/$TASK/`` for each task. When a task is launched, Yo
255+
enumerates all the files that will be included, and if any are more recent than
256+
the cached tarball, it rebuilds the tarball.
257+
258+
The idea behind ``INCLUDE_FILE`` is that it allows you to automatically include
259+
useful files from your client system directly onto the instance. As an example,
260+
you might want to include your ``~/.bashrc``, ``~/.vimrc`` and a collection of
261+
useful scripts. You can create a custom task which does so quite easily:
262+
263+
.. code::
264+
265+
INCLUDE_FILE ~/.bashrc
266+
INCLUDE_FILE ~/.vimrc
267+
INCLUDE_FILE ~/oci-scripts ~/bin
268+
269+
So the hope is that this mechanism will suit most use cases. However, there may
270+
be other cases that are more complex. For that, we have ``SENDFILE``.
271+
272+
Files copied by ``SENDFILE`` have none of the above logic applied to them. They
273+
are copied directly to the instance into the ``$TASK_DATA_DIR``. Then your
274+
script can process it however you would like. For instance, you may want to
275+
distribute a tarball containing software that your script will install manually.
276+
That could be achieved like so:
277+
278+
.. code::
279+
280+
SENDFILE ~/Software/mypkg-1.2.3.tar.gz
281+
282+
PKG_INSTALL dependency1-devel dependency2-devel
283+
mkdir build
284+
cd build
285+
tar xf $TASK_DATA_DIR/mypkg-1.2.3.tar.gz
286+
cd mypkg-1.2.3
287+
./configure --prefix=$HOME/.local
288+
make -j $(nproc)
289+
make install
290+
cd ../..
291+
rm -rf build $TASK_DATA_DIR/mypkg-1.2.3.tar.gz
292+
293+
Finally, there's one implementation detail worth noting: after Yo creates the
294+
``user.tar.gz`` and ``system.tar.gz`` tarballs, it treats them like any other
295+
file specified with ``SENDFILE``, except for one crucial difference. Files with
296+
those names get automatically extracted into the correct places by Yo's task
297+
launcher stub program. This means that you can elect to build your own archives
298+
that contain exactly what you want (rather than using ``INCLUDE_FILE`` to build
299+
them at runtime), and then specify them using ``SENDFILE``. Yo will extract
300+
those just the same as it does for the archives it creates.
301+
218302
Builtin Tasks
219303
-------------
220304

221-
- ``drgn`` - install drgn and (if possible) kernel debuginfo, supported on
305+
- ```drgn`` - install drgn and (if possible) kernel debuginfo, supported on
222306
Oracle Linux 8 and later.
223307
- ``ocid`` - enable and run the ``ocid`` service, which can automatically
224308
respond to OCI actions like resizing block volumes in sensible ways.
225309

226-
Some tasks are purely for tests or demonstration:
227-
228-
- ``test-task``
229-
- ``test-deps``
230-
- ``test-run-many``
231-
232310
The following task names are used as optional dependencies by ``yo``, but no
233311
task is included by that name, to allow users to customize their setup:
234312

tests/test_tasks.py

+277
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
#!/usr/bin/env python3
2+
import inspect
3+
import os
4+
import tarfile
5+
import time
6+
from pathlib import Path
7+
from unittest import mock
8+
9+
import pytest
10+
11+
from yo.tasks import build_tarball
12+
from yo.tasks import standardize_globs
13+
from yo.tasks import YoTask
14+
from yo.util import YoExc
15+
16+
17+
@pytest.fixture(autouse=True)
18+
def mock_all_tasks():
19+
with mock.patch("yo.tasks.list_tasks") as m:
20+
m.return_value = [
21+
"task_one",
22+
"task_two",
23+
"task_three",
24+
"task_four",
25+
"task_five",
26+
"task_six",
27+
]
28+
yield
29+
30+
31+
def test_depends_conflicts():
32+
contents = inspect.cleandoc(
33+
"""
34+
DEPENDS_ON task_one
35+
DEPENDS_ON task_two
36+
MAYBE_DEPENDS_ON task_three
37+
MAYBE_DEPENDS_ON dne_one
38+
CONFLICTS_WITH task_four
39+
CONFLICTS_WITH dne_two
40+
PREREQ_FOR task_five
41+
PREREQ_FOR dne_three
42+
"""
43+
)
44+
45+
task = YoTask.create_from_string("test_task", contents)
46+
47+
# the maybe_depends_on dne_one is dropped since it doesn't exist
48+
assert task.dependencies == ["task_one", "task_two", "task_three"]
49+
# all conflicts are kept
50+
assert task.conflicts == ["task_four", "dne_two"]
51+
# all prereqs are kept
52+
assert task.prereq_for == ["task_five", "dne_three"]
53+
54+
assert task.script == contents.replace(
55+
"MAYBE_DEPENDS_ON dne_one",
56+
"# MAYBE_DEPENDS_ON dne_one",
57+
).replace("MAYBE_DEPENDS_ON task_three", "DEPENDS_ON task_three")
58+
59+
60+
def test_include_files():
61+
contents = inspect.cleandoc(
62+
"""
63+
INCLUDE_FILE ~/.profile
64+
INCLUDE_FILE ~/Documents/"Important File.docx" /usr/share/docs/"Important File".docx
65+
INCLUDE_FILE ~/dotfiles/bashrc_oci.sh ~/.bashrc
66+
MAYBE_INCLUDE_FILE ~/.cache/yo.*.json ~/.cache/
67+
SENDFILE ~/data.tar.gz
68+
SENDFILE ~/"Special File"
69+
"""
70+
)
71+
72+
task = YoTask.create_from_string("test_task", contents)
73+
assert task.include_files == [
74+
("~/.profile", "~/.profile", False),
75+
(
76+
"~/Documents/Important File.docx",
77+
"/usr/share/docs/Important File.docx",
78+
False,
79+
),
80+
("~/dotfiles/bashrc_oci.sh", "~/.bashrc", False),
81+
("~/.cache/yo.*.json", "~/.cache/", True),
82+
]
83+
assert task.sendfiles == [
84+
Path.home() / "data.tar.gz",
85+
Path.home() / "Special File",
86+
]
87+
88+
expected_contents = "\n".join("# " + line for line in contents.split("\n"))
89+
assert task.script == expected_contents
90+
assert task.script == expected_contents
91+
92+
93+
def test_wrong_args():
94+
cases = [
95+
"INCLUDE_FILE",
96+
"MAYBE_INCLUDE_FILE",
97+
"INCLUDE_FILE one two three",
98+
"MAYBE_INCLUDE_FILE one two three",
99+
"SENDFILE",
100+
"SENDFILE one two",
101+
]
102+
for case in cases:
103+
with pytest.raises(YoExc):
104+
YoTask.create_from_string("test_task", case)
105+
106+
107+
def test_standardize_globs():
108+
assert standardize_globs(
109+
[
110+
# The standard: copy to the same path
111+
("~/.bashrc", "~/.bashrc", False),
112+
# An unusual: copy from homedir to a system path
113+
("~/.bashrc", "/etc/bashrc", False),
114+
# Also unusual: copy from system path to homedir
115+
("/etc/bashrc", "~/.bashrc", True),
116+
]
117+
) == (
118+
[
119+
(str(Path.home() / ".bashrc"), ".bashrc", False),
120+
("/etc/bashrc", ".bashrc", True),
121+
],
122+
[
123+
(str(Path.home() / ".bashrc"), "etc/bashrc", False),
124+
],
125+
)
126+
127+
# neither side can be relative
128+
failing = [
129+
[("relative path", "~/.bashrc", False)],
130+
[("/foobar", ".bashrc", False)],
131+
]
132+
for case in failing:
133+
with pytest.raises(YoExc):
134+
standardize_globs(case)
135+
136+
137+
def create_test_dir(tmp_path):
138+
# The important cases to cover are:
139+
# 1. A regular file
140+
# 2. A directory being included recursively
141+
# 3. A glob matching several files or directories
142+
tarball = tmp_path / "tarball.tar.gz"
143+
144+
bashrc = tmp_path / ".bashrc"
145+
bashrc.write_text("export PS2='$ '")
146+
147+
bash_history = tmp_path / ".bash_history"
148+
bash_history.write_text(":(){ :|:& };:")
149+
150+
note_dir = tmp_path / "my-notes"
151+
note_dir.mkdir()
152+
note_one = note_dir / "one.txt"
153+
note_one.write_text("some data")
154+
note_two = note_dir / "two.txt"
155+
note_two.write_text("some other data")
156+
unmatched_note = note_dir / "not included.md"
157+
unmatched_note.write_text("I won't be in the tarball")
158+
159+
doc_dir = tmp_path / "my-docs"
160+
doc_dir.mkdir()
161+
doc_one = doc_dir / ".hidden_document"
162+
doc_one.write_text("test data")
163+
doc_two = doc_dir / "an important, space-filled document title"
164+
doc_two.write_text("more test data!")
165+
doc_subdir = doc_dir / "Project"
166+
doc_subdir.mkdir()
167+
project_doc = doc_subdir / "Rollout plan.md"
168+
project_doc.write_text(
169+
"steps 1: write a plan, step 2: ???, step 3: profit!"
170+
)
171+
172+
included = [
173+
bashrc,
174+
note_one,
175+
note_two,
176+
doc_dir,
177+
doc_one,
178+
doc_two,
179+
doc_subdir,
180+
project_doc,
181+
]
182+
excluded = [unmatched_note, bash_history]
183+
return tarball, included, excluded
184+
185+
186+
def test_build_tarball_skip(tmp_path):
187+
ctx = mock.Mock()
188+
name = "test_task"
189+
190+
tarball, included, excluded = create_test_dir(tmp_path)
191+
base = str(tmp_path)
192+
include_files = [
193+
(f"{base}/.bashrc", ".bashrc", False),
194+
(f"{base}/my-notes/*.txt", "notes/", False),
195+
(f"{base}/my-docs/", "docs/", False),
196+
]
197+
198+
TEST_TIME = int(time.time())
199+
200+
# Set the mtimes to be older than the tarball
201+
for path in included:
202+
os.utime(path, times=(TEST_TIME, TEST_TIME - 5))
203+
# Set the excluded paths to be newer than the tarball,
204+
# demonstrating that they don't impact the generation.
205+
for path in excluded:
206+
os.utime(path, times=(TEST_TIME, TEST_TIME + 5))
207+
208+
tarball.write_text("foobar")
209+
os.utime(tarball, times=(TEST_TIME, TEST_TIME))
210+
211+
with mock.patch("yo.tasks.subprocess") as m:
212+
build_tarball(ctx, include_files, tmp_path, tarball, name)
213+
# Subprocess should not have been called at all
214+
assert not m.mock_calls
215+
# Ctx should not have been called at all
216+
assert not ctx.mock_calls
217+
218+
# ensure that making any file newer results in a rebuilt tarball
219+
for path in included:
220+
os.utime(path, times=(TEST_TIME, TEST_TIME + 5))
221+
with mock.patch("yo.tasks.subprocess") as m:
222+
build_tarball(ctx, include_files, tmp_path, tarball, name)
223+
assert m.mock_calls
224+
assert ctx.mock_calls
225+
ctx.reset_mock()
226+
os.utime(path, times=(TEST_TIME, TEST_TIME - 5))
227+
228+
229+
def test_build_tarball(tmp_path):
230+
ctx = mock.Mock()
231+
name = "test_task"
232+
233+
tarball, included, excluded = create_test_dir(tmp_path)
234+
base = str(tmp_path)
235+
include_files = [
236+
(f"{base}/.bashrc", ".bashrc", False),
237+
(f"{base}/my-notes/*.txt", "notes/", False),
238+
(f"{base}/my-docs/", "docs/", False),
239+
]
240+
241+
with mock.patch("yo.tasks.subprocess") as m:
242+
build_tarball(ctx, include_files, tmp_path, tarball, name)
243+
assert len(m.run.mock_calls) == 1
244+
assert m.run.mock_calls[0].args[0][:3] == ["tar", "-czhf", tarball]
245+
assert sorted(m.run.mock_calls[0].args[0][3:]) == sorted(
246+
[".bashrc", "notes/one.txt", "notes/two.txt", "docs"]
247+
)
248+
ctx.con.log.assert_called()
249+
250+
251+
def test_real_tarball(tmp_path):
252+
ctx = mock.Mock()
253+
name = "test_task"
254+
255+
tarball, included, excluded = create_test_dir(tmp_path)
256+
base = str(tmp_path)
257+
include_files = [
258+
(f"{base}/.bashrc", ".bashrc", False),
259+
(f"{base}/my-notes/*.txt", "notes/", False),
260+
(f"{base}/my-docs/", "docs/", False),
261+
]
262+
263+
build_tarball(ctx, include_files, tmp_path, tarball, name)
264+
ctx.con.log.assert_called()
265+
266+
tf = tarfile.open(tarball)
267+
expected_members = [
268+
".bashrc",
269+
"notes/one.txt",
270+
"notes/two.txt",
271+
"docs",
272+
"docs/.hidden_document",
273+
"docs/an important, space-filled document title",
274+
"docs/Project",
275+
"docs/Project/Rollout plan.md",
276+
]
277+
assert sorted(tf.getnames()) == sorted(expected_members)

0 commit comments

Comments
 (0)