├── .claudeignore ├── .gitignore ├── docs ├── orange-lab-910-512.png ├── electrs-wallet-bitbox.png ├── electrs-wallet-sparrow.png ├── electrs-wallet.md ├── amd-gpu.md ├── upgrade.md ├── troubleshooting.md ├── install.md ├── install-ssh.md ├── install-nodes.md ├── longhorn-disable.md ├── install-k3s.md └── backup.md ├── .vscode └── settings.json ├── .editorconfig ├── scripts ├── bitcoin-cli.sh ├── logs.sh ├── pg-dump.sh ├── exec-sh.sh ├── k3s-agent.sh ├── pg-restore.sh └── k3s-server.sh ├── opencode.json ├── .github ├── workflows │ ├── main.yml │ └── opencode.yml └── dependabot.yml ├── .prettierrc ├── tsconfig.json ├── components ├── office │ ├── index.ts │ ├── OFFICE.md │ └── nextcloud.ts ├── iot │ ├── IOT.md │ ├── index.ts │ └── home-assistant.ts ├── bitcoin │ ├── utils │ │ ├── bitcoin-conf.ts │ │ └── rpc-user.ts │ ├── bitcoin-knots.ts │ ├── bitcoin-core.ts │ ├── electrs.ts │ ├── mempool.ts │ ├── index.ts │ └── BITCOIN.md ├── monitoring │ ├── index.ts │ ├── beszel.ts │ └── MONITORING.md ├── data │ ├── index.ts │ ├── cloudnative-pg │ │ └── cloudnative-pg.ts │ ├── mariadb-operator │ │ └── mariadb-operator.ts │ └── DATA.md ├── system │ ├── cert-manager.ts │ ├── tailscale │ │ ├── tailscale.ts │ │ └── tailscale-operator.ts │ ├── minio │ │ ├── minio-s3-user.ts │ │ ├── minio.ts │ │ └── minio-s3-bucket.ts │ ├── debug.ts │ ├── amd-gpu-operator │ │ └── amd-gpu-operator.ts │ ├── index.ts │ ├── nvidia-gpu-operator.ts │ └── nfd.ts ├── grafana-dashboard.ts ├── ai │ ├── sdnext.ts │ ├── invokeai.ts │ ├── automatic1111.ts │ ├── n8n.ts │ ├── index.ts │ ├── kubeai.ts │ └── ollama.ts ├── metadata.ts ├── nodes.ts ├── services.ts ├── types.ts ├── mariadb.ts ├── postgres.ts ├── longhorn-volume.ts ├── databases.ts └── root-config.ts ├── package.json ├── LICENSE ├── index.ts ├── .devcontainer └── devcontainer.json ├── eslint.config.mjs ├── AGENTS.md └── README.md /.claudeignore: -------------------------------------------------------------------------------- 1 | bin 2 | node_modules 3 | Pulumi.*.yaml 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | /node_modules/ 3 | Pulumi.*.yaml 4 | .goose/ 5 | .github/copilot.yml 6 | -------------------------------------------------------------------------------- /docs/orange-lab-910-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QC-Labs/orange-lab/HEAD/docs/orange-lab-910-512.png -------------------------------------------------------------------------------- /docs/electrs-wallet-bitbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QC-Labs/orange-lab/HEAD/docs/electrs-wallet-bitbox.png -------------------------------------------------------------------------------- /docs/electrs-wallet-sparrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QC-Labs/orange-lab/HEAD/docs/electrs-wallet-sparrow.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 4 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | 11 | [Pulumi.*.yaml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /scripts/bitcoin-cli.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Wrapper for bitcoin-cli to set RPC authentication 4 | 5 | ADMINPASSWORD=$(pulumi stack output bitcoin --show-secrets | jq .bitcoinUsers.admin -r) 6 | 7 | bitcoin-cli -rpcconnect=bitcoin -rpcuser=admin -rpcpassword="${ADMINPASSWORD}" "$@" 8 | -------------------------------------------------------------------------------- /opencode.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://opencode.ai/config.json", 3 | "permission": { 4 | "bash": { 5 | "*": "ask", 6 | "git status": "allow", 7 | "git diff": "allow", 8 | "ls": "allow", 9 | "pwd": "allow", 10 | "npm run lint": "allow" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version-file: 'package.json' 13 | cache: 'npm' 14 | - run: npm ci 15 | - run: npm run lint 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "endOfLine": "lf", 4 | "printWidth": 90, 5 | "semi": true, 6 | "singleQuote": true, 7 | "tabWidth": 4, 8 | "trailingComma": "all", 9 | "useTabs": false, 10 | "overrides": [ 11 | { 12 | "files": "Pulumi.*.yaml", 13 | "options": { 14 | "tabWidth": 2 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: 'devcontainers' 10 | directory: '/' 11 | schedule: 12 | interval: weekly 13 | cooldown: 14 | default-days: 7 15 | -------------------------------------------------------------------------------- /scripts/logs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to quickly access application logs 4 | # Usage: logs [namespace] 5 | 6 | appName=$1 7 | namespace=${2:-$1} # app name as namespace if not provided 8 | label=app.kubernetes.io/name 9 | 10 | if [ -z "$appName" ]; then 11 | echo "Error: Application name is required" 12 | echo "Usage: logs [namespace]" 13 | exit 1 14 | fi 15 | 16 | if [[ "$appName" == "open-webui" ]]; then 17 | label=app.kubernetes.io/component 18 | fi 19 | 20 | kubectl logs -f -l $label=$appName -n $namespace --all-containers=true --ignore-errors=true 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "experimentalDecorators": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitReturns": true, 10 | "outDir": "bin", 11 | "pretty": true, 12 | "resolveJsonModule": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "es2016" 16 | }, 17 | "include": ["**/*.ts", "eslint.config.mjs"], 18 | "files": ["index.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /scripts/pg-dump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to dump PostgreSQL database from Kubernetes pod 4 | # Usage: pg-dump [namespace] 5 | 6 | appName=$1 7 | namespace=${2:-$1} # app name as namespace if not provided 8 | 9 | if [ -z "$appName" ]; then 10 | echo "Error: Application name is required" 11 | echo "Usage: pg-dump [namespace]" 12 | exit 1 13 | fi 14 | 15 | pod="${appName}-db-1" 16 | 17 | echo "Dumping database from pod: $pod" 18 | kubectl exec $pod -n $namespace -c postgres -- pg_dump -Fc -d $appName > $appName.dump 19 | 20 | if [ $? -eq 0 ]; then 21 | echo "Database dump completed successfully: $appName.dump" 22 | else 23 | echo "Error: Database dump failed" 24 | exit 1 25 | fi 26 | -------------------------------------------------------------------------------- /scripts/exec-sh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to exec into application container 4 | # Usage: exec [namespace] 5 | 6 | appName=$1 7 | namespace=${2:-$1} # app name as namespace if not provided 8 | label=app.kubernetes.io/name 9 | 10 | if [ -z "$appName" ]; then 11 | echo "Error: Application name is required" 12 | echo "Usage: exec [namespace]" 13 | exit 1 14 | fi 15 | 16 | if [[ "$appName" == "open-webui" ]]; then 17 | label=app.kubernetes.io/component 18 | fi 19 | 20 | # Get the first pod name 21 | pod=$(kubectl get pods -l $label=$appName -n $namespace -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) 22 | 23 | if [ -z "$pod" ]; then 24 | echo "Error: No pod found for app $appName in namespace $namespace" 25 | exit 1 26 | fi 27 | 28 | kubectl exec -it $pod -n $namespace -- /bin/bash -------------------------------------------------------------------------------- /scripts/k3s-agent.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Run this script where Pulumi is configured. 5 | # Copy and execute the output on each Kubernetes agent. 6 | 7 | K3S_URL=https://$(pulumi config get k3s:serverIp):6443 8 | K3S_TOKEN=$(pulumi config get k3s:agentToken) 9 | TS_AUTH_KEY=$(pulumi stack output system --show-secrets | jq .tailscaleAgentKey -r) 10 | 11 | echo export K3S_URL=$K3S_URL 12 | echo export NODE_IP='$(tailscale ip -4)' 13 | echo export K3S_TOKEN=$K3S_TOKEN 14 | echo export TS_AUTH_KEY=$TS_AUTH_KEY 15 | echo 16 | echo "curl -sfL https://get.k3s.io | sh -s - \ 17 | --server \$K3S_URL \ 18 | --token \$K3S_TOKEN \ 19 | --bind-address=\$NODE_IP \ 20 | --selinux \ 21 | --vpn-auth \"name=tailscale,joinKey=\$TS_AUTH_KEY\"" 22 | echo 23 | echo systemctl restart k3s-agent.service 24 | echo systemctl enable k3s-agent.service --now 25 | -------------------------------------------------------------------------------- /components/office/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { rootConfig } from '../root-config'; 3 | import { Nextcloud } from './nextcloud'; 4 | 5 | export class OfficeModule extends pulumi.ComponentResource { 6 | private readonly nextcloud?: Nextcloud; 7 | 8 | getExports() { 9 | return { 10 | endpoints: { 11 | nextcloud: this.nextcloud?.serviceUrl, 12 | }, 13 | nextcloud: { 14 | users: this.nextcloud?.users, 15 | db: this.nextcloud?.dbConfig, 16 | }, 17 | }; 18 | } 19 | 20 | constructor(name: string, opts?: pulumi.ComponentResourceOptions) { 21 | super('orangelab:office', name, {}, opts); 22 | 23 | if (rootConfig.isEnabled('nextcloud')) { 24 | this.nextcloud = new Nextcloud('nextcloud', { parent: this }); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/opencode.yml: -------------------------------------------------------------------------------- 1 | name: opencode 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | 7 | jobs: 8 | opencode: 9 | if: | 10 | contains(github.event.comment.body, ' /oc') || 11 | startsWith(github.event.comment.body, '/oc') || 12 | contains(github.event.comment.body, ' /opencode') || 13 | startsWith(github.event.comment.body, '/opencode') 14 | runs-on: ubuntu-latest 15 | permissions: 16 | id-token: write 17 | contents: read 18 | pull-requests: read 19 | issues: read 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Run opencode 25 | uses: sst/opencode/github@latest 26 | env: 27 | GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} 28 | GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} 29 | with: 30 | model: google/gemini-2.5-flash -------------------------------------------------------------------------------- /components/iot/IOT.md: -------------------------------------------------------------------------------- 1 | # IoT (Internet of Things) 2 | 3 | Components related to IoT (Internet of Things) sensors and home automation. 4 | 5 | ## Home Assistant 6 | 7 | | | | 8 | | ---------- | ------------------------------------------------------------- | 9 | | Homepage | https://www.home-assistant.io/ | 10 | | Helm chart | https://artifacthub.io/packages/helm/helm-hass/home-assistant | 11 | | Endpoints | `https://home-assistant..ts.net/` | 12 | 13 | Using zone is optional, but helps with making sure the application is deployed on same network as the sensors. 14 | 15 | ```sh 16 | kubectl label nodes topology.kubernetes.io/zone=home 17 | 18 | pulumi config set home-assistant:enabled true 19 | 20 | pulumi config set home-assistant:requiredNodeLabel "topology.kubernetes.io/zone=home" 21 | 22 | pulumi up 23 | ``` 24 | -------------------------------------------------------------------------------- /scripts/pg-restore.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to restore PostgreSQL database to Kubernetes pod 4 | # Usage: pg-restore [namespace] 5 | 6 | appName=$1 7 | namespace=${2:-$1} # app name as namespace if not provided 8 | 9 | if [ -z "$appName" ]; then 10 | echo "Error: Application name is required" 11 | echo "Usage: pg-restore [namespace]" 12 | exit 1 13 | fi 14 | 15 | # Check if dump file exists 16 | if [ ! -f "$appName.dump" ]; then 17 | echo "Error: Dump file '$appName.dump' not found" 18 | echo "Please run 'pg-dump $appName' first to create the dump file" 19 | exit 1 20 | fi 21 | 22 | pod="${appName}-db-1" 23 | 24 | echo "Restoring database to pod: $pod" 25 | kubectl exec -i $pod -n $namespace -c postgres -- pg_restore --no-owner --role=$appName -d $appName --verbose < $appName.dump 26 | 27 | if [ $? -eq 0 ]; then 28 | echo "Database restore completed successfully" 29 | else 30 | echo "Error: Database restore failed" 31 | exit 1 32 | fi 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orange-lab", 3 | "description": "Private infrastructure for cloud natives", 4 | "version": "0.4.0", 5 | "main": "index.ts", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">= 22.0.0" 9 | }, 10 | "scripts": { 11 | "deploy": "pulumi up", 12 | "preview": "pulumi preview --diff", 13 | "lint": "node_modules/.bin/eslint .", 14 | "test": "npm run lint" 15 | }, 16 | "dependencies": { 17 | "@pulumi/kubernetes": "4.24.1", 18 | "@pulumi/minio": "0.16.6", 19 | "@pulumi/pulumi": "3.210.0", 20 | "@pulumi/random": "4.18.4", 21 | "@pulumi/tailscale": "0.23.0" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "9.39.1", 25 | "@pulumi/eslint-plugin": "0.3.1", 26 | "@types/node": "24.10.1", 27 | "eslint": "9.39.1", 28 | "prettier": "3.7.4", 29 | "typescript": "5.9.3", 30 | "typescript-eslint": "8.48.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /components/iot/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { rootConfig } from '../root-config'; 3 | import { HomeAssistant } from './home-assistant'; 4 | 5 | export class IoTModule extends pulumi.ComponentResource { 6 | private readonly homeAssistant: HomeAssistant | undefined; 7 | 8 | getExports() { 9 | return { 10 | endpoints: { 11 | homeAssistant: this.homeAssistant?.endpointUrl, 12 | }, 13 | }; 14 | } 15 | 16 | constructor( 17 | name: string, 18 | opts?: pulumi.ComponentResourceOptions, 19 | ) { 20 | super('orangelab:iot', name, {}, opts); 21 | 22 | if (rootConfig.isEnabled('home-assistant')) { 23 | this.homeAssistant = new HomeAssistant( 24 | 'home-assistant', 25 | { 26 | trustedProxies: [rootConfig.clusterCidr, rootConfig.serviceCidr, '127.0.0.0/8'], 27 | }, 28 | { parent: this }, 29 | ); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /scripts/k3s-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Run this script where Pulumi is configured. 5 | # Copy and execute the output on Kubernetes server. 6 | 7 | CLUSTER_CIDR=$(pulumi config get k3s:clusterCidr) 8 | SERVICE_CIDR=$(pulumi config get k3s:serviceCidr) 9 | TS_AUTH_KEY=$(pulumi stack output system --show-secrets | jq .tailscaleServerKey -r) 10 | 11 | echo export NODE_IP='$(tailscale ip -4)' 12 | echo export CLUSTER_CIDR=$CLUSTER_CIDR 13 | echo export SERVICE_CIDR=$SERVICE_CIDR 14 | echo export TS_AUTH_KEY=$TS_AUTH_KEY 15 | echo 16 | echo "curl -sfL https://get.k3s.io | sh -s - \ 17 | --bind-address=\$NODE_IP \ 18 | --selinux \ 19 | --secrets-encryption \ 20 | --vpn-auth \"name=tailscale,joinKey=\$TS_AUTH_KEY\" \ 21 | --cluster-cidr=\$CLUSTER_CIDR \ 22 | --service-cidr=\$SERVICE_CIDR \ 23 | --kube-apiserver-arg=enable-admission-plugins=DefaultTolerationSeconds \ 24 | --kube-apiserver-arg=default-not-ready-toleration-seconds=10 \ 25 | --kube-apiserver-arg=default-unreachable-toleration-seconds=10" 26 | echo 27 | echo systemctl restart k3s.service 28 | echo systemctl enable k3s.service --now 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Adam Nowotny 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/bitcoin/utils/bitcoin-conf.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | 3 | import { RpcUser } from './rpc-user'; 4 | 5 | function createRpc(rpcUsers: Record): pulumi.Output { 6 | const authLines = Object.values(rpcUsers).map( 7 | user => pulumi.interpolate`rpcauth=${user.rpcAuth}`, 8 | ); 9 | return pulumi.all(authLines).apply(lines => lines.join('\n')); 10 | } 11 | 12 | function create({ prune, debug }: { prune: number; debug?: boolean }): string { 13 | return ` 14 | ${prune > 0 ? `prune=${prune.toString()}` : 'txindex=1'} 15 | blocksonly=0 16 | ${ 17 | debug 18 | ? `debug=all 19 | debugexclude=addrman 20 | debugexclude=bench 21 | debugexclude=estimatefee 22 | debugexclude=leveldb 23 | debugexclude=libevent 24 | debugexclude=mempool 25 | debugexclude=net 26 | debugexclude=txpackages 27 | debugexclude=validation` 28 | : '' 29 | } 30 | disablewallet=1 31 | listen=1 32 | listenonion=0 33 | maxconnections=20 34 | nodebuglogfile=1 35 | printtoconsole=1 36 | rest=0 37 | rpcallowip=0.0.0.0/0 38 | rpcbind=0.0.0.0 39 | server=1 40 | `; 41 | } 42 | 43 | export const BitcoinConf = { 44 | createRpc, 45 | create, 46 | }; 47 | -------------------------------------------------------------------------------- /components/monitoring/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { rootConfig } from '../root-config'; 3 | import { Beszel } from './beszel'; 4 | import { Prometheus } from './prometheus'; 5 | 6 | export class MonitoringModule extends pulumi.ComponentResource { 7 | prometheus: Prometheus | undefined; 8 | beszel: Beszel | undefined; 9 | 10 | getExports() { 11 | return { 12 | endpoints: { 13 | alertmanager: this.prometheus?.alertmanagerEndpointUrl, 14 | ...this.beszel?.app.network.endpoints, 15 | grafana: this.prometheus?.grafanaEndpointUrl, 16 | prometheus: this.prometheus?.prometheusEndpointUrl, 17 | }, 18 | }; 19 | } 20 | 21 | constructor(name: string, opts?: pulumi.ComponentResourceOptions) { 22 | super('orangelab:monitoring', name, {}, opts); 23 | 24 | if (rootConfig.isEnabled('prometheus')) { 25 | this.prometheus = new Prometheus('prometheus', { parent: this }); 26 | } 27 | 28 | if (rootConfig.isEnabled('beszel')) { 29 | this.beszel = new Beszel('beszel', { parent: this }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /components/data/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { CloudNativePG } from './cloudnative-pg/cloudnative-pg'; 3 | import { MariaDBOperator } from './mariadb-operator/mariadb-operator'; 4 | 5 | export class DataModule extends pulumi.ComponentResource { 6 | cloudNativePG?: CloudNativePG; 7 | mariaDBOperator?: MariaDBOperator; 8 | 9 | constructor(name: string, opts?: pulumi.ResourceOptions) { 10 | super('orangelab:data', name, {}, opts); 11 | 12 | const systemAlias = pulumi.interpolate`urn:pulumi:${pulumi.getStack()}::${pulumi.getProject()}::orangelab:system::system`; 13 | 14 | if (pulumi.getStack() && pulumi.getProject()) { 15 | this.cloudNativePG = new CloudNativePG('cloudnative-pg', { 16 | parent: this, 17 | aliases: [ 18 | { type: 'orangelab:system:CloudNativePG', parent: systemAlias }, 19 | ], 20 | }); 21 | this.mariaDBOperator = new MariaDBOperator('mariadb-operator', { 22 | parent: this, 23 | aliases: [ 24 | { type: 'orangelab:system:MariaDBOperator', parent: systemAlias }, 25 | ], 26 | }); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /components/system/cert-manager.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../application'; 4 | import { rootConfig } from '../root-config'; 5 | 6 | export class CertManager extends pulumi.ComponentResource { 7 | constructor(name: string, args = {}, opts?: pulumi.ResourceOptions) { 8 | super('orangelab:system:CertManager', name, args, opts); 9 | 10 | const config = new pulumi.Config(name); 11 | const app = new Application(this, name); 12 | 13 | new kubernetes.helm.v3.Release( 14 | name, 15 | { 16 | chart: 'cert-manager', 17 | repositoryOpts: { repo: 'https://charts.jetstack.io' }, 18 | version: config.get('version'), 19 | namespace: app.namespace, 20 | values: { 21 | crds: { 22 | enabled: true, 23 | keep: true, 24 | }, 25 | prometheus: rootConfig.enableMonitoring() 26 | ? { 27 | servicemonitor: { 28 | enabled: true, 29 | }, 30 | } 31 | : undefined, 32 | }, 33 | }, 34 | { parent: this }, 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /components/grafana-dashboard.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | 4 | interface GrafanaJson { 5 | title: string; 6 | } 7 | 8 | interface GrafanaDashboardArgs { 9 | configJson: GrafanaJson; 10 | title?: string; 11 | } 12 | 13 | export class GrafanaDashboard { 14 | constructor( 15 | private name: string, 16 | private scope: pulumi.ComponentResource, 17 | args: GrafanaDashboardArgs, 18 | ) { 19 | this.createGrafanaDashboard(args.configJson); 20 | } 21 | 22 | private createGrafanaDashboard(configJson: GrafanaJson): void { 23 | new kubernetes.core.v1.ConfigMap( 24 | `${this.name}-dashboard`, 25 | { 26 | metadata: { 27 | name: `${this.name}-dashboard`, 28 | labels: { 29 | grafana_dashboard: '1', 30 | }, 31 | annotations: { 32 | 'k8s-sidecar-target-folder': 'OrangeLab', 33 | }, 34 | }, 35 | data: { 36 | [`${this.name}-dashboard.json`]: JSON.stringify(configJson).replace( 37 | /\${DS_PROMETHEUS}/g, 38 | 'Prometheus', 39 | ), 40 | }, 41 | }, 42 | { parent: this.scope }, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /components/monitoring/beszel.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | 4 | export class Beszel extends pulumi.ComponentResource { 5 | public readonly app: Application; 6 | 7 | constructor(name: string, opts?: pulumi.ResourceOptions) { 8 | super('orangelab:monitoring:Beszel', name, {}, opts); 9 | 10 | const config = new pulumi.Config(name); 11 | const hubKey = config.get('hubKey'); 12 | 13 | this.app = new Application(this, name).addStorage().addDeployment({ 14 | image: 'henrygd/beszel:latest', 15 | port: 8090, 16 | env: { 17 | USER_CREATION: 'true', 18 | }, 19 | hostNetwork: true, 20 | volumeMounts: [{ mountPath: '/beszel_data' }], 21 | resources: { 22 | requests: { cpu: '5m', memory: '50Mi' }, 23 | limits: { memory: '200Mi' }, 24 | }, 25 | }); 26 | 27 | if (hubKey) { 28 | this.app.addDaemonSet({ 29 | name: 'agent', 30 | image: `henrygd/beszel-agent`, 31 | hostNetwork: true, 32 | env: { 33 | PORT: '45876', 34 | KEY: hubKey, 35 | }, 36 | resources: { 37 | requests: { cpu: '5m', memory: '20Mi' }, 38 | limits: { memory: '100Mi' }, 39 | }, 40 | }); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /components/ai/sdnext.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | import { StorageType } from '../types'; 4 | 5 | export class SDNext extends pulumi.ComponentResource { 6 | app: Application; 7 | 8 | constructor(name: string, opts?: pulumi.ResourceOptions) { 9 | super('orangelab:ai:SDNext', name, {}, opts); 10 | 11 | const config = new pulumi.Config(name); 12 | const cliArgs = config.require('cliArgs'); 13 | const amdGpu = config.get('amd-gpu'); 14 | const debug = config.getBoolean('debug') ?? false; 15 | 16 | this.app = new Application(this, name, { gpu: true }) 17 | .addStorage({ type: StorageType.GPU }) 18 | .addDeployment({ 19 | image: 'saladtechnologies/sdnext:base', 20 | port: 7860, 21 | commandArgs: [ 22 | '--listen', 23 | '--docs', 24 | '--skip-requirements', 25 | '--skip-extensions', 26 | '--skip-git', 27 | '--skip-torch', 28 | '--quick', 29 | cliArgs, 30 | ], 31 | env: { 32 | SD_DEBUG: debug ? 'true' : 'false', 33 | SD_USEROCM: amdGpu ? 'True' : undefined, 34 | }, 35 | volumeMounts: [{ mountPath: '/webui/data' }], 36 | resources: { requests: { cpu: '50m', memory: '2.5Gi' } }, 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /docs/electrs-wallet.md: -------------------------------------------------------------------------------- 1 | # Using Electrs with Bitcoin Wallets 2 | 3 | This guide shows how to configure popular Bitcoin wallets to connect to your self-hosted Electrs server running on port 50001 via Tailscale. 4 | 5 | The assumption is that you already have the wallet working with public servers, so f.e. udev rules are imported to access USB device. 6 | 7 | ```sh 8 | # Enable Bitcoin Core node 9 | pulumi config set bitcoin-core:enabled true 10 | # or Bitcoin Knots 11 | pulumi config set bitcoin-knots:enabled true 12 | pulumi up 13 | # Wait for initial blockchain sync to complete (this can take a few hours) 14 | 15 | # Then enable Electrs 16 | pulumi config set electrs:enabled true 17 | pulumi up 18 | # Wait for indexing to finish (this can take a few hours) 19 | ``` 20 | 21 | Use `electrs:50001` to connect your wallet software. SSL is not used as Tailscale already encrypts all traffic. 22 | 23 | --- 24 | 25 | ## BitBox App 26 | 27 | 1. Open BitBox App 28 | 2. Go to **Settings > Advanced settings > Connect your own full node** 29 | 3. Add your Electrs server `electrs:50001` 30 | 4. Remove all existing public Electrum server entries so only your instance is used 31 | 5. Save and restart the app if needed 32 | 33 | ![BitBox App Electrum server settings](./electrs-wallet-bitbox.png) 34 | 35 | --- 36 | 37 | ## Sparrow Wallet 38 | 39 | 1. Open Sparrow Wallet 40 | 2. Go to **File > Settings... > Server** 41 | 3. Set the server type to **Private Electrum** 42 | 4. Add your Electrs server `electrs:50001` 43 | 5. Click **Test Connection** 44 | 6. Save settings 45 | 46 | ![Sparrow Wallet server settings](./electrs-wallet-sparrow.png) 47 | -------------------------------------------------------------------------------- /components/ai/invokeai.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | import { StorageType } from '../types'; 4 | 5 | export class InvokeAi extends pulumi.ComponentResource { 6 | app: Application; 7 | 8 | constructor(name: string, opts?: pulumi.ResourceOptions) { 9 | super('orangelab:ai:InvokeAi', name, {}, opts); 10 | 11 | const config = new pulumi.Config(name); 12 | const debug = config.getBoolean('debug') ?? false; 13 | const huggingfaceToken = config.getSecret('huggingfaceToken'); 14 | const imageTag = config.get('amd-gpu') ? 'main-rocm' : 'latest'; 15 | 16 | this.app = new Application(this, name, { gpu: true }) 17 | .addStorage({ type: StorageType.GPU }) 18 | .addDeployment({ 19 | image: `ghcr.io/invoke-ai/invokeai:${imageTag}`, 20 | port: 9090, 21 | env: { 22 | INVOKEAI_ROOT: '/invokeai', 23 | INVOKEAI_HOST: '0.0.0.0', 24 | INVOKEAI_PORT: '9090', 25 | INVOKEAI_ENABLE_PARTIAL_LOADING: 'true', 26 | INVOKEAI_LOG_LEVEL: debug ? 'debug' : 'info', 27 | INVOKEAI_REMOTE_API_TOKENS: huggingfaceToken 28 | ? `[{"url_regex":"huggingface.co", "token": "${huggingfaceToken.get()}"}]` 29 | : undefined, 30 | }, 31 | healthChecks: true, 32 | volumeMounts: [{ mountPath: '/invokeai' }], 33 | resources: { requests: { cpu: '50m', memory: '1.5Gi' } }, 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /components/system/tailscale/tailscale.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import * as tailscale from '@pulumi/tailscale'; 3 | 4 | export class Tailscale extends pulumi.ComponentResource { 5 | public readonly serverKey: pulumi.Output | undefined; 6 | public readonly agentKey: pulumi.Output | undefined; 7 | public readonly tailnet: string; 8 | 9 | constructor(name: string, args = {}, opts?: pulumi.ResourceOptions) { 10 | super('orangelab:system:Tailscale', name, args, opts); 11 | 12 | this.tailnet = new pulumi.Config(name).require('tailnet'); 13 | 14 | const serverKey = new tailscale.TailnetKey( 15 | `${name}-server-key`, 16 | { 17 | reusable: true, 18 | ephemeral: false, 19 | preauthorized: true, 20 | description: 'Kubernetes server', 21 | expiry: 7776000, 22 | recreateIfInvalid: 'always', 23 | tags: ['tag:k8s-server'], 24 | }, 25 | { parent: this }, 26 | ); 27 | this.serverKey = serverKey.key; 28 | 29 | const agentKey = new tailscale.TailnetKey( 30 | `${name}-agent-key`, 31 | { 32 | reusable: true, 33 | ephemeral: false, 34 | preauthorized: true, 35 | description: 'Kubernetes agents', 36 | expiry: 7776000, 37 | recreateIfInvalid: 'always', 38 | tags: ['tag:k8s-agent'], 39 | }, 40 | { parent: this }, 41 | ); 42 | this.agentKey = agentKey.key; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /components/ai/automatic1111.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | import { StorageType } from '../types'; 4 | 5 | export class Automatic1111 extends pulumi.ComponentResource { 6 | app: Application; 7 | 8 | constructor(name: string, opts?: pulumi.ResourceOptions) { 9 | super('orangelab:ai:Automatic1111', name, {}, opts); 10 | 11 | const config = new pulumi.Config(name); 12 | const cliArgs = config.require('cliArgs'); 13 | 14 | this.app = new Application(this, name, { gpu: true }) 15 | .addStorage({ type: StorageType.GPU }) 16 | .addDeployment({ 17 | image: 'universonic/stable-diffusion-webui:full', 18 | commandArgs: ['--listen', '--api', '--skip-torch-cuda-test'], 19 | env: { 20 | COMMANDLINE_ARGS: cliArgs, 21 | }, 22 | port: 8080, 23 | runAsUser: 1000, 24 | volumeMounts: [ 25 | { 26 | mountPath: '/app/stable-diffusion-webui/models', 27 | subPath: 'models', 28 | }, 29 | { 30 | mountPath: '/app/stable-diffusion-webui/extensions', 31 | subPath: 'extensions', 32 | }, 33 | { 34 | mountPath: '/app/stable-diffusion-webui/outputs', 35 | subPath: 'outputs', 36 | }, 37 | ], 38 | resources: { 39 | requests: { cpu: '100m', memory: '2Gi' }, 40 | }, 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | import { AIModule } from './components/ai'; 3 | import { BitcoinModule } from './components/bitcoin'; 4 | import { DataModule } from './components/data'; 5 | import { IoTModule } from './components/iot'; 6 | import { MonitoringModule } from './components/monitoring'; 7 | import { OfficeModule } from './components/office'; 8 | import { rootConfig } from './components/root-config'; 9 | import { SystemModule } from './components/system'; 10 | 11 | const systemModule = new SystemModule('system'); 12 | export const system = systemModule.getExports(); 13 | 14 | if (rootConfig.isModuleEnabled('data')) { 15 | new DataModule('data', { dependsOn: systemModule }); 16 | } 17 | 18 | if (rootConfig.isModuleEnabled('monitoring')) { 19 | const monitoringModule = new MonitoringModule('monitoring', { 20 | dependsOn: systemModule, 21 | }); 22 | exports.monitoring = monitoringModule.getExports(); 23 | } 24 | 25 | if (rootConfig.isModuleEnabled('iot')) { 26 | const iotModule = new IoTModule('iot', { 27 | dependsOn: systemModule, 28 | }); 29 | exports.iot = iotModule.getExports(); 30 | } 31 | 32 | if (rootConfig.isModuleEnabled('ai')) { 33 | const aiModule = new AIModule('ai', { dependsOn: systemModule }); 34 | exports.ai = aiModule.getExports(); 35 | } 36 | 37 | if (rootConfig.isModuleEnabled('bitcoin')) { 38 | const bitcoinModule = new BitcoinModule('bitcoin', { dependsOn: systemModule }); 39 | exports.bitcoin = bitcoinModule.getExports(); 40 | } 41 | 42 | if (rootConfig.isModuleEnabled('office')) { 43 | const officeModule = new OfficeModule('office', { dependsOn: systemModule }); 44 | exports.office = officeModule.getExports(); 45 | } 46 | -------------------------------------------------------------------------------- /components/system/minio/minio-s3-user.ts: -------------------------------------------------------------------------------- 1 | import * as minio from '@pulumi/minio'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import * as random from '@pulumi/random'; 4 | 5 | export interface MinioS3UserArgs { 6 | username: string; 7 | } 8 | 9 | export class MinioS3User extends pulumi.ComponentResource { 10 | public readonly accessKey: pulumi.Output; 11 | public readonly secretKey: pulumi.Output; 12 | public readonly username: string; 13 | 14 | private readonly iamUser: minio.IamUser; 15 | private readonly serviceAccount: minio.IamServiceAccount; 16 | 17 | constructor( 18 | private name: string, 19 | private args: MinioS3UserArgs, 20 | opts?: pulumi.ResourceOptions, 21 | ) { 22 | super('orangelab:system:MinioS3User', name, args, opts); 23 | 24 | this.iamUser = new minio.IamUser( 25 | `${name}-iamuser`, 26 | { 27 | name: args.username, 28 | secret: this.createPassword(), 29 | }, 30 | { parent: this, provider: opts?.provider }, 31 | ); 32 | 33 | this.serviceAccount = new minio.IamServiceAccount( 34 | `${name}-sa`, 35 | { targetUser: this.iamUser.name }, 36 | { parent: this, provider: opts?.provider, ignoreChanges: ['policy'] }, 37 | ); 38 | 39 | this.username = args.username; 40 | this.accessKey = this.serviceAccount.accessKey; 41 | this.secretKey = this.serviceAccount.secretKey; 42 | } 43 | 44 | private createPassword() { 45 | return new random.RandomPassword( 46 | `${this.name}-password`, 47 | { length: 32, special: false }, 48 | { parent: this }, 49 | ).result; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/amd-gpu.md: -------------------------------------------------------------------------------- 1 | # AMD GPU Guide 2 | 3 | > **Note**: AMD GPU drivers are not as stable as NVIDIA depending on the model. Expect segmentation faults and other issues. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | # Enable automatic GPU detection with NFD 9 | pulumi config set nfd:enabled true 10 | pulumi config set nfd:gpu-autodetect true # default 11 | pulumi up 12 | 13 | # Check if nodes are properly labeled: 14 | kubectl get nodes --selector=orangelab/gpu-amd=true 15 | 16 | # Install cert-manager 17 | pulumi config set cert-manager:enabled true 18 | pulumi up 19 | 20 | # Enable AMD GPU operator 21 | pulumi config set amd-gpu-operator:enabled true 22 | pulumi up 23 | ``` 24 | 25 | ## Using AMD GPUs with Applications 26 | 27 | ### Ollama 28 | 29 | ```sh 30 | pulumi config set ollama:enabled true 31 | # Switch to ROCm image 32 | pulumi config set ollama:amd-gpu true 33 | pulumi up 34 | ``` 35 | 36 | #### AMD GPU Configuration Overrides 37 | 38 | For certain AMD GPUs, you may need to override configuration settings if not properly detected: 39 | 40 | ```sh 41 | # Example for Radeon 780M 42 | pulumi config set ollama:HSA_OVERRIDE_GFX_VERSION "11.0.0" 43 | pulumi config set ollama:HCC_AMDGPU_TARGETS "gfx1103" 44 | pulumi up 45 | ``` 46 | 47 | The `HSA_OVERRIDE_GFX_VERSION` setting can help with ROCm compatibility, while `HCC_AMDGPU_TARGETS` specifies the architecture target for ROCm applications. 48 | 49 | More information at https://github.com/ollama/ollama/blob/main/docs/gpu.md#overrides-on-linux 50 | 51 | ## Monitoring 52 | 53 | AMD GPU dashboards for Prometheus are automatically installed when monitoring is enabled: 54 | 55 | - AMD - Overview 56 | - AMD - GPU 57 | - AMD - Job 58 | - AMD - Compute Node 59 | 60 | ```sh 61 | pulumi config set amd-gpu-operator:enableMonitoring true 62 | pulumi up 63 | ``` 64 | -------------------------------------------------------------------------------- /docs/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | This guide explains the process for upgrading your Orange Lab installation. 4 | 5 | ## Standard Upgrades 6 | 7 | ```sh 8 | # Pull the latest changes 9 | git pull 10 | 11 | # Apply changes, make sure you verify the changes first 12 | pulumi up 13 | ``` 14 | 15 | If any application has problems deploying or gets stuck, try disabling and enabling the app while keeping storage intact: 16 | 17 | ```sh 18 | pulumi config set :enabled true 19 | pulumi config set :storageOnly true 20 | pulumi up 21 | 22 | pulumi config delete :storageOnly 23 | pulumi up 24 | ``` 25 | 26 | ## Handling Breaking Changes 27 | 28 | ### Preparing Volumes for Recovery 29 | 30 | Breaking changes might require removing an application including it's storage. The easiest method is to create a new volume to use in Longhorn UI, either by restoring volume from a recent backup or by cloning an existing dynamic volume. 31 | 32 | Once you switch from dynamic volume to static and use `fromVolume`, all further upgrades become easier and you just need to disable and enable applications as the storage will be kept unless you remove it in UI. 33 | 34 | ```sh 35 | pulumi config set :enabled false 36 | pulumi up 37 | 38 | pulumi config set :fromVolume "" 39 | pulumi config set :enabled true 40 | pulumi up 41 | ``` 42 | 43 | ### Upgrade Procedure 44 | 45 | Once you created the new static volume, disable the app completely and then enable it again. Only the related `PersistentVolume` and it's claim will be removed but the volume will just be detached in Longhorn and can be reused. 46 | 47 | Do this for all affected applications: 48 | 49 | ```sh 50 | git pull 51 | 52 | pulumi config set :enabled false 53 | pulumi up 54 | 55 | pulumi config set :enabled true 56 | pulumi up 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | It's easiest to use _Headlamp_ or _k9s_ to connect to cluster. Below are some useful commands when troubleshooting issues. 4 | 5 | ```sh 6 | # Check logs of the app, namespace optional if same as app name 7 | ./scripts/logs.sh [namespace] 8 | 9 | # Watch cluster events 10 | kubectl events -A -w 11 | 12 | # See all application resources 13 | kubectl get all -n 14 | ``` 15 | 16 | Pods can be stopped and will be recreated automatically. 17 | 18 | You can shut down the application resources, then recreate them. Note that if storage is removed then configuration will be lost and some data might need to be downloaded again (for example LLM models) 19 | 20 | ## HTTPS endpoint 21 | 22 | In case of issues connecting to the HTTPS endpoint, try connecting to the Kubernetes service directly, bypassing the Ingress and Tailscale `ts-*` proxy pod: 23 | 24 | ```sh 25 | # Find cluster IP address and port of the service 26 | kubectl get svc -n 27 | 28 | # Test connection or use browser (note services do not use HTTPS, only Ingress) 29 | curl http://:/ 30 | telnet 31 | ``` 32 | 33 | If that works, then Tailscale Ingress needs to be looked at. Try stopping the `ts-*` proxy pod, it will be recreated. Remember that first time you access an endpoint, the HTTPS certificate is provisioned and that can take up to a minute. 34 | 35 | Make sure there is no leftover entry for a service at https://login.tailscale.com/admin/machines. If there is a conflicting entry, remove it before enabling the app again (specifically the Ingress resource managed by Tailscale operator). 36 | 37 | ## Longhorn 38 | 39 | Longhorn troubleshooting guide - https://github.com/longhorn/longhorn/wiki/Troubleshooting 40 | 41 | Cheatsheet with useful commands - https://support.tools/training/longhorn/troubleshooting/ 42 | -------------------------------------------------------------------------------- /components/bitcoin/utils/rpc-user.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import * as random from '@pulumi/random'; 3 | import * as crypto from 'crypto'; 4 | 5 | /** 6 | * This class is used to create a random password for the RPC user and 7 | * generate the RPC authentication string. 8 | * 9 | * Test with: 10 | * ./scripts/bitcoin-cli.sh -getinfo 11 | */ 12 | export class RpcUser extends pulumi.ComponentResource { 13 | public username: string; 14 | public password: pulumi.Output; 15 | public rpcAuth: pulumi.Output; 16 | 17 | constructor( 18 | private name: string, 19 | private readonly args: { username: string }, 20 | opts?: pulumi.ResourceOptions, 21 | ) { 22 | super('orangelab:bitcoin:RpcUser', `${name}-${args.username}`, args, opts); 23 | this.username = args.username; 24 | this.password = this.createPassword(); 25 | this.rpcAuth = this.createRpcAuth(); 26 | } 27 | 28 | private createPassword() { 29 | return new random.RandomPassword( 30 | `${this.name}-${this.args.username}-password`, 31 | { length: 32, special: false }, 32 | { parent: this }, 33 | ).result; 34 | } 35 | 36 | private createRpcAuth(): pulumi.Output { 37 | const salt = new random.RandomString( 38 | `${this.name}-${this.args.username}-salt`, 39 | { length: 16, special: false }, 40 | { parent: this }, 41 | ).result; 42 | const rpcAuth = pulumi.all([this.password, salt]).apply(([password, salt]) => { 43 | const rpcPasswordHash = crypto 44 | .createHmac('sha256', salt) 45 | .update(password) 46 | .digest('hex'); 47 | return `${this.args.username}:${salt}$${rpcPasswordHash}`; 48 | }); 49 | return rpcAuth; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation - management node 2 | 3 | Management node is where you run Pulumi, most likely your laptop. 4 | 5 | ```sh 6 | git clone https://github.com/QC-Labs/orange-lab 7 | ``` 8 | 9 | ## Prerequisites - DevContainers (VSCode) 10 | 11 | _This method is recommended for new users as it doesn't require installing dependencies._ 12 | 13 | Make sure you have DevContainers extension installed (https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). 14 | 15 | Open project in VSCode. It will install the required dependencies and recommended extensions. You can then use the terminal inside VSCode to run commands. 16 | 17 | ## Prerequisites - Manual 18 | 19 | Install dependencies on the management node: 20 | 21 | ```sh 22 | # Install required packages 23 | brew install node pulumi kubectl 24 | # (Recommended) Install development packages 25 | brew install kubectl-cnpg k9s opencode 26 | flatpak install io.kinvolk.Headlamp 27 | flatpak install io.beekeeperstudio.Studio 28 | 29 | sudo tailscale up --operator=$USER --accept-routes 30 | ``` 31 | 32 | ## Pulumi 33 | 34 | Create Pulumi access token at https://app.pulumi.com/account/tokens 35 | 36 | ```sh 37 | pulumi login 38 | pulumi stack init 39 | pulumi stack select 40 | ``` 41 | 42 | ## Tailscale 43 | 44 | ```sh 45 | # Start Tailscale service on each node 46 | sudo tailscale up --operator=$USER --accept-routes 47 | ``` 48 | 49 | Add tags to your Tailnet ACLs (https://login.tailscale.com/admin/acls/file): 50 | 51 | ```json 52 | "tagOwners": { 53 | "tag:k8s-server": [], 54 | "tag:k8s-agent": [], 55 | "tag:k8s-operator": [], 56 | "tag:k8s": ["tag:k8s-operator"], 57 | } 58 | ``` 59 | 60 | Create Tailscale API access token for Pulumi (https://login.tailscale.com/admin/settings/keys) and add it to `Pulumi..yaml` with: 61 | 62 | ```sh 63 | pulumi config set tailscale:apiKey --secret 64 | pulumi config set tailscale:tailnet 65 | pulumi up 66 | ``` 67 | 68 | You can find Tailnet DNS name at https://login.tailscale.com/admin/dns 69 | 70 | Enable MagicDNS and HTTPS certificates on https://login.tailscale.com/admin/dns 71 | -------------------------------------------------------------------------------- /docs/install-ssh.md: -------------------------------------------------------------------------------- 1 | # SSH connection 2 | 3 | This step is **optional**, but makes it easier to login to remote hosts on your Tailscale network as administrator. It's useful when installing/upgrading K3s agents. 4 | 5 | The assumption is that you have SSH server enabled (`systemctl enable sshd.service --now`) and you can login using password to a user account created during OS installation. 6 | 7 | Let's create a new SSH key and enable key-based authentication for root user. 8 | 9 | Using passphrase is recommended as this will help with situation when your private key is leaked or copied. 10 | We'll use ssh-agent so you don't have to enter it too often. 11 | 12 | ```sh 13 | # Generate key on your management node, use passphrase 14 | ssh-keygen # interactive 15 | ssh-keygen -f ~/.ssh/orangelab -C "user@orangelab.space" 16 | 17 | # Add ssh key to agent 18 | ssh-add ~/.ssh/orangelab 19 | 20 | # Confirm the key is available to ssh-agent 21 | ssh-add -l 22 | 23 | # Create entry in remote ~/.ssh/authorized_keys 24 | # Use password login to "user" account 25 | ssh-copy-id -i ~/.ssh/orangelab user@ 26 | 27 | # Login using key authentication 28 | ssh user@ 29 | ``` 30 | 31 | Now we can log in to normal user account on remote host. Let's enable root login as well using the same key. 32 | 33 | If `authorized_keys` only has one entry, you can just copy it. Otherwise only copy the last line: 34 | 35 | ```sh 36 | # Copy authorized_keys from user to root account... 37 | sudo cp ~/.ssh/authorized_keys /root/.ssh/authorized_keys 38 | sudo chown root:root /root/.ssh/authorized_keys 39 | 40 | # ...or copy last added key only 41 | export LAST_KEY=$(tail -n 1 ~/.ssh/authorized_keys) 42 | sudo echo $LAST_KEY >> /root/.ssh/authorized_keys 43 | 44 | # Edit sshd_config and uncomment line permitting root login with keys only 45 | sudo nano /etc/ssh/sshd_config 46 | PermitRootLogin prohibit-password 47 | 48 | # Reload SSH daemon 49 | systemctl reload sshd.service 50 | 51 | # Log out and test connection to root account 52 | ssh root@ 53 | ``` 54 | 55 | More info: 56 | 57 | - https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent 58 | - https://www.ssh.com/academy/ssh/keygen 59 | -------------------------------------------------------------------------------- /components/data/cloudnative-pg/cloudnative-pg.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../../application'; 4 | import { rootConfig } from '../../root-config'; 5 | import grafanaDashboardJson from './grafana-dashboard.json'; 6 | import { GrafanaDashboard } from '../../grafana-dashboard'; 7 | 8 | export class CloudNativePG extends pulumi.ComponentResource { 9 | private readonly config: pulumi.Config; 10 | private readonly app: Application; 11 | private readonly version?: string; 12 | 13 | constructor(private readonly name: string, opts?: pulumi.ResourceOptions) { 14 | super('orangelab:data:CloudNativePG', name, {}, opts); 15 | 16 | this.config = new pulumi.Config(name); 17 | this.app = new Application(this, name, { 18 | namespace: 'cnpg-system', 19 | }); 20 | this.version = this.config.get('version'); 21 | 22 | this.createChart(); 23 | if (rootConfig.enableMonitoring()) { 24 | new GrafanaDashboard(this.name, this, { 25 | configJson: grafanaDashboardJson, 26 | title: 'CloudNativePG', 27 | }); 28 | } 29 | } 30 | 31 | private createChart(): kubernetes.helm.v3.Release { 32 | return new kubernetes.helm.v3.Release( 33 | this.name, 34 | { 35 | chart: 'cloudnative-pg', 36 | repositoryOpts: { 37 | repo: 'https://cloudnative-pg.github.io/charts/', 38 | }, 39 | version: this.version, 40 | namespace: this.app.namespace, 41 | values: { 42 | crds: { create: true }, 43 | monitoring: { 44 | podMonitorEnabled: rootConfig.enableMonitoring(), 45 | grafanaDashboard: { 46 | enabled: rootConfig.enableMonitoring(), 47 | }, 48 | }, 49 | config: { 50 | clusterWide: true, 51 | }, 52 | }, 53 | }, 54 | { parent: this }, 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:22", 7 | // Features to add to the dev container. More info: https://containers.dev/features. 8 | "features": { 9 | "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { 10 | "version": "latest", 11 | "helm": "none", 12 | "minikube": "none" 13 | }, 14 | "ghcr.io/devcontainers-extra/features/pulumi:1": {}, 15 | "ghcr.io/devcontainers-extra/features/tailscale:1": {}, 16 | "ghcr.io/audacioustux/devcontainers/k9s:1": {} 17 | }, 18 | 19 | // for Tailscale 20 | "runArgs": ["--device=/dev/net/tun", "--network=host"], 21 | 22 | "mounts": [ 23 | "source=${localEnv:HOME}${localEnv:USERPROFILE}/.kube/config,target=/home/node/.kube/config,type=bind,consistency=cached", 24 | "source=${localEnv:HOME}${localEnv:USERPROFILE}/.pulumi,target=/home/node/.pulumi,type=bind,consistency=cached", 25 | "source=/var/run/tailscale/tailscaled.sock,target=/var/run/tailscale/tailscaled.sock,type=bind,consistency=cached" 26 | ], 27 | 28 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 29 | // "forwardPorts": [], 30 | 31 | // Use 'postCreateCommand' to run commands after the container is created. 32 | "postCreateCommand": "npm install", 33 | 34 | // Configure tool-specific properties. 35 | "customizations": { 36 | "vscode": { 37 | "extensions": [ 38 | "dbaeumer.vscode-eslint", 39 | "EditorConfig.EditorConfig", 40 | "esbenp.prettier-vscode", 41 | "ms-kubernetes-tools.vscode-kubernetes-tools", 42 | "pulumi.pulumi-vscode-tools" 43 | ] 44 | } 45 | } 46 | 47 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 48 | // "remoteUser": "root" 49 | } 50 | -------------------------------------------------------------------------------- /components/bitcoin/bitcoin-knots.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | import { StorageType } from '../types'; 4 | import { BitcoinConf } from './utils/bitcoin-conf'; 5 | import { RpcUser } from './utils/rpc-user'; 6 | 7 | export interface BitcoinKnotsArgs { 8 | rpcUsers: Record; 9 | } 10 | 11 | export class BitcoinKnots extends pulumi.ComponentResource { 12 | public readonly app: Application; 13 | private readonly config: pulumi.Config; 14 | 15 | constructor( 16 | name: string, 17 | private args: BitcoinKnotsArgs, 18 | opts?: pulumi.ResourceOptions, 19 | ) { 20 | super('orangelab:bitcoin:BitcoinKnots', name, args, opts); 21 | 22 | this.config = new pulumi.Config(name); 23 | const prune = this.config.requireNumber('prune'); 24 | const debug = this.config.getBoolean('debug'); 25 | 26 | this.app = new Application(this, name) 27 | .addStorage({ type: StorageType.Large }) 28 | .addConfigVolume({ 29 | name: 'config', 30 | files: { 31 | 'bitcoin.conf': BitcoinConf.create({ prune, debug }), 32 | 'rpc.conf': BitcoinConf.createRpc(this.args.rpcUsers), 33 | }, 34 | }); 35 | 36 | this.createDeployment(); 37 | } 38 | 39 | private createDeployment() { 40 | const extraArgs = this.config.get('extraArgs') ?? ''; 41 | const version = this.config.require('version'); 42 | 43 | this.app.addDeployment({ 44 | resources: { 45 | requests: { cpu: '100m', memory: '2Gi' }, 46 | limits: { cpu: '2000m', memory: '8Gi' }, 47 | }, 48 | image: `btcpayserver/bitcoinknots:${version}`, 49 | ports: [ 50 | { name: 'rpc', port: 8332, tcp: true }, 51 | { name: 'p2p', port: 8333, tcp: true }, 52 | ], 53 | commandArgs: ['bitcoind', extraArgs], 54 | env: { 55 | BITCOIN_EXTRA_ARGS: [ 56 | 'includeconf=/conf/bitcoin.conf', 57 | 'includeconf=/conf/rpc.conf', 58 | ].join('\n'), 59 | }, 60 | volumeMounts: [ 61 | { mountPath: '/data' }, 62 | { name: 'config', mountPath: '/conf' }, 63 | ], 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /components/bitcoin/bitcoin-core.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | import { StorageType } from '../types'; 4 | import { BitcoinConf } from './utils/bitcoin-conf'; 5 | import { RpcUser } from './utils/rpc-user'; 6 | 7 | export interface BitcoinCoreArgs { 8 | rpcUsers: Record; 9 | } 10 | 11 | export class BitcoinCore extends pulumi.ComponentResource { 12 | public readonly app: Application; 13 | private readonly config: pulumi.Config; 14 | private readonly prune: number; 15 | 16 | constructor( 17 | private name: string, 18 | private args: BitcoinCoreArgs, 19 | opts?: pulumi.ResourceOptions, 20 | ) { 21 | super('orangelab:bitcoin:BitcoinCore', name, args, opts); 22 | 23 | this.config = new pulumi.Config(name); 24 | this.prune = this.config.requireNumber('prune'); 25 | const debug = this.config.getBoolean('debug'); 26 | 27 | this.app = new Application(this, name) 28 | .addStorage({ type: StorageType.Large }) 29 | .addConfigVolume({ 30 | name: 'config', 31 | files: { 32 | 'bitcoin.conf': BitcoinConf.create({ prune: this.prune, debug }), 33 | 'rpc.conf': BitcoinConf.createRpc(this.args.rpcUsers), 34 | }, 35 | }); 36 | 37 | this.createDeployment(); 38 | } 39 | 40 | private createDeployment() { 41 | const extraArgs = this.config.get('extraArgs') ?? ''; 42 | const version = this.config.require('version'); 43 | 44 | this.app.addDeployment({ 45 | resources: { 46 | requests: { cpu: '100m', memory: '2Gi' }, 47 | limits: { cpu: '2000m', memory: '8Gi' }, 48 | }, 49 | image: `btcpayserver/bitcoin:${version}`, 50 | ports: [ 51 | { name: 'rpc', port: 8332, tcp: true }, 52 | { name: 'p2p', port: 8333, tcp: true }, 53 | ], 54 | commandArgs: ['bitcoind', extraArgs], 55 | env: { 56 | BITCOIN_EXTRA_ARGS: [ 57 | 'includeconf=/conf/bitcoin.conf', 58 | 'includeconf=/conf/rpc.conf', 59 | ].join('\n'), 60 | }, 61 | volumeMounts: [ 62 | { mountPath: '/data' }, 63 | { name: 'config', mountPath: '/conf' }, 64 | ], 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import eslint from '@eslint/js'; 3 | import { defineConfig } from 'eslint/config'; 4 | import tseslint from 'typescript-eslint'; 5 | import pulumiPlugin from '@pulumi/eslint-plugin'; 6 | 7 | export default defineConfig( 8 | { 9 | ignores: ['node_modules/**', 'bin/**'], 10 | }, 11 | { 12 | files: ['**/*.{js,mjs,ts}'], 13 | }, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.node, 18 | }, 19 | parserOptions: { 20 | projectService: { 21 | allowDefaultProject: ['*.mjs'], 22 | }, 23 | tsconfigRootDir: import.meta.dirname, 24 | }, 25 | }, 26 | }, 27 | eslint.configs.recommended, 28 | tseslint.configs.recommendedTypeChecked, 29 | tseslint.configs.strictTypeChecked, 30 | tseslint.configs.stylisticTypeChecked, 31 | { 32 | plugins: { pulumi: pulumiPlugin }, 33 | rules: { 34 | '@typescript-eslint/consistent-type-definitions': 'error', 35 | '@typescript-eslint/method-signature-style': ['error', 'property'], 36 | '@typescript-eslint/no-explicit-any': 'error', 37 | '@typescript-eslint/no-unused-vars': [ 38 | 'warn', 39 | { 40 | args: 'all', 41 | argsIgnorePattern: '^_', 42 | caughtErrors: 'all', 43 | caughtErrorsIgnorePattern: '^_', 44 | destructuredArrayIgnorePattern: '^_', 45 | varsIgnorePattern: '^_', 46 | ignoreRestSiblings: true, 47 | }, 48 | ], 49 | '@typescript-eslint/prefer-includes': 'error', 50 | '@typescript-eslint/prefer-nullish-coalescing': 'error', 51 | '@typescript-eslint/prefer-optional-chain': 'error', 52 | '@typescript-eslint/prefer-string-starts-ends-with': 'error', 53 | '@typescript-eslint/unified-signatures': 'error', 54 | 'no-console': 'error', 55 | 'object-curly-newline': [ 56 | 'error', 57 | { 58 | ImportDeclaration: { multiline: true }, 59 | }, 60 | ], 61 | 'object-shorthand': ['error', 'always'], 62 | 'prefer-arrow-callback': 'error', 63 | 'sort-keys': ['error', 'asc', { minKeys: 10 }], 64 | }, 65 | }, 66 | ); 67 | -------------------------------------------------------------------------------- /components/system/debug.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | 4 | /* 5 | Disable debug first when switching volumes 6 | debug:enabled true 7 | 8 | Namespace volume was created in 9 | debug:namespace: app-namespace 10 | 11 | Use existing Longhorn volume instead of PVC. 12 | debug:fromVolume: restored-volume 13 | 14 | Size has to match the volume 15 | debug:storageSize: 10Gi 16 | 17 | Deploy debug pod to specific node. 18 | debug:requiredNodeLabel: kubernetes.io/hostname=my-laptop 19 | 20 | Local folder that will be mounted at /data-export 21 | debug:exportPath: /home/user/orangelab-export 22 | debug:exportPath: /run/media/user/usb-1/orangelab-export 23 | */ 24 | export class Debug extends pulumi.ComponentResource { 25 | app: Application; 26 | namespace: string; 27 | exportPath: string; 28 | 29 | constructor( 30 | private name: string, 31 | args = {}, 32 | opts?: pulumi.ResourceOptions, 33 | ) { 34 | super('orangelab:system:Debug', name, args, opts); 35 | 36 | const config = new pulumi.Config('debug'); 37 | const fromVolume = config.require('fromVolume'); 38 | this.namespace = config.require('namespace'); 39 | this.exportPath = config.require('exportPath'); 40 | 41 | this.app = new Application(this, name, { 42 | existingNamespace: this.namespace, 43 | }) 44 | .addLocalStorage({ name: 'local', hostPath: this.exportPath }) 45 | .addStorage({ fromVolume }); 46 | 47 | // Comment out one method 48 | this.createDeployment(); 49 | // this.createExportJob(); 50 | } 51 | 52 | private createDeployment() { 53 | this.app.addDeployment({ 54 | image: 'alpine', 55 | commandArgs: ['sleep', '3600'], 56 | volumeMounts: [ 57 | { mountPath: '/data' }, 58 | { name: 'local', mountPath: '/data-export' }, 59 | ], 60 | }); 61 | } 62 | 63 | private createExportJob() { 64 | this.app.addJob({ 65 | name: 'export', 66 | image: 'busybox', 67 | commandArgs: [ 68 | 'sh', 69 | '-c', 70 | `tar zcvf /data-export/${this.namespace}-$(date +"%Y%m%d").tgz /data/`, 71 | ], 72 | volumeMounts: [ 73 | { mountPath: '/data' }, 74 | { name: 'local', mountPath: '/data-export' }, 75 | ], 76 | restartPolicy: 'Never', 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /docs/install-nodes.md: -------------------------------------------------------------------------------- 1 | # Installation - Node Configuration 2 | 3 | This document covers general node configuration that should be done before installing K3s. 4 | 5 | ## Firewall 6 | 7 | Setup firewall rules on k3s server and worker nodes: 8 | 9 | ```sh 10 | firewall-cmd --permanent --add-source=10.42.0.0/16 # Pods 11 | firewall-cmd --permanent --add-source=10.43.0.0/16 # Services 12 | firewall-cmd --permanent --add-port=6443/tcp # API Server 13 | firewall-cmd --permanent --add-port=10250/tcp # Kubelet metrics 14 | firewall-cmd --permanent --add-port=9100/tcp # Prometheus metrics 15 | firewall-cmd --permanent --add-port=45876/tcp # Beszel metrics 16 | firewall-cmd --permanent --add-port=41641/tcp # Tailscale UDP 17 | systemctl reload firewalld 18 | ``` 19 | 20 | In case of connectivity issues, try disabling the firewall: 21 | 22 | ```sh 23 | systemctl disable firewalld.service --now 24 | ``` 25 | 26 | ## (Recommended) Disable swap 27 | 28 | It's recommended to disable swap memory when running Kubernetes as this helps with scheduling and reporting correct amount of resources available. 29 | 30 | ```sh 31 | sudo swapoff -a 32 | sudo systemctl mask dev-zram0.swap 33 | 34 | # confirm swap is disabled 35 | free -h 36 | ``` 37 | 38 | ## (Optional) Disable suspend 39 | 40 | ### Server 41 | 42 | ```sh 43 | # Disable all sleep modes 44 | sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 45 | ``` 46 | 47 | ### Laptop 48 | 49 | #### Ignore lid close 50 | 51 | To disable suspend mode when laptop lid is closed while on AC power, edit `/etc/systemd/logind.conf` and uncomment these lines 52 | 53 | ```conf 54 | HandleLidSwitch=suspend 55 | HandleLidSwitchExternalPower=ignore 56 | HandleLidSwitchDocked=ignore 57 | ``` 58 | 59 | If the file doesn't exist, copy it `cp /usr/lib/systemd/logind.conf /etc/systemd/` then edit. 60 | 61 | Restart service with `sudo systemctl reload systemd-logind.service` 62 | 63 | #### Don't suspend when on AC power 64 | 65 | Turn off suspend mode when on AC power. The setting in Gnome UI (Settings -> Power -> Automatic Suspend -> "When Plugged In") only applies when you're logged in, but not on login screen. You can check current settings with: 66 | 67 | ```sh 68 | # Check current settings 69 | sudo -u gdm dbus-run-session gsettings list-recursively org.gnome.settings-daemon.plugins.power | grep sleep 70 | 71 | # Output example: org.gnome.settings-daemon.plugins.power sleep-inactive-ac-timeout 900 72 | 73 | # Disable suspend mode on AC power: 74 | sudo -u gdm dbus-run-session gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-timeout 0 75 | ``` 76 | -------------------------------------------------------------------------------- /components/bitcoin/electrs.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | import { StorageType } from '../types'; 4 | import { RpcUser } from './utils/rpc-user'; 5 | 6 | export interface ElectrsArgs { 7 | rpcUser: RpcUser; 8 | bitcoinRpcUrl: pulumi.Input; 9 | bitcoinP2pUrl: pulumi.Input; 10 | } 11 | 12 | export class Electrs extends pulumi.ComponentResource { 13 | public readonly app: Application; 14 | private readonly config: pulumi.Config; 15 | 16 | constructor(name: string, private args: ElectrsArgs, opts?: pulumi.ResourceOptions) { 17 | super('orangelab:bitcoin:Electrs', name, args, opts); 18 | 19 | this.config = new pulumi.Config(name); 20 | const debug = this.config.getBoolean('debug'); 21 | const rpcHost = pulumi 22 | .output(this.args.bitcoinRpcUrl) 23 | .apply(url => new URL(`http://${url}`).host); 24 | 25 | this.app = new Application(this, name) 26 | .addStorage({ type: StorageType.Large }) 27 | .addConfigVolume({ 28 | name: 'config', 29 | files: { 30 | 'electrs.toml': pulumi.interpolate` 31 | auth = "${this.args.rpcUser.username}:${ 32 | this.args.rpcUser.password 33 | }" 34 | daemon_rpc_addr = "${rpcHost}" 35 | daemon_p2p_addr = "${this.args.bitcoinP2pUrl}" 36 | db_dir = "/data" 37 | electrum_rpc_addr = "0.0.0.0:50001" 38 | log_filters = ${debug ? '"DEBUG"' : '"INFO"'} 39 | `, 40 | }, 41 | }); 42 | this.createDeployment(); 43 | } 44 | 45 | private createDeployment() { 46 | if (!this.args.bitcoinRpcUrl || !this.args.bitcoinP2pUrl) return; 47 | const version = this.config.require('version'); 48 | const extraArgs = this.config.get('extraArgs') ?? ''; 49 | 50 | this.app.addDeployment({ 51 | image: `getumbrel/electrs:${version}`, 52 | ports: [{ name: 'rpc', port: 50001, tcp: true }], 53 | runAsUser: 1000, 54 | commandArgs: ['--conf=/conf/electrs.toml', extraArgs], 55 | volumeMounts: [ 56 | { mountPath: '/data' }, 57 | { name: 'config', mountPath: '/conf' }, 58 | ], 59 | resources: { 60 | requests: { cpu: '100m', memory: '8Gi' }, 61 | limits: { cpu: '2000m', memory: '16Gi' }, 62 | }, 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /components/metadata.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | 3 | export class Metadata { 4 | public namespace: string; 5 | private config: pulumi.Config; 6 | 7 | constructor( 8 | private appName: string, 9 | args: { namespace: string; config: pulumi.Config }, 10 | ) { 11 | this.config = args.config; 12 | this.namespace = args.namespace; 13 | } 14 | 15 | get(params?: { 16 | component?: string; 17 | annotations?: Record | undefined>; 18 | }): { 19 | name: string; 20 | namespace: string; 21 | labels: Record; 22 | annotations?: Record>; 23 | } { 24 | return { 25 | name: params?.component 26 | ? `${this.appName}-${params.component}` 27 | : this.appName, 28 | namespace: this.namespace, 29 | labels: 30 | this.removeUndefinedValues(this.createLabels(params?.component)) ?? {}, 31 | annotations: this.removeUndefinedValues(params?.annotations), 32 | }; 33 | } 34 | 35 | private removeUndefinedValues( 36 | obj: Record | undefined, 37 | ): Record | undefined { 38 | if (!obj) return undefined; 39 | const filteredEntries = Object.entries(obj).filter( 40 | ([, value]) => value !== undefined, 41 | ) as [string, T][]; 42 | return Object.fromEntries(filteredEntries); 43 | } 44 | 45 | getSelectorLabels(component?: string) { 46 | return { 47 | 'app.kubernetes.io/name': this.appName, 48 | 'app.kubernetes.io/component': component ?? 'default', 49 | }; 50 | } 51 | 52 | getAppLabels(componentName?: string) { 53 | const labels: Record = { 54 | 'app.kubernetes.io/name': this.appName, 55 | 'app.kubernetes.io/managed-by': 'OrangeLab', 56 | 'app.kubernetes.io/component': componentName ?? 'default', 57 | }; 58 | return labels; 59 | } 60 | 61 | private createLabels(componentName?: string) { 62 | const labels: Record = { 63 | 'app.kubernetes.io/name': this.appName, 64 | 'app.kubernetes.io/managed-by': 'OrangeLab', 65 | 'app.kubernetes.io/component': componentName ?? 'default', 66 | }; 67 | const version = this.config.get('version'); 68 | const appVersion = this.config.get('appVersion'); 69 | labels['app.kubernetes.io/version'] = appVersion ?? version; 70 | return labels; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /components/system/minio/minio.ts: -------------------------------------------------------------------------------- 1 | import * as minio from '@pulumi/minio'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import * as random from '@pulumi/random'; 4 | import { Application } from '../../application'; 5 | import { rootConfig } from '../../root-config'; 6 | 7 | export class Minio extends pulumi.ComponentResource { 8 | public readonly minioProvider: minio.Provider; 9 | public readonly users: Record> = {}; 10 | app: Application; 11 | 12 | constructor(private name: string, opts?: pulumi.ResourceOptions) { 13 | super('orangelab:system:Minio', name, {}, opts); 14 | 15 | const config = new pulumi.Config('minio'); 16 | const hostname = config.require('hostname'); 17 | const hostnameApi = config.require('hostname-api'); 18 | const dataPath = config.require('dataPath'); 19 | const rootUser = config.require('rootUser'); 20 | this.users = { 21 | [rootUser]: pulumi.output(this.createPassword()), 22 | }; 23 | 24 | this.app = new Application(this, name).addLocalStorage({ 25 | name: 'data', 26 | hostPath: dataPath, 27 | }); 28 | this.app.addDeployment({ 29 | image: 'quay.io/minio/minio', 30 | ports: [ 31 | { name: 'console', port: 9001, hostname }, 32 | { 33 | name: 'api', 34 | port: 9000, 35 | hostname: hostnameApi, 36 | // Use Tailscale ingress so Pulumi provider doesn't break 37 | ingressClassName: 'tailscale', 38 | }, 39 | ], 40 | env: { 41 | MINIO_CONSOLE_TLS_ENABLE: 'off', 42 | MINIO_ROOT_USER: rootUser, 43 | MINIO_ROOT_PASSWORD: this.users[rootUser], 44 | MINIO_BROWSER_REDIRECT_URL: this.app.network.getIngressInfo().url, 45 | }, 46 | commandArgs: ['server', '/data', '--console-address', ':9001'], 47 | volumeMounts: [{ name: 'data', mountPath: '/data' }], 48 | }); 49 | this.minioProvider = new minio.Provider( 50 | `${name}-provider`, 51 | { 52 | minioServer: `${hostnameApi}.${rootConfig.tailnetDomain}:443`, 53 | minioUser: rootUser, 54 | minioPassword: this.users[rootUser], 55 | minioSsl: true, 56 | }, 57 | { parent: this }, 58 | ); 59 | } 60 | 61 | private createPassword() { 62 | return new random.RandomPassword( 63 | `${this.name}-root-password`, 64 | { length: 32, special: false }, 65 | { parent: this }, 66 | ).result; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /components/system/minio/minio-s3-bucket.ts: -------------------------------------------------------------------------------- 1 | import * as minio from '@pulumi/minio'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { MinioS3User } from './minio-s3-user'; 4 | 5 | export interface MinioS3BucketArgs { 6 | bucketName: string; 7 | createBucket?: boolean; 8 | } 9 | 10 | export class MinioS3Bucket extends pulumi.ComponentResource { 11 | private readonly policy: minio.IamPolicy; 12 | private readonly bucket: minio.S3Bucket; 13 | 14 | constructor( 15 | public name: string, 16 | private args: MinioS3BucketArgs, 17 | private opts?: pulumi.ResourceOptions, 18 | ) { 19 | super('orangelab:system:MinioS3Bucket', name, args, opts); 20 | this.bucket = args.createBucket 21 | ? new minio.S3Bucket( 22 | `${name}-s3bucket`, 23 | { bucket: args.bucketName }, 24 | { parent: this, provider: opts?.provider, retainOnDelete: true }, 25 | ) 26 | : minio.S3Bucket.get( 27 | args.bucketName, 28 | args.bucketName, 29 | {}, 30 | { parent: this, provider: opts?.provider }, 31 | ); 32 | 33 | this.policy = this.createBucketPolicy(args.bucketName); 34 | } 35 | 36 | private createBucketPolicy(bucketName: string) { 37 | return new minio.IamPolicy( 38 | `${this.name}-policy`, 39 | { 40 | name: bucketName, 41 | policy: JSON.stringify({ 42 | Version: '2012-10-17', 43 | Statement: [ 44 | { 45 | Effect: 'Allow', 46 | Action: [ 47 | 's3:PutObject', 48 | 's3:GetObject', 49 | 's3:ListBucket', 50 | 's3:DeleteObject', 51 | ], 52 | Resource: [ 53 | `arn:aws:s3:::${bucketName}`, 54 | `arn:aws:s3:::${bucketName}/*`, 55 | ], 56 | }, 57 | ], 58 | }), 59 | }, 60 | { parent: this, provider: this.opts?.provider }, 61 | ); 62 | } 63 | 64 | public grantReadWrite(s3User: MinioS3User): void { 65 | new minio.IamUserPolicyAttachment( 66 | `${this.name}-policy-attachment`, 67 | { 68 | policyName: this.policy.name, 69 | userName: s3User.username, 70 | }, 71 | { parent: this, provider: this.opts?.provider, dependsOn: [s3User] }, 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /docs/longhorn-disable.md: -------------------------------------------------------------------------------- 1 | # Disabling Longhorn 2 | 3 | ## Overview 4 | 5 | **Note: Longhorn is the recommended storage solution for OrangeLab** as it provides distributed storage, replication, snapshots, and backup capabilities. However, there are specific situations where disabling Longhorn and using simpler storage options makes sense: 6 | 7 | 1. **Single-node deployments** where distributed storage features are not important 8 | 2. **Windows or macOS systems** using k3d, as Longhorn only runs on Linux 9 | 10 | For these cases, you can use the `local-path-provisioner` instead. Persistent volumes will be stored in `/var/lib/rancher/k3s/storage` 11 | 12 | Be aware of these limitations when not using Longhorn: 13 | 14 | - No storage replication 15 | - No automatic snapshots or volume repair 16 | - Backups to S3/MinIO will not be available 17 | - Data only exists on single host running the pods 18 | - Pods need to be scheduled on specific node 19 | 20 | ## Windows and macOS Support 21 | 22 | Windows and macOS support is limited: 23 | 24 | - K3s requires Linux to run workloads using _containerd_ directly 25 | - You could use [k3d](https://k3d.io/) which uses Docker as a wrapper to run containers 26 | - This setup works for some containers as long as they do not use persistent storage 27 | - Not a tested configuration, but feedback is welcome 28 | 29 | Using k3d, you can run some OrangeLab components, but you will need to set up the single-node configuration as described above because Longhorn only runs on Linux. 30 | 31 | For more information on k3d limitations with Longhorn, see: https://github.com/k3d-io/k3d/blob/main/docs/faq/faq.md#longhorn-in-k3d 32 | 33 | ## Use Local Storage for an Application 34 | 35 | To disable Longhorn and use local storage for each application: 36 | 37 | ```sh 38 | pulumi config set longhorn:enabled false 39 | # Example: Configure Ollama to use local-path storage 40 | pulumi config set ollama:storageClass local-path 41 | pulumi up 42 | ``` 43 | 44 | ## Node Selection with Local Storage 45 | 46 | When using local storage, you must ensure your application runs on a specific node where the storage is located. For persistent data, set the required node: 47 | 48 | ```sh 49 | # Run application on a specific node by hostname 50 | pulumi config set ollama:requiredNodeLabel kubernetes.io/hostname=my-server 51 | ``` 52 | 53 | This is critical with local storage since data is only available on the node where it was created. 54 | 55 | ## SELinux Considerations 56 | 57 | On SELinux-enabled systems (like Fedora, RHEL, CentOS), if deployment fails due to directory creation permissions on `/var/lib/rancher/k3s/storage/`, you can temporarily loosen SELinux restrictions: 58 | 59 | ```sh 60 | sudo setenforce 0 61 | # Run your deployment commands 62 | sudo setenforce 1 63 | ``` 64 | 65 | This applies when using the local-path provisioner. 66 | -------------------------------------------------------------------------------- /components/data/mariadb-operator/mariadb-operator.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../../application'; 4 | import { rootConfig } from '../../root-config'; 5 | 6 | export class MariaDBOperator extends pulumi.ComponentResource { 7 | private readonly config: pulumi.Config; 8 | private readonly app: Application; 9 | private readonly crdsChart: kubernetes.helm.v3.Release; 10 | private readonly version?: string; 11 | 12 | constructor(private readonly name: string, opts?: pulumi.ResourceOptions) { 13 | super('orangelab:data:MariaDBOperator', name, {}, opts); 14 | 15 | this.config = new pulumi.Config(name); 16 | this.app = new Application(this, name); 17 | this.version = this.config.get('version'); 18 | 19 | this.crdsChart = this.createCRDs(); 20 | this.createChart(); 21 | } 22 | 23 | private createCRDs(): kubernetes.helm.v3.Release { 24 | return new kubernetes.helm.v3.Release( 25 | `${this.name}-crds`, 26 | { 27 | chart: 'mariadb-operator-crds', 28 | repositoryOpts: { 29 | repo: 'https://mariadb-operator.github.io/mariadb-operator', 30 | }, 31 | version: this.version, 32 | namespace: this.app.namespace, 33 | }, 34 | { 35 | parent: this, 36 | dependsOn: [this.app.storage].filter(Boolean) as pulumi.Resource[], 37 | }, 38 | ); 39 | } 40 | 41 | private createChart(): kubernetes.helm.v3.Release { 42 | const debug = this.config.getBoolean('debug'); 43 | const monitoring = rootConfig.enableMonitoring(); 44 | return new kubernetes.helm.v3.Release( 45 | this.name, 46 | { 47 | chart: 'mariadb-operator', 48 | repositoryOpts: { 49 | repo: 'https://mariadb-operator.github.io/mariadb-operator', 50 | }, 51 | version: this.version, 52 | namespace: this.app.namespace, 53 | values: { 54 | affinity: this.app.nodes.getAffinity(), 55 | crds: { enabled: false }, 56 | logLevel: debug ? 'DEBUG' : 'INFO', 57 | metrics: { 58 | enabled: monitoring, 59 | serviceMonitor: { 60 | enabled: monitoring, 61 | }, 62 | }, 63 | webhook: { 64 | certManager: { 65 | enabled: true, 66 | }, 67 | }, 68 | }, 69 | }, 70 | { parent: this, dependsOn: [this.crdsChart] }, 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # OrangeLab Commands & Style Guide 2 | 3 | ## Build/Lint/Test Commands 4 | 5 | - Deployment: `pulumi up` 6 | - Preview changes: `pulumi preview --diff` 7 | - Lint: `npm run lint` 8 | - Test: `npm run test` (runs lint) 9 | 10 | ## Architectural Principles 11 | 12 | - **Simplicity Over Complexity (KISS):** Prioritize simple, straightforward solutions. Avoid over-engineering or introducing complex patterns without consultation. 13 | - **Loose Coupling:** Strive to keep components independent. Avoid creating circular dependencies. 14 | - **Consult on Architectural Changes:** Before implementing significant architectural changes (e.g., introducing new base classes, changing configuration management), discuss the proposed approach and alternatives first. 15 | - **Centralized Concerns:** Keep related logic together. For example, all user-facing console output should ideally be handled in a single, designated component like `root-config.ts` to ensure consistency. 16 | 17 | ## Code Style 18 | 19 | ### General 20 | 21 | - TypeScript with strict type checking 22 | - Pulumi for infrastructure as code 23 | - Modular architecture with clear separation of concerns 24 | - Prefer self-documenting code over comments 25 | - Use descriptive variable names and clear function signatures 26 | 27 | ### Naming Conventions 28 | 29 | - Use camelCase for variables, functions, and methods 30 | - Use PascalCase for classes, interfaces, and type aliases 31 | - Prefix private class members with underscore (\_) 32 | 33 | ### Import/Export Style 34 | 35 | - Group imports by external packages first, then internal modules 36 | - Use destructuring imports when appropriate 37 | - Sort imports alphabetically within each group 38 | 39 | ### Error Handling 40 | 41 | - Use assert for validations that should never fail 42 | - Prefer TypeScript's strict null checking 43 | - Use optional chaining (?.) for potentially undefined values 44 | 45 | ### Type Safety 46 | 47 | - Always use explicit types 48 | - Avoid `any` type 49 | - Prefer `variable?: Type` over `variable: Type | undefined` 50 | - Prefer optional chaining over null checks 51 | - Prefer nullish coalescing operator (??) over logical OR (||) 52 | - Prefix unused variables with underscore (\_) 53 | 54 | ### Components 55 | 56 | - Applications should use the Application class for Kubernetes resources 57 | - For Helm charts, use Application class for namespaces and storage 58 | - Follow the established pattern for new modules and components 59 | - Use constructor parameter properties with access modifiers (e.g., `constructor(private readonly args: Args)`) 60 | - Prefer composition over inheritance for component relationships 61 | 62 | ### Secrets and Security 63 | 64 | - Use `envSecret` field in `ContainerSpec` for sensitive data instead of command args 65 | - Application class automatically creates Kubernetes Secrets and configures envFrom 66 | - Environment variable names should be UPPERCASE following conventions 67 | - Never expose passwords or sensitive data in command line arguments 68 | -------------------------------------------------------------------------------- /components/ai/n8n.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import * as random from '@pulumi/random'; 3 | import { Application } from '../application'; 4 | import { rootConfig } from '../root-config'; 5 | import { DatabaseConfig } from '../types'; 6 | 7 | export interface N8nArgs { 8 | ollamaUrl?: string; 9 | } 10 | 11 | export class N8n extends pulumi.ComponentResource { 12 | app: Application; 13 | encryptionKey: pulumi.Output; 14 | postgresConfig?: DatabaseConfig; 15 | 16 | constructor(private name: string, args: N8nArgs, opts?: pulumi.ResourceOptions) { 17 | super('orangelab:ai:N8n', name, args, opts); 18 | 19 | const config = new pulumi.Config(name); 20 | const debug = rootConfig.isDebugEnabled(name); 21 | this.encryptionKey = pulumi.output( 22 | config.get('N8N_ENCRYPTION_KEY') ?? this.createEncryptionKey(), 23 | ); 24 | 25 | this.app = new Application(this, name).addStorage().addPostgres(); 26 | this.postgresConfig = this.app.databases?.getConfig(); 27 | const initContainer = this.app.databases?.getWaitContainer(this.postgresConfig); 28 | this.app.addDeployment({ 29 | image: 'docker.n8n.io/n8nio/n8n', 30 | port: 5678, 31 | volumeMounts: [{ mountPath: '/home/node/.n8n' }], 32 | runAsUser: 1000, 33 | resources: { 34 | requests: { memory: '250Mi' }, 35 | limits: { memory: '500Mi' }, 36 | }, 37 | env: { 38 | CREDENTIALS_OVERWRITE_DATA: args.ollamaUrl 39 | ? `{"ollamaApi": {"baseUrl": "${args.ollamaUrl}"}}` 40 | : undefined, 41 | DB_TYPE: 'postgresdb', 42 | N8N_DIAGNOSTICS_ENABLED: 'false', 43 | N8N_ENCRYPTION_KEY: this.encryptionKey, 44 | N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'true', 45 | N8N_HOST: this.app.network.getIngressInfo().hostname, 46 | N8N_LOG_LEVEL: debug ? 'debug' : undefined, 47 | N8N_METRICS: rootConfig.enableMonitoring() ? 'true' : 'false', 48 | N8N_PORT: '5678', 49 | N8N_PROTOCOL: 'http', 50 | N8N_PROXY_HOPS: '1', 51 | N8N_SECURE_COOKIE: 'false', 52 | }, 53 | envSecret: { 54 | DB_POSTGRESDB_DATABASE: this.postgresConfig?.database, 55 | DB_POSTGRESDB_HOST: this.postgresConfig?.hostname, 56 | DB_POSTGRESDB_PASSWORD: this.postgresConfig?.password, 57 | DB_POSTGRESDB_USER: this.postgresConfig?.username, 58 | }, 59 | initContainers: initContainer ? [initContainer] : undefined, 60 | }); 61 | } 62 | 63 | private createEncryptionKey() { 64 | return new random.RandomPassword( 65 | `${this.name}-encryption-key`, 66 | { length: 32, special: false }, 67 | { parent: this }, 68 | ).result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /docs/install-k3s.md: -------------------------------------------------------------------------------- 1 | # Installation - Kubernetes nodes (K3S) 2 | 3 | This document covers K3s installation and configuration. For general node preparation, see [Installation - Node Configuration](./install-nodes.md). 4 | 5 | # Installation - K3S server 6 | 7 | Server has to be installed before any other nodes can be added. 8 | 9 | Kubernetes server should be installed on a machine that's online 24/7 but it's not required - running everything on a single laptop will work, however the availability of services will be limited. 10 | 11 | Installing server will also run an agent node on the same machine. 12 | 13 | ## K3S server 14 | 15 | `k3s-server.sh` executed on _management node_ generates script to install K3S on _server node_: 16 | 17 | ```sh 18 | # Run where Pulumi is installed 19 | ./scripts/k3s-server.sh 20 | 21 | # Copy the generated script 22 | 23 | # Login to server node and paste generated script: 24 | ssh root@ 25 | ``` 26 | 27 | Check is the service is running: 28 | 29 | ```sh 30 | systemctl status k3s.service 31 | ``` 32 | 33 | The server should also create two files: 34 | 35 | - `/var/lib/rancher/k3s/server/token` - server token, needed by agents to connect 36 | - `/etc/rancher/k3s/k3s.yaml` - kubeconfig file, copy to your `~/.kube/config` 37 | 38 | ## Save server configuration 39 | 40 | Add the tailscale IP of the server to Pulumi configuration: 41 | 42 | ```sh 43 | # localhost 44 | pulumi config set k3s:serverIp $(tailscale ip -4) 45 | 46 | # Remote host. Find IP with 'tailscale status' 47 | pulumi config set k3s:serverIp 48 | ``` 49 | 50 | Add generated agent token to Pulumi configuration as secret: 51 | 52 | ```sh 53 | # localhost 54 | export K3S_TOKEN=$(sudo cat /var/lib/rancher/k3s/server/node-token) 55 | 56 | # SSH remote node 57 | export K3S_TOKEN=$(ssh root@ cat /var/lib/rancher/k3s/server/node-token) 58 | 59 | pulumi config set k3s:agentToken $K3S_TOKEN --secret 60 | ``` 61 | 62 | ## Kube config for admin 63 | 64 | ```sh 65 | # copy kubeconfig from server to your management node 66 | scp @:/etc/rancher/k3s/k3s.yaml ~/.kube/config 67 | ``` 68 | 69 | # Installation - K3S agents 70 | 71 | Install K3S agent nodes on any additional physical hardware. Server already runs an agent. 72 | 73 | ## K3S agent 74 | 75 | `k3s-agent.sh` executed on _management node_ generates script to install K3S on _agent node_: 76 | 77 | ```sh 78 | # Run where Pulumi is installed 79 | ./scripts/k3s-agent.sh 80 | 81 | # Copy the generated script 82 | 83 | # Login to agent node and paste generated script: 84 | ssh root@ 85 | ``` 86 | 87 | Check is the service is running: 88 | 89 | ```sh 90 | systemctl status k3s-agent.service 91 | ``` 92 | 93 | ## Node labels 94 | 95 | You can set node labels later when installing applications. Examples: 96 | 97 | ```sh 98 | # Storage node used by Longhorn, at least one is needed 99 | kubectl label nodes orangelab/storage=true 100 | 101 | # Set zone, used f.e. by home-assistant to deploy to node on same network as sensors 102 | kubectl label nodes topology.kubernetes.io/zone=home 103 | ``` 104 | 105 | > Note: GPU nodes are automatically detected and labeled by the [Node Feature Discovery](/components/system/SYSTEM.md#node-feature-discovery-nfd) component. 106 | -------------------------------------------------------------------------------- /docs/backup.md: -------------------------------------------------------------------------------- 1 | # Backup and Restore 2 | 3 | Longhorn provides automated snapshots and backups of persistent volumes to S3-compatible storage (MinIO). This guide explains how to set up, configure, and use this functionality. 4 | 5 | Longhorn jobs: 6 | 7 | - **Snapshots**: Taken hourly for all volumes (configurable with `longhorn:snapshotCron`) 8 | - **Incremental Backups**: Run daily at 00:15 (configurable with `longhorn:backupCron`) 9 | - If a volume has no changes since the last backup, no data is transferred 10 | 11 | **Note:** Snapshots are disabled by default to save storage space. You can enable them with `longhorn:snapshotEnabled` to be able to revert storage to previous state. It's recommneded however to enable daily backups instead as a snapshot is also taked during backup operation. 12 | 13 | Please refer to the official MinIO installation documentation for detailed instructions on setting up MinIO: [https://min.io/docs/minio/user-guide/install.html] 14 | 15 | ## Setup S3 storage (MinIO) 16 | 17 | Make sure MinIO is installed and functioning before enabling backups. See [MinIO installation instructions](/components/system/SYSTEM.md#minio-recommended) for details. 18 | 19 | ```sh 20 | # S3 bucket that will be used for backups. Default: backup-longhorn 21 | pulumi config set longhorn:backupBucket backup-longhorn 22 | 23 | # If bucket already created, set to false so Pulumi imports it 24 | # Set to true to create the bucket 25 | pulumi config set longhorn:backupBucketCreate false 26 | 27 | # Enable backup functionality in Longhorn. MinIO has to be running. 28 | pulumi config set longhorn:backupEnabled true 29 | ``` 30 | 31 | ## Managing Volume Backups 32 | 33 | You can configure which volumes to back up through two main approaches: 34 | 35 | ### Option 1: Back up all volumes by default 36 | 37 | Enable automatic backups for all volumes in the cluster, with the ability to exclude specific volumes: 38 | 39 | ```sh 40 | # Enable backup functionality 41 | pulumi config set longhorn:backupEnabled true 42 | 43 | # Back up all volumes by default 44 | pulumi config set longhorn:backupAllVolumes true 45 | 46 | # Optional: Exclude specific volumes from backup 47 | pulumi config set :backupVolume false 48 | 49 | pulumi up 50 | ``` 51 | 52 | ### Option 2: Back up only specific volumes 53 | 54 | Only back up volumes that are explicitly configured for backup: 55 | 56 | ```sh 57 | # Enable backup functionality 58 | pulumi config set longhorn:backupEnabled true 59 | 60 | # Don't back up volumes by default (this is the default setting) 61 | pulumi config set longhorn:backupAllVolumes false 62 | 63 | # Enable backup for specific application volumes 64 | pulumi config set :backupVolume true 65 | 66 | pulumi up 67 | ``` 68 | 69 | The backup setting precedence is: 70 | 71 | 1. App-specific setting (`:backupVolume`) if specified 72 | 2. Global setting (`longhorn:backupAllVolumes`) if no app-specific setting exists 73 | 74 | ## Restoring from Backup 75 | 76 | To restore a volume from a backup, follow these steps: 77 | 78 | 1. Restore the volume through the Longhorn UI first 79 | - Name the volume after the application (e.g., "ollama") 80 | 2. Use the `fromVolume` parameter to attach the existing volume: 81 | 82 | ```sh 83 | pulumi config set :fromVolume "my-restored-volume" 84 | pulumi up 85 | ``` 86 | 87 | This approach gives you better volume naming and allows you to verify the restore before attaching it to your application. 88 | -------------------------------------------------------------------------------- /components/bitcoin/mempool.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { Application } from '../application'; 3 | import { rootConfig } from '../root-config'; 4 | import { DatabaseConfig } from '../types'; 5 | import { RpcUser } from './utils/rpc-user'; 6 | 7 | export interface MempoolArgs { 8 | electrsUrl: pulumi.Input; 9 | rpcUser: RpcUser; 10 | bitcoinRpcUrl: pulumi.Input; 11 | } 12 | 13 | export class Mempool extends pulumi.ComponentResource { 14 | public readonly app: Application; 15 | public dbConfig?: DatabaseConfig; 16 | private readonly config: pulumi.Config; 17 | 18 | constructor(name: string, private args: MempoolArgs, opts?: pulumi.ResourceOptions) { 19 | super('orangelab:bitcoin:Mempool', name, args, opts); 20 | 21 | rootConfig.require(name, 'mariadb-operator'); 22 | 23 | this.config = new pulumi.Config(name); 24 | this.app = new Application(this, name).addMariaDB(); 25 | this.dbConfig = this.app.databases?.getConfig(); 26 | if (this.app.storageOnly) return; 27 | this.createDeployment(); 28 | } 29 | 30 | private createDeployment() { 31 | const version = this.config.require('version'); 32 | const hostname = this.config.require('hostname'); 33 | const rpcUrl = pulumi 34 | .output(this.args.bitcoinRpcUrl) 35 | .apply(url => new URL(`http://${url}`)); 36 | const electrsUrl = pulumi.output(this.args.electrsUrl).apply(url => { 37 | const [host, port] = url.split(':'); 38 | return { host, port }; 39 | }); 40 | 41 | this.app 42 | .addDeployment({ 43 | name: 'backend', 44 | image: `mempool/backend:${version}`, 45 | ports: [{ name: 'http', port: 8999, hostname: `${hostname}-backend` }], 46 | env: { 47 | CORE_RPC_HOST: rpcUrl.hostname, 48 | CORE_RPC_PORT: rpcUrl.port, 49 | DATABASE_DATABASE: this.dbConfig?.database, 50 | DATABASE_ENABLED: 'true', 51 | DATABASE_HOST: this.dbConfig?.hostname, 52 | ELECTRUM_HOST: electrsUrl.host, 53 | ELECTRUM_PORT: electrsUrl.port, 54 | ELECTRUM_TLS_ENABLED: 'false', 55 | MEMPOOL_BACKEND: 'electrum', 56 | }, 57 | envSecret: { 58 | CORE_RPC_USERNAME: this.args.rpcUser.username, 59 | CORE_RPC_PASSWORD: this.args.rpcUser.password, 60 | DATABASE_PASSWORD: this.dbConfig?.password, 61 | DATABASE_USERNAME: this.dbConfig?.username, 62 | }, 63 | resources: { 64 | requests: { cpu: '100m', memory: '512Mi' }, 65 | limits: { cpu: '1000m', memory: '4Gi' }, 66 | }, 67 | }) 68 | .addDeployment({ 69 | name: 'frontend', 70 | image: `mempool/frontend:${version}`, 71 | ports: [{ name: 'http', port: 8080, hostname }], 72 | env: { 73 | FRONTEND_HTTP_PORT: '8080', 74 | BACKEND_MAINNET_HTTP_HOST: 'mempool-backend', 75 | }, 76 | resources: { 77 | requests: { cpu: '100m', memory: '512Mi' }, 78 | limits: { cpu: '1000m', memory: '2Gi' }, 79 | }, 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /components/iot/home-assistant.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../application'; 4 | 5 | export interface HomeAssistantArgs { 6 | trustedProxies?: string[]; 7 | } 8 | 9 | export class HomeAssistant extends pulumi.ComponentResource { 10 | public readonly endpointUrl: string | undefined; 11 | 12 | constructor(name: string, args: HomeAssistantArgs, opts?: pulumi.ResourceOptions) { 13 | super('orangelab:iot:HomeAssistant', name, args, opts); 14 | 15 | const config = new pulumi.Config('home-assistant'); 16 | const version = config.get('version'); 17 | 18 | const app = new Application(this, name).addStorage({ 19 | overrideFullname: 'home-assistant-home-assistant-0', 20 | }); 21 | 22 | if (app.storageOnly) return; 23 | const ingressInfo = app.network.getIngressInfo(); 24 | new kubernetes.helm.v3.Release( 25 | name, 26 | { 27 | chart: 'home-assistant', 28 | namespace: app.namespace, 29 | version, 30 | repositoryOpts: { 31 | repo: 'http://pajikos.github.io/home-assistant-helm-chart/', 32 | }, 33 | values: { 34 | additionalMounts: [ 35 | { mountPath: '/run/dbus', name: 'dbus', readOnly: true }, 36 | ], 37 | additionalVolumes: [ 38 | { 39 | name: 'dbus', 40 | hostPath: { path: '/run/dbus', type: 'Directory' }, 41 | }, 42 | ], 43 | affinity: app.nodes.getAffinity(), 44 | configuration: { 45 | enabled: true, 46 | trusted_proxies: args.trustedProxies ?? [], 47 | }, 48 | fullnameOverride: name, 49 | hostNetwork: true, 50 | ingress: { 51 | enabled: true, 52 | className: ingressInfo.className, 53 | hosts: [ 54 | { 55 | host: ingressInfo.hostname, 56 | paths: [{ path: '/', pathType: 'Prefix' }], 57 | }, 58 | ], 59 | tls: [ 60 | { 61 | hosts: [ingressInfo.hostname], 62 | secretName: ingressInfo.tlsSecretName, 63 | }, 64 | ], 65 | annotations: ingressInfo.annotations, 66 | }, 67 | persistence: { 68 | enabled: true, 69 | storageClass: app.storage?.getStorageClass(), 70 | }, 71 | replicaCount: 1, 72 | securityContext: { 73 | capabilities: { 74 | add: ['NET_ADMIN', 'NET_RAW'], 75 | }, 76 | seccompProfile: { 77 | type: 'RuntimeDefault', 78 | }, 79 | seLinuxOptions: { 80 | type: 'spc_t', 81 | }, 82 | }, 83 | }, 84 | }, 85 | { parent: this, dependsOn: app.storage }, 86 | ); 87 | 88 | this.endpointUrl = ingressInfo.url; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /components/system/amd-gpu-operator/amd-gpu-operator.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../../application'; 4 | import { GrafanaDashboard } from '../../grafana-dashboard'; 5 | import { rootConfig } from '../../root-config'; 6 | import dashboardGpuJson from './amd-dashboard_gpu.json'; 7 | import dashboardJobJson from './amd-dashboard_job.json'; 8 | import dashboardNodeJson from './amd-dashboard_node.json'; 9 | import dashboardOverviewJson from './amd-dashboard_overview.json'; 10 | 11 | export class AmdGPUOperator extends pulumi.ComponentResource { 12 | private readonly config: pulumi.Config; 13 | private readonly app: Application; 14 | 15 | constructor(private readonly name: string, opts?: pulumi.ResourceOptions) { 16 | super('orangelab:system:AmdGPUOperator', name, {}, opts); 17 | 18 | rootConfig.require(name, 'cert-manager'); 19 | rootConfig.require(name, 'nfd'); 20 | 21 | this.config = new pulumi.Config(name); 22 | this.app = new Application(this, name); 23 | 24 | const chart = this.createChart(); 25 | this.createDeviceConfig(chart); 26 | 27 | if (rootConfig.enableMonitoring()) { 28 | this.createDashboards(); 29 | } 30 | } 31 | 32 | private createChart(): kubernetes.helm.v3.Release { 33 | return new kubernetes.helm.v3.Release( 34 | this.name, 35 | { 36 | chart: 'gpu-operator-charts', 37 | repositoryOpts: { repo: 'https://rocm.github.io/gpu-operator' }, 38 | version: this.config.get('version'), 39 | namespace: this.app.namespace, 40 | values: { 41 | kmm: { enabled: true }, 42 | installdefaultNFDRule: false, 43 | crds: { defaultCR: { install: false } }, 44 | nodeSelector: { 'orangelab/gpu-amd': 'true' }, 45 | 'node-feature-discovery': { enabled: false }, 46 | }, 47 | }, 48 | { parent: this }, 49 | ); 50 | } 51 | 52 | private createDeviceConfig(chart: kubernetes.helm.v3.Release) { 53 | return new kubernetes.apiextensions.CustomResource( 54 | `${this.name}-config`, 55 | { 56 | apiVersion: 'amd.com/v1alpha1', 57 | kind: 'DeviceConfig', 58 | metadata: { name: this.name, namespace: this.app.namespace }, 59 | spec: { 60 | driver: { enable: false }, 61 | devicePlugin: { enableNodeLabeller: true }, 62 | metricsExporter: { enable: rootConfig.enableMonitoring() }, 63 | selector: { 'orangelab/gpu-amd': 'true' }, 64 | testRunner: { enable: false }, 65 | }, 66 | }, 67 | { parent: this, dependsOn: chart }, 68 | ); 69 | } 70 | 71 | private createDashboards(): void { 72 | new GrafanaDashboard(`${this.name}-overview`, this, { 73 | configJson: dashboardOverviewJson, 74 | title: 'AMD - Overview', 75 | }); 76 | new GrafanaDashboard(`${this.name}-gpu`, this, { 77 | configJson: dashboardGpuJson, 78 | title: 'AMD - GPU', 79 | }); 80 | new GrafanaDashboard(`${this.name}-job`, this, { 81 | configJson: dashboardJobJson, 82 | title: 'AMD - Job', 83 | }); 84 | new GrafanaDashboard(`${this.name}-node`, this, { 85 | configJson: dashboardNodeJson, 86 | title: 'AMD - Compute Node', 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /components/ai/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { rootConfig } from '../root-config'; 3 | import { Automatic1111 } from './automatic1111'; 4 | import { InvokeAi } from './invokeai'; 5 | import { KubeAi } from './kubeai'; 6 | import { N8n } from './n8n'; 7 | import { Ollama } from './ollama'; 8 | import { OpenWebUI } from './open-webui'; 9 | import { SDNext } from './sdnext'; 10 | 11 | export class AIModule extends pulumi.ComponentResource { 12 | private readonly ollama?: Ollama; 13 | private readonly kubeAI?: KubeAi; 14 | private readonly openWebUI?: OpenWebUI; 15 | private readonly automatic1111?: Automatic1111; 16 | private readonly sdnext?: SDNext; 17 | private readonly invokeAi?: InvokeAi; 18 | private readonly n8n?: N8n; 19 | 20 | getExports() { 21 | return { 22 | endpoints: { 23 | ...this.automatic1111?.app.network.endpoints, 24 | ...this.invokeAi?.app.network.endpoints, 25 | kubeai: this.kubeAI?.serviceUrl, 26 | ollama: this.ollama?.endpointUrl, 27 | 'open-webui': this.openWebUI?.endpointUrl, 28 | ...this.sdnext?.app.network.endpoints, 29 | ...this.n8n?.app.network.endpoints, 30 | }, 31 | clusterEndpoints: { 32 | ...this.automatic1111?.app.network.clusterEndpoints, 33 | ...this.invokeAi?.app.network.clusterEndpoints, 34 | kubeai: this.kubeAI?.serviceUrl, 35 | ollama: this.ollama?.serviceUrl, 36 | ...this.sdnext?.app.network.clusterEndpoints, 37 | ...this.n8n?.app.network.clusterEndpoints, 38 | }, 39 | n8n: this.n8n 40 | ? { 41 | encryptionKey: this.n8n.encryptionKey, 42 | db: this.n8n.postgresConfig, 43 | } 44 | : undefined, 45 | }; 46 | } 47 | 48 | constructor(name: string, opts?: pulumi.ComponentResourceOptions) { 49 | super('orangelab:ai', name, {}, opts); 50 | 51 | if (rootConfig.isEnabled('ollama')) { 52 | this.ollama = new Ollama('ollama', { parent: this }); 53 | } 54 | 55 | if (rootConfig.isEnabled('automatic1111')) { 56 | this.automatic1111 = new Automatic1111('automatic1111', { parent: this }); 57 | } 58 | 59 | if (rootConfig.isEnabled('sdnext')) { 60 | this.sdnext = new SDNext('sdnext', { parent: this }); 61 | } 62 | 63 | if (rootConfig.isEnabled('kubeai')) { 64 | this.kubeAI = new KubeAi('kubeai', { parent: this }); 65 | } 66 | 67 | if (rootConfig.isEnabled('open-webui')) { 68 | this.openWebUI = new OpenWebUI( 69 | 'open-webui', 70 | { 71 | ollamaUrl: this.ollama?.serviceUrl, 72 | openAiUrl: this.kubeAI?.serviceUrl, 73 | automatic1111Url: 74 | this.sdnext?.app.network.clusterEndpoints.sdnext ?? 75 | this.automatic1111?.app.network.clusterEndpoints.automatic1111, 76 | }, 77 | { 78 | parent: this, 79 | dependsOn: [this.ollama, this.kubeAI, this.automatic1111].filter( 80 | x => x !== undefined, 81 | ), 82 | }, 83 | ); 84 | } 85 | 86 | if (rootConfig.isEnabled('invokeai')) { 87 | this.invokeAi = new InvokeAi('invokeai', { parent: this }); 88 | } 89 | 90 | if (rootConfig.isEnabled('n8n')) { 91 | this.n8n = new N8n( 92 | 'n8n', 93 | { ollamaUrl: this.ollama?.serviceUrl }, 94 | { parent: this }, 95 | ); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /components/system/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { rootConfig } from '../root-config'; 3 | import { AmdGPUOperator } from './amd-gpu-operator/amd-gpu-operator'; 4 | import { CertManager } from './cert-manager'; 5 | import { Debug } from './debug'; 6 | import { Longhorn } from './longhorn/longhorn'; 7 | import { Minio } from './minio/minio'; 8 | import { NodeFeatureDiscovery } from './nfd'; 9 | import { NvidiaGPUOperator } from './nvidia-gpu-operator'; 10 | import { Tailscale } from './tailscale/tailscale'; 11 | import { TailscaleOperator } from './tailscale/tailscale-operator'; 12 | 13 | export class SystemModule extends pulumi.ComponentResource { 14 | tailscaleServerKey: pulumi.Output | undefined; 15 | tailscaleAgentKey: pulumi.Output | undefined; 16 | domainName: string; 17 | 18 | longhorn?: Longhorn; 19 | minio?: Minio; 20 | 21 | getExports() { 22 | return { 23 | endpoints: { 24 | ...this.minio?.app.network.endpoints, 25 | longhorn: this.longhorn?.endpointUrl, 26 | }, 27 | clusterEndpoints: { 28 | ...this.minio?.app.network.clusterEndpoints, 29 | }, 30 | minioUsers: this.minio?.users, 31 | tailscaleAgentKey: this.tailscaleAgentKey, 32 | tailscaleServerKey: this.tailscaleServerKey, 33 | tailscaleDomain: this.domainName, 34 | }; 35 | } 36 | 37 | constructor(name: string, args = {}, opts?: pulumi.ResourceOptions) { 38 | super('orangelab:system', name, args, opts); 39 | 40 | const tailscale = new Tailscale('tailscale', {}, { parent: this }); 41 | this.tailscaleServerKey = tailscale.serverKey; 42 | this.tailscaleAgentKey = tailscale.agentKey; 43 | this.domainName = tailscale.tailnet; 44 | 45 | if (rootConfig.isEnabled('tailscale-operator')) { 46 | new TailscaleOperator( 47 | 'tailscale-operator', 48 | { namespace: 'tailscale' }, 49 | { parent: this }, 50 | ); 51 | } 52 | 53 | let nfd: NodeFeatureDiscovery | undefined; 54 | if ( 55 | rootConfig.isEnabled('nfd') || 56 | rootConfig.isEnabled('nvidia-gpu-operator') || 57 | rootConfig.isEnabled('amd-gpu-operator') 58 | ) { 59 | nfd = new NodeFeatureDiscovery('nfd', { parent: this }); 60 | } 61 | 62 | let certManager: CertManager | undefined; 63 | if ( 64 | rootConfig.isEnabled('cert-manager') || 65 | rootConfig.isEnabled('amd-gpu-operator') 66 | ) { 67 | certManager = new CertManager('cert-manager', {}, { parent: this }); 68 | } 69 | 70 | if (rootConfig.isEnabled('nvidia-gpu-operator')) { 71 | new NvidiaGPUOperator( 72 | 'nvidia-gpu-operator', 73 | {}, 74 | { parent: this, dependsOn: nfd }, 75 | ); 76 | } 77 | 78 | if (rootConfig.isEnabled('amd-gpu-operator')) { 79 | new AmdGPUOperator('amd-gpu-operator', { 80 | parent: this, 81 | dependsOn: [...(certManager ? [certManager] : []), ...(nfd ? [nfd] : [])], 82 | }); 83 | } 84 | 85 | if (rootConfig.isEnabled('minio')) { 86 | this.minio = new Minio('minio', { parent: this }); 87 | } 88 | 89 | if (rootConfig.isEnabled('longhorn')) { 90 | this.longhorn = new Longhorn( 91 | 'longhorn', 92 | { 93 | s3EndpointUrl: this.minio?.app.network.clusterEndpoints['minio-api'], 94 | minioProvider: this.minio?.minioProvider, 95 | }, 96 | { parent: this, dependsOn: [this.minio].filter(v => v !== undefined) }, 97 | ); 98 | } 99 | 100 | if (rootConfig.isEnabled('debug')) { 101 | new Debug('debug', {}, { parent: this }); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /components/nodes.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | 4 | type NodeSelectorTerm = kubernetes.types.input.core.v1.NodeSelectorTerm; 5 | 6 | export interface NodesArgs { 7 | config: pulumi.Config; 8 | gpu?: boolean; 9 | } 10 | 11 | export class Nodes { 12 | /** 13 | * Optional GPU type, if specified, will set node affinity for the specified GPU type. 14 | * If `gpu` is true, it will default to 'nvidia' unless 'amd-gpu' config is set to true. 15 | */ 16 | public readonly gpu?: 'nvidia' | 'amd'; 17 | 18 | constructor(private readonly args: NodesArgs) { 19 | if (args.gpu) { 20 | const useAmdGpu = args.config.getBoolean('amd-gpu') ?? false; 21 | this.gpu = useAmdGpu ? 'amd' : 'nvidia'; 22 | } 23 | } 24 | 25 | getAffinity(component?: string): kubernetes.types.input.core.v1.Affinity | undefined { 26 | const prefix = component ? `${component}/` : ''; 27 | const requiredTerms = this.getRequiredNodeSelectorTerms(prefix); 28 | const preferredLabel = this.args.config.get(`${prefix}preferredNodeLabel`); 29 | 30 | if (requiredTerms.length === 0 && !preferredLabel) return; 31 | 32 | return { 33 | nodeAffinity: { 34 | requiredDuringSchedulingIgnoredDuringExecution: 35 | requiredTerms.length > 0 36 | ? { nodeSelectorTerms: requiredTerms } 37 | : undefined, 38 | preferredDuringSchedulingIgnoredDuringExecution: preferredLabel 39 | ? [ 40 | { 41 | preference: this.getNodeSelectorTerm(preferredLabel), 42 | weight: 1, 43 | }, 44 | ] 45 | : undefined, 46 | }, 47 | }; 48 | } 49 | 50 | getVolumeAffinity(): kubernetes.types.input.core.v1.VolumeNodeAffinity | undefined { 51 | const terms: NodeSelectorTerm[] = []; 52 | const requiredNodeLabel = this.args.config.get('requiredNodeLabel'); 53 | if (requiredNodeLabel) { 54 | terms.push(this.getNodeSelectorTerm(requiredNodeLabel)); 55 | } 56 | if (this.args.gpu) { 57 | terms.push(this.getNodeSelectorTerm('orangelab/gpu=true')); 58 | } 59 | return terms.length 60 | ? { 61 | required: { 62 | nodeSelectorTerms: terms, 63 | }, 64 | } 65 | : undefined; 66 | } 67 | 68 | private getRequiredNodeSelectorTerms(prefix?: string): NodeSelectorTerm[] { 69 | const terms: NodeSelectorTerm[] = []; 70 | const requiredNodeLabel = this.args.config.get( 71 | `${prefix ?? ''}requiredNodeLabel`, 72 | ); 73 | 74 | if (requiredNodeLabel) { 75 | terms.push(this.getNodeSelectorTerm(requiredNodeLabel)); 76 | } 77 | 78 | if (this.gpu === 'amd') { 79 | terms.push(this.getNodeSelectorTerm('orangelab/gpu-amd=true')); 80 | } 81 | if (this.gpu === 'nvidia') { 82 | terms.push(this.getNodeSelectorTerm('orangelab/gpu-nvidia=true')); 83 | } 84 | return terms; 85 | } 86 | 87 | /** 88 | * Creates a NodeSelectorTerm from a label specification string. 89 | * 90 | * Format: key=value1|value2|value3 91 | * 92 | * Examples: 93 | * - "orangelab/gpu=true|nvidia" - matches nodes with label orangelab/gpu set to either "true" or "nvidia" 94 | * - "orangelab/gpu" - matches nodes that have the orangelab/gpu label (any value) 95 | * 96 | * @param labelSpec - Label specification in format "key", "key=value" or "key=value1|value2" 97 | * @returns NodeSelectorTerm with appropriate matchExpressions 98 | */ 99 | private getNodeSelectorTerm(labelSpec: string): NodeSelectorTerm { 100 | const [key, value] = labelSpec.split('='); 101 | if (!value) { 102 | return { matchExpressions: [{ key, operator: 'Exists' }] }; 103 | } 104 | return { 105 | matchExpressions: [ 106 | { 107 | key, 108 | operator: 'In', 109 | values: value.split('|'), 110 | }, 111 | ], 112 | }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /components/bitcoin/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import assert from 'assert'; 4 | import { rootConfig } from '../root-config'; 5 | import { BitcoinCore } from './bitcoin-core'; 6 | import { BitcoinKnots } from './bitcoin-knots'; 7 | import { Electrs } from './electrs'; 8 | import { Mempool } from './mempool'; 9 | import { RpcUser } from './utils/rpc-user'; 10 | 11 | export class BitcoinModule extends pulumi.ComponentResource { 12 | private readonly bitcoinKnots?: BitcoinKnots; 13 | private readonly bitcoinCore?: BitcoinCore; 14 | private readonly electrs?: Electrs; 15 | private readonly mempool?: Mempool; 16 | 17 | /** 18 | * Map of username to password 19 | */ 20 | bitcoinUsers: Record> = {}; 21 | 22 | getExports() { 23 | return { 24 | bitcoinUsers: Object.fromEntries( 25 | Object.entries(this.bitcoinUsers).map(([user, password]) => [ 26 | user, 27 | pulumi.secret(password), 28 | ]), 29 | ), 30 | endpoints: { 31 | ...this.bitcoinCore?.app.network.endpoints, 32 | ...this.bitcoinKnots?.app.network.endpoints, 33 | ...this.electrs?.app.network.endpoints, 34 | ...this.mempool?.app.network.endpoints, 35 | }, 36 | clusterEndpoints: { 37 | ...this.bitcoinCore?.app.network.clusterEndpoints, 38 | ...this.bitcoinKnots?.app.network.clusterEndpoints, 39 | ...this.electrs?.app.network.clusterEndpoints, 40 | ...this.mempool?.app.network.clusterEndpoints, 41 | }, 42 | mempool: this.mempool ? { db: this.mempool.dbConfig } : undefined, 43 | }; 44 | } 45 | 46 | constructor(name: string, opts?: pulumi.ComponentResourceOptions) { 47 | super('orangelab:bitcoin', name, {}, opts); 48 | 49 | const config = new pulumi.Config('bitcoin'); 50 | const usernames = config 51 | .require('rpcUsers') 52 | .split(',') 53 | .map(u => u.trim()); 54 | const rpcUsers: Record = {}; 55 | usernames.forEach(username => { 56 | rpcUsers[username] = new RpcUser(name, { username }, { parent: this }); 57 | this.bitcoinUsers[username] = rpcUsers[username].password; 58 | }); 59 | 60 | if (rootConfig.isEnabled('bitcoin-knots')) { 61 | this.bitcoinKnots = new BitcoinKnots( 62 | 'bitcoin-knots', 63 | { rpcUsers }, 64 | { parent: this }, 65 | ); 66 | } 67 | 68 | if (rootConfig.isEnabled('bitcoin-core')) { 69 | this.bitcoinCore = new BitcoinCore( 70 | 'bitcoin-core', 71 | { rpcUsers }, 72 | { parent: this }, 73 | ); 74 | } 75 | 76 | const bitcoinRpcUrl = 77 | this.bitcoinKnots?.app.network.clusterEndpoints['bitcoin-knots-rpc'] ?? 78 | this.bitcoinCore?.app.network.clusterEndpoints['bitcoin-core-rpc']; 79 | const bitcoinP2pUrl = 80 | this.bitcoinKnots?.app.network.clusterEndpoints['bitcoin-knots-p2p'] ?? 81 | this.bitcoinCore?.app.network.clusterEndpoints['bitcoin-core-p2p']; 82 | if (rootConfig.isEnabled('electrs')) { 83 | assert( 84 | bitcoinRpcUrl && bitcoinP2pUrl, 85 | 'Bitcoin node must be enabled for Electrs', 86 | ); 87 | this.electrs = new Electrs( 88 | 'electrs', 89 | { 90 | rpcUser: rpcUsers.electrs, 91 | bitcoinRpcUrl, 92 | bitcoinP2pUrl, 93 | }, 94 | { parent: this }, 95 | ); 96 | } 97 | 98 | if (rootConfig.isEnabled('mempool')) { 99 | const electrsUrl = this.electrs?.app.network.clusterEndpoints['electrs-rpc']; 100 | assert(electrsUrl, 'Electrs must be enabled for Mempool'); 101 | assert(bitcoinRpcUrl, 'Bitcoin RPC must be enabled for Mempool'); 102 | this.mempool = new Mempool( 103 | 'mempool', 104 | { 105 | electrsUrl, 106 | rpcUser: rpcUsers.mempool, 107 | bitcoinRpcUrl, 108 | }, 109 | { parent: this }, 110 | ); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /components/services.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import assert from 'node:assert'; 4 | import { Containers } from './containers'; 5 | import { Metadata } from './metadata'; 6 | import { Nodes } from './nodes'; 7 | import { Storage } from './storage'; 8 | import { ContainerSpec } from './types'; 9 | 10 | /** 11 | * Services class handles creation of Deployments, DaemonSets, Jobs, and ServiceAccounts for an application. 12 | */ 13 | export class Services { 14 | private serviceAccount?: kubernetes.core.v1.ServiceAccount; 15 | 16 | private readonly appName: string; 17 | private readonly metadata: Metadata; 18 | private readonly storage?: Storage; 19 | private readonly nodes: Nodes; 20 | private readonly config: pulumi.Config; 21 | 22 | constructor( 23 | appName: string, 24 | params: { 25 | metadata: Metadata; 26 | storage?: Storage; 27 | nodes: Nodes; 28 | config: pulumi.Config; 29 | }, 30 | private opts?: pulumi.ComponentResourceOptions, 31 | ) { 32 | this.appName = appName; 33 | this.metadata = params.metadata; 34 | this.storage = params.storage; 35 | this.nodes = params.nodes; 36 | this.config = params.config; 37 | } 38 | 39 | private getServiceAccount() { 40 | this.serviceAccount ??= this.createServiceAccount(); 41 | return this.serviceAccount; 42 | } 43 | 44 | private createServiceAccount() { 45 | return new kubernetes.core.v1.ServiceAccount( 46 | `${this.appName}-sa`, 47 | { metadata: this.metadata.get() }, 48 | this.opts, 49 | ); 50 | } 51 | 52 | createDeployment(spec: ContainerSpec) { 53 | const serviceAccount = this.getServiceAccount(); 54 | const podSpec = new Containers( 55 | this.appName, 56 | { 57 | metadata: this.metadata, 58 | storage: this.storage, 59 | serviceAccount, 60 | nodes: this.nodes, 61 | config: this.config, 62 | }, 63 | this.opts, 64 | ); 65 | const metadata = this.metadata.get({ component: spec.name }); 66 | return new kubernetes.apps.v1.Deployment( 67 | `${metadata.name}-deployment`, 68 | { 69 | metadata, 70 | spec: { 71 | replicas: 1, 72 | selector: { matchLabels: this.metadata.getSelectorLabels(spec.name) }, 73 | template: podSpec.createPodTemplateSpec(spec), 74 | strategy: { type: 'Recreate', rollingUpdate: undefined }, 75 | }, 76 | }, 77 | { 78 | ...this.opts, 79 | deleteBeforeReplace: true, 80 | }, 81 | ); 82 | } 83 | 84 | createDaemonSet(spec: ContainerSpec) { 85 | assert(spec.name, 'name is required for daemonset'); 86 | const serviceAccount = this.getServiceAccount(); 87 | const podSpec = new Containers(this.appName, { 88 | metadata: this.metadata, 89 | serviceAccount, 90 | config: this.config, 91 | nodes: this.nodes, 92 | storage: this.storage, 93 | }); 94 | return new kubernetes.apps.v1.DaemonSet( 95 | `${this.appName}-${spec.name}-daemonset`, 96 | { 97 | metadata: this.metadata.get({ component: spec.name }), 98 | spec: { 99 | selector: { 100 | matchLabels: this.metadata.getSelectorLabels(spec.name), 101 | }, 102 | template: podSpec.createPodTemplateSpec(spec), 103 | }, 104 | }, 105 | this.opts, 106 | ); 107 | } 108 | 109 | createJob(spec: ContainerSpec) { 110 | assert(spec.name, 'name is required for job'); 111 | const serviceAccount = this.getServiceAccount(); 112 | const podSpec = new Containers( 113 | this.appName, 114 | { 115 | metadata: this.metadata, 116 | storage: this.storage, 117 | serviceAccount, 118 | config: this.config, 119 | nodes: this.nodes, 120 | }, 121 | this.opts, 122 | ); 123 | return new kubernetes.batch.v1.Job( 124 | `${this.appName}-${spec.name}-job`, 125 | { 126 | metadata: this.metadata.get({ component: spec.name }), 127 | spec: { 128 | template: podSpec.createPodTemplateSpec(spec), 129 | }, 130 | }, 131 | this.opts, 132 | ); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /components/system/nvidia-gpu-operator.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { rootConfig } from '../root-config'; 4 | 5 | export class NvidiaGPUOperator extends pulumi.ComponentResource { 6 | constructor(name: string, args = {}, opts?: pulumi.ResourceOptions) { 7 | super('orangelab:system:NvidiaGPUOperator', name, args, opts); 8 | 9 | rootConfig.require(name, 'nfd'); 10 | 11 | const config = new pulumi.Config(name); 12 | const version = config.get('version'); 13 | 14 | const namespace = new kubernetes.core.v1.Namespace( 15 | `${name}-ns`, 16 | { metadata: { name } }, 17 | { parent: this }, 18 | ); 19 | 20 | new kubernetes.helm.v3.Release( 21 | name, 22 | { 23 | chart: 'gpu-operator', 24 | namespace: namespace.metadata.name, 25 | version, 26 | repositoryOpts: { repo: 'https://helm.ngc.nvidia.com/nvidia' }, 27 | values: { 28 | // NVIDIA Confidential Computing Manager for Kubernetes 29 | ccManager: { enabled: false }, 30 | // NVidia Data Center GPU Manager - https://docs.nvidia.com/datacenter/dcgm/latest/user-guide/index.html 31 | dcgm: { enabled: false }, 32 | dcgmExporter: { enabled: false }, 33 | // https://github.com/NVIDIA/k8s-device-plugin 34 | devicePlugin: { 35 | enabled: true, 36 | config: { 37 | create: true, 38 | name: 'device-plugin-config', 39 | default: 'default', 40 | data: { 41 | default: JSON.stringify({ 42 | version: 'v1', 43 | flags: { 44 | migStrategy: 'none', 45 | }, 46 | sharing: { 47 | timeSlicing: { 48 | resources: [ 49 | { 50 | name: 'nvidia.com/gpu', 51 | replicas: 4, 52 | }, 53 | ], 54 | }, 55 | }, 56 | }), 57 | }, 58 | }, 59 | }, 60 | // NVidia driver already installed on host 61 | driver: { 62 | enabled: false, 63 | nodeSelector: { 'orangelab/gpu-nvidia': 'true' }, 64 | }, 65 | gdrcopy: { enabled: false }, 66 | // GPUDirect Storage kernel driver - https://github.com/NVIDIA/gds-nvidia-fs 67 | gds: { enabled: false }, 68 | // GPU Feature Discovery 69 | gfd: { enabled: true }, 70 | kataManager: { enabled: false }, 71 | migManager: { enabled: false }, 72 | // Node Feature Discovery dependent chart 73 | nfd: { enabled: false }, 74 | nodeStatusExporter: { enabled: false }, 75 | operator: { defaultRuntime: 'containerd' }, 76 | sandboxDevicePlugin: { enabled: false }, 77 | // NVidia container toolkit 78 | toolkit: { 79 | enabled: true, 80 | env: [ 81 | { 82 | name: 'CONTAINERD_CONFIG', 83 | value: '/var/lib/rancher/k3s/agent/etc/containerd/config.toml', 84 | }, 85 | { 86 | name: 'CONTAINERD_SOCKET', 87 | value: '/run/k3s/containerd/containerd.sock', 88 | }, 89 | { 90 | name: 'CONTAINERD_RUNTIME_CLASS', 91 | value: 'nvidia', 92 | }, 93 | { 94 | name: 'CONTAINERD_SET_AS_DEFAULT', 95 | value: 'true', 96 | }, 97 | ], 98 | }, 99 | vfioManager: { enabled: false }, 100 | // https://github.com/NVIDIA/vgpu-device-manager 101 | vgpuDeviceManager: { enabled: false }, 102 | vgpuManager: { enabled: false }, 103 | }, 104 | }, 105 | { parent: this, deleteBeforeReplace: true }, 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /components/types.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | 3 | export enum StorageType { 4 | Default, 5 | GPU, 6 | Large, 7 | Database, 8 | } 9 | 10 | export interface ServicePort { 11 | name: string; 12 | port: number; 13 | hostname?: string; 14 | tcp?: boolean; 15 | ingressClassName?: 'tailscale' | 'traefik'; 16 | } 17 | 18 | export interface VolumeMount { 19 | mountPath: string; 20 | name?: string; 21 | subPath?: string; 22 | readOnly?: boolean; 23 | } 24 | 25 | export interface InitContainerSpec { 26 | name: string; 27 | /** 28 | * The Docker image to use for the init container. 29 | * Defaults to 'alpine:latest'. 30 | */ 31 | image?: string; 32 | command?: string[]; 33 | /** 34 | * Optional volume mounts for the init container. 35 | * If not provided, it will use the main container's volume mounts. 36 | */ 37 | volumeMounts?: VolumeMount[]; 38 | } 39 | 40 | /** 41 | * Represents the resource limits and requests for a container. 42 | */ 43 | export interface ContainerResources { 44 | limits?: { 45 | cpu?: string; 46 | memory?: string; 47 | }; 48 | requests?: { 49 | cpu?: string; 50 | memory?: string; 51 | }; 52 | } 53 | 54 | /** 55 | * Represents the specification for a container in a Kubernetes deployment. 56 | */ 57 | export interface ContainerSpec { 58 | name?: string; 59 | image: string; 60 | port?: number; 61 | ports?: ServicePort[]; 62 | command?: string[]; 63 | commandArgs?: string[] | pulumi.Output; 64 | env?: Record | undefined>; 65 | envSecret?: Record | undefined>; 66 | hostNetwork?: boolean; 67 | initContainers?: InitContainerSpec[]; 68 | volumeMounts?: VolumeMount[]; 69 | healthChecks?: boolean; 70 | resources?: ContainerResources; 71 | runAsUser?: number; 72 | restartPolicy?: string; 73 | } 74 | 75 | /** 76 | * Represents a local volume using local-path provisioner. 77 | */ 78 | export interface LocalVolume { 79 | name: string; 80 | hostPath: string; 81 | type?: 'Directory' | 'DirectoryOrCreate' | 'FileOrCreate' | 'CharDevice'; 82 | } 83 | 84 | /** 85 | * Represents a persistent volume configuration for Longhorn. 86 | */ 87 | 88 | export interface PersistentVolume { 89 | /** 90 | * The optional name suffix for the volume. 91 | * If provided, the full volume name will be `${appName}-${name}`. 92 | * If not provided, the `appName` will be used as the volume name. 93 | * This name also acts as a prefix for configuration lookups (e.g., `/storageSize`). 94 | */ 95 | name?: string; 96 | /** 97 | * The desired size of the persistent volume (e.g., "10Gi", "100Mi"). 98 | * If not provided, the value will be sourced from the Pulumi config key 99 | * `${name}/storageSize` or `storageSize` if `name` is not set. 100 | */ 101 | size?: string; 102 | /** 103 | * The type of persistent storage to use. 104 | * Defaults to `StorageType.Default` if not specified. 105 | */ 106 | type?: StorageType; 107 | /** 108 | * Specifies an existing volume name to potentially restore data from. 109 | * This is typically used in conjunction with backup/restore mechanisms. 110 | * If not provided, the value might be sourced from the Pulumi config key 111 | * `${name}/fromVolume` or `fromVolume` if `name` is not set. 112 | */ 113 | fromVolume?: string; 114 | /** 115 | * Allows explicitly setting the full name of the resulting PersistentVolumeClaim resource. 116 | * This is particularly useful for integration with StatefulSets using volume claim templates, 117 | * where Kubernetes automatically generates PVC names like `--`. 118 | * If not provided, the name defaults to `${appName}-${name}` or just `appName`. 119 | * More info at https://longhorn.io/docs/1.8.1/snapshots-and-backups/backup-and-restore/restore-statefulset/ 120 | */ 121 | overrideFullname?: string; 122 | /** 123 | * Labels to apply to the PVC 124 | */ 125 | labels?: Record; 126 | /** 127 | * Annotations to apply to the PVC 128 | */ 129 | annotations?: Record; 130 | } 131 | 132 | /** 133 | * Represents a volume that contains configuration files mounted in the same folder. 134 | */ 135 | export interface ConfigVolume { 136 | /** 137 | * The name of the config volume. 138 | * If not provided, defaults to 'config'. 139 | */ 140 | name?: string; 141 | /** 142 | * A map of configuration files to their contents. 143 | * The keys are the file names, and the values are the file contents. 144 | */ 145 | files: Record>; 146 | } 147 | 148 | /** 149 | * Represents the configuration needed to connect to a database instance. 150 | */ 151 | export interface DatabaseConfig { 152 | hostname: string; 153 | database: string; 154 | username: string; 155 | password: pulumi.Output; 156 | port: number; 157 | } 158 | -------------------------------------------------------------------------------- /components/data/DATA.md: -------------------------------------------------------------------------------- 1 | # Data module 2 | 3 | Components related to databases and data storage for applications, including PostgreSQL (CloudNativePG) and MariaDB operators. 4 | 5 | These components are optional. If you attempt to install an application or component that depends on a database and the required data component is not present, you will receive an error message indicating which database component to install. 6 | 7 | --- 8 | 9 | ## MariaDB Operator 10 | 11 | | | | 12 | | ----------- | --------------------------------------------------------------------------------------------------------- | 13 | | Homepage | https://mariadb-operator.github.io/mariadb-operator/ | 14 | | Helm values | https://github.com/mariadb-operator/mariadb-operator/blob/main/deploy/charts/mariadb-operator/values.yaml | 15 | | Dockerfile | https://github.com/MariaDB/mariadb-docker/blob/master/main/Dockerfile | 16 | 17 | The MariaDB Operator manages MariaDB/MySQL databases on Kubernetes using CRDs. It provisions and manages clusters, users, and databases declaratively. 18 | 19 | ### Installation 20 | 21 | ```sh 22 | pulumi config set mariadb-operator:enabled true 23 | ``` 24 | 25 | #### Override root password 26 | 27 | When restoring from backup, the generated root password won't match the one already in database. You have to either reset it (recommended) or use specific password to avoid auto-generation. 28 | 29 | ```sh 30 | pulumi config set --secret :db/rootPassword 31 | ``` 32 | 33 | #### Resetting root password 34 | 35 | ```sh 36 | # Shutdown all containers except database 37 | pulumi config set :storageOnly=true 38 | pulumi config set /db:enabled=true 39 | 40 | # Disable MariaDB authentication (allows logging in as root without password) 41 | pulumi config set /db:disableAuth=true 42 | pulumi up 43 | 44 | # log into the MariaDB container 45 | kubectl exec -it -db-0 -n -- mariadb -u root 46 | ``` 47 | 48 | Update root password using MariaDB CLI: 49 | 50 | ```sh 51 | # Initialize grant tables 52 | FLUSH PRIVILEGES; 53 | 54 | # Reset password, make sure it matches the `rootPassword` value in `-db-secret` 55 | ALTER USER 'root'@'%' IDENTIFIED BY ''; 56 | ALTER USER 'root'@'localhost' IDENTIFIED BY ''; 57 | FLUSH PRIVILEGES; 58 | ``` 59 | 60 | Exit with CTRL-D. Cleanup and re-enable database authentication: 61 | 62 | ```sh 63 | # Enable MariaDB authentication 64 | pulumi config rm /db:disableAuth 65 | pulumi up 66 | 67 | # Remove temporary config keys to start the apps using the database 68 | pulumi config rm :storageOnly 69 | pulumi config rm /db:enabled 70 | pulumi up 71 | ``` 72 | 73 | --- 74 | 75 | ## CloudNativePG Operator 76 | 77 | | | | 78 | | -------------- | ------------------------------------------------------------------------------------ | 79 | | Homepage | https://cloudnative-pg.io/ | 80 | | Helm chart | https://github.com/cloudnative-pg/charts | 81 | | Helm values | https://github.com/cloudnative-pg/charts/blob/main/charts/cloudnative-pg/values.yaml | 82 | | Cluster values | https://github.com/cloudnative-pg/charts/blob/main/charts/cluster/values.yaml | 83 | 84 | This component installs the CloudNativePG PostgreSQL operator using its official Helm chart. It manages the installation of CRDs and the operator itself, and supports optional monitoring integration. 85 | 86 | ### Installation 87 | 88 | ```sh 89 | # Optional: install CNPG kubectl plugin 90 | brew install kubectl-cnpg 91 | 92 | pulumi config set cloudnative-pg:enabled true 93 | ``` 94 | 95 | When setting `:storageOnly` to `true`, databases are shut down and only storage is retained. To keep PostgreSQL running, use: 96 | 97 | ```sh 98 | pulumi config set :db/enabled true 99 | ``` 100 | 101 | #### Manual Backup and Restore 102 | 103 | For manual database operations, use the provided scripts in the `scripts/` directory: 104 | 105 | **Database Dump:** 106 | 107 | ```sh 108 | # Create a database dump 109 | ./scripts/pg-dump.sh [namespace] 110 | 111 | # Example: dump n8n database 112 | ./scripts/pg-dump.sh n8n 113 | # Creates: n8n.dump 114 | ``` 115 | 116 | **Database Restore:** 117 | 118 | ```sh 119 | # Restore from a dump file 120 | ./scripts/pg-restore.sh [namespace] 121 | 122 | # Example: restore n8n database 123 | ./scripts/pg-restore.sh n8n 124 | # Requires: n8n.dump file to exist 125 | ``` 126 | 127 | #### Uninstall 128 | 129 | ```sh 130 | pulumi config set cloudnative-pg:enabled false 131 | pulumi up 132 | 133 | # Remove all CRDs (required before reinstalling) 134 | kubectl delete crd \ 135 | backups.postgresql.cnpg.io \ 136 | clusterimagecatalogs.postgresql.cnpg.io \ 137 | clusters.postgresql.cnpg.io \ 138 | databases.postgresql.cnpg.io \ 139 | imagecatalogs.postgresql.cnpg.io \ 140 | poolers.postgresql.cnpg.io \ 141 | publications.postgresql.cnpg.io \ 142 | scheduledbackups.postgresql.cnpg.io \ 143 | subscriptions.postgresql.cnpg.io 144 | ``` 145 | -------------------------------------------------------------------------------- /components/mariadb.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import * as random from '@pulumi/random'; 4 | import { Metadata } from './metadata'; 5 | import { DatabaseConfig } from './types'; 6 | 7 | export interface MariaDbClusterArgs { 8 | name: string; 9 | metadata: Metadata; 10 | storageSize: pulumi.Input; 11 | storageClassName?: pulumi.Input; 12 | storageOnly?: boolean; 13 | disableAuth?: boolean; 14 | enabled?: boolean; 15 | rootPassword?: pulumi.Input; 16 | password?: pulumi.Input; 17 | } 18 | 19 | export class MariaDbCluster extends pulumi.ComponentResource { 20 | private readonly mariadb?: kubernetes.apiextensions.CustomResource; 21 | private readonly secret: kubernetes.core.v1.Secret; 22 | 23 | private dbPassword: pulumi.Output; 24 | private rootPassword: pulumi.Output; 25 | private dbUser: string; 26 | private clusterName: string; 27 | 28 | constructor( 29 | private appName: string, 30 | private args: MariaDbClusterArgs, 31 | opts?: pulumi.ComponentResourceOptions, 32 | ) { 33 | super('orangelab:MariaDbCluster', appName, args, opts); 34 | this.clusterName = `${appName}-${this.args.name}`; 35 | this.dbUser = appName; 36 | this.dbPassword = pulumi.output( 37 | args.password ?? this.createPassword(this.dbUser), 38 | ); 39 | this.rootPassword = pulumi.output( 40 | this.args.rootPassword ?? this.createPassword('root'), 41 | ); 42 | 43 | this.secret = this.createSecret(); 44 | if (!args.enabled) return; 45 | this.mariadb = this.createCluster(); 46 | } 47 | 48 | private createSecret() { 49 | return new kubernetes.core.v1.Secret( 50 | `${this.clusterName}-secret`, 51 | { 52 | metadata: { 53 | name: `${this.clusterName}-secret`, 54 | namespace: this.args.metadata.namespace, 55 | labels: { 'k8s.mariadb.com/watch': '' }, 56 | }, 57 | stringData: { 58 | username: this.dbUser, 59 | password: this.dbPassword, 60 | rootPassword: this.rootPassword, 61 | }, 62 | }, 63 | { parent: this }, 64 | ); 65 | } 66 | 67 | private createCluster(): kubernetes.apiextensions.CustomResource { 68 | const metadata = this.args.metadata.get({ component: this.args.name }); 69 | const myCnf = pulumi.interpolate` 70 | [mariadb] 71 | skip-name-resolve 72 | temp-pool 73 | ${this.args.disableAuth ? 'skip-grant-tables' : ''} 74 | ${this.args.disableAuth ? 'skip-networking' : ''} 75 | `; 76 | return new kubernetes.apiextensions.CustomResource( 77 | this.clusterName, 78 | { 79 | apiVersion: 'k8s.mariadb.com/v1alpha1', 80 | kind: 'MariaDB', 81 | metadata, 82 | spec: { 83 | database: this.appName, 84 | inheritMetadata: { 85 | labels: this.args.metadata.getAppLabels(this.args.name), 86 | }, 87 | metrics: { enabled: true }, 88 | myCnf, 89 | passwordSecretKeyRef: { 90 | name: this.secret.metadata.name, 91 | key: 'password', 92 | generate: false, 93 | }, 94 | replicas: 1, 95 | resources: { 96 | requests: { cpu: '100m', memory: '128Mi' }, 97 | limits: { memory: '1Gi' }, 98 | }, 99 | rootPasswordSecretKeyRef: { 100 | name: this.secret.metadata.name, 101 | key: 'rootPassword', 102 | generate: false, 103 | }, 104 | storage: { 105 | volumeClaimTemplate: { 106 | accessModes: ['ReadWriteOnce'], 107 | resources: { 108 | requests: { storage: this.args.storageSize }, 109 | }, 110 | storageClassName: this.args.storageClassName, 111 | }, 112 | }, 113 | suspend: false, 114 | username: this.dbUser, 115 | }, 116 | }, 117 | { parent: this, dependsOn: [this.secret] }, 118 | ); 119 | } 120 | 121 | getConfig(): DatabaseConfig { 122 | return { 123 | hostname: this.clusterName, 124 | database: this.appName, 125 | username: this.appName, 126 | password: this.dbPassword, 127 | port: 3306, 128 | }; 129 | } 130 | 131 | private createPassword(username: string) { 132 | return new random.RandomPassword( 133 | `${this.clusterName}-${username}-password`, 134 | { length: 32, special: false }, 135 | { parent: this }, 136 | ).result; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /components/monitoring/MONITORING.md: -------------------------------------------------------------------------------- 1 | # Monitoring 2 | 3 | Optional components related monitoring the cluster. 4 | 5 | Recommended setup: 6 | 7 | ```sh 8 | pulumi config set beszel:enabled true 9 | pulumi up 10 | 11 | # copy key from UI 12 | pulumi config set beszel:hubKey 13 | pulumi up 14 | 15 | # add hosts using UI 16 | ``` 17 | 18 | ## Beszel 19 | 20 | | | | 21 | | --------- | ---------------------------------- | 22 | | Homepage | https://beszel.dev/ | 23 | | Endpoints | `https://beszel..ts.net/` | 24 | | | `https://beszel..ts.net/_/` | 25 | 26 | A lightweight alternative to Prometheus. 27 | 28 | First deploy Beszel hub with: 29 | 30 | ```sh 31 | pulumi config set beszel:enabled true 32 | pulumi up 33 | ``` 34 | 35 | Once the hub is deployed, go to `beszel..ts.net` endpoint and create an admin account. 36 | 37 | To deploy agents you need to find the generated public key. Click `Add system`, then copy the `Public key` field. Close the popup and do not add any systems yet. 38 | 39 | ```sh 40 | # replace with the copied value "ssh-ed25519 ..." 41 | pulumi config set beszel:hubKey 42 | pulumi up 43 | ``` 44 | 45 | Make sure to allow traffic to agents on port `45876`: 46 | 47 | ```sh 48 | firewall-cmd --permanent --add-port=45876/tcp 49 | ``` 50 | 51 | Once the agents are deployed, you need to manually add them in the UI of Beszel. Click `Add system`, select `docker`, then enter the hostname in the `Name` field and Tailscale IP in `Host/IP` 52 | 53 | You can find the IP address of your node using one of two ways: 54 | 55 | ```sh 56 | # List all hosts and IPs 57 | tailscale status 58 | 59 | # List only nodes added to cluster 60 | kubectl get nodes -o json | jq -r '.items[] | .metadata.name + " - " + .metadata.annotations["flannel.alpha.coreos.com/public-ip"]' 61 | ``` 62 | 63 | ## Prometheus 64 | 65 | | | | 66 | | ---------- | ------------------------------------------------------------------------------------------ | 67 | | Homepage | https://prometheus.io/ | 68 | | Helm chart | https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack | 69 | | Endpoints | `https://grafana..ts.net/` | 70 | | | `https://prometheus..ts.net/` | 71 | | | `https://alertmanager..ts.net/` | 72 | 73 | Prometheus provides much more detailed monitoring of the cluster. Many tools (like Headlamp) integrate with it to show metrics for Kubernetes resources. 74 | 75 | Enabling it will increase traffic between nodes. Expect over 1GB of data saved to storage per day, even with just a few nodes. 76 | 77 | Components will be deployed to all nodes by default. You can restrict that with `requiredNodeLabel` to deploy only to selected nodes: 78 | 79 | ```sh 80 | # (optional) only deploy to labeled nodes 81 | pulumi config set prometheus:requiredNodeLabel orangelab/prometheus=true 82 | # (optional) You need at least one node with orangelab/prometheus label 83 | kubectl label nodes orangelab/prometheus=true 84 | 85 | # Enable Prometheus 86 | pulumi config set prometheus:enabled true 87 | 88 | # (optional) Override grafana "admin" password 89 | pulumi config set prometheus:grafana-password --secret 90 | 91 | pulumi up 92 | ``` 93 | 94 | ### Grafana dashboards 95 | 96 | Once Prometheus is installed, additional metrics and Grafana dashboards can be enabled for applications that support it. 97 | 98 | ```sh 99 | # Enable additional metrics and dashboards 100 | # IMPORTANT: only enable once Prometheus has been installed. 101 | pulumi config set prometheus:enableComponentMonitoring true 102 | pulumi up 103 | ``` 104 | 105 | To remove dashboards created by @pulumiverse/grafana (not used anymore): 106 | 107 | ```sh 108 | STACK=lab 109 | 110 | pulumi state delete "urn:pulumi:$STACK::orangelab::orangelab:system\$orangelab:system:AmdGPUOperator\$grafana:oss/dashboard:Dashboard::amd-gpu-operator-node-dashboard" 111 | pulumi state delete "urn:pulumi:$STACK::orangelab::orangelab:system\$orangelab:system:TailscaleOperator\$grafana:oss/dashboard:Dashboard::tailscale-operator-dashboard" 112 | pulumi state delete "urn:pulumi:$STACK::orangelab::orangelab:system\$orangelab:system:AmdGPUOperator\$grafana:oss/dashboard:Dashboard::amd-gpu-operator-job-dashboard" 113 | pulumi state delete "urn:pulumi:$STACK::orangelab::orangelab:system\$orangelab:system:AmdGPUOperator\$grafana:oss/dashboard:Dashboard::amd-gpu-operator-overview-dashboard" 114 | pulumi state delete "urn:pulumi:$STACK::orangelab::orangelab:system\$orangelab:system:AmdGPUOperator\$grafana:oss/dashboard:Dashboard::amd-gpu-operator-gpu-dashboard" 115 | pulumi state delete "urn:pulumi:$STACK::orangelab::orangelab:system\$orangelab:system:Longhorn\$grafana:oss/dashboard:Dashboard::longhorn-dashboard" 116 | ``` 117 | 118 | ### Uninstall 119 | 120 | ```sh 121 | # Remove application monitoring before uninstalling Prometheus 122 | pulumi config set prometheus:enableComponentMonitoring false 123 | pulumi up 124 | 125 | # Remove Prometheus 126 | pulumi config set prometheus:enabled false 127 | pulumi up 128 | ``` 129 | 130 | CRDs need to be removed manually, more info at https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack#uninstall-helm-chart 131 | -------------------------------------------------------------------------------- /components/postgres.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import * as random from '@pulumi/random'; 4 | import { Metadata } from './metadata'; 5 | import { Nodes } from './nodes'; 6 | import { rootConfig } from './root-config'; 7 | import { DatabaseConfig } from './types'; 8 | 9 | export interface PostgresClusterArgs { 10 | name: string; 11 | metadata: Metadata; 12 | nodes: Nodes; 13 | storageSize: pulumi.Input; 14 | storageClassName?: pulumi.Input; 15 | enabled?: boolean; 16 | fromPVC?: string; 17 | instances?: number; 18 | password?: pulumi.Input; 19 | } 20 | 21 | export class PostgresCluster extends pulumi.ComponentResource { 22 | private readonly secret: kubernetes.core.v1.Secret; 23 | 24 | private dbPassword: pulumi.Output; 25 | private dbUser: string; 26 | private clusterName: string; 27 | 28 | constructor( 29 | private appName: string, 30 | private args: PostgresClusterArgs, 31 | opts?: pulumi.ComponentResourceOptions, 32 | ) { 33 | super('orangelab:PostgresCluster', appName, args, opts); 34 | this.clusterName = `${appName}-${this.args.name}`; 35 | this.dbUser = appName; 36 | this.dbPassword = pulumi.output( 37 | this.args.password ?? this.createPassword(this.dbUser), 38 | ); 39 | 40 | this.secret = this.createSecret(); 41 | if (!args.enabled) return; 42 | 43 | this.createCluster(); 44 | } 45 | 46 | private createSecret() { 47 | return new kubernetes.core.v1.Secret( 48 | `${this.clusterName}-secret`, 49 | { 50 | metadata: { 51 | name: `${this.clusterName}-secret`, 52 | namespace: this.args.metadata.namespace, 53 | labels: { 'cnpg.io/watch': '' }, 54 | }, 55 | stringData: { 56 | username: this.dbUser, 57 | password: this.dbPassword, 58 | }, 59 | }, 60 | { parent: this }, 61 | ); 62 | } 63 | 64 | private createCluster(): kubernetes.apiextensions.CustomResource { 65 | const metadata = this.args.metadata.get({ component: this.args.name }); 66 | const instances = this.args.instances ?? 1; 67 | const cluster = new kubernetes.apiextensions.CustomResource( 68 | this.clusterName, 69 | { 70 | apiVersion: 'postgresql.cnpg.io/v1', 71 | kind: 'Cluster', 72 | metadata, 73 | spec: { 74 | affinity: { 75 | topologyKey: 'kubernetes.io/hostname', 76 | nodeAffinity: this.args.nodes.getAffinity(this.args.name) 77 | ?.nodeAffinity, 78 | }, 79 | enablePDB: instances > 1, 80 | instances, 81 | inheritedMetadata: { 82 | labels: this.args.metadata.getAppLabels(this.args.name), 83 | }, 84 | bootstrap: { 85 | initdb: { 86 | database: this.appName, 87 | owner: this.dbUser, 88 | secret: { name: this.secret.metadata.name }, 89 | }, 90 | }, 91 | monitoring: rootConfig.enableMonitoring() 92 | ? { enablePodMonitor: true } 93 | : undefined, 94 | storage: { 95 | size: this.args.storageSize, 96 | pvcTemplate: this.args.fromPVC 97 | ? { 98 | dataSource: { 99 | apiGroup: 'v1', 100 | name: this.args.fromPVC, 101 | kind: 'PersistentVolumeClaim', 102 | }, 103 | } 104 | : { 105 | accessModes: ['ReadWriteOnce'], 106 | resources: { 107 | requests: { storage: this.args.storageSize }, 108 | }, 109 | storageClassName: this.args.storageClassName, 110 | volumeMode: 'Filesystem', 111 | }, 112 | }, 113 | resources: { 114 | requests: { cpu: '100m', memory: '128Mi' }, 115 | limits: { memory: '1Gi' }, 116 | }, 117 | }, 118 | }, 119 | { parent: this, dependsOn: [this.secret] }, 120 | ); 121 | return cluster; 122 | } 123 | 124 | getConfig(): DatabaseConfig { 125 | return { 126 | hostname: `${this.clusterName}-rw.${this.args.metadata.namespace}`, 127 | database: this.appName, 128 | username: this.dbUser, 129 | password: this.dbPassword, 130 | port: 5432, 131 | }; 132 | } 133 | 134 | private createPassword(username: string) { 135 | return new random.RandomPassword( 136 | `${this.clusterName}-${username}-password`, 137 | { length: 32, special: false }, 138 | { parent: this }, 139 | ).result; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /components/bitcoin/BITCOIN.md: -------------------------------------------------------------------------------- 1 | # Bitcoin module 2 | 3 | Components related to Bitcoin network nodes and related services. 4 | 5 | Recommended setup/tldr: 6 | 7 | ```sh 8 | # Create 1TB volume in Longhorn called "bitcoin" 9 | 10 | # Start node using the volume 11 | pulumi config set bitcoin-knots:enabled true 12 | pulumi config set bitcoin-knots:version 28.1 13 | pulumi config set bitcoin-knots:fromVolume bitcoin 14 | 15 | pulumi up 16 | ``` 17 | 18 | Initial blockchain synchronization takes a long time as about 700GB need to be downloaded. 19 | You can check the status with: 20 | 21 | ```sh 22 | # install bitcoin-cli locally if needed 23 | brew install bitcoin 24 | 25 | # Wrapper that will add RPC authentication 26 | ./scripts/bitcoin-cli.sh -getinfo 27 | ``` 28 | 29 | ## Bitcoin Knots 30 | 31 | | | | 32 | | ------------ | --------------------------------------------------------------------------------------------------- | 33 | | Homepage | https://bitcoinknots.org/ | 34 | | Docker image | https://hub.docker.com/r/btcpayserver/bitcoinknots | 35 | | Dockerfile | https://github.com/btcpayserver/dockerfile-deps/blob/master/BitcoinKnots/28.1/linuxamd64.Dockerfile | 36 | 37 | Bitcoin Knots is a conservative fork of Bitcoin Core with a focus on stability and conservative improvements. The node uses persistent volume storage mounted at `/data`. 38 | 39 | ```sh 40 | # Required configuration 41 | pulumi config set bitcoin-knots:enabled true 42 | 43 | # Lock version (recommended) 44 | pulumi config set bitcoin-knots:version 28.1 45 | 46 | # Optional configuration 47 | pulumi config set bitcoin-knots:prune 1000 # Prune mode (MB), 0 for full node with txindex 48 | pulumi config set bitcoin-knots:extraArgs "--maxconnections=25" # Additional bitcoind args 49 | 50 | pulumi up 51 | ``` 52 | 53 | You can disable the app but keep blockchain data with: 54 | 55 | ```sh 56 | pulumi config set bitcoin-knots:storageOnly true 57 | pulumi up 58 | ``` 59 | 60 | ## Bitcoin Core 61 | 62 | | | | 63 | | ------------ | ---------------------------------------------------------------------------------------------- | 64 | | Homepage | https://bitcoincore.org/ | 65 | | Docker image | https://hub.docker.com/r/btcpayserver/bitcoin | 66 | | Dockerfile | https://github.com/btcpayserver/dockerfile-deps/blob/master/Bitcoin/29.0/linuxamd64.Dockerfile | 67 | 68 | Bitcoin Core is the reference implementation of the Bitcoin protocol. The node uses persistent volume storage mounted at `/data`. 69 | 70 | ```sh 71 | # Required configuration 72 | pulumi config set bitcoin-core:enabled true 73 | 74 | # Lock version (recommended) 75 | pulumi config set bitcoin-core:version 29.0 76 | 77 | # Optional configuration 78 | pulumi config set bitcoin-core:prune 1000 # Prune mode (MB), 0 for full node with txindex 79 | pulumi config set bitcoin-core:extraArgs "-maxuploadtarget=500" # Additional bitcoind args 80 | 81 | pulumi up 82 | ``` 83 | 84 | You can disable the app but keep blockchain data with: 85 | 86 | ```sh 87 | pulumi config set bitcoin-core:storageOnly true 88 | pulumi up 89 | ``` 90 | 91 | ## Electrs 92 | 93 | | | | 94 | | ------------ | ------------------------------------------------------------------ | 95 | | Homepage | https://github.com/romanz/electrs | 96 | | Docker image | https://hub.docker.com/r/getumbrel/electrs | 97 | | Dockerfile | https://github.com/getumbrel/docker-electrs/blob/master/Dockerfile | 98 | 99 | Electrs is an implementation of the Electrum Server, which provides efficient querying of blockchain data and is used by wallet software to interact with the blockchain. It requires a full Bitcoin node (Core or Knots) to operate. Electrs uses persistent volume storage mounted at `/data`. 100 | 101 | ```sh 102 | # Required configuration 103 | pulumi config set electrs:enabled true 104 | 105 | # Optional configuration 106 | pulumi config set electrs:version v0.10.9 107 | 108 | pulumi up 109 | ``` 110 | 111 | Once indexing finishes, use `electrs:50001` to connect your wallets. More info at [Electrs wallet guide](../../docs/electrs-wallet.md) 112 | 113 | ## Mempool 114 | 115 | | | | 116 | | --------------- | ---------------------------------------------- | 117 | | Homepage | https://mempool.space/ | 118 | | Source code | https://github.com/mempool/mempool/tree/master | 119 | | Docker backend | https://hub.docker.com/r/mempool/backend | 120 | | Docker frontend | https://hub.docker.com/r/mempool/frontend | 121 | 122 | Mempool provides a visualization of the Bitcoin blockchain and acts as a block explorer. This allows you to inspect transactions and your addresses privately. 123 | 124 | ```sh 125 | # Make sure MariaDB-operator is installed 126 | pulumi config set mariadb-operator:enabled true 127 | pulumi up 128 | 129 | # Set the required configuration 130 | pulumi config set mempool:enabled true 131 | 132 | # Optional configuration 133 | pulumi config set mempool:version v3.2.1 # lock version 134 | pulumi config set mempool:hostname explorer # override hostname 135 | 136 | pulumi up 137 | ``` 138 | 139 | This will deploy Mempool frontend and backend connected to your Bitcoin node and Electrs server. 140 | 141 | You can access the frontend at https://mempool/ 142 | -------------------------------------------------------------------------------- /components/system/tailscale/tailscale-operator.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import { ClusterRole } from '@pulumi/kubernetes/rbac/v1'; 3 | import * as pulumi from '@pulumi/pulumi'; 4 | import { Application } from '../../application'; 5 | import { GrafanaDashboard } from '../../grafana-dashboard'; 6 | import dashboardJson from './tailscale-dashboard.json'; 7 | import { rootConfig } from '../../root-config'; 8 | 9 | interface TailscaleOperatorArgs { 10 | namespace?: string; 11 | } 12 | 13 | export class TailscaleOperator extends pulumi.ComponentResource { 14 | private readonly app: Application; 15 | 16 | constructor( 17 | private name: string, 18 | args: TailscaleOperatorArgs = {}, 19 | opts?: pulumi.ResourceOptions, 20 | ) { 21 | super('orangelab:system:TailscaleOperator', name, args, opts); 22 | 23 | const config = new pulumi.Config(name); 24 | const version = config.get('version'); 25 | const hostname = config.require('hostname'); 26 | const oauthClientId = config.requireSecret('oauthClientId'); 27 | const oauthClientSecret = config.requireSecret('oauthClientSecret'); 28 | 29 | this.app = new Application(this, name, { 30 | namespace: args.namespace, 31 | }).addDefaultLimits({ 32 | request: { cpu: '10m', memory: '100Mi' }, 33 | limit: { memory: '300Mi' }, 34 | }); 35 | 36 | const userRole = this.createUserRole(name); 37 | this.createUserRoleBinding(userRole, 'orangelab:users'); 38 | 39 | let proxyClass; 40 | if (rootConfig.enableMonitoring()) { 41 | proxyClass = new kubernetes.apiextensions.CustomResource( 42 | 'proxyClass', 43 | { 44 | apiVersion: 'tailscale.com/v1alpha1', 45 | kind: 'ProxyClass', 46 | metadata: { 47 | name: 'tailscale-proxyclass', 48 | namespace: this.app.namespace, 49 | }, 50 | spec: { 51 | metrics: { 52 | enable: true, 53 | serviceMonitor: { 54 | enable: true, 55 | }, 56 | }, 57 | }, 58 | }, 59 | { parent: this }, 60 | ); 61 | new GrafanaDashboard(name, this, { configJson: dashboardJson }); 62 | } 63 | 64 | new kubernetes.helm.v3.Release( 65 | name, 66 | { 67 | chart: 'tailscale-operator', 68 | namespace: this.app.namespace, 69 | version, 70 | repositoryOpts: { repo: 'https://pkgs.tailscale.com/helmcharts' }, 71 | values: { 72 | oauth: { clientId: oauthClientId, clientSecret: oauthClientSecret }, 73 | apiServerProxyConfig: { mode: 'true' }, 74 | operatorConfig: { 75 | hostname, 76 | logging: 'debug', // info, debug, dev 77 | }, 78 | proxyConfig: rootConfig.enableMonitoring() 79 | ? { defaultProxyClass: proxyClass?.metadata.name } 80 | : undefined, 81 | }, 82 | }, 83 | { parent: this }, 84 | ); 85 | } 86 | 87 | private createUserRoleBinding(userRole: ClusterRole, groupName: string) { 88 | new kubernetes.rbac.v1.ClusterRoleBinding( 89 | `${this.name}-user-cluster-role-binding`, 90 | { 91 | metadata: { ...this.app.metadata.get(), name: 'orangelab-user' }, 92 | subjects: [ 93 | { 94 | kind: 'Group', 95 | name: groupName, 96 | }, 97 | ], 98 | roleRef: { 99 | apiGroup: 'rbac.authorization.k8s.io', 100 | kind: 'ClusterRole', 101 | name: userRole.metadata.name, 102 | }, 103 | }, 104 | { parent: this }, 105 | ); 106 | } 107 | 108 | private createUserRole(name: string) { 109 | return new kubernetes.rbac.v1.ClusterRole( 110 | `${name}-user-cluster-role`, 111 | { 112 | metadata: { 113 | ...this.app.metadata.get(), 114 | name: 'orangelab-user-cluster-role', 115 | }, 116 | rules: [ 117 | { 118 | apiGroups: [ 119 | '', 120 | 'admissionregistration.k8s.io', 121 | 'apiextensions', 122 | 'apps', 123 | 'autoscaling', 124 | 'batch', 125 | 'coordination.k8s.io', 126 | 'discovery.k8s.io', 127 | 'extensions', 128 | 'helm.cattle.io', 129 | 'metrics.k8s.io', 130 | 'networking.k8s.io', 131 | 'node.k8s.io', 132 | 'policy', 133 | 'rbac.authorization.k8s.io', 134 | 'scheduling.k8s.io', 135 | 'storage.k8s.io', 136 | ], 137 | resources: ['*'], 138 | verbs: ['get', 'list', 'watch'], 139 | }, 140 | ], 141 | }, 142 | { parent: this }, 143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OrangeLab 2 | 3 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QC-Labs/orange-lab) 4 | 5 | Private infrastructure for cloud natives. 6 | 7 | OrangeLab logo 8 | 9 | ## Core components 10 | 11 | - Pulumi (https://www.pulumi.com/) - configuration management, deployments and infrastructure as code 12 | - Tailscale (https://tailscale.com/) - end-to-end encrypted communication between nodes 13 | - K3s (https://k3s.io/) - lightweight Kubernetes cluster 14 | - Longhorn (https://longhorn.io/) - distributed storage 15 | 16 | ## Principles and goals 17 | 18 | - decentralized - uses your physical machines potentially spread out over geographical locations, minimise dependency on external services and cloud providers 19 | - private by default - uses Tailscale/WireGuard for end to end encrypted communication, making services public has to be explicitly defined 20 | - OSS - prefer open source components that can be run locally 21 | - automation - use Pulumi and Helm to automate most tasks and configuration 22 | - easy to use - no deep Kubernetes knowledge required, sensible defaults 23 | - offline mode - continue working (with some limitations) over local network when internet connection lost 24 | - lightweight - can be run on a single laptop using default configuration, focus on consumer hardware 25 | - scalable - distribute workloads across multiple machines as they become available, optional use of cloud instances for autoscaling 26 | - self-healing - in case of problems, the system should recover with no user intervention 27 | - immutable - no snowflakes, as long as there is at least one Longhorn replica available, components can be destroyed and easily recreated 28 | 29 | # Applications 30 | 31 | [System module](./components/system/SYSTEM.md): 32 | 33 | - `amd-gpu-operator` - AMD GPU support 34 | - `cert-manager` - certificate management 35 | - `longhorn` - replicated storage 36 | - `minio` - S3-compatible storage (used as Longhorn backup target) 37 | - `nfd` - Node Feature Discovery (GPU autodetection) 38 | - `nvidia-gpu-operator` - NVidia GPU support 39 | - `tailscale-operator` - ingress support with Tailscale authentication 40 | 41 | [Data module](./components/data/DATA.md): 42 | 43 | - `cloudnative-pg` - PostgreSQL operator 44 | - `mariadb-operator` - MariaDB operator 45 | 46 | [Monitoring module](./components/monitoring/MONITORING.md): 47 | 48 | - `beszel` - Beszel lightweight monitoring 49 | - `prometheus` - Prometheus/Grafana monitoring 50 | 51 | [IoT module](./components/iot/IOT.md): 52 | 53 | - `home-assistant` - sensor and home automation platform 54 | 55 | [AI module](./components/ai/AI.md): 56 | 57 | - `automatic1111` - Automatic1111 Stable Diffusion WebUI 58 | - `kubeai` - Ollama and vLLM models over OpenAI-compatible API 59 | - `invokeai` - generative AI plaform, community edition 60 | - `n8n` - AI workflow automation 61 | - `ollama` - local large language models 62 | - `open-webui` - Open WebUI frontend 63 | - `sdnext` - SD.Next Stable Diffusion WebUI 64 | 65 | [Bitcoin module](./components/bitcoin/BITCOIN.md): 66 | 67 | - `bitcoin-core` - Bitcoin Core node 68 | - `bitcoin-knots` - Bitcoin Knots node 69 | - `electrs` - Electrs (Electrum) server implementation 70 | - `mempool` - Blockchain explorer 71 | 72 | [Office module](./components/office/OFFICE.md): 73 | 74 | - `nextcloud` - File sharing and collaboration suite 75 | 76 | # Platforms and limitations 77 | 78 | Installation instructions assume your machines are running Bluefin (Developer edition, https://projectbluefin.io/) based on Fedora Silverblue unless otherwise noted. 79 | It should run on any modern Linux distribution with Linux kernel 6.11.6+, even including Raspberry Pi. 80 | 81 | Windows and MacOS support is limited, specifically they cannot be used as storage nodes. 82 | 83 | See [Disabling Longhorn Guide](./docs/longhorn-disable.md) with instructions on using `local-path-provisioner` instead of Longhorn. 84 | 85 | Both NVIDIA and AMD GPUs are supported. See [AMD GPU support](/docs/amd-gpu.md) for more information. 86 | 87 | # Installation 88 | 89 | - [Installation - Setup Guide](./docs/install.md) - Initial Pulumi and Tailscale setup 90 | - [Installation - SSH Configuration](./docs/install-ssh.md) (optional) - Configure SSH keys on nodes for easier access 91 | - [Installation - Node Configuration](./docs/install-nodes.md) - Configure nodes (firewall, suspend settings) 92 | - [Installation - K3s Cluster](./docs/install-k3s.md) - Install Kubernetes cluster and label nodes 93 | - [components/system/SYSTEM.md](./components/system/SYSTEM.md) - Deploy system components 94 | - [components/data/DATA.md](./components/data/DATA.md) - Deploy data components (databases) 95 | 96 | After system components have been deployed, you can add any of the optional [#Applications](#applications). Details in each module documentation. 97 | 98 | For general application configuration and deployment instructions, see [Configuration Guide](./docs/configuration.md). 99 | 100 | # Documentation 101 | 102 | - [Ask Devin/DeepWiki](https://deepwiki.com/QC-Labs/orange-lab) - AI generated documentation and good place to ask questions 103 | - [Configuration Guide](./docs/configuration.md) - Application configuration and deployment 104 | - [Upgrade Guide](./docs/upgrade.md) - Upgrading your OrangeLab installation 105 | - [Disabling Longhorn](./docs/longhorn-disable.md) - Running OrangeLab without distributed storage 106 | - [AMD GPU support](./docs/amd-gpu.md) - Using AMD GPUs with OrangeLab 107 | - [Electrs Wallet Guide](./docs/electrs-wallet.md) - Connecting Bitcoin wallets to your Electrs server 108 | - [Backup and Restore](./docs/backup.md) - Using Longhorn backups with S3 storage 109 | - [Troubleshooting](./docs/troubleshooting.md) - Common issues and solutions 110 | -------------------------------------------------------------------------------- /components/longhorn-volume.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import assert from 'node:assert'; 4 | import { rootConfig } from './root-config'; 5 | import { StorageType } from './types'; 6 | 7 | interface LonghornVolumeArgs { 8 | name: string; 9 | namespace: pulumi.Output | string; 10 | size: string; 11 | /** 12 | * Determine storage class based on workload type 13 | */ 14 | type?: StorageType; 15 | /** 16 | * Override storage class used 17 | */ 18 | storageClass?: string; 19 | /** 20 | * Name of currently detached Longhorn volume. 21 | * Use with volumes restored from backup. New volume won't be created. 22 | */ 23 | fromVolume?: string; 24 | /** 25 | * Enable automated backups for volume by adding it to "backup" group 26 | */ 27 | enableBackup?: boolean; 28 | /** 29 | * Labels to apply to the PVC 30 | */ 31 | labels?: Record; 32 | /** 33 | * Annotations to apply to the PVC 34 | */ 35 | annotations?: Record; 36 | /** 37 | * Volume node affinity 38 | */ 39 | affinity?: kubernetes.types.input.core.v1.VolumeNodeAffinity; 40 | } 41 | 42 | const staleReplicaTimeout = (48 * 60).toString(); 43 | 44 | export class LonghornVolume extends pulumi.ComponentResource { 45 | volumeClaimName: string; 46 | storageClassName: pulumi.Output; 47 | isDynamic: boolean; 48 | size: pulumi.Output; 49 | 50 | constructor( 51 | private name: string, 52 | private args: LonghornVolumeArgs, 53 | opts?: pulumi.ComponentResourceOptions, 54 | ) { 55 | super('orangelab:LonghornVolume', name, args, opts); 56 | assert( 57 | !(args.storageClass && args.fromVolume), 58 | 'Cannot specify fromVolume when using custom storageClass', 59 | ); 60 | 61 | this.volumeClaimName = args.name; 62 | this.isDynamic = !args.fromVolume; 63 | if (args.fromVolume) { 64 | this.storageClassName = this.attachVolume(args); 65 | } else { 66 | this.storageClassName = this.createVolume(args); 67 | } 68 | this.size = pulumi.output(args.size); 69 | } 70 | 71 | private createVolume(args: LonghornVolumeArgs) { 72 | assert(!args.fromVolume); 73 | const pvc = this.createPVC({ 74 | name: this.volumeClaimName, 75 | storageClassName: 76 | this.args.storageClass ?? rootConfig.getStorageClass(args.type), 77 | }); 78 | return pvc.spec.storageClassName; 79 | } 80 | 81 | private attachVolume(args: LonghornVolumeArgs) { 82 | assert(args.fromVolume && !args.storageClass); 83 | const existingVolume = this.createLonghornPV({ 84 | name: args.name, 85 | volumeHandle: args.fromVolume, 86 | }); 87 | const pvc = this.createPVC({ 88 | name: this.volumeClaimName, 89 | storageClassName: existingVolume.spec.storageClassName, 90 | volumeName: existingVolume.metadata.name, 91 | }); 92 | return pvc.spec.storageClassName; 93 | } 94 | 95 | private createLonghornPV({ 96 | name, 97 | volumeHandle, 98 | }: { 99 | name: string; 100 | volumeHandle: string; 101 | }) { 102 | return new kubernetes.core.v1.PersistentVolume( 103 | `${this.name}-pv`, 104 | { 105 | metadata: { 106 | name, 107 | namespace: this.args.namespace, 108 | }, 109 | spec: { 110 | nodeAffinity: this.args.affinity, 111 | accessModes: ['ReadWriteOnce'], 112 | storageClassName: `longhorn-${volumeHandle}`, 113 | capacity: { storage: this.args.size }, 114 | volumeMode: 'Filesystem', 115 | persistentVolumeReclaimPolicy: 'Retain', 116 | csi: { 117 | driver: 'driver.longhorn.io', 118 | fsType: 'ext4', 119 | volumeAttributes: { 120 | numberOfReplicas: '1', 121 | staleReplicaTimeout, 122 | }, 123 | volumeHandle, 124 | }, 125 | }, 126 | }, 127 | { parent: this }, 128 | ); 129 | } 130 | 131 | private createPVC({ 132 | name, 133 | volumeName, 134 | storageClassName, 135 | }: { 136 | name: string; 137 | volumeName?: pulumi.Output; 138 | storageClassName: pulumi.Output | string; 139 | }) { 140 | const labels = { ...(this.args.labels ?? {}) }; 141 | 142 | labels['recurring-job.longhorn.io/source'] = 'enabled'; 143 | labels['recurring-job-group.longhorn.io/default'] = 'enabled'; 144 | 145 | if (this.args.enableBackup) { 146 | labels['recurring-job-group.longhorn.io/backup'] = 'enabled'; 147 | } 148 | 149 | return new kubernetes.core.v1.PersistentVolumeClaim( 150 | `${this.name}-pvc`, 151 | { 152 | metadata: { 153 | name, 154 | namespace: this.args.namespace, 155 | labels, 156 | annotations: this.args.annotations, 157 | }, 158 | spec: { 159 | accessModes: ['ReadWriteOnce'], 160 | storageClassName, 161 | volumeName, 162 | resources: { requests: { storage: this.args.size } }, 163 | }, 164 | }, 165 | { parent: this }, 166 | ); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /components/system/nfd.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../application'; 4 | import { rootConfig } from '../root-config'; 5 | 6 | export class NodeFeatureDiscovery extends pulumi.ComponentResource { 7 | private readonly config: pulumi.Config; 8 | private readonly app: Application; 9 | 10 | constructor(private readonly name: string, opts?: pulumi.ResourceOptions) { 11 | super('orangelab:system:NFD', name, {}, opts); 12 | 13 | this.config = new pulumi.Config(name); 14 | this.app = new Application(this, name); 15 | 16 | this.createHelmChart(); 17 | 18 | if (this.config.getBoolean('gpu-autodetect')) { 19 | this.createAmdGpuRule(); 20 | this.createNvidiaGpuRule(); 21 | } 22 | } 23 | 24 | private createHelmChart(): kubernetes.helm.v3.Release { 25 | return new kubernetes.helm.v3.Release( 26 | this.name, 27 | { 28 | chart: 'node-feature-discovery', 29 | repositoryOpts: { 30 | repo: 'https://kubernetes-sigs.github.io/node-feature-discovery/charts', 31 | }, 32 | version: this.config.get('version'), 33 | namespace: this.app.namespace, 34 | values: { 35 | prometheus: { enable: rootConfig.enableMonitoring() }, 36 | worker: { 37 | // set as priviledged to allow access to /etc/kubernetes/node-feature-discovery/features.d/ 38 | securityContext: { 39 | allowPrivilegeEscalation: true, 40 | privileged: true, 41 | }, 42 | }, 43 | }, 44 | }, 45 | { parent: this }, 46 | ); 47 | } 48 | 49 | // Based on https://github.com/ROCm/gpu-operator/blob/main/helm-charts/templates/nfd-default-rule.yaml 50 | private createAmdGpuRule(): kubernetes.apiextensions.CustomResource { 51 | const vendorId = ['1002']; // AMD vendor ID 52 | const gpuClass = ['0300']; // Display/GPU class 53 | return new kubernetes.apiextensions.CustomResource( 54 | `${this.name}-rule-amd`, 55 | { 56 | apiVersion: 'nfd.k8s-sigs.io/v1alpha1', 57 | kind: 'NodeFeatureRule', 58 | metadata: { name: 'amd-gpu-label-nfd-rule' }, 59 | spec: { 60 | rules: [ 61 | { 62 | name: 'amd-gpu', 63 | annotations: { 64 | 'node.longhorn.io/default-node-tags': 65 | '["gpu", "gpu-amd"]', 66 | }, 67 | labels: { 68 | 'orangelab/gpu': 'true', 69 | 'orangelab/gpu-amd': 'true', 70 | }, 71 | matchAny: [ 72 | { 73 | matchFeatures: [ 74 | { 75 | feature: 'pci.device', 76 | matchExpressions: { 77 | vendor: { op: 'In', value: vendorId }, 78 | class: { op: 'In', value: gpuClass }, 79 | }, 80 | }, 81 | ], 82 | }, 83 | ], 84 | }, 85 | ], 86 | }, 87 | }, 88 | { parent: this }, 89 | ); 90 | } 91 | 92 | private createNvidiaGpuRule(): kubernetes.apiextensions.CustomResource { 93 | const vendorId = ['10de']; // NVIDIA vendor ID 94 | const gpuClass = ['0300', '0302']; // Display/GPU classes 95 | return new kubernetes.apiextensions.CustomResource( 96 | `${this.name}-rule-nvidia`, 97 | { 98 | apiVersion: 'nfd.k8s-sigs.io/v1alpha1', 99 | kind: 'NodeFeatureRule', 100 | metadata: { name: 'nvidia-gpu-label-nfd-rule' }, 101 | spec: { 102 | rules: [ 103 | { 104 | name: 'nvidia-gpu', 105 | annotations: { 106 | 'node.longhorn.io/default-node-tags': 107 | '["gpu","gpu-nvidia"]', 108 | }, 109 | labels: { 110 | 'orangelab/gpu': 'true', 111 | 'orangelab/gpu-nvidia': 'true', 112 | }, 113 | matchAny: [ 114 | { 115 | matchFeatures: [ 116 | { 117 | feature: 'pci.device', 118 | matchExpressions: { 119 | vendor: { op: 'In', value: vendorId }, 120 | class: { op: 'In', value: gpuClass }, 121 | }, 122 | }, 123 | ], 124 | }, 125 | ], 126 | }, 127 | ], 128 | }, 129 | }, 130 | { parent: this }, 131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /components/databases.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { MariaDbCluster } from './mariadb'; 3 | import { Metadata } from './metadata'; 4 | import { Nodes } from './nodes'; 5 | import { PostgresCluster } from './postgres'; 6 | import { rootConfig } from './root-config'; 7 | import { Storage } from './storage'; 8 | import { DatabaseConfig, InitContainerSpec, StorageType } from './types'; 9 | 10 | export class Databases { 11 | private readonly databases: Record< 12 | string, 13 | MariaDbCluster | PostgresCluster | undefined 14 | > = {}; 15 | private readonly storage: Storage; 16 | private readonly storageOnly: boolean; 17 | private readonly metadata: Metadata; 18 | private readonly config: pulumi.Config; 19 | private readonly nodes: Nodes; 20 | 21 | constructor( 22 | private appName: string, 23 | args: { 24 | config: pulumi.Config; 25 | metadata: Metadata; 26 | nodes: Nodes; 27 | storage: Storage; 28 | storageOnly?: boolean; 29 | }, 30 | private opts?: pulumi.ComponentResourceOptions, 31 | ) { 32 | this.storage = args.storage; 33 | this.storageOnly = args.storageOnly ?? false; 34 | this.metadata = args.metadata; 35 | this.nodes = args.nodes; 36 | this.config = args.config; 37 | } 38 | 39 | addMariaDB(name = 'db'): void { 40 | rootConfig.require(this.appName, 'mariadb-operator'); 41 | if (this.databases[name]) { 42 | throw new Error(`Database ${this.appName}-${name} already exists.`); 43 | } 44 | this.storage.addPersistentVolume({ 45 | name, 46 | overrideFullname: `storage-${this.appName}-${name}-0`, 47 | type: StorageType.Database, 48 | }); 49 | const enabledDefault = this.config.getBoolean(`storageOnly`) ? false : true; 50 | const db = new MariaDbCluster( 51 | this.appName, 52 | { 53 | name, 54 | metadata: this.metadata, 55 | storageSize: this.storage.getStorageSize(name), 56 | storageClassName: this.storage.getStorageClass(name), 57 | storageOnly: this.storageOnly, 58 | disableAuth: this.config.getBoolean(`${name}/disableAuth`), 59 | password: this.config.getSecret(`${name}/password`), 60 | rootPassword: this.config.getSecret(`${name}/rootPassword`), 61 | enabled: this.config.getBoolean(`${name}/enabled`) ?? enabledDefault, 62 | }, 63 | this.opts, 64 | ); 65 | this.databases[name] = db; 66 | } 67 | 68 | addPostgres(name = 'db'): void { 69 | rootConfig.require(this.appName, 'cloudnative-pg'); 70 | if (this.databases[name]) { 71 | throw new Error(`Database ${this.appName}-${name} already exists.`); 72 | } 73 | const existingVolume = this.config.get(`${name}/fromVolume`); 74 | if (existingVolume) { 75 | this.storage.addPersistentVolume({ 76 | name, 77 | overrideFullname: `${this.appName}-${name}-1`, 78 | type: StorageType.Database, 79 | labels: { 80 | 'cnpg.io/cluster': `${this.appName}-${name}`, 81 | 'cnpg.io/instanceName': `${this.appName}-${name}-1`, 82 | 'cnpg.io/instanceRole': 'primary', 83 | 'cnpg.io/pvcRole': 'PG_DATA', 84 | role: 'primary', 85 | }, 86 | annotations: { 87 | 'cnpg.io/nodeSerial': '1', 88 | 'cnpg.io/pvcStatus': 'ready', 89 | }, 90 | }); 91 | } 92 | const enabledDefault = this.config.getBoolean(`storageOnly`) ? false : true; 93 | const db = new PostgresCluster( 94 | this.appName, 95 | { 96 | name, 97 | metadata: this.metadata, 98 | nodes: this.nodes, 99 | storageSize: this.config.require(`${name}/storageSize`), 100 | storageClassName: existingVolume 101 | ? this.storage.getStorageClass(name) 102 | : rootConfig.storageClass.Database, 103 | password: this.config.getSecret(`${name}/password`), 104 | enabled: this.config.getBoolean(`${name}/enabled`) ?? enabledDefault, 105 | fromPVC: existingVolume ? this.storage.getClaimName(name) : undefined, 106 | instances: this.config.getNumber(`${name}/instances`), 107 | }, 108 | this.opts, 109 | ); 110 | this.databases[name] = db; 111 | } 112 | 113 | /** 114 | * Returns the config for MariaDB or PostgreSQL instance for this app. 115 | * Includes: hostname, database, username, password 116 | */ 117 | getConfig(name = 'db'): DatabaseConfig { 118 | const db = this.databases[name]; 119 | if (!db) throw new Error(`Database ${this.appName}-${name} not found.`); 120 | return db.getConfig(); 121 | } 122 | 123 | /** 124 | * Returns an initContainer spec to wait for database until it accepts connections. 125 | */ 126 | getWaitContainer(dbConfig?: DatabaseConfig): InitContainerSpec { 127 | if (!dbConfig) { 128 | throw new Error('Database config is required for wait container.'); 129 | } 130 | const hostPort = `${dbConfig.hostname} ${dbConfig.port.toString()}`; 131 | return { 132 | name: 'wait-for-db', 133 | image: 'busybox:latest', 134 | command: [ 135 | 'sh', 136 | '-c', 137 | `until nc -z -v -w30 ${hostPort}; do echo "Waiting for database...${hostPort}" && sleep 5; done`, 138 | ], 139 | volumeMounts: [], 140 | }; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /components/ai/kubeai.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../application'; 4 | import { GrafanaDashboard } from '../grafana-dashboard'; 5 | import { rootConfig } from '../root-config'; 6 | import dashboardJson from './kubeai-dashboard-vllm.json'; 7 | 8 | export class KubeAi extends pulumi.ComponentResource { 9 | public readonly endpointUrl: string | undefined; 10 | public readonly serviceUrl: string | undefined; 11 | 12 | private readonly app: Application; 13 | private readonly config: pulumi.Config; 14 | 15 | constructor(private name: string, opts?: pulumi.ResourceOptions) { 16 | super('orangelab:ai:KubeAi', name, {}, opts); 17 | 18 | this.config = new pulumi.Config(name); 19 | const version = this.config.get('version'); 20 | const hostname = this.config.require('hostname'); 21 | const huggingfaceToken = this.config.getSecret('huggingfaceToken'); 22 | const models = this.config.get('models')?.split(',') ?? []; 23 | 24 | this.app = new Application(this, name, { gpu: true }); 25 | const ingresInfo = this.app.network.getIngressInfo(); 26 | const kubeAi = new kubernetes.helm.v3.Release( 27 | name, 28 | { 29 | chart: 'kubeai', 30 | namespace: this.app.namespace, 31 | version, 32 | repositoryOpts: { repo: 'https://www.kubeai.org' }, 33 | values: { 34 | affinity: this.app.nodes.getAffinity(), 35 | ingress: { 36 | enabled: true, 37 | className: ingresInfo.className, 38 | rules: [ 39 | { 40 | host: ingresInfo.hostname, 41 | paths: [ 42 | { path: '/', pathType: 'ImplementationSpecific' }, 43 | ], 44 | }, 45 | ], 46 | tls: [{ hosts: [ingresInfo.hostname] }], 47 | }, 48 | metrics: rootConfig.enableMonitoring() 49 | ? { 50 | prometheusOperator: { 51 | vLLMPodMonitor: { 52 | enabled: true, 53 | labels: {}, 54 | }, 55 | }, 56 | } 57 | : undefined, 58 | modelServers: { 59 | OLlama: { 60 | images: { 61 | 'amd-gpu': 'ollama/ollama:rocm', 62 | }, 63 | }, 64 | }, 65 | modelAutoscaling: { timeWindow: '30m' }, 66 | modelServerPods: { 67 | // required for NVidia detection 68 | securityContext: { 69 | privileged: true, 70 | allowPrivilegeEscalation: true, 71 | }, 72 | }, 73 | ['open-webui']: { enabled: false }, 74 | resourceProfiles: { 75 | nvidia: { 76 | runtimeClassName: 'nvidia', 77 | nodeSelector: { 'orangelab/gpu-nvidia': 'true' }, 78 | }, 79 | amd: { 80 | imageName: 'amd-gpu', 81 | nodeSelector: { 'orangelab/gpu-amd': 'true' }, 82 | limits: { 'amd.com/gpu': 1 }, 83 | }, 84 | }, 85 | secrets: { huggingface: { token: huggingfaceToken } }, 86 | }, 87 | }, 88 | { parent: this }, 89 | ); 90 | 91 | new kubernetes.helm.v3.Release( 92 | `${name}-models`, 93 | { 94 | chart: 'models', 95 | namespace: this.app.namespace, 96 | version, 97 | repositoryOpts: { repo: 'https://www.kubeai.org' }, 98 | values: { catalog: this.createModelCatalog(models) }, 99 | }, 100 | { parent: this, dependsOn: [kubeAi] }, 101 | ); 102 | 103 | if (rootConfig.enableMonitoring()) { 104 | new GrafanaDashboard(name, this, { configJson: dashboardJson }); 105 | } 106 | 107 | this.endpointUrl = ingresInfo.url; 108 | this.serviceUrl = `http://${hostname}.kubeai/openai/v1`; 109 | } 110 | 111 | private createModelCatalog(models: string[]): Record { 112 | const gfxVersion = this.config.get('HSA_OVERRIDE_GFX_VERSION'); 113 | const amdTargets = this.config.get('HCC_AMDGPU_TARGETS'); 114 | const modelProfiles = new Map(); 115 | modelProfiles.set('amd', { 116 | enabled: true, 117 | resourceProfile: 'amd:1', 118 | minReplicas: 0, 119 | env: { 120 | HSA_OVERRIDE_GFX_VERSION: gfxVersion, 121 | ...(amdTargets ? { HCC_AMDGPU_TARGETS: amdTargets } : {}), 122 | }, 123 | }); 124 | modelProfiles.set('nvidia', { 125 | enabled: true, 126 | resourceProfile: 'nvidia:1', 127 | minReplicas: 0, 128 | }); 129 | const modelList = models.map(model => { 130 | const [modelName, profile, minReplicas = 0] = model.split('/'); 131 | const info = { ...modelProfiles.get(profile || 'nvidia'), minReplicas }; 132 | return { [modelName]: info }; 133 | }); 134 | const catalog = Object.assign({}, ...modelList) as Record; 135 | return catalog; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /components/ai/ollama.ts: -------------------------------------------------------------------------------- 1 | import * as kubernetes from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { Application } from '../application'; 4 | import { StorageType } from '../types'; 5 | import { IngressInfo } from '../network'; 6 | 7 | export class Ollama extends pulumi.ComponentResource { 8 | public readonly endpointUrl?: string; 9 | public readonly serviceUrl?: string; 10 | 11 | private readonly app: Application; 12 | private readonly config: pulumi.Config; 13 | 14 | constructor( 15 | private name: string, 16 | opts?: pulumi.ResourceOptions, 17 | ) { 18 | super('orangelab:ai:Ollama', name, {}, opts); 19 | 20 | this.config = new pulumi.Config(name); 21 | const hostname = this.config.require('hostname'); 22 | 23 | this.app = new Application(this, name, { gpu: true }) 24 | .addDefaultLimits({ request: { cpu: '5m', memory: '3Gi' } }) 25 | .addStorage({ type: StorageType.GPU }); 26 | 27 | if (this.app.storageOnly) return; 28 | 29 | const ingresInfo = this.app.network.getIngressInfo(); 30 | this.createHelmRelease(ingresInfo); 31 | 32 | this.endpointUrl = ingresInfo.url; 33 | this.serviceUrl = `http://${hostname}.ollama:11434`; 34 | } 35 | 36 | private createHelmRelease(ingresInfo: IngressInfo) { 37 | const amdGpu = this.config.requireBoolean('amd-gpu'); 38 | const gfxVersion = this.config.get('HSA_OVERRIDE_GFX_VERSION'); 39 | const amdTargets = this.config.get('HCC_AMDGPU_TARGETS'); 40 | const debug = this.config.getBoolean('debug') ?? false; 41 | const extraEnv = [ 42 | { name: 'OLLAMA_DEBUG', value: debug ? 'true' : 'false' }, 43 | { 44 | name: 'OLLAMA_KEEP_ALIVE', 45 | value: this.config.require('OLLAMA_KEEP_ALIVE'), 46 | }, 47 | { name: 'OLLAMA_LOAD_TIMEOUT', value: '5m' }, 48 | { 49 | name: 'OLLAMA_CONTEXT_LENGTH', 50 | value: this.config.require('OLLAMA_CONTEXT_LENGTH'), 51 | }, 52 | ]; 53 | if (amdGpu && gfxVersion) { 54 | extraEnv.push({ 55 | name: 'HSA_OVERRIDE_GFX_VERSION', 56 | value: gfxVersion, 57 | }); 58 | } 59 | if (amdGpu && amdTargets) { 60 | extraEnv.push({ 61 | name: 'HCC_AMDGPU_TARGETS', 62 | value: amdTargets, 63 | }); 64 | } 65 | let imageTag = this.config.get('appVersion'); 66 | if (amdGpu && imageTag) imageTag = imageTag.concat('-rocm'); 67 | new kubernetes.helm.v3.Release( 68 | this.name, 69 | { 70 | chart: 'ollama', 71 | namespace: this.app.namespace, 72 | version: this.config.get('version'), 73 | repositoryOpts: { repo: 'https://otwld.github.io/ollama-helm/' }, 74 | values: { 75 | affinity: this.app.nodes.getAffinity(), 76 | extraEnv, 77 | fullnameOverride: 'ollama', 78 | image: { tag: imageTag }, 79 | ingress: { 80 | enabled: true, 81 | className: ingresInfo.className, 82 | hosts: [ 83 | { 84 | host: ingresInfo.hostname, 85 | paths: [{ path: '/', pathType: 'Prefix' }], 86 | }, 87 | ], 88 | tls: [ 89 | { 90 | hosts: [ingresInfo.hostname], 91 | secretName: ingresInfo.tlsSecretName, 92 | }, 93 | ], 94 | annotations: ingresInfo.annotations, 95 | }, 96 | ollama: { 97 | gpu: { 98 | // AMD does not support time slicing so ignore resource requests and use device volumes instead 99 | // appVersion has to be set to determine imageTag (Helm chart limitation) 100 | enabled: !imageTag?.includes('-rocm'), 101 | type: amdGpu ? 'amd' : 'nvidia', 102 | number: 1, 103 | }, 104 | models: { 105 | run: this.config.get('models')?.split(',') ?? [], 106 | }, 107 | }, 108 | ...(amdGpu 109 | ? { 110 | volumes: [ 111 | { 112 | name: 'kfd', 113 | hostPath: { path: '/dev/kfd' }, 114 | }, 115 | { 116 | name: 'dri', 117 | hostPath: { path: '/dev/dri' }, 118 | }, 119 | ], 120 | volumeMounts: [ 121 | { name: 'kfd', mountPath: '/dev/kfd' }, 122 | { name: 'dri', mountPath: '/dev/dri' }, 123 | ], 124 | } 125 | : {}), 126 | persistentVolume: { 127 | enabled: true, 128 | existingClaim: this.app.storage?.getClaimName(), 129 | }, 130 | replicaCount: 1, 131 | runtimeClassName: amdGpu ? undefined : 'nvidia', 132 | securityContext: { privileged: true }, 133 | }, 134 | }, 135 | { parent: this, dependsOn: this.app.storage }, 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /components/office/OFFICE.md: -------------------------------------------------------------------------------- 1 | # Office module 2 | 3 | Components related to office productivity and collaboration. 4 | 5 | ## Recommended setup/tldr 6 | 7 | ```sh 8 | pulumi config set nextcloud:enabled true 9 | 10 | # When restoring from backup 11 | pulumi config set nextcloud:fromVolume: nextcloud 12 | pulumi config set nextcloud:db/fromVolume: nextcloud-db 13 | pulumi config set --secret nextcloud:db/password 14 | pulumi config set --secret nextcloud:db/rootPassword 15 | 16 | pulumi up 17 | ``` 18 | 19 | ## Nextcloud 20 | 21 | | | | 22 | | ------------- | ------------------------------------------------------------------------ | 23 | | Homepage | https://nextcloud.com/ | 24 | | Helm chart | https://github.com/nextcloud/helm | 25 | | Helm values | https://github.com/nextcloud/helm/blob/main/charts/nextcloud/values.yaml | 26 | | Endpoints | `https://nextcloud..ts.net/` | 27 | | Documentation | https://docs.nextcloud.com/ | 28 | 29 | Nextcloud is a self-hosted productivity platform that lets you store files, collaborate, and run office apps in your own private cloud. 30 | 31 | It can also be used to store your contacts, bookmarks, calendar etc. and has a lot of additional modules which can be installed through the deployed website. 32 | 33 | ### Basic configuration 34 | 35 | ```sh 36 | # Enable Nextcloud 37 | pulumi config set nextcloud:enabled true 38 | # Set hostname (optional, default: nextcloud) 39 | pulumi config set nextcloud:hostname nextcloud 40 | # Set storage size (default: 20Gi) 41 | pulumi config set nextcloud:storageSize 50Gi 42 | # Set storage size for PostgreSQL database (default: 5Gi) 43 | pulumi config set nextcloud:storageSize 10Gi 44 | # (Optional) Set admin password, auto-generated by default 45 | pulumi config set nextcloud:adminPassword abcd1234 --secret 46 | pulumi up 47 | ``` 48 | 49 | Log in as `admin` and create a new user at: 50 | 51 | `https://nextcloud./settings/admin` 52 | 53 | ### Storage 54 | 55 | Nextcloud uses a persistent volume for file storage. You can expand the volume as needed. To keep data but disable the app: 56 | 57 | ```sh 58 | pulumi config set nextcloud:storageOnly true 59 | 60 | # Keep the database engine running (use this for DB maintanance) 61 | pulumi config set nextcloud:db/enabled true 62 | pulumi up 63 | ``` 64 | 65 | ### Access 66 | 67 | After deployment, access Nextcloud at: 68 | 69 | ```sh 70 | https://nextcloud./ 71 | ``` 72 | 73 | Login with the admin user. The password can be retrieved with: 74 | 75 | ```sh 76 | pulumi stack output --show-secrets --json | jq '.office.nextcloudUsers.admin' -r 77 | ``` 78 | 79 | ### Database 80 | 81 | Nextcloud uses a MariaDB database to store its data, which is managed by the MariaDB Operator. 82 | 83 | When restoring from a backup, the auto-generated passwords will not match the ones stored in the database and `config/config.php`. To prevent this mismatch, you should set the passwords explicitly after the initial install. This ensures that the application can connect to the database with the correct credentials after the restore. 84 | 85 | ```sh 86 | # Set password for the nextcloud user 87 | pulumi config set nextcloud:db/password YourNextcloudDbPassword --secret 88 | # Set password for the mariadb root user 89 | pulumi config set nextcloud:db/rootPassword YourMariaDbRootPassword --secret 90 | pulumi up 91 | ``` 92 | 93 | To get the current passwords, you can use the following commands: 94 | 95 | ```sh 96 | # Get the nextcloud user password from Pulumi stack output 97 | pulumi stack output --show-secrets --json | jq '.office.nextcloud.db.password' -r 98 | 99 | # Get the root user password from the Kubernetes secret 100 | kubectl get secret nextcloud-db-secret -n nextcloud -o jsonpath='{.data.rootPassword}' | base64 --decode 101 | ``` 102 | 103 | ### Upgrade 104 | 105 | After updating Nextcloud it could enter _maintanace mode_. 106 | 107 | In that case run the upgrade inside the container: 108 | 109 | ```sh 110 | # enter the container 111 | ./scripts/exec.sh nextcloud 112 | 113 | ./occ upgrade 114 | 115 | # Optional 116 | ./occ maintenance:mode --off 117 | ``` 118 | 119 | ### Resetting admin password 120 | 121 | If you lose the admin password, you can reset it. This is also helpful after restoring from backup and new password has been generated. 122 | 123 | 1. Get a shell inside the Nextcloud container using the `exec.sh` script. 124 | 125 | ```sh 126 | ./scripts/exec.sh nextcloud 127 | ``` 128 | 129 | 2. Once inside the container's shell, run the `occ` command to reset the password for the `admin` user. Replace `YourNewPassword` with a strong password. 130 | 131 | ```sh 132 | ./occ user:resetpassword admin YourNewPassword 133 | ``` 134 | 135 | 3. Exit the container shell. 136 | 137 | 4. (Recommended) Update the password in Pulumi config so it matches the one stored by Nextcloud. This helps restoring backups so the password is not auto-generated again. 138 | 139 | ```sh 140 | pulumi config set nextcloud:adminPassword YourNewPassword --secret 141 | ``` 142 | 143 | ### Pika Backup 144 | 145 | Pika Backup is a simple backup utility that can use Nextcloud as a remote backup destination via WebDAV. This allows you to store your local backups in your private cloud. 146 | 147 | To configure Pika Backup with Nextcloud as a remote location: 148 | 149 | 1. Install Pika Backup on your client machine. 150 | 2. Select "Remote Location" as the backup destination. 151 | 3. Use the following URL format: 152 | ``` 153 | dav://@nextcloud./remote.php/webdav/ 154 | ``` 155 | 4. Enter your Nextcloud username and password when prompted. 156 | 157 | This will allow Pika Backup to access your Nextcloud storage for automated backups. 158 | 159 | --- 160 | 161 | For more advanced configuration, see the [Nextcloud Helm chart documentation](https://github.com/nextcloud/helm) and [Nextcloud admin docs](https://docs.nextcloud.com/server/latest/). 162 | -------------------------------------------------------------------------------- /components/office/nextcloud.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@pulumi/kubernetes'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import * as random from '@pulumi/random'; 4 | import { Application } from '../application'; 5 | import { IngressInfo } from '../network'; 6 | import { rootConfig } from '../root-config'; 7 | import { DatabaseConfig } from '../types'; 8 | 9 | export class Nextcloud extends pulumi.ComponentResource { 10 | public readonly serviceUrl?: string; 11 | public readonly app: Application; 12 | public readonly users: Record> = {}; 13 | public readonly dbConfig?: DatabaseConfig; 14 | 15 | private readonly config: pulumi.Config; 16 | 17 | constructor( 18 | private appName: string, 19 | opts?: pulumi.ComponentResourceOptions, 20 | ) { 21 | super('orangelab:office:Nextcloud', appName, {}, opts); 22 | 23 | this.config = new pulumi.Config(appName); 24 | 25 | this.app = new Application(this, appName).addStorage().addMariaDB(); 26 | if (this.app.storageOnly) return; 27 | 28 | this.dbConfig = this.app.databases?.getConfig(); 29 | if (!this.dbConfig) throw new Error('Database not found'); 30 | const adminPassword = 31 | this.config.getSecret('adminPassword') ?? this.createPassword('admin'); 32 | const adminSecret = this.createAdminSecret(adminPassword); 33 | const ingressInfo = this.app.network.getIngressInfo(); 34 | this.users = { admin: adminPassword }; 35 | this.createHelmChart({ ingressInfo, adminSecret, dbConfig: this.dbConfig }); 36 | this.serviceUrl = ingressInfo.url; 37 | } 38 | 39 | private createHelmChart(args: { 40 | ingressInfo: IngressInfo; 41 | adminSecret: k8s.core.v1.Secret; 42 | dbConfig: DatabaseConfig; 43 | }) { 44 | const waitForDb = this.app.databases?.getWaitContainer(args.dbConfig); 45 | const debug = this.config.getBoolean('debug') ?? false; 46 | return new k8s.helm.v3.Release( 47 | this.appName, 48 | { 49 | chart: 'nextcloud', 50 | version: this.config.get('version'), 51 | repositoryOpts: { repo: 'https://nextcloud.github.io/helm/' }, 52 | namespace: this.app.namespace, 53 | values: { 54 | affinity: this.app.nodes.getAffinity(), 55 | externalDatabase: { 56 | enabled: true, 57 | type: 'mysql', 58 | host: args.dbConfig.hostname, 59 | user: args.dbConfig.username, 60 | password: args.dbConfig.password, 61 | database: args.dbConfig.database, 62 | }, 63 | ingress: { 64 | enabled: true, 65 | className: args.ingressInfo.className, 66 | hosts: [ 67 | { 68 | host: args.ingressInfo.hostname, 69 | paths: [{ path: '/', pathType: 'Prefix' }], 70 | }, 71 | ], 72 | tls: [ 73 | { 74 | hosts: [args.ingressInfo.hostname], 75 | secretName: args.ingressInfo.tlsSecretName, 76 | }, 77 | ], 78 | annotations: args.ingressInfo.annotations, 79 | }, 80 | internalDatabase: { enabled: false }, 81 | livenessProbe: { enabled: true }, 82 | metrics: { enabled: true }, 83 | nextcloud: { 84 | configs: { 85 | 'disable-skeleton.config.php': ` '', 88 | );`, 89 | ...(debug 90 | ? { 91 | 'logging.config.php': ` 'errorlog', 94 | );`, 95 | } 96 | : {}), 97 | }, 98 | extraInitContainers: [waitForDb], 99 | host: args.ingressInfo.hostname, 100 | existingSecret: { 101 | enabled: true, 102 | secretName: args.adminSecret.metadata.name, 103 | usernameKey: 'username', 104 | passwordKey: 'password', 105 | }, 106 | trustedDomains: [ 107 | rootConfig.tailnetDomain, 108 | rootConfig.customDomain, 109 | args.ingressInfo.hostname, 110 | ], 111 | }, 112 | persistence: { 113 | enabled: true, 114 | existingClaim: this.app.storage?.getClaimName(), 115 | }, 116 | phpClientHttpsFix: { 117 | enabled: args.ingressInfo.tls, 118 | protocol: args.ingressInfo.tls ? 'https' : 'http', 119 | }, 120 | readinessProbe: { enabled: true }, 121 | replicaCount: 1, 122 | startupProbe: { enabled: true }, 123 | }, 124 | }, 125 | { parent: this }, 126 | ); 127 | } 128 | 129 | private createAdminSecret(password: pulumi.Input) { 130 | return new k8s.core.v1.Secret( 131 | `${this.appName}-admin-secret`, 132 | { 133 | metadata: { namespace: this.app.namespace }, 134 | stringData: { 135 | username: 'admin', 136 | password, 137 | }, 138 | }, 139 | { parent: this }, 140 | ); 141 | } 142 | 143 | private createPassword(username: string) { 144 | return new random.RandomPassword( 145 | `${this.appName}-${username}-password`, 146 | { length: 32, special: false }, 147 | { parent: this }, 148 | ).result; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /components/root-config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { StorageType } from './types'; 4 | 5 | export const moduleDependencies: Record = { 6 | ai: ['automatic1111', 'invokeai', 'kubeai', 'n8n', 'ollama', 'open-webui', 'sdnext'], 7 | bitcoin: ['bitcoin-core', 'bitcoin-knots', 'electrs', 'mempool'], 8 | data: ['cloudnative-pg', 'mariadb-operator'], 9 | iot: ['home-assistant'], 10 | monitoring: ['beszel', 'prometheus'], 11 | office: ['nextcloud'], 12 | system: [ 13 | 'amd-gpu-operator', 14 | 'cert-manager', 15 | 'debug', 16 | 'longhorn', 17 | 'minio', 18 | 'nfd', 19 | 'nvidia-gpu-operator', 20 | 'tailscale', 21 | 'tailscale-operator', 22 | ], 23 | }; 24 | 25 | class RootConfig { 26 | constructor() { 27 | this.processDeprecated(); 28 | } 29 | 30 | public longhorn = { 31 | replicaCount: parseInt(this.requireAppConfig('longhorn', 'replicaCount')), 32 | replicaAutoBalance: this.requireAppConfig('longhorn', 'replicaAutoBalance'), 33 | }; 34 | public storageClass = { 35 | Default: 'longhorn', 36 | GPU: 'longhorn-gpu', 37 | Large: 'longhorn-large', 38 | Database: 'longhorn', 39 | }; 40 | public customDomain = this.getAppConfig('orangelab', 'customDomain'); 41 | public tailnetDomain = this.requireAppConfig('tailscale', 'tailnet'); 42 | public certManager = { 43 | clusterIssuer: this.requireAppConfig('cert-manager', 'clusterIssuer'), 44 | }; 45 | public clusterCidr = this.requireAppConfig('k3s', 'clusterCidr'); 46 | public serviceCidr = this.requireAppConfig('k3s', 'serviceCidr'); 47 | 48 | public isEnabled(name: string): boolean { 49 | const config = new pulumi.Config(name); 50 | return config.getBoolean('enabled') ?? false; 51 | } 52 | 53 | public isDebugEnabled(name: string): boolean { 54 | const config = new pulumi.Config(name); 55 | return config.getBoolean('debug') ?? false; 56 | } 57 | 58 | public isBackupEnabled(appName: string, volumeName?: string): boolean { 59 | const config = new pulumi.Config(appName); 60 | const volumePrefix = volumeName ? `${volumeName}/` : ''; 61 | const appSetting = config.getBoolean(`${volumePrefix}backupVolume`); 62 | return ( 63 | appSetting ?? 64 | new pulumi.Config('longhorn').getBoolean('backupAllVolumes') ?? 65 | false 66 | ); 67 | } 68 | 69 | public getStorageClass(storageType?: StorageType): string { 70 | switch (storageType) { 71 | case StorageType.GPU: 72 | return this.storageClass.GPU; 73 | case StorageType.Large: 74 | return this.storageClass.Large; 75 | case StorageType.Database: 76 | return this.storageClass.Database; 77 | default: 78 | return this.storageClass.Default; 79 | } 80 | } 81 | 82 | public enableMonitoring() { 83 | const config = new pulumi.Config('prometheus'); 84 | const prometheusEnabled = config.requireBoolean('enabled'); 85 | 86 | const componentsEnabled = config.requireBoolean('enableComponentMonitoring'); 87 | return prometheusEnabled && componentsEnabled; 88 | } 89 | 90 | public require(appName: string, dependencyName: string) { 91 | const config = new pulumi.Config(dependencyName); 92 | if (config.require('enabled') !== 'true') { 93 | throw new Error(`${appName}: missing dependency ${dependencyName}`); 94 | } 95 | } 96 | 97 | private getAppConfig(appName: string, key: string): string | undefined { 98 | const config = new pulumi.Config(appName); 99 | return config.get(key); 100 | } 101 | 102 | private requireAppConfig(appName: string, key: string): string { 103 | const config = new pulumi.Config(appName); 104 | return config.require(key); 105 | } 106 | 107 | private processDeprecated() { 108 | if (this.getAppConfig('longhorn', 'backupAccessKeyId')) { 109 | console.warn('longhorn:backupAccessKeyId is deprecated.'); 110 | } 111 | if (this.getAppConfig('longhorn', 'backupAccessKeySecret')) { 112 | console.warn('longhorn:backupAccessKeySecret is deprecated.'); 113 | } 114 | if (this.getAppConfig('longhorn', 'backupTarget')) { 115 | console.warn( 116 | 'longhorn:backupTarget is deprecated. Use longhorn:backupBucket instead.', 117 | ); 118 | } 119 | if (this.getAppConfig('grafana', 'url')) { 120 | console.warn('grafana:url is not used anymore'); 121 | } 122 | if (this.getAppConfig('grafana', 'auth')) { 123 | console.warn('grafana:auth is not used anymore'); 124 | } 125 | if (this.getAppConfig('orangelab', 'storageClass')) { 126 | console.warn( 127 | 'orangelab:storageClass is deprecated. Use per-app :storageClass if needed.', 128 | ); 129 | } 130 | if (this.getAppConfig('orangelab', 'storageClass-gpu')) { 131 | console.warn( 132 | 'orangelab:storageClass-gpu is deprecated. Use per-app :storageClass if needed.', 133 | ); 134 | } 135 | if (this.getAppConfig('orangelab', 'storageClass-large')) { 136 | console.warn( 137 | 'orangelab:storageClass-large is deprecated. Use per-app :storageClass if needed.', 138 | ); 139 | } 140 | if (this.getAppConfig('orangelab', 'storageClass-database')) { 141 | console.warn( 142 | 'orangelab:storageClass-database is deprecated. Use per-app :storageClass if needed.', 143 | ); 144 | } 145 | if (this.getAppConfig('mempool', 'db/maintenance')) { 146 | console.warn( 147 | 'mempool:db/maintenance is deprecated. Use mempool:db/disableAuth instead.', 148 | ); 149 | } 150 | } 151 | 152 | /** 153 | * Returns true if any component in the given module is enabled. 154 | */ 155 | public isModuleEnabled(module: string): boolean { 156 | const deps = moduleDependencies[module]; 157 | return deps.some(dep => this.isEnabled(dep)); 158 | } 159 | } 160 | 161 | export const rootConfig = new RootConfig(); 162 | --------------------------------------------------------------------------------