feat: initial lunarfront-manager app
This commit is contained in:
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
Default to using Bun instead of Node.js.
|
||||||
|
|
||||||
|
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||||
|
- Use `bun test` instead of `jest` or `vitest`
|
||||||
|
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||||
|
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||||
|
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||||
|
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||||
|
- Bun automatically loads .env, so don't use dotenv.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||||
|
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||||
|
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||||
|
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||||
|
- `WebSocket` is built-in. Don't use `ws`.
|
||||||
|
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||||
|
- Bun.$`ls` instead of execa.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use `bun test` to run tests.
|
||||||
|
|
||||||
|
```ts#index.test.ts
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
|
||||||
|
test("hello world", () => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
```ts#index.ts
|
||||||
|
import index from "./index.html"
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
routes: {
|
||||||
|
"/": index,
|
||||||
|
"/api/users/:id": {
|
||||||
|
GET: (req) => {
|
||||||
|
return new Response(JSON.stringify({ id: req.params.id }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// optional websocket support
|
||||||
|
websocket: {
|
||||||
|
open: (ws) => {
|
||||||
|
ws.send("Hello, world!");
|
||||||
|
},
|
||||||
|
message: (ws, message) => {
|
||||||
|
ws.send(message);
|
||||||
|
},
|
||||||
|
close: (ws) => {
|
||||||
|
// handle close
|
||||||
|
}
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||||
|
|
||||||
|
```html#index.html
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
<script type="module" src="./frontend.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
With the following `frontend.tsx`:
|
||||||
|
|
||||||
|
```tsx#frontend.tsx
|
||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
// import .css files directly and it works
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const root = createRoot(document.body);
|
||||||
|
|
||||||
|
export default function Frontend() {
|
||||||
|
return <h1>Hello, world!</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.render(<Frontend />);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run index.ts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun --hot ./index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM oven/bun:1 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS install
|
||||||
|
COPY package.json bun.lock* ./
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
FROM base
|
||||||
|
COPY --from=install /app/node_modules ./node_modules
|
||||||
|
COPY src ./src
|
||||||
|
COPY frontend ./frontend
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["bun", "run", "src/index.ts"]
|
||||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# lunarfront-manager
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||||
170
bun.lock
Normal file
170
bun.lock
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "lunarfront-manager",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
|
"fastify": "^5.8.4",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/node": "^25.5.2",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="],
|
||||||
|
|
||||||
|
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
|
||||||
|
|
||||||
|
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
||||||
|
|
||||||
|
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
|
||||||
|
|
||||||
|
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
|
||||||
|
|
||||||
|
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
||||||
|
|
||||||
|
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
||||||
|
|
||||||
|
"@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="],
|
||||||
|
|
||||||
|
"@fastify/static": ["@fastify/static@9.0.0", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA=="],
|
||||||
|
|
||||||
|
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||||
|
|
||||||
|
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||||
|
|
||||||
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
|
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||||
|
|
||||||
|
"avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
|
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="],
|
||||||
|
|
||||||
|
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||||
|
|
||||||
|
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||||
|
|
||||||
|
"fastify": ["fastify@5.8.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="],
|
||||||
|
|
||||||
|
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
|
||||||
|
|
||||||
|
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||||
|
|
||||||
|
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
|
||||||
|
|
||||||
|
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||||
|
|
||||||
|
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||||
|
|
||||||
|
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
||||||
|
|
||||||
|
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
|
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||||
|
|
||||||
|
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
|
||||||
|
|
||||||
|
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||||
|
|
||||||
|
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||||
|
|
||||||
|
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||||
|
|
||||||
|
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||||
|
|
||||||
|
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||||
|
|
||||||
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
|
||||||
|
|
||||||
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||||
|
|
||||||
|
"safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
|
||||||
|
|
||||||
|
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||||
|
|
||||||
|
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
|
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
|
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||||
|
|
||||||
|
"toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
|
||||||
|
|
||||||
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
68
frontend/index.html
Normal file
68
frontend/index.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LunarFront Manager</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #0f0f0f; color: #e0e0e0; min-height: 100vh; padding: 2rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 2rem; color: #fff; }
|
||||||
|
.card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px; padding: 1.5rem; max-width: 480px; }
|
||||||
|
h2 { font-size: 1rem; margin-bottom: 1rem; color: #aaa; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.4rem; }
|
||||||
|
input { width: 100%; padding: 0.6rem 0.8rem; background: #111; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 0.95rem; margin-bottom: 1rem; }
|
||||||
|
input:focus { outline: none; border-color: #555; }
|
||||||
|
button { padding: 0.6rem 1.2rem; background: #2563eb; border: none; border-radius: 6px; color: #fff; font-size: 0.95rem; cursor: pointer; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
button:disabled { background: #333; cursor: not-allowed; }
|
||||||
|
.status { margin-top: 1rem; font-size: 0.85rem; padding: 0.6rem 0.8rem; border-radius: 6px; display: none; }
|
||||||
|
.status.success { background: #14532d; color: #86efac; display: block; }
|
||||||
|
.status.error { background: #450a0a; color: #fca5a5; display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>LunarFront Manager</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Provision Customer</h2>
|
||||||
|
<label for="name">Customer Slug</label>
|
||||||
|
<input id="name" type="text" placeholder="acme-shop" pattern="[a-z0-9-]+" />
|
||||||
|
<button id="provision-btn" onclick="provision()">Provision</button>
|
||||||
|
<div id="provision-status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function provision() {
|
||||||
|
const name = document.getElementById('name').value.trim();
|
||||||
|
const btn = document.getElementById('provision-btn');
|
||||||
|
const status = document.getElementById('provision-status');
|
||||||
|
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Provisioning...';
|
||||||
|
status.className = 'status';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message ?? 'Unknown error');
|
||||||
|
status.textContent = `✓ ${data.slug} provisioned successfully`;
|
||||||
|
status.className = 'status success';
|
||||||
|
document.getElementById('name').value = '';
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `✗ ${err.message}`;
|
||||||
|
status.className = 'status error';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Provision';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "lunarfront-manager",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"start": "bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/node": "^25.5.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
|
"fastify": "^5.8.4",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/index.ts
Normal file
20
src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import staticFiles from "@fastify/static";
|
||||||
|
import { join } from "path";
|
||||||
|
import { customerRoutes } from "./routes/customers";
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
app.register(staticFiles, {
|
||||||
|
root: join(import.meta.dir, "../frontend"),
|
||||||
|
prefix: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
app.register(customerRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
|
app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" }, (err) => {
|
||||||
|
if (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
14
src/lib/config.ts
Normal file
14
src/lib/config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT ?? 3000),
|
||||||
|
doToken: process.env.DO_API_TOKEN!,
|
||||||
|
doDbClusterId: process.env.DO_DB_CLUSTER_ID!,
|
||||||
|
gitSshKey: process.env.GIT_SSH_KEY!,
|
||||||
|
gitRepoUrl: process.env.GIT_REPO_URL ?? "ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git",
|
||||||
|
dbUrl: process.env.DATABASE_URL!,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(config)) {
|
||||||
|
if (val === undefined || val === "") {
|
||||||
|
throw new Error(`Missing required env var for config key: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/lib/k8s.ts
Normal file
78
src/lib/k8s.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
// Read in-cluster service account token and CA
|
||||||
|
const SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||||
|
const K8S_API = process.env.KUBERNETES_SERVICE_HOST
|
||||||
|
? `https://${process.env.KUBERNETES_SERVICE_HOST}:${process.env.KUBERNETES_SERVICE_PORT}`
|
||||||
|
: "https://kubernetes.default.svc";
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
return readFileSync(SA_TOKEN_PATH, "utf-8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function k8sFetch(path: string, options: RequestInit = {}) {
|
||||||
|
const res = await fetch(`${K8S_API}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token()}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
// @ts-ignore - bun supports this
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`k8s API ${options.method ?? "GET"} ${path} → ${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSecret(namespace: string, name: string): Promise<Record<string, string>> {
|
||||||
|
const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`);
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(secret.data as Record<string, string>).map(([k, v]) => [k, Buffer.from(v, "base64").toString()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchSecret(namespace: string, name: string, data: Record<string, string>) {
|
||||||
|
const encoded = Object.fromEntries(
|
||||||
|
Object.entries(data).map(([k, v]) => [k, Buffer.from(v).toString("base64")])
|
||||||
|
);
|
||||||
|
return k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||||
|
body: JSON.stringify({ data: encoded }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchConfigMap(namespace: string, name: string, data: Record<string, string>) {
|
||||||
|
return k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||||
|
body: JSON.stringify({ data }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfigMap(namespace: string, name: string): Promise<Record<string, string>> {
|
||||||
|
const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`);
|
||||||
|
return cm.data ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createArgoCDApp(manifest: object) {
|
||||||
|
return k8sFetch(`/apis/argoproj.io/v1alpha1/namespaces/argocd/applications`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(manifest),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rolloutRestart(namespace: string, deployment: string) {
|
||||||
|
return k8sFetch(`/apis/apps/v1/namespaces/${namespace}/deployments/${deployment}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
spec: { template: { metadata: { annotations: { "kubectl.kubernetes.io/restartedAt": new Date().toISOString() } } } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
46
src/routes/customers.ts
Normal file
46
src/routes/customers.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do";
|
||||||
|
import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer";
|
||||||
|
import { addCustomerChart, removeCustomerChart } from "../services/git";
|
||||||
|
|
||||||
|
const ProvisionSchema = z.object({
|
||||||
|
name: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"),
|
||||||
|
appVersion: z.string().default("latest"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function customerRoutes(app: FastifyInstance) {
|
||||||
|
app.post("/customers", async (req, reply) => {
|
||||||
|
const body = ProvisionSchema.parse(req.body);
|
||||||
|
const slug = body.name;
|
||||||
|
|
||||||
|
app.log.info({ slug }, "provisioning customer");
|
||||||
|
|
||||||
|
const [, user] = await Promise.all([
|
||||||
|
createDatabase(slug),
|
||||||
|
createDatabaseUser(slug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await addCustomerToPool(slug, user.password);
|
||||||
|
addCustomerChart(slug, body.appVersion);
|
||||||
|
|
||||||
|
app.log.info({ slug }, "customer provisioned");
|
||||||
|
return reply.code(201).send({ slug, status: "provisioned" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/customers/:slug", async (req, reply) => {
|
||||||
|
const { slug } = req.params as { slug: string };
|
||||||
|
|
||||||
|
app.log.info({ slug }, "deprovisioning customer");
|
||||||
|
|
||||||
|
removeCustomerChart(slug);
|
||||||
|
await removeCustomerFromPool(slug);
|
||||||
|
await Promise.all([
|
||||||
|
deleteDatabase(slug),
|
||||||
|
deleteDatabaseUser(slug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app.log.info({ slug }, "customer deprovisioned");
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
48
src/services/do.ts
Normal file
48
src/services/do.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { config } from "../lib/config";
|
||||||
|
|
||||||
|
const DO_API = "https://api.digitalocean.com/v2";
|
||||||
|
|
||||||
|
async function doFetch(path: string, options: RequestInit = {}) {
|
||||||
|
const res = await fetch(`${DO_API}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${config.doToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`DO API ${options.method ?? "GET"} ${path} → ${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDatabase(name: string) {
|
||||||
|
return doFetch(`/databases/${config.doDbClusterId}/dbs`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDatabaseUser(name: string): Promise<{ name: string; password: string }> {
|
||||||
|
const res = await doFetch(`/databases/${config.doDbClusterId}/users`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
return res.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDatabaseUser(name: string) {
|
||||||
|
return doFetch(`/databases/${config.doDbClusterId}/users/${name}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDatabase(name: string) {
|
||||||
|
return doFetch(`/databases/${config.doDbClusterId}/dbs/${name}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDatabaseHost(): Promise<string> {
|
||||||
|
const res = await doFetch(`/databases/${config.doDbClusterId}`);
|
||||||
|
return res.database.connection.host;
|
||||||
|
}
|
||||||
76
src/services/git.ts
Normal file
76
src/services/git.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { execSync } from "child_process";
|
||||||
|
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { config } from "../lib/config";
|
||||||
|
|
||||||
|
function withRepo<T>(fn: (dir: string) => T): T {
|
||||||
|
const keyPath = join(tmpdir(), `manager-ssh-key-${Date.now()}`);
|
||||||
|
const dir = join(tmpdir(), `lunarfront-charts-${Date.now()}`);
|
||||||
|
|
||||||
|
writeFileSync(keyPath, config.gitSshKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o StrictHostKeyChecking=no`,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`git clone ${config.gitRepoUrl} ${dir}`, { env, stdio: "pipe" });
|
||||||
|
execSync(`git -C ${dir} config user.email "manager@lunarfront.tech"`, { env });
|
||||||
|
execSync(`git -C ${dir} config user.name "lunarfront-manager"`, { env });
|
||||||
|
const result = fn(dir);
|
||||||
|
execSync(`git -C ${dir} push origin main`, { env, stdio: "pipe" });
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
rmSync(keyPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCustomerChart(slug: string, appVersion: string) {
|
||||||
|
withRepo((dir) => {
|
||||||
|
const manifest = buildArgoCDApp(slug, appVersion);
|
||||||
|
writeFileSync(join(dir, "customers", `${slug}.yaml`), manifest);
|
||||||
|
execSync(`git -C ${dir} add customers/${slug}.yaml`);
|
||||||
|
execSync(`git -C ${dir} commit -m "feat: provision customer ${slug}"`, { env: process.env });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCustomerChart(slug: string) {
|
||||||
|
withRepo((dir) => {
|
||||||
|
rmSync(join(dir, "customers", `${slug}.yaml`), { force: true });
|
||||||
|
execSync(`git -C ${dir} add customers/${slug}.yaml`);
|
||||||
|
execSync(`git -C ${dir} commit -m "chore: deprovision customer ${slug}"`, { env: process.env });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArgoCDApp(slug: string, appVersion: string): string {
|
||||||
|
return `apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: customer-${slug}
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
sources:
|
||||||
|
- repoURL: git.lunarfront.tech/ryan/lunarfront-app
|
||||||
|
chart: lunarfront
|
||||||
|
targetRevision: "${appVersion}"
|
||||||
|
helm:
|
||||||
|
valueFiles:
|
||||||
|
- $values/customers/${slug}.yaml
|
||||||
|
- repoURL: ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git
|
||||||
|
targetRevision: main
|
||||||
|
ref: values
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: customer-${slug}
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
|
`;
|
||||||
|
}
|
||||||
50
src/services/pgbouncer.ts
Normal file
50
src/services/pgbouncer.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { getConfigMap, getSecret, patchConfigMap, patchSecret, rolloutRestart } from "../lib/k8s";
|
||||||
|
|
||||||
|
const NAMESPACE = "pgbouncer";
|
||||||
|
const DO_HOST = "lunarfront-postgres-do-user-35277853-0.e.db.ondigitalocean.com";
|
||||||
|
const DO_PORT = 25060;
|
||||||
|
|
||||||
|
export async function addCustomerToPool(slug: string, password: string) {
|
||||||
|
await Promise.all([
|
||||||
|
addDbEntry(slug),
|
||||||
|
addUserEntry(slug, password),
|
||||||
|
]);
|
||||||
|
await rolloutRestart(NAMESPACE, "pgbouncer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCustomerFromPool(slug: string) {
|
||||||
|
await Promise.all([
|
||||||
|
removeDbEntry(slug),
|
||||||
|
removeUserEntry(slug),
|
||||||
|
]);
|
||||||
|
await rolloutRestart(NAMESPACE, "pgbouncer");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDbEntry(slug: string) {
|
||||||
|
const cm = await getConfigMap(NAMESPACE, "pgbouncer-config");
|
||||||
|
const ini = cm["pgbouncer.ini"];
|
||||||
|
const newLine = ` ${slug} = host=${DO_HOST} port=${DO_PORT} dbname=${slug} user=${slug} pool_mode=session pool_size=3`;
|
||||||
|
const updated = ini.replace("[pgbouncer]", `${newLine}\n [pgbouncer]`);
|
||||||
|
await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDbEntry(slug: string) {
|
||||||
|
const cm = await getConfigMap(NAMESPACE, "pgbouncer-config");
|
||||||
|
const ini = cm["pgbouncer.ini"];
|
||||||
|
const updated = ini.split("\n").filter((l) => !l.includes(`dbname=${slug}`)).join("\n");
|
||||||
|
await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserEntry(slug: string, password: string) {
|
||||||
|
const secret = await getSecret(NAMESPACE, "pgbouncer-userlist");
|
||||||
|
const userlist = secret["userlist.txt"];
|
||||||
|
const updated = `${userlist}\n"${slug}" "${password}"`;
|
||||||
|
await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUserEntry(slug: string) {
|
||||||
|
const secret = await getSecret(NAMESPACE, "pgbouncer-userlist");
|
||||||
|
const userlist = secret["userlist.txt"];
|
||||||
|
const updated = userlist.split("\n").filter((l) => !l.startsWith(`"${slug}"`)).join("\n");
|
||||||
|
await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated });
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user