Compare commits

..
Author SHA1 Message Date
Richie ea3ec293d7 feat(comms): add Slack to communication packages
treefmt / nix fmt (pull_request) Successful in 7s
pytest / pytest (pull_request) Successful in 33s
build_systems / build-bob (pull_request) Successful in 1m3s
build_systems / build-brain (pull_request) Successful in 1m4s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m34s
build_systems / build-leviathan (pull_request) Successful in 1m37s
build_systems / build-jeeves (pull_request) Successful in 2m48s
2026-07-01 12:02:15 -04:00
Richie 03f610feed feat(ui): refactor HTML templates for consistent structure and improved styling
treefmt / nix fmt (pull_request) Successful in 5s
pytest / pytest (pull_request) Successful in 37s
build_systems / build-leviathan (pull_request) Successful in 1m20s
build_systems / build-jeeves (pull_request) Successful in 2m57s
build_systems / build-brain (pull_request) Successful in 3m3s
build_systems / build-rhapsody-in-green (pull_request) Successful in 3m25s
build_systems / build-bob (pull_request) Successful in 32m7s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 26s
build_systems / build-brain (push) Successful in 31s
build_systems / build-bob (push) Successful in 34s
build_systems / build-leviathan (push) Successful in 41s
build_systems / build-rhapsody-in-green (push) Successful in 46s
build_systems / build-jeeves (push) Successful in 2m10s
2026-06-29 10:23:13 -04:00
27 changed files with 1409 additions and 1362 deletions
-50
View File
@@ -1,51 +1 @@
# dotfiles
## Installer ISO
Build a bootable NixOS ISO with the installer preinstalled:
```sh
nix build .#iso
```
Write `result/iso/nixos-zfs-installer.iso` to a USB stick (for example with `dd`) or boot it in a VM. The image is the minimal NixOS installation CD with ZFS enabled and `nixos-installer` on `PATH`. SSH is enabled and the `nixos` and `root` accounts use the password `nixos`, so you can also run the installer remotely. Once booted:
```sh
sudo nixos-installer
```
The ISO bundles the `.#installer-nixos` package, a variant of the binary that keeps its Nix store linkage instead of being patched for foreign distributions.
## Installer binary
Build the self-contained installer executable with:
```sh
nix build .#installer
```
The flake package (defined in `python/installer/package.nix`) uses the Python builder in `python/installer/build.py`, which stages only the installer modules before running PyInstaller. You can also call it directly when `pyinstaller` and `patchelf` are on `PATH`:
```sh
python -m python.installer.build --output ./nixos-installer
```
Copy `result/bin/nixos-installer` to the installer USB stick and run it as root from the NixOS live environment:
```sh
sudo ./nixos-installer
```
Validate the live environment first with:
```sh
./nixos-installer --check
```
Paste a value into the TUI encryption password field to enable LUKS during install, or set `ENCRYPT_KEY`:
```sh
sudo env ENCRYPT_KEY='change-me' ./nixos-installer
```
The binary bundles the Python runtime and only the installer modules it imports. It still expects the NixOS installer environment to provide system install tools such as `parted`, `zfs`, `zpool`, `cryptsetup`, `nixos-generate-config`, and `nixos-install`.
-41
View File
@@ -65,35 +65,6 @@
devShells = forEachSystem (pkgs: import ./shell.nix { inherit pkgs; });
formatter = forEachSystem (pkgs: pkgs.treefmt);
packages = forEachSystem (
pkgs:
let
installer = pkgs.callPackage ./python/installer/package.nix { };
installer-nixos = pkgs.callPackage ./python/installer/package.nix { patchElf = false; };
in
{
inherit installer installer-nixos;
default = installer;
}
// lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "x86_64-linux") {
iso = self.nixosConfigurations.iso.config.system.build.isoImage;
}
);
apps = forEachSystem (
pkgs:
let
system = pkgs.stdenv.hostPlatform.system;
installer = {
type = "app";
program = "${self.packages.${system}.installer}/bin/nixos-installer";
meta.description = "One-file NixOS ZFS installer.";
};
in
{
inherit installer;
default = installer;
}
);
nixosConfigurations = {
bob = lib.nixosSystem {
@@ -126,18 +97,6 @@
];
specialArgs = { inherit inputs outputs; };
};
tortoise = lib.nixosSystem {
modules = [
./systems/tortoise
];
specialArgs = { inherit inputs outputs; };
};
iso = lib.nixosSystem {
modules = [
./systems/iso
];
specialArgs = { inherit inputs outputs; };
};
};
};
}
@@ -1,206 +0,0 @@
"""adding Phrase metadata tables.
Revision ID: dddee09eddcc
Revises: 96d72c748c24
Create Date: 2026-06-29 00:49:07.344159
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from python.orm import RichieBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "dddee09eddcc"
down_revision: str | None = "96d72c748c24"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = RichieBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"candidate_phrases",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("phrase_text", sa.Text(), nullable=False),
sa.Column("phrase_norm", sa.Text(), nullable=False),
sa.Column("token_count", sa.Integer(), nullable=False),
sa.Column("source_raw_ngram", sa.Boolean(), nullable=False),
sa.Column("source_yake", sa.Boolean(), nullable=False),
sa.Column("source_spacy_ner", sa.Boolean(), nullable=False),
sa.Column("source_spacy_noun_chunk", sa.Boolean(), nullable=False),
sa.Column("source_capitalized", sa.Boolean(), nullable=False),
sa.Column("source_metadata", sa.Boolean(), nullable=False),
sa.Column("spacy_label", sa.String(), nullable=True),
sa.Column("raw_count", sa.Integer(), nullable=False),
sa.Column("chapter_count", sa.Integer(), nullable=False),
sa.Column("yake_score", sa.Float(), nullable=True),
sa.Column("candidate_score", sa.Float(), nullable=False),
sa.Column(
"sample_contexts",
sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"),
nullable=True,
),
sa.Column("llm_judged", sa.Boolean(), nullable=False),
sa.Column("llm_keep", sa.Boolean(), nullable=True),
sa.Column("llm_confidence", sa.Float(), nullable=True),
sa.Column("llm_category", sa.String(), nullable=True),
sa.Column("llm_reason", sa.Text(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_candidate_phrases_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_candidate_phrases")),
sa.UniqueConstraint("book_id", "phrase_norm", name="uq_candidate_phrases_book_id_phrase_norm"),
schema=schema,
)
op.create_index(
"candidate_phrases_book_norm_idx", "candidate_phrases", ["book_id", "phrase_norm"], unique=False, schema=schema
)
op.create_index(
"candidate_phrases_book_score_idx",
"candidate_phrases",
["book_id", "candidate_score"],
unique=False,
schema=schema,
)
op.create_table(
"protected_phrases",
sa.Column("book_id", sa.Integer(), nullable=True),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("phrase_text", sa.Text(), nullable=False),
sa.Column("phrase_norm", sa.Text(), nullable=False),
sa.Column("canonical_id", sa.String(), nullable=False),
sa.Column("phrase_type", sa.String(), nullable=True),
sa.Column("token_count", sa.Integer(), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("importance", sa.Float(), nullable=False),
sa.Column("allow_nested", sa.Boolean(), nullable=False),
sa.Column("suppress_children", sa.Boolean(), nullable=False),
sa.Column("source_candidate_id", sa.Integer(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_protected_phrases_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["source_candidate_id"],
[f"{schema}.candidate_phrases.id"],
name=op.f("fk_protected_phrases_source_candidate_id_candidate_phrases"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_protected_phrases")),
sa.UniqueConstraint("book_id", "phrase_norm", name="uq_protected_phrases_book_id_phrase_norm"),
schema=schema,
)
op.create_index(
"protected_phrases_book_norm_idx", "protected_phrases", ["book_id", "phrase_norm"], unique=False, schema=schema
)
op.create_index("protected_phrases_norm_idx", "protected_phrases", ["phrase_norm"], unique=False, schema=schema)
op.create_index(
"protected_phrases_series_norm_idx",
"protected_phrases",
["series_id", "phrase_norm"],
unique=False,
schema=schema,
)
op.create_table(
"chunk_phrase_mentions",
sa.Column("chunk_id", sa.BigInteger(), nullable=False),
sa.Column("phrase_id", sa.Integer(), nullable=False),
sa.Column("book_id", sa.Integer(), nullable=True),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("start_char", sa.Integer(), nullable=False),
sa.Column("end_char", sa.Integer(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_chunk_phrase_mentions_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["chunk_id"],
[f"{schema}.ebook_chunk.id"],
name=op.f("fk_chunk_phrase_mentions_chunk_id_ebook_chunk"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["phrase_id"],
[f"{schema}.protected_phrases.id"],
name=op.f("fk_chunk_phrase_mentions_phrase_id_protected_phrases"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chunk_phrase_mentions")),
sa.UniqueConstraint("chunk_id", "phrase_id", "start_char", name="uq_chunk_phrase_mentions_chunk_phrase_start"),
schema=schema,
)
op.create_index(
"chunk_phrase_mentions_chunk_idx", "chunk_phrase_mentions", ["chunk_id"], unique=False, schema=schema
)
op.create_index(
"chunk_phrase_mentions_phrase_idx", "chunk_phrase_mentions", ["phrase_id"], unique=False, schema=schema
)
op.create_table(
"phrase_aliases",
sa.Column("phrase_id", sa.Integer(), nullable=False),
sa.Column("alias_text", sa.Text(), nullable=False),
sa.Column("alias_norm", sa.Text(), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["phrase_id"],
[f"{schema}.protected_phrases.id"],
name=op.f("fk_phrase_aliases_phrase_id_protected_phrases"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_phrase_aliases")),
sa.UniqueConstraint("phrase_id", "alias_norm", name="uq_phrase_aliases_phrase_id_alias_norm"),
schema=schema,
)
op.create_index("phrase_aliases_norm_idx", "phrase_aliases", ["alias_norm"], unique=False, schema=schema)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("phrase_aliases_norm_idx", table_name="phrase_aliases", schema=schema)
op.drop_table("phrase_aliases", schema=schema)
op.drop_index("chunk_phrase_mentions_phrase_idx", table_name="chunk_phrase_mentions", schema=schema)
op.drop_index("chunk_phrase_mentions_chunk_idx", table_name="chunk_phrase_mentions", schema=schema)
op.drop_table("chunk_phrase_mentions", schema=schema)
op.drop_index("protected_phrases_series_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_index("protected_phrases_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_index("protected_phrases_book_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_table("protected_phrases", schema=schema)
op.drop_index("candidate_phrases_book_score_idx", table_name="candidate_phrases", schema=schema)
op.drop_index("candidate_phrases_book_norm_idx", table_name="candidate_phrases", schema=schema)
op.drop_table("candidate_phrases", schema=schema)
# ### end Alembic commands ###
+12 -4
View File
@@ -3,20 +3,28 @@
from __future__ import annotations
import logging
import sys
from datetime import UTC, datetime
from os import getenv
from subprocess import PIPE, Popen
from apprise import Apprise
from python.logging_config import configure_logger as _configure_logger
logger = logging.getLogger(__name__)
def configure_logger(level: str = "INFO") -> None:
"""Configure the logger."""
_configure_logger(level)
"""Configure the logger.
Args:
level (str, optional): The logging level. Defaults to "INFO".
"""
logging.basicConfig(
level=level,
datefmt="%Y-%m-%dT%H:%M:%S%z",
format="%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
def bash_wrapper(command: str) -> tuple[str, int]:
+314 -49
View File
@@ -1,27 +1,210 @@
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #e3e5ea;
--text: #1c1f24;
--muted: #6b7280;
--accent: #4f46e5;
--accent-soft: #eef0fe;
--danger: #b42318;
--warn-bg: #fff8eb;
--warn-border: #e0a92e;
--warn-text: #7a5008;
--radius: 12px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.08);
}
html.theme-dark {
--bg: #0f1117;
--surface: #1a1d25;
--border: #2b303b;
--text: #e6e8ec;
--muted: #9aa1ad;
--accent: #818cf8;
--accent-soft: #262b45;
--danger: #f97066;
--warn-bg: #2a2410;
--warn-border: #b9881f;
--warn-text: #e8c97a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f7f7f4;
color: #202124;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.55;
}
main {
max-width: 960px;
max-width: 820px;
margin: 0 auto;
padding: 24px;
padding: 32px 20px 64px;
}
nav {
/* Header / nav */
.site-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.site-nav {
max-width: 820px;
margin: 0 auto;
padding: 12px 20px;
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
gap: 20px;
}
nav form {
.brand {
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 6px;
margin-right: auto;
}
.nav-links a {
padding: 6px 12px;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.94rem;
transition: background 0.15s, color 0.15s;
}
.nav-links a:hover {
background: var(--accent-soft);
color: var(--accent);
}
.dev-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
font-size: 1rem;
line-height: 1;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.theme-toggle:hover {
border-color: var(--accent);
filter: none;
}
h1 {
font-size: 1.6rem;
margin: 0 0 20px;
}
h2 {
font-size: 1.15rem;
margin: 0 0 8px;
}
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
/* Search form */
form {
margin: 0;
}
label {
font-weight: 600;
font-size: 0.92rem;
}
textarea {
display: block;
width: 100%;
margin: 8px 0 16px;
padding: 12px 14px;
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
resize: vertical;
transition: border-color 0.15s, box-shadow 0.15s;
}
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
font: inherit;
font-weight: 600;
color: #fff;
background: var(--accent);
border: none;
border-radius: 10px;
cursor: pointer;
transition: filter 0.15s;
}
button:hover {
filter: brightness(1.08);
}
.check {
display: inline-flex;
gap: 8px;
align-items: center;
font-weight: 500;
color: var(--muted);
}
.actions {
display: flex;
flex-wrap: wrap;
@@ -29,121 +212,203 @@ nav form {
margin-bottom: 24px;
}
textarea {
/* Answer + results */
#results {
display: block;
width: 100%;
margin: 8px 0 12px;
}
button {
padding: 8px 14px;
}
.check {
display: inline-flex;
gap: 8px;
align-items: center;
margin-right: 12px;
margin-top: 28px;
}
.rank-label {
margin-top: 24px;
font-weight: 700;
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
margin-bottom: 16px;
}
.answer {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
margin-bottom: 24px;
}
.answer p:last-child {
margin-bottom: 0;
}
.results {
padding-left: 24px;
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 16px;
}
.meta,
.scores,
.status {
color: #626a73;
.results > li {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 20px;
}
.results h2 {
font-size: 1.05rem;
}
.results h2 a {
color: var(--text);
text-decoration: none;
}
.results h2 a:hover {
color: var(--accent);
}
.meta {
color: var(--muted);
font-size: 0.88rem;
margin: 0 0 10px;
}
.scores {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0;
margin: 14px 0 0;
}
.scores div {
display: inline-flex;
gap: 4px;
gap: 6px;
align-items: baseline;
padding: 3px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.78rem;
}
.scores dt {
font-weight: 700;
font-weight: 600;
color: var(--muted);
}
.scores dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
/* Runtime — developer diagnostics, hidden unless dev mode is on */
.runtime {
margin-top: 16px;
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 20px;
margin-bottom: 24px;
}
html.dev .runtime {
display: block;
}
.timing-chart {
display: grid;
gap: 8px;
padding: 0;
margin: 12px 0 0;
list-style: none;
}
.timing-chart li {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(160px, 2fr) auto auto;
gap: 8px;
gap: 10px;
align-items: center;
font-size: 0.85rem;
}
.timing-bar {
height: 10px;
height: 8px;
overflow: hidden;
background: #e5e5df;
background: var(--bg);
border-radius: 999px;
}
.timing-bar span {
display: block;
height: 100%;
background: #3767c8;
background: var(--accent);
border-radius: 999px;
}
.timing-value,
.timing-remaining {
color: #626a73;
color: var(--muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
th,
td {
padding: 8px;
border-bottom: 1px solid #d8d8d2;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
text-align: left;
font-size: 0.9rem;
}
th {
font-weight: 700;
font-weight: 600;
color: var(--muted);
background: var(--bg);
}
tbody tr:last-child td {
border-bottom: none;
}
dl dt {
font-weight: 600;
color: var(--muted);
font-size: 0.85rem;
}
dl dd {
margin: 0 0 12px;
}
/* States */
.error {
color: #9f1d20;
font-weight: 700;
color: var(--danger);
font-weight: 600;
}
.notice {
margin: 8px 0;
padding: 8px 12px;
border-left: 4px solid #c8881d;
background: #fcf3e2;
color: #6b4a06;
font-weight: 600;
margin: 12px 0;
padding: 10px 14px;
border-left: 3px solid var(--warn-border);
border-radius: 6px;
background: var(--warn-bg);
color: var(--warn-text);
font-weight: 500;
}
.status {
color: var(--muted);
}
+43 -55
View File
@@ -1,57 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EPUB Admin</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
<h1>Admin</h1>
<section id="admin-status"></section>
<section class="actions">
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Scan</button>
</form>
<form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed</button>
</form>
<form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed all</button>
</form>
</section>
<section>
<h2>Embeddings</h2>
<table>
<thead>
{% extends "base.html" %}
{% block title %}EPUB Admin{% endblock %}
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %}
{% block content %}
<h1>Admin</h1>
<section id="admin-status"></section>
<section class="actions">
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Scan</button>
</form>
<form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed</button>
</form>
<form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed all</button>
</form>
</section>
<section>
<h2>Embeddings</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>Dimensions</th>
<th>Embedded</th>
<th>Missing</th>
<th>Total chunks</th>
</tr>
</thead>
<tbody>
{% for item in stats %}
<tr>
<th>Model</th>
<th>Dimensions</th>
<th>Embedded</th>
<th>Missing</th>
<th>Total chunks</th>
<td>{{ item.model_name }}</td>
<td>{{ item.dimension }}</td>
<td>{{ item.embedded_chunks }}</td>
<td>{{ item.missing_chunks }}</td>
<td>{{ item.total_chunks }}</td>
</tr>
</thead>
<tbody>
{% for item in stats %}
<tr>
<td>{{ item.model_name }}</td>
<td>{{ item.dimension }}</td>
<td>{{ item.embedded_chunks }}</td>
<td>{{ item.missing_chunks }}</td>
<td>{{ item.total_chunks }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EPUB Search{% endblock %}</title>
{% block head %}{% endblock %}
<link rel="stylesheet" href="/static/style.css?v={{ static_version('style.css') }}">
<script>
// Apply theme and dev mode before paint to avoid a flash of unstyled/wrong content.
(function () {
var stored = localStorage.getItem("ebook-theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var theme = stored || (prefersDark ? "dark" : "light");
document.documentElement.classList.add("theme-" + theme);
if (localStorage.getItem("ebook-dev-mode") === "on") {
document.documentElement.classList.add("dev");
}
})();
</script>
</head>
<body>
<header class="site-header">
<nav class="site-nav">
<a class="brand" href="/">EPUB Search</a>
<div class="nav-links">
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</div>
<button type="button" id="theme-toggle" class="theme-toggle" title="Toggle light / dark theme" aria-label="Toggle theme"></button>
<label class="dev-toggle" title="Show developer diagnostics">
<input type="checkbox" id="dev-mode-toggle">
<span>Dev</span>
</label>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<script>
(function () {
var toggle = document.getElementById("dev-mode-toggle");
if (toggle) {
toggle.checked = document.documentElement.classList.contains("dev");
toggle.addEventListener("change", function () {
document.documentElement.classList.toggle("dev", toggle.checked);
localStorage.setItem("ebook-dev-mode", toggle.checked ? "on" : "off");
});
}
var themeButton = document.getElementById("theme-toggle");
if (themeButton) {
var root = document.documentElement;
var sync = function () {
var isDark = root.classList.contains("theme-dark");
themeButton.textContent = isDark ? "☀️" : "🌙";
};
sync();
themeButton.addEventListener("click", function () {
var next = root.classList.contains("theme-dark") ? "light" : "dark";
root.classList.remove("theme-dark", "theme-light");
root.classList.add("theme-" + next);
localStorage.setItem("ebook-theme", next);
sync();
});
}
})();
</script>
</body>
</html>
@@ -1,32 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if source %}{{ source.title }}{% else %}Book not found{% endif %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
{% if source %}
<h1>{{ source.title }}</h1>
<p class="meta">{{ source.author or "Unknown author" }}</p>
<dl>
<dt>File</dt>
<dd>{{ source.file_path }}</dd>
<dt>Chapters</dt>
<dd>{{ chapter_count }}</dd>
<dt>Chunks</dt>
<dd>{{ chunk_count }}</dd>
</dl>
{% else %}
<h1>Book not found</h1>
{% endif %}
</main>
</body>
</html>
{% extends "base.html" %}
{% block title %}{% if source %}{{ source.title }}{% else %}Book not found{% endif %}{% endblock %}
{% block content %}
{% if source %}
<h1>{{ source.title }}</h1>
<p class="meta">{{ source.author or "Unknown author" }}</p>
<dl class="card">
<dt>File</dt>
<dd>{{ source.file_path }}</dd>
<dt>Chapters</dt>
<dd>{{ chapter_count }}</dd>
<dt>Chunks</dt>
<dd>{{ chunk_count }}</dd>
</dl>
{% else %}
<h1>Book not found</h1>
{% endif %}
{% endblock %}
+19 -31
View File
@@ -1,31 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EPUB Books</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
<h1>Books</h1>
{% if sources %}
<ol class="results">
{% for source in sources %}
<li>
<h2><a href="/books/{{ source.id }}">{{ source.title }}</a></h2>
<p class="meta">{{ source.author or "Unknown author" }}</p>
</li>
{% endfor %}
</ol>
{% else %}
<p>No EPUBs indexed.</p>
{% endif %}
</main>
</body>
</html>
{% extends "base.html" %}
{% block title %}EPUB Books{% endblock %}
{% block content %}
<h1>Books</h1>
{% if sources %}
<ol class="results">
{% for source in sources %}
<li>
<h2><a href="/books/{{ source.id }}">{{ source.title }}</a></h2>
<p class="meta">{{ source.author or "Unknown author" }}</p>
</li>
{% endfor %}
</ol>
{% else %}
<p>No EPUBs indexed.</p>
{% endif %}
{% endblock %}
@@ -39,7 +39,13 @@
<ol class="results">
{% for result in response.results %}
<li>
<h2>{{ result.source_title }}</h2>
<h2>
{% if result.source_id %}
<a href="/books/{{ result.source_id }}">{{ result.source_title }}</a>
{% else %}
{{ result.source_title }}
{% endif %}
</h2>
<p class="meta">
{% if result.source_author %}{{ result.source_author }}{% endif %}
{% if result.chapter_title %} · {{ result.chapter_title }}{% endif %}
+15 -25
View File
@@ -1,30 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EPUB Search</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
<h1>EPUB Search</h1>
<form hx-post="/search" hx-target="#results" hx-swap="innerHTML">
<label for="query">Search</label>
<textarea id="query" name="query" rows="4" required></textarea>
{% extends "base.html" %}
{% block title %}EPUB Search{% endblock %}
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %}
{% block content %}
<h1>Search</h1>
<form class="card" hx-post="/search" hx-target="#results" hx-swap="innerHTML">
<label for="query">What are you looking for?</label>
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required></textarea>
<div class="form-row">
<label class="check">
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
Rerank
</label>
<button type="submit">Search</button>
</form>
<section id="results"></section>
</main>
</body>
</html>
</div>
</form>
<section id="results"></section>
{% endblock %}
+10
View File
@@ -10,4 +10,14 @@ PACKAGE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = PACKAGE_DIR / "templates"
STATIC_DIR = PACKAGE_DIR / "static"
def static_version(filename: str) -> int:
"""Return a cache-busting token for a static file based on its modification time."""
try:
return int((STATIC_DIR / filename).stat().st_mtime)
except OSError:
return 0
templates = Jinja2Templates(directory=TEMPLATE_DIR)
templates.env.globals["static_version"] = static_version
+49 -54
View File
@@ -91,65 +91,60 @@ def ingest_configured_paths(session: Session, config: EbookSearchConfig) -> int:
def ingest_file(session: Session, path: Path, config: EbookSearchConfig) -> bool:
"""Ingest one EPUB file. Return True when the database changed."""
try:
resolved_path = path.expanduser().resolve()
logger.info("ebook_ingest_file_start path=%s", resolved_path)
file_hash = sha256_file(resolved_path)
existing = find_existing_source(session, resolved_path, file_hash)
if existing is not None and existing.file_sha256 == file_hash:
stat = resolved_path.stat()
existing.file_path = str(resolved_path)
existing.file_mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
existing.file_size = stat.st_size
session.flush()
logger.info("ebook_ingest_file_unchanged source_id=%s path=%s", existing.id, resolved_path)
return False
if existing is not None:
logger.info("ebook_ingest_file_replacing source_id=%s path=%s", existing.id, resolved_path)
session.delete(existing)
session.flush()
resolved_path = path.expanduser().resolve()
logger.info("ebook_ingest_file_start path=%s", resolved_path)
file_hash = sha256_file(resolved_path)
existing = find_existing_source(session, resolved_path, file_hash)
if existing is not None and existing.file_sha256 == file_hash:
stat = resolved_path.stat()
parsed = parse_epub(resolved_path)
source = EbookSource(
title=parsed.title,
author=parsed.author,
language=parsed.language,
publisher=parsed.publisher,
identifier=parsed.identifier,
file_path=str(resolved_path),
file_sha256=file_hash,
file_mtime=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
file_size=stat.st_size,
)
session.add(source)
existing.file_path = str(resolved_path)
existing.file_mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
existing.file_size = stat.st_size
session.flush()
logger.info("ebook_ingest_file_unchanged source_id=%s path=%s", existing.id, resolved_path)
return False
if existing is not None:
logger.info("ebook_ingest_file_replacing source_id=%s path=%s", existing.id, resolved_path)
session.delete(existing)
session.flush()
chunk_index = 0
for spine_index, parsed_chapter in enumerate(parsed.chapters):
chapter = EbookChapter(
source_id=source.id,
spine_index=spine_index,
title=parsed_chapter.title,
href=parsed_chapter.href,
)
session.add(chapter)
session.flush()
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
stat = resolved_path.stat()
parsed = parse_epub(resolved_path)
source = EbookSource(
title=parsed.title,
author=parsed.author,
language=parsed.language,
publisher=parsed.publisher,
identifier=parsed.identifier,
file_path=str(resolved_path),
file_sha256=file_hash,
file_mtime=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
file_size=stat.st_size,
)
session.add(source)
session.flush()
session.commit()
logger.info(
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s",
source.id,
resolved_path,
len(parsed.chapters),
chunk_index,
chunk_index = 0
for spine_index, parsed_chapter in enumerate(parsed.chapters):
chapter = EbookChapter(
source_id=source.id,
spine_index=spine_index,
title=parsed_chapter.title,
href=parsed_chapter.href,
)
except Exception:
logger.exception(f"ebook_ingest_file_error path={path}")
return False
else:
return True
session.add(chapter)
session.flush()
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
session.flush()
logger.info(
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s",
source.id,
resolved_path,
len(parsed.chapters),
chunk_index,
)
return True
def find_existing_source(session: Session, path: Path, file_hash: str) -> EbookSource | None:
+73 -155
View File
@@ -5,60 +5,41 @@ from __future__ import annotations
import curses
import logging
import sys
from argparse import ArgumentParser
from os import environ, getenv
from os import getenv
from pathlib import Path
from random import getrandbits
from subprocess import run
from subprocess import PIPE, Popen, run
from time import sleep
from typing import TYPE_CHECKING
from python.common import configure_logger
from python.installer.tui import draw_menu
from python.logging_config import configure_logger
from python.process import require_commands, run_output
if TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__)
REQUIRED_COMMANDS = (
"blkdiscard",
"cryptsetup",
"find",
"lsblk",
"mkfs.vfat",
"mount",
"nixos-generate-config",
"nixos-install",
"parted",
"readlink",
"zfs",
"zpool",
)
def bash_wrapper(command: str) -> str:
"""Execute a bash command and capture the output.
def configure_terminal() -> None:
"""Force a terminal type every live system has a terminfo entry for.
Args:
command (str): The bash command to be executed.
Terminals such as kitty advertise TERM values (xterm-kitty) that live
systems have no terminfo entry for, so setupterm fails before the TUI
can start.
Returns:
Tuple[str, int]: A tuple containing the output of the command (stdout) as a string,
the error output (stderr) as a string (optional), and the return code as an integer.
"""
environ["TERM"] = "xterm-256color"
logger.debug(f"running {command=}")
# This is a acceptable risk
process = Popen(command.split(), stdout=PIPE, stderr=PIPE)
output, _ = process.communicate()
if process.returncode != 0:
error = f"Failed to run command {command=} return code {process.returncode=}"
raise RuntimeError(error)
if getenv("TERMINFO") or getenv("TERMINFO_DIRS"):
return
terminfo_fallback_directories = (
Path("/run/current-system/sw/share/terminfo"),
Path("/usr/share/terminfo"),
Path("/etc/terminfo"),
Path("/lib/terminfo"),
)
existing_directories = [str(directory) for directory in terminfo_fallback_directories if directory.is_dir()]
if existing_directories:
environ["TERMINFO_DIRS"] = ":".join(existing_directories)
return output.decode()
def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
@@ -75,7 +56,7 @@ def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
swap_size = max(swap_size, 1)
reserve = max(reserve, 0)
run_output(("blkdiscard", "-f", disk))
bash_wrapper(f"blkdiscard -f {disk}")
if reserve > 0:
msg = f"Creating swap partition on {disk=} with size {swap_size=}GiB and reserve {reserve=}GiB"
@@ -91,28 +72,14 @@ def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
logger.debug(f"{swap_partition=}")
create_partitions = (
"parted",
"--script",
"--align=optimal",
disk,
"--",
"mklabel",
"gpt",
"mkpart",
"EFI",
"1MiB",
"4GiB",
"mkpart",
"root_pool",
"4GiB",
f"-{swap_start}GiB",
*swap_partition.split(),
"set",
"1",
"esp",
"on",
f"parted --script --align=optimal {disk} -- "
"mklabel gpt "
"mkpart EFI 1MiB 4GiB "
f"mkpart root_pool 4GiB -{swap_start}GiB "
f"{swap_partition}"
"set 1 esp on"
)
run_output(create_partitions)
bash_wrapper(create_partitions)
logger.info(f"{disk=} successfully partitioned")
@@ -128,43 +95,30 @@ def create_zfs_pool(pool_disks: Sequence[str], mnt_dir: str) -> None:
error = "disks must be a tuple of at least length 1"
raise ValueError(error)
zpool_create = [
"zpool",
"create",
"-o",
"ashift=12",
"-o",
"autotrim=on",
"-R",
mnt_dir,
"-O",
"acltype=posixacl",
"-O",
"canmount=off",
"-O",
"dnodesize=auto",
"-O",
"normalization=formD",
"-O",
"relatime=on",
"-O",
"xattr=sa",
"-O",
"mountpoint=legacy",
"-O",
"compression=zstd",
"-O",
"atime=off",
"root_pool",
]
zpool_create = (
"zpool create "
"-o ashift=12 "
"-o autotrim=on "
f"-R {mnt_dir} "
"-O acltype=posixacl "
"-O canmount=off "
"-O dnodesize=auto "
"-O normalization=formD "
"-O relatime=on "
"-O xattr=sa "
"-O mountpoint=legacy "
"-O compression=zstd "
"-O atime=off "
"root_pool "
)
if len(pool_disks) == 1:
zpool_create.append(pool_disks[0])
zpool_create += pool_disks[0]
else:
zpool_create.append("mirror")
zpool_create.extend(pool_disks)
zpool_create += "mirror "
zpool_create += " ".join(pool_disks)
run_output(zpool_create)
zpools = run_output(("zpool", "list", "-o", "name"))
bash_wrapper(zpool_create)
zpools = bash_wrapper("zpool list -o name")
if "root_pool" not in zpools.splitlines():
logger.critical("Failed to create root_pool")
sys.exit(1)
@@ -172,11 +126,11 @@ def create_zfs_pool(pool_disks: Sequence[str], mnt_dir: str) -> None:
def create_zfs_datasets() -> None:
"""Create ZFS datasets."""
run_output(("zfs", "create", "-o", "canmount=noauto", "-o", "reservation=10G", "root_pool/root"))
run_output(("zfs", "create", "root_pool/home"))
run_output(("zfs", "create", "-o", "reservation=1G", "root_pool/var"))
run_output(("zfs", "create", "-o", "compression=zstd-9", "-o", "reservation=10G", "root_pool/nix"))
datasets = run_output(("zfs", "list", "-o", "name"))
bash_wrapper("zfs create -o canmount=noauto -o reservation=10G root_pool/root")
bash_wrapper("zfs create root_pool/home")
bash_wrapper("zfs create root_pool/var -o reservation=1G")
bash_wrapper("zfs create -o compression=zstd-9 -o reservation=10G root_pool/nix")
datasets = bash_wrapper("zfs list -o name")
expected_datasets = {
"root_pool/root",
@@ -192,7 +146,7 @@ def create_zfs_datasets() -> None:
def get_cpu_manufacturer() -> str:
"""Get the CPU manufacturer."""
output = Path("/proc/cpuinfo").read_text()
output = bash_wrapper("cat /proc/cpuinfo")
id_vendor = {"AuthenticAMD": "amd", "GenuineIntel": "intel"}
@@ -206,7 +160,7 @@ def get_cpu_manufacturer() -> str:
def get_boot_drive_id(disk: str) -> str:
"""Get the boot drive ID."""
output = run_output(("lsblk", "-o", "UUID", f"{disk}-part1"))
output = bash_wrapper(f"lsblk -o UUID {disk}-part1")
return output.splitlines()[1]
@@ -266,28 +220,21 @@ def create_nix_hardware_file(mnt_dir: str, disks: Sequence[str], encrypt: str |
def install_nixos(mnt_dir: str, disks: Sequence[str], encrypt: str | None) -> None:
"""Install NixOS."""
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/root", mnt_dir))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/home", f"{mnt_dir}/home"))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/var", f"{mnt_dir}/var"))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/nix", f"{mnt_dir}/nix"))
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/root {mnt_dir}")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/home {mnt_dir}/home")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/var {mnt_dir}/var")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/nix {mnt_dir}/nix")
for disk in disks:
run_output(("mkfs.vfat", "-n", "EFI", f"{disk}-part1"))
bash_wrapper(f"mkfs.vfat -n EFI {disk}-part1")
# set up mirroring afterwards if more than one disk
run_output(
(
"mount",
"-t",
"vfat",
"-o",
"fmask=0077,dmask=0077,iocharset=iso8859-1,X-mount.mkdir",
f"{disks[0]}-part1",
f"{mnt_dir}/boot",
),
boot_partition = (
f"mount -t vfat -o fmask=0077,dmask=0077,iocharset=iso8859-1,X-mount.mkdir {disks[0]}-part1 {mnt_dir}/boot"
)
bash_wrapper(boot_partition)
run_output(("nixos-generate-config", "--root", mnt_dir))
bash_wrapper(f"nixos-generate-config --root {mnt_dir}")
create_nix_hardware_file(mnt_dir, disks, encrypt)
@@ -302,28 +249,21 @@ def installer(
) -> None:
"""Main."""
logger.info("Starting installation")
require_commands(REQUIRED_COMMANDS)
disks = tuple(sorted(disks))
for disk in disks:
partition_disk(disk, swap_size, reserve)
test = Popen(("printf", f"'{encrypt_key}'"), stdout=PIPE)
if encrypt_key:
sleep(1)
key_input = encrypt_key.encode()
run(
("cryptsetup", "luksFormat", "--type", "luks2", f"{disk}-part2", "-"),
input=key_input,
check=True,
)
run(
("cryptsetup", "luksOpen", f"{disk}-part2", f"luks-root-pool-{disk.split('/')[-1]}-part2", "-"),
input=key_input,
check=True,
)
for command in (
f"cryptsetup luksFormat --type luks2 {disk}-part2 -",
f"cryptsetup luksOpen {disk}-part2 luks-root-pool-{disk.split('/')[-1]}-part2 -",
):
run(command, check=True, stdin=test.stdout)
# nixos-install rejects mount points under world-writable paths like /tmp
mnt_dir = "/mnt"
# Fixed mount point for the new system; the installer runs as root on a fresh disk
mnt_dir = "/tmp/nix_install" # noqa: S108
Path(mnt_dir).mkdir(parents=True, exist_ok=True)
@@ -341,36 +281,14 @@ def installer(
logger.info("Installation complete")
def main(argv: Sequence[str] | None = None) -> None:
def main() -> None:
"""Main."""
parser = ArgumentParser(description="Install this NixOS configuration onto a ZFS root pool.")
parser.add_argument(
"--check",
action="store_true",
help="validate that the live environment has the external installer commands and exit",
)
parser.add_argument("--log-level", default=getenv("LOG_LEVEL", "DEBUG"), help="Python log level")
args = parser.parse_args(argv)
configure_logger("DEBUG")
configure_logger(args.log_level)
if args.check:
require_commands(REQUIRED_COMMANDS)
logger.info("installer runtime dependencies are available")
return
configure_terminal()
state = curses.wrapper(draw_menu)
encrypt_key = getenv("ENCRYPT_KEY")
if not encrypt_key:
encrypt_key = state.encryption_password
if not state.selected_device_ids:
logger.error("No disks selected; exiting without installing")
sys.exit(1)
logger.info("installing_nixos")
logger.info(f"disks: {state.selected_device_ids}")
logger.info(f"swap_size: {state.swap_size}")
-184
View File
@@ -1,184 +0,0 @@
"""Build the one-file installer binary."""
from __future__ import annotations
import logging
import os
import shutil
import stat
import subprocess
from argparse import ArgumentParser, Namespace
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__)
BINARY_NAME = "nixos-installer"
DEFAULT_INTERPRETER = "/lib64/ld-linux-x86-64.so.2"
INSTALLER_SOURCE_FILES = (
Path("python/__init__.py"),
Path("python/logging_config.py"),
Path("python/process.py"),
Path("python/installer/__init__.py"),
Path("python/installer/__main__.py"),
Path("python/installer/tui.py"),
)
class InstallerBuildError(RuntimeError):
"""Raised when the installer binary cannot be built."""
class MissingSourceFileError(InstallerBuildError):
"""Raised when a required source file is missing."""
def __init__(self, path: Path) -> None:
"""Store the missing path."""
super().__init__(f"Required installer source file is missing: {path}")
self.path = path
class MissingToolError(InstallerBuildError):
"""Raised when a required build tool is missing."""
def __init__(self, tool: str) -> None:
"""Store the missing tool name."""
super().__init__(f"Required build tool is missing from PATH: {tool}")
self.tool = tool
def repo_root() -> Path:
"""Return the repository root for direct script usage."""
return Path(__file__).resolve().parents[2]
def require_tool(tool: str) -> str:
"""Return the path to a tool or raise."""
tool_path = shutil.which(tool)
if tool_path is None:
raise MissingToolError(tool)
return tool_path
def copy_installer_source(source_root: Path, destination: Path) -> None:
"""Copy only the installer files into a minimal staging tree."""
if destination.exists():
shutil.rmtree(destination)
destination.mkdir(parents=True)
for relative_path in INSTALLER_SOURCE_FILES:
source = source_root / relative_path
if not source.is_file():
raise MissingSourceFileError(source)
target = destination / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def run_command(command: Sequence[str], *, env: dict[str, str] | None = None) -> None:
"""Run a build command."""
logger.info("running command=%s", command)
subprocess.run(command, check=True, env=env)
def pyinstaller_environment(staged_source: Path, build_root: Path) -> dict[str, str]:
"""Return environment variables for PyInstaller."""
env = os.environ.copy()
home = build_root / "home"
home.mkdir(parents=True, exist_ok=True)
env["HOME"] = str(home)
if existing_pythonpath := env.get("PYTHONPATH"):
env["PYTHONPATH"] = f"{staged_source}{os.pathsep}{existing_pythonpath}"
else:
env["PYTHONPATH"] = str(staged_source)
return env
def build_installer(
*,
source_root: Path,
build_root: Path,
output: Path,
interpreter: str,
patch_elf: bool,
) -> Path:
"""Build the one-file installer binary."""
pyinstaller = require_tool("pyinstaller")
if patch_elf:
patchelf = require_tool("patchelf")
source_root = source_root.resolve()
build_root = build_root.resolve()
output = output.resolve()
staged_source = build_root / "source"
dist_dir = build_root / "dist"
work_dir = build_root / "work"
spec_dir = build_root / "spec"
copy_installer_source(source_root, staged_source)
output.parent.mkdir(parents=True, exist_ok=True)
run_command(
(
pyinstaller,
"--clean",
"--onefile",
"--name",
BINARY_NAME,
"--paths",
str(staged_source),
"--distpath",
str(dist_dir),
"--workpath",
str(work_dir),
"--specpath",
str(spec_dir),
str(staged_source / "python/installer/__main__.py"),
),
env=pyinstaller_environment(staged_source, build_root),
)
built_binary = dist_dir / BINARY_NAME
shutil.copy2(built_binary, output)
output.chmod(output.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
if patch_elf:
run_command((patchelf, "--set-interpreter", interpreter, "--remove-rpath", str(output)))
return output
def parse_args(argv: Sequence[str] | None = None) -> Namespace:
"""Parse command-line arguments."""
parser = ArgumentParser(description="Build the one-file NixOS installer binary.")
parser.add_argument("--source-root", type=Path, default=repo_root(), help="repo or staged source root")
parser.add_argument("--build-root", type=Path, default=Path("build/nixos-installer"), help="temporary build root")
parser.add_argument("--output", type=Path, default=Path("dist/nixos-installer"), help="output binary path")
parser.add_argument("--interpreter", default=DEFAULT_INTERPRETER, help="ELF interpreter path for the USB binary")
parser.add_argument("--skip-patchelf", action="store_true", help="do not patch the final ELF binary")
parser.add_argument("--log-level", default="INFO", help="Python log level")
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> None:
"""Build the installer binary from the command line."""
args = parse_args(argv)
logging.basicConfig(level=args.log_level, format="%(levelname)s %(message)s")
build_installer(
source_root=args.source_root,
build_root=args.build_root,
output=args.output,
interpreter=args.interpreter,
patch_elf=not args.skip_patchelf,
)
if __name__ == "__main__":
main()
+739
View File
@@ -0,0 +1,739 @@
"""Install NixOS on a ZFS pool."""
from __future__ import annotations
import curses
import logging
import sys
from collections import defaultdict
from os import getenv
from pathlib import Path
from random import getrandbits
from subprocess import PIPE, Popen, run
from time import sleep
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__)
ESCAPE_KEY = 27
def configure_logger(level: str = "INFO") -> None:
"""Configure the logger.
Args:
level (str, optional): The logging level. Defaults to "INFO".
"""
logging.basicConfig(
level=level,
datefmt="%Y-%m-%dT%H:%M:%S%z",
format="%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
def bash_wrapper(command: str) -> str:
"""Execute a bash command and capture the output.
Args:
command (str): The bash command to be executed.
Returns:
Tuple[str, int]: A tuple containing the output of the command (stdout) as a string,
the error output (stderr) as a string (optional), and the return code as an integer.
"""
logger.debug(f"running {command=}")
# This is a acceptable risk
process = Popen(command.split(), stdout=PIPE, stderr=PIPE)
output, _ = process.communicate()
if process.returncode != 0:
error = f"Failed to run command {command=} return code {process.returncode=}"
raise RuntimeError(error)
return output.decode()
def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
"""Partition a disk.
Args:
disk (str): The disk to partition.
swap_size (int): The size of the swap partition in GB.
minimum value is 1.
reserve (int, optional): The size of the reserve partition in GB. Defaults to 0.
minimum value is 0.
"""
logger.info(f"partitioning {disk=}")
swap_size = max(swap_size, 1)
reserve = max(reserve, 0)
bash_wrapper(f"blkdiscard -f {disk}")
if reserve > 0:
msg = f"Creating swap partition on {disk=} with size {swap_size=}GiB and reserve {reserve=}GiB"
logger.info(msg)
swap_start = swap_size + reserve
swap_partition = f"mkpart swap -{swap_start}GiB -{reserve}GiB "
else:
logger.info(f"Creating swap partition on {disk=} with size {swap_size=}GiB")
swap_start = swap_size
swap_partition = f"mkpart swap -{swap_start}GiB 100% "
logger.debug(f"{swap_partition=}")
create_partitions = (
f"parted --script --align=optimal {disk} -- "
"mklabel gpt "
"mkpart EFI 1MiB 4GiB "
f"mkpart root_pool 4GiB -{swap_start}GiB "
f"{swap_partition}"
"set 1 esp on"
)
bash_wrapper(create_partitions)
logger.info(f"{disk=} successfully partitioned")
def create_zfs_pool(pool_disks: Sequence[str], mnt_dir: str) -> None:
"""Create a ZFS pool.
Args:
pool_disks (Sequence[str]): A tuple of disks to use for the pool.
mnt_dir (str): The mount directory.
"""
if len(pool_disks) <= 0:
error = "disks must be a tuple of at least length 1"
raise ValueError(error)
zpool_create = (
"zpool create "
"-o ashift=12 "
"-o autotrim=on "
f"-R {mnt_dir} "
"-O acltype=posixacl "
"-O canmount=off "
"-O dnodesize=auto "
"-O normalization=formD "
"-O relatime=on "
"-O xattr=sa "
"-O mountpoint=legacy "
"-O compression=zstd "
"-O atime=off "
"root_pool "
)
if len(pool_disks) == 1:
zpool_create += pool_disks[0]
else:
zpool_create += "mirror "
zpool_create += " ".join(pool_disks)
bash_wrapper(zpool_create)
zpools = bash_wrapper("zpool list -o name")
if "root_pool" not in zpools.splitlines():
logger.critical("Failed to create root_pool")
sys.exit(1)
def create_zfs_datasets() -> None:
"""Create ZFS datasets."""
bash_wrapper("zfs create -o canmount=noauto -o reservation=10G root_pool/root")
bash_wrapper("zfs create root_pool/home")
bash_wrapper("zfs create root_pool/var -o reservation=1G")
bash_wrapper("zfs create -o compression=zstd-9 -o reservation=10G root_pool/nix")
datasets = bash_wrapper("zfs list -o name")
expected_datasets = {
"root_pool/root",
"root_pool/home",
"root_pool/var",
"root_pool/nix",
}
missing_datasets = expected_datasets.difference(datasets.splitlines())
if missing_datasets:
logger.critical(f"Failed to create pools {missing_datasets}")
sys.exit(1)
def get_cpu_manufacturer() -> str:
"""Get the CPU manufacturer."""
output = bash_wrapper("cat /proc/cpuinfo")
id_vendor = {"AuthenticAMD": "amd", "GenuineIntel": "intel"}
for line in output.splitlines():
if "vendor_id" in line:
return id_vendor[line.split(": ")[1].strip()]
error = "Failed to get CPU manufacturer"
raise RuntimeError(error)
def get_boot_drive_id(disk: str) -> str:
"""Get the boot drive ID."""
output = bash_wrapper(f"lsblk -o UUID {disk}-part1")
return output.splitlines()[1]
def create_nix_hardware_file(mnt_dir: str, disks: Sequence[str], *, encrypt: bool) -> None:
"""Create a NixOS hardware file."""
cpu_manufacturer = get_cpu_manufacturer()
devices = ""
if encrypt:
disk = disks[0]
devices = (
f' luks.devices."luks-root-pool-{disk.split("/")[-1]}-part2"'
"= {\n"
f' device = "{disk}-part2";\n'
" bypassWorkqueues = true;\n"
" allowDiscards = true;\n"
" };\n"
)
host_id = format(getrandbits(32), "08x")
nix_hardware = (
"{ config, lib, modulesPath, ... }:\n"
"{\n"
' imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];\n\n'
" boot = {\n"
" initrd = {\n"
" availableKernelModules = [ \n"
' "ahci"\n'
' "ehci_pci"\n'
' "nvme"\n'
' "sd_mod"\n'
' "usb_storage"\n'
' "usbhid"\n'
' "xhci_pci"\n'
" ];\n"
" kernelModules = [ ];\n"
f" {devices}"
" };\n"
f' kernelModules = [ "kvm-{cpu_manufacturer}" ];\n'
" extraModulePackages = [ ];\n"
" };\n\n"
" fileSystems = {\n"
' "/" = lib.mkDefault {\n device = "root_pool/root";\n fsType = "zfs";\n };\n\n'
' "/home" = {\n device = "root_pool/home";\n fsType = "zfs";\n };\n\n'
' "/var" = {\n device = "root_pool/var";\n fsType = "zfs";\n };\n\n'
' "/nix" = {\n device = "root_pool/nix";\n fsType = "zfs";\n };\n\n'
' "/boot" = {\n'
f' device = "/dev/disk/by-uuid/{get_boot_drive_id(disks[0])}";\n'
' fsType = "vfat";\n'
" options = [\n"
' "fmask=0077"\n'
' "dmask=0077"\n'
" ];\n"
" };\n"
" };\n\n"
" swapDevices = [ ];\n\n"
" networking.useDHCP = lib.mkDefault true;\n\n"
' nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";\n'
f" hardware.cpu.{cpu_manufacturer}.updateMicrocode = lib.mkDefault "
"config.hardware.enableRedistributableFirmware;\n"
f' networking.hostId = "{host_id}";\n'
"}\n"
)
Path(f"{mnt_dir}/etc/nixos/hardware-configuration.nix").write_text(nix_hardware)
def install_nixos(mnt_dir: str, disks: Sequence[str], *, encrypt: bool) -> None:
"""Install NixOS."""
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/root {mnt_dir}")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/home {mnt_dir}/home")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/var {mnt_dir}/var")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/nix {mnt_dir}/nix")
for disk in disks:
bash_wrapper(f"mkfs.vfat -n EFI {disk}-part1")
# set up mirroring afterwards if more than one disk
boot_partition = (
f"mount -t vfat -o fmask=0077,dmask=0077,iocharset=iso8859-1,X-mount.mkdir {disks[0]}-part1 {mnt_dir}/boot"
)
bash_wrapper(boot_partition)
bash_wrapper(f"nixos-generate-config --root {mnt_dir}")
create_nix_hardware_file(mnt_dir, disks, encrypt=encrypt)
run(("nixos-install", "--root", mnt_dir), check=True)
def installer(
disks: set[str],
swap_size: int,
reserve: int,
encrypt_key: str | None,
) -> None:
"""Main."""
logger.info("Starting installation")
for disk in disks:
partition_disk(disk, swap_size, reserve)
if encrypt_key:
sleep(1)
key_input = encrypt_key.encode()
run(
("cryptsetup", "luksFormat", "--type", "luks2", f"{disk}-part2", "-"),
input=key_input,
check=True,
)
run(
(
"cryptsetup",
"luksOpen",
f"{disk}-part2",
f"luks-root-pool-{disk.split('/')[-1]}-part2",
"-",
),
input=key_input,
check=True,
)
# Fixed mount point for the new system; the installer runs as root on a fresh disk
mnt_dir = "/tmp/nix_install" # noqa: S108
Path(mnt_dir).mkdir(parents=True, exist_ok=True)
if encrypt_key:
pool_disks = [f"/dev/mapper/luks-root-pool-{disk.split('/')[-1]}-part2" for disk in disks]
else:
pool_disks = [f"{disk}-part2" for disk in disks]
create_zfs_pool(pool_disks, mnt_dir)
create_zfs_datasets()
install_nixos(mnt_dir, disks, encrypt=bool(encrypt_key))
logger.info("Installation complete")
class Cursor:
"""Track cursor position and constrain movement to screen bounds."""
def __init__(self) -> None:
"""Initialize cursor position and screen dimensions."""
self.x_position = 0
self.y_position = 0
self.height = 0
self.width = 0
def set_height(self, height: int) -> None:
"""Set the maximum screen height."""
self.height = height
def set_width(self, width: int) -> None:
"""Set the maximum screen width."""
self.width = width
def x_bounce_check(self, cursor: int) -> int:
"""Clamp an x position to the screen width."""
cursor = max(0, cursor)
return min(self.width - 1, cursor)
def y_bounce_check(self, cursor: int) -> int:
"""Clamp a y position to the screen height."""
cursor = max(0, cursor)
return min(self.height - 1, cursor)
def set_x(self, x: int) -> None:
"""Set the cursor x position."""
self.x_position = self.x_bounce_check(x)
def set_y(self, y: int) -> None:
"""Set the cursor y position."""
self.y_position = self.y_bounce_check(y)
def get_x(self) -> int:
"""Get the cursor x position."""
return self.x_position
def get_y(self) -> int:
"""Get the cursor y position."""
return self.y_position
def move_up(self) -> None:
"""Move the cursor up one row."""
self.set_y(self.y_position - 1)
def move_down(self) -> None:
"""Move the cursor down one row."""
self.set_y(self.y_position + 1)
def move_left(self) -> None:
"""Move the cursor left one column."""
self.set_x(self.x_position - 1)
def move_right(self) -> None:
"""Move the cursor right one column."""
self.set_x(self.x_position + 1)
def navigation(self, key: int) -> None:
"""Move the cursor for a curses navigation key."""
action = {
curses.KEY_DOWN: self.move_down,
curses.KEY_UP: self.move_up,
curses.KEY_RIGHT: self.move_right,
curses.KEY_LEFT: self.move_left,
}
action.get(key, lambda: None)()
class State:
"""State class to store the state of the program."""
def __init__(self) -> None:
"""Initialize installer menu state."""
self.key = 0
self.cursor = Cursor()
self.swap_size = 0
self.show_swap_input = False
self.reserve_size = 0
self.show_reserve_input = False
self.selected_device_ids = set()
def get_selected_devices(self) -> tuple[str]:
"""Get selected devices."""
return tuple(self.selected_device_ids)
def get_device(raw_device: str) -> dict[str, str]:
"""Parse an lsblk key-value device row."""
raw_device_components = raw_device.split(" ")
return {thing.split("=")[0].lower(): thing.split("=")[1].strip('"') for thing in raw_device_components}
def get_devices() -> list[dict[str, str]]:
"""Get a list of devices."""
# --bytes
raw_devices = bash_wrapper("lsblk --paths --pairs").splitlines()
return [get_device(raw_device) for raw_device in raw_devices]
def get_device_id_mapping() -> dict[str, set[str]]:
"""Get a list of device ids.
Returns:
list[str]: the list of device ids
"""
device_ids = bash_wrapper("find /dev/disk/by-id -type l").splitlines()
device_id_mapping: dict[str, set[str]] = defaultdict(set)
for device_id in device_ids:
device = bash_wrapper(f"readlink -f {device_id}").strip()
device_id_mapping[device].add(device_id)
return device_id_mapping
def calculate_device_menu_padding(devices: list[dict[str, str]], column: str, padding: int = 0) -> int:
"""Calculate the width needed for a device menu column."""
return max(len(device[column]) for device in devices) + padding
def draw_device_ids(
state: State,
row_number: int,
menu_start_x: int,
std_screen: curses.window,
menu_width: list[int],
device_ids: set[str],
) -> tuple[State, int]:
"""Draw selectable device IDs for a device row."""
for device_id in sorted(device_ids):
row_number = row_number + 1
if row_number == state.cursor.get_y() and state.cursor.get_x() in menu_width:
std_screen.attron(curses.A_BOLD)
if state.key == ord(" "):
if device_id not in state.selected_device_ids:
state.selected_device_ids.add(device_id)
else:
state.selected_device_ids.remove(device_id)
if device_id in state.selected_device_ids:
std_screen.attron(curses.color_pair(7))
std_screen.addstr(row_number, menu_start_x, f" {device_id}")
std_screen.attroff(curses.color_pair(7))
std_screen.attroff(curses.A_BOLD)
return state, row_number
def draw_device_menu(
std_screen: curses.window,
devices: list[dict[str, str]],
device_id_mapping: dict[str, set[str]],
state: State,
menu_start_y: int = 0,
menu_start_x: int = 0,
) -> tuple[State, int]:
"""Draw the device menu and handle user input.
Args:
std_screen (curses.window): the curses window to draw on
devices (list[dict[str, str]]): the list of devices to draw
device_id_mapping (dict[str, set[str]]): the list of device ids to draw
state (State): the state object to update
menu_start_y (int, optional): the y position to start drawing the menu. Defaults to 0.
menu_start_x (int, optional): the x position to start drawing the menu. Defaults to 0.
Returns:
State: the updated state object
"""
padding = 2
name_padding = calculate_device_menu_padding(devices, "name", padding)
size_padding = calculate_device_menu_padding(devices, "size", padding)
type_padding = calculate_device_menu_padding(devices, "type", padding)
mountpoints_padding = calculate_device_menu_padding(devices, "mountpoints", padding)
device_header = (
f"{'Name':{name_padding}}{'Size':{size_padding}}{'Type':{type_padding}}{'Mountpoints':{mountpoints_padding}}"
)
menu_width = range(menu_start_x, len(device_header) + menu_start_x)
std_screen.addstr(menu_start_y, menu_start_x, device_header, curses.color_pair(5))
devises_list_start = menu_start_y + 1
row_number = devises_list_start
for device in devices:
row_number = row_number + 1
device_name = device["name"]
device_row = (
f"{device_name:{name_padding}}"
f"{device['size']:{size_padding}}"
f"{device['type']:{type_padding}}"
f"{device['mountpoints']:{mountpoints_padding}}"
)
std_screen.addstr(row_number, menu_start_x, device_row)
state, row_number = draw_device_ids(
state=state,
row_number=row_number,
menu_start_x=menu_start_x,
std_screen=std_screen,
menu_width=menu_width,
device_ids=device_id_mapping[device_name],
)
return state, row_number
def debug_menu(std_screen: curses.window, key: int) -> None:
"""Draw debug information for the current curses screen."""
height, width = std_screen.getmaxyx()
width_height = f"Width: {width}, Height: {height}"
std_screen.addstr(height - 4, 0, width_height, curses.color_pair(5))
key_pressed = f"Last key pressed: {key}"[: width - 1]
if key == 0:
key_pressed = "No key press detected..."[: width - 1]
std_screen.addstr(height - 3, 0, key_pressed)
for i in range(8):
std_screen.addstr(height - 2, i * 3, f"{i}██", curses.color_pair(i))
def status_bar(
std_screen: curses.window,
cursor: Cursor,
width: int,
height: int,
) -> None:
"""Draw the footer status bar."""
std_screen.attron(curses.A_REVERSE)
std_screen.attron(curses.color_pair(3))
status_bar = f"Press 'q' to exit | STATUS BAR | Pos: {cursor.get_x()}, {cursor.get_y()}"
std_screen.addstr(height - 1, 0, status_bar)
std_screen.addstr(height - 1, len(status_bar), " " * (width - len(status_bar) - 1))
std_screen.attroff(curses.color_pair(3))
std_screen.attroff(curses.A_REVERSE)
def set_color() -> None:
"""Initialize curses color pairs."""
curses.start_color()
curses.use_default_colors()
for i in range(curses.COLORS):
curses.init_pair(i + 1, i, -1)
def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int) -> str:
"""Read text input from a curses screen."""
curses.echo()
std_screen.addstr(y, x, prompt)
input_str = ""
while True:
key = std_screen.getch()
if key == ord("\n"):
break
if key == ESCAPE_KEY:
input_str = ""
break
if key in (curses.KEY_BACKSPACE, ord("\b"), 127):
input_str = input_str[:-1]
std_screen.addstr(y, x + len(prompt), input_str + " ")
else:
input_str += chr(key)
std_screen.refresh()
curses.noecho()
return input_str
def swap_size_input(
std_screen: curses.window,
state: State,
swap_offset: int,
) -> State:
"""Handle swap size input."""
swap_size_text = "Swap size (GB): "
std_screen.addstr(swap_offset, 0, f"{swap_size_text}{state.swap_size}")
if state.key == ord("\n") and state.cursor.get_y() == swap_offset:
state.show_swap_input = True
if state.show_swap_input:
swap_size_str = get_text_input(std_screen, swap_size_text, swap_offset, 0)
try:
state.swap_size = int(swap_size_str)
state.show_swap_input = False
except ValueError:
std_screen.addstr(swap_offset, 0, "Invalid input. Press any key to continue.")
std_screen.getch()
state.show_swap_input = False
return state
def reserve_size_input(
std_screen: curses.window,
state: State,
reserve_offset: int,
) -> State:
"""Handle reserve size input."""
reserve_size_text = "reserve size (GB): "
std_screen.addstr(reserve_offset, 0, f"{reserve_size_text}{state.reserve_size}")
if state.key == ord("\n") and state.cursor.get_y() == reserve_offset:
state.show_reserve_input = True
if state.show_reserve_input:
reserve_size_str = get_text_input(std_screen, reserve_size_text, reserve_offset, 0)
try:
state.reserve_size = int(reserve_size_str)
state.show_reserve_input = False
except ValueError:
std_screen.addstr(reserve_offset, 0, "Invalid input. Press any key to continue.")
std_screen.getch()
state.show_reserve_input = False
return state
def draw_menu(std_screen: curses.window) -> State:
"""Draw the menu and handle user input.
Args:
std_screen (curses.window): the curses window to draw on
Returns:
State: the state object
"""
# Clear and refresh the screen for a blank canvas
std_screen.clear()
std_screen.refresh()
set_color()
state = State()
devices = get_devices()
device_id_mapping = get_device_id_mapping()
# Loop where k is the last character pressed
while state.key != ord("q"):
std_screen.clear()
height, width = std_screen.getmaxyx()
state.cursor.set_height(height)
state.cursor.set_width(width)
state.cursor.navigation(state.key)
state, device_menu_size = draw_device_menu(
std_screen=std_screen,
state=state,
devices=devices,
device_id_mapping=device_id_mapping,
)
swap_offset = device_menu_size + 2
swap_size_input(
std_screen=std_screen,
state=state,
swap_offset=swap_offset,
)
reserve_size_input(
std_screen=std_screen,
state=state,
reserve_offset=swap_offset + 1,
)
status_bar(std_screen, state.cursor, width, height)
debug_menu(std_screen, state.key)
std_screen.move(state.cursor.get_y(), state.cursor.get_x())
std_screen.refresh()
state.key = std_screen.getch()
return state
def main() -> None:
"""Run the installer menu and start installation."""
configure_logger("DEBUG")
state = curses.wrapper(draw_menu)
encrypt_key = getenv("ENCRYPT_KEY")
logger.info("installing_nixos")
logger.info(f"disks: {state.selected_device_ids}")
logger.info(f"swap_size: {state.swap_size}")
logger.info(f"reserve: {state.reserve_size}")
logger.info(f"encrypted: {bool(encrypt_key)}")
sleep(3)
installer(
disks=state.get_selected_devices(),
swap_size=state.swap_size,
reserve=state.reserve_size,
encrypt_key=encrypt_key,
)
if __name__ == "__main__":
main()
-49
View File
@@ -1,49 +0,0 @@
{
lib,
stdenv,
patchelf,
python314,
python314Packages,
patchElf ? true,
}:
stdenv.mkDerivation {
pname = "nixos-installer";
version = "0.1.0";
src = ../../.;
dontPatchELF = true;
dontStrip = true;
nativeBuildInputs = [
patchelf
python314
python314Packages.pyinstaller
];
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR"
python "$src/python/installer/build.py" \
--source-root "$src" \
--build-root "$TMPDIR/nixos-installer-build" \
--output "$PWD/nixos-installer" ${lib.optionalString (!patchElf) "--skip-patchelf"}
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 nixos-installer $out/bin/nixos-installer
runHook postInstall
'';
meta.description =
if patchElf then
"One-file NixOS ZFS installer patched to run on foreign Linux live environments."
else
"One-file NixOS ZFS installer linked against the Nix store, for the custom install ISO.";
}
+34 -76
View File
@@ -5,12 +5,30 @@ from __future__ import annotations
import curses
import logging
from collections import defaultdict
from python.process import run_output
from subprocess import PIPE, Popen
logger = logging.getLogger(__name__)
BYTE_MAX = 255
def bash_wrapper(command: str) -> str:
"""Execute a bash command and capture the output.
Args:
command (str): The bash command to be executed.
Returns:
Tuple[str, int]: A tuple containing the output of the command (stdout) as a string,
the error output (stderr) as a string (optional), and the return code as an integer.
"""
logger.debug(f"running {command=}")
# This is a acceptable risk
process = Popen(command.split(), stdout=PIPE, stderr=PIPE)
output, _ = process.communicate()
if process.returncode != 0:
error = f"Failed to run command {command=} return code {process.returncode=}"
raise RuntimeError(error)
return output.decode()
class Cursor:
@@ -103,9 +121,6 @@ class State:
self.reserve_size = 0
self.show_reserve_input = False
self.encryption_password = None
self.show_encryption_password_input = False
self.selected_device_ids: set[str] = set()
def get_selected_devices(self) -> tuple[str, ...]:
@@ -129,7 +144,7 @@ def get_device(raw_device: str) -> dict[str, str]:
def get_devices() -> list[dict[str, str]]:
"""Get a list of devices."""
# --bytes
raw_devices = run_output(("lsblk", "--paths", "--pairs")).splitlines()
raw_devices = bash_wrapper("lsblk --paths --pairs").splitlines()
return [get_device(raw_device) for raw_device in raw_devices]
@@ -160,22 +175,7 @@ def debug_menu(std_screen: curses.window, key: int) -> None:
std_screen.addstr(height - 2, i * 3, f"{i}██", curses.color_pair(i))
def draw_input_line(std_screen: curses.window, prompt: str, input_str: str, y: int, x: int, mask: str | None) -> None:
"""Draw an input line without leaking masked values."""
_, width = std_screen.getmaxyx()
displayed_input = input_str if mask is None else mask * len(input_str)
line = f"{prompt}{displayed_input}"
available_width = max(0, width - x - 1)
std_screen.move(y, x)
if available_width > 0:
std_screen.addstr(y, x, line[:available_width])
std_screen.clrtoeol()
std_screen.move(y, min(x + len(line), width - 1))
std_screen.refresh()
def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int, mask: str | None = None) -> str | None:
def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int) -> str:
"""Get text input.
Args:
@@ -183,27 +183,27 @@ def get_text_input(std_screen: curses.window, prompt: str, y: int, x: int, mask:
prompt (str): The prompt.
y (int): The y position.
x (int): The x position.
mask (str | None, optional): The character used to mask displayed input. Defaults to None.
Returns:
str | None: The input string, or None if input was cancelled.
str: The input string.
"""
esc_key = 27
curses.noecho()
curses.echo()
std_screen.addstr(y, x, prompt)
input_str = ""
draw_input_line(std_screen, prompt, input_str, y, x, mask)
while True:
key = std_screen.getch()
if key in (ord("\n"), ord("\r")):
if key == ord("\n"):
break
if key == esc_key:
curses.noecho()
return None
input_str = ""
break
if key in (curses.KEY_BACKSPACE, ord("\b"), 127):
input_str = input_str[:-1]
elif 0 <= key <= BYTE_MAX:
std_screen.addstr(y, x + len(prompt), input_str + " ")
else:
input_str += chr(key)
draw_input_line(std_screen, prompt, input_str, y, x, mask)
std_screen.refresh()
curses.noecho()
return input_str
@@ -230,9 +230,6 @@ def swap_size_input(
if state.show_swap_input:
swap_size_str = get_text_input(std_screen, swap_size_text, swap_offset, 0)
if swap_size_str is None:
state.show_swap_input = False
return state
try:
state.swap_size = int(swap_size_str)
state.show_swap_input = False
@@ -266,9 +263,6 @@ def reserve_size_input(
if state.show_reserve_input:
reserve_size_str = get_text_input(std_screen, reserve_size_text, reserve_offset, 0)
if reserve_size_str is None:
state.show_reserve_input = False
return state
try:
state.reserve_size = int(reserve_size_str)
state.show_reserve_input = False
@@ -280,37 +274,6 @@ def reserve_size_input(
return state
def encryption_password_input(
std_screen: curses.window,
state: State,
password_offset: int,
) -> State:
"""Encryption password input.
Args:
std_screen (curses.window): The curses window.
state (State): The state object.
password_offset (int): The password offset.
Returns:
State: The updated state object.
"""
password_status = "set" if state.encryption_password else "unset"
encryption_label = "Encryption password: "
encryption_prompt = "Encryption password (blank disables LUKS): "
std_screen.addstr(password_offset, 0, f"{encryption_label}{password_status}")
if state.key == ord("\n") and state.cursor.get_y() == password_offset:
state.show_encryption_password_input = True
if state.show_encryption_password_input:
password = get_text_input(std_screen, encryption_prompt, password_offset, 0)
if password is not None:
state.encryption_password = password
state.show_encryption_password_input = False
return state
def status_bar(
std_screen: curses.window,
cursor: Cursor,
@@ -342,12 +305,12 @@ def get_device_id_mapping() -> dict[str, set[str]]:
Returns:
list[str]: the list of device ids
"""
device_ids = run_output(("find", "/dev/disk/by-id", "-type", "l")).splitlines()
device_ids = bash_wrapper("find /dev/disk/by-id -type l").splitlines()
device_id_mapping: dict[str, set[str]] = defaultdict(set)
for device_id in device_ids:
device = run_output(("readlink", "-f", device_id)).strip()
device = bash_wrapper(f"readlink -f {device_id}").strip()
device_id_mapping[device].add(device_id)
return device_id_mapping
@@ -521,11 +484,6 @@ def draw_menu(std_screen: curses.window) -> State:
state=state,
reserve_offset=swap_offset + 1,
)
encryption_password_input(
std_screen=std_screen,
state=state,
password_offset=swap_offset + 2,
)
status_bar(std_screen, state.cursor, width, height)
-16
View File
@@ -1,16 +0,0 @@
"""Logging helpers shared by command-line tools."""
from __future__ import annotations
import logging
import sys
def configure_logger(level: str = "INFO") -> None:
"""Configure process-wide logging."""
logging.basicConfig(
level=level,
datefmt="%Y-%m-%dT%H:%M:%S%z",
format="%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
-8
View File
@@ -12,16 +12,12 @@ from python.orm.richie.contact import (
RelationshipType,
)
from python.orm.richie.ebook import (
EbookCandidatePhrase,
EbookChapter,
EbookChunk,
EbookChunkEmbedding1024,
EbookChunkEmbedding2560,
EbookChunkEmbedding4096,
EbookChunkPhraseMention,
EbookEmbeddingModel,
EbookPhraseAlias,
EbookProtectedPhrase,
EbookSource,
)
@@ -32,16 +28,12 @@ __all__ = [
"Contact",
"ContactNeed",
"ContactRelationship",
"EbookCandidatePhrase",
"EbookChapter",
"EbookChunk",
"EbookChunkEmbedding1024",
"EbookChunkEmbedding2560",
"EbookChunkEmbedding4096",
"EbookChunkPhraseMention",
"EbookEmbeddingModel",
"EbookPhraseAlias",
"EbookProtectedPhrase",
"EbookSource",
"Need",
"RelationshipType",
+2 -108
View File
@@ -5,23 +5,11 @@ from __future__ import annotations
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
JSON,
BigInteger,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from python.orm.richie.base import TableBase, TableBaseBig
JSON_DOCUMENT = JSON().with_variant(JSONB, "postgresql")
class EbookSource(TableBase):
"""One indexed EPUB file."""
@@ -106,7 +94,7 @@ class EbookEmbeddingModel(TableBase):
name: Mapped[str] = mapped_column(String, unique=True)
dimension: Mapped[int]
is_default: Mapped[bool] = mapped_column(default=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
class EbookChunkEmbedding1024(TableBaseBig):
@@ -148,97 +136,3 @@ class EbookChunkEmbedding4096(TableBaseBig):
chunk_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_chunk.id", ondelete="CASCADE"))
model_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_embedding_model.id", ondelete="CASCADE"))
embedding: Mapped[list[float]] = mapped_column(Vector(4096))
class EbookCandidatePhrase(TableBase):
"""A high-recall phrase candidate extracted from one book."""
__tablename__ = "candidate_phrases"
__table_args__ = (
UniqueConstraint("book_id", "phrase_norm", name="uq_candidate_phrases_book_id_phrase_norm"),
Index("candidate_phrases_book_score_idx", "book_id", "candidate_score"),
Index("candidate_phrases_book_norm_idx", "book_id", "phrase_norm"),
)
book_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
phrase_text: Mapped[str] = mapped_column(Text)
phrase_norm: Mapped[str] = mapped_column(Text)
token_count: Mapped[int]
source_raw_ngram: Mapped[bool] = mapped_column(default=False)
source_yake: Mapped[bool] = mapped_column(default=False)
source_spacy_ner: Mapped[bool] = mapped_column(default=False)
source_spacy_noun_chunk: Mapped[bool] = mapped_column(default=False)
source_capitalized: Mapped[bool] = mapped_column(default=False)
source_metadata: Mapped[bool] = mapped_column(default=False)
spacy_label: Mapped[str | None]
raw_count: Mapped[int] = mapped_column(default=0)
chapter_count: Mapped[int] = mapped_column(default=0)
yake_score: Mapped[float | None]
candidate_score: Mapped[float] = mapped_column(default=0.0)
sample_contexts: Mapped[list[str] | None] = mapped_column(JSON_DOCUMENT)
llm_judged: Mapped[bool] = mapped_column(default=False)
llm_keep: Mapped[bool | None]
llm_confidence: Mapped[float | None]
llm_category: Mapped[str | None]
llm_reason: Mapped[str | None] = mapped_column(Text)
class EbookProtectedPhrase(TableBase):
"""A phrase accepted by the LLM judge for protected query matching."""
__tablename__ = "protected_phrases"
__table_args__ = (
UniqueConstraint("book_id", "phrase_norm", name="uq_protected_phrases_book_id_phrase_norm"),
Index("protected_phrases_norm_idx", "phrase_norm"),
Index("protected_phrases_book_norm_idx", "book_id", "phrase_norm"),
Index("protected_phrases_series_norm_idx", "series_id", "phrase_norm"),
)
book_id: Mapped[int | None] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
phrase_text: Mapped[str] = mapped_column(Text)
phrase_norm: Mapped[str] = mapped_column(Text)
canonical_id: Mapped[str]
phrase_type: Mapped[str | None]
token_count: Mapped[int]
confidence: Mapped[float]
importance: Mapped[float] = mapped_column(default=0.5)
allow_nested: Mapped[bool] = mapped_column(default=False)
suppress_children: Mapped[bool] = mapped_column(default=True)
source_candidate_id: Mapped[int | None] = mapped_column(
ForeignKey("main.candidate_phrases.id", ondelete="SET NULL")
)
class EbookPhraseAlias(TableBase):
"""A normalized alias that maps to a protected phrase."""
__tablename__ = "phrase_aliases"
__table_args__ = (
UniqueConstraint("phrase_id", "alias_norm", name="uq_phrase_aliases_phrase_id_alias_norm"),
Index("phrase_aliases_norm_idx", "alias_norm"),
)
phrase_id: Mapped[int] = mapped_column(ForeignKey("main.protected_phrases.id", ondelete="CASCADE"))
alias_text: Mapped[str] = mapped_column(Text)
alias_norm: Mapped[str] = mapped_column(Text)
confidence: Mapped[float] = mapped_column(default=1.0)
class EbookChunkPhraseMention(TableBase):
"""A precomputed occurrence of a protected phrase inside one chunk."""
__tablename__ = "chunk_phrase_mentions"
__table_args__ = (
UniqueConstraint("chunk_id", "phrase_id", "start_char", name="uq_chunk_phrase_mentions_chunk_phrase_start"),
Index("chunk_phrase_mentions_phrase_idx", "phrase_id"),
Index("chunk_phrase_mentions_chunk_idx", "chunk_id"),
)
chunk_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_chunk.id", ondelete="CASCADE"))
phrase_id: Mapped[int] = mapped_column(ForeignKey("main.protected_phrases.id", ondelete="CASCADE"))
book_id: Mapped[int | None] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
start_char: Mapped[int]
end_char: Mapped[int | None]
-52
View File
@@ -1,52 +0,0 @@
"""Small subprocess helpers."""
from __future__ import annotations
import logging
import shutil
from subprocess import run
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
logger = logging.getLogger(__name__)
class CommandError(RuntimeError):
"""Raised when an external command fails."""
def __init__(self, command: Sequence[str], returncode: int, stderr: str) -> None:
"""Store command failure details."""
command_text = " ".join(command)
super().__init__(f"Failed to run command {command_text!r}: exit {returncode}\n{stderr}")
self.command = command
self.returncode = returncode
self.stderr = stderr
class MissingCommandsError(RuntimeError):
"""Raised when required external commands are not available."""
def __init__(self, commands: Sequence[str]) -> None:
"""Store missing command details."""
missing = ", ".join(commands)
super().__init__(f"Missing required installer commands: {missing}")
self.commands = commands
def require_commands(commands: Iterable[str]) -> None:
"""Raise when one or more executables are missing from PATH."""
missing_commands = sorted({command for command in commands if shutil.which(command) is None})
if missing_commands:
raise MissingCommandsError(missing_commands)
def run_output(command: Sequence[str]) -> str:
"""Run a command and return stdout."""
logger.debug("running command=%s", command)
result = run(command, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise CommandError(command, result.returncode, result.stderr)
return result.stdout
-55
View File
@@ -1,55 +0,0 @@
{
outputs,
lib,
pkgs,
modulesPath,
...
}:
{
imports = [
"${modulesPath}/installer/cd-dvd/installation-cd-minimal.nix"
];
nixpkgs.hostPlatform = "x86_64-linux";
image.baseName = lib.mkForce "nixos-zfs-installer";
# Keep the live kernel and ZFS in sync with the deployed systems so pools
# created by the installer import cleanly on first boot.
boot = {
kernelPackages = pkgs.linuxPackages_6_18;
zfs.package = pkgs.zfs_2_4;
supportedFilesystems.zfs = true;
};
networking.hostName = "installer";
# On flake-built systems <nixpkgs> resolves through the flake registry,
# so nixos-install fails without these features enabled.
nix.settings.experimental-features = [
"flakes"
"nix-command"
];
environment.systemPackages = [
outputs.packages.${pkgs.stdenv.hostPlatform.system}.installer-nixos
];
# Live-media only: sshd is already enabled by the installation profile,
# but ssh logins need a non-empty password.
users.users = {
nixos = {
password = "nixos";
initialHashedPassword = lib.mkForce null;
};
root = {
password = "nixos";
initialHashedPassword = lib.mkForce null;
};
};
services.getty.helpLine = ''
Run "sudo nixos-installer" to install NixOS onto a ZFS root pool.
SSH is enabled; the "nixos" and "root" passwords are "nixos".
'';
}
-37
View File
@@ -1,37 +0,0 @@
{ inputs, ... }:
{
imports = [
"${inputs.self}/users/richie"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/zerotier.nix"
./hardware.nix
];
boot.kernelParams = [ "panic=10" ];
networking = {
hostName = "tortoise";
hostId = "161cbd68";
firewall.enable = true;
networkmanager.enable = true;
};
services = {
openssh.ports = [ 867 ];
};
systemd = {
enableEmergencyMode = false;
# Only takes effect if the board exposes a hardware watchdog device.
settings.Manager = {
RuntimeWatchdogSec = "30s";
RebootWatchdogSec = "2m";
};
};
system.stateVersion = "26.11";
}
-69
View File
@@ -1,69 +0,0 @@
{
config,
lib,
modulesPath,
...
}:
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot = {
initrd = {
availableKernelModules = [
"ahci"
"ehci_pci"
"nvme"
"sd_mod"
"usb_storage"
"usbhid"
"xhci_pci"
];
kernelModules = [ ];
luks.devices."luks-root-pool-ata-Samsung_SSD_870_EVO_4TB_S757NL0Y315721H-part2" = {
device = "/dev/disk/by-id/ata-Samsung_SSD_870_EVO_4TB_S757NL0Y315721H-part2";
bypassWorkqueues = true;
allowDiscards = true;
};
};
kernelModules = [ "kvm-intel" ];
extraModulePackages = [ ];
};
fileSystems = {
"/" = lib.mkDefault {
device = "root_pool/root";
fsType = "zfs";
};
"/home" = {
device = "root_pool/home";
fsType = "zfs";
};
"/var" = {
device = "root_pool/var";
fsType = "zfs";
};
"/nix" = {
device = "root_pool/nix";
fsType = "zfs";
};
"/boot" = {
device = "/dev/disk/by-uuid/223B-9F08";
fsType = "vfat";
options = [
"fmask=0077"
"dmask=0077"
];
};
};
swapDevices = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
-5
View File
@@ -1,5 +0,0 @@
{
imports = [
../home/global.nix
];
}
+1
View File
@@ -3,6 +3,7 @@
home.packages = with pkgs; [
discord-canary
signal-desktop
slack
zoom-us
];
}