Compare commits

..
Author SHA1 Message Date
fred a251a80f25 ci: publish web+appservice sha images on next (#1407)
ci/woodpecker/pr/ci Pipeline was successful
build-web and build-appservice sat on a main-only when-anchor while
build-gateway used the next-capable one, so next-lane pushes produced
gateway sha images with no web/appservice counterpart — no sha-parity
image set existed for next-lane containerized deploys (measured: web and
appservice each had exactly one sha tag, the month-old last main build).

Fix: single shared image_build_when anchor for all three image steps;
web/appservice adopt the gateway-style branch guard (next = sha-only
destinations, tag refused; main adds :latest; anything else requires a
tag). The divergent second anchor is removed — two near-identical anchors
silently drifting apart is the defect class itself.

scripts/verify-release.test.mjs: 11/11 pass (SDLC-D-034 gate unchanged;
depends_on untouched, publish-next-npm serialization edge intact).
2026-08-26 17:10:46 -05:00
3 changed files with 96 additions and 169 deletions
+27 -14
View File
@@ -32,6 +32,11 @@ variables:
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
# #1407: ONE shared anchor for all three image steps. A second main-only
# anchor previously gated build-web/build-appservice, so next-lane pushes
# published gateway sha images with no web/appservice counterpart — no
# sha-parity set existed for next-lane containerized deploys. Every image
# step now builds on next too (sha-only destinations, enforced per step).
- &image_build_when
- event: tag
- event: [push, manual]
@@ -44,16 +49,6 @@ variables:
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
@@ -474,7 +469,7 @@ steps:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -488,8 +483,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next appservice publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
@@ -509,7 +513,7 @@ steps:
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -523,8 +527,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next web publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
+65 -82
View File
@@ -8,7 +8,7 @@ import {
Post,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { count, eq, sql, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import type { Auth } from '@mosaicstack/auth';
import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js';
@@ -16,12 +16,6 @@ import { DB } from '../database/database.module.js';
import { BootstrapSetupDto } from './bootstrap.dto.js';
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
/**
* Advisory lock key serializing bootstrap setup. Arbitrary constant; must only
* be unique among advisory lock keys used against this database.
*/
export const BOOTSTRAP_SETUP_LOCK_KEY = 0x626f6f74; // 'boot'
@Controller('api/bootstrap')
export class BootstrapController {
constructor(
@@ -37,83 +31,72 @@ export class BootstrapController {
@Post('setup')
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
// #1430: the zero-user check and the admin creation must be one critical
// section, or two concurrent setup calls can each create an admin. The
// transaction-scoped advisory lock serializes setup across all gateway
// instances sharing this database; the second caller blocks on the lock,
// then re-reads the count and gets 403.
return this.db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${BOOTSTRAP_SETUP_LOCK_KEY})`);
// Only allow setup when zero users exist
const [result] = await this.db.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// Only allow setup when zero users exist
const [result] = await tx.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// Create admin user via BetterAuth API
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
// Create admin user via BetterAuth API. BetterAuth writes on its own
// connection and commits independently of this transaction; the reads
// below still see the committed row (READ COMMITTED statement snapshot).
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await tx
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await tx.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await tx
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await this.db
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
}
}
+4 -73
View File
@@ -20,7 +20,7 @@
*/
import 'reflect-metadata';
import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
@@ -52,22 +52,13 @@ const mockAuth = {
},
};
// The controller runs setup inside db.transaction(tx) and first takes the
// #1430 advisory lock via tx.execute(). Inside the transaction it calls
// select().from() twice:
// Override db.select() so the second query (verify user exists) returns a user.
// The bootstrap controller calls select().from() twice:
// 1. count() to check zero users → returns [{total: 0}]
// 2. select().where().limit() → returns [the created user]
// callLog records the tx call order so the lock-before-check invariant is
// testable.
let selectCallCount = 0;
const callLog: string[] = [];
const mockTx = {
execute: () => {
callLog.push('execute');
return Promise.resolve([]);
},
const mockDbWithUser = {
select: () => {
callLog.push('select');
selectCallCount++;
return {
from: () => {
@@ -109,9 +100,6 @@ const mockTx = {
}),
}),
};
const mockDbWithUser = {
transaction: <T>(cb: (tx: typeof mockTx) => Promise<T>) => cb(mockTx),
};
// ─── Test suite ───────────────────────────────────────────────────────────────
@@ -165,11 +153,6 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
expect(body.user.email).toBe('[email protected]');
expect(body.token).toBeDefined();
expect(body.token.plaintext).toBeDefined();
// #1430: the advisory lock must be taken before the zero-user count, or
// two concurrent setups can both pass the check.
expect(callLog[0]).toBe('execute');
expect(callLog[1]).toBe('select');
});
it('returns 400 when extra forbidden properties are sent', async () => {
@@ -205,55 +188,3 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
expect(res.status).toBe(400);
});
});
// ─── #1430 regression: users-exist check runs inside the locked transaction ──
describe('POST /api/bootstrap/setup — setup already completed', () => {
let app: INestApplication;
const createUserSpy = vi.fn();
const lockedTx = {
execute: () => Promise.resolve([]),
select: () => ({
// count() sees an existing user — the locked re-check must reject.
from: () => Promise.resolve([{ total: 1 }]),
}),
};
const mockDbUsersExist = {
transaction: <T>(cb: (tx: typeof lockedTx) => Promise<T>) => cb(lockedTx),
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [BootstrapController],
providers: [
{ provide: AUTH, useValue: { api: { createUser: createUserSpy } } },
{ provide: DB, useValue: mockDbUsersExist },
],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('returns 403 and never calls createUser when users already exist', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: '[email protected]', password: 'password123' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(403);
expect(createUserSpy).not.toHaveBeenCalled();
});
});