Repository navigation
Expand file tree
/
Copy pathdeb_builder.py
More file actions
2290 lines (2043 loc) · 101 KB
/
Copy pathdeb_builder.py
File metadata and controls
2290 lines (2043 loc) · 101 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
DEB builder for Debian/Ubuntu hosts.
Produces monolithic .deb packages that install into /opt/scls/<flavor>/.
Mirrors how rpm_builder treats these as vendor packages: no binary/-dev
split, the stack sits outside the FHS, and runtime dependencies between
SCLS packages are stated explicitly in each recipe's `requires:` list.
Inherits UnixBuilder's download/extract/configure/build/install pipeline
(which already stages installs into a DESTDIR) and overrides install() to
stop at the staged buildroot instead of copying files into the live
prefix. create_deb() then wraps the buildroot with a DEBIAN/control file
and invokes `dpkg-deb --build`.
System-package dependency names come out of the recipe in RHEL form
(e.g. gmp-devel, openssl-devel) and are translated via
packaging/system_packages.yaml. Missing entries in that map are a hard
error so drift surfaces at package time instead of install time.
"""
import os
import sys
import argparse
import shutil
import subprocess
import platform
from pathlib import Path
from typing import Dict, List, Tuple
import yaml
from jinja2 import Environment, FileSystemLoader, select_autoescape
from build_common import (
BuildError, load_recipe, load_flavor, load_description,
clean_libtool_files,
check_no_triplet_programs,
extract_source,
write_registry_entry,
get_package_dependencies,
read_extra_packages,
resolve_flavor_key,
run_command,
should_build_package,
get_subpackages_for_flavor,
get_subpackage_dependencies,
)
from patch_common import apply_patches, get_all_patches
from unix_builder import UnixBuilder
SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent
SYSTEM_PACKAGE_MAP_PATH = PROJECT_ROOT / "packaging" / "system_packages.yaml"
# Dependencies that belong in Build-Depends only, not Depends. Matches the set
# rpm_builder uses so recipe `requires:` stays format-agnostic. Mostly build
# tools -- plus gklib, whose static archive is absorbed into libmetis.so, so
# nothing resolves against it at runtime. See doc/GKLIB_STATIC_POLICY.md.
BUILD_ONLY_DEPS = {'cmake', 'autoconf', 'automake', 'libtool', 'pkg-config', 'gklib'}
# Built as part of a flavor but kept out of the flavor meta-package. Narrower
# than BUILD_ONLY_DEPS: cmake is a tool users want, gklib is internal.
META_EXCLUDED = {'gklib'}
def load_system_package_map() -> Dict[str, str]:
"""Load the RHEL -> Debian package name map.
A value of None (YAML ~) means "drop this dep, no Debian equivalent."
We keep None in the returned dict so callers can distinguish "missing"
(unknown RHEL name, hard error) from "explicitly dropped."
"""
if not SYSTEM_PACKAGE_MAP_PATH.exists():
raise BuildError(
f"System package map not found: {SYSTEM_PACKAGE_MAP_PATH}. "
"deb_builder requires packaging/system_packages.yaml."
)
with open(SYSTEM_PACKAGE_MAP_PATH) as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise BuildError(
f"{SYSTEM_PACKAGE_MAP_PATH} must be a top-level mapping"
)
return data
def _deb_name(name: str) -> str:
"""Sanitize a recipe-internal name into a Debian Package: name.
Debian policy 5.6.1 restricts package names to [a-z0-9+\\-.]; underscores
in recipe names (e.g. `superlu_dist`) are mapped to hyphens.
"""
return name.replace('_', '-')
def detect_architecture() -> str:
"""Return dpkg architecture name for the current host."""
try:
out = subprocess.check_output(['dpkg', '--print-architecture'], text=True)
return out.strip()
except (FileNotFoundError, subprocess.CalledProcessError):
# Fallback table for CI or non-Debian dev hosts running dry-runs.
machine = platform.machine()
return {
'x86_64': 'amd64',
'aarch64': 'arm64',
'armv7l': 'armhf',
}.get(machine, machine)
def _read_deb_package_version(deb_path: Path) -> Tuple[str, str]:
"""Return (Package, Version) from the control metadata of a .deb.
Returns (None, None) on any failure so the caller can fall back
to a plain `apt-get install`.
"""
result = subprocess.run(
['dpkg-deb', '-f', str(deb_path), 'Package', 'Version'],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return (None, None)
fields = {}
for line in result.stdout.splitlines():
key, sep, val = line.partition(':')
if sep and key.strip():
fields[key.strip()] = val.strip()
return fields.get('Package'), fields.get('Version')
def _deb_is_already_installed_at_version(name: str, version: str) -> bool:
"""True if package `name` is currently installed with exactly `version`.
Uses dpkg-query to inspect the local dpkg database. Only an "ii"
(fully installed) status counts — half-configured/removed-but-not-
purged states don't; those need a fresh install anyway.
"""
if not name or not version:
return False
result = subprocess.run(
['dpkg-query', '-W', '-f', '${db:Status-Abbrev}\t${Version}', name],
capture_output=True, text=True, check=False,
)
# dpkg-query exits 1 when the package isn't known; that's not an error.
if result.returncode not in (0, 1):
return False
parts = result.stdout.split('\t', 1)
if len(parts) != 2:
return False
status, installed_version = parts
return status.startswith('ii') and installed_version.strip() == version
def assert_links_without_as_needed(compilers: Dict[str, str]) -> None:
"""Refuse to build if a compiler driver still links with --as-needed.
Ubuntu's gcc injects --as-needed through its built-in *link spec, so each
object keeps only the NEEDED entries it references directly; el9's gcc does
not. -Wl,--no-as-needed in LDFLAGS cannot undo that for libtool packages,
because libtool places every -Wl flag after the libraries. The fix is a
host-level gcc specs file without the injected flag (doc/BUILD_EXECUTION.md).
This checks the effect, not the file: link an unused -lgomp and require it
in NEEDED, for every driver the flavor uses.
"""
import tempfile
with tempfile.TemporaryDirectory() as tmp:
t = Path(tmp)
(t / 'probe.c').write_text('int scls_probe(void) { return 0; }\n')
subprocess.run(['gcc', '-fPIC', '-c', 'probe.c', '-o', 'probe.o'],
cwd=t, check=True, capture_output=True)
bad = []
for key in ('cc', 'cxx', 'fc'):
drv = compilers.get(key)
if not drv:
continue
out = t / f'probe_{key}.so'
r = subprocess.run([drv, '-shared', 'probe.o', '-lgomp', '-o', str(out)],
cwd=t, capture_output=True, text=True)
needed = subprocess.run(['readelf', '-d', str(out)], capture_output=True,
text=True).stdout if r.returncode == 0 else ''
if 'libgomp.so' not in needed:
bad.append(drv)
if bad:
raise BuildError(
f"{', '.join(bad)} link with --as-needed: an unused -lgomp was dropped from "
f"NEEDED. .deb builds must link like el9. Install the gcc specs override "
f"described in doc/BUILD_EXECUTION.md (Ubuntu build hosts) and retry."
)
def _apt_install_deb(deb_path: Path, label: str = "apt-get install") -> None:
"""Install a single .deb, routing to --reinstall when its exact
Package+Version is already installed.
Mirrors rpm_builder._dnf_install_rpms's partition-and-dispatch
semantics: a rebuild of the same version reinstalls (overwrites
files) rather than hitting apt's "already the newest version"
no-op; a different version goes through plain install which
handles upgrade/downgrade naturally.
"""
deb_arg = str(deb_path) if deb_path.is_absolute() else f"./{deb_path}"
name, version = _read_deb_package_version(deb_path)
if _deb_is_already_installed_at_version(name, version):
print(f"Reinstalling {deb_path} ({name}={version} already installed)")
# --allow-downgrades: when the same version is also in a configured SCLS
# repo, apt treats a local rebuild with other bytes as a downgrade of
# the repo copy and refuses under -y (U24 relink, 2026-09-29).
cmd = ['sudo', 'apt-get', 'install', '--reinstall', '--allow-downgrades',
'-y', deb_arg]
else:
print(f"Installing {deb_path}")
cmd = ['sudo', 'apt-get', 'install', '-y', deb_arg]
run_command(cmd, PROJECT_ROOT, os.environ, label)
def _apt_install_debs(deb_paths: List[Path], label: str = "apt-get install") -> None:
"""Install a batch of .debs in a single apt transaction.
Atomicity matters: subpackages declare cross-Depends on each other
(e.g. cblas → blas), and apt must see the whole batch in one
invocation to satisfy them; otherwise a fresh install of cblas
would fail before blas is in place.
`--reinstall` is added when ANY .deb in the batch matches a
same-version installed package — apt's flag is per-invocation.
For .debs whose version differs from the installed one (or aren't
installed at all), `--reinstall` is a no-op: apt installs/upgrades
them normally either way.
"""
if not deb_paths:
return
deb_args: List[str] = []
needs_reinstall = False
for deb_path in deb_paths:
deb_args.append(
str(deb_path) if deb_path.is_absolute() else f"./{deb_path}"
)
name, version = _read_deb_package_version(deb_path)
if _deb_is_already_installed_at_version(name, version):
needs_reinstall = True
cmd = ['sudo', 'apt-get', 'install', '-y']
if needs_reinstall:
# --allow-downgrades: see _apt_install_deb.
cmd.extend(['--reinstall', '--allow-downgrades'])
print(f"Installing (with --reinstall): {deb_args}")
else:
print(f"Installing: {deb_args}")
cmd.extend(deb_args)
run_command(cmd, PROJECT_ROOT, os.environ, label)
class DebBuilder(UnixBuilder):
def __init__(self, package: str, flavor: str):
super().__init__(package, flavor)
if self.platform != 'linux':
raise BuildError(
f"deb_builder requires a linux flavor; got platform={self.platform}"
)
self.system_package_map = load_system_package_map()
self.architecture = detect_architecture()
self.scls_name = f"scls-{self.flavor_name}-{_deb_name(self.package)}"
self.release = str(self.recipe.get('release', '1'))
self.maintainer = self.recipe.get(
'maintainer', 'Christian Messe <cmesse@lbl.gov>'
)
# Output directory for .deb files. Mirrors rpm_builder's rpmbuild/RPMS
# but lives under work/ like UnixBuilder's other artifacts.
self.deb_out_dir = self.rpms_dir # work/pkgs (from UnixBuilder)
# Staging dir for the buildroot. Populated by install(); consumed by
# create_deb(). Kept separate from UnixBuilder's macOS pkg-root so
# both code paths can coexist on a dual-purpose host.
self.destdir = self.work_dir / "destdir"
self.jinja_env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "templates")),
autoescape=select_autoescape(),
trim_blocks=True,
lstrip_blocks=True,
)
# -------------------------------------------------------------------
# Dependency collection (port of rpm_builder.get_rpm_requires, with the
# system-package names translated to their Debian equivalents at the end)
# -------------------------------------------------------------------
def _translate_system_deps(self, names: List[str]) -> List[str]:
"""Apply the RHEL->Debian package name map.
Names already prefixed with 'scls-' are internal SCLS packages and
pass through unchanged. Anything else must appear in the map; a
missing key is a hard error so the map stays complete as recipes
grow.
"""
out = []
for name in names:
if name.startswith('scls-'):
out.append(name)
continue
if name not in self.system_package_map:
raise BuildError(
f"System package '{name}' has no Debian mapping. "
f"Add it to {SYSTEM_PACKAGE_MAP_PATH.relative_to(PROJECT_ROOT)} "
f"or mark it null (~) if no Debian equivalent exists."
)
deb_name = self.system_package_map[name]
if deb_name is None:
continue # Explicitly dropped.
# A list maps one RHEL package onto several Debian ones, where RHEL
# bundles what Debian splits (e.g. MAD headers in rdma-core-devel).
out.extend(deb_name if isinstance(deb_name, list) else [deb_name])
return out
def _collect_recipe_system_deps(self) -> Tuple[List[str], List[str]]:
"""Pull rpm_build_requires / rpm_requires out of the recipe."""
build_requires = []
requires = []
def extend_flavor_aware(field, target):
val = self.recipe.get(field)
if val is None:
return
if isinstance(val, dict):
flavor_specific = resolve_flavor_key(self.flavor, val)
if flavor_specific:
target.extend(flavor_specific)
if 'all' in val:
target.extend(val['all'])
elif isinstance(val, list):
target.extend(val)
extend_flavor_aware('rpm_build_requires', build_requires)
extend_flavor_aware('rpm_requires', requires)
return build_requires, requires
def _flavor_builds_own_gcc(self) -> bool:
"""True if the `gcc` recipe is in the active flavor's build set.
When False (e.g. on the `gcc`, `mkl`, `debug`, `intel` flavors whose
gcc recipe is gated by `include_flavors: [lbl, macos]`), every package
built for this flavor — bootstrap or not — relies on the host
toolchain. When True (lbl, macos), non-bootstrap packages pick up
scls-<flavor>-gcc via the normal `requires:` chain instead.
"""
try:
gcc_recipe = load_recipe('gcc')
except Exception:
return False
return should_build_package(gcc_recipe, self.flavor)
def _collect_auto_system_deps(self) -> List[str]:
"""System build-requires that the builder injects automatically.
Mirrors rpm_builder's bootstrap-compiler logic and extends it to
cover the case rpm_builder silently papers over: non-bootstrap
packages on a Linux flavor whose stack does not include gcc.
Those packages use the HOST gcc/g++/gfortran at build time and so
need the same system toolchain deps that bootstrap packages get.
On RHEL this has been working by ambient state; the Ubuntu path
doesn't have that luxury.
Rules, in order of precedence:
1. Bootstrap package with bootstrap_compilers.cc containing 'gcc'
→ inject gcc/gcc-c++/gcc-gfortran (original behavior).
2. Bootstrap package with a non-gcc bootstrap cc (e.g. macOS's
'clang') → inject nothing; platform compiler is sufficient.
3. Non-bootstrap package on a flavor that does NOT build its own
gcc (gcc/mkl/debug/intel) → inject gcc/gcc-c++/gcc-gfortran.
This is the new case.
4. Non-bootstrap package on a flavor that DOES build its own gcc
(lbl, macos) → inject nothing; the recipe's own `requires:`
pulls scls-<flavor>-gcc.
"""
deps = []
features = self.recipe.get('features', {})
is_bootstrap = self.recipe.get('bootstrap', False)
if is_bootstrap:
bootstrap = self.flavor.get('bootstrap_compilers', {})
cc = bootstrap.get('cc', '/usr/bin/gcc')
if 'gcc' in cc:
deps.extend(['gcc', 'gcc-c++'])
if features.get('fortran', False):
deps.append('gcc-gfortran')
elif not self._flavor_builds_own_gcc():
deps.extend(['gcc', 'gcc-c++'])
if features.get('fortran', False):
deps.append('gcc-gfortran')
deps.extend(['make', 'git'])
return deps
def _collect_math_and_mpi_deps(self) -> Tuple[List[str], List[str]]:
"""Math library + MPI deps. Mirrors rpm_builder's logic so both
builders produce the same dependency graph.
"""
build_requires = []
requires = []
features = self.recipe.get('features', {})
math_feature = features.get('math', 'none')
if math_feature in ('serial', 'parallel'):
linalg = self.flavor.get('math', {}).get('linalg', 'reference')
if linalg == 'mkl':
for dep in ('intel-oneapi-mkl', 'intel-oneapi-mkl-devel'):
requires.append(dep)
build_requires.append(dep)
# TODO(mkl-soname-guard): floor is unversioned, mirroring
# rpm_builder. DEBIAN/control is written explicitly here and
# dpkg-deb --build runs directly (no dpkg-shlibdeps), so the
# MKL SONAME baked into each binary's DT_NEEDED is not
# captured in Depends. An MKL-only enhancement could scan
# built binaries for `libmkl_*\.so\.N` and emit the
# corresponding versioned `Depends:` lines here. The exact
# DEB dependency shape (package name / virtual provides)
# needs verification against Intel's apt repo metadata
# before implementing. See doc/MKL_ABI_POLICY.md.
if math_feature == 'parallel':
scls_scalapack = f"scls-{self.flavor_name}-scalapack"
requires.append(scls_scalapack)
build_requires.append(scls_scalapack)
elif linalg == 'openblas':
scls_openblas = f"scls-{self.flavor_name}-openblas"
requires.append(scls_openblas)
build_requires.append(scls_openblas)
if math_feature == 'parallel':
scls_scalapack = f"scls-{self.flavor_name}-scalapack"
requires.append(scls_scalapack)
build_requires.append(scls_scalapack)
elif linalg in ('reference', 'lapack'):
scls_blas = f"scls-{self.flavor_name}-blas"
scls_lapack = f"scls-{self.flavor_name}-lapack"
requires.extend([scls_blas, scls_lapack])
build_requires.extend([scls_blas, scls_lapack])
if math_feature == 'parallel':
scls_scalapack = f"scls-{self.flavor_name}-scalapack"
requires.append(scls_scalapack)
build_requires.append(scls_scalapack)
if features.get('mpi', False):
scls_mpi = f"scls-{self.flavor_name}-openmpi"
requires.append(scls_mpi)
build_requires.append(scls_mpi)
return build_requires, requires
def _collect_recipe_scls_deps(self) -> Tuple[List[str], List[str]]:
"""Recipe `requires:` list, transformed to scls-<flavor>-<name>.
BUILD_ONLY_DEPS (cmake, autoconf, automake, libtool, pkg-config,
gklib) are
treated as build-time only and omitted from runtime Depends in both
the list and dict forms. Most recipes declare cmake under
`requires: {all: [cmake, ...]}` and we don't want to pull cmake into
every package's runtime closure.
"""
build_requires = []
requires = []
recipe_requires = self.recipe.get('requires')
if recipe_requires is None:
return build_requires, requires
def add(names):
for req in names:
scls_req = f"scls-{self.flavor_name}-{_deb_name(req)}"
build_requires.append(scls_req)
if req not in BUILD_ONLY_DEPS:
requires.append(scls_req)
if isinstance(recipe_requires, dict):
flavor_specific = resolve_flavor_key(self.flavor, recipe_requires)
if flavor_specific:
add(flavor_specific)
if 'all' in recipe_requires:
add(recipe_requires['all'])
elif isinstance(recipe_requires, list):
add(recipe_requires)
return build_requires, requires
def get_deb_recommends(self) -> List[str]:
"""Weak runtime deps from the recipe's `rpm_recommends:`, translated.
Rendered as `Recommends:` on the main binary package (parallels
rpm_builder.get_rpm_recommends). apt installs Recommends by
default and skips them without error when unavailable.
"""
recommends: List[str] = []
val = self.recipe.get('rpm_recommends')
if isinstance(val, dict):
flavor_specific = resolve_flavor_key(self.flavor, val)
if flavor_specific:
recommends.extend(flavor_specific)
if 'all' in val:
recommends.extend(val['all'])
elif isinstance(val, list):
recommends.extend(val)
return list(dict.fromkeys(self._translate_system_deps(recommends)))
def get_deb_depends(self) -> Tuple[List[str], List[str]]:
"""Return (Build-Depends, Depends) with all system names translated."""
br, r = self._collect_recipe_system_deps()
br.extend(self._collect_auto_system_deps())
math_br, math_r = self._collect_math_and_mpi_deps()
br.extend(math_br)
r.extend(math_r)
scls_br, scls_r = self._collect_recipe_scls_deps()
br.extend(scls_br)
r.extend(scls_r)
br = self._translate_system_deps(br)
r = self._translate_system_deps(r)
# Dedupe preserving order.
br = list(dict.fromkeys(br))
r = list(dict.fromkeys(r))
return br, r
# -------------------------------------------------------------------
# Install: override UnixBuilder.install to stage only
# -------------------------------------------------------------------
def _generated_install_root(self) -> Path:
"""Stage generated-package files into the destdir buildroot.
UnixBuilder.run() has a fast path for generated packages (source: none)
that bypasses install() entirely and calls install_generated(), which
writes templates/files into _generated_install_root(). Defaulting
that to the live prefix would mutate /opt/scls/<flavor> during
the build; we redirect to the destdir buildroot so create_deb()
can wrap it.
Critically, self.prefix is NOT rebound — the parent still passes
str(self.prefix) as the Jinja `prefix` context, so rendered file
contents (e.g. share/scls/config.yaml) carry the real install prefix,
not the staging path.
"""
if self.destdir.exists():
shutil.rmtree(self.destdir)
destdir_prefix = self.destdir / str(self.prefix).lstrip('/')
destdir_prefix.mkdir(parents=True)
return destdir_prefix
def install(self, build_dir: Path, env: Dict[str, str]) -> None:
"""Stage the install into self.destdir; do not copy into live prefix.
Parallels UnixBuilder.install up through the DESTDIR make install
and pre/post hooks, then stops. We write the registry entry INTO
the destdir so it ships inside the .deb.
"""
# Hard-fail if the recipe declares final-post hooks. UnixBuilder
# runs these from run_final_post_install_commands() AFTER files
# have been copied from destdir into the live prefix; deb_builder
# stops at staging, so there is no equivalent point in the .deb
# build flow. The only recipe using final_post today is gcc on
# macOS (Mach-O install_name fixups), which is not a DEB target.
# Refuse to silently drop the hook for any future Linux consumer
# rather than producing a .deb that diverges from RPM behavior.
# Use resolve_flavor_key so inheritance/component fallbacks are
# respected — an exact-key membership test would miss a hook
# inherited from a parent flavor.
install_section = self.recipe.get('install', {}) or {}
flavor_final = install_section.get('flavor_final_post', {}) or {}
flavor_final_resolved = (
resolve_flavor_key(self.flavor, flavor_final)
if flavor_final else None
)
if install_section.get('final_post') or flavor_final_resolved:
raise BuildError(
f"Recipe {self.package} declares install.final_post or "
f"install.flavor_final_post matching flavor "
f"'{self.flavor_name}' (possibly via inheritance), which "
"is not yet implemented in deb_builder. Add a Debian-side "
"final-post step before relying on this hook."
)
if self.destdir.exists():
shutil.rmtree(self.destdir)
self.destdir.mkdir(parents=True)
# Stage lib -> lib64 in the destdir up-front so the package's
# `make install DESTDIR=...` lands files in the same layout we ship
# at the live prefix (Linux From Scratch convention; the environment
# package owns the symlink on the installed system). Without this,
# an autotools package whose libdir resolves to lib/ would stage
# into <destdir>/<prefix>/lib while one whose libdir resolves to
# lib64/ would stage into <destdir>/<prefix>/lib64, and
# write_registry_entry's pkg-config probe (which only looks at
# lib/pkgconfig) would miss the latter. We drop the staging symlink
# before dpkg-deb wraps the destdir so non-environment .debs don't
# claim ownership of the symlink.
destdir_prefix = self.destdir / str(self.prefix).lstrip('/')
destdir_prefix.mkdir(parents=True, exist_ok=True)
is_environment = self.package == 'environment'
staging_lib_symlink: Path | None = None
if platform.system() == 'Linux':
lib_link = destdir_prefix / 'lib'
lib64_dir = destdir_prefix / 'lib64'
if not lib_link.exists() and not lib_link.is_symlink():
lib64_dir.mkdir(parents=True, exist_ok=True)
lib_link.symlink_to('lib64')
if not is_environment:
staging_lib_symlink = lib_link
# Pre-install commands
if 'install' in self.recipe and 'pre' in self.recipe['install']:
for cmd in self.recipe['install']['pre']:
expanded = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded], build_dir, env, "pre-install")
if self.package == 'zlib' and (build_dir / 'zlib').exists():
build_dir = build_dir / 'zlib'
if not build_dir.exists():
raise BuildError(f"Build directory does not exist: {build_dir}")
# Custom vs default install. install.commands run from the source
# root, matching templates/default.spec.j2's %install section
# (CLAUDE.md documents this contract). For autotools and
# custom_makefile recipes build_dir == source_dir, but for cmake
# recipes build_dir is <source>/build, which would break
# commands like `install -m 644 lib/* ...` and `%{srcdir}/LICENSE`
# references. self.source_dir is set in the parent's run().
custom_install_cwd = self.source_dir if self.source_dir else build_dir
if 'install' in self.recipe and 'commands' in self.recipe['install']:
for cmd in self.recipe['install']['commands']:
cmd = cmd.replace('%{buildroot}', str(self.destdir))
cmd = cmd.replace('%{prefix}', str(self.prefix))
cmd = cmd.replace('%{libext}', self.lib_ext)
expanded = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded], custom_install_cwd, env, "install")
elif self.recipe.get('configure', {}).get('type') == 'meson':
# install.args / flavor_args go to `meson install`, as the spec
# template appends them to its meson install line.
install_cmd = ['meson', 'install', '-C', 'build', '--no-rebuild',
'--destdir', str(self.destdir)]
if 'install' in self.recipe and 'args' in self.recipe['install']:
install_cmd.extend(self.check_args(self.recipe['install']['args']))
if 'install' in self.recipe and 'flavor_args' in self.recipe['install']:
flavor_specific = resolve_flavor_key(self.flavor, self.recipe['install']['flavor_args'])
if flavor_specific:
install_cmd.extend(self.check_args(flavor_specific))
run_command(install_cmd, build_dir, env, "install")
else:
install_cmd = ['make', 'install', f'DESTDIR={self.destdir}']
if 'install' in self.recipe:
if 'args' in self.recipe['install']:
install_cmd.extend(self.check_args(self.recipe['install']['args']))
if 'flavor_args' in self.recipe['install']:
flavor_specific = resolve_flavor_key(
self.flavor, self.recipe['install']['flavor_args']
)
if flavor_specific:
install_cmd.extend(self.check_args(flavor_specific))
run_command(install_cmd, build_dir, env, "install")
# Post-install hooks. Three variants (post / flavor_post / platform_post)
# all use the same %{...} substitution conventions as UnixBuilder.
def run_post(cmds, label):
for cmd in cmds:
cmd = cmd.replace('%{buildroot}', str(self.destdir))
cmd = cmd.replace('%{final_prefix}', str(self.prefix))
cmd = cmd.replace('%{prefix}', str(destdir_prefix))
cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', cmd], build_dir, env, label)
if 'install' in self.recipe and 'post' in self.recipe['install']:
run_post(self.recipe['install']['post'], "post-install")
if 'install' in self.recipe and 'flavor_post' in self.recipe['install']:
fp = resolve_flavor_key(self.flavor, self.recipe['install']['flavor_post'])
if fp:
run_post(fp, "flavor-post-install")
if 'install' in self.recipe and 'platform_post' in self.recipe['install']:
pp = self.recipe['install']['platform_post'].get(self.platform)
if pp:
run_post(pp, f"platform-post-install ({self.platform})")
# Drop libtool archives and system-managed info directory.
clean_libtool_files(destdir_prefix)
check_no_triplet_programs(destdir_prefix, self.recipe)
info_dir = destdir_prefix / "share" / "info"
if info_dir.exists():
shutil.rmtree(info_dir, ignore_errors=True)
if not destdir_prefix.exists():
raise BuildError(f"No files staged under {destdir_prefix}")
# Write the SCLS registry entry INTO the destdir so it gets packaged
# alongside the rest of the install tree. write_registry_entry places
# the file at <prefix>/share/scls/registry/<name>.yaml, so passing
# destdir_prefix as "prefix" stages it correctly. install_prefix is
# the clean target path that gets embedded in the recorded
# cflags/ldflags — without it, the destdir would leak through.
write_registry_entry(
destdir_prefix, self.recipe, self.flavor_name,
install_prefix=self.prefix,
)
# Drop the staging-only lib -> lib64 symlink so non-environment
# .debs don't ship a duplicate of the symlink that the environment
# package owns. Files staged under it remain at lib64/ where they
# already live.
if staging_lib_symlink is not None and staging_lib_symlink.is_symlink():
staging_lib_symlink.unlink()
print(f"Staged install under {self.destdir}")
# -------------------------------------------------------------------
# .deb creation
# -------------------------------------------------------------------
def _binary_packages(self) -> List[Dict]:
"""Return one descriptor per produced .deb, in the order:
[main, subpkg_0, subpkg_1, ...]. Filters out subpackages whose
scls_name would collide with main (mirrors rpm_builder.get_
subpackages_for_spec — those subpackages' files stay in main).
Each descriptor:
scls_name — Debian Package: name (e.g. scls-debug-blas)
subpkg_name — recipe-side subpackage name; None for main
depends — list of Depends entries (already translated)
summary — Description: header
description — full description body (first line is summary)
"""
main_depends = self.get_deb_depends()[1]
main_description = (
load_description(self.package) or self.recipe.get('summary', self.package)
)
main_summary = (
self.recipe.get('summary') or main_description.split('\n', 1)[0]
)
# Pre-Depends on environment forces dpkg to fully unpack and
# configure the environment package before this package is unpacked
# — the Debian-side equivalent of RPM's Requires(pre). Plain Depends
# is too weak: dpkg may unpack two packages with mutual Depends in
# either order, and the first to unpack would create %{prefix}/lib/
# as a real directory and break the layout. The environment package
# itself naturally skips this self-dep.
env_pkg = f"scls-{self.flavor_name}-environment"
main_pre_depends: List[str] = []
if self.package != 'environment':
main_pre_depends.append(env_pkg)
if env_pkg not in main_depends:
main_depends.append(env_pkg)
out = [{
'scls_name': self.scls_name,
'subpkg_name': None,
'depends': main_depends,
'pre_depends': main_pre_depends,
'recommends': self.get_deb_recommends(),
'summary': main_summary,
'description': main_description,
}]
subpackages = get_subpackages_for_flavor(self.recipe, self.flavor_name)
for subpkg in subpackages:
scls_name = f"scls-{self.flavor_name}-{_deb_name(subpkg['name'])}"
if scls_name == self.scls_name:
# Collision with main — its files stay in main, no separate .deb.
continue
deps = get_subpackage_dependencies(subpkg, self.flavor_name)
translated = [f"scls-{self.flavor_name}-{_deb_name(d)}" for d in deps]
# Subpackages also install into %{prefix} and need env first.
sub_pre_depends = [env_pkg]
if env_pkg not in translated:
translated.append(env_pkg)
summary = subpkg.get('summary', f"{subpkg['name']} subpackage")
description = subpkg.get('description') or summary
out.append({
'scls_name': scls_name,
'subpkg_name': subpkg['name'],
'depends': translated,
'pre_depends': sub_pre_depends,
'summary': summary,
'description': description,
})
return out
def _enumerate_destdir_files(self) -> List[Tuple[Path, str]]:
"""Walk self.destdir/<prefix>/ and return [(real_path, view_path), ...]
view_path is the prefix-relative path with the LFS lib64/ rewritten
as lib/, matching how recipe `files:` globs are written. The LFS
`lib -> lib64` symlink staged by install() is filtered out — it's
recreated per-destdir during splitting.
"""
destdir_prefix = self.destdir / str(self.prefix).lstrip('/')
out: List[Tuple[Path, str]] = []
for path in destdir_prefix.rglob('*'):
if path.is_dir() and not path.is_symlink():
continue
try:
rel = path.relative_to(destdir_prefix)
except ValueError:
continue
rel_str = str(rel)
if rel_str == 'lib' and path.is_symlink():
continue
if rel_str.startswith('lib64/'):
view = 'lib/' + rel_str[len('lib64/'):]
else:
view = rel_str
out.append((path, view))
return out
def _split_destdir_for_subpackages(self) -> None:
"""Move files claimed by subpackage `files:` globs out of self.destdir
and into per-subpackage destdirs at work/build/destdir-<subpkg>.
Behaviors mirrored from the RPM split semantics:
* Subpackages whose `scls-<flavor>-<name>` collides with the main
package's name are NOT split out — their files remain in main.
* Each subpackage must claim at least one file (empty match is a
recipe bug, surfaced as BuildError).
* A file matched by two different subpackages' globs is a recipe
bug; we raise rather than silently let first-declared win.
* `match` is fnmatch.fnmatch on the lib/ view; this is the same
glob semantics build_common.match_files_to_subpackage uses for
the RPM path.
After this runs, self.destdir contains only files that belong to
the main package; per-subpackage destdirs contain their own files
under `<prefix>/lib64/...`. Subpackage destdirs intentionally do
NOT include a `<prefix>/lib -> lib64` symlink — the environment
package owns that path, and a duplicate would cause dpkg file-
conflict errors on install. Files moved under lib64/ remain
reachable via the lib/ view at runtime because environment
provides the symlink.
"""
import fnmatch
subpackages = get_subpackages_for_flavor(self.recipe, self.flavor_name)
splittable = [
s for s in subpackages
if f"scls-{self.flavor_name}-{_deb_name(s['name'])}" != self.scls_name
]
if not splittable:
return
files = self._enumerate_destdir_files()
view_to_real = {view: real for real, view in files}
ownership: Dict[str, str] = {} # view path -> claiming subpackage name
# Match a view path against a recipe pattern. RPM's %files treats a
# bare directory path as "the dir and everything under it"; emulate
# that here so recipes written for the RPM path (e.g.
# `share/sundials/examples`) still claim their tree on the deb path.
def matches(view: str, pat: str) -> bool:
return fnmatch.fnmatch(view, pat) or view.startswith(pat.rstrip('/') + '/')
# Assignment pass: figure out who claims what; reject overlap upfront
# so we don't half-move files before failing.
per_subpkg_views: Dict[str, List[str]] = {}
for subpkg in splittable:
patterns = subpkg.get('files', [])
if not patterns:
raise BuildError(
f"Subpackage {subpkg['name']} of {self.package} has no "
f"`files:` patterns; it would package nothing."
)
claimed = []
for view in view_to_real:
if not any(matches(view, pat) for pat in patterns):
continue
if view in ownership and ownership[view] != subpkg['name']:
raise BuildError(
f"File {view!r} matched by both "
f"{ownership[view]!r} and {subpkg['name']!r} "
f"subpackages of {self.package}; tighten one of "
f"the `files:` glob lists."
)
ownership[view] = subpkg['name']
claimed.append(view)
if not claimed:
raise BuildError(
f"Subpackage {subpkg['name']} of {self.package} matched "
f"zero installed files (patterns: {patterns}). Recipe "
f"bug: either the glob is wrong or the file isn't being "
f"installed."
)
per_subpkg_views[subpkg['name']] = claimed
# Move pass.
# On any failure inside this loop, the next `scls build <pkg>` run
# rebuilds self.destdir from scratch (install() does rmtree before
# restaging) and `_split_destdir_for_subpackages` rmtrees any
# `destdir-<subpkg>` it touches at start. So a partial split is not
# observable to a subsequent run; we just propagate the exception
# rather than implementing a stage-then-rename two-phase commit.
main_destdir_prefix = self.destdir / str(self.prefix).lstrip('/')
for subpkg_name, views in per_subpkg_views.items():
sub_destdir = self.work_dir / f"destdir-{subpkg_name}"
if sub_destdir.exists():
shutil.rmtree(sub_destdir)
sub_prefix = sub_destdir / str(self.prefix).lstrip('/')
sub_prefix.mkdir(parents=True)
# Do NOT create a `lib -> lib64` symlink in subpackage destdirs.
# The environment package owns `<prefix>/lib`; if a subpackage
# also packaged that symlink, dpkg would refuse the install with
# a file-conflict error. Files moved under lib64/ stay reachable
# via lib/ at runtime because environment provides the symlink.
for view in views:
real = view_to_real[view]
rel = real.relative_to(main_destdir_prefix)
dest = sub_prefix / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(real), str(dest))
# Drop now-empty directories under main (deepest first). This is
# cosmetic but prevents the main .deb from claiming ownership of
# empty trees that morally belong to a subpackage (e.g. an empty
# lib/cmake/cblas-1.0/ after cblas was split out).
self._prune_empty_dirs(main_destdir_prefix)
@staticmethod
def _prune_empty_dirs(root: Path) -> None:
"""Remove empty directories under root, deepest first. Symlinks are
never removed; non-empty directories are skipped silently.
"""
if not root.exists():
return
candidates = [
p for p in root.rglob('*')
if p.is_dir() and not p.is_symlink()
]
for path in sorted(candidates, key=lambda p: len(p.parts), reverse=True):
try:
path.rmdir()
except OSError:
pass
def write_control(self, destdir: Path, scls_name: str,
depends: List[str], summary: str,
description: str,
pre_depends: List[str] | None = None,
recommends: List[str] | None = None) -> None:
"""Render DEBIAN/control + DEBIAN/postinst into the given destdir.
Used for both the main package and each subpackage. We discard
Build-Depends here: this is a binary package (dpkg-deb --build
output), not a source package, so Build-Depends would be dead
metadata.
"""
debian_dir = destdir / "DEBIAN"
debian_dir.mkdir(parents=True, exist_ok=True)
# Debian control descriptions: first line is the summary (on the
# Description: line itself), subsequent lines are indented by one
# space, with '.' used for blank paragraph separators.
body = description.strip().splitlines()
if body and body[0] == summary:
body = body[1:]
template = self.jinja_env.get_template("default.control.j2")
rendered = template.render(
scls_name=scls_name,
version=self.recipe['version'],
release=self.release,
architecture=self.architecture,
maintainer=self.maintainer,
depends=depends,
pre_depends=pre_depends or [],
recommends=recommends or [],
homepage=self.recipe.get('homepage', ''),
summary=summary,
description_lines=body,
)
control_path = debian_dir / "control"
control_path.write_text(rendered)
print(f"Wrote {control_path}")
self._write_postinst(debian_dir)
def _write_postinst(self, debian_dir: Path) -> None:
"""Emit DEBIAN/postinst with the same fixups RPM runs in %post.
Mirrors rpm_builder.get_library_symlink_fixes(): some upstreams
produce hard copies instead of symlinks for `libfoo.so` ->
`libfoo.so.N`, which breaks the loader when the unversioned link
is the build-time linkage target. We also refresh the loader
cache for the SCLS prefix so consumers don't need
LD_LIBRARY_PATH at runtime.
Emitted into every package's DEBIAN/ (main and each subpackage).
The script is self-guarded with `[ -d {prefix}/lib ]` so it's a
no-op for packages that ship no libraries; ldconfig over the
whole prefix is idempotent.
We bake the literal install prefix in here because dpkg does not
substitute spec-style macros the way rpmbuild does for the spec
%post section.
"""
prefix = str(self.prefix)
script = f"""#!/bin/sh
# Auto-generated by scls deb_builder for {self.package}.