Skip to content

Commit 7ad165f

Browse files
committed
Add support for platform specific bundles
If the source bundle supports multiple platform then we can now build platform specific bundles to reduce the size of the bundle.
1 parent a5a722c commit 7ad165f

File tree

4 files changed

+245
-25
lines changed

4 files changed

+245
-25
lines changed

codeql_bundle/cli.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import click
1111
from pathlib import Path
1212
from codeql_bundle.helpers.codeql import CodeQLException
13-
from codeql_bundle.helpers.bundle import CustomBundle, BundleException
13+
from codeql_bundle.helpers.bundle import CustomBundle, BundleException, BundlePlatform
1414
from typing import List
1515
import sys
1616
import logging
@@ -30,7 +30,7 @@
3030
"-o",
3131
"--output",
3232
required=True,
33-
help="Path to store the custom CodeQL bundle. Can be a directory or a non-existing archive ending with the extension '.tar.gz'",
33+
help="Path to store the custom CodeQL bundle. Can be a directory or a non-existing archive ending with the extension '.tar.gz' if there is only a single bundle",
3434
type=click.Path(path_type=Path),
3535
)
3636
@click.option(
@@ -49,12 +49,14 @@
4949
),
5050
default="WARNING",
5151
)
52+
@click.option("-p", "--platform", multiple=True, type=click.Choice(["linux64", "osx64", "win64"], case_sensitive=False), help="Target platform for the bundle")
5253
@click.argument("packs", nargs=-1, required=True)
5354
def main(
5455
bundle_path: Path,
5556
output: Path,
5657
workspace: Path,
5758
loglevel: str,
59+
platform: List[str],
5860
packs: List[str],
5961
) -> None:
6062

@@ -73,15 +75,27 @@ def main(
7375
workspace = workspace.parent
7476

7577
logger.info(
76-
f"Creating custom bundle of {bundle_path} using CodeQL packs in workspace {workspace}"
78+
f"Creating custom bundle of {bundle_path} using CodeQL pack(s) in workspace {workspace}"
7779
)
7880

7981
try:
8082
bundle = CustomBundle(bundle_path, workspace)
83+
84+
unsupported_platforms = list(filter(lambda p: not bundle.supports_platform(BundlePlatform.from_string(p)), platform))
85+
if len(unsupported_platforms) > 0:
86+
logger.fatal(
87+
f"The provided bundle supports the platform(s) {', '.join(map(str, bundle.platforms))}, but doesn't support the following platform(s): {', '.join(unsupported_platforms)}"
88+
)
89+
sys.exit(1)
90+
8191
logger.info(f"Looking for CodeQL packs in workspace {workspace}")
82-
packs_in_workspace = bundle.getCodeQLPacks()
92+
packs_in_workspace = bundle.get_workspace_packs()
8393
logger.info(
84-
f"Found the CodeQL packs: {','.join(map(lambda p: p.config.name, packs_in_workspace))}"
94+
f"Found the CodeQL pack(s): {','.join(map(lambda p: p.config.name, packs_in_workspace))}"
95+
)
96+
97+
logger.info(
98+
f"Considering the following CodeQL pack(s) for inclusion in the custom bundle: {','.join(packs)}"
8599
)
86100

87101
if len(packs) > 0:
@@ -93,23 +107,22 @@ def main(
93107
else:
94108
selected_packs = packs_in_workspace
95109

96-
logger.info(
97-
f"Considering the following CodeQL packs for inclusion in the custom bundle: {','.join(map(lambda p: p.config.name, selected_packs))}"
98-
)
110+
99111
missing_packs = set(packs) - {pack.config.name for pack in selected_packs}
100112
if len(missing_packs) > 0:
101113
logger.fatal(
102-
f"The provided CodeQL workspace doesn't contain the provided packs '{','.join(missing_packs)}'",
114+
f"The provided CodeQL workspace doesn't contain the provided pack(s) '{','.join(missing_packs)}'",
103115
)
104116
sys.exit(1)
105117

106118
logger.info(
107-
f"Adding the packs {','.join(map(lambda p: p.config.name, selected_packs))} and its workspace dependencies to the custom bundle."
119+
f"Adding the pack(s) {','.join(map(lambda p: p.config.name, selected_packs))} and its workspace dependencies to the custom bundle."
108120
)
109121
bundle.add_packs(*selected_packs)
110-
logger.info(f"Bundling custom bundle at {output}")
111-
bundle.bundle(output)
112-
logger.info(f"Completed building of custom bundle.")
122+
logger.info(f"Bundling custom bundle(s) at {output}")
123+
platforms = set(map(BundlePlatform.from_string, platform))
124+
bundle.bundle(output, platforms)
125+
logger.info(f"Completed building of custom bundle(s).")
113126
except CodeQLException as e:
114127
logger.fatal(f"Failed executing CodeQL command with reason: '{e}'")
115128
sys.exit(1)

codeql_bundle/helpers/bundle.py

Lines changed: 162 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@
66
from pathlib import Path
77
from tempfile import TemporaryDirectory
88
import tarfile
9-
from typing import List, cast, Callable
9+
from typing import List, cast, Callable, Optional
1010
from collections import defaultdict
1111
import shutil
1212
import yaml
1313
import dataclasses
1414
import logging
15-
from enum import Enum
15+
from enum import Enum, verify, UNIQUE
1616
from dataclasses import dataclass
1717
from graphlib import TopologicalSorter
18+
import platform
19+
import concurrent.futures
1820

1921
logger = logging.getLogger(__name__)
2022

23+
@verify(UNIQUE)
2124
class CodeQLPackKind(Enum):
2225
QUERY_PACK = 1
2326
LIBRARY_PACK = 2
@@ -49,6 +52,9 @@ def get_dependencies_path(self) -> Path:
4952
def get_cache_path(self) -> Path:
5053
return self.path.parent / ".cache"
5154

55+
def is_stdlib_module(self) -> bool:
56+
return self.config.get_scope() == "codeql"
57+
5258
class BundleException(Exception):
5359
pass
5460

@@ -96,7 +102,7 @@ def inner(pack_to_be_resolved: CodeQLPack) -> ResolvedCodeQLPack:
96102
resolved_dep = inner(candidate_pack)
97103

98104
if not resolved_dep:
99-
raise PackResolverException(f"Could not resolve dependency {dep_name} for pack {pack_to_be_resolved.config.name}!")
105+
raise PackResolverException(f"Could not resolve dependency {dep_name}@{dep_version} for pack {pack_to_be_resolved.config.name}@{str(pack_to_be_resolved.config.version)}!")
100106
resolved_deps.append(resolved_dep)
101107

102108

@@ -108,6 +114,33 @@ def inner(pack_to_be_resolved: CodeQLPack) -> ResolvedCodeQLPack:
108114

109115
return builder()
110116

117+
@verify(UNIQUE)
118+
class BundlePlatform(Enum):
119+
LINUX = 1
120+
WINDOWS = 2
121+
OSX = 3
122+
123+
@staticmethod
124+
def from_string(platform: str) -> "BundlePlatform":
125+
if platform.lower() == "linux" or platform.lower() == "linux64":
126+
return BundlePlatform.LINUX
127+
elif platform.lower() == "windows" or platform.lower() == "win64":
128+
return BundlePlatform.WINDOWS
129+
elif platform.lower() == "osx" or platform.lower() == "osx64":
130+
return BundlePlatform.OSX
131+
else:
132+
raise BundleException(f"Invalid platform {platform}")
133+
134+
def __str__(self):
135+
if self == BundlePlatform.LINUX:
136+
return "linux64"
137+
elif self == BundlePlatform.WINDOWS:
138+
return "win64"
139+
elif self == BundlePlatform.OSX:
140+
return "osx64"
141+
else:
142+
raise BundleException(f"Invalid platform {self}")
143+
111144
class Bundle:
112145
def __init__(self, bundle_path: Path) -> None:
113146
self.tmp_dir = TemporaryDirectory()
@@ -127,6 +160,36 @@ def __init__(self, bundle_path: Path) -> None:
127160
else:
128161
raise BundleException("Invalid CodeQL bundle path")
129162

163+
def supports_linux() -> set[BundlePlatform]:
164+
if (self.bundle_path / "cpp" / "tools" / "linux64").exists():
165+
return {BundlePlatform.LINUX}
166+
else:
167+
return set()
168+
169+
def supports_macos() -> set[BundlePlatform]:
170+
if (self.bundle_path / "cpp" / "tools" / "osx64").exists():
171+
return {BundlePlatform.OSX}
172+
else:
173+
return set()
174+
175+
def supports_windows() -> set[BundlePlatform]:
176+
if (self.bundle_path / "cpp" / "tools" / "win64").exists():
177+
return {BundlePlatform.WINDOWS}
178+
else:
179+
return set()
180+
181+
self.platforms: set[BundlePlatform] = supports_linux() | supports_macos() | supports_windows()
182+
183+
current_system = platform.system()
184+
if not current_system in ["Linux", "Darwin", "Windows"]:
185+
raise BundleException(f"Unsupported system: {current_system}")
186+
if current_system == "Linux" and BundlePlatform.LINUX not in self.platforms:
187+
raise BundleException("Bundle doesn't support Linux!")
188+
elif current_system == "Darwin" and BundlePlatform.OSX not in self.platforms:
189+
raise BundleException("Bundle doesn't support OSX!")
190+
elif current_system == "Windows" and BundlePlatform.WINDOWS not in self.platforms:
191+
raise BundleException("Bundle doesn't support Windows!")
192+
130193
self.codeql = CodeQL(self.bundle_path / "codeql")
131194
try:
132195
logging.info(f"Validating the CodeQL CLI version part of the bundle.")
@@ -141,20 +204,24 @@ def __init__(self, bundle_path: Path) -> None:
141204

142205
self.bundle_packs: list[ResolvedCodeQLPack] = [resolve(pack) for pack in packs]
143206

207+
self.languages = self.codeql.resolve_languages()
208+
144209
except CodeQLException:
145210
raise BundleException("Cannot determine CodeQL version!")
146211

147-
148212
def __del__(self) -> None:
149213
if self.tmp_dir:
150214
logging.info(
151215
f"Removing temporary directory {self.tmp_dir.name} used to build custom bundle."
152216
)
153217
self.tmp_dir.cleanup()
154218

155-
def getCodeQLPacks(self) -> List[ResolvedCodeQLPack]:
219+
def get_bundle_packs(self) -> List[ResolvedCodeQLPack]:
156220
return self.bundle_packs
157221

222+
def supports_platform(self, platform: BundlePlatform) -> bool:
223+
return platform in self.platforms
224+
158225
class CustomBundle(Bundle):
159226
def __init__(self, bundle_path: Path, workspace_path: Path = Path.cwd()) -> None:
160227
Bundle.__init__(self, bundle_path)
@@ -184,7 +251,7 @@ def __init__(self, bundle_path: Path, workspace_path: Path = Path.cwd()) -> None
184251
f"Bundle doesn't have an associated temporary directory, created {self.tmp_dir.name} for building a custom bundle."
185252
)
186253

187-
def getCodeQLPacks(self) -> List[ResolvedCodeQLPack]:
254+
def get_workspace_packs(self) -> List[ResolvedCodeQLPack]:
188255
return self.workspace_packs
189256

190257
def add_packs(self, *packs: ResolvedCodeQLPack):
@@ -481,10 +548,93 @@ def bundle_query_pack(pack: ResolvedCodeQLPack):
481548
elif pack.kind == CodeQLPackKind.QUERY_PACK:
482549
bundle_query_pack(pack)
483550

484-
def bundle(self, output_path: Path):
485-
if output_path.is_dir():
486-
output_path = output_path / "codeql-bundle.tar.gz"
551+
def bundle(self, output_path: Path, platforms: set[BundlePlatform] = set()):
552+
if len(platforms) == 0:
553+
if output_path.is_dir():
554+
output_path = output_path / "codeql-bundle.tar.gz"
555+
556+
logging.debug(f"Bundling custom bundle to {output_path}.")
557+
with tarfile.open(output_path, mode="w:gz") as bundle_archive:
558+
bundle_archive.add(self.bundle_path, arcname="codeql")
559+
else:
560+
if not output_path.is_dir():
561+
raise BundleException(
562+
f"Output path {output_path} must be a directory when bundling for multiple platforms."
563+
)
564+
565+
unsupported_platforms = platforms - self.platforms
566+
if len(unsupported_platforms) > 0:
567+
raise BundleException(
568+
f"Unsupported platform(s) {', '.join(map(str,unsupported_platforms))} specified. Use the platform agnostic bundle to bundle for different platforms."
569+
)
570+
571+
def create_bundle_for_platform(bundle_output_path:Path, platform: BundlePlatform) -> None:
572+
"""Create a bundle for a single platform."""
573+
def filter_for_platform(platform: BundlePlatform) -> Callable[[tarfile.TarInfo], Optional[tarfile.TarInfo]]:
574+
"""Create a filter function that will only include files for the specified platform."""
575+
relative_tools_paths = [Path(lang) / "tools" for lang in self.languages] + [Path("tools")]
576+
577+
def get_nonplatform_tool_paths(platform: BundlePlatform) -> List[Path]:
578+
"""Get a list of paths to tools that are not for the specified platform relative to the root of a bundle."""
579+
specialize_path : Optional[Callable[[Path], List[Path]]] = None
580+
linux64_subpaths = [Path("linux64"), Path("linux")]
581+
osx64_subpaths = [Path("osx64"), Path("macos")]
582+
win64_subpaths = [Path("win64"), Path("windows")]
583+
if platform == BundlePlatform.LINUX:
584+
specialize_path = lambda p: [p / subpath for subpath in osx64_subpaths + win64_subpaths]
585+
elif platform == BundlePlatform.WINDOWS:
586+
specialize_path = lambda p: [p / subpath for subpath in osx64_subpaths + linux64_subpaths]
587+
elif platform == BundlePlatform.OSX:
588+
specialize_path = lambda p: [p / subpath for subpath in linux64_subpaths + win64_subpaths]
589+
else:
590+
raise BundleException(f"Unsupported platform {platform}.")
591+
592+
return [candidate for candidates in map(specialize_path, relative_tools_paths) for candidate in candidates]
593+
594+
def filter(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]:
595+
tarfile_path = Path(tarinfo.name)
596+
597+
exclusion_paths = get_nonplatform_tool_paths(platform)
598+
599+
# Manual exclusions based on diffing the contents of the platform specific bundles and the generated platform specific bundles.
600+
if platform != BundlePlatform.WINDOWS:
601+
exclusion_paths.append(Path("codeql.exe"))
602+
else:
603+
exclusion_paths.append(Path("swift/qltest"))
604+
exclusion_paths.append(Path("swift/resource-dir"))
605+
606+
if platform == BundlePlatform.LINUX:
607+
exclusion_paths.append(Path("swift/qltest/osx64"))
608+
exclusion_paths.append(Path("swift/resource-dir/osx64"))
609+
610+
if platform == BundlePlatform.OSX:
611+
exclusion_paths.append(Path("swift/qltest/linux64"))
612+
exclusion_paths.append(Path("swift/resource-dir/linux64"))
613+
614+
615+
tarfile_path_root = Path(tarfile_path.parts[0])
616+
exclusion_paths = [tarfile_path_root / path for path in exclusion_paths]
617+
618+
if any(tarfile_path.is_relative_to(path) for path in exclusion_paths):
619+
return None
620+
621+
return tarinfo
622+
623+
return filter
624+
logging.debug(f"Bundling custom bundle for {platform} to {bundle_output_path}.")
625+
with tarfile.open(bundle_output_path, mode="w:gz") as bundle_archive:
626+
bundle_archive.add(
627+
self.bundle_path, arcname="codeql", filter=filter_for_platform(platform)
628+
)
629+
630+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(platforms)) as executor:
631+
future_to_platform = {executor.submit(create_bundle_for_platform, output_path / f"codeql-bundle-{platform}.tar.gz", platform): platform for platform in platforms}
632+
for future in concurrent.futures.as_completed(future_to_platform):
633+
platform = future_to_platform[future]
634+
try:
635+
future.result()
636+
except Exception as exc:
637+
raise BundleException(f"Failed to create bundle for platform {platform} with exception: {exc}.")
638+
639+
487640

488-
logging.debug(f"Bundling custom bundle to {output_path}.")
489-
with tarfile.open(output_path, mode="w:gz") as bundle_archive:
490-
bundle_archive.add(self.bundle_path, arcname="codeql")

codeql_bundle/helpers/codeql.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,10 @@ def pack_create(
164164

165165
if cp.returncode != 0:
166166
raise CodeQLException(f"Failed to run {cp.args} command! {cp.stderr}")
167+
168+
def resolve_languages(self) -> set[str]:
169+
cp = self._exec("resolve", "languages", "--format=json")
170+
if cp.returncode == 0:
171+
return set(json.loads(cp.stdout).keys())
172+
else:
173+
raise CodeQLException(f"Failed to run {cp.args} command! {cp.stderr}")

0 commit comments

Comments
 (0)