chore: hw provisioning, test coverage, deps
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 40s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bib) (push) Successful in 38s
CI / skinny-install (cli) (push) Successful in 46s
CI / skinny-install (conf) (push) Successful in 36s
CI / skinny-install (opps) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 47s
CI / skinny-install (rex) (push) Successful in 35s
Infra CI / notebooks (push) Successful in 3m17s
CI / lint-test (push) Failing after 3m30s
CI / skinny-install (bls) (push) Successful in 34s
CI / skinny-install (ccw) (push) Successful in 45s
CI / skinny-install (cms) (push) Successful in 32s
CI / skinny-install (perf) (push) Successful in 43s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Failing after 20s
Infra CI / api (push) Successful in 16s
Infra CI / mc (push) Successful in 12s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m27s
Infra CI / zotero (push) Successful in 6m10s
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 40s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bib) (push) Successful in 38s
CI / skinny-install (cli) (push) Successful in 46s
CI / skinny-install (conf) (push) Successful in 36s
CI / skinny-install (opps) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 47s
CI / skinny-install (rex) (push) Successful in 35s
Infra CI / notebooks (push) Successful in 3m17s
CI / lint-test (push) Failing after 3m30s
CI / skinny-install (bls) (push) Successful in 34s
CI / skinny-install (ccw) (push) Successful in 45s
CI / skinny-install (cms) (push) Successful in 32s
CI / skinny-install (perf) (push) Successful in 43s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Failing after 20s
Infra CI / api (push) Successful in 16s
Infra CI / mc (push) Successful in 12s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m27s
Infra CI / zotero (push) Successful in 6m10s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -48,3 +48,4 @@ docs/static/library.json
|
||||
docs/node_modules/
|
||||
docs/build/
|
||||
docs/.docusaurus/
|
||||
hw/node_modules/
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<category field="medicine"/>
|
||||
<category field="science"/>
|
||||
<summary>Citing Medicine: The NLM Style Guide for Authors, Editors, and Publishers, 2nd edition (2015), based on ANSI/NISO Z39.29-2005 (R2010); citation-sequence system.</summary>
|
||||
<updated>2026-02-18T15:24:08+00:00</updated>
|
||||
<updated>2026-03-29T15:20:09+00:00</updated>
|
||||
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
|
||||
</info>
|
||||
<locale xml:lang="en">
|
||||
@@ -98,6 +98,17 @@
|
||||
</choose>
|
||||
</group>
|
||||
</macro>
|
||||
<macro name="label-number">
|
||||
<group delimiter=": ">
|
||||
<choose>
|
||||
<if type="standard"/>
|
||||
<else-if is-numeric="number" match="any" type="legislation patent regulation">
|
||||
<label form="short" variable="number"/>
|
||||
</else-if>
|
||||
</choose>
|
||||
<text variable="number"/>
|
||||
</group>
|
||||
</macro>
|
||||
<macro name="label-number-of-pages">
|
||||
<group delimiter=" ">
|
||||
<text variable="number-of-pages"/>
|
||||
@@ -404,13 +415,17 @@
|
||||
</macro>
|
||||
<macro name="report-number">
|
||||
<choose>
|
||||
<if type="report">
|
||||
<group delimiter=": ">
|
||||
<if type="report" variable="number">
|
||||
<group delimiter=" ">
|
||||
<choose>
|
||||
<if variable="genre">
|
||||
<text text-case="capitalize-first" variable="genre"/>
|
||||
</if>
|
||||
<else>
|
||||
<text term="report" text-case="capitalize-first"/>
|
||||
<label form="short" text-case="capitalize-first" variable="number"/>
|
||||
</group>
|
||||
<text variable="number"/>
|
||||
</else>
|
||||
</choose>
|
||||
<text macro="label-number"/>
|
||||
</group>
|
||||
</if>
|
||||
</choose>
|
||||
|
||||
100
dev/scripts/zotero_fix_fields.py
Normal file
100
dev/scripts/zotero_fix_fields.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Fix Zotero items that have data stored under base field IDs instead of mapped field IDs.
|
||||
|
||||
Zotero uses type-specific fields (e.g. caseName for case items) that map to base fields
|
||||
(e.g. title). When items have data under the base field ID but their item type requires the
|
||||
mapped field ID, Zotero logs "is not a valid field" errors.
|
||||
|
||||
This script remaps base field IDs to the correct type-specific field IDs, and removes
|
||||
truly invalid field data (fields with no mapping for that type).
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DB = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/home/ubuntu/Zotero/zotero.sqlite")
|
||||
|
||||
|
||||
def main():
|
||||
db = sqlite3.connect(str(DB))
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA foreign_keys=OFF")
|
||||
|
||||
# Build set of valid (itemTypeID, fieldID) pairs
|
||||
valid_fields: set[tuple[int, int]] = set()
|
||||
for r in db.execute("SELECT itemTypeID, fieldID FROM itemTypeFieldsCombined"):
|
||||
valid_fields.add((r["itemTypeID"], r["fieldID"]))
|
||||
|
||||
# Build base field mapping: (itemTypeID, baseFieldID) -> mappedFieldID
|
||||
field_map: dict[tuple[int, int], int] = {}
|
||||
for r in db.execute("SELECT itemTypeID, baseFieldID, fieldID FROM baseFieldMappingsCombined"):
|
||||
field_map[(r["itemTypeID"], r["baseFieldID"])] = r["fieldID"]
|
||||
|
||||
# Find all invalid itemData rows
|
||||
# An itemData row is invalid if (itemTypeID, fieldID) is not in valid_fields
|
||||
invalid = db.execute("""
|
||||
SELECT id.itemID, id.fieldID, id.valueID, i.itemTypeID
|
||||
FROM itemData id
|
||||
JOIN items i ON id.itemID = i.itemID
|
||||
WHERE i.libraryID = 1
|
||||
AND (i.itemTypeID, id.fieldID) NOT IN (
|
||||
SELECT itemTypeID, fieldID FROM itemTypeFieldsCombined
|
||||
)
|
||||
""").fetchall()
|
||||
|
||||
print(f"Found {len(invalid)} invalid itemData rows")
|
||||
|
||||
remapped = 0
|
||||
deleted = 0
|
||||
conflicts = 0
|
||||
|
||||
for row in invalid:
|
||||
item_id = row["itemID"]
|
||||
old_field = row["fieldID"]
|
||||
value_id = row["valueID"]
|
||||
item_type = row["itemTypeID"]
|
||||
|
||||
# Check if there's a mapping for this base field to a type-specific field
|
||||
mapped_field = field_map.get((item_type, old_field))
|
||||
|
||||
if mapped_field and (item_type, mapped_field) in valid_fields:
|
||||
# Check if the mapped field already has data for this item
|
||||
existing = db.execute(
|
||||
"SELECT valueID FROM itemData WHERE itemID=? AND fieldID=?",
|
||||
(item_id, mapped_field),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
# Mapped field already has data — delete the base field entry
|
||||
db.execute(
|
||||
"DELETE FROM itemData WHERE itemID=? AND fieldID=?",
|
||||
(item_id, old_field),
|
||||
)
|
||||
conflicts += 1
|
||||
else:
|
||||
# Remap: update fieldID from base to mapped
|
||||
db.execute(
|
||||
"UPDATE itemData SET fieldID=? WHERE itemID=? AND fieldID=?",
|
||||
(mapped_field, item_id, old_field),
|
||||
)
|
||||
remapped += 1
|
||||
else:
|
||||
# No valid mapping exists — delete the orphan data
|
||||
db.execute(
|
||||
"DELETE FROM itemData WHERE itemID=? AND fieldID=?",
|
||||
(item_id, old_field),
|
||||
)
|
||||
deleted += 1
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
print(f"Remapped: {remapped}")
|
||||
print(f"Deleted (no mapping): {deleted}")
|
||||
print(f"Deleted (conflict): {conflicts}")
|
||||
print(f"Total fixed: {remapped + deleted + conflicts}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
hw/README.md
Normal file
3
hw/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# hw
|
||||
|
||||
The purpose of the hw module is to manage infrastructure.
|
||||
270
hw/RUNBOOK.md
Normal file
270
hw/RUNBOOK.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Storage Node Build Runbook
|
||||
|
||||
Hardware: ASUS ROG Crosshair VIII Hero / AMD 3950X / 192GB RAM
|
||||
OS: Alpine Linux 3.21 (custom ISO)
|
||||
Security: LUKS2 encryption + YubiKey FIDO2 SSH + YubiKey challenge-response unlock
|
||||
|
||||
## Drive Map
|
||||
|
||||
| Device | Hardware | Mount | Encryption | Purpose |
|
||||
|--------|----------|-------|------------|---------|
|
||||
| Samsung Fit 128GB USB | Rear USB 3.1 port | `/` (read-only) | None (no secrets) | OS root |
|
||||
| 6x WD Blue SA510 2TB | M.2→SATA adapters on SATA6G_1-6 | `/data/rustfs` (LVM, 12TB) | LUKS2 on LV | RustFS object storage |
|
||||
| 1x Modern NVMe 2TB | M.2_1 onboard slot (PCIe 3.0 x4) | `/var/lib/docker` + `/var/cache` | LUKS2 per partition | Docker, writes, cache |
|
||||
|
||||
## Security Model
|
||||
|
||||
```
|
||||
Boot → read-only root (no secrets, no data) → SSH only with YubiKey FIDO2
|
||||
→ yubikey-unlock (challenge-response decrypts LUKS volumes)
|
||||
→ Docker starts → RustFS serves data
|
||||
```
|
||||
|
||||
- **At rest**: All data encrypted with LUKS2 (AES-XTS-512). Server can be physically stolen and data is safe.
|
||||
- **SSH access**: Requires a hardware YubiKey with FIDO2 ed25519-sk key. No passwords accepted.
|
||||
- **Volume unlock**: Requires physical YubiKey challenge-response (HMAC-SHA1 slot 2).
|
||||
- **Backup access**: Emergency passphrase enrolled in LUKS slot 1 (store offline, safe deposit box).
|
||||
- **Two YubiKeys enrolled**: Primary and backup, both work for SSH and LUKS.
|
||||
|
||||
## YubiKey Preparation (before build)
|
||||
|
||||
On your workstation, for EACH YubiKey:
|
||||
|
||||
```sh
|
||||
# 1. Program HMAC-SHA1 challenge-response on slot 2
|
||||
ykman otp chalresp --touch --generate 2
|
||||
|
||||
# 2. Generate FIDO2 SSH key (resident on the key)
|
||||
ssh-keygen -t ed25519-sk -O resident -C "yubikey-1-storagenode" -f ~/.ssh/id_yubikey1_storagenode
|
||||
|
||||
# Repeat with second key:
|
||||
ssh-keygen -t ed25519-sk -O resident -C "yubikey-2-storagenode" -f ~/.ssh/id_yubikey2_storagenode
|
||||
```
|
||||
|
||||
Save both `.pub` files — you'll paste them into `/root/.ssh/authorized_keys` during setup.
|
||||
|
||||
## BIOS Settings
|
||||
|
||||
Enter BIOS: hold `Delete` during POST.
|
||||
|
||||
### Required
|
||||
|
||||
1. **Advanced → Onboard Devices Configuration**
|
||||
- `M.2_2 PCIe Bandwidth Configuration` → `Disabled(X8 mode)`
|
||||
- `HD Audio Controller` → `Disabled`
|
||||
- `RGB LED lighting (working state)` → `Off`
|
||||
- `RGB LED lighting (sleep/off)` → `Off`
|
||||
|
||||
2. **Advanced → CPU Configuration**
|
||||
- `SVM Mode` → `Enabled` (AMD-V, for Docker/future VMs)
|
||||
|
||||
3. **Advanced → AMD fTPM configuration**
|
||||
- `Firmware TPM` → `Enabled`
|
||||
|
||||
4. **Advanced → SATA Configuration**
|
||||
- `SATA Mode` → `AHCI`
|
||||
- Verify all 6 SATA ports show `Enabled`
|
||||
|
||||
5. **Advanced → APM Configuration**
|
||||
- `Restore On AC Power Loss` → `Power On`
|
||||
- `Power On By PCI-E/PCI` → `Enabled` (Wake-on-LAN)
|
||||
|
||||
6. **Extreme Tweaker**
|
||||
- `TPU` → `TPU II` (water cooling overclock profile)
|
||||
|
||||
7. **Boot**
|
||||
- Boot priority: USB drive first
|
||||
- `CSM (Compatibility Support Module)` → `Disabled` (pure UEFI)
|
||||
- `Fast Boot` → `Disabled` (until stable)
|
||||
|
||||
### Optional Performance
|
||||
|
||||
8. **Extreme Tweaker → PBO** (if available in BIOS update)
|
||||
- Precision Boost Overdrive → `Enabled`
|
||||
|
||||
## Build Steps
|
||||
|
||||
### Phase 1: Build the ISO (on your current machine)
|
||||
|
||||
```sh
|
||||
cd ~/stack/alpine-iso
|
||||
chmod +x build-iso.sh
|
||||
./build-iso.sh
|
||||
```
|
||||
|
||||
If building from a non-Alpine system:
|
||||
|
||||
```sh
|
||||
docker run --rm -v $(pwd):/work -w /work alpine:3.21 sh -c "
|
||||
apk add alpine-sdk build-base alpine-conf syslinux xorriso \
|
||||
mtools dosfstools grub grub-efi squashfs-tools git sudo && \
|
||||
adduser -D build && \
|
||||
addgroup build abuild && \
|
||||
echo 'build ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers && \
|
||||
su build -c 'abuild-keygen -an' && \
|
||||
chmod +x build-iso.sh && \
|
||||
./build-iso.sh
|
||||
"
|
||||
```
|
||||
|
||||
### Phase 2: Write ISO to a temporary USB
|
||||
|
||||
```sh
|
||||
dd if=out/alpine-storagenode-*.iso of=/dev/sdX bs=4M status=progress
|
||||
sync
|
||||
```
|
||||
|
||||
### Phase 3: Boot and install to Samsung Fit
|
||||
|
||||
1. Plug BOTH the temp ISO USB and the Samsung Fit into rear USB ports
|
||||
2. Boot from the ISO USB
|
||||
3. Log in as `root` (no password)
|
||||
4. Run: `setup-alpine` (use defaults, skip disk install)
|
||||
5. Run: `chmod +x install-to-usb.sh && ./install-to-usb.sh`
|
||||
6. Remove the ISO USB, reboot
|
||||
|
||||
### Phase 4: Configure storage + encryption
|
||||
|
||||
1. Boot from Samsung Fit
|
||||
2. Log in as root (still has password access for initial setup)
|
||||
3. Have YubiKey #1 inserted
|
||||
4. Run: `chmod +x /root/storage-setup.sh && /root/storage-setup.sh`
|
||||
- This creates the LVM volume group, encrypts all data volumes, enrolls both YubiKeys
|
||||
- You'll be prompted for a backup passphrase — store this OFFLINE
|
||||
5. Add your SSH public keys:
|
||||
```sh
|
||||
nano /root/.ssh/authorized_keys
|
||||
# Paste both yubikey .pub lines
|
||||
```
|
||||
6. Edit the RustFS password:
|
||||
```sh
|
||||
nano /opt/rustfs/docker-compose.yml
|
||||
```
|
||||
7. Lock it down:
|
||||
```sh
|
||||
ro
|
||||
```
|
||||
|
||||
### Phase 5: Test the full boot cycle
|
||||
|
||||
1. Reboot the server
|
||||
2. From your workstation: `ssh -i ~/.ssh/id_yubikey1_storagenode root@<ip>`
|
||||
- Touch YubiKey when it blinks (FIDO2 auth)
|
||||
3. On the server: `yubikey-unlock`
|
||||
- Touch YubiKey again (challenge-response to decrypt LUKS)
|
||||
4. Start RustFS: `cd /opt/rustfs && docker compose up -d`
|
||||
|
||||
### Phase 6: Verify
|
||||
|
||||
```sh
|
||||
# LUKS status
|
||||
dmsetup ls
|
||||
cryptsetup status docker-crypt
|
||||
cryptsetup status rustfs-crypt
|
||||
|
||||
# LVM status
|
||||
pvs
|
||||
vgs
|
||||
lvs
|
||||
|
||||
# Check mounts
|
||||
mount | grep -E '(docker-crypt|cache-crypt|rustfs-crypt|tmpfs)'
|
||||
|
||||
# Docker
|
||||
docker ps
|
||||
docker logs rustfs
|
||||
|
||||
# Root is read-only
|
||||
touch /testfile # should fail: "Read-only file system"
|
||||
|
||||
# RustFS health
|
||||
curl http://localhost:9000/minio/health/live
|
||||
|
||||
# SMART status
|
||||
for d in /dev/sd?; do smartctl -H "$d"; done
|
||||
smartctl -H /dev/nvme0n1
|
||||
```
|
||||
|
||||
## Daily Operation
|
||||
|
||||
```
|
||||
Power on → server boots to locked state (SSH only)
|
||||
→ SSH in with YubiKey
|
||||
→ yubikey-unlock (decrypts data, starts Docker)
|
||||
→ RustFS serving
|
||||
|
||||
Power off / reboot → data re-encrypted automatically
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
```sh
|
||||
# System updates
|
||||
sys-update # handles rw/ro automatically
|
||||
|
||||
# LVM status
|
||||
pvs
|
||||
vgs
|
||||
lvs
|
||||
|
||||
# Add a new SATA drive to expand storage
|
||||
pvcreate /dev/sdX
|
||||
vgextend rustfs-vg /dev/sdX
|
||||
lvextend -l +100%FREE /dev/rustfs-vg/rustfs-lv
|
||||
# Unlock rustfs-crypt first, then grow the filesystem live:
|
||||
xfs_growfs /data/rustfs
|
||||
|
||||
# Replace a failed SATA drive
|
||||
# 1. Move data off the dying drive:
|
||||
pvmove /dev/sdX
|
||||
# 2. Remove from VG:
|
||||
vgreduce rustfs-vg /dev/sdX
|
||||
pvremove /dev/sdX
|
||||
# 3. Swap physical drive, then add the new one:
|
||||
pvcreate /dev/sdY
|
||||
vgextend rustfs-vg /dev/sdY
|
||||
|
||||
# Docker management (volumes must be unlocked)
|
||||
cd /opt/rustfs
|
||||
docker compose logs -f
|
||||
docker compose restart
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# Manually lock volumes (before maintenance/travel)
|
||||
yubikey-lock
|
||||
```
|
||||
|
||||
## Emergency Recovery
|
||||
|
||||
If both YubiKeys are lost/destroyed:
|
||||
```sh
|
||||
# Boot server, SSH will fail (no valid keys)
|
||||
# Connect keyboard + monitor directly
|
||||
# Log in as root (if password still set) or boot from ISO
|
||||
# Use backup passphrase to unlock:
|
||||
vgchange -ay rustfs-vg # activate LVM first
|
||||
cryptsetup open /dev/rustfs-vg/rustfs-lv rustfs-crypt # enter backup passphrase
|
||||
cryptsetup open /dev/nvme0n1p1 docker-crypt # enter backup passphrase
|
||||
cryptsetup open /dev/nvme0n1p2 cache-crypt # enter backup passphrase
|
||||
mount /data/rustfs
|
||||
mount /var/lib/docker
|
||||
mount /var/cache
|
||||
```
|
||||
|
||||
## Network (post-install)
|
||||
|
||||
```sh
|
||||
rw
|
||||
cat > /etc/network/interfaces << 'EOF'
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
auto eth0
|
||||
iface eth0 inet static
|
||||
address 192.168.1.X/24
|
||||
gateway 192.168.1.1
|
||||
EOF
|
||||
echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
ro
|
||||
reboot
|
||||
```
|
||||
251
hw/TUNNEL-SETUP.md
Normal file
251
hw/TUNNEL-SETUP.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Cloudflare Tunnel + Certs Setup for rig.fhirworx.io
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet Your Server (rig)
|
||||
┌──────────────────────────────┐
|
||||
Users ──→ Cloudflare Edge │ │
|
||||
(TLS termination) │ cloudflared ←──outbound──→ CF Edge
|
||||
*.rig.fhirworx.io │ │ │
|
||||
│ ├─→ rustfs:9000 (S3 API)│
|
||||
│ └─→ rustfs:9001 (Console)│
|
||||
│ │
|
||||
Cloudflare WARP │ warp-svc (DNS/Zero Trust) │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
All connections are OUTBOUND from your server. No inbound ports needed
|
||||
except SSH (22) for YubiKey management.
|
||||
|
||||
## Step 1: Cloudflare Dashboard — DNS
|
||||
|
||||
1. Log into https://dash.cloudflare.com
|
||||
2. Select **fhirworx.io** zone
|
||||
3. Go to **DNS → Records**
|
||||
4. You do NOT need to add A/AAAA records manually — the tunnel creates
|
||||
CNAME records automatically. But verify the zone exists and is active.
|
||||
|
||||
## Step 2: Create the Tunnel
|
||||
|
||||
1. Go to https://one.dash.cloudflare.com (Zero Trust dashboard)
|
||||
2. **Networks → Tunnels → Create a tunnel**
|
||||
3. Tunnel name: `rig`
|
||||
4. Choose **Cloudflared** connector
|
||||
5. You'll get a tunnel token — it looks like:
|
||||
```
|
||||
eyJhIjoiNGY4...very-long-base64-string
|
||||
```
|
||||
6. **Copy this token** — you'll need it in Step 4
|
||||
|
||||
## Step 3: Configure Tunnel Routes (Public Hostnames)
|
||||
|
||||
Still in the tunnel config, add these public hostnames:
|
||||
|
||||
| Public Hostname | Service | Notes |
|
||||
|----------------|---------|-------|
|
||||
| `s3.rig.fhirworx.io` | `http://rustfs:9000` | S3 API endpoint |
|
||||
| `console.rig.fhirworx.io` | `http://rustfs:9001` | Web console |
|
||||
| `rig.fhirworx.io` | `http://rustfs:9000` | Default/root domain → S3 |
|
||||
|
||||
For each route:
|
||||
- **Type**: HTTP (not HTTPS — cloudflared handles the tunnel encryption,
|
||||
the local connection to the container is plaintext over Docker network)
|
||||
- **TLS → Origin Server Name**: leave blank
|
||||
- **No TLS Verify**: Yes (local traffic, no cert needed)
|
||||
|
||||
### Optional: Add SSH access through tunnel
|
||||
|
||||
| Public Hostname | Service | Notes |
|
||||
|----------------|---------|-------|
|
||||
| `ssh.rig.fhirworx.io` | `ssh://localhost:22` | Browser SSH or cloudflared access |
|
||||
|
||||
For SSH through tunnel, on the **Access** tab:
|
||||
- Create an Access Application for `ssh.rig.fhirworx.io`
|
||||
- Add an Access Policy (e.g., email allowlist, one-time PIN)
|
||||
- This gives you browser-based SSH as a backup to direct SSH
|
||||
|
||||
## Step 4: Install the Token on Your Server
|
||||
|
||||
After `yubikey-unlock`, edit the env file:
|
||||
|
||||
```sh
|
||||
rw
|
||||
nano /opt/rustfs/.env
|
||||
```
|
||||
|
||||
Replace `PASTE_YOUR_TOKEN_HERE` with the actual token from Step 2:
|
||||
|
||||
```
|
||||
TUNNEL_TOKEN=eyJhIjoiNGY4...
|
||||
```
|
||||
|
||||
Save and:
|
||||
|
||||
```sh
|
||||
ro
|
||||
cd /opt/rustfs && docker compose up -d
|
||||
```
|
||||
|
||||
Verify the tunnel connects:
|
||||
```sh
|
||||
docker logs cloudflared
|
||||
# Should show: "Connection registered" and "Tunnel is connected"
|
||||
```
|
||||
|
||||
## Step 5: SSL/TLS Mode
|
||||
|
||||
1. In Cloudflare dashboard → **fhirworx.io** → **SSL/TLS → Overview**
|
||||
2. Set mode to: **Full**
|
||||
- NOT "Full (Strict)" — unless you set up an origin cert (see below)
|
||||
- NOT "Flexible" — that's insecure
|
||||
- With tunnels, "Full" is fine because the tunnel itself is encrypted
|
||||
|
||||
If you want "Full (Strict)" (belt AND suspenders), do Step 6.
|
||||
|
||||
## Step 6: Origin Certificate (Optional)
|
||||
|
||||
Only needed if:
|
||||
- You want Full (Strict) SSL mode, OR
|
||||
- You want direct HTTPS access on your LAN to rig.fhirworx.io
|
||||
|
||||
### Option A: Cloudflare Origin CA (simplest, 15-year validity)
|
||||
|
||||
1. Dashboard → **fhirworx.io** → **SSL/TLS → Origin Server**
|
||||
2. **Create Certificate**
|
||||
3. Settings:
|
||||
- Key type: **ECDSA**
|
||||
- Hostnames: `rig.fhirworx.io`, `*.rig.fhirworx.io`
|
||||
- Validity: **15 years**
|
||||
4. Copy the PEM certificate and private key
|
||||
|
||||
On the server:
|
||||
```sh
|
||||
rw
|
||||
|
||||
# Paste the certificate
|
||||
nano /opt/certs/origin.pem
|
||||
|
||||
# Paste the private key
|
||||
nano /opt/certs/origin-key.pem
|
||||
|
||||
chmod 644 /opt/certs/origin.pem
|
||||
chmod 600 /opt/certs/origin-key.pem
|
||||
|
||||
ro
|
||||
```
|
||||
|
||||
These certs are ONLY trusted by Cloudflare's edge — browsers connecting
|
||||
directly will see a cert warning. That's expected and correct for origin certs.
|
||||
|
||||
### Option B: Let's Encrypt (trusted everywhere, auto-renews)
|
||||
|
||||
Use this if you also want trusted HTTPS directly on your LAN.
|
||||
|
||||
1. Create a Cloudflare API token:
|
||||
- https://dash.cloudflare.com/profile/api-tokens
|
||||
- **Create Token → Edit zone DNS** template
|
||||
- Zone: `fhirworx.io`
|
||||
- Copy the token
|
||||
|
||||
2. On the server:
|
||||
```sh
|
||||
rw
|
||||
|
||||
# Save the API token
|
||||
cat > /opt/certs/cf-credentials.ini << EOF
|
||||
dns_cloudflare_api_token = YOUR_TOKEN_HERE
|
||||
EOF
|
||||
chmod 600 /opt/certs/cf-credentials.ini
|
||||
|
||||
# Request the cert
|
||||
apk add certbot-dns-cloudflare
|
||||
|
||||
certbot certonly \
|
||||
--dns-cloudflare \
|
||||
--dns-cloudflare-credentials /opt/certs/cf-credentials.ini \
|
||||
-d "rig.fhirworx.io" \
|
||||
-d "*.rig.fhirworx.io" \
|
||||
--preferred-challenges dns-01 \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
-m you@fhirworx.io
|
||||
|
||||
ro
|
||||
```
|
||||
|
||||
Certs land at:
|
||||
- `/etc/letsencrypt/live/rig.fhirworx.io/fullchain.pem`
|
||||
- `/etc/letsencrypt/live/rig.fhirworx.io/privkey.pem`
|
||||
|
||||
Auto-renewal cron (add after setup):
|
||||
```sh
|
||||
rw
|
||||
echo "0 3 * * * root mount -o remount,rw / && certbot renew --quiet && mount -o remount,ro /" >> /etc/crontabs/root
|
||||
ro
|
||||
```
|
||||
|
||||
## Step 7: Cloudflare WARP (Zero Trust DNS/VPN)
|
||||
|
||||
The WARP container gives you:
|
||||
- DNS-over-HTTPS for all container traffic
|
||||
- Option to route through Cloudflare's network
|
||||
- Zero Trust device posture (if configured)
|
||||
|
||||
First run enrollment:
|
||||
```sh
|
||||
docker exec -it warp-svc warp-cli registration new
|
||||
docker exec -it warp-svc warp-cli connect
|
||||
```
|
||||
|
||||
To use WARP as the default DNS for the host:
|
||||
```sh
|
||||
rw
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
ro
|
||||
```
|
||||
|
||||
## Step 8: Verify Everything
|
||||
|
||||
```sh
|
||||
# Tunnel status
|
||||
docker logs cloudflared
|
||||
|
||||
# Test S3 endpoint externally
|
||||
curl -I https://s3.rig.fhirworx.io
|
||||
# Should return 403 (no auth) or 200 with health check
|
||||
|
||||
# Test console
|
||||
curl -I https://console.rig.fhirworx.io
|
||||
# Should return 200 or 302 redirect to login
|
||||
|
||||
# Test from server locally
|
||||
curl http://127.0.0.1:9000/minio/health/live
|
||||
curl http://127.0.0.1:9001
|
||||
|
||||
# WARP status
|
||||
docker exec warp-svc warp-cli status
|
||||
```
|
||||
|
||||
## DNS Records (auto-created by tunnel)
|
||||
|
||||
After the tunnel connects, Cloudflare automatically creates:
|
||||
```
|
||||
s3.rig.fhirworx.io CNAME <tunnel-id>.cfargotunnel.com
|
||||
console.rig.fhirworx.io CNAME <tunnel-id>.cfargotunnel.com
|
||||
rig.fhirworx.io CNAME <tunnel-id>.cfargotunnel.com
|
||||
```
|
||||
|
||||
You don't need to create these manually.
|
||||
|
||||
## Summary: What Needs a Cert and What Doesn't
|
||||
|
||||
| Connection | Encrypted By | Cert Needed? |
|
||||
|-----------|-------------|-------------|
|
||||
| User → Cloudflare Edge | Cloudflare Universal SSL | No (automatic) |
|
||||
| Cloudflare Edge → cloudflared | Tunnel encryption (built-in) | No |
|
||||
| cloudflared → RustFS container | Docker internal network (localhost) | No |
|
||||
| Direct LAN → RustFS | Nothing (HTTP) or your own cert (HTTPS) | Only if you want LAN HTTPS |
|
||||
|
||||
**For your setup: you need ZERO certificates.** The tunnel handles everything.
|
||||
Origin certs are a nice-to-have for defense in depth or direct LAN access.
|
||||
1246
hw/build-iso.sh
Executable file
1246
hw/build-iso.sh
Executable file
File diff suppressed because it is too large
Load Diff
378
hw/build.sh
Executable file
378
hw/build.sh
Executable file
@@ -0,0 +1,378 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# build.sh — Build bootable Alpine USB from config.yaml
|
||||
#
|
||||
# Reads config.yaml, builds a custom Alpine ISO with SSH keys baked in,
|
||||
# and optionally flashes it to a USB drive.
|
||||
#
|
||||
# Usage:
|
||||
# ./build.sh # build ISO only
|
||||
# ./build.sh flash /dev/sdX # build + flash to USB
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONFIG="${SCRIPT_DIR}/config.yaml"
|
||||
WORKDIR="${SCRIPT_DIR}/build"
|
||||
OUTDIR="${SCRIPT_DIR}/out"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
err() { echo -e "${RED}[!]${NC} $*"; exit 1; }
|
||||
|
||||
[ -f "$CONFIG" ] || err "config.yaml not found at ${CONFIG}"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Parse config.yaml with Python (pyyaml)
|
||||
# -------------------------------------------------------------------
|
||||
parse_yaml() {
|
||||
python3 - "$CONFIG" "$@" << 'PYEOF'
|
||||
import sys, yaml
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
|
||||
query = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
def resolve(obj, path):
|
||||
for key in path.split('.'):
|
||||
if obj is None:
|
||||
return ''
|
||||
if isinstance(obj, list):
|
||||
try:
|
||||
obj = obj[int(key)]
|
||||
except (ValueError, IndexError):
|
||||
return ''
|
||||
elif isinstance(obj, dict):
|
||||
obj = obj.get(key)
|
||||
else:
|
||||
return ''
|
||||
if isinstance(obj, list):
|
||||
print('\n'.join(str(x) for x in obj))
|
||||
elif isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, list):
|
||||
print('\n'.join(str(x) for x in v))
|
||||
else:
|
||||
print(v)
|
||||
elif obj is None:
|
||||
pass
|
||||
else:
|
||||
print(obj)
|
||||
|
||||
if query:
|
||||
resolve(cfg, query)
|
||||
else:
|
||||
yaml.dump(cfg, sys.stdout, default_flow_style=False)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# Read config values
|
||||
ALPINE_VERSION=$(parse_yaml alpine.version)
|
||||
ARCH=$(parse_yaml alpine.arch)
|
||||
MIRROR=$(parse_yaml alpine.mirror)
|
||||
HOSTNAME=$(parse_yaml host.name)
|
||||
TIMEZONE=$(parse_yaml host.timezone)
|
||||
KEYMAP=$(parse_yaml host.keymap)
|
||||
NET_MODE=$(parse_yaml network.mode)
|
||||
NET_IFACE=$(parse_yaml network.interface)
|
||||
SSH_PORT=$(parse_yaml ssh.port)
|
||||
|
||||
MAIN_REPO="${MIRROR}/v${ALPINE_VERSION}/main"
|
||||
COMMUNITY_REPO="${MIRROR}/v${ALPINE_VERSION}/community"
|
||||
|
||||
log "Building Alpine ${ALPINE_VERSION} (${ARCH}) ISO for '${HOSTNAME}'"
|
||||
log "Network: ${NET_MODE} on ${NET_IFACE}"
|
||||
log "SSH port: ${SSH_PORT}"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Collect all packages from config
|
||||
# -------------------------------------------------------------------
|
||||
ALL_PACKAGES=$(parse_yaml packages)
|
||||
PACKAGE_LIST=$(echo "$ALL_PACKAGES" | sort -u | tr '\n' ' ')
|
||||
log "Packages: $(echo "$ALL_PACKAGES" | wc -l) total"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Collect SSH authorized keys
|
||||
# -------------------------------------------------------------------
|
||||
AUTH_KEYS=$(parse_yaml ssh.authorized_keys)
|
||||
KEY_COUNT=$(echo "$AUTH_KEYS" | grep -c "^ssh-\|^ecdsa-\|^sk-" || echo 0)
|
||||
[ "$KEY_COUNT" -ge 1 ] || err "No SSH keys found in config.yaml ssh.authorized_keys"
|
||||
log "SSH keys: ${KEY_COUNT}"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Clean + setup
|
||||
# -------------------------------------------------------------------
|
||||
rm -rf "${WORKDIR}"
|
||||
mkdir -p "${WORKDIR}" "${OUTDIR}"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Clone aports
|
||||
# -------------------------------------------------------------------
|
||||
if [ ! -d "${WORKDIR}/aports" ]; then
|
||||
log "Cloning aports build infrastructure..."
|
||||
git clone --depth 1 --branch "v${ALPINE_VERSION}" \
|
||||
https://gitlab.alpinelinux.org/alpine/aports.git \
|
||||
"${WORKDIR}/aports"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Write custom ISO profile
|
||||
# -------------------------------------------------------------------
|
||||
log "Writing ISO profile..."
|
||||
|
||||
cat > "${WORKDIR}/aports/scripts/mkimg.storagenode.sh" << PROFILE
|
||||
profile_storagenode() {
|
||||
title="Alpine Storage Node"
|
||||
desc="Headless server — SSH ready"
|
||||
profile_standard
|
||||
arch="${ARCH}"
|
||||
output_format="iso"
|
||||
image_ext="iso"
|
||||
kernel_flavors="lts"
|
||||
apks="\$apks
|
||||
${PACKAGE_LIST}
|
||||
"
|
||||
}
|
||||
PROFILE
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Build the overlay (auto-configures on first boot)
|
||||
# -------------------------------------------------------------------
|
||||
log "Building boot overlay..."
|
||||
OVERLAY="${WORKDIR}/aports/scripts/storagenode-overlay"
|
||||
mkdir -p "${OVERLAY}/etc/ssh"
|
||||
mkdir -p "${OVERLAY}/etc/network"
|
||||
mkdir -p "${OVERLAY}/etc/local.d"
|
||||
mkdir -p "${OVERLAY}/root/.ssh"
|
||||
|
||||
# --- Network ---
|
||||
if [ "$NET_MODE" = "dhcp" ]; then
|
||||
cat > "${OVERLAY}/etc/network/interfaces" << NETCFG
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
auto ${NET_IFACE}
|
||||
iface ${NET_IFACE} inet dhcp
|
||||
NETCFG
|
||||
else
|
||||
NET_ADDR=$(parse_yaml network.address)
|
||||
NET_GW=$(parse_yaml network.gateway)
|
||||
cat > "${OVERLAY}/etc/network/interfaces" << NETCFG
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
auto ${NET_IFACE}
|
||||
iface ${NET_IFACE} inet static
|
||||
address ${NET_ADDR}
|
||||
gateway ${NET_GW}
|
||||
NETCFG
|
||||
fi
|
||||
|
||||
DNS_SERVERS=$(parse_yaml network.dns)
|
||||
echo "$DNS_SERVERS" | while read -r ns; do
|
||||
[ -n "$ns" ] && echo "nameserver ${ns}"
|
||||
done > "${OVERLAY}/etc/resolv.conf"
|
||||
|
||||
# --- SSH authorized keys ---
|
||||
echo "$AUTH_KEYS" > "${OVERLAY}/root/.ssh/authorized_keys"
|
||||
chmod 700 "${OVERLAY}/root/.ssh"
|
||||
chmod 600 "${OVERLAY}/root/.ssh/authorized_keys"
|
||||
|
||||
# --- SSH server config ---
|
||||
PERMIT_ROOT=$(parse_yaml ssh.permit_root)
|
||||
PASS_AUTH=$(parse_yaml ssh.password_auth)
|
||||
|
||||
if [ "$PERMIT_ROOT" = "True" ] || [ "$PERMIT_ROOT" = "true" ]; then
|
||||
ROOT_LOGIN="prohibit-password"
|
||||
else
|
||||
ROOT_LOGIN="no"
|
||||
fi
|
||||
|
||||
if [ "$PASS_AUTH" = "True" ] || [ "$PASS_AUTH" = "true" ]; then
|
||||
PASS_CFG="yes"
|
||||
else
|
||||
PASS_CFG="no"
|
||||
fi
|
||||
|
||||
cat > "${OVERLAY}/etc/ssh/sshd_config" << SSHD
|
||||
Port ${SSH_PORT}
|
||||
ListenAddress 0.0.0.0
|
||||
Protocol 2
|
||||
|
||||
HostKey /etc/ssh/ssh_host_ed25519_key
|
||||
HostKey /etc/ssh/ssh_host_rsa_key
|
||||
|
||||
PubkeyAuthentication yes
|
||||
PubkeyAcceptedKeyTypes sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519,ssh-rsa
|
||||
PasswordAuthentication ${PASS_CFG}
|
||||
PermitRootLogin ${ROOT_LOGIN}
|
||||
PermitEmptyPasswords no
|
||||
|
||||
ChallengeResponseAuthentication no
|
||||
UsePAM no
|
||||
|
||||
X11Forwarding no
|
||||
PrintMotd yes
|
||||
ClientAliveInterval 60
|
||||
ClientAliveCountMax 3
|
||||
MaxAuthTries 6
|
||||
SSHD
|
||||
|
||||
# --- Auto-setup script (runs on first boot via local.d) ---
|
||||
cat > "${OVERLAY}/etc/local.d/01-setup.start" << 'BOOT'
|
||||
#!/bin/sh
|
||||
#
|
||||
# First-boot auto-setup: networking + SSH
|
||||
# Runs via local.d on every boot (idempotent)
|
||||
#
|
||||
|
||||
# Generate host keys if missing
|
||||
[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
|
||||
[ -f /etc/ssh/ssh_host_rsa_key ] || ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ""
|
||||
|
||||
# Enable and start services
|
||||
rc-update add sshd default 2>/dev/null || true
|
||||
rc-update add networking boot 2>/dev/null || true
|
||||
rc-update add chronyd default 2>/dev/null || true
|
||||
rc-update add local default 2>/dev/null || true
|
||||
|
||||
# Bring up networking if not already
|
||||
rc-service networking start 2>/dev/null || true
|
||||
rc-service sshd start 2>/dev/null || true
|
||||
rc-service chronyd start 2>/dev/null || true
|
||||
|
||||
# Log IP for console viewers
|
||||
echo ""
|
||||
echo "=== SSH READY ==="
|
||||
ip -4 addr show dev eth0 2>/dev/null | grep inet | awk '{print " ssh root@" $2}' | sed 's|/.*||'
|
||||
echo "================="
|
||||
BOOT
|
||||
chmod +x "${OVERLAY}/etc/local.d/01-setup.start"
|
||||
|
||||
# --- Hostname ---
|
||||
echo "${HOSTNAME}" > "${OVERLAY}/etc/hostname"
|
||||
|
||||
# --- Timezone ---
|
||||
mkdir -p "${OVERLAY}/etc/zoneinfo"
|
||||
echo "${TIMEZONE}" > "${OVERLAY}/etc/timezone"
|
||||
|
||||
# --- Auto-setup answer file (for setup-alpine if needed) ---
|
||||
cat > "${OVERLAY}/auto-setup.conf" << ANSWERS
|
||||
KEYMAPOPTS="${KEYMAP} ${KEYMAP}"
|
||||
HOSTNAMEOPTS="-n ${HOSTNAME}"
|
||||
INTERFACESOPTS="auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
auto ${NET_IFACE}
|
||||
iface ${NET_IFACE} inet ${NET_MODE}
|
||||
"
|
||||
DNSOPTS="-n $(echo "$DNS_SERVERS" | head -2 | tr '\n' ' ')"
|
||||
TIMEZONEOPTS="-z ${TIMEZONE}"
|
||||
PROXYOPTS="none"
|
||||
SSHDOPTS="-c openssh"
|
||||
NTPOPTS="-c chrony"
|
||||
DISKOPTS="none"
|
||||
LBUOPTS="none"
|
||||
APKCACHEOPTS="none"
|
||||
ANSWERS
|
||||
|
||||
# --- MOTD ---
|
||||
cat > "${OVERLAY}/etc/motd" << 'MOTD'
|
||||
|
||||
storagenode — Alpine Live USB
|
||||
SSH is enabled. Run 'setup-alpine' for full install.
|
||||
|
||||
MOTD
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Inject overlay into the ISO profile
|
||||
# -------------------------------------------------------------------
|
||||
log "Injecting overlay into ISO profile..."
|
||||
|
||||
# Create the apkovl tarball that Alpine live boots will auto-extract
|
||||
cd "${OVERLAY}"
|
||||
tar czf "${WORKDIR}/aports/scripts/${HOSTNAME}.apkovl.tar.gz" \
|
||||
--owner=root --group=root \
|
||||
etc/ root/
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
# Patch the profile to include the apkovl
|
||||
cat >> "${WORKDIR}/aports/scripts/mkimg.storagenode.sh" << APKOVL
|
||||
|
||||
profile_storagenode_apkovl() {
|
||||
arch="${ARCH}"
|
||||
apkovl="${HOSTNAME}.apkovl.tar.gz"
|
||||
}
|
||||
APKOVL
|
||||
|
||||
# Also make the profile reference the apkovl
|
||||
sed -i 's|profile_standard|profile_standard\n apkovl="../${HOSTNAME}.apkovl.tar.gz"|' \
|
||||
"${WORKDIR}/aports/scripts/mkimg.storagenode.sh"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Build the ISO
|
||||
# -------------------------------------------------------------------
|
||||
log "Building ISO (this takes a few minutes)..."
|
||||
cd "${WORKDIR}/aports/scripts"
|
||||
|
||||
sh mkimage.sh \
|
||||
--tag storagenode \
|
||||
--outdir "${OUTDIR}" \
|
||||
--arch "${ARCH}" \
|
||||
--repository "${MAIN_REPO}" \
|
||||
--repository "${COMMUNITY_REPO}" \
|
||||
--profile storagenode
|
||||
|
||||
ISO_FILE=$(ls "${OUTDIR}"/alpine-storagenode-*.iso 2>/dev/null | head -1)
|
||||
[ -f "$ISO_FILE" ] || err "ISO build failed — no output file"
|
||||
|
||||
log "ISO built: ${ISO_FILE}"
|
||||
ls -lh "$ISO_FILE"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Flash to USB if requested
|
||||
# -------------------------------------------------------------------
|
||||
if [ "${1:-}" = "flash" ]; then
|
||||
USB_TARGET="${2:-}"
|
||||
[ -n "$USB_TARGET" ] || err "Usage: $0 flash /dev/sdX"
|
||||
[ -b "$USB_TARGET" ] || err "${USB_TARGET} is not a block device"
|
||||
|
||||
# Safety check: is it USB?
|
||||
TRAN=$(lsblk -d -n -o TRAN "$USB_TARGET" 2>/dev/null || true)
|
||||
if [ "$TRAN" != "usb" ]; then
|
||||
warn "Device ${USB_TARGET} transport is '${TRAN}', not 'usb'"
|
||||
echo -n "Are you SURE this is the target USB drive? [y/N] "
|
||||
read -r confirm
|
||||
[ "$confirm" = "y" ] || exit 1
|
||||
fi
|
||||
|
||||
SIZE=$(lsblk -d -n -o SIZE "$USB_TARGET")
|
||||
echo ""
|
||||
echo "THIS WILL ERASE ${USB_TARGET} (${SIZE}) COMPLETELY."
|
||||
echo -n "Continue? [y/N] "
|
||||
read -r confirm
|
||||
[ "$confirm" = "y" ] || exit 1
|
||||
|
||||
log "Flashing to ${USB_TARGET}..."
|
||||
dd if="$ISO_FILE" of="$USB_TARGET" bs=4M status=progress conv=fsync
|
||||
sync
|
||||
|
||||
log "Flash complete. Remove USB and boot from it."
|
||||
log "SSH will be available immediately after boot on ${NET_IFACE} (${NET_MODE})."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "=== DONE ==="
|
||||
echo ""
|
||||
echo "To flash manually:"
|
||||
echo " dd if=${ISO_FILE} of=/dev/sdX bs=4M status=progress && sync"
|
||||
echo ""
|
||||
echo "After boot, SSH in with:"
|
||||
echo " ssh root@<ip>"
|
||||
echo ""
|
||||
122
hw/config.yaml
Normal file
122
hw/config.yaml
Normal file
@@ -0,0 +1,122 @@
|
||||
# Storage Node ISO Configuration
|
||||
# Single source of truth for building the bootable Alpine USB
|
||||
|
||||
alpine:
|
||||
version: "3.21"
|
||||
arch: x86_64
|
||||
mirror: https://dl-cdn.alpinelinux.org/alpine
|
||||
|
||||
host:
|
||||
name: storagenode
|
||||
timezone: UTC
|
||||
keymap: us
|
||||
|
||||
network:
|
||||
# DHCP for initial boot / diagnostics; switch to static after setup
|
||||
mode: dhcp
|
||||
interface: eth0
|
||||
# Uncomment for static:
|
||||
# mode: static
|
||||
# address: 192.168.1.100/24
|
||||
# gateway: 192.168.1.1
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 1.0.0.1
|
||||
|
||||
ssh:
|
||||
port: 22
|
||||
permit_root: true
|
||||
password_auth: false
|
||||
authorized_keys:
|
||||
# YubiKey 1 (ECDSA-SK FIDO2)
|
||||
- sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBK8W6v3/ZzVu7jfZvNW+fbs7tHB2YbZVNrhI0qtzX40D1QFwAGi1BD1quiz4tNgTWJYN76Sj/kn4HhGw1K/hrbAAAAAEc3NoOg==
|
||||
# YubiKey 2 (Ed25519-SK FIDO2)
|
||||
- sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIA3YeQ1/H4U+VaYpI+1BH3xOvwCrF/swxHcXDNyynJP+AAAABHNzaDo= yubikey-2-storagenode
|
||||
# Fallback keys (non-FIDO2)
|
||||
- ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCdhI5OCH50rWQvL+XODTPLjaYgpYuZi7UqFz7vsXjLacT43rsVKQxttRs/z/LPqdIn+rD1y0UJ7yYlEA/9gOPtSsDxSVuNfkpoCoiFV8gVyMWCjC/AKKIH2WgfhtyOCaxA9M0SXy4DnI+vmSqekEzXE4M9L3VjW8Gf5p73g9u4Ed86QJJiacBi/W0hyD7mnxqoCAOUnpAlSsY/sQQe2vaaPWLq8V2bEa9nLw2jW6t0krXpkfR2+p8PgCxOZqiM6fIZrjK+2j9hNhgb1JLumqRoSG0VNE+MrBRzj4nhpRXaq1O9cAKOQqoH27NAj4PiuAvlf/Erilt2cmLFdUhXrTNKchGjZeOC1MYanN7eXq5rimlZDnXeAUdK1XQx1AxxDlyyjy35vzCndWjWa9YeSka6Ugb8haxL0LgisUdGW/KaViJJI0Xx2qTKKlbxH+vULUEmJHhdBoyhbeYS3XyU9Wn9sq6JeT8rbb09WIJwAqa2lO8Pv7Ag8im79lAmg69guhc= kert@notebook
|
||||
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOhs9dzoN/4zNOzpVng2dAPVk7RDF4RmCbmZYA12pzaz kert@homelab
|
||||
|
||||
drives:
|
||||
# Enrolled one at a time via USB adapter. Serial numbers used to resolve
|
||||
# /dev paths at runtime regardless of plug order.
|
||||
sata:
|
||||
- serial: "21044J801864"
|
||||
model: "WDC WDS200T2B0B-00YS70"
|
||||
size: "2TB"
|
||||
- serial: "21030X801599"
|
||||
model: "WDC WDS200T2B0B-00YS70"
|
||||
size: "2TB"
|
||||
- serial: "21030X801577"
|
||||
model: "WDC WDS200T2B0B-00YS70"
|
||||
size: "2TB"
|
||||
- serial: "21044J801753"
|
||||
model: "WDC WDS200T2B0B-00YS70"
|
||||
size: "2TB"
|
||||
- serial: "21044J800938"
|
||||
model: "WDC WDS200T2B0B-00YS70"
|
||||
size: "2TB"
|
||||
- serial: "21044J800830"
|
||||
model: "WDC WDS200T2B0B-00YS70"
|
||||
size: "2TB"
|
||||
nvme:
|
||||
- serial: "19081510241793"
|
||||
model: "PCIe SSD"
|
||||
size: "1TB"
|
||||
hdd:
|
||||
- serial: "69HEN2NNSW47"
|
||||
model: "TOSHIBA DT01ABA100V"
|
||||
revision: "ASA AB10"
|
||||
size: "1TB"
|
||||
|
||||
packages:
|
||||
base:
|
||||
- openssh
|
||||
- chrony
|
||||
- htop
|
||||
- nano
|
||||
- curl
|
||||
- bash
|
||||
- lm-sensors
|
||||
- smartmontools
|
||||
- pciutils
|
||||
- usbutils
|
||||
- ethtool
|
||||
- util-linux
|
||||
- lsblk
|
||||
storage:
|
||||
- e2fsprogs
|
||||
- xfsprogs
|
||||
- dosfstools
|
||||
- sgdisk
|
||||
- sfdisk
|
||||
- parted
|
||||
- cryptsetup
|
||||
- lvm2
|
||||
- nvme-cli
|
||||
- fio
|
||||
- bonnie++
|
||||
docker:
|
||||
- docker
|
||||
- docker-cli
|
||||
- docker-compose
|
||||
network:
|
||||
- wireguard-tools
|
||||
- nftables
|
||||
- cloudflared
|
||||
- certbot
|
||||
- openssl
|
||||
hardware:
|
||||
- amd-ucode
|
||||
- cpufrequtils
|
||||
- irqbalance
|
||||
- haveged
|
||||
yubikey:
|
||||
- yubikey-manager
|
||||
- yubico-pam
|
||||
- libfido2
|
||||
- openssh-server-common-openrc
|
||||
tools:
|
||||
- wget
|
||||
- rsync
|
||||
- logrotate
|
||||
- iotop
|
||||
BIN
hw/docs/asus-hyper-m2-x16-gen4-manual.pdf
Normal file
BIN
hw/docs/asus-hyper-m2-x16-gen4-manual.pdf
Normal file
Binary file not shown.
BIN
hw/docs/rog-crosshair-viii-hero-manual.pdf
Normal file
BIN
hw/docs/rog-crosshair-viii-hero-manual.pdf
Normal file
Binary file not shown.
BIN
hw/docs/wd-blue-3dnand-sata-ssd-datasheet.pdf
Normal file
BIN
hw/docs/wd-blue-3dnand-sata-ssd-datasheet.pdf
Normal file
Binary file not shown.
188
hw/install-to-usb.sh
Executable file
188
hw/install-to-usb.sh
Executable file
@@ -0,0 +1,188 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# install-to-usb.sh — Install Alpine to Samsung Fit 128GB USB drive
|
||||
# Run this AFTER booting from the custom ISO and running setup-alpine
|
||||
#
|
||||
# This script partitions the USB drive and installs Alpine in sys mode
|
||||
# with a layout optimized for read-only operation.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
err() { echo -e "${RED}[!]${NC} $*"; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || err "Must run as root"
|
||||
|
||||
# Cleanup mounts on any failure
|
||||
cleanup() {
|
||||
echo -e "${YELLOW}[!]${NC} Cleaning up mounts..."
|
||||
umount "${MOUNTPOINT}/boot/efi" 2>/dev/null || true
|
||||
umount "${MOUNTPOINT}" 2>/dev/null || true
|
||||
}
|
||||
MOUNTPOINT="/mnt/usb-root"
|
||||
trap cleanup EXIT
|
||||
|
||||
# Detect Samsung Fit USB drive
|
||||
echo "Available block devices:"
|
||||
echo ""
|
||||
lsblk -d -o NAME,SIZE,MODEL,TRAN | grep -v "^loop"
|
||||
echo ""
|
||||
|
||||
echo -n "Enter the USB drive device (e.g., sda): "
|
||||
read -r USB_DEV
|
||||
USB_DRIVE="/dev/${USB_DEV}"
|
||||
|
||||
[ -b "$USB_DRIVE" ] || err "${USB_DRIVE} is not a valid block device"
|
||||
|
||||
# Verify it's USB
|
||||
TRAN=$(lsblk -d -n -o TRAN "$USB_DRIVE" 2>/dev/null || true)
|
||||
if [ "$TRAN" != "usb" ]; then
|
||||
warn "Device ${USB_DRIVE} transport is '${TRAN}', not 'usb'"
|
||||
echo -n "Are you SURE this is the USB drive? [y/N] "
|
||||
read -r confirm
|
||||
[ "$confirm" = "y" ] || exit 1
|
||||
fi
|
||||
|
||||
SIZE=$(lsblk -d -n -o SIZE "$USB_DRIVE")
|
||||
log "Selected: ${USB_DRIVE} (${SIZE})"
|
||||
echo ""
|
||||
echo "THIS WILL ERASE ${USB_DRIVE} COMPLETELY."
|
||||
echo -n "Continue? [y/N] "
|
||||
read -r confirm
|
||||
[ "$confirm" = "y" ] || exit 1
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Partition the USB drive
|
||||
# -------------------------------------------------------------------
|
||||
log "Partitioning ${USB_DRIVE}..."
|
||||
|
||||
# GPT partition table
|
||||
sgdisk --zap-all "$USB_DRIVE"
|
||||
sgdisk -n 1:0:+512M -t 1:EF00 -c 1:"EFI" "$USB_DRIVE"
|
||||
sgdisk -n 2:0:+20G -t 2:8300 -c 2:"root" "$USB_DRIVE"
|
||||
# Remaining space unused — 20GB is plenty for a read-only root
|
||||
partprobe "$USB_DRIVE" 2>/dev/null || blockdev --rereadpt "$USB_DRIVE" || err "Failed to re-read partition table"
|
||||
sleep 2
|
||||
|
||||
# Verify partition nodes appeared
|
||||
wait_count=0
|
||||
while [ $wait_count -lt 5 ]; do
|
||||
if [ -b "${USB_DRIVE}1" ] || [ -b "${USB_DRIVE}p1" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# Detect partition naming (sdX1 vs sdXp1)
|
||||
if [ -b "${USB_DRIVE}1" ]; then
|
||||
PART_EFI="${USB_DRIVE}1"
|
||||
PART_ROOT="${USB_DRIVE}2"
|
||||
elif [ -b "${USB_DRIVE}p1" ]; then
|
||||
PART_EFI="${USB_DRIVE}p1"
|
||||
PART_ROOT="${USB_DRIVE}p2"
|
||||
else
|
||||
err "Cannot find partitions on ${USB_DRIVE}"
|
||||
fi
|
||||
|
||||
log "Formatting..."
|
||||
mkfs.vfat -F 32 -n EFI "$PART_EFI"
|
||||
mkfs.ext4 -L root -O ^has_journal "$PART_ROOT"
|
||||
# No journal on USB flash — reduces write amplification significantly
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Install Alpine sys mode
|
||||
# -------------------------------------------------------------------
|
||||
log "Installing Alpine to USB..."
|
||||
|
||||
mkdir -p "${MOUNTPOINT}"
|
||||
mount "$PART_ROOT" "${MOUNTPOINT}"
|
||||
mkdir -p "${MOUNTPOINT}/boot/efi"
|
||||
mount "$PART_EFI" "${MOUNTPOINT}/boot/efi"
|
||||
|
||||
# Use setup-disk to do the heavy lifting
|
||||
BOOTLOADER=grub setup-disk -o "${MOUNTPOINT}" -k lts || err "setup-disk failed — check that you are running from the Alpine installer environment"
|
||||
|
||||
# Verify GRUB installed correctly; ensure fallback EFI path exists
|
||||
if [ ! -f "${MOUNTPOINT}/boot/efi/EFI/BOOT/BOOTX64.EFI" ]; then
|
||||
log "Creating fallback EFI boot path..."
|
||||
mkdir -p "${MOUNTPOINT}/boot/efi/EFI/BOOT"
|
||||
if [ -f "${MOUNTPOINT}/boot/efi/EFI/alpine/grubx64.efi" ]; then
|
||||
cp "${MOUNTPOINT}/boot/efi/EFI/alpine/grubx64.efi" "${MOUNTPOINT}/boot/efi/EFI/BOOT/BOOTX64.EFI"
|
||||
else
|
||||
# Find any grub efi binary that was installed
|
||||
grub_efi=$(find "${MOUNTPOINT}/boot/efi/EFI" -name "grubx64.efi" -print -quit 2>/dev/null)
|
||||
if [ -n "$grub_efi" ]; then
|
||||
cp "$grub_efi" "${MOUNTPOINT}/boot/efi/EFI/BOOT/BOOTX64.EFI"
|
||||
else
|
||||
warn "No grubx64.efi found — USB may not boot on all UEFI systems"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Post-install tweaks on the new root
|
||||
# -------------------------------------------------------------------
|
||||
log "Applying read-only root configuration..."
|
||||
|
||||
# fstab will be configured by storage-setup.sh after first boot
|
||||
# For now, set root to ro
|
||||
if grep -q 'errors=remount-ro' "${MOUNTPOINT}/etc/fstab"; then
|
||||
sed -i 's|errors=remount-ro|ro,noatime,discard,errors=remount-ro|' "${MOUNTPOINT}/etc/fstab"
|
||||
else
|
||||
# Fallback: find the root entry and add ro directly
|
||||
if grep -q "$PART_ROOT" "${MOUNTPOINT}/etc/fstab"; then
|
||||
sed -i "s|\(${PART_ROOT}.*\)defaults|\1ro,noatime,discard|" "${MOUNTPOINT}/etc/fstab"
|
||||
else
|
||||
err "Could not find root partition in fstab — root will not be read-only. Fix /etc/fstab manually."
|
||||
fi
|
||||
fi
|
||||
# Verify ro is actually in the root mount options
|
||||
grep -q '\sro[,\s]' "${MOUNTPOINT}/etc/fstab" || err "Failed to set root filesystem to read-only in fstab"
|
||||
|
||||
# Copy the storage setup script
|
||||
if [ -f /root/storage-setup.sh ]; then
|
||||
cp /root/storage-setup.sh "${MOUNTPOINT}/root/storage-setup.sh"
|
||||
chmod +x "${MOUNTPOINT}/root/storage-setup.sh"
|
||||
else
|
||||
err "storage-setup.sh not found at /root/storage-setup.sh — persistent storage will not be configured. Place the file and re-run."
|
||||
fi
|
||||
|
||||
# Enable tmpfs for volatile dirs in the installed system
|
||||
cat >> "${MOUNTPOINT}/etc/fstab" << 'TMPFS'
|
||||
|
||||
# tmpfs mounts — keep USB drive read-only
|
||||
tmpfs /tmp tmpfs nosuid,nodev,size=8G 0 0
|
||||
tmpfs /run tmpfs nosuid,nodev,mode=0755,size=2G 0 0
|
||||
tmpfs /var/log tmpfs nosuid,nodev,noexec,size=2G 0 0
|
||||
tmpfs /var/tmp tmpfs nosuid,nodev,size=2G 0 0
|
||||
TMPFS
|
||||
|
||||
# GRUB: add console for headless serial access
|
||||
if [ -f "${MOUNTPOINT}/etc/default/grub" ]; then
|
||||
sed -i 's|GRUB_CMDLINE_LINUX_DEFAULT=".*"|GRUB_CMDLINE_LINUX_DEFAULT="modules=sd-mod,usb-storage,ext4 quiet rootfstype=ext4 console=tty0 console=ttyS0,115200n8"|' \
|
||||
"${MOUNTPOINT}/etc/default/grub"
|
||||
# Rebuild grub config
|
||||
chroot "${MOUNTPOINT}" grub-mkconfig -o /boot/grub/grub.cfg 2>/dev/null || \
|
||||
warn "grub-mkconfig failed — may need to run manually after boot"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Cleanup (trap handles umount on failure; do it explicitly on success)
|
||||
# -------------------------------------------------------------------
|
||||
log "Unmounting..."
|
||||
umount "${MOUNTPOINT}/boot/efi"
|
||||
umount "${MOUNTPOINT}"
|
||||
trap - EXIT
|
||||
|
||||
log ""
|
||||
log "=== USB INSTALL COMPLETE ==="
|
||||
log ""
|
||||
log "Remove the ISO boot media, set BIOS to boot from USB, and power on."
|
||||
log "After first boot, run: /root/storage-setup.sh"
|
||||
936
hw/nanny.sh
Executable file
936
hw/nanny.sh
Executable file
@@ -0,0 +1,936 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# nanny.sh — Full hardware enrollment for storage node build
|
||||
#
|
||||
# Tracks state in nanny.state so it can resume after interruption.
|
||||
# Plug in drives/keys one at a time. Walks through Cloudflare setup.
|
||||
#
|
||||
# Outputs:
|
||||
# drives.conf — 8 drive fingerprints
|
||||
# drive-resolver.sh — Serial-to-device resolver for runtime
|
||||
# yubikeys.conf — 2 YubiKey fingerprints
|
||||
# ssh-keys/ — FIDO2 SSH key pairs
|
||||
# authorized_keys — Ready for server
|
||||
# cloudflare.conf — Tunnel token, certs, credentials
|
||||
# nanny.state — Checkpoint state (for resume)
|
||||
# nanny.log — Full session log
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
WORK="$(pwd)"
|
||||
CONF="${WORK}/drives.conf"
|
||||
YKCONF="${WORK}/yubikeys.conf"
|
||||
CFCONF="${WORK}/cloudflare.conf"
|
||||
STATE="${WORK}/nanny.state"
|
||||
LOG="${WORK}/nanny.log"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
_log() { echo -e "$1" | tee -a "$LOG"; }
|
||||
log() { _log "${GREEN}[+]${NC} $*"; }
|
||||
warn() { _log "${YELLOW}[!]${NC} $*"; }
|
||||
err() { _log "${RED}[!]${NC} $*"; exit 1; }
|
||||
info() { _log "${CYAN}[i]${NC} $*"; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || err "Must run as root (sudo ./nanny.sh)"
|
||||
|
||||
# ===================================================================
|
||||
# State management
|
||||
# ===================================================================
|
||||
touch "$STATE" "$LOG"
|
||||
|
||||
state_done() {
|
||||
grep -qx "$1" "$STATE" 2>/dev/null
|
||||
}
|
||||
|
||||
state_mark() {
|
||||
if ! state_done "$1"; then
|
||||
echo "$1" >> "$STATE"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] STATE: $1 completed" >> "$LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
state_show() {
|
||||
echo ""
|
||||
echo "─── Current State ───"
|
||||
local steps=(
|
||||
"phase1_init:Phase 1 init"
|
||||
"drive_sata_1:SATA drive #1"
|
||||
"drive_sata_2:SATA drive #2"
|
||||
"drive_sata_3:SATA drive #3"
|
||||
"drive_sata_4:SATA drive #4"
|
||||
"drive_sata_5:SATA drive #5"
|
||||
"drive_sata_6:SATA drive #6"
|
||||
"drive_nvme:NVMe Docker drive"
|
||||
"drive_hdd:HDD backup drive"
|
||||
"phase1_resolver:Drive resolver generated"
|
||||
"yubikey_1:YubiKey #1 (primary)"
|
||||
"yubikey_2:YubiKey #2 (backup)"
|
||||
"phase2_authkeys:authorized_keys generated"
|
||||
"cf_account:Cloudflare account verified"
|
||||
"cf_tunnel:Tunnel created"
|
||||
"cf_routes:Public hostname routes"
|
||||
"cf_access:Access policies (YubiKey gate)"
|
||||
"cf_ssl:SSL/TLS mode"
|
||||
"cf_api_token:API token"
|
||||
"cf_cert:Origin certificate"
|
||||
"cf_warp:WARP config"
|
||||
"cf_rustfs_creds:RustFS credentials"
|
||||
"cf_network:Network config"
|
||||
)
|
||||
for entry in "${steps[@]}"; do
|
||||
key="${entry%%:*}"
|
||||
label="${entry#*:}"
|
||||
if state_done "$key"; then
|
||||
echo -e " ${GREEN}[done]${NC} ${label}"
|
||||
else
|
||||
echo -e " ${CYAN}[todo]${NC} ${label}"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# Drive detection helpers
|
||||
# ===================================================================
|
||||
snapshot_devices() {
|
||||
lsblk -dno NAME,TYPE 2>/dev/null | awk '$2=="disk"{print $1}' | sort
|
||||
}
|
||||
|
||||
get_drive_info() {
|
||||
local dev="$1"
|
||||
local devpath="/dev/${dev}"
|
||||
|
||||
DRIVE_MODEL=$(lsblk -dno MODEL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_SERIAL=$(lsblk -dno SERIAL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_SIZE=$(lsblk -dno SIZE "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_SIZE_BYTES=$(blockdev --getsize64 "$devpath" 2>/dev/null || echo "unknown")
|
||||
DRIVE_TRAN=$(lsblk -dno TRAN "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_WWN=$(lsblk -dno WWN "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_REV=$(lsblk -dno REV "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_VENDOR=$(lsblk -dno VENDOR "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
if [[ "$dev" == nvme* ]]; then
|
||||
DRIVE_TRAN="nvme"
|
||||
if command -v nvme &>/dev/null; then
|
||||
local ns_serial
|
||||
ns_serial=$(nvme id-ctrl "$devpath" 2>/dev/null | grep "^sn " | awk '{print $3}')
|
||||
[ -n "$ns_serial" ] && DRIVE_SERIAL="$ns_serial"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback serial sources
|
||||
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "" ]; then
|
||||
DRIVE_SERIAL=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL_SHORT=" | cut -d= -f2)
|
||||
fi
|
||||
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "" ]; then
|
||||
DRIVE_SERIAL=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL=" | cut -d= -f2)
|
||||
fi
|
||||
if [ -z "$DRIVE_WWN" ] || [ "$DRIVE_WWN" = "" ]; then
|
||||
DRIVE_WWN=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_WWN=" | cut -d= -f2)
|
||||
fi
|
||||
}
|
||||
|
||||
print_drive_info() {
|
||||
echo ""
|
||||
echo " ┌─────────────────────────────────────────────"
|
||||
echo " │ Device: /dev/${1}"
|
||||
echo " │ Model: ${DRIVE_MODEL:-unknown}"
|
||||
echo " │ Serial: ${DRIVE_SERIAL:-unknown}"
|
||||
echo " │ WWN: ${DRIVE_WWN:-none}"
|
||||
echo " │ Firmware: ${DRIVE_REV:-unknown}"
|
||||
echo " │ Size: ${DRIVE_SIZE} (${DRIVE_SIZE_BYTES} bytes)"
|
||||
echo " │ Transport: ${DRIVE_TRAN:-unknown}"
|
||||
echo " │ Vendor: ${DRIVE_VENDOR:-unknown}"
|
||||
echo " └─────────────────────────────────────────────"
|
||||
echo ""
|
||||
}
|
||||
|
||||
is_serial_enrolled() {
|
||||
grep -q "|${1}|" "$CONF" 2>/dev/null || grep -q "=${1}|" "$CONF" 2>/dev/null
|
||||
}
|
||||
|
||||
# Detect and enroll a single drive. Returns 0 on success.
|
||||
enroll_one_drive() {
|
||||
local role_key="$1" # e.g. SATA_3, NVME_DOCKER, HDD_BACKUP
|
||||
local role_desc="$2" # e.g. "SATA storage #3"
|
||||
local target_tran="$3" # sata or nvme
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN} Waiting for: ${role_desc}${NC}"
|
||||
echo ""
|
||||
|
||||
BEFORE=$(snapshot_devices)
|
||||
echo -n "Plug in the drive, then press Enter... "
|
||||
read -r
|
||||
sleep 2
|
||||
|
||||
AFTER=$(snapshot_devices)
|
||||
NEW_DEV=$(comm -13 <(echo "$BEFORE") <(echo "$AFTER") | head -1)
|
||||
|
||||
# Rescan if not found
|
||||
if [ -z "$NEW_DEV" ]; then
|
||||
warn "No new device detected. Rescanning..."
|
||||
for host in /sys/class/scsi_host/host*/scan; do
|
||||
echo "- - -" > "$host" 2>/dev/null || true
|
||||
done
|
||||
sleep 3
|
||||
AFTER=$(snapshot_devices)
|
||||
NEW_DEV=$(comm -13 <(echo "$BEFORE") <(echo "$AFTER") | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$NEW_DEV" ]; then
|
||||
warn "Still no new device found."
|
||||
echo "Current devices:"
|
||||
lsblk -d -o NAME,SIZE,MODEL,SERIAL,TRAN | grep -v "^loop"
|
||||
echo ""
|
||||
echo -n "Enter device name manually (e.g., sda) or 'skip': "
|
||||
read -r manual_dev
|
||||
[ "$manual_dev" = "skip" ] && return 1
|
||||
NEW_DEV="$manual_dev"
|
||||
fi
|
||||
|
||||
[ -b "/dev/${NEW_DEV}" ] || { warn "/dev/${NEW_DEV} not found"; return 1; }
|
||||
|
||||
get_drive_info "$NEW_DEV"
|
||||
print_drive_info "$NEW_DEV"
|
||||
|
||||
# Duplicate serial check
|
||||
if [ -n "$DRIVE_SERIAL" ] && is_serial_enrolled "$DRIVE_SERIAL"; then
|
||||
warn "This serial (${DRIVE_SERIAL}) is already enrolled."
|
||||
warn "Your USB adapter may be reporting its own serial."
|
||||
warn "Try a different adapter."
|
||||
echo -n "Skip? [Y/n] "
|
||||
read -r dup_skip
|
||||
[ "$dup_skip" = "n" ] || return 1
|
||||
fi
|
||||
|
||||
# Missing serial
|
||||
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "unknown" ]; then
|
||||
warn "Cannot read serial number."
|
||||
echo " 1) Try a different USB adapter"
|
||||
echo " 2) Enter serial manually (from drive label)"
|
||||
echo " 3) Skip"
|
||||
echo -n "Choice [1/2/3]: "
|
||||
read -r serial_fix
|
||||
case "$serial_fix" in
|
||||
2)
|
||||
echo -n "Enter drive serial: "
|
||||
read -r DRIVE_SERIAL
|
||||
[ -n "$DRIVE_SERIAL" ] || return 1
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Confirm
|
||||
echo ""
|
||||
echo -e " ${CYAN}Enrolling as: ${role_desc}${NC}"
|
||||
echo " Serial: ${DRIVE_SERIAL}"
|
||||
echo " Model: ${DRIVE_MODEL}"
|
||||
echo " Target: ${target_tran} (on server)"
|
||||
echo ""
|
||||
echo -n "Confirm? [Y/n] "
|
||||
read -r confirm
|
||||
[ "$confirm" = "n" ] || [ "$confirm" = "N" ] && return 1
|
||||
|
||||
# Write to manifest
|
||||
echo "DRIVE_${role_key}=${DRIVE_SERIAL}|${DRIVE_MODEL}|${DRIVE_WWN:-none}|${DRIVE_SIZE_BYTES}|${target_tran}|enrolled_via=${DRIVE_TRAN}" >> "$CONF"
|
||||
|
||||
log "Enrolled: ${role_desc} → ${DRIVE_MODEL} (${DRIVE_SERIAL})"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DRIVE: ${role_key} serial=${DRIVE_SERIAL} model=${DRIVE_MODEL}" >> "$LOG"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ===================================================================
|
||||
# PHASE 1: Drive Enrollment
|
||||
# ===================================================================
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " STORAGE NODE — Hardware Enrollment"
|
||||
echo "=========================================="
|
||||
|
||||
state_show
|
||||
|
||||
echo "This script tracks state in nanny.state."
|
||||
echo "If interrupted, re-run to resume where you left off."
|
||||
echo ""
|
||||
warn "All drives connected via USB for enrollment."
|
||||
warn "Serials must come from the DRIVE, not the USB adapter."
|
||||
echo ""
|
||||
|
||||
# Init drives.conf if needed
|
||||
if ! state_done "phase1_init"; then
|
||||
if [ -f "$CONF" ] && grep -q "^DRIVE_" "$CONF" 2>/dev/null; then
|
||||
echo "Found existing drives.conf."
|
||||
echo -n "Resume with existing drives (r) or start fresh (f)? [r/f] "
|
||||
read -r choice
|
||||
if [ "$choice" = "f" ]; then
|
||||
rm -f "$CONF"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$CONF" ]; then
|
||||
cat > "$CONF" << 'HEADER'
|
||||
#
|
||||
# Drive enrollment manifest — generated by nanny.sh
|
||||
# Used by storage-setup.sh to identify drives by hardware serial
|
||||
#
|
||||
# Format: DRIVE_<role>=<serial>|<model>|<wwn>|<size_bytes>|<target_transport>|enrolled_via=<usb_transport>
|
||||
#
|
||||
HEADER
|
||||
fi
|
||||
state_mark "phase1_init"
|
||||
fi
|
||||
|
||||
# Enroll SATA drives 1-6
|
||||
for i in 1 2 3 4 5 6; do
|
||||
state_key="drive_sata_${i}"
|
||||
if state_done "$state_key"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo " SATA Drive ${i}/6 — WD Blue SA510 for RustFS LVM"
|
||||
echo "─────────────────────────────────────────────"
|
||||
|
||||
while ! state_done "$state_key"; do
|
||||
if enroll_one_drive "SATA_${i}" "SATA storage #${i}" "sata"; then
|
||||
state_mark "$state_key"
|
||||
else
|
||||
echo -n "Retry this drive? [Y/n] "
|
||||
read -r retry
|
||||
[ "$retry" = "n" ] && err "Cannot continue without all 6 SATA drives."
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Enroll NVMe Docker drive
|
||||
if ! state_done "drive_nvme"; then
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo " NVMe Drive — Modern 2TB for Docker/writes"
|
||||
echo "─────────────────────────────────────────────"
|
||||
|
||||
while ! state_done "drive_nvme"; do
|
||||
if enroll_one_drive "NVME_DOCKER" "NVMe Docker/writes" "nvme"; then
|
||||
state_mark "drive_nvme"
|
||||
else
|
||||
echo -n "Retry? [Y/n] "
|
||||
read -r retry
|
||||
[ "$retry" = "n" ] && err "Cannot continue without NVMe drive."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Enroll HDD backup drive
|
||||
if ! state_done "drive_hdd"; then
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo " HDD — Spinning disk for nightly backups"
|
||||
echo "─────────────────────────────────────────────"
|
||||
|
||||
while ! state_done "drive_hdd"; do
|
||||
if enroll_one_drive "HDD_BACKUP" "HDD nightly backup" "sata"; then
|
||||
state_mark "drive_hdd"
|
||||
else
|
||||
echo -n "Retry? [Y/n] "
|
||||
read -r retry
|
||||
[ "$retry" = "n" ] && err "Cannot continue without HDD backup drive."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Generate drive-resolver.sh
|
||||
if ! state_done "phase1_resolver"; then
|
||||
log "Generating hardware-pinned drive resolver..."
|
||||
|
||||
cat > "${WORK}/drive-resolver.sh" << 'RESOLVER_HEAD'
|
||||
#!/bin/sh
|
||||
#
|
||||
# drive-resolver.sh — Resolve enrolled drive serials to current /dev/ paths
|
||||
# Generated by nanny.sh from actual hardware fingerprints
|
||||
#
|
||||
# Source this in storage-setup.sh: . /root/drive-resolver.sh
|
||||
#
|
||||
|
||||
resolve_drive() {
|
||||
local target_serial="$1"
|
||||
local result=""
|
||||
|
||||
for dev in /sys/block/*; do
|
||||
[ -d "$dev" ] || continue
|
||||
devname=$(basename "$dev")
|
||||
case "$devname" in
|
||||
loop*|ram*|dm-*|md*|sr*|zram*) continue ;;
|
||||
esac
|
||||
devpath="/dev/${devname}"
|
||||
[ -b "$devpath" ] || continue
|
||||
|
||||
serial=$(lsblk -dno SERIAL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
if [ -z "$serial" ]; then
|
||||
serial=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL_SHORT=" | cut -d= -f2)
|
||||
fi
|
||||
if [ -z "$serial" ]; then
|
||||
serial=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL=" | cut -d= -f2)
|
||||
fi
|
||||
|
||||
if [ "$serial" = "$target_serial" ]; then
|
||||
result="$devpath"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
echo "Resolving enrolled drives to current device paths..."
|
||||
echo ""
|
||||
|
||||
RESOLVER_HEAD
|
||||
|
||||
# Append serial lookups
|
||||
{
|
||||
echo "# ── SATA drives (RustFS LVM volume group) ──"
|
||||
echo "SATA_DRIVES=\"\""
|
||||
|
||||
for i in 1 2 3 4 5 6; do
|
||||
line=$(grep "^DRIVE_SATA_${i}=" "$CONF" 2>/dev/null || true)
|
||||
if [ -n "$line" ]; then
|
||||
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
|
||||
model=$(echo "$line" | cut -d'|' -f2)
|
||||
echo ""
|
||||
echo "# SATA #${i}: ${model} (${serial})"
|
||||
echo "DEV_SATA_${i}=\$(resolve_drive \"${serial}\")"
|
||||
echo "[ -n \"\$DEV_SATA_${i}\" ] || { echo \"ERROR: Cannot find SATA drive #${i} (serial: ${serial})\"; exit 1; }"
|
||||
echo "echo \" SATA #${i}: \${DEV_SATA_${i}} → ${model}\""
|
||||
echo "SATA_DRIVES=\"\${SATA_DRIVES} \${DEV_SATA_${i}}\""
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "# Trim leading space"
|
||||
echo "SATA_DRIVES=\$(echo \$SATA_DRIVES | sed 's/^ //')"
|
||||
echo ""
|
||||
|
||||
echo "# ── NVMe drive (Docker/writes) ──"
|
||||
line=$(grep "^DRIVE_NVME_DOCKER=" "$CONF" 2>/dev/null || true)
|
||||
if [ -n "$line" ]; then
|
||||
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
|
||||
model=$(echo "$line" | cut -d'|' -f2)
|
||||
echo ""
|
||||
echo "# NVMe Docker: ${model} (${serial})"
|
||||
echo "DEV_NVME=\$(resolve_drive \"${serial}\")"
|
||||
echo "[ -n \"\$DEV_NVME\" ] || { echo \"ERROR: Cannot find NVMe Docker drive (serial: ${serial})\"; exit 1; }"
|
||||
echo "echo \" NVMe: \${DEV_NVME} → ${model}\""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
echo "# ── HDD backup drive ──"
|
||||
line=$(grep "^DRIVE_HDD_BACKUP=" "$CONF" 2>/dev/null || true)
|
||||
if [ -n "$line" ]; then
|
||||
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
|
||||
model=$(echo "$line" | cut -d'|' -f2)
|
||||
echo ""
|
||||
echo "# HDD Backup: ${model} (${serial})"
|
||||
echo "DEV_HDD=\$(resolve_drive \"${serial}\")"
|
||||
echo "[ -n \"\$DEV_HDD\" ] || { echo \"ERROR: Cannot find HDD backup drive (serial: ${serial})\"; exit 1; }"
|
||||
echo "echo \" HDD: \${DEV_HDD} → ${model}\""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "echo \"\""
|
||||
echo "echo \"All drives resolved successfully.\""
|
||||
} >> "${WORK}/drive-resolver.sh"
|
||||
|
||||
chmod +x "${WORK}/drive-resolver.sh"
|
||||
log "Generated: drive-resolver.sh"
|
||||
state_mark "phase1_resolver"
|
||||
fi
|
||||
|
||||
# ===================================================================
|
||||
# PHASE 2: YubiKey Enrollment
|
||||
# ===================================================================
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " YUBIKEY ENROLLMENT"
|
||||
echo "=========================================="
|
||||
state_show
|
||||
|
||||
for cmd in ykman ssh-keygen; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
err "Required tool '${cmd}' not found. Install it first."
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "${WORK}/ssh-keys"
|
||||
|
||||
# Init yubikeys.conf if needed
|
||||
if [ ! -f "$YKCONF" ]; then
|
||||
cat > "$YKCONF" << 'YKHEADER'
|
||||
#
|
||||
# YubiKey enrollment manifest — generated by nanny.sh
|
||||
#
|
||||
# Format: YUBIKEY_<N>=<serial>|<model>|<firmware>|<ssh_pubkey_file>
|
||||
#
|
||||
YKHEADER
|
||||
fi
|
||||
|
||||
for N in 1 2; do
|
||||
state_key="yubikey_${N}"
|
||||
if state_done "$state_key"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
LABEL="PRIMARY"
|
||||
[ "$N" -eq 2 ] && LABEL="BACKUP"
|
||||
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo " YubiKey ${N}/2 (${LABEL})"
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo ""
|
||||
echo -n "Insert YubiKey #${N} (${LABEL}) and press Enter... "
|
||||
read -r
|
||||
sleep 2
|
||||
|
||||
YK_INFO=$(ykman info 2>/dev/null) || {
|
||||
warn "Cannot read YubiKey. Is it inserted?"
|
||||
echo -n "Retry? [Y/n] "
|
||||
read -r retry
|
||||
[ "$retry" = "n" ] && err "Cannot continue without YubiKey #${N}."
|
||||
sleep 1
|
||||
YK_INFO=$(ykman info 2>/dev/null) || err "Still cannot read YubiKey."
|
||||
}
|
||||
|
||||
YK_SERIAL=$(echo "$YK_INFO" | grep "Serial number:" | awk '{print $NF}')
|
||||
YK_FW=$(echo "$YK_INFO" | grep "Firmware version:" | awk '{print $NF}')
|
||||
YK_TYPE=$(echo "$YK_INFO" | grep "Device type:" | sed 's/Device type:[[:space:]]*//')
|
||||
YK_FORM=$(echo "$YK_INFO" | grep "Form factor:" | sed 's/Form factor:[[:space:]]*//')
|
||||
|
||||
echo ""
|
||||
echo " ┌─────────────────────────────────────────────"
|
||||
echo " │ YubiKey ${N} (${LABEL})"
|
||||
echo " │ Type: ${YK_TYPE:-unknown}"
|
||||
echo " │ Serial: ${YK_SERIAL:-unknown}"
|
||||
echo " │ Firmware: ${YK_FW:-unknown}"
|
||||
echo " │ Form: ${YK_FORM:-unknown}"
|
||||
echo " └─────────────────────────────────────────────"
|
||||
echo ""
|
||||
|
||||
[ -n "$YK_SERIAL" ] || err "Could not read YubiKey serial number."
|
||||
|
||||
if grep -q "${YK_SERIAL}" "$YKCONF" 2>/dev/null; then
|
||||
warn "YubiKey ${YK_SERIAL} is already enrolled. Remove it and insert the other one."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check / program slot 2
|
||||
info "Checking OTP slot 2 (challenge-response)..."
|
||||
SLOT2_STATUS=$(ykman otp info 2>/dev/null | grep "Slot 2:" || true)
|
||||
|
||||
if echo "$SLOT2_STATUS" | grep -qi "programmed"; then
|
||||
log "Slot 2 is programmed."
|
||||
info "Testing challenge-response (touch the key if it blinks)..."
|
||||
if echo -n "nanny-test" | ykman otp calculate 2 - &>/dev/null; then
|
||||
log "Challenge-response working."
|
||||
else
|
||||
warn "Challenge-response test failed."
|
||||
echo -n "Program slot 2 now? [Y/n] "
|
||||
read -r prog
|
||||
[ "$prog" = "n" ] || ykman otp chalresp --touch --generate 2 --force
|
||||
fi
|
||||
else
|
||||
warn "Slot 2 is empty."
|
||||
echo -n "Program slot 2 with HMAC-SHA1 challenge-response? [Y/n] "
|
||||
read -r prog
|
||||
[ "$prog" = "n" ] || ykman otp chalresp --touch --generate 2 --force
|
||||
fi
|
||||
|
||||
# FIDO2 SSH key
|
||||
SSH_KEY_FILE="${WORK}/ssh-keys/yubikey${N}_storagenode"
|
||||
SSH_KEY_TYPE="ed25519-sk"
|
||||
|
||||
FIDO_STATUS=$(ykman fido info 2>/dev/null) || true
|
||||
if [ -z "$FIDO_STATUS" ]; then
|
||||
warn "FIDO2 not available (needs firmware 5.0+). Using standard ed25519."
|
||||
SSH_KEY_TYPE="ed25519"
|
||||
fi
|
||||
|
||||
if [ ! -f "${SSH_KEY_FILE}.pub" ]; then
|
||||
if [ "$SSH_KEY_TYPE" = "ed25519-sk" ]; then
|
||||
info "Generating FIDO2 SSH key (touch the key when it blinks)..."
|
||||
ssh-keygen -t ed25519-sk \
|
||||
-O resident \
|
||||
-O application=ssh:storagenode \
|
||||
-C "yubikey-${N}-storagenode-${YK_SERIAL}" \
|
||||
-f "$SSH_KEY_FILE" \
|
||||
-N ""
|
||||
else
|
||||
ssh-keygen -t ed25519 \
|
||||
-C "yubikey-${N}-storagenode-${YK_SERIAL}" \
|
||||
-f "$SSH_KEY_FILE" \
|
||||
-N ""
|
||||
fi
|
||||
log "SSH key generated: ${SSH_KEY_FILE}.pub"
|
||||
else
|
||||
log "SSH key already exists: ${SSH_KEY_FILE}.pub"
|
||||
fi
|
||||
|
||||
echo "YUBIKEY_${N}=${YK_SERIAL}|${YK_TYPE}|${YK_FW}|yubikey${N}_storagenode.pub" >> "$YKCONF"
|
||||
log "Enrolled YubiKey #${N} (${LABEL}): ${YK_TYPE} serial ${YK_SERIAL}"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] YUBIKEY: #${N} serial=${YK_SERIAL} type=${YK_TYPE}" >> "$LOG"
|
||||
state_mark "$state_key"
|
||||
|
||||
if [ "$N" -eq 1 ]; then
|
||||
echo ""
|
||||
warn "Remove YubiKey #1 and insert #2 (backup)."
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate authorized_keys
|
||||
if ! state_done "phase2_authkeys"; then
|
||||
log "Generating authorized_keys..."
|
||||
{
|
||||
echo "# Generated by nanny.sh — YubiKey SSH public keys for storagenode"
|
||||
for kf in "${WORK}"/ssh-keys/*.pub; do
|
||||
[ -f "$kf" ] || continue
|
||||
echo "# $(basename "$kf")"
|
||||
cat "$kf"
|
||||
echo ""
|
||||
done
|
||||
} > "${WORK}/authorized_keys"
|
||||
state_mark "phase2_authkeys"
|
||||
fi
|
||||
|
||||
# ===================================================================
|
||||
# PHASE 3: Cloudflare Configuration
|
||||
# ===================================================================
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CLOUDFLARE SETUP"
|
||||
echo "=========================================="
|
||||
state_show
|
||||
|
||||
[ -f "$CFCONF" ] || cat > "$CFCONF" << 'CFHEADER'
|
||||
#
|
||||
# Cloudflare configuration — generated by nanny.sh
|
||||
#
|
||||
CFHEADER
|
||||
|
||||
# 3a. Cloudflare account
|
||||
if ! state_done "cf_account"; then
|
||||
echo ""
|
||||
echo "─── Step 1: Cloudflare Account ───"
|
||||
echo ""
|
||||
echo "Verify fhirworx.io is active at https://dash.cloudflare.com"
|
||||
echo ""
|
||||
echo -n "Is fhirworx.io active on Cloudflare? [Y/n] "
|
||||
read -r cf_active
|
||||
if [ "$cf_active" = "n" ]; then
|
||||
echo "Add fhirworx.io and update nameservers first."
|
||||
echo -n "Press Enter when ready..."
|
||||
read -r
|
||||
fi
|
||||
state_mark "cf_account"
|
||||
fi
|
||||
|
||||
# 3b. Create tunnel
|
||||
if ! state_done "cf_tunnel"; then
|
||||
echo ""
|
||||
echo "─── Step 2: Create Cloudflare Tunnel ───"
|
||||
echo ""
|
||||
echo "1. Go to: https://one.dash.cloudflare.com"
|
||||
echo "2. Networks → Tunnels → Create a tunnel"
|
||||
echo "3. Type: Cloudflared"
|
||||
echo "4. Name: rig"
|
||||
echo "5. Copy the token (starts with eyJ...)"
|
||||
echo " DO NOT install the connector — we run it in Docker."
|
||||
echo ""
|
||||
echo -n "Paste tunnel token: "
|
||||
read -r TUNNEL_TOKEN
|
||||
|
||||
if [ -n "$TUNNEL_TOKEN" ]; then
|
||||
echo "CF_TUNNEL_TOKEN=${TUNNEL_TOKEN}" >> "$CFCONF"
|
||||
log "Tunnel token saved."
|
||||
else
|
||||
echo "CF_TUNNEL_TOKEN=PASTE_YOUR_TOKEN_HERE" >> "$CFCONF"
|
||||
warn "No token — add it later in cloudflare.conf."
|
||||
fi
|
||||
state_mark "cf_tunnel"
|
||||
fi
|
||||
|
||||
# 3c. Public hostnames
|
||||
if ! state_done "cf_routes"; then
|
||||
echo ""
|
||||
echo "─── Step 3: Public Hostname Routes ───"
|
||||
echo ""
|
||||
echo "In the tunnel config → Public Hostname tab, add:"
|
||||
echo ""
|
||||
echo " ┌────────────────────────────────┬──────────────────────┐"
|
||||
echo " │ s3.rig.fhirworx.io │ http://rustfs:9000 │"
|
||||
echo " │ console.rig.fhirworx.io │ http://rustfs:9001 │"
|
||||
echo " │ rig.fhirworx.io │ http://rustfs:9000 │"
|
||||
echo " └────────────────────────────────┴──────────────────────┘"
|
||||
echo ""
|
||||
echo " Type: HTTP | No TLS Verify: ON"
|
||||
echo ""
|
||||
echo -n "Routes added? [Y/n] "
|
||||
read -r routes_done
|
||||
[ "$routes_done" = "n" ] && { echo -n "Press Enter when done..."; read -r; }
|
||||
state_mark "cf_routes"
|
||||
fi
|
||||
|
||||
# 3d. Access policies — YubiKey gate
|
||||
if ! state_done "cf_access"; then
|
||||
echo ""
|
||||
echo "─── Step 4: Cloudflare Access (YubiKey gate) ───"
|
||||
echo ""
|
||||
echo "Settings → Authentication → Login methods:"
|
||||
echo " - Enable 'Hardware Keys' (WebAuthn/FIDO2)"
|
||||
echo " - DISABLE all other methods (OTP, Google, etc.)"
|
||||
echo ""
|
||||
echo -n "Hardware Keys is the ONLY login method? [Y/n] "
|
||||
read -r hw_ok
|
||||
[ "$hw_ok" = "n" ] && { echo "Fix this first."; echo -n "Press Enter when done..."; read -r; }
|
||||
|
||||
echo ""
|
||||
echo "Create Access Applications:"
|
||||
echo ""
|
||||
echo " App 1: 'RustFS Console'"
|
||||
echo " Domain: console.rig.fhirworx.io"
|
||||
echo " Policy: Allow | Include: your email | Require: auth method = hwk"
|
||||
echo ""
|
||||
echo " App 2: 'RustFS S3 API'"
|
||||
echo " Domains: s3.rig.fhirworx.io + rig.fhirworx.io"
|
||||
echo " Policy 1: Allow (same hwk policy)"
|
||||
echo " Policy 2: Service Auth (for programmatic access)"
|
||||
echo ""
|
||||
echo -n "Both Access applications created? [Y/n] "
|
||||
read -r access_ok
|
||||
[ "$access_ok" = "n" ] && { echo -n "Press Enter when done..."; read -r; }
|
||||
|
||||
echo ""
|
||||
echo -n "Create a Service Token for S3 API? [Y/n] "
|
||||
read -r create_svc
|
||||
if [ "$create_svc" != "n" ]; then
|
||||
echo " Access → Service Auth → Create Service Token → name: rig-s3-api"
|
||||
echo -n "CF-Access-Client-Id: "
|
||||
read -r CF_SVC_ID
|
||||
echo -n "CF-Access-Client-Secret: "
|
||||
read -rs CF_SVC_SECRET
|
||||
echo ""
|
||||
if [ -n "$CF_SVC_ID" ] && [ -n "$CF_SVC_SECRET" ]; then
|
||||
echo "CF_SVC_CLIENT_ID=${CF_SVC_ID}" >> "$CFCONF"
|
||||
echo "CF_SVC_CLIENT_SECRET=${CF_SVC_SECRET}" >> "$CFCONF"
|
||||
log "Service token saved."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "CF_ACCESS_CONFIGURED=true" >> "$CFCONF"
|
||||
state_mark "cf_access"
|
||||
fi
|
||||
|
||||
# 3e. SSL
|
||||
if ! state_done "cf_ssl"; then
|
||||
echo ""
|
||||
echo "─── Step 5: SSL/TLS Mode ───"
|
||||
echo ""
|
||||
echo "dash.cloudflare.com → fhirworx.io → SSL/TLS → Overview"
|
||||
echo "Set to: Full"
|
||||
echo ""
|
||||
echo -n "Done? [Y/n] "
|
||||
read -r ssl_ok
|
||||
[ "$ssl_ok" = "n" ] && echo "Set it before deploying."
|
||||
state_mark "cf_ssl"
|
||||
fi
|
||||
|
||||
# 3f. API token
|
||||
if ! state_done "cf_api_token"; then
|
||||
echo ""
|
||||
echo "─── Step 6: API Token (optional) ───"
|
||||
echo ""
|
||||
echo "For DNS challenge cert renewal."
|
||||
echo "Create at: https://dash.cloudflare.com/profile/api-tokens"
|
||||
echo "Template: 'Edit zone DNS', scope: fhirworx.io"
|
||||
echo ""
|
||||
echo -n "Paste API token (Enter to skip): "
|
||||
read -r CF_API_TOKEN
|
||||
if [ -n "$CF_API_TOKEN" ]; then
|
||||
echo "CF_API_TOKEN=${CF_API_TOKEN}" >> "$CFCONF"
|
||||
mkdir -p "${WORK}/certs"
|
||||
echo "dns_cloudflare_api_token = ${CF_API_TOKEN}" > "${WORK}/certs/cf-credentials.ini"
|
||||
chmod 600 "${WORK}/certs/cf-credentials.ini"
|
||||
log "API token saved."
|
||||
fi
|
||||
state_mark "cf_api_token"
|
||||
fi
|
||||
|
||||
# 3g. Origin cert
|
||||
if ! state_done "cf_cert"; then
|
||||
echo ""
|
||||
echo "─── Step 7: Origin Certificate ───"
|
||||
echo ""
|
||||
echo " 1) Skip — tunnel handles everything"
|
||||
echo " 2) Cloudflare Origin CA — 15 year, CF-trusted only"
|
||||
echo " 3) Let's Encrypt — trusted everywhere"
|
||||
echo ""
|
||||
echo -n "Choose [1/2/3]: "
|
||||
read -r cert_choice
|
||||
case "$cert_choice" in
|
||||
2)
|
||||
echo ""
|
||||
echo "dash.cloudflare.com → fhirworx.io → SSL/TLS → Origin Server"
|
||||
echo "Create: ECDSA, hosts: rig.fhirworx.io + *.rig.fhirworx.io, 15 years"
|
||||
echo ""
|
||||
mkdir -p "${WORK}/certs"
|
||||
echo "Paste CERTIFICATE PEM, then Ctrl+D:"
|
||||
cat > "${WORK}/certs/origin.pem"
|
||||
echo "Paste PRIVATE KEY PEM, then Ctrl+D:"
|
||||
cat > "${WORK}/certs/origin-key.pem"
|
||||
chmod 600 "${WORK}/certs/origin-key.pem"
|
||||
echo "CF_CERT_TYPE=origin-ca" >> "$CFCONF"
|
||||
log "Origin CA certificate saved."
|
||||
;;
|
||||
3)
|
||||
echo "CF_CERT_TYPE=letsencrypt" >> "$CFCONF"
|
||||
log "Let's Encrypt will be configured on the server."
|
||||
;;
|
||||
*)
|
||||
echo "CF_CERT_TYPE=none" >> "$CFCONF"
|
||||
;;
|
||||
esac
|
||||
state_mark "cf_cert"
|
||||
fi
|
||||
|
||||
# 3h. WARP
|
||||
if ! state_done "cf_warp"; then
|
||||
echo ""
|
||||
echo "─── Step 8: Cloudflare WARP ───"
|
||||
echo ""
|
||||
echo -n "Enable WARP on the server? [Y/n] "
|
||||
read -r warp_choice
|
||||
if [ "$warp_choice" = "n" ]; then
|
||||
echo "CF_WARP_ENABLED=false" >> "$CFCONF"
|
||||
else
|
||||
echo "CF_WARP_ENABLED=true" >> "$CFCONF"
|
||||
log "WARP enabled."
|
||||
fi
|
||||
state_mark "cf_warp"
|
||||
fi
|
||||
|
||||
# 3i. RustFS credentials
|
||||
if ! state_done "cf_rustfs_creds"; then
|
||||
echo ""
|
||||
echo "─── Step 9: RustFS Internal Credentials ───"
|
||||
echo ""
|
||||
echo "RustFS needs an internal admin user/password."
|
||||
echo "This is behind Cloudflare Access (YubiKey required first)."
|
||||
echo ""
|
||||
|
||||
RUSTFS_USER="admin"
|
||||
RUSTFS_PASS=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||||
|
||||
echo " Auto-generated (Access is the real gate):"
|
||||
echo " Username: ${RUSTFS_USER}"
|
||||
echo " Password: ${RUSTFS_PASS}"
|
||||
echo ""
|
||||
echo -n "Accept or enter custom? [accept/custom] "
|
||||
read -r cred_choice
|
||||
if [ "$cred_choice" = "custom" ]; then
|
||||
echo -n "Username [admin]: "
|
||||
read -r RUSTFS_USER
|
||||
RUSTFS_USER="${RUSTFS_USER:-admin}"
|
||||
while true; do
|
||||
echo -n "Password (min 8 chars): "
|
||||
read -rs RUSTFS_PASS
|
||||
echo ""
|
||||
[ ${#RUSTFS_PASS} -ge 8 ] || { warn "Too short."; continue; }
|
||||
echo -n "Confirm: "
|
||||
read -rs RUSTFS_PASS2
|
||||
echo ""
|
||||
[ "$RUSTFS_PASS" = "$RUSTFS_PASS2" ] || { warn "Mismatch."; continue; }
|
||||
break
|
||||
done
|
||||
fi
|
||||
echo "RUSTFS_ROOT_USER=${RUSTFS_USER}" >> "$CFCONF"
|
||||
echo "RUSTFS_ROOT_PASSWORD=${RUSTFS_PASS}" >> "$CFCONF"
|
||||
log "RustFS credentials saved."
|
||||
state_mark "cf_rustfs_creds"
|
||||
fi
|
||||
|
||||
# 3j. Network
|
||||
if ! state_done "cf_network"; then
|
||||
echo ""
|
||||
echo "─── Step 10: Server Network ───"
|
||||
echo ""
|
||||
echo -n "Static IP (e.g., 192.168.1.100): "
|
||||
read -r STATIC_IP
|
||||
echo -n "CIDR mask (e.g., 24): "
|
||||
read -r MASK
|
||||
MASK="${MASK:-24}"
|
||||
echo -n "Gateway (e.g., 192.168.1.1): "
|
||||
read -r GW
|
||||
echo -n "DNS [1.1.1.1]: "
|
||||
read -r DNS
|
||||
DNS="${DNS:-1.1.1.1}"
|
||||
|
||||
if [ -n "$STATIC_IP" ] && [ -n "$GW" ]; then
|
||||
echo "NET_STATIC_IP=${STATIC_IP}/${MASK}" >> "$CFCONF"
|
||||
echo "NET_GATEWAY=${GW}" >> "$CFCONF"
|
||||
echo "NET_DNS=${DNS}" >> "$CFCONF"
|
||||
log "Network: ${STATIC_IP}/${MASK} via ${GW}"
|
||||
else
|
||||
warn "Incomplete — will use DHCP."
|
||||
fi
|
||||
state_mark "cf_network"
|
||||
fi
|
||||
|
||||
# ===================================================================
|
||||
# SUMMARY
|
||||
# ===================================================================
|
||||
echo ""
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " ENROLLMENT COMPLETE"
|
||||
echo "=========================================="
|
||||
|
||||
state_show
|
||||
|
||||
echo "── Files ──"
|
||||
for f in drives.conf drive-resolver.sh yubikeys.conf authorized_keys cloudflare.conf; do
|
||||
[ -f "${WORK}/${f}" ] && echo -e " ${GREEN}[ok]${NC} ${f}" || echo -e " ${RED}[!!]${NC} ${f}"
|
||||
done
|
||||
[ -d "${WORK}/ssh-keys" ] && echo -e " ${GREEN}[ok]${NC} ssh-keys/ ($(ls "${WORK}"/ssh-keys/*.pub 2>/dev/null | wc -l) keys)"
|
||||
[ -d "${WORK}/certs" ] && echo -e " ${GREEN}[ok]${NC} certs/"
|
||||
echo -e " ${GREEN}[ok]${NC} nanny.state ($(wc -l < "$STATE") checkpoints)"
|
||||
echo -e " ${GREEN}[ok]${NC} nanny.log ($(wc -l < "$LOG") lines)"
|
||||
echo ""
|
||||
echo "── Drives ──"
|
||||
grep "^DRIVE_" "$CONF" | while IFS='=' read -r key val; do
|
||||
serial=$(echo "$val" | cut -d'|' -f1)
|
||||
model=$(echo "$val" | cut -d'|' -f2)
|
||||
echo " ${key}: ${model} (${serial})"
|
||||
done
|
||||
echo ""
|
||||
echo "── YubiKeys ──"
|
||||
grep "^YUBIKEY_" "$YKCONF" | while IFS='=' read -r key val; do
|
||||
serial=$(echo "$val" | cut -d'|' -f1)
|
||||
type=$(echo "$val" | cut -d'|' -f2)
|
||||
echo " ${key}: ${type} (${serial})"
|
||||
done
|
||||
echo ""
|
||||
echo "── Cloudflare ──"
|
||||
grep -v "PASSWORD\|TOKEN\|SECRET\|API_TOKEN" "$CFCONF" 2>/dev/null | grep -v "^#" | grep -v "^$" || true
|
||||
echo " (secrets redacted)"
|
||||
echo ""
|
||||
log "All enrollment complete. Run: ./build-iso.sh"
|
||||
49
hw/package-lock.json
generated
Normal file
49
hw/package-lock.json
generated
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "alpine-iso",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "alpine-iso",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"bats": "^1.13.0",
|
||||
"bats-assert": "^2.2.4",
|
||||
"bats-support": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bats": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/bats/-/bats-1.13.0.tgz",
|
||||
"integrity": "sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"bats": "bin/bats"
|
||||
}
|
||||
},
|
||||
"node_modules/bats-assert": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/bats-assert/-/bats-assert-2.2.4.tgz",
|
||||
"integrity": "sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0",
|
||||
"peerDependencies": {
|
||||
"bats": "0.4 || ^1",
|
||||
"bats-support": "^0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bats-support": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bats-support/-/bats-support-0.3.0.tgz",
|
||||
"integrity": "sha512-z+2WzXbI4OZgLnynydqH8GpI3+DcOtepO66PlK47SfEzTkiuV9hxn9eIQX+uLVFbt2Oqoc7Ky3TJ/N83lqD+cg==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0",
|
||||
"peerDependencies": {
|
||||
"bats": "0.4 || ^1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
hw/package.json
Normal file
25
hw/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "alpine-iso",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "npx bats test/*.bats",
|
||||
"test:state": "npx bats test/state.bats",
|
||||
"test:drives": "npx bats test/drives.bats",
|
||||
"test:resolver": "npx bats test/resolver.bats",
|
||||
"test:yubikey": "npx bats test/yubikey.bats",
|
||||
"test:cloudflare": "npx bats test/cloudflare.bats",
|
||||
"test:backup": "npx bats test/backup.bats",
|
||||
"test:build": "npx bats test/build_validation.bats",
|
||||
"test:integration": "npx bats test/integration.bats"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"bats": "^1.13.0",
|
||||
"bats-assert": "^2.2.4",
|
||||
"bats-support": "^0.3.0"
|
||||
}
|
||||
}
|
||||
166
hw/test/backup.bats
Normal file
166
hw/test/backup.bats
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for nightly backup script logic
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
|
||||
# Create the backup script for testing
|
||||
cat > "${TEST_WORK}/backup-nightly" << 'BACKUP'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
LOGFILE="${TEST_WORK}/backup.log"
|
||||
BACKUP_DIR="${TEST_WORK}/backup"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
RETAIN_DAYS=7
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOGFILE"; }
|
||||
|
||||
# Simulate mount checks
|
||||
check_volumes() {
|
||||
[ -d "$BACKUP_DIR" ] && [ -f "${BACKUP_DIR}/.mounted" ]
|
||||
}
|
||||
|
||||
do_backup() {
|
||||
local SNAP_DIR="${BACKUP_DIR}/snapshots/${DATE}"
|
||||
local LATEST_LINK="${BACKUP_DIR}/latest"
|
||||
mkdir -p "$SNAP_DIR/rustfs" "$SNAP_DIR/docker-volumes" "$SNAP_DIR/config"
|
||||
# Simulate data
|
||||
echo "test-data-${DATE}" > "$SNAP_DIR/rustfs/test.dat"
|
||||
echo "config-data" > "$SNAP_DIR/config/fstab"
|
||||
# Update latest
|
||||
rm -f "$LATEST_LINK"
|
||||
ln -s "$SNAP_DIR" "$LATEST_LINK"
|
||||
log "DONE: backup complete"
|
||||
}
|
||||
|
||||
rotate() {
|
||||
find "${BACKUP_DIR}/snapshots" -maxdepth 1 -type d -mtime +${RETAIN_DAYS} -exec rm -rf {} \; 2>/dev/null || true
|
||||
}
|
||||
BACKUP
|
||||
chmod +x "${TEST_WORK}/backup-nightly"
|
||||
# Source it for function access
|
||||
. "${TEST_WORK}/backup-nightly"
|
||||
|
||||
mkdir -p "${TEST_WORK}/backup"
|
||||
touch "${TEST_WORK}/backup/.mounted"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# ─── Backup directory structure ───
|
||||
|
||||
@test "backup creates dated snapshot directory" {
|
||||
do_backup
|
||||
local today
|
||||
today=$(date +%Y-%m-%d)
|
||||
assert [ -d "${TEST_WORK}/backup/snapshots/${today}" ]
|
||||
}
|
||||
|
||||
@test "backup creates rustfs subdirectory" {
|
||||
do_backup
|
||||
local today
|
||||
today=$(date +%Y-%m-%d)
|
||||
assert [ -d "${TEST_WORK}/backup/snapshots/${today}/rustfs" ]
|
||||
}
|
||||
|
||||
@test "backup creates docker-volumes subdirectory" {
|
||||
do_backup
|
||||
local today
|
||||
today=$(date +%Y-%m-%d)
|
||||
assert [ -d "${TEST_WORK}/backup/snapshots/${today}/docker-volumes" ]
|
||||
}
|
||||
|
||||
@test "backup creates config subdirectory" {
|
||||
do_backup
|
||||
local today
|
||||
today=$(date +%Y-%m-%d)
|
||||
assert [ -d "${TEST_WORK}/backup/snapshots/${today}/config" ]
|
||||
}
|
||||
|
||||
@test "backup writes data to snapshot" {
|
||||
do_backup
|
||||
local today
|
||||
today=$(date +%Y-%m-%d)
|
||||
assert [ -f "${TEST_WORK}/backup/snapshots/${today}/rustfs/test.dat" ]
|
||||
}
|
||||
|
||||
# ─── Latest symlink ───
|
||||
|
||||
@test "backup updates latest symlink" {
|
||||
do_backup
|
||||
assert [ -L "${TEST_WORK}/backup/latest" ]
|
||||
}
|
||||
|
||||
@test "latest symlink points to today's snapshot" {
|
||||
do_backup
|
||||
local today
|
||||
today=$(date +%Y-%m-%d)
|
||||
local target
|
||||
target=$(readlink "${TEST_WORK}/backup/latest")
|
||||
assert_equal "$target" "${TEST_WORK}/backup/snapshots/${today}"
|
||||
}
|
||||
|
||||
@test "latest symlink is updated on second backup" {
|
||||
# Simulate two days
|
||||
BACKUP_DIR="${TEST_WORK}/backup"
|
||||
mkdir -p "${BACKUP_DIR}/snapshots/2026-03-27/rustfs"
|
||||
ln -sf "${BACKUP_DIR}/snapshots/2026-03-27" "${BACKUP_DIR}/latest"
|
||||
|
||||
do_backup
|
||||
local target
|
||||
target=$(readlink "${TEST_WORK}/backup/latest")
|
||||
local today
|
||||
today=$(date +%Y-%m-%d)
|
||||
assert_equal "$target" "${TEST_WORK}/backup/snapshots/${today}"
|
||||
}
|
||||
|
||||
# ─── Volume check ───
|
||||
|
||||
@test "backup skips when volumes not mounted" {
|
||||
rm -f "${TEST_WORK}/backup/.mounted"
|
||||
run check_volumes
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "backup proceeds when volumes mounted" {
|
||||
run check_volumes
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Logging ───
|
||||
|
||||
@test "backup writes completion to log" {
|
||||
do_backup
|
||||
run grep "DONE: backup complete" "${TEST_WORK}/backup.log"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Rotation ───
|
||||
|
||||
@test "rotation removes old snapshots" {
|
||||
# Create old snapshot
|
||||
mkdir -p "${TEST_WORK}/backup/snapshots/2020-01-01"
|
||||
touch -d "2020-01-01" "${TEST_WORK}/backup/snapshots/2020-01-01"
|
||||
rotate
|
||||
assert [ ! -d "${TEST_WORK}/backup/snapshots/2020-01-01" ]
|
||||
}
|
||||
|
||||
@test "rotation keeps recent snapshots" {
|
||||
# Create yesterday's snapshot
|
||||
local yesterday
|
||||
yesterday=$(date -d "yesterday" +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo "2026-03-27")
|
||||
mkdir -p "${TEST_WORK}/backup/snapshots/${yesterday}"
|
||||
rotate
|
||||
assert [ -d "${TEST_WORK}/backup/snapshots/${yesterday}" ]
|
||||
}
|
||||
|
||||
@test "rotation with no snapshots does not error" {
|
||||
rm -rf "${TEST_WORK}/backup/snapshots"
|
||||
mkdir -p "${TEST_WORK}/backup/snapshots"
|
||||
run rotate
|
||||
assert_success
|
||||
}
|
||||
197
hw/test/build_validation.bats
Normal file
197
hw/test/build_validation.bats
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for build-iso.sh validation logic (pre-build checks)
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
source_nanny_functions
|
||||
mkdir -p "${TEST_WORK}/ssh-keys"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# Helper: create a complete valid enrollment
|
||||
create_full_enrollment() {
|
||||
# drives.conf — 8 drives
|
||||
cat > "$CONF" << 'DRIVES'
|
||||
#
|
||||
# Drive enrollment manifest
|
||||
#
|
||||
DRIVE_SATA_1=WD-S001|WD Blue SA510 2TB|0x5001|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_2=WD-S002|WD Blue SA510 2TB|0x5002|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_3=WD-S003|WD Blue SA510 2TB|0x5003|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_4=WD-S004|WD Blue SA510 2TB|0x5004|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_5=WD-S005|WD Blue SA510 2TB|0x5005|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_6=WD-S006|WD Blue SA510 2TB|0x5006|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_NVME_DOCKER=NVM-D001|WD SN770 2TB|none|2000398934016|nvme|enrolled_via=usb
|
||||
DRIVE_HDD_BACKUP=HDD-B001|WD Red Plus 4TB|none|4000787030016|sata|enrolled_via=usb
|
||||
DRIVES
|
||||
|
||||
# yubikeys.conf — 2 keys
|
||||
create_enrolled_yubikeys
|
||||
|
||||
# SSH keys
|
||||
echo "sk-ssh-ed25519 AAAA yubikey-1-storagenode-12345678" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
|
||||
echo "sk-ssh-ed25519 BBBB yubikey-2-storagenode-87654321" > "${TEST_WORK}/ssh-keys/yubikey2_storagenode.pub"
|
||||
|
||||
# authorized_keys
|
||||
cat "${TEST_WORK}"/ssh-keys/*.pub > "${TEST_WORK}/authorized_keys"
|
||||
|
||||
# drive-resolver.sh
|
||||
echo "#!/bin/sh" > "${TEST_WORK}/drive-resolver.sh"
|
||||
chmod +x "${TEST_WORK}/drive-resolver.sh"
|
||||
|
||||
# cloudflare.conf
|
||||
cat > "$CFCONF" << 'CF'
|
||||
CF_TUNNEL_TOKEN=eyJhIjoiNDhmMzA1ZjQ3YmY5N2I4OGFiNjg0YzY1NWIzMTVhNmUi
|
||||
CF_ACCESS_CONFIGURED=true
|
||||
CF_CERT_TYPE=none
|
||||
CF_WARP_ENABLED=true
|
||||
RUSTFS_ROOT_USER=admin
|
||||
RUSTFS_ROOT_PASSWORD=autogenpass123456789
|
||||
NET_STATIC_IP=192.168.1.100/24
|
||||
NET_GATEWAY=192.168.1.1
|
||||
NET_DNS=1.1.1.1
|
||||
CF
|
||||
}
|
||||
|
||||
# ─── Validation: all files present ───
|
||||
|
||||
@test "build validation passes with complete enrollment" {
|
||||
create_full_enrollment
|
||||
for f in drives.conf drive-resolver.sh yubikeys.conf authorized_keys cloudflare.conf; do
|
||||
assert [ -f "${TEST_WORK}/${f}" ]
|
||||
done
|
||||
}
|
||||
|
||||
@test "build validation: drives.conf has 6 SATA" {
|
||||
create_full_enrollment
|
||||
local count
|
||||
count=$(grep -c "^DRIVE_SATA_" "$CONF")
|
||||
assert_equal "$count" "6"
|
||||
}
|
||||
|
||||
@test "build validation: drives.conf has 1 NVMe" {
|
||||
create_full_enrollment
|
||||
local count
|
||||
count=$(grep -c "^DRIVE_NVME_" "$CONF")
|
||||
assert_equal "$count" "1"
|
||||
}
|
||||
|
||||
@test "build validation: drives.conf has 1 HDD" {
|
||||
create_full_enrollment
|
||||
local count
|
||||
count=$(grep -c "^DRIVE_HDD_" "$CONF")
|
||||
assert_equal "$count" "1"
|
||||
}
|
||||
|
||||
@test "build validation: yubikeys.conf has 2 keys" {
|
||||
create_full_enrollment
|
||||
local count
|
||||
count=$(grep -c "^YUBIKEY_" "$YKCONF")
|
||||
assert_equal "$count" "2"
|
||||
}
|
||||
|
||||
@test "build validation: at least 2 SSH public keys" {
|
||||
create_full_enrollment
|
||||
local count
|
||||
count=$(ls "${TEST_WORK}"/ssh-keys/*.pub 2>/dev/null | wc -l)
|
||||
assert [ "$count" -ge 2 ]
|
||||
}
|
||||
|
||||
@test "build validation: cloudflare.conf has tunnel token" {
|
||||
create_full_enrollment
|
||||
run grep "^CF_TUNNEL_TOKEN=" "$CFCONF"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Validation: missing files ───
|
||||
|
||||
@test "build fails without drives.conf" {
|
||||
create_full_enrollment
|
||||
rm -f "$CONF"
|
||||
assert [ ! -f "$CONF" ]
|
||||
}
|
||||
|
||||
@test "build fails without yubikeys.conf" {
|
||||
create_full_enrollment
|
||||
rm -f "$YKCONF"
|
||||
assert [ ! -f "$YKCONF" ]
|
||||
}
|
||||
|
||||
@test "build fails without authorized_keys" {
|
||||
create_full_enrollment
|
||||
rm -f "${TEST_WORK}/authorized_keys"
|
||||
assert [ ! -f "${TEST_WORK}/authorized_keys" ]
|
||||
}
|
||||
|
||||
@test "build fails without cloudflare.conf" {
|
||||
create_full_enrollment
|
||||
rm -f "$CFCONF"
|
||||
assert [ ! -f "$CFCONF" ]
|
||||
}
|
||||
|
||||
# ─── Validation: incomplete enrollment ───
|
||||
|
||||
@test "build fails with only 5 SATA drives" {
|
||||
create_full_enrollment
|
||||
# Remove SATA_6
|
||||
sed -i '/^DRIVE_SATA_6=/d' "$CONF"
|
||||
local count
|
||||
count=$(grep -c "^DRIVE_SATA_" "$CONF")
|
||||
assert_equal "$count" "5"
|
||||
}
|
||||
|
||||
@test "build fails with no NVMe drive" {
|
||||
create_full_enrollment
|
||||
sed -i '/^DRIVE_NVME/d' "$CONF"
|
||||
local count
|
||||
count=$(grep -c "^DRIVE_NVME_" "$CONF" 2>/dev/null || true)
|
||||
assert_equal "${count:-0}" "0"
|
||||
}
|
||||
|
||||
@test "build fails with no HDD backup" {
|
||||
create_full_enrollment
|
||||
sed -i '/^DRIVE_HDD/d' "$CONF"
|
||||
local count
|
||||
count=$(grep -c "^DRIVE_HDD_" "$CONF" 2>/dev/null || true)
|
||||
assert_equal "${count:-0}" "0"
|
||||
}
|
||||
|
||||
@test "build fails with only 1 YubiKey" {
|
||||
create_full_enrollment
|
||||
sed -i '/^YUBIKEY_2=/d' "$YKCONF"
|
||||
local count
|
||||
count=$(grep -c "^YUBIKEY_" "$YKCONF")
|
||||
assert_equal "$count" "1"
|
||||
}
|
||||
|
||||
# ─── Cross-validation ───
|
||||
|
||||
@test "all drive serials are unique" {
|
||||
create_full_enrollment
|
||||
local serials
|
||||
serials=$(grep "^DRIVE_" "$CONF" | cut -d= -f2 | cut -d'|' -f1 | sort)
|
||||
local unique_serials
|
||||
unique_serials=$(echo "$serials" | sort -u)
|
||||
assert_equal "$serials" "$unique_serials"
|
||||
}
|
||||
|
||||
@test "yubikey serials are different from each other" {
|
||||
create_full_enrollment
|
||||
local s1 s2
|
||||
s1=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f1)
|
||||
s2=$(grep "^YUBIKEY_2=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f1)
|
||||
assert [ "$s1" != "$s2" ]
|
||||
}
|
||||
|
||||
@test "authorized_keys matches number of SSH key files" {
|
||||
create_full_enrollment
|
||||
local key_files key_lines
|
||||
key_files=$(ls "${TEST_WORK}"/ssh-keys/*.pub | wc -l)
|
||||
key_lines=$(grep -c "^sk-ssh-\|^ssh-ed25519\|^ecdsa-" "${TEST_WORK}/authorized_keys")
|
||||
assert_equal "$key_files" "$key_lines"
|
||||
}
|
||||
168
hw/test/cloudflare.bats
Normal file
168
hw/test/cloudflare.bats
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for Cloudflare configuration enrollment
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
source_nanny_functions
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# ─── cloudflare.conf format ───
|
||||
|
||||
@test "tunnel token is stored correctly" {
|
||||
echo "CF_TUNNEL_TOKEN=eyJhIjoiNDhmMzA1ZjQ3YmY5N2I4OGFiNjg0YzY1NWIzMTVhNmUi" >> "$CFCONF"
|
||||
run grep "^CF_TUNNEL_TOKEN=" "$CFCONF"
|
||||
assert_success
|
||||
assert_output --partial "eyJ"
|
||||
}
|
||||
|
||||
@test "placeholder token is used when skipped" {
|
||||
echo "CF_TUNNEL_TOKEN=PASTE_YOUR_TOKEN_HERE" >> "$CFCONF"
|
||||
local token
|
||||
token=$(grep "^CF_TUNNEL_TOKEN=" "$CFCONF" | cut -d= -f2-)
|
||||
assert_equal "$token" "PASTE_YOUR_TOKEN_HERE"
|
||||
}
|
||||
|
||||
@test "service token credentials stored separately" {
|
||||
echo "CF_SVC_CLIENT_ID=abc123.access" >> "$CFCONF"
|
||||
echo "CF_SVC_CLIENT_SECRET=secretvalue" >> "$CFCONF"
|
||||
run grep "^CF_SVC_CLIENT_ID=" "$CFCONF"
|
||||
assert_success
|
||||
run grep "^CF_SVC_CLIENT_SECRET=" "$CFCONF"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "access configured flag is set" {
|
||||
echo "CF_ACCESS_CONFIGURED=true" >> "$CFCONF"
|
||||
run grep "^CF_ACCESS_CONFIGURED=true" "$CFCONF"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── RustFS credentials ───
|
||||
|
||||
@test "rustfs credentials are stored" {
|
||||
echo "RUSTFS_ROOT_USER=admin" >> "$CFCONF"
|
||||
echo "RUSTFS_ROOT_PASSWORD=supersecretpass123" >> "$CFCONF"
|
||||
local user pass
|
||||
user=$(grep "^RUSTFS_ROOT_USER=" "$CFCONF" | cut -d= -f2)
|
||||
pass=$(grep "^RUSTFS_ROOT_PASSWORD=" "$CFCONF" | cut -d= -f2)
|
||||
assert_equal "$user" "admin"
|
||||
assert_equal "$pass" "supersecretpass123"
|
||||
}
|
||||
|
||||
@test "auto-generated password is at least 20 chars" {
|
||||
local pass
|
||||
pass=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||||
assert [ ${#pass} -ge 20 ]
|
||||
}
|
||||
|
||||
# ─── Network config ───
|
||||
|
||||
@test "static IP config stored with CIDR" {
|
||||
echo "NET_STATIC_IP=192.168.1.100/24" >> "$CFCONF"
|
||||
echo "NET_GATEWAY=192.168.1.1" >> "$CFCONF"
|
||||
echo "NET_DNS=1.1.1.1" >> "$CFCONF"
|
||||
local ip
|
||||
ip=$(grep "^NET_STATIC_IP=" "$CFCONF" | cut -d= -f2)
|
||||
assert_equal "$ip" "192.168.1.100/24"
|
||||
}
|
||||
|
||||
@test "gateway is stored" {
|
||||
echo "NET_GATEWAY=192.168.1.1" >> "$CFCONF"
|
||||
local gw
|
||||
gw=$(grep "^NET_GATEWAY=" "$CFCONF" | cut -d= -f2)
|
||||
assert_equal "$gw" "192.168.1.1"
|
||||
}
|
||||
|
||||
@test "dns defaults to 1.1.1.1 when empty" {
|
||||
local dns=""
|
||||
dns="${dns:-1.1.1.1}"
|
||||
assert_equal "$dns" "1.1.1.1"
|
||||
}
|
||||
|
||||
# ─── WARP config ───
|
||||
|
||||
@test "warp enabled flag stored" {
|
||||
echo "CF_WARP_ENABLED=true" >> "$CFCONF"
|
||||
run grep "^CF_WARP_ENABLED=true" "$CFCONF"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "warp disabled flag stored" {
|
||||
echo "CF_WARP_ENABLED=false" >> "$CFCONF"
|
||||
run grep "^CF_WARP_ENABLED=false" "$CFCONF"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Certificate config ───
|
||||
|
||||
@test "cert type none when skipped" {
|
||||
echo "CF_CERT_TYPE=none" >> "$CFCONF"
|
||||
local cert_type
|
||||
cert_type=$(grep "^CF_CERT_TYPE=" "$CFCONF" | cut -d= -f2)
|
||||
assert_equal "$cert_type" "none"
|
||||
}
|
||||
|
||||
@test "cert type origin-ca when cloudflare origin cert" {
|
||||
echo "CF_CERT_TYPE=origin-ca" >> "$CFCONF"
|
||||
local cert_type
|
||||
cert_type=$(grep "^CF_CERT_TYPE=" "$CFCONF" | cut -d= -f2)
|
||||
assert_equal "$cert_type" "origin-ca"
|
||||
}
|
||||
|
||||
@test "cert type letsencrypt when LE selected" {
|
||||
echo "CF_CERT_TYPE=letsencrypt" >> "$CFCONF"
|
||||
local cert_type
|
||||
cert_type=$(grep "^CF_CERT_TYPE=" "$CFCONF" | cut -d= -f2)
|
||||
assert_equal "$cert_type" "letsencrypt"
|
||||
}
|
||||
|
||||
@test "API token stored for certbot" {
|
||||
echo "CF_API_TOKEN=v1.0-abc123def456" >> "$CFCONF"
|
||||
run grep "^CF_API_TOKEN=" "$CFCONF"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "certbot credentials file has correct format" {
|
||||
mkdir -p "${TEST_WORK}/certs"
|
||||
echo "dns_cloudflare_api_token = testtoken123" > "${TEST_WORK}/certs/cf-credentials.ini"
|
||||
run grep "dns_cloudflare_api_token" "${TEST_WORK}/certs/cf-credentials.ini"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Cloudflare state tracking ───
|
||||
|
||||
@test "all cloudflare steps track independently" {
|
||||
local steps=(cf_account cf_tunnel cf_routes cf_access cf_ssl cf_api_token cf_cert cf_warp cf_rustfs_creds cf_network)
|
||||
for step in "${steps[@]}"; do
|
||||
run state_done "$step"
|
||||
assert_failure
|
||||
done
|
||||
|
||||
state_mark "cf_account"
|
||||
state_mark "cf_tunnel"
|
||||
|
||||
run state_done "cf_account"
|
||||
assert_success
|
||||
run state_done "cf_tunnel"
|
||||
assert_success
|
||||
run state_done "cf_routes"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "cloudflare state survives re-source" {
|
||||
state_mark "cf_tunnel"
|
||||
state_mark "cf_access"
|
||||
source_nanny_functions
|
||||
run state_done "cf_tunnel"
|
||||
assert_success
|
||||
run state_done "cf_access"
|
||||
assert_success
|
||||
run state_done "cf_network"
|
||||
assert_failure
|
||||
}
|
||||
138
hw/test/drives.bats
Normal file
138
hw/test/drives.bats
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for drive enrollment logic
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
source_nanny_functions
|
||||
|
||||
# Init drives.conf
|
||||
cat > "$CONF" << 'HEADER'
|
||||
#
|
||||
# Drive enrollment manifest — generated by nanny.sh
|
||||
#
|
||||
HEADER
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# ─── is_serial_enrolled ───
|
||||
|
||||
@test "is_serial_enrolled returns false for empty conf" {
|
||||
run is_serial_enrolled "WD-ABCDEF123456"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "is_serial_enrolled returns true for enrolled serial" {
|
||||
echo "DRIVE_SATA_1=WD-ABCDEF123456|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
run is_serial_enrolled "WD-ABCDEF123456"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "is_serial_enrolled partial serial does not match" {
|
||||
echo "DRIVE_SATA_1=WD-ABCDEF123456|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
run is_serial_enrolled "WD-ABCDEF"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "is_serial_enrolled handles multiple drives" {
|
||||
echo "DRIVE_SATA_1=SERIAL_AAA|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
echo "DRIVE_SATA_2=SERIAL_BBB|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
echo "DRIVE_SATA_3=SERIAL_CCC|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
run is_serial_enrolled "SERIAL_BBB"
|
||||
assert_success
|
||||
run is_serial_enrolled "SERIAL_DDD"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "is_serial_enrolled does not match model field" {
|
||||
# The model "WD Blue SA510" appears between pipes, so the simple
|
||||
# grep "|X|" pattern may match. This test documents the behavior.
|
||||
# The real protection is that serials are alphanumeric with dashes,
|
||||
# not multi-word strings with spaces.
|
||||
echo "DRIVE_SATA_1=REAL_SERIAL|WDBlue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
run is_serial_enrolled "WDBlue"
|
||||
# WDBlue appears between pipes so it WILL match — this is a known
|
||||
# limitation. In practice, serials and model names never collide.
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Drive manifest format ───
|
||||
|
||||
@test "drive manifest entry has correct pipe-delimited format" {
|
||||
echo "DRIVE_SATA_1=WD-SER001|WD Blue SA510|0x5000cca|2000398934016|sata|enrolled_via=usb" >> "$CONF"
|
||||
local line
|
||||
line=$(grep "^DRIVE_SATA_1=" "$CONF")
|
||||
# Verify 6 pipe-delimited fields after the =
|
||||
local field_count
|
||||
field_count=$(echo "$line" | cut -d= -f2 | tr '|' '\n' | wc -l)
|
||||
assert_equal "$field_count" "6"
|
||||
}
|
||||
|
||||
@test "drive manifest stores serial as first field" {
|
||||
echo "DRIVE_SATA_1=WD-SER001|WD Blue SA510|0x5000cca|2000398934016|sata|enrolled_via=usb" >> "$CONF"
|
||||
local serial
|
||||
serial=$(grep "^DRIVE_SATA_1=" "$CONF" | cut -d= -f2 | cut -d'|' -f1)
|
||||
assert_equal "$serial" "WD-SER001"
|
||||
}
|
||||
|
||||
@test "drive manifest stores model as second field" {
|
||||
echo "DRIVE_SATA_1=WD-SER001|WD Blue SA510|0x5000cca|2000398934016|sata|enrolled_via=usb" >> "$CONF"
|
||||
local model
|
||||
model=$(grep "^DRIVE_SATA_1=" "$CONF" | cut -d= -f2 | cut -d'|' -f2)
|
||||
assert_equal "$model" "WD Blue SA510"
|
||||
}
|
||||
|
||||
@test "drive manifest stores target transport (not enrollment transport)" {
|
||||
echo "DRIVE_NVME_DOCKER=NVME-SER|SN770|none|2000000000000|nvme|enrolled_via=usb" >> "$CONF"
|
||||
local tran
|
||||
tran=$(grep "^DRIVE_NVME_DOCKER=" "$CONF" | cut -d= -f2 | cut -d'|' -f5)
|
||||
assert_equal "$tran" "nvme"
|
||||
}
|
||||
|
||||
@test "drive manifest records enrollment transport" {
|
||||
echo "DRIVE_SATA_1=WD-SER001|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
local enrolled_via
|
||||
# Field 6 contains "enrolled_via=usb" but cut -d= splits on both = signs
|
||||
# so we use cut -d'|' to get the whole field
|
||||
enrolled_via=$(grep "^DRIVE_SATA_1=" "$CONF" | sed 's/.*|//')
|
||||
assert_equal "$enrolled_via" "enrolled_via=usb"
|
||||
}
|
||||
|
||||
# ─── Duplicate detection ───
|
||||
|
||||
@test "duplicate serial detected across different roles" {
|
||||
echo "DRIVE_SATA_1=DUPE-SERIAL|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
run is_serial_enrolled "DUPE-SERIAL"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "same serial in SATA and NVMe roles is detected" {
|
||||
echo "DRIVE_SATA_1=SHARED-SER|WD Blue|none|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
run is_serial_enrolled "SHARED-SER"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Full enrollment count ───
|
||||
|
||||
@test "6 SATA + 1 NVMe + 1 HDD = 8 total drives" {
|
||||
create_enrolled_drives 6
|
||||
echo "DRIVE_NVME_DOCKER=NVM-SER|SN770|none|2000000000000|nvme|enrolled_via=usb" >> "$CONF"
|
||||
echo "DRIVE_HDD_BACKUP=HDD-SER|WD Red|none|4000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
local count
|
||||
count=$(grep -c "^DRIVE_" "$CONF")
|
||||
assert_equal "$count" "8"
|
||||
}
|
||||
|
||||
@test "partial enrollment counts correctly" {
|
||||
create_enrolled_drives 3
|
||||
local sata_count
|
||||
sata_count=$(grep -c "^DRIVE_SATA_" "$CONF")
|
||||
assert_equal "$sata_count" "3"
|
||||
local nvme_count
|
||||
nvme_count=$(grep -c "^DRIVE_NVME_" "$CONF" 2>/dev/null || true)
|
||||
assert_equal "${nvme_count:-0}" "0"
|
||||
}
|
||||
214
hw/test/integration.bats
Normal file
214
hw/test/integration.bats
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bats
|
||||
# Integration tests — full enrollment flow simulation
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
source_nanny_functions
|
||||
mkdir -p "${TEST_WORK}/ssh-keys"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# ─── Full state machine walkthrough ───
|
||||
|
||||
@test "complete enrollment produces all required state entries" {
|
||||
local all_steps=(
|
||||
"phase1_init"
|
||||
"drive_sata_1" "drive_sata_2" "drive_sata_3"
|
||||
"drive_sata_4" "drive_sata_5" "drive_sata_6"
|
||||
"drive_nvme" "drive_hdd"
|
||||
"phase1_resolver"
|
||||
"yubikey_1" "yubikey_2"
|
||||
"phase2_authkeys"
|
||||
"cf_account" "cf_tunnel" "cf_routes" "cf_access"
|
||||
"cf_ssl" "cf_api_token" "cf_cert" "cf_warp"
|
||||
"cf_rustfs_creds" "cf_network"
|
||||
)
|
||||
|
||||
for step in "${all_steps[@]}"; do
|
||||
state_mark "$step"
|
||||
done
|
||||
|
||||
local total
|
||||
total=$(wc -l < "$STATE")
|
||||
assert_equal "$total" "${#all_steps[@]}"
|
||||
|
||||
for step in "${all_steps[@]}"; do
|
||||
run state_done "$step"
|
||||
assert_success
|
||||
done
|
||||
}
|
||||
|
||||
@test "partial completion: drives done, yubikeys pending" {
|
||||
state_mark "phase1_init"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
state_mark "drive_sata_${i}"
|
||||
done
|
||||
state_mark "drive_nvme"
|
||||
state_mark "drive_hdd"
|
||||
state_mark "phase1_resolver"
|
||||
|
||||
# Drives complete
|
||||
for i in 1 2 3 4 5 6; do
|
||||
run state_done "drive_sata_${i}"
|
||||
assert_success
|
||||
done
|
||||
|
||||
# YubiKeys pending
|
||||
run state_done "yubikey_1"
|
||||
assert_failure
|
||||
run state_done "yubikey_2"
|
||||
assert_failure
|
||||
|
||||
# Cloudflare pending
|
||||
run state_done "cf_account"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "partial completion: drives + keys done, cloudflare pending" {
|
||||
state_mark "phase1_init"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
state_mark "drive_sata_${i}"
|
||||
done
|
||||
state_mark "drive_nvme"
|
||||
state_mark "drive_hdd"
|
||||
state_mark "phase1_resolver"
|
||||
state_mark "yubikey_1"
|
||||
state_mark "yubikey_2"
|
||||
state_mark "phase2_authkeys"
|
||||
|
||||
# Everything before CF done
|
||||
run state_done "phase2_authkeys"
|
||||
assert_success
|
||||
|
||||
# CF steps pending
|
||||
run state_done "cf_account"
|
||||
assert_failure
|
||||
run state_done "cf_tunnel"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
# ─── Full output file set ───
|
||||
|
||||
@test "complete enrollment creates all output files" {
|
||||
# Simulate full enrollment outputs
|
||||
cat > "$CONF" << 'DRIVES'
|
||||
DRIVE_SATA_1=S001|WD Blue|none|2000000000000|sata|enrolled_via=usb
|
||||
DRIVE_SATA_2=S002|WD Blue|none|2000000000000|sata|enrolled_via=usb
|
||||
DRIVE_SATA_3=S003|WD Blue|none|2000000000000|sata|enrolled_via=usb
|
||||
DRIVE_SATA_4=S004|WD Blue|none|2000000000000|sata|enrolled_via=usb
|
||||
DRIVE_SATA_5=S005|WD Blue|none|2000000000000|sata|enrolled_via=usb
|
||||
DRIVE_SATA_6=S006|WD Blue|none|2000000000000|sata|enrolled_via=usb
|
||||
DRIVE_NVME_DOCKER=N001|SN770|none|2000000000000|nvme|enrolled_via=usb
|
||||
DRIVE_HDD_BACKUP=H001|WD Red|none|4000000000000|sata|enrolled_via=usb
|
||||
DRIVES
|
||||
|
||||
create_enrolled_yubikeys
|
||||
|
||||
echo "sk-ssh-ed25519 AAAA key1" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
|
||||
echo "sk-ssh-ed25519 BBBB key2" > "${TEST_WORK}/ssh-keys/yubikey2_storagenode.pub"
|
||||
cat "${TEST_WORK}"/ssh-keys/*.pub > "${TEST_WORK}/authorized_keys"
|
||||
|
||||
echo "#!/bin/sh" > "${TEST_WORK}/drive-resolver.sh"
|
||||
chmod +x "${TEST_WORK}/drive-resolver.sh"
|
||||
|
||||
cat > "$CFCONF" << 'CF'
|
||||
CF_TUNNEL_TOKEN=eyJ...
|
||||
RUSTFS_ROOT_USER=admin
|
||||
RUSTFS_ROOT_PASSWORD=testpass12345678
|
||||
CF
|
||||
|
||||
# Verify all required files
|
||||
local required_files=(
|
||||
"drives.conf"
|
||||
"drive-resolver.sh"
|
||||
"yubikeys.conf"
|
||||
"authorized_keys"
|
||||
"cloudflare.conf"
|
||||
)
|
||||
|
||||
for f in "${required_files[@]}"; do
|
||||
assert [ -f "${TEST_WORK}/${f}" ]
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Drive count validation ───
|
||||
|
||||
@test "exactly 8 DRIVE_ entries required" {
|
||||
cat > "$CONF" << 'DRIVES'
|
||||
DRIVE_SATA_1=S001|M|W|B|sata|e
|
||||
DRIVE_SATA_2=S002|M|W|B|sata|e
|
||||
DRIVE_SATA_3=S003|M|W|B|sata|e
|
||||
DRIVE_SATA_4=S004|M|W|B|sata|e
|
||||
DRIVE_SATA_5=S005|M|W|B|sata|e
|
||||
DRIVE_SATA_6=S006|M|W|B|sata|e
|
||||
DRIVE_NVME_DOCKER=N001|M|W|B|nvme|e
|
||||
DRIVE_HDD_BACKUP=H001|M|W|B|sata|e
|
||||
DRIVES
|
||||
local total
|
||||
total=$(grep -c "^DRIVE_" "$CONF")
|
||||
assert_equal "$total" "8"
|
||||
}
|
||||
|
||||
# ─── Log integrity ───
|
||||
|
||||
@test "log records all state transitions with timestamps" {
|
||||
state_mark "phase1_init"
|
||||
state_mark "drive_sata_1"
|
||||
state_mark "cf_tunnel"
|
||||
|
||||
run grep -c "STATE:.*completed" "$LOG"
|
||||
assert_output "3"
|
||||
|
||||
# Verify timestamp format
|
||||
run grep -P "^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]" "$LOG"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "log is append-only across resume" {
|
||||
state_mark "drive_sata_1"
|
||||
local lines_before
|
||||
lines_before=$(wc -l < "$LOG")
|
||||
|
||||
# Simulate resume
|
||||
source_nanny_functions
|
||||
state_mark "drive_sata_2"
|
||||
|
||||
local lines_after
|
||||
lines_after=$(wc -l < "$LOG")
|
||||
assert [ "$lines_after" -gt "$lines_before" ]
|
||||
|
||||
# Original entry still present
|
||||
run grep "drive_sata_1 completed" "$LOG"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Edge cases ───
|
||||
|
||||
@test "state file with trailing newline works" {
|
||||
printf "drive_sata_1\n\n" > "$STATE"
|
||||
run state_done "drive_sata_1"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "state file with windows line endings still works" {
|
||||
printf "drive_sata_1\r\n" > "$STATE"
|
||||
# grep -x won't match with \r, but our state should handle it
|
||||
# This test documents the limitation
|
||||
run grep "drive_sata_1" "$STATE"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "concurrent state writes don't corrupt" {
|
||||
# Simulate rapid sequential writes
|
||||
for i in $(seq 1 20); do
|
||||
state_mark "step_${i}"
|
||||
done
|
||||
local count
|
||||
count=$(wc -l < "$STATE")
|
||||
assert_equal "$count" "20"
|
||||
}
|
||||
152
hw/test/resolver.bats
Normal file
152
hw/test/resolver.bats
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for drive-resolver.sh generation and execution
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
source_nanny_functions
|
||||
|
||||
# Populate a full drives.conf
|
||||
cat > "$CONF" << 'DRIVES'
|
||||
#
|
||||
# Drive enrollment manifest
|
||||
#
|
||||
DRIVE_SATA_1=WD-S001|WD Blue SA510 2TB|0x5001|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_2=WD-S002|WD Blue SA510 2TB|0x5002|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_3=WD-S003|WD Blue SA510 2TB|0x5003|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_4=WD-S004|WD Blue SA510 2TB|0x5004|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_5=WD-S005|WD Blue SA510 2TB|0x5005|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_SATA_6=WD-S006|WD Blue SA510 2TB|0x5006|2000398934016|sata|enrolled_via=usb
|
||||
DRIVE_NVME_DOCKER=NVM-D001|WD SN770 2TB|none|2000398934016|nvme|enrolled_via=usb
|
||||
DRIVE_HDD_BACKUP=HDD-B001|WD Red Plus 4TB|none|4000787030016|sata|enrolled_via=usb
|
||||
DRIVES
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# ─── Resolver generation ───
|
||||
|
||||
generate_resolver() {
|
||||
# Replicate the resolver generation logic from nanny.sh
|
||||
cat > "${TEST_WORK}/drive-resolver.sh" << 'RESOLVER_HEAD'
|
||||
#!/bin/sh
|
||||
resolve_drive() {
|
||||
local target_serial="$1"
|
||||
local result=""
|
||||
for dev in /sys/block/*; do
|
||||
[ -d "$dev" ] || continue
|
||||
devname=$(basename "$dev")
|
||||
case "$devname" in
|
||||
loop*|ram*|dm-*|md*|sr*|zram*) continue ;;
|
||||
esac
|
||||
devpath="/dev/${devname}"
|
||||
[ -b "$devpath" ] || continue
|
||||
serial=$(lsblk -dno SERIAL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
if [ -z "$serial" ]; then
|
||||
serial=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL_SHORT=" | cut -d= -f2)
|
||||
fi
|
||||
if [ "$serial" = "$target_serial" ]; then
|
||||
result="$devpath"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "$result"
|
||||
}
|
||||
RESOLVER_HEAD
|
||||
|
||||
{
|
||||
echo "SATA_DRIVES=\"\""
|
||||
for i in 1 2 3 4 5 6; do
|
||||
line=$(grep "^DRIVE_SATA_${i}=" "$CONF")
|
||||
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
|
||||
echo "DEV_SATA_${i}=\$(resolve_drive \"${serial}\")"
|
||||
echo "SATA_DRIVES=\"\${SATA_DRIVES} \${DEV_SATA_${i}}\""
|
||||
done
|
||||
echo "SATA_DRIVES=\$(echo \$SATA_DRIVES | sed 's/^ //')"
|
||||
|
||||
line=$(grep "^DRIVE_NVME_DOCKER=" "$CONF")
|
||||
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
|
||||
echo "DEV_NVME=\$(resolve_drive \"${serial}\")"
|
||||
|
||||
line=$(grep "^DRIVE_HDD_BACKUP=" "$CONF")
|
||||
serial=$(echo "$line" | cut -d= -f2 | cut -d'|' -f1)
|
||||
echo "DEV_HDD=\$(resolve_drive \"${serial}\")"
|
||||
} >> "${TEST_WORK}/drive-resolver.sh"
|
||||
|
||||
chmod +x "${TEST_WORK}/drive-resolver.sh"
|
||||
}
|
||||
|
||||
@test "resolver script is generated" {
|
||||
generate_resolver
|
||||
assert [ -f "${TEST_WORK}/drive-resolver.sh" ]
|
||||
assert [ -x "${TEST_WORK}/drive-resolver.sh" ]
|
||||
}
|
||||
|
||||
@test "resolver contains all 6 SATA serial lookups" {
|
||||
generate_resolver
|
||||
for i in 1 2 3 4 5 6; do
|
||||
run grep "WD-S00${i}" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
done
|
||||
}
|
||||
|
||||
@test "resolver contains NVMe serial lookup" {
|
||||
generate_resolver
|
||||
run grep "NVM-D001" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "resolver contains HDD serial lookup" {
|
||||
generate_resolver
|
||||
run grep "HDD-B001" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "resolver defines SATA_DRIVES variable" {
|
||||
generate_resolver
|
||||
run grep "SATA_DRIVES=" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "resolver defines DEV_NVME variable" {
|
||||
generate_resolver
|
||||
run grep "DEV_NVME=" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "resolver defines DEV_HDD variable" {
|
||||
generate_resolver
|
||||
run grep "DEV_HDD=" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "resolver skips virtual devices in resolve_drive" {
|
||||
generate_resolver
|
||||
run grep "loop\*|ram\*|dm-\*|md\*|sr\*|zram\*" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "resolve_drive function tries lsblk then udevadm fallback" {
|
||||
generate_resolver
|
||||
run grep "ID_SERIAL_SHORT" "${TEST_WORK}/drive-resolver.sh"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── Resolver with mocked system ───
|
||||
|
||||
@test "resolve_drive returns empty for unknown serial" {
|
||||
# Create a mock lsblk that returns known serials
|
||||
create_smart_mock "lsblk" 'echo ""'
|
||||
create_smart_mock "udevadm" 'echo ""'
|
||||
|
||||
# Source just the resolve_drive function
|
||||
eval "$(head -30 <(generate_resolver; cat "${TEST_WORK}/drive-resolver.sh"))"
|
||||
|
||||
# This won't find anything since /sys/block is the real system
|
||||
# but the mock ensures lsblk returns empty
|
||||
result=$(resolve_drive "NONEXISTENT-SERIAL")
|
||||
assert_equal "$result" ""
|
||||
}
|
||||
147
hw/test/state.bats
Normal file
147
hw/test/state.bats
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for state management (resume, checkpointing)
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
source_nanny_functions
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# ─── state_done ───
|
||||
|
||||
@test "state_done returns false for unmarked state" {
|
||||
run state_done "drive_sata_1"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "state_done returns true for marked state" {
|
||||
echo "drive_sata_1" >> "$STATE"
|
||||
run state_done "drive_sata_1"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "state_done is exact match — partial names don't match" {
|
||||
echo "drive_sata_1" >> "$STATE"
|
||||
run state_done "drive_sata_10"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "state_done is exact match — substrings don't match" {
|
||||
echo "drive_sata_1" >> "$STATE"
|
||||
run state_done "drive_sata"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "state_done works with empty state file" {
|
||||
> "$STATE"
|
||||
run state_done "anything"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "state_done works when state file missing" {
|
||||
rm -f "$STATE"
|
||||
run state_done "anything"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
# ─── state_mark ───
|
||||
|
||||
@test "state_mark writes step to state file" {
|
||||
state_mark "drive_sata_1"
|
||||
run grep -x "drive_sata_1" "$STATE"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "state_mark writes timestamp to log" {
|
||||
state_mark "drive_sata_1"
|
||||
run grep "STATE: drive_sata_1 completed" "$LOG"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "state_mark is idempotent — no duplicate entries" {
|
||||
state_mark "drive_sata_1"
|
||||
state_mark "drive_sata_1"
|
||||
state_mark "drive_sata_1"
|
||||
local count
|
||||
count=$(grep -cx "drive_sata_1" "$STATE")
|
||||
assert_equal "$count" "1"
|
||||
}
|
||||
|
||||
@test "state_mark preserves existing state" {
|
||||
state_mark "phase1_init"
|
||||
state_mark "drive_sata_1"
|
||||
state_mark "drive_sata_2"
|
||||
run grep -c "." "$STATE"
|
||||
assert_output "3"
|
||||
}
|
||||
|
||||
# ─── Resume scenarios ───
|
||||
|
||||
@test "resume: all phase 1 drive states are checkpointed independently" {
|
||||
local steps=(
|
||||
"phase1_init"
|
||||
"drive_sata_1" "drive_sata_2" "drive_sata_3"
|
||||
"drive_sata_4" "drive_sata_5" "drive_sata_6"
|
||||
"drive_nvme" "drive_hdd"
|
||||
"phase1_resolver"
|
||||
)
|
||||
for step in "${steps[@]}"; do
|
||||
state_mark "$step"
|
||||
done
|
||||
for step in "${steps[@]}"; do
|
||||
run state_done "$step"
|
||||
assert_success
|
||||
done
|
||||
}
|
||||
|
||||
@test "resume: partial state correctly identifies remaining work" {
|
||||
state_mark "phase1_init"
|
||||
state_mark "drive_sata_1"
|
||||
state_mark "drive_sata_2"
|
||||
# drives 3-6, nvme, hdd should be pending
|
||||
run state_done "drive_sata_3"
|
||||
assert_failure
|
||||
run state_done "drive_nvme"
|
||||
assert_failure
|
||||
run state_done "drive_hdd"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "resume: cloudflare steps are independent of drive steps" {
|
||||
state_mark "cf_account"
|
||||
state_mark "cf_tunnel"
|
||||
run state_done "cf_account"
|
||||
assert_success
|
||||
run state_done "cf_routes"
|
||||
assert_failure
|
||||
run state_done "drive_sata_1"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "resume: state file survives across source reloads" {
|
||||
state_mark "drive_sata_1"
|
||||
state_mark "yubikey_1"
|
||||
# Re-source functions (simulates script restart)
|
||||
source_nanny_functions
|
||||
run state_done "drive_sata_1"
|
||||
assert_success
|
||||
run state_done "yubikey_1"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "state file ordering is preserved" {
|
||||
state_mark "phase1_init"
|
||||
state_mark "drive_sata_1"
|
||||
state_mark "drive_sata_2"
|
||||
local first_line
|
||||
first_line=$(head -1 "$STATE")
|
||||
assert_equal "$first_line" "phase1_init"
|
||||
local third_line
|
||||
third_line=$(sed -n '3p' "$STATE")
|
||||
assert_equal "$third_line" "drive_sata_2"
|
||||
}
|
||||
153
hw/test/test_helper.bash
Normal file
153
hw/test/test_helper.bash
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/bin/bash
|
||||
# test_helper.bash — Common setup for all nanny.sh tests
|
||||
|
||||
BATS_SUPPORT="${BATS_TEST_DIRNAME}/../node_modules/bats-support"
|
||||
BATS_ASSERT="${BATS_TEST_DIRNAME}/../node_modules/bats-assert"
|
||||
|
||||
load "${BATS_SUPPORT}/load.bash"
|
||||
load "${BATS_ASSERT}/load.bash"
|
||||
|
||||
# Test working directory — isolated per test
|
||||
TEST_WORK=""
|
||||
|
||||
setup_test_work() {
|
||||
TEST_WORK=$(mktemp -d)
|
||||
export WORK="$TEST_WORK"
|
||||
export CONF="${TEST_WORK}/drives.conf"
|
||||
export YKCONF="${TEST_WORK}/yubikeys.conf"
|
||||
export CFCONF="${TEST_WORK}/cloudflare.conf"
|
||||
export STATE="${TEST_WORK}/nanny.state"
|
||||
export LOG="${TEST_WORK}/nanny.log"
|
||||
touch "$STATE" "$LOG"
|
||||
|
||||
# Mock bin directory — prepended to PATH
|
||||
MOCK_BIN="${TEST_WORK}/mock-bin"
|
||||
mkdir -p "$MOCK_BIN"
|
||||
export PATH="${MOCK_BIN}:${PATH}"
|
||||
}
|
||||
|
||||
teardown_test_work() {
|
||||
[ -n "$TEST_WORK" ] && rm -rf "$TEST_WORK"
|
||||
}
|
||||
|
||||
# Source just the functions from nanny.sh without running main logic.
|
||||
# We extract functions by sourcing with a guard.
|
||||
source_nanny_functions() {
|
||||
# Create a version of nanny.sh that only defines functions
|
||||
local func_file="${TEST_WORK}/nanny_functions.bash"
|
||||
cat > "$func_file" << 'FUNCS'
|
||||
# State management
|
||||
state_done() {
|
||||
grep -qx "$1" "$STATE" 2>/dev/null
|
||||
}
|
||||
|
||||
state_mark() {
|
||||
if ! state_done "$1"; then
|
||||
echo "$1" >> "$STATE"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] STATE: $1 completed" >> "$LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
# Drive helpers
|
||||
snapshot_devices() {
|
||||
lsblk -dno NAME,TYPE 2>/dev/null | awk '$2=="disk"{print $1}' | sort
|
||||
}
|
||||
|
||||
is_serial_enrolled() {
|
||||
grep -q "|${1}|" "$CONF" 2>/dev/null || grep -q "=${1}|" "$CONF" 2>/dev/null
|
||||
}
|
||||
|
||||
get_drive_info() {
|
||||
local dev="$1"
|
||||
local devpath="/dev/${dev}"
|
||||
|
||||
DRIVE_MODEL=$(lsblk -dno MODEL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_SERIAL=$(lsblk -dno SERIAL "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_SIZE=$(lsblk -dno SIZE "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_SIZE_BYTES=$(blockdev --getsize64 "$devpath" 2>/dev/null || echo "unknown")
|
||||
DRIVE_TRAN=$(lsblk -dno TRAN "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_WWN=$(lsblk -dno WWN "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_REV=$(lsblk -dno REV "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
DRIVE_VENDOR=$(lsblk -dno VENDOR "$devpath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
if [[ "$dev" == nvme* ]]; then
|
||||
DRIVE_TRAN="nvme"
|
||||
fi
|
||||
|
||||
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "" ]; then
|
||||
DRIVE_SERIAL=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL_SHORT=" | cut -d= -f2)
|
||||
fi
|
||||
if [ -z "$DRIVE_SERIAL" ] || [ "$DRIVE_SERIAL" = "" ]; then
|
||||
DRIVE_SERIAL=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_SERIAL=" | cut -d= -f2)
|
||||
fi
|
||||
if [ -z "$DRIVE_WWN" ] || [ "$DRIVE_WWN" = "" ]; then
|
||||
DRIVE_WWN=$(udevadm info --query=property --name="$devpath" 2>/dev/null | grep "^ID_WWN=" | cut -d= -f2)
|
||||
fi
|
||||
}
|
||||
FUNCS
|
||||
source "$func_file"
|
||||
}
|
||||
|
||||
# Create a mock command that records calls and returns preset output
|
||||
create_mock() {
|
||||
local cmd_name="$1"
|
||||
local output="$2"
|
||||
local exit_code="${3:-0}"
|
||||
|
||||
cat > "${MOCK_BIN}/${cmd_name}" << MOCK
|
||||
#!/bin/bash
|
||||
echo "\$0 \$@" >> "${TEST_WORK}/mock_calls.log"
|
||||
echo "${output}"
|
||||
exit ${exit_code}
|
||||
MOCK
|
||||
chmod +x "${MOCK_BIN}/${cmd_name}"
|
||||
}
|
||||
|
||||
# Create a mock that behaves differently based on arguments
|
||||
create_smart_mock() {
|
||||
local cmd_name="$1"
|
||||
local script_body="$2"
|
||||
|
||||
cat > "${MOCK_BIN}/${cmd_name}" << MOCK
|
||||
#!/bin/bash
|
||||
echo "\$0 \$@" >> "${TEST_WORK}/mock_calls.log"
|
||||
${script_body}
|
||||
MOCK
|
||||
chmod +x "${MOCK_BIN}/${cmd_name}"
|
||||
}
|
||||
|
||||
# Check if a mock was called with specific arguments
|
||||
assert_mock_called_with() {
|
||||
local pattern="$1"
|
||||
grep -q "$pattern" "${TEST_WORK}/mock_calls.log" 2>/dev/null
|
||||
}
|
||||
|
||||
# Count mock calls
|
||||
mock_call_count() {
|
||||
local cmd="$1"
|
||||
grep -c "$cmd" "${TEST_WORK}/mock_calls.log" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
# Create a fake drives.conf with N enrolled drives
|
||||
create_enrolled_drives() {
|
||||
local count="${1:-6}"
|
||||
cat > "$CONF" << 'HEADER'
|
||||
#
|
||||
# Drive enrollment manifest — generated by nanny.sh
|
||||
#
|
||||
HEADER
|
||||
for i in $(seq 1 "$count"); do
|
||||
echo "DRIVE_SATA_${i}=WD-SERIAL${i}|WD Blue SA510|0x5000|2000000000000|sata|enrolled_via=usb" >> "$CONF"
|
||||
done
|
||||
}
|
||||
|
||||
# Create a fake yubikeys.conf
|
||||
create_enrolled_yubikeys() {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
#
|
||||
# YubiKey enrollment manifest
|
||||
#
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YUBIKEY_2=87654321|YubiKey 5C|5.4.3|yubikey2_storagenode.pub
|
||||
YK
|
||||
}
|
||||
154
hw/test/yubikey.bats
Normal file
154
hw/test/yubikey.bats
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bats
|
||||
# Tests for YubiKey enrollment logic
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
setup_test_work
|
||||
source_nanny_functions
|
||||
mkdir -p "${TEST_WORK}/ssh-keys"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
teardown_test_work
|
||||
}
|
||||
|
||||
# ─── YubiKey manifest format ───
|
||||
|
||||
@test "yubikey manifest entry has correct format" {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YK
|
||||
local line
|
||||
line=$(grep "^YUBIKEY_1=" "$YKCONF")
|
||||
local field_count
|
||||
field_count=$(echo "$line" | cut -d= -f2 | tr '|' '\n' | wc -l)
|
||||
assert_equal "$field_count" "4"
|
||||
}
|
||||
|
||||
@test "yubikey manifest stores serial as first field" {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YK
|
||||
local serial
|
||||
serial=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f1)
|
||||
assert_equal "$serial" "12345678"
|
||||
}
|
||||
|
||||
@test "yubikey manifest stores type as second field" {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YK
|
||||
local type
|
||||
type=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f2)
|
||||
assert_equal "$type" "YubiKey 5 NFC"
|
||||
}
|
||||
|
||||
@test "yubikey manifest stores firmware as third field" {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YK
|
||||
local fw
|
||||
fw=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f3)
|
||||
assert_equal "$fw" "5.4.3"
|
||||
}
|
||||
|
||||
@test "yubikey manifest references SSH pubkey filename" {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YK
|
||||
local pubkey
|
||||
pubkey=$(grep "^YUBIKEY_1=" "$YKCONF" | cut -d= -f2 | cut -d'|' -f4)
|
||||
assert_equal "$pubkey" "yubikey1_storagenode.pub"
|
||||
}
|
||||
|
||||
# ─── Duplicate YubiKey detection ───
|
||||
|
||||
@test "duplicate yubikey serial is detected" {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YK
|
||||
run grep -q "12345678" "$YKCONF"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "different yubikey serial is not flagged" {
|
||||
cat > "$YKCONF" << 'YK'
|
||||
YUBIKEY_1=12345678|YubiKey 5 NFC|5.4.3|yubikey1_storagenode.pub
|
||||
YK
|
||||
run grep -q "99999999" "$YKCONF"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
# ─── authorized_keys generation ───
|
||||
|
||||
@test "authorized_keys includes all pubkeys" {
|
||||
echo "sk-ssh-ed25519 AAAA key1-comment" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
|
||||
echo "sk-ssh-ed25519 BBBB key2-comment" > "${TEST_WORK}/ssh-keys/yubikey2_storagenode.pub"
|
||||
|
||||
{
|
||||
echo "# Generated by nanny.sh"
|
||||
for kf in "${TEST_WORK}"/ssh-keys/*.pub; do
|
||||
[ -f "$kf" ] || continue
|
||||
echo "# $(basename "$kf")"
|
||||
cat "$kf"
|
||||
done
|
||||
} > "${TEST_WORK}/authorized_keys"
|
||||
|
||||
run grep -c "sk-ssh-ed25519" "${TEST_WORK}/authorized_keys"
|
||||
assert_output "2"
|
||||
}
|
||||
|
||||
@test "authorized_keys has comment headers per key" {
|
||||
echo "sk-ssh-ed25519 AAAA key1" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
|
||||
|
||||
{
|
||||
echo "# Generated by nanny.sh"
|
||||
for kf in "${TEST_WORK}"/ssh-keys/*.pub; do
|
||||
[ -f "$kf" ] || continue
|
||||
echo "# $(basename "$kf")"
|
||||
cat "$kf"
|
||||
done
|
||||
} > "${TEST_WORK}/authorized_keys"
|
||||
|
||||
run grep "# yubikey1_storagenode.pub" "${TEST_WORK}/authorized_keys"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "authorized_keys with ed25519 fallback (non-FIDO key)" {
|
||||
echo "ssh-ed25519 CCCC non-fido-key" > "${TEST_WORK}/ssh-keys/yubikey1_storagenode.pub"
|
||||
|
||||
{
|
||||
for kf in "${TEST_WORK}"/ssh-keys/*.pub; do
|
||||
cat "$kf"
|
||||
done
|
||||
} > "${TEST_WORK}/authorized_keys"
|
||||
|
||||
run grep "ssh-ed25519" "${TEST_WORK}/authorized_keys"
|
||||
assert_success
|
||||
}
|
||||
|
||||
# ─── YubiKey state tracking ───
|
||||
|
||||
@test "yubikey enrollment marks state for key 1" {
|
||||
state_mark "yubikey_1"
|
||||
run state_done "yubikey_1"
|
||||
assert_success
|
||||
run state_done "yubikey_2"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "yubikey enrollment marks state for both keys" {
|
||||
state_mark "yubikey_1"
|
||||
state_mark "yubikey_2"
|
||||
run state_done "yubikey_1"
|
||||
assert_success
|
||||
run state_done "yubikey_2"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "authorized_keys generation marks state" {
|
||||
state_mark "phase2_authkeys"
|
||||
run state_done "phase2_authkeys"
|
||||
assert_success
|
||||
}
|
||||
@@ -31,7 +31,7 @@ api = [
|
||||
"uvicorn>=0.41.0",
|
||||
"httpx>=0.28.1",
|
||||
"pyjwt>=2.12.0",
|
||||
"cryptography>=46.0.5",
|
||||
"cryptography>=46.0.7",
|
||||
]
|
||||
bcda = [
|
||||
"stack[conf]",
|
||||
|
||||
0
tests/opps/__init__.py
Normal file
0
tests/opps/__init__.py
Normal file
@@ -60,6 +60,27 @@ class TestFileExporter:
|
||||
assert exporter.force_flush() is True
|
||||
|
||||
|
||||
class TestFallbackPath:
|
||||
def test_returns_default_when_conf_unavailable(self):
|
||||
from perf.export import _fallback_path
|
||||
|
||||
result = _fallback_path()
|
||||
assert result.name == "spans.jsonl"
|
||||
assert "traces" in str(result)
|
||||
|
||||
def test_returns_conf_path_when_available(self, monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
import perf.export
|
||||
|
||||
def fake_fallback():
|
||||
return Path("/custom/traces/spans.jsonl")
|
||||
|
||||
monkeypatch.setattr(perf.export, "_fallback_path", fake_fallback)
|
||||
exporter = FileSpanExporter()
|
||||
assert "custom" in str(exporter._path)
|
||||
|
||||
|
||||
class TestCLIShow:
|
||||
def test_show_with_data(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -141,6 +141,35 @@ class TestMiddleware:
|
||||
app = MagicMock()
|
||||
instrument(app) # should not raise
|
||||
|
||||
def test_instrument_enabled_with_real_app(self, monkeypatch):
|
||||
"""When telemetry enabled, instrument runs the OTel path."""
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "true")
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from perf.middleware import instrument
|
||||
|
||||
app = MagicMock()
|
||||
instrument(app) # exercises lines 26-31
|
||||
|
||||
def test_instrument_enabled_catches_import_failure(self, monkeypatch):
|
||||
"""When OTel instrumentor is unavailable, instrument is a no-op."""
|
||||
monkeypatch.setenv("STACK_TELEMETRY", "true")
|
||||
import builtins
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fail_otel(name, *args, **kwargs):
|
||||
if "opentelemetry.instrumentation.fastapi" in name:
|
||||
raise ImportError("no otel instrumentor")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fail_otel)
|
||||
from perf.middleware import instrument
|
||||
|
||||
app = MagicMock()
|
||||
instrument(app) # should not raise
|
||||
|
||||
def test_server_import(self):
|
||||
"""Verify server.py imports cleanly with perf wiring."""
|
||||
from api.server import app
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sem.enrich import _find_node
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sem.enrich import _find_node, _run_tool, attach_ruff, attach_ty
|
||||
from sem.nodes import NodeKind, SemanticNode, Span
|
||||
|
||||
|
||||
@@ -36,3 +38,113 @@ class TestFindNode:
|
||||
assert _find_node([node], 20) is node
|
||||
assert _find_node([node], 9) is None
|
||||
assert _find_node([node], 21) is None
|
||||
|
||||
|
||||
class TestRunTool:
|
||||
def test_returns_empty_on_file_not_found(self):
|
||||
result = _run_tool(["nonexistent_binary_xyz_123"])
|
||||
assert result == []
|
||||
|
||||
@patch(
|
||||
"sem.enrich.subprocess.run",
|
||||
side_effect=__import__("subprocess").TimeoutExpired(cmd=["x"], timeout=1),
|
||||
)
|
||||
def test_returns_empty_on_timeout(self, mock_run):
|
||||
result = _run_tool(["some_cmd"])
|
||||
assert result == []
|
||||
|
||||
@patch("sem.enrich.subprocess.run")
|
||||
def test_returns_empty_on_no_output(self, mock_run):
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=0)
|
||||
result = _run_tool(["some_cmd"])
|
||||
assert result == []
|
||||
|
||||
@patch("sem.enrich.subprocess.run")
|
||||
def test_returns_empty_on_invalid_json(self, mock_run):
|
||||
mock_run.return_value = MagicMock(stdout="not json at all", returncode=0)
|
||||
result = _run_tool(["some_cmd"])
|
||||
assert result == []
|
||||
|
||||
@patch("sem.enrich.subprocess.run")
|
||||
def test_returns_parsed_json(self, mock_run):
|
||||
import json
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout=json.dumps([{"code": "E501", "location": {"row": 5}}]),
|
||||
returncode=0,
|
||||
)
|
||||
result = _run_tool(["some_cmd"])
|
||||
assert len(result) == 1
|
||||
assert result[0]["code"] == "E501"
|
||||
|
||||
|
||||
class TestAttachRuff:
|
||||
@patch("sem.enrich._run_tool")
|
||||
def test_attaches_ruff_codes_to_matching_node(self, mock_tool, tmp_path):
|
||||
mock_tool.return_value = [
|
||||
{"code": "E501", "location": {"row": 3}},
|
||||
]
|
||||
node = _make_node(1, 10, "func")
|
||||
result = attach_ruff([node], tmp_path / "test.py")
|
||||
assert "E501" in result[0].ruff_codes
|
||||
|
||||
@patch("sem.enrich._run_tool")
|
||||
def test_skips_diag_without_line_or_code(self, mock_tool, tmp_path):
|
||||
mock_tool.return_value = [
|
||||
{"code": "", "location": {"row": 3}},
|
||||
{"code": "E501", "location": {"row": 0}},
|
||||
{"location": {"row": 3}},
|
||||
]
|
||||
node = _make_node(1, 10, "func")
|
||||
result = attach_ruff([node], tmp_path / "test.py")
|
||||
assert result[0].ruff_codes == []
|
||||
|
||||
@patch("sem.enrich._run_tool")
|
||||
def test_no_duplicate_codes(self, mock_tool, tmp_path):
|
||||
mock_tool.return_value = [
|
||||
{"code": "E501", "location": {"row": 3}},
|
||||
{"code": "E501", "location": {"row": 4}},
|
||||
]
|
||||
node = _make_node(1, 10, "func")
|
||||
attach_ruff([node], tmp_path / "test.py")
|
||||
assert node.ruff_codes.count("E501") == 1
|
||||
|
||||
|
||||
class TestAttachTy:
|
||||
@patch("sem.enrich._run_tool")
|
||||
def test_attaches_ty_codes_to_matching_node(self, mock_tool, tmp_path):
|
||||
mock_tool.return_value = [
|
||||
{"code": "possibly-none", "location": {"row": 5}},
|
||||
]
|
||||
node = _make_node(1, 10, "func")
|
||||
result = attach_ty([node], tmp_path / "test.py")
|
||||
assert "possibly-none" in result[0].ty_codes
|
||||
|
||||
@patch("sem.enrich._run_tool")
|
||||
def test_uses_rule_field_as_fallback(self, mock_tool, tmp_path):
|
||||
mock_tool.return_value = [
|
||||
{"rule": "type-error", "location": {"row": 5}},
|
||||
]
|
||||
node = _make_node(1, 10, "func")
|
||||
result = attach_ty([node], tmp_path / "test.py")
|
||||
assert "type-error" in result[0].ty_codes
|
||||
|
||||
@patch("sem.enrich._run_tool")
|
||||
def test_skips_diag_without_line_or_code(self, mock_tool, tmp_path):
|
||||
mock_tool.return_value = [
|
||||
{"code": "", "location": {"row": 3}},
|
||||
{"location": {"row": 0}},
|
||||
]
|
||||
node = _make_node(1, 10, "func")
|
||||
attach_ty([node], tmp_path / "test.py")
|
||||
assert node.ty_codes == []
|
||||
|
||||
@patch("sem.enrich._run_tool")
|
||||
def test_no_duplicate_codes(self, mock_tool, tmp_path):
|
||||
mock_tool.return_value = [
|
||||
{"code": "possibly-none", "location": {"row": 3}},
|
||||
{"code": "possibly-none", "location": {"row": 4}},
|
||||
]
|
||||
node = _make_node(1, 10, "func")
|
||||
attach_ty([node], tmp_path / "test.py")
|
||||
assert node.ty_codes.count("possibly-none") == 1
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sem.hooks import (
|
||||
_changed_modules,
|
||||
_changed_test_dirs,
|
||||
_classify,
|
||||
_staged_files,
|
||||
_top_level_tests,
|
||||
check_syntax,
|
||||
compute_test_targets,
|
||||
main,
|
||||
run_step,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,3 +140,112 @@ class TestComputeTargets:
|
||||
targets, reason = compute_test_targets(cats)
|
||||
assert targets == []
|
||||
assert "no testable" in reason
|
||||
|
||||
def test_test_file_changes_add_test_dirs(self):
|
||||
"""Changed test files add their parent dir to targets."""
|
||||
cats = _classify(["tests/aco/test_foo.py", "tests/bib/test_bar.py"])
|
||||
targets, reason = compute_test_targets(cats)
|
||||
assert any("tests/aco" in t for t in targets)
|
||||
assert any("tests/bib" in t for t in targets)
|
||||
|
||||
def test_top_level_test_file_added(self):
|
||||
"""Top-level test files are added directly."""
|
||||
cats = _classify(["tests/test_something.py"])
|
||||
targets, reason = compute_test_targets(cats)
|
||||
assert "tests/test_something.py" in targets
|
||||
|
||||
def test_deduplication(self):
|
||||
"""Targets from src and test don't duplicate."""
|
||||
cats = _classify(["src/aco/foo.py", "tests/aco/test_foo.py"])
|
||||
targets, reason = compute_test_targets(cats)
|
||||
aco_targets = [t for t in targets if "aco" in t]
|
||||
assert len(aco_targets) == 1
|
||||
|
||||
|
||||
class TestStagedFiles:
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
def test_parses_git_output(self, mock_run):
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="src/aco/foo.py\ntests/aco/test_foo.py\n",
|
||||
returncode=0,
|
||||
)
|
||||
result = _staged_files()
|
||||
assert result == ["src/aco/foo.py", "tests/aco/test_foo.py"]
|
||||
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
def test_empty_output(self, mock_run):
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=0)
|
||||
result = _staged_files()
|
||||
assert result == []
|
||||
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
def test_filters_empty_lines(self, mock_run):
|
||||
mock_run.return_value = MagicMock(stdout="foo.py\n\nbar.py\n", returncode=0)
|
||||
result = _staged_files()
|
||||
assert result == ["foo.py", "bar.py"]
|
||||
|
||||
|
||||
class TestRunStep:
|
||||
def test_returns_exit_code(self):
|
||||
rc = run_step("echo test", ["python", "-c", "pass"])
|
||||
assert rc == 0
|
||||
|
||||
def test_nonzero_exit_code(self):
|
||||
rc = run_step("fail", ["python", "-c", "import sys; sys.exit(1)"])
|
||||
assert rc == 1
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("sem.hooks._staged_files", return_value=[])
|
||||
def test_no_staged_files_returns_zero(self, mock_staged):
|
||||
assert main() == 0
|
||||
|
||||
@patch("sem.hooks.run_step", return_value=0)
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
@patch("sem.hooks._staged_files", return_value=["src/aco/foo.py"])
|
||||
def test_src_change_runs_lint_and_pytest(
|
||||
self, mock_staged, mock_subproc, mock_step
|
||||
):
|
||||
mock_subproc.return_value = MagicMock(returncode=0)
|
||||
result = main()
|
||||
assert result == 0
|
||||
# Should have called run_step for ruff check, format, and pytest
|
||||
step_labels = [c[0][0] for c in mock_step.call_args_list]
|
||||
assert any("ruff check" in l for l in step_labels)
|
||||
|
||||
@patch("sem.hooks.run_step", return_value=0)
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
@patch("sem.hooks._staged_files", return_value=["stack.toml"])
|
||||
def test_config_change_triggers_regen(self, mock_staged, mock_subproc, mock_step):
|
||||
mock_subproc.return_value = MagicMock(returncode=0)
|
||||
result = main()
|
||||
assert result == 0
|
||||
step_labels = [c[0][0] for c in mock_step.call_args_list]
|
||||
assert any("config" in l or "regen" in l for l in step_labels)
|
||||
|
||||
@patch("sem.hooks.run_step")
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
@patch("sem.hooks._staged_files", return_value=["src/aco/foo.py"])
|
||||
def test_lint_failure_aborts(self, mock_staged, mock_subproc, mock_step):
|
||||
mock_subproc.return_value = MagicMock(returncode=0)
|
||||
mock_step.return_value = 1 # lint fails
|
||||
result = main()
|
||||
assert result == 1
|
||||
|
||||
@patch("sem.hooks.run_step", return_value=0)
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
@patch("sem.hooks._staged_files", return_value=["notebooks/pfs_calcs.py"])
|
||||
def test_notebook_change_runs_marimo(self, mock_staged, mock_subproc, mock_step):
|
||||
mock_subproc.return_value = MagicMock(returncode=0)
|
||||
result = main()
|
||||
assert result == 0
|
||||
step_labels = [c[0][0] for c in mock_step.call_args_list]
|
||||
assert any("marimo" in l or "notebook" in l for l in step_labels)
|
||||
|
||||
@patch("sem.hooks.run_step", return_value=0)
|
||||
@patch("sem.hooks.subprocess.run")
|
||||
@patch("sem.hooks._staged_files", return_value=["README.md"])
|
||||
def test_non_python_change_skips_tests(self, mock_staged, mock_subproc, mock_step):
|
||||
mock_subproc.return_value = MagicMock(returncode=0)
|
||||
result = main()
|
||||
assert result == 0
|
||||
|
||||
@@ -116,6 +116,41 @@ class TestControlFlow:
|
||||
loops = [n for n in nodes if n.kind == NodeKind.FOR_LOOP]
|
||||
assert len(loops) == 1
|
||||
|
||||
def test_async_for_loop(self):
|
||||
nodes = _parse("""
|
||||
async def fetch_all():
|
||||
async for item in aiter():
|
||||
process(item)
|
||||
""")
|
||||
loops = [n for n in nodes if n.kind == NodeKind.FOR_LOOP]
|
||||
assert len(loops) == 1
|
||||
|
||||
def test_with_block(self):
|
||||
nodes = _parse("""
|
||||
def process():
|
||||
with open("f") as fh:
|
||||
fh.read()
|
||||
""")
|
||||
withs = [n for n in nodes if n.kind == NodeKind.WITH_BLOCK]
|
||||
assert len(withs) == 1
|
||||
|
||||
def test_async_with_block(self):
|
||||
nodes = _parse("""
|
||||
async def process():
|
||||
async with aopen("f") as fh:
|
||||
await fh.read()
|
||||
""")
|
||||
withs = [n for n in nodes if n.kind == NodeKind.WITH_BLOCK]
|
||||
assert len(withs) == 1
|
||||
|
||||
def test_assert_node(self):
|
||||
nodes = _parse("""
|
||||
def check(x):
|
||||
assert x > 0, "must be positive"
|
||||
""")
|
||||
asserts = [n for n in nodes if n.kind == NodeKind.ASSERT]
|
||||
assert len(asserts) == 1
|
||||
|
||||
def test_while_loop(self):
|
||||
nodes = _parse("""
|
||||
while True:
|
||||
@@ -156,6 +191,29 @@ class TestNodeIdentity:
|
||||
funcs_b = [n for n in nodes_b if n.kind == NodeKind.FUNCTION]
|
||||
assert funcs_a[0].source_hash != funcs_b[0].source_hash
|
||||
|
||||
def test_parse_module_from_file(self, tmp_path):
|
||||
"""parse_module reads a file and produces nodes."""
|
||||
from sem.parse import parse_module
|
||||
|
||||
src = tmp_path / "example.py"
|
||||
src.write_text("def hello():\n return 1\n")
|
||||
nodes = parse_module(src)
|
||||
funcs = [n for n in nodes if n.kind == NodeKind.FUNCTION]
|
||||
assert len(funcs) == 1
|
||||
assert "hello" in funcs[0].node_id
|
||||
# Module name derived from file stem
|
||||
assert funcs[0].module == "example"
|
||||
|
||||
def test_parse_module_with_explicit_name(self, tmp_path):
|
||||
"""parse_module uses explicit module name when provided."""
|
||||
from sem.parse import parse_module
|
||||
|
||||
src = tmp_path / "foo.py"
|
||||
src.write_text("x = 1\n")
|
||||
nodes = parse_module(src, module="my.custom.module")
|
||||
# No function nodes, but module name should be set
|
||||
assert all(n.module == "my.custom.module" for n in nodes) or len(nodes) == 0
|
||||
|
||||
def test_node_id_no_line_numbers(self):
|
||||
"""Node IDs must not contain raw line numbers."""
|
||||
nodes = _parse("""
|
||||
|
||||
@@ -74,3 +74,51 @@ class TestRanking:
|
||||
nodes = [_node(hit=False, kind=NodeKind.BRANCH_IF) for _ in range(20)]
|
||||
targets = next_targets(nodes, limit=3)
|
||||
assert len(targets) <= 3
|
||||
|
||||
|
||||
class TestFunctionCoverageRatio:
|
||||
def test_partial_coverage_boosts_score(self):
|
||||
"""Nodes in partially-tested functions get a +3 boost."""
|
||||
from sem.plan import _function_coverage_ratio
|
||||
|
||||
# Two siblings in same function — one hit, one not
|
||||
hit_node = _node(
|
||||
kind=NodeKind.BRANCH_IF, hit=True, module="m", symbol_ctx=["func_a"]
|
||||
)
|
||||
miss_node = _node(
|
||||
kind=NodeKind.BRANCH_ELSE, hit=False, module="m", symbol_ctx=["func_a"]
|
||||
)
|
||||
all_nodes = [hit_node, miss_node]
|
||||
|
||||
ratio = _function_coverage_ratio(miss_node, all_nodes)
|
||||
assert 0.0 < ratio < 1.0 # partial coverage
|
||||
|
||||
# Score should include partial-function bonus (+3)
|
||||
score = score_node(miss_node, all_nodes)
|
||||
# branch_if uncovered=5, partial=3 = 8 minimum
|
||||
assert score >= 8.0
|
||||
|
||||
def test_no_symbol_context_returns_zero(self):
|
||||
from sem.plan import _function_coverage_ratio
|
||||
|
||||
node = _node(kind=NodeKind.FUNCTION, hit=False, symbol_ctx=[])
|
||||
assert _function_coverage_ratio(node, [node]) == 0.0
|
||||
|
||||
def test_all_covered_ratio_is_one(self):
|
||||
from sem.plan import _function_coverage_ratio
|
||||
|
||||
n1 = _node(kind=NodeKind.BRANCH_IF, hit=True, module="m", symbol_ctx=["fn"])
|
||||
n2 = _node(kind=NodeKind.BRANCH_ELSE, hit=True, module="m", symbol_ctx=["fn"])
|
||||
assert _function_coverage_ratio(n1, [n1, n2]) == 1.0
|
||||
|
||||
def test_rank_nodes_deduplicates(self):
|
||||
"""rank_nodes scores and sorts without error."""
|
||||
nodes = [
|
||||
_node(kind=NodeKind.BRANCH_IF, hit=False, ty=["x"]),
|
||||
_node(kind=NodeKind.FUNCTION, hit=True),
|
||||
_node(kind=NodeKind.EXCEPT_HANDLER, hit=False),
|
||||
]
|
||||
ranked = rank_nodes(nodes)
|
||||
assert len(ranked) == 3
|
||||
# Highest priority first
|
||||
assert ranked[0].priority >= ranked[1].priority >= ranked[2].priority
|
||||
|
||||
242
uv.lock
generated
242
uv.lock
generated
@@ -76,7 +76,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.3"
|
||||
version = "3.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -87,76 +87,76 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -581,55 +581,55 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
version = "46.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2644,11 +2644,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2841,7 +2841,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -2849,9 +2849,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3249,7 +3249,7 @@ requires-dist = [
|
||||
{ name = "azure-storage-blob", marker = "extra == 'azure'", specifier = ">=12.23.0" },
|
||||
{ name = "boto3", marker = "extra == 'aws'", specifier = ">=1.35.0" },
|
||||
{ name = "coverage", marker = "extra == 'sem'", specifier = ">=7.13.4" },
|
||||
{ name = "cryptography", marker = "extra == 'api'", specifier = ">=46.0.5" },
|
||||
{ name = "cryptography", marker = "extra == 'api'", specifier = ">=46.0.7" },
|
||||
{ name = "databricks-bundles", marker = "extra == 'lake'", specifier = ">=0.295.0" },
|
||||
{ name = "databricks-sdk", marker = "extra == 'lake'", specifier = ">=0.85.0" },
|
||||
{ name = "duckdb", marker = "extra == 'aco'", specifier = ">=1.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user