Skip to content

Commit 0f5f78b

Browse files
committed
WIP
1 parent 83394d0 commit 0f5f78b

2 files changed

Lines changed: 182 additions & 93 deletions

File tree

scripts/qchatbuild.py

Lines changed: 182 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
1+
import base64
12
from dataclasses import dataclass
23
import json
34
import pathlib
45
from functools import cache
56
import os
67
import shutil
78
import time
8-
from typing import Any, Mapping, Sequence
9+
from typing import Any, Mapping, Sequence, List, Optional
910
from build import generate_sha
1011
from const import APPLE_TEAM_ID, CHAT_BINARY_NAME, CHAT_PACKAGE_NAME, LINUX_ARCHIVE_NAME
11-
from signing import (
12-
load_gpg_signer,
13-
)
14-
from util import debug, info, isDarwin, run_cmd, run_cmd_output, warn
12+
from util import debug, info, isDarwin, isLinux, run_cmd, run_cmd_output, warn
1513
from rust import cargo_cmd_name, rust_env, rust_targets
1614
from importlib import import_module
1715

16+
Args = Sequence[str | os.PathLike]
17+
Env = Mapping[str, str | os.PathLike]
18+
Cwd = str | os.PathLike
19+
1820
BUILD_DIR_RELATIVE = pathlib.Path(os.environ.get("BUILD_DIR") or "build")
1921
BUILD_DIR = BUILD_DIR_RELATIVE.absolute()
2022

21-
REGION = "us-west-2"
23+
CD_SIGNER_REGION = "us-west-2"
2224
SIGNING_API_BASE_URL = "https://api.signer.builder-tools.aws.dev"
2325
MACOS_BUNDLE_ID = "com.amazon.codewhisperer"
2426

@@ -27,12 +29,18 @@
2729
class CdSigningData:
2830
bucket_name: str
2931
'''The bucket hosting signing artifacts accessible by CD Signer.'''
30-
aws_account_id: str
31-
'''The id of the account hosting the build'''
32-
aws_region: str
33-
'''The AWS region where the app is built'''
34-
notarizing_secret_id: str
35-
signing_role_name: str
32+
apple_notarizing_secret_arn: str
33+
'''The ARN of the secret containing the Apple ID and password, used during notarization'''
34+
signing_role_arn: str
35+
'''The ARN of the role used by CD Signer'''
36+
37+
38+
@dataclass
39+
class MacOSBuildOutput:
40+
chat_path: pathlib.Path
41+
'''The path to the chat binary'''
42+
chat_zip_path: pathlib.Path
43+
'''The path to the chat binary zipped'''
3644

3745

3846
def run_cargo_tests():
@@ -127,7 +135,7 @@ def cd_signer_request(method: str, path: str, data: str | None = None):
127135
url = f"{SIGNING_API_BASE_URL}{path}"
128136
headers = {"Content-Type": "application/json"}
129137
request = AWSRequest(method=method, url=url, data=data, headers=headers)
130-
SigV4Auth(get_creds(), "signer-builder-tools", REGION).add_auth(request)
138+
SigV4Auth(get_creds(), "signer-builder-tools", CD_SIGNER_REGION).add_auth(request)
131139

132140
for i in range(1, 8):
133141
debug(f"Sending request {method} to {url} with data: {data}")
@@ -167,7 +175,7 @@ def cd_signer_start_request(request_id: str, source_key: str, destination_key: s
167175
path=f"/signing_requests/{request_id}/start",
168176
data=json.dumps(
169177
{
170-
"iamRole": f"arn:aws:iam::{signing_data.aws_account_id}:role/{signing_data.signing_role_name}",
178+
"iamRole": f"{signing_data.signing_role_arn}",
171179
"s3Location": {
172180
"bucket": signing_data.bucket_name,
173181
"sourceKey": source_key,
@@ -237,12 +245,18 @@ def manifest(
237245
}
238246

239247

240-
def sign_executable(signing_data: CdSigningData, chat_path: pathlib.Path) -> pathlib.Path:
241-
name = chat_path.name
248+
def sign_executable(signing_data: CdSigningData, exe_path: pathlib.Path) -> pathlib.Path:
249+
'''
250+
Signs an executable with CD Signer.
251+
252+
Returns:
253+
The path to the signed executable
254+
'''
255+
name = exe_path.name
242256
info(f"Signing {name}")
243257

244258
info("Packaging...")
245-
package_path = cd_build_signed_package(chat_path)
259+
package_path = cd_build_signed_package(exe_path)
246260

247261
info("Uploading...")
248262
run_cmd(["aws", "s3", "rm", "--recursive", f"s3://{signing_data.bucket_name}/signed"])
@@ -283,33 +297,40 @@ def sign_executable(signing_data: CdSigningData, chat_path: pathlib.Path) -> pat
283297

284298
info("Signed!")
285299

300+
# CD Signer should return the signed executable in a zip file containing the structure:
301+
# "Payload/EXECUTABLES_TO_SIGN/{executable name}".
286302
info("Downloading...")
287-
# CD Signer should return the signed executable under "Payload/EXECUTABLES_TO_SIGN/{executable name}"
288-
run_cmd(["aws", "s3", "cp", f"s3://{signing_data.bucket_name}/signed/signed.zip", "signed.zip"], cwd=BUILD_DIR)
289303

290304
# Create a new directory for unzipping the signed executable.
305+
zip_dl_path = BUILD_DIR / pathlib.Path("signed.zip")
306+
run_cmd(["aws", "s3", "cp", f"s3://{signing_data.bucket_name}/signed/signed.zip", zip_dl_path])
291307
payload_path = BUILD_DIR / "signed"
292308
shutil.rmtree(payload_path, ignore_errors=True)
293-
run_cmd(["unzip", "signed.zip", "-d", payload_path], cwd=BUILD_DIR)
309+
run_cmd(["unzip", zip_dl_path, "-d", payload_path])
310+
zip_dl_path.unlink()
294311
signed_exe_path = BUILD_DIR / "signed" / "Payload" / "EXECUTABLES_TO_SIGN" / name
295312
# Verify that the exe is signed
296313
run_cmd(["codesign", "--verify", "--verbose=4", signed_exe_path])
297314
return signed_exe_path
298315

299316

300-
def notarize_executable(signing_data: CdSigningData, executable_path: pathlib.Path):
317+
def notarize_executable(signing_data: CdSigningData, exe_path: pathlib.Path):
318+
'''
319+
Submits an executable to Apple notary service.
320+
'''
301321
# Load the Apple id and password from secrets manager.
302-
secret_id = signing_data.notarizing_secret_id
322+
secret_id = signing_data.apple_notarizing_secret_arn
323+
secret_region = parse_region_from_arn(signing_data.apple_notarizing_secret_arn)
303324
info(f"Loading secretmanager value: {secret_id}")
304-
secret_value = run_cmd_output(["aws", "--region", signing_data.aws_region, "secretsmanager", "get-secret-value", "--secret-id", secret_id])
325+
secret_value = run_cmd_output(["aws", "--region", secret_region, "secretsmanager", "get-secret-value", "--secret-id", secret_id])
305326
secret_string = json.loads(secret_value)["SecretString"]
306327
secrets = json.loads(secret_string)
307328

308329
# Submit the exe to Apple notary service. It must be zipped first.
309-
info(f"Submitting {executable_path} to Apple notary service")
310-
zip_path = BUILD_DIR / f"{executable_path.name}.zip"
330+
info(f"Submitting {exe_path} to Apple notary service")
331+
zip_path = BUILD_DIR / f"{exe_path.name}.zip"
311332
zip_path.unlink(missing_ok=True)
312-
run_cmd(["zip", "-j", zip_path, executable_path], cwd=BUILD_DIR)
333+
run_cmd(["zip", "-j", zip_path, exe_path], cwd=BUILD_DIR)
313334
submit_res = run_cmd_output(
314335
[
315336
"xcrun",
@@ -331,27 +352,142 @@ def notarize_executable(signing_data: CdSigningData, executable_path: pathlib.Pa
331352
# Confirm notarization succeeded.
332353
assert(json.loads(submit_res)["status"] == "Accepted")
333354

355+
# Cleanup
356+
zip_path.unlink()
334357

335-
def sign_and_notarize(signing_data: CdSigningData, chat_path: pathlib.Path):
358+
359+
def sign_and_notarize(signing_data: CdSigningData, chat_path: pathlib.Path) -> pathlib.Path:
360+
'''
361+
Signs an executable with CD Signer, and verifies it with Apple notary service.
362+
363+
Returns:
364+
The path to the signed executable.
365+
'''
336366
# First, sign the application
337-
executable_path = sign_executable(signing_data, chat_path)
367+
chat_path = sign_executable(signing_data, chat_path)
338368

339369
# Next, notarize the application
340-
notarize_executable(signing_data, executable_path)
370+
notarize_executable(signing_data, chat_path)
371+
372+
return chat_path
373+
341374

342375

343376
def build_macos(chat_path: pathlib.Path, signing_data: CdSigningData | None):
344-
chat_dst = BUILD_DIR / "qchat"
377+
'''
378+
Creates a qchat.zip under the build directory.
379+
'''
380+
chat_dst = BUILD_DIR / CHAT_BINARY_NAME
345381
chat_dst.unlink(missing_ok=True)
346382
shutil.copy2(chat_path, chat_dst)
347383

348384
if signing_data:
349-
sign_and_notarize(signing_data, chat_dst)
385+
chat_dst = sign_and_notarize(signing_data, chat_dst)
350386

351-
return chat_dst
387+
zip_path = BUILD_DIR / f"{CHAT_BINARY_NAME}.zip"
388+
zip_path.unlink(missing_ok=True)
389+
390+
info(f"Creating zip output to {zip_path}")
391+
run_cmd(["zip", "-j", zip_path, chat_dst], cwd=BUILD_DIR)
392+
generate_sha(zip_path)
393+
394+
395+
class GpgSigner:
396+
def __init__(self, gpg_id: str, gpg_secret_key: str, gpg_passphrase: str):
397+
self.gpg_id = gpg_id
398+
self.gpg_secret_key = gpg_secret_key
399+
self.gpg_passphrase = gpg_passphrase
352400

401+
self.gpg_home = pathlib.Path.home() / ".gnupg-tmp"
402+
self.gpg_home.mkdir(parents=True, exist_ok=True, mode=0o700)
353403

354-
def build_linux(chat_path: pathlib.Path):
404+
# write gpg secret key to file
405+
self.gpg_secret_key_path = self.gpg_home / "gpg_secret"
406+
self.gpg_secret_key_path.write_bytes(base64.b64decode(gpg_secret_key))
407+
408+
self.gpg_passphrase_path = self.gpg_home / "gpg_pass"
409+
self.gpg_passphrase_path.write_text(gpg_passphrase)
410+
411+
run_cmd(["gpg", "--version"])
412+
413+
info("Importing GPG key")
414+
run_cmd(["gpg", "--list-keys"], env=self.gpg_env())
415+
run_cmd(
416+
["gpg", *self.sign_args(), "--allow-secret-key-import", "--import", self.gpg_secret_key_path],
417+
env=self.gpg_env(),
418+
)
419+
run_cmd(["gpg", "--list-keys"], env=self.gpg_env())
420+
421+
def gpg_env(self) -> Env:
422+
return {**os.environ, "GNUPGHOME": self.gpg_home}
423+
424+
def sign_args(self) -> Args:
425+
return [
426+
"--batch",
427+
"--pinentry-mode",
428+
"loopback",
429+
"--no-tty",
430+
"--yes",
431+
"--passphrase-file",
432+
self.gpg_passphrase_path,
433+
]
434+
435+
def sign_file(self, path: pathlib.Path) -> List[pathlib.Path]:
436+
info(f"Signing {path.name}")
437+
run_cmd(
438+
["gpg", "--detach-sign", *self.sign_args(), "--local-user", self.gpg_id, path],
439+
env=self.gpg_env(),
440+
)
441+
run_cmd(
442+
["gpg", "--detach-sign", *self.sign_args(), "--armor", "--local-user", self.gpg_id, path],
443+
env=self.gpg_env(),
444+
)
445+
return [path.with_suffix(f"{path.suffix}.asc"), path.with_suffix(f"{path.suffix}.sig")]
446+
447+
def clean(self):
448+
info("Cleaning gpg keys")
449+
shutil.rmtree(self.gpg_home, ignore_errors=True)
450+
451+
452+
def get_secretmanager_json(secret_id: str, secret_region: str):
453+
info(f"Loading secretmanager value: {secret_id}")
454+
secret_value = run_cmd_output(["aws", "--region", secret_region, "secretsmanager", "get-secret-value", "--secret-id", secret_id])
455+
secret_string = json.loads(secret_value)["SecretString"]
456+
return json.loads(secret_string)
457+
458+
459+
def load_gpg_signer() -> Optional[GpgSigner]:
460+
if gpg_id := os.getenv("TEST_PGP_ID"):
461+
gpg_secret_key = os.getenv("TEST_PGP_SECRET_KEY")
462+
gpg_passphrase = os.getenv("TEST_PGP_PASSPHRASE")
463+
if gpg_secret_key is not None and gpg_passphrase is not None:
464+
info("Using test pgp key", gpg_id)
465+
return GpgSigner(gpg_id=gpg_id, gpg_secret_key=gpg_secret_key, gpg_passphrase=gpg_passphrase)
466+
467+
pgp_secret_arn = os.getenv("SIGNING_PGP_KEY_SECRET_ARN")
468+
info(f"SIGNING_PGP_KEY_SECRET_ARN: {pgp_secret_arn}")
469+
if pgp_secret_arn:
470+
pgp_secret_region = parse_region_from_arn(pgp_secret_arn)
471+
gpg_secret_json = get_secretmanager_json(pgp_secret_arn, pgp_secret_region)
472+
gpg_id = gpg_secret_json["gpg_id"]
473+
gpg_secret_key = gpg_secret_json["gpg_secret_key"]
474+
gpg_passphrase = gpg_secret_json["gpg_passphrase"]
475+
return GpgSigner(gpg_id=gpg_id, gpg_secret_key=gpg_secret_key, gpg_passphrase=gpg_passphrase)
476+
else:
477+
return None
478+
479+
480+
def parse_region_from_arn(arn: str) -> str:
481+
# ARN format: arn:partition:service:region:account-id:resource-type/resource-id
482+
# Check if we have enough parts and the ARN starts with "arn:"
483+
parts = arn.split(":")
484+
if len(parts) >= 4:
485+
return parts[3]
486+
487+
return ""
488+
489+
490+
def build_linux(chat_path: pathlib.Path, signer: GpgSigner | None):
355491
"""
356492
Creates tar.gz, tar.xz, tar.zst, and zip archives under `BUILD_DIR`.
357493
@@ -364,8 +500,6 @@ def build_linux(chat_path: pathlib.Path):
364500
archive_path.mkdir(parents=True, exist_ok=True)
365501
shutil.copy2(chat_path, archive_path / CHAT_BINARY_NAME)
366502

367-
signer = load_gpg_signer()
368-
369503
info(f"Building {archive_name}.tar.gz")
370504
tar_gz_path = BUILD_DIR / f"{archive_name}.tar.gz"
371505
run_cmd(["tar", "-czf", tar_gz_path, archive_path])
@@ -388,25 +522,23 @@ def build_linux(chat_path: pathlib.Path):
388522

389523
def build(
390524
release: bool,
391-
output_bucket: str | None = None,
392-
signing_bucket: str | None = None,
393-
aws_account_id: str | None = None,
394-
aws_region: str | None = None,
395-
apple_id_secret: str | None = None,
396-
signing_role_name: str | None = None,
397525
stage_name: str | None = None,
398526
run_lints: bool = True,
399527
run_test: bool = True,
400528
):
401529
BUILD_DIR.mkdir(exist_ok=True)
402530

403-
if signing_bucket and aws_account_id and aws_region and apple_id_secret and signing_role_name:
531+
disable_signing = os.environ.get('DISABLE_SIGNING')
532+
533+
gpg_signer = load_gpg_signer() if not disable_signing and isLinux() else None
534+
signing_role_arn = os.environ.get('SIGNING_ROLE_ARN')
535+
signing_bucket_name = os.environ.get('SIGNING_BUCKET_NAME')
536+
signing_apple_notarizing_secret_arn = os.environ.get('SIGNING_APPLE_NOTARIZING_SECRET_ARN')
537+
if not disable_signing and isDarwin() and signing_role_arn and signing_bucket_name and signing_apple_notarizing_secret_arn:
404538
signing_data = CdSigningData(
405-
bucket_name=signing_bucket,
406-
aws_account_id=aws_account_id,
407-
aws_region=aws_region,
408-
notarizing_secret_id=apple_id_secret,
409-
signing_role_name=signing_role_name,
539+
bucket_name=signing_bucket_name,
540+
apple_notarizing_secret_arn=signing_apple_notarizing_secret_arn,
541+
signing_role_arn=signing_role_arn,
410542
)
411543
else:
412544
signing_data = None
@@ -423,7 +555,7 @@ def build(
423555

424556
info(f"Release: {release}")
425557
info(f"Targets: {targets}")
426-
info(f"Signing app: {signing_data is not None}")
558+
info(f"Signing app: {signing_data is not None or gpg_signer is not None}")
427559

428560
if run_test:
429561
info("Running cargo tests")
@@ -441,13 +573,6 @@ def build(
441573
)
442574

443575
if isDarwin():
444-
chat_path = build_macos(chat_path, signing_data)
445-
sha_path = generate_sha(chat_path)
446-
447-
if output_bucket:
448-
staging_location = f"s3://{output_bucket}/staging/"
449-
info(f"Build complete, sending to {staging_location}")
450-
run_cmd(["aws", "s3", "cp", chat_path, staging_location])
451-
run_cmd(["aws", "s3", "cp", sha_path, staging_location])
576+
build_macos(chat_path, signing_data)
452577
else:
453-
build_linux(chat_path)
578+
build_linux(chat_path, gpg_signer)

0 commit comments

Comments
 (0)