15 Commits

Author SHA1 Message Date
ION606 bf98e2055e final attempt before I give up for today 2025-09-13 22:13:34 -04:00
ION606 2c538a1cf9 why 2025-09-13 21:15:35 -04:00
ION606 1b827c05a6 I will scream 2025-09-13 21:08:45 -04:00
ION606 2649ac6117 ingress dump 2025-09-13 19:00:56 -04:00
ION606 fe0974d162 ingress dump 2025-09-13 18:28:59 -04:00
ION606 45ad5f5901 opened ports 2025-09-13 13:40:32 -04:00
ION606 4e127f663b attempting to add scheduler UI 2025-09-13 13:04:33 -04:00
ION606 7975430489 GOD I AM SO DUMB 2025-09-13 11:56:35 -04:00
ION606 a4952581ec adding kustomization 2025-09-13 11:44:13 -04:00
ION606 469dfcd094 I gave up on airflow 2025-09-13 10:23:14 -04:00
ION606 abd4ee798b attempt to add airflow ini 3 2025-09-13 10:14:48 -04:00
ION606 b3f58e6e4a attempt to add airflow ini 2 2025-09-13 09:55:05 -04:00
ION606 ab7eaa0581 attempt to add airflow ini 2025-09-13 09:30:30 -04:00
ION606 26f4608c93 I have no idea what's going on 2025-09-12 22:38:10 -04:00
ION606 f600e46537 debugging 2025-09-12 22:33:26 -04:00
50 changed files with 1578 additions and 364 deletions
+3
View File
@@ -134,3 +134,6 @@ __pycache__/
.venv/ .venv/
*.xml *.xml
temp.*
bun.lock
tmp/
+2 -2
View File
@@ -2,10 +2,10 @@
My openWebUI/searxng configs, plugins, RAG server, as well as a custom program that runs the AI's code in isolated Docker containers My openWebUI/searxng configs, plugins, RAG server, as well as a custom program that runs the AI's code in isolated Docker containers
*Last updated: 2025-09-10* *Last updated: 2025-09-13*
> [!TIP] > [!TIP]
> Looking for the compose version of this? See the [compose]() > Looking for the compose version of this? See the [compose branch](https://git.ion606.com/ION606/ollama-plus/src/branch/compose/)
--- ---
+16 -5
View File
@@ -1,10 +1,14 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: AppProject kind: AppProject
metadata: { name: ai-stack, namespace: argocd } metadata:
name: ai-stack
namespace: argocd
spec: spec:
destinations: destinations:
- server: https://kubernetes.default.svc - server: https://kubernetes.default.svc
namespace: ai namespace: ai
- server: https://kubernetes.default.svc
namespace: argo
# # only add if need to deploy into argocd (ehhhhh) # # only add if need to deploy into argocd (ehhhhh)
# - server: https://kubernetes.default.svc # - server: https://kubernetes.default.svc
# namespace: argocd # namespace: argocd
@@ -13,14 +17,21 @@ spec:
--- ---
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: ai-stack, namespace: argocd } metadata:
name: ai-stack
namespace: argocd
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://git.ion606.com/ion606/ollama-plus repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: argo targetRevision: argo
path: apps/children path: apps/children
directory: { recurse: true }
syncPolicy: syncPolicy:
automated: { prune: true, selfHeal: true } automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+18
View File
@@ -0,0 +1,18 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: argo-templates
namespace: ai
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: argo
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: apps/argo-templates
syncPolicy:
automated:
prune: true
selfHeal: true
-18
View File
@@ -1,18 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: airflow, namespace: ai }
spec:
project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai }
sources:
- repoURL: https://airflow.apache.org
chart: airflow
targetRevision: "*"
helm:
valueFiles:
- $values/values/airflow.yaml
- repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: argo
ref: values
syncPolicy: { automated: { prune: true, selfHeal: true } }
+11 -4
View File
@@ -1,11 +1,18 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: browser, namespace: ai } metadata:
name: browser
namespace: ai
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://your.git/repo.git repoURL: https://git.ion606.com/ion606/ollama-plus.git
targetRevision: main targetRevision: main
path: manifests/browser path: manifests/browser
syncPolicy: { automated: { prune: true, selfHeal: true } } syncPolicy:
automated:
prune: true
selfHeal: true
+12 -3
View File
@@ -1,11 +1,20 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: coderunner, namespace: ai } metadata:
name: coderunner
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://git.ion606.com/ion606/ollama-plus repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main targetRevision: main
path: manifests/coderunner path: manifests/coderunner
syncPolicy: { automated: { prune: true, selfHeal: true } } syncPolicy:
automated:
prune: true
selfHeal: true
+21
View File
@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: debug-netshoot
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/debug
syncPolicy:
automated:
prune: true
selfHeal: true
+39
View File
@@ -0,0 +1,39 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ollama-scheduler.yaml
- coderunner.yaml
- tools.yaml
- rag-server.yaml
- openwebui.yaml
- postgresql.yaml
- searxng.yaml
- browser.yaml
- debug.yaml
- policy.yaml
- policy-argo.yaml
generatorOptions:
disableNameSuffixHash: true
# FINDME: The global branch for my repo
configMapGenerator:
- name: ollama-plus-revs
literals:
- targetRevision=argo
# Inject targetRevision from the ConfigMap into apps (kill me)
replacements:
- source:
kind: ConfigMap
name: ollama-plus-revs
fieldPath: data.targetRevision
targets:
- select:
kind: Application
labelSelector: repo.ion606.com/ollama-plus=true
fieldPaths:
- spec.source.targetRevision
options:
create: true
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ollama-scheduler
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: argo
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/argo-schedules-api
syncPolicy:
automated:
prune: true
selfHeal: true
+12 -3
View File
@@ -1,9 +1,15 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: openwebui, namespace: ai } metadata:
name: openwebui
namespace: ai
annotations:
argocd.argoproj.io/sync-wave: "0"
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://helm.openwebui.com repoURL: https://helm.openwebui.com
chart: open-webui chart: open-webui
@@ -11,4 +17,7 @@ spec:
helm: helm:
valueFiles: valueFiles:
- apps/values/openwebui.yaml - apps/values/openwebui.yaml
syncPolicy: { automated: { prune: true, selfHeal: true } } syncPolicy:
automated:
prune: true
selfHeal: true
+21
View File
@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: policy-argo
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: argo
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/policy-argo
syncPolicy:
automated:
prune: true
selfHeal: true
+21
View File
@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: policy
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/policy
syncPolicy:
automated:
prune: true
selfHeal: true
+12 -3
View File
@@ -1,9 +1,15 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: postgresql, namespace: ai } metadata:
name: postgresql
namespace: ai
annotations:
argocd.argoproj.io/sync-wave: "-10"
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://charts.bitnami.com/bitnami repoURL: https://charts.bitnami.com/bitnami
chart: postgresql chart: postgresql
@@ -12,4 +18,7 @@ spec:
valueFiles: valueFiles:
- apps/values/postgresql.yaml - apps/values/postgresql.yaml
syncPolicy: { automated: { prune: true, selfHeal: true } } syncPolicy:
automated:
prune: true
selfHeal: true
+12 -3
View File
@@ -1,11 +1,20 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: rag-server, namespace: ai } metadata:
name: rag-server
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://git.ion606.com/ion606/ollama-plus repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main targetRevision: main
path: manifests/rag-server path: manifests/rag-server
syncPolicy: { automated: { prune: true, selfHeal: true } } syncPolicy:
automated:
prune: true
selfHeal: true
+10 -3
View File
@@ -1,9 +1,13 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: searxng, namespace: ai } metadata:
name: searxng
namespace: ai
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://charts.kubito.dev repoURL: https://charts.kubito.dev
chart: searxng chart: searxng
@@ -11,4 +15,7 @@ spec:
helm: helm:
valueFiles: valueFiles:
- apps/values/searxng.yaml - apps/values/searxng.yaml
syncPolicy: { automated: { prune: true, selfHeal: true } } syncPolicy:
automated:
prune: true
selfHeal: true
+12 -3
View File
@@ -1,11 +1,20 @@
apiVersion: argoproj.io/v1alpha1 apiVersion: argoproj.io/v1alpha1
kind: Application kind: Application
metadata: { name: tools, namespace: ai } metadata:
name: tools
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec: spec:
project: ai-stack project: ai-stack
destination: { server: https://kubernetes.default.svc, namespace: ai } destination:
server: https://kubernetes.default.svc
namespace: ai
source: source:
repoURL: https://git.ion606.com/ion606/ollama-plus repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main targetRevision: main
path: manifests/tools path: manifests/tools
syncPolicy: { automated: { prune: true, selfHeal: true } } syncPolicy:
automated:
prune: true
selfHeal: true
-50
View File
@@ -1,50 +0,0 @@
useStandardNaming: true
executor: KubernetesExecutor
airflow:
extraPipPackages:
- "apache-airflow-providers-cncf-kubernetes>=7.4.0"
# metastore (postgres)
env:
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
value: "postgresql+psycopg2://postgres:mypassword@postgresql-primary.ai.svc.cluster.local:5432/openwebui"
pgbouncer:
enabled: true
logs:
persistence:
enabled: true
size: 2Gi
allowPodLaunching: true
scheduler:
resources:
requests: { cpu: "200m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
webserver:
secretKeySecretName: airflow-webserver-secret
service:
type: NodePort
nodePort: 30082 # 3000032767
resources:
requests: { cpu: "100m", memory: "256Mi" }
limits: { cpu: "500m", memory: "512Mi" }
triggerer:
resources:
requests: { cpu: "50m", memory: "128Mi" }
limits: { cpu: "200m", memory: "256Mi" }
# bc using nodeport
ingress:
enabled: false
# naur helm hooks for these jobs
createUserJob:
useHelmHooks: false
migrateDatabaseJob:
useHelmHooks: false
+7 -7
View File
@@ -4,22 +4,22 @@ image:
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
service: service:
type: NodePort # or ClusterIP if actually using ingress below type: NodePort # use NodePort instead of ClusterIP so openwebui is accessible externally
nodePort: 4000 nodePort: 30400
persistence: persistence:
enabled: true enabled: true
size: 5Gi size: 5Gi
ingress: ingress:
enabled: false # set true for http://openwebui.local via nginx enabled: false # disable ingress since we'll use NodePort
className: nginx className: nginx
hosts: hosts:
- host: openwebui.local - host: openwebui.local
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
tls: [] # no https bc I lazy tls: [] # no https for local/minikube
# NO SECRETS!!! # NO SECRETS!!!
extraEnvVars: extraEnvVars:
@@ -35,6 +35,6 @@ extraEnvVars:
value: "postgresql://postgres:mypassword@postgresql-primary.ai.svc.cluster.local:5432/openwebui" value: "postgresql://postgres:mypassword@postgresql-primary.ai.svc.cluster.local:5432/openwebui"
- name: PGVECTOR_CREATE_EXTENSION - name: PGVECTOR_CREATE_EXTENSION
value: "true" value: "true"
# no bish # no bish
# - name: WEBUI_URL # - name: WEBUI_URL
# value: "http://openwebui.local" # value: "http://openwebui.local"
+3
View File
@@ -1,3 +1,6 @@
# stupid mismatch fix
fullnameOverride: postgresql
architecture: replication architecture: replication
auth: auth:
+3 -2
View File
@@ -3,8 +3,8 @@ image:
tag: "latest" tag: "latest"
service: service:
type: NodePort # or ClusterIP if using ingress type: NodePort # expose service externally
# nodePort: 30081 nodePort: 30081
ingress: ingress:
enabled: false enabled: false
@@ -14,6 +14,7 @@ ingress:
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
# env: # env:
# SEARXNG_SECRET: "please-change-me" # SEARXNG_SECRET: "please-change-me"
# # helps with URL generation & results links # # helps with URL generation & results links
-131
View File
@@ -1,131 +0,0 @@
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
ports:
- "4000:8080"
volumes:
- open-webui:/app/backend/data
extra_hosts:
- host.docker.internal:host-gateway
restart: always
depends_on:
- postgres
- tools
networks:
- internal
tools:
container_name: openwebui_tools
build:
context: ./tools
dockerfile: Dockerfile
env_file: .env
restart: on-failure
networks:
- internal
postgres:
image: postgres:latest
container_name: openwebui_postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=mypassword
- POSTGRES_DB=openwebui_db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
# 8080
searxng:
image: searxng/searxng:latest
container_name: searxng
volumes:
- ./searxng.yml:/etc/searxng/settings.yml:ro,Z
- searxng_data:/etc/searxng:rw
restart: always
# DELETEME: for local testing only (extern port closed)
ports:
- "4001:8080"
networks:
- internal
coderunner:
build:
context: ./coderunner
dockerfile: Dockerfile
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8787/openapi.json"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
user: "1000:1000"
group_add:
- "977"
# death
environment:
DOCKER_HOST: "unix:///var/run/docker.sock"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:Z
# - ./tmp:/tmp
read_only: true
tmpfs:
- /run:rw,nosuid,nodev
- /tmp:rw,exec,nosuid,nodev,size=64m
security_opt:
- no-new-privileges:true
- label=disable
networks:
- internal
browser:
build:
context: ./browser
dockerfile: Dockerfile
container_name: browser
networks:
- internal
# playwright/chromium has larger /dev/shm :D
shm_size: "1gb"
user: "1000:1000"
environment:
WEBUI_IP: "0.0.0.0"
WEBUI_PORT: "7788"
ports:
- "7788:7788"
tmpfs:
- /opt/web-ui/tmp:rw,exec,nosuid,nodev,mode=1777,size=64m
volumes:
- webui_data:/data
# - webui_env:/opt/web-ui/.env
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7788').read()"]
interval: 30s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
open-webui:
pgdata:
searxng_data:
webui_data:
networks:
internal:
driver: bridge
@@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama-scheduler
namespace: argo
spec:
replicas: 1
selector:
matchLabels:
app: ollama-scheduler
template:
metadata:
labels:
app: ollama-scheduler
spec:
serviceAccountName: ollama-scheduler
containers:
- name: ollama-scheduler
image: docker.io/ion606/ollama-scheduler:0.1.0
imagePullPolicy: IfNotPresent
env:
- name: PORT
value: "12253"
- name: NS
value: "argo"
ports:
- name: http
containerPort: 12253
readinessProbe:
tcpSocket:
port: 12253
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 12253
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "256Mi"
---
apiVersion: v1
kind: Service
metadata:
name: ollama-scheduler
namespace: argo
spec:
selector:
app: ollama-scheduler
ports:
- name: http
port: 12253
targetPort: 12253
type: ClusterIP
+18
View File
@@ -0,0 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ollama-scheduler
namespace: argo
spec:
ingressClassName: nginx
rules:
- host: scheduler.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ollama-scheduler
port:
number: 12253
+37
View File
@@ -0,0 +1,37 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: ollama-scheduler
namespace: argo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ollama-scheduler
namespace: argo
rules:
- apiGroups: ["argoproj.io"]
resources: ["cronworkflows"]
verbs: ["create", "get", "list", "watch", "update", "patch", "delete"]
- apiGroups: ["argoproj.io"]
resources: ["workflows"]
verbs: ["create", "get", "list"]
- apiGroups: ["argoproj.io"]
resources: ["workflowtemplates"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ollama-scheduler
namespace: argo
subjects:
- kind: ServiceAccount
name: ollama-scheduler
namespace: argo
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ollama-scheduler
@@ -0,0 +1,17 @@
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: hello-template
namespace: argo
spec:
entrypoint: run
arguments:
parameters:
- { name: message, value: "hello from argo" }
templates:
- name: run
container:
image: alpine:3.19
command: ["/bin/sh","-lc"]
args: ["echo \"{{workflow.parameters.message}}\""]
@@ -0,0 +1,27 @@
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: ollama-job-template
namespace: argo
spec:
entrypoint: run
arguments:
parameters:
- { name: task, value: "quick-check" }
- { name: prompt, value: "reindex embeddings" }
templates:
- name: run
container:
# Replace with an image that can reach your Ollama endpoint
image: curlimages/curl:8.9.0
command: ["/bin/sh","-lc"]
args:
- >-
echo "task: {{workflow.parameters.task}}";
echo "prompt: {{workflow.parameters.prompt}}";
# Example call (adjust host/port or service DNS):
# curl -s http://ollama.ai.svc.cluster.local:11434/api/generate \
# -H 'content-type: application/json' \
# -d '{"model":"llama3.1","prompt":"{{workflow.parameters.prompt}}"}' | tee /tmp/out.json;
echo done.
+12 -13
View File
@@ -18,23 +18,22 @@ spec:
requests: { cpu: "250m", memory: "256Mi" } requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "1", memory: "1Gi" } # hard cap limits: { cpu: "1", memory: "1Gi" } # hard cap
readinessProbe: readinessProbe:
{ httpGet: { path: "/", port: 7788 }
httpGet: { path: "/", port: 7788 }, initialDelaySeconds: 5
initialDelaySeconds: 5, periodSeconds: 10
periodSeconds: 10,
}
livenessProbe: livenessProbe:
{ httpGet: { path: "/", port: 7788 }
httpGet: { path: "/", port: 7788 }, initialDelaySeconds: 15
initialDelaySeconds: 15, periodSeconds: 20
periodSeconds: 20,
}
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: { name: browser, namespace: ai } metadata: { name: browser, namespace: ai }
spec: spec:
selector: { app: browser } selector: { app: browser }
ports: [{ name: http, port: 7788, targetPort: 7788 }] ports:
type: ClusterIP - name: http
port: 7788
targetPort: 7788
nodePort: 30788
type: NodePort
+15 -16
View File
@@ -14,23 +14,18 @@ spec:
ports: [{ containerPort: 8787 }] ports: [{ containerPort: 8787 }]
env: env:
- { name: PORT, value: "8787" } - { name: PORT, value: "8787" }
- { - name: NAMESPACE
name: NAMESPACE,
valueFrom: valueFrom:
{ fieldRef: { fieldPath: metadata.namespace } }, fieldRef:
} fieldPath: metadata.namespace
readinessProbe: readinessProbe:
{ httpGet: { path: "/openapi.json", port: 8787 }
httpGet: { path: "/openapi.json", port: 8787 }, initialDelaySeconds: 5
initialDelaySeconds: 5, periodSeconds: 10
periodSeconds: 10,
}
livenessProbe: livenessProbe:
{ httpGet: { path: "/openapi.json", port: 8787 }
httpGet: { path: "/openapi.json", port: 8787 }, initialDelaySeconds: 15
initialDelaySeconds: 15, periodSeconds: 20
periodSeconds: 20,
}
resources: resources:
requests: { cpu: "100m", memory: "128Mi" } requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" } limits: { cpu: "500m", memory: "512Mi" }
@@ -40,5 +35,9 @@ kind: Service
metadata: { name: coderunner, namespace: ai } metadata: { name: coderunner, namespace: ai }
spec: spec:
selector: { app: coderunner } selector: { app: coderunner }
ports: [{ name: http, port: 8787, targetPort: 8787 }] ports:
type: ClusterIP - name: http
port: 8787
targetPort: 8787
nodePort: 31787
type: NodePort
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- netshoot.yaml
+26
View File
@@ -0,0 +1,26 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: netshoot
namespace: ai
spec:
replicas: 1
selector:
matchLabels:
app: netshoot
template:
metadata:
labels:
app: netshoot
spec:
containers:
- name: netshoot
image: nicolaka/netshoot:latest
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c", "sleep infinity"]
securityContext:
capabilities:
add: ["NET_ADMIN", "NET_RAW"]
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "200m", memory: "256Mi" }
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../policy/allow-ollama-scheduler-ingress.yaml
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-browser-ingress
namespace: ai
spec:
podSelector:
matchLabels:
app: browser
policyTypes: ["Ingress"]
ingress:
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- { protocol: TCP, port: 7788 }
+26
View File
@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-common-egress
namespace: ai
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
# Allow DNS to kube-dns/CoreDNS in kube-system
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
# Allow PostgreSQL to services/pods in namespace ai on 5432
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ai
ports:
- { protocol: TCP, port: 5432 }
+20
View File
@@ -0,0 +1,20 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-https-egress
namespace: ai
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
# exclude RFC1918/private ranges so this only permits Internet egress
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- { protocol: TCP, port: 443 }
- { protocol: TCP, port: 80 }
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ollama-scheduler-ingress
namespace: argo
spec:
podSelector:
matchLabels:
app: ollama-scheduler
policyTypes: ["Ingress"]
ingress:
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- { protocol: TCP, port: 12253 }
@@ -0,0 +1,20 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-openwebui-ingress
namespace: ai
spec:
# Select the Open WebUI pods deployed by the Helm release "openwebui"
podSelector:
matchLabels:
app.kubernetes.io/instance: openwebui
policyTypes: ["Ingress"]
ingress:
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
# Open WebUI typically listens on 8080 (chart default), sometimes 80
- { protocol: TCP, port: 8080 }
- { protocol: TCP, port: 80 }
+4 -2
View File
@@ -1,6 +1,8 @@
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: NetworkPolicy kind: NetworkPolicy
metadata: { name: default-deny-all, namespace: ai } metadata:
name: default-deny-all
namespace: ai
spec: spec:
podSelector: {} podSelector: {} # die
policyTypes: ["Ingress", "Egress"] policyTypes: ["Ingress", "Egress"]
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- default-deny.yaml
- allow-openwebui-ingress.yaml
- allow-browser-ingress.yaml
- allow-common-egress.yaml
- allow-https-egress.yaml
+9 -7
View File
@@ -20,12 +20,10 @@ spec:
- { name: OLLAMA_CHAT_MODEL, value: "llama3.1" } - { name: OLLAMA_CHAT_MODEL, value: "llama3.1" }
- { name: OLLAMA_EMBED_MODEL, value: "nomic-embed-text" } - { name: OLLAMA_EMBED_MODEL, value: "nomic-embed-text" }
readinessProbe: readinessProbe:
{ httpGet: { path: "/openapi.json", port: 8788 } } httpGet: { path: "/openapi.json", port: 8788 }
livenessProbe: livenessProbe:
{ httpGet: { path: "/", port: 8788 }
httpGet: { path: "/", port: 8788 }, initialDelaySeconds: 10
initialDelaySeconds: 10,
}
resources: resources:
requests: { cpu: "200m", memory: "256Mi" } requests: { cpu: "200m", memory: "256Mi" }
limits: { cpu: "1", memory: "1Gi" } limits: { cpu: "1", memory: "1Gi" }
@@ -35,5 +33,9 @@ kind: Service
metadata: { name: rag-server, namespace: ai } metadata: { name: rag-server, namespace: ai }
spec: spec:
selector: { app: rag-server } selector: { app: rag-server }
ports: [{ name: http, port: 8788, targetPort: 8788 }] ports:
type: ClusterIP - name: http
port: 8788
targetPort: 8788
nodePort: 31788
type: NodePort
+9 -7
View File
@@ -16,12 +16,10 @@ spec:
- { name: PORT, value: "1331" } - { name: PORT, value: "1331" }
- { name: ROKU_IP, value: "192.0.2.10" } - { name: ROKU_IP, value: "192.0.2.10" }
readinessProbe: readinessProbe:
{ httpGet: { path: "/roku/openapi.json", port: 1331 } } httpGet: { path: "/roku/openapi.json", port: 1331 }
livenessProbe: livenessProbe:
{ httpGet: { path: "/roku/openapi.json", port: 1331 }
httpGet: { path: "/roku/openapi.json", port: 1331 }, initialDelaySeconds: 10
initialDelaySeconds: 10,
}
resources: resources:
requests: { cpu: "100m", memory: "128Mi" } requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" } limits: { cpu: "500m", memory: "512Mi" }
@@ -31,5 +29,9 @@ kind: Service
metadata: { name: tools, namespace: ai } metadata: { name: tools, namespace: ai }
spec: spec:
selector: { app: tools } selector: { app: tools }
ports: [{ name: http, port: 1331, targetPort: 1331 }] ports:
type: ClusterIP - name: http
port: 1331
targetPort: 1331
nodePort: 31331
type: NodePort
+6
View File
@@ -0,0 +1,6 @@
node_modules
npm-cache
bun.lock
bun.lockb
.DS_Store
*.log
+14
View File
@@ -0,0 +1,14 @@
FROM oven/bun:1 as base
WORKDIR /app
# prod deps
COPY package.json ./package.json
RUN bun install --ci --production
COPY server.mjs ./server.mjs
COPY public ./public
USER bun
EXPOSE 12253
ENV NODE_ENV=production
CMD ["bun", "run", "server.mjs"]
+14
View File
@@ -0,0 +1,14 @@
{
"name": "ollama-scheduler",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "bun run server.mjs",
"dev": "bun run --hot server.mjs"
},
"dependencies": {
"@kubernetes/client-node": "^0.22.1",
"@types/node": "^24.3.3"
}
}
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Schedules UI</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<h1>Manage Your Tasks and Follow-Ups!</h1>
<!-- login card -->
<section class="card" id="auth">
<h2>login</h2>
<p class="muted">
enter your open webui user id (uuid). this is sent as the
<code class="inline">x-user-id</code> header on api requests.
</p>
<form id="loginForm">
<div class="row">
<div>
<label for="userId">user id (uuid)</label>
<input
id="userId"
name="userId"
type="text"
required
placeholder="e.g. 5a8d1d7e-..." />
</div>
<div>
<label for="displayName">display name (optional)</label>
<input
id="displayName"
name="displayName"
type="text"
placeholder="your name" />
</div>
</div>
<div class="actions" style="margin-top: 0.75rem">
<button type="submit">save & set header</button>
<button type="button" id="logoutBtn">logout</button>
</div>
<p id="authStatus" class="muted"></p>
</form>
</section>
<!-- schedules list -->
<section class="card">
<div
class="actions"
style="justify-content: space-between; align-items: center">
<h2 style="margin: 0">your schedules</h2>
<div class="actions">
<button id="refreshBtn">refresh</button>
</div>
</div>
<div
id="listStatus"
class="muted"
style="margin: 0.4rem 0 0.6rem"></div>
<div style="overflow: auto">
<table>
<thead>
<tr>
<th>name</th>
<th>schedules</th>
<th>tz</th>
<th>template</th>
<th>entrypoint</th>
<th>one-shot</th>
<th>actions</th>
</tr>
</thead>
<tbody id="schedulesTbody"></tbody>
</table>
</div>
</section>
<!-- create/update schedule -->
<section class="card">
<h2>create / update schedule</h2>
<form id="createForm">
<div class="row">
<div>
<label for="name">name</label>
<input
id="name"
name="name"
type="text"
required
placeholder="daily-report" />
</div>
<div>
<label for="tz">timezone</label>
<input
id="tz"
name="tz"
type="text"
value="America/New_York" />
</div>
</div>
<div class="row">
<div>
<label for="iso"
>run at (iso datetime, or leave empty if using
cron)</label
>
<input id="iso" name="iso" type="datetime-local" />
</div>
<div>
<label for="cron"
>cron (min hour day month *), if not using
iso</label
>
<input
id="cron"
name="cron"
type="text"
placeholder="30 9 * * *" />
</div>
</div>
<div class="row">
<div>
<label for="templateName">workflow template</label>
<input
id="templateName"
name="templateName"
type="text"
required
placeholder="report-template" />
</div>
<div>
<label for="entrypoint">entrypoint (optional)</label>
<input
id="entrypoint"
name="entrypoint"
type="text"
placeholder="main" />
</div>
</div>
<div class="row">
<div>
<label
><input id="clusterScope" type="checkbox" />
template is cluster-scoped</label
>
</div>
<div>
<label
><input id="oneShot" type="checkbox" /> stop after
first success (one-shot)</label
>
</div>
</div>
<label for="params">parameters (json object)</label>
<textarea
id="params"
name="params"
placeholder='{"report_kind":"summary"}'></textarea>
<div class="actions" style="margin-top: 0.75rem">
<button type="submit">upsert schedule</button>
<button type="button" id="loadTemplatesBtn">
load workflow templates
</button>
</div>
<div
id="createStatus"
class="muted"
style="margin-top: 0.5rem"></div>
</form>
<details style="margin-top: 0.75rem">
<summary class="muted">available workflow templates</summary>
<ul id="templatesUl" class="muted"></ul>
</details>
</section>
<!-- run now -->
<section class="card">
<h2>run now</h2>
<form id="runNowForm">
<div class="row">
<div>
<label for="rnName">name (label only)</label>
<input
id="rnName"
type="text"
placeholder="ad-hoc-run" />
</div>
<div>
<label for="rnTemplateName">workflow template</label>
<input
id="rnTemplateName"
type="text"
placeholder="report-template"
required />
</div>
</div>
<div class="row">
<div>
<label for="rnEntrypoint">entrypoint (optional)</label>
<input
id="rnEntrypoint"
type="text"
placeholder="main" />
</div>
<div>
<label
><input id="rnClusterScope" type="checkbox" />
template is cluster-scoped</label
>
</div>
</div>
<label for="rnParams">parameters (json object)</label>
<textarea
id="rnParams"
placeholder='{"report_kind":"summary"}'></textarea>
<div class="actions" style="margin-top: 0.75rem">
<button type="submit">run now</button>
</div>
<div
id="runNowStatus"
class="muted"
style="margin-top: 0.5rem"></div>
</form>
</section>
<script type="module" src="script.js"></script>
</body>
</html>
+267
View File
@@ -0,0 +1,267 @@
const state = {
userId: localStorage.getItem("userId") || "",
displayName: localStorage.getItem("displayName") || "",
};
const $ = (sel) => document.querySelector(sel),
setText = (sel, v) => {
const el = $(sel);
if (el) el.textContent = v;
};
const authStatusEl = $("#authStatus"),
listStatusEl = $("#listStatus"),
createStatusEl = $("#createStatus"),
runNowStatusEl = $("#runNowStatus"),
schedulesTbody = $("#schedulesTbody"),
templatesUl = $("#templatesUl");
// update login ui from state
function paintAuth() {
$("#userId").value = state.userId || "";
$("#displayName").value = state.displayName || "";
if (state.userId) {
authStatusEl.textContent = `logged in as ${state.displayName ? state.displayName + " · " : ""
}${state.userId}`;
} else {
authStatusEl.textContent = "not logged in";
}
}
// wrap fetch to always attach x-user-id
async function apiFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
if (!state.userId)
throw new Error(
"no user id set — use the login form first"
);
headers.set("x-user-id", state.userId); // custom header
if (
!headers.has("content-type") &&
options.body &&
!(options.body instanceof FormData)
) {
headers.set("content-type", "application/json");
}
const resp = await fetch(url, { ...options, headers });
if (!resp.ok) {
// try to surface json error bodies
let msg = `${resp.status} ${resp.statusText}`;
try {
const data = await resp.json();
if (data && data.error) msg = data.error;
} catch { }
throw new Error(msg);
}
return resp;
}
// render list
function renderSchedules(items = []) {
schedulesTbody.innerHTML = "";
items.forEach((it) => {
const tr = document.createElement("tr");
const tRef = it.templateRef
? it.templateRef.clusterScope
? `(cluster) ${it.templateRef.name}`
: it.templateRef.name
: "";
tr.innerHTML = `
<td>${escapeHtml(it.displayName || it.name || "")}</td>
<td>${(it.schedules || []).map(escapeHtml).join("<br/>")}</td>
<td>${escapeHtml(it.timezone || "")}</td>
<td>${escapeHtml(tRef)}</td>
<td>${escapeHtml(it.entrypoint || "")}</td>
<td>${it.oneShot ? "yes" : "no"}</td>
<td class="actions">
<button data-del="${encodeURIComponent(it.name)}">delete</button>
</td>
`;
schedulesTbody.appendChild(tr);
});
}
// tiny escape helper
function escapeHtml(s = "") {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
// wire up events
$("#loginForm").addEventListener("submit", (e) => {
e.preventDefault();
const userId = $("#userId").value.trim(),
displayName = $("#displayName").value.trim();
if (!userId) {
authStatusEl.textContent = "please enter a user id";
return;
}
state.userId = userId;
state.displayName = displayName;
localStorage.setItem("userId", state.userId);
localStorage.setItem("displayName", state.displayName);
paintAuth();
});
$("#logoutBtn").addEventListener("click", () => {
localStorage.removeItem("userId");
localStorage.removeItem("displayName");
state.userId = "";
state.displayName = "";
paintAuth();
});
$("#refreshBtn").addEventListener("click", async () => {
try {
listStatusEl.textContent = "loading...";
const res = await apiFetch("/api/schedules");
const data = await res.json();
renderSchedules(data.items || []);
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0
} schedule(s)`;
} catch (e) {
listStatusEl.textContent = `error: ${e.message}`;
}
});
// delete handler (delegated)
schedulesTbody.addEventListener("click", async (e) => {
const target = e.target;
if (!(target instanceof HTMLButtonElement)) return;
const name = target.getAttribute("data-del");
if (!name) return;
try {
target.disabled = true;
const res = await apiFetch(`/schedules/${name}`, {
method: "DELETE",
});
if (res.status === 204) {
target.closest("tr")?.remove();
listStatusEl.textContent = "deleted";
} else {
listStatusEl.textContent = "unexpected response";
}
} catch (err) {
listStatusEl.textContent = `error: ${err.message}`;
} finally {
target.disabled = false;
}
});
// create/update schedule
$("#createForm").addEventListener("submit", async (e) => {
e.preventDefault();
try {
createStatusEl.textContent = "saving...";
const name = $("#name").value.trim(),
tz = $("#tz").value.trim() || "America/New_York",
iso = $("#iso").value
? new Date($("#iso").value).toISOString()
: "",
cron = $("#cron").value.trim(),
templateName = $("#templateName").value.trim(),
entrypoint = $("#entrypoint").value.trim(),
clusterScope = $("#clusterScope").checked,
oneShot = $("#oneShot").checked,
paramsRaw = $("#params").value.trim();
let parameters = {};
if (paramsRaw) {
try {
parameters = JSON.parse(paramsRaw);
} catch {
throw new Error("parameters must be valid json");
}
}
const payload = {
name,
when: cron ? { cron } : { iso },
tz,
oneShot,
template: { name: templateName, clusterScope },
parameters,
entrypoint: entrypoint || undefined,
};
await apiFetch("/schedules", {
method: "POST",
body: JSON.stringify(payload),
});
createStatusEl.textContent = "saved ✅";
$("#refreshBtn").click();
} catch (err) {
createStatusEl.textContent = `error: ${err.message}`;
}
});
// run now
$("#runNowForm").addEventListener("submit", async (e) => {
e.preventDefault();
try {
runNowStatusEl.textContent = "starting...";
const name = $("#rnName").value.trim() || "ad-hoc",
templateName = $("#rnTemplateName").value.trim(),
entrypoint = $("#rnEntrypoint").value.trim(),
clusterScope = $("#rnClusterScope").checked,
paramsRaw = $("#rnParams").value.trim();
let parameters = {};
if (paramsRaw) {
try {
parameters = JSON.parse(paramsRaw);
} catch {
throw new Error("parameters must be valid json");
}
}
const payload = {
name,
template: { name: templateName, clusterScope },
entrypoint: entrypoint || undefined,
parameters,
};
await apiFetch("/run-now", {
method: "POST",
body: JSON.stringify(payload),
});
runNowStatusEl.textContent = "started ✅";
} catch (err) {
runNowStatusEl.textContent = `error: ${err.message}`;
}
});
// load workflow templates for convenience
$("#loadTemplatesBtn").addEventListener("click", async () => {
try {
templatesUl.innerHTML = "";
templatesUl.parentElement.open = true;
const res = await apiFetch("/api/workflowtemplates"),
data = await res.json();
(data.items || []).forEach((t) => {
const li = document.createElement("li");
li.textContent = t.name;
templatesUl.appendChild(li);
});
} catch (e) {
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(
e.message
)}</li>`;
}
});
// boot
paintAuth();
// auto-refresh if already logged in
if (state.userId) $("#refreshBtn").click();
+96
View File
@@ -0,0 +1,96 @@
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
body {
margin: 2rem;
display: grid;
gap: 1.5rem;
max-width: 980px;
}
form,
.card {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 12px;
}
label {
display: block;
margin: 0.25rem 0 0.15rem;
}
input[type="text"],
input[type="datetime-local"],
select,
textarea {
width: 100%;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid #bbb;
}
textarea {
min-height: 96px;
font-family: ui-monospace, Menlo, monospace;
}
button {
padding: 0.55rem 0.9rem;
border-radius: 10px;
border: 1px solid #888;
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
th,
td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #ddd;
text-align: left;
}
.row {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.muted {
opacity: 0.75;
font-size: 0.92rem;
}
.danger {
color: #a30000;
}
.ok {
color: #008000;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
@media (max-width: 800px) {
.row {
grid-template-columns: 1fr;
}
}
code.inline {
padding: 0.15rem 0.3rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #f7f7f7;
}
+233
View File
@@ -0,0 +1,233 @@
import http from 'http'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node'
const GROUP = 'argoproj.io',
VERSION = 'v1alpha1',
CRON_PLURAL = 'cronworkflows',
WF_PLURAL = 'workflows',
NAMESPACE = process.env.NS || 'argo',
// k8s label/annotation keys (must be lowercase dns-labels)
LABEL_USER_KEY = 'openwebui.user-id',
ANNO_DISPLAY_NAME = 'openwebui/display-name';
// load cluster credentials
const kc = new KubeConfig();
try { kc.loadFromCluster() } catch { kc.loadFromDefault() }
const co = kc.makeApiClient(CustomObjectsApi);
// build cron string from an iso timestamp in a tz
const cronFromISO = (iso, tz = 'America/New_York') => {
const dt = new Date(iso),
parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: false
}).formatToParts(dt).reduce((a, p) => (a[p.type] = p.value, a), {}),
m = Number(parts.month), d = Number(parts.day), h = Number(parts.hour), min = Number(parts.minute);
return `${min} ${h} ${d} ${m} *`;
}
// derive a k8s-safe, user-scoped name and preserve a human display name
const scopedName = (name, userId) => {
// keep to dns-1123 by trimming/normalizing a bit; add an 8-char user suffix for uniqueness
const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40),
suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon';
return `${base}--u-${suffix}`;
}
// ensure we have a user id header
const requireUserId = (req) => {
const userId = String(req.headers['x-user-id'] || '').trim();
if (!userId) throw Object.assign(new Error('missing x-user-id header'), { status: 401 });
return userId;
}
// normalize parameters and force-inject user_id
const buildParams = (parameters = {}, userId) => {
const merged = { ...parameters, user_id: userId },
args = Object.entries(merged).map(([name, value]) => ({ name, value }));
return args.length ? { parameters: args } : undefined;
}
// create or update a cronworkflow that runs a workflowtemplate (scoped to user)
async function upsertCronWorkflow({
name, when, tz = 'America/New_York', oneShot = false,
template = { name: '', clusterScope: false },
parameters = {}, entrypoint, userId
}) {
const schedule = when.cron ?? cronFromISO(when.iso, tz),
nameActual = scopedName(name, userId),
body = {
apiVersion: `${GROUP}/${VERSION}`,
kind: 'CronWorkflow',
metadata: {
name: nameActual,
labels: { [LABEL_USER_KEY]: userId },
annotations: { [ANNO_DISPLAY_NAME]: name },
},
spec: {
timezone: tz,
schedules: [schedule],
concurrencyPolicy: 'Forbid',
...(oneShot ? { stopStrategy: { expression: 'cronworkflow.succeeded >= 1' } } : {}),
workflowSpec: {
...(entrypoint ? { entrypoint } : {}),
arguments: buildParams(parameters, userId),
workflowTemplateRef: {
name: template.name,
...(template.clusterScope ? { clusterScope: true } : {})
}
}
}
};
// try patch, else create
try {
await co.patchNamespacedCustomObject(
GROUP, VERSION, NAMESPACE, CRON_PLURAL, nameActual, body,
undefined, undefined, undefined,
{ headers: { 'content-type': 'application/merge-patch+json' } }
);
} catch {
await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, body);
}
}
// run immediately (no schedule) by creating a workflow from the same template (scoped to user)
async function runNow({ name, template, parameters = {}, entrypoint, userId }) {
const wf = {
apiVersion: `${GROUP}/${VERSION}`,
kind: 'Workflow',
metadata: {
generateName: `${scopedName(name, userId)}-`,
labels: { [LABEL_USER_KEY]: userId },
annotations: { [ANNO_DISPLAY_NAME]: name },
},
spec: {
...(entrypoint ? { entrypoint } : {}),
arguments: buildParams(parameters, userId),
workflowTemplateRef: {
name: template.name,
...(template.clusterScope ? { clusterScope: true } : {})
}
}
};
await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, WF_PLURAL, wf);
}
const __filename = fileURLToPath(import.meta.url),
__dirname = path.dirname(__filename),
publicDir = path.join(__dirname, 'public');
// tiny json helper
const readJson = (req) => new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c);
req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } });
req.on('error', reject);
});
const server = http.createServer(async (req, res) => {
try {
// death
const origin = req.headers.origin || '*'
res.setHeader('access-control-allow-origin', origin)
res.setHeader('vary', 'origin')
res.setHeader('access-control-allow-headers', 'content-type, x-user-id')
res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS')
if (req.method === 'OPTIONS') return res.writeHead(204).end()
// minimal static ui
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
try {
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
} catch {
res.writeHead(404).end('ui not found');
}
return;
}
// list CronWorkflows for the calling user
if (req.method === 'GET' && req.url === '/api/schedules') {
const userId = requireUserId(req),
list = await co.listNamespacedCustomObject(
GROUP, VERSION, NAMESPACE, CRON_PLURAL,
undefined, undefined, undefined, `${LABEL_USER_KEY}=${userId}` // labelSelector
),
items = (list.body.items || []).map(it => ({
name: it.metadata?.name,
displayName: it.metadata?.annotations?.[ANNO_DISPLAY_NAME] || it.metadata?.name,
userId: it.metadata?.labels?.[LABEL_USER_KEY],
timezone: it.spec?.timezone,
schedules: it.spec?.schedules,
oneShot: Boolean(it.spec?.stopStrategy),
templateRef: it.spec?.workflowSpec?.workflowTemplateRef,
entrypoint: it.spec?.workflowSpec?.entrypoint,
}));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }));
}
// list WorkflowTemplates for UI (shared)
if (req.method === 'GET' && req.url === '/api/workflowtemplates') {
const list = await co.listNamespacedCustomObject(GROUP, VERSION, NAMESPACE, 'workflowtemplates'),
items = (list.body.items || []).map(it => ({ name: it.metadata?.name }));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }));
}
// create/update a user-scoped schedule
if (req.method === 'POST' && req.url === '/schedules') {
const userId = requireUserId(req),
input = await readJson(req);
await upsertCronWorkflow({ ...input, userId });
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
}
// run a job now for the calling user
if (req.method === 'POST' && req.url === '/run-now') {
const userId = requireUserId(req),
input = await readJson(req);
await runNow({ ...input, userId });
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
}
// delete a schedule owned by the calling user
if (req.method === 'DELETE' && req.url?.startsWith('/schedules/')) {
const userId = requireUserId(req),
name = decodeURIComponent(req.url.split('/').pop());
// guard: verify ownership via label before deletion
const obj = await co.getNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name),
owner = obj.body?.metadata?.labels?.[LABEL_USER_KEY];
if (owner !== userId) {
res.writeHead(403, { 'content-type': 'application/json' })
.end(JSON.stringify({ ok: false, error: 'forbidden: schedule not owned by this user' }));
return;
}
await co.deleteNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name);
return res.writeHead(204).end();
}
res.writeHead(404).end('not found');
} catch (e) {
const code = Number(e.status) || 500;
res.writeHead(code, { 'content-type': 'application/json' })
.end(JSON.stringify({ ok: false, error: e.message || String(e) }));
}
});
const port = Number(process.env.PORT) || 12253;
server.listen(port, () => console.log(`schedules api listening on :${port}`));
+4
View File
@@ -13,3 +13,7 @@ docker push ion606/rag-server:latest;
# tools # tools
docker build -t ion606/tools:latest ./tools; docker build -t ion606/tools:latest ./tools;
docker push ion606/tools:latest; docker push ion606/tools:latest;
# scheduling
docker build -t ion606/ollama-scheduler:latest ./scheduler;
docker push ion606/ollama-scheduler:latest;
+26 -9
View File
@@ -5,13 +5,24 @@ set -euo pipefail;
# cluster + ingress addons (nginx + ingress-dns) # cluster + ingress addons (nginx + ingress-dns)
# https://kubernetes.io/docs/tasks/access-application-cluster/ingress-minikube/ # https://kubernetes.io/docs/tasks/access-application-cluster/ingress-minikube/
# https://minikube.sigs.k8s.io/docs/handbook/addons/ingress-dns/ # https://minikube.sigs.k8s.io/docs/handbook/addons/ingress-dns/
minikube start --driver=docker || true; # NOTE: publish ports 80/443 from the node to the host when using Docker driver
minikube start --driver=docker --cni=cilium --ports=80:80,443:443;
minikube addons enable ingress; minikube addons enable ingress;
minikube addons enable ingress-dns; minikube addons enable ingress-dns;
# wait for Cilium (if present) and ingress controller to become Ready
kubectl -n kube-system rollout status ds/cilium --timeout=180s || echo "WARN: cilium DaemonSet not found or not Ready yet";
if kubectl -n ingress-nginx get ds/ingress-nginx-controller >/dev/null 2>&1; then
kubectl -n ingress-nginx rollout status ds/ingress-nginx-controller --timeout=180s || true;
else
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s || true;
fi
# namespaces # namespaces
kubectl create namespace argocd --dry-run=client -o yaml | kubectl apply -f -; kubectl create namespace argocd --dry-run=client -o yaml | kubectl apply -f -;
kubectl create namespace ai --dry-run=client -o yaml | kubectl apply -f -; kubectl create namespace ai --dry-run=client -o yaml | kubectl apply -f -;
# argo workflows namespace (for cronworkflows/workflows + templates)
kubectl create namespace argo --dry-run=client -o yaml | kubectl apply -f -;
# install argo cd (stable manifest) # install argo cd (stable manifest)
# https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/ # https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/
@@ -26,18 +37,24 @@ kubectl rollout status deploy/argocd-application-controller -n argocd --timeout=
# NOTE: creates the child Applications in apps/children/* # NOTE: creates the child Applications in apps/children/*
kubectl apply -n argocd -f apps/0-project-and-root.yaml; kubectl apply -n argocd -f apps/0-project-and-root.yaml;
# service! echo "DEBUG: writing pods to 'tmp/pods.txt'"
# SEE???? I CAN USE DASHES AND NOT JUST CAMELCASE!!! mkdir -p tmp || ""
kubectl -n ai create secret generic airflow-fernet-key-secret --from-literal=fernet-key=$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))') kubectl get pod -o wide --all-namespaces > tmp/pods.txt
kubectl -n ai create secret generic airflow-webserver-secret --from-literal=webserver-secret-key=$(python3 -c 'import secrets;print(secrets.token_hex(16))')
minikube service -n ai airflow-webserver --url # quick ingress test hint
MINIKUBE_IP=$(minikube ip || echo "<minikube-ip>")
echo "";
echo "To test ingress locally (without DNS), run:";
echo " curl -H 'Host: openwebui.local' http://$MINIKUBE_IP/";
echo "If name doesn't resolve on your host, add to /etc/hosts:";
echo " sudo sh -c 'echo \"$MINIKUBE_IP openwebui.local\" >> /etc/hosts'";
# port-forward argocd ui # port-forward argocd ui
echo ""; echo "";
echo "argocd initial admin password (username 'admin'):"; echo "argocd initial admin password (username 'admin'):";
kubectl -n argocd get secret argocd-initial-admin-secret \ kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo "";
-o jsonpath='{.data.password}' | base64 -d; echo "";
echo ""; echo "";
echo "port-forwarding argocd ui to https://localhost:8443 (ctrl+c to stop) ..."; echo "port-forwarding argocd ui to https://localhost:8443 (ctrl+c to stop) ...";
kubectl -n argocd port-forward svc/argocd-server 8443:443;
# kubectl -n argocd port-forward svc/scheduler-ui 12253:12253
kubectl -n argocd port-forward svc/argocd-server 8443:443