Skip to content

Commit 22aa874

Browse files
authored
Merge pull request #5 from constructive-io/dev/various-functions-2
2 parents 4c136e4 + c34dc03 commit 22aa874

87 files changed

Lines changed: 6806 additions & 7970 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-k8s-deployment.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ jobs:
2525
k8s-ci-test:
2626
runs-on: ubuntu-latest
2727
timeout-minutes: 45
28+
strategy:
29+
fail-fast: false
30+
matrix:
31+
function:
32+
- hello-world
33+
- llm-internal-calvin
34+
- opencode-headless
35+
- twilio-sms
36+
- llm-external
37+
- send-email-link
38+
- crypto-login
39+
- github-repo-creator
40+
- pytorch-gpu
41+
- runtime-script
42+
- rust-hello-world
43+
- simple-bash
44+
- simple-email
45+
- stripe-function
2846

2947
steps:
3048
- name: Checkout
@@ -196,6 +214,29 @@ jobs:
196214
echo "All pods (final):" && kubectl get pods -A
197215
echo "Knative services:" && kubectl get ksvc -A || true
198216
217+
- name: Setup Node.js
218+
uses: actions/setup-node@v4
219+
with:
220+
node-version: '20'
221+
222+
- name: Install pnpm
223+
uses: pnpm/action-setup@v2
224+
with:
225+
version: 9
226+
227+
- name: Install dependencies
228+
run: pnpm install --no-frozen-lockfile
229+
230+
- name: Build and Load Test Runner Image
231+
run: |
232+
make build-test-runner KIND_CLUSTER_NAME=local
233+
234+
- name: Run K8s Tests
235+
run: |
236+
# Ensure kubectl proxy port is available or managed by the runner
237+
pnpm exec ts-node scripts/test-runner.ts --function ${{ matrix.function }}
238+
239+
199240
- name: Dump diagnostics on failure
200241
if: always()
201242
run: |

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,5 @@ dist
137137
# Vite logs files
138138
vite.config.js.timestamp-*
139139
vite.config.ts.timestamp-*
140+
functions/opencode-headless/_calvincode_build
141+
functions/opencode-headless/bin/

Makefile

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
.PHONY: build clean lint test test-all build-test-runner docker-build docker-build-simple-email docker-build-send-email-link docker-push docker-push-simple-email docker-push-send-email-link
22

33
REGISTRY := ghcr.io/constructive-io/constructive-functions
4-
KIND_BIN ?= /opt/homebrew/bin/kind
4+
# Detect kind binary (search PATH, fallback to Homebrew)
5+
KIND_BIN := $(shell which kind)
6+
ifeq ($(KIND_BIN),)
7+
KIND_BIN := /opt/homebrew/bin/kind
8+
endif
9+
KIND_CLUSTER_NAME ?= interweb-local
510

611
SUBDIRS := functions/hello-world functions/simple-email functions/send-email-link functions/runtime-script
712

@@ -49,12 +54,37 @@ docker-push-send-email-link:
4954
docker push $(REGISTRY)/send-email-link:latest
5055

5156
# Kubernetes Test Runner
52-
test-k8s-all: build-test-runner
53-
@echo "Running All K8s Tests (Centralized Runner)..."
54-
# Run the centralized TS test runner
55-
npx ts-node scripts/test-runner.ts
57+
# Run All Tests inside K8s (Centralized Runner)
58+
test-k8s-all:
59+
@echo "Running all K8s tests via centralized KubernetesJS runner..."
60+
pnpm exec ts-node scripts/test-runner.ts
5661

5762
build-test-runner:
5863
@echo "Building Shared Test Runner Image..."
59-
docker build -f functions/_runtimes/node/Dockerfile.test -t constructive/function-test-runner:v2 .
60-
$(KIND_BIN) load docker-image constructive/function-test-runner:v2 --name interweb-local
64+
docker build -f functions/_runtimes/node/Dockerfile.test -t constructive/function-test-runner:v4 .
65+
$(KIND_BIN) load docker-image constructive/function-test-runner:v4 --name $(KIND_CLUSTER_NAME)
66+
67+
# Individual Test Shortcuts
68+
test-calvin:
69+
pnpm exec ts-node scripts/test-runner.ts --function llm-internal-calvin
70+
71+
test-opencode-headless:
72+
pnpm exec ts-node scripts/test-runner.ts --function opencode-headless
73+
74+
test-twilio:
75+
pnpm exec ts-node scripts/test-runner.ts --function twilio-sms
76+
77+
test-llm-external:
78+
pnpm exec ts-node scripts/test-runner.ts --function llm-external
79+
80+
test-email:
81+
pnpm exec ts-node scripts/test-runner.ts --function send-email-link
82+
83+
# Cleanup K8s Resources
84+
k8s-clean:
85+
@echo "Cleaning up K8s jobs for constructive-functions..."
86+
# Delete all jobs matching test-* or *-exec-* pattern (batch delete)
87+
@kubectl get jobs -n default --no-headers -o custom-columns=":metadata.name" | grep -E "^test-|-exec-" | xargs kubectl delete job -n default --ignore-not-found || true
88+
# Delete all pods matching test-* or *-exec-* pattern (orphaned pods) (batch delete)
89+
@kubectl get pods -n default --no-headers -o custom-columns=":metadata.name" | grep -E "^test-|-exec-" | xargs kubectl delete pod -n default --ignore-not-found || true
90+
@echo "Done."

functions/_runtimes/agentic/Dockerfile.agentic

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
3636
ENV PATH="/root/.cargo/bin:${PATH}"
3737

3838
# 3. Install PostGraphile
39-
RUN npm install -g postgraphile @graphile-contrib/pg-simplify-inflector
39+
# 3. Install PostGraphile
40+
RUN npm install -g pnpm && pnpm add -g postgraphile @graphile-contrib/pg-simplify-inflector
4041

4142
# 4. Install Ollama & Bake Models
4243
# We install Ollama, then start it in the background to pull models into the image layers.

functions/_runtimes/node/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ COPY package.json ./
77
RUN npm install -g pnpm@10.12.2 && pnpm install --prod
88

99
COPY dist ./dist
10+
COPY runner.js ./runner.js
1011

1112
ENV NODE_ENV=production
1213
ENV PORT=8080
1314

1415
USER node
1516

16-
CMD ["node", "dist/index.js"]
17+
CMD ["node", "runner.js", "dist/index.js"]
1718

functions/_runtimes/node/Dockerfile.test

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,21 @@ WORKDIR /app
99
# Ensure clean slate
1010
RUN rm -rf node_modules
1111

12-
# 3. Install Dependencies from NPM
13-
RUN npm install -g pnpm@9 && pnpm install --no-frozen-lockfile
12+
# 3. Configure PNPM Home
13+
ENV PNPM_HOME="/root/.local/share/pnpm"
14+
ENV PATH="$PNPM_HOME:$PATH"
15+
ENV SHELL="/bin/bash"
1416

15-
# 4. Build
16-
# 5. Install PGPM from NPM
17-
RUN npm install -g pgpm
17+
# 4. Install Dependencies from NPM
18+
RUN npm install -g pnpm@9 && \
19+
pnpm setup && \
20+
pnpm install --no-frozen-lockfile
21+
22+
# 5. Connect to global (not needed for pnpm v9+)
23+
# RUN pnpm route-global
24+
25+
# 6. Install PGPM from NPM
26+
RUN pnpm add -g pgpm
1827

1928
# Run as postgres user to avoid 'role root does not exist' in pgsql-test
2029
# handle existing user/group if created by apk

functions/_runtimes/node/runner.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
const path = require('path');
2+
const fs = require('fs');
3+
4+
const run = async () => {
5+
// 1. Resolve Dependencies from CWD (User's Function Context)
6+
// This logic ensures we find express/graphql-request in the function's node_modules,
7+
// regardless of where runner.js is located (Local Dev vs Docker).
8+
const resolveDep = (name) => {
9+
try {
10+
return require(require.resolve(name, { paths: [process.cwd()] }));
11+
} catch (e) {
12+
console.error(`[runner] Failed to resolve dependency '${name}' from ${process.cwd()}`);
13+
console.error(e.message);
14+
process.exit(1);
15+
}
16+
};
17+
18+
const express = resolveDep('express');
19+
const bodyParser = resolveDep('body-parser');
20+
const { GraphQLClient } = resolveDep('graphql-request');
21+
const http = require('http');
22+
const https = require('https');
23+
const { URL } = require('url');
24+
25+
// 2. Resolve User Handler
26+
const relativePath = process.argv[2] || 'dist/index.js';
27+
const absolutePath = path.resolve(process.cwd(), relativePath);
28+
29+
let userModule;
30+
try {
31+
userModule = require(absolutePath);
32+
} catch (e) {
33+
console.error(`[runner] Failed to load function at ${absolutePath}`);
34+
console.error(e.message);
35+
process.exit(1);
36+
}
37+
38+
const handler = userModule.default || userModule;
39+
40+
if (typeof handler !== 'function') {
41+
console.error(`[runner] Export at ${absolutePath} is not a function.`);
42+
process.exit(1);
43+
}
44+
45+
// 3. Setup App & Helper Functions (Ported from knative-job-fn/src/index.ts)
46+
// We implement a simplified version of the logic to avoid needing deep imports.
47+
// However, since we are replacing the shim which used `express` directly usually,
48+
// or `knative-job-fn` library...
49+
// Correct approach: The shim used `app` from `@constructive-io/knative-job-fn`.
50+
// We should try to use THAT if available, to preserve exact behavior (headers, logging).
51+
52+
let app;
53+
try {
54+
// Try to load the standard wrapper if present
55+
const jobFn = resolveDep('@constructive-io/knative-job-fn');
56+
// The library usually exports { default: { post: ..., listen: ... } } or similar?
57+
// Let's check how functions imported it: "import app from '@constructive-io/knative-job-fn';"
58+
// It exports 'default'.
59+
const lib = jobFn.default || jobFn;
60+
61+
// The library exposes an 'app' like object but 'listen' is the main entry.
62+
// But we want to inject our handler into a route.
63+
// Library usage in shim: `app.post('/', ...)`
64+
// Library implementation: `app` IS express() basically, but wrapped.
65+
66+
// Actually the library exports an object: { post: ..., listen: ... }
67+
// We can use it directly.
68+
app = lib;
69+
} catch (e) {
70+
// Fallback to raw express if wrapper missing (unlikely given package.json)
71+
console.warn('[runner] @constructive-io/knative-job-fn not found, falling back to raw express');
72+
app = express();
73+
app.use(bodyParser.json());
74+
}
75+
76+
// 4. Setup GraphQL Client
77+
const graphqlEndpoint = process.env.GRAPHQL_ENDPOINT || 'http://constructive-server:3000/graphql';
78+
if (!process.env.GRAPHQL_ENDPOINT) {
79+
// Warn if falling back, to aid debugging
80+
console.warn(`[runner] GRAPHQL_ENDPOINT not set, defaulting to internal k8s service: ${graphqlEndpoint}`);
81+
}
82+
const client = new GraphQLClient(graphqlEndpoint);
83+
84+
// 5. Setup Route
85+
app.post('/', async (req, res) => {
86+
try {
87+
const result = await handler(req.body, { client, headers: req.headers });
88+
89+
// Standard Shim Error Handling Heuristics
90+
if (result && result.error) {
91+
// Heuristics for 400 vs 500
92+
if (['Missing prompt', 'Unsupported provider', 'Missing "query" in payload',
93+
'Missing repoName or githubToken', 'Missing X-Database-Id header or DEFAULT_DATABASE_ID',
94+
'Missing required field', "Either 'html' or 'text' must be provided",
95+
"Missing address, message, or signature"].some(s => result.error.includes(s) || s === result.error)) {
96+
return res.status(400).json(result);
97+
}
98+
return res.status(500).json(result);
99+
}
100+
101+
res.status(200).json(result);
102+
} catch (e) {
103+
console.error(e);
104+
res.status(500).json({ error: e.message });
105+
}
106+
});
107+
108+
// 6. Start Server
109+
const port = Number(process.env.PORT ?? 8080);
110+
app.listen(port, () => {
111+
console.log(`[runner] Function '${relativePath}' listening on port ${port}`);
112+
});
113+
};
114+
115+
run().catch(e => {
116+
console.error('[runner] Fatal:', e);
117+
process.exit(1);
118+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
2+
import { KubernetesClient } from 'kubernetesjs';
3+
import * as fs from 'fs';
4+
import { createJobTeardown } from '../../test-utils';
5+
6+
// Mock interaction is hard without actually signing.
7+
// We will test startup for now.
8+
9+
describe('Crypto Login Function (Integration)', () => {
10+
let k8s: KubernetesClient;
11+
const NAMESPACE = 'default';
12+
let proxyProcess: any;
13+
14+
beforeAll(async () => {
15+
const { spawn } = require('child_process');
16+
proxyProcess = spawn('kubectl', ['proxy', '--port=8004']);
17+
await new Promise(resolve => setTimeout(resolve, 2000));
18+
k8s = new KubernetesClient({ restEndpoint: 'http://127.0.0.1:8004' } as any);
19+
});
20+
21+
afterAll(async () => {
22+
if (proxyProcess) proxyProcess.kill();
23+
});
24+
25+
it('should orchestrate the crypto-login job and verify startup', async () => {
26+
const jobName = `crypto-login-exec-${Math.floor(Date.now() / 1000)}`;
27+
// Initial cleanup (force)
28+
try { await k8s.deleteBatchV1NamespacedJob({ path: { namespace: NAMESPACE, name: jobName }, query: { propagationPolicy: 'Background' } }); } catch (e) { }
29+
30+
const teardown = createJobTeardown(k8s, NAMESPACE, jobName);
31+
32+
// We run a simple startup test here.
33+
// Logic verification for signatures (ETH, SOL, BTC) is best done via unit tests or inside the pod if we can curl it.
34+
// For integration, we just check it stands up.
35+
// TODO: Enhance to `curl` the pod with signatures if possible, but requires generating valid signatures in test code.
36+
37+
const jobManifest = {
38+
apiVersion: 'batch/v1',
39+
kind: 'Job',
40+
metadata: { name: jobName, namespace: NAMESPACE, labels: { "job-name": jobName, "app": "crypto-login" } },
41+
spec: {
42+
backoffLimit: 0,
43+
template: {
44+
metadata: { labels: { "job-name": jobName } },
45+
spec: {
46+
restartPolicy: 'Never',
47+
containers: [{
48+
name: 'crypto-login',
49+
image: 'constructive/function-test-runner:v4',
50+
imagePullPolicy: "IfNotPresent",
51+
command: ["npx", "ts-node", "functions/_runtimes/node/runner.js", "functions/crypto-login/src/index.ts"],
52+
env: [
53+
{ name: "PORT", value: "8080" },
54+
{ name: "PGHOST", value: "postgres" },
55+
{ name: "PGPASSWORD", value: process.env.PGPASSWORD },
56+
{ name: "STRIPE_PUBLISHABLE_KEY", value: process.env.STRIPE_PUBLISHABLE_KEY },
57+
{ name: "STRIPE_SECRET_KEY", value: process.env.STRIPE_SECRET_KEY },
58+
{ name: "TWILIO_ACCOUNT_SID", value: process.env.TWILIO_ACCOUNT_SID },
59+
{ name: "TWILIO_AUTH_TOKEN", value: process.env.TWILIO_AUTH_TOKEN },
60+
{ name: "CALVIN_API_KEY", value: process.env.CALVIN_API_KEY },
61+
{ name: "OPENAI_API_KEY", value: process.env.OPENAI_API_KEY }
62+
]
63+
}]
64+
}
65+
}
66+
}
67+
};
68+
69+
await k8s.createBatchV1NamespacedJob({ path: { namespace: NAMESPACE }, body: jobManifest, query: {} });
70+
71+
let success = false;
72+
let logsResponse = '';
73+
let podName = '';
74+
75+
for (let i = 0; i < 30; i++) {
76+
try {
77+
if (!podName) {
78+
const pods = await k8s.listCoreV1NamespacedPod({ path: { namespace: NAMESPACE }, query: { labelSelector: `job-name=${jobName}` } });
79+
if (pods.items && pods.items.length > 0) podName = pods.items[0].metadata.name;
80+
}
81+
if (podName) {
82+
try {
83+
const res = await fetch(`http://127.0.0.1:8004/api/v1/namespaces/${NAMESPACE}/pods/${podName}/log?tailLines=50`);
84+
const logs = await res.text();
85+
if (logs.includes('listening on port')) {
86+
success = true;
87+
logsResponse = logs;
88+
break;
89+
}
90+
logsResponse = logs;
91+
} catch (e) { }
92+
}
93+
} catch (e) { }
94+
await new Promise(r => setTimeout(r, 2000));
95+
}
96+
97+
if (!success) throw new Error(`Crypto Login Service Failed: ${logsResponse}`);
98+
expect(success).toBe(true); // Just test startup
99+
100+
await teardown();
101+
}, 120000);
102+
});

0 commit comments

Comments
 (0)