Refactor resolve-profile script

This is paving the way for identity broker improvements for opt-in
regions. The output is functionally identical between the two scripts
modulo the svcs change. Hopefully this makes the transformation process
a little more clear.
This commit is contained in:
Mike Crute 2020-05-21 10:45:05 -07:00
parent fe362af91f
commit 1fd42af98d
2 changed files with 132 additions and 147 deletions

View File

@ -56,25 +56,33 @@ pkgs {
tzdata = true tzdata = true
} }
svcs { svcs {
devfs = "sysinit" sysinit {
dmesg = "sysinit" devfs = true
hwdrivers = "sysinit" dmesg = true
mdev = "sysinit" hwdrivers = true
acpid = "boot" mdev = true
bootmisc = "boot" }
hostname = "boot" boot {
hwclock = "boot" acpid = true
modules = "boot" bootmisc = true
swap = "boot" hostname = true
sysctl = "boot" hwclock = true
syslog = "boot" modules = true
chronyd = "default" swap = true
networking = "default" sysctl = true
sshd = "default" syslog = true
tiny-ec2-bootstrap = "default" }
killprocs = "shutdown" default {
mount-ro = "shutdown" chronyd = true
savecache = "shutdown" networking = true
sshd = true
tiny-ec2-bootstrap = true
}
shutdown {
killprocs = true
mount-ro = true
savecache = true
}
} }
kernel_modules { kernel_modules {
sd-mod = true sd-mod = true

View File

@ -1,156 +1,133 @@
@PYTHON@ @PYTHON@
# vim: set ts=4 et: # vim: set ts=4 et:
import json
import os import os
import shutil
import sys import sys
import boto3 import json
from botocore.exceptions import ClientError import shutil
import argparse
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pyhocon import ConfigFactory from pyhocon import ConfigFactory
if len(sys.argv) != 2:
sys.exit("Usage: " + os.path.basename(__file__) + " <profile>")
PROFILE = sys.argv[1] # Just group together our transforms
class Transforms:
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
# path to the profile config file
PROFILE_CONF = os.path.join(SCRIPT_DIR, '..', 'profiles', PROFILE + '.conf')
# load the profile's build configuration
BUILDS = ConfigFactory.parse_file(PROFILE_CONF)['BUILDS']
# where we store the profile's builds' config/output
PROFILE_DIR = os.path.join(SCRIPT_DIR, 'profile', PROFILE)
if not os.path.exists(PROFILE_DIR):
os.makedirs(PROFILE_DIR)
# fold these build config keys' dict to scalar
FOLD_DICTS = {
'ami_access': ',{0}',
'ami_regions': ',{0}',
'repos': "\n@{1} {0}",
'pkgs': ' {0}@{1}',
'kernel_modules': ',{0}',
'kernel_options': ' {0}'
}
NOW = datetime.utcnow() NOW = datetime.utcnow()
ONE_DAY = timedelta(days=1) TOMORROW = NOW + timedelta(days=1)
unquote = lambda x: x.strip('"')
@staticmethod
def force_iso_date(input):
return datetime.fromisoformat(input).isoformat(timespec="seconds")
@classmethod
def resolve_tomorrow(cls, input):
return cls.TOMORROW.isoformat(timespec="seconds")
@classmethod
def resolve_now(cls, input):
return cls.NOW.strftime("%Y%m%d%H%M%S")
@classmethod
def fold_comma(cls, input):
return ",".join([cls.unquote(k) for k in input.keys()])
@classmethod
def fold_space(cls, input):
return " ".join([cls.unquote(k) for k in input.keys()])
@classmethod
def fold_repos(cls, input):
return "\n".join(
f"@{v} {cls.unquote(k)}" if isinstance(v, str) else cls.unquote(k)
for k, v in input.items())
@staticmethod
def fold_packages(input):
return " ".join(
f"{k}@{v}" if isinstance(v, str) else k
for k, v in input.items())
@staticmethod
def fold_services(input):
return " ".join(
"{}={}".format(k, ",".join(v.keys()))
for k, v in input.items())
# func to fold dict down to scalar class ConfigBuilder:
def fold(fdict, ffmt):
folded = ''
for fkey, fval in fdict.items():
fkey = fkey.strip('"') # complex keys may be in quotes
if fval is True:
folded += ffmt[0] + fkey
elif fval not in [None, False]:
folded += ffmt.format(fkey, fval)
return folded[1:]
_CFG_TRANSFORMS = {
"ami_access" : Transforms.fold_comma,
"ami_regions" : Transforms.fold_comma,
"kernel_modules" : Transforms.fold_comma,
"kernel_options" : Transforms.fold_space,
"repos" : Transforms.fold_repos,
"pkgs" : Transforms.fold_packages,
"svcs" : Transforms.fold_services,
"revision" : Transforms.resolve_now,
"end_of_life" : lambda x: \
Transforms.force_iso_date(Transforms.resolve_tomorrow(x)),
}
# list of AWS regions, and whether they're enabled def __init__(self, config_path, out_dir):
all_regions = {} self.config_path = config_path
AWS = boto3.session.Session() self.out_dir = out_dir
sys.stderr.write("\n>>> Determining region availability...")
sys.stderr.flush()
for region in AWS.get_available_regions('ec2'):
ec2 = AWS.client('ec2', region_name=region)
try:
ec2.describe_regions()
except ClientError as e:
if e.response['Error']['Code'] == 'AuthFailure':
sys.stderr.write('-')
sys.stderr.flush()
all_regions[region] = False
continue
elif e.response['Error']['Code'] == 'UnauthorizedOperation':
# have access to the region, but not to ec2:DescribeRegions
pass
else:
raise
sys.stderr.write('+')
sys.stderr.flush()
all_regions[region] = True
sys.stderr.write("\n")
for region, available in all_regions.items(): def build(self, profile):
if available is False: build_config = ConfigFactory.parse_file(self.config_path)
sys.stderr.write(f"*** WARNING: skipping disabled region {region}\n")
print() for build, cfg in build_config["BUILDS"].items():
build_dir = os.path.join(self.out_dir, build)
# parse/resolve HOCON profile's builds' config # Always start fresh
for build, cfg in BUILDS.items(): shutil.rmtree(build_dir, ignore_errors=True)
print(f">>> Resolving configuration for '{build}'")
build_dir = os.path.join(PROFILE_DIR, build)
# make a fresh profile build directory
if os.path.exists(build_dir):
shutil.rmtree(build_dir)
os.makedirs(build_dir) os.makedirs(build_dir)
# populate profile build vars cfg["profile"] = profile
cfg['profile'] = PROFILE cfg["profile_build"] = build
cfg['profile_build'] = build
# mostly edge-related temporal substitutions # Order of operations is important here
if cfg['end_of_life'] == '@TOMORROW@': for k, v in cfg.items():
cfg['end_of_life'] = (NOW + ONE_DAY).isoformat(timespec='seconds') transform = self._CFG_TRANSFORMS.get(k)
elif cfg['end_of_life'] is not None: if transform:
# to explicitly UTC-ify end_of_life cfg[k] = transform(v)
cfg['end_of_life'] = datetime.fromisoformat(
cfg['end_of_life'] + '+00:00').isoformat(timespec='seconds')
if cfg['revision'] == '@NOW@':
cfg['revision'] = NOW.strftime('%Y%m%d%H%M%S')
# 'ALL' region expansion (or retraction) if isinstance(v, str) and "{var." in v:
if 'ALL' in cfg['ami_regions']: cfg[k] = v.format(var=cfg)
all_val = cfg['ami_regions']['ALL']
if all_val not in [None, False]:
cfg['ami_regions'] = all_regions
else:
cfg['ami_regions'] = {}
else:
# warn/remove disabled regions
for region, enabled in all_regions.items():
if enabled is not False or region not in cfg['ami_regions']:
continue
if cfg['ami_regions'][region] not in [None, False]:
cfg['ami_regions'][region] = False
# fold dict vars to scalars with open(os.path.join(build_dir, "vars.json"), "w") as out:
for foldkey, foldfmt in FOLD_DICTS.items(): json.dump(cfg, out, indent=4, separators=(",", ": "))
cfg[foldkey] = fold(cfg[foldkey], foldfmt)
# fold 'svcs' dict to scalar
lvls = {}
for svc, lvl in cfg['svcs'].items():
if lvl is True:
# service in default runlevel
lvls['default'].append(svc)
elif lvl not in [None, False]:
# service in specified runlevel (skip svc when false/null)
if lvl not in lvls.keys():
lvls[lvl] = []
lvls[lvl].append(svc)
cfg['svcs'] = ' '.join(
str(lvl) + '=' + ','.join(
str(svc) for svc in svcs
) for lvl, svcs in lvls.items()
)
# resolve ami_name and ami_desc def find_repo_root():
cfg['ami_name'] = cfg['ami_name'].format(var=cfg) path = os.getcwd()
cfg['ami_desc'] = cfg['ami_desc'].format(var=cfg)
# write build vars file while ".git" not in set(os.listdir(path)) and path != "/":
with open(os.path.join(build_dir, 'vars.json'), 'w') as out: path = os.path.dirname(path)
json.dump(cfg, out, indent=4, separators=(',', ': '))
print() if path == "/":
raise Exception("No repo found, stopping at /")
return path
def main(args):
parser = argparse.ArgumentParser(description="Build Packer JSON variable "
"files from HOCON build profiles")
parser.add_argument("profile", help="name of profile to build")
args = parser.parse_args()
root = find_repo_root()
ConfigBuilder(
os.path.join(root, "profiles", f"{args.profile}.conf"),
os.path.join(root, "build", "profile", args.profile)
).build(args.profile)
if __name__ == "__main__":
main(sys.argv)