8000 fix(install): install on a computer without Docker or KVM access, and run the line again there by adeelahmad · Pull Request #368 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions .github/workflows/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,30 @@ jobs:
steps:
- uses: actions/checkout@v4

# The runner comes with Docker; a new computer doesn't, so the installer has to install it (install.sh: from
# get.docker.com) and use it as administrator for this run, as the person isn't in the docker group yet
- name: A new computer, without Docker
run: |
sudo systemctl stop docker.socket docker.service containerd.service 2>/dev/null || true
pkgs="$(dpkg-query -W -f '${Package}\n' 2>/dev/null | grep -E '^(docker|moby|containerd|runc)' || true)"
[ -z "$pkgs" ] || printf '%s\n' "$pkgs" | xargs sudo apt-get purge -y -q >/dev/null
sudo rm -rf /usr/local/bin/docker* /usr/libexec/docker /usr/local/lib/docker /etc/apt/sources.list.d/docker* \
/etc/apt/keyrings/docker* /var/lib/docker /var/lib/containerd /etc/docker
sudo gpasswd -d "$(id -un)" docker 2>/dev/null || true
hash -r
if command -v docker; then echo "Docker is still here"; exit 1; fi

- name: Start the installer, as install.sh does on a first install
run: |
LENS_INSTALLER_TOKEN=ci-token python 8000 3 installer/lens_installer.py --no-browser --port 3990 >installer.log 2>&1 &
timeout 30 sh -c 'until curl -s -o /dev/null http://127.0.0.1:3990/; do sleep 1; done'
cat installer.log
# its link sets the cookie that every other call needs
curl -fsS -c jar -o /dev/null "http://127.0.0.1:3990/?t=ci-token"
curl -fsS -b jar http://127.0.0.1:3990/api/system | jq '{os_name, docker, plan, port}'
curl -fsS -b jar http://127.0.0.1:3990/api/system >system.json
jq '{os_name, docker, plan, port}' system.json
# it says what it will install first: Docker, which this computer hasn't got
jq -e '.docker.installed == false and (.plan.docker | map(.what) | any(startswith("Docker Engine")))' system.json

- name: Answer it and install
run: |
Expand All @@ -104,7 +120,7 @@ jobs:
since=0
while :; do
p="$(curl -fsS -b jar "http://127.0.0.1:3990/api/progress?since=$since")"
echo "$p" | jq -r '.lines[]'
echo "$p" | jq -r '.lines[]' | tee -a install.log
since="$(echo "$p" | jq .next)"
state="$(echo "$p" | jq -r .state)"
[ "$state" = running ] || break
Expand All @@ -113,6 +129,7 @@ jobs:
echo "$p" | jq '{state, url, error}'
[ "$state" = "done" ] || exit 1
echo "$p" | jq -r .url >setup-url
grep -q 'Installing Docker (get.docker.com)' install.log || { echo "install.sh didn't install Docker"; exit 1; }

- name: Lens answers, waits for its first admin, and has the installer's settings
run: |
Expand All @@ -121,23 +138,24 @@ jobs:
curl -fsS -o /dev/null "$url"
curl -fsS "$base/api/v1/auth/status" | grep -q '"setup_required":true'
cd "$LENS_DIR"
docker compose -f docker-compose.prod.yml logs backend | grep "Applied the installer's settings: llm, budget"
# Docker as administrator: the person isn't in the docker group until they sign in again
sudo docker compose -f docker-compose.prod.yml logs backend | grep "Applied the installer's settings: llm, budget"
# saved in Lens: the key doesn't stay in .env
! grep -q '^LENS_SETUP_SETTINGS=' .env

- name: What the installer and the containers said
if: failure()
run: |
cat installer.log || true
cd "$LENS_DIR" && docker compose -f docker-compose.prod.yml ps -a && docker compose -f docker-compose.prod.yml logs --tail 200
cd "$LENS_DIR" && sudo docker compose -f docker-compose.prod.yml ps -a && sudo docker compose -f docker-compose.prod.yml logs --tail 200

- name: Stop
if: always()
run: |
curl -fsS -b jar -H 'X-Lens-Installer: 1' -X POST -d '{}' http://127.0.0.1:3990/api/quit || true
if [ -d "$LENS_DIR" ]; then
if [ -d "$LENS_DIR" ] && command -v docker; then
cd "$LENS_DIR"
docker compose -f docker-compose.prod.yml down -v || true
sudo docker compose -f docker-compose.prod.yml down -v || true
fi

install:
Expand All @@ -157,6 +175,7 @@ jobs:
- name: Install (as a user would, piped into sh)
run: |
sh < install.sh | tee install.log
test "${PIPESTATUS[0]}" = 0 || { echo "install.sh failed"; exit 1; }
grep -Eo 'http://[^ ]+/setup\?code=[A-Za-z0-9]{12}' install.log | head -1 > setup-url
test -s setup-url || { echo "install.sh didn't hand over a setup address"; exit 1; }
echo "Setup address: $(sed 's/code=.*/code=…/' setup-url)"
Expand Down Expand Up @@ -227,18 +246,21 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: KVM for the runner's user
# As on a new computer, KVM is there but this user may not use it yet (it's root's and the kvm group's): install.sh
# gives them access itself
- name: A new computer, whose user can't use KVM yet
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls -l /dev/kvm
if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then echo "this runner's user can already use KVM"; exit 1; fi

- name: Install (as a user would, piped into sh)
run: |
sh < install.sh | tee install.log
test "${PIPESTATUS[0]}" = 0 || { echo "install.sh failed"; exit 1; }
grep -Eo 'http://[^ ]+/setup\?code=[A-Za-z0-9]{12}' install.log | head -1 > setup-url
test -s setup-url || { echo "install.sh didn't hand over a setup address"; exit 1; }
grep -q 'Installing microsandbox' install.log || { echo "install.sh didn't install microsandbox"; exit 1; }
grep -q "use KVM (the kvm group)" install.log || { echo "install.sh didn't give this user KVM"; exit 1; }

- name: When this machine's DNS has no address for a name, the sandbox looks it up with public resolvers
run: |
Expand All @@ -261,6 +283,7 @@ jobs:
grep -q ' Building Lens' install.log || { echo "install.sh didn't show the sandbox's progress"; exit 1; }
before="$(grep -E '^(ACCESS_SECRET_KEY|ARCHIVE_SECRET_KEY|AUTH_SECRET|SURREAL_PASS|LENS_SETUP_CODE)=' "$LENS_DIR/.env" | sha256sum)"
LENS_BACKEND='' sh < install.sh | tee again.log
test "${PIPESTATUS[0]}" = 0 || { echo "install.sh failed"; exit 1; }
after="$(grep -E '^(ACCESS_SECRET_KEY|ARCHIVE_SECRET_KEY|AUTH_SECRET|SURREAL_PASS|LENS_SETUP_CODE)=' "$LENS_DIR/.env" | sha256sum)"
test "$before" = "$after" || { echo "the second run changed the secrets"; exit 1; }
test "$(stat -c %a "$LENS_DIR/.env")" = 600 || { echo "the second run left .env readable to others"; exit 1; }
Expand Down Expand Up @@ -316,6 +339,7 @@ jobs:
msb volume create lens-data-2
rm -rf "$LENS_DIR"
sh < install.sh | tee fresh.log
test "${PIPESTATUS[0]}" = 0 || { echo "install.sh failed"; exit 1; }
grep -q 'Starting Lens on a microsandbox volume of its own, lens-data-2' fresh.log
grep -q '^LENS_VOLUME=lens-data-2$' "$LENS_DIR/.env" || { echo "the new install didn't start on a volume of its own"; exit 1; }
msb volume ls -q | grep -qx lens-data || { echo "the earlier install's volume is gone"; exit 1; }
Expand All @@ -339,6 +363,7 @@ jobs:
done
before="$(grep -E '^(ACCESS_SECRET_KEY|ARCHIVE_SECRET_KEY|AUTH_SECRET|SURREAL_PASS|LENS_SETUP_CODE)=' "$LENS_DIR/.env" | sha256sum)"
LENS_BACKEND='' sh < install.sh | tee moved.log
test "${PIPESTATUS[0]}" = 0 || { echo "install.sh failed"; exit 1; }
grep -q "Another install's database" moved.log || { echo "the sandbox didn't say the keys don't open the database"; exit 1; }
grep -q 'Starting Lens on a microsandbox volume of its own, lens-data-3' moved.log
grep -q '^LENS_VOLUME=lens-data-3$' "$LENS_DIR/.env" || { echo "the install didn't move to a volume of its own"; exit 1; }
Expand All @@ -352,6 +377,7 @@ jobs:
curl -fsS "$base/api/v1/auth/status" | grep -q '"setup_required":true'
# running the line again stays on it
LENS_BACKEND='' sh < install.sh | tee stay.log
test "${PIPESTATUS[0]}" = 0 || { echo "install.sh failed"; exit 1; }
if grep -q 'volume of its own' stay.log; then echo "it moved again"; exit 1; fi
grep -q '^LENS_VOLUME=lens-data-3$' "$LENS_DIR/.env"
curl -fsS -o /dev/null "$url"
Expand Down
2 changes: 1 addition & 1 deletion docs/get-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ curl -fsSL https://raw.githubusercontent.com/adeelahmad/lense/main/install.sh |

On Linux or a Mac, this installs Docker if it's missing (get.docker.com on Linux; OrbStack with Homebrew on a Mac,
and Homebrew too if needed), after listing what it will install and asking (only at a terminal; `LENS_YES=1` skips
the question),
the question; CI installs on a runner with Docker taken off first, as on a new computer),
gets Lens into `~/lens`, writes the secrets once, builds and starts the stack, and opens the setup page with the setup
code already filled in. On a server without a desktop, Lens is reachable from the network and the link uses the
server's address. Run the same line again to update: the secrets, the database and the archive are kept. Settings go
Expand Down
7 changes: 5 additions & 2 deletions docs/microsandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ stays on Docker, because its database and archive are in Docker volumes.

It needs hardware virtualization:

- **Linux** on x86-64 or 64-bit ARM (a Raspberry Pi 4 or 5 on a 64-bit OS) with `/dev/kvm`. The installer adds you to
the `kvm` group. `msb doctor` checks the machine.
- **Linux** on x86-64 or 64-bit ARM (a Raspberry Pi 4 or 5 on a 64-bit OS) with `/dev/kvm`. The installer loads KVM
if it isn't and adds you to the `kvm` group, so there's nothing to do first. Until your next sign-in makes the group
count, it runs microsandbox with the group (`sg kvm`) and lets you use `/dev/kvm` itself (with `setfacl`, installing
the `acl` package if it's missing). CI installs on a runner whose user can't use KVM yet, and runs the line twice.
`msb doctor` checks the machine.
- **A Mac** with Apple silicon.

## How it runs
Expand Down
51 changes: 42 additions & 9 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ have() { command -v "$1" >/dev/null 2>&1; }
secret() {
if have openssl; then openssl rand -hex 32; else od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; fi
}
code() { LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 12; }
code() { LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 12; }

os="$(uname -s)"

Expand All @@ -70,6 +70,20 @@ msb_ok() {
esac
}
tty_ok() { [ "${LENS_YES:-0}" != 1 ] && (: </dev/tty) 2>/dev/null; }
# KVM for this run: this process can use /dev/kvm, or its user is in the group that owns it, which sg gives a process
# before the next sign-in does. (The access setfacl gives the user lasts only until udev resets it, which a VM starting
# or stopping does.)
in_kvm() { id -Gn ${1:+"$1"} 2>/dev/null | tr ' ' '\n' | grep -qx kvm; }
kvm_ok() {
{ [ -r /dev/kvm ] && [ -w /dev/kvm ]; } ||
{ have sg && [ "$(stat -c %G /dev/kvm 2>/dev/null)" = kvm ] && in_kvm "$(id -un)"; }
}
# Starts Lens's sandbox, with the kvm group when msb needs it (below)
run_sandbox() {
if [ -n "$kvm_sg" ]; then sg kvm -c ./.lens-sandbox >/dev/null; else ./.lens-sandbox >/dev/null; fi
}
# A word for sh -c, in single quotes
quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
# has_db VOLUME: whether a microsandbox volume may hold a Lens database. Only one it can see has none counts as without:
# empty, or with run.sh's tools (.lens) and no database (surreal/lens.db) next to them. One it can't look into (not
# where microsandbox keeps them by default) counts as with.
Expand Down Expand Up @@ -164,7 +178,7 @@ if [ "$BACKEND" = microsandbox ]; then
have msb || need "microsandbox (from install.microsandbox.dev, into ~/.microsandbox)"
if [ "$os" = Linux ]; then
[ -e /dev/kvm ] || need "the KVM kernel module (modprobe, with sudo)"
{ [ -r /dev/kvm ] && [ -w /dev/kvm ]; } || need "access to KVM for $(id -un) (the kvm group, with sudo)"
kvm_ok || need "access to KVM for $(id -un) (the kvm group, with sudo)"
fi
else
if ! have docker; then
Expand Down Expand Up @@ -214,12 +228,31 @@ if [ "$os" = Linux ] && [ ! -e /dev/kvm ]; then
$(sudo_ok) modprobe kvm_intel 2>/dev/null || $(sudo_ok) modprobe kvm_amd 2>/dev/null || $(sudo_ok) modprobe kvm 2>/dev/null || true
[ -e /dev/kvm ] || fail "KVM didn't load. Turn on virtualization (VT-x or AMD-V) in the firmware settings, or use LENS_BACKEND=docker."
fi
if [ "$os" = Linux ] && ! { [ -r /dev/kvm ] && [ -w /dev/kvm ]; }; then
if [ "$os" = Linux ] && ! kvm_ok; then
say "Letting $(id -un) use KVM (the kvm group)..."
$(sudo_ok) usermod -aG kvm "$(id -un)" 2>/dev/null || true
# the group counts from the next sign-in; for this run, access for this user alone
# the group counts from the next sign-in; for this run, access for this user alone (setfacl is in the acl package,
# which a minimal system may not have)
if ! have setfacl; then
if have apt-get; then $(sudo_ok) apt-get install -y -q acl >/dev/null 2>&1 || true
elif have dnf; then $(sudo_ok) dnf install -y -q acl >/dev/null 2>&1 || true
fi
fi
have setfacl && $(sudo_ok) setfacl -m "u:$(id -un):rw" /dev/kvm 2>/dev/null || true
[ -r /dev/kvm ] && [ -w /dev/kvm ] || fail "Can't use /dev/kvm. Sign out and in again (or run: msb doctor), then run this again."
kvm_ok || fail "Can't use /dev/kvm. Sign out and in again (or run: msb doctor), then run this again."
fi
MSB="$(command -v msb)"
# Until the next sign-in gives this user's processes the kvm group, msb runs with it, through sg (which takes one
# command line, so each word is quoted into it): msb can't start or stop a sandbox without KVM, and the access setfacl
# gave lasts only until udev resets it, which a VM starting or stopping does.
kvm_sg=""
if [ "$os" = Linux ] && have sg && ! in_kvm && in_kvm "$(id -un)"; then
kvm_sg=1
msb() {
line="exec $(quote "$MSB")"
for a in "$@"; do line="$line $(quote "$a")"; done
sg kvm -c "$line"
}
fi
else
# --- Docker -------------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -470,7 +503,7 @@ if [ "$BACKEND" = microsandbox ]; then
{
echo "#!/bin/sh"
echo "# Starts Lens in its microsandbox (written by install.sh)"
printf 'exec %s' "'$(command -v msb)'"
printf 'exec %s' "'$MSB'"
for a in "$@"; do printf " '%s'" "$a"; done
echo
} >.lens-sandbox
Expand All @@ -494,7 +527,7 @@ if [ "$BACKEND" = microsandbox ]; then
sleep 2
done
since="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # what the new sandbox logs, for the progress below
./.lens-sandbox >/dev/null
run_sandbox
LOGS="msb logs -f lens"
TAIL="msb logs lens --tail 40"
STOP="msb stop lens"
Expand All @@ -504,7 +537,7 @@ if [ "$BACKEND" = microsandbox ]; then
unit=/etc/systemd/system/lens-microsandbox.service
printf '%s\n' "[Unit]" "Description=Lens (microsandbox)" "After=network-online.target" "Wants=network-online.target" "" \
"[Service]" "Type=oneshot" "RemainAfterExit=yes" "User=$(id -un)" "ExecStart=$PWD/.lens-sandbox" \
"ExecStop=$(command -v msb) stop lens" "" "[Install]" "WantedBy=multi-user.target" |
"ExecStop=$MSB stop lens" "" "[Install]" "WantedBy=multi-user.target" |
$(sudo_ok) tee "$unit" >/dev/null && $(sudo_ok) systemctl enable lens-microsandbox.service >/dev/null 2>&1 || true
elif [ "$os" = Darwin ]; then
plist="$HOME/Library/LaunchAgents/dev.lens.microsandbox.plist"
Expand Down Expand Up @@ -604,7 +637,7 @@ other_volume() {
write_sandbox
msb stop -q -t 30 lens </dev/null >/dev/null 2>&1 || true
since="$(date -u +%Y-%m-%dT%H:%M:%SZ)" seen=0 down=0 step=""
./.lens-sandbox >/dev/null
run_sandbox
start=$(date +%s) # a first start on this volume: it installs and builds everything again
shown=$start
}
Expand Down
7 changes: 4 additions & 3 deletions installer/test_lens_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,14 @@ def test_admin_password_is_needed_when_the_plan_asks(self):
self.assertIn("password", json.loads(data)["error"])

def wait(self):
for _ in range(100):
# a minute: on a busy Windows runner, sh (Git's) can take several seconds just to start the fake install.sh
end = time.monotonic() + 60
while True:
_, _, data = self.call("GET", "/api/progress")
p = json.loads(data)
if p["state"] not in ("idle", "running"):
if p["state"] not in ("idle", "running") or time.monotonic() > end:
return p
time.sleep(0.1)
return p


if __name__ == "__main__":
Expand Down
Loading
0