Compare commits

...

5 Commits

Author SHA1 Message Date
davidtio
9f0ecf67da fix: revert uid=1000 pin — compose sets user: "0" instead
In rootless Docker, uid=0 in-container = cldrzd on host (not privileged).
Pinning to uid=1000 in-container mapped to host uid=100999 (phantom UID),
which cannot write to the cldrzd-owned data directory.

The Dockerfile USER directive is overridden by compose user: "0" anyway,
so revert to a standard non-root app user without explicit uid/gid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:05:19 +08:00
davidtio
776a6e8973 Pin app user to uid/gid=1000 for Clouderized bind mount compatibility
Clouderized platform convention: all containers run as uid=1000/gid=1000
so data directories (owned by host cldrzd user) are writable without
insecure world-write permissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:47:21 +08:00
davidtio
4760fd22c0 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 17:59:31 +08:00
davidtio
371f2c949d 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 17:59:26 +08:00
davidtio
78a5f98f11 Fix /data permissions: create directory owned by app user
Docker mounts named volumes as root by default. Without pre-creating /data
in the image with correct ownership, the app user cannot write tasks.db,
causing a 502 on any route that touches TaskStore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:59:20 +08:00
5 changed files with 126 additions and 93 deletions

View File

@@ -20,6 +20,7 @@ RUN sbt "set test in assembly := false" assembly
FROM eclipse-temurin:25-jre-alpine
RUN addgroup -S app && adduser -S app -G app
RUN mkdir -p /data && chown app:app /data
WORKDIR /app
COPY --from=build --chown=app:app /app/target/scala-3.7.1/demoapp.jar ./demoapp.jar

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()