fmartingr
/
jeeves
Archived
1
0
Fork 0

Workspace initial support

This commit is contained in:
Felipe Martín 2019-10-09 06:45:57 +00:00
parent 3ef8ec8d87
commit 9565fa4640
9 changed files with 117 additions and 22 deletions

View File

@ -1,4 +1,5 @@
import logging import logging
from abc import abstractmethod
import pydantic import pydantic
@ -12,3 +13,7 @@ class Action:
def __init__(self, parameters=None): def __init__(self, parameters=None):
self.logger = logging.getLogger(self.__class__.__name__) self.logger = logging.getLogger(self.__class__.__name__)
self.parameters = self.Parameters(**(parameters or {})) self.parameters = self.Parameters(**(parameters or {}))
@abstractmethod
def execute(self, workspace, **kwargs):
pass

View File

@ -18,6 +18,9 @@ class ScriptAction(Action):
If no shebang is provided, a default of ``#!/bin/bash -e`` will be used, if If no shebang is provided, a default of ``#!/bin/bash -e`` will be used, if
the provided shebang interpreter is not found on the system the action will fail. the provided shebang interpreter is not found on the system the action will fail.
The working directory for the process is the workspace path by default. This path
is also exposed as the ``WORKSPACE_PATH`` environment variable.
.. automethod:: _get_script .. automethod:: _get_script
""" """
@ -47,7 +50,8 @@ class ScriptAction(Action):
return f"{self.DEFAULT_SHEBANG}{os.linesep}{self.parameters.script}" return f"{self.DEFAULT_SHEBANG}{os.linesep}{self.parameters.script}"
return self.parameters.script return self.parameters.script
def execute(self): def execute(self, **kwargs):
workspace = kwargs.get("workspace")
script = self._get_script() script = self._get_script()
# Write the script to a temporary file # Write the script to a temporary file
@ -59,7 +63,11 @@ class ScriptAction(Action):
os.chmod(script_file.name, mode=0o500) os.chmod(script_file.name, mode=0o500)
process = subprocess.run( process = subprocess.run(
script_file.name, stdout=subprocess.PIPE, stderr=subprocess.STDOUT script_file.name,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=workspace.path,
env={"WORKSPACE_PATH": workspace.path},
) )
os.unlink(script_file.name) os.unlink(script_file.name)

View File

@ -10,21 +10,21 @@ from .base import Action
class StubSuccessAction(Action): class StubSuccessAction(Action):
id = "stub/success" id = "stub/success"
def execute(self, message=None): def execute(self, **kwargs):
return Result(success=True) return Result(success=True)
class StubNonSuccessAction(Action): class StubNonSuccessAction(Action):
id = "stub/non-success" id = "stub/non-success"
def execute(self): def execute(self, **kwargs):
return Result(output="error!", success=False) return Result(output="error!", success=False)
class StubUncaughtExceptionAction(Action): class StubUncaughtExceptionAction(Action):
id = "stub/uncaught-exception" id = "stub/uncaught-exception"
def execute(self): def execute(self, **kwargs):
raise Exception("Oh god...") raise Exception("Oh god...")

View File

@ -20,7 +20,7 @@ class Executor:
def execute_step(self, step: ExecutionStep): def execute_step(self, step: ExecutionStep):
try: try:
step.result = step.task.action.execute() step.result = step.task.action.execute(workspace=self._execution.workspace)
except Exception as error: except Exception as error:
# Catch unhandled exceptions, mark the result as unsuccessful # Catch unhandled exceptions, mark the result as unsuccessful
# and append the error as output. # and append the error as output.
@ -39,6 +39,16 @@ class Executor:
return step.result return step.result
def start(self): def start(self):
for step in self._execution.steps: """
Executes (sync) all the actions for the provided flow.
"""
for step in self.steps:
self.execute_step(step) self.execute_step(step)
self._execution.success = step.result.success self._execution.success = step.result.success
self.post_execution()
def post_execution(self):
"""
Cleanup after a flow have been executed.
"""
self._execution.workspace.destroy()

View File

@ -1,4 +1,7 @@
import shutil
import tempfile
from typing import Any, Dict, List, Text, Optional from typing import Any, Dict, List, Text, Optional
from pathlib import Path
from dataclasses import field from dataclasses import field
import pydantic import pydantic
@ -38,11 +41,33 @@ class ExecutionStep(BaseObject):
result: Optional[Result] = None result: Optional[Result] = None
class Workspace(BaseObject):
path: Path = None # type: ignore
@pydantic.validator("path", pre=True, always=True)
def path_default(cls, v): # pylint: disable=no-self-argument
"""
Ensures that a Workspace always have a path set up.
"""
return v or tempfile.mkdtemp(prefix="jeeves_")
def destroy(self):
"""
Removes the workspace path from the filesystem
"""
shutil.rmtree(self.path)
class Execution(BaseObject): class Execution(BaseObject):
flow: Flow flow: Flow
steps: List[ExecutionStep] steps: List[ExecutionStep]
workspace: Workspace = None # type: ignore
success: bool = False success: bool = False
@pydantic.validator("workspace", pre=True, always=True)
def workspace_default(cls, v): # pylint: disable=no-self-argument
return v or Workspace()
@property @property
def output(self): def output(self):
for step in self.steps: for step in self.steps:

View File

@ -1,8 +1,24 @@
import os
import pytest import pytest
from jeeves.core.objects import Workspace
from jeeves.core.registry import ActionRegistry from jeeves.core.registry import ActionRegistry
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def autoregister_actions(): def autoregister_actions():
ActionRegistry.autodiscover() ActionRegistry.autodiscover()
@pytest.fixture
def workspace_obj():
"""
Fixture that returns a :any:`core.objects.Workspace` object and then ensures
the path it's deleted.
Used for action tests.
"""
workspace = Workspace()
yield workspace
os.rmdir(workspace.path)

View File

@ -19,35 +19,35 @@ def get_completed_process(returncode=0, stdout=b"", **kwargs):
@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) @mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process()))
def test_script_bash_task_ok(): def test_script_bash_task_ok(workspace_obj):
task = Task.parse_obj(MOCK_DEFINITION).action task = Task.parse_obj(MOCK_DEFINITION).action
result = task.execute() result = task.execute(workspace=workspace_obj)
assert result.success assert result.success
@mock.patch( @mock.patch(
"subprocess.run", mock.MagicMock(return_value=get_completed_process(returncode=1)) "subprocess.run", mock.MagicMock(return_value=get_completed_process(returncode=1))
) )
def test_script_bash_task_ko(): def test_script_bash_task_ko(workspace_obj):
task = Task.parse_obj(MOCK_DEFINITION).action task = Task.parse_obj(MOCK_DEFINITION).action
result = task.execute() result = task.execute(workspace=workspace_obj)
assert not result.success assert not result.success
@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) @mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process()))
def test_script_no_shebang_defaults_to_bash_ok(): def test_script_no_shebang_defaults_to_bash_ok(workspace_obj):
definition = MOCK_DEFINITION.copy() definition = MOCK_DEFINITION.copy()
definition["parameters"]["script"] = definition["parameters"]["script"].strip( definition["parameters"]["script"] = definition["parameters"]["script"].strip(
"#!/bin/bash" "#!/bin/bash"
) )
task = Task.parse_obj(definition).action task = Task.parse_obj(definition).action
assert task._get_script().startswith(task.DEFAULT_SHEBANG) assert task._get_script().startswith(task.DEFAULT_SHEBANG)
result = task.execute() result = task.execute(workspace=workspace_obj)
assert result.success assert result.success
@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) @mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process()))
def test_script_with_other_shebang_ok(): def test_script_with_other_shebang_ok(workspace_obj):
py_interpreter = sys.executable py_interpreter = sys.executable
expected_output = "Hello world! (from python)" expected_output = "Hello world! (from python)"
definition = MOCK_DEFINITION.copy() definition = MOCK_DEFINITION.copy()
@ -55,11 +55,11 @@ def test_script_with_other_shebang_ok():
definition["parameters"]["script"] = py_script definition["parameters"]["script"] = py_script
task = Task.parse_obj(definition).action task = Task.parse_obj(definition).action
assert task._get_script().startswith(f"#!{py_interpreter}") assert task._get_script().startswith(f"#!{py_interpreter}")
result = task.execute() result = task.execute(workspace=workspace_obj)
assert result.success assert result.success
def test_script_stdout_and_stderr_is_sent_to_result_ok(): def test_script_stdout_and_stderr_is_sent_to_result_ok(workspace_obj):
""" """
..warning:: This test actually calls ``subprocess.run``. ..warning:: This test actually calls ``subprocess.run``.
@ -78,25 +78,25 @@ def test_script_stdout_and_stderr_is_sent_to_result_ok():
definition = MOCK_DEFINITION.copy() definition = MOCK_DEFINITION.copy()
definition["parameters"]["script"] = script definition["parameters"]["script"] = script
task = Task.parse_obj(definition).action task = Task.parse_obj(definition).action
result = task.execute() result = task.execute(workspace=workspace_obj)
assert "Hello" in result.output assert "Hello" in result.output
assert "World" in result.output assert "World" in result.output
@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) @mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process()))
def test_script_task_cleans_tempfile_ok(): def test_script_task_cleans_tempfile_ok(workspace_obj):
"""Make sure that the script is removed from the system after execution""" """Make sure that the script is removed from the system after execution"""
task = Task.parse_obj(MOCK_DEFINITION).action task = Task.parse_obj(MOCK_DEFINITION).action
temp = tempfile.NamedTemporaryFile(mode="w", delete=False) temp = tempfile.NamedTemporaryFile(mode="w", delete=False)
with mock.patch( with mock.patch(
"tempfile.NamedTemporaryFile", mock.MagicMock(return_value=temp) "tempfile.NamedTemporaryFile", mock.MagicMock(return_value=temp)
) as mocked: ) as mocked:
task.execute() task.execute(workspace=workspace_obj)
assert not os.path.isfile(mocked.return_value.name) assert not os.path.isfile(mocked.return_value.name)
@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) @mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process()))
def test_script_task_sets_permissions_for_owner_only_ok(): def test_script_task_sets_permissions_for_owner_only_ok(workspace_obj):
"""Make sure that the script have only read and execution permissions for owner""" """Make sure that the script have only read and execution permissions for owner"""
task = Task.parse_obj(MOCK_DEFINITION).action task = Task.parse_obj(MOCK_DEFINITION).action
temp = tempfile.NamedTemporaryFile(mode="w", delete=False) temp = tempfile.NamedTemporaryFile(mode="w", delete=False)
@ -104,7 +104,16 @@ def test_script_task_sets_permissions_for_owner_only_ok():
"tempfile.NamedTemporaryFile", mock.MagicMock(return_value=temp) "tempfile.NamedTemporaryFile", mock.MagicMock(return_value=temp)
) as mocked: ) as mocked:
with mock.patch("os.unlink"): with mock.patch("os.unlink"):
task.execute() task.execute(workspace=workspace_obj)
stat = os.stat(mocked.return_value.name) stat = os.stat(mocked.return_value.name)
assert oct(stat.st_mode).endswith("500") assert oct(stat.st_mode).endswith("500")
os.unlink(mocked.return_value.name) os.unlink(mocked.return_value.name)
@mock.patch("subprocess.run")
def test_script_task_appends_workspace_env_variable_ok(run_mock, workspace_obj):
"""Make sure that the WORKSPACE_PATH environment variable is sent correctly """
run_mock.return_value = get_completed_process()
task = Task.parse_obj(MOCK_DEFINITION).action
task.execute(workspace=workspace_obj)
assert run_mock.call_args[1]["env"] == {"WORKSPACE_PATH": workspace_obj.path}

View File

@ -1,5 +1,8 @@
import os.path
from unittest import mock
from jeeves.core.executor import Executor from jeeves.core.executor import Executor
from .factories import FlowFactory, TaskFactory from jeeves.core.tests.factories import FlowFactory, TaskFactory
def test_executor_success_task_ok(): def test_executor_success_task_ok():
@ -30,3 +33,22 @@ def test_executor_uncaught_exception_in_task_ok():
assert runner._execution.steps[0].result assert runner._execution.steps[0].result
assert runner._execution.steps[0].result.success is False assert runner._execution.steps[0].result.success is False
assert runner._execution.success is False assert runner._execution.success is False
@mock.patch("jeeves.core.actions.stub.StubSuccessAction.execute")
def test_executor_run_action_with_workpsace_ok(execute_mock):
task = TaskFactory(type="jeeves.core.actions.stub:StubSuccessAction")
flow = FlowFactory(tasks=[task])
runner = Executor(flow)
runner.start()
assert execute_mock.called
execute_mock.assert_called_with(workspace=runner._execution.workspace)
def test_executor_cleans_workspace_after_ok():
task = TaskFactory(type="jeeves.core.actions.stub:StubSuccessAction")
flow = FlowFactory(tasks=[task])
runner = Executor(flow)
path = runner._execution.workspace.path
runner.start()
assert not os.path.isdir(path)