diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 338bc06..00cee7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,3 +153,34 @@ jobs: - name: Run pre-commit run: pip install pre-commit && pre-commit run --all-files + + # Lint + render the Helm chart so template/value regressions are caught in CI. + helm: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: azure/setup-helm@v4 + with: + version: "v3.16.3" + + - name: Helm lint + run: helm lint deploy/helm/emry + + - name: Helm template (default values) + run: helm template emry deploy/helm/emry + + - name: Helm template (auth token + ingress + PVC) + run: | + helm template emry deploy/helm/emry \ + --set auth.token=ci-secret \ + --set ingress.enabled=true \ + --set ingress.host=emry.example.com \ + --set logs.existingClaim=emry-logs + + - name: Helm template (auth + ingress + PVC) + run: | + helm template emry deploy/helm/emry \ + --set auth.token=ci-token \ + --set ingress.enabled=true \ + --set logs.existingClaim=emry-logs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e15bb77..912f704 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,8 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + # Helm templates are Go templates, not valid YAML; check values/Chart only. + exclude: ^deploy/helm/.*/templates/ - id: check-toml - id: check-merge-conflict - id: check-added-large-files diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..95ebacd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1 + +# ---- Builder --------------------------------------------------------------- +# Pin to the workspace MSRV (see rust-toolchain.toml). The `emry` binary is +# produced by the `emry-cli` crate. +FROM rust:1.88-slim AS builder + +WORKDIR /src + +# Cache-friendly layering: copy only the manifests first so `cargo fetch` +# (dependency resolution + download) is cached until Cargo.toml/Cargo.lock +# actually change, not on every source edit. +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +# The Python SDK is part of the workspace's file tree but not needed to build +# the CLI; the manifests above reference only the Rust crates. + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + cargo fetch + +# Now build the release binary. A cache mount on the target dir keeps +# incremental artifacts across builds; we copy the binary out afterwards. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target \ + cargo build --release -p emry-cli \ + && cp /src/target/release/emry /usr/local/bin/emry + +# ---- Runtime --------------------------------------------------------------- +# Debian slim (NOT distroless): the GPU poller and other helpers may shell out +# (e.g. `nvidia-smi`), so we keep a real shell and libc. Still small. +FROM debian:bookworm-slim AS runtime + +# ca-certificates is handy for any outbound TLS (webhook/Slack alerts); tini as +# a tiny init so signals (SIGTERM on pod stop) are forwarded cleanly. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user. Fixed UID/GID so a mounted /logs volume can be chowned to it. +RUN groupadd --gid 10001 emry \ + && useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin emry + +COPY --from=builder /usr/local/bin/emry /usr/local/bin/emry + +# The dashboard reads a directory of run logs from /logs. Mount a volume here +# (a PVC or hostPath in Kubernetes; see deploy/helm/emry). Owned by the emry +# user so file-mode writers running as the same user can populate it. +RUN mkdir -p /logs && chown emry:emry /logs +VOLUME ["/logs"] + +USER emry +WORKDIR /home/emry + +# The live web dashboard binds this port (default 8787 in `emry web`). +EXPOSE 8787 + +# tini reaps zombies and forwards signals to `emry`. +ENTRYPOINT ["/usr/bin/tini", "--", "emry"] + +# Serve the multi-run project dashboard over /logs. NOTE: `emry web` takes the +# log directory via `--project ` (there is no separate --log-dir flag for +# the web subcommand); `--project` is the directory it scans for runs. +# --host 0.0.0.0 so the dashboard is reachable from outside the container; set +# EMRY_AUTH_TOKEN (and TLS) when exposing it beyond localhost. +CMD ["web", "--project", "/logs", "--port", "8787", "--host", "0.0.0.0"] diff --git a/deploy/helm/emry/.helmignore b/deploy/helm/emry/.helmignore new file mode 100644 index 0000000..4bb337b --- /dev/null +++ b/deploy/helm/emry/.helmignore @@ -0,0 +1,17 @@ +# Patterns to ignore when packaging Helm charts. +.DS_Store +.git/ +.gitignore +.bzr/ +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deploy/helm/emry/Chart.yaml b/deploy/helm/emry/Chart.yaml new file mode 100644 index 0000000..e16f942 --- /dev/null +++ b/deploy/helm/emry/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: emry +description: Emry live training-metrics dashboard — serves the multi-run web UI over a directory of run logs. +type: application +# Chart version — bump on chart changes. +version: 0.1.0 +# The Emry release the default image tracks. +appVersion: "0.1.0" +home: https://github.com/femboyisp/emry +sources: + - https://github.com/femboyisp/emry +keywords: + - emry + - machine-learning + - metrics + - dashboard +maintainers: + - name: femboyisp contributors diff --git a/deploy/helm/emry/templates/NOTES.txt b/deploy/helm/emry/templates/NOTES.txt new file mode 100644 index 0000000..63a359d --- /dev/null +++ b/deploy/helm/emry/templates/NOTES.txt @@ -0,0 +1,46 @@ +Emry dashboard {{ .Chart.AppVersion }} has been deployed as release {{ .Release.Name }}. + +The web server is running: + + emry web --project {{ .Values.logsMountPath }} --port {{ .Values.service.port }} + +It serves the multi-run project dashboard over the logs volume mounted at +{{ .Values.logsMountPath }} and exposes a liveness/readiness probe at GET /healthz. + +Reach the dashboard: +{{- if .Values.ingress.enabled }} + + http{{ if .Values.ingress.tls }}s{{ end }}://{{ .Values.ingress.host }}{{ .Values.ingress.path }} +{{- else if eq .Values.service.type "LoadBalancer" }} + + Watch the external IP be assigned: + kubectl get svc -w {{ include "emry.fullname" . }} --namespace {{ .Release.Namespace }} +{{- else if eq .Values.service.type "NodePort" }} + + export NODE_PORT=$(kubectl get -o jsonpath="{.spec.ports[0].nodePort}" svc {{ include "emry.fullname" . }} --namespace {{ .Release.Namespace }}) + export NODE_IP=$(kubectl get nodes -o jsonpath="{.items[0].status.addresses[0].address}" --namespace {{ .Release.Namespace }}) + echo "http://$NODE_IP:$NODE_PORT" +{{- else }} + + Port-forward to your machine: + kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ include "emry.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + # then open http://127.0.0.1:{{ .Values.service.port }} +{{- end }} + +{{- if or .Values.auth.token .Values.auth.existingSecret }} + +Dashboard auth is ENABLED: requests must present the token from Secret +"{{ include "emry.authSecretName" . }}" (key "{{ include "emry.authSecretKey" . }}"), +wired into the container as EMRY_AUTH_TOKEN. +{{- else }} + +Dashboard auth is DISABLED. Set `auth.token` (or `auth.existingSecret`) to gate +access via EMRY_AUTH_TOKEN. +{{- end }} + +{{- if and (not .Values.logs.existingClaim) (not .Values.logs.hostPath) }} + +WARNING: no persistent logs volume configured (using an ephemeral emptyDir). +The dashboard will show no runs unless something populates {{ .Values.logsMountPath }}. +Set `logs.existingClaim` (a PVC) or `logs.hostPath` to mount your run logs. +{{- end }} diff --git a/deploy/helm/emry/templates/_helpers.tpl b/deploy/helm/emry/templates/_helpers.tpl new file mode 100644 index 0000000..55c0015 --- /dev/null +++ b/deploy/helm/emry/templates/_helpers.tpl @@ -0,0 +1,85 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "emry.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this +(by the DNS naming spec). +*/}} +{{- define "emry.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "emry.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "emry.labels" -}} +helm.sh/chart: {{ include "emry.chart" . }} +{{ include "emry.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "emry.selectorLabels" -}} +app.kubernetes.io/name: {{ include "emry.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use. +*/}} +{{- define "emry.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "emry.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Name of the Secret holding the dashboard auth token. +Uses the user-provided existingSecret when set, otherwise the chart-managed one. +*/}} +{{- define "emry.authSecretName" -}} +{{- if .Values.auth.existingSecret }} +{{- .Values.auth.existingSecret }} +{{- else }} +{{- printf "%s-auth" (include "emry.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Key within the auth Secret that holds the token. +*/}} +{{- define "emry.authSecretKey" -}} +{{- if .Values.auth.existingSecret }} +{{- default "token" .Values.auth.existingSecretKey }} +{{- else }} +{{- "token" }} +{{- end }} +{{- end }} diff --git a/deploy/helm/emry/templates/deployment.yaml b/deploy/helm/emry/templates/deployment.yaml new file mode 100644 index 0000000..d76265d --- /dev/null +++ b/deploy/helm/emry/templates/deployment.yaml @@ -0,0 +1,123 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "emry.fullname" . }} + labels: + {{- include "emry.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "emry.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "emry.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "emry.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automount }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + # Serve the multi-run project dashboard over the mounted logs volume. + # `emry web` takes the log directory via `--project ` (there is + # no separate --log-dir flag for the web subcommand). + args: + - "web" + - "--project" + - {{ .Values.logsMountPath | quote }} + - "--port" + - {{ .Values.service.port | quote }} + # Bind all interfaces so the kubelet probes and Service (which reach + # the pod IP, not loopback) can connect. Pair with auth.token/TLS. + - "--host" + - {{ .Values.host | default "0.0.0.0" | quote }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: EMRY_PROJECT + value: {{ .Values.projectName | quote }} + {{- if or .Values.auth.token .Values.auth.existingSecret }} + # Dashboard auth token (EMRY-061) sourced from a Secret. + - name: EMRY_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "emry.authSecretName" . }} + key: {{ include "emry.authSecretKey" . }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: logs + mountPath: {{ .Values.logsMountPath }} + {{- if .Values.logs.existingClaim }} + readOnly: true + {{- end }} + {{- if index .Values.securityContext "readOnlyRootFilesystem" }} + # Writable scratch for a read-only root filesystem. + - name: tmp + mountPath: /tmp + {{- end }} + volumes: + - name: logs + {{- if .Values.logs.existingClaim }} + persistentVolumeClaim: + claimName: {{ .Values.logs.existingClaim }} + {{- else if .Values.logs.hostPath }} + hostPath: + path: {{ .Values.logs.hostPath }} + type: DirectoryOrCreate + {{- else }} + emptyDir: {{ .Values.logs.emptyDir | toYaml | nindent 12 }} + {{- end }} + {{- if index .Values.securityContext "readOnlyRootFilesystem" }} + - name: tmp + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/emry/templates/ingress.yaml b/deploy/helm/emry/templates/ingress.yaml new file mode 100644 index 0000000..506642a --- /dev/null +++ b/deploy/helm/emry/templates/ingress.yaml @@ -0,0 +1,33 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "emry.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "emry.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} +{{- end }} diff --git a/deploy/helm/emry/templates/secret.yaml b/deploy/helm/emry/templates/secret.yaml new file mode 100644 index 0000000..8c40596 --- /dev/null +++ b/deploy/helm/emry/templates/secret.yaml @@ -0,0 +1,15 @@ +{{- /* +Create a Secret holding the dashboard auth token only when the user supplied an +inline token AND did not point at their own existing Secret. +*/ -}} +{{- if and .Values.auth.token (not .Values.auth.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ printf "%s-auth" (include "emry.fullname" .) }} + labels: + {{- include "emry.labels" . | nindent 4 }} +type: Opaque +stringData: + token: {{ .Values.auth.token | quote }} +{{- end }} diff --git a/deploy/helm/emry/templates/service.yaml b/deploy/helm/emry/templates/service.yaml new file mode 100644 index 0000000..3a00bd5 --- /dev/null +++ b/deploy/helm/emry/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "emry.fullname" . }} + labels: + {{- include "emry.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "emry.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/emry/templates/serviceaccount.yaml b/deploy/helm/emry/templates/serviceaccount.yaml new file mode 100644 index 0000000..08a83dd --- /dev/null +++ b/deploy/helm/emry/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "emry.serviceAccountName" . }} + labels: + {{- include "emry.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/deploy/helm/emry/values.yaml b/deploy/helm/emry/values.yaml new file mode 100644 index 0000000..0521ebe --- /dev/null +++ b/deploy/helm/emry/values.yaml @@ -0,0 +1,118 @@ +# Default values for the emry chart. +# This is a YAML-formatted file. Values are grouped by concern. + +replicaCount: 1 + +image: + repository: ghcr.io/femboyisp/emry + # Overrides the image tag; defaults to the chart appVersion when empty. + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# The project/experiment name shown in the dashboard header. The web server is +# launched as `emry web --project --port `; this +# value is passed via the EMRY_PROJECT env only for display context and does +# not change the log directory (that is always the mounted /logs path). +projectName: emry + +# Where the container reads run logs from. This path is mounted from the volume +# configured under `logs` below and passed to `emry web --project `. +logsMountPath: /logs + +# Interface the dashboard binds inside the pod. Must be 0.0.0.0 for the kubelet +# probes and Service to reach it; only change if you know what you're doing. +host: "0.0.0.0" + +service: + type: ClusterIP + # The dashboard HTTP port. Passed to `emry web --port`. + port: 8787 + +# Run-logs volume the dashboard reads. Choose ONE source: +# - existingClaim: mount a pre-existing PersistentVolumeClaim (recommended in +# a real cluster; the training jobs write run logs into the same PVC). +# - hostPath: mount a host directory (handy for single-node/dev clusters). +# If neither is set, an ephemeral emptyDir is used (data is lost on restart and +# there is nothing to display unless a sidecar populates it) — dev only. +logs: + existingClaim: "" + hostPath: "" + # Only used when neither existingClaim nor hostPath is set. + emptyDir: {} + +# Dashboard authentication. When set, the container is given EMRY_AUTH_TOKEN, +# which the web server (EMRY-061) uses to gate access. +# - Set `auth.token` to have the chart create a Secret holding the token. +# - Or set `auth.existingSecret` (+ optional key) to reference your own Secret. +# Leave both empty to run the dashboard unauthenticated. +auth: + token: "" + existingSecret: "" + existingSecretKey: token + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 10001 + +securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +ingress: + enabled: false + className: "" + annotations: {} + host: emry.local + path: / + pathType: Prefix + tls: [] + # - secretName: emry-tls + # hosts: + # - emry.local + +resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi + +livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + +readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + +nodeSelector: {} +tolerations: [] +affinity: {} + +# Extra environment variables to inject into the container. +extraEnv: []