diff --git a/Readme.md b/Readme.md index 64fde51..6f48f43 100644 --- a/Readme.md +++ b/Readme.md @@ -1,52 +1,135 @@ # Dave -Dave is a dashboard for Docker, perfect for home servers. It dynamically updates it's list of links to apps based on `labels` set with Docker. +Dave is a dashboard for Docker and Kubernetes, perfect for home servers. It dynamically updates its list of links to apps based on `labels` set with Docker or `annotations` on Kubernetes Services. ## Docker compose setup -``` - dave: - image: theknarf/dave - ports: - - 80:80 - volumes: - - /var/run/docker.sock:/var/run/docker.sock - restart: unless-stopped +```yaml +dave: + image: theknarf/dave + ports: + - 80:80 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped ``` Then for each service you want to add to the apps list of `dave` add the following labels: -``` - helloworld: - image: theknarf/hello-world - ports: - - 81:80 - labels: - - "dave.url=//localhost:81/" - - "dave.name=Hello World" - restart: unless-stopped +```yaml +helloworld: + image: theknarf/hello-world + ports: + - 81:80 + labels: + - "dave.url=//localhost:81/" + - "dave.name=Hello World" + restart: unless-stopped ``` See full `docker-compose` examples in the [examples folder](./examples). -## Enviroment variables and labels +## Kubernetes setup + +Dave can also run in Kubernetes and discover services via annotations. -You can set the following enviroment variables on the Docker image `theknarf/dave`: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dave +spec: + replicas: 1 + selector: + matchLabels: + app: dave + template: + metadata: + labels: + app: dave + spec: + serviceAccountName: dave + containers: + - name: dave + image: theknarf/dave + ports: + - containerPort: 80 + env: + - name: DAVE_PROVIDER + value: "kubernetes" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: dave +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: dave-service-reader +rules: +- apiGroups: [""] + resources: ["services"] + verbs: ["list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: dave-service-reader +subjects: +- kind: ServiceAccount + name: dave + namespace: default +roleRef: + kind: ClusterRole + name: dave-service-reader + apiGroup: rbac.authorization.k8s.io +``` + +Then for each service you want to show on the dashboard, add annotations: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: my-app + annotations: + dave.name: "My Application" + dave.url: "http://my-app.example.com" + dave.icon: "mdi:application" +spec: + selector: + app: my-app + ports: + - port: 80 +``` + +## Environment variables Variable|Default|Description --------|-------|----------- +DAVE_PROVIDER|auto|Provider to use: `docker`, `kubernetes`, or `auto` (auto-detects) bgcolor|#EDEEC0|Background color textcolor|#433E0E|Text color -accentcolor|#553555|Accent color, used for url's +accentcolor|#553555|Accent color, used for urls mdx||The markdown used for the dashboard forceHttps|false|Redirect to `https`. Possible values `all`, `dave`, `false`. -Labels you can set on containers you want to show on the dashboard: +## Labels / Annotations + +For Docker containers, use labels. For Kubernetes services, use annotations. The format is the same: + +Name|Default|Description|Note +----|-------|-----------|---- +`dave.name`|Container/Service name|Name to show on the dashboard.| +`dave.url`||URL to link to.|Set either `dave.url` or `dave.relativeSubdomain` but not both. +`dave.relativeSubdomain`||URL to link to, relative to the domain that the dashboard is served from.|Set either `dave.url` or `dave.relativeSubdomain` but not both. +`dave.icon`||The name of an icon, taken from [Iconify](https://iconify.design/).|Some containers have default icons + +## Provider auto-detection -Label|Default|Description|Note ------|-------|-----------|---- -`dave.name`|Container name|Name to show on the dashboard.| -`dave.url`||Url to link to.|Set either `dave.url` or `dave.relativeSubdomain` but not both. -`dave.relativeSubdomain`||Url to link to, relative to the domain that the dashboard is served from.|Set either `dave.url` or `dave.relativeSubdomain` but not both. -`dave.icon`||The name of an icon, taken from [Iconify](https://iconify.design/).|Some containers have default icon +When `DAVE_PROVIDER` is set to `auto` (the default), Dave will: +1. Check if running inside Kubernetes (via `KUBERNETES_SERVICE_HOST` env var) +2. Fall back to Docker if `/var/run/docker.sock` exists +3. Default to Docker for backwards compatibility diff --git a/src/docker/index.ts b/src/docker/index.ts index bbda0af..f2d5d66 100644 --- a/src/docker/index.ts +++ b/src/docker/index.ts @@ -1,5 +1,6 @@ import fallbackIcon from '../fallback-icon'; import { Docker } from 'node-docker-api'; +import type { AppProps } from '../providers'; const fetch = (docker : Docker, path : string, callOverride = {}) => { const call = { @@ -51,14 +52,7 @@ interface Container { Status: string; }; -export interface AppProps { - id: string; - icon: string; - name: string; - status: string; - url?: string; - relativeSubdomain?: string; -}; +// AppProps is now exported from '../providers' const processContainer = (containers : Container[]) : AppProps[] => { return containers diff --git a/src/kubernetes/index.ts b/src/kubernetes/index.ts new file mode 100644 index 0000000..8c198a6 --- /dev/null +++ b/src/kubernetes/index.ts @@ -0,0 +1,112 @@ +import * as https from 'https'; +import * as fs from 'fs'; +import fallbackIcon from '../fallback-icon'; +import { AppProps } from '../providers'; + +interface DaveLabels { + name?: string; + url?: string; + relativeSubdomain?: string; + icon?: string; +} + +interface V1Service { + metadata?: { + uid?: string; + name?: string; + namespace?: string; + annotations?: { [key: string]: string }; + }; +} + +interface V1ServiceList { + items: V1Service[]; +} + +const processAnnotations = (annotations: { [key: string]: string } | undefined): DaveLabels => { + if (!annotations) return {}; + + return Object + .keys(annotations) + .filter(key => key.toLowerCase().startsWith('dave.')) + .map(key => ({ + key: key.substring(5), // Removes 'dave.' + value: annotations[key], + })) + .reduce( + (acc: DaveLabels, { key, value }) => ({ + ...acc, + [key]: value, + }), + {} + ); +}; + +const fetchFromKubernetes = async (path: string): Promise => { + // In-cluster config paths + const tokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token'; + const caPath = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'; + const namespaceFile = '/var/run/secrets/kubernetes.io/serviceaccount/namespace'; + + const host = process.env.KUBERNETES_SERVICE_HOST; + const port = process.env.KUBERNETES_SERVICE_PORT || '443'; + + if (!host) { + throw new Error('Not running in Kubernetes cluster'); + } + + const token = fs.readFileSync(tokenPath, 'utf8'); + const ca = fs.readFileSync(caPath); + + return new Promise((resolve, reject) => { + const options = { + hostname: host, + port: parseInt(port), + path, + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + }, + ca, + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(JSON.parse(data)); + } else { + reject(new Error(`Kubernetes API error: ${res.statusCode} - ${data}`)); + } + }); + }); + + req.on('error', reject); + req.end(); + }); +}; + +export const getServicesWithLabels = async (): Promise => { + const response = await fetchFromKubernetes('/api/v1/services') as V1ServiceList; + const services = response.items; + + return services + .map((service: V1Service) => { + const annotations = processAnnotations(service.metadata?.annotations); + const name = service.metadata?.name || 'unknown'; + const namespace = service.metadata?.namespace || 'default'; + + return { + id: service.metadata?.uid || `${namespace}-${name}`, + name: annotations.name || name, + icon: annotations.icon || fallbackIcon(name), + status: 'Running', + url: annotations.url || '', + relativeSubdomain: annotations.relativeSubdomain || '', + }; + }) + .filter(({ url, relativeSubdomain }: { url: string; relativeSubdomain: string }) => url !== '' || relativeSubdomain !== '') + .sort((first: AppProps, second: AppProps) => first.name.localeCompare(second.name)); +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index fbc62b0..a9750aa 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -4,7 +4,7 @@ import MDX from '@mdx-js/runtime'; import App, { AppProps as IApp } from '../components/app'; import Grid from '../components/grid'; import useForceHttps, { ForceHttpsStatus, replaceUrlWithHttps } from '../force-https'; -import { getContainersWithLabels, AppProps } from '../docker'; +import { getApps, AppProps } from '../providers'; import { themeVars } from '../styles/index.css'; import 'inter-ui/Inter (web)/inter.css'; import { createInlineTheme } from '@vanilla-extract/dynamic'; @@ -73,7 +73,7 @@ const Home : React.FC = ({ colors, mdx, appData, forceHttps }) => { }); } - const gotoApp = (app) => { + const gotoApp = (app: IApp) => { window.location.href = app.href; }; @@ -94,7 +94,7 @@ const Home : React.FC = ({ colors, mdx, appData, forceHttps }) => { export default Home; export const getServerSideProps : GetServerSideProps = async (context) => { - const appData = await getContainersWithLabels(); + const appData = await getApps(); const defaultMdx = ` # Dave diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..43610d5 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,48 @@ +import { getContainersWithLabels } from '../docker'; +import { getServicesWithLabels } from '../kubernetes'; + +export interface AppProps { + id: string; + icon: string; + name: string; + status: string; + url?: string; + relativeSubdomain?: string; +} + +export type Provider = 'docker' | 'kubernetes' | 'auto'; + +const detectProvider = (): Provider => { + // Check for Kubernetes environment (in-cluster) + if (process.env.KUBERNETES_SERVICE_HOST) { + return 'kubernetes'; + } + + // Check for Docker socket + try { + const fs = require('fs'); + if (fs.existsSync('/var/run/docker.sock')) { + return 'docker'; + } + } catch { + // Ignore errors + } + + // Default to docker for backwards compatibility + return 'docker'; +}; + +export const getApps = async (): Promise => { + const configuredProvider = (process.env.DAVE_PROVIDER || 'auto').toLowerCase() as Provider; + const provider = configuredProvider === 'auto' ? detectProvider() : configuredProvider; + + console.log(`[Dave] Using provider: ${provider}`); + + switch (provider) { + case 'kubernetes': + return getServicesWithLabels(); + case 'docker': + default: + return getContainersWithLabels(); + } +};