local docker dev + starting on gameplay
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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()) {
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user