Show More
@@ -0,0 +1,127 b'' | |||
|
1 | ==================== | |
|
2 | Mercurial Automation | |
|
3 | ==================== | |
|
4 | ||
|
5 | This directory contains code and utilities for building and testing Mercurial | |
|
6 | on remote machines. | |
|
7 | ||
|
8 | The ``automation.py`` Script | |
|
9 | ============================ | |
|
10 | ||
|
11 | ``automation.py`` is an executable Python script (requires Python 3.5+) | |
|
12 | that serves as a driver to common automation tasks. | |
|
13 | ||
|
14 | When executed, the script will *bootstrap* a virtualenv in | |
|
15 | ``<source-root>/build/venv-automation`` then re-execute itself using | |
|
16 | that virtualenv. So there is no need for the caller to have a virtualenv | |
|
17 | explicitly activated. This virtualenv will be populated with various | |
|
18 | dependencies (as defined by the ``requirements.txt`` file). | |
|
19 | ||
|
20 | To see what you can do with this script, simply run it:: | |
|
21 | ||
|
22 | $ ./automation.py | |
|
23 | ||
|
24 | Local State | |
|
25 | =========== | |
|
26 | ||
|
27 | By default, local state required to interact with remote servers is stored | |
|
28 | in the ``~/.hgautomation`` directory. | |
|
29 | ||
|
30 | We attempt to limit persistent state to this directory. Even when | |
|
31 | performing tasks that may have side-effects, we try to limit those | |
|
32 | side-effects so they don't impact the local system. e.g. when we SSH | |
|
33 | into a remote machine, we create a temporary directory for the SSH | |
|
34 | config so the user's known hosts file isn't updated. | |
|
35 | ||
|
36 | AWS Integration | |
|
37 | =============== | |
|
38 | ||
|
39 | Various automation tasks integrate with AWS to provide access to | |
|
40 | resources such as EC2 instances for generic compute. | |
|
41 | ||
|
42 | This obviously requires an AWS account and credentials to work. | |
|
43 | ||
|
44 | We use the ``boto3`` library for interacting with AWS APIs. We do not employ | |
|
45 | any special functionality for telling ``boto3`` where to find AWS credentials. See | |
|
46 | https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html | |
|
47 | for how ``boto3`` works. Once you have configured your environment such | |
|
48 | that ``boto3`` can find credentials, interaction with AWS should *just work*. | |
|
49 | ||
|
50 | .. hint:: | |
|
51 | ||
|
52 | Typically you have a ``~/.aws/credentials`` file containing AWS | |
|
53 | credentials. If you manage multiple credentials, you can override which | |
|
54 | *profile* to use at run-time by setting the ``AWS_PROFILE`` environment | |
|
55 | variable. | |
|
56 | ||
|
57 | Resource Management | |
|
58 | ------------------- | |
|
59 | ||
|
60 | Depending on the task being performed, various AWS services will be accessed. | |
|
61 | This of course requires AWS credentials with permissions to access these | |
|
62 | services. | |
|
63 | ||
|
64 | The following AWS services can be accessed by automation tasks: | |
|
65 | ||
|
66 | * EC2 | |
|
67 | * IAM | |
|
68 | * Simple Systems Manager (SSM) | |
|
69 | ||
|
70 | Various resources will also be created as part of performing various tasks. | |
|
71 | This also requires various permissions. | |
|
72 | ||
|
73 | The following AWS resources can be created by automation tasks: | |
|
74 | ||
|
75 | * EC2 key pairs | |
|
76 | * EC2 security groups | |
|
77 | * EC2 instances | |
|
78 | * IAM roles and instance profiles | |
|
79 | * SSM command invocations | |
|
80 | ||
|
81 | When possible, we prefix resource names with ``hg-`` so they can easily | |
|
82 | be identified as belonging to Mercurial. | |
|
83 | ||
|
84 | .. important:: | |
|
85 | ||
|
86 | We currently assume that AWS accounts utilized by *us* are single | |
|
87 | tenancy. Attempts to have discrete users of ``automation.py`` (including | |
|
88 | sharing credentials across machines) using the same AWS account can result | |
|
89 | in them interfering with each other and things breaking. | |
|
90 | ||
|
91 | Cost of Operation | |
|
92 | ----------------- | |
|
93 | ||
|
94 | ``automation.py`` tries to be frugal with regards to utilization of remote | |
|
95 | resources. Persistent remote resources are minimized in order to keep costs | |
|
96 | in check. For example, EC2 instances are often ephemeral and only live as long | |
|
97 | as the operation being performed. | |
|
98 | ||
|
99 | Under normal operation, recurring costs are limited to: | |
|
100 | ||
|
101 | * Storage costs for AMI / EBS snapshots. This should be just a few pennies | |
|
102 | per month. | |
|
103 | ||
|
104 | When running EC2 instances, you'll be billed accordingly. By default, we | |
|
105 | use *small* instances, like ``t3.medium``. This instance type costs ~$0.07 per | |
|
106 | hour. | |
|
107 | ||
|
108 | .. note:: | |
|
109 | ||
|
110 | When running Windows EC2 instances, AWS bills at the full hourly cost, even | |
|
111 | if the instance doesn't run for a full hour (per-second billing doesn't | |
|
112 | apply to Windows AMIs). | |
|
113 | ||
|
114 | Managing Remote Resources | |
|
115 | ------------------------- | |
|
116 | ||
|
117 | Occassionally, there may be an error purging a temporary resource. Or you | |
|
118 | may wish to forcefully purge remote state. Commands can be invoked to manually | |
|
119 | purge remote resources. | |
|
120 | ||
|
121 | To terminate all EC2 instances that we manage:: | |
|
122 | ||
|
123 | $ automation.py terminate-ec2-instances | |
|
124 | ||
|
125 | To purge all EC2 resources that we manage:: | |
|
126 | ||
|
127 | $ automation.py purge-ec2-resources |
@@ -0,0 +1,70 b'' | |||
|
1 | #!/usr/bin/env python3 | |
|
2 | # | |
|
3 | # automation.py - Perform tasks on remote machines | |
|
4 | # | |
|
5 | # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | |
|
6 | # | |
|
7 | # This software may be used and distributed according to the terms of the | |
|
8 | # GNU General Public License version 2 or any later version. | |
|
9 | ||
|
10 | import os | |
|
11 | import pathlib | |
|
12 | import subprocess | |
|
13 | import sys | |
|
14 | import venv | |
|
15 | ||
|
16 | ||
|
17 | HERE = pathlib.Path(os.path.abspath(__file__)).parent | |
|
18 | REQUIREMENTS_TXT = HERE / 'requirements.txt' | |
|
19 | SOURCE_DIR = HERE.parent.parent | |
|
20 | VENV = SOURCE_DIR / 'build' / 'venv-automation' | |
|
21 | ||
|
22 | ||
|
23 | def bootstrap(): | |
|
24 | venv_created = not VENV.exists() | |
|
25 | ||
|
26 | VENV.parent.mkdir(exist_ok=True) | |
|
27 | ||
|
28 | venv.create(VENV, with_pip=True) | |
|
29 | ||
|
30 | if os.name == 'nt': | |
|
31 | venv_bin = VENV / 'Scripts' | |
|
32 | pip = venv_bin / 'pip.exe' | |
|
33 | python = venv_bin / 'python.exe' | |
|
34 | else: | |
|
35 | venv_bin = VENV / 'bin' | |
|
36 | pip = venv_bin / 'pip' | |
|
37 | python = venv_bin / 'python' | |
|
38 | ||
|
39 | args = [str(pip), 'install', '-r', str(REQUIREMENTS_TXT), | |
|
40 | '--disable-pip-version-check'] | |
|
41 | ||
|
42 | if not venv_created: | |
|
43 | args.append('-q') | |
|
44 | ||
|
45 | subprocess.run(args, check=True) | |
|
46 | ||
|
47 | os.environ['HGAUTOMATION_BOOTSTRAPPED'] = '1' | |
|
48 | os.environ['PATH'] = '%s%s%s' % ( | |
|
49 | venv_bin, os.pathsep, os.environ['PATH']) | |
|
50 | ||
|
51 | subprocess.run([str(python), __file__] + sys.argv[1:], check=True) | |
|
52 | ||
|
53 | ||
|
54 | def run(): | |
|
55 | import hgautomation.cli as cli | |
|
56 | ||
|
57 | # Need to strip off main Python executable. | |
|
58 | cli.main() | |
|
59 | ||
|
60 | ||
|
61 | if __name__ == '__main__': | |
|
62 | try: | |
|
63 | if 'HGAUTOMATION_BOOTSTRAPPED' not in os.environ: | |
|
64 | bootstrap() | |
|
65 | else: | |
|
66 | run() | |
|
67 | except subprocess.CalledProcessError as e: | |
|
68 | sys.exit(e.returncode) | |
|
69 | except KeyboardInterrupt: | |
|
70 | sys.exit(1) |
@@ -0,0 +1,59 b'' | |||
|
1 | # __init__.py - High-level automation interfaces | |
|
2 | # | |
|
3 | # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | |
|
4 | # | |
|
5 | # This software may be used and distributed according to the terms of the | |
|
6 | # GNU General Public License version 2 or any later version. | |
|
7 | ||
|
8 | # no-check-code because Python 3 native. | |
|
9 | ||
|
10 | import pathlib | |
|
11 | import secrets | |
|
12 | ||
|
13 | from .aws import ( | |
|
14 | AWSConnection, | |
|
15 | ) | |
|
16 | ||
|
17 | ||
|
18 | class HGAutomation: | |
|
19 | """High-level interface for Mercurial automation. | |
|
20 | ||
|
21 | Holds global state, provides access to other primitives, etc. | |
|
22 | """ | |
|
23 | ||
|
24 | def __init__(self, state_path: pathlib.Path): | |
|
25 | self.state_path = state_path | |
|
26 | ||
|
27 | state_path.mkdir(exist_ok=True) | |
|
28 | ||
|
29 | def default_password(self): | |
|
30 | """Obtain the default password to use for remote machines. | |
|
31 | ||
|
32 | A new password will be generated if one is not stored. | |
|
33 | """ | |
|
34 | p = self.state_path / 'default-password' | |
|
35 | ||
|
36 | try: | |
|
37 | with p.open('r', encoding='ascii') as fh: | |
|
38 | data = fh.read().strip() | |
|
39 | ||
|
40 | if data: | |
|
41 | return data | |
|
42 | ||
|
43 | except FileNotFoundError: | |
|
44 | pass | |
|
45 | ||
|
46 | password = secrets.token_urlsafe(24) | |
|
47 | ||
|
48 | with p.open('w', encoding='ascii') as fh: | |
|
49 | fh.write(password) | |
|
50 | fh.write('\n') | |
|
51 | ||
|
52 | p.chmod(0o0600) | |
|
53 | ||
|
54 | return password | |
|
55 | ||
|
56 | def aws_connection(self, region: str): | |
|
57 | """Obtain an AWSConnection instance bound to a specific region.""" | |
|
58 | ||
|
59 | return AWSConnection(self, region) |
This diff has been collapsed as it changes many lines, (879 lines changed) Show them Hide them | |||
@@ -0,0 +1,879 b'' | |||
|
1 | # aws.py - Automation code for Amazon Web Services | |
|
2 | # | |
|
3 | # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | |
|
4 | # | |
|
5 | # This software may be used and distributed according to the terms of the | |
|
6 | # GNU General Public License version 2 or any later version. | |
|
7 | ||
|
8 | # no-check-code because Python 3 native. | |
|
9 | ||
|
10 | import contextlib | |
|
11 | import copy | |
|
12 | import hashlib | |
|
13 | import json | |
|
14 | import os | |
|
15 | import pathlib | |
|
16 | import subprocess | |
|
17 | import time | |
|
18 | ||
|
19 | import boto3 | |
|
20 | import botocore.exceptions | |
|
21 | ||
|
22 | from .winrm import ( | |
|
23 | run_powershell, | |
|
24 | wait_for_winrm, | |
|
25 | ) | |
|
26 | ||
|
27 | ||
|
28 | SOURCE_ROOT = pathlib.Path(os.path.abspath(__file__)).parent.parent.parent.parent | |
|
29 | ||
|
30 | INSTALL_WINDOWS_DEPENDENCIES = (SOURCE_ROOT / 'contrib' / | |
|
31 | 'install-windows-dependencies.ps1') | |
|
32 | ||
|
33 | ||
|
34 | KEY_PAIRS = { | |
|
35 | 'automation', | |
|
36 | } | |
|
37 | ||
|
38 | ||
|
39 | SECURITY_GROUPS = { | |
|
40 | 'windows-dev-1': { | |
|
41 | 'description': 'Mercurial Windows instances that perform build automation', | |
|
42 | 'ingress': [ | |
|
43 | { | |
|
44 | 'FromPort': 22, | |
|
45 | 'ToPort': 22, | |
|
46 | 'IpProtocol': 'tcp', | |
|
47 | 'IpRanges': [ | |
|
48 | { | |
|
49 | 'CidrIp': '0.0.0.0/0', | |
|
50 | 'Description': 'SSH from entire Internet', | |
|
51 | }, | |
|
52 | ], | |
|
53 | }, | |
|
54 | { | |
|
55 | 'FromPort': 3389, | |
|
56 | 'ToPort': 3389, | |
|
57 | 'IpProtocol': 'tcp', | |
|
58 | 'IpRanges': [ | |
|
59 | { | |
|
60 | 'CidrIp': '0.0.0.0/0', | |
|
61 | 'Description': 'RDP from entire Internet', | |
|
62 | }, | |
|
63 | ], | |
|
64 | ||
|
65 | }, | |
|
66 | { | |
|
67 | 'FromPort': 5985, | |
|
68 | 'ToPort': 5986, | |
|
69 | 'IpProtocol': 'tcp', | |
|
70 | 'IpRanges': [ | |
|
71 | { | |
|
72 | 'CidrIp': '0.0.0.0/0', | |
|
73 | 'Description': 'PowerShell Remoting (Windows Remote Management)', | |
|
74 | }, | |
|
75 | ], | |
|
76 | } | |
|
77 | ], | |
|
78 | }, | |
|
79 | } | |
|
80 | ||
|
81 | ||
|
82 | IAM_ROLES = { | |
|
83 | 'ephemeral-ec2-role-1': { | |
|
84 | 'description': 'Mercurial temporary EC2 instances', | |
|
85 | 'policy_arns': [ | |
|
86 | 'arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM', | |
|
87 | ], | |
|
88 | }, | |
|
89 | } | |
|
90 | ||
|
91 | ||
|
92 | ASSUME_ROLE_POLICY_DOCUMENT = ''' | |
|
93 | { | |
|
94 | "Version": "2012-10-17", | |
|
95 | "Statement": [ | |
|
96 | { | |
|
97 | "Effect": "Allow", | |
|
98 | "Principal": { | |
|
99 | "Service": "ec2.amazonaws.com" | |
|
100 | }, | |
|
101 | "Action": "sts:AssumeRole" | |
|
102 | } | |
|
103 | ] | |
|
104 | } | |
|
105 | '''.strip() | |
|
106 | ||
|
107 | ||
|
108 | IAM_INSTANCE_PROFILES = { | |
|
109 | 'ephemeral-ec2-1': { | |
|
110 | 'roles': [ | |
|
111 | 'ephemeral-ec2-role-1', | |
|
112 | ], | |
|
113 | } | |
|
114 | } | |
|
115 | ||
|
116 | ||
|
117 | # User Data for Windows EC2 instance. Mainly used to set the password | |
|
118 | # and configure WinRM. | |
|
119 | # Inspired by the User Data script used by Packer | |
|
120 | # (from https://www.packer.io/intro/getting-started/build-image.html). | |
|
121 | WINDOWS_USER_DATA = ''' | |
|
122 | <powershell> | |
|
123 | ||
|
124 | # TODO enable this once we figure out what is failing. | |
|
125 | #$ErrorActionPreference = "stop" | |
|
126 | ||
|
127 | # Set administrator password | |
|
128 | net user Administrator "%s" | |
|
129 | wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE | |
|
130 | ||
|
131 | # First, make sure WinRM can't be connected to | |
|
132 | netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block | |
|
133 | ||
|
134 | # Delete any existing WinRM listeners | |
|
135 | winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null | |
|
136 | winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null | |
|
137 | ||
|
138 | # Create a new WinRM listener and configure | |
|
139 | winrm create winrm/config/listener?Address=*+Transport=HTTP | |
|
140 | winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}' | |
|
141 | winrm set winrm/config '@{MaxTimeoutms="7200000"}' | |
|
142 | winrm set winrm/config/service '@{AllowUnencrypted="true"}' | |
|
143 | winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}' | |
|
144 | winrm set winrm/config/service/auth '@{Basic="true"}' | |
|
145 | winrm set winrm/config/client/auth '@{Basic="true"}' | |
|
146 | ||
|
147 | # Configure UAC to allow privilege elevation in remote shells | |
|
148 | $Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' | |
|
149 | $Setting = 'LocalAccountTokenFilterPolicy' | |
|
150 | Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force | |
|
151 | ||
|
152 | # Configure and restart the WinRM Service; Enable the required firewall exception | |
|
153 | Stop-Service -Name WinRM | |
|
154 | Set-Service -Name WinRM -StartupType Automatic | |
|
155 | netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any | |
|
156 | Start-Service -Name WinRM | |
|
157 | ||
|
158 | # Disable firewall on private network interfaces so prompts don't appear. | |
|
159 | Set-NetFirewallProfile -Name private -Enabled false | |
|
160 | </powershell> | |
|
161 | '''.lstrip() | |
|
162 | ||
|
163 | ||
|
164 | WINDOWS_BOOTSTRAP_POWERSHELL = ''' | |
|
165 | Write-Output "installing PowerShell dependencies" | |
|
166 | Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | |
|
167 | Set-PSRepository -Name PSGallery -InstallationPolicy Trusted | |
|
168 | Install-Module -Name OpenSSHUtils -RequiredVersion 0.0.2.0 | |
|
169 | ||
|
170 | Write-Output "installing OpenSSL server" | |
|
171 | Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | |
|
172 | # Various tools will attempt to use older versions of .NET. So we enable | |
|
173 | # the feature that provides them so it doesn't have to be auto-enabled | |
|
174 | # later. | |
|
175 | Write-Output "enabling .NET Framework feature" | |
|
176 | Install-WindowsFeature -Name Net-Framework-Core | |
|
177 | ''' | |
|
178 | ||
|
179 | ||
|
180 | class AWSConnection: | |
|
181 | """Manages the state of a connection with AWS.""" | |
|
182 | ||
|
183 | def __init__(self, automation, region: str): | |
|
184 | self.automation = automation | |
|
185 | self.local_state_path = automation.state_path | |
|
186 | ||
|
187 | self.prefix = 'hg-' | |
|
188 | ||
|
189 | self.session = boto3.session.Session(region_name=region) | |
|
190 | self.ec2client = self.session.client('ec2') | |
|
191 | self.ec2resource = self.session.resource('ec2') | |
|
192 | self.iamclient = self.session.client('iam') | |
|
193 | self.iamresource = self.session.resource('iam') | |
|
194 | ||
|
195 | ensure_key_pairs(automation.state_path, self.ec2resource) | |
|
196 | ||
|
197 | self.security_groups = ensure_security_groups(self.ec2resource) | |
|
198 | ensure_iam_state(self.iamresource) | |
|
199 | ||
|
200 | def key_pair_path_private(self, name): | |
|
201 | """Path to a key pair private key file.""" | |
|
202 | return self.local_state_path / 'keys' / ('keypair-%s' % name) | |
|
203 | ||
|
204 | def key_pair_path_public(self, name): | |
|
205 | return self.local_state_path / 'keys' / ('keypair-%s.pub' % name) | |
|
206 | ||
|
207 | ||
|
208 | def rsa_key_fingerprint(p: pathlib.Path): | |
|
209 | """Compute the fingerprint of an RSA private key.""" | |
|
210 | ||
|
211 | # TODO use rsa package. | |
|
212 | res = subprocess.run( | |
|
213 | ['openssl', 'pkcs8', '-in', str(p), '-nocrypt', '-topk8', | |
|
214 | '-outform', 'DER'], | |
|
215 | capture_output=True, | |
|
216 | check=True) | |
|
217 | ||
|
218 | sha1 = hashlib.sha1(res.stdout).hexdigest() | |
|
219 | return ':'.join(a + b for a, b in zip(sha1[::2], sha1[1::2])) | |
|
220 | ||
|
221 | ||
|
222 | def ensure_key_pairs(state_path: pathlib.Path, ec2resource, prefix='hg-'): | |
|
223 | remote_existing = {} | |
|
224 | ||
|
225 | for kpi in ec2resource.key_pairs.all(): | |
|
226 | if kpi.name.startswith(prefix): | |
|
227 | remote_existing[kpi.name[len(prefix):]] = kpi.key_fingerprint | |
|
228 | ||
|
229 | # Validate that we have these keys locally. | |
|
230 | key_path = state_path / 'keys' | |
|
231 | key_path.mkdir(exist_ok=True, mode=0o700) | |
|
232 | ||
|
233 | def remove_remote(name): | |
|
234 | print('deleting key pair %s' % name) | |
|
235 | key = ec2resource.KeyPair(name) | |
|
236 | key.delete() | |
|
237 | ||
|
238 | def remove_local(name): | |
|
239 | pub_full = key_path / ('keypair-%s.pub' % name) | |
|
240 | priv_full = key_path / ('keypair-%s' % name) | |
|
241 | ||
|
242 | print('removing %s' % pub_full) | |
|
243 | pub_full.unlink() | |
|
244 | print('removing %s' % priv_full) | |
|
245 | priv_full.unlink() | |
|
246 | ||
|
247 | local_existing = {} | |
|
248 | ||
|
249 | for f in sorted(os.listdir(key_path)): | |
|
250 | if not f.startswith('keypair-') or not f.endswith('.pub'): | |
|
251 | continue | |
|
252 | ||
|
253 | name = f[len('keypair-'):-len('.pub')] | |
|
254 | ||
|
255 | pub_full = key_path / f | |
|
256 | priv_full = key_path / ('keypair-%s' % name) | |
|
257 | ||
|
258 | with open(pub_full, 'r', encoding='ascii') as fh: | |
|
259 | data = fh.read() | |
|
260 | ||
|
261 | if not data.startswith('ssh-rsa '): | |
|
262 | print('unexpected format for key pair file: %s; removing' % | |
|
263 | pub_full) | |
|
264 | pub_full.unlink() | |
|
265 | priv_full.unlink() | |
|
266 | continue | |
|
267 | ||
|
268 | local_existing[name] = rsa_key_fingerprint(priv_full) | |
|
269 | ||
|
270 | for name in sorted(set(remote_existing) | set(local_existing)): | |
|
271 | if name not in local_existing: | |
|
272 | actual = '%s%s' % (prefix, name) | |
|
273 | print('remote key %s does not exist locally' % name) | |
|
274 | remove_remote(actual) | |
|
275 | del remote_existing[name] | |
|
276 | ||
|
277 | elif name not in remote_existing: | |
|
278 | print('local key %s does not exist remotely' % name) | |
|
279 | remove_local(name) | |
|
280 | del local_existing[name] | |
|
281 | ||
|
282 | elif remote_existing[name] != local_existing[name]: | |
|
283 | print('key fingerprint mismatch for %s; ' | |
|
284 | 'removing from local and remote' % name) | |
|
285 | remove_local(name) | |
|
286 | remove_remote('%s%s' % (prefix, name)) | |
|
287 | del local_existing[name] | |
|
288 | del remote_existing[name] | |
|
289 | ||
|
290 | missing = KEY_PAIRS - set(remote_existing) | |
|
291 | ||
|
292 | for name in sorted(missing): | |
|
293 | actual = '%s%s' % (prefix, name) | |
|
294 | print('creating key pair %s' % actual) | |
|
295 | ||
|
296 | priv_full = key_path / ('keypair-%s' % name) | |
|
297 | pub_full = key_path / ('keypair-%s.pub' % name) | |
|
298 | ||
|
299 | kp = ec2resource.create_key_pair(KeyName=actual) | |
|
300 | ||
|
301 | with priv_full.open('w', encoding='ascii') as fh: | |
|
302 | fh.write(kp.key_material) | |
|
303 | fh.write('\n') | |
|
304 | ||
|
305 | priv_full.chmod(0o0600) | |
|
306 | ||
|
307 | # SSH public key can be extracted via `ssh-keygen`. | |
|
308 | with pub_full.open('w', encoding='ascii') as fh: | |
|
309 | subprocess.run( | |
|
310 | ['ssh-keygen', '-y', '-f', str(priv_full)], | |
|
311 | stdout=fh, | |
|
312 | check=True) | |
|
313 | ||
|
314 | pub_full.chmod(0o0600) | |
|
315 | ||
|
316 | ||
|
317 | def delete_instance_profile(profile): | |
|
318 | for role in profile.roles: | |
|
319 | print('removing role %s from instance profile %s' % (role.name, | |
|
320 | profile.name)) | |
|
321 | profile.remove_role(RoleName=role.name) | |
|
322 | ||
|
323 | print('deleting instance profile %s' % profile.name) | |
|
324 | profile.delete() | |
|
325 | ||
|
326 | ||
|
327 | def ensure_iam_state(iamresource, prefix='hg-'): | |
|
328 | """Ensure IAM state is in sync with our canonical definition.""" | |
|
329 | ||
|
330 | remote_profiles = {} | |
|
331 | ||
|
332 | for profile in iamresource.instance_profiles.all(): | |
|
333 | if profile.name.startswith(prefix): | |
|
334 | remote_profiles[profile.name[len(prefix):]] = profile | |
|
335 | ||
|
336 | for name in sorted(set(remote_profiles) - set(IAM_INSTANCE_PROFILES)): | |
|
337 | delete_instance_profile(remote_profiles[name]) | |
|
338 | del remote_profiles[name] | |
|
339 | ||
|
340 | remote_roles = {} | |
|
341 | ||
|
342 | for role in iamresource.roles.all(): | |
|
343 | if role.name.startswith(prefix): | |
|
344 | remote_roles[role.name[len(prefix):]] = role | |
|
345 | ||
|
346 | for name in sorted(set(remote_roles) - set(IAM_ROLES)): | |
|
347 | role = remote_roles[name] | |
|
348 | ||
|
349 | print('removing role %s' % role.name) | |
|
350 | role.delete() | |
|
351 | del remote_roles[name] | |
|
352 | ||
|
353 | # We've purged remote state that doesn't belong. Create missing | |
|
354 | # instance profiles and roles. | |
|
355 | for name in sorted(set(IAM_INSTANCE_PROFILES) - set(remote_profiles)): | |
|
356 | actual = '%s%s' % (prefix, name) | |
|
357 | print('creating IAM instance profile %s' % actual) | |
|
358 | ||
|
359 | profile = iamresource.create_instance_profile( | |
|
360 | InstanceProfileName=actual) | |
|
361 | remote_profiles[name] = profile | |
|
362 | ||
|
363 | for name in sorted(set(IAM_ROLES) - set(remote_roles)): | |
|
364 | entry = IAM_ROLES[name] | |
|
365 | ||
|
366 | actual = '%s%s' % (prefix, name) | |
|
367 | print('creating IAM role %s' % actual) | |
|
368 | ||
|
369 | role = iamresource.create_role( | |
|
370 | RoleName=actual, | |
|
371 | Description=entry['description'], | |
|
372 | AssumeRolePolicyDocument=ASSUME_ROLE_POLICY_DOCUMENT, | |
|
373 | ) | |
|
374 | ||
|
375 | remote_roles[name] = role | |
|
376 | ||
|
377 | for arn in entry['policy_arns']: | |
|
378 | print('attaching policy %s to %s' % (arn, role.name)) | |
|
379 | role.attach_policy(PolicyArn=arn) | |
|
380 | ||
|
381 | # Now reconcile state of profiles. | |
|
382 | for name, meta in sorted(IAM_INSTANCE_PROFILES.items()): | |
|
383 | profile = remote_profiles[name] | |
|
384 | wanted = {'%s%s' % (prefix, role) for role in meta['roles']} | |
|
385 | have = {role.name for role in profile.roles} | |
|
386 | ||
|
387 | for role in sorted(have - wanted): | |
|
388 | print('removing role %s from %s' % (role, profile.name)) | |
|
389 | profile.remove_role(RoleName=role) | |
|
390 | ||
|
391 | for role in sorted(wanted - have): | |
|
392 | print('adding role %s to %s' % (role, profile.name)) | |
|
393 | profile.add_role(RoleName=role) | |
|
394 | ||
|
395 | ||
|
396 | def find_windows_server_2019_image(ec2resource): | |
|
397 | """Find the Amazon published Windows Server 2019 base image.""" | |
|
398 | ||
|
399 | images = ec2resource.images.filter( | |
|
400 | Filters=[ | |
|
401 | { | |
|
402 | 'Name': 'owner-alias', | |
|
403 | 'Values': ['amazon'], | |
|
404 | }, | |
|
405 | { | |
|
406 | 'Name': 'state', | |
|
407 | 'Values': ['available'], | |
|
408 | }, | |
|
409 | { | |
|
410 | 'Name': 'image-type', | |
|
411 | 'Values': ['machine'], | |
|
412 | }, | |
|
413 | { | |
|
414 | 'Name': 'name', | |
|
415 | 'Values': ['Windows_Server-2019-English-Full-Base-2019.02.13'], | |
|
416 | }, | |
|
417 | ]) | |
|
418 | ||
|
419 | for image in images: | |
|
420 | return image | |
|
421 | ||
|
422 | raise Exception('unable to find Windows Server 2019 image') | |
|
423 | ||
|
424 | ||
|
425 | def ensure_security_groups(ec2resource, prefix='hg-'): | |
|
426 | """Ensure all necessary Mercurial security groups are present. | |
|
427 | ||
|
428 | All security groups are prefixed with ``hg-`` by default. Any security | |
|
429 | groups having this prefix but aren't in our list are deleted. | |
|
430 | """ | |
|
431 | existing = {} | |
|
432 | ||
|
433 | for group in ec2resource.security_groups.all(): | |
|
434 | if group.group_name.startswith(prefix): | |
|
435 | existing[group.group_name[len(prefix):]] = group | |
|
436 | ||
|
437 | purge = set(existing) - set(SECURITY_GROUPS) | |
|
438 | ||
|
439 | for name in sorted(purge): | |
|
440 | group = existing[name] | |
|
441 | print('removing legacy security group: %s' % group.group_name) | |
|
442 | group.delete() | |
|
443 | ||
|
444 | security_groups = {} | |
|
445 | ||
|
446 | for name, group in sorted(SECURITY_GROUPS.items()): | |
|
447 | if name in existing: | |
|
448 | security_groups[name] = existing[name] | |
|
449 | continue | |
|
450 | ||
|
451 | actual = '%s%s' % (prefix, name) | |
|
452 | print('adding security group %s' % actual) | |
|
453 | ||
|
454 | group_res = ec2resource.create_security_group( | |
|
455 | Description=group['description'], | |
|
456 | GroupName=actual, | |
|
457 | ) | |
|
458 | ||
|
459 | group_res.authorize_ingress( | |
|
460 | IpPermissions=group['ingress'], | |
|
461 | ) | |
|
462 | ||
|
463 | security_groups[name] = group_res | |
|
464 | ||
|
465 | return security_groups | |
|
466 | ||
|
467 | ||
|
468 | def terminate_ec2_instances(ec2resource, prefix='hg-'): | |
|
469 | """Terminate all EC2 instances managed by us.""" | |
|
470 | waiting = [] | |
|
471 | ||
|
472 | for instance in ec2resource.instances.all(): | |
|
473 | if instance.state['Name'] == 'terminated': | |
|
474 | continue | |
|
475 | ||
|
476 | for tag in instance.tags or []: | |
|
477 | if tag['Key'] == 'Name' and tag['Value'].startswith(prefix): | |
|
478 | print('terminating %s' % instance.id) | |
|
479 | instance.terminate() | |
|
480 | waiting.append(instance) | |
|
481 | ||
|
482 | for instance in waiting: | |
|
483 | instance.wait_until_terminated() | |
|
484 | ||
|
485 | ||
|
486 | def remove_resources(c, prefix='hg-'): | |
|
487 | """Purge all of our resources in this EC2 region.""" | |
|
488 | ec2resource = c.ec2resource | |
|
489 | iamresource = c.iamresource | |
|
490 | ||
|
491 | terminate_ec2_instances(ec2resource, prefix=prefix) | |
|
492 | ||
|
493 | for image in ec2resource.images.all(): | |
|
494 | if image.name.startswith(prefix): | |
|
495 | remove_ami(ec2resource, image) | |
|
496 | ||
|
497 | for group in ec2resource.security_groups.all(): | |
|
498 | if group.group_name.startswith(prefix): | |
|
499 | print('removing security group %s' % group.group_name) | |
|
500 | group.delete() | |
|
501 | ||
|
502 | for profile in iamresource.instance_profiles.all(): | |
|
503 | if profile.name.startswith(prefix): | |
|
504 | delete_instance_profile(profile) | |
|
505 | ||
|
506 | for role in iamresource.roles.all(): | |
|
507 | if role.name.startswith(prefix): | |
|
508 | print('removing role %s' % role.name) | |
|
509 | role.delete() | |
|
510 | ||
|
511 | ||
|
512 | def wait_for_ip_addresses(instances): | |
|
513 | """Wait for the public IP addresses of an iterable of instances.""" | |
|
514 | for instance in instances: | |
|
515 | while True: | |
|
516 | if not instance.public_ip_address: | |
|
517 | time.sleep(2) | |
|
518 | instance.reload() | |
|
519 | continue | |
|
520 | ||
|
521 | print('public IP address for %s: %s' % ( | |
|
522 | instance.id, instance.public_ip_address)) | |
|
523 | break | |
|
524 | ||
|
525 | ||
|
526 | def remove_ami(ec2resource, image): | |
|
527 | """Remove an AMI and its underlying snapshots.""" | |
|
528 | snapshots = [] | |
|
529 | ||
|
530 | for device in image.block_device_mappings: | |
|
531 | if 'Ebs' in device: | |
|
532 | snapshots.append(ec2resource.Snapshot(device['Ebs']['SnapshotId'])) | |
|
533 | ||
|
534 | print('deregistering %s' % image.id) | |
|
535 | image.deregister() | |
|
536 | ||
|
537 | for snapshot in snapshots: | |
|
538 | print('deleting snapshot %s' % snapshot.id) | |
|
539 | snapshot.delete() | |
|
540 | ||
|
541 | ||
|
542 | def wait_for_ssm(ssmclient, instances): | |
|
543 | """Wait for SSM to come online for an iterable of instance IDs.""" | |
|
544 | while True: | |
|
545 | res = ssmclient.describe_instance_information( | |
|
546 | Filters=[ | |
|
547 | { | |
|
548 | 'Key': 'InstanceIds', | |
|
549 | 'Values': [i.id for i in instances], | |
|
550 | }, | |
|
551 | ], | |
|
552 | ) | |
|
553 | ||
|
554 | available = len(res['InstanceInformationList']) | |
|
555 | wanted = len(instances) | |
|
556 | ||
|
557 | print('%d/%d instances available in SSM' % (available, wanted)) | |
|
558 | ||
|
559 | if available == wanted: | |
|
560 | return | |
|
561 | ||
|
562 | time.sleep(2) | |
|
563 | ||
|
564 | ||
|
565 | def run_ssm_command(ssmclient, instances, document_name, parameters): | |
|
566 | """Run a PowerShell script on an EC2 instance.""" | |
|
567 | ||
|
568 | res = ssmclient.send_command( | |
|
569 | InstanceIds=[i.id for i in instances], | |
|
570 | DocumentName=document_name, | |
|
571 | Parameters=parameters, | |
|
572 | CloudWatchOutputConfig={ | |
|
573 | 'CloudWatchOutputEnabled': True, | |
|
574 | }, | |
|
575 | ) | |
|
576 | ||
|
577 | command_id = res['Command']['CommandId'] | |
|
578 | ||
|
579 | for instance in instances: | |
|
580 | while True: | |
|
581 | try: | |
|
582 | res = ssmclient.get_command_invocation( | |
|
583 | CommandId=command_id, | |
|
584 | InstanceId=instance.id, | |
|
585 | ) | |
|
586 | except botocore.exceptions.ClientError as e: | |
|
587 | if e.response['Error']['Code'] == 'InvocationDoesNotExist': | |
|
588 | print('could not find SSM command invocation; waiting') | |
|
589 | time.sleep(1) | |
|
590 | continue | |
|
591 | else: | |
|
592 | raise | |
|
593 | ||
|
594 | if res['Status'] == 'Success': | |
|
595 | break | |
|
596 | elif res['Status'] in ('Pending', 'InProgress', 'Delayed'): | |
|
597 | time.sleep(2) | |
|
598 | else: | |
|
599 | raise Exception('command failed on %s: %s' % ( | |
|
600 | instance.id, res['Status'])) | |
|
601 | ||
|
602 | ||
|
603 | @contextlib.contextmanager | |
|
604 | def temporary_ec2_instances(ec2resource, config): | |
|
605 | """Create temporary EC2 instances. | |
|
606 | ||
|
607 | This is a proxy to ``ec2client.run_instances(**config)`` that takes care of | |
|
608 | managing the lifecycle of the instances. | |
|
609 | ||
|
610 | When the context manager exits, the instances are terminated. | |
|
611 | ||
|
612 | The context manager evaluates to the list of data structures | |
|
613 | describing each created instance. The instances may not be available | |
|
614 | for work immediately: it is up to the caller to wait for the instance | |
|
615 | to start responding. | |
|
616 | """ | |
|
617 | ||
|
618 | ids = None | |
|
619 | ||
|
620 | try: | |
|
621 | res = ec2resource.create_instances(**config) | |
|
622 | ||
|
623 | ids = [i.id for i in res] | |
|
624 | print('started instances: %s' % ' '.join(ids)) | |
|
625 | ||
|
626 | yield res | |
|
627 | finally: | |
|
628 | if ids: | |
|
629 | print('terminating instances: %s' % ' '.join(ids)) | |
|
630 | for instance in res: | |
|
631 | instance.terminate() | |
|
632 | print('terminated %d instances' % len(ids)) | |
|
633 | ||
|
634 | ||
|
635 | @contextlib.contextmanager | |
|
636 | def create_temp_windows_ec2_instances(c: AWSConnection, config): | |
|
637 | """Create temporary Windows EC2 instances. | |
|
638 | ||
|
639 | This is a higher-level wrapper around ``create_temp_ec2_instances()`` that | |
|
640 | configures the Windows instance for Windows Remote Management. The emitted | |
|
641 | instances will have a ``winrm_client`` attribute containing a | |
|
642 | ``pypsrp.client.Client`` instance bound to the instance. | |
|
643 | """ | |
|
644 | if 'IamInstanceProfile' in config: | |
|
645 | raise ValueError('IamInstanceProfile cannot be provided in config') | |
|
646 | if 'UserData' in config: | |
|
647 | raise ValueError('UserData cannot be provided in config') | |
|
648 | ||
|
649 | password = c.automation.default_password() | |
|
650 | ||
|
651 | config = copy.deepcopy(config) | |
|
652 | config['IamInstanceProfile'] = { | |
|
653 | 'Name': 'hg-ephemeral-ec2-1', | |
|
654 | } | |
|
655 | config.setdefault('TagSpecifications', []).append({ | |
|
656 | 'ResourceType': 'instance', | |
|
657 | 'Tags': [{'Key': 'Name', 'Value': 'hg-temp-windows'}], | |
|
658 | }) | |
|
659 | config['UserData'] = WINDOWS_USER_DATA % password | |
|
660 | ||
|
661 | with temporary_ec2_instances(c.ec2resource, config) as instances: | |
|
662 | wait_for_ip_addresses(instances) | |
|
663 | ||
|
664 | print('waiting for Windows Remote Management service...') | |
|
665 | ||
|
666 | for instance in instances: | |
|
667 | client = wait_for_winrm(instance.public_ip_address, 'Administrator', password) | |
|
668 | print('established WinRM connection to %s' % instance.id) | |
|
669 | instance.winrm_client = client | |
|
670 | ||
|
671 | yield instances | |
|
672 | ||
|
673 | ||
|
674 | def ensure_windows_dev_ami(c: AWSConnection, prefix='hg-'): | |
|
675 | """Ensure Windows Development AMI is available and up-to-date. | |
|
676 | ||
|
677 | If necessary, a modern AMI will be built by starting a temporary EC2 | |
|
678 | instance and bootstrapping it. | |
|
679 | ||
|
680 | Obsolete AMIs will be deleted so there is only a single AMI having the | |
|
681 | desired name. | |
|
682 | ||
|
683 | Returns an ``ec2.Image`` of either an existing AMI or a newly-built | |
|
684 | one. | |
|
685 | """ | |
|
686 | ec2client = c.ec2client | |
|
687 | ec2resource = c.ec2resource | |
|
688 | ssmclient = c.session.client('ssm') | |
|
689 | ||
|
690 | name = '%s%s' % (prefix, 'windows-dev') | |
|
691 | ||
|
692 | config = { | |
|
693 | 'BlockDeviceMappings': [ | |
|
694 | { | |
|
695 | 'DeviceName': '/dev/sda1', | |
|
696 | 'Ebs': { | |
|
697 | 'DeleteOnTermination': True, | |
|
698 | 'VolumeSize': 32, | |
|
699 | 'VolumeType': 'gp2', | |
|
700 | }, | |
|
701 | } | |
|
702 | ], | |
|
703 | 'ImageId': find_windows_server_2019_image(ec2resource).id, | |
|
704 | 'InstanceInitiatedShutdownBehavior': 'stop', | |
|
705 | 'InstanceType': 't3.medium', | |
|
706 | 'KeyName': '%sautomation' % prefix, | |
|
707 | 'MaxCount': 1, | |
|
708 | 'MinCount': 1, | |
|
709 | 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id], | |
|
710 | } | |
|
711 | ||
|
712 | commands = [ | |
|
713 | # Need to start the service so sshd_config is generated. | |
|
714 | 'Start-Service sshd', | |
|
715 | 'Write-Output "modifying sshd_config"', | |
|
716 | r'$content = Get-Content C:\ProgramData\ssh\sshd_config', | |
|
717 | '$content = $content -replace "Match Group administrators","" -replace "AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys",""', | |
|
718 | r'$content | Set-Content C:\ProgramData\ssh\sshd_config', | |
|
719 | 'Import-Module OpenSSHUtils', | |
|
720 | r'Repair-SshdConfigPermission C:\ProgramData\ssh\sshd_config -Confirm:$false', | |
|
721 | 'Restart-Service sshd', | |
|
722 | 'Write-Output "installing OpenSSL client"', | |
|
723 | 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0', | |
|
724 | 'Set-Service -Name sshd -StartupType "Automatic"', | |
|
725 | 'Write-Output "OpenSSH server running"', | |
|
726 | ] | |
|
727 | ||
|
728 | with INSTALL_WINDOWS_DEPENDENCIES.open('r', encoding='utf-8') as fh: | |
|
729 | commands.extend(l.rstrip() for l in fh) | |
|
730 | ||
|
731 | # Disable Windows Defender when bootstrapping because it just slows | |
|
732 | # things down. | |
|
733 | commands.insert(0, 'Set-MpPreference -DisableRealtimeMonitoring $true') | |
|
734 | commands.append('Set-MpPreference -DisableRealtimeMonitoring $false') | |
|
735 | ||
|
736 | # Compute a deterministic fingerprint to determine whether image needs | |
|
737 | # to be regenerated. | |
|
738 | fingerprint = { | |
|
739 | 'instance_config': config, | |
|
740 | 'user_data': WINDOWS_USER_DATA, | |
|
741 | 'initial_bootstrap': WINDOWS_BOOTSTRAP_POWERSHELL, | |
|
742 | 'bootstrap_commands': commands, | |
|
743 | } | |
|
744 | ||
|
745 | fingerprint = json.dumps(fingerprint, sort_keys=True) | |
|
746 | fingerprint = hashlib.sha256(fingerprint.encode('utf-8')).hexdigest() | |
|
747 | ||
|
748 | # Find existing AMIs with this name and delete the ones that are invalid. | |
|
749 | # Store a reference to a good image so it can be returned one the | |
|
750 | # image state is reconciled. | |
|
751 | images = ec2resource.images.filter( | |
|
752 | Filters=[{'Name': 'name', 'Values': [name]}]) | |
|
753 | ||
|
754 | existing_image = None | |
|
755 | ||
|
756 | for image in images: | |
|
757 | if image.tags is None: | |
|
758 | print('image %s for %s lacks required tags; removing' % ( | |
|
759 | image.id, image.name)) | |
|
760 | remove_ami(ec2resource, image) | |
|
761 | else: | |
|
762 | tags = {t['Key']: t['Value'] for t in image.tags} | |
|
763 | ||
|
764 | if tags.get('HGIMAGEFINGERPRINT') == fingerprint: | |
|
765 | existing_image = image | |
|
766 | else: | |
|
767 | print('image %s for %s has wrong fingerprint; removing' % ( | |
|
768 | image.id, image.name)) | |
|
769 | remove_ami(ec2resource, image) | |
|
770 | ||
|
771 | if existing_image: | |
|
772 | return existing_image | |
|
773 | ||
|
774 | print('no suitable Windows development image found; creating one...') | |
|
775 | ||
|
776 | with create_temp_windows_ec2_instances(c, config) as instances: | |
|
777 | assert len(instances) == 1 | |
|
778 | instance = instances[0] | |
|
779 | ||
|
780 | wait_for_ssm(ssmclient, [instance]) | |
|
781 | ||
|
782 | # On first boot, install various Windows updates. | |
|
783 | # We would ideally use PowerShell Remoting for this. However, there are | |
|
784 | # trust issues that make it difficult to invoke Windows Update | |
|
785 | # remotely. So we use SSM, which has a mechanism for running Windows | |
|
786 | # Update. | |
|
787 | print('installing Windows features...') | |
|
788 | run_ssm_command( | |
|
789 | ssmclient, | |
|
790 | [instance], | |
|
791 | 'AWS-RunPowerShellScript', | |
|
792 | { | |
|
793 | 'commands': WINDOWS_BOOTSTRAP_POWERSHELL.split('\n'), | |
|
794 | }, | |
|
795 | ) | |
|
796 | ||
|
797 | # Reboot so all updates are fully applied. | |
|
798 | print('rebooting instance %s' % instance.id) | |
|
799 | ec2client.reboot_instances(InstanceIds=[instance.id]) | |
|
800 | ||
|
801 | time.sleep(15) | |
|
802 | ||
|
803 | print('waiting for Windows Remote Management to come back...') | |
|
804 | client = wait_for_winrm(instance.public_ip_address, 'Administrator', | |
|
805 | c.automation.default_password()) | |
|
806 | print('established WinRM connection to %s' % instance.id) | |
|
807 | instance.winrm_client = client | |
|
808 | ||
|
809 | print('bootstrapping instance...') | |
|
810 | run_powershell(instance.winrm_client, '\n'.join(commands)) | |
|
811 | ||
|
812 | print('bootstrap completed; stopping %s to create image' % instance.id) | |
|
813 | instance.stop() | |
|
814 | ||
|
815 | ec2client.get_waiter('instance_stopped').wait( | |
|
816 | InstanceIds=[instance.id], | |
|
817 | WaiterConfig={ | |
|
818 | 'Delay': 5, | |
|
819 | }) | |
|
820 | print('%s is stopped' % instance.id) | |
|
821 | ||
|
822 | image = instance.create_image( | |
|
823 | Name=name, | |
|
824 | Description='Mercurial Windows development environment', | |
|
825 | ) | |
|
826 | ||
|
827 | image.create_tags(Tags=[ | |
|
828 | { | |
|
829 | 'Key': 'HGIMAGEFINGERPRINT', | |
|
830 | 'Value': fingerprint, | |
|
831 | }, | |
|
832 | ]) | |
|
833 | ||
|
834 | print('waiting for image %s' % image.id) | |
|
835 | ||
|
836 | ec2client.get_waiter('image_available').wait( | |
|
837 | ImageIds=[image.id], | |
|
838 | ) | |
|
839 | ||
|
840 | print('image %s available as %s' % (image.id, image.name)) | |
|
841 | ||
|
842 | return image | |
|
843 | ||
|
844 | ||
|
845 | @contextlib.contextmanager | |
|
846 | def temporary_windows_dev_instances(c: AWSConnection, image, instance_type, | |
|
847 | prefix='hg-', disable_antivirus=False): | |
|
848 | """Create a temporary Windows development EC2 instance. | |
|
849 | ||
|
850 | Context manager resolves to the list of ``EC2.Instance`` that were created. | |
|
851 | """ | |
|
852 | config = { | |
|
853 | 'BlockDeviceMappings': [ | |
|
854 | { | |
|
855 | 'DeviceName': '/dev/sda1', | |
|
856 | 'Ebs': { | |
|
857 | 'DeleteOnTermination': True, | |
|
858 | 'VolumeSize': 32, | |
|
859 | 'VolumeType': 'gp2', | |
|
860 | }, | |
|
861 | } | |
|
862 | ], | |
|
863 | 'ImageId': image.id, | |
|
864 | 'InstanceInitiatedShutdownBehavior': 'stop', | |
|
865 | 'InstanceType': instance_type, | |
|
866 | 'KeyName': '%sautomation' % prefix, | |
|
867 | 'MaxCount': 1, | |
|
868 | 'MinCount': 1, | |
|
869 | 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id], | |
|
870 | } | |
|
871 | ||
|
872 | with create_temp_windows_ec2_instances(c, config) as instances: | |
|
873 | if disable_antivirus: | |
|
874 | for instance in instances: | |
|
875 | run_powershell( | |
|
876 | instance.winrm_client, | |
|
877 | 'Set-MpPreference -DisableRealtimeMonitoring $true') | |
|
878 | ||
|
879 | yield instances |
@@ -0,0 +1,273 b'' | |||
|
1 | # cli.py - Command line interface for automation | |
|
2 | # | |
|
3 | # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | |
|
4 | # | |
|
5 | # This software may be used and distributed according to the terms of the | |
|
6 | # GNU General Public License version 2 or any later version. | |
|
7 | ||
|
8 | # no-check-code because Python 3 native. | |
|
9 | ||
|
10 | import argparse | |
|
11 | import os | |
|
12 | import pathlib | |
|
13 | ||
|
14 | from . import ( | |
|
15 | aws, | |
|
16 | HGAutomation, | |
|
17 | windows, | |
|
18 | ) | |
|
19 | ||
|
20 | ||
|
21 | SOURCE_ROOT = pathlib.Path(os.path.abspath(__file__)).parent.parent.parent.parent | |
|
22 | DIST_PATH = SOURCE_ROOT / 'dist' | |
|
23 | ||
|
24 | ||
|
25 | def bootstrap_windows_dev(hga: HGAutomation, aws_region): | |
|
26 | c = hga.aws_connection(aws_region) | |
|
27 | image = aws.ensure_windows_dev_ami(c) | |
|
28 | print('Windows development AMI available as %s' % image.id) | |
|
29 | ||
|
30 | ||
|
31 | def build_inno(hga: HGAutomation, aws_region, arch, revision, version): | |
|
32 | c = hga.aws_connection(aws_region) | |
|
33 | image = aws.ensure_windows_dev_ami(c) | |
|
34 | DIST_PATH.mkdir(exist_ok=True) | |
|
35 | ||
|
36 | with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts: | |
|
37 | instance = insts[0] | |
|
38 | ||
|
39 | windows.synchronize_hg(SOURCE_ROOT, revision, instance) | |
|
40 | ||
|
41 | for a in arch: | |
|
42 | windows.build_inno_installer(instance.winrm_client, a, | |
|
43 | DIST_PATH, | |
|
44 | version=version) | |
|
45 | ||
|
46 | ||
|
47 | def build_wix(hga: HGAutomation, aws_region, arch, revision, version): | |
|
48 | c = hga.aws_connection(aws_region) | |
|
49 | image = aws.ensure_windows_dev_ami(c) | |
|
50 | DIST_PATH.mkdir(exist_ok=True) | |
|
51 | ||
|
52 | with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts: | |
|
53 | instance = insts[0] | |
|
54 | ||
|
55 | windows.synchronize_hg(SOURCE_ROOT, revision, instance) | |
|
56 | ||
|
57 | for a in arch: | |
|
58 | windows.build_wix_installer(instance.winrm_client, a, | |
|
59 | DIST_PATH, version=version) | |
|
60 | ||
|
61 | ||
|
62 | def build_windows_wheel(hga: HGAutomation, aws_region, arch, revision): | |
|
63 | c = hga.aws_connection(aws_region) | |
|
64 | image = aws.ensure_windows_dev_ami(c) | |
|
65 | DIST_PATH.mkdir(exist_ok=True) | |
|
66 | ||
|
67 | with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts: | |
|
68 | instance = insts[0] | |
|
69 | ||
|
70 | windows.synchronize_hg(SOURCE_ROOT, revision, instance) | |
|
71 | ||
|
72 | for a in arch: | |
|
73 | windows.build_wheel(instance.winrm_client, a, DIST_PATH) | |
|
74 | ||
|
75 | ||
|
76 | def build_all_windows_packages(hga: HGAutomation, aws_region, revision): | |
|
77 | c = hga.aws_connection(aws_region) | |
|
78 | image = aws.ensure_windows_dev_ami(c) | |
|
79 | DIST_PATH.mkdir(exist_ok=True) | |
|
80 | ||
|
81 | with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts: | |
|
82 | instance = insts[0] | |
|
83 | ||
|
84 | winrm_client = instance.winrm_client | |
|
85 | ||
|
86 | windows.synchronize_hg(SOURCE_ROOT, revision, instance) | |
|
87 | ||
|
88 | for arch in ('x86', 'x64'): | |
|
89 | windows.purge_hg(winrm_client) | |
|
90 | windows.build_wheel(winrm_client, arch, DIST_PATH) | |
|
91 | windows.purge_hg(winrm_client) | |
|
92 | windows.build_inno_installer(winrm_client, arch, DIST_PATH) | |
|
93 | windows.purge_hg(winrm_client) | |
|
94 | windows.build_wix_installer(winrm_client, arch, DIST_PATH) | |
|
95 | ||
|
96 | ||
|
97 | def terminate_ec2_instances(hga: HGAutomation, aws_region): | |
|
98 | c = hga.aws_connection(aws_region) | |
|
99 | aws.terminate_ec2_instances(c.ec2resource) | |
|
100 | ||
|
101 | ||
|
102 | def purge_ec2_resources(hga: HGAutomation, aws_region): | |
|
103 | c = hga.aws_connection(aws_region) | |
|
104 | aws.remove_resources(c) | |
|
105 | ||
|
106 | ||
|
107 | def run_tests_windows(hga: HGAutomation, aws_region, instance_type, | |
|
108 | python_version, arch, test_flags): | |
|
109 | c = hga.aws_connection(aws_region) | |
|
110 | image = aws.ensure_windows_dev_ami(c) | |
|
111 | ||
|
112 | with aws.temporary_windows_dev_instances(c, image, instance_type, | |
|
113 | disable_antivirus=True) as insts: | |
|
114 | instance = insts[0] | |
|
115 | ||
|
116 | windows.synchronize_hg(SOURCE_ROOT, '.', instance) | |
|
117 | windows.run_tests(instance.winrm_client, python_version, arch, | |
|
118 | test_flags) | |
|
119 | ||
|
120 | ||
|
121 | def get_parser(): | |
|
122 | parser = argparse.ArgumentParser() | |
|
123 | ||
|
124 | parser.add_argument( | |
|
125 | '--state-path', | |
|
126 | default='~/.hgautomation', | |
|
127 | help='Path for local state files', | |
|
128 | ) | |
|
129 | parser.add_argument( | |
|
130 | '--aws-region', | |
|
131 | help='AWS region to use', | |
|
132 | default='us-west-1', | |
|
133 | ) | |
|
134 | ||
|
135 | subparsers = parser.add_subparsers() | |
|
136 | ||
|
137 | sp = subparsers.add_parser( | |
|
138 | 'bootstrap-windows-dev', | |
|
139 | help='Bootstrap the Windows development environment', | |
|
140 | ) | |
|
141 | sp.set_defaults(func=bootstrap_windows_dev) | |
|
142 | ||
|
143 | sp = subparsers.add_parser( | |
|
144 | 'build-all-windows-packages', | |
|
145 | help='Build all Windows packages', | |
|
146 | ) | |
|
147 | sp.add_argument( | |
|
148 | '--revision', | |
|
149 | help='Mercurial revision to build', | |
|
150 | default='.', | |
|
151 | ) | |
|
152 | sp.set_defaults(func=build_all_windows_packages) | |
|
153 | ||
|
154 | sp = subparsers.add_parser( | |
|
155 | 'build-inno', | |
|
156 | help='Build Inno Setup installer(s)', | |
|
157 | ) | |
|
158 | sp.add_argument( | |
|
159 | '--arch', | |
|
160 | help='Architecture to build for', | |
|
161 | choices={'x86', 'x64'}, | |
|
162 | nargs='*', | |
|
163 | default=['x64'], | |
|
164 | ) | |
|
165 | sp.add_argument( | |
|
166 | '--revision', | |
|
167 | help='Mercurial revision to build', | |
|
168 | default='.', | |
|
169 | ) | |
|
170 | sp.add_argument( | |
|
171 | '--version', | |
|
172 | help='Mercurial version string to use in installer', | |
|
173 | ) | |
|
174 | sp.set_defaults(func=build_inno) | |
|
175 | ||
|
176 | sp = subparsers.add_parser( | |
|
177 | 'build-windows-wheel', | |
|
178 | help='Build Windows wheel(s)', | |
|
179 | ) | |
|
180 | sp.add_argument( | |
|
181 | '--arch', | |
|
182 | help='Architecture to build for', | |
|
183 | choices={'x86', 'x64'}, | |
|
184 | nargs='*', | |
|
185 | default=['x64'], | |
|
186 | ) | |
|
187 | sp.add_argument( | |
|
188 | '--revision', | |
|
189 | help='Mercurial revision to build', | |
|
190 | default='.', | |
|
191 | ) | |
|
192 | sp.set_defaults(func=build_windows_wheel) | |
|
193 | ||
|
194 | sp = subparsers.add_parser( | |
|
195 | 'build-wix', | |
|
196 | help='Build WiX installer(s)' | |
|
197 | ) | |
|
198 | sp.add_argument( | |
|
199 | '--arch', | |
|
200 | help='Architecture to build for', | |
|
201 | choices={'x86', 'x64'}, | |
|
202 | nargs='*', | |
|
203 | default=['x64'], | |
|
204 | ) | |
|
205 | sp.add_argument( | |
|
206 | '--revision', | |
|
207 | help='Mercurial revision to build', | |
|
208 | default='.', | |
|
209 | ) | |
|
210 | sp.add_argument( | |
|
211 | '--version', | |
|
212 | help='Mercurial version string to use in installer', | |
|
213 | ) | |
|
214 | sp.set_defaults(func=build_wix) | |
|
215 | ||
|
216 | sp = subparsers.add_parser( | |
|
217 | 'terminate-ec2-instances', | |
|
218 | help='Terminate all active EC2 instances managed by us', | |
|
219 | ) | |
|
220 | sp.set_defaults(func=terminate_ec2_instances) | |
|
221 | ||
|
222 | sp = subparsers.add_parser( | |
|
223 | 'purge-ec2-resources', | |
|
224 | help='Purge all EC2 resources managed by us', | |
|
225 | ) | |
|
226 | sp.set_defaults(func=purge_ec2_resources) | |
|
227 | ||
|
228 | sp = subparsers.add_parser( | |
|
229 | 'run-tests-windows', | |
|
230 | help='Run tests on Windows', | |
|
231 | ) | |
|
232 | sp.add_argument( | |
|
233 | '--instance-type', | |
|
234 | help='EC2 instance type to use', | |
|
235 | default='t3.medium', | |
|
236 | ) | |
|
237 | sp.add_argument( | |
|
238 | '--python-version', | |
|
239 | help='Python version to use', | |
|
240 | choices={'2.7', '3.5', '3.6', '3.7', '3.8'}, | |
|
241 | default='2.7', | |
|
242 | ) | |
|
243 | sp.add_argument( | |
|
244 | '--arch', | |
|
245 | help='Architecture to test', | |
|
246 | choices={'x86', 'x64'}, | |
|
247 | default='x64', | |
|
248 | ) | |
|
249 | sp.add_argument( | |
|
250 | '--test-flags', | |
|
251 | help='Extra command line flags to pass to run-tests.py', | |
|
252 | ) | |
|
253 | sp.set_defaults(func=run_tests_windows) | |
|
254 | ||
|
255 | return parser | |
|
256 | ||
|
257 | ||
|
258 | def main(): | |
|
259 | parser = get_parser() | |
|
260 | args = parser.parse_args() | |
|
261 | ||
|
262 | local_state_path = pathlib.Path(os.path.expanduser(args.state_path)) | |
|
263 | automation = HGAutomation(local_state_path) | |
|
264 | ||
|
265 | if not hasattr(args, 'func'): | |
|
266 | parser.print_help() | |
|
267 | return | |
|
268 | ||
|
269 | kwargs = dict(vars(args)) | |
|
270 | del kwargs['func'] | |
|
271 | del kwargs['state_path'] | |
|
272 | ||
|
273 | args.func(automation, **kwargs) |
@@ -0,0 +1,287 b'' | |||
|
1 | # windows.py - Automation specific to Windows | |
|
2 | # | |
|
3 | # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | |
|
4 | # | |
|
5 | # This software may be used and distributed according to the terms of the | |
|
6 | # GNU General Public License version 2 or any later version. | |
|
7 | ||
|
8 | # no-check-code because Python 3 native. | |
|
9 | ||
|
10 | import os | |
|
11 | import pathlib | |
|
12 | import re | |
|
13 | import subprocess | |
|
14 | import tempfile | |
|
15 | ||
|
16 | from .winrm import ( | |
|
17 | run_powershell, | |
|
18 | ) | |
|
19 | ||
|
20 | ||
|
21 | # PowerShell commands to activate a Visual Studio 2008 environment. | |
|
22 | # This is essentially a port of vcvarsall.bat to PowerShell. | |
|
23 | ACTIVATE_VC9_AMD64 = r''' | |
|
24 | Write-Output "activating Visual Studio 2008 environment for AMD64" | |
|
25 | $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0" | |
|
26 | $Env:VCINSTALLDIR = "${root}\VC\" | |
|
27 | $Env:WindowsSdkDir = "${root}\WinSDK\" | |
|
28 | $Env:PATH = "${root}\VC\Bin\amd64;${root}\WinSDK\Bin\x64;${root}\WinSDK\Bin;$Env:PATH" | |
|
29 | $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:PATH" | |
|
30 | $Env:LIB = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIB" | |
|
31 | $Env:LIBPATH = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIBPATH" | |
|
32 | '''.lstrip() | |
|
33 | ||
|
34 | ACTIVATE_VC9_X86 = r''' | |
|
35 | Write-Output "activating Visual Studio 2008 environment for x86" | |
|
36 | $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0" | |
|
37 | $Env:VCINSTALLDIR = "${root}\VC\" | |
|
38 | $Env:WindowsSdkDir = "${root}\WinSDK\" | |
|
39 | $Env:PATH = "${root}\VC\Bin;${root}\WinSDK\Bin;$Env:PATH" | |
|
40 | $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:INCLUDE" | |
|
41 | $Env:LIB = "${root}\VC\Lib;${root}\WinSDK\Lib;$Env:LIB" | |
|
42 | $Env:LIBPATH = "${root}\VC\lib;${root}\WinSDK\Lib:$Env:LIBPATH" | |
|
43 | '''.lstrip() | |
|
44 | ||
|
45 | HG_PURGE = r''' | |
|
46 | $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH" | |
|
47 | Set-Location C:\hgdev\src | |
|
48 | hg.exe --config extensions.purge= purge --all | |
|
49 | if ($LASTEXITCODE -ne 0) { | |
|
50 | throw "process exited non-0: $LASTEXITCODE" | |
|
51 | } | |
|
52 | Write-Output "purged Mercurial repo" | |
|
53 | ''' | |
|
54 | ||
|
55 | HG_UPDATE_CLEAN = r''' | |
|
56 | $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH" | |
|
57 | Set-Location C:\hgdev\src | |
|
58 | hg.exe --config extensions.purge= purge --all | |
|
59 | if ($LASTEXITCODE -ne 0) {{ | |
|
60 | throw "process exited non-0: $LASTEXITCODE" | |
|
61 | }} | |
|
62 | hg.exe update -C {revision} | |
|
63 | if ($LASTEXITCODE -ne 0) {{ | |
|
64 | throw "process exited non-0: $LASTEXITCODE" | |
|
65 | }} | |
|
66 | hg.exe log -r . | |
|
67 | Write-Output "updated Mercurial working directory to {revision}" | |
|
68 | '''.lstrip() | |
|
69 | ||
|
70 | BUILD_INNO = r''' | |
|
71 | Set-Location C:\hgdev\src | |
|
72 | $python = "C:\hgdev\python27-{arch}\python.exe" | |
|
73 | C:\hgdev\python37-x64\python.exe contrib\packaging\inno\build.py --python $python | |
|
74 | if ($LASTEXITCODE -ne 0) {{ | |
|
75 | throw "process exited non-0: $LASTEXITCODE" | |
|
76 | }} | |
|
77 | '''.lstrip() | |
|
78 | ||
|
79 | BUILD_WHEEL = r''' | |
|
80 | Set-Location C:\hgdev\src | |
|
81 | C:\hgdev\python27-{arch}\Scripts\pip.exe wheel --wheel-dir dist . | |
|
82 | if ($LASTEXITCODE -ne 0) {{ | |
|
83 | throw "process exited non-0: $LASTEXITCODE" | |
|
84 | }} | |
|
85 | ''' | |
|
86 | ||
|
87 | BUILD_WIX = r''' | |
|
88 | Set-Location C:\hgdev\src | |
|
89 | $python = "C:\hgdev\python27-{arch}\python.exe" | |
|
90 | C:\hgdev\python37-x64\python.exe contrib\packaging\wix\build.py --python $python {extra_args} | |
|
91 | if ($LASTEXITCODE -ne 0) {{ | |
|
92 | throw "process exited non-0: $LASTEXITCODE" | |
|
93 | }} | |
|
94 | ''' | |
|
95 | ||
|
96 | RUN_TESTS = r''' | |
|
97 | C:\hgdev\MinGW\msys\1.0\bin\sh.exe --login -c "cd /c/hgdev/src/tests && /c/hgdev/{python_path}/python.exe run-tests.py {test_flags}" | |
|
98 | if ($LASTEXITCODE -ne 0) {{ | |
|
99 | throw "process exited non-0: $LASTEXITCODE" | |
|
100 | }} | |
|
101 | ''' | |
|
102 | ||
|
103 | ||
|
104 | def get_vc_prefix(arch): | |
|
105 | if arch == 'x86': | |
|
106 | return ACTIVATE_VC9_X86 | |
|
107 | elif arch == 'x64': | |
|
108 | return ACTIVATE_VC9_AMD64 | |
|
109 | else: | |
|
110 | raise ValueError('illegal arch: %s; must be x86 or x64' % arch) | |
|
111 | ||
|
112 | ||
|
113 | def fix_authorized_keys_permissions(winrm_client, path): | |
|
114 | commands = [ | |
|
115 | '$ErrorActionPreference = "Stop"', | |
|
116 | 'Repair-AuthorizedKeyPermission -FilePath %s -Confirm:$false' % path, | |
|
117 | 'icacls %s /remove:g "NT Service\sshd"' % path, | |
|
118 | ] | |
|
119 | ||
|
120 | run_powershell(winrm_client, '\n'.join(commands)) | |
|
121 | ||
|
122 | ||
|
123 | def synchronize_hg(hg_repo: pathlib.Path, revision: str, ec2_instance): | |
|
124 | """Synchronize local Mercurial repo to remote EC2 instance.""" | |
|
125 | ||
|
126 | winrm_client = ec2_instance.winrm_client | |
|
127 | ||
|
128 | with tempfile.TemporaryDirectory() as temp_dir: | |
|
129 | temp_dir = pathlib.Path(temp_dir) | |
|
130 | ||
|
131 | ssh_dir = temp_dir / '.ssh' | |
|
132 | ssh_dir.mkdir() | |
|
133 | ssh_dir.chmod(0o0700) | |
|
134 | ||
|
135 | # Generate SSH key to use for communication. | |
|
136 | subprocess.run([ | |
|
137 | 'ssh-keygen', '-t', 'rsa', '-b', '4096', '-N', '', | |
|
138 | '-f', str(ssh_dir / 'id_rsa')], | |
|
139 | check=True, capture_output=True) | |
|
140 | ||
|
141 | # Add it to ~/.ssh/authorized_keys on remote. | |
|
142 | # This assumes the file doesn't already exist. | |
|
143 | authorized_keys = r'c:\Users\Administrator\.ssh\authorized_keys' | |
|
144 | winrm_client.execute_cmd(r'mkdir c:\Users\Administrator\.ssh') | |
|
145 | winrm_client.copy(str(ssh_dir / 'id_rsa.pub'), authorized_keys) | |
|
146 | fix_authorized_keys_permissions(winrm_client, authorized_keys) | |
|
147 | ||
|
148 | public_ip = ec2_instance.public_ip_address | |
|
149 | ||
|
150 | ssh_config = temp_dir / '.ssh' / 'config' | |
|
151 | ||
|
152 | with open(ssh_config, 'w', encoding='utf-8') as fh: | |
|
153 | fh.write('Host %s\n' % public_ip) | |
|
154 | fh.write(' User Administrator\n') | |
|
155 | fh.write(' StrictHostKeyChecking no\n') | |
|
156 | fh.write(' UserKnownHostsFile %s\n' % (ssh_dir / 'known_hosts')) | |
|
157 | fh.write(' IdentityFile %s\n' % (ssh_dir / 'id_rsa')) | |
|
158 | ||
|
159 | env = dict(os.environ) | |
|
160 | env['HGPLAIN'] = '1' | |
|
161 | env['HGENCODING'] = 'utf-8' | |
|
162 | ||
|
163 | hg_bin = hg_repo / 'hg' | |
|
164 | ||
|
165 | res = subprocess.run( | |
|
166 | ['python2.7', str(hg_bin), 'log', '-r', revision, '-T', '{node}'], | |
|
167 | cwd=str(hg_repo), env=env, check=True, capture_output=True) | |
|
168 | ||
|
169 | full_revision = res.stdout.decode('ascii') | |
|
170 | ||
|
171 | args = [ | |
|
172 | 'python2.7', hg_bin, | |
|
173 | '--config', 'ui.ssh=ssh -F %s' % ssh_config, | |
|
174 | '--config', 'ui.remotecmd=c:/hgdev/venv-bootstrap/Scripts/hg.exe', | |
|
175 | 'push', '-r', full_revision, 'ssh://%s/c:/hgdev/src' % public_ip, | |
|
176 | ] | |
|
177 | ||
|
178 | subprocess.run(args, cwd=str(hg_repo), env=env, check=True) | |
|
179 | ||
|
180 | run_powershell(winrm_client, | |
|
181 | HG_UPDATE_CLEAN.format(revision=full_revision)) | |
|
182 | ||
|
183 | # TODO detect dirty local working directory and synchronize accordingly. | |
|
184 | ||
|
185 | ||
|
186 | def purge_hg(winrm_client): | |
|
187 | """Purge the Mercurial source repository on an EC2 instance.""" | |
|
188 | run_powershell(winrm_client, HG_PURGE) | |
|
189 | ||
|
190 | ||
|
191 | def find_latest_dist(winrm_client, pattern): | |
|
192 | """Find path to newest file in dist/ directory matching a pattern.""" | |
|
193 | ||
|
194 | res = winrm_client.execute_ps( | |
|
195 | '$v = Get-ChildItem -Path C:\hgdev\src\dist -Filter "%s" ' | |
|
196 | '| Sort-Object LastWriteTime -Descending ' | |
|
197 | '| Select-Object -First 1\n' | |
|
198 | '$v.name' % pattern | |
|
199 | ) | |
|
200 | return res[0] | |
|
201 | ||
|
202 | ||
|
203 | def copy_latest_dist(winrm_client, pattern, dest_path): | |
|
204 | """Copy latest file matching pattern in dist/ directory. | |
|
205 | ||
|
206 | Given a WinRM client and a file pattern, find the latest file on the remote | |
|
207 | matching that pattern and copy it to the ``dest_path`` directory on the | |
|
208 | local machine. | |
|
209 | """ | |
|
210 | latest = find_latest_dist(winrm_client, pattern) | |
|
211 | source = r'C:\hgdev\src\dist\%s' % latest | |
|
212 | dest = dest_path / latest | |
|
213 | print('copying %s to %s' % (source, dest)) | |
|
214 | winrm_client.fetch(source, str(dest)) | |
|
215 | ||
|
216 | ||
|
217 | def build_inno_installer(winrm_client, arch: str, dest_path: pathlib.Path, | |
|
218 | version=None): | |
|
219 | """Build the Inno Setup installer on a remote machine. | |
|
220 | ||
|
221 | Using a WinRM client, remote commands are executed to build | |
|
222 | a Mercurial Inno Setup installer. | |
|
223 | """ | |
|
224 | print('building Inno Setup installer for %s' % arch) | |
|
225 | ||
|
226 | extra_args = [] | |
|
227 | if version: | |
|
228 | extra_args.extend(['--version', version]) | |
|
229 | ||
|
230 | ps = get_vc_prefix(arch) + BUILD_INNO.format(arch=arch, | |
|
231 | extra_args=' '.join(extra_args)) | |
|
232 | run_powershell(winrm_client, ps) | |
|
233 | copy_latest_dist(winrm_client, '*.exe', dest_path) | |
|
234 | ||
|
235 | ||
|
236 | def build_wheel(winrm_client, arch: str, dest_path: pathlib.Path): | |
|
237 | """Build Python wheels on a remote machine. | |
|
238 | ||
|
239 | Using a WinRM client, remote commands are executed to build a Python wheel | |
|
240 | for Mercurial. | |
|
241 | """ | |
|
242 | print('Building Windows wheel for %s' % arch) | |
|
243 | ps = get_vc_prefix(arch) + BUILD_WHEEL.format(arch=arch) | |
|
244 | run_powershell(winrm_client, ps) | |
|
245 | copy_latest_dist(winrm_client, '*.whl', dest_path) | |
|
246 | ||
|
247 | ||
|
248 | def build_wix_installer(winrm_client, arch: str, dest_path: pathlib.Path, | |
|
249 | version=None): | |
|
250 | """Build the WiX installer on a remote machine. | |
|
251 | ||
|
252 | Using a WinRM client, remote commands are executed to build a WiX installer. | |
|
253 | """ | |
|
254 | print('Building WiX installer for %s' % arch) | |
|
255 | extra_args = [] | |
|
256 | if version: | |
|
257 | extra_args.extend(['--version', version]) | |
|
258 | ||
|
259 | ps = get_vc_prefix(arch) + BUILD_WIX.format(arch=arch, | |
|
260 | extra_args=' '.join(extra_args)) | |
|
261 | run_powershell(winrm_client, ps) | |
|
262 | copy_latest_dist(winrm_client, '*.msi', dest_path) | |
|
263 | ||
|
264 | ||
|
265 | def run_tests(winrm_client, python_version, arch, test_flags=''): | |
|
266 | """Run tests on a remote Windows machine. | |
|
267 | ||
|
268 | ``python_version`` is a ``X.Y`` string like ``2.7`` or ``3.7``. | |
|
269 | ``arch`` is ``x86`` or ``x64``. | |
|
270 | ``test_flags`` is a str representing extra arguments to pass to | |
|
271 | ``run-tests.py``. | |
|
272 | """ | |
|
273 | if not re.match('\d\.\d', python_version): | |
|
274 | raise ValueError('python_version must be \d.\d; got %s' % | |
|
275 | python_version) | |
|
276 | ||
|
277 | if arch not in ('x86', 'x64'): | |
|
278 | raise ValueError('arch must be x86 or x64; got %s' % arch) | |
|
279 | ||
|
280 | python_path = 'python%s-%s' % (python_version.replace('.', ''), arch) | |
|
281 | ||
|
282 | ps = RUN_TESTS.format( | |
|
283 | python_path=python_path, | |
|
284 | test_flags=test_flags or '', | |
|
285 | ) | |
|
286 | ||
|
287 | run_powershell(winrm_client, ps) |
@@ -0,0 +1,82 b'' | |||
|
1 | # winrm.py - Interact with Windows Remote Management (WinRM) | |
|
2 | # | |
|
3 | # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | |
|
4 | # | |
|
5 | # This software may be used and distributed according to the terms of the | |
|
6 | # GNU General Public License version 2 or any later version. | |
|
7 | ||
|
8 | # no-check-code because Python 3 native. | |
|
9 | ||
|
10 | import logging | |
|
11 | import pprint | |
|
12 | import time | |
|
13 | ||
|
14 | from pypsrp.client import ( | |
|
15 | Client, | |
|
16 | ) | |
|
17 | from pypsrp.powershell import ( | |
|
18 | PowerShell, | |
|
19 | PSInvocationState, | |
|
20 | RunspacePool, | |
|
21 | ) | |
|
22 | import requests.exceptions | |
|
23 | ||
|
24 | ||
|
25 | logger = logging.getLogger(__name__) | |
|
26 | ||
|
27 | ||
|
28 | def wait_for_winrm(host, username, password, timeout=120, ssl=False): | |
|
29 | """Wait for the Windows Remoting (WinRM) service to become available. | |
|
30 | ||
|
31 | Returns a ``psrpclient.Client`` instance. | |
|
32 | """ | |
|
33 | ||
|
34 | end_time = time.time() + timeout | |
|
35 | ||
|
36 | while True: | |
|
37 | try: | |
|
38 | client = Client(host, username=username, password=password, | |
|
39 | ssl=ssl, connection_timeout=5) | |
|
40 | client.execute_cmd('echo "hello world"') | |
|
41 | return client | |
|
42 | except requests.exceptions.ConnectionError: | |
|
43 | if time.time() >= end_time: | |
|
44 | raise | |
|
45 | ||
|
46 | time.sleep(1) | |
|
47 | ||
|
48 | ||
|
49 | def format_object(o): | |
|
50 | if isinstance(o, str): | |
|
51 | return o | |
|
52 | ||
|
53 | try: | |
|
54 | o = str(o) | |
|
55 | except TypeError: | |
|
56 | o = pprint.pformat(o.extended_properties) | |
|
57 | ||
|
58 | return o | |
|
59 | ||
|
60 | ||
|
61 | def run_powershell(client, script): | |
|
62 | with RunspacePool(client.wsman) as pool: | |
|
63 | ps = PowerShell(pool) | |
|
64 | ps.add_script(script) | |
|
65 | ||
|
66 | ps.begin_invoke() | |
|
67 | ||
|
68 | while ps.state == PSInvocationState.RUNNING: | |
|
69 | ps.poll_invoke() | |
|
70 | for o in ps.output: | |
|
71 | print(format_object(o)) | |
|
72 | ||
|
73 | ps.output[:] = [] | |
|
74 | ||
|
75 | ps.end_invoke() | |
|
76 | ||
|
77 | for o in ps.output: | |
|
78 | print(format_object(o)) | |
|
79 | ||
|
80 | if ps.state == PSInvocationState.FAILED: | |
|
81 | raise Exception('PowerShell execution failed: %s' % | |
|
82 | ' '.join(map(format_object, ps.streams.error))) |
@@ -0,0 +1,119 b'' | |||
|
1 | # | |
|
2 | # This file is autogenerated by pip-compile | |
|
3 | # To update, run: | |
|
4 | # | |
|
5 | # pip-compile -U --generate-hashes --output-file contrib/automation/requirements.txt contrib/automation/requirements.txt.in | |
|
6 | # | |
|
7 | asn1crypto==0.24.0 \ | |
|
8 | --hash=sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87 \ | |
|
9 | --hash=sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49 \ | |
|
10 | # via cryptography | |
|
11 | boto3==1.9.111 \ | |
|
12 | --hash=sha256:06414c75d1f62af7d04fd652b38d1e4fd3cfd6b35bad978466af88e2aaecd00d \ | |
|
13 | --hash=sha256:f3b77dff382374773d02411fa47ee408f4f503aeebd837fd9dc9ed8635bc5e8e | |
|
14 | botocore==1.12.111 \ | |
|
15 | --hash=sha256:6af473c52d5e3e7ff82de5334e9fee96b2d5ec2df5d78bc00cd9937e2573a7a8 \ | |
|
16 | --hash=sha256:9f5123c7be704b17aeacae99b5842ab17bda1f799dd29134de8c70e0a50a45d7 \ | |
|
17 | # via boto3, s3transfer | |
|
18 | certifi==2019.3.9 \ | |
|
19 | --hash=sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5 \ | |
|
20 | --hash=sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae \ | |
|
21 | # via requests | |
|
22 | cffi==1.12.2 \ | |
|
23 | --hash=sha256:00b97afa72c233495560a0793cdc86c2571721b4271c0667addc83c417f3d90f \ | |
|
24 | --hash=sha256:0ba1b0c90f2124459f6966a10c03794082a2f3985cd699d7d63c4a8dae113e11 \ | |
|
25 | --hash=sha256:0bffb69da295a4fc3349f2ec7cbe16b8ba057b0a593a92cbe8396e535244ee9d \ | |
|
26 | --hash=sha256:21469a2b1082088d11ccd79dd84157ba42d940064abbfa59cf5f024c19cf4891 \ | |
|
27 | --hash=sha256:2e4812f7fa984bf1ab253a40f1f4391b604f7fc424a3e21f7de542a7f8f7aedf \ | |
|
28 | --hash=sha256:2eac2cdd07b9049dd4e68449b90d3ef1adc7c759463af5beb53a84f1db62e36c \ | |
|
29 | --hash=sha256:2f9089979d7456c74d21303c7851f158833d48fb265876923edcb2d0194104ed \ | |
|
30 | --hash=sha256:3dd13feff00bddb0bd2d650cdb7338f815c1789a91a6f68fdc00e5c5ed40329b \ | |
|
31 | --hash=sha256:4065c32b52f4b142f417af6f33a5024edc1336aa845b9d5a8d86071f6fcaac5a \ | |
|
32 | --hash=sha256:51a4ba1256e9003a3acf508e3b4f4661bebd015b8180cc31849da222426ef585 \ | |
|
33 | --hash=sha256:59888faac06403767c0cf8cfb3f4a777b2939b1fbd9f729299b5384f097f05ea \ | |
|
34 | --hash=sha256:59c87886640574d8b14910840327f5cd15954e26ed0bbd4e7cef95fa5aef218f \ | |
|
35 | --hash=sha256:610fc7d6db6c56a244c2701575f6851461753c60f73f2de89c79bbf1cc807f33 \ | |
|
36 | --hash=sha256:70aeadeecb281ea901bf4230c6222af0248c41044d6f57401a614ea59d96d145 \ | |
|
37 | --hash=sha256:71e1296d5e66c59cd2c0f2d72dc476d42afe02aeddc833d8e05630a0551dad7a \ | |
|
38 | --hash=sha256:8fc7a49b440ea752cfdf1d51a586fd08d395ff7a5d555dc69e84b1939f7ddee3 \ | |
|
39 | --hash=sha256:9b5c2afd2d6e3771d516045a6cfa11a8da9a60e3d128746a7fe9ab36dfe7221f \ | |
|
40 | --hash=sha256:9c759051ebcb244d9d55ee791259ddd158188d15adee3c152502d3b69005e6bd \ | |
|
41 | --hash=sha256:b4d1011fec5ec12aa7cc10c05a2f2f12dfa0adfe958e56ae38dc140614035804 \ | |
|
42 | --hash=sha256:b4f1d6332339ecc61275bebd1f7b674098a66fea11a00c84d1c58851e618dc0d \ | |
|
43 | --hash=sha256:c030cda3dc8e62b814831faa4eb93dd9a46498af8cd1d5c178c2de856972fd92 \ | |
|
44 | --hash=sha256:c2e1f2012e56d61390c0e668c20c4fb0ae667c44d6f6a2eeea5d7148dcd3df9f \ | |
|
45 | --hash=sha256:c37c77d6562074452120fc6c02ad86ec928f5710fbc435a181d69334b4de1d84 \ | |
|
46 | --hash=sha256:c8149780c60f8fd02752d0429246088c6c04e234b895c4a42e1ea9b4de8d27fb \ | |
|
47 | --hash=sha256:cbeeef1dc3c4299bd746b774f019de9e4672f7cc666c777cd5b409f0b746dac7 \ | |
|
48 | --hash=sha256:e113878a446c6228669144ae8a56e268c91b7f1fafae927adc4879d9849e0ea7 \ | |
|
49 | --hash=sha256:e21162bf941b85c0cda08224dade5def9360f53b09f9f259adb85fc7dd0e7b35 \ | |
|
50 | --hash=sha256:fb6934ef4744becbda3143d30c6604718871495a5e36c408431bf33d9c146889 \ | |
|
51 | # via cryptography | |
|
52 | chardet==3.0.4 \ | |
|
53 | --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \ | |
|
54 | --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ | |
|
55 | # via requests | |
|
56 | cryptography==2.6.1 \ | |
|
57 | --hash=sha256:066f815f1fe46020877c5983a7e747ae140f517f1b09030ec098503575265ce1 \ | |
|
58 | --hash=sha256:210210d9df0afba9e000636e97810117dc55b7157c903a55716bb73e3ae07705 \ | |
|
59 | --hash=sha256:26c821cbeb683facb966045e2064303029d572a87ee69ca5a1bf54bf55f93ca6 \ | |
|
60 | --hash=sha256:2afb83308dc5c5255149ff7d3fb9964f7c9ee3d59b603ec18ccf5b0a8852e2b1 \ | |
|
61 | --hash=sha256:2db34e5c45988f36f7a08a7ab2b69638994a8923853dec2d4af121f689c66dc8 \ | |
|
62 | --hash=sha256:409c4653e0f719fa78febcb71ac417076ae5e20160aec7270c91d009837b9151 \ | |
|
63 | --hash=sha256:45a4f4cf4f4e6a55c8128f8b76b4c057027b27d4c67e3fe157fa02f27e37830d \ | |
|
64 | --hash=sha256:48eab46ef38faf1031e58dfcc9c3e71756a1108f4c9c966150b605d4a1a7f659 \ | |
|
65 | --hash=sha256:6b9e0ae298ab20d371fc26e2129fd683cfc0cfde4d157c6341722de645146537 \ | |
|
66 | --hash=sha256:6c4778afe50f413707f604828c1ad1ff81fadf6c110cb669579dea7e2e98a75e \ | |
|
67 | --hash=sha256:8c33fb99025d353c9520141f8bc989c2134a1f76bac6369cea060812f5b5c2bb \ | |
|
68 | --hash=sha256:9873a1760a274b620a135054b756f9f218fa61ca030e42df31b409f0fb738b6c \ | |
|
69 | --hash=sha256:9b069768c627f3f5623b1cbd3248c5e7e92aec62f4c98827059eed7053138cc9 \ | |
|
70 | --hash=sha256:9e4ce27a507e4886efbd3c32d120db5089b906979a4debf1d5939ec01b9dd6c5 \ | |
|
71 | --hash=sha256:acb424eaca214cb08735f1a744eceb97d014de6530c1ea23beb86d9c6f13c2ad \ | |
|
72 | --hash=sha256:c8181c7d77388fe26ab8418bb088b1a1ef5fde058c6926790c8a0a3d94075a4a \ | |
|
73 | --hash=sha256:d4afbb0840f489b60f5a580a41a1b9c3622e08ecb5eec8614d4fb4cd914c4460 \ | |
|
74 | --hash=sha256:d9ed28030797c00f4bc43c86bf819266c76a5ea61d006cd4078a93ebf7da6bfd \ | |
|
75 | --hash=sha256:e603aa7bb52e4e8ed4119a58a03b60323918467ef209e6ff9db3ac382e5cf2c6 \ | |
|
76 | # via pypsrp | |
|
77 | docutils==0.14 \ | |
|
78 | --hash=sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6 \ | |
|
79 | --hash=sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274 \ | |
|
80 | --hash=sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6 \ | |
|
81 | # via botocore | |
|
82 | idna==2.8 \ | |
|
83 | --hash=sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407 \ | |
|
84 | --hash=sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c \ | |
|
85 | # via requests | |
|
86 | jmespath==0.9.4 \ | |
|
87 | --hash=sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6 \ | |
|
88 | --hash=sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c \ | |
|
89 | # via boto3, botocore | |
|
90 | ntlm-auth==1.2.0 \ | |
|
91 | --hash=sha256:7bc02a3fbdfee7275d3dc20fce8028ed8eb6d32364637f28be9e9ae9160c6d5c \ | |
|
92 | --hash=sha256:9b13eaf88f16a831637d75236a93d60c0049536715aafbf8190ba58a590b023e \ | |
|
93 | # via pypsrp | |
|
94 | pycparser==2.19 \ | |
|
95 | --hash=sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3 \ | |
|
96 | # via cffi | |
|
97 | pypsrp==0.3.1 \ | |
|
98 | --hash=sha256:309853380fe086090a03cc6662a778ee69b1cae355ae4a932859034fd76e9d0b \ | |
|
99 | --hash=sha256:90f946254f547dc3493cea8493c819ab87e152a755797c93aa2668678ba8ae85 | |
|
100 | python-dateutil==2.8.0 \ | |
|
101 | --hash=sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb \ | |
|
102 | --hash=sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e \ | |
|
103 | # via botocore | |
|
104 | requests==2.21.0 \ | |
|
105 | --hash=sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e \ | |
|
106 | --hash=sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b \ | |
|
107 | # via pypsrp | |
|
108 | s3transfer==0.2.0 \ | |
|
109 | --hash=sha256:7b9ad3213bff7d357f888e0fab5101b56fa1a0548ee77d121c3a3dbfbef4cb2e \ | |
|
110 | --hash=sha256:f23d5cb7d862b104401d9021fc82e5fa0e0cf57b7660a1331425aab0c691d021 \ | |
|
111 | # via boto3 | |
|
112 | six==1.12.0 \ | |
|
113 | --hash=sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c \ | |
|
114 | --hash=sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73 \ | |
|
115 | # via cryptography, pypsrp, python-dateutil | |
|
116 | urllib3==1.24.1 \ | |
|
117 | --hash=sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39 \ | |
|
118 | --hash=sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22 \ | |
|
119 | # via botocore, requests |
@@ -12,6 +12,11 b' New errors are not allowed. Warnings are' | |||
|
12 | 12 | > -X hgext/fsmonitor/pywatchman \ |
|
13 | 13 | > -X mercurial/thirdparty \ |
|
14 | 14 | > | sed 's-\\-/-g' | "$check_code" --warnings --per-file=0 - || false |
|
15 | Skipping contrib/automation/hgautomation/__init__.py it has no-che?k-code (glob) | |
|
16 | Skipping contrib/automation/hgautomation/aws.py it has no-che?k-code (glob) | |
|
17 | Skipping contrib/automation/hgautomation/cli.py it has no-che?k-code (glob) | |
|
18 | Skipping contrib/automation/hgautomation/windows.py it has no-che?k-code (glob) | |
|
19 | Skipping contrib/automation/hgautomation/winrm.py it has no-che?k-code (glob) | |
|
15 | 20 | Skipping contrib/packaging/hgpackaging/downloads.py it has no-che?k-code (glob) |
|
16 | 21 | Skipping contrib/packaging/hgpackaging/inno.py it has no-che?k-code (glob) |
|
17 | 22 | Skipping contrib/packaging/hgpackaging/py2exe.py it has no-che?k-code (glob) |
General Comments 0
You need to be logged in to leave comments.
Login now