Compare commits

..

2 Commits

Author SHA1 Message Date
davidtio
5851cb6559 Bump to v2.0.0: blue theme upgrade demo
Changes version to 2.0.0 and header color from teal (#0F766E) to blue (#1D4ED8).
Used to demonstrate a zero-data-loss upgrade on Clouderized.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 14:15:28 +08:00
davidtio
a012c69693 Add SQLite persistence via Docker named volume
Tasks are now stored in SQLite (DB_PATH env var, defaults to ./tasks.db).
Pre-seeding runs only when the table is empty, so upgrades preserve data.
This is the v1.0.0 baseline for the persistence demo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 14:15:17 +08:00
4 changed files with 125 additions and 93 deletions

121
README.md
View File

@@ -1,97 +1,83 @@
# demoapp — Clouderized Demo App # demoapp — Clouderized Demo App
A task manager built with Scala 3 + Thorium, used to demo the full Clouderized deployment cycle: edit config, push, rebuild, see changes live. A task manager built with Scala 3 + Thorium, used to demonstrate Clouderized's core value prop: **stateful app upgrades with zero data loss**.
Tasks are persisted in SQLite backed by a Docker named volume (`demouser-data`). Rebuilding or upgrading the container leaves all user data intact.
## Prerequisites ## Prerequisites
- Access to `git.clouderized.com` (Gitea) - Access to `git.clouderized.com` (Gitea)
- The app deployed at `demoapp.clouderized.com` - The app deployed at `demouser.clouderized.com`
- VPS SSH access: `ssh -i ssh/id_rsa -p 9876 cldrzd@194.233.75.123`
## Running the Demo ---
### 1. Show the live app ## v1 → v2 Upgrade Demo (with data persistence)
Open `https://demoapp.clouderized.com` in a browser. Point out: This is the primary demo sequence. It shows that Clouderized can upgrade a running app without wiping user data.
- The **teal header** with app name and version (v1.0.0) ### 1. Deploy v1.0.0
- CRUD functionality — add, complete, and delete tasks
- The `/health` endpoint returning `{"status":"ok"}`
### 2. Make a visible change On the VPS:
Edit `src/main/resources/site.conf`:
```diff
app {
- name = "DemoCust Tasks"
- version = "1.0.0"
+ name = "Clouderized Tasks"
+ version = "1.1.0"
theme {
- background = "#0F766E"
+ background = "#1E40AF"
font = "DM Sans"
- fontSize = "16px"
+ fontSize = "18px"
}
}
```
This changes the header from **teal to blue**, bumps the version, and increases font size.
### 3. Push the change
```bash ```bash
git add src/main/resources/site.conf cd ~/customers/demouser/demoapp && git fetch --tags && git checkout v1.0.0
git commit -m "Update theme to blue, bump to v1.1.0" cd ~/customers/demouser && docker compose up -d --build
git push
``` ```
### 4. Watch the rebuild Visit `https://demouser.clouderized.com`:
- **Teal header**, version badge shows **v1.0.0**
- Three pre-seeded tasks are present
- `/health` returns `{"status":"ok"}`
Go to Gitea Actions at `git.clouderized.com/cldrzd/demoapp/actions` and watch the pipeline: ### 2. Add user data
1. `sbt assembly` builds the fat JAR In the browser, add 23 tasks and delete one of the pre-seeded tasks. This simulates real user activity before an upgrade.
2. Docker builds the container image
3. The old container is replaced with the new one
### 5. See the result ### 3. Upgrade to v2.0.0
Refresh the browser. The page now shows: On the VPS:
- **Blue header** instead of teal
- Version **v1.1.0** in the top-right
- Larger font size
### 6. Reset for the next demo
```bash ```bash
git revert HEAD --no-edit cd ~/customers/demouser/demoapp && git checkout v2.0.0
git push cd ~/customers/demouser && docker compose up -d --build
``` ```
The app rebuilds and returns to its original teal theme at v1.0.0. Revisit `https://demouser.clouderized.com`:
- **Blue header**, version badge shows **v2.0.0**
- All tasks added in step 2 are still there
- Pre-seeded tasks are not re-added (seeding is skipped when the table is non-empty)
### 4. Verify persistence
```bash
# On the VPS — confirm the volume exists and is mounted
docker volume inspect demouser-data
```
---
## How Persistence Works
| Component | Detail |
|-----------|--------|
| Storage | SQLite at `/data/tasks.db` inside the container |
| Volume | Docker named volume `demouser-data` mounted at `/data` |
| Env var | `DB_PATH=/data/tasks.db` (set in `docker-compose.yml`) |
| Seeding | Pre-seed runs only when the `tasks` table is empty |
The named volume `demouser-data` survives `docker compose up --build` — data is never lost on rebuild.
---
## Config Reference ## Config Reference
All visual changes are driven by `src/main/resources/site.conf`: All visual changes are driven by `src/main/resources/site.conf`:
| Key | Default | What it controls | | Key | v1.0.0 | v2.0.0 | What it controls |
|-----|---------|-----------------| |-----|--------|--------|-----------------|
| `app.name` | `"DemoCust Tasks"` | Header title | | `app.version` | `"1.0.0"` | `"2.0.0"` | Version badge in header |
| `app.version` | `"1.0.0"` | Version badge in header | | `app.theme.background` | `"#0F766E"` (teal) | `"#1D4ED8"` (blue) | Header and button color |
| `app.theme.background` | `"#0F766E"` (teal) | Header and button color |
| `app.theme.font` | `"DM Sans"` | Body font family |
| `app.theme.fontSize` | `"16px"` | Base font size |
## Suggested color swaps for demos
| Color | Hex | Effect |
|-------|-----|--------|
| Teal (default) | `#0F766E` | Clouderized brand color |
| Blue | `#1E40AF` | Obvious visual change |
| Purple | `#7C3AED` | Another clear contrast |
| Red | `#DC2626` | High-contrast demo |
## Endpoints ## Endpoints
@@ -105,6 +91,5 @@ All visual changes are driven by `src/main/resources/site.conf`:
## Notes ## Notes
- Task storage is **in-memory** — it resets on every redeploy, which keeps the demo clean.
- The app runs on the **Pro tier** (2GB RAM, 2 vCPU) since JVM needs the headroom. - The app runs on the **Pro tier** (2GB RAM, 2 vCPU) since JVM needs the headroom.
- Three sample tasks are pre-seeded on startup. - Local dev uses `./tasks.db` (relative path) when `DB_PATH` is not set.

View File

@@ -13,7 +13,8 @@ lazy val root = project
libraryDependencies ++= Seq( libraryDependencies ++= Seq(
"com.greenfossil" %% "thorium" % thoriumVersion, "com.greenfossil" %% "thorium" % thoriumVersion,
"com.typesafe" % "config" % "1.4.3", "com.typesafe" % "config" % "1.4.3",
"ch.qos.logback" % "logback-classic" % "1.5.6" "ch.qos.logback" % "logback-classic" % "1.5.6",
"org.xerial" % "sqlite-jdbc" % "3.47.1.0"
), ),
assembly / mainClass := Some("Main"), assembly / mainClass := Some("Main"),

View File

@@ -1,8 +1,8 @@
app { app {
name = "DemoCust Tasks" name = "DemoCust Tasks"
version = "1.0.0" version = "2.0.0"
theme { theme {
background = "#0F766E" background = "#1D4ED8"
font = "DM Sans" font = "DM Sans"
fontSize = "16px" fontSize = "16px"
} }

View File

@@ -1,41 +1,87 @@
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.sql.{Connection, DriverManager}
import scala.jdk.CollectionConverters.*
case class Task(id: UUID, title: String, completed: Boolean) case class Task(id: UUID, title: String, completed: Boolean)
object TaskStore: object TaskStore:
private val tasks = ConcurrentHashMap[UUID, Task]() private val dbPath = sys.env.getOrElse("DB_PATH", "./tasks.db")
private val url = s"jdbc:sqlite:$dbPath"
// Pre-seed sample tasks Class.forName("org.sqlite.JDBC")
private def connect(): Connection = DriverManager.getConnection(url)
// Initialize schema and pre-seed if empty
locally { locally {
val samples = List( val conn = connect()
"Deploy Scala app to Clouderized", try
"Set up CI/CD with Gitea Actions", val stmt = conn.createStatement()
"Configure custom domain" stmt.execute(
) "CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0)"
samples.foreach { title => )
val id = UUID.randomUUID() val rs = stmt.executeQuery("SELECT COUNT(*) FROM tasks")
tasks.put(id, Task(id, title, false)) val count = rs.getInt(1)
} if count == 0 then
val samples = List(
"Deploy Scala app to Clouderized",
"Set up CI/CD with Gitea Actions",
"Configure custom domain"
)
val ps = conn.prepareStatement("INSERT INTO tasks (id, title, completed) VALUES (?, ?, 0)")
samples.foreach { title =>
ps.setString(1, UUID.randomUUID().toString)
ps.setString(2, title)
ps.executeUpdate()
}
finally conn.close()
} }
def all(): List[Task] = def all(): List[Task] =
tasks.values().asScala.toList.sortBy(_.title) val conn = connect()
try
val rs = conn.createStatement().executeQuery("SELECT id, title, completed FROM tasks ORDER BY title")
val buf = collection.mutable.ListBuffer.empty[Task]
while rs.next() do
buf += Task(UUID.fromString(rs.getString("id")), rs.getString("title"), rs.getInt("completed") == 1)
buf.toList
finally conn.close()
def add(title: String): Task = def add(title: String): Task =
val id = UUID.randomUUID() val id = UUID.randomUUID()
val task = Task(id, title.trim, false) val conn = connect()
tasks.put(id, task) try
task val ps = conn.prepareStatement("INSERT INTO tasks (id, title, completed) VALUES (?, ?, 0)")
ps.setString(1, id.toString)
ps.setString(2, title.trim)
ps.executeUpdate()
Task(id, title.trim, false)
finally conn.close()
def toggle(id: UUID): Unit = def toggle(id: UUID): Unit =
Option(tasks.get(id)).foreach { task => val conn = connect()
tasks.put(id, task.copy(completed = !task.completed)) try
} val ps = conn.prepareStatement(
"UPDATE tasks SET completed = CASE WHEN completed = 1 THEN 0 ELSE 1 END WHERE id = ?"
)
ps.setString(1, id.toString)
ps.executeUpdate()
finally conn.close()
def delete(id: UUID): Unit = def delete(id: UUID): Unit =
tasks.remove(id) val conn = connect()
try
val ps = conn.prepareStatement("DELETE FROM tasks WHERE id = ?")
ps.setString(1, id.toString)
ps.executeUpdate()
finally conn.close()
def get(id: UUID): Option[Task] = def get(id: UUID): Option[Task] =
Option(tasks.get(id)) val conn = connect()
try
val ps = conn.prepareStatement("SELECT id, title, completed FROM tasks WHERE id = ?")
ps.setString(1, id.toString)
val rs = ps.executeQuery()
if rs.next() then
Some(Task(UUID.fromString(rs.getString("id")), rs.getString("title"), rs.getInt("completed") == 1))
else None
finally conn.close()