diff --git a/changes.d/6658.fix.md b/changes.d/6658.fix.md
new file mode 100644
index 00000000000..51876a73e37
--- /dev/null
+++ b/changes.d/6658.fix.md
@@ -0,0 +1 @@
+Fixed `cylc reinstall` not picking up file permissions changes.
diff --git a/cylc/flow/install.py b/cylc/flow/install.py
index 87b8b6b0565..284f7ff9e77 100644
--- a/cylc/flow/install.py
+++ b/cylc/flow/install.py
@@ -213,19 +213,16 @@ def reinstall_workflow(
dry_run=dry_run,
)
- # Add '+++' to -out-format to mark lines passed through formatter.
- rsync_cmd.append('--out-format=+++%o %n%L+++')
+ rsync_cmd.append('--out-format=%i %o %n%L')
+ # %i: itemized changes - needed for rsync to report files with
+ # changed permissions
# Run rsync command:
reinstall_log.info(cli_format(rsync_cmd))
LOG.debug(cli_format(rsync_cmd))
proc = Popen(rsync_cmd, stdout=PIPE, stderr=PIPE, text=True) # nosec
# * command is constructed via internal interface
- stdout, stderr = proc.communicate()
-
- # Strip unwanted output.
- stdout = ('\n'.join(re.findall(r'\+\+\+(.*)\+\+\+', stdout))).strip()
- stderr = stderr.strip()
+ stdout, stderr = (i.strip() for i in proc.communicate())
if proc.returncode != 0:
raise WorkflowFilesError(
diff --git a/cylc/flow/remote.py b/cylc/flow/remote.py
index e0ab7544a39..15ab7ab2f96 100644
--- a/cylc/flow/remote.py
+++ b/cylc/flow/remote.py
@@ -183,6 +183,9 @@ def get_includes_to_rsync(rsync_includes=None):
'--out-format=%o %n%L',
'--no-t'
]
+# %o: the operation (send or del.)
+# %n: filename
+# %L: "-> symlink_target" if applicable
DEFAULT_INCLUDES = [
'/ana/***', # Rose ana analysis modules
diff --git a/cylc/flow/scripts/reinstall.py b/cylc/flow/scripts/reinstall.py
index 1c545944be4..3e55e6edd7f 100644
--- a/cylc/flow/scripts/reinstall.py
+++ b/cylc/flow/scripts/reinstall.py
@@ -68,10 +68,16 @@
"cylc install" even if present in the source directory.
"""
+from functools import partial
from pathlib import Path
+import re
import sys
-from typing import Optional, TYPE_CHECKING, List, Callable
-from functools import partial
+from typing import (
+ TYPE_CHECKING,
+ Callable,
+ List,
+ Optional,
+)
from ansimarkup import parse as cparse
@@ -79,22 +85,26 @@
ServiceFileError,
WorkflowFilesError,
)
-from cylc.flow.install import (
- reinstall_workflow,
-)
+import cylc.flow.flags
+from cylc.flow.install import reinstall_workflow
from cylc.flow.network.multi import call_multi
from cylc.flow.option_parsers import (
+ ID_MULTI_ARG_DOC,
CylcOptionParser as COP,
OptionSettings,
- ID_MULTI_ARG_DOC
)
from cylc.flow.pathutil import get_workflow_run_dir
from cylc.flow.plugins import run_plugins_async
+from cylc.flow.terminal import (
+ DIM,
+ cli_function,
+ is_terminal,
+)
from cylc.flow.workflow_files import (
get_workflow_source_dir,
load_contact_file,
)
-from cylc.flow.terminal import cli_function, DIM, is_terminal
+
if TYPE_CHECKING:
from optparse import Values
@@ -269,9 +279,8 @@ async def reinstall(
update anything, else returns True.
"""
- # run pre_configure plugins
if not dry_run:
- # don't run plugins in dry-mode
+ # run pre_configure plugins
async for _entry_point, _plugin_result in run_plugins_async(
'cylc.pre_configure',
srcdir=src_dir,
@@ -287,18 +296,17 @@ async def reinstall(
dry_run=dry_run,
)
- # display changes
if dry_run:
- if not stdout or stdout == 'send ./':
- # no rsync output == no changes => exit
+ # display changes
+ changes = format_reinstall_output(stdout)
+ if not changes:
return False
# display rsync output
- write('\n'.join(format_rsync_out(stdout)), file=sys.stderr)
+ write('\n'.join(changes), file=sys.stdout)
- # run post_install plugins
if not dry_run:
- # don't run plugins in dry-mode
+ # run post_install plugins
async for _entry_point, _plugin_result in run_plugins_async(
'cylc.post_install',
srcdir=src_dir,
@@ -311,15 +319,15 @@ async def reinstall(
return True
-def format_rsync_out(out: str) -> List[str]:
+def format_reinstall_output(out: str) -> List[str]:
r"""Format rsync stdout for presenting to users.
Note: Output formats of different rsync implementations may differ so keep
this code simple and robust.
Example:
- >>> format_rsync_out(
- ... 'send foo\ndel. bar\nbaz'
+ >>> format_reinstall_output(
+ ... '>f+++++++++ send foo\n*deleting del. bar\nbaz'
... '\ncannot delete non-empty directory: opt'
... ) == [
... cparse('send foo'),
@@ -331,20 +339,23 @@ def format_rsync_out(out: str) -> List[str]:
"""
lines = []
for line in out.splitlines():
- if line[0:4] == 'send':
- # file added or updated
- lines.append(cparse(f'{line}'))
- elif line[0:4] == 'del.':
- # file deleted
- lines.append(cparse(f'{line}'))
- elif line == 'cannot delete non-empty directory: opt':
- # These "cannot delete non-empty directory" messages can arise
- # as a result of excluding files within sub-directories.
- # This opt dir message is likely to occur when a rose-suit.conf
+ if not line or line.startswith('cannot delete non-empty directory:'):
+ # This can arise as a result of deleting a subdir when we are
+ # excluding files within the subdir.
+ # This is likely to occur for the opt dir when a rose-suite.conf
# file is present.
+ # Skip this line as nothing will happen to this dir.
continue
+ match = re.match(r'^(.{11}) (send|del\.) (.*)$', line)
+ if match:
+ summary, operation, file = match.groups()
+ color = 'green' if operation == 'send' else 'red'
+ formatted_line = f"<{color}>{operation} {file}{color}>"
+ if cylc.flow.flags.verbosity > 0:
+ formatted_line = f"<{DIM}>{summary}{DIM}> {formatted_line}"
+ lines.append(cparse(formatted_line))
else:
- # other uncategorised log line
+ # shouldn't happen; tolerate unknown rsync implementation?
lines.append(line)
return lines
diff --git a/tests/integration/test_reinstall.py b/tests/integration/test_reinstall.py
index d1d89b66b2d..00f2de7cb12 100644
--- a/tests/integration/test_reinstall.py
+++ b/tests/integration/test_reinstall.py
@@ -19,28 +19,23 @@
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
+from secrets import token_hex
from types import SimpleNamespace
-from uuid import uuid1
import pytest
-from cylc.flow.exceptions import (
- WorkflowFilesError,
-)
-from cylc.flow.install import (
- reinstall_workflow,
-)
+from cylc.flow.exceptions import WorkflowFilesError
+from cylc.flow.install import reinstall_workflow
from cylc.flow.option_parsers import Options
from cylc.flow.scripts.reinstall import (
get_option_parser as reinstall_gop,
reinstall_cli,
)
-from cylc.flow.workflow_files import (
- WorkflowFiles,
-)
+from cylc.flow.workflow_files import WorkflowFiles
from .utils.entry_points import EntryPointWrapper
+
ReInstallOptions = Options(reinstall_gop())
# cli opts
@@ -66,9 +61,22 @@ def non_interactive(monkeypatch):
)
+@pytest.fixture
+def answer_prompt(monkeypatch: pytest.MonkeyPatch):
+ """Answer reinstall prompt."""
+
+ def inner(answer: str):
+ monkeypatch.setattr(
+ 'cylc.flow.scripts.reinstall._input', lambda x: answer
+ )
+
+ return inner
+
+
@pytest.fixture
def one_src(tmp_path):
- src_dir = tmp_path
+ src_dir = tmp_path / 'src'
+ src_dir.mkdir()
(src_dir / 'flow.cylc').touch()
(src_dir / 'rose-suite.conf').touch()
return SimpleNamespace(path=src_dir)
@@ -76,7 +84,7 @@ def one_src(tmp_path):
@pytest.fixture
def one_run(one_src, test_dir, run_dir):
- w_run_dir = test_dir / str(uuid1())
+ w_run_dir = test_dir / token_hex(4)
w_run_dir.mkdir()
(w_run_dir / 'flow.cylc').touch()
(w_run_dir / 'rose-suite.conf').touch()
@@ -151,7 +159,7 @@ async def test_interactive(
capsys,
capcall,
interactive,
- monkeypatch
+ answer_prompt
):
"""It should perform a dry-run and prompt in interactive mode."""
# capture reinstall calls
@@ -162,11 +170,7 @@ async def test_interactive(
# give it something to reinstall
(one_src.path / 'a').touch()
- # reinstall answering "no" to any prompt
- monkeypatch.setattr(
- 'cylc.flow.scripts.reinstall._input',
- lambda x: 'n'
- )
+ answer_prompt('n')
assert (
await reinstall_cli(opts=ReInstallOptions(), workflow_id=one_run.id)
is False
@@ -177,11 +181,7 @@ async def test_interactive(
assert 'Reinstall canceled, no changes made.' in capsys.readouterr().out
reinstall_calls.clear()
- # reinstall answering "yes" to any prompt
- monkeypatch.setattr(
- 'cylc.flow.scripts.reinstall._input',
- lambda x: 'y'
- )
+ answer_prompt('y')
assert await reinstall_cli(opts=ReInstallOptions(), workflow_id=one_run.id)
# 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):
async def test_rose_warning(
- one_src, one_run, capsys, interactive, monkeypatch
+ one_src, one_run, capsys, interactive, answer_prompt
):
"""It should warn that Rose installed files will be deleted.
@@ -252,11 +252,7 @@ async def test_rose_warning(
'Files created by Rose file installation will show as deleted'
)
- # reinstall answering "no" to any prompt
- monkeypatch.setattr(
- 'cylc.flow.scripts.reinstall._input',
- lambda x: 'n'
- )
+ answer_prompt('n')
(one_src.path / 'a').touch() # give it something to install
# reinstall (with rose-suite.conf file)
@@ -311,6 +307,35 @@ async def test_rsync_fail(one_src, one_run, mock_glbl_cfg, non_interactive):
assert 'An error occurred reinstalling' in str(exc_ctx.value)
+async def test_permissions_change(
+ one_src,
+ one_run,
+ interactive,
+ answer_prompt,
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture,
+):
+ """It detects permissions changes."""
+ # Add script file:
+ script_path: Path = one_src.path / 'myscript'
+ script_path.touch()
+ await reinstall_cli(
+ opts=ReInstallOptions(skip_interactive=True), workflow_id=one_run.id
+ )
+ assert (one_run.path / 'myscript').is_file()
+ capsys.readouterr() # clears capsys
+
+ # Change permissions (e.g. user forgot to make it executable before)
+ script_path.chmod(0o777)
+ # Answer "no" to reinstall prompt (we just want to test dry run)
+ answer_prompt('n')
+ await reinstall_cli(
+ opts=ReInstallOptions(), workflow_id=one_run.id
+ )
+ out, _ = capsys.readouterr()
+ assert "send myscript" in out
+
+
@pytest.fixture
def my_install_plugin(monkeypatch):
"""This configures a single post_install plugin.
diff --git a/tests/unit/scripts/test_reinstall.py b/tests/unit/scripts/test_reinstall.py
new file mode 100644
index 00000000000..822069fe054
--- /dev/null
+++ b/tests/unit/scripts/test_reinstall.py
@@ -0,0 +1,56 @@
+# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
+# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+from textwrap import dedent
+
+from ansimarkup import parse as cparse
+import pytest
+
+from cylc.flow.scripts.reinstall import format_reinstall_output
+from cylc.flow.terminal import DIM
+
+
+@pytest.mark.parametrize('verbose', [True, False])
+def test_format_reinstall_output(verbose, monkeypatch: pytest.MonkeyPatch):
+ """It should:
+ - colorize the output
+ - remove the itemized changes summary if not in verbose mode
+ - remove the "cannot delete non-empty directory" message
+ """
+ output = dedent("""
+ *deleting del. Cloud.jpg
+ >f+++++++++ send cloud.jpg
+ .f...p..... send foo
+ >fcsTp..... send bar
+ cannot delete non-empty directory: scarf
+ >f+++++++++ send meow.txt
+ cL+++++++++ send garage -> foo
+ """).strip()
+ expected = [
+ f"<{DIM}>*deleting {DIM}> del. Cloud.jpg",
+ f"<{DIM}>>f+++++++++{DIM}> send cloud.jpg",
+ f"<{DIM}>.f...p.....{DIM}> send foo",
+ f"<{DIM}>>fcsTp.....{DIM}> send bar",
+ f"<{DIM}>>f+++++++++{DIM}> send meow.txt",
+ f"<{DIM}>cL+++++++++{DIM}> send garage -> foo",
+ ]
+ if verbose:
+ monkeypatch.setattr('cylc.flow.flags.verbosity', 1)
+ else:
+ # itemized changes summary should not be in output
+ shift = len(f'<{DIM}>{DIM}> ') + 11
+ expected = [i[shift:] for i in expected]
+ assert format_reinstall_output(output) == [cparse(i) for i in expected]