verify_wheel_contents.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. #!/usr/bin/env python3
  2. """Verify a built wheel ships the required bundled data and only the intended configuration templates.
  3. The wheel must contain the tracked configuration templates and must NOT contain a
  4. live configuration.py (which holds SECRET_KEY and database credentials), any other
  5. local configuration*.py variant, or any ldap_config*.py (which holds LDAP bind
  6. credentials). This guards against a dirty or manual build leaking secrets into a
  7. published artifact. The wheel must also ship the runtime-critical bundled data
  8. (release metadata, templates, translations, static assets, the pre-rendered
  9. documentation site, deployment examples), so a broken build fails here with a
  10. precise message instead of at smoke-test time. main() also cross-checks
  11. pyproject.toml's wheel force-include table against
  12. REQUIRED_FILES/REQUIRED_PREFIXES/ALLOWED, so an addition there without matching
  13. verifier coverage fails too.
  14. """
  15. import sys
  16. import tomllib
  17. import zipfile
  18. from pathlib import Path, PurePosixPath
  19. # The scan covers the entire wheel; only these two tracked templates (at netbox/<name> after the
  20. # wheel `sources = ["netbox"]` strip) are allowed to ship.
  21. ALLOWED = {
  22. 'netbox/configuration_example.py',
  23. 'netbox/configuration_testing.py',
  24. }
  25. # Runtime-critical bundled data; mirrors the force-include table in pyproject.toml.
  26. # Hand-maintained, not derived: main() cross-checks pyproject.toml's wheel
  27. # force-include table against these sets (plus ALLOWED) in the other direction, so a
  28. # force-include added there without matching coverage here also fails.
  29. REQUIRED_FILES = {
  30. 'netbox/_data/contrib/apache.conf',
  31. 'netbox/_data/contrib/gunicorn.py',
  32. 'netbox/_data/contrib/netbox-rq.service',
  33. 'netbox/_data/contrib/netbox.env',
  34. 'netbox/_data/contrib/netbox.service',
  35. 'netbox/_data/contrib/nginx.conf',
  36. 'netbox/_data/contrib/uwsgi.ini',
  37. 'netbox/_data/docs/index.html',
  38. 'netbox/_data/docs/models/dcim/device/index.html',
  39. 'netbox/_data/release.yaml',
  40. }
  41. REQUIRED_PREFIXES = (
  42. 'netbox/_data/docs/',
  43. 'netbox/_data/project-static/dist/',
  44. 'netbox/_data/project-static/img/',
  45. 'netbox/_data/project-static/js/',
  46. 'netbox/_data/templates/',
  47. 'netbox/_data/translations/',
  48. )
  49. def configuration_members(names):
  50. """Return the set of configuration*.py members anywhere inside the wheel."""
  51. members = set()
  52. for name in names:
  53. path = PurePosixPath(name)
  54. # Scan the whole wheel: any configuration*.py outside the two tracked templates, or any
  55. # ldap_config*.py at all, is a leak, wherever it sits in the archive.
  56. if path.suffix == '.py' and (path.name.startswith('configuration') or path.name.startswith('ldap_config')):
  57. members.add(name)
  58. return members
  59. def missing_runtime_data(names):
  60. """Return the sorted list of required bundled files and prefixes absent from the wheel."""
  61. missing = sorted(REQUIRED_FILES - names)
  62. missing += [prefix for prefix in REQUIRED_PREFIXES if not any(name.startswith(prefix) for name in names)]
  63. return missing
  64. def uncovered_force_includes(pyproject_path):
  65. """Return wheel force-include destinations in pyproject.toml not covered by this verifier.
  66. REQUIRED_FILES/REQUIRED_PREFIXES/ALLOWED stay hand-owned and independent of pyproject.toml
  67. (a full derivation would let a deleted force-include line shrink the expectations with it
  68. and go silently green), so this only catches drift in one direction: a force-include added
  69. to pyproject.toml without matching verifier coverage. A deleted force-include line still
  70. fails the REQUIRED_FILES/REQUIRED_PREFIXES checks in missing_runtime_data().
  71. """
  72. with open(pyproject_path, 'rb') as handle:
  73. pyproject = tomllib.load(handle)
  74. force_include = pyproject['tool']['hatch']['build']['targets']['wheel']['force-include']
  75. uncovered = []
  76. for destination in force_include.values():
  77. # The wheel's `sources = ["netbox"]` setting strips one leading "netbox/" segment from
  78. # every path, including these force-include destinations.
  79. stripped = destination.removeprefix('netbox/')
  80. if stripped in REQUIRED_FILES or stripped in ALLOWED or f'{stripped}/' in REQUIRED_PREFIXES:
  81. continue
  82. uncovered.append(destination)
  83. return sorted(uncovered)
  84. def main(argv):
  85. if len(argv) != 2:
  86. print('usage: verify_wheel_contents.py <wheel>')
  87. return 2
  88. with zipfile.ZipFile(argv[1]) as archive:
  89. names = set(archive.namelist())
  90. found = configuration_members(names)
  91. missing = sorted(ALLOWED - found)
  92. unexpected = sorted(found - ALLOWED)
  93. missing_data = missing_runtime_data(names)
  94. pyproject_path = Path(__file__).resolve().parents[1] / 'pyproject.toml'
  95. uncovered = uncovered_force_includes(pyproject_path)
  96. if missing or unexpected or missing_data or uncovered:
  97. print('Wheel contents are not as expected:')
  98. if missing:
  99. print(f' - missing templates: {missing}')
  100. if unexpected:
  101. print(f' - unexpected (possible secret leak): {unexpected}')
  102. if missing_data:
  103. print(f' - missing runtime data: {missing_data}')
  104. if uncovered:
  105. print(f' - pyproject.toml force-includes not covered by this verifier: {uncovered}')
  106. return 1
  107. print(f'OK: wheel ships the required bundled data and only the {len(ALLOWED)} configuration templates')
  108. return 0
  109. if __name__ == '__main__':
  110. sys.exit(main(sys.argv))