Skip to content

Commit 0da135c

Browse files
committed
add plan docs dir with various plan
1 parent 328c3c3 commit 0da135c

5 files changed

Lines changed: 1725 additions & 0 deletions

File tree

docs/plan/00-overview.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Constructive Functions — Project Overview
2+
3+
## Purpose
4+
5+
Knative function framework for Constructive: template-based code generation, shared runtime, per-function Docker images, and Kubernetes deployment manifests.
6+
7+
## Architecture
8+
9+
```
10+
functions/ handler.json + handler.ts (user code)
11+
12+
13+
templates/ Real files with {{placeholder}} tokens
14+
15+
16+
scripts/generate.ts Template copy + JSON merge + placeholder replacement
17+
18+
19+
generated/ Workspace packages (package.json, index.ts, Dockerfile, k8s/)
20+
│ + symlinks to handler.ts / *.d.ts
21+
22+
pnpm build TypeScript compilation → dist/
23+
24+
├──► Docker image Per-function 3-stage build (scripts/docker-build.ts)
25+
└──► K8s/Knative Manifests in generated/<name>/k8s/
26+
```
27+
28+
## Package Map
29+
30+
| Package | Path | Role |
31+
|---------|------|------|
32+
| `@constructive-io/fn-runtime` | `packages/fn-runtime/` | createFunctionServer(), FunctionHandler type, context building, GraphQL clients |
33+
| `@constructive-io/knative-job-fn` | `packages/fn-app/` | Express app factory with error middleware and request logging |
34+
| `@constructive-io/knative-job-service` | `job/service/` | KnativeJobsSvc — loads and starts functions + job orchestration |
35+
| `@constructive-io/knative-job-server` | `job/server/` | Job callback receiver |
36+
| `@constructive-io/knative-job-worker` | `job/worker/` | Job poller and dispatcher |
37+
| `@constructive-io/<name>-fn` | `generated/<name>/` | Per-function packages (auto-generated) |
38+
39+
### Workspace config (`pnpm-workspace.yaml`)
40+
```yaml
41+
packages:
42+
- 'generated/*'
43+
- 'packages/*'
44+
- 'job/*'
45+
```
46+
47+
## Key Scripts
48+
49+
| Command | Script | Purpose |
50+
|---------|--------|---------|
51+
| `pnpm generate` | `scripts/generate.ts` | Generate all function packages from templates |
52+
| `pnpm generate -- --only=<name>` | same | Generate single function |
53+
| `pnpm docker:build` | `scripts/docker-build.ts --all` | Build Docker images for all functions |
54+
| `make docker-build-<name>` | `scripts/docker-build.ts --only=<name>` | Build single function image |
55+
| `pnpm build` | `pnpm -r run build` | Compile all workspace packages |
56+
57+
## Common Patterns
58+
59+
### CJS TypeScript (Node v22+ with `--experimental-strip-types`)
60+
```typescript
61+
const fs = require('fs') as typeof import('fs');
62+
const path = require('path') as typeof import('path');
63+
```
64+
Node strips types at runtime but does NOT transform import syntax. Scripts use CJS `require()` with type assertions.
65+
66+
### Idempotent file writes
67+
```typescript
68+
function writeIfChanged(filePath: string, content: string): boolean {
69+
if (fs.existsSync(filePath)) {
70+
const existing = fs.readFileSync(filePath, 'utf-8');
71+
if (existing === content) return false;
72+
}
73+
fs.writeFileSync(filePath, content, 'utf-8');
74+
return true;
75+
}
76+
```
77+
78+
### Symlinks for handler code
79+
Generated packages symlink `handler.ts` and `*.d.ts` back to `functions/<name>/` so user code stays in one place.
80+
81+
## Completed Work
82+
83+
- [x] Template directory structure (`templates/node-graphql/` — 5 files)
84+
- [x] Template-based generator (`scripts/generate.ts` — recursive walk, placeholder replacement, JSON merge)
85+
- [x] Per-function Docker build script (`scripts/docker-build.ts`)
86+
- [x] fn-runtime package (server, context, GraphQL clients, types)
87+
- [x] fn-app package (Express factory with error middleware)
88+
- [x] job/service (KnativeJobsSvc with function loading and job orchestration)
89+
- [x] 3 functions: example, simple-email, send-email-link
90+
- [x] Docker compose dev setup
91+
- [x] K8s base manifests and overlays
92+
93+
## Workstreams (In Progress)
94+
95+
| # | Workstream | Plan File | Branch | Dependencies | Status |
96+
|---|-----------|-----------|--------|-------------|--------|
97+
| 1 | Docker CI | `01-docker-ci.md` | `feat/docker-ci` | None | Planned |
98+
| 2 | Testing | `02-testing-strategy.md` | `feat/testing` | Layer 2 needs WS3; Layer 3 needs WS1 | Planned |
99+
| 3 | Function Registry | `03-function-registry.md` | `feat/function-registry` | None | Planned |
100+
| 4 | Env Handling | `04-env-handling.md` | `feat/env-handling` | None | Planned |
101+
102+
### Parallel execution
103+
WS1, WS3, WS4 can run on separate branches simultaneously. WS2-Layer1 (unit tests) can also run in parallel. Merge order: WS1 → WS4 → WS3 → WS2 (or any order that resolves conflicts in `scripts/generate.ts` since WS3 and WS4 both modify it).
104+
105+
### Conflict zones
106+
- `scripts/generate.ts` — modified by WS3 (add `generateRegistry()`) and WS4 (add `generateEnvExample()`, env validation). If implementing on separate branches, the second to merge will need a small conflict resolution in the `FunctionManifest` interface and `main()` function.

docs/plan/01-docker-ci.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# WS1: Docker Build CI Workflow
2+
3+
**Branch**: `feat/docker-ci`
4+
**Dependencies**: None — can start immediately
5+
**Estimated files**: 1 new
6+
7+
## Context
8+
9+
Per-function Docker images are built locally via `scripts/docker-build.ts` (reads `generated/<name>/Dockerfile`). There is no CI workflow to build and push these images to GHCR.
10+
11+
### Current state
12+
- `scripts/docker-build.ts` — local build script supporting `--all`, `--only=<name>`, `--tag=<tag>`, `--registry=<registry>`
13+
- `scripts/generate.ts` — generates `generated/<name>/Dockerfile` from `templates/node-graphql/Dockerfile` with `{{name}}` replaced
14+
- Each generated Dockerfile is a 3-stage build that runs `generate.ts --only=<name>` inside the container, then `pnpm install`, `pnpm build`, `pnpm deploy`
15+
- Existing K8s CI workflow at `.github/workflows/test-k8s-deployment.yaml` only tests Knative installation, not Docker builds
16+
17+
### Reference pattern
18+
Follow `constructive-db/.github/workflows/docker.yaml`:
19+
- Matrix strategy for multiple image targets
20+
- `docker/metadata-action@v5` for tag generation
21+
- `docker/build-push-action@v5` with `push: ${{ github.event_name != 'pull_request' }}`
22+
- GHCR login only on non-PR events
23+
- GHA build cache (`cache-from: type=gha`, `cache-to: type=gha,mode=max`)
24+
- Concurrency groups
25+
26+
## Requirements
27+
28+
1. Build Docker images for every function discovered in `functions/*/handler.json`
29+
2. On **push to main**: build AND push to GHCR
30+
3. On **pull request**: build only (no push) — validates the Dockerfile works
31+
4. On **workflow_dispatch**: manual trigger, build + push
32+
5. Dynamic function discovery — no hardcoded function list in the workflow
33+
6. Per-function cache scoping to avoid cache eviction between functions
34+
7. Image naming: `ghcr.io/<owner>/<name>-fn` (matches `docker-build.ts` convention)
35+
8. Tags: latest (on default branch), git SHA (short), semver (on tags)
36+
37+
## Implementation
38+
39+
### Create `.github/workflows/docker.yaml`
40+
41+
```yaml
42+
name: Publish Docker Images
43+
44+
on:
45+
push:
46+
branches: [main]
47+
pull_request:
48+
branches: [main]
49+
workflow_dispatch: {}
50+
51+
permissions:
52+
contents: read
53+
packages: write
54+
55+
concurrency:
56+
group: ${{ github.workflow }}-${{ github.ref }}-docker
57+
cancel-in-progress: true
58+
59+
jobs:
60+
discover:
61+
name: Discover functions
62+
runs-on: ubuntu-latest
63+
outputs:
64+
matrix: ${{ steps.find.outputs.matrix }}
65+
steps:
66+
- name: Checkout
67+
uses: actions/checkout@v4
68+
69+
- name: Find functions from handler.json
70+
id: find
71+
run: |
72+
entries=$(
73+
for f in functions/*/handler.json; do
74+
[ -f "$f" ] || continue
75+
name=$(jq -r .name "$f")
76+
dir=$(dirname "$f" | xargs basename)
77+
echo "{\"name\":\"$name\",\"dir\":\"$dir\"}"
78+
done | jq -s -c '.'
79+
)
80+
echo "matrix={\"include\":$entries}" >> "$GITHUB_OUTPUT"
81+
echo "Discovered functions: $entries"
82+
83+
build:
84+
name: Build ${{ matrix.name }}-fn
85+
needs: discover
86+
runs-on: ubuntu-latest
87+
if: ${{ needs.discover.outputs.matrix != '{"include":[]}' }}
88+
89+
strategy:
90+
fail-fast: false
91+
matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
92+
93+
env:
94+
REGISTRY: ghcr.io
95+
96+
steps:
97+
- name: Checkout
98+
uses: actions/checkout@v4
99+
100+
- name: Setup Node.js
101+
uses: actions/setup-node@v4
102+
with:
103+
node-version: '22'
104+
105+
- name: Generate Dockerfile
106+
run: |
107+
node --experimental-strip-types scripts/generate.ts --only=${{ matrix.dir }}
108+
109+
- name: Set up Docker Buildx
110+
uses: docker/setup-buildx-action@v3
111+
112+
- name: Log in to GHCR
113+
if: github.event_name != 'pull_request'
114+
uses: docker/login-action@v3
115+
with:
116+
registry: ${{ env.REGISTRY }}
117+
username: ${{ github.actor }}
118+
password: ${{ secrets.GITHUB_TOKEN }}
119+
120+
- name: Docker meta
121+
id: meta
122+
uses: docker/metadata-action@v5
123+
with:
124+
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.name }}-fn
125+
tags: |
126+
type=ref,event=tag
127+
type=semver,pattern={{version}}
128+
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
129+
type=sha,format=short,prefix=
130+
131+
- name: Build and push
132+
uses: docker/build-push-action@v5
133+
with:
134+
context: .
135+
file: generated/${{ matrix.dir }}/Dockerfile
136+
push: ${{ github.event_name != 'pull_request' }}
137+
tags: ${{ steps.meta.outputs.tags }}
138+
cache-from: type=gha,scope=${{ matrix.name }}
139+
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
140+
```
141+
142+
### Design decisions
143+
144+
**Dynamic matrix via discovery job**: The `discover` job reads `functions/*/handler.json` and outputs a JSON matrix. This means adding a new function only requires creating `functions/<name>/handler.json` — no workflow changes needed.
145+
146+
**`matrix.dir` vs `matrix.name`**: The directory name (e.g., `simple-email`) may differ from the handler.json `name` field (e.g., `knative-job-example` vs dir `example`). We pass both:
147+
- `matrix.dir` — used for `--only=` flag and Dockerfile path (generate.ts filters by directory name)
148+
- `matrix.name` — used for image naming (from handler.json `name` field)
149+
150+
**No QEMU/multi-platform**: Starting with `linux/amd64` only. Multi-platform can be added later if ARM64 nodes are used in production.
151+
152+
**Per-function cache scope**: `scope=${{ matrix.name }}` ensures each function's Docker layers are cached independently. Without scoping, functions would evict each other's cache entries since GHA has a 10GB cache limit per repo.
153+
154+
**Generate before build**: The workflow runs `generate.ts --only=<dir>` to create the Dockerfile. This only needs Node.js, not pnpm or full dependencies, because generate.ts uses only Node builtins (`fs`, `path`).
155+
156+
## Verification
157+
158+
1. **PR test**: Create a PR that adds this workflow. GitHub Actions should show:
159+
- `Discover functions` job finds all 3 functions
160+
- 3 parallel `Build <name>-fn` jobs
161+
- Each builds successfully but does NOT push (PR event)
162+
163+
2. **Push test**: Merge to main. Verify:
164+
- Images appear at `ghcr.io/<owner>/simple-email-fn:latest`
165+
- Images appear at `ghcr.io/<owner>/send-email-link-fn:latest`
166+
- Short SHA tags are applied
167+
168+
3. **New function test**: Add a new `functions/test-fn/handler.json` and verify it appears as a 4th matrix job automatically.
169+
170+
4. **Manual trigger**: Use `workflow_dispatch` to trigger a build + push manually.

0 commit comments

Comments
 (0)