commit b4a740590bdc0c69743ec3b2c099d8938437ff39 Author: Jonas Kaninda Date: Mon Jul 6 20:02:09 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39c284a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.env +*.md +Dockerfile +compose.yaml +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..334977c --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# Copy to .env for local development (docker compose reads it automatically). + +# HTTP port the app listens on. +PORT=8080 + +# Display name shown in the UI / health payload. +APP_NAME=Miabi Guestbook + +# Version shown in the UI / /api/info. Overrides the build-time version. +# Set to different values per app (e.g. 1.0.0 vs 2.0.0) for a canary demo. +# APP_VERSION=1.0.0 + +# Seed sample entries on first run (only when the table is empty). Set to +# false to start with an empty wall. +SEED=true + +# === Database ====================== +# Driver: "postgres" or "sqlite". Leave unset to auto-detect: Postgres when +# it is configured (DATABASE_URL or DB_HOST present), otherwise SQLite. +# DB_DRIVER=postgres + +# SQLite (zero-config; used when no Postgres is configured). File path, or +# ":memory:" for an ephemeral database. The parent directory is created on +# startup. Locally this defaults to ./data/guestbook.db; the container image +# uses /data/guestbook.db (a volume). On Miabi, mount a volume at /data. +# DB_PATH=data/guestbook.db + +# Postgres — when you attach a Miabi managed database, Miabi injects these for +# you. DATABASE_URL takes precedence; otherwise the discrete DB_* parts are +# assembled into a DSN. +DATABASE_URL=postgres://postgres:postgres@db:5432/guestbook?sslmode=disable + +# Discrete parts (used only when DATABASE_URL is empty): +# DB_HOST=db +# DB_PORT=5432 +# DB_USER=postgres +# DB_PASSWORD=postgres +# DB_NAME=guestbook +# DB_SSL_MODE=disable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a3a903 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache-dependency-path: go.sum + - name: Verify modules + run: go mod verify + - name: Vet + run: go vet ./... + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + - name: Build + run: go build ./... + - name: Test + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8963453 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + packages: write + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + # The agent image is published to Docker Hub (primary, matches the platform + # default miabi/agent) and mirrored to the GitHub Container Registry (GHCR). + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push guestbook + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + VERSION=${{ env.VERSION }} + tags: | + miabi/guestbook:latest + miabi/guestbook:${{ env.VERSION }} + ghcr.io/miabi-io/guestbook:latest + ghcr.io/miabi-io/guestbook:${{ env.VERSION }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c72538e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +/app +/tmp +/data +*.log +*.db +*.db-shm +*.db-wal +guestbook +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff470d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.26-alpine AS build +WORKDIR /src + +# Version baked into the binary. Build each canary image with e.g. +# docker build --build-arg VERSION=1.0.0 -t guestbook:1.0.0 . +# docker build --build-arg VERSION=2.0.0 -t guestbook:2.0.0 . +ARG VERSION=dev + +# Cache module downloads. +COPY go.mod go.sum ./ +RUN go mod download + +# Build a static binary (the web UI is embedded via go:embed, so no assets to copy). +COPY . . +RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.version=${VERSION}" -o /out/guestbook . + +RUN mkdir -p /data + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=build /out/guestbook /app/guestbook + +# Writable /data volume for the SQLite database, owned by the non-root user. +COPY --from=build --chown=nonroot:nonroot /data /data +VOLUME ["/data"] + +ENV PORT=8080 \ + DB_PATH=/data/guestbook.db +EXPOSE 8080 + +# Runs as the non-root user provided by the distroless image. +ENTRYPOINT ["/app/guestbook"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d8922e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Jonas Kaninda + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..96454ed --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# Miabi Guestbook — example app + +A tiny, self-contained web app used as a **reference for deploying to +[Miabi](https://github.com/miabi-io/miabi)**. It is a classic guestbook: +visitors leave a name and a message, and the wall shows every signature. + +It deliberately exercises everything a real Miabi deployment touches, in +miniature: + +- **[Okapi](https://github.com/jkaninda/okapi) (Go)** REST API, driven by + **`okapicli`** — a `server` subcommand with graceful-shutdown lifecycle hooks + (the same CLI layout Miabi uses) +- **SPA served by `app.WebFS`** — the embedded UI is mounted straight from an + `embed.FS`; Okapi serves real assets and falls back to `index.html` for + client-side routes (home + an **All signatures** page), while registered API + routes keep precedence +- **Live updates over SSE** — new/removed signatures stream to every open tab, + with a real-time **connected-clients** ("N online") indicator +- **Version badge** — the serving build's version is shown in the UI and + `/api/info`; ideal for watching a **canary rollout** decide which build answers +- **[GORM](https://gorm.io) with PostgreSQL _or_ SQLite** — `uint` PKs and soft + deletes, the same conventions Miabi uses. Attach a Miabi managed Postgres, or + fall back to a zero-config SQLite file (pure-Go driver, no CGO). +- **Structured logging** via [`jkaninda/logger`](https://github.com/jkaninda/logger) +- **Env-based config** — auto-detects the driver; reads `DATABASE_URL` / `DB_*` +- **`AutoMigrate` on startup** with retry (tolerates the DB coming up a beat later) +- **First-run seeder** — populates the wall when the table is empty (`SEED=false` to skip) +- **`/healthz` readiness probe** that returns `503` until the database is reachable +- **Single static binary** — the whole UI is embedded with `go:embed`, so the + image is one distroless layer with **no Node build step** + +## Why this UI approach + +For an example that should deploy in seconds, the frontend is a **single +embedded `index.html`** — modern CSS (glassmorphism cards, gradient mesh, dark +theme) plus a little vanilla JS calling the JSON API. No CDN, no bundler, no +`node_modules`. It looks polished, loads instantly, works offline/air-gapped, +and keeps the container tiny. + + + +## API + +| Method | Path | Description | +|----------|----------------------|------------------------------------| +| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version` | +| `GET` | `/api/info` | App name, serving `version`, connected-client count | +| `GET` | `/api/entries` | List entries + total; paginated via `?limit=&offset=` | +| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live) | +| `DELETE` | `/api/entries/{id}` | Delete an entry (broadcast live) | +| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `deleted`, `presence` events | +| `GET` | `/` · `/all` | The web UI (home · all-signatures page) | + +### Live updates (SSE) + +`/api/stream` is a Server-Sent Events endpoint. On connect the client gets a +`welcome` event (serving version + online count); thereafter the server pushes +`created` / `deleted` events as the wall changes and `presence` events whenever +the number of connected clients changes. The UI uses these to update the wall in +real time and show a live **“N online”** indicator — no polling. + +## Configuration + +| Variable | Default | Purpose | +|----------------|------------------|-------------------------------------------| +| `PORT` | `8080` | HTTP listen port | +| `APP_NAME` | `Miabi Guestbook`| Display name in the UI / health payload | +| `APP_VERSION` | build version | Overrides the version shown in the UI / `/api/info`. Defaults to the value baked at build time (`-ldflags "-X main.version=…"`, or the `VERSION` build-arg). | +| `SEED` | `true` | Seed sample entries on first run (empty table only) | +| `DB_DRIVER` | _(auto)_ | `postgres` or `sqlite`. Unset → auto: Postgres when configured, else SQLite. | +| `DB_PATH` | `data/guestbook.db` (`/data/guestbook.db` in the image) | SQLite file path (or `:memory:`). Its parent dir is created on startup. The container stores it on the `/data` volume. | +| `DATABASE_URL` | — | Full Postgres DSN (URL or key=value). Preferred — set for you when you attach a Miabi **managed database**. Takes precedence over `DB_*`. | +| `DB_HOST` | `localhost` | Database host (used when `DATABASE_URL` is empty) | +| `DB_PORT` | `5432` | Database port | +| `DB_USER` | `postgres` | Database user | +| `DB_PASSWORD` | `postgres` | Database password | +| `DB_NAME` | `guestbook` | Database name | +| `DB_SSL_MODE` | `disable` | Postgres `sslmode` | + +> **On Miabi:** attach a managed database to the app and Miabi injects these +> variables automatically — `DATABASE_URL` for the full DSN, plus the discrete +> `DB_*` parts. You don't set them by hand. + +## Run locally + +With Docker Compose (Postgres + app): + +```bash +docker compose up --build +# open http://localhost:8080 +``` + +Or run the binary directly. With nothing configured it uses a zero-config +SQLite file, so this just works: + +```bash +go run . # runs the default "server" command +go run . server -p 9000 # okapicli flags: choose the port +go run . --help # list commands and flags +``` + +To point it at your own Postgres, set `DATABASE_URL` (or the `DB_*` vars) first: + +```bash +cp .env.example .env # edit DATABASE_URL +export $(grep -v '^#' .env | xargs) +go run . +``` + +## Build the image + +```bash +docker build --build-arg VERSION=1.0.0 -t miabi-guestbook:1.0.0 . +docker run -p 8080:8080 -e DATABASE_URL=postgres://... miabi/guestbook:1.0.0 +``` + +The version can also be overridden at runtime with `-e APP_VERSION=…`. + +## Canary deployment demo + +The version badge makes this app a ready-made **canary** example. Build two +otherwise-identical images and let Miabi split traffic between them — the badge +(and `/api/info`) tells you which build served each request: + +```bash +docker build --build-arg VERSION=1.0.0 -t miabi-guestbook:1.0.0 . +docker build --build-arg VERSION=2.0.0 -t miabi/guestbook:2.0.0 . +``` + +1. Deploy `1.0.0` as the app; attach a **shared** managed Postgres. +2. Roll out `2.0.0` as a **canary** with a small weight (e.g. 10%). +3. Refresh the page a few times: ~1 in 10 loads shows the `v2.0.0` badge (a + different colour), the rest `v1.0.0`. Because both versions share the same + database, signatures created on either build appear on both — and stream + live to every open tab via SSE. +4. Shift the weight up and promote `2.0.0` when you're happy. + +> No build args? Set `APP_VERSION=1.0.0` / `APP_VERSION=2.0.0` in each app's +> environment instead — same effect. + +## Deploy on Miabi + +1. **Create a database** — provision a PostgreSQL database in your workspace. +2. **Create an application** — deploy this repo (Git build) or the image above. +3. **Attach the database** — Miabi injects the connection as `DATABASE_URL`. +4. **Set the health check** to `GET /healthz` and the port to `8080`. +5. **Connect a domain** — Miabi issues automatic SSL and routes traffic. + +The app migrates its schema on first boot, so there is no separate release step. diff --git a/broker.go b/broker.go new file mode 100644 index 0000000..c110d4f --- /dev/null +++ b/broker.go @@ -0,0 +1,83 @@ +package main + +import "sync" + +// Event is a server-to-client message pushed over SSE. +// +// Type selects the shape of the payload: +// - "created" → Entry is set (a new signature) +// - "deleted" → ID is set (a removed signature) +// - "presence" → Online is set (connected-client count changed) +type Event struct { + Type string `json:"type"` + Entry *Entry `json:"entry,omitempty"` + ID uint `json:"id,omitempty"` + Online int `json:"online,omitempty"` +} + +// Broker is a tiny in-process pub/sub hub. Each connected SSE client holds one +// subscription; handlers Publish entry events, and the broker itself emits a +// "presence" event whenever the number of connected clients changes. +type Broker struct { + mu sync.RWMutex + subs map[chan Event]struct{} +} + +// NewBroker creates an empty Broker. +func NewBroker() *Broker { + return &Broker{subs: make(map[chan Event]struct{})} +} + +// Subscribe registers a new client and returns its event channel. It also +// broadcasts the updated presence count to everyone. +func (b *Broker) Subscribe() chan Event { + ch := make(chan Event, 16) + + b.mu.Lock() + b.subs[ch] = struct{}{} + n := len(b.subs) + b.mu.Unlock() + + b.emit(Event{Type: "presence", Online: n}) + return ch +} + +// Unsubscribe removes a client, closes its channel, and broadcasts the updated +// presence count. +func (b *Broker) Unsubscribe(ch chan Event) { + b.mu.Lock() + if _, ok := b.subs[ch]; !ok { + b.mu.Unlock() + return + } + delete(b.subs, ch) + close(ch) + n := len(b.subs) + b.mu.Unlock() + + b.emit(Event{Type: "presence", Online: n}) +} + +// Publish broadcasts an event to every connected client. +func (b *Broker) Publish(ev Event) { b.emit(ev) } + +// Online returns the current number of connected clients. +func (b *Broker) Online() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subs) +} + +// emit fans an event out to all subscribers. Sends are non-blocking: a slow +// client that has filled its buffer simply misses the event rather than +// stalling the broker. +func (b *Broker) emit(ev Event) { + b.mu.RLock() + defer b.mu.RUnlock() + for ch := range b.subs { + select { + case ch <- ev: + default: + } + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..54d33b0 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,32 @@ +# Local development: Postgres + the app. Mirrors what Miabi wires up for you +# (a managed Postgres attached to the app via DATABASE_URL). +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: guestbook + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + timeout: 3s + retries: 10 + ports: + - "5432:5432" + volumes: + - db-data:/var/lib/postgresql/data + + app: + build: . + environment: + PORT: 8080 + DATABASE_URL: postgres://postgres:postgres@db:5432/guestbook?sslmode=disable + depends_on: + db: + condition: service_healthy + ports: + - "8080:8080" + +volumes: + db-data: diff --git a/config.go b/config.go new file mode 100644 index 0000000..ca17579 --- /dev/null +++ b/config.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "strings" + + goutils "github.com/jkaninda/go-utils" +) + +// Config is the runtime configuration, read entirely from environment +// variables — the twelve-factor way, and exactly how Miabi injects settings +// into a deployed application (Postgres connection details land here when you +// attach a managed database to the app). +type Config struct { + Port int + DBDriver string // "postgres" or "sqlite" + DatabaseDSN string + AppName string + Version string + Seed bool +} + +// LoadConfig builds the Config from the environment. +func LoadConfig() (Config, error) { + driver, dsn := resolveDatabase() + return Config{ + Port: goutils.EnvInt("PORT", 8080), + AppName: goutils.Env("APP_NAME", "Miabi Guestbook"), + Version: resolveVersion(), + Seed: goutils.Env("SEED", "true") == "true", + DBDriver: driver, + DatabaseDSN: dsn, + }, nil +} + +// resolveDatabase picks the database driver and DSN. +// +// The driver comes from DB_DRIVER (postgres|sqlite). When DB_DRIVER is unset it +// is inferred: if Postgres is configured (DATABASE_URL or DB_HOST is present) +// Postgres is used; otherwise the app falls back to a zero-config SQLite file — +// handy for local dev and demos with nothing else to set up. On Miabi, attach a +// managed Postgres and it is selected automatically. +func resolveDatabase() (driver, dsn string) { + driver = strings.ToLower(goutils.Env("DB_DRIVER", "")) + if driver == "" { + if goutils.Env("DATABASE_URL", "") != "" || goutils.Env("DB_HOST", "") != "" { + driver = "postgres" + } else { + driver = "sqlite" + } + } + + switch driver { + case "sqlite", "sqlite3": + // A file path; use ":memory:" for an ephemeral in-memory database. The + // parent directory is created on startup. In the container this defaults + // to /data (a volume); see DB_PATH in the Dockerfile. + return "sqlite", goutils.Env("DB_PATH", "data/guestbook.db") + default: + return "postgres", postgresDSN() + } +} + +// postgresDSN resolves the Postgres connection, following Miabi's convention: +// +// - DATABASE_URL — a full DSN (URL or key=value). Preferred, and what Miabi's +// managed databases inject. When set, it is used verbatim. +// - Discrete DB_* variables — assembled into a key=value DSN otherwise. +func postgresDSN() string { + if url := goutils.Env("DATABASE_URL", ""); url != "" { + return url + } + return fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%d sslmode=%s", + goutils.Env("DB_HOST", "localhost"), + goutils.Env("DB_USER", "postgres"), + goutils.Env("DB_PASSWORD", "postgres"), + goutils.Env("DB_NAME", "guestbook"), + goutils.EnvInt("DB_PORT", 5432), + goutils.Env("DB_SSL_MODE", "disable"), + ) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..420637b --- /dev/null +++ b/go.mod @@ -0,0 +1,47 @@ +module github.com/miabi-io/guestbook + +go 1.26.3 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/jkaninda/go-utils v0.1.4 + github.com/jkaninda/logger v0.0.5 + github.com/jkaninda/okapi v0.8.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/getkin/kin-openapi v0.140.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oasdiff/yaml v0.1.0 // indirect + github.com/oasdiff/yaml3 v0.0.13 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..616c801 --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= +github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jkaninda/go-utils v0.1.4 h1:ZdNlI+yLWc4/S0qKcCNQIPj+6lHSdJcGaxtRADAifAU= +github.com/jkaninda/go-utils v0.1.4/go.mod h1:Aa54jEAcDykc3CnOdreqZG80UfSZOvrYecyusu+oPb4= +github.com/jkaninda/logger v0.0.5 h1:fTHKgDsHtuN8rkSBvwe6QStfi8yIdm8O3r0g99dRDTY= +github.com/jkaninda/logger v0.0.5/go.mod h1:ZUXJ2BdxDPG6e8t6mbKhc2ZFaFi2Iuy/4ukquFrPkFE= +github.com/jkaninda/okapi v0.8.0 h1:Ch/ZX9CCsiXlQqv4Ebm3/soGFwbklUAnKjtJ95XvSJo= +github.com/jkaninda/okapi v0.8.0/go.mod h1:TzBj5VE2rzr0H/6pANG2+ISbe6u46XfLzUxHu62BPv8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..d3c0e35 --- /dev/null +++ b/handlers.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/jkaninda/okapi" +) + +// Handler holds the dependencies for the HTTP layer. +type Handler struct { + store *Store + broker *Broker + appName string + version string +} + +// createEntryRequest is the POST /api/entries body. +type createEntryRequest struct { + Name string `json:"name"` + Message string `json:"message"` +} + +const ( + maxNameLen = 60 + maxMessageLen = 500 +) + +// Health reports liveness/readiness. Returns 503 when the database is +// unreachable so Miabi's health checks hold traffic until the DB is up. +func (h *Handler) Health(c *okapi.Context) error { + if err := h.store.Ping(c.Request().Context()); err != nil { + return c.JSON(http.StatusServiceUnavailable, okapi.M{ + "status": "unavailable", + "db": "down", + "version": h.version, + }) + } + return c.OK(okapi.M{"status": "ok", "db": "up", "app": h.appName, "version": h.version}) +} + +// Info returns app metadata for the UI: name, the serving version (useful for +// spotting which build answered during a canary rollout), and how many clients +// are currently connected. +func (h *Handler) Info(c *okapi.Context) error { + return c.OK(okapi.M{ + "app": h.appName, + "version": h.version, + "online": h.broker.Online(), + }) +} + +// ListEntries returns guestbook entries, newest first, with simple pagination. +// Query params: limit (default 100, max 200) and offset (default 0). The home +// page requests a small limit; the "All signatures" page pages through them. +func (h *Handler) ListEntries(c *okapi.Context) error { + ctx := c.Request().Context() + + limit := atoiDefault(c.Query("limit"), 100) + if limit <= 0 || limit > 200 { + limit = 100 + } + offset := atoiDefault(c.Query("offset"), 0) + if offset < 0 { + offset = 0 + } + + entries, err := h.store.List(ctx, limit, offset) + if err != nil { + return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load entries"}) + } + count, _ := h.store.Count(ctx) + return c.OK(okapi.M{ + "entries": entries, + "total": count, + "limit": limit, + "offset": offset, + }) +} + +// atoiDefault parses s as an int, returning def when empty or invalid. +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// CreateEntry adds a new signature to the wall and broadcasts it live. +func (h *Handler) CreateEntry(c *okapi.Context) error { + var req createEntryRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"}) + } + + name := strings.TrimSpace(req.Name) + message := strings.TrimSpace(req.Message) + + if name == "" { + name = "Anonymous" + } + if len(name) > maxNameLen { + name = name[:maxNameLen] + } + if message == "" { + return c.JSON(http.StatusBadRequest, okapi.M{"error": "message is required"}) + } + if len(message) > maxMessageLen { + return c.JSON(http.StatusBadRequest, okapi.M{ + "error": "message is too long (max 500 characters)", + }) + } + + entry, err := h.store.Create(c.Request().Context(), name, message) + if err != nil { + return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"}) + } + + h.broker.Publish(Event{Type: "created", Entry: &entry}) + return c.Created(entry) +} + +// DeleteEntry removes an entry by id and broadcasts the removal live. +func (h *Handler) DeleteEntry(c *okapi.Context) error { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid id"}) + } + ok, err := h.store.Delete(c.Request().Context(), id) + if err != nil { + return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not delete entry"}) + } + if !ok { + return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"}) + } + + h.broker.Publish(Event{Type: "deleted", ID: uint(id)}) + return c.NoContent() +} + +// Stream is the SSE endpoint. Each client subscribes to the broker and receives +// live "created" / "deleted" / "presence" events. A "welcome" event is sent +// first so the client immediately knows the serving version and online count. +func (h *Handler) Stream(c *okapi.Context) error { + ctx, cancel := context.WithCancel(c.Request().Context()) + defer cancel() + + subscription := h.broker.Subscribe() + defer h.broker.Unsubscribe(subscription) + + msgs := make(chan okapi.Message, 32) + go func() { + defer close(msgs) + + welcome := okapi.Message{Event: "welcome", Data: okapi.M{ + "version": h.version, + "online": h.broker.Online(), + }} + select { + case msgs <- welcome: + case <-ctx.Done(): + return + } + + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-subscription: + if !ok { + return + } + select { + case msgs <- okapi.Message{Event: ev.Type, Data: ev}: + case <-ctx.Done(): + return + } + } + } + }() + + return c.SSEStreamWithOptions(ctx, msgs, &okapi.StreamOptions{ + Serializer: &okapi.JSONSerializer{}, + PingInterval: 25 * time.Second, + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e934b45 --- /dev/null +++ b/main.go @@ -0,0 +1,105 @@ +// Command guestbook is a tiny, self-contained web application used to +// demonstrate deploying to Miabi: an Okapi (Go) API + embedded SPA, backed by +// a Postgres (or SQLite) database via GORM. +// +// It is a classic "guestbook" — visitors leave a name and a message, and the +// wall shows every signature. Everything a real Miabi deployment exercises is +// here in miniature: env-based config (DATABASE_URL), an attached managed +// database, a GORM AutoMigrate on startup, a first-run seeder, a /healthz +// readiness probe, and a single static binary that ships the whole frontend +// embedded — no Node build step. +// +// The HTTP server is driven by okapicli (a "server" subcommand with graceful +// lifecycle hooks), and the embedded UI is served with app.WebFS. +package main + +import ( + "context" + "embed" + "time" + + "github.com/jkaninda/logger" + "github.com/jkaninda/okapi" + "github.com/jkaninda/okapi/okapicli" +) + +//go:embed all:web +var webFS embed.FS + +func main() { + cfg, err := LoadConfig() + if err != nil { + logger.Fatal("invalid configuration", "error", err) + } + + ctx := context.Background() + + store, err := NewStore(cfg.DBDriver, cfg.DatabaseDSN) + if err != nil { + logger.Fatal("failed to open database", "error", err) + } + + if err := migrateWithRetry(store, 30, 2*time.Second); err != nil { + logger.Fatal("failed to run migrations", "error", err) + } + logger.Info("schema ready", "app", cfg.AppName) + + // Seed sample data on first run (empty table only). + if cfg.Seed { + if err := Seed(ctx, store, cfg.DBDriver); err != nil { + logger.Fatal("failed to seed database", "error", err) + } + } + + broker := NewBroker() + h := &Handler{store: store, broker: broker, appName: cfg.AppName, version: cfg.Version} + + app := okapi.New(okapi.WithPort(cfg.Port)) + app.WithDebug() + app.Use(okapi.LoggerMiddleware, okapi.RequestID()) + + app.Get("/healthz", h.Health) + api := app.Group("/api") + api.Get("/info", h.Info) + api.Get("/entries", h.ListEntries) + api.Post("/entries", h.CreateEntry) + api.Delete("/entries/{id:int}", h.DeleteEntry) + api.Get("/stream", h.Stream) // Server-Sent Events: live entries + presence + + // Serve the embedded single-page UI. Okapi serves real files directly and + // falls back to index.html for any other path so a client-side router can + // take over. + app.WebFS("/", webFS, okapi.WebConfig{Root: "web"}) + + cli := okapicli.New(app, "guestbook") + cli.Command("server", "Start the Guestbook HTTP server", func(cmd *okapicli.Command) error { + cmd.Okapi().WithPort(cmd.GetInt("port")) + return cmd.CLI().RunServer(&okapicli.RunOptions{ + ShutdownTimeout: 10 * time.Second, + OnStarted: func() { + logger.Info("server started", "app", cfg.AppName, "version", cfg.Version, "port", cmd.GetInt("port")) + }, + OnShutdown: func() { + logger.Info("shutting down") + _ = store.Close() + }, + }) + }).Int("port", "p", cfg.Port, "HTTP server port") + cli.DefaultCommand("server") + + if err := cli.Execute(); err != nil { + logger.Fatal("server error", "error", err) + } +} + +func migrateWithRetry(store *Store, attempts int, wait time.Duration) error { + var err error + for i := 0; i < attempts; i++ { + if err = store.Migrate(); err == nil { + return nil + } + logger.Warn("waiting for database", "attempt", i+1, "of", attempts, "error", err) + time.Sleep(wait) + } + return err +} diff --git a/seed.go b/seed.go new file mode 100644 index 0000000..f11c580 --- /dev/null +++ b/seed.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + + "github.com/jkaninda/logger" +) + +// dbDisplayName maps a driver to a friendly name for the UI/seed copy. +func dbDisplayName(driver string) string { + switch driver { + case "sqlite", "sqlite3": + return "SQLite" + case "postgres": + return "PostgreSQL" + default: + return driver + } +} + +func seedEntries(driver string) []struct{ Name, Message string } { + return []struct{ Name, Message string }{ + {"Miabi", fmt.Sprintf("Welcome to your guestbook 👋 This app is running on Miabi and stores signatures in %s.", dbDisplayName(driver))}, + + {"Ada", "Connected my GitHub repository, pushed code, and Miabi handled the build and deployment automatically."}, + {"Grace", "Automatic SSL certificates in a few clicks. No reverse proxy configuration required."}, + {"Linus", "Deployed a Docker image directly from a registry and it was online in under a minute."}, + {"Jude", "The built-in monitoring dashboard makes it easy to keep an eye on application health."}, + {"Dennis", "Provisioned a PostgreSQL database and attached it to my application without touching Docker commands."}, + {"Ken", "I like that every feature is available through the API as well as the web interface."}, + {"Barbara", "GitOps support means my infrastructure and applications stay reproducible."}, + {"Tim", "Multi-tenant workspaces make shared hosting on Docker feel safe and organized."}, + {"James", "Imported an existing container into Miabi and started managing it from the dashboard."}, + {"Josh", "Backups, databases, deployments, and domains all managed from a single place."}, + {"Brendan", "The built-in container registry integrates nicely with CI/CD pipelines."}, + {"Sophie", "Added a custom domain and Miabi configured routing and TLS automatically."}, + {"Alex", "Deploying from a marketplace template was much faster than setting everything up manually."}, + {"Nadia", "Workspace isolation makes it easy to host applications for multiple teams on one server."}, + {"Chris", "Connected a remote node through the agent and expanded capacity without opening inbound ports."}, + {"Anonymous", "Finally, an open-source self-hosted PaaS that feels simple without sacrificing power."}, + } +} + +func Seed(ctx context.Context, store *Store, driver string) error { + count, err := store.Count(ctx) + if err != nil { + return err + } + if count > 0 { + logger.Info("seed skipped, entries already present", "count", count) + return nil + } + + entries := seedEntries(driver) + for _, e := range entries { + if _, err := store.Create(ctx, e.Name, e.Message); err != nil { + return err + } + } + logger.Info("database seeded", "entries", len(entries)) + return nil +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..58c8e5c --- /dev/null +++ b/store.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/glebarez/sqlite" // pure-Go SQLite (no CGO — keeps the static build) + "github.com/jkaninda/logger" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// Entry is a single signature on the guestbook wall. +type Entry struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"type:varchar(60);not null" json:"name"` + Message string `gorm:"type:varchar(500);not null" json:"message"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + UpdatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +type Store struct { + db *gorm.DB +} + +// NewStore opens a GORM connection using the given driver ("postgres" or +// "sqlite") and DSN. It does not block on the database being reachable — call +// Ping (e.g. from the health check) for that. +func NewStore(driver, dsn string) (*Store, error) { + var dialector gorm.Dialector + switch driver { + case "sqlite", "sqlite3": + // Create the parent directory (e.g. ./data or /data) so the SQLite + // file can be written on first run. + if err := ensureSQLiteDir(dsn); err != nil { + return nil, fmt.Errorf("create sqlite directory: %w", err) + } + dialector = sqlite.Open(dsn) + case "postgres", "": + dialector = postgres.Open(dsn) + default: + return nil, fmt.Errorf("unsupported DB_DRIVER %q (want postgres or sqlite)", driver) + } + + db, err := gorm.Open(dialector, &gorm.Config{}) + if err != nil { + return nil, err + } + logger.Info("database connected", "driver", driver) + return &Store{db: db}, nil +} + +// ensureSQLiteDir creates the directory that will hold the SQLite file. It is a +// no-op for in-memory databases and for a bare filename in the current dir. +func ensureSQLiteDir(dsn string) error { + // Strip a "file:" prefix and any "?query" so we get the plain file path. + path := strings.TrimPrefix(dsn, "file:") + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + if path == "" || strings.Contains(path, ":memory:") { + return nil + } + dir := filepath.Dir(path) + if dir == "" || dir == "." { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +// Close releases the underlying connection pool. +func (s *Store) Close() error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +// Ping verifies the database is reachable. Used by the health endpoint so +// Miabi can gate traffic until the app is actually ready. +func (s *Store) Ping(ctx context.Context) error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) +} + +func (s *Store) Migrate() error { + return s.db.AutoMigrate(&Entry{}) +} + +// List returns entries newest first, with limit/offset pagination. +func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + var entries []Entry + err := s.db.WithContext(ctx). + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&entries).Error + return entries, err +} + +// Create inserts a new entry and returns it with its generated id/timestamp. +func (s *Store) Create(ctx context.Context, name, message string) (Entry, error) { + e := Entry{Name: name, Message: message} + err := s.db.WithContext(ctx).Create(&e).Error + return e, err +} + +// Delete soft-deletes an entry by id. Returns true when a row was affected. +func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) { + res := s.db.WithContext(ctx).Delete(&Entry{}, id) + return res.RowsAffected > 0, res.Error +} + +// Count returns the total number of (non-deleted) entries. +func (s *Store) Count(ctx context.Context) (int64, error) { + var n int64 + err := s.db.WithContext(ctx).Model(&Entry{}).Count(&n).Error + return n, err +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..ee1b5be --- /dev/null +++ b/version.go @@ -0,0 +1,17 @@ +package main + +import goutils "github.com/jkaninda/go-utils" + +// version is the build version. Set it at build time with: +// +// go build -ldflags "-X main.version=1.0.0" +// +// It is overridable at runtime with APP_VERSION — handy for the canary demo, +// where you run two otherwise-identical builds (e.g. 1.0.0 and 2.0.0) and watch +// which one serves each request. +var version = "dev" + +// resolveVersion returns APP_VERSION when set, otherwise the build version. +func resolveVersion() string { + return goutils.Env("APP_VERSION", version) +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e3a3c04 --- /dev/null +++ b/web/index.html @@ -0,0 +1,427 @@ + + + + + + Miabi Guestbook + + + + +
+
+ +
+ v… + 0 online +
+
+ +
+

The Guestbook

+

A tiny example app — Okapi (Go) API + database, in a single container. Sign the wall and watch it update live.

+ +
+ + +
+
+
+ +
+ +
+ 0 / 500 + +
+
+ +
+

Recent signatures

+ — +
+
    +
    Loading…
    + +
    + + + + + +
    + +
    + + + +