Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "daily"
groups:
"github-actions":
patterns:
- "*"
26 changes: 2 additions & 24 deletions .github/workflows/release/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,28 +98,6 @@ copy_image() {
}

RAILPACK_VERSION=$(grep "RAILPACK_VERSION=" "$PROJECT_ROOT/builders/railpack/Dockerfile" | cut -d= -f 2)
RAILPACK_RELEASE_SHA=$(gh api "repos/railwayapp/railpack/commits/v$RAILPACK_VERSION" --jq '.sha')

get_railpack_image_tag() {
# Railpack container images are only published when their respective Dockerfiles and GH Actions workflows are updated, and they're only tagged with their current commit hashes.
# If we want to get the fully-qualified (i.e. non-`latest`) image tag given a version number, we need to search for the most recently-published container image before the creation date of the release.

CANDIDATES=()
for FILE in "$@"; do
# Fetch the single most recent commit for this file relative to the Release SHA
COMMIT_DATA=$(gh api "repos/railwayapp/railpack/commits?path=$FILE&sha=$RAILPACK_RELEASE_SHA&per_page=1" \
--jq '.[0] | {sha: .sha, date: .commit.committer.date}')

if [ "$COMMIT_DATA" != "null" ]; then
CANDIDATES+=("$COMMIT_DATA")
fi
done

# Sort candidates by date and pick the last (most recent) one
SHA=$(printf '%s\n' "${CANDIDATES[@]}" | jq -s -r 'sort_by(.date) | last | .sha')

echo "sha-$SHA"
}

build_images() {
echo "### Container Images" >> "$NOTES_FILE"
Expand All @@ -140,8 +118,8 @@ copy_railpack_images() {
RAILPACK_INTERNAL_RUNTIME_IMAGE="$REGISTRY_BASE/railpack-runtime:$RAILPACK_VERSION"

copy_image ".anvilops.env.railpackInternalFrontendImage" "ghcr.io/railwayapp/railpack-frontend:v$RAILPACK_VERSION" "$RAILPACK_INTERNAL_FRONTEND_IMAGE"
copy_image ".anvilops.env.railpackInternalBuilderImage" "ghcr.io/railwayapp/railpack-builder:$(get_railpack_image_tag "images/debian/build/Dockerfile" ".github/workflows/publish-builder.yml")" "$RAILPACK_INTERNAL_BUILDER_IMAGE"
copy_image ".anvilops.env.railpackInternalRuntimeImage" "ghcr.io/railwayapp/railpack-runtime:$(get_railpack_image_tag "images/debian/runtime/Dockerfile" ".github/workflows/publish-runtime.yml")" "$RAILPACK_INTERNAL_RUNTIME_IMAGE"
copy_image ".anvilops.env.railpackInternalBuilderImage" "ghcr.io/railwayapp/railpack-builder:$RAILPACK_VERSION" "$RAILPACK_INTERNAL_BUILDER_IMAGE"
copy_image ".anvilops.env.railpackInternalRuntimeImage" "ghcr.io/railwayapp/railpack-runtime:$RAILPACK_VERSION" "$RAILPACK_INTERNAL_RUNTIME_IMAGE"
}

publish_chart() {
Expand Down
28 changes: 28 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

When AnvilOps is built as a Docker image, this Node.js app serves the static files in the `frontend` directory.

## Project Structure

The backend is divided into a few major components:

| Path | Purpose | Allowed Imports | Notes |
| -------------- | ----------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/db` | Database access | | |
| `src/handlers` | API route handlers | `src/service` | Handlers should only contain the logic required to map an HTTP request to a Service function call and back to an HTTP response. They shouldn't contain any business logic. |
| `src/service` | Business logic | `src/db` | Services should explicitly list all their dependencies (repositories and other services) in their constructor so that they can be swapped out when necessary for testing. |
| `src/jobs` | Scripts run as cronjobs | | Jobs shouldn't import any AnvilOps code directly since some files have side effects (e.g. quitting if environment variables are invalid) that are unexpected in cron jobs. |

When a request is received, it'll go through a Handler first, which will call a function on its corresponding Service, which may execute database operations in the Database module or call other Services.

All of a Service's dependencies are defined in its constructor. The default instances of each Service are created in `src/service/index.ts`. At runtime, those instances are always used, and custom instances may be created with mock dependencies for testing.

All methods in the DB and Service modules should catch exceptions that reveal implementation details (e.g. Prisma errors) and rethrow them with classes enumerated in the method's `@throws` clause. The `cause` parameter should still include the original error.

Import restrictions are enforced by the `eslint-plugin-boundaries` ESLint plugin.

### Adding a new API Handler

1. Add a new entry to `paths` in the OpenAPI spec.
2. Run `npm run generate` in the `openapi` directory.
3. Create a new file in `service/` named after the operationId in the OpenAPI spec. This file should contain a class with one function, both named after the operationId. The class's constructor should receive the service's dependencies (repositories and other services) and store them as private instance variables so that the function can use them. This file shouldn't contain any HTTP implementation details like requests, responses, or status codes.
4. Create a new instance of the class you created in Step 4 in `src/service/index.ts`, plugging in default dependencies from the `db` and `service` modules.
5. Create a new file in `handlers/` named after the operationId in the OpenAPI spec. The file should contain one exported function named after the operationId plus the word "Handler". Use the HandlerMap type from `src/types.ts` to explicitly define the type of the handler function. This function should parse the request, call the corresponding Service function, catch any errors, and return a response.
6. Add the handler to the `handlers` map in `src/handlers/index.ts`.

## Setup

### GitHub App
Expand Down
112 changes: 111 additions & 1 deletion backend/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// @ts-check

import js from "@eslint/js";
import boundaries from "eslint-plugin-boundaries";
import { defineConfig } from "eslint/config";
import globals from "globals";
import tseslint from "typescript-eslint";

export default defineConfig({
files: ["**/*.{ts,tsx}"],
ignores: ["src/generated/**"],
ignores: ["src/generated/prisma/**"],
languageOptions: {
ecmaVersion: 2024,
globals: globals.node,
Expand All @@ -18,7 +19,9 @@ export default defineConfig({
linterOptions: {
reportUnusedInlineConfigs: "error",
},
plugins: { boundaries },
rules: {
...boundaries.configs.strict.rules,
"array-callback-return": "error",
"preserve-caught-error": "warn",
"no-await-in-loop": "warn",
Expand All @@ -37,6 +40,113 @@ export default defineConfig({
"error",
{ ignoreRestSiblings: true },
],
"boundaries/element-types": [
"error",
{
// https://www.jsboundaries.dev/docs/setup/rules/
default: "disallow",
rules: [
{
from: "server",
allow: ["services/index", "services/errors", "handlers", "env"],
},
{
from: "db",
allow: ["db/errors", "env"],
},
{
from: "handlers",
allow: ["services"],
importKind: "type",
},
{
from: "handlers",
allow: ["services/errors", "services/index", "express-utils"],
},
{
from: "express-utils",
allow: ["server", "handlers"],
importKind: "type",
},
{
from: "services",
allow: ["db/errors", "services/errors", "lib", "env"],
},
{
from: ["lib", "handlers", "services"],
allow: ["db"],
importKind: "type",
},
{
from: "services/index",
allow: ["db", "services"],
},
{
from: "*",
allow: ["openapi"],
importKind: "type",
},
{
from: "*",
allow: ["logger"],
},
{
from: ["db", "jobs"],
allow: ["prisma-generated"],
},
{
from: "index",
allow: ["env", "db", "server", "services/index"],
},
],
},
],
"boundaries/no-private": "off",
},
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
settings: {
"boundaries/elements": [
// Modules that can only access the modules which are directly related.
// Handlers -> Services -> DB
{ type: "services", pattern: "service/**" },
{ type: "db", pattern: "db/**" },
{ type: "handlers", pattern: "handlers/**" },
{ type: "lib", pattern: "lib/**" },
// Jobs should be separate because some files have side effects that are undesirable in jobs (e.g. validating environment variables and throwing an error if they aren't present)
{ type: "jobs", pattern: "jobs/**" },
// Exceptions to above:
// - Error classes can be accessed between layers
{
type: "services/errors",
pattern: "src/service/errors/index.ts",
mode: "full",
},
{ type: "db/errors", pattern: "src/db/errors/index.ts", mode: "full" },
// - services/index.ts contains instances of all services with default dependencies
{ type: "services/index", pattern: "src/service/index.ts", mode: "full" },
// Files that can be imported by anyone
{ type: "openapi", pattern: "src/generated/openapi.ts", mode: "full" },
{ type: "logger", pattern: "src/logger.ts", mode: "full" },
// Files that shouldn't import any AnvilOps files
{ type: "prisma-generated", pattern: "src/generated/prisma/**" },
{
type: "prisma-configs",
pattern: ["prisma/types.d.ts", "prisma.config.ts"],
mode: "full",
},
{
type: "otel-instrumentation",
pattern: "src/instrumentation.ts",
mode: "full",
},
// Separate package; should be accessed as "regclient-napi" instead of a direct file path
{ type: "regclient-napi", pattern: "regclient-napi/**" },
// Web server entrypoint
{ type: "express-utils", pattern: "src/types.ts", mode: "full" },
{ type: "index", pattern: "src/index.ts", mode: "full" },
{ type: "server", pattern: "server/**" },
// Environment variables
{ type: "env", pattern: "src/lib/env.ts", mode: "full" },
],
},
});
Loading