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
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
- 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)
- CRUD functionality — add, complete, and delete tasks
- The `/health` endpoint returning `{"status":"ok"}`
### 1. Deploy v1.0.0
### 2. Make a visible change
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
On the VPS:
```bash
git add src/main/resources/site.conf
git commit -m "Update theme to blue, bump to v1.1.0"
git push
cd ~/customers/demouser/demoapp && git fetch --tags && git checkout v1.0.0
cd ~/customers/demouser && docker compose up -d --build
```
### 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
2. Docker builds the container image
3. The old container is replaced with the new one
In the browser, add 23 tasks and delete one of the pre-seeded tasks. This simulates real user activity before an upgrade.
### 5. See the result
### 3. Upgrade to v2.0.0
Refresh the browser. The page now shows:
- **Blue header** instead of teal
- Version **v1.1.0** in the top-right
- Larger font size
### 6. Reset for the next demo
On the VPS:
```bash
git revert HEAD --no-edit
git push
cd ~/customers/demouser/demoapp && git checkout v2.0.0
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
All visual changes are driven by `src/main/resources/site.conf`:
| Key | Default | What it controls |
|-----|---------|-----------------|
| `app.name` | `"DemoCust Tasks"` | Header title |
| `app.version` | `"1.0.0"` | Version badge in header |
| `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 |
| Key | v1.0.0 | v2.0.0 | What it controls |
|-----|--------|--------|-----------------|
| `app.version` | `"1.0.0"` | `"2.0.0"` | Version badge in header |
| `app.theme.background` | `"#0F766E"` (teal) | `"#1D4ED8"` (blue) | Header and button color |
## Endpoints
@@ -105,6 +91,5 @@ All visual changes are driven by `src/main/resources/site.conf`:
## 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.
- 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(
"com.greenfossil" %% "thorium" % thoriumVersion,
"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"),

View File

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

View File

@@ -1,41 +1,87 @@
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import scala.jdk.CollectionConverters.*
import java.sql.{Connection, DriverManager}
case class Task(id: UUID, title: String, completed: Boolean)
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 {
val conn = connect()
try
val stmt = conn.createStatement()
stmt.execute(
"CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0)"
)
val rs = stmt.executeQuery("SELECT COUNT(*) FROM tasks")
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 =>
val id = UUID.randomUUID()
tasks.put(id, Task(id, title, false))
ps.setString(1, UUID.randomUUID().toString)
ps.setString(2, title)
ps.executeUpdate()
}
finally conn.close()
}
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 =
val id = UUID.randomUUID()
val task = Task(id, title.trim, false)
tasks.put(id, task)
task
val conn = connect()
try
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 =
Option(tasks.get(id)).foreach { task =>
tasks.put(id, task.copy(completed = !task.completed))
}
val conn = connect()
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 =
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] =
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()