local docker dev + starting on gameplay

This commit is contained in:
2026-09-16 09:30:29 -05:00
parent ab9573b875
commit d4b4de17aa
5 changed files with 348 additions and 25 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/api
RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/server
FROM alpine:3.22
+236
View File
@@ -0,0 +1,236 @@
# Deployment
`go-fishing` runs on a single VPS with Docker Compose.
Images are stored in the private Gitea registry at:
```text
git.hat.fish
```
## Traffic
```text
fishing.jp.wtf:22
-> go-fishing SSH server
fishing.jp.wtf:80
-> Nginx
-> redirect to HTTPS
fishing.jp.wtf:443
-> Nginx
-> go-fishing HTTP server
VPS:2222
-> OpenSSH
```
Port `22` belongs to the application.
The VPS's administrative SSH server listens on `2222`.
## Docker
The Compose stack contains:
```text
go-fishing
postgres
```
The application image is:
```text
git.hat.fish/jaron/go-fishing:latest
```
PostgreSQL uses:
```text
postgres:18-alpine
```
PostgreSQL data is stored in a Docker volume.
The database is only accessible through the Docker network.
```text
go-fishing -> db:5432
```
There is no reason to expose PostgreSQL publicly.
## HTTP
The Go application exposes an HTTP server on:
```text
9900
```
It is not intended to be accessed directly.
Nginx proxies HTTPS traffic to it:
```text
https://fishing.jp.wtf
-> nginx :443
-> 127.0.0.1:9900
```
A minimal Nginx configuration looks like:
```nginx
server {
listen 443 ssl;
server_name fishing.jp.wtf;
ssl_certificate /etc/letsencrypt/live/fishing.jp.wtf/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/fishing.jp.wtf/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9900;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name fishing.jp.wtf;
return 301 https://$host$request_uri;
}
```
Nginx does not handle SSH traffic.
## TLS
TLS certificates come from Let's Encrypt and are managed by Certbot.
Certificate files live under:
```text
/etc/letsencrypt/live/fishing.jp.wtf/
```
The important files are:
```text
fullchain.pem
privkey.pem
```
The certificate was created with:
```bash
sudo certbot --nginx -d fishing.jp.wtf
```
Renewal is handled automatically by Certbot.
Check it with:
```bash
systemctl status certbot.timer
```
Test renewal with:
```bash
sudo certbot renew --dry-run
```
The Go application does not manage TLS itself.
## SSH
`go-fishing` runs an SSH server using Charm Wish.
Users connect with:
```bash
ssh user@fishing.jp.wtf
```
The VPS itself is administered with:
```bash
ssh -p 2222 user@<vps>
```
Nginx and the TLS certificate are unrelated to SSH.
## Configuration
Secrets and environment-specific configuration live in `.env` on the VPS.
Example:
```env
COOKIE_STORE_SECRET="top secret secret"
DB_CONNECTION_STRING="postgres://dev:dev@localhost:5432/go_fishing"
POSTGRES_USER="dev"
POSTGRES_PASSWORD="dev"
POSTGRES_DB="go_fishing"
ENV="DEVELOPMENT"
```
## Deployment
Deployment is currently manual.
Build and push:
```bash
# If building for same CPU architecture
docker build -t git.hat.fish/jaron/go-fishing:latest .
# If building from another architecture
docker build --platform=linux/amd64 -t git.hat.fish/jaron/go-fishing:latest .
docker push git.hat.fish/jaron/go-fishing:latest
```
Deploy on the VPS:
```bash
docker compose pull
docker compose up -d
```
That's the deployment process.
There is no CI/CD pipeline yet.
## Infrastructure
The complete production setup is:
```text
DNS
└── fishing.jp.wtf
|
v
VPS
├── OpenSSH :2222
├── Nginx :80/:443
│ ├── Let's Encrypt certificate
│ └── proxy -> localhost:9900
└── Docker Compose
├── go-fishing
│ └── SSH :22
└── PostgreSQL
└── Docker volume
Gitea
└── container registry
```
There is no Kubernetes, managed database, load balancer, deployment platform, or other infrastructure layer.
None is currently needed.
+106 -23
View File
@@ -217,18 +217,21 @@ const (
)
type model struct {
DB *db.Database
page page
tabs []string
tab tab
term string
profile string
term string
width int
height int
bg string
DB *db.Database
page page
angler models.Angler
tabs []string
tab tab
error string
ti textinput.Model
spinner spinner.Model
angler models.Angler
active tea.Model
cast models.Cast
}
func (m model) Init() tea.Cmd {
@@ -249,7 +252,13 @@ func (m model) Init() tea.Cmd {
}
func (m model) ChooseNameView() tea.View {
input := lipgloss.JoinVertical(lipgloss.Top, "Got a name?", m.ti.View())
var error string
if len(m.error) > 0 {
error = lipgloss.NewStyle().Foreground(lipgloss.Red).Render(m.error)
}
input := lipgloss.JoinVertical(lipgloss.Top, "aye fresh meat, got a name laddie?", m.ti.View(), error)
center := lipgloss.Place(
m.width,
@@ -279,7 +288,7 @@ func (m model) ChooseNameView() tea.View {
c = m.ti.Cursor()
c.Y += lipgloss.Height("aye, fresh meat – got a name laddie?") + (m.height-3-lipgloss.Height(input))/2
c.Y += lipgloss.Height("aye fresh meat, got a name laddie?") + (m.height-3-lipgloss.Height(input))/2
c.X += (m.width - lipgloss.Width(input)) / 2
v := tea.NewView(str)
@@ -294,20 +303,33 @@ func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
m.error = ""
switch msg.String() {
case "enter":
err := m.DB.Conn.Get(
&m.angler,
"UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email",
m.ti.Value(), m.angler.ID,
)
existing := 0
desired_username := m.ti.Value()
err := m.DB.Conn.Get(&existing, "SELECT 1 FROM anglers WHERE angler_username = $1", desired_username)
if err != nil {
// TODO: handle error
}
m, cmd = m.gotoGameplayView()
cmds = append(cmds, cmd)
if existing > 0 {
m.error = "that one is taken"
} else {
err = m.DB.Conn.Get(
&m.angler,
"UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email",
desired_username, m.angler.ID,
)
if err != nil {
// TODO: handle error
}
m, cmd = m.gotoGameplayView()
cmds = append(cmds, cmd)
}
}
}
@@ -318,7 +340,8 @@ func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
var buttonStyle = lipgloss.NewStyle().Background(lipgloss.Color("30")).Padding(0, 3).Align(lipgloss.Center)
var buttonStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Background(lipgloss.Color("8"))
var buttonActiveStyle = buttonStyle.Background(lipgloss.Color("30"))
var sidebarBtnStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Foreground(lipgloss.Color("30"))
var sidebarBtnActiveStyle = sidebarBtnStyle.UnsetForeground().Background(lipgloss.Color("30"))
@@ -348,7 +371,11 @@ func (m model) GameplayView() tea.View {
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("30")).
Padding(paddingY, paddingX).
Render(fmt.Sprintf("Ay matie %s", m.angler.Username))
Render(lipgloss.JoinVertical(
lipgloss.Left,
lipgloss.NewStyle().MarginBottom(1).Render(fmt.Sprintf("Aye matie %s...time to get fishin'", m.angler.Username)),
buttonActiveStyle.Render("Cast"),
))
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)")
@@ -387,17 +414,54 @@ func (m model) GameplayView() tea.View {
type GameplayKeyMap struct {
CycleTab key.Binding
AltCycleTab key.Binding
Interact key.Binding
}
var DefaultGameplayKeyMap = GameplayKeyMap{
CycleTab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("shift+tab", "Cycle Tab Right"),
key.WithHelp("tab", "Cycle Tab Right"),
),
AltCycleTab: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "Cycle Tab Left"),
),
Interact: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "Interact with selected option"),
),
}
var sayings = []string{
// Near Misses
"Close but no cigar",
"Almost only counts in horseshoes and hand grenades",
"A day late and a dollar short",
"A miss is as good as a mile",
"Always a bridesmaid, never a bride",
"Snatched defeat from the jaws of victory",
"Fell at the last hurdle",
"Missed the boat",
"Missed it by a whisker",
"So near, and yet so far",
"A swing and a miss",
// Futile Efforts
"Beating a dead horse",
"Tilting at windmills",
"Squeezing blood from a stone",
"Nailing jelly to a wall",
"Herding cats",
"Rearranging deck chairs on the Titanic",
"Pushing a rope",
"Spitting in the wind",
"Putting toothpaste back in the tube",
"Shoveling snow in a blizzard",
"Spinning your wheels",
"Emptying the ocean with a teaspoon",
"Barking up the wrong tree",
"A wild goose chase",
"Catching the wind in a net",
}
func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) {
@@ -422,6 +486,25 @@ func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) {
} else {
m.tab -= 1
}
case key.Matches(msg, DefaultGameplayKeyMap.Interact):
// if m.active = {
is_casting, err := m.DB.IsCasting(m.angler.ID)
if is_casting {
m.error = "looks like you are already casting somewhere"
// TODO: clear after a timeout
}
m.cast, err = m.DB.CreateNewCast(m.angler.ID)
if err != nil {
m.error = "unable to start your cast"
// TODO: handle error
}
// TODO: implement rest of casting logic
// }
}
}
@@ -498,17 +581,17 @@ func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOpti
angler, _ := s.Context().Value("angler").(models.Angler)
m := model{
DB: DB,
tabs: []string{"fishin'", "wares", "dock", "settings"},
tab: TabFishin,
page: PageChooseName,
term: pty.Term,
width: pty.Window.Width,
height: pty.Window.Height,
bg: "light",
DB: DB,
page: PageChooseName,
angler: angler,
tabs: []string{"fishin'", "wares", "dock", "settings"},
tab: TabFishin,
ti: textinput.New(),
spinner: spinner.New(),
angler: angler,
}
if angler.Username == gossh.FingerprintSHA256(s.PublicKey()) {
+4 -1
View File
@@ -5,7 +5,10 @@ services:
dockerfile: Dockerfile.dev
volumes:
- .:/app
command: air --build.cmd "go build -o /tmp/api ./cmd/api" --build.entrypoint "/tmp/api"
command: air --misc.startup_banner "====== fishin' 🎣 ======" --build.cmd "go build -o /tmp/server ./cmd/server" --build.entrypoint "/tmp/server"
ports:
- "9900:9900"
- "2222:22"
db:
ports:
- "5432:5432"
+1
View File
@@ -8,6 +8,7 @@ services:
environment:
COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET}
DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ENV: ${ENV}
ports:
- "22:22"
- "9900:9900"