windows.py
472 lines
| 15.7 KiB
| text/x-python
|
PythonLexer
Gregory Szorc
|
r42191 | # windows.py - Automation specific to Windows | ||
# | ||||
# Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | ||||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
# no-check-code because Python 3 native. | ||||
Gregory Szorc
|
r43177 | import datetime | ||
Gregory Szorc
|
r42191 | import os | ||
Gregory Szorc
|
r43177 | import paramiko | ||
Gregory Szorc
|
r42191 | import pathlib | ||
import re | ||||
import subprocess | ||||
import tempfile | ||||
Gregory Szorc
|
r43177 | from .pypi import ( | ||
upload as pypi_upload, | ||||
) | ||||
Gregory Szorc
|
r42191 | from .winrm import ( | ||
run_powershell, | ||||
) | ||||
# PowerShell commands to activate a Visual Studio 2008 environment. | ||||
# This is essentially a port of vcvarsall.bat to PowerShell. | ||||
ACTIVATE_VC9_AMD64 = r''' | ||||
Write-Output "activating Visual Studio 2008 environment for AMD64" | ||||
$root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0" | ||||
$Env:VCINSTALLDIR = "${root}\VC\" | ||||
$Env:WindowsSdkDir = "${root}\WinSDK\" | ||||
$Env:PATH = "${root}\VC\Bin\amd64;${root}\WinSDK\Bin\x64;${root}\WinSDK\Bin;$Env:PATH" | ||||
$Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:PATH" | ||||
$Env:LIB = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIB" | ||||
$Env:LIBPATH = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIBPATH" | ||||
'''.lstrip() | ||||
ACTIVATE_VC9_X86 = r''' | ||||
Write-Output "activating Visual Studio 2008 environment for x86" | ||||
$root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0" | ||||
$Env:VCINSTALLDIR = "${root}\VC\" | ||||
$Env:WindowsSdkDir = "${root}\WinSDK\" | ||||
$Env:PATH = "${root}\VC\Bin;${root}\WinSDK\Bin;$Env:PATH" | ||||
$Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:INCLUDE" | ||||
$Env:LIB = "${root}\VC\Lib;${root}\WinSDK\Lib;$Env:LIB" | ||||
Matt Harbison
|
r42804 | $Env:LIBPATH = "${root}\VC\lib;${root}\WinSDK\Lib;$Env:LIBPATH" | ||
Gregory Szorc
|
r42191 | '''.lstrip() | ||
HG_PURGE = r''' | ||||
$Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH" | ||||
Set-Location C:\hgdev\src | ||||
hg.exe --config extensions.purge= purge --all | ||||
if ($LASTEXITCODE -ne 0) { | ||||
throw "process exited non-0: $LASTEXITCODE" | ||||
} | ||||
Write-Output "purged Mercurial repo" | ||||
''' | ||||
HG_UPDATE_CLEAN = r''' | ||||
$Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH" | ||||
Set-Location C:\hgdev\src | ||||
hg.exe --config extensions.purge= purge --all | ||||
if ($LASTEXITCODE -ne 0) {{ | ||||
throw "process exited non-0: $LASTEXITCODE" | ||||
}} | ||||
hg.exe update -C {revision} | ||||
if ($LASTEXITCODE -ne 0) {{ | ||||
throw "process exited non-0: $LASTEXITCODE" | ||||
}} | ||||
hg.exe log -r . | ||||
Write-Output "updated Mercurial working directory to {revision}" | ||||
'''.lstrip() | ||||
BUILD_INNO = r''' | ||||
Set-Location C:\hgdev\src | ||||
$python = "C:\hgdev\python27-{arch}\python.exe" | ||||
C:\hgdev\python37-x64\python.exe contrib\packaging\inno\build.py --python $python | ||||
if ($LASTEXITCODE -ne 0) {{ | ||||
throw "process exited non-0: $LASTEXITCODE" | ||||
}} | ||||
'''.lstrip() | ||||
BUILD_WHEEL = r''' | ||||
Set-Location C:\hgdev\src | ||||
C:\hgdev\python27-{arch}\Scripts\pip.exe wheel --wheel-dir dist . | ||||
if ($LASTEXITCODE -ne 0) {{ | ||||
throw "process exited non-0: $LASTEXITCODE" | ||||
}} | ||||
''' | ||||
BUILD_WIX = r''' | ||||
Set-Location C:\hgdev\src | ||||
$python = "C:\hgdev\python27-{arch}\python.exe" | ||||
C:\hgdev\python37-x64\python.exe contrib\packaging\wix\build.py --python $python {extra_args} | ||||
if ($LASTEXITCODE -ne 0) {{ | ||||
throw "process exited non-0: $LASTEXITCODE" | ||||
}} | ||||
''' | ||||
RUN_TESTS = r''' | ||||
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}" | ||||
if ($LASTEXITCODE -ne 0) {{ | ||||
throw "process exited non-0: $LASTEXITCODE" | ||||
}} | ||||
''' | ||||
Gregory Szorc
|
r43177 | X86_WHEEL_FILENAME = 'mercurial-{version}-cp27-cp27m-win32.whl' | ||
X64_WHEEL_FILENAME = 'mercurial-{version}-cp27-cp27m-win_amd64.whl' | ||||
X86_EXE_FILENAME = 'Mercurial-{version}.exe' | ||||
X64_EXE_FILENAME = 'Mercurial-{version}-x64.exe' | ||||
X86_MSI_FILENAME = 'mercurial-{version}-x86.msi' | ||||
X64_MSI_FILENAME = 'mercurial-{version}-x64.msi' | ||||
MERCURIAL_SCM_BASE_URL = 'https://mercurial-scm.org/release/windows' | ||||
X86_USER_AGENT_PATTERN = '.*Windows.*' | ||||
X64_USER_AGENT_PATTERN = '.*Windows.*(WOW|x)64.*' | ||||
X86_EXE_DESCRIPTION = ('Mercurial {version} Inno Setup installer - x86 Windows ' | ||||
'- does not require admin rights') | ||||
X64_EXE_DESCRIPTION = ('Mercurial {version} Inno Setup installer - x64 Windows ' | ||||
'- does not require admin rights') | ||||
X86_MSI_DESCRIPTION = ('Mercurial {version} MSI installer - x86 Windows ' | ||||
'- requires admin rights') | ||||
X64_MSI_DESCRIPTION = ('Mercurial {version} MSI installer - x64 Windows ' | ||||
'- requires admin rights') | ||||
Gregory Szorc
|
r42191 | |||
def get_vc_prefix(arch): | ||||
if arch == 'x86': | ||||
return ACTIVATE_VC9_X86 | ||||
elif arch == 'x64': | ||||
return ACTIVATE_VC9_AMD64 | ||||
else: | ||||
raise ValueError('illegal arch: %s; must be x86 or x64' % arch) | ||||
def fix_authorized_keys_permissions(winrm_client, path): | ||||
commands = [ | ||||
'$ErrorActionPreference = "Stop"', | ||||
'Repair-AuthorizedKeyPermission -FilePath %s -Confirm:$false' % path, | ||||
Gregory Szorc
|
r42231 | r'icacls %s /remove:g "NT Service\sshd"' % path, | ||
Gregory Szorc
|
r42191 | ] | ||
run_powershell(winrm_client, '\n'.join(commands)) | ||||
def synchronize_hg(hg_repo: pathlib.Path, revision: str, ec2_instance): | ||||
"""Synchronize local Mercurial repo to remote EC2 instance.""" | ||||
winrm_client = ec2_instance.winrm_client | ||||
with tempfile.TemporaryDirectory() as temp_dir: | ||||
temp_dir = pathlib.Path(temp_dir) | ||||
ssh_dir = temp_dir / '.ssh' | ||||
ssh_dir.mkdir() | ||||
ssh_dir.chmod(0o0700) | ||||
# Generate SSH key to use for communication. | ||||
subprocess.run([ | ||||
'ssh-keygen', '-t', 'rsa', '-b', '4096', '-N', '', | ||||
'-f', str(ssh_dir / 'id_rsa')], | ||||
check=True, capture_output=True) | ||||
# Add it to ~/.ssh/authorized_keys on remote. | ||||
# This assumes the file doesn't already exist. | ||||
authorized_keys = r'c:\Users\Administrator\.ssh\authorized_keys' | ||||
winrm_client.execute_cmd(r'mkdir c:\Users\Administrator\.ssh') | ||||
winrm_client.copy(str(ssh_dir / 'id_rsa.pub'), authorized_keys) | ||||
fix_authorized_keys_permissions(winrm_client, authorized_keys) | ||||
public_ip = ec2_instance.public_ip_address | ||||
ssh_config = temp_dir / '.ssh' / 'config' | ||||
with open(ssh_config, 'w', encoding='utf-8') as fh: | ||||
fh.write('Host %s\n' % public_ip) | ||||
fh.write(' User Administrator\n') | ||||
fh.write(' StrictHostKeyChecking no\n') | ||||
fh.write(' UserKnownHostsFile %s\n' % (ssh_dir / 'known_hosts')) | ||||
fh.write(' IdentityFile %s\n' % (ssh_dir / 'id_rsa')) | ||||
Gregory Szorc
|
r42467 | if not (hg_repo / '.hg').is_dir(): | ||
raise Exception('%s is not a Mercurial repository; ' | ||||
'synchronization not yet supported' % hg_repo) | ||||
Gregory Szorc
|
r42191 | env = dict(os.environ) | ||
env['HGPLAIN'] = '1' | ||||
env['HGENCODING'] = 'utf-8' | ||||
hg_bin = hg_repo / 'hg' | ||||
res = subprocess.run( | ||||
['python2.7', str(hg_bin), 'log', '-r', revision, '-T', '{node}'], | ||||
cwd=str(hg_repo), env=env, check=True, capture_output=True) | ||||
full_revision = res.stdout.decode('ascii') | ||||
args = [ | ||||
'python2.7', hg_bin, | ||||
'--config', 'ui.ssh=ssh -F %s' % ssh_config, | ||||
'--config', 'ui.remotecmd=c:/hgdev/venv-bootstrap/Scripts/hg.exe', | ||||
Gregory Szorc
|
r42911 | # Also ensure .hgtags changes are present so auto version | ||
# calculation works. | ||||
'push', '-f', '-r', full_revision, '-r', 'file(.hgtags)', | ||||
Gregory Szorc
|
r42468 | 'ssh://%s/c:/hgdev/src' % public_ip, | ||
Gregory Szorc
|
r42191 | ] | ||
Gregory Szorc
|
r42884 | res = subprocess.run(args, cwd=str(hg_repo), env=env) | ||
# Allow 1 (no-op) to not trigger error. | ||||
if res.returncode not in (0, 1): | ||||
res.check_returncode() | ||||
Gregory Szorc
|
r42191 | |||
run_powershell(winrm_client, | ||||
HG_UPDATE_CLEAN.format(revision=full_revision)) | ||||
# TODO detect dirty local working directory and synchronize accordingly. | ||||
def purge_hg(winrm_client): | ||||
"""Purge the Mercurial source repository on an EC2 instance.""" | ||||
run_powershell(winrm_client, HG_PURGE) | ||||
def find_latest_dist(winrm_client, pattern): | ||||
"""Find path to newest file in dist/ directory matching a pattern.""" | ||||
res = winrm_client.execute_ps( | ||||
Gregory Szorc
|
r42231 | r'$v = Get-ChildItem -Path C:\hgdev\src\dist -Filter "%s" ' | ||
Gregory Szorc
|
r42191 | '| Sort-Object LastWriteTime -Descending ' | ||
'| Select-Object -First 1\n' | ||||
'$v.name' % pattern | ||||
) | ||||
return res[0] | ||||
def copy_latest_dist(winrm_client, pattern, dest_path): | ||||
"""Copy latest file matching pattern in dist/ directory. | ||||
Given a WinRM client and a file pattern, find the latest file on the remote | ||||
matching that pattern and copy it to the ``dest_path`` directory on the | ||||
local machine. | ||||
""" | ||||
latest = find_latest_dist(winrm_client, pattern) | ||||
source = r'C:\hgdev\src\dist\%s' % latest | ||||
dest = dest_path / latest | ||||
print('copying %s to %s' % (source, dest)) | ||||
winrm_client.fetch(source, str(dest)) | ||||
def build_inno_installer(winrm_client, arch: str, dest_path: pathlib.Path, | ||||
version=None): | ||||
"""Build the Inno Setup installer on a remote machine. | ||||
Using a WinRM client, remote commands are executed to build | ||||
a Mercurial Inno Setup installer. | ||||
""" | ||||
print('building Inno Setup installer for %s' % arch) | ||||
extra_args = [] | ||||
if version: | ||||
extra_args.extend(['--version', version]) | ||||
ps = get_vc_prefix(arch) + BUILD_INNO.format(arch=arch, | ||||
extra_args=' '.join(extra_args)) | ||||
run_powershell(winrm_client, ps) | ||||
copy_latest_dist(winrm_client, '*.exe', dest_path) | ||||
def build_wheel(winrm_client, arch: str, dest_path: pathlib.Path): | ||||
"""Build Python wheels on a remote machine. | ||||
Using a WinRM client, remote commands are executed to build a Python wheel | ||||
for Mercurial. | ||||
""" | ||||
print('Building Windows wheel for %s' % arch) | ||||
ps = get_vc_prefix(arch) + BUILD_WHEEL.format(arch=arch) | ||||
run_powershell(winrm_client, ps) | ||||
copy_latest_dist(winrm_client, '*.whl', dest_path) | ||||
def build_wix_installer(winrm_client, arch: str, dest_path: pathlib.Path, | ||||
version=None): | ||||
"""Build the WiX installer on a remote machine. | ||||
Using a WinRM client, remote commands are executed to build a WiX installer. | ||||
""" | ||||
print('Building WiX installer for %s' % arch) | ||||
extra_args = [] | ||||
if version: | ||||
extra_args.extend(['--version', version]) | ||||
ps = get_vc_prefix(arch) + BUILD_WIX.format(arch=arch, | ||||
extra_args=' '.join(extra_args)) | ||||
run_powershell(winrm_client, ps) | ||||
copy_latest_dist(winrm_client, '*.msi', dest_path) | ||||
def run_tests(winrm_client, python_version, arch, test_flags=''): | ||||
"""Run tests on a remote Windows machine. | ||||
``python_version`` is a ``X.Y`` string like ``2.7`` or ``3.7``. | ||||
``arch`` is ``x86`` or ``x64``. | ||||
``test_flags`` is a str representing extra arguments to pass to | ||||
``run-tests.py``. | ||||
""" | ||||
Gregory Szorc
|
r42231 | if not re.match(r'\d\.\d', python_version): | ||
raise ValueError(r'python_version must be \d.\d; got %s' % | ||||
Gregory Szorc
|
r42191 | python_version) | ||
if arch not in ('x86', 'x64'): | ||||
raise ValueError('arch must be x86 or x64; got %s' % arch) | ||||
python_path = 'python%s-%s' % (python_version.replace('.', ''), arch) | ||||
ps = RUN_TESTS.format( | ||||
python_path=python_path, | ||||
test_flags=test_flags or '', | ||||
) | ||||
run_powershell(winrm_client, ps) | ||||
Gregory Szorc
|
r43177 | |||
def resolve_wheel_artifacts(dist_path: pathlib.Path, version: str): | ||||
return ( | ||||
dist_path / X86_WHEEL_FILENAME.format(version=version), | ||||
dist_path / X64_WHEEL_FILENAME.format(version=version), | ||||
) | ||||
def resolve_all_artifacts(dist_path: pathlib.Path, version: str): | ||||
return ( | ||||
dist_path / X86_WHEEL_FILENAME.format(version=version), | ||||
dist_path / X64_WHEEL_FILENAME.format(version=version), | ||||
dist_path / X86_EXE_FILENAME.format(version=version), | ||||
dist_path / X64_EXE_FILENAME.format(version=version), | ||||
dist_path / X86_MSI_FILENAME.format(version=version), | ||||
dist_path / X64_MSI_FILENAME.format(version=version), | ||||
) | ||||
def generate_latest_dat(version: str): | ||||
x86_exe_filename = X86_EXE_FILENAME.format(version=version) | ||||
x64_exe_filename = X64_EXE_FILENAME.format(version=version) | ||||
x86_msi_filename = X86_MSI_FILENAME.format(version=version) | ||||
x64_msi_filename = X64_MSI_FILENAME.format(version=version) | ||||
entries = ( | ||||
( | ||||
'10', | ||||
version, | ||||
X86_USER_AGENT_PATTERN, | ||||
'%s/%s' % (MERCURIAL_SCM_BASE_URL, x86_exe_filename), | ||||
X86_EXE_DESCRIPTION.format(version=version), | ||||
), | ||||
( | ||||
'10', | ||||
version, | ||||
X64_USER_AGENT_PATTERN, | ||||
'%s/%s' % (MERCURIAL_SCM_BASE_URL, x64_exe_filename), | ||||
X64_EXE_DESCRIPTION.format(version=version), | ||||
), | ||||
( | ||||
'10', | ||||
version, | ||||
X86_USER_AGENT_PATTERN, | ||||
'%s/%s' % (MERCURIAL_SCM_BASE_URL, x86_msi_filename), | ||||
X86_MSI_DESCRIPTION.format(version=version), | ||||
), | ||||
( | ||||
'10', | ||||
version, | ||||
X64_USER_AGENT_PATTERN, | ||||
'%s/%s' % (MERCURIAL_SCM_BASE_URL, x64_msi_filename), | ||||
X64_MSI_DESCRIPTION.format(version=version) | ||||
) | ||||
) | ||||
lines = ['\t'.join(e) for e in entries] | ||||
return '\n'.join(lines) + '\n' | ||||
def publish_artifacts_pypi(dist_path: pathlib.Path, version: str): | ||||
"""Publish Windows release artifacts to PyPI.""" | ||||
wheel_paths = resolve_wheel_artifacts(dist_path, version) | ||||
for p in wheel_paths: | ||||
if not p.exists(): | ||||
raise Exception('%s not found' % p) | ||||
print('uploading wheels to PyPI (you may be prompted for credentials)') | ||||
pypi_upload(wheel_paths) | ||||
def publish_artifacts_mercurial_scm_org(dist_path: pathlib.Path, version: str, | ||||
ssh_username=None): | ||||
"""Publish Windows release artifacts to mercurial-scm.org.""" | ||||
all_paths = resolve_all_artifacts(dist_path, version) | ||||
for p in all_paths: | ||||
if not p.exists(): | ||||
raise Exception('%s not found' % p) | ||||
client = paramiko.SSHClient() | ||||
client.load_system_host_keys() | ||||
# We assume the system SSH configuration knows how to connect. | ||||
print('connecting to mercurial-scm.org via ssh...') | ||||
try: | ||||
client.connect('mercurial-scm.org', username=ssh_username) | ||||
except paramiko.AuthenticationException: | ||||
print('error authenticating; is an SSH key available in an SSH agent?') | ||||
raise | ||||
print('SSH connection established') | ||||
print('opening SFTP client...') | ||||
sftp = client.open_sftp() | ||||
print('SFTP client obtained') | ||||
for p in all_paths: | ||||
dest_path = '/var/www/release/windows/%s' % p.name | ||||
print('uploading %s to %s' % (p, dest_path)) | ||||
with p.open('rb') as fh: | ||||
data = fh.read() | ||||
with sftp.open(dest_path, 'wb') as fh: | ||||
fh.write(data) | ||||
fh.chmod(0o0664) | ||||
latest_dat_path = '/var/www/release/windows/latest.dat' | ||||
now = datetime.datetime.utcnow() | ||||
backup_path = dist_path / ( | ||||
'latest-windows-%s.dat' % now.strftime('%Y%m%dT%H%M%S')) | ||||
print('backing up %s to %s' % (latest_dat_path, backup_path)) | ||||
with sftp.open(latest_dat_path, 'rb') as fh: | ||||
latest_dat_old = fh.read() | ||||
with backup_path.open('wb') as fh: | ||||
fh.write(latest_dat_old) | ||||
print('writing %s with content:' % latest_dat_path) | ||||
latest_dat_content = generate_latest_dat(version) | ||||
print(latest_dat_content) | ||||
with sftp.open(latest_dat_path, 'wb') as fh: | ||||
fh.write(latest_dat_content.encode('ascii')) | ||||
def publish_artifacts(dist_path: pathlib.Path, version: str, | ||||
pypi=True, mercurial_scm_org=True, | ||||
ssh_username=None): | ||||
"""Publish Windows release artifacts. | ||||
Files are found in `dist_path`. We will look for files with version string | ||||
`version`. | ||||
`pypi` controls whether we upload to PyPI. | ||||
`mercurial_scm_org` controls whether we upload to mercurial-scm.org. | ||||
""" | ||||
if pypi: | ||||
publish_artifacts_pypi(dist_path, version) | ||||
if mercurial_scm_org: | ||||
publish_artifacts_mercurial_scm_org(dist_path, version, | ||||
ssh_username=ssh_username) | ||||