def configure(self, options): """Configure the unit agent.""" super(UnitAgent, self).configure(options) if not options.get("unit_name"): msg = ("--unit-name must be provided in the command line, " "or $JUJU_UNIT_NAME in the environment") raise JujuError(msg) self.executor = HookExecutor() self.api_factory = UnitSettingsFactory( self.executor.get_hook_context, logging.getLogger("unit.hook.api")) self.api_socket = None self.workflow = None
def setUp(self): yield super(LifecycleTestBase, self).setUp() if self.juju_directory is None: self.juju_directory = self.makeDir() self.hook_log = self.capture_logging("hook.output", level=logging.DEBUG) self.agent_log = self.capture_logging("unit-agent", level=logging.DEBUG) self.executor = HookExecutor() self.executor.start() self.change_environment(PATH=os.environ["PATH"], JUJU_UNIT_NAME="service-unit/0")
def setUp(self): yield super(ControlResolvedTest, self).setUp() config = {"environments": {"firstenv": {"type": "dummy"}}} self.write_config(dump(config)) self.config.load() yield self.add_relation_state("wordpress", "mysql") yield self.add_relation_state("wordpress", "varnish") self.service1 = yield self.service_state_manager.get_service_state( "mysql") self.service_unit1 = yield self.service1.add_unit_state() self.service_unit2 = yield self.service1.add_unit_state() self.unit1_workflow = UnitWorkflowState(self.client, self.service_unit1, None, self.makeDir()) yield self.unit1_workflow.set_state("started") self.environment = self.config.get_default() self.provider = self.environment.get_machine_provider() self.output = self.capture_logging() self.stderr = self.capture_stream("stderr") self.executor = HookExecutor()
def setUp(self): yield super(LifecycleTestBase, self).setUp() if self.juju_directory is None: self.juju_directory = self.makeDir() self.hook_log = self.capture_logging("hook.output", level=logging.DEBUG) self.agent_log = self.capture_logging("unit-agent", level=logging.DEBUG) self.executor = HookExecutor() self.executor.start() self.change_environment( PATH=os.environ["PATH"], JUJU_UNIT_NAME="service-unit/0")
class UnitAgent(BaseAgent): """An juju Unit Agent. Provides for the management of a charm, via hook execution in response to external events in the coordination space (zookeeper). """ name = "juju-unit-agent" @classmethod def setup_options(cls, parser): super(UnitAgent, cls).setup_options(parser) unit_name = os.environ.get("JUJU_UNIT_NAME", "") parser.add_argument("--unit-name", default=unit_name) @property def unit_name(self): return self.config["unit_name"] def get_agent_name(self): return "unit:%s" % self.unit_name def configure(self, options): """Configure the unit agent.""" super(UnitAgent, self).configure(options) if not options.get("unit_name"): msg = ("--unit-name must be provided in the command line, " "or $JUJU_UNIT_NAME in the environment") raise JujuError(msg) self.executor = HookExecutor() self.api_factory = UnitSettingsFactory( self.executor.get_hook_context, logging.getLogger("unit.hook.api")) self.api_socket = None self.workflow = None @inlineCallbacks def start(self): """Start the unit agent process.""" self.service_state_manager = ServiceStateManager(self.client) self.executor.start() # Retrieve our unit and configure working directories. service_name = self.unit_name.split("/")[0] service_state = yield self.service_state_manager.get_service_state( service_name) self.unit_state = yield service_state.get_unit_state( self.unit_name) self.unit_directory = os.path.join( self.config["juju_directory"], "units", self.unit_state.unit_name.replace("/", "-")) self.state_directory = os.path.join( self.config["juju_directory"], "state") # Setup the server portion of the cli api exposed to hooks. from twisted.internet import reactor self.api_socket = reactor.listenUNIX( os.path.join(self.unit_directory, HOOK_SOCKET_FILE), self.api_factory) # Setup the unit state's address address = yield get_unit_address(self.client) yield self.unit_state.set_public_address( (yield address.get_public_address())) yield self.unit_state.set_private_address( (yield address.get_private_address())) # Inform the system, we're alive. yield self.unit_state.connect_agent() self.lifecycle = UnitLifecycle( self.client, self.unit_state, service_state, self.unit_directory, self.executor) self.workflow = UnitWorkflowState( self.client, self.unit_state, self.lifecycle, self.state_directory) if self.get_watch_enabled(): yield self.unit_state.watch_resolved(self.cb_watch_resolved) yield self.unit_state.watch_hook_debug(self.cb_watch_hook_debug) yield service_state.watch_config_state( self.cb_watch_config_changed) # Fire initial transitions, only if successful if (yield self.workflow.transition_state("installed")): yield self.workflow.transition_state("started") # Upgrade can only be processed if we're in a running state so # for case of a newly started unit, do it after the unit is started. if self.get_watch_enabled(): yield self.unit_state.watch_upgrade_flag( self.cb_watch_upgrade_flag) @inlineCallbacks def stop(self): """Stop the unit agent process.""" if self.workflow: yield self.workflow.transition_state("stopped") if self.api_socket: yield self.api_socket.stopListening() yield self.api_factory.stopFactory() @inlineCallbacks def cb_watch_resolved(self, change): """Update the unit's state, when its resolved. Resolved operations form the basis of error recovery for unit workflows. A resolved operation can optionally specify hook execution. The unit agent runs the error recovery transition if the unit is not in a running state. """ # Would be nice if we could fold this into an atomic # get and delete primitive. # Check resolved setting resolved = yield self.unit_state.get_resolved() if resolved is None: returnValue(None) # Clear out the setting yield self.unit_state.clear_resolved() # Verify its not already running if (yield self.workflow.get_state()) == "started": returnValue(None) log.info("Resolved detected, firing retry transition") # Fire a resolved transition try: if resolved["retry"] == RETRY_HOOKS: yield self.workflow.fire_transition_alias("retry_hook") else: yield self.workflow.fire_transition_alias("retry") except Exception: log.exception("Unknown error while transitioning for resolved") @inlineCallbacks def cb_watch_hook_debug(self, change): """Update the hooks to be debugged when the settings change. """ debug = yield self.unit_state.get_hook_debug() debug_hooks = debug and debug.get("debug_hooks") or None self.executor.set_debug(debug_hooks) @inlineCallbacks def cb_watch_upgrade_flag(self, change): """Update the unit's charm when requested. """ upgrade_flag = yield self.unit_state.get_upgrade_flag() if upgrade_flag: log.info("Upgrade detected, starting upgrade") upgrade = CharmUpgradeOperation(self) try: yield upgrade.run() except Exception: log.exception("Error while upgrading") @inlineCallbacks def cb_watch_config_changed(self, change): """Trigger hook on configuration change""" # Verify it is running current_state = yield self.workflow.get_state() log.debug("Configuration Changed") if current_state != "started": log.debug( "Configuration updated on service in a non-started state") returnValue(None) yield self.workflow.fire_transition("reconfigure")
def setUp(self): self._executor = HookExecutor() self.output = self.capture_logging("hook.executor", logging.DEBUG)
class HookExecutorTest(TestCase): def setUp(self): self._executor = HookExecutor() self.output = self.capture_logging("hook.executor", logging.DEBUG) @inlineCallbacks def test_observer(self): """An observer can be registered against the executor to recieve callbacks when hooks are executed.""" results = [] d = Deferred() def observer(hook_path): results.append(hook_path) if len(results) == 3: d.callback(True) self._executor.set_observer(observer) self._executor.start() class _Invoker(object): def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) hook_path = self.makeFile("hook content") yield self._executor(_Invoker(), hook_path) # Also observes non existant hooks yield self._executor(_Invoker(), self.makeFile()) self.assertEqual(len(results), 3) @inlineCallbacks def test_start_deferred_ends_on_stop(self): """The executor start method returns a deferred that fires when the executor has been stopped.""" stopped = [] def on_start_finish(result): self.assertTrue(stopped) d = self._executor.start() d.addCallback(on_start_finish) stopped.append(True) yield self._executor.stop() self._executor.debug = True yield d def test_start_start(self): """Attempting to start twice raises an exception.""" self._executor.start() return self.assertFailure(self._executor.start(), AssertionError) def test_stop_stop(self): """Attempt to stop twice raises an exception.""" self._executor.start() self._executor.stop() return self.assertFailure(self._executor.stop(), AssertionError) @inlineCallbacks def test_debug_hook(self): """A debug hook is executed if a debug hook name is found. """ self.output = self.capture_logging( "hook.executor", level=logging.DEBUG) results = [] class _Invoker(object): def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) self._executor.set_debug(["*"]) self._executor.start() yield self._executor(_Invoker(), "abc") self.assertNotEqual(results, ["abc"]) self.assertIn("abc", self.output.getvalue()) def test_get_debug_hook_path_executable(self): """The debug hook path return from the executor should be executable. """ self.patch( juju.hooks.executor, "DEBUG_HOOK_TEMPLATE", "#!/bin/bash\n echo {hook_name}\n exit 0") self._executor.set_debug(["*"]) debug_hook = self._executor.get_hook_path("something/good") stdout = open(self.makeFile(), "w+") p = subprocess.Popen(debug_hook, stdout=stdout.fileno()) self.assertEqual(p.wait(), 0) stdout.seek(0) self.assertEqual(stdout.read(), "good\n") @inlineCallbacks def test_end_debug_with_exited_process(self): """Ending debug with a process that has already ended is a noop.""" results = [] class _Invoker(object): process_ended = Deferred() def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) return self.process_ended def send_signal(self, signal_id): if results: results.append(1) raise ProcessExitedAlready() results.append(2) raise ValueError("No such process") self._executor.start() self._executor.set_debug(["abc"]) hook_done = self._executor(_Invoker(), "abc") self._executor.set_debug(None) _Invoker.process_ended.callback(True) yield hook_done self.assertEqual(len(results), 2) self.assertNotEqual(results[0], "abc") self.assertEqual(results[1], 1) @inlineCallbacks def test_end_debug_with_hook_not_started(self): results = [] class _Invoker(object): process_ended = Deferred() def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) return self.process_ended def send_signal(self, signal_id): if len(results) == 1: results.append(1) raise ValueError() results.append(2) raise ProcessExitedAlready() self._executor.start() self._executor.set_debug(["abc"]) hook_done = self._executor(_Invoker(), "abc") self._executor.set_debug(None) _Invoker.process_ended.callback(True) yield hook_done self.assertEqual(len(results), 2) self.assertNotEqual(results[0], "abc") self.assertEqual(results[1], 1) @inlineCallbacks def test_end_debug_with_debug_running(self): """If a debug hook is running, it is signaled if the debug is disabled. """ self.patch( juju.hooks.executor, "DEBUG_HOOK_TEMPLATE", "\n".join(("#!/bin/bash", "exit_handler() {", " echo clean exit", " exit 0", "}", 'trap "exit_handler" HUP', "sleep 0.2", "exit 1"))) unit_dir = self.makeDir() charm_dir = os.path.join(unit_dir, "charm") self.makeDir(path=charm_dir) self._executor.set_debug(["*"]) log = logging.getLogger("invoker") # Populate environment variables for default invoker. self.change_environment( JUJU_UNIT_NAME="dummy/1", PATH="/bin/:/usr/bin") output = self.capture_logging("invoker", level=logging.DEBUG) invoker = Invoker( None, None, "constant", self.makeFile(), unit_dir, log) self._executor.start() hook_done = self._executor(invoker, "abc") # Give a moment for execution to start. yield self.sleep(0.1) self._executor.set_debug(None) yield hook_done self.assertIn("clean exit", output.getvalue()) def test_get_debug_hook_path(self): """A debug hook file path is returned if a debug hook name is found. """ # Default is to return the file path. file_path = self.makeFile() hook_name = os.path.basename(file_path) self.assertEquals(self._executor.get_hook_path(file_path), file_path) # Hook names can be specified as globs. self._executor.set_debug(["*"]) debug_hook_path = self._executor.get_hook_path(file_path) self.assertNotEquals(file_path, debug_hook_path) # The hook base name is suffixed onto the debug hook file self.assertIn(os.path.basename(file_path), os.path.basename(debug_hook_path)) # Verify the debug hook contents. debug_hook_file = open(debug_hook_path) debug_contents = debug_hook_file.read() debug_hook_file.close() self.assertIn("hook.sh", debug_contents) self.assertIn("-n %s" % hook_name, debug_contents) self.assertTrue(os.access(debug_hook_path, os.X_OK)) # The hook debug can be set back to none. self._executor.set_debug(None) self.assertEquals(self._executor.get_hook_path(file_path), file_path) # the executor can debug only selected hooks. self._executor.set_debug(["abc"]) self.assertEquals(self._executor.get_hook_path(file_path), file_path) # The debug hook file is removed on the next hook path access. self.assertFalse(os.path.exists(debug_hook_path)) def test_hook_exception_propgates(self): """An error in a hook is propogated to the execution deferred.""" class _Invoker: def get_context(self): return None def __call__(self, hook_path): raise AttributeError("Foo") hook_path = self.makeFile("never got here") self._executor.start() return self.assertFailure( self._executor(_Invoker(), hook_path), AttributeError) @inlineCallbacks def test_executor_running_property(self): self._executor.start() self.assertTrue(self._executor.running) yield self._executor.stop() self.assertFalse(self._executor.running) @inlineCallbacks def test_nonexistant_hook_skipped(self): """If a hook does not exist a warning is logged and the hook skipped. """ class _Invoker: def get_context(self): return None self._executor.start() hook_path = self.makeFile() value = yield self._executor(_Invoker(), hook_path) self.assertEqual(value, False) self.assertIn("Hook does not exist, skipping %s" % hook_path, self.output.getvalue()) def test_start_stop_start(self): """The executor can be stopped and restarted.""" results = [] def invoke(hook_path): results.append(hook_path) self._executor(invoke, "1") start_complete = self._executor.start() self._executor.stop() yield start_complete self.assertEqual(len(results), 1) self._executor(invoke, "1") self._executor(invoke, "2") start_complete = self._executor.start() self._executor.stop() yield start_complete self.assertEqual(len(results), 3) @inlineCallbacks def test_run_priority_hook_while_already_running(self): """Attempting to run a priority hook while running is an error. """ def invoke(hook_path): pass self._executor.start() error = yield self.assertFailure( self._executor.run_priority_hook(invoke, "foobar"), AssertionError) self.assertEquals(str(error), "Executor must not be running") @inlineCallbacks def test_prioritize_with_queued(self): """A prioritized hook will execute before queued hooks. """ results = [] execs = [] hooks = [self.makeFile(str(i)) for i in range(5)] class _Invoker(object): def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) invoke = _Invoker() for i in hooks: execs.append(self._executor(invoke, i)) priority_hook = self.makeFile(str("me first")) yield self._executor.run_priority_hook(invoke, priority_hook) self._executor.start() yield gather_results(execs) hooks.insert(0, priority_hook) self.assertEqual(results, hooks) def test_serialized_execution(self): """Hook execution is serialized via the HookExecution api. """ wait_callback = [Deferred() for i in range(5)] finish_callback = [Deferred() for i in range(5)] results = [] @inlineCallbacks def invoker(hook_path): results.append(hook_path) yield finish_callback[len(results) - 1] wait_callback[len(results) - 1].callback(True) start_complete = self._executor.start() for i in range(5): self._executor(invoker, "hook-%s" % i) self.assertEqual(len(results), 1) finish_callback[1].callback(True) self.assertEqual(len(results), 1) # Verify stop behavior stop_complete = yield self._executor.stop() # Finish the running execution. finish_callback[0].callback(True) # Verify we've stopped executing. yield stop_complete self.assertTrue(start_complete.called) self.assertEqual(len(results), 1) # Start the executioner again. self._executor.start() for finish in finish_callback[2:]: finish.callback(True) self.assertEqual(len(results), 5)
class LifecycleTestBase(RelationTestBase): juju_directory = None @inlineCallbacks def setUp(self): yield super(LifecycleTestBase, self).setUp() if self.juju_directory is None: self.juju_directory = self.makeDir() self.hook_log = self.capture_logging("hook.output", level=logging.DEBUG) self.agent_log = self.capture_logging("unit-agent", level=logging.DEBUG) self.executor = HookExecutor() self.executor.start() self.change_environment( PATH=os.environ["PATH"], JUJU_UNIT_NAME="service-unit/0") @inlineCallbacks def setup_default_test_relation(self): mysql_ep = RelationEndpoint( "mysql", "client-server", "app", "server") wordpress_ep = RelationEndpoint( "wordpress", "client-server", "db", "client") self.states = yield self.add_relation_service_unit_from_endpoints( mysql_ep, wordpress_ep) self.unit_directory = os.path.join(self.juju_directory, "units", self.states["unit"].unit_name.replace("/", "-")) os.makedirs(os.path.join(self.unit_directory, "charm", "hooks")) os.makedirs(os.path.join(self.juju_directory, "state")) def write_hook(self, name, text, no_exec=False): hook_path = os.path.join(self.unit_directory, "charm", "hooks", name) hook_file = open(hook_path, "w") hook_file.write(text.strip()) hook_file.flush() hook_file.close() if not no_exec: os.chmod(hook_path, stat.S_IRWXU) return hook_path def wait_on_hook(self, name=None, count=None, sequence=(), debug=False, executor=None): """Wait on the given named hook to be executed. @param: name: if specified only one hook name can be waited on at a given time. @param: count: Multiples of the same name can be captured by specifying the count parameter. @param: sequence: A list of hook names executed in sequence to be waited on @param: debug: This parameter enables debug stdout loogging. @param: executor: A HookExecutor instance to use instead of the default """ d = Deferred() results = [] assert name is not None or sequence, "Hook match must be specified" def observer(hook_path): hook_name = os.path.basename(hook_path) results.append(hook_name) if debug: print "-> exec hook", hook_name if d.called: return if results == sequence: d.callback(True) if hook_name == name and count is None: d.callback(True) if hook_name == name and results.count(hook_name) == count: d.callback(True) executor = executor or self.executor executor.set_observer(observer) return d def wait_on_state(self, workflow, state, debug=False): state_changed = Deferred() def observer(workflow_state, state_variables): if debug: print " workflow state", state, workflow if workflow_state == state: state_changed.callback(True) workflow.set_observer(observer) return state_changed def capture_output(self, stdout=True): """Convience method to capture log output. Useful tool for observing interaction between components. """ if stdout: output = sys.stdout else: output = StringIO.StringIO() for log_name, indent in ( ("statemachine", 0), ("hook.executor", 2), ("hook.scheduler", 1), ("unit.lifecycle", 1), ("unit.relation.watch", 1), ("unit.relation.lifecycle", 1)): formatter = logging.Formatter( (" " * indent) + "%(name)s: %(message)s") self.capture_logging( log_name, level=logging.DEBUG, log_file=output, formatter=formatter) print return output
class HookExecutorTest(TestCase): def setUp(self): self._executor = HookExecutor() self.output = self.capture_logging("hook.executor", logging.DEBUG) @inlineCallbacks def test_observer(self): """An observer can be registered against the executor to recieve callbacks when hooks are executed.""" results = [] d = Deferred() def observer(hook_path): results.append(hook_path) if len(results) == 3: d.callback(True) self._executor.set_observer(observer) self._executor.start() class _Invoker(object): def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) hook_path = self.makeFile("hook content") yield self._executor(_Invoker(), hook_path) # Also observes non existant hooks yield self._executor(_Invoker(), self.makeFile()) self.assertEqual(len(results), 3) @inlineCallbacks def test_start_deferred_ends_on_stop(self): """The executor start method returns a deferred that fires when the executor has been stopped.""" stopped = [] def on_start_finish(result): self.assertTrue(stopped) d = self._executor.start() d.addCallback(on_start_finish) stopped.append(True) yield self._executor.stop() self._executor.debug = True yield d def test_start_start(self): """Attempting to start twice raises an exception.""" self._executor.start() return self.assertFailure(self._executor.start(), AssertionError) def test_stop_stop(self): """Attempt to stop twice raises an exception.""" self._executor.start() self._executor.stop() return self.assertFailure(self._executor.stop(), AssertionError) @inlineCallbacks def test_debug_hook(self): """A debug hook is executed if a debug hook name is found. """ self.output = self.capture_logging("hook.executor", level=logging.DEBUG) results = [] class _Invoker(object): def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) self._executor.set_debug(["*"]) self._executor.start() yield self._executor(_Invoker(), "abc") self.assertNotEqual(results, ["abc"]) self.assertIn("abc", self.output.getvalue()) def test_get_debug_hook_path_executable(self): """The debug hook path return from the executor should be executable. """ self.patch(juju.hooks.executor, "DEBUG_HOOK_TEMPLATE", "#!/bin/bash\n echo {hook_name}\n exit 0") self._executor.set_debug(["*"]) debug_hook = self._executor.get_hook_path("something/good") stdout = open(self.makeFile(), "w+") p = subprocess.Popen(debug_hook, stdout=stdout.fileno()) self.assertEqual(p.wait(), 0) stdout.seek(0) self.assertEqual(stdout.read(), "good\n") @inlineCallbacks def test_end_debug_with_exited_process(self): """Ending debug with a process that has already ended is a noop.""" results = [] class _Invoker(object): process_ended = Deferred() def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) return self.process_ended def send_signal(self, signal_id): if results: results.append(1) raise ProcessExitedAlready() results.append(2) raise ValueError("No such process") self._executor.start() self._executor.set_debug(["abc"]) hook_done = self._executor(_Invoker(), "abc") self._executor.set_debug(None) _Invoker.process_ended.callback(True) yield hook_done self.assertEqual(len(results), 2) self.assertNotEqual(results[0], "abc") self.assertEqual(results[1], 1) @inlineCallbacks def test_end_debug_with_hook_not_started(self): results = [] class _Invoker(object): process_ended = Deferred() def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) return self.process_ended def send_signal(self, signal_id): if len(results) == 1: results.append(1) raise ValueError() results.append(2) raise ProcessExitedAlready() self._executor.start() self._executor.set_debug(["abc"]) hook_done = self._executor(_Invoker(), "abc") self._executor.set_debug(None) _Invoker.process_ended.callback(True) yield hook_done self.assertEqual(len(results), 2) self.assertNotEqual(results[0], "abc") self.assertEqual(results[1], 1) @inlineCallbacks def test_end_debug_with_debug_running(self): """If a debug hook is running, it is signaled if the debug is disabled. """ self.patch( juju.hooks.executor, "DEBUG_HOOK_TEMPLATE", "\n".join( ("#!/bin/bash", "exit_handler() {", " echo clean exit", " exit 0", "}", 'trap "exit_handler" HUP', "sleep 0.2", "exit 1"))) unit_dir = self.makeDir() charm_dir = os.path.join(unit_dir, "charm") self.makeDir(path=charm_dir) self._executor.set_debug(["*"]) log = logging.getLogger("invoker") # Populate environment variables for default invoker. self.change_environment(JUJU_UNIT_NAME="dummy/1", PATH="/bin/:/usr/bin") output = self.capture_logging("invoker", level=logging.DEBUG) invoker = Invoker(None, None, "constant", self.makeFile(), unit_dir, log) self._executor.start() hook_done = self._executor(invoker, "abc") # Give a moment for execution to start. yield self.sleep(0.1) self._executor.set_debug(None) yield hook_done self.assertIn("clean exit", output.getvalue()) def test_get_debug_hook_path(self): """A debug hook file path is returned if a debug hook name is found. """ # Default is to return the file path. file_path = self.makeFile() hook_name = os.path.basename(file_path) self.assertEquals(self._executor.get_hook_path(file_path), file_path) # Hook names can be specified as globs. self._executor.set_debug(["*"]) debug_hook_path = self._executor.get_hook_path(file_path) self.assertNotEquals(file_path, debug_hook_path) # The hook base name is suffixed onto the debug hook file self.assertIn(os.path.basename(file_path), os.path.basename(debug_hook_path)) # Verify the debug hook contents. debug_hook_file = open(debug_hook_path) debug_contents = debug_hook_file.read() debug_hook_file.close() self.assertIn("hook.sh", debug_contents) self.assertIn("-n %s" % hook_name, debug_contents) self.assertTrue(os.access(debug_hook_path, os.X_OK)) # The hook debug can be set back to none. self._executor.set_debug(None) self.assertEquals(self._executor.get_hook_path(file_path), file_path) # the executor can debug only selected hooks. self._executor.set_debug(["abc"]) self.assertEquals(self._executor.get_hook_path(file_path), file_path) # The debug hook file is removed on the next hook path access. self.assertFalse(os.path.exists(debug_hook_path)) def test_hook_exception_propgates(self): """An error in a hook is propogated to the execution deferred.""" class _Invoker: def get_context(self): return None def __call__(self, hook_path): raise AttributeError("Foo") hook_path = self.makeFile("never got here") self._executor.start() return self.assertFailure(self._executor(_Invoker(), hook_path), AttributeError) @inlineCallbacks def test_executor_running_property(self): self._executor.start() self.assertTrue(self._executor.running) yield self._executor.stop() self.assertFalse(self._executor.running) @inlineCallbacks def test_nonexistant_hook_skipped(self): """If a hook does not exist a warning is logged and the hook skipped. """ class _Invoker: def get_context(self): return None self._executor.start() hook_path = self.makeFile() value = yield self._executor(_Invoker(), hook_path) self.assertEqual(value, False) self.assertIn("Hook does not exist, skipping %s" % hook_path, self.output.getvalue()) def test_start_stop_start(self): """The executor can be stopped and restarted.""" results = [] def invoke(hook_path): results.append(hook_path) self._executor(invoke, "1") start_complete = self._executor.start() self._executor.stop() yield start_complete self.assertEqual(len(results), 1) self._executor(invoke, "1") self._executor(invoke, "2") start_complete = self._executor.start() self._executor.stop() yield start_complete self.assertEqual(len(results), 3) @inlineCallbacks def test_run_priority_hook_while_already_running(self): """Attempting to run a priority hook while running is an error. """ def invoke(hook_path): pass self._executor.start() error = yield self.assertFailure( self._executor.run_priority_hook(invoke, "foobar"), AssertionError) self.assertEquals(str(error), "Executor must not be running") @inlineCallbacks def test_prioritize_with_queued(self): """A prioritized hook will execute before queued hooks. """ results = [] execs = [] hooks = [self.makeFile(str(i)) for i in range(5)] class _Invoker(object): def get_context(self): return None def __call__(self, hook_path): results.append(hook_path) invoke = _Invoker() for i in hooks: execs.append(self._executor(invoke, i)) priority_hook = self.makeFile(str("me first")) yield self._executor.run_priority_hook(invoke, priority_hook) self._executor.start() yield gather_results(execs) hooks.insert(0, priority_hook) self.assertEqual(results, hooks) def test_serialized_execution(self): """Hook execution is serialized via the HookExecution api. """ wait_callback = [Deferred() for i in range(5)] finish_callback = [Deferred() for i in range(5)] results = [] @inlineCallbacks def invoker(hook_path): results.append(hook_path) yield finish_callback[len(results) - 1] wait_callback[len(results) - 1].callback(True) start_complete = self._executor.start() for i in range(5): self._executor(invoker, "hook-%s" % i) self.assertEqual(len(results), 1) finish_callback[1].callback(True) self.assertEqual(len(results), 1) # Verify stop behavior stop_complete = yield self._executor.stop() # Finish the running execution. finish_callback[0].callback(True) # Verify we've stopped executing. yield stop_complete self.assertTrue(start_complete.called) self.assertEqual(len(results), 1) # Start the executioner again. self._executor.start() for finish in finish_callback[2:]: finish.callback(True) self.assertEqual(len(results), 5)
class UnitAgent(BaseAgent): """An juju Unit Agent. Provides for the management of a charm, via hook execution in response to external events in the coordination space (zookeeper). """ name = "juju-unit-agent" @classmethod def setup_options(cls, parser): super(UnitAgent, cls).setup_options(parser) unit_name = os.environ.get("JUJU_UNIT_NAME", "") parser.add_argument("--unit-name", default=unit_name) @property def unit_name(self): return self.config["unit_name"] def get_agent_name(self): return "unit:%s" % self.unit_name def configure(self, options): """Configure the unit agent.""" super(UnitAgent, self).configure(options) if not options.get("unit_name"): msg = ("--unit-name must be provided in the command line, " "or $JUJU_UNIT_NAME in the environment") raise JujuError(msg) self.executor = HookExecutor() self.api_factory = UnitSettingsFactory( self.executor.get_hook_context, logging.getLogger("unit.hook.api")) self.api_socket = None self.workflow = None @inlineCallbacks def start(self): """Start the unit agent process.""" self.service_state_manager = ServiceStateManager(self.client) self.executor.start() # Retrieve our unit and configure working directories. service_name = self.unit_name.split("/")[0] service_state = yield self.service_state_manager.get_service_state( service_name) self.unit_state = yield service_state.get_unit_state(self.unit_name) self.unit_directory = os.path.join( self.config["juju_directory"], "units", self.unit_state.unit_name.replace("/", "-")) self.state_directory = os.path.join(self.config["juju_directory"], "state") # Setup the server portion of the cli api exposed to hooks. from twisted.internet import reactor self.api_socket = reactor.listenUNIX( os.path.join(self.unit_directory, HOOK_SOCKET_FILE), self.api_factory) # Setup the unit state's address address = yield get_unit_address(self.client) yield self.unit_state.set_public_address( (yield address.get_public_address())) yield self.unit_state.set_private_address( (yield address.get_private_address())) # Inform the system, we're alive. yield self.unit_state.connect_agent() self.lifecycle = UnitLifecycle(self.client, self.unit_state, service_state, self.unit_directory, self.executor) self.workflow = UnitWorkflowState(self.client, self.unit_state, self.lifecycle, self.state_directory) if self.get_watch_enabled(): yield self.unit_state.watch_resolved(self.cb_watch_resolved) yield self.unit_state.watch_hook_debug(self.cb_watch_hook_debug) yield service_state.watch_config_state( self.cb_watch_config_changed) # Fire initial transitions, only if successful if (yield self.workflow.transition_state("installed")): yield self.workflow.transition_state("started") # Upgrade can only be processed if we're in a running state so # for case of a newly started unit, do it after the unit is started. if self.get_watch_enabled(): yield self.unit_state.watch_upgrade_flag( self.cb_watch_upgrade_flag) @inlineCallbacks def stop(self): """Stop the unit agent process.""" if self.workflow: yield self.workflow.transition_state("stopped") if self.api_socket: yield self.api_socket.stopListening() yield self.api_factory.stopFactory() @inlineCallbacks def cb_watch_resolved(self, change): """Update the unit's state, when its resolved. Resolved operations form the basis of error recovery for unit workflows. A resolved operation can optionally specify hook execution. The unit agent runs the error recovery transition if the unit is not in a running state. """ # Would be nice if we could fold this into an atomic # get and delete primitive. # Check resolved setting resolved = yield self.unit_state.get_resolved() if resolved is None: returnValue(None) # Clear out the setting yield self.unit_state.clear_resolved() # Verify its not already running if (yield self.workflow.get_state()) == "started": returnValue(None) log.info("Resolved detected, firing retry transition") # Fire a resolved transition try: if resolved["retry"] == RETRY_HOOKS: yield self.workflow.fire_transition_alias("retry_hook") else: yield self.workflow.fire_transition_alias("retry") except Exception: log.exception("Unknown error while transitioning for resolved") @inlineCallbacks def cb_watch_hook_debug(self, change): """Update the hooks to be debugged when the settings change. """ debug = yield self.unit_state.get_hook_debug() debug_hooks = debug and debug.get("debug_hooks") or None self.executor.set_debug(debug_hooks) @inlineCallbacks def cb_watch_upgrade_flag(self, change): """Update the unit's charm when requested. """ upgrade_flag = yield self.unit_state.get_upgrade_flag() if upgrade_flag: log.info("Upgrade detected, starting upgrade") upgrade = CharmUpgradeOperation(self) try: yield upgrade.run() except Exception: log.exception("Error while upgrading") @inlineCallbacks def cb_watch_config_changed(self, change): """Trigger hook on configuration change""" # Verify it is running current_state = yield self.workflow.get_state() log.debug("Configuration Changed") if current_state != "started": log.debug( "Configuration updated on service in a non-started state") returnValue(None) yield self.workflow.fire_transition("reconfigure")
class UnitAgent(BaseAgent): """An juju Unit Agent. Provides for the management of a charm, via hook execution in response to external events in the coordination space (zookeeper). """ name = "juju-unit-agent" @classmethod def setup_options(cls, parser): super(UnitAgent, cls).setup_options(parser) unit_name = os.environ.get("JUJU_UNIT_NAME", "") parser.add_argument("--unit-name", default=unit_name) @property def unit_name(self): return self.config["unit_name"] def get_agent_name(self): return "unit:%s" % self.unit_name def configure(self, options): """Configure the unit agent.""" super(UnitAgent, self).configure(options) if not options.get("unit_name"): msg = ("--unit-name must be provided in the command line, " "or $JUJU_UNIT_NAME in the environment") raise JujuError(msg) self.executor = HookExecutor() self.api_factory = UnitSettingsFactory( self.executor.get_hook_context, self.executor.get_invoker, logging.getLogger("unit.hook.api")) self.api_socket = None self.workflow = None @inlineCallbacks def start(self): """Start the unit agent process.""" service_state_manager = ServiceStateManager(self.client) # Retrieve our unit and configure working directories. service_name = self.unit_name.split("/")[0] self.service_state = yield service_state_manager.get_service_state( service_name) self.unit_state = yield self.service_state.get_unit_state( self.unit_name) self.unit_directory = os.path.join( self.config["juju_directory"], "units", self.unit_state.unit_name.replace("/", "-")) self.state_directory = os.path.join( self.config["juju_directory"], "state") # Setup the server portion of the cli api exposed to hooks. socket_path = os.path.join(self.unit_directory, HOOK_SOCKET_FILE) if os.path.exists(socket_path): os.unlink(socket_path) from twisted.internet import reactor self.api_socket = reactor.listenUNIX(socket_path, self.api_factory) # Setup the unit state's address address = yield get_unit_address(self.client) yield self.unit_state.set_public_address( (yield address.get_public_address())) yield self.unit_state.set_private_address( (yield address.get_private_address())) if self.get_watch_enabled(): yield self.unit_state.watch_hook_debug(self.cb_watch_hook_debug) # Inform the system, we're alive. yield self.unit_state.connect_agent() # Start paying attention to the debug-log setting if self.get_watch_enabled(): yield self.unit_state.watch_hook_debug(self.cb_watch_hook_debug) self.lifecycle = UnitLifecycle( self.client, self.unit_state, self.service_state, self.unit_directory, self.state_directory, self.executor) self.workflow = UnitWorkflowState( self.client, self.unit_state, self.lifecycle, self.state_directory) # Set up correct lifecycle and executor state given the persistent # unit workflow state, and fire any starting transitions if necessary. with (yield self.workflow.lock()): yield self.workflow.synchronize(self.executor) if self.get_watch_enabled(): yield self.unit_state.watch_resolved(self.cb_watch_resolved) yield self.service_state.watch_config_state( self.cb_watch_config_changed) yield self.unit_state.watch_upgrade_flag( self.cb_watch_upgrade_flag) @inlineCallbacks def stop(self): """Stop the unit agent process.""" if self.lifecycle.running: yield self.lifecycle.stop(fire_hooks=False, stop_relations=False) yield self.executor.stop() if self.api_socket: yield self.api_socket.stopListening() yield self.api_factory.stopFactory() @inlineCallbacks def cb_watch_resolved(self, change): """Update the unit's state, when its resolved. Resolved operations form the basis of error recovery for unit workflows. A resolved operation can optionally specify hook execution. The unit agent runs the error recovery transition if the unit is not in a running state. """ # Would be nice if we could fold this into an atomic # get and delete primitive. # Check resolved setting resolved = yield self.unit_state.get_resolved() if resolved is None: returnValue(None) # Clear out the setting yield self.unit_state.clear_resolved() with (yield self.workflow.lock()): if (yield self.workflow.get_state()) == "started": returnValue(None) try: log.info("Resolved detected, firing retry transition") if resolved["retry"] == RETRY_HOOKS: yield self.workflow.fire_transition_alias("retry_hook") else: yield self.workflow.fire_transition_alias("retry") except Exception: log.exception("Unknown error while transitioning for resolved") @inlineCallbacks def cb_watch_hook_debug(self, change): """Update the hooks to be debugged when the settings change. """ debug = yield self.unit_state.get_hook_debug() debug_hooks = debug and debug.get("debug_hooks") or None self.executor.set_debug(debug_hooks) @inlineCallbacks def cb_watch_upgrade_flag(self, change): """Update the unit's charm when requested. """ upgrade_flag = yield self.unit_state.get_upgrade_flag() if not upgrade_flag: log.info("No upgrade flag set.") return log.info("Upgrade detected") # Clear the flag immediately; this means that upgrade requests will # be *ignored* by units which are not "started", and will need to be # reissued when the units are in acceptable states. yield self.unit_state.clear_upgrade_flag() new_id = yield self.service_state.get_charm_id() old_id = yield self.unit_state.get_charm_id() if new_id == old_id: log.info("Upgrade ignored: already running latest charm") return with (yield self.workflow.lock()): state = yield self.workflow.get_state() if state != "started": if upgrade_flag["force"]: yield self.lifecycle.upgrade_charm( fire_hooks=False, force=True) log.info("Forced upgrade complete") return log.warning( "Cannot upgrade: unit is in non-started state %s. Reissue " "upgrade command to try again.", state) return log.info("Starting upgrade") if (yield self.workflow.fire_transition("upgrade_charm")): log.info("Upgrade complete") else: log.info("Upgrade failed") @inlineCallbacks def cb_watch_config_changed(self, change): """Trigger hook on configuration change""" # Verify it is running with (yield self.workflow.lock()): current_state = yield self.workflow.get_state() log.debug("Configuration Changed") if current_state != "started": log.debug( "Configuration updated on service in a non-started state") returnValue(None) yield self.workflow.fire_transition("configure")
class LifecycleTestBase(RelationTestBase): juju_directory = None @inlineCallbacks def setUp(self): yield super(LifecycleTestBase, self).setUp() if self.juju_directory is None: self.juju_directory = self.makeDir() self.hook_log = self.capture_logging("hook.output", level=logging.DEBUG) self.agent_log = self.capture_logging("unit-agent", level=logging.DEBUG) self.executor = HookExecutor() self.executor.start() self.change_environment(PATH=os.environ["PATH"], JUJU_UNIT_NAME="service-unit/0") @inlineCallbacks def setup_default_test_relation(self): mysql_ep = RelationEndpoint("mysql", "client-server", "app", "server") wordpress_ep = RelationEndpoint("wordpress", "client-server", "db", "client") self.states = yield self.add_relation_service_unit_from_endpoints( mysql_ep, wordpress_ep) self.unit_directory = os.path.join( self.juju_directory, "units", self.states["unit"].unit_name.replace("/", "-")) os.makedirs(os.path.join(self.unit_directory, "charm", "hooks")) os.makedirs(os.path.join(self.juju_directory, "state")) def write_hook(self, name, text, no_exec=False): hook_path = os.path.join(self.unit_directory, "charm", "hooks", name) hook_file = open(hook_path, "w") hook_file.write(text.strip()) hook_file.flush() hook_file.close() if not no_exec: os.chmod(hook_path, stat.S_IRWXU) return hook_path def wait_on_hook(self, name=None, count=None, sequence=(), debug=False, executor=None): """Wait on the given named hook to be executed. @param: name: if specified only one hook name can be waited on at a given time. @param: count: Multiples of the same name can be captured by specifying the count parameter. @param: sequence: A list of hook names executed in sequence to be waited on @param: debug: This parameter enables debug stdout loogging. @param: executor: A HookExecutor instance to use instead of the default """ d = Deferred() results = [] assert name is not None or sequence, "Hook match must be specified" def observer(hook_path): hook_name = os.path.basename(hook_path) results.append(hook_name) if debug: print "-> exec hook", hook_name if d.called: return if results == sequence: d.callback(True) if hook_name == name and count is None: d.callback(True) if hook_name == name and results.count(hook_name) == count: d.callback(True) executor = executor or self.executor executor.set_observer(observer) return d def wait_on_state(self, workflow, state, debug=False): state_changed = Deferred() def observer(workflow_state, state_variables): if debug: print " workflow state", state, workflow if workflow_state == state: state_changed.callback(True) workflow.set_observer(observer) return state_changed def capture_output(self, stdout=True): """Convience method to capture log output. Useful tool for observing interaction between components. """ if stdout: output = sys.stdout else: output = StringIO.StringIO() for log_name, indent in (("statemachine", 0), ("hook.executor", 2), ("hook.scheduler", 1), ("unit.lifecycle", 1), ("unit.relation.watch", 1), ("unit.relation.lifecycle", 1)): formatter = logging.Formatter((" " * indent) + "%(name)s: %(message)s") self.capture_logging(log_name, level=logging.DEBUG, log_file=output, formatter=formatter) print return output