Skip to content

Commit 736c60e

Browse files
boardwalk (bug): Ensure workflow is set as succeeded false when beginning a workflow (#105)
* boardwalk (bug): Ensure workflow is set as succeeded false when beginning a workflow This PR adds a test/fix to ensure that when reusing a workspace -- for example, updating the target version to upgrade an operating system to -- the remote state file is updated to reflect the fact that the Workspace's Workflow has not yet successfully completed, prior to said workflow actually beginning. This ensures that if the reused Workspace's workflow completed successfully, a failure within the current workflow won't requite manual user intervention to reset the remote state file's workflow success attribute to false. This allows the workflow to be resumed, as prior to this if a host met preconditions, and as a result of the workflow they altered to a state where they are no longer met -- e.g., an OS upgrade -- the workflow would refuse to allow resumption of the failed workflow, because -- to Boardwalk -- the host already succeeded. Compatible with previous release.
1 parent b576059 commit 736c60e

13 files changed

+893
-636
lines changed

.github/workflows/make-test.yml

+34-5
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22
name: Run Boardwalk test suites
33

44
on:
5-
workflow_dispatch:
65
pull_request:
6+
push:
7+
branches:
8+
- main
9+
workflow_dispatch:
710

811
permissions:
912
contents: read
1013
pull-requests: read
1114

1215
jobs:
13-
make-test:
16+
python-tests:
17+
name: Boardwalk Test Suite
1418
runs-on: ubuntu-latest
1519

1620
steps:
@@ -79,10 +83,35 @@ jobs:
7983
# Run pytest
8084
- run: make test-pytest
8185

82-
# Check code formatting and import sorting
83-
- run: make test-ruff
84-
8586
# Commented out because we're planning on switching to a different static
8687
# typechecker, and frankly whatever is taking pyright _minutes_ to run is
8788
# a little excessive, for now.
8889
# - run: make test-pyright
90+
91+
ansible-lint:
92+
name: Ansible Lint
93+
runs-on: ubuntu-latest
94+
steps:
95+
- uses: actions/checkout@v4
96+
- name: Run ansible-lint
97+
uses: ansible/[email protected]
98+
# optional (see below):
99+
with:
100+
args: "--config-file ${{ github.workspace }}/test/ansible-lint.yaml"
101+
working_directory: ${{ github.workspace }}/test
102+
103+
ruff-lint-check:
104+
name: Ruff - Linting check
105+
runs-on: ubuntu-latest
106+
steps:
107+
- uses: actions/checkout@v4
108+
- uses: astral-sh/ruff-action@v1
109+
110+
ruff-formatting-check:
111+
name: Ruff - Formatting check
112+
runs-on: ubuntu-latest
113+
steps:
114+
- uses: actions/checkout@v4
115+
- uses: astral-sh/ruff-action@v1
116+
with:
117+
args: "format --check"

CONTRIBUTING.md

+12
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ Automated tests should be developed for cases that clearly improve Boardwalk's
8888
reliability, user and developer experience. Otherwise, there is no specific
8989
enforcement of test coverage.
9090

91+
#### `ansible-lint`
92+
93+
Both
94+
[Ansible](https://github.com/ansible/ansible/tree/devel?tab=GPL-3.0-1-ov-file#readme)
95+
and
96+
[`ansible-lint`](https://github.com/ansible/ansible-lint?tab=GPL-3.0-1-ov-file#readme)
97+
are licensed under the GNU GPLv3. Consequently, to guard against the GPLv3.0
98+
license attaching to Boardwalk, `ansible-lint` is not included as a development
99+
dependency, even as an optional development dependency. Consequently, to execute
100+
`make test-ansible-lint`, `ansible-lint` will need to be available (e.g., via a
101+
`pipx` install, or similar).
102+
91103
### Versioning
92104

93105
The boardwalk pip module uses semantic versioning. Please make sure to update

Makefile

+5-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,11 @@ render-d2:
8989

9090
# Runs all available tests
9191
.PHONY: test
92-
test: test-pytest test-ruff test-pyright test-semgrep
92+
test: test-pytest test-ruff test-pyright test-semgrep test-ansible-lint
93+
94+
.PHONY: test-ansible-lint
95+
test-ansible-lint: develop
96+
-cd test && poetry run ansible-lint --config-file ansible-lint.yaml
9397

9498
# Run pytest verbosely if we're running manually, but normally if we're in a CI environment.
9599
.PHONY: test-pytest

poetry.lock

+547-587
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+10-9
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ requires-python = ">=3.11"
1111

1212
[tool.poetry]
1313
name = "boardwalk"
14-
version = "0.8.24"
14+
version = "0.8.25"
1515
description = "Boardwalk is a linear Ansible workflow engine"
1616
readme = "README.md"
1717
authors = [
@@ -41,23 +41,24 @@ include = [
4141
"Issues" = "https://github.com/Backblaze/boardwalk/issues"
4242

4343
[tool.poetry.dependencies]
44-
python = ">=3.11,<4"
44+
aiohttp = "^3.11.7" # Required by slack-bolt's AsyncApp
4545
ansible-runner = ">=2.3.0"
4646
click = ">=8.1.3"
4747
cryptography = ">=38.0.3"
48-
email-validator = ">=1.3.0" # Required by pydantic to validate emails using EmailStr
48+
email-validator = ">=1.3.0" # Required by pydantic to validate emails using EmailStr
49+
loguru = "^0.7.2"
4950
pydantic = ">=2.4.2"
50-
tornado = ">=6.2"
51+
python = ">=3.11,<4"
5152
slack-bolt = "^1.18.1"
52-
aiohttp = "^3.10.11" # Required by slack-bolt's AsyncApp
53-
loguru = "^0.7.2"
53+
tornado = ">=6.4.2"
54+
5455

5556
[tool.poetry.group.dev.dependencies]
57+
anyio = "^4.6.2.post1"
5658
pyright = "==1.1.350"
57-
semgrep = ">=1.92.0"
58-
ruff = "^0.7.4"
5959
pytest = "^8.1.1"
60-
anyio = "^4.6.2.post1"
60+
ruff = "^0.8.0"
61+
semgrep = ">=1.92.0"
6162

6263
[tool.poetry.group.docs]
6364
optional = true

src/boardwalk/cli_run.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -716,8 +716,11 @@ def execute_host_workflow(host: Host, workspace: Workspace, verbosity: int):
716716
remote_state = host.get_remote_state()
717717
try:
718718
remote_state.workspaces[workspace.name].workflow.started = True
719+
remote_state.workspaces[workspace.name].workflow.succeeded = False
719720
except KeyError:
720-
remote_state.workspaces[workspace.name] = RemoteStateWorkspace(workflow=RemoteStateWorkflow(started=True))
721+
remote_state.workspaces[workspace.name] = RemoteStateWorkspace(
722+
workflow=RemoteStateWorkflow(started=True, succeeded=False)
723+
)
721724
host.set_remote_state(remote_state, become_password, _check_mode)
722725

723726
if boardwalkd_client:

test/ansible-lint.yaml

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
# .ansible-lint
3+
4+
# exclude_paths included in this file are parsed relative to this file's location
5+
# and not relative to the CWD of execution. CLI arguments passed to the --exclude
6+
# option are parsed relative to the CWD of execution.
7+
exclude_paths:
8+
- .cache/ # implicit unless exclude_paths is defined in config
9+
- semgrep-rules.yml
10+
- server-client/playbooks/malformed_playbook.yml
11+
# parseable: true
12+
quiet: true
13+
# strict: true
14+
# verbosity: 1
15+
16+
use_default_rules: true
17+
18+
# Ansible-lint does not automatically load rules that have the 'opt-in' tag.
19+
# You must enable opt-in rules by listing each rule 'id' below.
20+
enable_list:
21+
- args
22+
- empty-string-compare # opt-in
23+
- no-log-password # opt-in
24+
- no-same-owner # opt-in
25+
- name[prefix] # opt-in
26+
- galaxy-version-incorrect # opt-in
27+
# add yaml here if you want to avoid ignoring yaml checks when yamllint
28+
# library is missing. Normally its absence just skips using that rule.
29+
- yaml
30+
31+
# Allow setting custom prefix for name[prefix] rule
32+
task_name_prefix: "{stem} | "

test/integration/test_workspaces.py

+78-32
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import platform
3+
import re
34
import signal
45
from pathlib import Path
56
from typing import Any
@@ -9,6 +10,52 @@
910
from anyio.streams.text import TextReceiveStream
1011

1112

13+
async def execute_boardwalk_workspace_test(
14+
workspace_name: str,
15+
failure_expected: bool,
16+
failure_msg: str,
17+
get_become_password_file_path: Path,
18+
use_isolated_boardwalk_directory,
19+
):
20+
WORKSPACE_CAUGHT_REGEX = re.compile(
21+
rf"The {workspace_name} workspace is remotely caught on .+ Waiting for release before continuing"
22+
)
23+
envvars: dict[str, Any] = {
24+
"ANSIBLE_BECOME_PASSWORD_FILE": get_become_password_file_path,
25+
"BOARDWALK_VERBOSITY": "2",
26+
}
27+
new_environ = os.environ | envvars
28+
new_environ.pop("ANSIBLE_BECOME_ASK_PASS", None)
29+
commands: tuple[str, ...] = (
30+
f"boardwalk -vv workspace use {workspace_name}",
31+
"boardwalk init",
32+
"boardwalk run",
33+
)
34+
output_stdout = []
35+
output_stderr = []
36+
os.chdir(use_isolated_boardwalk_directory)
37+
async with create_task_group():
38+
for command in commands:
39+
with fail_after(delay=90) as scope:
40+
async with await open_process(command=command, env=new_environ) as process:
41+
async for text in TextReceiveStream(process.stdout): # type:ignore
42+
# To allow for reading what was received, if the test ends up failing.
43+
print(text)
44+
output_stdout.append(text)
45+
if re.search(WORKSPACE_CAUGHT_REGEX, text):
46+
process.send_signal(signal.SIGINT)
47+
async for text in TextReceiveStream(process.stderr): # type:ignore
48+
print(text)
49+
output_stderr.append(text)
50+
assert not scope.cancelled_caught
51+
52+
if failure_expected:
53+
assert failure_msg in "".join(output_stdout)
54+
else:
55+
assert "Host completed successfully; wrapping up" in "".join(output_stdout)
56+
assert process.returncode == 0
57+
58+
1259
@pytest.mark.anyio
1360
@pytest.mark.skipif(condition="CI" in os.environ, reason="Not yet able to execute non-interactively.")
1461
@pytest.mark.parametrize(
@@ -58,38 +105,37 @@ async def test_development_workspaces(
58105
get_become_password_file_path: Path,
59106
use_isolated_boardwalk_directory,
60107
):
61-
envvars: dict[str, Any] = {
62-
"ANSIBLE_BECOME_PASSWORD_FILE": get_become_password_file_path,
63-
"BOARDWALK_VERBOSITY": "2",
64-
}
65-
new_environ = os.environ | envvars
66-
new_environ.pop("ANSIBLE_BECOME_ASK_PASS", None)
67-
commands: tuple[str, ...] = (
68-
f"boardwalk -vv workspace use {workspace_name}",
69-
"boardwalk init",
70-
"boardwalk run",
108+
await execute_boardwalk_workspace_test(
109+
workspace_name=workspace_name,
110+
failure_msg=failure_msg,
111+
failure_expected=failure_expected,
112+
get_become_password_file_path=get_become_password_file_path,
113+
use_isolated_boardwalk_directory=use_isolated_boardwalk_directory,
71114
)
72-
output_stdout = []
73-
output_stderr = []
74-
os.chdir(use_isolated_boardwalk_directory)
75-
async with create_task_group():
76-
for command in commands:
77-
with fail_after(delay=90) as scope:
78-
async with await open_process(command=command, env=new_environ) as process:
79-
async for text in TextReceiveStream(process.stdout): # type:ignore
80-
# To allow for reading what was received, if the test ends up failing.
81-
print(text)
82-
output_stdout.append(text)
83-
if failure_expected and "Waiting for release before continuing" in text:
84-
process.send_signal(signal.SIGINT)
85-
async for text in TextReceiveStream(process.stderr): # type:ignore
86-
print(text)
87-
output_stderr.append(text)
88115

89-
assert not scope.cancelled_caught
90116

91-
if failure_expected:
92-
assert failure_msg in "".join(output_stdout)
93-
else:
94-
assert "Host completed successfully; wrapping up" in "".join(output_stdout)
95-
assert process.returncode == 0
117+
@pytest.mark.anyio
118+
@pytest.mark.skipif(condition="CI" in os.environ, reason="Not yet able to execute non-interactively.")
119+
async def test_ensure_remote_workflow_success_state_false_during_workflow_execution(
120+
get_become_password_file_path: Path,
121+
use_isolated_boardwalk_directory,
122+
workspace_name: str = "RemoteStateShouldHaveWorkflowSucceededValueSetAsFalseDuringExecutionWorkspace",
123+
failure_expected: bool = False,
124+
failure_msg: str = "",
125+
):
126+
# This first execution ensures the Workspace completes at least once (ensuring the state is set)
127+
await execute_boardwalk_workspace_test(
128+
workspace_name=workspace_name,
129+
failure_msg=failure_msg,
130+
failure_expected=failure_expected,
131+
get_become_password_file_path=get_become_password_file_path,
132+
use_isolated_boardwalk_directory=use_isolated_boardwalk_directory,
133+
)
134+
# This second execution actually verifies that the succeeded state is False
135+
await execute_boardwalk_workspace_test(
136+
workspace_name=workspace_name,
137+
failure_msg=failure_msg,
138+
failure_expected=failure_expected,
139+
get_become_password_file_path=get_become_password_file_path,
140+
use_isolated_boardwalk_directory=use_isolated_boardwalk_directory,
141+
)

test/server-client/Boardwalkfile.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING
55

66
from pylib.regression_bz_svreng_609 import * # noqa: F403
7+
from pylib.remote_state_set_unsuccessful_during_active_workflow import * # noqa: F403
78

89
from boardwalk import PlaybookJob, TaskJob, Workflow, Workspace, WorkspaceConfig, path
910

@@ -139,7 +140,7 @@ class MalformedYAMLJob(TaskJob):
139140
"""
140141

141142
def tasks(self) -> AnsibleTasksType:
142-
return [{"ansible.builtin.import_tasks": path("malformed_playbook.yml")}]
143+
return [{"ansible.builtin.import_tasks": path("playbooks/malformed_playbook.yml")}]
143144

144145

145146
class ShouldSucceedPlaybookExecutionTestJob(PlaybookJob):

0 commit comments

Comments
 (0)