local docker dev + starting on gameplay
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
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
|
FROM alpine:3.22
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -217,18 +217,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
DB *db.Database
|
|
||||||
page page
|
|
||||||
tabs []string
|
|
||||||
tab tab
|
|
||||||
term string
|
|
||||||
profile string
|
profile string
|
||||||
|
term string
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
bg string
|
bg string
|
||||||
|
DB *db.Database
|
||||||
|
page page
|
||||||
|
angler models.Angler
|
||||||
|
tabs []string
|
||||||
|
tab tab
|
||||||
|
error string
|
||||||
ti textinput.Model
|
ti textinput.Model
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
angler models.Angler
|
active tea.Model
|
||||||
|
cast models.Cast
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
@@ -249,7 +252,13 @@ func (m model) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) ChooseNameView() tea.View {
|
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(
|
center := lipgloss.Place(
|
||||||
m.width,
|
m.width,
|
||||||
@@ -279,7 +288,7 @@ func (m model) ChooseNameView() tea.View {
|
|||||||
|
|
||||||
c = m.ti.Cursor()
|
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
|
c.X += (m.width - lipgloss.Width(input)) / 2
|
||||||
|
|
||||||
v := tea.NewView(str)
|
v := tea.NewView(str)
|
||||||
@@ -294,20 +303,33 @@ func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) {
|
|||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
|
m.error = ""
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "enter":
|
case "enter":
|
||||||
err := m.DB.Conn.Get(
|
existing := 0
|
||||||
&m.angler,
|
desired_username := m.ti.Value()
|
||||||
"UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email",
|
err := m.DB.Conn.Get(&existing, "SELECT 1 FROM anglers WHERE angler_username = $1", desired_username)
|
||||||
m.ti.Value(), m.angler.ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: handle error
|
// TODO: handle error
|
||||||
}
|
}
|
||||||
|
|
||||||
m, cmd = m.gotoGameplayView()
|
if existing > 0 {
|
||||||
cmds = append(cmds, cmd)
|
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...)
|
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 sidebarBtnStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Foreground(lipgloss.Color("30"))
|
||||||
var sidebarBtnActiveStyle = sidebarBtnStyle.UnsetForeground().Background(lipgloss.Color("30"))
|
var sidebarBtnActiveStyle = sidebarBtnStyle.UnsetForeground().Background(lipgloss.Color("30"))
|
||||||
@@ -348,7 +371,11 @@ func (m model) GameplayView() tea.View {
|
|||||||
BorderStyle(lipgloss.NormalBorder()).
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("30")).
|
BorderForeground(lipgloss.Color("30")).
|
||||||
Padding(paddingY, paddingX).
|
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)")
|
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)")
|
||||||
|
|
||||||
@@ -387,17 +414,54 @@ func (m model) GameplayView() tea.View {
|
|||||||
type GameplayKeyMap struct {
|
type GameplayKeyMap struct {
|
||||||
CycleTab key.Binding
|
CycleTab key.Binding
|
||||||
AltCycleTab key.Binding
|
AltCycleTab key.Binding
|
||||||
|
Interact key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var DefaultGameplayKeyMap = GameplayKeyMap{
|
var DefaultGameplayKeyMap = GameplayKeyMap{
|
||||||
CycleTab: key.NewBinding(
|
CycleTab: key.NewBinding(
|
||||||
key.WithKeys("tab"),
|
key.WithKeys("tab"),
|
||||||
key.WithHelp("shift+tab", "Cycle Tab Right"),
|
key.WithHelp("tab", "Cycle Tab Right"),
|
||||||
),
|
),
|
||||||
AltCycleTab: key.NewBinding(
|
AltCycleTab: key.NewBinding(
|
||||||
key.WithKeys("shift+tab"),
|
key.WithKeys("shift+tab"),
|
||||||
key.WithHelp("shift+tab", "Cycle Tab Left"),
|
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) {
|
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 {
|
} else {
|
||||||
m.tab -= 1
|
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)
|
angler, _ := s.Context().Value("angler").(models.Angler)
|
||||||
|
|
||||||
m := model{
|
m := model{
|
||||||
DB: DB,
|
|
||||||
tabs: []string{"fishin'", "wares", "dock", "settings"},
|
|
||||||
tab: TabFishin,
|
|
||||||
page: PageChooseName,
|
|
||||||
term: pty.Term,
|
term: pty.Term,
|
||||||
width: pty.Window.Width,
|
width: pty.Window.Width,
|
||||||
height: pty.Window.Height,
|
height: pty.Window.Height,
|
||||||
bg: "light",
|
bg: "light",
|
||||||
|
DB: DB,
|
||||||
|
page: PageChooseName,
|
||||||
|
angler: angler,
|
||||||
|
tabs: []string{"fishin'", "wares", "dock", "settings"},
|
||||||
|
tab: TabFishin,
|
||||||
ti: textinput.New(),
|
ti: textinput.New(),
|
||||||
spinner: spinner.New(),
|
spinner: spinner.New(),
|
||||||
angler: angler,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if angler.Username == gossh.FingerprintSHA256(s.PublicKey()) {
|
if angler.Username == gossh.FingerprintSHA256(s.PublicKey()) {
|
||||||
@@ -5,7 +5,10 @@ services:
|
|||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/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:
|
ports:
|
||||||
- "9900:9900"
|
- "9900:9900"
|
||||||
- "2222:22"
|
- "2222:22"
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET}
|
COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET}
|
||||||
DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
ENV: ${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "22:22"
|
- "22:22"
|
||||||
- "9900:9900"
|
- "9900:9900"
|
||||||
|
|||||||
Reference in New Issue
Block a user