diff options
author | Mike Crute <mike@crute.us> | 2020-05-21 10:45:05 -0700 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2020-05-21 16:45:17 -0700 |
commit | 1fd42af98dac79011496a7956a8cf592b596e2cf (patch) | |
tree | 6865f6c46f78b8b2757d2b1e52b2d4a8bdc7d2c6 | |
parent | fe362af91ffbe5874bce764c3230ae857693e359 (diff) | |
download | alpine-ec2-ami-1fd42af98dac79011496a7956a8cf592b596e2cf.tar.bz2 alpine-ec2-ami-1fd42af98dac79011496a7956a8cf592b596e2cf.tar.xz alpine-ec2-ami-1fd42af98dac79011496a7956a8cf592b596e2cf.zip |
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.
-rw-r--r-- | profiles/base/1 | 46 | ||||
-rw-r--r-- | scripts/resolve-profile.py.in | 273 |
2 files changed, 152 insertions, 167 deletions
diff --git a/profiles/base/1 b/profiles/base/1 index fdda3f0..18decd1 100644 --- a/profiles/base/1 +++ b/profiles/base/1 | |||
@@ -56,25 +56,33 @@ pkgs { | |||
56 | tzdata = true | 56 | tzdata = true |
57 | } | 57 | } |
58 | svcs { | 58 | svcs { |
59 | devfs = "sysinit" | 59 | sysinit { |
60 | dmesg = "sysinit" | 60 | devfs = true |
61 | hwdrivers = "sysinit" | 61 | dmesg = true |
62 | mdev = "sysinit" | 62 | hwdrivers = true |
63 | acpid = "boot" | 63 | mdev = true |
64 | bootmisc = "boot" | 64 | } |
65 | hostname = "boot" | 65 | boot { |
66 | hwclock = "boot" | 66 | acpid = true |
67 | modules = "boot" | 67 | bootmisc = true |
68 | swap = "boot" | 68 | hostname = true |
69 | sysctl = "boot" | 69 | hwclock = true |
70 | syslog = "boot" | 70 | modules = true |
71 | chronyd = "default" | 71 | swap = true |
72 | networking = "default" | 72 | sysctl = true |
73 | sshd = "default" | 73 | syslog = true |
74 | tiny-ec2-bootstrap = "default" | 74 | } |
75 | killprocs = "shutdown" | 75 | default { |
76 | mount-ro = "shutdown" | 76 | chronyd = true |
77 | savecache = "shutdown" | 77 | networking = true |
78 | sshd = true | ||
79 | tiny-ec2-bootstrap = true | ||
80 | } | ||
81 | shutdown { | ||
82 | killprocs = true | ||
83 | mount-ro = true | ||
84 | savecache = true | ||
85 | } | ||
78 | } | 86 | } |
79 | kernel_modules { | 87 | kernel_modules { |
80 | sd-mod = true | 88 | sd-mod = true |
diff --git a/scripts/resolve-profile.py.in b/scripts/resolve-profile.py.in index 884cd2d..2905423 100644 --- a/scripts/resolve-profile.py.in +++ b/scripts/resolve-profile.py.in | |||
@@ -1,156 +1,133 @@ | |||
1 | @PYTHON@ | 1 | @PYTHON@ |
2 | # vim: set ts=4 et: | 2 | # vim: set ts=4 et: |
3 | 3 | ||
4 | import json | ||
5 | import os | 4 | import os |
6 | import shutil | ||
7 | import sys | 5 | import sys |
8 | import boto3 | 6 | import json |
9 | from botocore.exceptions import ClientError | 7 | import shutil |
8 | import argparse | ||
10 | from datetime import datetime, timedelta | 9 | from datetime import datetime, timedelta |
10 | |||
11 | from pyhocon import ConfigFactory | 11 | from pyhocon import ConfigFactory |
12 | 12 | ||
13 | if len(sys.argv) != 2: | 13 | |
14 | sys.exit("Usage: " + os.path.basename(__file__) + " <profile>") | 14 | # Just group together our transforms |
15 | 15 | class Transforms: | |
16 | PROFILE = sys.argv[1] | 16 | |
17 | 17 | NOW = datetime.utcnow() | |
18 | SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) | 18 | TOMORROW = NOW + timedelta(days=1) |
19 | 19 | ||
20 | # path to the profile config file | 20 | unquote = lambda x: x.strip('"') |
21 | PROFILE_CONF = os.path.join(SCRIPT_DIR, '..', 'profiles', PROFILE + '.conf') | 21 | |
22 | 22 | @staticmethod | |
23 | # load the profile's build configuration | 23 | def force_iso_date(input): |
24 | BUILDS = ConfigFactory.parse_file(PROFILE_CONF)['BUILDS'] | 24 | return datetime.fromisoformat(input).isoformat(timespec="seconds") |
25 | 25 | ||
26 | # where we store the profile's builds' config/output | 26 | @classmethod |
27 | PROFILE_DIR = os.path.join(SCRIPT_DIR, 'profile', PROFILE) | 27 | def resolve_tomorrow(cls, input): |
28 | if not os.path.exists(PROFILE_DIR): | 28 | return cls.TOMORROW.isoformat(timespec="seconds") |
29 | os.makedirs(PROFILE_DIR) | 29 | |
30 | 30 | @classmethod | |
31 | # fold these build config keys' dict to scalar | 31 | def resolve_now(cls, input): |
32 | FOLD_DICTS = { | 32 | return cls.NOW.strftime("%Y%m%d%H%M%S") |
33 | 'ami_access': ',{0}', | 33 | |
34 | 'ami_regions': ',{0}', | 34 | @classmethod |
35 | 'repos': "\n@{1} {0}", | 35 | def fold_comma(cls, input): |
36 | 'pkgs': ' {0}@{1}', | 36 | return ",".join([cls.unquote(k) for k in input.keys()]) |
37 | 'kernel_modules': ',{0}', | 37 | |
38 | 'kernel_options': ' {0}' | 38 | @classmethod |
39 | } | 39 | def fold_space(cls, input): |
40 | 40 | return " ".join([cls.unquote(k) for k in input.keys()]) | |
41 | NOW = datetime.utcnow() | 41 | |
42 | ONE_DAY = timedelta(days=1) | 42 | @classmethod |
43 | 43 | def fold_repos(cls, input): | |
44 | 44 | return "\n".join( | |
45 | # func to fold dict down to scalar | 45 | f"@{v} {cls.unquote(k)}" if isinstance(v, str) else cls.unquote(k) |
46 | def fold(fdict, ffmt): | 46 | for k, v in input.items()) |
47 | folded = '' | 47 | |
48 | for fkey, fval in fdict.items(): | 48 | @staticmethod |
49 | fkey = fkey.strip('"') # complex keys may be in quotes | 49 | def fold_packages(input): |
50 | if fval is True: | 50 | return " ".join( |
51 | folded += ffmt[0] + fkey | 51 | f"{k}@{v}" if isinstance(v, str) else k |
52 | elif fval not in [None, False]: | 52 | for k, v in input.items()) |
53 | folded += ffmt.format(fkey, fval) | 53 | |
54 | return folded[1:] | 54 | @staticmethod |
55 | 55 | def fold_services(input): | |
56 | 56 | return " ".join( | |
57 | # list of AWS regions, and whether they're enabled | 57 | "{}={}".format(k, ",".join(v.keys())) |
58 | all_regions = {} | 58 | for k, v in input.items()) |
59 | AWS = boto3.session.Session() | 59 | |
60 | sys.stderr.write("\n>>> Determining region availability...") | 60 | |
61 | sys.stderr.flush() | 61 | class ConfigBuilder: |
62 | for region in AWS.get_available_regions('ec2'): | 62 | |
63 | ec2 = AWS.client('ec2', region_name=region) | 63 | _CFG_TRANSFORMS = { |
64 | try: | 64 | "ami_access" : Transforms.fold_comma, |
65 | ec2.describe_regions() | 65 | "ami_regions" : Transforms.fold_comma, |
66 | except ClientError as e: | 66 | "kernel_modules" : Transforms.fold_comma, |
67 | if e.response['Error']['Code'] == 'AuthFailure': | 67 | "kernel_options" : Transforms.fold_space, |
68 | sys.stderr.write('-') | 68 | "repos" : Transforms.fold_repos, |
69 | sys.stderr.flush() | 69 | "pkgs" : Transforms.fold_packages, |
70 | all_regions[region] = False | 70 | "svcs" : Transforms.fold_services, |
71 | continue | 71 | "revision" : Transforms.resolve_now, |
72 | elif e.response['Error']['Code'] == 'UnauthorizedOperation': | 72 | "end_of_life" : lambda x: \ |
73 | # have access to the region, but not to ec2:DescribeRegions | 73 | Transforms.force_iso_date(Transforms.resolve_tomorrow(x)), |
74 | pass | 74 | } |
75 | else: | 75 | |
76 | raise | 76 | def __init__(self, config_path, out_dir): |
77 | sys.stderr.write('+') | 77 | self.config_path = config_path |
78 | sys.stderr.flush() | 78 | self.out_dir = out_dir |
79 | all_regions[region] = True | 79 | |
80 | sys.stderr.write("\n") | 80 | def build(self, profile): |
81 | 81 | build_config = ConfigFactory.parse_file(self.config_path) | |
82 | for region, available in all_regions.items(): | 82 | |
83 | if available is False: | 83 | for build, cfg in build_config["BUILDS"].items(): |
84 | sys.stderr.write(f"*** WARNING: skipping disabled region {region}\n") | 84 | build_dir = os.path.join(self.out_dir, build) |
85 | 85 | ||
86 | print() | 86 | # Always start fresh |
87 | 87 | shutil.rmtree(build_dir, ignore_errors=True) | |
88 | # parse/resolve HOCON profile's builds' config | 88 | os.makedirs(build_dir) |
89 | for build, cfg in BUILDS.items(): | 89 | |
90 | print(f">>> Resolving configuration for '{build}'") | 90 | cfg["profile"] = profile |
91 | build_dir = os.path.join(PROFILE_DIR, build) | 91 | cfg["profile_build"] = build |
92 | 92 | ||
93 | # make a fresh profile build directory | 93 | # Order of operations is important here |
94 | if os.path.exists(build_dir): | 94 | for k, v in cfg.items(): |
95 | shutil.rmtree(build_dir) | 95 | transform = self._CFG_TRANSFORMS.get(k) |
96 | os.makedirs(build_dir) | 96 | if transform: |
97 | 97 | cfg[k] = transform(v) | |
98 | # populate profile build vars | 98 | |
99 | cfg['profile'] = PROFILE | 99 | if isinstance(v, str) and "{var." in v: |
100 | cfg['profile_build'] = build | 100 | cfg[k] = v.format(var=cfg) |
101 | 101 | ||
102 | # mostly edge-related temporal substitutions | 102 | with open(os.path.join(build_dir, "vars.json"), "w") as out: |
103 | if cfg['end_of_life'] == '@TOMORROW@': | 103 | json.dump(cfg, out, indent=4, separators=(",", ": ")) |
104 | cfg['end_of_life'] = (NOW + ONE_DAY).isoformat(timespec='seconds') | 104 | |
105 | elif cfg['end_of_life'] is not None: | 105 | |
106 | # to explicitly UTC-ify end_of_life | 106 | def find_repo_root(): |
107 | cfg['end_of_life'] = datetime.fromisoformat( | 107 | path = os.getcwd() |
108 | cfg['end_of_life'] + '+00:00').isoformat(timespec='seconds') | 108 | |
109 | if cfg['revision'] == '@NOW@': | 109 | while ".git" not in set(os.listdir(path)) and path != "/": |
110 | cfg['revision'] = NOW.strftime('%Y%m%d%H%M%S') | 110 | path = os.path.dirname(path) |
111 | 111 | ||
112 | # 'ALL' region expansion (or retraction) | 112 | if path == "/": |
113 | if 'ALL' in cfg['ami_regions']: | 113 | raise Exception("No repo found, stopping at /") |
114 | all_val = cfg['ami_regions']['ALL'] | 114 | |
115 | if all_val not in [None, False]: | 115 | return path |
116 | cfg['ami_regions'] = all_regions | 116 | |
117 | else: | 117 | |
118 | cfg['ami_regions'] = {} | 118 | def main(args): |
119 | else: | 119 | parser = argparse.ArgumentParser(description="Build Packer JSON variable " |
120 | # warn/remove disabled regions | 120 | "files from HOCON build profiles") |
121 | for region, enabled in all_regions.items(): | 121 | parser.add_argument("profile", help="name of profile to build") |
122 | if enabled is not False or region not in cfg['ami_regions']: | 122 | args = parser.parse_args() |
123 | continue | 123 | |
124 | if cfg['ami_regions'][region] not in [None, False]: | 124 | root = find_repo_root() |
125 | cfg['ami_regions'][region] = False | 125 | |
126 | 126 | ConfigBuilder( | |
127 | # fold dict vars to scalars | 127 | os.path.join(root, "profiles", f"{args.profile}.conf"), |
128 | for foldkey, foldfmt in FOLD_DICTS.items(): | 128 | os.path.join(root, "build", "profile", args.profile) |
129 | cfg[foldkey] = fold(cfg[foldkey], foldfmt) | 129 | ).build(args.profile) |
130 | 130 | ||
131 | # fold 'svcs' dict to scalar | 131 | |
132 | lvls = {} | 132 | if __name__ == "__main__": |
133 | for svc, lvl in cfg['svcs'].items(): | 133 | main(sys.argv) |
134 | if lvl is True: | ||
135 | # service in default runlevel | ||
136 | lvls['default'].append(svc) | ||
137 | elif lvl not in [None, False]: | ||
138 | # service in specified runlevel (skip svc when false/null) | ||
139 | if lvl not in lvls.keys(): | ||
140 | lvls[lvl] = [] | ||
141 | lvls[lvl].append(svc) | ||
142 | cfg['svcs'] = ' '.join( | ||
143 | str(lvl) + '=' + ','.join( | ||
144 | str(svc) for svc in svcs | ||
145 | ) for lvl, svcs in lvls.items() | ||
146 | ) | ||
147 | |||
148 | # resolve ami_name and ami_desc | ||
149 | cfg['ami_name'] = cfg['ami_name'].format(var=cfg) | ||
150 | cfg['ami_desc'] = cfg['ami_desc'].format(var=cfg) | ||
151 | |||
152 | # write build vars file | ||
153 | with open(os.path.join(build_dir, 'vars.json'), 'w') as out: | ||
154 | json.dump(cfg, out, indent=4, separators=(',', ': ')) | ||
155 | |||
156 | print() | ||