Merge lp:~bcsaller/pyjuju/config-set into lp:pyjuju

Proposed by Benjamin Saller
Status: Merged
Approved by: Gustavo Niemeyer
Approved revision: 240
Merged at revision: 248
Proposed branch: lp:~bcsaller/pyjuju/config-set
Merge into: lp:pyjuju
Diff against target: 1050 lines (+705/-30)
22 files modified
Makefile (+1/-1)
docs/source/drafts/service-config.rst (+8/-1)
ensemble/control/__init__.py (+2/-0)
ensemble/control/config_set.py (+75/-0)
ensemble/control/tests/test_config_set.py (+89/-0)
ensemble/formula/bundle.py (+10/-3)
ensemble/formula/config.py (+177/-0)
ensemble/formula/directory.py (+3/-0)
ensemble/formula/errors.py (+5/-0)
ensemble/formula/tests/repository/dummy/config.yaml (+5/-0)
ensemble/formula/tests/repository/wordpress/config.yaml (+3/-0)
ensemble/formula/tests/test_bundle.py (+7/-0)
ensemble/formula/tests/test_config.py (+141/-0)
ensemble/formula/tests/test_directory.py (+9/-1)
ensemble/hooks/cli.py (+24/-18)
ensemble/hooks/tests/test_cli.py (+21/-1)
ensemble/lib/schema.py (+12/-0)
ensemble/lib/tests/test_schema.py (+14/-1)
ensemble/state/formula.py (+12/-4)
ensemble/state/service.py (+42/-0)
ensemble/state/tests/test_formula.py (+16/-0)
ensemble/state/tests/test_service.py (+29/-0)
To merge this branch: bzr merge lp:~bcsaller/pyjuju/config-set
Reviewer Review Type Date Requested Status
Gustavo Niemeyer Approve
Review via email: mp+60235@code.launchpad.net

Description of the change

This is `ensemble set` minus the hook transition invocation. I will follow with that in another branch but want to get this part reviewed sooner than later so we can move on.

To post a comment you must log in.
Revision history for this message
Kapil Thangavelu (hazmat) wrote :

One note from the pair programming session on agent support for config-set, was that this subcommand shouldn't require a repository argument, it should instead fetch the service's formula and inspect that for its config.

Probably this should modify the formula publisher and formula state to capture the config schema into zk directly when dpeloying, else it will need to be parsed from provider storage.

Revision history for this message
Benjamin Saller (bcsaller) wrote :

> One note from the pair programming session on agent support for config-set,
> was that this subcommand shouldn't require a repository argument, it should
> instead fetch the service's formula and inspect that for its config.
>
> Probably this should modify the formula publisher and formula state to capture
> the config schema into zk directly when dpeloying, else it will need to be
> parsed from provider storage.

While there was some related code in the option handling around this that had to be removed the code was actually using the formula state object properly. I think this comment came from the bad explanation I gave while pairing, but didn't reflect the current state of the code at the time.

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

Very nicely organized, thanks. Just a few details before we can merge this:

[1]

+ """Set Service Options.

s/Service/service/
s/Options/options/

[2]

+ Services provide various options which can be set using this
+ command.

Here is a slightly more comprehensive version:

  Service formulas may define dynamic options which may be tweaked
  at deployment time, or over the lifetime of the service. This
  command allows changing these settings.

[3]

The overall logic in the command looks good.

Some minor details:

+ formula_manager = FormulaStateManager(client)
+ formula = yield formula_manager.get_formula_by_service_name(service_name)
+
+ service_manager = ServiceStateManager(client)
+ service = yield service_manager.get_service_state(service_name)

The function get_formula_by_service_name is untested and is likely
not necessary. E.g.:

    formula = yield service.get_formula_state()

[4]

+ # Parse the options using the formula config.yaml metadata to
+ # validate
+ # This will raise (and must be caught) on error
+ # parse config options

This comment would benefit from some additional love.

[5]

+ except KeyError:
+ # its ok not to contain a config.yaml
+ # it means no options will be handled
+ pass

KeyError can mean lots of things in Python. It'd be nice if rather than
catching this exception the logic would check for the existence of the
file in advance. This way we'll know we're never ignoring a KeyError
which we didn't intend to.

[6]

+def parse_keyvalue_pairs(options):

This needs tests.

[7]

+ @classmethod
+ def load_file(cls, pathname, optional=False):
(...)
+ def load(self, data):

Why is 'load_file' a class method and 'load' a normal one?

[8]

+ if not data or not isinstance(data, dict):
+ raise ValueError("Expected YAML dict of options metadata")

We need a more formal definition and verification of the config.yaml schema,
similar to how we do it for the metadata file.

[9]

+ raise ValueError(
+ "Expected `options` top level key in options metadata")
(...)
+ raise KeyError(
+ "%s is not a valid configuration option." % (
+ option))

We should never raise KeyError or ValueError or any other standard Python
exceptions, or we'll have no way to catch the *application* error without
wondering if we're not catching something we didn't intend to by mistake.

[10]

+ # Setup the watch deferred callback after the user defined callback
+ # has returned successfully from the existence invocation.
+ callback_d = maybeDeferred(callback, bool(exists))
+ callback_d.addCallback(
+ lambda x: watch_d.addCallback(watcher) and x)

This will need some tweaks along the lines of the work Kapil has been
pushing forward. Please catch up with him about it.

review: Needs Fixing
Revision history for this message
Benjamin Saller (bcsaller) wrote :
Download full text (4.2 KiB)

On Mon, May 23, 2011 at 2:37 PM, Gustavo Niemeyer <email address hidden> wrote:
> Review: Needs Fixing
>
> Very nicely organized, thanks.  Just a few details before we can merge this:
>
>
> [1]
>
> +    """Set Service Options.
>
> s/Service/service/
> s/Options/options/
>
>
Done

> [2]
>
> +    Services provide various options which can be set using this
> +    command.
>
> Here is a slightly more comprehensive version:
>
>  Service formulas may define dynamic options which may be tweaked
>  at deployment time, or over the lifetime of the service.  This
>  command allows changing these settings.
>

Changed.

> [3]
>
> The overall logic in the command looks good.
>
> Some minor details:
>
> +    formula_manager = FormulaStateManager(client)
> +    formula = yield formula_manager.get_formula_by_service_name(service_name)
> +
> +    service_manager = ServiceStateManager(client)
> +    service = yield service_manager.get_service_state(service_name)
>
> The function get_formula_by_service_name is untested and is likely
> not necessary. E.g.:
>
>    formula = yield service.get_formula_state()
>

Changed and removed formula_by_service_name.

> [4]
>
> +    # Parse the options using the formula config.yaml metadata to
> +    # validate
> +    # This will raise (and must be caught) on error
> +    # parse config options
>
> This comment would benefit from some additional love.
Cleaned
>
> [5]
>
> +        except KeyError:
> +            # its ok not to contain a config.yaml
> +            # it means no options will be handled
> +            pass
>
> KeyError can mean lots of things in Python.  It'd be nice if rather than
> catching this exception the logic would check for the existence of the
> file in advance.  This way we'll know we're never ignoring a KeyError
> which we didn't intend to.

Changed to ServiceConfigError throughout.

>
> [6]
>
> +def parse_keyvalue_pairs(options):
>
> This needs tests.

It had coverage in terms of the cli test which exercised the arg
parsing but a specific test has been added as well.

>
> [7]
>
> +    @classmethod
> +    def load_file(cls, pathname, optional=False):
> (...)
> +    def load(self, data):
>
> Why is 'load_file' a class method and 'load' a normal one?
>

The thinking was that load_file was just a convenience method wrapping
the common usage of init and load calls but I agree the distinction
isn't very clear.

> [8]
>
> +        if not data or not isinstance(data, dict):
> +            raise ValueError("Expected YAML dict of options metadata")
>
> We need a more formal definition and verification of the config.yaml schema,
> similar to how we do it for the metadata file.

I included a proposed version of this. It out of the scope of the spec
we agreed to but does reflect some of our talks about possible
directions for this.

>
> [9]
>
> +            raise ValueError(
> +                "Expected `options` top level key in options metadata")
> (...)
> +                raise KeyError(
> +                    "%s is not a valid configuration option." % (
> +                        option))
>
> We should never raise KeyError or ValueError or any other standard Python
> exceptions, or we'll have no way to cat...

Read more...

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

Interactive interaction online, and +1!

<niemeyer> bcsaller: Oh, cool.. just a small note re. the review: [5] seems untouched
<bcsaller> checking
<niemeyer> bcsaller: Re. [6], thanks for the test. We really need tests specific to the feature, otherwise you remove the other method later and this logic goes untested
<niemeyer> bcsaller: It would be fine to move the logic from the other tests though
<niemeyer> bcsaller: and only test on the layer above what you haven't already tested for the layer below
<niemeyer> bcsaller: Having a ton of logic on a layer below which is only touched by tests for upper layers is the issue
<bcsaller> yeah, looks like maybe I did miss [5], I think I made changes related to [9] and thought I got it
<niemeyer> bcsaller: No worris
<niemeyer> es
<niemeyer> bcsaller: re [8], is there a reason why you didn't use our schema framework in the same way we do for metadata and configuration?
<bcsaller> niemeyer: yes, the formula author has to write these things, so either I did something simple or I had to write a yaml serialization of the schema denifnition
<niemeyer> bcsaller: I don't understand.. how's that different from the metadata file, or the environments configuration file?
<bcsaller> because the schema is coded in Python by us for the metadata file, for the config.yaml they are writing a validation spec of their own
<niemeyer> bcsaller: No, it's not..
<niemeyer> bcsaller: The schema is a generic piece of framework which you can use to parse plain dictionaries and values
<niemeyer> bcsaller: and is used both for the metadata and the environments file, which are also written by hand
<bcsaller> yes, but the desc of that is done in python
<niemeyer> bcsaller: Yes, and we can do the same for the config yaml, I believe
<niemeyer> ?
<bcsaller> I didn't want formula authors to have to write in python to use the schema system
<niemeyer> bcsaller: They don't have to..
<niemeyer> bcsaller: We're talking about validating a yaml file
<bcsaller> oh, you mean to validate that the config.yaml is correct
<niemeyer> bcsaller: Yes
<niemeyer> bcsaller: Point [8] in the review
<bcsaller> that's fine, yes, I implemented validation of option values
<niemeyer> bcsaller: Yes, and why isn't that written using our schema framework?
<bcsaller> I think I misunderstood in that case, oh well we get a new feature
<niemeyer> bcsaller: in declarative style.. that was the actual questoin
<bcsaller> validating the config.yaml (which isn't really done now) using the schema would be fine
<niemeyer> bcsaller: Yeah, that was point [8]
<niemeyer> bcsaller: You can do that in a follow up branch
<bcsaller> I have no objection to that, I thought you wanted to have them write schema to validate the options they allow for the service
<niemeyer> bcsaller: Nope
<niemeyer> bcsaller: We just have to validate the file format before bundling a formula
<niemeyer> bcsaller: Otherwise it will blow up when the formula is deployed only, which would be quite sad
<bcsaller> ok, I can add that tonight, it's a small change
<niemeyer> bcsaller: Should be indeed, no rush, and thanks!

review: Approve
Revision history for this message
Benjamin Saller (bcsaller) wrote :

This version includes both validation of config.yaml using lib.schema and subsequent runtime validation of values set by the user via simple validators (type and/or regex). As discussed these two features are being pushed at once to speed the approval process along.

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :
Download full text (3.8 KiB)

Idea looks good, implementation looks good, several small issues
in terms of polishing the branch for merging.

[11]

+description and an optional default value. Additionally type may be

s/type/'type'/

[12]

+ config = ConfigOptions()
+ content = None

The second line may be dropped.

[13]

+ "options": Dict(String(),
+ OneOf(
+ # regex type requires validator
+ KeyDict({"type": Constant("regex"),

Indentation should be revised here. Feel free to define an
auxiliary variable externally.

[14]

+ "validator": String(),

We need a new RegEx type in the schema to validate the regular
expression.

[15]

+ optional=["default", "description",]),

s/",]/"]/

Same on the bottom one.

[16]

+ @classmethod
+ def load_file(cls, pathname, optional=False):

Drop the @classmethod here. load() doesn't have it, and MetaData doesn't use it
either, so let's make the interface even. Also, please rename the functions to
conform to the same conventions established by MetaData: parse() and load(), the
former with a data _string_, the latter with a filename. Add parse_serialization_data
to handle the dict case. The behavior of these functions should also mimic MetaData:
load() raises FileNotFound, and so on.

These two classes are exactly alike. There's no reason for us to diverge and
then having to remind of which interface does what, which load accepts a dict,
etc.

[17]

+ def validate(self, options):
+ """Validate options using the file at `path`.

Which path?

[18]

+ This method will return a dict with options that have passed
+ validation. This means options not declared in the formula
+ metadata will trigger a ServiceConfigError exception.
+
+ This method includes defaults for options which have no values
+ supplied if such defaults are defined in the metadata.

Please reword:

This method validates all the provided options, and returns a new
dictionary with values properly typed and default values set for
missing keys. If a provided option is unknown or its value
fails validation, ServiceConfigError is raised.

[19]

+ "%s is not a valid configuration option." % (option))
(...)
+ "a normal Python regex pattern for %s" % (
+ option))
(...)
+ "See http://docs.python.org/library/re.html" % (
+ option))
(...)
+ raise ServiceConfigError("Invalid options specification: %s" % (
+ error))

This is a tuple: (option,)

This is an expression with redundant parenthesis: (option)

Drop the parenthesis in all of those cases.

[20]

+ validator = validation_kinds[kind]
+ if kind == "regex":
+ pattern = self._data[option].get("validator")

The interface for validators can deal with such distinctions so that
we don't have to special case them in the generic function. E.g.:

validator = validation_kinds[kind]
value, valid = validator(value, self._data[option])

[21]

+class ServiceConfigError(Exception):
+ """Indicates an issue related to service options."""
+ ...

Read more...

review: Needs Fixing
Revision history for this message
Benjamin Saller (bcsaller) wrote :
Download full text (5.5 KiB)

On Wed, Jun 1, 2011 at 3:01 PM, Gustavo Niemeyer <email address hidden> wrote:
> Review: Needs Fixing
> Idea looks good, implementation looks good, several small issues
> in terms of polishing the branch for merging.
>
>
> [11]
>
> +description and an optional default value. Additionally type may be
>
> s/type/'type'/
>
>

Changed

> [12]
>
> +        config = ConfigOptions()
> +        content = None
>
> The second line may be dropped.
>
>

Fixed.

> [13]
>
>
> +    "options": Dict(String(),
> +                    OneOf(
> +    # regex type requires validator
> +    KeyDict({"type": Constant("regex"),
>
> Indentation should be revised here.  Feel free to define an
> auxiliary variable externally.
>

Done

>
> [14]
>
> +             "validator": String(),
>
> We need a new RegEx type in the schema to validate the regular
> expression.
>

Added with simple tests.

>
> [15]
>
> +            optional=["default", "description",]),
>
> s/",]/"]/
>
> Same on the bottom one.
>

Done

>
> [16]
>
> +    @classmethod
> +    def load_file(cls, pathname, optional=False):
>
> Drop the @classmethod here.  load() doesn't have it, and MetaData doesn't use it
> either, so let's make the interface even.  Also, please rename the functions to
> conform to the same conventions established by MetaData: parse() and load(), the
> former with a data _string_, the latter with a filename.  Add parse_serialization_data
> to handle the dict case.  The behavior of these functions should also mimic MetaData:
> load() raises FileNotFound, and so on.
>
> These two classes are exactly alike.  There's no reason for us to diverge and
> then having to remind of which interface does what, which load accepts a dict,
> etc.
>

While I made these changes its worth noting that they are not exactly
the same as a missing config.yaml means the service takes no options.
So, for example, file not found isn't considered an error. This is to
simplify formula development, all services take no options now and
should continue to run that way. Once `formulate` becomes standard we
might require this file with an empty options dict as that will be
generated for the author.

> [17]
>
> +    def validate(self, options):
> +        """Validate options using the file at `path`.
>
> Which path?

Corrected ;)
>
>
> [18]
>
> +        This method will return a dict with options that have passed
> +        validation. This means options not declared in the formula
> +        metadata will trigger a ServiceConfigError exception.
> +
> +        This method includes defaults for options which have no values
> +        supplied if such defaults are defined in the metadata.
>
> Please reword:
>
> This method validates all the provided options, and returns a new
> dictionary with values properly typed and default values set for
> missing keys.  If a provided option is unknown or its value
> fails validation, ServiceConfigError is raised.
>
Done

>
> [19]
>
> +                    "%s is not a valid configuration option." % (option))
> (...)
> +                        "a normal  Python regex pattern for %s" % (
> +                        option))
> (...)
> +                        "See http://docs.python.org/lib...

Read more...

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

This looks good. +1 considering the following points:

[16]

This point still has the same issues, as discussed online.

[24]

+ })
+
+
+
+
+
+class ConfigOptions(object):

Two spaces only please.

[25]

+ try:
+ value = int(value)
+ except ValueError:
+ pass
+ return value, isinstance(value, int)

This is validating twice. I suggest something like this:

try:
    return int(value), True
except ValueError:
    return value, False

Same for the other equivalent type validators.

[26]

+ return value, re.match(pattern, value)
+ return re.match(pat, str) is not None

Some dead code.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'Makefile'
--- Makefile 2011-05-27 15:17:10 +0000
+++ Makefile 2011-06-08 15:41:33 +0000
@@ -37,7 +37,7 @@
37 @test -n "$(modified)" && echo $(modified) | xargs pyflakes37 @test -n "$(modified)" && echo $(modified) | xargs pyflakes
3838
3939
40modified=$(shell bzr status -S -r branch::prev |grep -P '^\s*M' | awk '{print $$2;}'| grep -P "test_.*\.py$$")40modified=$(shell bzr status -S -r branch::prev |grep -P '^\s*\+?[MN]' | awk '{print $$2;}'| grep -P "test_.*\.py$$")
41ptests:41ptests:
42 @test -n "$(modified)" && echo $(modified) | xargs ./test42 @test -n "$(modified)" && echo $(modified) | xargs ./test
4343
4444
=== modified file 'docs/source/drafts/service-config.rst'
--- docs/source/drafts/service-config.rst 2011-05-05 09:41:11 +0000
+++ docs/source/drafts/service-config.rst 2011-06-08 15:41:33 +0000
@@ -81,7 +81,12 @@
81The specification of possible configuration values is intentionally81The specification of possible configuration values is intentionally
82minimal, but still evolving. Currently the formula define a list of82minimal, but still evolving. Currently the formula define a list of
83names which they react. Information includes a human readable83names which they react. Information includes a human readable
84description and an optional default value.84description and an optional default value. Additionally `type` may be
85specified. All options have a default type of 'str' which means its
86value will only be treated as a text string. Other valid options are
87'int', 'float' and 'regex'. When 'regex' is used an addtional element
88must be provided, 'validator'. This must be a valid Python regex as
89specified at http://docs.python.org/lib/re.html
8590
86The following `config.yaml` would be included in the top level91The following `config.yaml` would be included in the top level
87directory of a formula and includes a list of option definitions::92directory of a formula and includes a list of option definitions::
@@ -96,6 +101,8 @@
96 password:101 password:
97 default: changeme102 default: changeme
98 description: Password to be used for the account specified by 'username'103 description: Password to be used for the account specified by 'username'
104 type: regex
105 validator: '.{6,12}'
99 username:106 username:
100 default: admin107 default: admin
101 description: The name of the initial account (given admin permissions).108 description: The name of the initial account (given admin permissions).
102109
=== modified file 'ensemble/control/__init__.py'
--- ensemble/control/__init__.py 2011-05-24 14:20:01 +0000
+++ ensemble/control/__init__.py 2011-06-08 15:41:33 +0000
@@ -9,6 +9,7 @@
9import add_relation9import add_relation
10import add_unit10import add_unit
11import bootstrap11import bootstrap
12import config_set
12import debug_hook13import debug_hook
13import debug_log14import debug_log
14import deploy15import deploy
@@ -32,6 +33,7 @@
32 add_relation,33 add_relation,
33 add_unit,34 add_unit,
34 bootstrap,35 bootstrap,
36 config_set,
35 debug_log,37 debug_log,
36 debug_hook,38 debug_hook,
37 deploy,39 deploy,
3840
=== added file 'ensemble/control/config_set.py'
--- ensemble/control/config_set.py 1970-01-01 00:00:00 +0000
+++ ensemble/control/config_set.py 2011-06-08 15:41:33 +0000
@@ -0,0 +1,75 @@
1from twisted.internet.defer import inlineCallbacks
2
3from ensemble.environment.errors import EnvironmentsConfigError
4from ensemble.hooks.cli import parse_keyvalue_pairs
5from ensemble.state.service import ServiceStateManager
6
7
8def configure_subparser(subparsers):
9 sub_parser = subparsers.add_parser("set",
10 help=command.__doc__)
11
12 sub_parser.add_argument(
13 "--environment", "-e",
14 help="Environment to status.")
15
16 sub_parser.add_argument("service_name",
17 help="The name of the service the options apply to.")
18 sub_parser.add_argument("service_options",
19 nargs="+",
20 help="""name=value for option to set""")
21
22 return sub_parser
23
24
25def command(options):
26 """Set service options.
27
28 Service formulas may define dynamic options which may be tweaked
29 at deployment time, or over the lifetime of the service. This
30 command allows changing these settings.
31
32 $ ensemble set <service_name> option=value [option=value]
33
34 or
35
36 $ ensemble set <service_name> --filename local.yaml
37
38 """
39 environment = options.environments.get(options.environment)
40 if environment is None and options.environment:
41 raise EnvironmentsConfigError(
42 "Invalid environment %r" % options.environment)
43 elif environment is None:
44 environment = options.environments.get_default()
45
46 return config_set(environment,
47 options.service_name,
48 options.service_options)
49
50
51@inlineCallbacks
52def config_set(environment, service_name, service_options):
53 """Collect and render status information. """
54 provider = environment.get_machine_provider()
55 client = yield provider.connect()
56
57 # Get the service and the formula
58 #
59 service_manager = ServiceStateManager(client)
60 service = yield service_manager.get_service_state(service_name)
61 formula = yield service.get_formula_state()
62
63 # Use the formula's ConfigOptions instance to validate the
64 # arguments to config_set. Invalid options passed to this method
65 # will thrown an exception.
66 options = parse_keyvalue_pairs(service_options)
67
68 config = yield formula.get_config()
69 options = config.validate(options)
70
71 # Apply the change
72 state = yield service.get_config()
73 state.update(options)
74 yield state.write()
75
076
=== added file 'ensemble/control/tests/test_config_set.py'
--- ensemble/control/tests/test_config_set.py 1970-01-01 00:00:00 +0000
+++ ensemble/control/tests/test_config_set.py 2011-06-08 15:41:33 +0000
@@ -0,0 +1,89 @@
1from twisted.internet.defer import inlineCallbacks
2
3from ensemble.control import main
4from ensemble.control.config_set import config_set
5from ensemble.control.tests.test_status import StatusTestBase
6from ensemble.formula.tests.test_metadata import test_repository_path
7
8class ControlEnsembleSetTest(StatusTestBase):
9
10 @inlineCallbacks
11 def setUp(self):
12 yield super(ControlEnsembleSetTest, self).setUp()
13 self.output = self.capture_logging()
14 self.stderr = self.capture_stream("stderr")
15
16 @inlineCallbacks
17 def tearDown(self):
18 yield super(ControlEnsembleSetTest, self).tearDown()
19 self.reset_logging()
20
21 @inlineCallbacks
22 def test_set_and_get(self):
23 system = yield self.build_topology()
24
25 self.mock_environment()
26 self.mocker.replay()
27
28 yield config_set(self.environment,
29 "wordpress",
30 ["blog-title=That\'ll do, pig."])
31
32
33 # Verify the state is accessible
34 wordpress = system["services"]["wordpress"]
35 state = yield wordpress.get_config()
36 self.assertEqual(state, {"blog-title": "That\'ll do, pig."})
37
38
39 @inlineCallbacks
40 def test_set_invalid_option(self):
41 yield self.build_topology()
42 self.mock_environment()
43 finished = self.setup_cli_reactor()
44 self.setup_exit(0)
45 self.mocker.replay()
46
47 main(["set",
48 "wordpress",
49 "blog-roll=What's a blog-roll?"])
50 yield finished
51
52 # Make sure we got an error message to the user
53 self.assertIn("blog-roll is not a valid configuration option.",
54 self.stderr.getvalue())
55
56 @inlineCallbacks
57 def test_set_invalid_service(self):
58 yield self.build_topology()
59 self.mock_environment()
60 finished = self.setup_cli_reactor()
61 self.setup_exit(0)
62 self.mocker.replay()
63
64 main(["set",
65 "whatever",
66 "blog-roll=What's a blog-roll?"])
67 yield finished
68
69 self.assertIn("Service 'whatever' was not found",
70 self.stderr.getvalue())
71
72
73 @inlineCallbacks
74 def test_set_valid_option(self):
75 system = yield self.build_topology()
76 self.mock_environment()
77 finished = self.setup_cli_reactor()
78 self.setup_exit(0)
79 self.mocker.replay()
80
81 main(["set",
82 "wordpress",
83 'blog-title=My title'])
84 yield finished
85
86 # Verify the state is accessible
87 wordpress = system["services"]["wordpress"]
88 state = yield wordpress.get_config()
89 self.assertEqual(state, {"blog-title": "My title"})
090
=== modified file 'ensemble/formula/bundle.py'
--- ensemble/formula/bundle.py 2011-01-19 18:20:47 +0000
+++ ensemble/formula/bundle.py 2011-06-08 15:41:33 +0000
@@ -5,6 +5,7 @@
5from zipfile import ZipFile, BadZipfile5from zipfile import ZipFile, BadZipfile
66
7from ensemble.formula.base import FormulaBase7from ensemble.formula.base import FormulaBase
8from ensemble.formula.config import ConfigOptions
8from ensemble.formula.metadata import MetaData9from ensemble.formula.metadata import MetaData
9from ensemble.errors import FormulaError10from ensemble.errors import FormulaError
10from ensemble.lib.filehash import compute_file_hash11from ensemble.lib.filehash import compute_file_hash
@@ -20,15 +21,21 @@
20 raise FormulaError(path, "Must be a ZIP-file (%s)." % str(exc))21 raise FormulaError(path, "Must be a ZIP-file (%s)." % str(exc))
2122
22 metadata = MetaData()23 metadata = MetaData()
23 try:24 if "metadata.yaml" not in zf.namelist():
24 content = zf.read("metadata.yaml")
25 except KeyError:
26 raise FormulaError(path,25 raise FormulaError(path,
27 "Archive does not contain required file: "26 "Archive does not contain required file: "
28 "\"metadata.yaml\".")27 "\"metadata.yaml\".")
2928
29 content = zf.read("metadata.yaml")
30 metadata.parse(content)30 metadata.parse(content)
31 self.metadata = metadata31 self.metadata = metadata
32
33 config = ConfigOptions()
34 if "config.yaml" in zf.namelist():
35 content = zf.read("config.yaml")
36 config.parse(content)
37 self.config = config
38
32 self.path = isinstance(path, file) and path.name or path39 self.path = isinstance(path, file) and path.name or path
3340
34 def compute_sha256(self):41 def compute_sha256(self):
3542
=== added file 'ensemble/formula/config.py'
--- ensemble/formula/config.py 1970-01-01 00:00:00 +0000
+++ ensemble/formula/config.py 2011-06-08 15:41:33 +0000
@@ -0,0 +1,177 @@
1import os
2import re
3
4import yaml
5
6from ensemble.lib.schema import (SchemaError, KeyDict, Dict, String,
7 Constant, OneOf, Int, Float)
8from ensemble.formula.errors import ServiceConfigError
9
10
11# regex type requires validator
12REGEX_TYPE_SCHEMA = KeyDict({"type": Constant("regex"),
13 "validator": String(),
14 "default": OneOf(String(), Int(), Float()),
15 "description": String(),
16 },
17 optional=["default", "description"])
18
19SIMPLE_TYPE_SCHEMA = KeyDict({"type": OneOf(Constant("str"),
20 Constant("int"),
21 Constant("float")),
22 "default": OneOf(String(), Int(), Float()),
23 "description": String(),
24 },
25 optional=["default", "description"])
26
27# Schema used to validate ConfigOptions specifications
28CONFIG_SCHEMA = KeyDict({
29 "options": Dict(
30 String(),
31 OneOf(REGEX_TYPE_SCHEMA, SIMPLE_TYPE_SCHEMA))
32 })
33
34
35
36
37
38class ConfigOptions(object):
39 """Represents the configuration options exposed by a formula.
40
41 The intended usage is that Forumla provide access to these objects
42 and then use them to `validate` inputs provided in the `ensemble
43 set` and `ensemble deploy` code paths.
44 """
45
46 def __init__(self):
47 self._data = {}
48
49 def load(self, pathname, optional=False):
50 """Construct a ConfigOptions instance from a YAML file."""
51 data = None
52 if os.path.exists(pathname):
53 data = open(pathname, "r").read()
54
55 if not data and optional:
56 data = "options: {}\n"
57
58 if not data:
59 raise ServiceConfigError(
60 "Missing required service options metadata: %s" % (
61 pathname))
62
63 self.parse(data)
64 return self
65
66 def parse(self, data):
67 """Load data into the config object.
68
69 Data can be a properly encoded YAML string or an dict, such as
70 one returned by `get_serialization_data`.
71
72 Each call to `load` replaces any existing data.
73 """
74 if isinstance(data, basestring):
75 raw_data = yaml.load(data)
76 elif isinstance(data, dict):
77 raw_data = data
78 else:
79 raise TypeError("Unknown data type for `load`: %s" % type(data))
80
81 data = self._parse(raw_data)
82 self._data = data
83
84 def _parse(self, data):
85 """Verify we have sensible option metadata.
86
87 Returns the `options` dict from within the YAML data.
88 """
89 if not data or not isinstance(data, dict):
90 raise ServiceConfigError("Expected YAML dict of options metadata")
91
92 try:
93 data = CONFIG_SCHEMA.coerce(data, [])
94 except SchemaError, error:
95 raise ServiceConfigError("Invalid options specification: %s" % (
96 error))
97
98 return data["options"]
99
100 def get_defaults(self):
101 """Return a mapping of option: default for all options."""
102 d = {}
103 for name, options in self._data.items():
104 if "default" in options:
105 d[name] = options["default"]
106
107 return d
108
109 def validate(self, options):
110 """Validate options using the loaded validation data.
111
112 This method validates all the provided options, and returns a
113 new dictionary with values properly typed and default values
114 set for missing keys. If a provided option is unknown or its
115 value fails validation, ServiceConfigError is raised.
116
117 This method includes defaults for options which have no values
118 supplied if such defaults are defined in the metadata.
119 """
120 d = self.get_defaults()
121
122 for option, value in options.items():
123 if option not in self._data:
124 raise ServiceConfigError(
125 "%s is not a valid configuration option." % (option))
126
127 # see if there is a type associated with the option
128 kind = self._data[option].get("type", "str")
129 if kind not in validation_kinds:
130 raise ServiceConfigError(
131 "Unknown service option type: %s" % kind)
132
133 # apply validation
134 validator = validation_kinds[kind]
135 value, valid = validator(value, self._data[option])
136 if not valid:
137 raise ServiceConfigError(
138 "Invalid value for %s: %s" % (option, value))
139
140 d[option] = value
141
142 return d
143
144 def get_serialization_data(self):
145 return dict(options=self._data.copy())
146
147
148# Validators return (type mapped value, valid boolean)
149def validate_str(value, options):
150 return str(value), True
151
152def validate_int(value, options):
153 try:
154 value = int(value)
155 except ValueError:
156 pass
157 return value, isinstance(value, int)
158
159def validate_float(value, options):
160 try:
161 value = float(value)
162 except ValueError:
163 pass
164 return value, isinstance(value, float)
165
166def validate_regex(value, options):
167 pattern = options["validator"]
168 return value, re.match(pattern, value)
169 return re.match(pat, str) is not None
170
171# maps service option types to callables
172validation_kinds = {
173 "str": validate_str,
174 "int": validate_int,
175 "float": validate_float,
176 "regex": validate_regex
177 }
0178
=== modified file 'ensemble/formula/directory.py'
--- ensemble/formula/directory.py 2011-01-19 17:00:12 +0000
+++ ensemble/formula/directory.py 2011-06-08 15:41:33 +0000
@@ -2,6 +2,7 @@
2import zipfile2import zipfile
3import tempfile3import tempfile
44
5from ensemble.formula.config import ConfigOptions
5from ensemble.formula.metadata import MetaData6from ensemble.formula.metadata import MetaData
6from ensemble.formula.bundle import FormulaBundle7from ensemble.formula.bundle import FormulaBundle
7from ensemble.formula.base import FormulaBase8from ensemble.formula.base import FormulaBase
@@ -21,6 +22,8 @@
21 def __init__(self, path):22 def __init__(self, path):
22 self.path = path23 self.path = path
23 self.metadata = MetaData(os.path.join(path, "metadata.yaml"))24 self.metadata = MetaData(os.path.join(path, "metadata.yaml"))
25 self.config = ConfigOptions()
26 self.config.load(os.path.join(path, "config.yaml"), optional=True)
24 self._temp_bundle = None27 self._temp_bundle = None
25 self._temp_bundle_file = None28 self._temp_bundle_file = None
2629
2730
=== modified file 'ensemble/formula/errors.py'
--- ensemble/formula/errors.py 2011-04-08 05:45:41 +0000
+++ ensemble/formula/errors.py 2011-06-08 15:41:33 +0000
@@ -46,3 +46,8 @@
4646
47 def __str__(self):47 def __str__(self):
48 return "Formula %r is the latest revision known"48 return "Formula %r is the latest revision known"
49
50
51class ServiceConfigError(Exception):
52 """Indicates an issue related to service options."""
53
4954
=== added file 'ensemble/formula/tests/repository/dummy/config.yaml'
--- ensemble/formula/tests/repository/dummy/config.yaml 1970-01-01 00:00:00 +0000
+++ ensemble/formula/tests/repository/dummy/config.yaml 2011-06-08 15:41:33 +0000
@@ -0,0 +1,5 @@
1options:
2 title: {default: My Title, description: A descriptive title used for the service., type: str}
3 outlook: {description: No default outlook., type: str}
4 username: {default: admin, description: The name of the initial account (given admin permissions)., type: regex, validator: '\w{8}'}
5 skill-level: {description: A number indicating skill., type: int}
06
=== added file 'ensemble/formula/tests/repository/wordpress/config.yaml'
--- ensemble/formula/tests/repository/wordpress/config.yaml 1970-01-01 00:00:00 +0000
+++ ensemble/formula/tests/repository/wordpress/config.yaml 2011-06-08 15:41:33 +0000
@@ -0,0 +1,3 @@
1options:
2 blog-title: {default: My Title, description: A descriptive title used for the blog., type: str}
3
04
=== modified file 'ensemble/formula/tests/test_bundle.py'
--- ensemble/formula/tests/test_bundle.py 2011-01-24 17:15:28 +0000
+++ ensemble/formula/tests/test_bundle.py 2011-06-08 15:41:33 +0000
@@ -59,6 +59,13 @@
5959
60 self.fail("Expected formula error.")60 self.fail("Expected formula error.")
6161
62 def test_bundled_config(self):
63 """Make sure that config is accessible from a bundle."""
64 from ensemble.formula.tests.test_config import sample_yaml_data
65 bundle = FormulaBundle(self.filename)
66 self.assertEquals(bundle.config.get_serialization_data(),
67 sample_yaml_data)
68
62 def test_info(self):69 def test_info(self):
63 bundle = FormulaBundle(self.filename)70 bundle = FormulaBundle(self.filename)
64 self.assertTrue(bundle.metadata is not None)71 self.assertTrue(bundle.metadata is not None)
6572
=== added file 'ensemble/formula/tests/test_config.py'
--- ensemble/formula/tests/test_config.py 1970-01-01 00:00:00 +0000
+++ ensemble/formula/tests/test_config.py 2011-06-08 15:41:33 +0000
@@ -0,0 +1,141 @@
1# -*- encoding: utf-8 -*-
2import yaml
3
4from ensemble.lib.testing import TestCase
5from ensemble.formula.config import ConfigOptions
6from ensemble.formula.errors import ServiceConfigError
7
8sample_configuration = """
9options:
10 title: {default: My Title, description: A descriptive title used for the service., type: str}
11 outlook: {description: No default outlook., type: str}
12 username: {default: admin, description: The name of the initial account (given admin permissions)., type: regex, validator: '\w{8}'}
13 skill-level: {description: A number indicating skill., type: int}
14"""
15
16sample_yaml_data = yaml.load(sample_configuration)
17
18sample_config_defaults = {
19 "title": "My Title",
20 "username": "admin"
21 }
22
23class ConfigOptionsTest(TestCase):
24
25 def setUp(self):
26 self.config = ConfigOptions()
27
28 def test_load(self):
29 """Validate we can load data or get expected errors."""
30
31 # load valid data
32 filename = self.makeFile(sample_configuration)
33 self.config.load(filename)
34 self.assertEqual(self.config.get_serialization_data(),
35 sample_yaml_data)
36
37 # and with bad data expected exceptions
38 self.assertRaises(ServiceConfigError,
39 self.config.load, "foo: [1, 2")
40
41 # test with dict based data
42 self.config.parse(sample_yaml_data)
43 self.assertEqual(self.config.get_serialization_data(),
44 sample_yaml_data)
45
46
47 # and with an unhandled type
48 self.assertRaises(TypeError, self.config.load, 1.234)
49
50 def test_load_file(self):
51 sample_path = self.makeFile(sample_configuration)
52 config = ConfigOptions()
53 config.load(sample_path)
54
55 self.assertEqual(config.get_serialization_data(),
56 sample_yaml_data)
57
58 # and an expected exception
59 error = self.assertRaises(ServiceConfigError, config.load, "missing_file")
60 self.assertEqual(error.message,
61 "Missing required service options metadata: missing_file")
62
63 # but the optional flag handles missing files
64 config = config.load("missing_file", optional=True)
65
66
67 def test_defaults(self):
68 self.config.parse(sample_configuration)
69 defaults = self.config.get_defaults()
70 self.assertEqual(defaults, sample_config_defaults)
71
72 def test_parse(self):
73 """Verify that _parse checks and raises."""
74 # no options dict
75 self.assertRaises(ServiceConfigError, self.config.parse, {"foo": "bar"})
76
77
78 def test_validate(self):
79 sample_input = {
80 "title": "Helpful Title",
81 "outlook": "Peachy",
82 }
83
84 self.config.parse(sample_configuration)
85 data = self.config.validate(sample_input)
86
87 # This should include an overridden value, a default and a new
88 # value.
89 self.assertEqual(data,
90 {"username": "admin",
91 "outlook": "Peachy",
92 "title": "Helpful Title"})
93
94 # now try to set a value outside the expected
95 sample_input["bad"] = "value"
96 error = self.assertRaises(ServiceConfigError, self.config.validate, sample_input)
97 self.assertEqual(error.message,
98 "bad is not a valid configuration option.")
99
100 # validating with an empty instance
101 # the service takes no options
102 config = ConfigOptions()
103 self.assertRaises(ServiceConfigError, config.validate, sample_input)
104
105 def test_validate_regex(self):
106 self.config.parse(sample_configuration)
107
108 error = self.assertRaises(ServiceConfigError,
109 self.config.validate, dict(username="1234"))
110 self.assertIn(str(error), "Invalid value for username: 1234")
111
112
113 data = self.config.validate(dict(username="12345678"))
114 self.assertEqual(data, {"username": "12345678", "title": "My Title"})
115
116
117 def test_validate_regex_wo_validator(self):
118 """Assure that validator is required for type=regex."""
119 local_configuration = sample_configuration + \
120 """ fails: {description: missing required., type: regex}"""
121 error = self.assertRaises(ServiceConfigError,
122 self.config.parse,
123 local_configuration)
124 self.assertIn("options.fails.validator: required value not found",
125 str(error))
126
127
128 def test_validate_types(self):
129
130 self.config.parse(sample_configuration)
131
132 error = self.assertRaises(ServiceConfigError,
133 self.config.validate, {"skill-level": "NaN"})
134 self.assertIn(str(error), "Invalid value for skill-level: NaN")
135
136
137 data = self.config.validate({"skill-level": "9001"})
138 # its over 9000!
139 self.assertEqual(data, {"skill-level": 9001,
140 "title": "My Title",
141 "username": "admin"})
0142
=== modified file 'ensemble/formula/tests/test_directory.py'
--- ensemble/formula/tests/test_directory.py 2011-03-21 17:42:43 +0000
+++ ensemble/formula/tests/test_directory.py 2011-06-08 15:41:33 +0000
@@ -56,7 +56,7 @@
56 included = [info.filename for info in zf.infolist()]56 included = [info.filename for info in zf.infolist()]
57 self.assertEqual(57 self.assertEqual(
58 set(included),58 set(included),
59 set(("metadata.yaml", "empty/", "src/", "src/hello.c")))59 set(("metadata.yaml", "empty/", "src/", "src/hello.c", "config.yaml")))
6060
61 def test_as_bundle(self):61 def test_as_bundle(self):
62 directory = FormulaDirectory(self.sample_dir1)62 directory = FormulaDirectory(self.sample_dir1)
@@ -123,3 +123,11 @@
123 def test_as_directory(self):123 def test_as_directory(self):
124 directory = FormulaDirectory(self.sample_dir1)124 directory = FormulaDirectory(self.sample_dir1)
125 self.assertIs(directory.as_directory(), directory)125 self.assertIs(directory.as_directory(), directory)
126
127 def test_config(self):
128 """Validate that ConfigOptions are available on the formula"""
129 from ensemble.formula.tests.test_config import sample_yaml_data
130 directory = FormulaDirectory(sample_directory)
131 self.assertEquals(directory.config.get_serialization_data(),
132 sample_yaml_data)
133
126134
=== modified file 'ensemble/hooks/cli.py'
--- ensemble/hooks/cli.py 2011-05-03 08:54:13 +0000
+++ ensemble/hooks/cli.py 2011-06-08 15:41:33 +0000
@@ -141,7 +141,7 @@
141 raise exit141 raise exit
142142
143 if self.keyvalue_pairs:143 if self.keyvalue_pairs:
144 self.parse_kvpairs()144 self.parse_kvpairs(self.options.keyvalue_pairs)
145145
146 return options146 return options
147147
@@ -151,23 +151,9 @@
151 level=self.options.log_level,151 level=self.options.log_level,
152 stream=self.options.log_file)152 stream=self.options.log_file)
153153
154 def parse_kvpairs(self):154 def parse_kvpairs(self, options):
155 data = {}155 data = parse_keyvalue_pairs(options)
156 for kv in self.options.keyvalue_pairs:156 # cache
157 k, v = kv.split("=", 1)
158 if v.startswith("@"):
159 # handle fileinput per spec
160 # XXX: todo -- sanitize input
161 filename = v[1:]
162 try:
163 v = open(filename, "r").read()
164 except IOError:
165 raise EnsembleError(
166 "No such file or directory: %s (argument:%s)" % (
167 filename,
168 k))
169 data[k] = v
170
171 self.options.keyvalue_pairs = data157 self.options.keyvalue_pairs = data
172 return data158 return data
173159
@@ -255,3 +241,23 @@
255 logging.error("Invalid log level %s" % level)241 logging.error("Invalid log level %s" % level)
256 level = logging.INFO242 level = logging.INFO
257 return level243 return level
244
245
246def parse_keyvalue_pairs(options):
247 data = {}
248 for kv in options:
249 k, v = kv.split("=", 1)
250 if v.startswith("@"):
251 # handle fileinput per spec
252 # XXX: todo -- sanitize input
253 filename = v[1:]
254 try:
255 v = open(filename, "r").read()
256 except IOError:
257 raise EnsembleError(
258 "No such file or directory: %s (argument:%s)" % (
259 filename,
260 k))
261 data[k] = v
262
263 return data
258264
=== modified file 'ensemble/hooks/tests/hooks/sleep-hook' (properties changed: +x to -x)
=== modified file 'ensemble/hooks/tests/test_cli.py'
--- ensemble/hooks/tests/test_cli.py 2011-05-03 08:54:13 +0000
+++ ensemble/hooks/tests/test_cli.py 2011-06-08 15:41:33 +0000
@@ -4,7 +4,9 @@
44
5from twisted.internet.defer import inlineCallbacks, returnValue5from twisted.internet.defer import inlineCallbacks, returnValue
66
7from ensemble.hooks.cli import CommandLineClient, parse_log_level7from ensemble.errors import EnsembleError
8from ensemble.hooks.cli import (CommandLineClient, parse_log_level,
9 parse_keyvalue_pairs)
8from ensemble.lib.testing import TestCase10from ensemble.lib.testing import TestCase
911
1012
@@ -321,3 +323,21 @@
321 self.assertEquals(parse_log_level("ERROR"), logging.ERROR)323 self.assertEquals(parse_log_level("ERROR"), logging.ERROR)
322 self.assertEquals(parse_log_level(logging.INFO), logging.INFO)324 self.assertEquals(parse_log_level(logging.INFO), logging.INFO)
323 self.assertEquals(parse_log_level(logging.ERROR), logging.ERROR)325 self.assertEquals(parse_log_level(logging.ERROR), logging.ERROR)
326
327
328 def test_parse_keyvalue_pairs(self):
329 sample = self.makeFile("INPUT DATA")
330
331 # test various styles of options being read
332 options = ["alpha=beta",
333 "content=@%s" % sample]
334
335 data = parse_keyvalue_pairs(options)
336 self.assertEquals(data["alpha"], "beta")
337 self.assertEquals(data["content"], "INPUT DATA")
338
339 # and check an error condition
340 options = ["content=@missing"]
341 error = self.assertRaises(EnsembleError, parse_keyvalue_pairs, options)
342 self.assertIn("No such file", str(error))
343
324344
=== modified file 'ensemble/lib/schema.py'
--- ensemble/lib/schema.py 2010-10-28 20:18:36 +0000
+++ ensemble/lib/schema.py 2011-06-08 15:41:33 +0000
@@ -1,4 +1,5 @@
1"""A schema system for validation of dict-based values."""1"""A schema system for validation of dict-based values."""
2import re
23
34
4class SchemaError(Exception):5class SchemaError(Exception):
@@ -110,6 +111,17 @@
110 raise SchemaExpectationError(path, "unicode", repr(value))111 raise SchemaExpectationError(path, "unicode", repr(value))
111 return value112 return value
112113
114class Regex(object):
115 """Something that must be a valid Python regular expression."""
116
117 def coerce(self, value, path):
118 try:
119 regex = re.compile(value)
120 except re.error:
121 raise SchemaExpectationError(path,
122 "regex",
123 repr(value))
124 return regex
113125
114class UnicodeOrString(object):126class UnicodeOrString(object):
115 """Something that must be a C{unicode} or {str}.127 """Something that must be a C{unicode} or {str}.
116128
=== modified file 'ensemble/lib/tests/test_schema.py'
--- ensemble/lib/tests/test_schema.py 2010-11-03 21:12:16 +0000
+++ ensemble/lib/tests/test_schema.py 2011-06-08 15:41:33 +0000
@@ -1,9 +1,11 @@
1import re
2
1from ensemble.lib.testing import TestCase3from ensemble.lib.testing import TestCase
24
3from ensemble.lib.schema import (5from ensemble.lib.schema import (
4 SchemaError, SchemaExpectationError, Constant, Bool,6 SchemaError, SchemaExpectationError, Constant, Bool,
5 Int, Float, String, Unicode, UnicodeOrString, List, KeyDict,7 Int, Float, String, Unicode, UnicodeOrString, List, KeyDict,
6 Dict, Tuple, OneOf, Any)8 Dict, Tuple, OneOf, Any, Regex)
79
810
9PATH = ["<pa", "th>"]11PATH = ["<pa", "th>"]
@@ -161,6 +163,17 @@
161 r"got '\xff'")163 r"got '\xff'")
162164
163165
166 def test_regex(self):
167 exp = "\w+"
168 pat = re.compile(exp)
169 self.assertEquals(Regex().coerce(exp, PATH), pat)
170
171 def test_regex_bad_regex(self):
172 exp = "([a-"
173 error = self.assertRaises(
174 SchemaError, Regex().coerce, exp, PATH)
175 self.assertIn("expected regex", str(error))
176
164177
165 def test_list(self):178 def test_list(self):
166 schema = List(Int())179 schema = List(Int())
167180
=== modified file 'ensemble/state/formula.py'
--- ensemble/state/formula.py 2010-10-29 19:55:50 +0000
+++ ensemble/state/formula.py 2011-06-08 15:41:33 +0000
@@ -5,16 +5,15 @@
55
6from zookeeper import NoNodeException6from zookeeper import NoNodeException
77
8from ensemble.formula.config import ConfigOptions
8from ensemble.formula.metadata import MetaData9from ensemble.formula.metadata import MetaData
10from ensemble.state.base import StateBase
9from ensemble.state.errors import FormulaStateNotFound11from ensemble.state.errors import FormulaStateNotFound
1012
1113
12class FormulaStateManager(object):14class FormulaStateManager(StateBase):
13 """Manages the state of formulas in an environment."""15 """Manages the state of formulas in an environment."""
1416
15 def __init__(self, client):
16 self._client = client
17
18 @inlineCallbacks17 @inlineCallbacks
19 def add_formula_state(self, namespace, formula):18 def add_formula_state(self, namespace, formula):
20 """Register metadata about the provided Formula.19 """Register metadata about the provided Formula.
@@ -26,6 +25,7 @@
26 revision = formula.metadata.revision25 revision = formula.metadata.revision
2726
28 formula_data = {27 formula_data = {
28 "config": formula.config.get_serialization_data(),
29 "metadata": formula.metadata.get_serialization_data(),29 "metadata": formula.metadata.get_serialization_data(),
30 "sha256": formula.get_sha256(),30 "sha256": formula.get_sha256(),
31 }31 }
@@ -71,6 +71,9 @@
71 self._metadata = MetaData()71 self._metadata = MetaData()
72 self._metadata.parse_serialization_data(formula_data["metadata"])72 self._metadata.parse_serialization_data(formula_data["metadata"])
7373
74 self._config = ConfigOptions()
75 self._config.parse(formula_data["config"])
76
74 # Just a health check:77 # Just a health check:
75 assert self._metadata.name == name78 assert self._metadata.name == name
76 assert self._metadata.revision == revision79 assert self._metadata.revision == revision
@@ -104,6 +107,11 @@
104 """Return deferred MetaData."""107 """Return deferred MetaData."""
105 return succeed(self._metadata)108 return succeed(self._metadata)
106109
110 def get_config(self):
111 """Return deferred ConfigOptions."""
112 return succeed(self._config)
113
107 def get_sha256(self):114 def get_sha256(self):
108 """Return deferred sha256 for the formula."""115 """Return deferred sha256 for the formula."""
109 return succeed(self._sha256)116 return succeed(self._sha256)
117
110118
=== modified file 'ensemble/state/service.py'
--- ensemble/state/service.py 2011-05-26 19:56:11 +0000
+++ ensemble/state/service.py 2011-06-08 15:41:33 +0000
@@ -271,6 +271,10 @@
271 return "/services/" + self._internal_id271 return "/services/" + self._internal_id
272272
273 @property273 @property
274 def _config_path(self):
275 return "%s/config" % self._zk_path
276
277 @property
274 def _exposed_path(self):278 def _exposed_path(self):
275 """Path of ZK node that if it exists, indicates service is exposed."""279 """Path of ZK node that if it exists, indicates service is exposed."""
276 return "/services/%s/exposed" % self._internal_id280 return "/services/%s/exposed" % self._internal_id
@@ -467,6 +471,44 @@
467471
468 return self._watch_topology(watch_topology)472 return self._watch_topology(watch_topology)
469473
474 @inlineCallbacks
475 def watch_config_state(self, callback):
476 """Observe changes to config state for a service.
477
478 @param callback: A function/method which accepts the YAMLState
479 node of the changed service. No effort is made to present
480 deltas to the change function.
481
482 Note there are no guarantees that this function will be called
483 once for *every* change in the topology, which means that multiple
484 modifications may be observed as a single call.
485
486 This method currently sets a pretty much perpetual watch (errors
487 will make it bail out). In order to cleanly stop the watcher, a
488 StopWatch exception can be raised by the callback.
489 """
490 @inlineCallbacks
491 def watcher(change_event):
492 if self._client.connected:
493 exists_d, watch_d = self._client.exists_and_watch(
494 self._config_path)
495 yield callback(change_event)
496 watch_d.addCallback(watcher)
497
498 exists_d, watch_d = self._client.exists_and_watch(self._config_path)
499
500 exists = yield exists_d
501
502 # Setup the watch deferred callback after the user defined callback
503 # has returned successfully from the existence invocation.
504 callback_d = maybeDeferred(callback, bool(exists))
505 callback_d.addCallback(
506 lambda x: watch_d.addCallback(watcher) and x)
507
508 # Wait on the first callback, reflecting present state, not a zk watch
509 yield callback_d
510
511
470 def watch_service_unit_states(self, callback):512 def watch_service_unit_states(self, callback):
471 """Observe changes in service unit membership for this service.513 """Observe changes in service unit membership for this service.
472514
473515
=== modified file 'ensemble/state/tests/test_formula.py'
--- ensemble/state/tests/test_formula.py 2010-12-17 20:46:18 +0000
+++ ensemble/state/tests/test_formula.py 2011-06-08 15:41:33 +0000
@@ -36,6 +36,7 @@
36 formula_data = yaml.load(content)36 formula_data = yaml.load(content)
37 self.assertEquals(formula_data, {37 self.assertEquals(formula_data, {
38 "metadata": self.formula.metadata.get_serialization_data(),38 "metadata": self.formula.metadata.get_serialization_data(),
39 "config": self.formula.config.get_serialization_data(),
39 "sha256": self.formula.get_sha256(),40 "sha256": self.formula.get_sha256(),
40 })41 })
4142
@@ -80,6 +81,21 @@
80 self.assertEquals(metadata.revision, 1)81 self.assertEquals(metadata.revision, 1)
8182
82 @inlineCallbacks83 @inlineCallbacks
84 def test_formula_state_config_options(self):
85 """Verify ConfigOptions present and correct."""
86 from ensemble.formula.tests.test_config import sample_yaml_data
87
88 yield self.formula_state_manager.add_formula_state(
89 "namespace", self.formula)
90 formula_state = yield self.formula_state_manager.get_formula_state(
91 "namespace:dummy-1")
92 config = yield formula_state.get_config()
93 self.assertEquals(config.get_serialization_data(),
94 sample_yaml_data)
95
96
97
98 @inlineCallbacks
83 def test_get_non_existing_formula_prior_to_initialization(self):99 def test_get_non_existing_formula_prior_to_initialization(self):
84 """100 """
85 Getting a formula before the formulas node was even101 Getting a formula before the formulas node was even
86102
=== modified file 'ensemble/state/tests/test_service.py'
--- ensemble/state/tests/test_service.py 2011-05-26 19:56:11 +0000
+++ ensemble/state/tests/test_service.py 2011-06-08 15:41:33 +0000
@@ -2282,6 +2282,34 @@
2282 self.assertEqual(metadata.summary, "Blog engine")2282 self.assertEqual(metadata.summary, "Blog engine")
22832283
2284 @inlineCallbacks2284 @inlineCallbacks
2285 def test_watch_config_options(self):
2286 """Verify callback trigger on config options modification"""
2287
2288 service_state = yield self.service_state_manager.add_service_state(
2289 "wordpress", self.formula_state)
2290 results = []
2291
2292 def callback(value):
2293 results.append(value)
2294
2295 yield service_state.watch_config_state(callback)
2296 config = yield service_state.get_config()
2297 config["alpha"] = "beta"
2298 yield config.write()
2299
2300 yield self.poke_zk()
2301 self.assertIdentical(results.pop(0), True)
2302 self.assertIdentical(results.pop(0).type_name, "changed")
2303
2304 # and changing it again should trigger the callback again
2305 config["gamma"] = "delta"
2306 yield config.write()
2307
2308 yield self.poke_zk()
2309 self.assertEqual(len(results), 1)
2310 self.assertIdentical(results.pop(0).type_name, "changed")
2311
2312 @inlineCallbacks
2285 def test_get_open_ports(self):2313 def test_get_open_ports(self):
2286 """Verify introspection and that the ports changes are immediate."""2314 """Verify introspection and that the ports changes are immediate."""
2287 service_state = yield self.add_service("wordpress")2315 service_state = yield self.add_service("wordpress")
@@ -2478,3 +2506,4 @@
2478 self.assertEqual(results[-1].type_name, "changed")2506 self.assertEqual(results[-1].type_name, "changed")
2479 self.assertEqual(contents[-1], [{'port': 53, 'proto': 'udp'}, {'port': 443, 'proto': 'tcp'}])2507 self.assertEqual(contents[-1], [{'port': 53, 'proto': 'udp'}, {'port': 443, 'proto': 'tcp'}])
2480 yield self.poke_zk()2508 yield self.poke_zk()
2509

Subscribers

People subscribed via source and target branches

to status/vote changes: