-
Notifications
You must be signed in to change notification settings - Fork 0
Add offline bundle verification and offline installer with doctor readiness checks #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import hashlib | ||
| import json | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
|
|
||
| def _sha256(path: Path) -> str: | ||
| h = hashlib.sha256() | ||
| with path.open("rb") as f: | ||
| for chunk in iter(lambda: f.read(1024 * 1024), b""): | ||
| h.update(chunk) | ||
| return h.hexdigest() | ||
|
|
||
|
|
||
| def verify_bundle(bundle_dir: Path, policy_path: Path) -> dict[str, Any]: | ||
| violations: list[str] = [] | ||
| checked_assets: list[dict[str, Any]] = [] | ||
|
|
||
| if not bundle_dir.exists(): | ||
| return { | ||
| "ok": False, | ||
| "violations": [f"bundle directory not found: {bundle_dir}"], | ||
| "checked_assets": [], | ||
| } | ||
|
|
||
| if not policy_path.exists(): | ||
| return { | ||
| "ok": False, | ||
| "violations": [f"policy file not found: {policy_path}"], | ||
| "checked_assets": [], | ||
| } | ||
|
|
||
| policy = json.loads(policy_path.read_text(encoding="utf-8")) | ||
| allowlist = set(policy.get("allowlist", [])) | ||
| allowed_licenses = set(policy.get("allowed_licenses", [])) | ||
| assets = policy.get("assets", []) | ||
|
|
||
| if not assets: | ||
| violations.append("policy has no assets") | ||
|
|
||
| for asset in assets: | ||
| rel_path = asset.get("path") | ||
| expected_hash = (asset.get("sha256") or "").lower() | ||
| license_name = asset.get("license", "UNKNOWN") | ||
| target = bundle_dir / rel_path if rel_path else bundle_dir | ||
|
|
||
| asset_result = { | ||
| "path": rel_path, | ||
| "exists": False, | ||
| "hash_ok": False, | ||
| "allowlisted": False, | ||
| "license_ok": False, | ||
| "license": license_name, | ||
| } | ||
|
|
||
| if not rel_path: | ||
| violations.append("asset.path is required") | ||
| checked_assets.append(asset_result) | ||
| continue | ||
|
|
||
| if rel_path in allowlist: | ||
| asset_result["allowlisted"] = True | ||
| else: | ||
| violations.append(f"allowlist violation: {rel_path}") | ||
|
|
||
| if license_name in allowed_licenses: | ||
| asset_result["license_ok"] = True | ||
| else: | ||
| violations.append(f"license violation: {rel_path} ({license_name})") | ||
|
|
||
| if target.exists() and target.is_file(): | ||
| asset_result["exists"] = True | ||
| digest = _sha256(target) | ||
| asset_result["sha256"] = digest | ||
| if expected_hash and digest == expected_hash: | ||
| asset_result["hash_ok"] = True | ||
| else: | ||
| violations.append( | ||
| f"hash mismatch: {rel_path} expected={expected_hash or '<empty>'} actual={digest}" | ||
| ) | ||
| else: | ||
| violations.append(f"missing file: {rel_path}") | ||
|
|
||
| checked_assets.append(asset_result) | ||
|
|
||
| return { | ||
| "ok": not violations, | ||
| "violations": violations, | ||
| "checked_assets": checked_assets, | ||
| "policy_file": str(policy_path), | ||
| "bundle_dir": str(bundle_dir), | ||
| } | ||
|
|
||
|
|
||
| def _build_parser() -> argparse.ArgumentParser: | ||
| parser = argparse.ArgumentParser(description="Offline bundle verification helper") | ||
| sub = parser.add_subparsers(dest="command", required=True) | ||
|
|
||
| verify = sub.add_parser("verify", help="verify offline bundle policy/hash/license checks") | ||
| verify.add_argument("--bundle-dir", type=Path, required=True) | ||
| verify.add_argument("--policy", type=Path, required=True) | ||
|
|
||
| return parser | ||
|
|
||
|
|
||
| def main(argv: list[str] | None = None) -> int: | ||
| parser = _build_parser() | ||
| args = parser.parse_args(argv) | ||
|
|
||
| if args.command == "verify": | ||
| report = verify_bundle(args.bundle_dir, args.policy) | ||
| print(json.dumps(report, ensure_ascii=False, indent=2)) | ||
| if not report["ok"]: | ||
| return 1 | ||
| return 0 | ||
|
|
||
| return 2 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| param( | ||
| [string]$BundleDir = "$(Join-Path $PSScriptRoot '.offline_bundle')", | ||
| [string]$PolicyFile = "" | ||
| ) | ||
|
|
||
| $ErrorActionPreference = 'Stop' | ||
| if ([string]::IsNullOrWhiteSpace($PolicyFile)) { | ||
| $PolicyFile = Join-Path $BundleDir 'meta/offline_policy.json' | ||
| } | ||
| $WheelDir = Join-Path $BundleDir 'wheels' | ||
| $ReqFile = Join-Path $BundleDir 'meta/offline_requirements.txt' | ||
|
|
||
| Write-Host '[1/3] Verifying offline bundle policy/hash/license...' | ||
| python -m bitnet_tools.offline_bundle verify --bundle-dir "$BundleDir" --policy "$PolicyFile" | ||
| if ($LASTEXITCODE -ne 0) { | ||
| Write-Error '[ERROR] Policy verification failed. Installation aborted.' | ||
| exit 1 | ||
| } | ||
|
|
||
| Write-Host '[2/3] Installing from offline wheel bundle only...' | ||
| if (Test-Path $ReqFile) { | ||
| python -m pip install --no-index --find-links "$WheelDir" -r "$ReqFile" | ||
| } else { | ||
| python -m pip install --no-index --find-links "$WheelDir" bitnet-tools | ||
| } | ||
| if ($LASTEXITCODE -ne 0) { | ||
| exit $LASTEXITCODE | ||
| } | ||
|
|
||
| Write-Host '[3/3] Offline installation complete.' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" | ||
| BUNDLE_DIR="${BUNDLE_DIR:-${ROOT_DIR}/.offline_bundle}" | ||
| POLICY_FILE="${POLICY_FILE:-${BUNDLE_DIR}/meta/offline_policy.json}" | ||
| WHEEL_DIR="${BUNDLE_DIR}/wheels" | ||
| REQ_FILE="${BUNDLE_DIR}/meta/offline_requirements.txt" | ||
|
|
||
| printf '[1/3] Verifying offline bundle policy/hash/license...\n' | ||
| if ! python -m bitnet_tools.offline_bundle verify --bundle-dir "${BUNDLE_DIR}" --policy "${POLICY_FILE}"; then | ||
| echo "[ERROR] Policy verification failed. Installation aborted." | ||
| exit 1 | ||
| fi | ||
|
|
||
| printf '[2/3] Installing from offline wheel bundle only...\n' | ||
| if [[ -f "${REQ_FILE}" ]]; then | ||
| python -m pip install --no-index --find-links "${WHEEL_DIR}" -r "${REQ_FILE}" | ||
| else | ||
| python -m pip install --no-index --find-links "${WHEEL_DIR}" bitnet-tools | ||
| fi | ||
|
|
||
| printf '[3/3] Offline installation complete.\n' | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ META_DIR="${BUNDLE_DIR}/meta" | |
|
|
||
| mkdir -p "${WHEEL_DIR}" "${MODEL_DIR}" "${META_DIR}" | ||
|
|
||
| echo "[1/6] Collecting environment metadata" | ||
| echo "[1/7] Collecting environment metadata" | ||
| python -V | tee "${META_DIR}/python_version.txt" | ||
| pip --version | tee "${META_DIR}/pip_version.txt" | ||
| python -m pip freeze | tee "${META_DIR}/pip_freeze.txt" >/dev/null | ||
|
|
@@ -20,54 +20,91 @@ python=$(python -V 2>&1) | |
| pip=$(pip --version) | ||
| MANIFEST | ||
|
|
||
| echo "[2/6] Building local project wheel" | ||
| echo "[2/7] Building local project wheel" | ||
| if python -m pip wheel --no-build-isolation "${ROOT_DIR}" -w "${WHEEL_DIR}"; then | ||
| echo "local wheel build: success" | ||
| else | ||
| echo "local wheel build failed" | tee "${META_DIR}/wheel_build_warning.txt" | ||
| fi | ||
|
|
||
| # Optional runtime dependencies for charts/notebooks/tests | ||
| cat > "${META_DIR}/requirements_online.txt" <<REQ | ||
| matplotlib | ||
| pandas | ||
| jupyterlab | ||
| pytest | ||
| REQ | ||
| cp "${META_DIR}/requirements_online.txt" "${META_DIR}/offline_requirements.txt" | ||
|
|
||
| echo "[3/6] Attempting to download optional dependency wheels" | ||
| echo "[3/7] Attempting to download optional dependency wheels" | ||
| if python -m pip download -r "${META_DIR}/requirements_online.txt" -d "${WHEEL_DIR}"; then | ||
| echo "optional wheel download: success" | ||
| else | ||
| echo "optional wheel download: failed (network/proxy 제한 가능)" | tee "${META_DIR}/download_warning.txt" | ||
| fi | ||
|
|
||
| echo "[4/6] Attempting to fetch Ollama install script for offline archive" | ||
| echo "[4/7] Attempting to fetch Ollama install script for offline archive" | ||
| if curl -fsSL https://ollama.com/install.sh -o "${MODEL_DIR}/ollama_install.sh"; then | ||
| echo "ollama installer script archived" | ||
| else | ||
| echo "ollama installer download failed (network/proxy 제한 가능)" | tee -a "${META_DIR}/download_warning.txt" | ||
| fi | ||
|
|
||
| echo "[5/6] Attempting to detect local ollama" | ||
| echo "[5/7] Attempting to detect local ollama" | ||
| if command -v ollama >/dev/null 2>&1; then | ||
| ollama --version | tee "${META_DIR}/ollama_version.txt" | ||
| # Avoid model pull in automated script unless explicitly requested | ||
| echo "ollama detected; model pull can be run manually:" | tee -a "${META_DIR}/ollama_version.txt" | ||
| echo " ollama pull <bitnet-model-tag>" | tee -a "${META_DIR}/ollama_version.txt" | ||
| else | ||
| echo "ollama not installed in current environment" | tee "${META_DIR}/ollama_version.txt" | ||
| fi | ||
|
|
||
| echo "[6/6] Writing offline install guide" | ||
| echo "[6/7] Writing policy (allowlist/hash/license)" | ||
| ROOT_DIR="$ROOT_DIR" BUNDLE_DIR="$BUNDLE_DIR" python - <<'PY' | ||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import json | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
| bundle = Path(os.environ["BUNDLE_DIR"]) | ||
| meta = bundle / "meta" | ||
| assets = [] | ||
|
|
||
| for path in sorted((bundle / "wheels").glob("*.whl")): | ||
| h = hashlib.sha256(path.read_bytes()).hexdigest() | ||
| rel = path.relative_to(bundle).as_posix() | ||
| assets.append({"path": rel, "sha256": h, "license": "UNKNOWN"}) | ||
|
Comment on lines
+74
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The generated policy only records wheel files (and optionally Useful? React with 👍 / 👎. |
||
|
|
||
| ollama_script = bundle / "models" / "ollama_install.sh" | ||
| if ollama_script.exists(): | ||
| h = hashlib.sha256(ollama_script.read_bytes()).hexdigest() | ||
| assets.append({"path": "models/ollama_install.sh", "sha256": h, "license": "MIT"}) | ||
|
|
||
| policy = { | ||
| "version": "1.0", | ||
| "bundle": bundle.name, | ||
| "allowlist": [asset["path"] for asset in assets], | ||
| "allowed_licenses": ["MIT", "BSD-3-Clause", "Apache-2.0", "PSF-2.0", "UNKNOWN"], | ||
| "assets": assets, | ||
| } | ||
|
|
||
| (meta / "offline_policy.json").write_text(json.dumps(policy, ensure_ascii=False, indent=2), encoding="utf-8") | ||
| PY | ||
|
|
||
| echo "[7/7] Writing offline install guide" | ||
| cat > "${BUNDLE_DIR}/OFFLINE_USE.md" <<GUIDE | ||
| # Offline bundle usage | ||
|
|
||
| ## Install project from local wheel | ||
| python -m pip install --no-index --find-links ./wheels bitnet-tools | ||
| ## 1) 정책 검증 + 설치 (Linux/macOS) | ||
| ./offline_install.sh | ||
|
|
||
| ## 2) 정책 검증 + 설치 (Windows PowerShell) | ||
| ./offline_install.ps1 | ||
|
|
||
| ## Optional dependencies (if downloaded) | ||
| python -m pip install --no-index --find-links ./wheels matplotlib pandas jupyterlab pytest | ||
| ## 검증 정책 | ||
| - 설치 전 SHA256/허용목록/라이선스 검증을 수행합니다. | ||
| - 위반 항목이 하나라도 있으면 설치를 즉시 중단합니다. | ||
|
|
||
| ## Notes | ||
| - If optional wheel download failed, rerun this script in a network-allowed environment. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
scripts/prepare_online_bundle.shalways writesmeta/offline_requirements.txtwith only optional packages (matplotlib,pandas,jupyterlab,pytest), so this branch is always taken andbitnet-toolsis never installed on a fresh offline host. It also makes optional-wheel download failures fatal at install time, even though the download step is marked best-effort. The installer should installbitnet-toolsexplicitly (and treat optional deps separately).Useful? React with 👍 / 👎.