-
Notifications
You must be signed in to change notification settings - Fork 129
chore(examples): multitenant deploys example #2527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| FROM node:18-alpine | ||
|
|
||
| WORKDIR /app | ||
|
|
||
| # Install rivet CLI | ||
| RUN apk add --no-cache curl unzip | ||
| RUN curl -fsSL https://get.rivet.gg/install.sh | sh | ||
|
|
||
| # Copy package files and install dependencies | ||
| COPY package.json yarn.lock ./ | ||
| RUN yarn install --frozen-lockfile | ||
|
|
||
| # Copy application code | ||
| COPY . . | ||
|
|
||
| # Build the application | ||
| RUN yarn build | ||
|
|
||
| # Expose the port the app will run on | ||
| EXPOSE 3000 | ||
|
|
||
| # Create a non-root user | ||
| RUN addgroup -S appgroup && adduser -S appuser -G appgroup | ||
| USER appuser | ||
|
|
||
| # Start the application | ||
| CMD ["node", "dist/index.js"] |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,121 @@ | ||||||||||||||||||||||||||
| # Multitenant Deploys for Rivet | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| A simple Node.js service for handling multi-tenant deployments with Rivet. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ## Features | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| - Accepts source code uploads via a multipart POST request | ||||||||||||||||||||||||||
| - Validates the presence of a Dockerfile | ||||||||||||||||||||||||||
| - Deploys the code to Rivet using `rivet publish` | ||||||||||||||||||||||||||
| - Sets up a custom domain route for the application | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ## Getting Started | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ### Prerequisites | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| - Node.js | ||||||||||||||||||||||||||
| - [Rivet CLI](https://rivet.gg/docs/install) | ||||||||||||||||||||||||||
| - Rivet cloud token ([instructions on how to generate](https://rivet.gg/docs/tokens#cloud-token)) | ||||||||||||||||||||||||||
| - Rivet project ID | ||||||||||||||||||||||||||
| - For example if your project is at `https://hub.rivet.gg/projects/foobar`, the ID is `foobar` | ||||||||||||||||||||||||||
| - Rivet environment ID | ||||||||||||||||||||||||||
| - For example if your environment is at `https://hub.rivet.gg/projects/foobar/environments/prod`, the ID is `prod` | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ### Environment Variables | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| You'll need to set the following environment variables: | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||
| RIVET_CLOUD_TOKEN=your_rivet_cloud_token | ||||||||||||||||||||||||||
| RIVET_PROJECT=your_project_id | ||||||||||||||||||||||||||
| RIVET_ENVIRONMENT=your_environment_name | ||||||||||||||||||||||||||
| PORT=3000 # Optional, defaults to 3000 | ||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| You can do this by using [`export`](https://askubuntu.com/a/58828) or [dotenv](https://www.npmjs.com/package/dotenv). | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ### Developing | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||
| yarn install | ||||||||||||||||||||||||||
| yarn dev | ||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| You can now use `POST http://locahlost:3000/deploy/my-app-id`. Read more about example usage below. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ### Testing | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||
| yarn test | ||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ## API Usage | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| `POST /deploy/:appId` | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| **Request:** | ||||||||||||||||||||||||||
| - URL Path Parameter: | ||||||||||||||||||||||||||
| - `appId`: Unique identifier for the application (3-30 characters, lowercase alphanumeric with hyphens) | ||||||||||||||||||||||||||
| - Multipart form data containing: | ||||||||||||||||||||||||||
| - `Dockerfile`: A valid Dockerfile for the application (required) | ||||||||||||||||||||||||||
| - Additional files for the application | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| **Response:** | ||||||||||||||||||||||||||
| ```json | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| "success": true, | ||||||||||||||||||||||||||
| "appId": "your-app-id", | ||||||||||||||||||||||||||
| "endpoint": "https://your-app-id.example.com" | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ## Example Usage | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ```javascript | ||||||||||||||||||||||||||
| const appId = "my-app-id"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Form data that includes project files | ||||||||||||||||||||||||||
| const formData = new FormData(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const serverContent = ` | ||||||||||||||||||||||||||
| const http = require("http"); | ||||||||||||||||||||||||||
| const server = http.createServer((req, res) => { | ||||||||||||||||||||||||||
| res.writeHead(200, { "Content-Type": "text/plain" }); | ||||||||||||||||||||||||||
| res.end("Hello from " + process.env.MY_ENV_VAR); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| server.listen(8080); | ||||||||||||||||||||||||||
| `; | ||||||||||||||||||||||||||
| const serverBlob = new Blob([serverContent], { | ||||||||||||||||||||||||||
| type: "application/octet-stream" | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| formData.append("server.js", serverBlob, "server.js"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const dockerfileContent = ` | ||||||||||||||||||||||||||
| FROM node:22-alpine | ||||||||||||||||||||||||||
| WORKDIR /app | ||||||||||||||||||||||||||
| COPY . . | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # Set env var from build arg | ||||||||||||||||||||||||||
| ARG MY_ENV_VAR | ||||||||||||||||||||||||||
| ENV MY_ENV_VAR=$MY_ENV_VAR | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # Create a non-root user | ||||||||||||||||||||||||||
| RUN addgroup -S rivetgroup && adduser -S rivet -G rivetgroup | ||||||||||||||||||||||||||
| USER rivet | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| CMD ["node", "server.js"] | ||||||||||||||||||||||||||
| `; | ||||||||||||||||||||||||||
| const dockerfileBlob = new Blob([dockerfileContent], { | ||||||||||||||||||||||||||
| type: "application/octet-stream" | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| formData.append("Dockerfile", dockerfileBlob, "Dockerfile"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Run the deploy | ||||||||||||||||||||||||||
| const response = fetch(`http://localhost:3000/deploy/${appId}`, { | ||||||||||||||||||||||||||
| method: "POST", | ||||||||||||||||||||||||||
| body: formData | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||
| const { endpoint } = await response.json(); | ||||||||||||||||||||||||||
|
Comment on lines
+114
to
+119
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Example code is missing 'await' for fetch call and error handling for non-OK responses
Suggested change
|
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| { | ||
| "name": "multitenant-deploys", | ||
| "packageManager": "yarn@4.6.0", | ||
| "scripts": { | ||
| "dev": "tsx src/index.ts", | ||
| "test": "vitest run", | ||
| "build": "tsc" | ||
| }, | ||
| "dependencies": { | ||
| "@hono/node-server": "^1.7.0", | ||
| "@rivet-gg/api-full": "workspace:*", | ||
| "axios": "^1.6.7", | ||
| "hono": "^4.0.5", | ||
| "temp": "^0.9.4" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^20.11.19", | ||
| "@types/temp": "^0.9.4", | ||
| "tsx": "^4.7.0", | ||
| "typescript": "^5.3.3", | ||
| "vitest": "^1.2.2" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,155 @@ | ||||||
| import { Hono } from "hono"; | ||||||
| import { exec } from "node:child_process"; | ||||||
| import { promisify } from "node:util"; | ||||||
| import * as fs from "node:fs/promises"; | ||||||
| import * as path from "node:path"; | ||||||
| import temp from "temp"; | ||||||
| import { RivetClient } from "@rivet-gg/api-full"; | ||||||
|
|
||||||
| const execAsync = promisify(exec); | ||||||
|
|
||||||
| // Auto-track and cleanup temp directories/files | ||||||
| temp.track(); | ||||||
|
|
||||||
| // Config | ||||||
| const RIVET_CLOUD_TOKEN = process.env.RIVET_CLOUD_TOKEN; | ||||||
| const RIVET_PROJECT = process.env.RIVET_PROJECT; | ||||||
| const RIVET_ENVIRONMENT = process.env.RIVET_ENVIRONMENT; | ||||||
|
|
||||||
| if (!RIVET_CLOUD_TOKEN || !RIVET_PROJECT || !RIVET_ENVIRONMENT) { | ||||||
| throw new Error( | ||||||
| "Missing required environment variables: RIVET_CLOUD_TOKEN, RIVET_PROJECT, RIVET_ENVIRONMENT", | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| export const rivet = new RivetClient({ token: RIVET_CLOUD_TOKEN }); | ||||||
|
|
||||||
| export const app = new Hono(); | ||||||
|
|
||||||
| app.onError((err, c) => { | ||||||
| console.error("Error during operation:", err); | ||||||
| return c.json( | ||||||
| { | ||||||
| error: "Operation failed", | ||||||
| message: err instanceof Error ? err.message : String(err), | ||||||
| }, | ||||||
| 500, | ||||||
| ); | ||||||
| }); | ||||||
|
|
||||||
| app.get("/", (c) => { | ||||||
| return c.text("Multitenant Deploy Example"); | ||||||
| }); | ||||||
|
|
||||||
| app.post("/deploy/:appId", async (c) => { | ||||||
| const appId = c.req.param("appId"); | ||||||
|
|
||||||
| // Get the form data | ||||||
| const formData = await c.req.formData(); | ||||||
|
|
||||||
| if (!appId || typeof appId !== "string") { | ||||||
| return c.json({ error: "Missing or invalid appId" }, 400); | ||||||
| } | ||||||
|
|
||||||
| // Validate app ID (alphanumeric and hyphens only, 3-30 chars) | ||||||
| if (!/^[a-z0-9-]{3,30}$/.test(appId)) { | ||||||
| return c.json( | ||||||
| { | ||||||
| error: "Invalid appId format. Must be 3-30 characters, lowercase alphanumeric with hyphens.", | ||||||
| }, | ||||||
| 400, | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| // Create a temp directory for the files | ||||||
| const tempDir = await temp.mkdir("rivet-deploy-"); | ||||||
| const tempDirProject = path.join(tempDir, "project"); | ||||||
|
|
||||||
| // Process and save each file | ||||||
| let hasDockerfile = false; | ||||||
| for (const [fieldName, value] of formData.entries()) { | ||||||
| // Skip non-file fields | ||||||
| if (!(value instanceof File)) continue; | ||||||
|
|
||||||
| const filePath = path.join(tempDirProject, fieldName); | ||||||
|
|
||||||
| await fs.mkdir(path.dirname(filePath), { recursive: true }); | ||||||
|
|
||||||
| await fs.writeFile(filePath, Buffer.from(await value.arrayBuffer())); | ||||||
|
|
||||||
| if (fieldName === "Dockerfile") { | ||||||
| hasDockerfile = true; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| if (!hasDockerfile) { | ||||||
| return c.json({ error: "Dockerfile is required" }, 400); | ||||||
| } | ||||||
|
|
||||||
| // Tags unique to this app's functions | ||||||
| const appTags = { | ||||||
| // Specifies that this app is deployed by a user | ||||||
| type: "user-app", | ||||||
| // Specifies which app this function belongs to | ||||||
| // | ||||||
| // Used for attributing billing & more | ||||||
| app: appId, | ||||||
| }; | ||||||
|
|
||||||
| // Write Rivet config | ||||||
| const functionName = `fn-${appId}`; | ||||||
| const rivetConfig = { | ||||||
| functions: { | ||||||
| [functionName]: { | ||||||
| build_path: "./project/", | ||||||
| dockerfile: "./project/Dockerfile", | ||||||
| build_args: { | ||||||
| // See MY_ENV_VAR build args in Dockerfile | ||||||
| MY_ENV_VAR: "custom env var", | ||||||
| APP_ID: appId, | ||||||
| }, | ||||||
| tags: appTags, | ||||||
| route_subpaths: true, | ||||||
| strip_prefix: true, | ||||||
| resources: { cpu: 125, memory: 128 }, | ||||||
| // If you want to host at a subpath: | ||||||
| // path: "/foobar" | ||||||
| }, | ||||||
| }, | ||||||
| }; | ||||||
| await fs.writeFile( | ||||||
| path.join(tempDir, "rivet.json"), | ||||||
| JSON.stringify(rivetConfig), | ||||||
| ); | ||||||
|
|
||||||
| // Run rivet publish command | ||||||
| console.log(`Deploying app ${appId} from ${tempDir}...`); | ||||||
|
|
||||||
| // Run the deploy command | ||||||
| const deployResult = await execAsync( | ||||||
| `rivet deploy --environment ${RIVET_ENVIRONMENT} --non-interactive`, | ||||||
| { | ||||||
| cwd: tempDir, | ||||||
| }, | ||||||
| ); | ||||||
|
|
||||||
| console.log("Publish output:", deployResult.stdout); | ||||||
|
|
||||||
| // Get the function endpoint | ||||||
| const endpointResult = await execAsync( | ||||||
| `rivet function endpoint --environment prod ${functionName}`, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Environment mismatch. Using 'prod' here but RIVET_ENVIRONMENT from env vars on line 130
Suggested change
|
||||||
| { | ||||||
| cwd: tempDir, | ||||||
| }, | ||||||
| ); | ||||||
|
|
||||||
| // Strip any extra text and just get the URL | ||||||
| const endpointUrl = endpointResult.stdout.trim(); | ||||||
| console.log("Function endpoint:", endpointUrl); | ||||||
|
|
||||||
| return c.json({ | ||||||
| success: true, | ||||||
| appId, | ||||||
| endpoint: endpointUrl, | ||||||
| }); | ||||||
| }); | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { serve } from "@hono/node-server"; | ||
| import { app } from "./app"; | ||
|
|
||
| const PORT = process.env.PORT || 3000; | ||
| console.log(`Server starting on port ${PORT}...`); | ||
| serve({ | ||
| fetch: app.fetch, | ||
| port: Number(PORT), | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
syntax: Typo in example URL: 'locahlost' should be 'localhost'