diff --git a/.gitignore b/.gitignore index b02f485..6a1df10 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ __pycache__/ *.xml temp.* +bun.lock diff --git a/README.md b/README.md index 2d76384..6feeaac 100644 --- a/README.md +++ b/README.md @@ -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 -*Last updated: 2025-09-10* +*Last updated: 2025-09-13* > [!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/) --- diff --git a/apps/children/ollama-scheduler.yaml b/apps/children/ollama-scheduler.yaml index 1af399e..e34f886 100644 --- a/apps/children/ollama-scheduler.yaml +++ b/apps/children/ollama-scheduler.yaml @@ -13,7 +13,7 @@ spec: source: repoURL: https://git.ion606.com/ion606/ollama-plus targetRevision: main - path: manifests/argo-ollama-scheduler + path: manifests/argo-schedules-api syncPolicy: automated: prune: true diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c3d1e61..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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 diff --git a/manifests/argo-schedules-api/deployment.yaml b/manifests/argo-schedules-api/deployment.yaml index dbdb729..9ab920e 100644 --- a/manifests/argo-schedules-api/deployment.yaml +++ b/manifests/argo-schedules-api/deployment.yaml @@ -7,47 +7,42 @@ spec: replicas: 1 selector: matchLabels: - - app: ollama-scheduler + app: ollama-scheduler template: metadata: labels: - - app: ollama-scheduler - + 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: "3000" + value: "12253" - name: NS value: "argo" ports: - name: http - containerPort: 3000 - + containerPort: 12253 readinessProbe: tcpSocket: - - port: 3000 + port: 12253 initialDelaySeconds: 3 periodSeconds: 10 - livenessProbe: tcpSocket: - - port: 3000 + port: 12253 initialDelaySeconds: 10 periodSeconds: 20 - resources: requests: - - cpu: "50m" - - memory: "64Mi" + cpu: "50m" + memory: "64Mi" limits: - - cpu: "200m" - - memory: "256Mi" + cpu: "200m" + memory: "256Mi" --- apiVersion: v1 kind: Service @@ -56,9 +51,9 @@ metadata: namespace: argo spec: selector: - - app: ollama-scheduler + app: ollama-scheduler ports: - name: http - port: 3000 - targetPort: 3000 + port: 12253 + targetPort: 12253 type: ClusterIP diff --git a/scheduler/.dockerignore b/scheduler/.dockerignore index 2eb32eb..836bd61 100644 --- a/scheduler/.dockerignore +++ b/scheduler/.dockerignore @@ -1,6 +1,6 @@ node_modules npm-cache +bun.lock bun.lockb .DS_Store *.log - diff --git a/scheduler/Dockerfile b/scheduler/Dockerfile index 9421b36..aef65db 100644 --- a/scheduler/Dockerfile +++ b/scheduler/Dockerfile @@ -6,9 +6,9 @@ COPY package.json ./package.json RUN bun install --ci --production COPY server.mjs ./server.mjs +COPY public ./public USER bun -EXPOSE 3000 +EXPOSE 12253 ENV NODE_ENV=production CMD ["bun", "run", "server.mjs"] - diff --git a/scheduler/package.json b/scheduler/package.json index d7b2b0e..0d13237 100644 --- a/scheduler/package.json +++ b/scheduler/package.json @@ -8,6 +8,7 @@ "dev": "bun run --hot server.mjs" }, "dependencies": { - "@kubernetes/client-node": "^0.22.1" + "@kubernetes/client-node": "^0.22.1", + "@types/node": "^24.3.3" } } \ No newline at end of file diff --git a/scheduler/public/index.html b/scheduler/public/index.html new file mode 100644 index 0000000..a646a97 --- /dev/null +++ b/scheduler/public/index.html @@ -0,0 +1,236 @@ + + + + + + Schedules UI + + + +

Manage Your Tasks and Follow-Ups!

+ + +
+

login

+

+ enter your open webui user id (uuid). this is sent as the + x-user-id header on api requests. +

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+

+
+
+ + +
+
+

your schedules

+
+ +
+
+
+
+ + + + + + + + + + + + + +
nameschedulestztemplateentrypointone-shotactions
+
+
+ + +
+

create / update schedule

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ + + + +
+ + +
+
+
+ +
+ available workflow templates + +
+
+ + +
+

run now

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+ + + +
+ +
+
+
+
+ + + + diff --git a/scheduler/public/script.js b/scheduler/public/script.js new file mode 100644 index 0000000..494f32e --- /dev/null +++ b/scheduler/public/script.js @@ -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 = ` + ${escapeHtml(it.displayName || it.name || "")} + ${(it.schedules || []).map(escapeHtml).join("
")} + ${escapeHtml(it.timezone || "")} + ${escapeHtml(tRef)} + ${escapeHtml(it.entrypoint || "")} + ${it.oneShot ? "yes" : "no"} + + + + `; + schedulesTbody.appendChild(tr); + }); +} + +// tiny escape helper +function escapeHtml(s = "") { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +// 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 = `
  • error: ${escapeHtml( + e.message + )}
  • `; + } +}); + +// boot +paintAuth(); + +// auto-refresh if already logged in +if (state.userId) $("#refreshBtn").click(); \ No newline at end of file diff --git a/scheduler/public/style.css b/scheduler/public/style.css new file mode 100644 index 0000000..1c37bed --- /dev/null +++ b/scheduler/public/style.css @@ -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; +} \ No newline at end of file diff --git a/scheduler/server.mjs b/scheduler/server.mjs index 3b8445d..e708e1b 100644 --- a/scheduler/server.mjs +++ b/scheduler/server.mjs @@ -1,123 +1,233 @@ -// 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 fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' 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' +const GROUP = 'argoproj.io', + VERSION = 'v1alpha1', + CRON_PLURAL = 'cronworkflows', + WF_PLURAL = 'workflows', + NAMESPACE = process.env.NS || 'argo', -// load cluster credentials (or fallback to local kubeconfig for dev) -const kc = new KubeConfig() + // 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) -// helper: build cron string from an iso timestamp in a tz +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) - 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} *` + 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} *`; } -// create or update a cronworkflow that runs a workflowtemplate +// 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 + name, when, tz = 'America/New_York', oneShot = false, + template = { name: '', clusterScope: false }, + parameters = {}, entrypoint, userId }) { - const schedule = when.cron ?? cronFromISO(when.iso, tz) - const args = Object.entries(parameters).map(([name, value]) => ({ name, value })) + const schedule = when.cron ?? cronFromISO(when.iso, tz), + nameActual = scopedName(name, userId), - 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 } : {}) - } - } - } - } + 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, name, body, - undefined, undefined, undefined, - { headers: { 'content-type': 'application/merge-patch+json' } } - ) - } catch { - await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, body) - } + // 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 -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) +// 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); } -// tiny http api +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 { - 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 })) - } -}) + 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() -const port = Number(process.env.PORT) || 3000 -server.listen(port, () => console.log(`schedules api listening on :${port}`)) + // 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}`)); diff --git a/scripts/setup.sh b/scripts/setup.sh index 6efaf44..0433936 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -35,4 +35,6 @@ kubectl -n argocd get secret argocd-initial-admin-secret \ -o jsonpath='{.data.password}' | base64 -d; echo ""; echo ""; 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 ai port-forward svc/scheduler-ui 12253:12253 +kubectl -n argocd port-forward svc/argocd-server 8443:443