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 | > -X hgext/fsmonitor/pywatchman \ |
|
12 | > -X hgext/fsmonitor/pywatchman \ | |
13 | > -X mercurial/thirdparty \ |
|
13 | > -X mercurial/thirdparty \ | |
14 | > | sed 's-\\-/-g' | "$check_code" --warnings --per-file=0 - || false |
|
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 | Skipping contrib/packaging/hgpackaging/downloads.py it has no-che?k-code (glob) |
|
20 | Skipping contrib/packaging/hgpackaging/downloads.py it has no-che?k-code (glob) | |
16 | Skipping contrib/packaging/hgpackaging/inno.py it has no-che?k-code (glob) |
|
21 | Skipping contrib/packaging/hgpackaging/inno.py it has no-che?k-code (glob) | |
17 | Skipping contrib/packaging/hgpackaging/py2exe.py it has no-che?k-code (glob) |
|
22 | Skipping contrib/packaging/hgpackaging/py2exe.py it has no-che?k-code (glob) |
@@ -18,6 +18,7 b' outputs, which should be fixed later.' | |||||
18 | > 'tests/**.t' \ |
|
18 | > 'tests/**.t' \ | |
19 | > -X hgweb.cgi \ |
|
19 | > -X hgweb.cgi \ | |
20 | > -X setup.py \ |
|
20 | > -X setup.py \ | |
|
21 | > -X contrib/automation/ \ | |||
21 | > -X contrib/debugshell.py \ |
|
22 | > -X contrib/debugshell.py \ | |
22 | > -X contrib/hgweb.fcgi \ |
|
23 | > -X contrib/hgweb.fcgi \ | |
23 | > -X contrib/packaging/hg-docker \ |
|
24 | > -X contrib/packaging/hg-docker \ |
@@ -5,6 +5,7 b'' | |||||
5 |
|
5 | |||
6 | #if no-py3 |
|
6 | #if no-py3 | |
7 | $ testrepohg files 'set:(**.py)' \ |
|
7 | $ testrepohg files 'set:(**.py)' \ | |
|
8 | > -X contrib/automation/ \ | |||
8 | > -X contrib/packaging/hgpackaging/ \ |
|
9 | > -X contrib/packaging/hgpackaging/ \ | |
9 | > -X contrib/packaging/inno/ \ |
|
10 | > -X contrib/packaging/inno/ \ | |
10 | > -X contrib/packaging/wix/ \ |
|
11 | > -X contrib/packaging/wix/ \ |
General Comments 0
You need to be logged in to leave comments.
Login now