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
1=== modified file 'Makefile'
2--- Makefile 2011-05-27 15:17:10 +0000
3+++ Makefile 2011-06-08 15:41:33 +0000
4@@ -37,7 +37,7 @@
5 @test -n "$(modified)" && echo $(modified) | xargs pyflakes
6
7
8-modified=$(shell bzr status -S -r branch::prev |grep -P '^\s*M' | awk '{print $$2;}'| grep -P "test_.*\.py$$")
9+modified=$(shell bzr status -S -r branch::prev |grep -P '^\s*\+?[MN]' | awk '{print $$2;}'| grep -P "test_.*\.py$$")
10 ptests:
11 @test -n "$(modified)" && echo $(modified) | xargs ./test
12
13
14=== modified file 'docs/source/drafts/service-config.rst'
15--- docs/source/drafts/service-config.rst 2011-05-05 09:41:11 +0000
16+++ docs/source/drafts/service-config.rst 2011-06-08 15:41:33 +0000
17@@ -81,7 +81,12 @@
18 The specification of possible configuration values is intentionally
19 minimal, but still evolving. Currently the formula define a list of
20 names which they react. Information includes a human readable
21-description and an optional default value.
22+description and an optional default value. Additionally `type` may be
23+specified. All options have a default type of 'str' which means its
24+value will only be treated as a text string. Other valid options are
25+'int', 'float' and 'regex'. When 'regex' is used an addtional element
26+must be provided, 'validator'. This must be a valid Python regex as
27+specified at http://docs.python.org/lib/re.html
28
29 The following `config.yaml` would be included in the top level
30 directory of a formula and includes a list of option definitions::
31@@ -96,6 +101,8 @@
32 password:
33 default: changeme
34 description: Password to be used for the account specified by 'username'
35+ type: regex
36+ validator: '.{6,12}'
37 username:
38 default: admin
39 description: The name of the initial account (given admin permissions).
40
41=== modified file 'ensemble/control/__init__.py'
42--- ensemble/control/__init__.py 2011-05-24 14:20:01 +0000
43+++ ensemble/control/__init__.py 2011-06-08 15:41:33 +0000
44@@ -9,6 +9,7 @@
45 import add_relation
46 import add_unit
47 import bootstrap
48+import config_set
49 import debug_hook
50 import debug_log
51 import deploy
52@@ -32,6 +33,7 @@
53 add_relation,
54 add_unit,
55 bootstrap,
56+ config_set,
57 debug_log,
58 debug_hook,
59 deploy,
60
61=== added file 'ensemble/control/config_set.py'
62--- ensemble/control/config_set.py 1970-01-01 00:00:00 +0000
63+++ ensemble/control/config_set.py 2011-06-08 15:41:33 +0000
64@@ -0,0 +1,75 @@
65+from twisted.internet.defer import inlineCallbacks
66+
67+from ensemble.environment.errors import EnvironmentsConfigError
68+from ensemble.hooks.cli import parse_keyvalue_pairs
69+from ensemble.state.service import ServiceStateManager
70+
71+
72+def configure_subparser(subparsers):
73+ sub_parser = subparsers.add_parser("set",
74+ help=command.__doc__)
75+
76+ sub_parser.add_argument(
77+ "--environment", "-e",
78+ help="Environment to status.")
79+
80+ sub_parser.add_argument("service_name",
81+ help="The name of the service the options apply to.")
82+ sub_parser.add_argument("service_options",
83+ nargs="+",
84+ help="""name=value for option to set""")
85+
86+ return sub_parser
87+
88+
89+def command(options):
90+ """Set service options.
91+
92+ Service formulas may define dynamic options which may be tweaked
93+ at deployment time, or over the lifetime of the service. This
94+ command allows changing these settings.
95+
96+ $ ensemble set <service_name> option=value [option=value]
97+
98+ or
99+
100+ $ ensemble set <service_name> --filename local.yaml
101+
102+ """
103+ environment = options.environments.get(options.environment)
104+ if environment is None and options.environment:
105+ raise EnvironmentsConfigError(
106+ "Invalid environment %r" % options.environment)
107+ elif environment is None:
108+ environment = options.environments.get_default()
109+
110+ return config_set(environment,
111+ options.service_name,
112+ options.service_options)
113+
114+
115+@inlineCallbacks
116+def config_set(environment, service_name, service_options):
117+ """Collect and render status information. """
118+ provider = environment.get_machine_provider()
119+ client = yield provider.connect()
120+
121+ # Get the service and the formula
122+ #
123+ service_manager = ServiceStateManager(client)
124+ service = yield service_manager.get_service_state(service_name)
125+ formula = yield service.get_formula_state()
126+
127+ # Use the formula's ConfigOptions instance to validate the
128+ # arguments to config_set. Invalid options passed to this method
129+ # will thrown an exception.
130+ options = parse_keyvalue_pairs(service_options)
131+
132+ config = yield formula.get_config()
133+ options = config.validate(options)
134+
135+ # Apply the change
136+ state = yield service.get_config()
137+ state.update(options)
138+ yield state.write()
139+
140
141=== added file 'ensemble/control/tests/test_config_set.py'
142--- ensemble/control/tests/test_config_set.py 1970-01-01 00:00:00 +0000
143+++ ensemble/control/tests/test_config_set.py 2011-06-08 15:41:33 +0000
144@@ -0,0 +1,89 @@
145+from twisted.internet.defer import inlineCallbacks
146+
147+from ensemble.control import main
148+from ensemble.control.config_set import config_set
149+from ensemble.control.tests.test_status import StatusTestBase
150+from ensemble.formula.tests.test_metadata import test_repository_path
151+
152+class ControlEnsembleSetTest(StatusTestBase):
153+
154+ @inlineCallbacks
155+ def setUp(self):
156+ yield super(ControlEnsembleSetTest, self).setUp()
157+ self.output = self.capture_logging()
158+ self.stderr = self.capture_stream("stderr")
159+
160+ @inlineCallbacks
161+ def tearDown(self):
162+ yield super(ControlEnsembleSetTest, self).tearDown()
163+ self.reset_logging()
164+
165+ @inlineCallbacks
166+ def test_set_and_get(self):
167+ system = yield self.build_topology()
168+
169+ self.mock_environment()
170+ self.mocker.replay()
171+
172+ yield config_set(self.environment,
173+ "wordpress",
174+ ["blog-title=That\'ll do, pig."])
175+
176+
177+ # Verify the state is accessible
178+ wordpress = system["services"]["wordpress"]
179+ state = yield wordpress.get_config()
180+ self.assertEqual(state, {"blog-title": "That\'ll do, pig."})
181+
182+
183+ @inlineCallbacks
184+ def test_set_invalid_option(self):
185+ yield self.build_topology()
186+ self.mock_environment()
187+ finished = self.setup_cli_reactor()
188+ self.setup_exit(0)
189+ self.mocker.replay()
190+
191+ main(["set",
192+ "wordpress",
193+ "blog-roll=What's a blog-roll?"])
194+ yield finished
195+
196+ # Make sure we got an error message to the user
197+ self.assertIn("blog-roll is not a valid configuration option.",
198+ self.stderr.getvalue())
199+
200+ @inlineCallbacks
201+ def test_set_invalid_service(self):
202+ yield self.build_topology()
203+ self.mock_environment()
204+ finished = self.setup_cli_reactor()
205+ self.setup_exit(0)
206+ self.mocker.replay()
207+
208+ main(["set",
209+ "whatever",
210+ "blog-roll=What's a blog-roll?"])
211+ yield finished
212+
213+ self.assertIn("Service 'whatever' was not found",
214+ self.stderr.getvalue())
215+
216+
217+ @inlineCallbacks
218+ def test_set_valid_option(self):
219+ system = yield self.build_topology()
220+ self.mock_environment()
221+ finished = self.setup_cli_reactor()
222+ self.setup_exit(0)
223+ self.mocker.replay()
224+
225+ main(["set",
226+ "wordpress",
227+ 'blog-title=My title'])
228+ yield finished
229+
230+ # Verify the state is accessible
231+ wordpress = system["services"]["wordpress"]
232+ state = yield wordpress.get_config()
233+ self.assertEqual(state, {"blog-title": "My title"})
234
235=== modified file 'ensemble/formula/bundle.py'
236--- ensemble/formula/bundle.py 2011-01-19 18:20:47 +0000
237+++ ensemble/formula/bundle.py 2011-06-08 15:41:33 +0000
238@@ -5,6 +5,7 @@
239 from zipfile import ZipFile, BadZipfile
240
241 from ensemble.formula.base import FormulaBase
242+from ensemble.formula.config import ConfigOptions
243 from ensemble.formula.metadata import MetaData
244 from ensemble.errors import FormulaError
245 from ensemble.lib.filehash import compute_file_hash
246@@ -20,15 +21,21 @@
247 raise FormulaError(path, "Must be a ZIP-file (%s)." % str(exc))
248
249 metadata = MetaData()
250- try:
251- content = zf.read("metadata.yaml")
252- except KeyError:
253+ if "metadata.yaml" not in zf.namelist():
254 raise FormulaError(path,
255 "Archive does not contain required file: "
256 "\"metadata.yaml\".")
257
258+ content = zf.read("metadata.yaml")
259 metadata.parse(content)
260 self.metadata = metadata
261+
262+ config = ConfigOptions()
263+ if "config.yaml" in zf.namelist():
264+ content = zf.read("config.yaml")
265+ config.parse(content)
266+ self.config = config
267+
268 self.path = isinstance(path, file) and path.name or path
269
270 def compute_sha256(self):
271
272=== added file 'ensemble/formula/config.py'
273--- ensemble/formula/config.py 1970-01-01 00:00:00 +0000
274+++ ensemble/formula/config.py 2011-06-08 15:41:33 +0000
275@@ -0,0 +1,177 @@
276+import os
277+import re
278+
279+import yaml
280+
281+from ensemble.lib.schema import (SchemaError, KeyDict, Dict, String,
282+ Constant, OneOf, Int, Float)
283+from ensemble.formula.errors import ServiceConfigError
284+
285+
286+# regex type requires validator
287+REGEX_TYPE_SCHEMA = KeyDict({"type": Constant("regex"),
288+ "validator": String(),
289+ "default": OneOf(String(), Int(), Float()),
290+ "description": String(),
291+ },
292+ optional=["default", "description"])
293+
294+SIMPLE_TYPE_SCHEMA = KeyDict({"type": OneOf(Constant("str"),
295+ Constant("int"),
296+ Constant("float")),
297+ "default": OneOf(String(), Int(), Float()),
298+ "description": String(),
299+ },
300+ optional=["default", "description"])
301+
302+# Schema used to validate ConfigOptions specifications
303+CONFIG_SCHEMA = KeyDict({
304+ "options": Dict(
305+ String(),
306+ OneOf(REGEX_TYPE_SCHEMA, SIMPLE_TYPE_SCHEMA))
307+ })
308+
309+
310+
311+
312+
313+class ConfigOptions(object):
314+ """Represents the configuration options exposed by a formula.
315+
316+ The intended usage is that Forumla provide access to these objects
317+ and then use them to `validate` inputs provided in the `ensemble
318+ set` and `ensemble deploy` code paths.
319+ """
320+
321+ def __init__(self):
322+ self._data = {}
323+
324+ def load(self, pathname, optional=False):
325+ """Construct a ConfigOptions instance from a YAML file."""
326+ data = None
327+ if os.path.exists(pathname):
328+ data = open(pathname, "r").read()
329+
330+ if not data and optional:
331+ data = "options: {}\n"
332+
333+ if not data:
334+ raise ServiceConfigError(
335+ "Missing required service options metadata: %s" % (
336+ pathname))
337+
338+ self.parse(data)
339+ return self
340+
341+ def parse(self, data):
342+ """Load data into the config object.
343+
344+ Data can be a properly encoded YAML string or an dict, such as
345+ one returned by `get_serialization_data`.
346+
347+ Each call to `load` replaces any existing data.
348+ """
349+ if isinstance(data, basestring):
350+ raw_data = yaml.load(data)
351+ elif isinstance(data, dict):
352+ raw_data = data
353+ else:
354+ raise TypeError("Unknown data type for `load`: %s" % type(data))
355+
356+ data = self._parse(raw_data)
357+ self._data = data
358+
359+ def _parse(self, data):
360+ """Verify we have sensible option metadata.
361+
362+ Returns the `options` dict from within the YAML data.
363+ """
364+ if not data or not isinstance(data, dict):
365+ raise ServiceConfigError("Expected YAML dict of options metadata")
366+
367+ try:
368+ data = CONFIG_SCHEMA.coerce(data, [])
369+ except SchemaError, error:
370+ raise ServiceConfigError("Invalid options specification: %s" % (
371+ error))
372+
373+ return data["options"]
374+
375+ def get_defaults(self):
376+ """Return a mapping of option: default for all options."""
377+ d = {}
378+ for name, options in self._data.items():
379+ if "default" in options:
380+ d[name] = options["default"]
381+
382+ return d
383+
384+ def validate(self, options):
385+ """Validate options using the loaded validation data.
386+
387+ This method validates all the provided options, and returns a
388+ new dictionary with values properly typed and default values
389+ set for missing keys. If a provided option is unknown or its
390+ value fails validation, ServiceConfigError is raised.
391+
392+ This method includes defaults for options which have no values
393+ supplied if such defaults are defined in the metadata.
394+ """
395+ d = self.get_defaults()
396+
397+ for option, value in options.items():
398+ if option not in self._data:
399+ raise ServiceConfigError(
400+ "%s is not a valid configuration option." % (option))
401+
402+ # see if there is a type associated with the option
403+ kind = self._data[option].get("type", "str")
404+ if kind not in validation_kinds:
405+ raise ServiceConfigError(
406+ "Unknown service option type: %s" % kind)
407+
408+ # apply validation
409+ validator = validation_kinds[kind]
410+ value, valid = validator(value, self._data[option])
411+ if not valid:
412+ raise ServiceConfigError(
413+ "Invalid value for %s: %s" % (option, value))
414+
415+ d[option] = value
416+
417+ return d
418+
419+ def get_serialization_data(self):
420+ return dict(options=self._data.copy())
421+
422+
423+# Validators return (type mapped value, valid boolean)
424+def validate_str(value, options):
425+ return str(value), True
426+
427+def validate_int(value, options):
428+ try:
429+ value = int(value)
430+ except ValueError:
431+ pass
432+ return value, isinstance(value, int)
433+
434+def validate_float(value, options):
435+ try:
436+ value = float(value)
437+ except ValueError:
438+ pass
439+ return value, isinstance(value, float)
440+
441+def validate_regex(value, options):
442+ pattern = options["validator"]
443+ return value, re.match(pattern, value)
444+ return re.match(pat, str) is not None
445+
446+# maps service option types to callables
447+validation_kinds = {
448+ "str": validate_str,
449+ "int": validate_int,
450+ "float": validate_float,
451+ "regex": validate_regex
452+ }
453
454=== modified file 'ensemble/formula/directory.py'
455--- ensemble/formula/directory.py 2011-01-19 17:00:12 +0000
456+++ ensemble/formula/directory.py 2011-06-08 15:41:33 +0000
457@@ -2,6 +2,7 @@
458 import zipfile
459 import tempfile
460
461+from ensemble.formula.config import ConfigOptions
462 from ensemble.formula.metadata import MetaData
463 from ensemble.formula.bundle import FormulaBundle
464 from ensemble.formula.base import FormulaBase
465@@ -21,6 +22,8 @@
466 def __init__(self, path):
467 self.path = path
468 self.metadata = MetaData(os.path.join(path, "metadata.yaml"))
469+ self.config = ConfigOptions()
470+ self.config.load(os.path.join(path, "config.yaml"), optional=True)
471 self._temp_bundle = None
472 self._temp_bundle_file = None
473
474
475=== modified file 'ensemble/formula/errors.py'
476--- ensemble/formula/errors.py 2011-04-08 05:45:41 +0000
477+++ ensemble/formula/errors.py 2011-06-08 15:41:33 +0000
478@@ -46,3 +46,8 @@
479
480 def __str__(self):
481 return "Formula %r is the latest revision known"
482+
483+
484+class ServiceConfigError(Exception):
485+ """Indicates an issue related to service options."""
486+
487
488=== added file 'ensemble/formula/tests/repository/dummy/config.yaml'
489--- ensemble/formula/tests/repository/dummy/config.yaml 1970-01-01 00:00:00 +0000
490+++ ensemble/formula/tests/repository/dummy/config.yaml 2011-06-08 15:41:33 +0000
491@@ -0,0 +1,5 @@
492+options:
493+ title: {default: My Title, description: A descriptive title used for the service., type: str}
494+ outlook: {description: No default outlook., type: str}
495+ username: {default: admin, description: The name of the initial account (given admin permissions)., type: regex, validator: '\w{8}'}
496+ skill-level: {description: A number indicating skill., type: int}
497
498=== added file 'ensemble/formula/tests/repository/wordpress/config.yaml'
499--- ensemble/formula/tests/repository/wordpress/config.yaml 1970-01-01 00:00:00 +0000
500+++ ensemble/formula/tests/repository/wordpress/config.yaml 2011-06-08 15:41:33 +0000
501@@ -0,0 +1,3 @@
502+options:
503+ blog-title: {default: My Title, description: A descriptive title used for the blog., type: str}
504+
505
506=== modified file 'ensemble/formula/tests/test_bundle.py'
507--- ensemble/formula/tests/test_bundle.py 2011-01-24 17:15:28 +0000
508+++ ensemble/formula/tests/test_bundle.py 2011-06-08 15:41:33 +0000
509@@ -59,6 +59,13 @@
510
511 self.fail("Expected formula error.")
512
513+ def test_bundled_config(self):
514+ """Make sure that config is accessible from a bundle."""
515+ from ensemble.formula.tests.test_config import sample_yaml_data
516+ bundle = FormulaBundle(self.filename)
517+ self.assertEquals(bundle.config.get_serialization_data(),
518+ sample_yaml_data)
519+
520 def test_info(self):
521 bundle = FormulaBundle(self.filename)
522 self.assertTrue(bundle.metadata is not None)
523
524=== added file 'ensemble/formula/tests/test_config.py'
525--- ensemble/formula/tests/test_config.py 1970-01-01 00:00:00 +0000
526+++ ensemble/formula/tests/test_config.py 2011-06-08 15:41:33 +0000
527@@ -0,0 +1,141 @@
528+# -*- encoding: utf-8 -*-
529+import yaml
530+
531+from ensemble.lib.testing import TestCase
532+from ensemble.formula.config import ConfigOptions
533+from ensemble.formula.errors import ServiceConfigError
534+
535+sample_configuration = """
536+options:
537+ title: {default: My Title, description: A descriptive title used for the service., type: str}
538+ outlook: {description: No default outlook., type: str}
539+ username: {default: admin, description: The name of the initial account (given admin permissions)., type: regex, validator: '\w{8}'}
540+ skill-level: {description: A number indicating skill., type: int}
541+"""
542+
543+sample_yaml_data = yaml.load(sample_configuration)
544+
545+sample_config_defaults = {
546+ "title": "My Title",
547+ "username": "admin"
548+ }
549+
550+class ConfigOptionsTest(TestCase):
551+
552+ def setUp(self):
553+ self.config = ConfigOptions()
554+
555+ def test_load(self):
556+ """Validate we can load data or get expected errors."""
557+
558+ # load valid data
559+ filename = self.makeFile(sample_configuration)
560+ self.config.load(filename)
561+ self.assertEqual(self.config.get_serialization_data(),
562+ sample_yaml_data)
563+
564+ # and with bad data expected exceptions
565+ self.assertRaises(ServiceConfigError,
566+ self.config.load, "foo: [1, 2")
567+
568+ # test with dict based data
569+ self.config.parse(sample_yaml_data)
570+ self.assertEqual(self.config.get_serialization_data(),
571+ sample_yaml_data)
572+
573+
574+ # and with an unhandled type
575+ self.assertRaises(TypeError, self.config.load, 1.234)
576+
577+ def test_load_file(self):
578+ sample_path = self.makeFile(sample_configuration)
579+ config = ConfigOptions()
580+ config.load(sample_path)
581+
582+ self.assertEqual(config.get_serialization_data(),
583+ sample_yaml_data)
584+
585+ # and an expected exception
586+ error = self.assertRaises(ServiceConfigError, config.load, "missing_file")
587+ self.assertEqual(error.message,
588+ "Missing required service options metadata: missing_file")
589+
590+ # but the optional flag handles missing files
591+ config = config.load("missing_file", optional=True)
592+
593+
594+ def test_defaults(self):
595+ self.config.parse(sample_configuration)
596+ defaults = self.config.get_defaults()
597+ self.assertEqual(defaults, sample_config_defaults)
598+
599+ def test_parse(self):
600+ """Verify that _parse checks and raises."""
601+ # no options dict
602+ self.assertRaises(ServiceConfigError, self.config.parse, {"foo": "bar"})
603+
604+
605+ def test_validate(self):
606+ sample_input = {
607+ "title": "Helpful Title",
608+ "outlook": "Peachy",
609+ }
610+
611+ self.config.parse(sample_configuration)
612+ data = self.config.validate(sample_input)
613+
614+ # This should include an overridden value, a default and a new
615+ # value.
616+ self.assertEqual(data,
617+ {"username": "admin",
618+ "outlook": "Peachy",
619+ "title": "Helpful Title"})
620+
621+ # now try to set a value outside the expected
622+ sample_input["bad"] = "value"
623+ error = self.assertRaises(ServiceConfigError, self.config.validate, sample_input)
624+ self.assertEqual(error.message,
625+ "bad is not a valid configuration option.")
626+
627+ # validating with an empty instance
628+ # the service takes no options
629+ config = ConfigOptions()
630+ self.assertRaises(ServiceConfigError, config.validate, sample_input)
631+
632+ def test_validate_regex(self):
633+ self.config.parse(sample_configuration)
634+
635+ error = self.assertRaises(ServiceConfigError,
636+ self.config.validate, dict(username="1234"))
637+ self.assertIn(str(error), "Invalid value for username: 1234")
638+
639+
640+ data = self.config.validate(dict(username="12345678"))
641+ self.assertEqual(data, {"username": "12345678", "title": "My Title"})
642+
643+
644+ def test_validate_regex_wo_validator(self):
645+ """Assure that validator is required for type=regex."""
646+ local_configuration = sample_configuration + \
647+ """ fails: {description: missing required., type: regex}"""
648+ error = self.assertRaises(ServiceConfigError,
649+ self.config.parse,
650+ local_configuration)
651+ self.assertIn("options.fails.validator: required value not found",
652+ str(error))
653+
654+
655+ def test_validate_types(self):
656+
657+ self.config.parse(sample_configuration)
658+
659+ error = self.assertRaises(ServiceConfigError,
660+ self.config.validate, {"skill-level": "NaN"})
661+ self.assertIn(str(error), "Invalid value for skill-level: NaN")
662+
663+
664+ data = self.config.validate({"skill-level": "9001"})
665+ # its over 9000!
666+ self.assertEqual(data, {"skill-level": 9001,
667+ "title": "My Title",
668+ "username": "admin"})
669
670=== modified file 'ensemble/formula/tests/test_directory.py'
671--- ensemble/formula/tests/test_directory.py 2011-03-21 17:42:43 +0000
672+++ ensemble/formula/tests/test_directory.py 2011-06-08 15:41:33 +0000
673@@ -56,7 +56,7 @@
674 included = [info.filename for info in zf.infolist()]
675 self.assertEqual(
676 set(included),
677- set(("metadata.yaml", "empty/", "src/", "src/hello.c")))
678+ set(("metadata.yaml", "empty/", "src/", "src/hello.c", "config.yaml")))
679
680 def test_as_bundle(self):
681 directory = FormulaDirectory(self.sample_dir1)
682@@ -123,3 +123,11 @@
683 def test_as_directory(self):
684 directory = FormulaDirectory(self.sample_dir1)
685 self.assertIs(directory.as_directory(), directory)
686+
687+ def test_config(self):
688+ """Validate that ConfigOptions are available on the formula"""
689+ from ensemble.formula.tests.test_config import sample_yaml_data
690+ directory = FormulaDirectory(sample_directory)
691+ self.assertEquals(directory.config.get_serialization_data(),
692+ sample_yaml_data)
693+
694
695=== modified file 'ensemble/hooks/cli.py'
696--- ensemble/hooks/cli.py 2011-05-03 08:54:13 +0000
697+++ ensemble/hooks/cli.py 2011-06-08 15:41:33 +0000
698@@ -141,7 +141,7 @@
699 raise exit
700
701 if self.keyvalue_pairs:
702- self.parse_kvpairs()
703+ self.parse_kvpairs(self.options.keyvalue_pairs)
704
705 return options
706
707@@ -151,23 +151,9 @@
708 level=self.options.log_level,
709 stream=self.options.log_file)
710
711- def parse_kvpairs(self):
712- data = {}
713- for kv in self.options.keyvalue_pairs:
714- k, v = kv.split("=", 1)
715- if v.startswith("@"):
716- # handle fileinput per spec
717- # XXX: todo -- sanitize input
718- filename = v[1:]
719- try:
720- v = open(filename, "r").read()
721- except IOError:
722- raise EnsembleError(
723- "No such file or directory: %s (argument:%s)" % (
724- filename,
725- k))
726- data[k] = v
727-
728+ def parse_kvpairs(self, options):
729+ data = parse_keyvalue_pairs(options)
730+ # cache
731 self.options.keyvalue_pairs = data
732 return data
733
734@@ -255,3 +241,23 @@
735 logging.error("Invalid log level %s" % level)
736 level = logging.INFO
737 return level
738+
739+
740+def parse_keyvalue_pairs(options):
741+ data = {}
742+ for kv in options:
743+ k, v = kv.split("=", 1)
744+ if v.startswith("@"):
745+ # handle fileinput per spec
746+ # XXX: todo -- sanitize input
747+ filename = v[1:]
748+ try:
749+ v = open(filename, "r").read()
750+ except IOError:
751+ raise EnsembleError(
752+ "No such file or directory: %s (argument:%s)" % (
753+ filename,
754+ k))
755+ data[k] = v
756+
757+ return data
758
759=== modified file 'ensemble/hooks/tests/hooks/sleep-hook' (properties changed: +x to -x)
760=== modified file 'ensemble/hooks/tests/test_cli.py'
761--- ensemble/hooks/tests/test_cli.py 2011-05-03 08:54:13 +0000
762+++ ensemble/hooks/tests/test_cli.py 2011-06-08 15:41:33 +0000
763@@ -4,7 +4,9 @@
764
765 from twisted.internet.defer import inlineCallbacks, returnValue
766
767-from ensemble.hooks.cli import CommandLineClient, parse_log_level
768+from ensemble.errors import EnsembleError
769+from ensemble.hooks.cli import (CommandLineClient, parse_log_level,
770+ parse_keyvalue_pairs)
771 from ensemble.lib.testing import TestCase
772
773
774@@ -321,3 +323,21 @@
775 self.assertEquals(parse_log_level("ERROR"), logging.ERROR)
776 self.assertEquals(parse_log_level(logging.INFO), logging.INFO)
777 self.assertEquals(parse_log_level(logging.ERROR), logging.ERROR)
778+
779+
780+ def test_parse_keyvalue_pairs(self):
781+ sample = self.makeFile("INPUT DATA")
782+
783+ # test various styles of options being read
784+ options = ["alpha=beta",
785+ "content=@%s" % sample]
786+
787+ data = parse_keyvalue_pairs(options)
788+ self.assertEquals(data["alpha"], "beta")
789+ self.assertEquals(data["content"], "INPUT DATA")
790+
791+ # and check an error condition
792+ options = ["content=@missing"]
793+ error = self.assertRaises(EnsembleError, parse_keyvalue_pairs, options)
794+ self.assertIn("No such file", str(error))
795+
796
797=== modified file 'ensemble/lib/schema.py'
798--- ensemble/lib/schema.py 2010-10-28 20:18:36 +0000
799+++ ensemble/lib/schema.py 2011-06-08 15:41:33 +0000
800@@ -1,4 +1,5 @@
801 """A schema system for validation of dict-based values."""
802+import re
803
804
805 class SchemaError(Exception):
806@@ -110,6 +111,17 @@
807 raise SchemaExpectationError(path, "unicode", repr(value))
808 return value
809
810+class Regex(object):
811+ """Something that must be a valid Python regular expression."""
812+
813+ def coerce(self, value, path):
814+ try:
815+ regex = re.compile(value)
816+ except re.error:
817+ raise SchemaExpectationError(path,
818+ "regex",
819+ repr(value))
820+ return regex
821
822 class UnicodeOrString(object):
823 """Something that must be a C{unicode} or {str}.
824
825=== modified file 'ensemble/lib/tests/test_schema.py'
826--- ensemble/lib/tests/test_schema.py 2010-11-03 21:12:16 +0000
827+++ ensemble/lib/tests/test_schema.py 2011-06-08 15:41:33 +0000
828@@ -1,9 +1,11 @@
829+import re
830+
831 from ensemble.lib.testing import TestCase
832
833 from ensemble.lib.schema import (
834 SchemaError, SchemaExpectationError, Constant, Bool,
835 Int, Float, String, Unicode, UnicodeOrString, List, KeyDict,
836- Dict, Tuple, OneOf, Any)
837+ Dict, Tuple, OneOf, Any, Regex)
838
839
840 PATH = ["<pa", "th>"]
841@@ -161,6 +163,17 @@
842 r"got '\xff'")
843
844
845+ def test_regex(self):
846+ exp = "\w+"
847+ pat = re.compile(exp)
848+ self.assertEquals(Regex().coerce(exp, PATH), pat)
849+
850+ def test_regex_bad_regex(self):
851+ exp = "([a-"
852+ error = self.assertRaises(
853+ SchemaError, Regex().coerce, exp, PATH)
854+ self.assertIn("expected regex", str(error))
855+
856
857 def test_list(self):
858 schema = List(Int())
859
860=== modified file 'ensemble/state/formula.py'
861--- ensemble/state/formula.py 2010-10-29 19:55:50 +0000
862+++ ensemble/state/formula.py 2011-06-08 15:41:33 +0000
863@@ -5,16 +5,15 @@
864
865 from zookeeper import NoNodeException
866
867+from ensemble.formula.config import ConfigOptions
868 from ensemble.formula.metadata import MetaData
869+from ensemble.state.base import StateBase
870 from ensemble.state.errors import FormulaStateNotFound
871
872
873-class FormulaStateManager(object):
874+class FormulaStateManager(StateBase):
875 """Manages the state of formulas in an environment."""
876
877- def __init__(self, client):
878- self._client = client
879-
880 @inlineCallbacks
881 def add_formula_state(self, namespace, formula):
882 """Register metadata about the provided Formula.
883@@ -26,6 +25,7 @@
884 revision = formula.metadata.revision
885
886 formula_data = {
887+ "config": formula.config.get_serialization_data(),
888 "metadata": formula.metadata.get_serialization_data(),
889 "sha256": formula.get_sha256(),
890 }
891@@ -71,6 +71,9 @@
892 self._metadata = MetaData()
893 self._metadata.parse_serialization_data(formula_data["metadata"])
894
895+ self._config = ConfigOptions()
896+ self._config.parse(formula_data["config"])
897+
898 # Just a health check:
899 assert self._metadata.name == name
900 assert self._metadata.revision == revision
901@@ -104,6 +107,11 @@
902 """Return deferred MetaData."""
903 return succeed(self._metadata)
904
905+ def get_config(self):
906+ """Return deferred ConfigOptions."""
907+ return succeed(self._config)
908+
909 def get_sha256(self):
910 """Return deferred sha256 for the formula."""
911 return succeed(self._sha256)
912+
913
914=== modified file 'ensemble/state/service.py'
915--- ensemble/state/service.py 2011-05-26 19:56:11 +0000
916+++ ensemble/state/service.py 2011-06-08 15:41:33 +0000
917@@ -271,6 +271,10 @@
918 return "/services/" + self._internal_id
919
920 @property
921+ def _config_path(self):
922+ return "%s/config" % self._zk_path
923+
924+ @property
925 def _exposed_path(self):
926 """Path of ZK node that if it exists, indicates service is exposed."""
927 return "/services/%s/exposed" % self._internal_id
928@@ -467,6 +471,44 @@
929
930 return self._watch_topology(watch_topology)
931
932+ @inlineCallbacks
933+ def watch_config_state(self, callback):
934+ """Observe changes to config state for a service.
935+
936+ @param callback: A function/method which accepts the YAMLState
937+ node of the changed service. No effort is made to present
938+ deltas to the change function.
939+
940+ Note there are no guarantees that this function will be called
941+ once for *every* change in the topology, which means that multiple
942+ modifications may be observed as a single call.
943+
944+ This method currently sets a pretty much perpetual watch (errors
945+ will make it bail out). In order to cleanly stop the watcher, a
946+ StopWatch exception can be raised by the callback.
947+ """
948+ @inlineCallbacks
949+ def watcher(change_event):
950+ if self._client.connected:
951+ exists_d, watch_d = self._client.exists_and_watch(
952+ self._config_path)
953+ yield callback(change_event)
954+ watch_d.addCallback(watcher)
955+
956+ exists_d, watch_d = self._client.exists_and_watch(self._config_path)
957+
958+ exists = yield exists_d
959+
960+ # Setup the watch deferred callback after the user defined callback
961+ # has returned successfully from the existence invocation.
962+ callback_d = maybeDeferred(callback, bool(exists))
963+ callback_d.addCallback(
964+ lambda x: watch_d.addCallback(watcher) and x)
965+
966+ # Wait on the first callback, reflecting present state, not a zk watch
967+ yield callback_d
968+
969+
970 def watch_service_unit_states(self, callback):
971 """Observe changes in service unit membership for this service.
972
973
974=== modified file 'ensemble/state/tests/test_formula.py'
975--- ensemble/state/tests/test_formula.py 2010-12-17 20:46:18 +0000
976+++ ensemble/state/tests/test_formula.py 2011-06-08 15:41:33 +0000
977@@ -36,6 +36,7 @@
978 formula_data = yaml.load(content)
979 self.assertEquals(formula_data, {
980 "metadata": self.formula.metadata.get_serialization_data(),
981+ "config": self.formula.config.get_serialization_data(),
982 "sha256": self.formula.get_sha256(),
983 })
984
985@@ -80,6 +81,21 @@
986 self.assertEquals(metadata.revision, 1)
987
988 @inlineCallbacks
989+ def test_formula_state_config_options(self):
990+ """Verify ConfigOptions present and correct."""
991+ from ensemble.formula.tests.test_config import sample_yaml_data
992+
993+ yield self.formula_state_manager.add_formula_state(
994+ "namespace", self.formula)
995+ formula_state = yield self.formula_state_manager.get_formula_state(
996+ "namespace:dummy-1")
997+ config = yield formula_state.get_config()
998+ self.assertEquals(config.get_serialization_data(),
999+ sample_yaml_data)
1000+
1001+
1002+
1003+ @inlineCallbacks
1004 def test_get_non_existing_formula_prior_to_initialization(self):
1005 """
1006 Getting a formula before the formulas node was even
1007
1008=== modified file 'ensemble/state/tests/test_service.py'
1009--- ensemble/state/tests/test_service.py 2011-05-26 19:56:11 +0000
1010+++ ensemble/state/tests/test_service.py 2011-06-08 15:41:33 +0000
1011@@ -2282,6 +2282,34 @@
1012 self.assertEqual(metadata.summary, "Blog engine")
1013
1014 @inlineCallbacks
1015+ def test_watch_config_options(self):
1016+ """Verify callback trigger on config options modification"""
1017+
1018+ service_state = yield self.service_state_manager.add_service_state(
1019+ "wordpress", self.formula_state)
1020+ results = []
1021+
1022+ def callback(value):
1023+ results.append(value)
1024+
1025+ yield service_state.watch_config_state(callback)
1026+ config = yield service_state.get_config()
1027+ config["alpha"] = "beta"
1028+ yield config.write()
1029+
1030+ yield self.poke_zk()
1031+ self.assertIdentical(results.pop(0), True)
1032+ self.assertIdentical(results.pop(0).type_name, "changed")
1033+
1034+ # and changing it again should trigger the callback again
1035+ config["gamma"] = "delta"
1036+ yield config.write()
1037+
1038+ yield self.poke_zk()
1039+ self.assertEqual(len(results), 1)
1040+ self.assertIdentical(results.pop(0).type_name, "changed")
1041+
1042+ @inlineCallbacks
1043 def test_get_open_ports(self):
1044 """Verify introspection and that the ports changes are immediate."""
1045 service_state = yield self.add_service("wordpress")
1046@@ -2478,3 +2506,4 @@
1047 self.assertEqual(results[-1].type_name, "changed")
1048 self.assertEqual(contents[-1], [{'port': 53, 'proto': 'udp'}, {'port': 443, 'proto': 'tcp'}])
1049 yield self.poke_zk()
1050+

Subscribers

People subscribed via source and target branches

to status/vote changes: