1
"""Interactive configuration support for Landscape.
3
This module, and specifically L{LandscapeSetupScript}, implements the support
4
for the C{landscape-config} script.
14
from StringIO import StringIO
16
from landscape.lib.tag import is_valid_tag
18
from landscape.sysvconfig import SysVConfig, ProcessError
19
from landscape.lib.amp import MethodCallError
20
from landscape.lib.twisted_util import gather_results
21
from landscape.lib.fetch import fetch, FetchError
22
from landscape.lib.bootstrap import BootstrapList, BootstrapDirectory
23
from landscape.reactor import LandscapeReactor
24
from landscape.broker.registration import InvalidCredentialsError
25
from landscape.broker.config import BrokerConfiguration
26
from landscape.broker.amp import RemoteBrokerConnector
29
class ConfigurationError(Exception):
30
"""Raised when required configuration values are missing."""
33
class ImportOptionError(ConfigurationError):
34
"""Raised when there are issues with handling the --import option."""
37
def print_text(text, end="\n", error=False):
42
stream.write(text + end)
46
def get_invalid_users(users):
48
Process a string with a list of comma separated usernames, this returns
49
any usernames not known to the underlying user database.
52
user_list = [user.strip() for user in users.split(",")]
53
if "ALL" in user_list:
54
if len(user_list) > 1:
55
raise ConfigurationError(
56
"Extra users specified with ALL users")
57
user_list.remove("ALL")
59
for user in user_list:
63
invalid_users.append(user)
67
class LandscapeSetupConfiguration(BrokerConfiguration):
69
unsaved_options = ("no_start", "disable", "silent", "ok_no_register",
72
def _load_external_options(self):
73
"""Handle the --import parameter.
75
Imported options behave as if they were passed in the
76
command line, with precedence being given to real command
83
if "://" in self.import_from:
84
# If it's from a URL, download it now.
86
os.environ["http_proxy"] = self.http_proxy
88
os.environ["https_proxy"] = self.https_proxy
89
content = self.fetch_import_url(self.import_from)
90
parser = self._get_config_object(
91
alternative_config=StringIO(content))
92
elif not os.path.isfile(self.import_from):
93
raise ImportOptionError("File %s doesn't exist." %
97
parser = self._get_config_object(
98
alternative_config=self.import_from)
100
raise ImportOptionError(
101
"Couldn't read configuration from %s." %
103
except Exception, error:
104
raise ImportOptionError(str(error))
106
# But real command line options have precedence.
108
if parser and self.config_section in parser:
109
options = parser[self.config_section]
111
raise ImportOptionError("Nothing to import at %s." %
113
options.update(self._command_line_options)
114
self._command_line_options = options
116
def fetch_import_url(self, url):
117
"""Handle fetching of URLs passed to --url."""
119
print_text("Fetching configuration from %s..." % url)
123
except FetchError, error:
124
error_message = str(error)
125
if error_message is not None:
126
raise ImportOptionError(
127
"Couldn't download configuration from %s: %s" %
128
(url, error_message))
131
def make_parser(self):
133
Specialize the parser, adding configure-specific options.
135
parser = super(LandscapeSetupConfiguration, self).make_parser()
137
parser.add_option("--import", dest="import_from",
138
metavar="FILENAME_OR_URL",
139
help="Filename or URL to import configuration from. "
140
"Imported options behave as if they were "
141
"passed in the command line, with precedence "
142
"being given to real command line options.")
143
parser.add_option("--script-users", metavar="USERS",
144
help="A comma-separated list of users to allow "
145
"scripts to run. To allow scripts to be run "
146
"by any user, enter: ALL")
147
parser.add_option("--include-manager-plugins", metavar="PLUGINS",
149
help="A comma-separated list of manager plugins to "
151
parser.add_option("-n", "--no-start", action="store_true",
152
help="Don't start the client automatically.")
153
parser.add_option("--ok-no-register", action="store_true",
154
help="Return exit code 0 instead of 2 if the client "
155
"can't be registered.")
156
parser.add_option("--silent", action="store_true", default=False,
157
help="Run without manual interaction.")
158
parser.add_option("--disable", action="store_true", default=False,
159
help="Stop running clients and disable start at "
161
parser.add_option("--init", action="store_true", default=False,
162
help="Set up the client directories structure "
167
class LandscapeSetupScript(object):
169
An interactive procedure which manages the prompting and temporary storage
170
of configuration parameters.
172
Various attributes on this object will be set on C{config} after L{run} is
175
@ivar config: The L{BrokerConfiguration} object to read and set values from
179
def __init__(self, config):
182
def show_help(self, text):
183
lines = text.strip().splitlines()
184
print_text("\n" + "".join([line.strip() + "\n" for line in lines]))
186
def prompt_get_input(self, msg, required):
187
"""Prompt the user on the terminal for a value
189
@param msg: Message to prompt user with
190
@param required: True if value must be entered
193
value = raw_input(msg)
198
self.show_help("This option is required to configure Landscape.")
200
def prompt(self, option, msg, required=False):
201
"""Prompt the user on the terminal for a value.
203
@param option: The attribute of C{self.config} that contains the
204
default and which the value will be assigned to.
205
@param msg: The message to prompt the user with (via C{raw_input}).
206
@param required: If True, the user will be required to enter a value
209
default = getattr(self.config, option, None)
211
msg += " [%s]: " % default
214
required = required and not (bool(default))
215
result = self.prompt_get_input(msg, required)
217
setattr(self.config, option, result)
219
def password_prompt(self, option, msg, required=False):
220
"""Prompt the user on the terminal for a password and mask the value.
222
This also prompts the user twice and errors if both values don't match.
224
@param option: The attribute of C{self.config} that contains the
225
default and which the value will be assigned to.
226
@param msg: The message to prompt the user with (via C{raw_input}).
227
@param required: If True, the user will be required to enter a value
230
default = getattr(self.config, option, None)
233
value = getpass.getpass(msg)
235
value2 = getpass.getpass("Please confirm: ")
238
self.show_help("Keys must match.")
240
setattr(self.config, option, value)
242
elif default or not required:
245
self.show_help("This option is required to configure "
248
def prompt_yes_no(self, message, default=True):
250
default_msg = " [Y/n]"
252
default_msg = " [y/N]"
254
value = raw_input(message + default_msg).lower()
256
if value.startswith("n"):
258
if value.startswith("y"):
260
self.show_help("Invalid input.")
264
def query_computer_title(self):
265
if "computer_title" in self.config.get_command_line_options():
270
The computer title you provide will be used to represent this
271
computer in the Landscape user interface. It's important to use
272
a title that will allow the system to be easily recognized when
273
it appears on the pending computers page.
276
self.prompt("computer_title", "This computer's title", True)
278
def query_account_name(self):
279
if "account_name" in self.config.get_command_line_options():
284
You must now specify the name of the Landscape account you
285
want to register this computer with. Your account name is shown
286
under 'Account name' at https://landscape.canonical.com .
289
self.prompt("account_name", "Account name", True)
291
def query_registration_key(self):
292
command_line_options = self.config.get_command_line_options()
293
if "registration_key" in command_line_options:
298
A registration key may be associated with your Landscape
299
account to prevent unauthorized registration attempts. This
300
is not your personal login password. It is optional, and unless
301
explicitly set on the server, it may be skipped here.
303
If you don't remember the registration key you can find it
304
at https://landscape.canonical.com/account/%s
305
""" % self.config.account_name)
307
self.password_prompt("registration_key",
308
"Account registration key")
310
def query_proxies(self):
311
options = self.config.get_command_line_options()
312
if "http_proxy" in options and "https_proxy" in options:
317
The Landscape client communicates with the server over HTTP and
318
HTTPS. If your network requires you to use a proxy to access HTTP
319
and/or HTTPS web sites, please provide the address of these
320
proxies now. If you don't use a proxy, leave these fields empty.
323
if not "http_proxy" in options:
324
self.prompt("http_proxy", "HTTP proxy URL")
325
if not "https_proxy" in options:
326
self.prompt("https_proxy", "HTTPS proxy URL")
328
def query_script_plugin(self):
329
options = self.config.get_command_line_options()
330
if "include_manager_plugins" in options and "script_users" in options:
331
invalid_users = get_invalid_users(options["script_users"])
333
raise ConfigurationError("Unknown system users: %s" %
334
", ".join(invalid_users))
338
Landscape has a feature which enables administrators to run
339
arbitrary scripts on machines under their control. By default this
340
feature is disabled in the client, disallowing any arbitrary script
341
execution. If enabled, the set of users that scripts may run as is
344
msg = "Enable script execution?"
346
p.strip() for p in self.config.include_manager_plugins.split(",")]
347
if included_plugins == [""]:
348
included_plugins = []
349
default = "ScriptExecution" in included_plugins
350
if self.prompt_yes_no(msg, default=default):
351
if "ScriptExecution" not in included_plugins:
352
included_plugins.append("ScriptExecution")
355
By default, scripts are restricted to the 'landscape' and
356
'nobody' users. Please enter a comma-delimited list of users
357
that scripts will be restricted to. To allow scripts to be run
358
by any user, enter "ALL".
361
self.prompt("script_users", "Script users")
362
invalid_users = get_invalid_users(
363
self.config.script_users)
364
if not invalid_users:
367
self.show_help("Unknown system users: %s" %
368
",".join(invalid_users))
369
self.config.script_users = None
371
if "ScriptExecution" in included_plugins:
372
included_plugins.remove("ScriptExecution")
373
self.config.include_manager_plugins = ", ".join(included_plugins)
375
def query_access_group(self):
376
"""Query access group from the user."""
377
options = self.config.get_command_line_options()
378
if "access_group" in options:
379
return # an access group is already provided, don't ask for one
381
self.show_help("You may provide an access group for this computer "
383
self.prompt("access_group", "Access group", False)
385
def _get_invalid_tags(self, tagnames):
387
Splits a string on , and checks the validity of each tag, returns any
392
tags = [tag.strip() for tag in tagnames.split(",")]
393
invalid_tags = [tag for tag in tags if not is_valid_tag(tag)]
396
def query_tags(self):
397
"""Query tags from the user."""
398
options = self.config.get_command_line_options()
399
if "tags" in options:
400
invalid_tags = self._get_invalid_tags(options["tags"])
402
raise ConfigurationError("Invalid tags: %s" %
403
", ".join(invalid_tags))
406
self.show_help("You may provide tags for this computer e.g. "
409
self.prompt("tags", "Tags", False)
410
if self._get_invalid_tags(self.config.tags):
411
self.show_help("Tag names may only contain alphanumeric "
413
self.config.tags = None # Reset for the next prompt
417
def show_header(self):
420
This script will interactively set up the Landscape client. It will
421
ask you a few questions about this computer and your Landscape
422
account, and will submit that information to the Landscape server.
423
After this computer is registered it will need to be approved by an
424
account administrator on the pending computers page.
426
Please see https://landscape.canonical.com for more information.
430
"""Kick off the interactive process which prompts the user for data.
432
Data will be saved to C{self.config}.
435
self.query_computer_title()
436
self.query_account_name()
437
self.query_registration_key()
439
self.query_script_plugin()
440
self.query_access_group()
444
def setup_init_script_and_start_client():
445
"Configure the init script to start the client on boot."
446
# XXX This function is misnamed; it doesn't start the client.
447
sysvconfig = SysVConfig()
448
sysvconfig.set_start_on_boot(True)
451
def stop_client_and_disable_init_script():
453
Stop landscape-client and change configuration to prevent starting
454
landscape-client on boot.
456
sysvconfig = SysVConfig()
457
sysvconfig.stop_landscape()
458
sysvconfig.set_start_on_boot(False)
461
def setup_http_proxy(config):
463
If a http_proxy and a https_proxy value are not set then copy the values,
464
if any, from the environment variables L{http_proxy} and L{https_proxy}.
466
if config.http_proxy is None and os.environ.get("http_proxy"):
467
config.http_proxy = os.environ["http_proxy"]
468
if config.https_proxy is None and os.environ.get("https_proxy"):
469
config.https_proxy = os.environ["https_proxy"]
472
def check_account_name_and_password(config):
474
Ensure that silent configurations which plan to start landscape-client are
475
have both an account_name and computer title.
477
if config.silent and not config.no_start:
478
if not (config.get("account_name") and config.get("computer_title")):
479
raise ConfigurationError("An account name and computer title are "
483
def check_script_users(config):
485
If the configuration allows for script execution ensure that the configured
486
users are valid for that purpose.
488
if config.get("script_users"):
489
invalid_users = get_invalid_users(config.get("script_users"))
491
raise ConfigurationError("Unknown system users: %s" %
492
", ".join(invalid_users))
493
if not config.include_manager_plugins:
494
config.include_manager_plugins = "ScriptExecution"
497
def decode_base64_ssl_public_certificate(config):
499
Decode base64 encoded SSL certificate and push that back into place in the
502
# WARNING: ssl_public_certificate is misnamed, it's not the key of the
503
# certificate, but the actual certificate itself.
504
if config.ssl_public_key and config.ssl_public_key.startswith("base64:"):
505
decoded_cert = base64.decodestring(config.ssl_public_key[7:])
506
config.ssl_public_key = store_public_key_data(
507
config, decoded_cert)
512
Perform steps to ensure that landscape-client is correctly configured
513
before we attempt to register it with a landscape server.
515
If we are not configured to be silent then interrogate the user to provide
516
necessary details for registration.
518
bootstrap_tree(config)
520
sysvconfig = SysVConfig()
521
if not config.no_start:
523
setup_init_script_and_start_client()
524
elif not sysvconfig.is_configured_to_run():
525
answer = raw_input("\nThe Landscape client must be started "
526
"on boot to operate correctly.\n\n"
527
"Start Landscape client on boot? (Y/n): ")
528
if not answer.upper().startswith("N"):
529
setup_init_script_and_start_client()
531
sys.exit("Aborting Landscape configuration")
533
setup_http_proxy(config)
534
check_account_name_and_password(config)
536
check_script_users(config)
538
script = LandscapeSetupScript(config)
540
decode_base64_ssl_public_certificate(config)
542
# Restart the client to ensure that it's using the new configuration.
543
if not config.no_start:
545
sysvconfig.restart_landscape()
547
print_text("Couldn't restart the Landscape client.", error=True)
548
print_text("This machine will be registered with the provided "
549
"details when the client runs.", error=True)
551
if config.ok_no_register:
556
def bootstrap_tree(config):
557
"""Create the client directories tree."""
559
BootstrapDirectory("$data_path", "landscape", "root", 0755),
560
BootstrapDirectory("$annotations_path", "landscape", "landscape",
562
BootstrapList(bootstrap_list).bootstrap(
563
data_path=config.data_path, annotations_path=config.annotations_path)
566
def store_public_key_data(config, certificate_data):
568
Write out the data from the SSL certificate provided to us, either from a
569
bootstrap.conf file, or from EC2-style user-data.
571
@param config: The L{BrokerConfiguration} object in use.
572
@param certificate_data: a string of data that represents the contents of
573
the file to be written.
574
@return the L{BrokerConfiguration} object that was passed in, updated to
575
reflect the path of the ssl_public_key file.
577
key_filename = os.path.join(
579
os.path.basename(config.get_config_filename() + ".ssl_public_key"))
580
print_text("Writing SSL CA certificate to %s..." % key_filename)
581
key_file = open(key_filename, "w")
582
key_file.write(certificate_data)
587
def register(config, on_message=print_text, on_error=sys.exit, reactor=None,
589
"""Instruct the Landscape Broker to register the client.
591
The broker will be instructed to reload its configuration and then to
592
attempt a registration.
594
@param reactor: The reactor to use. Please only pass reactor when you
595
have totally mangled everything with mocker. Otherwise bad things
597
@param max_retries: The number of times to retry connecting to the
598
landscape client service. The delay between retries is calculated
599
by Twisted and increases geometrically. The default of 14 results in
600
a total wait time of about 70 seconds.
607
0.05 * (1 - 1.62 ** 14) / (1 - 1.62) = 69 seconds
610
reactor = LandscapeReactor()
614
if not config.ok_no_register:
616
if error is not None:
617
exit_with_error.append(error)
618
connector.disconnect()
622
on_message("Invalid account name or "
623
"registration key.", error=True)
627
on_message("System successfully registered.")
629
def exchange_failure():
630
on_message("We were unable to contact the server. "
631
"Your internet connection may be down. "
632
"The landscape client will continue to try and contact "
633
"the server periodically.",
637
def handle_registration_errors(failure):
638
# We'll get invalid credentials through the signal.
639
failure.trap(InvalidCredentialsError, MethodCallError)
640
connector.disconnect()
642
def catch_all(failure):
643
on_message(failure.getTraceback(), error=True)
644
on_message("Unknown error occurred.", error=True)
647
on_message("Please wait... ", "")
651
def got_connection(remote):
652
handlers = {"registration-done": success,
653
"registration-failed": failure,
654
"exchange-failed": exchange_failure}
656
remote.call_on_event(handlers),
657
remote.register().addErrback(handle_registration_errors)]
658
# We consume errors here to ignore errors after the first one.
659
# catch_all will be called for the very first deferred that fails.
660
results = gather_results(deferreds, consume_errors=True)
661
results.addErrback(catch_all)
662
results.addCallback(stop)
664
def got_error(failure):
665
on_message("There was an error communicating with the Landscape"
666
" client.", error=True)
667
on_message("This machine will be registered with the provided "
668
"details when the client runs.", error=True)
671
connector = RemoteBrokerConnector(reactor, config)
672
result = connector.connect(max_retries=max_retries, quiet=True)
673
result.addCallback(got_connection)
674
result.addErrback(got_error)
679
on_error(exit_with_error[0])
685
config = LandscapeSetupConfiguration()
688
except ImportOptionError, error:
689
print_text(str(error), error=True)
693
sys.exit("landscape-config must be run as root.")
696
bootstrap_tree(config)
699
# Disable startup on boot and stop the client, if one is running.
701
stop_client_and_disable_init_script()
704
# Setup client configuration.
709
sys.exit("Aborting Landscape configuration")
711
# Attempt to register the client.
715
answer = raw_input("\nRequest a new registration for "
716
"this computer now? (Y/n): ")
717
if not answer.upper().startswith("N"):