mirror of
https://github.com/hanzoai/lsp.git
synced 2026-09-30 22:52:48 +00:00
The table always carried the other entries; the image carried one toolchain, so
Available() answered false and the daemon served Go alone. Install the rest —
node with typescript-language-server, pyright and intelephense; the Rust
component tarballs with rust-src; clangd's own release; a JDK with jdtls; ruby
with ruby-lsp; composer for PHP — every version pinned, and add the java, php
and ruby entries beside them.
The three new entries keep the scripts-off bargain rather than bending it. PHP
fetches with composer's own --no-scripts --no-plugins. Java has two dependency
resolvers and both ARE build tools, so it has no fetch and answers from source
and the JDK. Ruby's bundle install compiles native extensions, so it is marked
Executes and refused, as python's is.
Three things that would otherwise be silent:
* typescript-language-server prefers the TREE's node_modules/typescript —
dependency-authored JavaScript, loaded and run in the serve phase, undoing
what --ignore-scripts bought one phase earlier. Pin tsserver to the image's.
* ruby-lsp creates .ruby-lsp/ in the project and shells out to bundle install
unless BUNDLE_GEMFILE is set at all. On a read-only tree that is EACCES
before the server starts.
* Equinox takes its writable configuration area from the passwd entry, not
$HOME, so jdtls would write to /home/lsp, which the jail does not bind.
Name the area under the jail's own /tmp.
RLIMIT_NPROC is per UID and every jail shares it, so 256 became a way for a
JVM's forty threads to stop another language forking at all.
Co-authored-by: Hanzo Dev <[email protected]>
270 lines
14 KiB
Docker
270 lines
14 KiB
Docker
# syntax=docker/dockerfile:1
|
|
|
|
# lsp — the jailed language-server daemon.
|
|
#
|
|
# Three stages:
|
|
# 1. build — compiles the static Go binary.
|
|
# 2. tools — compiles the language servers the runtime will run.
|
|
# 3. runtime — lean Debian with the toolchains + tini, non-root.
|
|
#
|
|
# The isolation is the Go-NATIVE jailer built into the binary (namespaces +
|
|
# minimal chroot + seccomp) running under a gVisor RuntimeClass — NOT nsjail.
|
|
# nsjail is incompatible with gVisor (it calls prctl(SECUREBITS), which gVisor
|
|
# does not implement, and needs a cgroup ns plus a netlink net-ns gVisor
|
|
# rejects), so this image ships no jailer binary at all: the daemon re-execs
|
|
# ITSELF as the jail child.
|
|
#
|
|
# Built in CI on the self-hosted amd64 scale set — never on GitHub's builders,
|
|
# never on a developer's laptop. The cluster is amd64; the image targets
|
|
# linux/amd64.
|
|
|
|
# ── 1. The daemon ────────────────────────────────────────────────────────────
|
|
FROM golang:1.26.5-bookworm AS build
|
|
WORKDIR /src
|
|
# Module graph first for layer caching. There are no dependencies today — stdlib
|
|
# only, by design — but this keeps the shape right if one is ever added.
|
|
COPY go.mod ./
|
|
RUN go mod download
|
|
COPY . .
|
|
# CGO off ⇒ a fully static binary with no shared-library surface of its own. The
|
|
# jail child re-execs this exact binary.
|
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
|
go build -trimpath -ldflags="-s -w" -o /out/lsp .
|
|
|
|
# ── 2. gopls ─────────────────────────────────────────────────────────────────
|
|
#
|
|
# The one server that is BUILT rather than downloaded, because the Go build
|
|
# stage is already here. Every other server arrives as its project's own
|
|
# release artifact in the runtime stage, beside the toolchain it needs.
|
|
FROM golang:1.26.5-bookworm AS tools
|
|
ARG GOPLS_VERSION=v0.23.0
|
|
RUN GOBIN=/out CGO_ENABLED=0 go install golang.org/x/tools/gopls@${GOPLS_VERSION}
|
|
|
|
# ── 3. Runtime ───────────────────────────────────────────────────────────────
|
|
#
|
|
# EVERY VERSION BELOW IS PINNED, and none of them is `latest`. A floating server
|
|
# means two builds of the same commit answer differently, and the thing that
|
|
# changed is a binary nobody reviewed. Bumping one is one line here.
|
|
#
|
|
# A language is SERVED when its server binary is on PATH — internal/lsp/langs.go
|
|
# carries the entry and Lang.Available() reads this image to decide whether the
|
|
# entry is true. So this file, and only this file, is what makes a language real.
|
|
#
|
|
# The image is large, and that is the honest cost of nine languages: a compiler
|
|
# or an interpreter per language, because a language server resolves definitions
|
|
# by type-checking source and cannot do it without one. It is pulled once per
|
|
# node and the daemon is long-lived.
|
|
FROM debian:bookworm-slim AS runtime
|
|
|
|
# ca-certificates for the FETCH phase's TLS to the module proxy; tini to reap the
|
|
# jail children (each root forks a language server, which forks `go list`).
|
|
# curl and unzip fetch and unpack the toolchains below.
|
|
#
|
|
# Then the half of the toolchain set that Debian versions better than we would:
|
|
#
|
|
# python3 pyright reads a tree's import search path out of a real
|
|
# interpreter; without one it sees only its bundled typeshed.
|
|
# ruby ruby-lsp is a gem, and the standard library it indexes is this
|
|
# interpreter's.
|
|
# php-* composer IS a PHP program, so the PHP fetch needs the interpreter
|
|
# even though the server itself is a Node one.
|
|
# g++ the C++ standard headers clangd resolves <vector> in, and the
|
|
# compiler a gem with a native extension builds with.
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
ca-certificates tini curl unzip \
|
|
python3 \
|
|
ruby ruby-dev \
|
|
php-cli php-curl php-mbstring php-zip \
|
|
g++ make \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# ── Go ───────────────────────────────────────────────────────────────────────
|
|
#
|
|
# The Go toolchain, which gopls shells out to for `go list` and which the fetch
|
|
# phase runs as `go mod download`. A pinned tarball rather than apt: Debian's
|
|
# golang lags, and the toolchain version decides which repositories resolve —
|
|
# GOTOOLCHAIN=local (langs.go) never downloads a newer one, so a repo whose
|
|
# go.mod names a Go past this pin degrades to unresolved imports. Kept at the
|
|
# latest 1.26.x so the fleet's own repos (cloud requires 1.26.5) resolve; bump
|
|
# this line when a repo needs a newer one.
|
|
ARG GO_VERSION=1.26.5
|
|
RUN set -eux; \
|
|
arch="$(dpkg --print-architecture)"; \
|
|
case "$arch" in amd64) goarch=amd64 ;; arm64) goarch=arm64 ;; *) echo "unsupported arch $arch"; exit 1 ;; esac; \
|
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${goarch}.tar.gz" -o /tmp/go.tgz; \
|
|
tar -C /usr/local -xzf /tmp/go.tgz; \
|
|
rm /tmp/go.tgz
|
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
|
|
|
# ── Node, and the three languages that ride on it ────────────────────────────
|
|
#
|
|
# One runtime, three servers: TypeScript/JavaScript, Python (pyright) and PHP
|
|
# (intelephense) are all Node programs. That is most of why this image can speak
|
|
# nine languages without being nine images.
|
|
#
|
|
# typescript is pinned to 5.x DELIBERATELY. TypeScript 7 is the native rewrite
|
|
# and ships no `bin/tsserver`; typescript-language-server drives tsserver, so
|
|
# the pair only exists at 5.x. When the server moves to the native protocol,
|
|
# both lines move together.
|
|
#
|
|
# --ignore-scripts on the global install, for the same reason the fetch phase
|
|
# uses it: a package's install hook is code nobody read. None of these four has
|
|
# one, which is what makes the flag free rather than brave.
|
|
ARG NODE_VERSION=24.19.0
|
|
ARG TYPESCRIPT_VERSION=5.9.3
|
|
ARG TYPESCRIPT_SERVER_VERSION=5.3.0
|
|
ARG PYRIGHT_VERSION=1.1.411
|
|
ARG INTELEPHENSE_VERSION=1.18.5
|
|
RUN set -eux; \
|
|
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz" -o /tmp/node.tgz; \
|
|
tar -C /usr/local --strip-components=1 --no-same-owner -xzf /tmp/node.tgz \
|
|
--exclude='*/CHANGELOG.md' --exclude='*/LICENSE' --exclude='*/README.md'; \
|
|
rm /tmp/node.tgz; \
|
|
npm install -g --ignore-scripts --no-audit --no-fund \
|
|
"typescript@${TYPESCRIPT_VERSION}" \
|
|
"typescript-language-server@${TYPESCRIPT_SERVER_VERSION}" \
|
|
"pyright@${PYRIGHT_VERSION}" \
|
|
"intelephense@${INTELEPHENSE_VERSION}"; \
|
|
npm cache clean --force
|
|
|
|
# ── Rust ─────────────────────────────────────────────────────────────────────
|
|
#
|
|
# The component tarballs, NOT rustup, and the difference is what is left in the
|
|
# image afterwards. rustup is a toolchain DOWNLOADER: with it installed, a
|
|
# tenant's rust-toolchain.toml is an instruction to go and get another compiler
|
|
# — an unbounded download in the phase that has a network, and a hang in the
|
|
# phase that does not. The real binaries under /usr/local read no toolchain file
|
|
# and can fetch nothing. It is the Rust spelling of GOTOOLCHAIN=local.
|
|
#
|
|
# rust-src is not an extra: rust-analyzer resolves the standard library from
|
|
# SOURCE, so without it every std symbol in every answer is unresolved.
|
|
ARG RUST_VERSION=1.97.1
|
|
RUN set -eux; \
|
|
for pkg in \
|
|
"rustc-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
|
|
"cargo-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
|
|
"rust-std-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
|
|
"rust-analyzer-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
|
|
"rust-src-${RUST_VERSION}" ; do \
|
|
curl -fsSL "https://static.rust-lang.org/dist/${pkg}.tar.gz" -o /tmp/rust.tgz; \
|
|
mkdir /tmp/rust; \
|
|
tar -C /tmp/rust --strip-components=1 --no-same-owner -xzf /tmp/rust.tgz; \
|
|
/tmp/rust/install.sh --prefix=/usr/local --disable-ldconfig; \
|
|
rm -rf /tmp/rust /tmp/rust.tgz; \
|
|
done
|
|
|
|
# ── C and C++ ────────────────────────────────────────────────────────────────
|
|
#
|
|
# clangd from the project's own release rather than apt: one pinned archive that
|
|
# carries its resource directory — the builtin headers it resolves <stddef.h>
|
|
# in — beside the binary, which it finds by resolving its own path. Hence the
|
|
# symlink into /usr/local/bin rather than a copy.
|
|
ARG CLANGD_VERSION=22.1.6
|
|
RUN set -eux; \
|
|
curl -fsSL "https://github.com/clangd/clangd/releases/download/${CLANGD_VERSION}/clangd-linux-${CLANGD_VERSION}.zip" -o /tmp/clangd.zip; \
|
|
unzip -q /tmp/clangd.zip -d /usr/local; \
|
|
mv "/usr/local/clangd_${CLANGD_VERSION}" /usr/local/clangd; \
|
|
chmod +x /usr/local/clangd/bin/clangd; \
|
|
ln -s /usr/local/clangd/bin/clangd /usr/local/bin/clangd; \
|
|
rm /tmp/clangd.zip
|
|
|
|
# ── Java ─────────────────────────────────────────────────────────────────────
|
|
#
|
|
# A JDK because jdtls IS a Java program, and a JDK rather than a JRE because it
|
|
# is also what jdtls reads the platform's own sources out of (lib/src.zip).
|
|
#
|
|
# The milestone, not the snapshot: `snapshots/latest.txt` moves, and a build
|
|
# that reads it is a build whose language server nobody chose. A milestone is a
|
|
# version plus the build stamp that identifies it, which together are immutable.
|
|
#
|
|
# `bin/jdtls` is the project's own Python launcher, and it is what langs.go
|
|
# starts rather than a hand-written `java -jar`: it already asks Equinox for a
|
|
# CASCADED configuration (`osgi.sharedConfiguration.area.readOnly=true`), which
|
|
# is the one thing an OSGi application needs to run from a read-only
|
|
# installation. Reproducing that argv here would be a second copy of it to keep
|
|
# right. It resolves its own path, so the symlink onto PATH is enough.
|
|
#
|
|
# `config_linux` is x86_64's, which is this image's — see the platform list in
|
|
# hanzo.yml. An arm64 image would need the launcher to pick config_linux_arm,
|
|
# and it does not.
|
|
ARG JDK_VERSION=21.0.12
|
|
ARG JDK_BUILD=8
|
|
ARG JDTLS_VERSION=1.60.0
|
|
ARG JDTLS_BUILD=202606262232
|
|
RUN set -eux; \
|
|
curl -fsSL "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-${JDK_VERSION}%2B${JDK_BUILD}/OpenJDK21U-jdk_x64_linux_hotspot_${JDK_VERSION}_${JDK_BUILD}.tar.gz" -o /tmp/jdk.tgz; \
|
|
mkdir -p /usr/local/jdk; \
|
|
tar -C /usr/local/jdk --strip-components=1 --no-same-owner -xzf /tmp/jdk.tgz; \
|
|
rm /tmp/jdk.tgz; \
|
|
ln -s /usr/local/jdk/bin/java /usr/local/bin/java; \
|
|
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz" -o /tmp/jdtls.tgz; \
|
|
mkdir -p /usr/local/jdtls; \
|
|
tar -C /usr/local/jdtls --no-same-owner -xzf /tmp/jdtls.tgz; \
|
|
rm /tmp/jdtls.tgz; \
|
|
ln -s /usr/local/jdtls/bin/jdtls /usr/local/bin/jdtls
|
|
|
|
# ── PHP ──────────────────────────────────────────────────────────────────────
|
|
#
|
|
# composer, for the fetch phase only: `--no-scripts --no-plugins` is composer's
|
|
# own switch for "place vendor/, run none of its authors' code", the exact shape
|
|
# of npm's --ignore-scripts. The server itself is intelephense, installed with
|
|
# Node above.
|
|
ARG COMPOSER_VERSION=2.10.2
|
|
RUN set -eux; \
|
|
curl -fsSL "https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar" -o /usr/local/bin/composer; \
|
|
chmod +x /usr/local/bin/composer
|
|
|
|
# ── Ruby ─────────────────────────────────────────────────────────────────────
|
|
#
|
|
# Installed into Debian's own gem directory (/var/lib/gems) rather than a private
|
|
# one, so RubyGems finds it with no GEM_HOME in the jail's built environment —
|
|
# the bind in internal/lsp/root.go is the whole of the wiring. The executable
|
|
# lands in /usr/local/bin, which is already on the jail's PATH.
|
|
ARG RUBY_LSP_VERSION=0.26.10
|
|
RUN gem install ruby-lsp --version "${RUBY_LSP_VERSION}" --no-document
|
|
|
|
COPY --from=tools /out/gopls /usr/local/bin/gopls
|
|
COPY --from=build /out/lsp /usr/local/bin/lsp
|
|
|
|
# Roots and dependency caches live on a PVC mounted at /var/lib/lsp. The staging
|
|
# directory each jail chroots into is /tmp, an emptyDir — it holds nothing but
|
|
# mountpoints and must be writable by the non-root uid.
|
|
#
|
|
# THE UID IS FIXED, NOT ALLOCATED. `useradd -r` takes whatever system id happens
|
|
# to be free in the base image, so the number changes when the base image changes
|
|
# and nothing in this file says so. Two things downstream need it to be a
|
|
# constant and a NUMBER:
|
|
#
|
|
# * `runAsNonRoot: true` is refused against a USER given as a name — the
|
|
# kubelet cannot prove `lsp` is not root without resolving /etc/passwd inside
|
|
# the image, so it does not try:
|
|
# container has runAsNonRoot and image has non-numeric user (lsp),
|
|
# cannot verify user is non-root
|
|
# That is a CreateContainerConfigError at every start, not a warning.
|
|
# * `fsGroup` on the PVC must name the same group, or the volume mounts
|
|
# root-owned and the first `go mod download` into it fails on EACCES.
|
|
#
|
|
# 65532 is the conventional distroless non-root id, so this agrees with every
|
|
# other hardened image in the fleet rather than inventing a number.
|
|
RUN groupadd -g 65532 lsp && useradd -u 65532 -g 65532 -d /home/lsp -m lsp \
|
|
&& mkdir -p /var/lib/lsp/roots /var/lib/lsp/deps \
|
|
&& chown -R 65532:65532 /var/lib/lsp
|
|
|
|
ENV LSP_LISTEN=":8000" \
|
|
LSP_ROOTS="/var/lib/lsp/roots" \
|
|
LSP_DEPS="/var/lib/lsp/deps" \
|
|
LSP_STAGE="/tmp/jail" \
|
|
LSP_PROXY="https://proxy.golang.org,direct" \
|
|
LSP_SUMDB="sum.golang.org"
|
|
# LSP_KEY is NOT set here. It comes from KMS through a KMSSecret; an unset key
|
|
# makes the daemon refuse every request rather than serve whoever asks.
|
|
|
|
# The NUMBER, not the name — see the useradd above. `USER lsp` reads better and
|
|
# is the one spelling the kubelet cannot check.
|
|
USER 65532
|
|
EXPOSE 8000
|
|
|
|
# tini as PID 1 so orphaned language-server processes are reaped.
|
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
|
CMD ["/usr/local/bin/lsp"]
|