diff --git a/apps/0-project-and-root.yaml b/apps/0-project-and-root.yaml index af86767..6b713eb 100644 --- a/apps/0-project-and-root.yaml +++ b/apps/0-project-and-root.yaml @@ -7,6 +7,8 @@ spec: destinations: - server: https://kubernetes.default.svc namespace: ai + - server: https://kubernetes.default.svc + namespace: argo # # only add if need to deploy into argocd (ehhhhh) # - server: https://kubernetes.default.svc # namespace: argocd @@ -27,8 +29,6 @@ spec: repoURL: https://git.ion606.com/ion606/ollama-plus targetRevision: argo path: apps/children - directory: - recurse: true syncPolicy: automated: prune: true diff --git a/apps/argo-templates.yaml b/apps/argo-templates.yaml new file mode 100644 index 0000000..dbf18fe --- /dev/null +++ b/apps/argo-templates.yaml @@ -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 diff --git a/apps/children/argo-ollama-scheduler.yaml b/apps/children/argo-ollama-scheduler.yaml new file mode 100644 index 0000000..1af399e --- /dev/null +++ b/apps/children/argo-ollama-scheduler.yaml @@ -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-ollama-scheduler + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/apps/children/coderunner.yaml b/apps/children/coderunner.yaml index 03cbfaf..d98bde8 100644 --- a/apps/children/coderunner.yaml +++ b/apps/children/coderunner.yaml @@ -3,6 +3,8 @@ kind: Application metadata: name: coderunner namespace: ai + labels: + repo.ion606.com/ollama-plus: "true" spec: project: ai-stack destination: diff --git a/apps/children/kustomization.yaml b/apps/children/kustomization.yaml new file mode 100644 index 0000000..fb614ca --- /dev/null +++ b/apps/children/kustomization.yaml @@ -0,0 +1,35 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - argo-ollama-scheduler.yaml + - coderunner.yaml + - tools.yaml + - rag-server.yaml + - openwebui.yaml + - postgresql.yaml + - searxng.yaml + - browser.yaml + +generatorOptions: + disableNameSuffixHash: true + +configMapGenerator: + - name: ollama-plus-revs + literals: + - targetRevision=main + +# 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 diff --git a/apps/children/rag-server.yaml b/apps/children/rag-server.yaml index 0b5eb66..3594237 100644 --- a/apps/children/rag-server.yaml +++ b/apps/children/rag-server.yaml @@ -3,6 +3,8 @@ kind: Application metadata: name: rag-server namespace: ai + labels: + repo.ion606.com/ollama-plus: "true" spec: project: ai-stack destination: diff --git a/apps/children/tools.yaml b/apps/children/tools.yaml index 13bf5a1..6d67871 100644 --- a/apps/children/tools.yaml +++ b/apps/children/tools.yaml @@ -3,6 +3,8 @@ kind: Application metadata: name: tools namespace: ai + labels: + repo.ion606.com/ollama-plus: "true" spec: project: ai-stack destination: diff --git a/manifests/argo-schedules-api/deployment.yaml b/manifests/argo-schedules-api/deployment.yaml new file mode 100644 index 0000000..4b71f25 --- /dev/null +++ b/manifests/argo-schedules-api/deployment.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: schedules-api + namespace: argo +spec: + replicas: 1 + selector: + matchLabels: { app: schedules-api } + template: + metadata: + labels: { app: schedules-api } + spec: + serviceAccountName: schedules-api + containers: + - name: schedules-api + # TODO: build & push your image, then update below + image: ghcr.io/your-org/schedules-api:0.1.0 + imagePullPolicy: IfNotPresent + env: + - { name: PORT, value: "3000" } + - { name: NS, value: "argo" } + ports: + - { name: http, containerPort: 3000 } + readinessProbe: + tcpSocket: { port: 3000 } + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + tcpSocket: { port: 3000 } + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "200m", memory: "256Mi" } +--- +apiVersion: v1 +kind: Service +metadata: + name: schedules-api + namespace: argo +spec: + selector: { app: schedules-api } + ports: + - { name: http, port: 3000, targetPort: 3000 } + type: ClusterIP diff --git a/manifests/argo-schedules-api/rbac.yaml b/manifests/argo-schedules-api/rbac.yaml new file mode 100644 index 0000000..6f67f9a --- /dev/null +++ b/manifests/argo-schedules-api/rbac.yaml @@ -0,0 +1,47 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: schedules-api + namespace: argo +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: schedules-api + 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"] +--- +# If you need ClusterWorkflowTemplate support, create this ClusterRole and a ClusterRoleBinding +# with subject serviceAccountName: schedules-api, namespace: argo +# apiVersion: rbac.authorization.k8s.io/v1 +# kind: ClusterRole +# metadata: +# name: schedules-api-cwft-read +# rules: +# - apiGroups: ["argoproj.io"] +# resources: ["clusterworkflowtemplates"] +# verbs: ["get","list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: schedules-api + namespace: argo +subjects: + - kind: ServiceAccount + name: schedules-api + namespace: argo +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: schedules-api + diff --git a/manifests/argo-templates/workflowtemplate-hello.yaml b/manifests/argo-templates/workflowtemplate-hello.yaml new file mode 100644 index 0000000..37c73f5 --- /dev/null +++ b/manifests/argo-templates/workflowtemplate-hello.yaml @@ -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}}\""] + diff --git a/manifests/argo-templates/workflowtemplate-ollama.yaml b/manifests/argo-templates/workflowtemplate-ollama.yaml new file mode 100644 index 0000000..ec2b225 --- /dev/null +++ b/manifests/argo-templates/workflowtemplate-ollama.yaml @@ -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. + diff --git a/scheduler/.dockerignore b/scheduler/.dockerignore new file mode 100644 index 0000000..2eb32eb --- /dev/null +++ b/scheduler/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-cache +bun.lockb +.DS_Store +*.log + diff --git a/scheduler/Dockerfile b/scheduler/Dockerfile new file mode 100644 index 0000000..9421b36 --- /dev/null +++ b/scheduler/Dockerfile @@ -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 + +USER bun +EXPOSE 3000 +ENV NODE_ENV=production +CMD ["bun", "run", "server.mjs"] + diff --git a/scheduler/package.json b/scheduler/package.json new file mode 100644 index 0000000..893873c --- /dev/null +++ b/scheduler/package.json @@ -0,0 +1,14 @@ +{ + "name": "schedules-api", + "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" + } +} + diff --git a/scheduler/server.mjs b/scheduler/server.mjs new file mode 100644 index 0000000..3b8445d --- /dev/null +++ b/scheduler/server.mjs @@ -0,0 +1,123 @@ +// bun run server.mjs +// tiny schedules api to manage argo cronworkflows/workflows via k8s CRDs +// comments intentionally lowercase per original style + +import http from 'http' +import { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node' + +const GROUP = 'argoproj.io' +const VERSION = 'v1alpha1' +const CRON_PLURAL = 'cronworkflows' +const WF_PLURAL = 'workflows' +const NAMESPACE = process.env.NS || 'argo' + +// load cluster credentials (or fallback to local kubeconfig for dev) +const kc = new KubeConfig() +try { kc.loadFromCluster() } catch { kc.loadFromDefault() } +const co = kc.makeApiClient(CustomObjectsApi) + +// helper: build cron string from an iso timestamp in a tz +const cronFromISO = (iso, tz = 'America/New_York') => { + const dt = new Date(iso) + const 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), {}) + const m = Number(parts.month), d = Number(parts.day), h = Number(parts.hour), min = Number(parts.minute) + return `${min} ${h} ${d} ${m} *` +} + +// create or update a cronworkflow that runs a workflowtemplate +async function upsertCronWorkflow({ + name, when, tz = 'America/New_York', oneShot = false, + template = { name: '', clusterScope: false }, + parameters = {}, entrypoint +}) { + const schedule = when.cron ?? cronFromISO(when.iso, tz) + const args = Object.entries(parameters).map(([name, value]) => ({ name, value })) + + const body = { + apiVersion: `${GROUP}/${VERSION}`, + kind: 'CronWorkflow', + metadata: { name }, + spec: { + timezone: tz, + schedules: [schedule], + concurrencyPolicy: 'Forbid', + ...(oneShot ? { stopStrategy: { expression: 'cronworkflow.succeeded >= 1' } } : {}), + workflowSpec: { + ...(entrypoint ? { entrypoint } : {}), + arguments: args.length ? { parameters: args } : undefined, + workflowTemplateRef: { + name: template.name, + ...(template.clusterScope ? { clusterScope: true } : {}) + } + } + } + } + + // try patch, else create + try { + await co.patchNamespacedCustomObject( + GROUP, VERSION, NAMESPACE, CRON_PLURAL, name, 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 +async function runNow({ name, template, parameters = {}, entrypoint }) { + const args = Object.entries(parameters).map(([name, value]) => ({ name, value })) + const wf = { + apiVersion: `${GROUP}/${VERSION}`, + kind: 'Workflow', + metadata: { generateName: `${name}-` }, + spec: { + ...(entrypoint ? { entrypoint } : {}), + arguments: args.length ? { parameters: args } : undefined, + workflowTemplateRef: { + name: template.name, + ...(template.clusterScope ? { clusterScope: true } : {}) + } + } + } + await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, WF_PLURAL, wf) +} + +// tiny http api +const server = http.createServer(async (req, res) => { + try { + if (req.method === 'POST' && req.url === '/schedules') { + const input = JSON.parse(await new Promise(r => { + let d = ''; req.on('data', c => d += c); req.on('end', () => r(d)) + })) + await upsertCronWorkflow(input) + res.writeHead(201).end(JSON.stringify({ ok: true })) + return + } + if (req.method === 'POST' && req.url === '/run-now') { + const input = JSON.parse(await new Promise(r => { + let d = ''; req.on('data', c => d += c); req.on('end', () => r(d)) + })) + await runNow(input) + res.writeHead(201).end(JSON.stringify({ ok: true })) + return + } + if (req.method === 'DELETE' && req.url?.startsWith('/schedules/')) { + const name = decodeURIComponent(req.url.split('/').pop()) + await co.deleteNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name) + res.writeHead(204).end() + return + } + res.writeHead(404).end('not found') + } catch (e) { + res.writeHead(500).end(JSON.stringify({ ok: false, error: e.message })) + } +}) + +const port = Number(process.env.PORT) || 3000 +server.listen(port, () => console.log(`schedules api listening on :${port}`)) + diff --git a/scripts/rebuild_and_push_imgs.sh b/scripts/rebuild_and_push_imgs.sh index ff23e0a..13e6bc0 100644 --- a/scripts/rebuild_and_push_imgs.sh +++ b/scripts/rebuild_and_push_imgs.sh @@ -13,3 +13,7 @@ docker push ion606/rag-server:latest; # tools docker build -t ion606/tools:latest ./tools; docker push ion606/tools:latest; + +# scheduling +docker build -t ion606/ollama-scheduler:latest ./scheduler; +docker push ion606/ollama-scheduler:latest; diff --git a/scripts/setup.sh b/scripts/setup.sh index 1f6c7e8..6efaf44 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -12,6 +12,8 @@ minikube addons enable ingress-dns; # namespaces kubectl create namespace argocd --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) # https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/