1
0
Fork 0
mirror of synced 2024-07-09 00:06:05 +12:00

Rework how we connect to containers.

This commit is contained in:
Sam Rose 2024-03-28 17:36:26 +00:00
parent f43f03a3b4
commit 90cfdd661d
No known key found for this signature in database
8 changed files with 152 additions and 100 deletions

View file

@ -136,7 +136,8 @@ jobs:
fi fi
test-server: test-server:
runs-on: budi-tubby-tornado-quad-core-150gb runs-on:
group: hosted-runners
env: env:
DEBUG: testcontainers,testcontainers:exec,testcontainers:build,testcontainers:pull DEBUG: testcontainers,testcontainers:exec,testcontainers:build,testcontainers:pull
steps: steps:

View file

@ -1,6 +1,8 @@
import { DatabaseImpl } from "../../../src/db" import { DatabaseImpl } from "../../../src/db"
import { execSync } from "child_process" import { execSync } from "child_process"
const IPV4_PORT_REGEX = new RegExp(`0\\.0\\.0\\.0:(\\d+)->(\\d+)/tcp`, "g")
interface ContainerInfo { interface ContainerInfo {
Command: string Command: string
CreatedAt: string CreatedAt: string
@ -19,7 +21,10 @@ interface ContainerInfo {
} }
function getTestcontainers(): ContainerInfo[] { function getTestcontainers(): ContainerInfo[] {
return execSync("docker ps --format json") // We use --format json to make sure the output is nice and machine-readable,
// and we use --no-trunc so that the command returns full container IDs so we
// can filter on them correctly.
return execSync("docker ps --format json --no-trunc")
.toString() .toString()
.split("\n") .split("\n")
.filter(x => x.length > 0) .filter(x => x.length > 0)
@ -27,32 +32,51 @@ function getTestcontainers(): ContainerInfo[] {
.filter(x => x.Labels.includes("org.testcontainers=true")) .filter(x => x.Labels.includes("org.testcontainers=true"))
} }
function getContainerByImage(image: string) { export function getContainerByImage(image: string) {
return getTestcontainers().find(x => x.Image.startsWith(image)) const containers = getTestcontainers().filter(x => x.Image.startsWith(image))
if (containers.length > 1) {
throw new Error(`Multiple containers found with image: ${image}`)
}
return containers[0]
} }
function getExposedPort(container: ContainerInfo, port: number) { export function getContainerById(id: string) {
const match = container.Ports.match(new RegExp(`0.0.0.0:(\\d+)->${port}/tcp`)) return getTestcontainers().find(x => x.ID === id)
if (!match) { }
return undefined
export interface Port {
host: number
container: number
}
export function getExposedV4Ports(container: ContainerInfo): Port[] {
let ports: Port[] = []
for (const match of container.Ports.matchAll(IPV4_PORT_REGEX)) {
ports.push({ host: parseInt(match[1]), container: parseInt(match[2]) })
} }
return parseInt(match[1]) return ports
}
export function getExposedV4Port(container: ContainerInfo, port: number) {
return getExposedV4Ports(container).find(x => x.container === port)?.host
} }
export function setupEnv(...envs: any[]) { export function setupEnv(...envs: any[]) {
// We start couchdb in globalSetup.ts, in the root of the monorepo, so it
// should be relatively safe to look for it by its image name.
const couch = getContainerByImage("budibase/couchdb") const couch = getContainerByImage("budibase/couchdb")
if (!couch) { if (!couch) {
throw new Error("CouchDB container not found") throw new Error("CouchDB container not found")
} }
const couchPort = getExposedPort(couch, 5984) const couchPort = getExposedV4Port(couch, 5984)
if (!couchPort) { if (!couchPort) {
throw new Error("CouchDB port not found") throw new Error("CouchDB port not found")
} }
const configs = [ const configs = [
{ key: "COUCH_DB_PORT", value: `${couchPort}` }, { key: "COUCH_DB_PORT", value: `${couchPort}` },
{ key: "COUCH_DB_URL", value: `http://localhost:${couchPort}` }, { key: "COUCH_DB_URL", value: `http://127.0.0.1:${couchPort}` },
] ]
for (const config of configs.filter(x => !!x.value)) { for (const config of configs.filter(x => !!x.value)) {
@ -60,7 +84,4 @@ export function setupEnv(...envs: any[]) {
env._set(config.key, config.value) env._set(config.key, config.value)
} }
} }
// @ts-expect-error
DatabaseImpl.nano = undefined
} }

View file

@ -6,6 +6,8 @@ import * as mongodb from "./mongodb"
import * as mysql from "./mysql" import * as mysql from "./mysql"
import * as mssql from "./mssql" import * as mssql from "./mssql"
import * as mariadb from "./mariadb" import * as mariadb from "./mariadb"
import { GenericContainer } from "testcontainers"
import { testContainerUtils } from "@budibase/backend-core/tests"
export type DatasourceProvider = () => Promise<Datasource> export type DatasourceProvider = () => Promise<Datasource>
@ -63,3 +65,26 @@ export async function rawQuery(ds: Datasource, sql: string): Promise<any> {
} }
} }
} }
export async function startContainer(container: GenericContainer) {
if (process.env.REUSE_CONTAINERS) {
container = container.withReuse()
}
const startedContainer = await container.start()
const info = testContainerUtils.getContainerById(startedContainer.getId())
if (!info) {
throw new Error("Container not found")
}
// Some Docker runtimes, when you expose a port, will bind it to both
// 127.0.0.1 and ::1, so ipv4 and ipv6. The port spaces of ipv4 and ipv6
// addresses are not shared, and testcontainers will sometimes give you back
// the ipv6 port. There's no way to know that this has happened, and if you
// try to then connect to `localhost:port` you may attempt to bind to the v4
// address which could be unbound or even an entirely different container. For
// that reason, we don't use testcontainers' `getExposedPort` function,
// preferring instead our own method that guaranteed v4 ports.
return testContainerUtils.getExposedV4Ports(info)
}

View file

@ -2,7 +2,10 @@ import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait } from "testcontainers" import { GenericContainer, Wait } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy" import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
import { rawQuery } from "./mysql" import { rawQuery } from "./mysql"
import { generator } from "@budibase/backend-core/tests" import { generator, testContainerUtils } from "@budibase/backend-core/tests"
import { startContainer } from "."
let ports: Promise<testContainerUtils.Port[]>
class MariaDBWaitStrategy extends AbstractWaitStrategy { class MariaDBWaitStrategy extends AbstractWaitStrategy {
async waitUntilReady(container: any, boundPorts: any, startTime?: Date) { async waitUntilReady(container: any, boundPorts: any, startTime?: Date) {
@ -22,22 +25,22 @@ class MariaDBWaitStrategy extends AbstractWaitStrategy {
} }
export async function getDatasource(): Promise<Datasource> { export async function getDatasource(): Promise<Datasource> {
let container = new GenericContainer("mariadb:lts") if (!ports) {
.withExposedPorts(3306) ports = startContainer(
.withEnvironment({ MARIADB_ROOT_PASSWORD: "password" }) new GenericContainer("mariadb:lts")
.withWaitStrategy(new MariaDBWaitStrategy()) .withExposedPorts(3306)
.withEnvironment({ MARIADB_ROOT_PASSWORD: "password" })
if (process.env.REUSE_CONTAINERS) { .withWaitStrategy(new MariaDBWaitStrategy())
container = container.withReuse() )
} }
const startedContainer = await container.start() const port = (await ports).find(x => x.container === 3306)?.host
if (!port) {
const host = startedContainer.getHost() throw new Error("MariaDB port not found")
const port = startedContainer.getMappedPort(3306) }
const config = { const config = {
host, host: "127.0.0.1",
port, port,
user: "root", user: "root",
password: "password", password: "password",

View file

@ -1,34 +1,38 @@
import { testContainerUtils } from "@budibase/backend-core/tests"
import { Datasource, SourceName } from "@budibase/types" import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait } from "testcontainers" import { GenericContainer, Wait } from "testcontainers"
import { startContainer } from "."
let ports: Promise<testContainerUtils.Port[]>
export async function getDatasource(): Promise<Datasource> { export async function getDatasource(): Promise<Datasource> {
let container = new GenericContainer("mongo:7.0-jammy") if (!ports) {
.withExposedPorts(27017) ports = startContainer(
.withEnvironment({ new GenericContainer("mongo:7.0-jammy")
MONGO_INITDB_ROOT_USERNAME: "mongo", .withExposedPorts(27017)
MONGO_INITDB_ROOT_PASSWORD: "password", .withEnvironment({
}) MONGO_INITDB_ROOT_USERNAME: "mongo",
.withWaitStrategy( MONGO_INITDB_ROOT_PASSWORD: "password",
Wait.forSuccessfulCommand( })
`mongosh --eval "db.version()"` .withWaitStrategy(
).withStartupTimeout(10000) Wait.forSuccessfulCommand(
`mongosh --eval "db.version()"`
).withStartupTimeout(10000)
)
) )
if (process.env.REUSE_CONTAINERS) {
container = container.withReuse()
} }
const startedContainer = await container.start() const port = (await ports).find(x => x.container === 27017)
if (!port) {
const host = startedContainer.getHost() throw new Error("MongoDB port not found")
const port = startedContainer.getMappedPort(27017) }
return { return {
type: "datasource", type: "datasource",
source: SourceName.MONGODB, source: SourceName.MONGODB,
plus: false, plus: false,
config: { config: {
connectionString: `mongodb://mongo:password@${host}:${port}`, connectionString: `mongodb://mongo:password@127.0.0.1:${port.host}`,
db: "mongo", db: "mongo",
}, },
} }

View file

@ -1,43 +1,41 @@
import { Datasource, SourceName } from "@budibase/types" import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait } from "testcontainers" import { GenericContainer, Wait } from "testcontainers"
import mssql from "mssql" import mssql from "mssql"
import { generator } from "@budibase/backend-core/tests" import { generator, testContainerUtils } from "@budibase/backend-core/tests"
import { startContainer } from "."
let ports: Promise<testContainerUtils.Port[]>
export async function getDatasource(): Promise<Datasource> { export async function getDatasource(): Promise<Datasource> {
let container = new GenericContainer( if (!ports) {
"mcr.microsoft.com/mssql/server:2022-latest" ports = startContainer(
) new GenericContainer("mcr.microsoft.com/mssql/server:2022-latest")
.withExposedPorts(1433) .withExposedPorts(1433)
.withEnvironment({ .withEnvironment({
ACCEPT_EULA: "Y", ACCEPT_EULA: "Y",
MSSQL_SA_PASSWORD: "Password_123", MSSQL_SA_PASSWORD: "Password_123",
// This is important, as Microsoft allow us to use the "Developer" edition // This is important, as Microsoft allow us to use the "Developer" edition
// of SQL Server for development and testing purposes. We can't use other // of SQL Server for development and testing purposes. We can't use other
// versions without a valid license, and we cannot use the Developer // versions without a valid license, and we cannot use the Developer
// version in production. // version in production.
MSSQL_PID: "Developer", MSSQL_PID: "Developer",
}) })
.withWaitStrategy( .withWaitStrategy(
Wait.forSuccessfulCommand( Wait.forSuccessfulCommand(
"/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Password_123 -q 'SELECT 1'" "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Password_123 -q 'SELECT 1'"
) )
)
) )
if (process.env.REUSE_CONTAINERS) {
container = container.withReuse()
} }
const startedContainer = await container.start() const port = (await ports).find(x => x.container === 1433)?.host
const host = startedContainer.getHost()
const port = startedContainer.getMappedPort(1433)
const datasource: Datasource = { const datasource: Datasource = {
type: "datasource_plus", type: "datasource_plus",
source: SourceName.SQL_SERVER, source: SourceName.SQL_SERVER,
plus: true, plus: true,
config: { config: {
server: host, server: "127.0.0.1",
port, port,
user: "sa", user: "sa",
password: "Password_123", password: "Password_123",

View file

@ -2,7 +2,10 @@ import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait } from "testcontainers" import { GenericContainer, Wait } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy" import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
import mysql from "mysql2/promise" import mysql from "mysql2/promise"
import { generator } from "@budibase/backend-core/tests" import { generator, testContainerUtils } from "@budibase/backend-core/tests"
import { startContainer } from "."
let ports: Promise<testContainerUtils.Port[]>
class MySQLWaitStrategy extends AbstractWaitStrategy { class MySQLWaitStrategy extends AbstractWaitStrategy {
async waitUntilReady(container: any, boundPorts: any, startTime?: Date) { async waitUntilReady(container: any, boundPorts: any, startTime?: Date) {
@ -25,26 +28,23 @@ class MySQLWaitStrategy extends AbstractWaitStrategy {
} }
export async function getDatasource(): Promise<Datasource> { export async function getDatasource(): Promise<Datasource> {
let container = new GenericContainer("mysql:8.3") if (!ports) {
.withExposedPorts(3306) ports = startContainer(
.withEnvironment({ MYSQL_ROOT_PASSWORD: "password" }) new GenericContainer("mysql:8.3")
.withWaitStrategy(new MySQLWaitStrategy().withStartupTimeout(10000)) .withExposedPorts(3306)
.withEnvironment({ MYSQL_ROOT_PASSWORD: "password" })
if (process.env.REUSE_CONTAINERS) { .withWaitStrategy(new MySQLWaitStrategy().withStartupTimeout(10000))
container = container.withReuse() )
} }
const startedContainer = await container.start() const port = (await ports).find(x => x.container === 3306)?.host
const host = startedContainer.getHost()
const port = startedContainer.getMappedPort(3306)
const datasource: Datasource = { const datasource: Datasource = {
type: "datasource_plus", type: "datasource_plus",
source: SourceName.MYSQL, source: SourceName.MYSQL,
plus: true, plus: true,
config: { config: {
host, host: "127.0.0.1",
port, port,
user: "root", user: "root",
password: "password", password: "password",

View file

@ -1,33 +1,33 @@
import { Datasource, SourceName } from "@budibase/types" import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait } from "testcontainers" import { GenericContainer, Wait } from "testcontainers"
import pg from "pg" import pg from "pg"
import { generator } from "@budibase/backend-core/tests" import { generator, testContainerUtils } from "@budibase/backend-core/tests"
import { startContainer } from "."
let ports: Promise<testContainerUtils.Port[]>
export async function getDatasource(): Promise<Datasource> { export async function getDatasource(): Promise<Datasource> {
let container = new GenericContainer("postgres:16.1-bullseye") if (!ports) {
.withExposedPorts(5432) ports = startContainer(
.withEnvironment({ POSTGRES_PASSWORD: "password" }) new GenericContainer("postgres:16.1-bullseye")
.withWaitStrategy( .withExposedPorts(5432)
Wait.forSuccessfulCommand( .withEnvironment({ POSTGRES_PASSWORD: "password" })
"pg_isready -h localhost -p 5432" .withWaitStrategy(
).withStartupTimeout(10000) Wait.forSuccessfulCommand(
"pg_isready -h localhost -p 5432"
).withStartupTimeout(10000)
)
) )
if (process.env.REUSE_CONTAINERS) {
container = container.withReuse()
} }
const startedContainer = await container.start() const port = (await ports).find(x => x.container === 5432)?.host
const host = startedContainer.getHost()
const port = startedContainer.getMappedPort(5432)
const datasource: Datasource = { const datasource: Datasource = {
type: "datasource_plus", type: "datasource_plus",
source: SourceName.POSTGRES, source: SourceName.POSTGRES,
plus: true, plus: true,
config: { config: {
host, host: "127.0.0.1",
port, port,
database: "postgres", database: "postgres",
user: "postgres", user: "postgres",