Merge lp:~bcsaller/pyjuju/config-set into lp:pyjuju
- config-set
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gustavo Niemeyer | Approve | ||
Review via email: mp+60235@code.launchpad.net |
Commit message
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.
Kapil Thangavelu (hazmat) wrote : | # |
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.
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 = FormulaStateMan
+ formula = yield formula_
+
+ service_manager = ServiceStateMan
+ service = yield service_
The function get_formula_
not necessary. E.g.:
formula = yield service.
[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_
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(
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_
+ lambda x: watch_d.
This will need some tweaks along the lines of the work Kapil has been
pushing forward. Please catch up with him about it.
Benjamin Saller (bcsaller) wrote : | # |
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 = FormulaStateMan
> + formula = yield formula_
> +
> + service_manager = ServiceStateMan
> + service = yield service_
>
> The function get_formula_
> not necessary. E.g.:
>
> formula = yield service.
>
Changed and removed formula_
> [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_
>
> 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(
>
> 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...
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!
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.
Gustavo Niemeyer (niemeyer) wrote : | # |
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=
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_serializa
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://
+ option))
(...)
+ raise ServiceConfigEr
+ 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_
+ if kind == "regex":
+ pattern = self._data[
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_
value, valid = validator(value, self._data[option])
[21]
+class ServiceConfigEr
+ """Indicates an issue related to service options."""
+ ...
Benjamin Saller (bcsaller) wrote : | # |
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=
>
> 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_serializa
> 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://
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(
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.
Preview Diff
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 | + |
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.