Skip to content

Commit 59f3da4

Browse files
Merge pull request #6658 from MetRonnie/rsync
Fix reinstall not detecting changed permissions by itself
2 parents 43fe2ed + 3f37872 commit 59f3da4

File tree

6 files changed

+158
-65
lines changed

6 files changed

+158
-65
lines changed

changes.d/6658.fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed `cylc reinstall` not picking up file permissions changes.

cylc/flow/install.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -213,19 +213,16 @@ def reinstall_workflow(
213213
dry_run=dry_run,
214214
)
215215

216-
# Add '+++' to -out-format to mark lines passed through formatter.
217-
rsync_cmd.append('--out-format=+++%o %n%L+++')
216+
rsync_cmd.append('--out-format=%i %o %n%L')
217+
# %i: itemized changes - needed for rsync to report files with
218+
# changed permissions
218219

219220
# Run rsync command:
220221
reinstall_log.info(cli_format(rsync_cmd))
221222
LOG.debug(cli_format(rsync_cmd))
222223
proc = Popen(rsync_cmd, stdout=PIPE, stderr=PIPE, text=True) # nosec
223224
# * command is constructed via internal interface
224-
stdout, stderr = proc.communicate()
225-
226-
# Strip unwanted output.
227-
stdout = ('\n'.join(re.findall(r'\+\+\+(.*)\+\+\+', stdout))).strip()
228-
stderr = stderr.strip()
225+
stdout, stderr = (i.strip() for i in proc.communicate())
229226

230227
if proc.returncode != 0:
231228
raise WorkflowFilesError(

cylc/flow/remote.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ def get_includes_to_rsync(rsync_includes=None):
183183
'--out-format=%o %n%L',
184184
'--no-t'
185185
]
186+
# %o: the operation (send or del.)
187+
# %n: filename
188+
# %L: "-> symlink_target" if applicable
186189

187190
DEFAULT_INCLUDES = [
188191
'/ana/***', # Rose ana analysis modules

cylc/flow/scripts/reinstall.py

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -68,33 +68,43 @@
6868
"cylc install" even if present in the source directory.
6969
"""
7070

71+
from functools import partial
7172
from pathlib import Path
73+
import re
7274
import sys
73-
from typing import Optional, TYPE_CHECKING, List, Callable
74-
from functools import partial
75+
from typing import (
76+
TYPE_CHECKING,
77+
Callable,
78+
List,
79+
Optional,
80+
)
7581

7682
from ansimarkup import parse as cparse
7783

7884
from cylc.flow.exceptions import (
7985
ServiceFileError,
8086
WorkflowFilesError,
8187
)
82-
from cylc.flow.install import (
83-
reinstall_workflow,
84-
)
88+
import cylc.flow.flags
89+
from cylc.flow.install import reinstall_workflow
8590
from cylc.flow.network.multi import call_multi
8691
from cylc.flow.option_parsers import (
92+
ID_MULTI_ARG_DOC,
8793
CylcOptionParser as COP,
8894
OptionSettings,
89-
ID_MULTI_ARG_DOC
9095
)
9196
from cylc.flow.pathutil import get_workflow_run_dir
9297
from cylc.flow.plugins import run_plugins_async
98+
from cylc.flow.terminal import (
99+
DIM,
100+
cli_function,
101+
is_terminal,
102+
)
93103
from cylc.flow.workflow_files import (
94104
get_workflow_source_dir,
95105
load_contact_file,
96106
)
97-
from cylc.flow.terminal import cli_function, DIM, is_terminal
107+
98108

99109
if TYPE_CHECKING:
100110
from optparse import Values
@@ -269,9 +279,8 @@ async def reinstall(
269279
update anything, else returns True.
270280
271281
"""
272-
# run pre_configure plugins
273282
if not dry_run:
274-
# don't run plugins in dry-mode
283+
# run pre_configure plugins
275284
async for _entry_point, _plugin_result in run_plugins_async(
276285
'cylc.pre_configure',
277286
srcdir=src_dir,
@@ -287,18 +296,17 @@ async def reinstall(
287296
dry_run=dry_run,
288297
)
289298

290-
# display changes
291299
if dry_run:
292-
if not stdout or stdout == 'send ./':
293-
# no rsync output == no changes => exit
300+
# display changes
301+
changes = format_reinstall_output(stdout)
302+
if not changes:
294303
return False
295304

296305
# display rsync output
297-
write('\n'.join(format_rsync_out(stdout)), file=sys.stderr)
306+
write('\n'.join(changes), file=sys.stdout)
298307

299-
# run post_install plugins
300308
if not dry_run:
301-
# don't run plugins in dry-mode
309+
# run post_install plugins
302310
async for _entry_point, _plugin_result in run_plugins_async(
303311
'cylc.post_install',
304312
srcdir=src_dir,
@@ -311,15 +319,15 @@ async def reinstall(
311319
return True
312320

313321

314-
def format_rsync_out(out: str) -> List[str]:
322+
def format_reinstall_output(out: str) -> List[str]:
315323
r"""Format rsync stdout for presenting to users.
316324
317325
Note: Output formats of different rsync implementations may differ so keep
318326
this code simple and robust.
319327
320328
Example:
321-
>>> format_rsync_out(
322-
... 'send foo\ndel. bar\nbaz'
329+
>>> format_reinstall_output(
330+
... '>f+++++++++ send foo\n*deleting del. bar\nbaz'
323331
... '\ncannot delete non-empty directory: opt'
324332
... ) == [
325333
... cparse('<green>send foo</green>'),
@@ -331,20 +339,23 @@ def format_rsync_out(out: str) -> List[str]:
331339
"""
332340
lines = []
333341
for line in out.splitlines():
334-
if line[0:4] == 'send':
335-
# file added or updated
336-
lines.append(cparse(f'<green>{line}</green>'))
337-
elif line[0:4] == 'del.':
338-
# file deleted
339-
lines.append(cparse(f'<red>{line}</red>'))
340-
elif line == 'cannot delete non-empty directory: opt':
341-
# These "cannot delete non-empty directory" messages can arise
342-
# as a result of excluding files within sub-directories.
343-
# This opt dir message is likely to occur when a rose-suit.conf
342+
if not line or line.startswith('cannot delete non-empty directory:'):
343+
# This can arise as a result of deleting a subdir when we are
344+
# excluding files within the subdir.
345+
# This is likely to occur for the opt dir when a rose-suite.conf
344346
# file is present.
347+
# Skip this line as nothing will happen to this dir.
345348
continue
349+
match = re.match(r'^(.{11}) (send|del\.) (.*)$', line)
350+
if match:
351+
summary, operation, file = match.groups()
352+
color = 'green' if operation == 'send' else 'red'
353+
formatted_line = f"<{color}>{operation} {file}</{color}>"
354+
if cylc.flow.flags.verbosity > 0:
355+
formatted_line = f"<{DIM}>{summary}</{DIM}> {formatted_line}"
356+
lines.append(cparse(formatted_line))
346357
else:
347-
# other uncategorised log line
358+
# shouldn't happen; tolerate unknown rsync implementation?
348359
lines.append(line)
349360
return lines
350361

tests/integration/test_reinstall.py

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,23 @@
1919
import asyncio
2020
from contextlib import asynccontextmanager
2121
from pathlib import Path
22+
from secrets import token_hex
2223
from types import SimpleNamespace
23-
from uuid import uuid1
2424

2525
import pytest
2626

27-
from cylc.flow.exceptions import (
28-
WorkflowFilesError,
29-
)
30-
from cylc.flow.install import (
31-
reinstall_workflow,
32-
)
27+
from cylc.flow.exceptions import WorkflowFilesError
28+
from cylc.flow.install import reinstall_workflow
3329
from cylc.flow.option_parsers import Options
3430
from cylc.flow.scripts.reinstall import (
3531
get_option_parser as reinstall_gop,
3632
reinstall_cli,
3733
)
38-
from cylc.flow.workflow_files import (
39-
WorkflowFiles,
40-
)
34+
from cylc.flow.workflow_files import WorkflowFiles
4135

4236
from .utils.entry_points import EntryPointWrapper
4337

38+
4439
ReInstallOptions = Options(reinstall_gop())
4540

4641
# cli opts
@@ -66,17 +61,30 @@ def non_interactive(monkeypatch):
6661
)
6762

6863

64+
@pytest.fixture
65+
def answer_prompt(monkeypatch: pytest.MonkeyPatch):
66+
"""Answer reinstall prompt."""
67+
68+
def inner(answer: str):
69+
monkeypatch.setattr(
70+
'cylc.flow.scripts.reinstall._input', lambda x: answer
71+
)
72+
73+
return inner
74+
75+
6976
@pytest.fixture
7077
def one_src(tmp_path):
71-
src_dir = tmp_path
78+
src_dir = tmp_path / 'src'
79+
src_dir.mkdir()
7280
(src_dir / 'flow.cylc').touch()
7381
(src_dir / 'rose-suite.conf').touch()
7482
return SimpleNamespace(path=src_dir)
7583

7684

7785
@pytest.fixture
7886
def one_run(one_src, test_dir, run_dir):
79-
w_run_dir = test_dir / str(uuid1())
87+
w_run_dir = test_dir / token_hex(4)
8088
w_run_dir.mkdir()
8189
(w_run_dir / 'flow.cylc').touch()
8290
(w_run_dir / 'rose-suite.conf').touch()
@@ -151,7 +159,7 @@ async def test_interactive(
151159
capsys,
152160
capcall,
153161
interactive,
154-
monkeypatch
162+
answer_prompt
155163
):
156164
"""It should perform a dry-run and prompt in interactive mode."""
157165
# capture reinstall calls
@@ -162,11 +170,7 @@ async def test_interactive(
162170
# give it something to reinstall
163171
(one_src.path / 'a').touch()
164172

165-
# reinstall answering "no" to any prompt
166-
monkeypatch.setattr(
167-
'cylc.flow.scripts.reinstall._input',
168-
lambda x: 'n'
169-
)
173+
answer_prompt('n')
170174
assert (
171175
await reinstall_cli(opts=ReInstallOptions(), workflow_id=one_run.id)
172176
is False
@@ -177,11 +181,7 @@ async def test_interactive(
177181
assert 'Reinstall canceled, no changes made.' in capsys.readouterr().out
178182
reinstall_calls.clear()
179183

180-
# reinstall answering "yes" to any prompt
181-
monkeypatch.setattr(
182-
'cylc.flow.scripts.reinstall._input',
183-
lambda x: 'y'
184-
)
184+
answer_prompt('y')
185185
assert await reinstall_cli(opts=ReInstallOptions(), workflow_id=one_run.id)
186186

187187
# two rsync calls should have been made (i.e. the --dry-run and the real)
@@ -241,7 +241,7 @@ async def test_rsync_stuff(one_src, one_run, capsys, non_interactive):
241241

242242

243243
async def test_rose_warning(
244-
one_src, one_run, capsys, interactive, monkeypatch
244+
one_src, one_run, capsys, interactive, answer_prompt
245245
):
246246
"""It should warn that Rose installed files will be deleted.
247247
@@ -252,11 +252,7 @@ async def test_rose_warning(
252252
'Files created by Rose file installation will show as deleted'
253253
)
254254

255-
# reinstall answering "no" to any prompt
256-
monkeypatch.setattr(
257-
'cylc.flow.scripts.reinstall._input',
258-
lambda x: 'n'
259-
)
255+
answer_prompt('n')
260256
(one_src.path / 'a').touch() # give it something to install
261257

262258
# reinstall (with rose-suite.conf file)
@@ -311,6 +307,35 @@ async def test_rsync_fail(one_src, one_run, mock_glbl_cfg, non_interactive):
311307
assert 'An error occurred reinstalling' in str(exc_ctx.value)
312308

313309

310+
async def test_permissions_change(
311+
one_src,
312+
one_run,
313+
interactive,
314+
answer_prompt,
315+
monkeypatch: pytest.MonkeyPatch,
316+
capsys: pytest.CaptureFixture,
317+
):
318+
"""It detects permissions changes."""
319+
# Add script file:
320+
script_path: Path = one_src.path / 'myscript'
321+
script_path.touch()
322+
await reinstall_cli(
323+
opts=ReInstallOptions(skip_interactive=True), workflow_id=one_run.id
324+
)
325+
assert (one_run.path / 'myscript').is_file()
326+
capsys.readouterr() # clears capsys
327+
328+
# Change permissions (e.g. user forgot to make it executable before)
329+
script_path.chmod(0o777)
330+
# Answer "no" to reinstall prompt (we just want to test dry run)
331+
answer_prompt('n')
332+
await reinstall_cli(
333+
opts=ReInstallOptions(), workflow_id=one_run.id
334+
)
335+
out, _ = capsys.readouterr()
336+
assert "send myscript" in out
337+
338+
314339
@pytest.fixture
315340
def my_install_plugin(monkeypatch):
316341
"""This configures a single post_install plugin.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
2+
# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
from textwrap import dedent
18+
19+
from ansimarkup import parse as cparse
20+
import pytest
21+
22+
from cylc.flow.scripts.reinstall import format_reinstall_output
23+
from cylc.flow.terminal import DIM
24+
25+
26+
@pytest.mark.parametrize('verbose', [True, False])
27+
def test_format_reinstall_output(verbose, monkeypatch: pytest.MonkeyPatch):
28+
"""It should:
29+
- colorize the output
30+
- remove the itemized changes summary if not in verbose mode
31+
- remove the "cannot delete non-empty directory" message
32+
"""
33+
output = dedent("""
34+
*deleting del. Cloud.jpg
35+
>f+++++++++ send cloud.jpg
36+
.f...p..... send foo
37+
>fcsTp..... send bar
38+
cannot delete non-empty directory: scarf
39+
>f+++++++++ send meow.txt
40+
cL+++++++++ send garage -> foo
41+
""").strip()
42+
expected = [
43+
f"<{DIM}>*deleting </{DIM}> <red>del. Cloud.jpg</red>",
44+
f"<{DIM}>>f+++++++++</{DIM}> <green>send cloud.jpg</green>",
45+
f"<{DIM}>.f...p.....</{DIM}> <green>send foo</green>",
46+
f"<{DIM}>>fcsTp.....</{DIM}> <green>send bar</green>",
47+
f"<{DIM}>>f+++++++++</{DIM}> <green>send meow.txt</green>",
48+
f"<{DIM}>cL+++++++++</{DIM}> <green>send garage -> foo</green>",
49+
]
50+
if verbose:
51+
monkeypatch.setattr('cylc.flow.flags.verbosity', 1)
52+
else:
53+
# itemized changes summary should not be in output
54+
shift = len(f'<{DIM}></{DIM}> ') + 11
55+
expected = [i[shift:] for i in expected]
56+
assert format_reinstall_output(output) == [cparse(i) for i in expected]

0 commit comments

Comments
 (0)