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
4 changes: 1 addition & 3 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,4 @@ coverage
.git
.gitignore
.vscode
src/test/.auth
# The package-lock.json contains info that prevents @localnerve/gulp-imagemin from installing on a different os
package-lock.json
src/test/.auth
79 changes: 67 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
FROM node:24.12.0-alpine

ARG UID=1000
ARG GID=1000
FROM node:24.12.0-alpine AS builder
ARG AUTHZ_URL=http://localhost:9010
ARG AUTHZ_CLIENT_ID=E37D308D-9068-4FCC-BFFB-2AA535014B64
ARG DEV_BUILD=0
ARG TARGETARCH

USER root
RUN apk --no-cache add shadow
RUN usermod -u $UID -g node -o node; \
groupmod -g $GID -o node
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app

USER node
WORKDIR /home/node/app
COPY --chown=node:node ./ ./
RUN npm install --loglevel info

# Install all deps (including devDeps for the build step)
COPY package*.json ./
RUN npm ci

# Copy source and build
COPY . .
RUN if [ "$DEV_BUILD" = "0" ]; then \
echo "Production build"; \
echo "Building with AUTHZ_URL=$AUTHZ_URL, AUTHZ_CLIENT_ID=$AUTHZ_CLIENT_ID"; \
Expand All @@ -28,6 +23,66 @@ else \
SW_INSTRUMENT=1 AUTHZ_URL=$AUTHZ_URL AUTHZ_CLIENT_ID=$AUTHZ_CLIENT_ID npm run build:dev; \
fi

# Production runtime stage - minimal size with only production dependencies
FROM node:24.12.0-alpine AS runtime-prod
WORKDIR /home/node/app

ARG UID=1000
ARG GID=1000

USER root

RUN apk --no-cache add shadow && \
usermod -u $UID -g node -o node && \
groupmod -g $GID -o node && \
mkdir -p /home/node/app && \
chown -R node:node /home/node/app

USER node

# Fresh install of production dependencies only for minimal size
COPY --from=builder --chown=node:node /home/node/app/package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

# Copy the specific folders required for your start script
COPY --from=builder --chown=node:node /home/node/app/dist ./dist
COPY --from=builder --chown=node:node /home/node/app/src/application/server ./src/application/server

# Set runtime environment
ENV NODE_ENV=production

EXPOSE 5000

ENTRYPOINT ["npm", "start", "--", "--PORT=5000", "--ENV-PATH=/run/secrets/jam-env.json"]

# Development runtime stage - includes all dependencies (c8, etc.) for testing
FROM node:24.12.0-alpine AS runtime-dev
WORKDIR /home/node/app

ARG UID=1000
ARG GID=1000

USER root

RUN apk --no-cache add shadow && \
usermod -u $UID -g node -o node && \
groupmod -g $GID -o node && \
mkdir -p /home/node/app && \
chown -R node:node /home/node/app

USER node

# Copy all dependencies from builder (includes c8 and other devDependencies)
COPY --from=builder --chown=node:node /home/node/app/package*.json ./
COPY --from=builder --chown=node:node /home/node/app/node_modules ./node_modules

# Copy the specific folders required for your start script
COPY --from=builder --chown=node:node /home/node/app/dist ./dist
COPY --from=builder --chown=node:node /home/node/app/src/application/server ./src/application/server

# Set runtime environment
ENV NODE_ENV=production

EXPOSE 5000

ENTRYPOINT ["npm", "start", "--", "--PORT=5000", "--ENV-PATH=/run/secrets/jam-env.json"]
2 changes: 1 addition & 1 deletion docker/build-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ export AUTHZ_URL=$(find_envvar AUTHZ_URL)
export AUTHZ_CLIENT_ID=$(find_envvar AUTHZ_CLIENT_ID)

echo Building "$IMAGE_TAG" image with AUTHZ_URL=$AUTHZ_URL and AUTHZ_CLIENT_ID=$AUTHZ_CLIENT_ID
docker buildx build --tag "$IMAGE_TAG" --secret id=jam-build,src=$HOSTENV_FILE --file $DOCKER_FILE $PROJECT_DIR --build-arg UID=`id -u` --build-arg GID=`id -g` --build-arg AUTHZ_URL --build-arg AUTHZ_CLIENT_ID
docker buildx build --target runtime-prod --tag "$IMAGE_TAG" --secret id=jam-build,src=$HOSTENV_FILE --file $DOCKER_FILE $PROJECT_DIR --build-arg UID=`id -u` --build-arg GID=`id -g` --build-arg AUTHZ_URL --build-arg AUTHZ_CLIENT_ID
1 change: 1 addition & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ services:
build:
context: ../
dockerfile: ./Dockerfile
target: runtime-prod
args:
UID: ${UID}
GID: ${GID}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jam-build",
"version": "2.8.9",
"version": "2.9.0",
"description": "An adventurous, scalable, fullstack web application reference project",
"main": "index.js",
"type": "module",
Expand Down
66 changes: 48 additions & 18 deletions src/test/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function deleteImageByName (imageName) {
}
*/

async function checkImageExists (imageName) {
async function checkImageExists(imageName) {
const dockerode = (await getContainerRuntimeClient()).container.dockerode;
try {
await dockerode.getImage(imageName.toString()).inspect();
Expand All @@ -50,19 +50,47 @@ async function checkImageExists (imageName) {
}
}

export async function createAppContainer (authorizerContainer, containerNetwork, mariadbContainer, appImageName) {
/**
* Clean up dangling builder stage images from multi-stage builds.
* The TestContainers reaper cannot track intermediate build stages,
* so we need to manually clean them up.
*/
async function cleanupBuilderImages() {
const dockerode = (await getContainerRuntimeClient()).container.dockerode;
try {
debug('Cleaning up dangling builder stage images...');
const images = await dockerode.listImages({
filters: { dangling: ['true'] }
});

for (const image of images) {
try {
debug(`Removing dangling image ${image.Id}...`);
await dockerode.getImage(image.Id).remove({ force: false });
} catch (err) {
debug(`Could not remove image ${image.Id}:`, err.message);
}
}
debug('Builder image cleanup complete.');
} catch (err) {
debug('Error during builder image cleanup:', err.message);
}
}

export async function createAppContainer(authorizerContainer, containerNetwork, mariadbContainer, appImageName) {
const forceAppBuild = !!(process.env.FORCE_BUILD);

debug(`Checking ${appImageName}... FORCE_BUILD=${forceAppBuild}`);

let appContainerImage;
if (await checkImageExists(appImageName) && !forceAppBuild) {
debug(`Using image ${appImageName} without building...`);
appContainerImage = new GenericContainer(appImageName);
} else {
const userInfo = os.userInfo();
debug(`Building image ${appImageName}`, userInfo, os.arch(), process.env.AUTHZ_URL, process.env.AUTHZ_CLIENT_ID);
appContainerImage = await GenericContainer.fromDockerfile(path.resolve(thisDir, toRoot))

appContainerImage = await GenericContainer.fromDockerfile(path.resolve(thisDir, toRoot), 'Dockerfile')
.withBuildArgs({
UID: `${userInfo.uid}`,
GID: `${userInfo.gid}`,
Expand All @@ -72,14 +100,18 @@ export async function createAppContainer (authorizerContainer, containerNetwork,
AUTHZ_URL: process.env.AUTHZ_URL,
AUTHZ_CLIENT_ID: process.env.AUTHZ_CLIENT_ID
})
.withTarget('runtime-dev')
.withCache(true)
.build(appImageName, {
deleteOnExit: false
});

// Clean up dangling builder stage images that the reaper cannot track
await cleanupBuilderImages();
}

debug(`Starting ${appImageName}...`);

const appContainer = await appContainerImage
.withName('jam-build')
.withNetwork(containerNetwork)
Expand All @@ -99,13 +131,13 @@ export async function createAppContainer (authorizerContainer, containerNetwork,
})
.withWaitStrategy(Wait.forLogMessage(/listening on port \d+/))
.start();

debug(`Container ${appImageName} started.`);

return appContainer;
}

export async function createDatabaseAndAuthorizer () {
export async function createDatabaseAndAuthorizer() {
let client, authorizerContainer;
const dbHost = 'mariadb';

Expand All @@ -129,17 +161,15 @@ export async function createDatabaseAndAuthorizer () {
database: mariadbContainer.getDatabase(),
user: 'root',
password: mariadbContainer.getRootPassword(),
logger: () => {} // console.log // eslint-disable-line
logger: () => { } // console.log // eslint-disable-line
});

debug('Creating prerequisite databases and users...');
await client.query('CREATE DATABASE authorizer'); // must exist prior to authorizer
await client.query(`CREATE USER IF NOT EXISTS '${
process.env.DB_USER
}'@'%' IDENTIFIED BY '${process.env.DB_PASSWORD}'`);
await client.query(`CREATE USER IF NOT EXISTS '${
process.env.DB_APP_USER
}'@'%' IDENTIFIED BY '${process.env.DB_APP_PASSWORD}';`);
await client.query(`CREATE USER IF NOT EXISTS '${process.env.DB_USER
}'@'%' IDENTIFIED BY '${process.env.DB_PASSWORD}'`);
await client.query(`CREATE USER IF NOT EXISTS '${process.env.DB_APP_USER
}'@'%' IDENTIFIED BY '${process.env.DB_APP_PASSWORD}';`);

debug('Starting authorizer container...');
authorizerContainer = await new GenericContainer('localnerve/authorizer:1.5.3')
Expand All @@ -164,7 +194,7 @@ export async function createDatabaseAndAuthorizer () {
// sanity checks - jam_build and authorizer databases exist, authorizer tables exist
// await client.query('show databases');
// await client.query('show tables from authorizer');

debug('Creating jam_build database tables...');
await client.importFile({
file: path.resolve(thisDir, toRoot, './data/database/002-mariadb-ddl-tables.sql')
Expand All @@ -187,7 +217,7 @@ export async function createDatabaseAndAuthorizer () {

// sanity check - procs should exist and be granted to jbuser and jbadmin
// await client.query('SHOW PROCEDURE STATUS WHERE Db = \'jam_build\'');

} finally {
if (client) {
await client.end();
Expand Down