Compare commits

...
3 Commits
Author SHA1 Message Date
DenozordecandCursor e190785d4f feat(web): add module create form on /modules/new
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 58s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 3m1s
Форма POST /v1/modules вместо заглушки: тип, название, расписание и DoH для DOMAINS. После 201 переход на карточку; кнопка «Создать» только при bgp:modules:write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 20:17:51 +07:00
Denozordec 3fd05ff833 fix(tests): improve concurrency handling in worker tests and enhance module cloning
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Successful in 1m14s
quality / bird2 (push) Successful in 18s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 2m53s
- Added environment variable `EVOBGP_JOB_MAX_CONCURRENT` to control job concurrency in tests.
- Modified worker test to ensure proper synchronization of job processing by holding workers until both jobs are enqueued.
- Updated memory store methods to return cloned module instances, preventing unintended mutations of original modules during operations.
2026-08-18 19:58:39 +07:00
Denozordec e7f24f0be4 feat(docker): update runtime configurations and documentation for evobgp components
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / go (push) Failing after 44s
quality / bird2 (push) Skipped
CD / quality (push) Failing after 55s
CD / publish (push) Skipped
- Changed the inheritance for `evobgp-deploy` and `evobgp-node` targets to use `_go-runtime-birdc`, reflecting the inclusion of the `bird` and `birdc` components.
- Updated README and Dockerfile comments to clarify the runtime environments for various evobgp components, specifying the use of `bird` and `birdc` for parse-check functionality.
- Adjusted quickstart documentation to accurately describe the `evobgp-api` image, highlighting the inclusion of `bird` and `birdc` for enhanced functionality.
2026-08-18 19:47:14 +07:00
12 changed files with 478 additions and 62 deletions
@@ -0,0 +1,393 @@
import { Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, ShieldOff } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Switch } from '@evobgp/ui/components/switch'
import {
ToggleGroup,
ToggleGroupItem,
} from '@evobgp/ui/components/toggle-group'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { SelectMenu } from '@/components/select-field'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { sessionCanWriteModules } from '@/lib/auth'
import { dohProfileShortLabel } from '@/lib/modules/helpers'
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { useCreateModuleMutation } from '@/queries/modules'
import type { DohResolverPolicy, ModuleCreate, ModuleType } from '@/types/api'
/**
* Create module — settings-3 / settings-16 Frame rows.
* @see https://reui.io/preview/base/settings-3
* @see https://reui.io/preview/base/settings-16
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/docs/components/base/number-field
*/
const MODULE_TYPES: ModuleType[] = ['IP_RANGES', 'AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS']
const MODULE_TYPE_HINT: Record<ModuleType, string> = {
IP_RANGES: 'Статические префиксы. Тип после создания не меняется.',
AS_PREFIXES: 'Префиксы по номерам AS (RIPEstat). Тип после создания не меняется.',
CDN_CIDRS: 'CIDR-списки с URL-источников. Тип после создания не меняется.',
DOMAINS: 'FQDN → префиксы через DoH. Тип после создания не меняется.',
}
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
{ value: 'failover', label: dohPolicyRu('failover') },
{ value: 'union', label: dohPolicyRu('union') },
]
export function ModuleCreateForm() {
const navigate = useNavigate()
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const createMutation = useCreateModuleMutation()
const [type, setType] = useState<ModuleType>('IP_RANGES')
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState(0)
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
const [cronExpr, setCronExpr] = useState('')
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
const isDomains = type === 'DOMAINS'
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
function toggleDohProfile(id: string, checked: boolean) {
setDohProfileIds((prev) => {
if (checked) {
if (prev.includes(id)) return prev
return [...prev, id]
}
return prev.filter((x) => x !== id)
})
}
async function submit() {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error('Укажите название модуля')
return
}
if (!Number.isInteger(priority)) {
toast.error('Приоритет должен быть целым числом')
return
}
let refresh: number | undefined
if (refreshIntervalSec.trim() !== '') {
const n = Number(refreshIntervalSec)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
toast.error('Интервал обновления должен быть целым числом ≥ 0')
return
}
refresh = n
}
const body: ModuleCreate = {
type,
name: trimmedName,
enabled,
priority,
}
if (refresh !== undefined) {
body.refresh_interval_sec = refresh
}
const cron = cronExpr.trim()
if (cron) {
body.cron_expr = cron
}
if (defaultCommunityId) {
body.default_community_id = defaultCommunityId
}
if (isDomains) {
body.doh_resolver_policy = dohResolverPolicy
body.doh_profile_ids = dohProfileIds
}
try {
const created = await createMutation.mutateAsync(body)
await navigate({ to: '/modules/$moduleId', params: { moduleId: created.id } })
} catch {
// toast in mutation
}
}
const back = (
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<ArrowLeft />
К списку
</Button>
)
if (sessionQ.isPending) {
return (
<PageShell className="mx-auto w-full max-w-3xl">
<PageHeader title="Новый модуль" description="Создание маршрутного списка" actions={back} />
</PageShell>
)
}
if (!canWrite) {
return (
<PageShell>
<PageHeader
title="Новый модуль"
description="Создание маршрутного списка"
actions={back}
/>
<Alert variant="warning">
<ShieldOff aria-hidden />
<AlertTitle>Недостаточно прав</AlertTitle>
<AlertDescription>
Нужно право bgp:modules:write, чтобы создавать модули.
</AlertDescription>
</Alert>
</PageShell>
)
}
return (
<PageShell className="mx-auto w-full max-w-3xl">
<PageHeader
title="Новый модуль"
description="Тип задаётся один раз при создании. Записи (AS, домены, CIDR) добавляются на карточке модуля."
actions={back}
/>
<SettingsCard
title="Параметры"
description="Основные поля. Тип после сохранения изменить нельзя."
footer={
<>
<Button variant="outline" type="button" render={<Link to="/modules" />}>
Отмена
</Button>
<LoadingButton
type="button"
loading={createMutation.isPending}
onClick={() => void submit()}
>
Создать
</LoadingButton>
</>
}
>
<SettingsFieldGroup
legend="Создание модуля"
description="Тип, название и расписание нового маршрутного списка."
>
<SettingRow
title="Тип"
description={MODULE_TYPE_HINT[type]}
stacked
labelFor="mod-create-type"
>
<ToggleGroup
multiple={false}
value={[type]}
onValueChange={(value) => {
if (value.length > 0) setType(value[0] as ModuleType)
}}
variant="outline"
size="sm"
aria-label="Тип модуля"
className="flex w-full min-w-0 flex-wrap"
>
{MODULE_TYPES.map((value) => (
<ToggleGroupItem key={value} value={value} className="gap-1.5">
{moduleTypeRu(value)}
</ToggleGroupItem>
))}
</ToggleGroup>
</SettingRow>
<SettingRow
title="Название"
description="Короткое имя в списках и на карточке."
labelFor="mod-create-name"
stacked
>
<Input
id="mod-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Например: static-ru"
className="w-full min-w-0"
/>
</SettingRow>
<SettingRow
title="Включён"
description="Выключенный модуль не участвует в обновлении и применении."
>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label="Модуль включён"
/>
</SettingRow>
<SettingRow
title="Приоритет"
description="Меньше значение — выше приоритет при агрегации."
labelFor="mod-create-priority"
>
<NumberField
id="mod-create-priority"
value={priority}
onValueChange={(next) => {
if (typeof next === 'number' && Number.isFinite(next)) {
setPriority(Math.trunc(next))
}
}}
step={1}
className="w-full max-w-40"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</SettingRow>
<SettingRow
title="Интервал обновления"
description="Секунды. Пусто — без периодического ingest по интервалу."
labelFor="mod-create-interval"
>
<Input
id="mod-create-interval"
type="number"
min={0}
placeholder="пусто"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
className="w-full max-w-40"
/>
</SettingRow>
<SettingRow
title="Cron"
description="Опциональное расписание планировщика, например 0 * * * *."
labelFor="mod-create-cron"
>
<Input
id="mod-create-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
className="w-full min-w-0"
/>
</SettingRow>
<SettingRow
title="Community по умолчанию"
description="Подставляется в записи без своей community."
labelFor="mod-create-community"
last={!isDomains}
>
<CommunitySelect
id="mod-create-community"
value={defaultCommunityId}
onValueChange={setDefaultCommunityId}
communities={communities}
nullable
/>
</SettingRow>
{isDomains ? (
<>
<SettingRow
title="Политика DoH"
description="Как выбирать профили при нескольких URL."
labelFor="mod-create-doh-policy"
>
<SelectMenu
id="mod-create-doh-policy"
items={DOH_POLICY_ITEMS}
value={dohResolverPolicy}
onValueChange={(v) => {
if (v) setDohResolverPolicy(v)
}}
/>
</SettingRow>
<SettingRow
title="DoH профили"
description="Упорядоченный список. Пусто — резолв без профилей модуля."
stacked
last
>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
<div className="flex w-full min-w-0 flex-col gap-2">
{dohProfiles.map((p) => {
const checked = dohProfileIds.includes(p.id)
return (
<label
key={p.id}
htmlFor={`mod-create-doh-${p.id}`}
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id={`mod-create-doh-${p.id}`}
checked={checked}
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{dohProfileShortLabel(p.id, dohProfiles)}
</span>
<span className="text-muted-foreground truncate text-xs" title={p.url}>
{p.url}
</span>
</span>
</label>
)
})}
</div>
)}
</SettingRow>
</>
) : null}
</SettingsFieldGroup>
</SettingsCard>
</PageShell>
)
}
@@ -5,17 +5,19 @@ import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-s
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
export function ProjectsEmptyState() {
export function ProjectsEmptyState({ canCreate = true }: { canCreate?: boolean }) {
return (
<IllustratedEmptyState
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
action={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Новый модуль
</Button>
canCreate ? (
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Новый модуль
</Button>
) : undefined
}
/>
)
+13
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import { overviewKeys } from '@/queries/overview'
import type {
ModuleCreate,
ModulePatch,
ModuleRow,
ModulesResponse,
@@ -64,6 +65,18 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
})
}
export function useCreateModuleMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ModuleCreate) => apiMutate<ModuleRow>('/v1/modules', 'POST', body),
onSuccess: (data) => {
toast.success('Модуль создан')
invalidateModules(qc, data.id)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать модуль'),
})
}
export function useUpdateModuleMutation() {
const qc = useQueryClient()
return useMutation({
+11 -5
View File
@@ -10,6 +10,8 @@ import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import { modulesListQueryOptions } from '@/queries/modules'
export const Route = createFileRoute('/_auth/modules/')({
@@ -18,6 +20,8 @@ export const Route = createFileRoute('/_auth/modules/')({
function ModulesListComponent() {
const query = useQuery(modulesListQueryOptions())
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
return (
<div className="flex flex-col gap-6">
@@ -42,10 +46,12 @@ function ModulesListComponent() {
<FrameDataGrid
title="Все модули"
actions={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Создать
</Button>
canWrite ? (
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Создать
</Button>
) : undefined
}
>
<QueryState
@@ -54,7 +60,7 @@ function ModulesListComponent() {
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyContent={<ProjectsEmptyState />}
emptyContent={<ProjectsEmptyState canCreate={canWrite} />}
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
+4 -39
View File
@@ -1,42 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { createFileRoute } from '@tanstack/react-router'
import { ModuleCreateForm } from '@/components/modules/module-create-form'
export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent,
component: ModuleCreateForm,
})
function NewModuleComponent() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Создание через API</FrameTitle>
<FrameDescription>
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
</pre>
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</FramePanel>
</Frame>
</div>
)
}
+2 -2
View File
@@ -14,8 +14,8 @@ BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store че
| Образ | Runtime base | Заметка |
|-------|----------------|---------|
| scheduler, ingest, render, deploy, node | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
| api, all | `debian:bookworm-slim` + `birdc` | только клиент birdc, без демона `bird` |
| scheduler, ingest, render | `gcr.io/distroless/static-debian12:nonroot` | static Go (`CGO_ENABLED=0`), без shell |
| api, all, deploy, node | `debian:bookworm-slim` + `bird` + `birdc` | `bird -p` (parse-check) и `birdc`; демон не запускается |
| agent | тот же Ubuntu+bird2, что bird2 | общие слои с `evobgp-bird2` |
| bird2 | Ubuntu Noble + пакет bird2 | |
| web, web-all | `nginx:1.27-alpine` | `worker_processes 1` |
+2 -2
View File
@@ -236,13 +236,13 @@ target "evobgp-render" {
}
target "evobgp-deploy" {
inherits = ["_go-runtime"]
inherits = ["_go-runtime-birdc"]
args = { BIN = "evobgp-deploy" }
tags = image-tags("evobgp-deploy")
}
target "evobgp-node" {
inherits = ["_go-runtime"]
inherits = ["_go-runtime-birdc"]
args = { BIN = "evobgp-node" }
tags = image-tags("evobgp-node")
}
+5 -3
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
# Воркеры (runtime): distroless static. api/all (runtime-birdc): debian-slim + birdc.
# Воркеры (runtime): distroless static. api/all/deploy/node (runtime-birdc): debian-slim + bird + birdc.
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl
ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
@@ -57,14 +57,15 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \
&& rm -f /tmp/bird-from-source.sh
# scheduler / ingest / render / deploy / node — static Go, без shell.
# scheduler / ingest / render — static Go, без shell.
FROM ${BASE_DISTROLESS} AS runtime
ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
# api / all — birdc (readline + ncurses). Только клиент birdc, без демона bird.
# api / all / deploy / node — bird -p (parse-check) + birdc configure.
# Демон BIRD в этом контейнере не запускается; процесс bird — в образе evobgp-bird2.
FROM ${BASE_DEBIAN} AS runtime-birdc
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
@@ -74,6 +75,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& rm -rf /var/lib/apt/lists/*
ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
COPY --from=birdc /usr/local/sbin/bird /usr/local/sbin/bird
COPY --from=birdc /usr/local/sbin/birdc /usr/local/sbin/birdc
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
+1 -1
View File
@@ -39,7 +39,7 @@ docker login git.shx.one
| Образ | Назначение | Страница пакета (пример) | Pull |
|--------|------------|--------------------------|------|
| `evobgp-api` | HTTP API (с `birdc` в образе) | [packages/…/evobgp-api](https://git.shx.one/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shx.one/denozord/evobgp-api:latest` |
| `evobgp-api` | HTTP API (с `bird`/`birdc` в образе для parse-check) | [packages/…/evobgp-api](https://git.shx.one/denozord/-/packages/container/evobgp-api/latest) | `docker pull git.shx.one/denozord/evobgp-api:latest` |
| `evobgp-all` | Монолит microVPS: API + in-process воркеры scheduler/ingest/render/deploy | [packages/…/evobgp-all](https://git.shx.one/denozord/-/packages/container/evobgp-all/latest) | `docker pull git.shx.one/denozord/evobgp-all:latest` |
| `evobgp-scheduler` | Планировщик (reference) | [packages/…/evobgp-scheduler](https://git.shx.one/denozord/-/packages/container/evobgp-scheduler/latest) | `docker pull git.shx.one/denozord/evobgp-scheduler:latest` |
| `evobgp-ingest` | Ingest CDN / ETag | [packages/…/evobgp-ingest](https://git.shx.one/denozord/-/packages/container/evobgp-ingest/latest) | `docker pull git.shx.one/denozord/evobgp-ingest:latest` |
+10 -1
View File
@@ -10,6 +10,7 @@ import (
func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
t.Setenv("EVOBGP_ASN_RESOLVE", "0")
t.Setenv("EVOBGP_BIRD_ACTIVE_DIR", "") // skip bird binary path in deploy_apply
t.Setenv("EVOBGP_JOB_MAX_CONCURRENT", "8")
m := store.NewMemory()
m.SeedDemo()
@@ -35,8 +36,15 @@ func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
t.Fatal(err)
}
// Hold workers until both jobs are enqueued so inflightRefresh=2 before either
// finishModuleRefreshSuccess. Otherwise a fast ingest can finalize+deploy before
// the second Enqueue — sequential refreshes correctly produce two deploy_apply jobs.
start := make(chan struct{})
wk := &Worker{Store: m}
reg := NewRegistry(wk.Process)
reg := NewRegistry(func(j *Job) {
<-start
wk.Process(j)
})
wk.Registry = reg
mid1 := modIP
@@ -47,6 +55,7 @@ func TestParallelModuleRefresh_CoalescesDeployApply(t *testing.T) {
if _, _, err := reg.Enqueue(tenant, KindModuleRefresh, nil, &mid2, map[string]any{"module_id": mod2.ID}); err != nil {
t.Fatal(err)
}
close(start)
waitSucceededJobsByKindCount(t, reg, tenant, KindModuleRefresh, 2)
+25 -1
View File
@@ -414,6 +414,9 @@ func (m *Memory) ListModules(tenantID string) []*Module {
}
return out[i].Name < out[j].Name
})
for i := range out {
out[i] = cloneModule(out[i])
}
return out
}
@@ -446,7 +449,7 @@ func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
if mod.TenantID != tenantID {
return nil, ErrTenantScope
}
return mod, nil
return cloneModule(mod), nil
}
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
@@ -684,3 +687,24 @@ func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit i
}
return page, nextCursor, hasMore
}
func cloneStringPtr(s *string) *string {
if s == nil {
return nil
}
v := *s
return &v
}
func cloneModule(m *Module) *Module {
if m == nil {
return nil
}
cp := *m
cp.DefaultCommunityID = cloneStringPtr(m.DefaultCommunityID)
cp.DohProfileID = cloneStringPtr(m.DohProfileID)
cp.DohProfileIDs = append([]string(nil), m.DohProfileIDs...)
cp.LastRefreshedAt = cloneTime(m.LastRefreshedAt)
cp.DeletedAt = cloneTime(m.DeletedAt)
return &cp
}
+5 -3
View File
@@ -38,7 +38,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
}
NormalizeModuleDoh(mod)
m.modules[id] = mod
return mod, nil
return cloneModule(mod), nil
}
func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error) {
@@ -74,12 +74,14 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
mod.DefaultCommunityID = &v
}
}
ApplyModuleDohPatch(mod, patch)
if patch.DohProfileIDs != nil || patch.DohProfileID != nil || patch.DohResolverPolicy != nil {
ApplyModuleDohPatch(mod, patch)
}
if patch.LastRefreshedAt != nil {
t := patch.LastRefreshedAt.UTC()
mod.LastRefreshedAt = &t
}
return mod, nil
return cloneModule(mod), nil
}
func (m *Memory) SoftDeleteModule(tenantID, moduleID string) error {