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__)) NOW = datetime.utcnow()
TOMORROW = NOW + timedelta(days=1)
# path to the profile config file unquote = lambda x: x.strip('"')
PROFILE_CONF = os.path.join(SCRIPT_DIR, '..', 'profiles', PROFILE + '.conf')
# load the profile's build configuration @staticmethod
BUILDS = ConfigFactory.parse_file(PROFILE_CONF)['BUILDS'] def force_iso_date(input):
return datetime.fromisoformat(input).isoformat(timespec="seconds")
# where we store the profile's builds' config/output @classmethod
PROFILE_DIR = os.path.join(SCRIPT_DIR, 'profile', PROFILE) def resolve_tomorrow(cls, input):
if not os.path.exists(PROFILE_DIR): return cls.TOMORROW.isoformat(timespec="seconds")
os.makedirs(PROFILE_DIR)
# fold these build config keys' dict to scalar @classmethod
FOLD_DICTS = { def resolve_now(cls, input):
'ami_access': ',{0}', return cls.NOW.strftime("%Y%m%d%H%M%S")
'ami_regions': ',{0}',
'repos': "\n@{1} {0}",
'pkgs': ' {0}@{1}',
'kernel_modules': ',{0}',
'kernel_options': ' {0}'
}
NOW = datetime.utcnow() @classmethod
ONE_DAY = timedelta(days=1) 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 = '' _CFG_TRANSFORMS = {
for fkey, fval in fdict.items(): "ami_access" : Transforms.fold_comma,
fkey = fkey.strip('"') # complex keys may be in quotes "ami_regions" : Transforms.fold_comma,
if fval is True: "kernel_modules" : Transforms.fold_comma,
folded += ffmt[0] + fkey "kernel_options" : Transforms.fold_space,
elif fval not in [None, False]: "repos" : Transforms.fold_repos,
folded += ffmt.format(fkey, fval) "pkgs" : Transforms.fold_packages,
return folded[1:] "svcs" : Transforms.fold_services,
"revision" : Transforms.resolve_now,
"end_of_life" : lambda x: \
Transforms.force_iso_date(Transforms.resolve_tomorrow(x)),
}
def __init__(self, config_path, out_dir):
self.config_path = config_path
self.out_dir = out_dir
def build(self, profile):
build_config = ConfigFactory.parse_file(self.config_path)
for build, cfg in build_config["BUILDS"].items():
build_dir = os.path.join(self.out_dir, build)
# Always start fresh
shutil.rmtree(build_dir, ignore_errors=True)
os.makedirs(build_dir)
cfg["profile"] = profile
cfg["profile_build"] = build
# Order of operations is important here
for k, v in cfg.items():
transform = self._CFG_TRANSFORMS.get(k)
if transform:
cfg[k] = transform(v)
if isinstance(v, str) and "{var." in v:
cfg[k] = v.format(var=cfg)
with open(os.path.join(build_dir, "vars.json"), "w") as out:
json.dump(cfg, out, indent=4, separators=(",", ": "))
# list of AWS regions, and whether they're enabled def find_repo_root():
all_regions = {} path = os.getcwd()
AWS = boto3.session.Session()
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(): while ".git" not in set(os.listdir(path)) and path != "/":
if available is False: path = os.path.dirname(path)
sys.stderr.write(f"*** WARNING: skipping disabled region {region}\n")
print() if path == "/":
raise Exception("No repo found, stopping at /")
# parse/resolve HOCON profile's builds' config return path
for build, cfg in BUILDS.items():
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)
# populate profile build vars def main(args):
cfg['profile'] = PROFILE parser = argparse.ArgumentParser(description="Build Packer JSON variable "
cfg['profile_build'] = build "files from HOCON build profiles")
parser.add_argument("profile", help="name of profile to build")
args = parser.parse_args()
# mostly edge-related temporal substitutions root = find_repo_root()
if cfg['end_of_life'] == '@TOMORROW@':
cfg['end_of_life'] = (NOW + ONE_DAY).isoformat(timespec='seconds')
elif cfg['end_of_life'] is not None:
# to explicitly UTC-ify end_of_life
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) ConfigBuilder(
if 'ALL' in cfg['ami_regions']: os.path.join(root, "profiles", f"{args.profile}.conf"),
all_val = cfg['ami_regions']['ALL'] os.path.join(root, "build", "profile", args.profile)
if all_val not in [None, False]: ).build(args.profile)
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
for foldkey, foldfmt in FOLD_DICTS.items():
cfg[foldkey] = fold(cfg[foldkey], foldfmt)
# fold 'svcs' dict to scalar if __name__ == "__main__":
lvls = {} main(sys.argv)
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
cfg['ami_name'] = cfg['ami_name'].format(var=cfg)
cfg['ami_desc'] = cfg['ami_desc'].format(var=cfg)
# write build vars file
with open(os.path.join(build_dir, 'vars.json'), 'w') as out:
json.dump(cfg, out, indent=4, separators=(',', ': '))
print()