Skip to content
Merged
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
149 changes: 149 additions & 0 deletions .github/workflows/build-and-push-worker-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
name: Build and Push Docker Image

on:
push:
branches:
- main
paths:
- 'apps/web/package.json'
Comment thread
SimonLoir marked this conversation as resolved.
Comment on lines +4 to +8
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the full workflow file
cat -n .github/workflows/build-and-push-worker-image.yml

Repository: SeaStackApp/sea-stack-app

Length of output: 5657


🏁 Script executed:

# Check the directory structure to understand the project layout
ls -la apps/ 2>/dev/null | head -20

Repository: SeaStackApp/sea-stack-app

Length of output: 338


🏁 Script executed:

# Look for similar workflows to understand the pattern
ls -la .github/workflows/ | grep -E "build|push|image"

Repository: SeaStackApp/sea-stack-app

Length of output: 282


🏁 Script executed:

# Check if apps/workers/package.json exists
[ -f apps/workers/package.json ] && echo "apps/workers/package.json EXISTS" || echo "apps/workers/package.json MISSING"

Repository: SeaStackApp/sea-stack-app

Length of output: 101


🏁 Script executed:

# Check if apps/web/package.json exists
[ -f apps/web/package.json ] && echo "apps/web/package.json EXISTS" || echo "apps/web/package.json MISSING"

Repository: SeaStackApp/sea-stack-app

Length of output: 97


Update the workflow to monitor apps/workers/package.json instead of apps/web/package.json.

The workflow triggers on changes to apps/web/package.json (line 8) and reads the version from there (lines 31, 39), but it builds the Docker image from apps/workers/Dockerfile (line 92). This is a copy-paste error from the web workflow. The worker image should be versioned and triggered based on apps/workers/package.json changes.

Apply the following changes:

     paths:
-      - 'apps/web/package.json'
+      - 'apps/workers/package.json'
 
 # ... later in the workflow ...
 
       - name: Get current version
         id: get_version
         run: |
-          VERSION=$(jq -r '.version' apps/web/package.json)
+          VERSION=$(jq -r '.version' apps/workers/package.json)
           echo "version=$VERSION" >> $GITHUB_OUTPUT
           echo "Current version: $VERSION"
 
       - name: Get previous version
         id: get_prev_version
         run: |
-          if git show HEAD^1:apps/web/package.json > /dev/null 2>&1; then
-            PREV_VERSION=$(git show HEAD^1:apps/web/package.json | jq -r '.version')
+          if git show HEAD^1:apps/workers/package.json > /dev/null 2>&1; then
+            PREV_VERSION=$(git show HEAD^1:apps/workers/package.json | jq -r '.version')
           else
             PREV_VERSION="none"
           fi

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
.github/workflows/build-and-push-worker-image.yml around lines 4-8 (and also
update references at lines ~31, ~39 and note the Dockerfile at ~92): the
workflow currently watches and reads version from apps/web/package.json due to a
copy-paste error; change the path trigger from 'apps/web/package.json' to
'apps/workers/package.json', and update any steps that read package.json for the
image version (at lines ~31 and ~39) to read from apps/workers/package.json
instead so the worker image build (which uses apps/workers/Dockerfile at ~92) is
triggered and versioned by the worker package.json.


env:
REGISTRY: ghcr.io
IMAGE_NAME: seastackapp/seastack-worker

jobs:
check-version-change:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version_changed: ${{ steps.check.outputs.changed }}
new_version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4.2.2
with:
fetch-depth: 2

- name: Get current version
id: get_version
run: |
VERSION=$(jq -r '.version' apps/web/package.json)
Comment thread
SimonLoir marked this conversation as resolved.
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Current version: $VERSION"

- name: Get previous version
id: get_prev_version
run: |
if git show HEAD^1:apps/web/package.json > /dev/null 2>&1; then
PREV_VERSION=$(git show HEAD^1:apps/web/package.json | jq -r '.version')
Comment thread
SimonLoir marked this conversation as resolved.
else
PREV_VERSION="none"
fi
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
echo "Previous version: $PREV_VERSION"

- name: Check if version changed
id: check
run: |
CURRENT="${{ steps.get_version.outputs.version }}"
PREVIOUS="${{ steps.get_prev_version.outputs.prev_version }}"
if [ "$CURRENT" != "$PREVIOUS" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "Version changed from $PREVIOUS to $CURRENT"
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "Version did not change"
fi

build-and-push:
needs: check-version-change
if: needs.check-version-change.outputs.version_changed == 'true'
strategy:
matrix:
include:
- runner: ubuntu-latest
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4.2.2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.7.1

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push by digest
id: build
uses: docker/build-push-action@v6.10.0
with:
context: .
file: ./apps/workers/Dockerfile
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"

- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1

merge:
needs: [check-version-change, build-and-push]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.7.1

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5.6.1
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=${{ needs.check-version-change.outputs.new_version }}

- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.11.3",
"version": "0.11.4",
"type": "module",
"private": true,
"scripts": {
Expand Down
78 changes: 78 additions & 0 deletions apps/workers/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Multi-stage Dockerfile for the workers app in a pnpm + turbo monorepo
# Uses Alpine for the final worker image

# -----------------------
# 1) Base image with pnpm
# -----------------------
FROM node:24-alpine AS base

# Runtime dependencies commonly needed by Node native modules & Prisma
RUN apk add --no-cache libc6-compat openssl

# Enable corepack and pin pnpm (must match repo tooling)
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
Comment thread
SimonLoir marked this conversation as resolved.

WORKDIR /app

# -----------------------
# 2) Dependencies layer
# - install workspace deps with caching
# -----------------------
FROM base AS deps

# Build toolchain for native modules (not in final image)
RUN apk add --no-cache python3 make g++

# Copy only files needed to resolve the dependency graph to leverage Docker cache
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./

# Copy workspace manifests used by the workers app and its internal deps
COPY apps/workers/package.json ./apps/workers/package.json
COPY packages ./packages

# Install dependencies (workspace-aware) using lockfile
RUN pnpm install --frozen-lockfile

# -----------------------
# 3) Build layer
# -----------------------
FROM deps AS build

# Bring in full source to build the workers package
COPY . .

# Provide safe defaults for codegen steps that don't require a live DB
ENV DATABASE_URL=postgres://postgres:password@postgres:5432/postgres

# Generate Prisma client for @repo/db (no live DB needed)
RUN pnpm --filter @repo/db... generate || pnpm --filter @repo/db generate || true

# Build the workers app (outputs to apps/workers/dist)
RUN pnpm --filter workers build

# Produce a pruned, production-ready output for just the workers package
# This includes the package files, its dist, and a pruned node_modules
RUN pnpm deploy --filter workers --prod /out

# -----------------------
# 4) Runtime layer (Alpine)
# -----------------------
FROM node:24-alpine AS runner

ENV NODE_ENV=production

# Runtime libs needed by Prisma and other native deps
RUN apk add --no-cache libc6-compat openssl

# Non-root user for security
RUN adduser -D worker

WORKDIR /app

# Copy the pruned deployment from build stage
COPY --from=build /out/ .

USER worker

# Start the worker process
CMD ["node", "dist/index.mjs"]
4 changes: 4 additions & 0 deletions apps/workers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@
"dependencies": {
"@dotenvx/dotenvx": "^1.51.1",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"@repo/db": "workspace:*",
"@repo/queues": "workspace:*",
"@repo/utils": "workspace:*",
"@types/ssh2": "^1.15.5",
"bullmq": "^5.65.1",
"ioredis": "^5.8.2",
"pino": "^10.1.0",
"pino-pretty": "^13.1.3",
"pg": "^8.16.3",
"prisma": "^7.1.0",
"ssh2": "^1.17.0"
},
Expand Down
95 changes: 61 additions & 34 deletions apps/workers/src/backups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ import { setupWorker } from './setupWorker';
import { BACKUPS_QUEUE_NAME, VolumeBackupJob } from '@repo/queues';
import { prisma } from '@repo/db';
import {
decrypt,
encrypt,
generateRcloneFlags,
generateVolumeName,
getLogger,
getS3Storage,
getSSHClient,
parseRetentionString,
remoteExec,
sh,
} from '@repo/utils';
import { Client } from 'ssh2';

export const setUpVolumeBackups = () => {
return setupWorker<VolumeBackupJob>(BACKUPS_QUEUE_NAME, async (job) => {
console.log(`Processing job ${job.id}`);
const { logger, logs } = getLogger();
console.info(`Processing job ${job.id}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use structured logger consistently instead of console methods.

Lines 20, 39, and 42 still use console.info and console.error instead of the structured logger initialized on line 19. This inconsistency means these messages won't be captured in the encrypted logs or benefit from structured logging.

Apply this diff to use the logger consistently:

         const { logger, logs } = getLogger();
-        console.info(`Processing job ${job.id}`);
+        logger.info(`Processing job ${job.id}`);
         const schedule = await prisma.volumeBackupSchedule.findUnique({
             where: { id: job.data.schedule },
             include: {
@@ -37,10 +37,10 @@
             },
         });
         if (!schedule) {
-            console.error(`Could not find schedule ${job.data.schedule}`);
+            logger.error(`Could not find schedule ${job.data.schedule}`);
             return;
         }
-        console.info(
+        logger.info(
             `Starting backup for schedule ${schedule.id} (${schedule.volume.name})`
         );

Also applies to: 39-42

🤖 Prompt for AI Agents
In apps/workers/src/backups.ts around lines 20 and 39–42, replace the direct
console calls with the structured logger initialized on line 19: change
console.info(`Processing job ${job.id}`) to logger.info and change console.error
usages to logger.error; pass the same message text but include job id and any
error object as structured fields (e.g., logger.info({ jobId: job.id },
"Processing job") and logger.error({ jobId: job.id, err }, "Error message")) so
all messages are captured by the encrypted/structured logger.

const schedule = await prisma.volumeBackupSchedule.findUnique({
where: { id: job.data.schedule },
include: {
Expand All @@ -34,13 +39,22 @@ export const setUpVolumeBackups = () => {
console.error(`Could not find schedule ${job.data.schedule}`);
return;
}
console.log(
console.info(
`Starting backup for schedule ${schedule.id} (${schedule.volume.name})`
);
const service = schedule.volume.service;
const serverId = service.server.id;
const volumeName = generateVolumeName(schedule.volume.name, service.id);
const backupFilename = `backup-${volumeName}-${job.id}.tar.zst`;
const baseFileName = `backup-${volumeName}`;
const backupFilename = `${baseFileName}.tar.zst`;
const run = await prisma.backupRun.create({
data: {
status: 'RUNNING',
volumeBackupSchedule: {
connect: { id: schedule.id },
},
},
});

let connection: Client | undefined = undefined;
try {
Expand All @@ -49,46 +63,59 @@ export const setUpVolumeBackups = () => {
serverId,
schedule.volume.service.server.organizations[0]!.id
);
console.log('Connected to server via SSH');
logger.debug('Connected to server via SSH');

const command = sh`docker run --rm -v ${volumeName}:/data alpine sh -c "tar -C /data -cf - ." | zstd -z -19 -o ${backupFilename}`;
const command = sh`docker run --rm -v ${volumeName}:/data alpine sh -c "tar -C /data -cf - ." | zstd -z -19 -o ${backupFilename} -f`;

console.log(`Running command: ${command}`);
logger.debug(`Running command: ${command}`);
await remoteExec(connection, command);
console.log(`Backup created: ${backupFilename}`);

console.log('Uploading backup file to S3 using rclone');
logger.debug('Uploading backup file to S3 using rclone');

const s3 = await prisma.s3Storage.findFirst({
where: { id: schedule.storageDestinationId },
});
const s3 = await getS3Storage(
prisma,
schedule.storageDestinationId
);

if (!s3) {
throw new Error(
`Could not find S3 storage destination ${schedule.storageDestinationId}`
);
}
const flags = generateRcloneFlags(s3);
const target = sh`:s3:${s3.bucket}/seastack/backups/${schedule.id}/${new Date().getTime()}-${backupFilename}`;
const secureName = sh`./${backupFilename}`;
const rcloneCommand = `rclone copyto ${secureName} ${target} ${flags} --progress`;

const flags = [
'--s3-provider=Other',
sh`--s3-access-key-id=${decrypt(s3.accessKeyId)}`,
sh`--s3-secret-access-key=${decrypt(s3.secretAccessKey)}`,
sh`--s3-endpoint=${s3.endpoint}`,
s3.region ? `--s3-region=${s3.region}` : '',
'--s3-acl=private',
sh`--s3-force-path-style=${s3.usePathStyle ? 'true' : 'false'}`,
].join(' ');
const target = sh`:s3:${s3.bucket}`;
const secureName = sh`${backupFilename}`;
const rcloneCommand = `rclone copy ${secureName} ${target} ${flags} --progress`;
logger.info(`Running command: ${rcloneCommand}`);
logger.info(await remoteExec(connection, rcloneCommand));

console.log(`Running command: ${rcloneCommand}`);
console.log(await remoteExec(connection, rcloneCommand));
logger.info('Creating copies for data retention');
const { rules } = parseRetentionString(schedule.retention);
for (const { unit } of rules) {
if (unit === 'latest') continue;
const newTarget = sh`:s3:${s3.bucket}/seastack/backups/${schedule.id}/${baseFileName}.${unit}`;

console.log('Deleting local backup file');
await remoteExec(connection, sh`rm ${backupFilename}`);
const copyCommand = `rclone copyto ${target} ${newTarget} ${flags} --progress`;
logger.info(`Running command: ${copyCommand}`);
logger.info(await remoteExec(connection, copyCommand));
}
Comment thread
SimonLoir marked this conversation as resolved.

logger.debug('Deleting local backup file');
logger.debug(
await remoteExec(connection, sh`rm ${backupFilename}`)
);

logger.info(`Backup created: ${backupFilename}`);
await prisma.backupRun.update({
where: { id: run.id },
data: {
status: 'SUCCESS',
artifactLocation: backupFilename,
Comment thread
SimonLoir marked this conversation as resolved.
logs: encrypt(logs.join('')),
},
});
Comment on lines +105 to +112
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Store the complete S3 artifact path, not just the filename.

Line 109 sets artifactLocation to just backupFilename (e.g., "backup-volume.tar.zst"), but the actual S3 upload on line 81 includes a timestamp and full path: :s3:${s3.bucket}/seastack/backups/${schedule.id}/${timestamp}-${backupFilename}. Storing only the filename makes it difficult to retrieve the backup later since the timestamp information is lost.

Apply this diff to store the complete S3 path:

             const flags = generateRcloneFlags(s3);
-            const target = sh`:s3:${s3.bucket}/seastack/backups/${schedule.id}/${new Date().getTime()}-${backupFilename}`;
+            const timestamp = new Date().getTime();
+            const target = sh`:s3:${s3.bucket}/seastack/backups/${schedule.id}/${timestamp}-${backupFilename}`;
+            const artifactPath = `s3://${s3.bucket}/seastack/backups/${schedule.id}/${timestamp}-${backupFilename}`;
             const secureName = sh`./${backupFilename}`;
             const rcloneCommand = `rclone copyto ${secureName} ${target} ${flags} --progress`;
 
@@ -106,7 +108,7 @@
             await prisma.backupRun.update({
                 where: { id: run.id },
                 data: {
                     status: 'SUCCESS',
-                    artifactLocation: backupFilename,
+                    artifactLocation: artifactPath,
                     logs: encrypt(logs.join('')),
                 },
             });

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/workers/src/backups.ts around lines 105 to 112, the code stores only
backupFilename in artifactLocation but the uploaded S3 object includes the full
path and timestamp
(s3:${s3.bucket}/seastack/backups/${schedule.id}/${timestamp}-${backupFilename});
update the code to store the exact S3 URI used for upload instead of just the
filename — either reuse the upload path variable created when calling s3.upload
(or construct the same string using s3.bucket, schedule.id, timestamp and
backupFilename) and assign that full S3 path to artifactLocation so retrieval
later can use the exact stored URI.

} catch (error) {
console.error(error);
logger.error(error);
await prisma.backupRun.update({
where: { id: run.id },
data: { status: 'FAILED', logs: encrypt(logs.join('')) },
});
throw error;
} finally {
if (connection) connection.end();
Expand Down
Loading
Loading