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
140 changes: 140 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Deployment Guide for Google Cloud Run

This guide outlines the steps to deploy the RSS Reader application to Google Cloud Run, including setting up the PostgreSQL database and background update tasks.

## Prerequisites

* **Google Cloud Platform Account** with billing enabled.
* **gcloud CLI** installed and authenticated.
* **Existing Docker Image** hosted in Google Container Registry (GCR) or Artifact Registry.

## 1. Environment Setup

Define the following environment variables for your deployment.

```bash
export PROJECT_ID="your-gcp-project-id"
export REGION="us-central1" # Or your preferred region
export IMAGE_URL="docker.pkg.dev/your-gcp-project-id/your-repo/your-image:latest"
export DB_INSTANCE_NAME="rss-postgres"
export DB_NAME="rss"
export DB_USER="rss_user"
export DB_PASSWORD="your-secure-password"
export SERVICE_NAME="rss-reader"
export JOB_TOKEN="your-secret-job-token" # Generate a strong random string
export JWT_SECRET="your-secure-jwt-secret" # Generate a strong random string for token signing
export OAUTH_CLIENT_ID="your-google-oauth-client-id"
export OAUTH_CLIENT_SECRET="your-google-oauth-client-secret"
export GOOGLE_API_KEY="your-google-gemini-api-key"
```

## 2. Infrastructure Setup

### Create Cloud SQL Instance

Create a PostgreSQL instance (if you haven't already).

```bash
gcloud sql instances create $DB_INSTANCE_NAME \
--database-version=POSTGRES_15 \
--cpu=1 \
--memory=3840MiB \
--region=$REGION
```

Create the database and user.

```bash
gcloud sql databases create $DB_NAME --instance=$DB_INSTANCE_NAME

gcloud sql users create $DB_USER \
--instance=$DB_INSTANCE_NAME \
--password=$DB_PASSWORD
```

## 3. Configuration

### Service Definition
Create a `service.yaml` file with the following content. This defines the Cloud Run service.

**Important:** Replace placeholders (like `YOUR_PROJECT_ID`, `YOUR_IMAGE_URL`) with your actual values or use `envsubst`.

```yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: rss-reader
annotations:
run.googleapis.com/maxScale: '1'
run.googleapis.com/launch-stage: BETA
spec:
template:
metadata:
annotations:
run.googleapis.com/cloudsql-instances: ${PROJECT_ID}:${REGION}:${DB_INSTANCE_NAME}
run.googleapis.com/execution-environment: gen1
run.googleapis.com/startup-cpu-boost: 'true'
spec:
containers:
- image: IMAGE_URL
name: rss-reader-app
ports:
- containerPort: 8080
env:
- name: DATASOURCE_URL
value: "jdbc:postgresql:///rss?cloudSqlInstance=$PROJECT_ID:$REGION:$DB_INSTANCE_NAME&socketFactory=com.google.cloud.sql.postgres.SocketFactory&user=$DB_USER&password=$DB_PASSWORD"
- name: CLIENT_ID
value: "$OAUTH_CLIENT_ID"
- name: CLIENT_SECRET
value: "$OAUTH_CLIENT_SECRET"
- name: GOOGLE_API_KEY
value: "$GOOGLE_API_KEY"
- name: JOB_TOKEN
value: "$JOB_TOKEN"
- name: JWT_SECRET
value: "$JWT_SECRET"
- name: SERVER_URL
value: "https://rss-reader-PROJECT_ID.REGION.run.app" # Update after first deploy if needed
- name: CORS_URL
value: "https://rss-reader-PROJECT_ID.REGION.run.app" # Update after first deploy if needed
resources:
limits:
cpu: 1000m
memory: 512Mi
startupProbe:
failureThreshold: 1
periodSeconds: 240
tcpSocket:
port: 8080
timeoutSeconds: 240
timeoutSeconds: 300
```

## 4. Deploy to Cloud Run

Deploy using the `service.yaml`.

```bash
envsubst < service.yaml | gcloud run services replace - --region=$REGION
```

## 5. Configure Cloud Scheduler

Set up a job to trigger the feed update every 10 minutes.

```bash
# Get the Service URL
SERVICE_URL=$(gcloud run services describe $SERVICE_NAME --platform managed --region $REGION --format "value(status.url)")

gcloud scheduler jobs create http rss-update-job \
--schedule="*/10 * * * *" \
--uri="$SERVICE_URL/api/jobs/update" \
--http-method=POST \
--headers="Authorization=Bearer $JOB_TOKEN" \
--location=$REGION \
--description="Trigger RSS feed updates"
```

## 6. Database Migrations

The application runs Flyway migrations automatically on startup. No manual schema application is required.
46 changes: 27 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# RSS Reader

A modern, web-based RSS reader application built with Scala, Scala.js, and Laminar. It provides a simple and clean interface for reading your favorite RSS feeds, with the ability to generate summaries of unread articles.
A modern, containerized RSS reader application built with Scala, Scala.js, and Laminar. Designed for cloud-native deployment (optimized for Google Cloud Run), it provides a simple and clean interface for reading your favorite RSS feeds, with AI-powered summaries of unread articles.

## Features

Expand All @@ -12,7 +12,6 @@ A modern, web-based RSS reader application built with Scala, Scala.js, and Lamin
- **AI-Powered Summaries**: Generate summaries of all your unread articles using Google's Generative AI.
- **Secure Authentication**: Authentication is handled securely via Google OAuth2.
- **Responsive Design**: The application is designed to work on both desktop and mobile browsers.
- **Observability**: Built-in metrics collection with OpenTelemetry, exportable to Prometheus and visualizable in Grafana.

## Tech Stack

Expand All @@ -25,9 +24,6 @@ A modern, web-based RSS reader application built with Scala, Scala.js, and Lamin
- [Flyway](https://flywaydb.org/): For database migrations.
- [circe](https://circe.github.io/circe/): For JSON manipulation.
- [PureConfig](https://pureconfig.github.io/): For loading configuration.
- [OpenTelemetry](https://opentelemetry.io/): For metrics collection and observability.
- [Prometheus](https://prometheus.io/): For metrics storage and querying.
- [Grafana](https://grafana.com/): For metrics visualization and dashboards.

### Frontend

Expand All @@ -45,38 +41,36 @@ A modern, web-based RSS reader application built with Scala, Scala.js, and Lamin
- [Node.js 20](https://nodejs.org/) and [npm](https://www.npmjs.com/) (version specified in `.nvmrc`)
- [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/)

### Running with Docker (Recommended)
### Running Locally with Docker

This is the easiest way to run the application.
This is the easiest way to test the application locally in a production-like environment.

1. **Set up environment variables**:
You'll need to provide your Google OAuth credentials. Create a `.env` file in the `scripts/local-docker` directory with the following content:
You'll need to provide your Google OAuth credentials and Gemini API key. Create a `.env` file in the `scripts/local-docker` directory with the following content:
```
CLIENT_ID=your_google_client_id
CLIENT_SECRET=your_google_client_secret
GOOGLE_API_KEY=your_google_ai_api_key
JWT_SECRET=your_secure_jwt_secret
```

2. **Build Docker images**:
This command will build the Docker image.
This command will build the Docker image locally.
```bash
sbt buildImage
```

3. **Run the application**:
Use Docker Compose to start all the services.
Use Docker Compose to start all the services (App, Postgres, Caddy).
```bash
docker-compose -f scripts/local-docker/docker-compose.yml up
```
The application will be available at:
- Main app: `http://localhost`
- Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3000` (default credentials: admin/admin)
- Metrics endpoint: `http://localhost:9464/metrics`

### Local Development

This setup is for actively developing the application.
This setup is for actively developing the application with hot-reloading where possible.

1. **Start the database**:
Prepare and start a PostgreSQL database instance.
Expand All @@ -88,6 +82,7 @@ This setup is for actively developing the application.
export CLIENT_ID=your_google_client_id
export CLIENT_SECRET=your_google_client_secret
export GOOGLE_API_KEY=your_google_ai_api_key
export JWT_SECRET=your_secure_jwt_secret
sbt server/run
```
The server will be running on `http://localhost`.
Expand All @@ -107,16 +102,29 @@ The application is configured using environment variables.
| `SERVER_URL` | The public URL of the server. Used for OAuth redirect URI. | `https://localhost` | No |
| `CORS_URL` | The allowed origin for CORS requests. | `https://localhost` | No |
| `GOOGLE_API_KEY` | The API key for Google's Generative AI. | - | For summary feature |
| `JOB_TOKEN` | Secret token for triggering background jobs via HTTP. | - | No |
| `JWT_SECRET` | Secret string used for signing JWT tokens. | - | **Yes** |
| `REGISTRY` | The Docker registry to push the image to | - | No |

## Deployment

The Docker image built with `sbt buildImage` can be used for production deployment. The image includes both the backend server and frontend assets. You can adapt the `scripts/local-docker/docker-compose.yml` file for your production environment. Remember to configure all the necessary environment variables.
The application is optimized for deployment as a **Google Cloud Run** service. The container image includes both the backend server and the pre-built frontend assets.

To push the image to a registry, set the `REGISTRY` environment variable and run:
```bash
sbt pushImage
```
For a comprehensive step-by-step deployment guide, including Cloud SQL and Cloud Scheduler setup, please refer to **[DEPLOY.md](DEPLOY.md)**.

### Building and Pushing to Registry

To build the production image and push it to your configured container registry:

1. Set the `REGISTRY` environment variable:
```bash
export REGISTRY=docker.pkg.dev/your-project/your-repo/rss-reader
```

2. Run the push command:
```bash
sbt pushImage
```

## License

Expand Down
73 changes: 52 additions & 21 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import com.typesafe.sbt.SbtNativePackager.autoImport.NativePackagerHelper.*
import com.typesafe.sbt.packager.docker.Cmd
import com.typesafe.sbt.packager.docker.*
import org.scalajs.linker.interface.ModuleSplitStyle

import scala.sys.process.*

lazy val projectVersion = "2.4.3"
lazy val projectVersion = "2.4.4-gcr"
lazy val organizationName = "ru.trett"
lazy val scala3Version = "3.7.4"
lazy val circeVersion = "0.14.15"
lazy val htt4sVersion = "1.0.0-M45"
lazy val logs4catVersion = "2.7.1"
lazy val otel4sVersion = "0.14.0"
lazy val doobieVersion = "1.0.0-RC11"
lazy val customScalaOptions = Seq("-Wunused:imports", "-rewrite", "-source:3.4-migration")

Expand Down Expand Up @@ -68,22 +67,65 @@ lazy val client = project
lazy val server = project
.in(file("server"))
.dependsOn(shared.jvm)
.enablePlugins(JavaAppPackaging, DockerPlugin)
.enablePlugins(JavaAppPackaging, DockerPlugin, GraalVMNativeImagePlugin)
.settings(
version := projectVersion,
organization := organizationName,
scalaVersion := scala3Version,
name := "server",
dockerBaseImage := "eclipse-temurin:21-jre-jammy",
dockerPermissionStrategy := DockerPermissionStrategy.None,
dockerBaseImage := "debian:12-slim",
dockerCommands := {
val commands = dockerCommands.value
val filteredCommands = commands.filter {
case Cmd("RUN", _*) => false
case Cmd("USER", _*) => false
case Cmd("ENTRYPOINT", _*) => false
case Cmd("CMD", _*) => false
case Cmd("WORKDIR", _*) => false
case ExecCmd("ENTRYPOINT", _*) => false
case ExecCmd("CMD", _*) => false
case _ => true
}
filteredCommands ++ Seq(
Cmd("RUN", "apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*"),
Cmd("WORKDIR", "/opt/docker"),
ExecCmd(
"ENTRYPOINT",
"/opt/docker/bin/server"
)
)
},
dockerRepository := sys.env.get("REGISTRY"),
dockerExposedPorts := Seq(8080),
Docker / mappings := {
val nativeImage = (GraalVMNativeImage / packageBin).value
val standardMappings = (Docker / mappings).value
standardMappings.filter { case (file, path) =>
!path.contains("bin/server") && !path.contains("lib/")
} :+ (nativeImage -> "/opt/docker/bin/server")
},
graalVMNativeImageOptions ++= Seq(
"--no-fallback",
"-H:+ReportExceptionStackTraces",
"--verbose",
"--enable-https",
"--enable-http",
"-H:IncludeResources=application\\.conf",
"-H:IncludeResources=logback\\.xml",
"-H:IncludeResources=public/.*",
"-H:DeadlockWatchdogInterval=900",
"-Ob",
"-J-Xmx24G",
"-R:MaxHeapSize=512m",
"--initialize-at-build-time=org.slf4j.LoggerFactory,ch.qos.logback,com.fasterxml.jackson",
"--initialize-at-run-time=io.netty.channel.epoll.Epoll,io.netty.channel.epoll.Native,io.netty.channel.epoll.EpollEventLoop,io.netty.channel.epoll.EpollEventLoopGroup,io.netty.channel.kqueue.KQueue,io.netty.channel.kqueue.Native,io.netty.channel.kqueue.KQueueEventLoopGroup,org.http4s.MimeDB"
),
watchSources ++= (client / Compile / watchSources).value,
javaOptions += "-Dotel.java.global-autoconfigure.enabled=true",
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % "3.6.3",
"org.slf4j" % "slf4j-api" % "2.0.17",
"ch.qos.logback" % "logback-classic" % "1.5.25",
"org.flywaydb" % "flyway-core" % "11.17.2",
"com.github.pureconfig" %% "pureconfig-core" % "0.17.9"
),
libraryDependencies ++= Seq(
Expand All @@ -96,17 +138,6 @@ lazy val server = project
"org.typelevel" %% "log4cats-core",
"org.typelevel" %% "log4cats-slf4j"
).map(_ % logs4catVersion),
libraryDependencies ++= Seq(
"org.typelevel" %% "otel4s-oteljava",
"org.typelevel" %% "otel4s-instrumentation-metrics"
).map(_ % otel4sVersion),
libraryDependencies ++= Seq(
"org.typelevel" %% "otel4s-oteljava" % "0.14.0",
"io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % "2.22.0-alpha",
"io.opentelemetry" % "opentelemetry-exporter-prometheus" % "1.45.0-alpha",
"io.opentelemetry" % "opentelemetry-exporter-otlp" % "1.56.0" % Runtime,
"io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % "1.56.0" % Runtime
),
libraryDependencies ++= Seq(
"io.circe" %%% "circe-core",
"io.circe" %%% "circe-generic",
Expand All @@ -119,9 +150,9 @@ lazy val server = project
"org.tpolecat" %% "doobie-postgres-circe"
).map(_ % doobieVersion),
libraryDependencies += "org.jsoup" % "jsoup" % "1.21.2",
libraryDependencies += "com.github.blemale" %% "scaffeine" % "5.3.0",
libraryDependencies += "io.circe" %% "circe-fs2" % "0.14.1",
libraryDependencies += "org.flywaydb" % "flyway-database-postgresql" % "11.17.2" % "runtime",
libraryDependencies += "com.github.jwt-scala" %% "jwt-circe" % "10.0.1",
libraryDependencies += "com.google.cloud.sql" % "postgres-socket-factory" % "1.15.1",
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % Test,
libraryDependencies += "org.scalamock" %% "scalamock" % "7.5.2" % Test,
libraryDependencies += "org.testcontainers" % "testcontainers" % "2.0.2" % Test,
Expand All @@ -131,7 +162,7 @@ lazy val server = project
Compile / run / fork := true,
Compile / packageDoc / mappings := Seq(),
Compile / resourceGenerators += Def.task {
val _ = (client / Compile / fastLinkJS).value
val _ = (client / Compile / fullLinkJS).value
val distDir = buildClientDist.value
val targetDir = (Compile / resourceManaged).value / "public"
IO.copyDirectory(distDir, targetDir)
Expand Down
File renamed without changes
File renamed without changes.
1 change: 1 addition & 0 deletions client/public/site.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"RSS Reader","short_name":"RSS","icons":[{"src":"/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
1 change: 0 additions & 1 deletion client/site.webmanifest

This file was deleted.

Loading
Loading