File size: 12,807 Bytes
a28ec65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Air-gapped install bundle (REQ-DIST-004).

* ``create_bundle(output)`` β€” packages the local Ollama binary + the
  qwen3.5:4b model store + license files + a manifest.json into a
  ``uofa-llm-bundle-<platform>-v<version>.tar.gz``.
* ``consume_bundle(path)`` β€” verifies platform + SHA-256s, then unpacks
  the bundle into ``~/.uofa/runtime/<platform>/`` and
  ``~/.uofa/cache/ollama_models/`` to produce the same end state as a
  connected ``uofa setup``.

Bundles are platform-specific. macOS/Linux/Windows binaries are not
interchangeable, so the bundle's filename embeds the platform tag and
``consume_bundle`` rejects bundles whose tag does not match the host.
"""

from __future__ import annotations

import datetime as _dt
import hashlib
import importlib.resources
import json
import os
import shutil
import tarfile
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

from uofa_cli import setup_install, setup_state


_MANIFEST_NAME = "manifest.json"
_MANIFEST_SCHEMA = "1"
_DEFAULT_MODEL = "qwen3.5:4b"


@dataclass(frozen=True)
class BundleManifest:
    """Parsed view of manifest.json inside the tarball."""

    schema_version: str
    uofa_version: str
    platform: str
    created_at: str
    ollama_version: str
    model_tag: str
    files: dict[str, dict]  # path -> {"sha256": ..., "size": ...}


# ── Filename helpers ──────────────────────────────────────────


def default_bundle_filename(platform: str | None = None, uofa_version: str | None = None) -> str:
    platform = platform or setup_install.detect_wheel_platform_tag()
    uofa_version = uofa_version or _uofa_version()
    return f"uofa-llm-bundle-{platform}-v{uofa_version}.tar.gz"


def _uofa_version() -> str:
    try:
        from importlib.metadata import version
        return version("uofa")
    except Exception:
        return "0.0.0"


# ── Bundle creation ───────────────────────────────────────────


def create_bundle(
    output_path: Path,
    *,
    platform: str | None = None,
    model_tag: str = _DEFAULT_MODEL,
    cfg: setup_state.SetupConfig | None = None,
) -> Path:
    """Build a self-contained install bundle at *output_path*.

    Reads the local Ollama install + model store described by *cfg*
    (defaults to the active ~/.uofa/config.toml). Writes a tar.gz that
    ``consume_bundle`` can unpack on a disconnected machine.
    """
    cfg = cfg or setup_state.assert_ready()
    platform = platform or setup_install.detect_wheel_platform_tag()
    binary = cfg.ollama_binary
    if not binary.exists():
        raise FileNotFoundError(f"Configured Ollama binary missing: {binary}")

    models_root = cfg.ollama_models_dir or _detect_byo_models_dir(binary)
    if models_root is None or not models_root.is_dir():
        raise FileNotFoundError(
            f"Could not locate Ollama model store; tried {models_root}. "
            "Ensure `ollama pull qwen3.5:4b` has been run."
        )

    model_files = _collect_model_files(models_root, model_tag)
    if not model_files:
        raise FileNotFoundError(
            f"Model {model_tag} not found in {models_root}; pull it first."
        )

    licenses = _bundled_license_files()

    files_in_bundle: list[tuple[Path, str]] = []
    files_in_bundle.append((binary, _bundle_arcname_for_binary(platform, binary)))
    for src, rel in model_files:
        files_in_bundle.append((src, f"models/{rel}"))
    for license_src in licenses:
        files_in_bundle.append((license_src, license_src.name))

    manifest = _build_manifest(files_in_bundle, platform, model_tag)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory() as tmp:
        manifest_path = Path(tmp) / _MANIFEST_NAME
        manifest_path.write_text(json.dumps(manifest.__dict__, indent=2, sort_keys=True))
        readme_path = Path(tmp) / "README.txt"
        readme_path.write_text(_render_bundle_readme(manifest))

        with tarfile.open(output_path, "w:gz") as tf:
            tf.add(manifest_path, arcname=_MANIFEST_NAME)
            tf.add(readme_path, arcname="README.txt")
            for src, arcname in files_in_bundle:
                tf.add(src, arcname=arcname)

    return output_path


# ── Bundle consumption ────────────────────────────────────────


def consume_bundle(
    bundle_path: Path,
    *,
    on_status=None,
) -> setup_state.SetupConfig:
    """Verify and unpack a bundle into the UofA-managed locations."""
    say = on_status or (lambda _: None)
    if not bundle_path.is_file():
        raise FileNotFoundError(bundle_path)

    host_platform = setup_install.detect_wheel_platform_tag()

    with tarfile.open(bundle_path, "r:gz") as tf:
        manifest = _read_manifest_from_tar(tf)
        if manifest.platform != host_platform:
            raise PlatformMismatchError(
                f"Bundle was built for {manifest.platform!r}, but this host "
                f"is {host_platform!r}. Build a matching bundle on a "
                f"{host_platform} machine."
            )

        say(f"Verifying bundle contents ({len(manifest.files)} files)...")
        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            tf.extractall(tmp_path)
            _verify_extracted_files(tmp_path, manifest)

            runtime_dir = setup_state.runtime_dir(host_platform)
            models_dir = setup_state.models_cache_dir()
            runtime_dir.mkdir(parents=True, exist_ok=True)
            models_dir.mkdir(parents=True, exist_ok=True)

            binary_arcname = _binary_arcname_in_manifest(manifest)
            binary_src = tmp_path / binary_arcname
            binary_dst = runtime_dir / Path(binary_arcname).name
            say(f"Installing binary -> {binary_dst}")
            shutil.copy2(binary_src, binary_dst)
            binary_dst.chmod(binary_dst.stat().st_mode | 0o755)

            say(f"Restoring model store -> {models_dir}")
            for arcname in manifest.files:
                if not arcname.startswith("models/"):
                    continue
                src = tmp_path / arcname
                dst = models_dir / arcname[len("models/"):]
                dst.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src, dst)

    cfg = setup_state.SetupConfig(
        mode="managed",
        ollama_binary=binary_dst,
        ollama_port=11434,
        ollama_models_dir=models_dir,
        model_tag=manifest.model_tag,
        installed_at=_dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
        uofa_version=_uofa_version(),
    )
    setup_state.save_config(cfg)
    say(f"Wrote {setup_state.config_path()}")
    return cfg


class PlatformMismatchError(RuntimeError):
    """Raised when the bundle's platform tag does not match the host."""


# ── Internals ─────────────────────────────────────────────────


def _detect_byo_models_dir(binary: Path) -> Path | None:
    """For BYO mode, return the user's pre-existing ~/.ollama/models dir."""
    env_dir = os.environ.get("OLLAMA_MODELS")
    if env_dir:
        return Path(env_dir)
    return Path.home() / ".ollama" / "models"


def _collect_model_files(models_root: Path, model_tag: str) -> list[tuple[Path, str]]:
    """Return (absolute_src, models-relative-arcname) for the given tag.

    Walks Ollama's standard layout: a manifest JSON under
    ``manifests/registry.ollama.ai/library/<name>/<tag>`` plus referenced
    blobs under ``blobs/sha256-<digest>``.
    """
    name, _, tag = model_tag.partition(":")
    if not tag:
        tag = "latest"

    manifest_path = (
        models_root / "manifests" / "registry.ollama.ai" / "library" / name / tag
    )
    if not manifest_path.is_file():
        return []

    files: list[tuple[Path, str]] = [
        (manifest_path, manifest_path.relative_to(models_root).as_posix())
    ]

    manifest_data = json.loads(manifest_path.read_text())
    digests: list[str] = []
    if isinstance(manifest_data.get("config"), dict):
        digests.append(manifest_data["config"]["digest"])
    for layer in manifest_data.get("layers", []):
        digests.append(layer["digest"])

    blobs_dir = models_root / "blobs"
    for digest in digests:
        # Ollama stores blobs as 'sha256-<hex>' (dash-separated, not colon).
        normalized = digest.replace(":", "-")
        blob_path = blobs_dir / normalized
        if not blob_path.is_file():
            raise FileNotFoundError(
                f"Model layer missing: {blob_path} (referenced by {manifest_path})"
            )
        files.append((blob_path, blob_path.relative_to(models_root).as_posix()))

    return files


def _bundle_arcname_for_binary(platform: str, binary: Path) -> str:
    if platform.startswith("win_"):
        return "ollama.exe"
    return "ollama"


def _binary_arcname_in_manifest(manifest: BundleManifest) -> str:
    for arcname in manifest.files:
        if arcname in ("ollama", "ollama.exe"):
            return arcname
    raise ValueError("Bundle manifest does not list an ollama binary")


def _bundled_license_files() -> list[Path]:
    """Return paths to LICENSE-ollama.txt and LICENSE-qwen.txt under LICENSES/."""
    pkg_files = importlib.resources.files("uofa_cli")
    here = Path(str(pkg_files)).resolve()
    # LICENSES/ lives at the wheel's repo root, not inside the package, so
    # walk up looking for it.
    for parent in [here, *here.parents]:
        candidate = parent / "LICENSES"
        if candidate.is_dir():
            return [
                candidate / "LICENSE-ollama.txt",
                candidate / "LICENSE-qwen.txt",
            ]
    return []  # tolerated; manifest will simply not list licenses


def _build_manifest(
    files: Iterable[tuple[Path, str]],
    platform: str,
    model_tag: str,
) -> BundleManifest:
    file_index: dict[str, dict] = {}
    for src, arcname in files:
        file_index[arcname] = {
            "sha256": _sha256_of(src),
            "size": src.stat().st_size,
        }
    return BundleManifest(
        schema_version=_MANIFEST_SCHEMA,
        uofa_version=_uofa_version(),
        platform=platform,
        created_at=_dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
        ollama_version=_local_ollama_version(),
        model_tag=model_tag,
        files=file_index,
    )


def _local_ollama_version() -> str:
    """Best-effort lookup of the Ollama version recorded in the manifest."""
    try:
        manifest = setup_install.load_ollama_manifest()
        return manifest.get("meta", {}).get("ollama_version", "unknown")
    except Exception:
        return "unknown"


def _sha256_of(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def _read_manifest_from_tar(tf: tarfile.TarFile) -> BundleManifest:
    try:
        member = tf.getmember(_MANIFEST_NAME)
    except KeyError:
        raise ValueError(f"Bundle missing {_MANIFEST_NAME}")
    f = tf.extractfile(member)
    if f is None:
        raise ValueError(f"Bundle's {_MANIFEST_NAME} is unreadable")
    raw = json.loads(f.read())
    return BundleManifest(**raw)


def _verify_extracted_files(extract_root: Path, manifest: BundleManifest) -> None:
    for arcname, entry in manifest.files.items():
        path = extract_root / arcname
        if not path.is_file():
            raise FileNotFoundError(f"Bundle is missing declared file: {arcname}")
        actual = _sha256_of(path)
        if actual != entry["sha256"]:
            raise ValueError(
                f"SHA-256 mismatch in bundle for {arcname}\n"
                f"  expected: {entry['sha256']}\n"
                f"  actual:   {actual}"
            )


def _render_bundle_readme(manifest: BundleManifest) -> str:
    return (
        "UofA LLM Install Bundle\n"
        "=======================\n\n"
        f"  Created:      {manifest.created_at}\n"
        f"  Platform:     {manifest.platform}\n"
        f"  Model:        {manifest.model_tag}\n"
        f"  Ollama:       {manifest.ollama_version}\n"
        f"  UofA:         {manifest.uofa_version}\n\n"
        "Install on the target machine with:\n\n"
        "    uofa setup --bundle <this-file>.tar.gz\n\n"
        "Licenses for bundled software are included in this archive\n"
        "(LICENSE-ollama.txt, LICENSE-qwen.txt).\n"
    )