Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
NODE_ENV=prod
PORT=8080
SOCKETIO_PORT=9000
# Preserve room membership while clients reconnect after a socket deploy.
ROOM_RECONNECT_GRACE_MS=60000
# Grand Prix is intentionally disabled until the mode is redesigned.
GRAND_PRIX_ENABLED=false
# Manual Compose commands use this tag. scripts/deploy.sh overrides it with
# the full deployed commit SHA.
APP_IMAGE_TAG=local

# Public URLs used by the client build
Expand Down
128 changes: 123 additions & 5 deletions README_DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# LetsCube Deployment

This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `redis`, and `nginx`.
This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `postgres`, `redis`, and `nginx`.

## Current Startup Audit

Expand Down Expand Up @@ -42,10 +42,16 @@ cp .env.example .env.prod
Start or update production:

```bash
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build
APP_IMAGE_TAG="$(git rev-parse HEAD)" \
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build
```

The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB and Redis are internal only.
Using the full commit SHA keeps manually deployed application images
immutable. `APP_IMAGE_TAG=local` remains available for direct development and
one-off Compose use. The deployment script always overrides `APP_IMAGE_TAG`
with the commit it checked out.

The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB, PostgreSQL, and Redis are internal only.

TLS expects Let’s Encrypt files at:

Expand All @@ -70,6 +76,7 @@ View logs:
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f api
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f socket
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f mongo
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f postgres
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f redis
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f nginx
```
Expand All @@ -87,6 +94,117 @@ Deploy latest code from the server checkout:
APP_DIR=/opt/letscube scripts/deploy.sh
```

The default `DEPLOY_TARGET=auto` chooses the smallest safe application target:

| Changed paths | Resolved target |
| --- | --- |
| Documentation, CI, or agent metadata only | `none` |
| Browser client only | `api` (which also serves the static client) |
| Shared socket protocol, server, dependency, container, deployment, or unknown files | `all` |

Override the classifier when the intended runtime impact is known:

```bash
DEPLOY_TARGET=api APP_DIR=/opt/letscube scripts/deploy.sh
DEPLOY_TARGET=socket APP_DIR=/opt/letscube scripts/deploy.sh
DEPLOY_TARGET=all APP_DIR=/opt/letscube scripts/deploy.sh
DEPLOY_TARGET=none APP_DIR=/opt/letscube scripts/deploy.sh
```

Accepted values are `auto`, `api`, `socket`, `all`, and `none`. Explicit
targets are honored even when the checkout is already current. With `auto`, an
unchanged checkout exits without replacing healthy running containers. If API,
socket, or nginx is missing, `auto` bootstraps both application services;
`DEPLOY_FORCE=true` also rebuilds and replaces both at the current commit.
Readiness waits are capped at 60 seconds before automatic rollback; override
that bound with `DEPLOY_WAIT_TIMEOUT_SECONDS` when diagnosing unusually slow
startup.

You can verify the conservative path classifier without fetching code or
touching Docker:

```bash
scripts/test-deploy-classifier.sh
scripts/test-deploy.sh
scripts/classify-deploy-target.sh client/src/App.jsx
```

For any selected application target, the deploy script builds one shared image
tagged with the full commit SHA, waits for backing services without recreating
them, and applies PostgreSQL migrations before replacing application
containers. It snapshots the exact image ID of every selected running service,
then replaces and health-checks services one at a time. For `all`, socket rolls
out before API/static so the backward-compatible server is ready before the new
browser client is served. nginx is gracefully reloaded only after a selected
service changes, so it resolves the replacement container without dropping
unrelated connections. If nginx is not running during a bootstrap deploy, its
startup is deferred until the selected application services are ready.

If replacement readiness or the nginx reload fails, recent diagnostic logs are
printed and every application service attempted in that deploy is restored in
reverse order using its exact pre-deploy image and Compose configuration. nginx
is then reloaded again.
Rollback image tags are retained locally for manual recovery until normal image
cleanup. Database migrations are not automatically reversed, so migrations
must remain backward-compatible with the previous application image.

The checkout remains at the fetched commit after a failed deploy. Correct the
failure and rerun with the explicit target printed by the script; a later
`auto` run with no new commit otherwise treats the checkout as up to date.

Socket clients briefly reconnect only when `socket` is selected. Room
membership, the current scramble, waiting/competing state, and room admin are
preserved during `ROOM_RECONNECT_GRACE_MS` (60 seconds by default). A client
that reconnects within that window resumes the existing room without being
logically removed first. Only users with no socket on any Socket.IO instance
after the grace window are removed from the room.

An in-progress solve continues timing in the browser during that reconnect. If
the solve finishes before the socket is ready, the result is retained locally,
the next solve is blocked, and the saved result is submitted only after the
client rejoins the room. The local copy is cleared only after the server
acknowledges persistence, so a refresh or repeated reconnect does not discard
the time.

Set the grace window in `.env.prod` if production deploys or client reconnects
need more time:

```bash
ROOM_RECONNECT_GRACE_MS=90000
```

Keep the grace shorter than the time in which an abandoned room should appear
active. Explicit leaves, kicks, and bans remain immediate and do not use the
grace window.

Grand Prix is intentionally disabled in production:

```bash
GRAND_PRIX_ENABLED=false
```

Keep this setting disabled until the legacy scheduler is redesigned. Setting
it to `true` explicitly restores the old mode for development or controlled
testing.

### Health Checks

The API and socket processes expose dependency-aware health endpoints.
Unavailable MongoDB or Redis returns HTTP `503` with the failing check marked
`error`. PostgreSQL dual writes are optional, so a PostgreSQL outage returns
HTTP `200` with overall status `degraded` while the primary MongoDB/Redis path
remains ready.

```bash
curl -sS https://letscube.net/health/api
curl -sS https://letscube.net/health/socket
```

The default Socket.IO namespace also accepts a `health_check` event. It returns
the socket health report through the acknowledgment callback, or emits
`health_status` when no callback is provided. This tests a real Socket.IO
connection in addition to the HTTP readiness endpoint.

## Backups And Restore

Run a MongoDB backup:
Expand Down Expand Up @@ -122,10 +240,10 @@ Do not upgrade the old VPS in place for this migration.
5. Clone this repo to `/opt/letscube`.
6. Create `.env.prod` with production secrets and domains.
7. Copy or issue Let’s Encrypt certs for `letscube.net`.
8. Start MongoDB and Redis containers.
8. Start MongoDB, PostgreSQL, and Redis containers.
9. Restore MongoDB from the production backup.
10. Start the full production stack.
11. Test frontend loading, login, room create/join, race/session flows, Redis-backed socket behavior, backups, and restore on non-production data.
12. Confirm MongoDB and Redis are not publicly reachable.
12. Confirm MongoDB, PostgreSQL, and Redis are not publicly reachable.
13. Point DNS at the new VPS.
14. Keep the old VPS available for rollback until the new stack has been stable.
143 changes: 138 additions & 5 deletions client/src/components/Navigation.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import React, { Suspense, lazy } from 'react';
import PropTypes from 'prop-types';
import { Switch, Route, Redirect } from 'react-router-dom';
import {
Switch, Route, Redirect, useLocation,
} from 'react-router-dom';
import { connect, useDispatch } from 'react-redux';
import { push } from 'connected-react-router';
// import Backdrop from '@material-ui/core/Backdrop';
import CircularProgress from '@material-ui/core/CircularProgress';
import Button from '@material-ui/core/Button';
import Snackbar from '@material-ui/core/Snackbar';
import Alert from '@material-ui/lab/Alert';
import { makeStyles } from '@material-ui/core/styles';
Expand All @@ -13,12 +17,97 @@ import Footer from './Footer';
import WCARedirect from './WCARedirect';
import Admin from './Admin';
import { closeMessage } from '../store/messages/actions';
import { discardPendingResult } from '../store/room/actions';
import {
canDiscardPendingResult,
isPendingResult,
pendingResultBelongsToUser,
} from '../store/room/resultOutbox';
import Text from './Text';

const Lobby = lazy(() => import('./Lobby/index'));
const Room = lazy(() => import('./Room/index'));
const Profile = lazy(() => import('./common/Profile'));

export const shouldShowGlobalPendingResult = (room, pendingResult) => (
isPendingResult(pendingResult)
&& !(room.accessCode && room.type === 'normal' && !room.fetching)
);

export function GlobalPendingResultAlert({
atPendingRoom,
error,
onDiscard,
onReturn,
pendingResult,
status,
userId,
}) {
const belongsToUser = pendingResultBelongsToUser(pendingResult, userId);
const canDiscardResult = canDiscardPendingResult(pendingResult, status);
let message;

if (!belongsToUser) {
message = canDiscardResult
? 'A saved time for another account is stored on this device. Switch back to that account or discard it.'
: 'A saved time for another account may already be submitting. Switch back to that account to finish it.';
} else if (status === 'failed') {
message = `Your saved time could not be submitted: ${error.message}`;
} else if (atPendingRoom) {
message = canDiscardResult
? 'Your time is still saved on this device, but its room is not currently joined. Restore access to the room or discard the saved result.'
: 'Your time may already be submitting, but its room is not currently joined. Restore access to the room to finish it.';
} else {
message = canDiscardResult
? `Your saved time is waiting in room ${pendingResult.roomId}. Return there to submit it, or discard it.`
: `Your saved time is waiting in room ${pendingResult.roomId}. Return there to finish submitting it.`;
}

return (
<Alert
severity={status === 'failed' ? 'error' : 'warning'}
action={(
<>
{belongsToUser && !atPendingRoom && (
<Button color="inherit" size="small" onClick={onReturn}>
Return to room
</Button>
)}
{canDiscardResult && (
<Button color="inherit" size="small" onClick={onDiscard}>
Discard saved result
</Button>
)}
</>
)}
>
{message}
</Alert>
);
}

GlobalPendingResultAlert.propTypes = {
atPendingRoom: PropTypes.bool.isRequired,
error: PropTypes.shape({
message: PropTypes.string,
}),
onDiscard: PropTypes.func.isRequired,
onReturn: PropTypes.func.isRequired,
pendingResult: PropTypes.shape({
deliveryAttempted: PropTypes.bool,
roomId: PropTypes.string,
submissionId: PropTypes.string,
userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}).isRequired,
status: PropTypes.oneOf(['pending', 'sending', 'failed']).isRequired,
userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};

GlobalPendingResultAlert.defaultProps = {
error: null,
userId: undefined,
};

const useStyles = makeStyles((theme) => ({
root: {
display: 'flex',
Expand Down Expand Up @@ -46,10 +135,20 @@ const useStyles = makeStyles((theme) => ({
}));

function Navigation({
room, connected, user, messages, server,
room, roomList, connected, user, messages, server,
}) {
const classes = useStyles();
const dispatch = useDispatch();
const location = useLocation();
const roomConnectionInterrupted = !!room._id && !roomList.connected;
const connectionInterrupted = room._id
? roomConnectionInterrupted
: (!connected || server.reconnecting);
const resultSubmission = room.resultSubmission || {};
const pendingResult = resultSubmission.pendingResult;
const showGlobalPendingResult = shouldShowGlobalPendingResult(room, pendingResult);
const pendingRoomPath = pendingResult ? `/rooms/${pendingResult.roomId}` : null;
const atPendingRoom = location.pathname === pendingRoomPath;

const handleClose = (index, event, reason) => {
if (reason === 'clickaway') {
Expand Down Expand Up @@ -82,9 +181,11 @@ function Navigation({

<div className={classes.container}>
<Header />
{(!connected || server.reconnecting) && (
<Alert severity="error">
Disconnected from server.
{connectionInterrupted && (
<Alert severity={roomConnectionInterrupted ? 'warning' : 'error'}>
{roomConnectionInterrupted
? 'Room connection interrupted. You can finish your solve; your completed time will be saved on this device.'
: 'Disconnected from server.'}
{ (server.reconnecting || server.reconnectAttempts > 0) && (
` Reconnecting...${server.reconnectAttempts} attempts`
)}
Expand All @@ -99,6 +200,17 @@ function Navigation({
)}
</Alert>
)}
{showGlobalPendingResult && (
<GlobalPendingResultAlert
atPendingRoom={atPendingRoom}
error={resultSubmission.error}
onDiscard={() => dispatch(discardPendingResult(pendingResult.submissionId))}
onReturn={() => dispatch(push(pendingRoomPath))}
pendingResult={pendingResult}
status={resultSubmission.status}
userId={user.id}
/>
)}
<main className={classes.content}>
<Suspense fallback={Loading}>
{!user.fetching && (
Expand Down Expand Up @@ -145,6 +257,23 @@ Navigation.propTypes = {
})),
room: PropTypes.shape({
_id: PropTypes.string,
accessCode: PropTypes.string,
fetching: PropTypes.bool,
type: PropTypes.string,
resultSubmission: PropTypes.shape({
status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']),
pendingResult: PropTypes.shape({
deliveryAttempted: PropTypes.bool,
roomId: PropTypes.string,
submissionId: PropTypes.string,
}),
error: PropTypes.shape({
message: PropTypes.string,
}),
}),
}),
roomList: PropTypes.shape({
connected: PropTypes.bool,
}),
server: PropTypes.shape({
reconnecting: PropTypes.bool,
Expand All @@ -161,6 +290,9 @@ Navigation.defaultProps = {
room: {
id: undefined,
},
roomList: {
connected: false,
},
server: {
reconnecting: false,
reconnectAttempts: 0,
Expand All @@ -174,6 +306,7 @@ const mapStateToProps = (state) => ({
user: state.user,
messages: state.messages.messages,
room: state.room,
roomList: state.roomList,
});

export default connect(mapStateToProps)(Navigation);
Loading
Loading