Skip to content

Commit 764f77e

Browse files
authored
Merge pull request #4 from rvermeulen/multi-platform-support
Add support for platform specific bundles
2 parents 36a34cb + 7ad165f commit 764f77e

File tree

6 files changed

+249
-28
lines changed

6 files changed

+249
-28
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: 164 additions & 13 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):
@@ -229,7 +296,7 @@ def add_to_graph(pack: ResolvedCodeQLPack, processed_packs: set[ResolvedCodeQLPa
229296
logger.debug(f"Adding stdlib dependency {std_lib_dep.config.name}@{str(std_lib_dep.config.version)} to {pack.config.name}@{str(pack.config.version)}")
230297
pack.dependencies.append(std_lib_dep)
231298
logger.debug(f"Adding pack {pack.config.name}@{str(pack.config.version)} to dependency graph")
232-
pack_sorter.add(pack, *pack.dependencies)
299+
pack_sorter.add(pack)
233300
for dep in pack.dependencies:
234301
if dep not in processed_packs:
235302
add_to_graph(dep, processed_packs, std_lib_deps)
@@ -277,6 +344,7 @@ def bundle_customization_pack(customization_pack: ResolvedCodeQLPack):
277344
def copy_pack(pack: ResolvedCodeQLPack) -> ResolvedCodeQLPack:
278345
pack_copy_dir = (
279346
Path(self.tmp_dir.name)
347+
/ "temp" # Add a temp path segment because the standard library packs have scope 'codeql' that collides with the 'codeql' directory in the bundle that is extracted to the temporary directory.
280348
/ cast(str, pack.config.get_scope())
281349
/ pack.config.get_pack_name()
282350
/ str(pack.config.version)
@@ -480,10 +548,93 @@ def bundle_query_pack(pack: ResolvedCodeQLPack):
480548
elif pack.kind == CodeQLPackKind.QUERY_PACK:
481549
bundle_query_pack(pack)
482550

483-
def bundle(self, output_path: Path):
484-
if output_path.is_dir():
485-
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+
486640

487-
logging.debug(f"Bundling custom bundle to {output_path}.")
488-
with tarfile.open(output_path, mode="w:gz") as bundle_archive:
489-
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)