UBERF-9726: Fix integrations in accounts for CR 24.1 (#8490)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2025-04-08 10:47:27 +04:00 committed by GitHub
parent 8ea386abe8
commit 66325de693
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 336 additions and 61 deletions

View File

@ -146,21 +146,12 @@ export interface AccountClient {
updateIntegration: (integration: Integration) => Promise<void>
deleteIntegration: (integrationKey: IntegrationKey) => Promise<void>
getIntegration: (integrationKey: IntegrationKey) => Promise<Integration | null>
listIntegrations: (filter: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
}) => Promise<Integration[]>
listIntegrations: (filter: Partial<IntegrationKey>) => Promise<Integration[]>
addIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
updateIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
deleteIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<void>
getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<IntegrationSecret | null>
listIntegrationsSecrets: (filter: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
key?: string
}) => Promise<IntegrationSecret[]>
listIntegrationsSecrets: (filter: Partial<IntegrationSecretKey>) => Promise<IntegrationSecret[]>
getAccountInfo: (uuid: PersonUuid) => Promise<AccountInfo>
setCookie: () => Promise<void>
@ -786,11 +777,7 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async listIntegrations (filter: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
}): Promise<Integration[]> {
async listIntegrations (filter: Partial<IntegrationKey>): Promise<Integration[]> {
const request = {
method: 'listIntegrations' as const,
params: filter
@ -835,12 +822,7 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async listIntegrationsSecrets (filter: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
key?: string
}): Promise<IntegrationSecret[]> {
async listIntegrationsSecrets (filter: Partial<IntegrationSecretKey>): Promise<IntegrationSecret[]> {
const request = {
method: 'listIntegrationsSecrets' as const,
params: filter

View File

@ -59,7 +59,7 @@ export interface MailboxInfo {
export interface Integration {
socialId: PersonId
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid?: WorkspaceUuid
workspaceUuid: WorkspaceUuid | null
data?: Record<string, any>
}
@ -68,7 +68,7 @@ export type IntegrationKey = Omit<Integration, 'data'>
export interface IntegrationSecret {
socialId: PersonId
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid?: WorkspaceUuid
workspaceUuid: WorkspaceUuid | null
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
secret: string
}

View File

@ -889,15 +889,14 @@ describe('integration methods', () => {
expect(mockDb.integrationSecret.findOne).toHaveBeenCalledWith(mockSecretKey)
})
test('should throw error when secret not found', async () => {
test('should return null when integration secret not found', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
extra: { service: 'github' }
})
;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null)
await expect(getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
)
const result = await getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)
expect(result).toBeNull()
})
test('should throw error for unauthorized service', async () => {

View File

@ -62,6 +62,15 @@ interface MongoIndex {
options: CreateIndexesOptions & { name: string }
}
function getFilteredQuery<T> (query: Query<T>): Query<T> {
return Object.entries(query).reduce<Query<T>>((acc, [key, value]) => {
if (value !== undefined) {
acc[key as keyof Query<T>] = value
}
return acc
}, {})
}
export class MongoDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined>
implements DbCollection<T> {
constructor (
@ -123,11 +132,11 @@ implements DbCollection<T> {
}
async find (query: Query<T>, sort?: Sort<T>, limit?: number): Promise<T[]> {
return await this.findCursor(query, sort, limit).toArray()
return await this.findCursor(getFilteredQuery(query), sort, limit).toArray()
}
findCursor (query: Query<T>, sort?: Sort<T>, limit?: number): FindCursor<T> {
const cursor = this.collection.find<T>(query as Filter<T>)
const cursor = this.collection.find<T>(getFilteredQuery(query) as Filter<T>)
if (sort !== undefined) {
cursor.sort(sort as RawSort)
@ -147,7 +156,7 @@ implements DbCollection<T> {
}
async findOne (query: Query<T>): Promise<T | null> {
const doc = await this.collection.findOne<T>(query as Filter<T>)
const doc = await this.collection.findOne<T>(getFilteredQuery(query) as Filter<T>)
if (doc === null) {
return null
}
@ -190,11 +199,11 @@ implements DbCollection<T> {
}
}
await this.collection.updateOne(query as Filter<T>, resOps)
await this.collection.updateOne(getFilteredQuery(query) as Filter<T>, resOps)
}
async deleteMany (query: Query<T>): Promise<void> {
await this.collection.deleteMany(query as Filter<T>)
await this.collection.deleteMany(getFilteredQuery(query) as Filter<T>)
}
}
@ -218,7 +227,7 @@ export class AccountMongoDbCollection extends MongoDbCollection<Account, 'uuid'>
}
async findOne (query: Query<Account>): Promise<Account | null> {
const res = await this.collection.findOne<Account>(query as Filter<Account>)
const res = await this.collection.findOne<Account>(getFilteredQuery(query) as Filter<Account>)
return res !== null ? this.convertToObj(res) : null
}
@ -247,7 +256,7 @@ export class WorkspaceStatusMongoDbCollection implements DbCollection<WorkspaceS
private toWsQuery (query: Query<WorkspaceStatus>): Query<WorkspaceInfoWithStatus> {
const res: Query<WorkspaceInfoWithStatus> = {}
for (const key of Object.keys(query)) {
for (const key of Object.keys(getFilteredQuery(query))) {
const qVal = (query as any)[key]
if (key === 'workspaceUuid') {
res.uuid = qVal

View File

@ -180,8 +180,8 @@ implements DbCollection<T> {
break
}
default: {
currIdx++
if (qKey !== null) {
currIdx++
whereChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
values.push(qKey)
} else {
@ -955,10 +955,7 @@ export class PostgresAccountDB implements AccountDB {
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
key STRING,
secret STRING NOT NULL,
CONSTRAINT integration_secrets_pk PRIMARY KEY (social_id, kind, _def_ws_uuid, key),
CONSTRAINT integration_secrets_integrations_fk FOREIGN KEY (social_id, kind, _def_ws_uuid)
REFERENCES ${this.ns}.integrations(social_id, kind, _def_ws_uuid)
ON DELETE CASCADE
CONSTRAINT integration_secrets_pk PRIMARY KEY (social_id, kind, _def_ws_uuid, key)
);
`
]

View File

@ -518,7 +518,7 @@ export async function createIntegration (
verifyAllowedServices(integrationServices, extra)
const { socialId, kind, workspaceUuid, data } = params
if (kind == null || socialId == null) {
if (kind == null || socialId == null || workspaceUuid === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
@ -553,6 +553,10 @@ export async function updateIntegration (
verifyAllowedServices(integrationServices, extra)
const { socialId, kind, workspaceUuid, data } = params
if (kind == null || socialId == null || workspaceUuid === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
if (existing == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
@ -572,11 +576,16 @@ export async function deleteIntegration (
verifyAllowedServices(integrationServices, extra)
const { socialId, kind, workspaceUuid } = params
if (kind == null || socialId == null || workspaceUuid === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const existing = await db.integration.findOne({ socialId, kind, workspaceUuid })
if (existing == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {}))
}
await db.integrationSecret.deleteMany({ socialId, kind, workspaceUuid })
await db.integration.deleteMany({ socialId, kind, workspaceUuid })
}
@ -585,11 +594,7 @@ export async function listIntegrations (
db: AccountDB,
branding: Branding | null,
token: string,
params: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
}
params: Partial<IntegrationKey>
): Promise<Integration[]> {
const { account, extra } = decodeTokenVerbose(ctx, token)
const isAllowedService = verifyAllowedServices(integrationServices, extra, false)
@ -636,6 +641,10 @@ export async function getIntegration (
const isAllowedService = verifyAllowedServices(integrationServices, extra, false)
const { socialId, kind, workspaceUuid } = params
if (kind == null || socialId == null || workspaceUuid === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (!isAllowedService) {
const existingSocialId = await db.socialId.findOne({ _id: socialId, personUuid: account, verifiedOn: { $gt: 0 } })
@ -658,7 +667,7 @@ export async function addIntegrationSecret (
verifyAllowedServices(integrationServices, extra)
const { socialId, kind, workspaceUuid, key, secret } = params
if (kind == null || socialId == null || key == null) {
if (kind == null || socialId == null || workspaceUuid === undefined || key == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
@ -690,6 +699,10 @@ export async function updateIntegrationSecret (
const { socialId, kind, workspaceUuid, key, secret } = params
const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key }
if (kind == null || socialId == null || workspaceUuid === undefined || key == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const existingSecret = await db.integrationSecret.findOne(secretKey)
if (existingSecret == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
@ -710,6 +723,10 @@ export async function deleteIntegrationSecret (
const { socialId, kind, workspaceUuid, key } = params
const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key }
if (kind == null || socialId == null || workspaceUuid === undefined || key == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const existingSecret = await db.integrationSecret.findOne(secretKey)
if (existingSecret == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
@ -729,11 +746,12 @@ export async function getIntegrationSecret (
verifyAllowedServices(integrationServices, extra)
const { socialId, kind, workspaceUuid, key } = params
const existing = await db.integrationSecret.findOne({ socialId, kind, workspaceUuid, key })
if (existing == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {}))
if (kind == null || socialId == null || workspaceUuid === undefined || key == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const existing = await db.integrationSecret.findOne({ socialId, kind, workspaceUuid, key })
return existing
}
@ -742,12 +760,7 @@ export async function listIntegrationsSecrets (
db: AccountDB,
branding: Branding | null,
token: string,
params: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
key?: string
}
params: Partial<IntegrationSecretKey>
): Promise<IntegrationSecret[]> {
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(integrationServices, extra)

View File

@ -142,7 +142,7 @@ export interface MailboxInfo {
export interface Integration {
socialId: PersonId
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid?: WorkspaceUuid
workspaceUuid: WorkspaceUuid | null
data?: Record<string, any>
}
@ -151,7 +151,7 @@ export type IntegrationKey = Omit<Integration, 'data'>
export interface IntegrationSecret {
socialId: PersonId
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid?: WorkspaceUuid
workspaceUuid: WorkspaceUuid | null
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
secret: string
}

View File

@ -28,7 +28,7 @@ services:
- 27018:27018
restart: unless-stopped
cockroach:
image: cockroachdb/cockroach:latest-v24.2
image: cockroachdb/cockroach:v24.1.2
ports:
- '26258:26257'
- '18089:8080'

View File

@ -3,6 +3,7 @@ PLATFORM_TRANSACTOR='ws://localhost:3334'
STAGING_URL='https://front.hc.engineering'
PLATFORM_USER='user1'
PLATFORM_USER_SECOND='user2'
PLATFORM_ADMIN='admin'
PLATFORM_WS='sanity-ws'
PLATFORM_TOKEN='eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InVzZXIxIiwid29ya3NwYWNlIjoic2FuaXR5LXdzIn0.hfUCqePHO-WNps2by4B-CYGKIpDpLG0WVCUUtU-SVI4'
LOCAL_URL='http://localhost:3003/'

View File

@ -51,6 +51,7 @@
"cross-env": "~7.0.3",
"@hcengineering/core": "^0.6.32",
"@hcengineering/client-resources": "^0.6.27",
"@hcengineering/account": "^0.6.0"
"@hcengineering/account": "^0.6.0",
"@hcengineering/account-client": "^0.6.0"
}
}

View File

@ -0,0 +1,20 @@
import { getClient as getClientRaw, type AccountClient } from '@hcengineering/account-client'
import { LocalUrl, PlatformAdmin } from '../utils'
let adminAccountClient: AccountClient
export async function getAdminAccountClient (): Promise<AccountClient> {
if (adminAccountClient != null) {
return adminAccountClient
}
const unauthClient = getClientRaw(LocalUrl)
const loginInfo = await unauthClient.login(PlatformAdmin, '1234')
if (loginInfo == null) {
throw new Error('Failed to login as admin')
}
adminAccountClient = getClientRaw(LocalUrl, loginInfo.token)
return adminAccountClient
}

View File

@ -0,0 +1,252 @@
import { expect, test } from '@playwright/test'
import { faker } from '@faker-js/faker'
import { Integration, IntegrationSecret } from '@hcengineering/account'
import { buildSocialIdString, SocialIdType } from '@hcengineering/core'
import { PlatformUser } from './utils'
import { getAdminAccountClient } from './API/AccountClient'
test.describe('integrations in accounts tests', () => {
test('manage integrations', async () => {
const accountClient = await getAdminAccountClient()
const personUuid = await accountClient.findPersonBySocialKey(
buildSocialIdString({ type: SocialIdType.EMAIL, value: PlatformUser })
)
if (personUuid == null) {
throw new Error('Failed to find person for PlatformUser: ' + PlatformUser)
}
const personId1 = await accountClient.addSocialIdToPerson(personUuid, SocialIdType.EMAIL, faker.word.words(1), true)
const personId2 = await accountClient.addSocialIdToPerson(
personUuid,
SocialIdType.GITHUB,
faker.word.words(1),
true
)
const personId3 = await accountClient.addSocialIdToPerson(
personUuid,
SocialIdType.GOOGLE,
faker.word.words(1),
true
)
const workspaces = await accountClient.listWorkspaces()
if (workspaces.length === 0) {
throw new Error('No workspaces found')
}
const workspaceUuid = workspaces[0].uuid
// Test data setup
const integration1: Integration = {
socialId: personId1,
kind: 'github',
workspaceUuid: null, // Global integration
data: {
repo: 'repo1',
owner: 'owner1',
branch: 'main'
}
}
const integration2: Integration = {
socialId: personId2,
kind: 'telegram-bot',
workspaceUuid,
data: {
chatId: '123',
username: 'bot1',
webhookUrl: 'https://example.com/webhook'
}
}
const integration3: Integration = {
socialId: personId3,
kind: 'mailbox',
workspaceUuid,
data: {
email: 'test@example.com',
name: 'Test Mailbox',
settings: { folder: 'INBOX' }
}
}
// Create and verify integrations one by one
await accountClient.createIntegration(integration1)
const checkIntegration1 = await accountClient.getIntegration({
socialId: integration1.socialId,
kind: integration1.kind,
workspaceUuid: integration1.workspaceUuid
})
expect(checkIntegration1).toEqual(integration1)
await accountClient.createIntegration(integration2)
let checkIntegration2 = await accountClient.getIntegration({
socialId: integration2.socialId,
kind: integration2.kind,
workspaceUuid: integration2.workspaceUuid
})
expect(checkIntegration2).toEqual(integration2)
await accountClient.createIntegration(integration3)
const checkIntegration3 = await accountClient.getIntegration({
socialId: integration3.socialId,
kind: integration3.kind,
workspaceUuid: integration3.workspaceUuid
})
expect(checkIntegration3).toEqual(integration3)
// Create secrets
const secret1: IntegrationSecret = {
socialId: personId1,
kind: 'github',
workspaceUuid: null,
key: 'token',
secret: 'github_pat_token_123'
}
const secret2: IntegrationSecret = {
socialId: personId2,
kind: 'telegram-bot',
workspaceUuid,
key: 'bot_token',
secret: 'telegram_bot_token_123'
}
const secret3: IntegrationSecret = {
socialId: personId2,
kind: 'telegram-bot',
workspaceUuid,
key: 'api_key',
secret: 'telegram_api_key_123'
}
// Add and verify secrets one by one
await accountClient.addIntegrationSecret(secret1)
const checkSecret1 = await accountClient.getIntegrationSecret({
socialId: secret1.socialId,
kind: secret1.kind,
workspaceUuid: secret1.workspaceUuid,
key: secret1.key
})
expect(checkSecret1).toEqual(secret1)
await accountClient.addIntegrationSecret(secret2)
let checkSecret2 = await accountClient.getIntegrationSecret({
socialId: secret2.socialId,
kind: secret2.kind,
workspaceUuid: secret2.workspaceUuid,
key: secret2.key
})
expect(checkSecret2).toEqual(secret2)
await accountClient.addIntegrationSecret(secret3)
const checkSecret3 = await accountClient.getIntegrationSecret({
socialId: secret3.socialId,
kind: secret3.kind,
workspaceUuid: secret3.workspaceUuid,
key: secret3.key
})
expect(checkSecret3).toEqual(secret3)
// Test listing with various filters
const allIntegrations = await accountClient.listIntegrations({})
expect(allIntegrations).toHaveLength(3)
expect(allIntegrations).toEqual(expect.arrayContaining([integration1, integration2, integration3]))
const workspaceIntegrations = await accountClient.listIntegrations({ workspaceUuid })
expect(workspaceIntegrations).toHaveLength(2)
expect(workspaceIntegrations).toEqual(expect.arrayContaining([integration2, integration3]))
const githubIntegrations = await accountClient.listIntegrations({ kind: 'github' })
expect(githubIntegrations).toHaveLength(1)
expect(githubIntegrations[0]).toEqual(integration1)
const telegramIntegrations = await accountClient.listIntegrations({ kind: 'telegram-bot' })
expect(telegramIntegrations).toHaveLength(1)
expect(telegramIntegrations[0]).toEqual(integration2)
// Test listing secrets with filters
const allSecrets = await accountClient.listIntegrationsSecrets({})
expect(allSecrets).toHaveLength(3)
expect(allSecrets).toEqual(expect.arrayContaining([secret1, secret2, secret3]))
const workspaceSecrets = await accountClient.listIntegrationsSecrets({
workspaceUuid
})
expect(workspaceSecrets).toHaveLength(2)
expect(workspaceSecrets).toEqual(expect.arrayContaining([secret2, secret3]))
const telegramSecrets = await accountClient.listIntegrationsSecrets({ kind: 'telegram-bot' })
expect(telegramSecrets).toHaveLength(2)
expect(telegramSecrets).toEqual(expect.arrayContaining([secret2, secret3]))
// Test updates
const updatedIntegration2: Integration = {
...integration2,
data: {
chatId: '456',
username: 'bot1_updated',
webhookUrl: 'https://example.com/webhook2'
}
}
await accountClient.updateIntegration(updatedIntegration2)
checkIntegration2 = await accountClient.getIntegration({
socialId: integration2.socialId,
kind: integration2.kind,
workspaceUuid: integration2.workspaceUuid
})
expect(checkIntegration2).toEqual(updatedIntegration2)
const updatedSecret2: IntegrationSecret = {
...secret2,
secret: 'telegram_bot_token_456'
}
await accountClient.updateIntegrationSecret(updatedSecret2)
checkSecret2 = await accountClient.getIntegrationSecret({
socialId: secret2.socialId,
kind: secret2.kind,
workspaceUuid: secret2.workspaceUuid,
key: secret2.key
})
expect(checkSecret2).toEqual(updatedSecret2)
// Test deletions
await accountClient.deleteIntegration({
socialId: integration1.socialId,
kind: integration1.kind,
workspaceUuid: integration1.workspaceUuid
})
await accountClient.deleteIntegrationSecret({
socialId: secret2.socialId,
kind: secret2.kind,
workspaceUuid: secret2.workspaceUuid,
key: secret2.key
})
// Verify deletions
const remainingIntegrations = await accountClient.listIntegrations({})
expect(remainingIntegrations).toHaveLength(2)
expect(remainingIntegrations).toEqual(expect.arrayContaining([updatedIntegration2, integration3]))
const remainingSecrets = await accountClient.listIntegrationsSecrets({})
expect(remainingSecrets).toHaveLength(1)
expect(remainingSecrets).toEqual(expect.arrayContaining([secret3]))
// Verify deleted items don't exist
const deletedIntegration = await accountClient.getIntegration({
socialId: integration1.socialId,
kind: integration1.kind,
workspaceUuid: integration1.workspaceUuid
})
expect(deletedIntegration).toBeNull()
const deletedSecret = await accountClient.getIntegrationSecret({
socialId: secret2.socialId,
kind: secret2.kind,
workspaceUuid: secret2.workspaceUuid,
key: secret2.key
})
expect(deletedSecret).toBeNull()
})
})

View File

@ -14,6 +14,7 @@ export const PlatformURI = process.env.PLATFORM_URI as string
export const PlatformTransactor = process.env.PLATFORM_TRANSACTOR as string
export const PlatformUser = process.env.PLATFORM_USER as string
export const PlatformUserSecond = process.env.PLATFORM_USER_SECOND as string
export const PlatformAdmin = process.env.PLATFORM_ADMIN as string
export const PlatformWs = process.env.PLATFORM_WS as string
export const PlatformSetting = process.env.SETTING as string
export const PlatformSettingSecond = process.env.SETTING_SECOND as string

View File

@ -32,7 +32,7 @@ services:
- 27018:27018
restart: unless-stopped
cockroach:
image: cockroachdb/cockroach:latest-v24.2
image: cockroachdb/cockroach:v24.1.2
extra_hosts:
- 'huly.local:host-gateway'
ports: