blob: ddb1930fa17d7a2cd0176e5a35f05b12b8ce8347 [file] [log] [blame]
#!/usr/bin/env python3
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# There's a bug in asteroid with Python 3.9's NamedTuple being
# recognized for the dynamically generated class that it is. Should be fixed
# with the next release, but 'til then...
#pylint:disable=inherit-non-class
from __future__ import print_function
"""
This script is meant as a drop-in replacement for the old _postinstall.pl Perl script.
It does, however, offer several more command-line flags not present in the original, to aid in
testing.
-a, --automatic If there are questions in the config file which do not have answers,
the script will look to the defaults for the answer. If the answer is
not in the defaults the script will exit.
--cfile [FILE] An input config file used to ask and answer questions.
--debug Enables verbose logging output.
--defaults [FILE] Writes out a configuration file with defaults which can be used as
input. If no FILE is given, writes to stdout.
-n, --no-root Enable running as a non-root user (may cause failure).
-r DIR, --root-directory DIR Set the directory to be treated as the system's root directory (e.g.
for testing). Default: /
-u USER, --ops-user USER Specify a username to own Traffic Ops files and processes.
Default: trafops
-g GROUP, --ops-group GROUP Specify the group to own Traffic Ops files and processes.
Default: trafops
--no-restart-to Skip restarting Traffic Ops after configuration and database changes
are applied.
--no-database Skip all database operations.
>>> [c for c in [[a for a in b if not a.config_var] for b in DEFAULTS.values()] if c]
[]
"""
import argparse
import base64
import errno
import getpass
import grp
import hashlib
import json
import logging
import os
import pwd
import random
import re
import shutil
import stat
import string
import subprocess
import sys
from collections import namedtuple
from struct import unpack, pack
# Paths for output configuration files
DATABASE_CONF_FILE = "/opt/traffic_ops/app/conf/production/database.conf"
DB_CONF_FILE = "/opt/traffic_ops/app/db/dbconf.yml"
TV_DATABASE_CONF_FILE = "/opt/traffic_ops/app/conf/production/tv.conf"
TV_DB_CONF_FILE = "/opt/traffic_ops/app/db/trafficvault/dbconf.yml"
CDN_CONF_FILE = "/opt/traffic_ops/app/conf/cdn.conf"
LDAP_CONF_FILE = "/opt/traffic_ops/app/conf/ldap.conf"
USERS_CONF_FILE = "/opt/traffic_ops/install/data/json/users.json"
PROFILES_CONF_FILE = "/opt/traffic_ops/install/data/profiles/"
OPENSSL_CONF_FILE = "/opt/traffic_ops/install/data/json/openssl_configuration.json"
PARAM_CONF_FILE = "/opt/traffic_ops/install/data/json/profiles.json"
TRAFFIC_VAULT_AES_KEY_FILE = "/opt/traffic_ops/app/conf/aes.key"
POST_INSTALL_CFG = "/opt/traffic_ops/install/data/json/post_install.json"
# Log file for the installer
# TODO: determine if logging to a file should be directly supported.
# LOG_FILE = "/var/log/traffic_ops/postinstall.log"
# Log file for CPAN output
# TODO: The Perl used to "rotate" this file on every run, for some reason. Should we?
# CPAN_LOG_FILE = "/var/log/traffic_ops/cpan.log"
# Configuration file output with answers which can be used as input to postinstall
# TODO: Perl used to always write its defaults out to this file when requested.
# Python, instead, outputs to stdout. This is breaking, but more flexible. Change it?
# OUTPUT_CONFIG_FILE = "/opt/traffic_ops/install/bin/configuration_file.json"
if sys.version_info.major >= 3:
# Accepting a string for json.dump()'s `indent` keyword argument is a Python 3 feature
indent = "\t" # type: str
else:
indent = 4 # type: int
str = unicode # type: type[unicode]
class Question(object):
"""
Question represents a single question to be asked of the user, to determine a configuration
value.
>>> Question("question", "answer", "var")
Question(question='question', default='answer', config_var='var', hidden=False)
"""
def __init__(self, question, default, config_var, hidden = False): # type: (str, str, str, bool) -> None
self.question = question
self.default = default
self.config_var = config_var
self.hidden = hidden
def __str__(self): # type: () -> str
if self.default:
return "{question} [{default}]: ".format(question=self.question, default=self.default)
return "{question}: ".format(question=self.question)
def __repr__(self): # type: () -> str
qstn = self.question
ans = self.default
cfgvr = self.config_var
hddn = self.hidden
return "Question(question='{qstn}', default='{ans}', config_var='{cfgvr}', hidden={hddn})".format(qstn=qstn, ans=ans, cfgvr=cfgvr, hddn=hddn)
def ask(self): # type: () -> str
"""
Asks the user the Question interactively.
If 'hidden' is true, output will not be echoed.
"""
if self.hidden:
while True:
passwd = getpass.getpass(str(self))
if not passwd:
continue
if passwd == getpass.getpass("Re-Enter {question}: ".format(question=self.question)):
return passwd
print("Error: passwords do not match, try again")
ipt = input(self)
return ipt if ipt else self.default
def to_json(self): # type: () -> str
"""
Converts a question to JSON encoding.
>>> Question("Do the thing?", "yes", "cfg_var", True).to_json()
'{"Do the thing?": "yes", "config_var": "cfg_var", "hidden": true}'
>>> Question("Do the other thing?", "no", "other cfg_var").to_json()
'{"Do the other thing?": "no", "config_var": "other cfg_var"}'
"""
qstn = self.question
ans = self.default
cfgvr = self.config_var
if self.hidden:
return '{{"{qstn}": "{ans}", "config_var": "{cfgvr}", "hidden": true}}'.format(qstn=qstn, ans=ans, cfgvr=cfgvr)
return '{{"{qstn}": "{ans}", "config_var": "{cfgvr}"}}'.format(qstn=qstn, ans=ans, cfgvr=cfgvr)
def serialize(self): # type: () -> object
"""Returns a serializable dictionary, suitable for converting to JSON."""
return {self.question: self.default, "config_var": self.config_var, "hidden": self.hidden}
class User(namedtuple('User', ['username', 'password'])):
"""Users represents a user that will be inserted into the Traffic Ops database.
Attributes
----------
self.username: str
The user's username.
self.password: str
The user's password - IN PLAINTEXT.
"""
class SSLConfig:
"""SSLConfig bundles the options for generating new (self-signed) SSL certificates"""
def __init__(self, gen_cert, cfg_map): # type: (bool, dict[str, str]) -> None
self.gen_cert = gen_cert
self.rsa_password = cfg_map["rsaPassword"]
self.params = "/C={country}/ST={state}/L={locality}/O={company}/OU={org_unit}/CN={common_name}/"
self.params = self.params.format(**cfg_map)
class CDNConfig(namedtuple('CDNConfig', ['gen_secret', 'num_secrets', 'port', 'num_workers', 'url', 'ldap_conf_location'])):
"""CDNConfig holds all of the options needed to format a cdn.conf file."""
def generate_secret(self, conf):
"""
Generates new secrets - if configured to do so - and adds them to the passed cdn.conf
configuration.
"""
if not self.gen_secret:
return
if isinstance(conf, dict) and "secrets" in conf and isinstance(conf["secrets"], list):
logging.debug("Secrets found in cdn.conf file")
else:
conf["secrets"] = []
logging.debug("No secrets found in cdn.conf file")
conf["secrets"].insert(0, random_word())
if self.num_secrets and len(conf["secrets"]) > self.num_secrets:
conf["secrets"] = conf["secrets"][:self.num_secrets - 1]
def insert_url(self, conf):
"""
Inserts the configured URL - if it is not an empty string - into the passed cdn.conf
configuration, in to.base_url.
"""
if not self.url:
return
if "to" not in conf or not isinstance(conf["to"], dict):
conf["to"] = {}
conf["to"]["base_url"] = self.url
# The default question/answer set
DEFAULTS = {
DATABASE_CONF_FILE: [
Question("Database type", "Pg", "type"),
Question("Database name", "traffic_ops", "dbname"),
Question("Database server hostname IP or FQDN", "localhost", "hostname"),
Question("Database port number", "5432", "port"),
Question("Traffic Ops database user", "traffic_ops", "user"),
Question("Password for Traffic Ops database user", "", "password", hidden=True)
],
TV_DATABASE_CONF_FILE: [
Question("Traffic Vault Database type", "Pg", "type"),
Question("Traffic Vault Database name", "traffic_vault", "dbname"),
Question("Traffic Vault Database server hostname IP or FQDN", "localhost", "hostname"),
Question("Traffic Vault Database port number", "5432", "port"),
Question("Traffic Vault database user", "traffic_vault", "user"),
Question("Password for Traffic Vault database user", "", "password", hidden=True)
],
CDN_CONF_FILE: [
Question("Generate a new secret?", "yes", "genSecret"),
Question("Number of secrets to keep?", "1", "keepSecrets"),
Question("Port to serve on?", "443", "port"),
Question("Number of workers?", "12", "workers"),
Question("Traffic Ops url?", "http://localhost:3000", "base_url"),
Question("ldap.conf location?", "/opt/traffic_ops/app/conf/ldap.conf", "ldap_conf_location"),
],
LDAP_CONF_FILE:[
Question("Do you want to set up LDAP?", "no", "setupLdap"),
Question("LDAP server hostname", "", "host"),
Question("LDAP Admin DN", "", "admin_dn"),
Question("LDAP Admin Password", "", "admin_pass", hidden=True),
Question("LDAP Search Base", "", "search_base"),
Question("LDAP Search Query", "", "search_query"),
Question("LDAP Skip TLS verify", "", "insecure"),
Question("LDAP Timeout Seconds", "", "ldap_timeout_secs")
],
USERS_CONF_FILE: [
Question("Administration username for Traffic Ops", "admin", "tmAdminUser"),
Question("Password for the admin user", "", "tmAdminPw", hidden=True)
],
PROFILES_CONF_FILE: [
Question("Add custom profiles?", "no", "custom_profiles")
],
OPENSSL_CONF_FILE: [
Question("Do you want to generate a certificate?", "yes", "genCert"),
Question("Country Name (2 letter code)", "", "country"),
Question("State or Province Name (full name)", "", "state"),
Question("Locality Name (eg, city)", "", "locality"),
Question("Organization Name (eg, company)", "", "company"),
Question("Organizational Unit Name (eg, section)", "", "org_unit"),
Question("Common Name (eg, your name or your server's hostname)", "", "common_name"),
Question("RSA Passphrase", "CHANGEME!!", "rsaPassword", hidden=True)
],
PARAM_CONF_FILE: [
Question("Traffic Ops url", "https://localhost", "tm.url"),
Question("Human-readable CDN Name. (No whitespace, please)", "kabletown_cdn", "cdn_name"),
Question(
"DNS sub-domain for which your CDN is authoritative",
"cdn1.kabletown.net",
"dns_subdomain"
)
]
}
class ConfigEncoder(json.JSONEncoder):
"""
ConfigEncoder encodes a dictionary of filenames to configuration question lists as JSON.
>>> ConfigEncoder().encode({'/test/file':[Question('question', 'default', 'cfg_var', True)]})
'{"/test/file": [{"question": "default", "config_var": "cfg_var", "hidden": true}]}'
"""
# The linter is just wrong about this
def default(self, o): # type: (object) -> object
"""
Returns a serializable representation of 'o'.
Specifically, it does this by attempting to convert a dictionary of filenames to Question
lists to a dictionary of filenames to lists of dictionaries of strings to strings, falling
back on default encoding if the proper typing is not found.
"""
if isinstance(o, Question):
return o.serialize()
return json.JSONEncoder.default(self, o)
def get_config(questions, fname, automatic = False): # type: (list[Question], str, bool) -> dict[str, str]
"""Asks all provided questions, or uses their defaults in automatic mode"""
logging.info("===========%s===========", fname)
config = {}
for question in questions:
answer = question.default if automatic else question.ask()
config[question.config_var] = answer
return config
def generate_db_conf(qstns, fname, automatic, root): # (list[Question], str, bool, str) -> dict
"""
Generates the database.conf file and returns a map of its configuration.
Also writes the configuration file to the file 'fname' under the directory 'root'.
"""
db_conf = get_config(qstns, fname, automatic)
typ = db_conf.get("type", "UNKNOWN")
hostname = db_conf.get("hostname", "UNKNOWN")
port = db_conf.get("port", "UNKNOWN")
db_conf["description"] = "{typ} database on {hostname}:{port}".format(typ=typ, hostname=hostname, port=port)
path = os.path.join(root, fname.lstrip('/'))
with open(path, 'w+') as conf_file:
json.dump(db_conf, conf_file, indent=indent)
print(file=conf_file)
logging.info("Database configuration has been saved")
return db_conf
def generate_todb_conf(fname, root, conf): # (str, str, dict)
"""
Generates the dbconf.yml file.
Also writes the configuration file to the file 'fname' under the directory 'root'.
"""
driver = "postgres"
if "type" not in conf:
logging.warning("Driver type not found in todb config; using 'postgres'")
else:
driver = "postgres" if conf["type"] == "Pg" else conf["type"]
path = os.path.join(root, fname.lstrip('/'))
hostname = conf.get('hostname', 'UNKNOWN')
port = conf.get('port', 'UNKNOWN')
user = conf.get('user', 'UNKNOWN')
password = conf.get('password', 'UNKNOWN')
dbname = conf.get('dbname', 'UNKNOWN')
open_line = "host={hostname} port={port} user={user} password={password} dbname={dbname}".format(hostname=hostname, port=port, user=user, password=password, dbname=dbname)
with open(path, 'w+') as conf_file:
print("production:", file=conf_file)
print(" driver:", driver, file=conf_file)
print(" open: {open_line} sslmode=disable".format(open_line=open_line), file=conf_file)
def generate_ldap_conf(questions, fname, automatic, root): # type: (list[Question], str, bool, str) -> None
"""
Generates the ldap.conf file by asking the questions or using default answers in auto mode.
Also writes the configuration to the file 'fname' under the directory 'root'
"""
use_ldap_question = [q for q in questions if q.question == "Do you want to set up LDAP?"]
if not use_ldap_question:
logging.warning("Couldn't find question asking if LDAP should be set up, using default: no")
return
use_ldap = use_ldap_question[0].default if automatic else use_ldap_question[0].ask()
if use_ldap.lower() not in {'y', 'yes'}:
logging.info("Not setting up ldap")
return
ldap_conf = get_config([q for q in questions if q is not use_ldap_question[0]], fname, automatic)
keys = (
'host',
'admin_dn',
'admin_pass',
'search_base',
'search_query',
'insecure',
'ldap_timeout_secs'
)
for key in keys:
if key not in ldap_conf:
raise ValueError("{key} is a required key in {fname}".format(key=key, fname=fname))
keys_converted = {'password': 'admin_pass', 'hostname': 'host'}
for deprecated, key in keys_converted.items():
if deprecated in ldap_conf and ldap_conf[key] == '':
ldap_conf[key] = ldap_conf[deprecated]
if not re.match(r"^\S+:\d+$", ldap_conf["host"]):
raise ValueError("host in {fname} must be of form 'hostname:port'".format(fname=fname))
path = os.path.join(root, fname.lstrip('/'))
try:
os.makedirs(os.path.dirname(path))
except OSError as e:
if e.errno == errno.EEXIST:
pass
with open(path, 'w+') as conf_file:
json.dump(ldap_conf, conf_file, indent=indent)
print(file=conf_file)
def hash_pass(passwd): # type: (str) -> str
"""
Generates a Scrypt-based hash of the given password in a Perl-compatible format.
It's hard-coded - like the Perl - to use 64 random bytes for the salt, n=16384,
r=8, p=1 and dklen=64.
"""
n = 2 ** 14
r_val = 8
p_val = 1
dklen = 64
salt = os.urandom(dklen)
if sys.version_info.major >= 3 and hasattr(hashlib, 'scrypt'): # Python 2.7 and CentOS 7's Python 3.6 do not include hashlib.scrypt()
hashed = hashlib.scrypt(passwd.encode(), salt=salt, n=n, r=r_val, p=p_val, dklen=dklen)
else:
hashed = Scrypt(password=passwd.encode(), salt=salt, cost_factor=n, block_size_factor=r_val, parallelization_factor=p_val, key_length=dklen).derive()
hashed_b64 = base64.standard_b64encode(hashed).decode()
salt_b64 = base64.standard_b64encode(salt).decode()
return "SCRYPT:{n}:{r_val}:{p_val}:{salt_b64}:{hashed_b64}".format(n=n, r_val=r_val, p_val=p_val, salt_b64=salt_b64, hashed_b64=hashed_b64)
class Scrypt:
def __init__(self, password, salt, cost_factor, block_size_factor, parallelization_factor, key_length): # type: (bytes, bytes, int, int, int, int) -> None
self.password = password # type: bytes
self.salt = salt # type: bytes
self.cost_factor = cost_factor # type: int
self.block_size_factor = block_size_factor # type: int
self.parallelization_factor = parallelization_factor # type: int
self.key_length = key_length
self.block_unit = 32 * self.block_size_factor # 1 block unit = 32 * block_size_factor 32-bit ints
def derive(self): # type: () -> bytes
salt_length = 2 ** 7 * self.block_size_factor * self.parallelization_factor # type: int
pack_format = '<' + 'L' * int(salt_length / 4) # `<` means `little-endian` and `L` means `unsigned long`
salt = hashlib.pbkdf2_hmac('sha256', password=self.password, salt=self.salt, iterations=1, dklen=salt_length) # type: bytes
block = list(unpack(pack_format, salt)) # type: list[int]
block = self.ROMix(block)
salt = pack(pack_format, *block)
key = hashlib.pbkdf2_hmac('sha256', password=self.password, salt=salt, iterations=1, dklen=self.key_length) # type: bytes
return key
def ROMix(self, block): # type: (list[int]) -> list[int]
xored_block = [0] * len(block) # type: list[int]
variations = [list()] * self.cost_factor # type: list[list[int]]
variations[0] = block
index = 1
while index < self.cost_factor:
variations[index] = self.block_mix(variations[index - 1])
index += 1
block = self.block_mix(variations[-1])
for unused in variations:
variation_index = block[self.block_unit - 16] % self.cost_factor # type: int
variation = variations[variation_index]
for index, unused in enumerate(xored_block):
xored_block[index] = block[index] ^ variation[index]
block = self.block_mix(xored_block)
return block
def block_mix(self, previous_block): # type: (list[int]) -> list[int]
block = previous_block[:] # type: list[int]
X_length = 16 # X is the list of numbers within `block` that we mix
copy_index = self.block_unit - X_length
X = previous_block[copy_index:copy_index + X_length] # type: list[int]
octet_index = 0 # type: int
block_xor_index = 0
while octet_index < 2 * self.block_size_factor:
for index, unused in enumerate(X):
X[index] ^= previous_block[block_xor_index + index]
block_xor_index += X_length
self.salsa20(X)
block_offset = (int(octet_index / 2) + octet_index % 2 * self.block_size_factor) * X_length
block[block_offset:block_offset + X_length] = X
octet_index += 1
return block
def salsa20(self, block): # type: (list[int]) -> None
X = block[:] # make a copy (list.copy() is Python 3-only)
for i in range(0, 4):
# These bit shifting operations could be condensed into a single line of list comprehensions,
# but there is a >3x performance benefit from writing it out explicitly.
bits = X[0] + X[12] & 0xffffffff
X[4] ^= bits << 7 | bits >> 32 - 7
bits = X[4] + X[0] & 0xffffffff
X[8] ^= bits << 9 | bits >> 32 - 9
bits = X[8] + X[4] & 0xffffffff
X[12] ^= bits << 13 | bits >> 32 - 13
bits = X[12] + X[8] & 0xffffffff
X[0] ^= bits << 18 | bits >> 32 - 18
bits = X[5] + X[1] & 0xffffffff
X[9] ^= bits << 7 | bits >> 32 - 7
bits = X[9] + X[5] & 0xffffffff
X[13] ^= bits << 9 | bits >> 32 - 9
bits = X[13] + X[9] & 0xffffffff
X[1] ^= bits << 13 | bits >> 32 - 13
bits = X[1] + X[13] & 0xffffffff
X[5] ^= bits << 18 | bits >> 32 - 18
bits = X[10] + X[6] & 0xffffffff
X[14] ^= bits << 7 | bits >> 32 - 7
bits = X[14] + X[10] & 0xffffffff
X[2] ^= bits << 9 | bits >> 32 - 9
bits = X[2] + X[14] & 0xffffffff
X[6] ^= bits << 13 | bits >> 32 - 13
bits = X[6] + X[2] & 0xffffffff
X[10] ^= bits << 18 | bits >> 32 - 18
bits = X[15] + X[11] & 0xffffffff
X[3] ^= bits << 7 | bits >> 32 - 7
bits = X[3] + X[15] & 0xffffffff
X[7] ^= bits << 9 | bits >> 32 - 9
bits = X[7] + X[3] & 0xffffffff
X[11] ^= bits << 13 | bits >> 32 - 13
bits = X[11] + X[7] & 0xffffffff
X[15] ^= bits << 18 | bits >> 32 - 18
bits = X[0] + X[3] & 0xffffffff
X[1] ^= bits << 7 | bits >> 32 - 7
bits = X[1] + X[0] & 0xffffffff
X[2] ^= bits << 9 | bits >> 32 - 9
bits = X[2] + X[1] & 0xffffffff
X[3] ^= bits << 13 | bits >> 32 - 13
bits = X[3] + X[2] & 0xffffffff
X[0] ^= bits << 18 | bits >> 32 - 18
bits = X[5] + X[4] & 0xffffffff
X[6] ^= bits << 7 | bits >> 32 - 7
bits = X[6] + X[5] & 0xffffffff
X[7] ^= bits << 9 | bits >> 32 - 9
bits = X[7] + X[6] & 0xffffffff
X[4] ^= bits << 13 | bits >> 32 - 13
bits = X[4] + X[7] & 0xffffffff
X[5] ^= bits << 18 | bits >> 32 - 18
bits = X[10] + X[9] & 0xffffffff
X[11] ^= bits << 7 | bits >> 32 - 7
bits = X[11] + X[10] & 0xffffffff
X[8] ^= bits << 9 | bits >> 32 - 9
bits = X[8] + X[11] & 0xffffffff
X[9] ^= bits << 13 | bits >> 32 - 13
bits = X[9] + X[8] & 0xffffffff
X[10] ^= bits << 18 | bits >> 32 - 18
bits = X[15] + X[14] & 0xffffffff
X[12] ^= bits << 7 | bits >> 32 - 7
bits = X[12] + X[15] & 0xffffffff
X[13] ^= bits << 9 | bits >> 32 - 9
bits = X[13] + X[12] & 0xffffffff
X[14] ^= bits << 13 | bits >> 32 - 13
bits = X[14] + X[13] & 0xffffffff
X[15] ^= bits << 18 | bits >> 32 - 18
for index in range(0, 16):
block[index] = block[index] + X[index] & 0xffffffff
def generate_users_conf(qstns, fname, auto, root): # type: (list[Question], str, bool, str) -> User
"""
Generates a users.json file from the given questions and returns a User containing the same
information.
"""
config = get_config(qstns, fname, auto)
if "tmAdminUser" not in config or "tmAdminPw" not in config:
raise ValueError("{fname} must include 'tmAdminUser' and 'tmAdminPw'".format(fname=fname))
hashed_pass = hash_pass(config["tmAdminPw"])
path = os.path.join(root, fname.lstrip('/'))
with open(path, 'w+') as conf_file:
json.dump({"username": config["tmAdminUser"], "password": hashed_pass}, conf_file, indent=indent)
print(file=conf_file)
return User(config["tmAdminUser"], config["tmAdminPw"])
def generate_profiles_dir(questions): # type: (list[Question]) -> None
"""
I truly have no idea what's going on here. This is what the Perl did, so I
copied it. It does nothing. Literally nothing.
"""
#pylint:disable=unused-variable
user_in = questions
#pylint:enable=unused-variable
def generate_openssl_conf(questions, fname, auto): # type: (list[Question], str, bool) -> SSLConfig
"""
Constructs an SSLConfig by asking the passed questions, or using their default answers if in
auto mode.
"""
cfg_map = get_config(questions, fname, auto)
if "genCert" not in cfg_map:
raise ValueError("missing 'genCert' key")
gen_cert = cfg_map["genCert"].lower() in {"y", "yes"}
return SSLConfig(gen_cert, cfg_map)
def generate_param_conf(qstns, fname, auto, root): # type: (list[Question], str, bool, str) -> dict
"""
Generates a profiles.json by asking the passed questions, or using their default answers in auto
mode.
Also writes the file to 'fname' in the directory 'root'.
"""
conf = get_config(qstns, fname, auto)
path = os.path.join(root, fname.lstrip('/'))
with open(path, 'w+') as conf_file:
json.dump(conf, conf_file, indent=indent)
print(file=conf_file)
return conf
def sanity_check_config(cfg, automatic): # type: (dict[str, list[Question]], bool) -> int
"""
Checks a user-input configuration file, and outputs the number of files in the
default question set that did not appear in the input.
:param cfg: The user's parsed input questions.
:param automatic: If :keyword:`True` all missing questions will use their default answers.
Otherwise, the user will be prompted for answers.
"""
diffs = 0
for fname, file in DEFAULTS.items():
if fname not in cfg:
logging.warning("File '%s' found in defaults but not config file", fname)
cfg[fname] = []
for default_value in file:
for config_value in cfg[fname]:
if default_value.config_var == config_value.config_var:
break
else:
question = default_value.question
answer = default_value.default
if not automatic:
logging.info("Prompting user for answer")
if default_value.hidden:
answer = default_value.ask()
elif default_value.hidden:
logging.info("Adding question '%s' with default answer", question)
else:
logging.info("Adding question '%s' with default answer %s", question, answer)
# The Perl here would ask questions, but those would just get asked later
# anyway, so I'm not sure why.
cfg[fname].append(Question(question, answer, default_value.config_var, default_value.hidden))
diffs += 1
return diffs
def unmarshal_config(dct): # type: (dict) -> dict[str, list[Question]]
"""
Reads in a raw parsed configuration file and returns the resulting configuration.
>>> unmarshal_config({"test": [{"Do the thing?": "yes", "config_var": "thing"}]})
{'test': [Question(question='Do the thing?', default='yes', config_var='thing', hidden=False)]}
>>> unmarshal_config({"test": [{"foo": "", "config_var": "bar", "hidden": True}]})
{'test': [Question(question='foo', default='', config_var='bar', hidden=True)]}
"""
ret = {}
for file, questions in dct.items():
if not isinstance(questions, list):
raise ValueError("file '{file}' has malformed questions".format(file=file))
qstns = []
for qstn in questions:
if not isinstance(qstn, dict):
raise ValueError("file '{file}' has a malformed question ({qstn})".format(file=file, qstn=qstn))
try:
question = next(key for key in qstn.keys() if key not in ("hidden", "config_var"))
except StopIteration:
raise ValueError("question in '{file}' has no question/answer properties ({qstn})".format(file=file, qstn=qstn))
answer = qstn[question]
if not isinstance(question, str) or not isinstance(answer, str):
errstr = "question in '{file}' has malformed question/answer property ({question}: {answer})".format(file=file, question=question, answer=answer)
raise ValueError(errstr)
del qstn[question]
hidden = False
if "hidden" in qstn:
hidden = bool(qstn["hidden"])
del qstn["hidden"]
if "config_var" not in qstn:
raise ValueError("question in '{file}' has no 'config_var' property".format(file=file))
cfg_var = qstn["config_var"]
if not isinstance(cfg_var, str):
raise ValueError("question in '{file}' has malformed 'config_var' property ({cfg_var})".format(file=file, cfg_var=cfg_var))
del qstn["config_var"]
if qstn:
logging.warning("Found unknown extra properties in question in '%s' (%r)", file, qstn.keys())
qstns.append(Question(question, answer, cfg_var, hidden=hidden))
ret[file] = qstns
return ret
def write_encryption_key(aes_key_location): # type: (str) -> None
"""
Creates an AES encryption key for the postgres traffic vault backend
:param aes_key_location: Denotes the location of the aes encryption key file
:returns: None
"""
args = (
"rand",
"-out",
aes_key_location,
"-base64",
"32"
)
if not exec_openssl("Generating an AES encryption key to {loc}".format(loc=aes_key_location), *args):
logging.debug("AES key generation failed")
raise OSError("failed to generate AES key")
def exec_openssl(description, *cmd_args): # type: (str, ...) -> bool
"""
Executes openssl with the supplied command-line arguments.
:param description: Describes the operation taking place for logging purposes.
:returns: Whether or not the execution succeeded, success being defined by an exit code of zero
"""
logging.info(description)
cmd = ("/usr/bin/openssl",) + cmd_args
while True:
proc = subprocess.Popen(
cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
universal_newlines=True,
)
proc.wait()
if proc.returncode == 0:
return True
output = proc.communicate()
logging.debug("openssl exec failed with code %s; stderr: %s", proc.returncode, output[1])
while True:
ans = input("{description} failed. Try again (y/n) [y]: ".format(description=description))
if not ans or ans.lower().startswith('n'):
return False
if ans.lower().startswith('y'):
break
def setup_certificates(conf, root, ops_user, ops_group): # type: (SSLConfig, str, str, str) -> int
"""
Generates self-signed SSL certificates from the given configuration.
:returns: For whatever reason this subroutine needs to dictate the return code of the script, so that's what it returns.
"""
if not conf.gen_cert:
logging.info("Not generating openssl certification")
return 0
if not os.path.isfile('/usr/bin/openssl') or not os.access('/usr/bin/openssl', os.X_OK):
logging.error("Unable to install SSL certificates as openssl is not installed")
cmd = os.path.join(root, "opt/traffic_ops/install/bin/generateCert")
logging.error("Install openssl and then run %s to install SSL certificates", cmd)
return 4
logging.info("Installing SSL Certificates")
logging.info("\n\tWe're now running a script to generate a self signed X509 SSL certificate")
logging.info("Postinstall SSL Certificate Creation")
# Perl logs this before actually generating a key. So we do too.
logging.info("The server key has been generated")
args = (
"genrsa",
"-des3",
"-out",
"server.key",
"-passout",
"pass:{rsa_password}".format(rsa_password=conf.rsa_password),
"1024"
)
if not exec_openssl("Generating an RSA Private Server Key", *args):
return 1
args = (
"req",
"-new",
"-key",
"server.key",
"-out",
"server.csr",
"-passin",
"pass:{rsa_password}".format(rsa_password=conf.rsa_password),
"-subj",
conf.params
)
if not exec_openssl("Creating a Certificate Signing Request (CSR)", *args):
return 1
logging.info("The Certificate Signing Request has been generated")
os.rename("server.key", "server.key.orig")
args = (
"rsa",
"-in",
"server.key.orig",
"-out",
"server.key",
"-passin",
"pass:{rsa_password}".format(rsa_password=conf.rsa_password)
)
if not exec_openssl("Removing the pass phrase from the server key", *args):
return 1
logging.info("The pass phrase has been removed from the server key")
args = (
"x509",
"-req",
"-days",
"365",
"-in",
"server.csr",
"-signkey",
"server.key",
"-out",
"server.crt"
)
if not exec_openssl("Generating a Self-signed certificate", *args):
return 1
logging.info("A server key and self signed certificate has been generated")
logging.info("Installing a server key and certificate")
keypath = os.path.join(root, 'etc/pki/tls/private/localhost.key')
shutil.copy("server.key", keypath)
os.chmod(keypath, stat.S_IRUSR | stat.S_IWUSR)
os.chown(keypath, pwd.getpwnam(ops_user).pw_uid, grp.getgrnam(ops_group).gr_gid)
logging.info("The private key has been installed")
logging.info("Installing self signed certificate")
certpath = os.path.join(root, 'etc/pki/tls/certs/localhost.crt')
shutil.copy("server.crt", certpath)
os.chmod(certpath, stat.S_IRUSR | stat.S_IWUSR)
os.chown(certpath, pwd.getpwnam(ops_user).pw_uid, grp.getgrnam(ops_group).gr_gid)
logging.info("Saving the self signed csr")
csrpath = os.path.join(root, 'etc/pki/tls/certs/localhost.csr')
shutil.copy("server.csr", csrpath)
os.chmod(csrpath, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH)
os.chown(csrpath, pwd.getpwnam(ops_user).pw_uid, grp.getgrnam(ops_group).gr_gid)
log_msg = """
The self signed certificate has now been installed.
You may obtain a certificate signed by a Certificate Authority using the
server.csr file saved in the current directory. Once you have obtained
a signed certificate, copy it to %s and
restart Traffic Ops."""
logging.info(log_msg, certpath)
cdn_conf_path = os.path.join(root, "opt/traffic_ops/app/conf/cdn.conf")
try:
with open(cdn_conf_path) as conf_file:
cdn_conf = json.load(conf_file)
except (OSError, ValueError) as e:
exception = OSError("reading {cdn_conf_path}: {e}".format(cdn_conf_path=cdn_conf_path, e=e))
exception.__cause__ = e
raise exception
if (
not isinstance(cdn_conf, dict) or
"hypnotoad" not in cdn_conf or
not isinstance(cdn_conf["hypnotoad"], dict)
):
logging.critical("Malformed %s; improper object and/or missing 'hypnotoad' key", cdn_conf_path)
return 1
hypnotoad = cdn_conf["hypnotoad"]
if (
"listen" not in hypnotoad or
not isinstance(hypnotoad["listen"], list) or
not hypnotoad["listen"] or
not isinstance(hypnotoad["listen"][0], str)
):
log_msg = """ The "listen" portion of %s is missing from %s
Please ensure it contains the same structure as the one originally installed"""
logging.error(log_msg, cdn_conf_path, cdn_conf_path)
return 1
listen = hypnotoad["listen"][0]
if "cert={certpath}".format(certpath=certpath) not in listen or "key={keypath}".format(keypath=keypath) not in listen:
log_msg = """ The "listen" portion of %s does not reference the same "cert=" and "key=" values as are created here.
Please modify %s to add the following as parameters:
?cert=/path/to/SSL/certificate&key=/path/to/SSL/key"""
logging.error(log_msg, cdn_conf_path, cdn_conf_path)
return 1
return 0
def random_word(length = 12): # type: (int) -> str
"""
Returns a randomly generated string 'length' characters long containing only word
characters ([a-zA-Z0-9_]).
"""
word_chars = string.ascii_letters + string.digits + '_'
return ''.join(random.choice(word_chars) for _ in range(length))
def generate_cdn_conf(questions, fname, automatic, root): # type: (list[Question], str, bool, str) -> bool
"""
Generates some properties of a cdn.conf file based on the passed questions.
This modifies or writes the file 'fname' under the directory 'root'.
:returns: A boolean value denoting whether or not a postgres traffic vault backend is configured.
"""
cdn_conf = get_config(questions, fname, automatic)
if "genSecret" not in cdn_conf:
raise ValueError("missing 'genSecret' config_var")
gen_secret = cdn_conf["genSecret"].lower() in {'y', 'yes'}
try:
num_secrets = int(cdn_conf["keepSecrets"])
except KeyError as e:
exception = ValueError("missing 'keepSecrets' config_var")
exception.__cause__ = e
raise exception
except ValueError as e:
exception = ValueError("invalid 'keepSecrets' config_var value: {e}".format(e=e))
exception.__cause__ = e
raise exception
try:
port = cdn_conf["port"] # type: str
except KeyError as e:
exception = ValueError("missing 'port' config_var")
exception.__cause__ = e
raise exception
except ValueError as e:
exception = ValueError("invalid 'port' config_var value: {e}".format(e=e))
exception.__cause__ = e
raise exception
try:
workers = int(cdn_conf["workers"])
except KeyError as e:
exception = ValueError("missing 'workers' config_var")
exception.__cause__ = e
raise exception
except ValueError as e:
exception = ValueError("invalid 'workers' config_var value: {e}".format(e=e))
exception.__cause__ = e
raise exception
try:
url = cdn_conf["base_url"]
except KeyError as e:
exception = ValueError("missing 'base_url' config_var")
exception.__cause__ = e
raise exception
try:
ldap_loc = cdn_conf["ldap_conf_location"]
except KeyError as e:
exception = ValueError("missing 'ldap_conf_location' config_var")
exception.__cause__ = e
raise exception
conf = CDNConfig(gen_secret, num_secrets, port, workers, url, ldap_loc)
path = os.path.join(root, fname.lstrip('/'))
existing_conf = {}
if os.path.isfile(path):
with open(path) as conf_file:
try:
existing_conf = json.load(conf_file)
except ValueError as e:
exception = ValueError("invalid existing cdn.config at {path}: {e}".format(path=path, e=e))
exception.__cause__ = e
raise exception
if not isinstance(existing_conf, dict):
logging.warning("Existing cdn.conf (at '%s') is not an object - overwriting", path)
existing_conf = {}
conf.generate_secret(existing_conf)
conf.insert_url(existing_conf)
if (
"traffic_ops_golang" not in existing_conf or
not isinstance(existing_conf["traffic_ops_golang"], dict)
):
existing_conf["traffic_ops_golang"] = {}
existing_conf["traffic_ops_golang"]["port"] = conf.port
err_log = os.path.join(root, "var/log/traffic_ops/error.log")
existing_conf["traffic_ops_golang"]["log_location_error"] = err_log
access_log = os.path.join(root, "var/log/traffic_ops/access.log")
existing_conf["traffic_ops_golang"]["log_location_event"] = access_log
traffic_vault_backend = "postgres"
tv_aes_key_location = os.path.join(root, TRAFFIC_VAULT_AES_KEY_FILE.lstrip('/'))
if "hypnotoad" not in existing_conf or not isinstance(existing_conf["hypnotoad"], dict):
existing_conf["hypnotoad"]["workers"] = conf.num_workers
with open(path, "w+") as conf_file:
json.dump(existing_conf, conf_file, indent=indent)
print(file=conf_file)
logging.info("CDN configuration has been saved")
try:
traffic_vault_backend = existing_conf["traffic_ops_golang"]["traffic_vault_backend"]
except KeyError as e:
logging.warning("no traffic vault backend configured, using default postgres")
if traffic_vault_backend == "postgres":
try:
tv_aes_key_location = existing_conf["traffic_ops_golang"]["traffic_vault_config"]["aes_key_location"]
except KeyError as e:
logging.warning("no traffic vault aes encryption key location specified, using default %s", TRAFFIC_VAULT_AES_KEY_FILE)
write_encryption_key(tv_aes_key_location)
return traffic_vault_backend == "postgres"
def db_connection_string(dbconf): # type: (dict) -> str
"""
Constructs a database connection string from the passed configuration object.
"""
user = dbconf["user"]
password = dbconf["password"]
db_name = "traffic_ops" if dbconf["type"] == "Pg" else dbconf["type"]
hostname = dbconf["hostname"]
port = dbconf["port"]
return "postgresql://{user}:{password}@{hostname}:{port}/{db_name}".format(user=user, password=password, hostname=hostname, port=port, db_name=db_name)
def exec_psql(conn_str, query, **args): # type: (str, str, dict) -> str
"""
Executes SQL queries by forking and exec-ing '/usr/bin/psql'.
:param conn_str: A "connection string" that defines the postgresql resource in the format
{schema}://{user}:{password}@{host or IP}:{port}/{database}
:param query: The query to be run. It can actually be a script containing multiple queries.
:returns: The comma-separated columns of each line-delimited row of the results of the query.
"""
cmd = ["/usr/bin/psql", "--tuples-only", "-d", conn_str, "-c", query] + list(args.values())
proc = subprocess.Popen(
cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
universal_newlines=True,
)
proc.wait()
output = proc.communicate()
if proc.returncode != 0:
logging.debug("psql exec failed; stderr: %s\n\tstdout: %s", output[1], output[0])
raise OSError("failed to execute database query")
if sys.version_info.major >= 3:
return output[0].strip()
else:
return string.strip(output[0])
def invoke_db_admin_pl(action, root, tv): # type: (str, str, bool) -> None
"""
Exectues admin with the given action, and looks for it from the given root directory.
"""
path = os.path.join(root, "opt/traffic_ops/app")
# This is a workaround for admin using hard-coded relative paths. That
# should be fixed at some point, IMO, but for now this works.
os.chdir(path)
cmd = [os.path.join(path, "db/admin"), "--env=production", action]
if tv:
cmd = [os.path.join(path, "db/admin"), "--trafficvault","--env=production", action]
proc = subprocess.Popen(
cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
universal_newlines=True,
)
output = proc.communicate() # type: str
if proc.returncode != 0:
logging.debug("admin exec failed; stderr: %s\n\tstdout: %s", output[1], output[0])
raise OSError("Database {action} failed".format(action=action))
logging.info("Database %s succeeded", action)
def setup_database_data(conn_str, user, param_conf, root, postgresTV): # type: (str, User, dict, str, bool) -> None
"""
Sets up all necessary initial database data using `/usr/bin/sql`
"""
logging.info("paramconf %s", param_conf)
logging.info("Setting up the database data")
tables_found_query = '''
SELECT EXISTS(
SELECT 1
FROM pg_tables
WHERE schemaname = 'public'
AND tablename = 'tm_user'
);'''
if exec_psql(conn_str, tables_found_query) == "t":
logging.info("Found existing tables skipping table creation")
else:
invoke_db_admin_pl("load_schema", root, False)
invoke_db_admin_pl("migrate", root, False)
invoke_db_admin_pl("seed", root, False)
invoke_db_admin_pl("patch", root, False)
if postgresTV:
invoke_db_admin_pl("create_user", root, True)
invoke_db_admin_pl("createdb", root, True)
invoke_db_admin_pl("load_schema", root, True)
invoke_db_admin_pl("migrate", root, True)
hashed_pass = hash_pass(user.password)
insert_admin_query = '''
INSERT INTO tm_user (username, tenant_id, role, local_passwd, confirm_local_passwd)
VALUES (
'{}',
(SELECT id FROM tenant WHERE name = 'root'),
(SELECT id FROM role WHERE name = 'admin'),
'{hashed_pass}',
'{hashed_pass}'
)
ON CONFLICT (username) DO NOTHING;
'''.format(user.username, hashed_pass=hashed_pass)
_ = exec_psql(conn_str, insert_admin_query)
logging.info("=========== Setting up cdn")
insert_cdn_query = "\n\t-- global parameters" + '''
INSERT INTO cdn (name, domain_name, dnssec_enabled)
VALUES ('{cdn_name}', '{dns_subdomain}', false)
ON CONFLICT DO NOTHING;
'''.format(**param_conf)
logging.info("\n%s", insert_cdn_query)
_ = exec_psql(conn_str, insert_cdn_query)
tm_url = param_conf["tm.url"]
logging.info("=========== Setting up parameters")
insert_parameters_query = "\n\t-- global parameters" + '''
INSERT INTO parameter (name, config_file, value)
VALUES ('tm.url', 'global', '{tm_url}'),
('tm.infourl', 'global', '{tm_url}/doc'),
-- CRConfic.json parameters
('geolocation.polling.url', 'CRConfig.json', '{tm_url}/routing/GeoLite2-City.mmdb.gz'),
('geolocation6.polling.url', 'CRConfig.json', '{tm_url}/routing/GeoLiteCityv6.dat.jz')
ON CONFLICT (name, config_file, value) DO NOTHING;
'''.format(tm_url=tm_url)
logging.info("\n%s", insert_parameters_query)
_ = exec_psql(conn_str, insert_parameters_query)
logging.info("\n=========== Setting up profiles")
insert_profiles_query = "\n\t-- global parameters" + '''
INSERT INTO profile (name, description, type, cdn)
VALUES ('GLOBAL' 'Global Traffic Ops profile, DO NOT DELETE', 'UNK_PROFILE', (SELECT id FROM cdn WHERE name='ALL'))
ON CONFLICT DO NOTHING;
INSERT INTO profile_parameter (profile, parameter)
VALUES
(
(SELECT id FROM profile WHERE name = 'GLOBAL'),
(
SELECT id
FROM parameter
WHERE name = 'tm.url'
AND config_file = 'global'
AND value = '{tm_url}'
)
),
(
(SELECT id FROM profile WHERE name = 'GLOBAL'),
(
SELECT id
FROM parameter
WHERE name = 'tm.infourl'
AND config_file = 'global'
AND value = '{tm_url}/doc'
)
),
(
(SELECT id FROM profile WHERE name = 'GLOBAL'),
(
SELECT id
FROM parameter
WHERE name = 'geolocation.polling.url'
AND config_file = 'CRConfig.json'
AND value = '{tm_url}/routing/GeoLite2-City.mmdb.gz'
)
),
(
(SELECT id FROM profile WHERE name = 'GLOBAL'),
(
SELECT id
FROM parameter
WHERE name = 'geolocation6.polling.url'
AND config_file = 'CRConfig.json'
AND value = '{tm_url}/routing/GeoLiteCityv6.mmdb.gz'
)
)
ON CONFLICT (profile, parameter) DO NOTHING;
'''.format(tm_url=tm_url)
logging.info("\n%s", insert_profiles_query)
_ = exec_psql(conn_str, insert_cdn_query)
def main(
automatic, # type: bool
debug, # type: bool
defaults, # type: str
cfile, # type: str
root_dir, # type: str
ops_user, # type: str
ops_group, # type: str
no_restart_to, # type: bool
no_database, # type: bool
):
"""
Runs the main routine given the parsed arguments as input.
:rtype: int
"""
postgresTV = False
if debug:
logging.getLogger().setLevel(logging.DEBUG)
else:
logging.getLogger().setLevel(logging.INFO)
# At this point, the Perl script... unzipped its own logfile?
logging.info("Starting postinstall")
# The Perl printed this whether or not the logger was actually at the debug level
# so we do too
logging.info("Debug is on")
if automatic:
logging.info("Running in automatic mode")
if defaults is not None:
try:
if defaults:
try:
with open(defaults, "w") as dump_file:
json.dump(DEFAULTS, dump_file, indent=indent)
except OSError as e:
logging.critical("Writing output: %s", e)
return 1
else:
json.dump(DEFAULTS, sys.stdout, cls=ConfigEncoder, indent=indent)
print()
except ValueError as e:
logging.critical("Converting defaults to JSON: %s", e)
return 1
return 0
if not cfile:
logging.info("No input file given - using defaults")
user_input = DEFAULTS
else:
logging.info("Using input file %s", cfile)
try:
with open(cfile) as conf_file:
user_input = unmarshal_config(json.load(conf_file))
diffs = sanity_check_config(user_input, automatic)
logging.info(
"File sanity check complete - found %s difference%s",
diffs,
'' if diffs == 1 else 's'
)
except (OSError, ValueError) as e:
logging.critical("Reading in input file '%s': %s", cfile, e)
return 1
try:
dbconf = generate_db_conf(user_input[DATABASE_CONF_FILE], DATABASE_CONF_FILE, automatic, root_dir)
generate_todb_conf(DB_CONF_FILE, root_dir, dbconf)
# the new "/opt/traffic_ops/app/conf/production/tv.conf" section for Traffic Vault PostgreSQL backend is optional
if TV_DATABASE_CONF_FILE in user_input:
tv_dbconf = generate_db_conf(user_input[TV_DATABASE_CONF_FILE], TV_DATABASE_CONF_FILE, automatic, root_dir)
generate_todb_conf(TV_DB_CONF_FILE, root_dir, tv_dbconf)
generate_ldap_conf(user_input[LDAP_CONF_FILE], LDAP_CONF_FILE, automatic, root_dir)
admin_conf = generate_users_conf(
user_input[USERS_CONF_FILE],
USERS_CONF_FILE,
automatic,
root_dir
)
generate_profiles_dir(user_input[PROFILES_CONF_FILE])
opensslconf = generate_openssl_conf(user_input[OPENSSL_CONF_FILE], OPENSSL_CONF_FILE, automatic)
paramconf = generate_param_conf(user_input[PARAM_CONF_FILE], PARAM_CONF_FILE, automatic, root_dir)
postinstall_cfg = os.path.join(root_dir, POST_INSTALL_CFG.lstrip('/'))
if not os.path.isfile(postinstall_cfg):
with open(postinstall_cfg, 'w+') as conf_file:
print("{}", file=conf_file)
except OSError as e:
logging.critical("Writing configuration: %s", e)
return 1
except ValueError as e:
logging.critical("Generating configuration: %s", e)
return 1
try:
cert_code = setup_certificates(opensslconf, root_dir, ops_user, ops_group)
if cert_code:
return cert_code
except OSError as e:
logging.critical("Setting up SSL Certificates: %s", e)
return 1
try:
postgresTV = generate_cdn_conf(user_input[CDN_CONF_FILE], CDN_CONF_FILE, automatic, root_dir)
except OSError as e:
logging.critical("Generating cdn.conf: %s", e)
return 1
if not no_database:
try:
conn_str = db_connection_string(dbconf)
except KeyError as e:
logging.error("Missing database connection variable: %s", e)
logging.error(
"Can't connect to the database. " \
"Use the script `/opt/traffic_ops/install/bin/todb_bootstrap.sh` " \
"on the db server to create it and run `postinstall` again."
)
return 1
if not os.path.isfile("/usr/bin/psql") or not os.access("/usr/bin/psql", os.X_OK):
logging.critical("psql is not installed, please install it to continue with database setup")
return 1
def db_connect_failed():
logging.error("Failed to set up database: %s", e)
logging.error(
"Can't connect to the database. "
"Use the script `/opt/traffic_ops/install/bin/todb_bootstrap.sh` "
"on the db server to create it and run `postinstall` again."
)
try:
setup_database_data(conn_str, admin_conf, paramconf, root_dir, postgresTV)
except (subprocess.CalledProcessError, OSError) as e:
db_connect_failed()
return 1
except subprocess.SubprocessError as e:
db_connect_failed()
return 1
if not no_restart_to:
logging.info("Starting Traffic Ops")
try:
cmd = ["/sbin/service", "traffic_ops", "restart"]
proc = subprocess.Popen(
cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
universal_newlines=True,
)
if proc.wait():
raise subprocess.CalledProcessError(proc.returncode, cmd)
except (subprocess.CalledProcessError, OSError) as e:
output = proc.communicate()
logging.critical("Failed to restart Traffic Ops, return code %s: %s", e.returncode, e)
logging.debug("stderr: %s\n\tstdout: %s", output[1], output[0])
return 1
except OSError as e:
logging.critical("Failed to restart Traffic Ops: unknown error occurred: %s", e)
return 1
# Perl didn't actually do any "waiting" before reporting success, so
# neither do we
logging.info("Waiting for Traffic Ops to restart")
else:
logging.info("Skipping Traffic Ops restart")
logging.info("Success! Postinstall complete.")
return 0
if __name__ == '__main__':
logging.basicConfig(stream=sys.stdout)
PARSER = argparse.ArgumentParser()
PARSER.add_argument(
"-a",
"--automatic",
help="If there are questions in the config file which do not have answers, the script " +
"will look to the defaults for the answer. If the answer is not in the defaults the " +
"script will exit",
action="store_true"
)
PARSER.add_argument(
"--cfile",
help="An input config file used to ask and answer questions",
type=str,
default=None
)
PARSER.add_argument(
"-cfile",
help=argparse.SUPPRESS,
type=str,
default=None,
dest="legacy_cfile"
)
PARSER.add_argument("--debug", help="Enables verbose output", action="store_true")
PARSER.add_argument("-debug", help=argparse.SUPPRESS, dest="legacy_debug", action="store_true")
PARSER.add_argument(
"--defaults",
help="Writes out a configuration file with defaults which can be used as input",
type=str,
nargs="?",
default=None,
const=""
)
PARSER.add_argument(
"-defaults",
help=argparse.SUPPRESS,
type=str,
nargs="?",
default=None,
const="",
dest="legacy_defaults"
)
PARSER.add_argument(
"-n",
"--no-root",
help="Enable running as a non-root user (may cause failure)",
action="store_true"
)
PARSER.add_argument(
"-r",
"--root-directory",
help="Set the directory to be treated as the system's root directory (e.g. for testing)",
type=str,
default="/"
)
PARSER.add_argument(
"-u",
"--ops-user",
help="Specify a username to own Traffic Ops files and processes",
type=str,
default="trafops"
)
PARSER.add_argument(
"-g",
"--ops-group",
help="Specify the group to own Traffic Ops files and processes",
type=str,
default="trafops"
)
PARSER.add_argument(
"--no-restart-to",
help="Skip restarting Traffic Ops after configuration and database changes are applied",
action="store_true"
)
PARSER.add_argument("--no-database", help="Skip all database operations", action="store_true")
ARGS = PARSER.parse_args()
USED_LEGACY_ARGS = False
DEFAULTS_ARG = None
if ARGS.legacy_defaults:
if ARGS.defaults:
logging.error("cannot specify both '--defaults' and '-defaults'")
sys.exit(1)
USED_LEGACY_ARGS = True
DEFAULTS_ARG = ARGS.legacy_defaults
else:
DEFAULTS_ARG = ARGS.defaults
DEBUG = False
if ARGS.legacy_debug:
if ARGS.debug:
logging.error("cannot specify both '--debug' and '-debug'")
sys.exit(1)
USED_LEGACY_ARGS = True
DEBUG = ARGS.legacy_debug
else:
DEBUG = ARGS.debug
CFILE = None
if ARGS.legacy_cfile:
if ARGS.cfile:
logging.error("cannot specify both '--cfile' and '-cfile'")
sys.exit(1)
USED_LEGACY_ARGS = True
CFILE = ARGS.legacy_cfile
else:
CFILE = ARGS.cfile
if not ARGS.no_root and os.getuid() != 0:
logging.error("You must run this script as the root user")
logging.shutdown()
sys.exit(1)
if USED_LEGACY_ARGS:
logging.warning(
"passing long options with a single '-' is deprecated, please use '--' in the future"
)
try:
EXIT_CODE = main(
ARGS.automatic,
DEBUG,
DEFAULTS_ARG,
CFILE,
os.path.abspath(ARGS.root_directory),
ARGS.ops_user,
ARGS.ops_group,
ARGS.no_restart_to,
ARGS.no_database
)
sys.exit(EXIT_CODE)
except KeyboardInterrupt:
sys.exit(1)
finally:
logging.shutdown()