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
from abc import abstractmethod
import pydantic
@ -12,3 +13,7 @@ class Action:
def __init__(self, parameters=None):
self.logger = logging.getLogger(self.__class__.__name__)
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
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
"""
@ -47,7 +50,8 @@ class ScriptAction(Action):
return f"{self.DEFAULT_SHEBANG}{os.linesep}{self.parameters.script}"
return self.parameters.script
def execute(self):
def execute(self, **kwargs):
workspace = kwargs.get("workspace")
script = self._get_script()
# Write the script to a temporary file
@ -59,7 +63,11 @@ class ScriptAction(Action):
os.chmod(script_file.name, mode=0o500)
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)

View File

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

View File

@ -20,7 +20,7 @@ class Executor:
def execute_step(self, step: ExecutionStep):
try:
step.result = step.task.action.execute()
step.result = step.task.action.execute(workspace=self._execution.workspace)
except Exception as error:
# Catch unhandled exceptions, mark the result as unsuccessful
# and append the error as output.
@ -39,6 +39,16 @@ class Executor:
return step.result
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._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 pathlib import Path
from dataclasses import field
import pydantic
@ -38,11 +41,33 @@ class ExecutionStep(BaseObject):
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):
flow: Flow
steps: List[ExecutionStep]
workspace: Workspace = None # type: ignore
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
def output(self):
for step in self.steps:

View File

@ -1,8 +1,24 @@
import os
import pytest
from jeeves.core.objects import Workspace
from jeeves.core.registry import ActionRegistry
@pytest.fixture(scope="session", autouse=True)
def autoregister_actions():
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()))
def test_script_bash_task_ok():
def test_script_bash_task_ok(workspace_obj):
task = Task.parse_obj(MOCK_DEFINITION).action
result = task.execute()
result = task.execute(workspace=workspace_obj)
assert result.success
@mock.patch(
"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
result = task.execute()
result = task.execute(workspace=workspace_obj)
assert not result.success
@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["parameters"]["script"] = definition["parameters"]["script"].strip(
"#!/bin/bash"
)
task = Task.parse_obj(definition).action
assert task._get_script().startswith(task.DEFAULT_SHEBANG)
result = task.execute()
result = task.execute(workspace=workspace_obj)
assert result.success
@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
expected_output = "Hello world! (from python)"
definition = MOCK_DEFINITION.copy()
@ -55,11 +55,11 @@ def test_script_with_other_shebang_ok():
definition["parameters"]["script"] = py_script
task = Task.parse_obj(definition).action
assert task._get_script().startswith(f"#!{py_interpreter}")
result = task.execute()
result = task.execute(workspace=workspace_obj)
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``.
@ -78,25 +78,25 @@ def test_script_stdout_and_stderr_is_sent_to_result_ok():
definition = MOCK_DEFINITION.copy()
definition["parameters"]["script"] = script
task = Task.parse_obj(definition).action
result = task.execute()
result = task.execute(workspace=workspace_obj)
assert "Hello" in result.output
assert "World" in result.output
@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"""
task = Task.parse_obj(MOCK_DEFINITION).action
temp = tempfile.NamedTemporaryFile(mode="w", delete=False)
with mock.patch(
"tempfile.NamedTemporaryFile", mock.MagicMock(return_value=temp)
) as mocked:
task.execute()
task.execute(workspace=workspace_obj)
assert not os.path.isfile(mocked.return_value.name)
@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"""
task = Task.parse_obj(MOCK_DEFINITION).action
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)
) as mocked:
with mock.patch("os.unlink"):
task.execute()
task.execute(workspace=workspace_obj)
stat = os.stat(mocked.return_value.name)
assert oct(stat.st_mode).endswith("500")
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 .factories import FlowFactory, TaskFactory
from jeeves.core.tests.factories import FlowFactory, TaskFactory
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.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)