[linstor-gui] Add package for LINBIT linstor-gui web UI

Ships LINBIT's LINSTOR web UI (GPL-3.0) as an opt-in system package under
packages/system/linstor-gui so operators can manage LINSTOR nodes, resources,
and volumes from a browser instead of the linstor CLI.

The UI is built from the upstream pkg.linbit.com tarball onto an
nginx-unprivileged base image. A chart-supplied nginx.conf proxies /v1 and
/metrics to the LINSTOR controller REST API over mTLS using the existing
linstor-client-tls secret created by the linstor package.

Only a ClusterIP Service is created; no Ingress is shipped, because LINSTOR's
controller API is a privileged cluster-wide storage surface and auth depends
on the deployment's OIDC setup. Operators opt in via bundles.enabledPackages
and wire up ingress + auth themselves (port-forward works out of the box).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov Daniil 2026-04-13 11:42:42 +05:00
parent 1436fee2dd
commit ae88fe3779
No known key found for this signature in database
GPG key ID: FA953A439C637F04
13 changed files with 453 additions and 0 deletions

View file

@ -0,0 +1,21 @@
---
apiVersion: cozystack.io/v1alpha1
kind: PackageSource
metadata:
name: cozystack.linstor-gui
spec:
sourceRef:
kind: OCIRepository
name: cozystack-packages
namespace: cozy-system
path: /
variants:
- name: default
dependsOn:
- cozystack.linstor
components:
- name: linstor-gui
path: system/linstor-gui
install:
namespace: cozy-linstor
releaseName: linstor-gui

View file

@ -149,6 +149,7 @@
{{include "cozystack.platform.package.optional.default" (list "cozystack.external-dns-application" $) }}
{{include "cozystack.platform.package.optional.default" (list "cozystack.external-secrets-operator" $) }}
{{include "cozystack.platform.package.optional.default" (list "cozystack.velero" $) }}
{{include "cozystack.platform.package.optional.default" (list "cozystack.linstor-gui" $) }}
{{- if has "cozystack.bootbox" (default (list) .Values.bundles.enabledPackages) }}
{{include "cozystack.platform.package.default" (list "cozystack.bootbox-application" $) }}
{{include "cozystack.platform.package.default" (list "cozystack.bootbox" $) }}

View file

@ -0,0 +1,3 @@
apiVersion: v2
name: cozy-linstor-gui
version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process

View file

@ -0,0 +1,32 @@
export NAME=linstor-gui
export NAMESPACE=cozy-linstor
include ../../../hack/common-envs.mk
include ../../../hack/package.mk
LINSTOR_GUI_VERSION ?= 2.3.0
image: image-linstor-gui
image-linstor-gui:
docker buildx build images/linstor-gui \
--provenance false \
--builder=$(BUILDER) \
--platform=linux/amd64,linux/arm64 \
--build-arg LINSTOR_GUI_VERSION=$(LINSTOR_GUI_VERSION) \
--tag $(REGISTRY)/linstor-gui:$(call settag,$(LINSTOR_GUI_VERSION)) \
--tag $(REGISTRY)/linstor-gui:$(call settag,$(LINSTOR_GUI_VERSION)-$(TAG)) \
--cache-from type=registry,ref=$(REGISTRY)/linstor-gui:latest \
--cache-to type=inline \
--metadata-file images/linstor-gui.json \
--push=$(PUSH) \
--label "org.opencontainers.image.source=https://github.com/cozystack/cozystack" \
--load=$(LOAD)
REPOSITORY="$(REGISTRY)/linstor-gui" \
yq -i '.image.repository = strenv(REPOSITORY)' values.yaml
TAG="$(call settag,$(LINSTOR_GUI_VERSION))@$$(yq e '."containerimage.digest"' images/linstor-gui.json -o json -r)" \
yq -i '.image.tag = strenv(TAG)' values.yaml
rm -f images/linstor-gui.json
test:
helm unittest .

View file

@ -0,0 +1,43 @@
# linstor-gui
Cozystack system package for [LINBIT/linstor-gui](https://github.com/LINBIT/linstor-gui)
— a web UI for managing LINSTOR nodes, resources, volumes and snapshots.
Installed alongside the `linstor` package in the `cozy-linstor` namespace. The UI
proxies the LINSTOR controller REST API at `https://linstor-controller.cozy-linstor.svc:3371`
using mTLS with the `linstor-client-tls` secret created by the `linstor` package.
## Exposing the UI
This package only creates a `ClusterIP` Service. It does **not** ship an ingress,
because authentication depends on the deployment's Keycloak / OIDC setup and
LINSTOR's controller API is a privileged cluster-wide storage management
surface. Cluster admins should wire up ingress + auth explicitly, for example:
```bash
kubectl -n cozy-linstor port-forward svc/linstor-gui 3373:80
```
then open <http://localhost:3373>.
## Parameters
### Image
| Name | Description | Value |
| ------------------ | ---------------------------------------------------------- | ----------------------------------------- |
| `image.repository` | LINSTOR GUI container image repository | `ghcr.io/cozystack/cozystack/linstor-gui` |
| `image.tag` | LINSTOR GUI container image tag (digest recommended) | `2.3.0` |
### Deployment
| Name | Description | Value |
| ---------- | ------------------------------- | ----- |
| `replicas` | Number of linstor-gui replicas | `1` |
### LINSTOR controller connection
| Name | Description | Value |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
| `linstor.endpoint` | In-cluster URL of the LINSTOR controller REST API (HTTPS, mTLS) | `https://linstor-controller.cozy-linstor.svc:3371` |
| `linstor.clientSecret` | Kubernetes Secret with `tls.crt`, `tls.key`, `ca.crt` used as the mTLS client certificate against the LINSTOR controller. Created by the `linstor` package. | `linstor-client-tls` |

View file

@ -0,0 +1,19 @@
# Upstream: https://github.com/LINBIT/linstor-gui (GPL-3.0)
# Serves the pre-built LINSTOR GUI tarball published by LINBIT from a hardened
# nginx-unprivileged image. nginx.conf is supplied by the chart via ConfigMap,
# so the upstream docker-entrypoint.sh / nginx.conf.template is not used.
FROM nginxinc/nginx-unprivileged:1.29-alpine
ARG LINSTOR_GUI_VERSION
USER root
RUN apk add --no-cache curl tar && \
curl -fsSL "https://pkg.linbit.com/downloads/linstor/linstor-gui-${LINSTOR_GUI_VERSION}.tar.gz" -o /tmp/linstor-gui.tar.gz && \
mkdir -p /usr/share/nginx/html && \
tar -xzf /tmp/linstor-gui.tar.gz -C /usr/share/nginx/html --strip-components=2 "linstor-gui-${LINSTOR_GUI_VERSION}/dist" && \
rm -f /tmp/linstor-gui.tar.gz && \
apk del curl tar && \
chown -R 101:0 /usr/share/nginx/html
USER 101
EXPOSE 3373
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,17 @@
{{/*
Common labels
*/}}
{{- define "linstor-gui.labels" -}}
app.kubernetes.io/name: linstor-gui
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: cozystack
{{- end }}
{{/*
Selector labels
*/}}
{{- define "linstor-gui.selectorLabels" -}}
app.kubernetes.io/name: linstor-gui
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View file

@ -0,0 +1,82 @@
{{/*
Parse the LINSTOR endpoint so nginx can set `proxy_ssl_name` correctly.
The endpoint is expected to be an https:// URL like
https://linstor-controller.cozy-linstor.svc:3371
*/}}
{{- $endpoint := trimPrefix "https://" (trimPrefix "http://" .Values.linstor.endpoint) -}}
{{- $endpointHost := (splitList ":" $endpoint) | first -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: linstor-gui-nginx
labels:
{{- include "linstor-gui.labels" . | nindent 4 }}
data:
nginx.conf: |
worker_processes auto;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
sendfile on;
keepalive_timeout 65;
server {
listen 3373;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Static UI assets
location / {
try_files $uri $uri/ /index.html;
}
# Proxy LINSTOR REST API over mTLS to the controller
location /v1 {
proxy_pass {{ .Values.linstor.endpoint }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_ssl_certificate /etc/linstor/client/tls.crt;
proxy_ssl_certificate_key /etc/linstor/client/tls.key;
proxy_ssl_trusted_certificate /etc/linstor/client/ca.crt;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
proxy_ssl_server_name on;
proxy_ssl_name {{ $endpointHost }};
}
location /metrics {
proxy_pass {{ .Values.linstor.endpoint }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_ssl_certificate /etc/linstor/client/tls.crt;
proxy_ssl_certificate_key /etc/linstor/client/tls.key;
proxy_ssl_trusted_certificate /etc/linstor/client/ca.crt;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
proxy_ssl_server_name on;
proxy_ssl_name {{ $endpointHost }};
}
location = /healthz {
access_log off;
return 200 'ok';
add_header Content-Type text/plain;
}
}
}

View file

@ -0,0 +1,101 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: linstor-gui
labels:
{{- include "linstor-gui.labels" . | nindent 4 }}
annotations:
reloader.stakater.com/auto: "true"
spec:
replicas: {{ .Values.replicas }}
selector:
matchLabels:
{{- include "linstor-gui.selectorLabels" . | nindent 6 }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
annotations:
checksum/nginx-config: {{ include (print $.Template.BasePath "/configmap-nginx.yaml") . | sha256sum }}
labels:
{{- include "linstor-gui.labels" . | nindent 8 }}
spec:
serviceAccountName: linstor-gui
automountServiceAccountToken: false
priorityClassName: system-cluster-critical
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
{{- include "linstor-gui.selectorLabels" . | nindent 20 }}
topologyKey: kubernetes.io/hostname
containers:
- name: linstor-gui
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3373
protocol: TCP
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 20
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
capabilities:
drop:
- ALL
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
- name: linstor-client-tls
mountPath: /etc/linstor/client
readOnly: true
- name: tmp
mountPath: /tmp
- name: nginx-cache
mountPath: /var/cache/nginx
volumes:
- name: nginx-config
configMap:
name: linstor-gui-nginx
- name: linstor-client-tls
secret:
secretName: {{ .Values.linstor.clientSecret }}
defaultMode: 0400
- name: tmp
emptyDir: {}
- name: nginx-cache
emptyDir: {}

View file

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: linstor-gui
labels:
{{- include "linstor-gui.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
selector:
{{- include "linstor-gui.selectorLabels" . | nindent 4 }}

View file

@ -0,0 +1,7 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: linstor-gui
labels:
{{- include "linstor-gui.labels" . | nindent 4 }}
automountServiceAccountToken: false

View file

@ -0,0 +1,95 @@
suite: linstor-gui deployment
templates:
- templates/deployment.yaml
- templates/service.yaml
- templates/configmap-nginx.yaml
tests:
- it: renders a ClusterIP service on port 80 -> http
template: templates/service.yaml
release:
name: linstor-gui
namespace: cozy-linstor
asserts:
- isKind:
of: Service
- equal:
path: spec.type
value: ClusterIP
- equal:
path: spec.ports[0].port
value: 80
- equal:
path: spec.ports[0].targetPort
value: http
- it: mounts the LINSTOR client TLS secret into the pod
template: templates/deployment.yaml
release:
name: linstor-gui
namespace: cozy-linstor
asserts:
- isKind:
of: Deployment
- contains:
path: spec.template.spec.volumes
content:
name: linstor-client-tls
secret:
secretName: linstor-client-tls
defaultMode: 0400
- contains:
path: spec.template.spec.containers[0].volumeMounts
content:
name: linstor-client-tls
mountPath: /etc/linstor/client
readOnly: true
- it: runs as a non-root user with a read-only root filesystem
template: templates/deployment.yaml
release:
name: linstor-gui
namespace: cozy-linstor
asserts:
- equal:
path: spec.template.spec.containers[0].securityContext.runAsNonRoot
value: true
- equal:
path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem
value: true
- equal:
path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation
value: false
- it: proxies /v1 to the configured LINSTOR controller with mTLS
template: templates/configmap-nginx.yaml
release:
name: linstor-gui
namespace: cozy-linstor
asserts:
- isKind:
of: ConfigMap
- matchRegex:
path: data["nginx.conf"]
pattern: "location /v1"
- matchRegex:
path: data["nginx.conf"]
pattern: "proxy_pass https://linstor-controller.cozy-linstor.svc:3371"
- matchRegex:
path: data["nginx.conf"]
pattern: "proxy_ssl_certificate /etc/linstor/client/tls.crt"
- it: overrides linstor endpoint and client secret
template: templates/configmap-nginx.yaml
release:
name: linstor-gui
namespace: cozy-linstor
set:
linstor.endpoint: https://my-controller.example.svc:3371
asserts:
- matchRegex:
path: data["nginx.conf"]
pattern: "proxy_pass https://my-controller.example.svc:3371"
- matchRegex:
path: data["nginx.conf"]
pattern: "proxy_ssl_name my-controller.example.svc"

View file

@ -0,0 +1,17 @@
## @section Image
## @param image.repository LINSTOR GUI container image repository
## @param image.tag LINSTOR GUI container image tag (digest recommended)
image:
repository: ghcr.io/cozystack/cozystack/linstor-gui
tag: 2.3.0
## @section Deployment
## @param replicas Number of linstor-gui replicas
replicas: 1
## @section LINSTOR controller connection
## @param linstor.endpoint In-cluster URL of the LINSTOR controller REST API (HTTPS, mTLS)
## @param linstor.clientSecret Kubernetes Secret with `tls.crt`, `tls.key`, `ca.crt` used as the mTLS client certificate against the LINSTOR controller. Created by the `linstor` package.
linstor:
endpoint: "https://linstor-controller.cozy-linstor.svc:3371"
clientSecret: "linstor-client-tls"